diff --git a/.github/workflows/tbtc-signer-formal.yml b/.github/workflows/tbtc-signer-formal.yml new file mode 100644 index 0000000000..0ad5d52ee5 --- /dev/null +++ b/.github/workflows/tbtc-signer-formal.yml @@ -0,0 +1,81 @@ +name: tBTC Signer Formal Verification + +on: + pull_request: + paths: + - pkg/tbtc/signer/** + - .github/workflows/tbtc-signer-formal.yml + schedule: + - cron: "23 5 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: tbtc-signer-formal-${{ github.ref }} + cancel-in-progress: true + +jobs: + signer-rust-checks: + name: Signer Rust checks + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --manifest-path pkg/tbtc/signer/Cargo.toml -- --check + + - name: Run clippy + run: cargo clippy --manifest-path pkg/tbtc/signer/Cargo.toml --all-targets -- -D warnings + + - name: Run signer tests + env: + TBTC_SIGNER_STATE_PATH: /tmp/tbtc-signer-ci-state-${{ github.run_id }}-${{ github.run_attempt }}.json + run: cargo test --manifest-path pkg/tbtc/signer/Cargo.toml + + signer-formal-invariants: + name: Signer formal invariants + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Run signer formal invariant tests + # Filters cargo test by the formal_verification_ prefix so only + # the formal-invariant test cases run (faster + clearer signal + # than the full suite). Matches the convention used in the + # source monorepo's ci-formal-verification.yml. + run: cargo test --manifest-path pkg/tbtc/signer/Cargo.toml formal_verification_ + + tla-model-checks: + name: TLA model checks + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Run TLA model checks + # Iterates over every .cfg under pkg/tbtc/signer/docs/formal/models/ + # and runs TLC against the matching .tla module. MODELS_PATH defaults + # to the canonical signer-relative path; override via env var for + # alternate environments (set in extraction/frost-signer-mirror PR). + run: pkg/tbtc/signer/scripts/formal/run_tla_models.sh diff --git a/pkg/tbtc/signer/.gitignore b/pkg/tbtc/signer/.gitignore new file mode 100644 index 0000000000..2f7896d1d1 --- /dev/null +++ b/pkg/tbtc/signer/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/pkg/tbtc/signer/Cargo.lock b/pkg/tbtc/signer/Cargo.lock new file mode 100644 index 0000000000..16f0234fd5 --- /dev/null +++ b/pkg/tbtc/signer/Cargo.lock @@ -0,0 +1,1731 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes", +] + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitcoin" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-internals", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-units" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "const-crc32-nostd" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808ac43170e95b11dd23d78aa9eaac5bea45776a602955552c4e833f3f0f823d" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "derive-getters" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74ef43543e701c01ad77d3a5922755c6a1d71b22d942cb8042be4994b380caff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "frost-core" +version = "3.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b28afb08296406bf64550a289fe37a779747cf24b062a8ad5f24f9c871d4b425" +dependencies = [ + "byteorder", + "const-crc32-nostd", + "derive-getters", + "document-features", + "hex", + "itertools 0.14.0", + "postcard", + "rand_core 0.6.4", + "serde", + "serdect", + "thiserror", + "visibility", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "frost-rerandomized" +version = "3.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53efb7cfa387cb25b5a5ce3269dd05704fd879bc72b07d3598db0e4d222de16f" +dependencies = [ + "derive-getters", + "document-features", + "frost-core", + "hex", + "rand_core 0.6.4", +] + +[[package]] +name = "frost-secp256k1-tr" +version = "3.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b8aa04ff85d94c85b5b679909380df227bf09206e4705670e54cf628b2effd6" +dependencies = [ + "document-features", + "frost-core", + "frost-rerandomized", + "k256", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "elliptic-curve", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tbtc-signer" +version = "0.1.0" +dependencies = [ + "bitcoin", + "chacha20poly1305", + "criterion", + "frost-secp256k1-tr", + "hex", + "libc", + "pretty_assertions", + "proptest", + "rand_chacha 0.3.1", + "serde", + "serde_json", + "sha2", + "thiserror", + "zeroize", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "serde", + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/pkg/tbtc/signer/Cargo.toml b/pkg/tbtc/signer/Cargo.toml new file mode 100644 index 0000000000..ef958976a2 --- /dev/null +++ b/pkg/tbtc/signer/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "tbtc-signer" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "tBTC Rust signer bootstrap crate for C ABI integration" + +[lib] +name = "frost_tbtc" +crate-type = ["cdylib", "rlib"] + +[features] +default = [] +bench-restart-hook = [] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +hex = "0.4" +thiserror = "2.0" +frost-secp256k1-tr = "=3.0.0-rc.0" +chacha20poly1305 = "0.10" +rand_chacha = "0.3" +libc = "0.2" +zeroize = { version = "1.8", default-features = false, features = ["serde"] } +bitcoin = "0.32" + +[dev-dependencies] +criterion = "0.5" +pretty_assertions = "1.4" +proptest = "1.6" + +[[bench]] +name = "phase5_roast" +harness = false +required-features = ["bench-restart-hook"] diff --git a/pkg/tbtc/signer/README.md b/pkg/tbtc/signer/README.md new file mode 100644 index 0000000000..e36d16344b --- /dev/null +++ b/pkg/tbtc/signer/README.md @@ -0,0 +1,397 @@ +# tbtc-signer (bootstrap) + +This crate is the first implementation slice of the Rust rewrite plan tracked +in `docs/rust-rewrite-bootstrap.md`. + +## Current scope + +- Exposes a C ABI (`libfrost_tbtc`) with coarse operations keyed by `session_id`: + - `RunDKG` + - `StartSignRound` + - `FinalizeSignRound` + - `BuildTaprootTx` + - `RefreshShares` +- Exposes ROAST liveness policy metadata via: + - `RoastLivenessPolicy` +- Exposes hardening/runtime counters via: + - `HardeningMetrics` +- Exposes transcript-accountability and blame-proof helpers via: + - `RoastTranscriptAudit` + - `VerifyBlameProof` +- Exposes auto-quarantine status via: + - `QuarantineStatus` +- Exposes refresh cadence + emergency rekey controls via: + - `RefreshCadenceStatus` + - `TriggerEmergencyRekey` +- Exposes differential safety harness and canary rollout controls via: + - `RunDifferentialFuzzing` + - `CanaryRolloutStatus` + - `PromoteCanary` + - `RollbackCanary` +- Enforces idempotency semantics per `session_id` for retries, with optional + file-backed state persistence. +- Uses deterministic JSON request/response envelopes across the FFI boundary. +- Provides explicit, typed error codes for retry-safe orchestration. +- Keeps bootstrap synthetic finalize behavior fail-closed by default; enable it + explicitly with `TBTC_SIGNER_ALLOW_BOOTSTRAP=true` in non-production profiles + only. +- Rejects bootstrap dealer DKG when `TBTC_SIGNER_PROFILE=production`; production + requires distributed DKG wiring before this path can be enabled. + +## Not yet implemented + +- ROAST coordinator logic. +- Full Taproot script-tree construction/signing policy semantics (current + `BuildTaprootTx` path assembles validated unsigned transactions from provided + inputs/outputs). +- Canonical non-JSON serialization compatibility rules/tests for the FFI + boundary. + +## Build + +```bash +cd pkg/tbtc/signer +cargo build +``` + +For a dynamic library artifact: + +```bash +cd pkg/tbtc/signer +cargo build --release +# target/release/libfrost_tbtc.{so,dylib,dll} +``` + +## Test + +```bash +cd pkg/tbtc/signer +cargo test +``` + +## Admission Checker (P0-M1) + +Run the pre-admission checker for operator onboarding policy: + +```bash +cd pkg/tbtc/signer +cargo run --bin admission_checker -- \ + --policy scripts/admission-policy-v1.sample.json \ + --candidate scripts/admission-candidate.sample.json \ + --existing scripts/admission-existing.sample.json +``` + +Exit codes: + +- `0`: candidate satisfies policy +- `1`: candidate rejected (see JSON reason codes in stdout) +- `2`: checker input/config error + +To evaluate a governance override, pass both: + +- `--override ` for the signed override artifact +- `--override-registry ` for the consumed-override replay-protection registry + +Note: the override registry assumes single-writer access. Do not run concurrent +`admission_checker` invocations against the same `--override-registry` path. + +`scripts/admission-override.sample.json` documents the artifact schema and +requires a real Schnorr signature over `payload_json`. + +Sample input schemas are provided in: + +- `pkg/tbtc/signer/scripts/admission-policy-v1.sample.json` +- `pkg/tbtc/signer/scripts/admission-candidate.sample.json` +- `pkg/tbtc/signer/scripts/admission-existing.sample.json` +- `pkg/tbtc/signer/scripts/admission-override.sample.json` +- `pkg/tbtc/signer/scripts/admission-override-registry.sample.json` + +## Encrypted State Key Providers + +Signer state persistence is encrypted at rest. Key-provider behavior is controlled +by the following environment variables: + +- `TBTC_SIGNER_STATE_KEY_PROVIDER`: + - `env` (default): read key from `TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX`. + - `command`: execute `TBTC_SIGNER_STATE_KEY_COMMAND` and read key from stdout. +- `TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX`: + - 64 hex chars (32 bytes) when provider is `env`. +- `TBTC_SIGNER_STATE_KEY_COMMAND`: + - shell command executed via `/bin/sh -lc` when provider is `command`. +- `TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS`: + - timeout for command-provider execution in seconds (default `30`, range `1..300`). +- `TBTC_SIGNER_STATE_PATH`: + - signer state file path. Required when `TBTC_SIGNER_PROFILE=production`; + non-production profiles default to a temp-dir state file if omitted. +- `TBTC_SIGNER_PROFILE`: + - when set to `production`, provider `env` is rejected fail-closed, + `TBTC_SIGNER_STATE_PATH` is required, bootstrap dealer DKG is rejected, and + `TBTC_SIGNER_ALLOW_BOOTSTRAP` cannot enable synthetic finalize payloads. + The production profile also forces ROAST strict attempt-context enforcement + even if `TBTC_SIGNER_ENABLE_ROAST_STRICT` is unset or false. + +Set these environment variables before the first FFI call in the process. The +engine state handle is initialized once per process from the settled +`TBTC_SIGNER_STATE_PATH` and key-provider configuration. + +Command-provider contract (`TBTC_SIGNER_STATE_KEY_COMMAND`): + +- Must exit with status `0`. +- Must write a single 32-byte key as hex (64 chars) to stdout. +- Trailing newline is allowed. +- Must return the same key across signer restarts for the same state file. +- Should not log key material. + +The encrypted envelope stores a derived key identifier (`sha256:`), and +load fails closed if the configured provider returns a different key. +State files written before this change with legacy +`key_id=TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX` remain readable for compatibility. + +### Local/dev example (env provider) + +```bash +export TBTC_SIGNER_STATE_KEY_PROVIDER=env +export TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX="$(openssl rand -hex 32)" +``` + +### AWS KMS example (command provider) + +Assumes a ciphertext blob was produced earlier and stored on disk. + +```bash +export TBTC_SIGNER_PROFILE=production +export TBTC_SIGNER_STATE_KEY_PROVIDER=command +export TBTC_SIGNER_STATE_KEY_COMMAND='aws kms decrypt \ + --region "$AWS_REGION" \ + --ciphertext-blob fileb://"$TBTC_SIGNER_STATE_KEY_BLOB_PATH" \ + --query Plaintext --output text \ + | base64 --decode \ + | xxd -p -c 256' +``` + +### GCP KMS example (command provider) + +```bash +export TBTC_SIGNER_PROFILE=production +export TBTC_SIGNER_STATE_KEY_PROVIDER=command +export TBTC_SIGNER_STATE_KEY_COMMAND='tmp="$(mktemp)"; \ + gcloud kms decrypt \ + --location "$GCP_KMS_LOCATION" \ + --keyring "$GCP_KMS_KEYRING" \ + --key "$GCP_KMS_KEY" \ + --ciphertext-file "$TBTC_SIGNER_STATE_KEY_BLOB_PATH" \ + --plaintext-file "$tmp" >/dev/null && \ + xxd -p -c 256 "$tmp"; \ + rc=$?; rm -f "$tmp"; exit $rc' +``` + +### HSM/agent example (command provider) + +If a local HSM-backed agent is available: + +```bash +export TBTC_SIGNER_PROFILE=production +export TBTC_SIGNER_STATE_KEY_PROVIDER=command +export TBTC_SIGNER_STATE_KEY_COMMAND='/opt/tbtc-signer/bin/state-key-agent \ + --key tbtc-signer-state-v1 \ + --format hex' +``` + +### Rotation, Recovery, and Failure Modes + +State-key rotation must be planned as an operator runbook, not an automatic +startup behavior. To rotate, stop the signer, back up the encrypted state file, +decrypt or unwrap the current state key through the existing provider, re-encrypt +the state file with the new provider key in an offline maintenance step, then +start with the new command provider and verify the envelope `key_id` matches the +new provider. Do not delete the old KMS/HSM material until restart/load evidence +has been captured and rollback has been approved. + +Recovery requires restoring both the encrypted state file and the provider-side +key material or wrapped key blob for the envelope `key_id`. If either side is +missing, the signer must remain stopped or quarantined; replacing the provider +with a different key intentionally fails closed with a key-id mismatch. + +Failure-mode responses: + +- Missing command, non-zero exit, timeout, non-UTF-8 output, malformed hex, or + key-id mismatch: leave `TBTC_SIGNER_PROFILE=production` enabled, keep the + signer out of service, and repair the provider or restore matching key + material. Do not fall back to `env` in production. +- KMS/HSM outage: keep the node failed closed, confirm other operators preserve + threshold availability, and use the approved provider recovery path before + restarting. +- Suspected provider compromise: stop the signer, preserve logs and state + artifacts, rotate through the offline process above, and require security-owner + approval before returning to service. + +## Benchmarks (Phase 5 Scaffold) + +Run the Phase 5 benchmark harness: + +```bash +cd pkg/tbtc/signer +cargo bench --features bench-restart-hook --bench phase5_roast +``` + +Current benchmark groups: + +- `phase5/ffi_run_dkg` (`RunDKG` happy path) +- `phase5/ffi_start_sign_round` (`StartSignRound` happy path) +- `phase5/ffi_finalize_sign_round` (bootstrap finalize happy path) +- `phase5/ffi_start_sign_round_recovery`: + - `timeout_transition_authorized` + - `invalid_share_proof_transition_with_rotation` +- `phase5/ffi_start_sign_round_replay_guard`: + - `stale_attempt_rejected_after_transition` +- `phase5/ffi_start_sign_round_restart_paths`: + - `authorized_transition_after_reload` + - `stale_attempt_rejected_after_reload` + +## Chaos Suite (Phase 5) + +Run the Phase 5 chaos/failure-injection suite: + +```bash +cd pkg/tbtc/signer +./scripts/run_phase5_chaos_suite.sh +``` + +Scenario coverage and pass criteria: + +- `stale_payload_replay_or_duplication`: stale attempt payloads remain fail-closed + after authorized advancement and reload. +- `restart_recovery_authorized_transition`: authorized transition succeeds after + restart/reload with deterministic attempt context. +- `process_crash_active_attempt`: consumed-attempt replay guard survives + simulated crash and cache loss. +- `persist_fault_pre_rename`: previous durable state remains intact after + injected pre-rename persist fault. +- `persist_fault_post_rename`: renamed durable state remains loadable after + injected post-rename persist fault. + +## FFI contract + +- Header: `pkg/tbtc/signer/include/frost_tbtc.h` +- All API payloads are JSON bytes. +- Success: `status_code = 0`, response envelope in `buffer`. +- Error: `status_code = 1`, + `{"code":"...","message":"...","recovery_class":"..."}` JSON in `buffer`. +- `recovery_class` values: + - `recoverable`: caller can retry with corrected/updated input. + - `terminal`: session state is terminal for the current operation/session. +- `frost_tbtc_roast_liveness_policy` response: + - `coordinator_timeout_ms`: effective coordinator-timeout policy in + milliseconds. + - `timeout_source`: timeout clock/source identifier (`keep_core_wall_clock`). + - `advance_trigger`: policy trigger used for attempt advancement + (`coordinator_timeout`). + - `exclusion_evidence_policy`: evidence policy marker + (`timeout_or_invalid_share_proof`). +- `frost_tbtc_hardening_metrics` response includes: + - runtime version and enforcement flags for provenance/admission/policy gates + - counters for DKG calls/successes/admission rejects + - counters for start-sign-round calls/successes + - counters for build-tx calls/successes/policy rejects + - counters for refresh-shares calls/successes + - counters for transcript-audit and blame-proof verification calls/successes + - counters for finalize calls/successes and attempt transition/failover events + - counters for auto-quarantine fault events/enforcements and current + quarantined-operator count + - counters for overdue refresh sessions and emergency-rekey-required sessions + - counters for differential-fuzz runs/critical divergences and canary + promotions/rollbacks + - p95 latency and sample-count fields for `run_dkg`, `start_sign_round`, + `build_taproot_tx`, `finalize_sign_round`, and `refresh_shares` +- Coordinator timeout policy config: + - env var: `TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS` + - valid range: `1000..=300000` + - default: `30000` +- Provenance gate config: + - `TBTC_SIGNER_ENFORCE_PROVENANCE_GATE` + - `TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS` (must be `approved`) + - `TBTC_SIGNER_PROVENANCE_TRUST_ROOT` (required 32-byte x-only secp256k1 public key hex) + - `TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD` (signed JSON containing `status`, `runtime_version`, and required `expires_at_unix`) + - `TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX` (schnorr signature hex over `sha256(payload_bytes)`) + - `TBTC_SIGNER_MIN_APPROVED_VERSION` +- Admission policy config: + - `TBTC_SIGNER_ENFORCE_ADMISSION_POLICY` + - `TBTC_SIGNER_ADMISSION_MIN_PARTICIPANTS` + - `TBTC_SIGNER_ADMISSION_MIN_THRESHOLD` + - `TBTC_SIGNER_ADMISSION_REQUIRED_IDENTIFIERS` (comma-separated) + - `TBTC_SIGNER_ADMISSION_ALLOWLIST_IDENTIFIERS` (comma-separated; unset to disable, empty string is invalid) +- Signing policy firewall config: + - `TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL` + - `TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES` (comma-separated, e.g. `p2tr,p2wpkh`) + - `TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT` (required when firewall is enabled) + - `TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS` (required when firewall is enabled) + - `TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS` (required when firewall is enabled) + - `TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR` / `TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR` + - Note: setting `ALLOWED_UTC_START_HOUR == ALLOWED_UTC_END_HOUR` opens a + 24-hour window (all hours permitted). + - `TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE` + - Signing-path binding: when the firewall is enabled, `StartSignRound.message_hex` + must equal `sha256(tx_hex_bytes)` from the same-session `BuildTaprootTx` + result; `FinalizeSignRound` re-validates the same binding. + - `BuildTaprootTx` currently accepts caller-derived `script_pubkey_hex` + outputs; until full script-tree construction lands, keep the firewall + enabled and restrict `TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES` to the + intended output classes, such as `p2tr`. +- Transcript accountability / quarantine config: + - `TBTC_SIGNER_ENABLE_AUTO_QUARANTINE` + - `TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD` + - `TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY` + - `TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY` + - `TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS` + - `RoastTranscriptAudit` returns persisted attempt-transition records (hash + + exclusion evidence) for a session. + - `VerifyBlameProof` validates a claimed excluded operator/reason against the + persisted transcript record for the requested attempt. + - `QuarantineStatus` reports current score/quarantine state for an operator. +- Refresh cadence / emergency rekey: + - `TBTC_SIGNER_REFRESH_CADENCE_SECONDS` (valid range: `60..=2592000`, + default `86400`) + - `RefreshCadenceStatus` reports continuity/overdue status and rekey flags. + - `TriggerEmergencyRekey` marks a session as rekey-required and blocks + additional signing starts for that session. +- Differential safety + canary controls: + - `RunDifferentialFuzzing` runs deterministic differential checks for ROAST + attempt context hashing and policy-bound signing message derivation. + - `CanaryRolloutStatus` reports current rollout cohort and SLO gate posture. + This endpoint is provenance-gated. + - `PromoteCanary` enforces `10% -> 50% -> 100%` progression and halts on SLO + gate failure. + - `RollbackCanary` restores the previous cohort with persisted config + versioning. + - SLO gate env vars: + - `TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS` + - `TBTC_SIGNER_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS` + - `TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS` +- Known limitations (P0 scope): + - Policy gates default to disabled: provenance/admission/signing enforcement + gates require explicit `=true` env vars. +- `StartSignRound.attempt_transition_evidence.exclusion_evidence` schema: + - `reason`: `coordinator_timeout` or `invalid_share_proof` + - `excluded_member_identifiers`: members excluded from the next attempt + - `invalid_share_proof_fingerprint`: required only for + `invalid_share_proof`, omitted for `coordinator_timeout` +- `StartSignRound` response telemetry: + - `attempt_transition_telemetry` is included when attempt advancement is + authorized, with: + - from/to attempt numbers + - from/to coordinator identifiers + - transition reason + - excluded member identifiers + - `coordinator_rotated` flag +- Representative error codes: + - `provenance_gate_rejected`: provenance/min-version gate rejected request. + - `admission_policy_rejected`: DKG admission policy rejected request. + - `signing_policy_rejected`: signing policy firewall rejected request. + - `lifecycle_policy_rejected`: refresh/canary lifecycle policy rejected + request. + - `session_conflict`: same session retried with a different payload. + - `session_finalized`: `StartSignRound` called after successful finalize on + that session. + - `synthetic_contribution_rejected`: synthetic finalize payload used while + bootstrap mode is disabled. +- Call `frost_tbtc_free_buffer` for every returned buffer. diff --git a/pkg/tbtc/signer/benches/phase5_roast.rs b/pkg/tbtc/signer/benches/phase5_roast.rs new file mode 100644 index 0000000000..68255da742 --- /dev/null +++ b/pkg/tbtc/signer/benches/phase5_roast.rs @@ -0,0 +1,837 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Once, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const MESSAGE_HEX: &str = "4b2f57fd3d2e4fd8d68abf9f6ba5e8d51f68de3a63f4f47c8aa2d43f0ca1bc52"; +const ROAST_INCLUDED_PARTICIPANTS_FINGERPRINT_DOMAIN: &str = "FROST-ROAST-INCLUDED-FPR-v1"; +const ROAST_ATTEMPT_ID_DOMAIN: &str = "FROST-ROAST-ATTEMPT-ID-v1"; +const ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT: &str = "coordinator_timeout"; +const ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF: &str = "invalid_share_proof"; + +static BENCH_ENV_INIT: Once = Once::new(); +static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1); +static BENCHMARK_COORDINATORS: OnceLock = OnceLock::new(); + +macro_rules! call_raw { + ($fn_name:path, $request:expr) => {{ + let request_bytes = serde_json::to_vec(&$request).expect("request serialization"); + let result = $fn_name(request_bytes.as_ptr(), request_bytes.len()); + let status_code = result.status_code; + let response_bytes = if result.buffer.ptr.is_null() || result.buffer.len == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(result.buffer.ptr, result.buffer.len).to_vec() } + }; + frost_tbtc::frost_tbtc_free_buffer(result.buffer.ptr, result.buffer.len); + + (status_code, response_bytes) + }}; +} + +macro_rules! call_json { + ($fn_name:path, $request:expr) => {{ + let (status_code, response_bytes) = call_raw!($fn_name, $request); + if status_code != 0 { + panic!( + "ffi call failed [{}]: {}", + stringify!($fn_name), + String::from_utf8_lossy(&response_bytes) + ); + } + + response_bytes + }}; +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +struct DkgParticipant { + identifier: u16, + public_key_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +struct RunDkgRequest { + session_id: String, + participants: Vec, + threshold: u16, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct DkgResult { + key_group: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +struct AttemptContext { + attempt_number: u32, + coordinator_identifier: u16, + included_participants: Vec, + included_participants_fingerprint: String, + attempt_id: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +struct AttemptExclusionEvidence { + reason: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + excluded_member_identifiers: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + invalid_share_proof_fingerprint: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +struct AttemptTransitionEvidence { + from_attempt_number: u32, + from_attempt_id: String, + from_coordinator_identifier: u16, + previous_round_id: String, + previous_sign_request_fingerprint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + exclusion_evidence: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +struct StartSignRoundRequest { + session_id: String, + member_identifier: u16, + message_hex: String, + key_group: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + signing_participants: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + attempt_context: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + attempt_transition_evidence: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +struct RoundContribution { + identifier: u16, + signature_share_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct AttemptTransitionTelemetry { + reason: String, + #[serde(default)] + excluded_member_identifiers: Vec, + coordinator_rotated: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct RoundState { + session_id: String, + round_id: String, + message_digest_hex: String, + #[serde(default)] + attempt_transition_telemetry: Option, + own_contribution: RoundContribution, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +struct FinalizeSignRoundRequest { + session_id: String, + round_contributions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + attempt_context: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct SignatureResult { + signature_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct ErrorResponse { + code: String, + message: String, + recovery_class: String, +} + +#[derive(Clone, Debug)] +struct BenchmarkCoordinators { + attempt_one_all_members: u16, + attempt_two_all_members: u16, +} + +fn hash_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn canonicalize_included_participants(mut included_participants: Vec) -> Vec { + included_participants.sort_unstable(); + included_participants.dedup(); + assert!( + included_participants + .iter() + .all(|identifier| *identifier != 0), + "included participants must be non-zero" + ); + included_participants +} + +fn push_framed_component(payload: &mut Vec, component: &[u8]) { + let component_len = u32::try_from(component.len()).expect("component length within u32"); + payload.extend_from_slice(&component_len.to_be_bytes()); + payload.extend_from_slice(component); +} + +fn roast_hash_hex_with_components(domain: &str, components: &[&[u8]]) -> String { + let mut payload = Vec::new(); + push_framed_component(&mut payload, domain.as_bytes()); + for component in components { + push_framed_component(&mut payload, component); + } + + hash_hex(&payload) +} + +fn message_digest_hex() -> String { + let message_bytes = hex::decode(MESSAGE_HEX).expect("message hex"); + hash_hex(&message_bytes) +} + +fn roast_included_participants_fingerprint_hex(included_participants: &[u16]) -> String { + let mut participant_payload = Vec::new(); + for participant_identifier in included_participants { + push_framed_component( + &mut participant_payload, + &participant_identifier.to_be_bytes(), + ); + } + + roast_hash_hex_with_components( + ROAST_INCLUDED_PARTICIPANTS_FINGERPRINT_DOMAIN, + &[&participant_payload], + ) +} + +fn roast_attempt_id_hex( + session_id: &str, + message_digest_hex: &str, + attempt_number: u32, + coordinator_identifier: u16, + included_participants_fingerprint_hex: &str, +) -> String { + roast_hash_hex_with_components( + ROAST_ATTEMPT_ID_DOMAIN, + &[ + session_id.as_bytes(), + message_digest_hex.as_bytes(), + &attempt_number.to_be_bytes(), + &coordinator_identifier.to_be_bytes(), + included_participants_fingerprint_hex.as_bytes(), + ], + ) +} + +fn canonicalize_start_sign_round_request_for_fingerprint(request: &mut StartSignRoundRequest) { + if let Some(signing_participants) = request.signing_participants.as_mut() { + signing_participants.sort_unstable(); + } + + if let Some(attempt_context) = request.attempt_context.as_mut() { + attempt_context.included_participants.sort_unstable(); + attempt_context.included_participants_fingerprint = attempt_context + .included_participants_fingerprint + .to_ascii_lowercase(); + attempt_context.attempt_id = attempt_context.attempt_id.to_ascii_lowercase(); + } + + if let Some(transition_evidence) = request.attempt_transition_evidence.as_mut() { + transition_evidence.from_attempt_id = transition_evidence + .from_attempt_id + .trim() + .to_ascii_lowercase(); + if let Some(exclusion_evidence) = transition_evidence.exclusion_evidence.as_mut() { + exclusion_evidence.reason = exclusion_evidence.reason.trim().to_ascii_lowercase(); + exclusion_evidence + .excluded_member_identifiers + .sort_unstable(); + if let Some(proof_fingerprint) = + exclusion_evidence.invalid_share_proof_fingerprint.as_mut() + { + *proof_fingerprint = proof_fingerprint.trim().to_ascii_lowercase(); + } + } + } +} + +fn sign_request_fingerprint(request: &StartSignRoundRequest) -> String { + let mut canonical_request = request.clone(); + canonicalize_start_sign_round_request_for_fingerprint(&mut canonical_request); + let bytes = serde_json::to_vec(&canonical_request).expect("fingerprint request serialization"); + hash_hex(&bytes) +} + +fn ensure_benchmark_environment() { + BENCH_ENV_INIT.call_once(|| { + let bench_nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("unix epoch") + .as_nanos(); + let state_path = + std::env::temp_dir().join(format!("frost_tbtc_phase5_bench_state_{bench_nonce}.json")); + let _ = std::fs::remove_file(&state_path); + + std::env::set_var("TBTC_SIGNER_STATE_PATH", &state_path); + std::env::set_var("TBTC_SIGNER_MAX_SESSIONS", "200000"); + std::env::set_var("TBTC_SIGNER_ALLOW_BOOTSTRAP", "true"); + std::env::set_var("TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK", "true"); + }); +} + +fn next_session_id(prefix: &str) -> String { + let index = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("phase5-bench-{prefix}-{index}") +} + +fn run_dkg(session_id: &str) -> DkgResult { + let request = RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + }; + + serde_json::from_slice(&call_json!(frost_tbtc::frost_tbtc_run_dkg, request)) + .expect("dkg response") +} + +fn build_attempt_context( + session_id: &str, + attempt_number: u32, + coordinator_identifier: u16, + included_participants: Vec, +) -> AttemptContext { + let canonical_included_participants = canonicalize_included_participants(included_participants); + let included_participants_fingerprint = + roast_included_participants_fingerprint_hex(&canonical_included_participants); + let attempt_id = roast_attempt_id_hex( + session_id, + &message_digest_hex(), + attempt_number, + coordinator_identifier, + &included_participants_fingerprint, + ); + + AttemptContext { + attempt_number, + coordinator_identifier, + included_participants: canonical_included_participants, + included_participants_fingerprint, + attempt_id, + } +} + +fn probe_deterministic_coordinator(attempt_number: u32, included_participants: Vec) -> u16 { + let canonical_included_participants = canonicalize_included_participants(included_participants); + let probe_session_id = next_session_id("coord-probe"); + let dkg_result = run_dkg(&probe_session_id); + + let mut errors = Vec::new(); + for candidate in &canonical_included_participants { + let request = StartSignRoundRequest { + session_id: probe_session_id.clone(), + member_identifier: 1, + message_hex: MESSAGE_HEX.to_string(), + key_group: dkg_result.key_group.clone(), + signing_participants: Some(canonical_included_participants.clone()), + attempt_context: Some(build_attempt_context( + &probe_session_id, + attempt_number, + *candidate, + canonical_included_participants.clone(), + )), + attempt_transition_evidence: None, + }; + + let (status_code, response_bytes) = + call_raw!(frost_tbtc::frost_tbtc_start_sign_round, request); + if status_code == 0 { + return *candidate; + } + + errors.push(String::from_utf8_lossy(&response_bytes).to_string()); + } + + panic!( + "failed to resolve deterministic coordinator for attempt [{}] participants {:?}: {}", + attempt_number, + canonical_included_participants, + errors.join(" | ") + ); +} + +fn benchmark_coordinators() -> &'static BenchmarkCoordinators { + BENCHMARK_COORDINATORS.get_or_init(|| BenchmarkCoordinators { + attempt_one_all_members: probe_deterministic_coordinator(1, vec![1, 2, 3]), + attempt_two_all_members: probe_deterministic_coordinator(2, vec![1, 2, 3]), + }) +} + +fn participants_excluding(excluded_member_identifier: u16) -> Vec { + canonicalize_included_participants( + [1_u16, 2_u16, 3_u16] + .into_iter() + .filter(|identifier| *identifier != excluded_member_identifier) + .collect(), + ) +} + +fn start_sign_round(session_id: &str, key_group: &str) -> RoundState { + let request = StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: MESSAGE_HEX.to_string(), + key_group: key_group.to_string(), + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + + serde_json::from_slice(&call_json!( + frost_tbtc::frost_tbtc_start_sign_round, + request + )) + .expect("start sign round response") +} + +fn bootstrap_synthetic_share_hex(round_state: &RoundState, identifier: u16) -> String { + let mut hasher = Sha256::new(); + hasher.update( + format!( + "tbtc-signer-bootstrap-contribution-v1:{}:{}:{}:{}", + round_state.session_id, + round_state.round_id, + round_state.message_digest_hex, + identifier + ) + .as_bytes(), + ); + hex::encode(hasher.finalize()) +} + +fn setup_timeout_transition_request() -> StartSignRoundRequest { + ensure_benchmark_environment(); + + let coordinators = benchmark_coordinators(); + let session_id = next_session_id("transition-timeout"); + let dkg_result = run_dkg(&session_id); + + let attempt_one_request = StartSignRoundRequest { + session_id: session_id.clone(), + member_identifier: 1, + message_hex: MESSAGE_HEX.to_string(), + key_group: dkg_result.key_group.clone(), + signing_participants: None, + attempt_context: Some(build_attempt_context( + &session_id, + 1, + coordinators.attempt_one_all_members, + vec![1, 2, 3], + )), + attempt_transition_evidence: None, + }; + let attempt_one_fingerprint = sign_request_fingerprint(&attempt_one_request); + let attempt_one_round_state: RoundState = serde_json::from_slice(&call_json!( + frost_tbtc::frost_tbtc_start_sign_round, + attempt_one_request.clone() + )) + .expect("attempt one round state"); + + let transition_evidence = AttemptTransitionEvidence { + from_attempt_number: 1, + from_attempt_id: attempt_one_request + .attempt_context + .expect("attempt one context") + .attempt_id, + from_coordinator_identifier: coordinators.attempt_one_all_members, + previous_round_id: attempt_one_round_state.round_id, + previous_sign_request_fingerprint: attempt_one_fingerprint, + exclusion_evidence: Some(AttemptExclusionEvidence { + reason: ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT.to_string(), + excluded_member_identifiers: vec![], + invalid_share_proof_fingerprint: None, + }), + }; + + StartSignRoundRequest { + session_id: session_id.clone(), + member_identifier: 1, + message_hex: MESSAGE_HEX.to_string(), + key_group: dkg_result.key_group, + signing_participants: None, + attempt_context: Some(build_attempt_context( + &session_id, + 2, + coordinators.attempt_two_all_members, + vec![1, 2, 3], + )), + attempt_transition_evidence: Some(transition_evidence), + } +} + +fn setup_invalid_share_transition_request() -> StartSignRoundRequest { + ensure_benchmark_environment(); + + let coordinators = benchmark_coordinators(); + let excluded_member_identifier = coordinators.attempt_one_all_members; + let incoming_included_participants = participants_excluding(excluded_member_identifier); + let incoming_coordinator_identifier = + probe_deterministic_coordinator(2, incoming_included_participants.clone()); + let session_id = next_session_id("transition-invalid-share"); + let dkg_result = run_dkg(&session_id); + + let attempt_one_request = StartSignRoundRequest { + session_id: session_id.clone(), + member_identifier: 1, + message_hex: MESSAGE_HEX.to_string(), + key_group: dkg_result.key_group.clone(), + signing_participants: None, + attempt_context: Some(build_attempt_context( + &session_id, + 1, + coordinators.attempt_one_all_members, + vec![1, 2, 3], + )), + attempt_transition_evidence: None, + }; + let attempt_one_fingerprint = sign_request_fingerprint(&attempt_one_request); + let attempt_one_round_state: RoundState = serde_json::from_slice(&call_json!( + frost_tbtc::frost_tbtc_start_sign_round, + attempt_one_request.clone() + )) + .expect("attempt one round state"); + + let transition_evidence = AttemptTransitionEvidence { + from_attempt_number: 1, + from_attempt_id: attempt_one_request + .attempt_context + .expect("attempt one context") + .attempt_id, + from_coordinator_identifier: coordinators.attempt_one_all_members, + previous_round_id: attempt_one_round_state.round_id, + previous_sign_request_fingerprint: attempt_one_fingerprint, + exclusion_evidence: Some(AttemptExclusionEvidence { + reason: ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF.to_string(), + excluded_member_identifiers: vec![excluded_member_identifier], + invalid_share_proof_fingerprint: Some("aa55".to_string()), + }), + }; + + StartSignRoundRequest { + session_id: session_id.clone(), + member_identifier: 1, + message_hex: MESSAGE_HEX.to_string(), + key_group: dkg_result.key_group, + signing_participants: Some(incoming_included_participants.clone()), + attempt_context: Some(build_attempt_context( + &session_id, + 2, + incoming_coordinator_identifier, + incoming_included_participants, + )), + attempt_transition_evidence: Some(transition_evidence), + } +} + +fn setup_stale_attempt_replay_request() -> StartSignRoundRequest { + ensure_benchmark_environment(); + + let coordinators = benchmark_coordinators(); + let session_id = next_session_id("stale-attempt-replay"); + let dkg_result = run_dkg(&session_id); + + let attempt_one_request = StartSignRoundRequest { + session_id: session_id.clone(), + member_identifier: 1, + message_hex: MESSAGE_HEX.to_string(), + key_group: dkg_result.key_group.clone(), + signing_participants: None, + attempt_context: Some(build_attempt_context( + &session_id, + 1, + coordinators.attempt_one_all_members, + vec![1, 2, 3], + )), + attempt_transition_evidence: None, + }; + let attempt_one_fingerprint = sign_request_fingerprint(&attempt_one_request); + let attempt_one_round_state: RoundState = serde_json::from_slice(&call_json!( + frost_tbtc::frost_tbtc_start_sign_round, + attempt_one_request.clone() + )) + .expect("attempt one round state"); + + let transition_evidence = AttemptTransitionEvidence { + from_attempt_number: 1, + from_attempt_id: attempt_one_request + .attempt_context + .as_ref() + .expect("attempt one context") + .attempt_id + .clone(), + from_coordinator_identifier: coordinators.attempt_one_all_members, + previous_round_id: attempt_one_round_state.round_id, + previous_sign_request_fingerprint: attempt_one_fingerprint, + exclusion_evidence: Some(AttemptExclusionEvidence { + reason: ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT.to_string(), + excluded_member_identifiers: vec![], + invalid_share_proof_fingerprint: None, + }), + }; + + let attempt_two_request = StartSignRoundRequest { + session_id: session_id.clone(), + member_identifier: 1, + message_hex: MESSAGE_HEX.to_string(), + key_group: dkg_result.key_group, + signing_participants: None, + attempt_context: Some(build_attempt_context( + &session_id, + 2, + coordinators.attempt_two_all_members, + vec![1, 2, 3], + )), + attempt_transition_evidence: Some(transition_evidence), + }; + let _: RoundState = serde_json::from_slice(&call_json!( + frost_tbtc::frost_tbtc_start_sign_round, + attempt_two_request + )) + .expect("attempt two round state"); + + attempt_one_request +} + +fn benchmark_run_dkg(c: &mut Criterion) { + ensure_benchmark_environment(); + + let mut group = c.benchmark_group("phase5/ffi_run_dkg"); + group.bench_function("happy_path", |b| { + b.iter(|| { + let session_id = next_session_id("dkg"); + black_box(run_dkg(&session_id)); + }); + }); + group.finish(); +} + +fn benchmark_start_sign_round(c: &mut Criterion) { + ensure_benchmark_environment(); + + let mut group = c.benchmark_group("phase5/ffi_start_sign_round"); + group.bench_function("happy_path", |b| { + b.iter_batched( + || { + let session_id = next_session_id("start"); + let dkg_result = run_dkg(&session_id); + (session_id, dkg_result.key_group) + }, + |(session_id, key_group)| { + black_box(start_sign_round(&session_id, &key_group)); + }, + BatchSize::SmallInput, + ); + }); + group.finish(); +} + +fn benchmark_finalize_sign_round_bootstrap(c: &mut Criterion) { + ensure_benchmark_environment(); + + let mut group = c.benchmark_group("phase5/ffi_finalize_sign_round"); + group.bench_function("bootstrap_happy_path", |b| { + b.iter_batched( + || { + let session_id = next_session_id("finalize"); + let dkg_result = run_dkg(&session_id); + let round_state = start_sign_round(&session_id, &dkg_result.key_group); + let round_contributions = vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + RoundContribution { + identifier: 3, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 3), + }, + ]; + + FinalizeSignRoundRequest { + session_id, + round_contributions, + attempt_context: None, + } + }, + |request| { + let response_bytes = + call_json!(frost_tbtc::frost_tbtc_finalize_sign_round, request); + let finalize_result: SignatureResult = + serde_json::from_slice(&response_bytes).expect("finalize response"); + black_box(finalize_result.signature_hex); + }, + BatchSize::SmallInput, + ); + }); + group.finish(); +} + +fn benchmark_start_sign_round_recovery(c: &mut Criterion) { + ensure_benchmark_environment(); + let _ = benchmark_coordinators(); + + let mut group = c.benchmark_group("phase5/ffi_start_sign_round_recovery"); + group.bench_function("timeout_transition_authorized", |b| { + b.iter_batched( + setup_timeout_transition_request, + |request| { + let response_bytes = call_json!(frost_tbtc::frost_tbtc_start_sign_round, request); + let round_state: RoundState = + serde_json::from_slice(&response_bytes).expect("timeout transition response"); + let telemetry = round_state + .attempt_transition_telemetry + .expect("timeout transition telemetry"); + assert_eq!(telemetry.reason, ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT); + assert!(telemetry.excluded_member_identifiers.is_empty()); + black_box(round_state.round_id); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("invalid_share_proof_transition_with_rotation", |b| { + b.iter_batched( + setup_invalid_share_transition_request, + |request| { + let expected_excluded_members = request + .attempt_transition_evidence + .as_ref() + .expect("transition evidence") + .exclusion_evidence + .as_ref() + .expect("exclusion evidence") + .excluded_member_identifiers + .clone(); + let response_bytes = call_json!(frost_tbtc::frost_tbtc_start_sign_round, request); + let round_state: RoundState = serde_json::from_slice(&response_bytes) + .expect("invalid-share transition response"); + let telemetry = round_state + .attempt_transition_telemetry + .expect("invalid-share transition telemetry"); + assert_eq!(telemetry.reason, ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF); + assert_eq!( + telemetry.excluded_member_identifiers, + expected_excluded_members + ); + assert!(telemetry.coordinator_rotated); + black_box(round_state.round_id); + }, + BatchSize::SmallInput, + ); + }); + + group.finish(); +} + +fn benchmark_start_sign_round_replay_guard(c: &mut Criterion) { + ensure_benchmark_environment(); + let _ = benchmark_coordinators(); + + let mut group = c.benchmark_group("phase5/ffi_start_sign_round_replay_guard"); + group.bench_function("stale_attempt_rejected_after_transition", |b| { + b.iter_batched( + setup_stale_attempt_replay_request, + |request| { + let (status_code, response_bytes) = + call_raw!(frost_tbtc::frost_tbtc_start_sign_round, request); + assert_eq!(status_code, 1); + let error: ErrorResponse = + serde_json::from_slice(&response_bytes).expect("error response"); + assert_eq!(error.code, "validation_error"); + assert!(error.message.contains("stale")); + black_box(error.message); + }, + BatchSize::SmallInput, + ); + }); + group.finish(); +} + +fn benchmark_start_sign_round_restart_paths(c: &mut Criterion) { + ensure_benchmark_environment(); + let _ = benchmark_coordinators(); + + let mut group = c.benchmark_group("phase5/ffi_start_sign_round_restart_paths"); + group.bench_function("authorized_transition_after_reload", |b| { + b.iter_batched( + setup_timeout_transition_request, + |request| { + frost_tbtc::frost_tbtc_reload_state_from_storage_for_benchmarks() + .expect("reload signer state from storage"); + let response_bytes = call_json!(frost_tbtc::frost_tbtc_start_sign_round, request); + let round_state: RoundState = serde_json::from_slice(&response_bytes) + .expect("authorized transition response after reload"); + let telemetry = round_state + .attempt_transition_telemetry + .expect("authorized transition telemetry after reload"); + assert_eq!(telemetry.reason, ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT); + assert!(telemetry.excluded_member_identifiers.is_empty()); + black_box(round_state.round_id); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("stale_attempt_rejected_after_reload", |b| { + b.iter_batched( + setup_stale_attempt_replay_request, + |request| { + frost_tbtc::frost_tbtc_reload_state_from_storage_for_benchmarks() + .expect("reload signer state from storage"); + let (status_code, response_bytes) = + call_raw!(frost_tbtc::frost_tbtc_start_sign_round, request); + assert_eq!(status_code, 1); + let error: ErrorResponse = + serde_json::from_slice(&response_bytes).expect("error response"); + assert_eq!(error.code, "validation_error"); + assert!(error.message.contains("stale")); + black_box(error.message); + }, + BatchSize::SmallInput, + ); + }); + group.finish(); +} + +criterion_group!( + phase5_benches, + benchmark_run_dkg, + benchmark_start_sign_round, + benchmark_finalize_sign_round_bootstrap, + benchmark_start_sign_round_recovery, + benchmark_start_sign_round_replay_guard, + benchmark_start_sign_round_restart_paths +); +criterion_main!(phase5_benches); diff --git a/pkg/tbtc/signer/build.sh b/pkg/tbtc/signer/build.sh new file mode 100644 index 0000000000..bc50e75129 --- /dev/null +++ b/pkg/tbtc/signer/build.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +cargo build --release diff --git a/pkg/tbtc/signer/docs/formal/models/README.md b/pkg/tbtc/signer/docs/formal/models/README.md new file mode 100644 index 0000000000..042b45ff1d --- /dev/null +++ b/pkg/tbtc/signer/docs/formal/models/README.md @@ -0,0 +1,56 @@ +# Formal Verification Models + +This directory contains executable TLA+ models used for signer hardening +formal-verification checks. + +Model coverage: + +- `RoastAttemptStateMachine.tla`: + ROAST attempt-transition invariants (attempt monotonicity, coordinator and + cohort safety, replay rejection shape). This model does not cover the full + Aborted/Completed/Nonce lifecycle. +- `StateKeyProviderPolicy.tla`: + encrypted-state key/provider policy invariants aligned with PR #82 surfaces + (provider policy gating + key-id binding fail-closed). +- `TeeEnforcementModes.tla`: + TEE enforcement mode transition and admission invariants aligned with PR #88 + policy surfaces. +- `RoastRolloutPolicy.tla`: + Phase 5 staged rollout and rollback transition guards (canary progression, + rollback preconditions, and halted-mode terminal behavior). + +Model bounds: + +- `RoastAttemptStateMachine.tla` is currently bounded to participants `{1,2,3,4}` + and max attempt `6` for exhaustive TLC search in CI. +- `StateKeyProviderPolicy.tla` uses profile/provider/key-id finite domains that + represent policy transitions, not arbitrary unbounded inputs. +- `TeeEnforcementModes.tla` uses finite mode and attestation domains. +- `RoastRolloutPolicy.tla` uses finite rollout stages and trigger booleans to + exhaustively check stage/rollback constraints. + +Traceability matrix: + +- `RoastAttemptStateMachine.tla`: + `MonotonicAttemptNumber`, `ReplaySafe` -> + `validate_attempt_context`, replay guards in start/finalize flow in + `tools/tbtc-signer/src/engine.rs`. +- `StateKeyProviderPolicy.tla`: + `LoadSuccessImpliesExactBinding`, `FailClosedDisallowedProvider` -> + `decode_encrypted_state_envelope`, `encode_encrypted_state_envelope` in + `tools/tbtc-signer/src/engine.rs`. +- `TeeEnforcementModes.tla`: + `EnforceModeRequiresValidAttestationWithoutOverride`, + `NoDirectDisabledToEnforceTransition` -> policy design in + `docs/frost-migration/tee-whitelisted-signer-enforcement-plan.md`. +- `RoastRolloutPolicy.tla`: + `BroadRequiresCanaryHistory`, `RollbackTransitionRequiresTrigger`, + `CanaryHoldBlocksPromotion`, `BootstrapCannotJumpToBroad`, + `EmergencyStopBlocksForwardProgress`, `HaltedModeIsTerminal` -> + `docs/frost-migration/roast-phase-5-security-rollout-gates.md`. + +Run all models with: + +```bash +scripts/formal/run_tla_models.sh +``` diff --git a/pkg/tbtc/signer/docs/formal/models/RoastAttemptStateMachine.cfg b/pkg/tbtc/signer/docs/formal/models/RoastAttemptStateMachine.cfg new file mode 100644 index 0000000000..4efb270aad --- /dev/null +++ b/pkg/tbtc/signer/docs/formal/models/RoastAttemptStateMachine.cfg @@ -0,0 +1,7 @@ +SPECIFICATION Spec + +INVARIANTS +TypeOK +MonotonicAttemptNumber +ReplaySafe +ConsumedRegistryIsDurableSuperset diff --git a/pkg/tbtc/signer/docs/formal/models/RoastAttemptStateMachine.tla b/pkg/tbtc/signer/docs/formal/models/RoastAttemptStateMachine.tla new file mode 100644 index 0000000000..20ebb38946 --- /dev/null +++ b/pkg/tbtc/signer/docs/formal/models/RoastAttemptStateMachine.tla @@ -0,0 +1,112 @@ +------------------------------ MODULE RoastAttemptStateMachine ------------------------------ +EXTENDS FiniteSets, Naturals, Sequences, TLC + +Participants == {1, 2, 3, 4} +Threshold == 2 +MaxAttempt == 6 + +VARIABLES + attemptNumber, + lastAttemptNumber, + activeParticipants, + coordinator, + consumedAttemptIds, + durableConsumedAttemptIds + +vars == + <> + +SetMin(S) == + CHOOSE x \in S: \A y \in S: x <= y + +AttemptId(attempt, coordinatorIdentifier, includedParticipants) == + <> + +CurrentAttemptId == + AttemptId(attemptNumber, coordinator, activeParticipants) + +CanAdvance(excluded) == + /\ attemptNumber < MaxAttempt + /\ excluded \subseteq activeParticipants + /\ activeParticipants \ excluded # {} + /\ Cardinality(activeParticipants \ excluded) >= Threshold + +Init == + /\ attemptNumber = 1 + /\ lastAttemptNumber = 1 + /\ activeParticipants = Participants + /\ coordinator = SetMin(Participants) + /\ consumedAttemptIds = {} + /\ durableConsumedAttemptIds = {} + +Advance(excluded) == + /\ CanAdvance(excluded) + /\ attemptNumber' = attemptNumber + 1 + /\ lastAttemptNumber' = attemptNumber + /\ activeParticipants' = activeParticipants \ excluded + /\ coordinator' = SetMin(activeParticipants') + /\ consumedAttemptIds' = consumedAttemptIds \cup {CurrentAttemptId} + /\ durableConsumedAttemptIds' = durableConsumedAttemptIds \cup {CurrentAttemptId} + +RestartReload == + /\ attemptNumber' = attemptNumber + /\ lastAttemptNumber' = lastAttemptNumber + /\ activeParticipants' = activeParticipants + /\ coordinator' = coordinator + /\ consumedAttemptIds' = durableConsumedAttemptIds + /\ durableConsumedAttemptIds' = durableConsumedAttemptIds + +CacheLoss == + /\ attemptNumber' = attemptNumber + /\ lastAttemptNumber' = lastAttemptNumber + /\ activeParticipants' = activeParticipants + /\ coordinator' = coordinator + /\ consumedAttemptIds' = {} + /\ durableConsumedAttemptIds' = durableConsumedAttemptIds + +Stay == + /\ attemptNumber' = attemptNumber + /\ lastAttemptNumber' = lastAttemptNumber + /\ activeParticipants' = activeParticipants + /\ coordinator' = coordinator + /\ consumedAttemptIds' = consumedAttemptIds + /\ durableConsumedAttemptIds' = durableConsumedAttemptIds + +Next == + \/ \E excluded \in SUBSET activeParticipants: Advance(excluded) + \/ RestartReload + \/ CacheLoss + \/ Stay + +Spec == + Init /\ [][Next]_vars + +ConsumedIdWellFormed(id) == + /\ Len(id) = 3 + /\ id[1] \in 1..MaxAttempt + /\ id[2] \in Participants + /\ id[3] \subseteq Participants + +TypeOK == + /\ attemptNumber \in 1..MaxAttempt + /\ lastAttemptNumber \in 1..MaxAttempt + /\ lastAttemptNumber <= attemptNumber + /\ activeParticipants \subseteq Participants + /\ activeParticipants # {} + /\ Cardinality(activeParticipants) >= Threshold + /\ coordinator \in activeParticipants + /\ \A id \in consumedAttemptIds: ConsumedIdWellFormed(id) + /\ \A id \in durableConsumedAttemptIds: ConsumedIdWellFormed(id) + /\ consumedAttemptIds \subseteq durableConsumedAttemptIds + +MonotonicAttemptNumber == + attemptNumber >= lastAttemptNumber + +ReplaySafe == + /\ CurrentAttemptId \notin durableConsumedAttemptIds + /\ \A id \in durableConsumedAttemptIds: id[1] < attemptNumber + +ConsumedRegistryIsDurableSuperset == + consumedAttemptIds \subseteq durableConsumedAttemptIds + +============================================================================= diff --git a/pkg/tbtc/signer/docs/formal/models/RoastRolloutPolicy.cfg b/pkg/tbtc/signer/docs/formal/models/RoastRolloutPolicy.cfg new file mode 100644 index 0000000000..e076118b64 --- /dev/null +++ b/pkg/tbtc/signer/docs/formal/models/RoastRolloutPolicy.cfg @@ -0,0 +1,10 @@ +SPECIFICATION Spec + +INVARIANT TypeOK +INVARIANT BroadRequiresCanaryHistory + +PROPERTY RollbackTransitionRequiresTrigger +PROPERTY CanaryHoldBlocksPromotion +PROPERTY BootstrapCannotJumpToBroad +PROPERTY EmergencyStopBlocksForwardProgress +PROPERTY HaltedModeIsTerminal diff --git a/pkg/tbtc/signer/docs/formal/models/RoastRolloutPolicy.tla b/pkg/tbtc/signer/docs/formal/models/RoastRolloutPolicy.tla new file mode 100644 index 0000000000..c9ed50fb94 --- /dev/null +++ b/pkg/tbtc/signer/docs/formal/models/RoastRolloutPolicy.tla @@ -0,0 +1,122 @@ +----------------------------- MODULE RoastRolloutPolicy ----------------------------- +EXTENDS TLC + +Stages == {"bootstrap", "canary", "broad", "rollback", "halted"} + +VARIABLES stage, canaryCompleted, holdTrigger, rollbackTrigger, manualOverride, emergencyStop + +vars == <> + +Init == + /\ stage = "bootstrap" + /\ canaryCompleted = FALSE + /\ holdTrigger \in BOOLEAN + /\ rollbackTrigger \in BOOLEAN + /\ manualOverride \in BOOLEAN + /\ emergencyStop \in BOOLEAN + +UpdateSignals == + /\ holdTrigger' \in BOOLEAN + /\ rollbackTrigger' \in BOOLEAN + /\ manualOverride' \in BOOLEAN + /\ emergencyStop' \in BOOLEAN + /\ UNCHANGED <> + +StartCanary == + /\ stage = "bootstrap" + /\ ~emergencyStop + /\ stage' = "canary" + /\ UNCHANGED <> + +PromoteCanaryToBroad == + /\ stage = "canary" + /\ ~holdTrigger + /\ ~rollbackTrigger + /\ ~emergencyStop + /\ stage' = "broad" + /\ canaryCompleted' = TRUE + /\ UNCHANGED <> + +HoldCanary == + /\ stage = "canary" + /\ holdTrigger + /\ UNCHANGED vars + +RollbackFromCanary == + /\ stage = "canary" + /\ rollbackTrigger + /\ stage' = "rollback" + /\ UNCHANGED <> + +RollbackFromBroad == + /\ stage = "broad" + /\ rollbackTrigger + /\ stage' = "rollback" + /\ UNCHANGED <> + +RecoverRollbackToCanary == + /\ stage = "rollback" + /\ manualOverride + /\ ~rollbackTrigger + /\ ~emergencyStop + /\ stage' = "canary" + /\ UNCHANGED <> + +EmergencyHalt == + /\ emergencyStop + /\ stage' = "halted" + /\ UNCHANGED <> + +StayHalted == + /\ stage = "halted" + /\ stage' = "halted" + /\ UNCHANGED <> + +NoOp == + /\ ~emergencyStop + /\ UNCHANGED vars + +Next == + \/ UpdateSignals + \/ StartCanary + \/ PromoteCanaryToBroad + \/ HoldCanary + \/ RollbackFromCanary + \/ RollbackFromBroad + \/ RecoverRollbackToCanary + \/ EmergencyHalt + \/ StayHalted + \/ NoOp + +Spec == Init /\ [][Next]_vars + +TypeOK == + /\ stage \in Stages + /\ canaryCompleted \in BOOLEAN + /\ holdTrigger \in BOOLEAN + /\ rollbackTrigger \in BOOLEAN + /\ manualOverride \in BOOLEAN + /\ emergencyStop \in BOOLEAN + +BroadRequiresCanaryHistory == stage = "broad" => canaryCompleted + +RollbackTransitionRequiresTrigger == + [][((stage # "rollback" /\ stage' = "rollback") => + /\ rollbackTrigger + /\ stage \in {"canary", "broad"})]_vars + +CanaryHoldBlocksPromotion == + [][((stage = "canary" /\ (holdTrigger \/ rollbackTrigger)) => stage' # "broad")]_vars + +BootstrapCannotJumpToBroad == [][(stage = "bootstrap" => stage' # "broad")]_vars + +EmergencyStopBlocksForwardProgress == + [][ + /\ ((emergencyStop /\ stage = "bootstrap") => stage' # "canary") + /\ ((emergencyStop /\ stage = "canary") => stage' # "broad") + /\ ((emergencyStop /\ stage = "rollback") => stage' # "canary") + ]_vars + +HaltedModeIsTerminal == [][(stage = "halted" => stage' = "halted")]_vars + +===================================================================================== diff --git a/pkg/tbtc/signer/docs/formal/models/StateKeyProviderPolicy.cfg b/pkg/tbtc/signer/docs/formal/models/StateKeyProviderPolicy.cfg new file mode 100644 index 0000000000..78502fa1ce --- /dev/null +++ b/pkg/tbtc/signer/docs/formal/models/StateKeyProviderPolicy.cfg @@ -0,0 +1,11 @@ +SPECIFICATION Spec + +CONSTANTS +EnforceProductionProfileGate = FALSE + +INVARIANTS +TypeOK +LoadSuccessImpliesExactBinding +FailClosedDisallowedProvider +PersistedProviderCompliesWithPolicy +ProductionGateRejectsEnv diff --git a/pkg/tbtc/signer/docs/formal/models/StateKeyProviderPolicy.production.cfg b/pkg/tbtc/signer/docs/formal/models/StateKeyProviderPolicy.production.cfg new file mode 100644 index 0000000000..81c17a14d3 --- /dev/null +++ b/pkg/tbtc/signer/docs/formal/models/StateKeyProviderPolicy.production.cfg @@ -0,0 +1,11 @@ +SPECIFICATION Spec + +CONSTANTS +EnforceProductionProfileGate = TRUE + +INVARIANTS +TypeOK +LoadSuccessImpliesExactBinding +FailClosedDisallowedProvider +PersistedProviderCompliesWithPolicy +ProductionGateRejectsEnv diff --git a/pkg/tbtc/signer/docs/formal/models/StateKeyProviderPolicy.tla b/pkg/tbtc/signer/docs/formal/models/StateKeyProviderPolicy.tla new file mode 100644 index 0000000000..76a54886d8 --- /dev/null +++ b/pkg/tbtc/signer/docs/formal/models/StateKeyProviderPolicy.tla @@ -0,0 +1,113 @@ +------------------------------ MODULE StateKeyProviderPolicy ------------------------------ +EXTENDS TLC + +Profiles == {"development", "production"} +Providers == {"env", "command", "kms", "hsm"} +KeyIds == {"kid-a", "kid-b", "kid-c"} + +CONSTANT EnforceProductionProfileGate + +SupportedProviders(profile) == + IF /\ EnforceProductionProfileGate + /\ profile = "production" + THEN {"command"} + ELSE {"env"} + +VARIABLES + profile, + runtimeProvider, + runtimeKeyId, + envelopeProvider, + envelopeKeyId, + requestedProvider, + requestedKeyId, + loadOutcome + +vars == + <> + +LoadSucceeds(profileValue, provider, keyId) == + /\ provider = envelopeProvider + /\ keyId = envelopeKeyId + /\ provider \in SupportedProviders(profileValue) + +Init == + /\ profile = "development" + /\ runtimeProvider = "env" + /\ runtimeKeyId = "kid-a" + /\ envelopeProvider = runtimeProvider + /\ envelopeKeyId = runtimeKeyId + /\ requestedProvider = runtimeProvider + /\ requestedKeyId = runtimeKeyId + /\ loadOutcome = "ok" + +SetProfile(newProfile) == + /\ newProfile \in Profiles + /\ profile' = newProfile + /\ loadOutcome' = IF LoadSucceeds(newProfile, requestedProvider, requestedKeyId) THEN "ok" ELSE "reject" + /\ UNCHANGED <> + +SetRuntime(provider, keyId) == + /\ provider \in Providers + /\ keyId \in KeyIds + /\ runtimeProvider' = provider + /\ runtimeKeyId' = keyId + /\ UNCHANGED <> + +Persist == + /\ runtimeProvider \in SupportedProviders(profile) + /\ envelopeProvider' = runtimeProvider + /\ envelopeKeyId' = runtimeKeyId + /\ loadOutcome' = IF /\ requestedProvider = runtimeProvider + /\ requestedKeyId = runtimeKeyId + /\ requestedProvider \in SupportedProviders(profile) + THEN "ok" + ELSE "reject" + /\ UNCHANGED <> + +AttemptLoad(provider, keyId) == + /\ provider \in Providers + /\ keyId \in KeyIds + /\ requestedProvider' = provider + /\ requestedKeyId' = keyId + /\ loadOutcome' = IF LoadSucceeds(profile, provider, keyId) THEN "ok" ELSE "reject" + /\ UNCHANGED <> + +Next == + \/ \E newProfile \in Profiles: SetProfile(newProfile) + \/ \E provider \in Providers: \E keyId \in KeyIds: SetRuntime(provider, keyId) + \/ Persist + \/ \E provider \in Providers: \E keyId \in KeyIds: AttemptLoad(provider, keyId) + +Spec == + Init /\ [][Next]_vars + +TypeOK == + /\ profile \in Profiles + /\ runtimeProvider \in Providers + /\ runtimeKeyId \in KeyIds + /\ envelopeProvider \in Providers + /\ envelopeKeyId \in KeyIds + /\ requestedProvider \in Providers + /\ requestedKeyId \in KeyIds + /\ loadOutcome \in {"ok", "reject"} + +LoadSuccessImpliesExactBinding == + loadOutcome = "ok" => + /\ requestedProvider = envelopeProvider + /\ requestedKeyId = envelopeKeyId + /\ requestedProvider \in SupportedProviders(profile) + +FailClosedDisallowedProvider == + requestedProvider \notin SupportedProviders(profile) => loadOutcome = "reject" + +PersistedProviderCompliesWithPolicy == + loadOutcome = "ok" => envelopeProvider \in SupportedProviders(profile) + +ProductionGateRejectsEnv == + /\ EnforceProductionProfileGate + /\ profile = "production" + /\ requestedProvider = "env" + => loadOutcome = "reject" + +============================================================================= diff --git a/pkg/tbtc/signer/docs/formal/models/TeeEnforcementModes.cfg b/pkg/tbtc/signer/docs/formal/models/TeeEnforcementModes.cfg new file mode 100644 index 0000000000..5240a752df --- /dev/null +++ b/pkg/tbtc/signer/docs/formal/models/TeeEnforcementModes.cfg @@ -0,0 +1,7 @@ +SPECIFICATION Spec + +INVARIANTS +TypeOK +EnforceModeRequiresValidAttestationWithoutOverride +NoDirectDisabledToEnforceTransition +AdmissionDecisionIsStable diff --git a/pkg/tbtc/signer/docs/formal/models/TeeEnforcementModes.tla b/pkg/tbtc/signer/docs/formal/models/TeeEnforcementModes.tla new file mode 100644 index 0000000000..2f96909c90 --- /dev/null +++ b/pkg/tbtc/signer/docs/formal/models/TeeEnforcementModes.tla @@ -0,0 +1,89 @@ +------------------------------ MODULE TeeEnforcementModes ------------------------------ +EXTENDS TLC + +Modes == {"disabled", "audit", "enforce"} +AttestationStates == {"valid", "invalid", "missing"} + +VARIABLES + mode, + previousMode, + attestation, + breakGlassActive, + lastAdmission + +vars == + <> + +AdmissionDecision(enforcementMode, attestationState, breakGlass) == + IF /\ enforcementMode = "enforce" + /\ ~breakGlass + /\ attestationState # "valid" + THEN "deny" + ELSE "allow" + +AllowedModeTransition(from, to) == + \/ /\ from = "disabled" /\ to \in {"disabled", "audit"} + \/ /\ from = "audit" /\ to \in {"disabled", "audit", "enforce"} + \/ /\ from = "enforce" /\ to \in {"audit", "enforce"} + +Init == + /\ mode = "disabled" + /\ previousMode = "disabled" + /\ attestation = "missing" + /\ breakGlassActive = FALSE + /\ lastAdmission = AdmissionDecision(mode, attestation, breakGlassActive) + +SetMode(newMode) == + /\ newMode \in Modes + /\ AllowedModeTransition(mode, newMode) + /\ previousMode' = mode + /\ mode' = newMode + /\ attestation' = attestation + /\ breakGlassActive' = breakGlassActive + /\ lastAdmission' = AdmissionDecision(mode', attestation', breakGlassActive') + +SetAttestation(newAttestation) == + /\ newAttestation \in AttestationStates + /\ attestation' = newAttestation + /\ UNCHANGED <> + /\ lastAdmission' = AdmissionDecision(mode, attestation', breakGlassActive) + +SetBreakGlass(newBreakGlass) == + /\ newBreakGlass \in BOOLEAN + /\ breakGlassActive' = newBreakGlass + /\ UNCHANGED <> + /\ lastAdmission' = AdmissionDecision(mode, attestation, breakGlassActive') + +ReevaluateAdmission == + /\ lastAdmission' = AdmissionDecision(mode, attestation, breakGlassActive) + /\ UNCHANGED <> + +Next == + \/ \E newMode \in Modes: SetMode(newMode) + \/ \E newAttestation \in AttestationStates: SetAttestation(newAttestation) + \/ \E newBreakGlass \in BOOLEAN: SetBreakGlass(newBreakGlass) + \/ ReevaluateAdmission + +Spec == + Init /\ [][Next]_vars + +TypeOK == + /\ mode \in Modes + /\ previousMode \in Modes + /\ attestation \in AttestationStates + /\ breakGlassActive \in BOOLEAN + /\ lastAdmission \in {"allow", "deny"} + +EnforceModeRequiresValidAttestationWithoutOverride == + (/\ mode = "enforce" + /\ ~breakGlassActive + /\ lastAdmission = "allow") + => attestation = "valid" + +NoDirectDisabledToEnforceTransition == + ~(previousMode = "disabled" /\ mode = "enforce") + +AdmissionDecisionIsStable == + lastAdmission = AdmissionDecision(mode, attestation, breakGlassActive) + +============================================================================= diff --git a/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md b/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md new file mode 100644 index 0000000000..487d993aec --- /dev/null +++ b/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md @@ -0,0 +1,131 @@ +# RFC: Permissioned Signer Set Hardening Roadmap (Post-ROAST/FROST) + +Date: 2026-03-01 +Status: Draft for review +Owner: Threshold Labs + DAO Operations +Scope: Additional security and operability hardening for DAO-whitelisted +FROST/ROAST signer sets. + +## Context + +The ROAST/FROST migration delivers core cryptographic and orchestration +capability. The next risk-reduction layer is operational hardening for a +permissioned signer model. + +This RFC defines a phased plan that does not require formal verification or +TEEs as prerequisites, while remaining compatible with either in future. + +## Goals + +1. Improve accountability for signer and coordinator behavior. +2. Reduce operator concentration and supply-chain risk. +3. Improve liveness during partial failures and targeted abuse. +4. Make releases safer with deterministic rollout and rollback controls. + +## Non-goals + +1. Transition to a permissionless signer set. +2. Replace FROST/ROAST protocol primitives. +3. Use TEEs as a mandatory trust anchor for baseline production safety. + +## Phase Plan + +### Phase P0 (Weeks 0-6): Baseline Controls + +| Milestone | Deliverables | Primary owners | Exit criteria | +| --- | --- | --- | --- | +| `P0-M1` Signer admission policy v1 | Operator policy spec (geo/provider diversity, HSM/KMS class, patch SLA, incident-response contact), automated pre-admission checker, DAO override workflow | Protocol + Ops + Governance | New admissions are policy-gated, and non-compliant operators are blocked with reason codes | +| `P0-M2` Deterministic builds + provenance enforcement | Reproducible signer build recipe, signed provenance artifacts, startup attestation verification, minimum-approved-version gate | Platform + Security | Unattested or unapproved binaries fail closed and cannot join signer pool | +| `P0-M3` Signing policy firewall v1 | Rule engine for allowed transaction/script classes, value/rate/time-window controls, policy decision logging | Protocol + Security | Unauthorized signing requests are rejected pre-signing with auditable policy events | +| `P0-M4` Telemetry + SLO baseline | Attempt-level metrics, signer/coordinator health metrics, alert thresholds, weekly ops report template | Platform + Ops | Dashboard and alerts cover attempt success, latency, and policy reject/fault rates | + +### Phase P1 (Weeks 6-12): Accountability + Liveness Hardening + +| Milestone | Deliverables | Primary owners | Exit criteria | +| --- | --- | --- | --- | +| `P1-M1` Accountable ROAST transcripts | Attempt transcript hashing/signing, evidence persistence, verifier for blame proofs (equivocation/non-participation) | Protocol + Security | Faults can be proven and attributed from persisted evidence | +| `P1-M2` Reputation + auto-quarantine | Operator scoring model (latency/fault/policy violations), auto-exclusion thresholds, manual DAO re-enable path | Ops + Governance + Protocol | Repeatedly faulty operators are automatically excluded within one policy epoch | +| `P1-M3` Active-active coordinators + anti-DoS transport limits | Coordinator failover protocol, authenticated transport budget/rate limits, replay-resistant request envelopes | Protocol + Platform | Coordinator loss does not halt signing; abuse load is rate-limited without breaking healthy flow | +| `P1-M4` Chaos and fault-injection program | Monthly drills (coordinator crash, signer loss, partition, stale attempt replay), drill runbook, corrective action tracker | Ops + Security | Drills run on schedule and unresolved critical findings block promotion | + +### Phase P2 (Weeks 12-20): Lifecycle + Deployment Safety + +| Milestone | Deliverables | Primary owners | Exit criteria | +| --- | --- | --- | --- | +| `P2-M1` Periodic key refresh/reshare cadence | Reshare policy and tooling, no-address-rotation proof points, emergency rekey path | Protocol + Ops | Refresh is repeatable and does not violate wallet continuity invariants | +| `P2-M2` Implementation diversity + differential fuzzing | Independent verification path or secondary implementation checks, differential/fuzz harnesses, divergence triage workflow | Security + Protocol | Differential CI runs continuously with no unresolved critical divergence | +| `P2-M3` Canary rollout + instant rollback controls | 10%-50%-100% rollout policy, signer cohort canaries, one-command rollback and config pinning | Platform + Ops | Canary progression is automated by SLO gates; rollback is validated under incident drill | + +## Acceptance Test Catalog + +### P0 acceptance tests + +- `AT-P0-01` Admission checks enforce policy: + onboarding fails for provider-diversity violation, missing attestation, or + expired patch SLA; compliant operator passes. +- `AT-P0-02` Provenance gate is fail-closed: + signer startup exits non-zero with untrusted provenance; starts normally with + approved provenance and version floor. +- `AT-P0-03` Policy firewall blocks unauthorized sign requests: + disallowed script/value/rate requests are rejected with stable reason codes; + canonical mint/redeem vectors pass. +- `AT-P0-04` Observability baseline exists: + load scenario (100+ attempts) produces metrics for success, p95 latency, + policy rejects, and coordinator failovers; alerts trigger on threshold breach. + +### P1 acceptance tests + +- `AT-P1-01` Transcript accountability: + injected equivocation/non-participation faults produce verifiable blame + proofs and operator attribution. +- `AT-P1-02` Quarantine automation: + operator crossing fault threshold is auto-excluded within one epoch and + cannot rejoin without explicit governance action. +- `AT-P1-03` Coordinator resilience: + primary coordinator kill during attempt triggers standby takeover within SLO + without double-signing or orphaned finalize. +- `AT-P1-04` Abuse resistance: + high-rate malformed request traffic is dropped by limits while nominal flows + remain within target latency budget. + +### P2 acceptance tests + +- `AT-P2-01` Refresh continuity: + scheduled reshare completes without wallet identity drift and without + violating signing availability SLO. +- `AT-P2-02` Differential safety: + fuzz corpus and deterministic vectors run across both implementations/checkers + with no unresolved critical divergence. +- `AT-P2-03` Upgrade safety: + canary promotion halts automatically on SLO breach; rollback restores prior + cohort config within declared recovery objective. + +## Governance and Operational Decisions Needed + +1. DAO ratifies minimum operator requirements and enforcement policy. +2. DAO ratifies automatic quarantine thresholds and override process. +3. Security owner ratifies provenance trust roots and signing keys. +4. Ops owner ratifies SLO targets used as rollout and rollback gates. + +## Evidence and Reporting + +Milestone evidence should be attached to the relevant implementation PRs or +release/governance records. This repository should retain durable design and +runbook material, not per-run packet scaffolds or raw execution logs. + +## Resourcing Estimate + +- Protocol/runtime: 2 engineers +- Platform/DevOps: 1 engineer +- Security/review: 0.5 FTE equivalent +- Operations/governance support: 0.5 FTE equivalent + +Estimated timeline with parallel workstreams: 4-5 months. + +## Open Questions + +1. Should operator diversity constraints be hard requirements or weighted score + factors at admission time? +2. Which components are mandatory for provenance enforcement in v1 + (binary-only vs binary+config bundles)? +3. Do we require dual approval for manual quarantine overrides? diff --git a/pkg/tbtc/signer/docs/roast-implementation-plan.md b/pkg/tbtc/signer/docs/roast-implementation-plan.md new file mode 100644 index 0000000000..43b56a78fa --- /dev/null +++ b/pkg/tbtc/signer/docs/roast-implementation-plan.md @@ -0,0 +1,288 @@ +# ROAST Implementation Plan (FROST Migration) + +Date: 2026-02-27 +Status: Draft implementation roadmap +Owner: Threshold Labs +Scope: native FROST signing robustness via ROAST-style coordinator semantics + +## 1. Why This Plan Exists + +ROAST coordinator semantics are currently deferred in the Rust signer migration. +This document provides a concrete implementation path. + +## 2. Current Baseline + +Implemented today: + +- Native signer supports cohort-aware `StartSignRound.signing_participants`. +- Finalize is strict to the declared round cohort. +- Retry/cohort attempt metadata is propagated in keep-core runtime. +- Deterministic ROAST-style coordinator selection exists in keep-core + (`pkg/frost/roast.SelectCoordinator`). + +Not implemented today: + +- Full ROAST coordinator state machine and policy enforcement in native path. +- Protocol-level coordinator authorization checks. +- Complete malicious/aborting participant robustness flow. + +## 3. Goal And Non-Goals + +Goal: + +- Implement ROAST-style coordinator semantics end-to-end for native FROST + signing so liveness under aborting/failing participants is materially stronger + than current restart/re-cohort-only behavior. + +Non-goals (for this plan): + +- Distributed DKG redesign. +- Full protocol replacement beyond current FROST migration architecture. +- Mandatory true late t-of-n finalize in the same increment set. + +## 4. Design Principles + +1. Fail closed on ambiguity or transcript mismatch. +2. Keep attempt identity explicit and stable across Rust + keep-core boundaries. +3. Bind every signing step to a deterministic transcript (message, session, + attempt, cohort, coordinator context). +4. Prefer incremental rollout with strict feature gating and clear fallback + behavior. +5. Prefer a stateless coordinator model where possible; coordinator authority + and active attempt context should be derivable from transcript + static + session configuration. Stateful transition authorization and replay + registries remain mandatory for fail-closed restart safety. + +## 5. Threat Model Snapshot + +- Malicious coordinator: + attempts unauthorized advancement, malformed cohort context, or replay. +- Malicious participant: + strategic aborts/invalid shares to force repeated retries or unfair exclusion. +- Network adversary: + replay/reorder/delay/duplication of attempt-context-bearing messages. +- Corrupt persistent state: + tampered state payloads attempting stale-attempt acceptance after restart. + +This is a scoped threat model for implementation sequencing; deeper adversarial +analysis is a Phase 5 gate artifact. + +## 6. Target Semantics + +- Every attempt has a deterministic `attempt_id`. +- Coordinator for attempt `k` is deterministic from attempt seed + included + members + attempt number. +- Participants reject requests whose attempt/coordinator context does not match + local transcript expectations. +- Retry policy is explicit: rotate coordinator first when possible; then exclude + members only when timeout/blame evidence policy allows it. +- Attempt-number advancement is authenticated by defined policy (quorum timeout + evidence, blame evidence, or an equivalent signed transition rule). +- Replay/nonce-safety invariants are preserved across attempt transitions. +- Observability can explain why an attempt failed and why next attempt was + selected. + +## 7. Phased Implementation Plan + +### Phase 0: Protocol Spec Freeze + +Deliverables: + +- Short RFC/decision brief for ROAST semantics in current architecture. +- Phase 0 artifact: + `docs/frost-migration/roast-phase-0-spec-freeze.md`. +- Nonce-safety argument for attempt transitions (cohort/coordinator changes) + under current deterministic nonce model. +- Threat-model-to-control mapping for coordinator, participant, network, and + persisted-state adversaries. +- Canonical transcript fields and domain-separation tags. +- Error taxonomy for coordinator/attempt mismatch and stale attempt reuse. +- Resolve known preconditions from prior reviews before coordinator enforcement: + - response validation bypass risk in cohort response handling, + - double-derivation ambiguity for included members, + - consumed-registry capacity-check ordering in sign path. + +Acceptance criteria: + +- Spec approved by signer and keep-core owners. +- No unresolved ambiguity on attempt identity or coordinator authority. +- Cross-language test vectors pass for canonical attempt-context hashing. + +### Phase 1: API/Contract Extensions + +Deliverables: + +- Extend native signer request envelope(s) with explicit attempt context: + `attempt_number`, `attempt_id`, `coordinator_identifier`, + `included_participants_fingerprint`. +- Canonical serialization/hash rules for attempt context. +- Backward-compatible contract gating (`feature`/env/runtime flag). +- Explicit migration behavior for pre-ROAST persisted sessions when strict mode + is enabled (recommended: fail closed with clear error and require session + restart). +- Shared cross-repo test vectors for attempt-context serialization/hash + round-trip. + +Acceptance criteria: + +- FFI tests cover encode/decode, mismatch rejection, idempotent retry behavior. +- Strict mode rejects missing/invalid attempt context. +- Migration-path tests cover pre-ROAST session-state behavior on strict-mode + enablement. + +### Phase 1.5: Consumed-Registry Integration + +Deliverables: + +- Define relationship between `attempt_id`, existing `round_id`, and consumed + registries (`consumed_sign_round_ids`, `consumed_finalize_round_ids`). +- Decide whether attempt tracking is additive (new registry) or replacement key + strategy, with explicit replay implications. +- Define cap policy for attempt-related registries and interaction with + `TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION`. +- Ensure capacity-check ordering is fail-closed before state mutation. + +Acceptance criteria: + +- Registry model is documented and implemented consistently in Rust + keep-core + retry handling. +- Capacity-limit tests cover deterministic fail-closed behavior (no eviction + weakening). + +### Phase 2: Coordinator Policy Enforcement + +Deliverables: + +- keep-core coordinator runtime enforces selected coordinator semantics for each + attempt (not informational-only). +- Reject non-authorized coordinator actions in native flow. +- Implement authenticated attempt-transition policy (who can advance attempt and + with what evidence). + +Acceptance criteria: + +- Integration matrix covers: + + | Scenario | Expected | + | --- | --- | + | Correct coordinator + correct attempt context | Accept | + | Wrong coordinator + correct attempt context | Reject | + | Correct coordinator + wrong attempt number | Reject | + | Correct coordinator + wrong participants fingerprint | Reject | + | Coordinator valid for attempt N but request carries N+1 | Reject | + | Valid payload for stale attempt `< current` | Reject | + +- Deterministic attempt transitions are reproducible across retries/restarts. + +### Phase 3: Attempt Transcript And Replay Hardening + +Deliverables: + +- Bind signer-side state to `(session_id, attempt_id, message, cohort)` and + reject cross-attempt replay. +- Define state-machine behavior for concurrent/future attempt payloads (for + example receiving attempt `N+1` while local state is at `N`). +- Persist attempt lifecycle artifacts needed for restart-safe enforcement. +- Add bounded retention policy for attempt registries (fail closed, no silent + eviction that weakens replay protection). + +Acceptance criteria: + +- Restart tests prove stale attempt replay rejection. +- Concurrency tests prove deterministic handling of future-attempt payloads. +- Capacity-limit tests prove deterministic fail-closed behavior. + +### Phase 4: Liveness Policy And Recovery Behavior + +Deliverables: + +- Explicit policy for excluding failed members and advancing to next attempt. +- Coordinator-failure detection semantics (timeout source, default timeout, + configurability, and who triggers advance). +- Evidence requirements for exclusion/blame (timeout-only vs cryptographic proof + for invalid-share faults). +- Clear distinction between recoverable (retry) and terminal (abort) errors. +- Optional policy hook for adaptive backoff/coordinator rotation. + +Acceptance criteria: + +- End-to-end tests with injected signer/coordinator failures succeed when + threshold is still available. +- Failure reasons are surfaced in structured telemetry. +- Exclusion decisions are traceable to policy-defined evidence in logs/telemetry. + +### Phase 5: Security/Review Gates And Rollout + +Deliverables: + +- Phase 5 gate artifact: + `docs/frost-migration/roast-phase-5-security-rollout-gates.md`. +- Adversarial review packet focused on coordinator authority, transcript + binding, replay resistance, and restart safety. +- Rollout plan with feature flags, canary stages, and rollback conditions. +- Operational readiness metrics and rollback thresholds: + - attempt success rate, + - coordinator rotations per signing request, + - p95/p99 signing latency deltas vs baseline. +- Provisional threshold bands are documented in the Phase 5 gate artifact and + must be calibrated against baseline before production cutover. +- Benchmarks for happy-path, single-member failure, and coordinator-failure + conditions, validated against tBTC protocol timeout budgets. +- Chaos test suite for network partition/delay/duplication and process crash + during active signing rounds. + +Acceptance criteria: + +- All blocking review findings resolved. +- Human sign-off recorded for ROAST gate. +- Performance and chaos criteria meet documented rollout thresholds. + +## 8. Proposed Chunking + +Recommended chunk order (docs+code): + +1. Resolve precondition findings from prior cohort/consumed-registry reviews. +2. Phase 0 spec freeze + shared test vectors. +3. Phase 1 FFI/API contract scaffolding + strict-mode migration semantics. +4. Phase 1.5 consumed-registry integration and cap policy. +5. Phase 2 coordinator enforcement + authenticated attempt advancement. +6. Phase 3 transcript/replay/restart/concurrency hardening. +7. Phase 4 liveness policy + coordinator timeout/blame evidence. +8. Phase 5 performance benchmarks + chaos/failure-matrix testing. +9. Adversarial review packet + rollout runbook + human sign-off. + +## 9. Dependencies And Ownership + +- `tbtc` repo: + - `tools/tbtc-signer` request/state model updates and tests. +- `threshold-network/keep-core` repo: + - coordinator policy enforcement, runtime retry transitions, integration tests. +- Canonical attempt-context struct source of truth: + `tools/tbtc-signer/src/api.rs`. +- keep-core must implement byte-for-byte compatible encode/decode + hashing. +- Shared attempt-context vectors should be maintained and validated in both + repos (proposed path: + `docs/frost-migration/test-vectors/roast-attempt-context-v1.json`). + +## 10. Relation To True Late t-of-n Finalize + +ROAST and true late t-of-n are different roadmap items. + +- ROAST should be implemented first for robustness/liveness guarantees. +- True late t-of-n can be reconsidered after ROAST based on production evidence. +- Attempt/transcript identifiers should be versioned so late t-of-n (if adopted + later) can reuse model foundations without breaking compatibility. + +## 11. Definition Of Done + +ROAST is considered implemented for this migration when: + +- coordinator authority is enforced in native flow, +- attempt transcript binding is enforced end-to-end, +- replay/restart safety is proven by tests, +- liveness under partial failures is demonstrated by e2e and chaos failure + matrix tests, +- benchmarked latency/rotation metrics remain within documented rollout + thresholds, +- backward-compatible upgrade behavior from pre-ROAST sessions is tested, and +- external human review sign-off is complete. diff --git a/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md b/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md new file mode 100644 index 0000000000..536d00bef0 --- /dev/null +++ b/pkg/tbtc/signer/docs/roast-phase-0-spec-freeze.md @@ -0,0 +1,197 @@ +# ROAST Phase 0 Spec Freeze + +Date: 2026-02-27 +Status: Draft (for signer + keep-core owner approval) +Owner: Threshold Labs +Scope: canonical attempt-context contract and coordinator semantics for ROAST migration + +## 1. Purpose + +Freeze the minimum cross-repo contract required before ROAST code increments: + +- attempt identity fields, +- deterministic hashing/domain separation, +- coordinator authorization semantics, +- fail-closed error taxonomy. + +This spec is an input to Phase 1 implementation work in: + +- `tbtc` (`tools/tbtc-signer`), and +- `threshold-network/keep-core`. + +## 2. Decisions (Frozen For Phase 1) + +1. Attempt context is mandatory in strict ROAST mode for sign/finalize flow. +2. Attempt context is canonicalized identically in Rust and Go before hashing. +3. `attempt_id` is deterministic and transcript-bound. +4. Coordinator authority is enforced (not informational-only). +5. Stale or replayed attempt payloads fail closed. +6. Attempt advancement (`N -> N+1`) must be authorized by policy-defined + evidence; no single actor can force advancement unilaterally. +7. Pre-ROAST persisted sessions under strict mode require explicit migration + behavior (recommended fail-closed requiring session restart). + +## 3. Attempt Context Contract + +Attempt context fields: + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `attempt_number` | `u32` | yes | 1-based monotonic per logical signing flow | +| `coordinator_identifier` | `u16` | yes | member identifier selected for this attempt | +| `included_participants` | `Vec` | yes | sorted unique non-zero participant IDs | +| `included_participants_fingerprint` | `hex(sha256)` | yes | canonical hash of included set | +| `attempt_id` | `hex(sha256)` | yes | canonical transcript identifier | + +Validation rules: + +- `attempt_number >= 1`. +- `included_participants` must be non-empty, unique, sorted ascending, and + include `coordinator_identifier`. +- `included_participants.len() >= threshold`. +- `attempt_id` and `included_participants_fingerprint` must match recomputed + canonical values. + +## 4. Canonical Hashing Rules + +Domain separation tags: + +- `FROST-ROAST-INCLUDED-FPR-v1` +- `FROST-ROAST-ATTEMPT-ID-v1` + +Canonical framing: + +- Length-prefixed binary framing for every component: + `len(component_u32_be) || component_bytes`. +- Integers encoded big-endian fixed width (`u16`, `u32`). +- Session/message identifiers encoded as raw bytes after strict validation. + +Included participants fingerprint: + +- `included_participants_fingerprint = SHA256(tag || framed(sorted_unique_ids))` + +Attempt id: + +- `attempt_id = SHA256(tag || framed(session_id) || framed(message_digest_hex) || framed(attempt_number) || framed(coordinator_identifier) || framed(included_participants_fingerprint))` + +Output format: + +- Lowercase hex string, no prefix. + +## 5. Coordinator Semantics + +- keep-core computes deterministic coordinator for each attempt using existing + ROAST-style selection policy. +- Native signer validates that payload coordinator matches attempt context and + included participants. +- Requests from non-authorized coordinator context are rejected in strict mode. +- Coordinator authorization applies per attempt; retries require new valid + attempt context. + +## 6. Attempt Transition Authorization + +- Transition from attempt `N` to `N+1` requires policy-defined authorization + evidence (for example quorum timeout evidence, blame evidence, or equivalent + signed transition proof). +- Coordinator selection for `N+1` is deterministic but does not by itself + authorize transition; authorization evidence is required. +- Future-attempt requests lacking valid transition authorization are rejected + fail-closed. + +## 7. Request Surface Changes (Phase 1 Input) + +The following request families gain `attempt_context` in strict ROAST mode: + +- `StartSignRound` +- `FinalizeSignRound` + +Transitional gating: + +- `TBTC_SIGNER_ENABLE_ROAST_STRICT=true` (or equivalent feature gate) enables + strict enforcement. +- While disabled, attempt context may be accepted but is not mandatory. +- Pre-ROAST persisted sessions in strict mode follow explicit migration + behavior (recommended fail-closed requiring session restart under new + attempt-context-aware session). + +## 8. Error Taxonomy (Fail-Closed) + +Proposed stable codes: + +| Code | Meaning | +| --- | --- | +| `attempt_context_missing` | required attempt context absent in strict mode | +| `attempt_context_invalid` | malformed fields or canonicalization violation | +| `attempt_id_mismatch` | provided attempt id differs from recomputed value | +| `coordinator_mismatch` | coordinator does not match authorized attempt context | +| `attempt_stale` | attempt number older than active/known session attempt | +| `attempt_future` | attempt number is ahead of local state without valid transition authorization | +| `attempt_transition_unauthorized` | attempt advancement evidence invalid/missing | +| `attempt_replay` | attempt id already consumed for this transcript | +| `attempt_conflict` | same session retry with materially different attempt context | +| `attempt_exhausted` | retry policy limit reached | +| `pre_roast_session_unsupported` | strict mode rejects session persisted without required ROAST attempt context | + +Mapping guidance: + +- Rust signer returns structured engine errors mapped to these stable codes at + FFI boundary. +- keep-core treats `attempt_stale`, `attempt_replay`, `coordinator_mismatch`, + `attempt_id_mismatch`, and `attempt_transition_unauthorized` as non-retriable + for that attempt payload. + +## 9. Replay, Restart, And Concurrency Invariants + +1. Attempt id is single-use for a given `(session_id, message, cohort)` flow. +2. Stale attempts (lower `attempt_number`) are rejected after higher attempt is + accepted. +3. Future attempts (`attempt_number > current`) are accepted only when + transition authorization is valid; otherwise rejected fail-closed. +4. Persisted state reload/restart must preserve replay/stale-attempt rejection. +5. Bounded attempt registries must fail closed when capacity is reached (no + eviction policy that weakens replay protection). + +## 10. Consumed Registry Integration Notes + +- Existing `round_id`-based consumed registries remain authoritative for + nonce/single-use protection. +- `attempt_id` tracking is additive for coordinator/attempt-transition replay + semantics (not a silent replacement of `round_id` protections). +- Cap policy for attempt registries vs existing consumed registries must be + explicitly documented in Phase 1.5 implementation work. + +## 11. Threat Model Notes + +- Malicious coordinator: + prevented from unilateral attempt advancement by transition authorization and + coordinator-context validation. +- Malicious participant: + exclusion requires policy-defined evidence; not based on unverified claims. +- Network adversary: + replay/reorder/delay is constrained by attempt id, attempt number, and + transition-authorization validation. +- Corrupt persisted state: + stale/future/replay acceptance must remain fail-closed after restart/reload. + +## 12. Out Of Scope For Phase 0 + +- Full liveness policy tuning (timeouts/backoff policy details). +- True late t-of-n finalize semantics. +- DKG architecture redesign. + +## 13. Approval Checklist + +Required approvers: + +- signer owner, +- keep-core owner, +- security reviewer. + +Sign-off criteria: + +1. Both implementations can produce identical hashes for test vectors. +2. No ambiguity remains about coordinator authorization checks. +3. Error taxonomy is stable enough for integration tests and telemetry. +4. Strict-mode gate behavior is explicitly defined. +5. Transition-authorization behavior is specified for stale/future attempts. +6. Migration behavior for pre-ROAST persisted sessions is explicitly chosen. diff --git a/pkg/tbtc/signer/docs/roast-phase-1.5-consumed-registry-integration.md b/pkg/tbtc/signer/docs/roast-phase-1.5-consumed-registry-integration.md new file mode 100644 index 0000000000..00c53126c7 --- /dev/null +++ b/pkg/tbtc/signer/docs/roast-phase-1.5-consumed-registry-integration.md @@ -0,0 +1,69 @@ +# ROAST Phase 1.5: Consumed-Registry Integration + +Date: 2026-02-27 +Status: Signer-side complete (keep-core compatibility confirmed at contract level) +Owner: Threshold Labs +Scope: bind ROAST attempt identity to signer round identity before coordinator-policy phases + +## Objective + +Define and start implementing how `attempt_id` interacts with signer +single-use/replay keys (`round_id`) so later ROAST phases can support +multi-attempt semantics without weakening nonce/replay protections. + +## Decisions Implemented In This Increment + +1. `round_id` is now derived with an explicit attempt component. +2. When `attempt_context` is present, the attempt component is + `attempt_context.attempt_id` canonicalized to lowercase. +3. When `attempt_context` is absent, a stable sentinel (`none`) is used to + preserve deterministic round-id derivation for legacy/non-strict flow. +4. `attempt_context` fingerprint canonicalization now lowercases + `included_participants_fingerprint` and `attempt_id` to avoid false + idempotency conflicts from hex case variance. + +## Rationale + +- Keeps existing `round_id`-bound nonce-safety model intact while making round + identity attempt-aware. +- Avoids mixed-case hex drift between validation (`eq_ignore_ascii_case`) and + idempotency fingerprinting. +- Preserves backward compatibility for non-strict mode by keeping deterministic + round-id behavior when attempt context is omitted. + +## Evidence (Code + Tests) + +- Round-id derivation helper: + `tools/tbtc-signer/src/engine.rs` (`derive_round_id`, + `round_attempt_id_component`). +- Attempt-context canonicalization fix: + `tools/tbtc-signer/src/engine.rs` (`canonicalize_attempt_context_for_fingerprint`). +- Hash golden vectors: + `engine::tests::roast_attempt_context_hash_vectors_match_expected_values`. +- Round-id attempt binding test: + `engine::tests::derive_round_id_binds_attempt_id_case_insensitive_component`. +- Case-variant idempotent retry test: + `engine::tests::start_sign_round_accepts_hex_case_variant_attempt_context_idempotent_retry`. +- Consumed sign-round registry capacity with attempt context: + `engine::tests::start_sign_round_rejects_when_consumed_sign_round_registry_is_at_capacity_with_attempt_context`. +- Consumed finalize request-fingerprint registry capacity with attempt context: + `engine::tests::finalize_sign_round_rejects_when_consumed_request_registry_is_at_capacity_with_attempt_context`. +- Consumed finalize round-id registry capacity with attempt context: + `engine::tests::finalize_sign_round_rejects_when_consumed_round_registry_is_at_capacity_with_attempt_context`. + +## Compatibility Confirmation + +- Signer request/response contract remains backward compatible for keep-core: + `attempt_context` is optional at the API layer and strictness is controlled by + `TBTC_SIGNER_ENABLE_ROAST_STRICT`. +- Replay and nonce single-use guards remain additive and fail-closed: + round-id consumed registries remain authoritative, and `attempt_context` is + folded into round identity instead of replacing existing guards. +- Existing keep-core retry/cohort wiring evidence remains valid under this + model (see `docs/frost-migration/rust-rewrite-bootstrap.md` keep-core + integration notes and linked `threshold-network/keep-core` commits). + +## Remaining Work + +1. Continue Phase 2 coordinator policy enforcement: + `docs/frost-migration/roast-phase-2-coordinator-policy-enforcement.md`. diff --git a/pkg/tbtc/signer/docs/roast-phase-2-coordinator-policy-enforcement.md b/pkg/tbtc/signer/docs/roast-phase-2-coordinator-policy-enforcement.md new file mode 100644 index 0000000000..e8b07ebe23 --- /dev/null +++ b/pkg/tbtc/signer/docs/roast-phase-2-coordinator-policy-enforcement.md @@ -0,0 +1,85 @@ +# ROAST Phase 2: Coordinator Policy Enforcement + +Date: 2026-02-27 +Status: Complete +Owner: Threshold Labs +Scope: enforce active attempt/coordinator policy in signer flows with authenticated attempt advancement checks + +## Objective + +Enforce attempt-context consistency across signer `StartSignRound` and +`FinalizeSignRound` calls so stale/future/mismatched attempt payloads fail +closed under ROAST strict mode. + +## Decisions Implemented In This Increment + +1. Added per-session `active_attempt_context` state in signer runtime and + persisted session state. +2. Enforced active-attempt matching when an attempt context is active: + - missing `attempt_context` rejects in strict mode and remains accepted in + non-strict compatibility mode, + - stale attempt number (`< active`) rejects, + - future attempt number (`> active`) requires valid transition evidence and + otherwise rejects fail-closed, + - coordinator mismatch rejects, + - participants/fingerprint/attempt-id mismatch rejects. +3. Bound finalize attempt context to the active start attempt context to prevent + coordinator/attempt drift between phases. +4. Added cleanup semantics: active attempt context is cleared with other signing + material on finalize lifecycle teardown. +5. Added explicit `attempt_transition_evidence` contract validation for + `attempt_number` advancement: + - only `N -> N+1` is accepted, + - previous attempt/coordinator fields must match active context, + - `previous_round_id` and `previous_sign_request_fingerprint` must match + active signer session state, + - new attempt ID must differ from active attempt ID. +6. Added deterministic coordinator authorization parity with keep-core + `pkg/frost/roast.SelectCoordinator` policy: + - signer recomputes coordinator from canonical included participants, + message-derived attempt seed, and attempt number, + - request `coordinator_identifier` must match the deterministic selection + result or the request is rejected fail-closed. + +## Evidence (Code + Tests) + +- State model updates: + `tools/tbtc-signer/src/engine.rs` (`SessionState.active_attempt_context`, + `PersistedSessionState.active_attempt_context`). +- Policy enforcement helper: + `tools/tbtc-signer/src/engine.rs` (`enforce_active_attempt_context_match`). +- Deterministic coordinator selector parity helper: + `tools/tbtc-signer/src/go_math_rand.rs` + (`select_coordinator_identifier`). +- Start stale-attempt rejection: + `engine::tests::start_sign_round_rejects_stale_attempt_number_against_active_attempt_context`. +- Start future-attempt rejection: + `engine::tests::start_sign_round_rejects_future_attempt_number_without_transition_authorization`. +- Start next-attempt acceptance with valid evidence: + `engine::tests::start_sign_round_allows_next_attempt_with_valid_transition_evidence`. +- Start next-attempt acceptance with valid evidence after restart/reload: + `engine::tests::start_sign_round_allows_next_attempt_with_valid_transition_evidence_after_reload`. +- Start next-attempt rejection with invalid evidence: + `engine::tests::start_sign_round_rejects_next_attempt_with_invalid_transition_evidence`. +- Start far-future attempt rejection even with evidence: + `engine::tests::start_sign_round_rejects_far_future_attempt_even_with_transition_evidence`. +- Start stale-attempt rejection remains enforced after authorized transition and + restart/reload: + `engine::tests::start_sign_round_rejects_stale_attempt_after_authorized_transition_across_reload`. +- Start non-deterministic coordinator rejection (strict mode): + `engine::tests::start_sign_round_rejects_nondeterministic_coordinator_identifier_in_roast_strict_mode`. +- Finalize coordinator mismatch rejection: + `engine::tests::finalize_sign_round_rejects_coordinator_mismatch_against_active_attempt_context`. +- Finalize stale-attempt rejection: + `engine::tests::finalize_sign_round_rejects_stale_attempt_number_against_active_attempt_context`. +- Non-strict finalize compatibility with active attempt context: + `engine::tests::finalize_sign_round_accepts_missing_attempt_context_when_not_strict_with_active_attempt_context`. +- Non-strict finalize compatibility after restart/reload: + `engine::tests::finalize_sign_round_accepts_missing_attempt_context_after_reload_when_not_strict`. +- Non-strict payload mismatch remains conflict-classified: + `engine::tests::start_sign_round_returns_session_conflict_for_attempt_context_presence_mismatch_in_non_strict_mode`. + +## Remaining Work + +1. No open blocking items for Phase 2 coordinator-policy scope. Next protocol + increment is Phase 3 transcript/replay hardening. diff --git a/pkg/tbtc/signer/docs/roast-phase-3-attempt-transcript-replay-hardening.md b/pkg/tbtc/signer/docs/roast-phase-3-attempt-transcript-replay-hardening.md new file mode 100644 index 0000000000..185a9182f3 --- /dev/null +++ b/pkg/tbtc/signer/docs/roast-phase-3-attempt-transcript-replay-hardening.md @@ -0,0 +1,51 @@ +# ROAST Phase 3: Attempt Transcript And Replay Hardening + +Date: 2026-02-27 +Status: Complete +Owner: Threshold Labs +Scope: explicit attempt replay registry hardening for signer-side transcript lifecycle + +## Objective + +Harden signer replay behavior by persisting a dedicated consumed-attempt registry +so attempt replay safety does not depend only on `round_id` composition. + +## Decisions Implemented In This Increment + +1. Added per-session `consumed_attempt_ids` tracking in signer runtime state. +2. Persisted `consumed_attempt_ids` in durable session state with: + - empty-entry rejection, + - duplicate-entry rejection, + - bounded-size fail-closed validation using existing consumed-registry cap. +3. Enforced `attempt_id` replay rejection in `start_sign_round` before round + signing material generation: + - if `attempt_context.attempt_id` is already consumed for the session, + signer rejects fail-closed. +4. Enforced `consumed_attempt_ids` capacity checks before mutation and without + eviction behavior. +5. Extended restart/reload replay tests to prove consumed-attempt protection + remains active after cache loss and process restart. + +## Evidence (Code + Tests) + +- Runtime and persistence model updates: + `tools/tbtc-signer/src/engine.rs` (`SessionState.consumed_attempt_ids`, + `PersistedSessionState.consumed_attempt_ids`, + `SessionState::try_from`, `PersistedSessionState::try_from`). +- Start-path attempt replay enforcement: + `tools/tbtc-signer/src/engine.rs` (`start_sign_round` consumed-attempt checks). +- Persisted-state validation tests: + - `engine::tests::persisted_session_state_rejects_empty_consumed_attempt_id` + - `engine::tests::persisted_session_state_rejects_duplicate_consumed_attempt_id` + - `engine::tests::persisted_session_state_rejects_consumed_attempt_registry_over_limit` +- Replay enforcement tests: + - `engine::tests::start_sign_round_rejects_consumed_attempt_id_when_sign_cache_is_missing` + - `engine::tests::start_sign_round_attempt_replay_guard_survives_process_restart_with_sign_cache_loss` +- Capacity fail-closed test: + - `engine::tests::start_sign_round_rejects_when_consumed_attempt_registry_is_at_capacity_with_attempt_context` + +## Remaining Phase 3 Work + +1. No open blocking items for Phase 3 transcript/replay scope. Next protocol + increment is Phase 4 liveness policy and recovery behavior: + `docs/frost-migration/roast-phase-4-liveness-policy-recovery.md`. diff --git a/pkg/tbtc/signer/docs/roast-phase-4-liveness-policy-recovery.md b/pkg/tbtc/signer/docs/roast-phase-4-liveness-policy-recovery.md new file mode 100644 index 0000000000..1edb98646e --- /dev/null +++ b/pkg/tbtc/signer/docs/roast-phase-4-liveness-policy-recovery.md @@ -0,0 +1,89 @@ +# ROAST Phase 4: Liveness Policy And Recovery Behavior + +Date: 2026-02-27 +Status: In progress +Owner: Threshold Labs +Scope: establish explicit recoverable-vs-terminal semantics for signer failures + +## Objective + +Begin Phase 4 by making retry/abort intent explicit in signer error responses, +so keep-core and operators can distinguish transient/retryable failures from +terminal/session-ending failures using machine-readable fields. + +## Decisions Implemented In This Increment + +1. Added `EngineError::recovery_class()` classification in + `tools/tbtc-signer/src/errors.rs` with values: + - `recoverable` + - `terminal` +2. Extended signer FFI error payloads with `ErrorResponse.recovery_class` in + `tools/tbtc-signer/src/api.rs` and `tools/tbtc-signer/src/ffi.rs`. +3. Preserved existing `code`/`message` error contract while adding explicit + recovery intent for policy/telemetry consumers. +4. Added `frost_tbtc_roast_liveness_policy` FFI endpoint and + `engine::roast_liveness_policy()` with an env-configurable coordinator + timeout (`TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS`, default `30000ms`). +5. Extended `AttemptTransitionEvidence` with structured + `exclusion_evidence` (`reason`, `excluded_member_identifiers`, + `invalid_share_proof_fingerprint`) and validated it on attempt advancement. +6. Added structured transition telemetry on successful attempt advancement via + `RoundState.attempt_transition_telemetry` (from/to attempt, from/to + coordinator, reason, excluded members, `coordinator_rotated`). + +## Rationale + +- Phase 4 requires a clear distinction between retryable and terminal failures. +- Keep-core retry loops and future liveness policies should not infer retry + semantics from error text. +- This change is additive: existing error code handling remains intact. + +## Evidence (Code + Tests) + +- Recovery classification method + unit test: + - `tools/tbtc-signer/src/errors.rs` + - `errors::tests::recovery_class_maps_retryable_and_terminal_errors` +- FFI payload extension: + - `tools/tbtc-signer/src/api.rs` (`ErrorResponse`) + - `tools/tbtc-signer/src/ffi.rs` (`error_result`) +- API-level assertions: + - `tools/tbtc-signer/src/lib.rs` + - `run_dkg_rejects_conflicting_repeat_request_for_same_session` + - `roast_liveness_policy_reports_default_contract` + - `start_and_finalize_sign_round_rejects_synthetic_contributions_when_bootstrap_disabled` + - `start_sign_round_returns_session_finalized_after_finalize` + - `start_sign_round_returns_session_not_found_for_unknown_session` + - `build_taproot_tx_rejects_invalid_input_txid_hex` +- Timeout policy parser validation: + - `tools/tbtc-signer/src/engine.rs` + - `roast_coordinator_timeout_ms_env_parser_is_strict_bounds` +- Exclusion/blame evidence validation: + - `tools/tbtc-signer/src/api.rs` (`AttemptExclusionEvidence`, `AttemptTransitionEvidence`) + - `tools/tbtc-signer/src/engine.rs` (`validate_transition_exclusion_evidence`) + - `start_sign_round_rejects_next_attempt_without_exclusion_evidence` + - `start_sign_round_rejects_timeout_reason_with_invalid_share_fingerprint` + - `start_sign_round_accepts_invalid_share_proof_exclusion_evidence` + - `start_sign_round_rejects_invalid_share_proof_without_fingerprint` + - `start_sign_round_rejects_invalid_share_proof_with_empty_fingerprint` +- Transition telemetry assertions: + - `tools/tbtc-signer/src/api.rs` (`AttemptTransitionTelemetry`) + - `start_sign_round_allows_next_attempt_with_valid_transition_evidence` + - `start_sign_round_accepts_invalid_share_proof_exclusion_evidence` +- FFI header contract update: + - `tools/tbtc-signer/include/frost_tbtc.h` +- Phase 4 liveness, exclusion-evidence, and transition-telemetry evidence is + summarized in this document. +- Contract documentation alignment: + - `tools/tbtc-signer/README.md` (`FFI contract` section now includes `recovery_class`) + +## Remaining Phase 4 Work + +1. Wire keep-core runtime to consume and enforce the signer-exported + coordinator-timeout policy contract. +2. Wire keep-core attempt-transition flow to emit/consume + `exclusion_evidence` (`coordinator_timeout` vs `invalid_share_proof`) and + map runtime faults into the schema. +3. Wire keep-core consumers to ingest `attempt_transition_telemetry` and + propagate it into operator-facing logs/metrics. +4. Build end-to-end liveness tests with injected coordinator/member failures + and traceable recovery outcomes. diff --git a/pkg/tbtc/signer/docs/roast-phase-5-baseline-calibration.md b/pkg/tbtc/signer/docs/roast-phase-5-baseline-calibration.md new file mode 100644 index 0000000000..3647c37c35 --- /dev/null +++ b/pkg/tbtc/signer/docs/roast-phase-5-baseline-calibration.md @@ -0,0 +1,69 @@ +# ROAST Phase 5 Baseline Calibration Worksheet + +Date: 2026-03-01 +Status: Pending environment readiness +Owner: Threshold Labs +Scope: baseline metric capture and final threshold calibration for Phase 5 + +## 1. Purpose + +Capture baseline operational metrics and finalize Phase 5 hold/rollback +thresholds before production ROAST canary progression. + +This worksheet is consumed by: + +- `docs/frost-migration/roast-phase-5-security-rollout-gates.md` + +## 2. Baseline Window Metadata + +| Field | Value | +| --- | --- | +| Baseline window start (UTC) | `TBD` | +| Baseline window end (UTC) | `TBD` | +| Window duration | `TBD` | +| Signer fleet scope | `TBD` | +| Wallet cohort scope | `TBD` | +| Data source dashboards/queries | `TBD` | +| Environment notes | `TBD` | + +## 3. Baseline Metrics Capture + +| Metric | Baseline Value | Source | Notes | +| --- | --- | --- | --- | +| Attempt success rate | `TBD` | `TBD` | | +| Coordinator rotations/request (mean) | `TBD` | `TBD` | | +| Signing latency p95 | `TBD` | `TBD` | | +| Signing latency p99 | `TBD` | `TBD` | | +| Terminal failure ratio | `TBD` | `TBD` | | + +## 4. Final Threshold Calibration + +Use baseline values above to confirm/tune thresholds used in rollout gates. + +| Trigger | Provisional Threshold | Final Threshold | Rationale | +| --- | --- | --- | --- | +| Hold: success rate | `< 99.0%` over 6h | `TBD` | | +| Rollback: success rate | `< 97.0%` over 1h | `TBD` | | +| Hold: coordinator rotations/request | `> 0.35` over 6h | `TBD` | | +| Rollback: coordinator rotations/request | `> 0.60` over 1h | `TBD` | | +| Hold: p95 latency delta | `> +25%` for 1h | `TBD` | | +| Rollback: p99 latency delta | `> +40%` for 30m | `TBD` | | +| Hold: terminal failures | `> 0.5%` for 1h | `TBD` | | +| Rollback: terminal failures | `> 1.0%` for 30m | `TBD` | | + +## 5. Approval Inputs + +Record completion artifacts for release or governance approval linkage: + +1. baseline dashboard snapshot references (`TBD`) +2. query outputs/raw exports checksum references (`TBD`) +3. threshold update commit/PR reference (`TBD`) +4. reviewer acknowledgment references (`TBD`) +5. formal methods summary packet: + `docs/frost-migration/formal-verification/formal-methods-summary-packet.md` + +## 6. Blocker Tracking + +| Blocker | Status | Owner | Notes | +| --- | --- | --- | --- | +| Testnet baseline window unavailable | `OPEN` | `UNASSIGNED` | Populate when environment is restored | diff --git a/pkg/tbtc/signer/docs/roast-phase-5-rollout-runbook.md b/pkg/tbtc/signer/docs/roast-phase-5-rollout-runbook.md new file mode 100644 index 0000000000..08e06bf435 --- /dev/null +++ b/pkg/tbtc/signer/docs/roast-phase-5-rollout-runbook.md @@ -0,0 +1,132 @@ +# ROAST Phase 5 Rollout Runbook + +Date: 2026-03-01 +Status: Draft (awaiting baseline calibration) +Owner: Threshold Labs +Scope: staged ROAST rollout operations, monitoring, hold/rollback actions + +## 1. Objective + +Provide the operator procedure for staged ROAST rollout with explicit gate +checks, incident actions, and evidence capture requirements. + +This runbook is paired with: + +- `docs/frost-migration/roast-phase-5-security-rollout-gates.md` +- Future mandatory TEE hardening profile + (activation-gated): + `docs/frost-migration/tee-whitelisted-signer-enforcement-plan.md` + +## 2. Prerequisites + +Before Stage 1 canary: + +1. Security/correctness gate checks are green. +2. Benchmark suite is current: + - `cd pkg/tbtc/signer && cargo bench --features bench-restart-hook --bench phase5_roast` +3. Chaos/failure suite is green: + - `cd pkg/tbtc/signer && ./scripts/run_phase5_chaos_suite.sh` +4. Pre-ROAST baseline window captured for: + - attempt success rate + - coordinator rotations per signing request + - p95/p99 signing latency +5. Baseline worksheet populated: + - `docs/frost-migration/roast-phase-5-baseline-calibration.md` + +## 3. Rollout Stages + +1. Stage 1 (Canary): + - scope: 5% signer fleet, limited wallet cohort + - hold: 24 hours minimum +2. Stage 2 (Expanded): + - scope: 25% signer fleet, broader cohort + - hold: 24 hours minimum +3. Stage 3 (General Availability): + - scope: 100% signer fleet + - start only if Stage 1 and Stage 2 remained within thresholds + +## 4. Monitoring And Decision Thresholds + +Use the thresholds from +`docs/frost-migration/roast-phase-5-security-rollout-gates.md`. + +Hold thresholds: + +1. success rate `< 99.0%` over rolling 6 hours +2. coordinator rotations/request `> 0.35` over rolling 6 hours +3. p95 latency delta `> +25%` for 1 hour +4. terminal failures `> 0.5%` over 1 hour + +Rollback thresholds: + +1. success rate `< 97.0%` over rolling 1 hour +2. coordinator rotations/request `> 0.60` over rolling 1 hour +3. p99 latency delta `> +40%` for 30 minutes +4. terminal failures `> 1.0%` over 30 minutes + +## 5. Immediate No-Go Triggers + +Pause rollout immediately and open incident response if any are observed: + +1. unauthorized attempt advancement acceptance +2. consumed attempt/round replay-protection regression +3. restart inconsistency with divergent transition decisions +4. missing transition/recovery telemetry needed for operator triage + +## 6. Incident Response Steps + +1. Freeze progression to the next stage. +2. Record trigger metric(s), start/end time, and affected scope. +3. Capture logs/events for: + - attempt transition reason + - coordinator rotation counts + - excluded participant evidence +4. Classify outcome: + - `hold` (within hold-only threshold breach) + - `rollback` (rollback threshold/no-go breach) +5. If rollback is required: + - disable ROAST rollout flag for current scope + - return traffic to previous stable config + - verify metric recovery in the next 30-60 minutes + +## 7. Evidence Capture Per Stage + +For each stage, archive: + +1. start/end timestamps (UTC) and signer/wallet scope +2. metric snapshots for success, rotations, p95/p99 latency, terminal failures +3. count of recovery-class events by reason +4. incident tickets opened and closure status +5. decision record: `proceed`, `hold`, or `rollback` + +## 8. Exit Criteria + +Rollout is complete when: + +1. Stage 1 and Stage 2 hold windows complete without rollback/no-go triggers. +2. Stage 3 reaches steady-state without threshold breach. +3. Required security, signer, runtime, and governance approvals are recorded in + the release or governance record. + +## 9. Post-Activation Cleanup + +Once the production activation packet is approved and Stage 3 has reached +steady-state, the readiness-gate machinery has served its purpose and is +removed from the tree. In a dedicated cleanup PR, delete: + +1. `scripts/formal/check_frost_activation_gate.mjs`, + `check_frost_funded_live_run_gate.mjs`, + `check_frost_operator_dry_run_gate.mjs`, + `check_frost_production_indexing_gate.mjs`, + `check_frost_release_artifact_gate.mjs`, and + `check_p2tr_fraud_gas_dos_gate.mjs`, plus any helpers in + `scripts/formal/` that become orphaned. +2. The matching evidence manifests and runbooks under `docs/operations/` + (`frost-roast-*-evidence-v0.json` and the associated + `frost-roast-*-runbook-*.md` / packet template files). +3. The `readiness:gates:check` script entry in the root `package.json` and + the chained call from `formal:vectors:check`. + +The signed activation packet and merged code are the durable record after +activation; the gate scripts only exist to keep the pre-activation door +closed and should not outlive that role. diff --git a/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md b/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md new file mode 100644 index 0000000000..859fb17bc7 --- /dev/null +++ b/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md @@ -0,0 +1,155 @@ +# ROAST Phase 5: Security/Review Gates And Rollout + +Date: 2026-02-28 +Status: In progress +Owner: Threshold Labs +Scope: define rollout decision gates, provisional rollback thresholds, and +evidence requirements for ROAST enablement + +## Objective + +Translate the Phase 5 goals from `roast-implementation-plan.md` into explicit +go/no-go checks that can be used during staged rollout decisions. + +This increment adds draft operational thresholds (requested in prior review) so +rollout decisions are bounded before final canary execution begins. + +## Gate Framework + +### Gate 1: Security/Correctness Sign-Off + +Required before any production canary: + +1. Adversarial review packet complete with no unresolved CRITICAL/HIGH findings. +2. Replay, transition-authorization, and restart-safety test suites green. +3. Cross-repo contract compatibility verified for: + - `recovery_class` + - `exclusion_evidence` + - `attempt_transition_telemetry` + +### Gate 2: Canary Readiness + +Required before stage 1 canary: + +1. Baseline metrics captured for pre-ROAST control window: + - success rate + - coordinator rotations per signing request + - p95 and p99 signing latency +2. Observability dashboards include transition reason and recovery class splits. +3. Rollback playbook validated in a dry-run incident simulation. + +### Gate 3: Progressive Rollout + +Recommended stages: + +1. Stage 1: 5% signer fleet / limited wallet cohort, hold for 24h. +2. Stage 2: 25% signer fleet / broader cohort, hold for 24h. +3. Stage 3: 100% rollout after Phase 5 acceptance criteria remain green. + +## Provisional Rollback Thresholds (Draft) + +These thresholds are intentionally conservative and should be tuned once the +baseline window is recorded. + +1. Attempt success rate: + - `hold` if `< 99.0%` over any rolling 6-hour canary window. + - `rollback` if `< 97.0%` over any rolling 1-hour window. +2. Coordinator rotations per signing request: + - `hold` if `> 0.35` average over rolling 6 hours. + - `rollback` if `> 0.60` average over rolling 1 hour. +3. Signing latency deltas vs baseline: + - `hold` if p95 delta `> +25%` for 1 hour. + - `rollback` if p99 delta `> +40%` for 30 minutes. +4. Terminal failure ratio: + - `hold` if terminal failures exceed `0.5%` of signing attempts in 1 hour. + - `rollback` if terminal failures exceed `1.0%` in 30 minutes. + +## No-Go Triggers + +Immediate rollout pause and incident response escalation: + +1. Any evidence of unauthorized attempt advancement acceptance. +2. Any replay-protection regression for consumed attempt/round identifiers. +3. Any state-restart inconsistency causing divergent transition decisions. +4. Missing telemetry fields required for operator triage in canary incidents. + +## Evidence Checklist + +Before final sign-off, collect and archive: + +1. Security review packet with explicit GO/Conditional GO decision. +2. Benchmark output for: + - happy path + - single-member failure + - coordinator-timeout recovery +3. Chaos/failure-matrix results for: + - network delay/duplication + - process crash during active attempt + - recovery after restart +4. Rollout metrics snapshots for each canary stage and final production cutover. +5. Final approval record attached to the release or governance decision. +6. Baseline calibration worksheet: + - `docs/frost-migration/roast-phase-5-baseline-calibration.md` + +## Initial Benchmark Scaffold (Implemented) + +- Benchmark harness added at `pkg/tbtc/signer/benches/phase5_roast.rs`. +- Run command: + `cd pkg/tbtc/signer && cargo bench --features bench-restart-hook --bench phase5_roast` +- Current benchmark groups: + - `phase5/ffi_run_dkg` + - `phase5/ffi_start_sign_round` + - `phase5/ffi_finalize_sign_round` + - `phase5/ffi_start_sign_round_recovery` + - `timeout_transition_authorized` + - `invalid_share_proof_transition_with_rotation` + - `phase5/ffi_start_sign_round_replay_guard` + - `stale_attempt_rejected_after_transition` + - `phase5/ffi_start_sign_round_restart_paths` + - `authorized_transition_after_reload` + - `stale_attempt_rejected_after_reload` +- Phase 5 benchmark and chaos evidence is summarized in this rollout gate + packet. + +## Chaos/Failure Injection Suite (Implemented) + +- Suite runner: + `pkg/tbtc/signer/scripts/run_phase5_chaos_suite.sh` +- Run command: + `cd pkg/tbtc/signer && ./scripts/run_phase5_chaos_suite.sh` +- Scenario pass/fail criteria: + - `stale_payload_replay_or_duplication`: + stale attempt payloads remain fail-closed after authorized advancement and + reload. + - `restart_recovery_authorized_transition`: + authorized transition succeeds after restart/reload with deterministic + attempt context. + - `process_crash_active_attempt`: + consumed-attempt replay guard survives simulated crash and cache loss. + - `persist_fault_pre_rename`: + previous durable state remains intact after injected pre-rename persist + fault. + - `persist_fault_post_rename`: + renamed durable state remains loadable after injected post-rename persist + fault. + +## Rollout Runbook (Implemented) + +- Runbook artifact: + `docs/frost-migration/roast-phase-5-rollout-runbook.md` +- Future mandatory TEE hardening profile + (activation-gated): + `docs/frost-migration/tee-whitelisted-signer-enforcement-plan.md` + +## Baseline Calibration Worksheet (Prepared) + +- Worksheet artifact: + `docs/frost-migration/roast-phase-5-baseline-calibration.md` +- Current blocker: + environment readiness for baseline data collection. + +## Remaining Phase 5 Work + +1. Populate baseline worksheet and record final threshold values. +2. Complete required human approval entries in the release or governance + record. diff --git a/pkg/tbtc/signer/docs/rust-rewrite-bootstrap.md b/pkg/tbtc/signer/docs/rust-rewrite-bootstrap.md new file mode 100644 index 0000000000..c873eadcf1 --- /dev/null +++ b/pkg/tbtc/signer/docs/rust-rewrite-bootstrap.md @@ -0,0 +1,267 @@ +# Rust Rewrite Bootstrap (tbtc-signer) + +Date: 2026-02-23 + +This document tracks the initial code bootstrap for the `tbtc-signer` Rust +rewrite architecture. + +## Implemented in this branch + +- Added `tools/tbtc-signer` Rust crate that builds a `cdylib` named + `libfrost_tbtc`. +- Added a C ABI contract in `tools/tbtc-signer/include/frost_tbtc.h`. +- Implemented coarse request/response operations keyed by `session_id`: + - `frost_tbtc_run_dkg` + - `frost_tbtc_start_sign_round` + - `frost_tbtc_finalize_sign_round` + - `frost_tbtc_build_taproot_tx` + - `frost_tbtc_refresh_shares` +- Implemented idempotency and conflict checks for retried operations under the + same session ID. +- Added file-backed persistent session-state adapter with atomic writes and + schema-validated reload for crash/restart recovery scaffolding. + - Storage path: `TBTC_SIGNER_STATE_PATH` when set, otherwise temp-dir default + `frost_tbtc_engine_state.json` for non-production bootstrap runs. The + production profile rejects the implicit temp-dir state path. Operators must + settle `TBTC_SIGNER_PROFILE`, `TBTC_SIGNER_STATE_PATH`, and key-provider + environment before the first signer FFI call because the engine state handle + is initialized once per process. + - Durability semantics: temp-state file is `sync_all`'d before rename, then + parent directory is synced after rename to close power-loss persistence gaps. +- Added persistence hardening guardrails for state storage: + - process-level state lock file (`.lock`) with non-blocking + exclusive lock acquisition to prevent concurrent writer processes, + - load/persist operations are bound to the active lock path in-process (do + not follow later env-var path changes), + - corruption policy defaults to fail-closed and can be set to + `quarantine_and_reset` via `TBTC_SIGNER_STATE_CORRUPTION_POLICY`, + - existing empty state-file loads emit warning diagnostics instead of silent + reset behavior, + - corrupted backup retention cap via + `TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT` (default `5` backups). + - added regression coverage proving in-process state-path switching is + rejected after lock acquisition. + - added crash-matrix coverage for truncated (partial-write-like) state-file + payload handling under both fail-closed and quarantine-and-reset policies. + - added crash-matrix coverage for schema-version mismatch recovery policy + behavior under both fail-closed and quarantine-and-reset modes. + - added fault-injection crash-matrix coverage for persist-path failures + before rename and after rename: + - pre-rename failure preserves prior durable state after restart and + cleans up temp state artifacts, + - post-rename failure (before directory sync) still yields a loadable + persisted snapshot after restart. + - added regression coverage for true multi-process state-lock contention. + - added integration restart/reload coverage proving persisted multi-session + state recovers after simulated process restart, idempotent retries remain + stable after reload, and new sessions can progress post-recovery. +- Wired `frost-secp256k1-tr` primitives for coarse signing sessions: + - `RunDkg` uses deterministic dealer key generation and derives `key_group` + from the FROST verifying key, + - `StartSignRound` emits member-scoped real signature-share contributions and + supports optional `signing_participants` for explicit signing cohorts + (`None` defaults to all DKG participants), + - deterministic round nonce derivation now binds directly to message bytes + (in addition to `round_id`) as defense-in-depth against future + round-ID-schema drift, + - deterministic seed framing is now length-prefixed per input component to + avoid delimiter ambiguity when binary fields contain embedded `0x00` bytes, + - `FinalizeSignRound` enforces real-contribution membership against the + resolved signing cohort and aggregates Schnorr signatures over that cohort, + while preserving bootstrap synthetic-contribution compatibility. + - `FinalizeSignRound` now returns an explicit validation error when real + contribution identifiers do not exactly match the round signing cohort, + avoiding opaque aggregate-signature failures for contributor-set mismatch. + - Added regression coverage proving real finalize rejects contribution + identifiers outside the resolved signing cohort before aggregation. +- Replaced version-suffix bootstrap gating with explicit fail-closed runtime + control for synthetic finalize payloads: + - `FinalizeSignRound` synthetic-contribution acceptance now requires + `TBTC_SIGNER_ALLOW_BOOTSTRAP=true` in a non-production profile, + - default behavior is fail-closed (synthetic finalize payloads are rejected), + - `TBTC_SIGNER_PROFILE=production` forces bootstrap synthetic finalize + rejection even if the bootstrap env flag is set, + - `TBTC_SIGNER_PROFILE=production` requires an explicit + `TBTC_SIGNER_STATE_PATH` and rejects the implicit temp-dir state path, + - `TBTC_SIGNER_PROFILE=production` rejects bootstrap dealer DKG before session + state mutation so the dealer-only path cannot be used as production DKG, + - `TBTC_SIGNER_PROFILE=production` forces ROAST strict attempt-context + enforcement even if `TBTC_SIGNER_ENABLE_ROAST_STRICT` is unset or false, + - added FFI coverage for enabled/disabled bootstrap finalize behavior and + strict env-flag parsing. +- Replaced placeholder `BuildTaprootTx` behavior with `rust-bitcoin` transaction + assembly for unsigned version-2 transactions: + - validates non-empty input/output sets and parses input txids/output scripts, + - validates `value_sats` accounting to reject overspend payloads + (`output_total > input_total`), + - validates input/output value-sum arithmetic for `u64` overflow safety, + - validates per-input/per-output `value_sats` against Bitcoin max-money + bounds and rejects duplicate input outpoints (`txid:vout`), + - returns serialized transaction hex built via `bitcoin::Transaction`, + - adds session-keyed idempotency/conflict semantics for repeated build calls, + - explicitly rejects `script_tree_hex` until full script-tree semantics are + implemented (no silent ignore behavior). +- Wired keep-core wallet orchestration to route unsigned transaction shape data + through the native tbtc-signer `BuildTaprootTx` CGO bridge path: + - added canonical unsigned transaction I/O extraction on + `bitcoin.TransactionBuilder`, + - extended keep-core native tbtc-signer engine registration/CGO contract + with `BuildTaprootTx` request/response handling, + - invoked `BuildTaprootTx` from wallet transaction signing before sig-hash + computation and surfaced returned `tx_hex` at coordinator runtime, + - added focused unit coverage for request/response encoding, bridge + unavailability handling, and transaction I/O extraction. +- Wired keep-core transitional bootstrap signing orchestration to pass explicit + threshold cohorts via `StartSignRound.signing_participants`, validate + round-state cohort consistency, and add non-full cohort coverage + (`threshold-network/keep-core` PR: + `https://github.com/threshold-network/keep-core/pull/3868`). +- Added keep-core bootstrap attempt-variation coverage for same-session cohort + changes, asserting `StartSignRound` cohort inputs across retries and + `session_conflict` fallback propagation for mismatched retry cohorts + (`threshold-network/keep-core` commit `69e844216`). +- Added keep-core non-bootstrap native FROST cohort-attempt variation coverage: + two signing rounds with different attempt cohorts (`[1,2,3]` then `[1,3]`) + now validate commitment/signature-share participant sets in + `NewSigningPackage` and `Aggregate` + (`threshold-network/keep-core` commit `9ff880422`). +- Added keep-core signer-executor runtime retry integration coverage for + strict native FFI mode, proving cohort changes across attempts propagate + through runtime `Attempt` fields passed to native signing execution + (`threshold-network/keep-core` commit `d63d08bdd`). +- Added keep-core transitional `frost-tbtc-signer-v1` runtime retry/cohort + integration coverage under strict native FFI mode: + one signer is forced to miss legacy fallback share material, attempt-1 + includes that signer and fails, attempt-2 excludes it and succeeds, and + `StartSignRound.signing_participants` cohorts are asserted across attempts + (`threshold-network/keep-core` commit `7814f81a9`). +- Added post-finalize signing-material cleanup in `tools/tbtc-signer` session + state: on successful finalize, bootstrap DKG key packages, DKG public key + package cache, sign-request fingerprint, sign message bytes, and round state + are removed while preserving finalize idempotency cache. +- Added finalized-session guardrails in `tools/tbtc-signer`: subsequent + `StartSignRound` calls for an already-finalized session return + `session_finalized`, preventing round restart and nonce/key-material reuse on + the same session ID. +- Added best-effort zeroization during post-finalize material purge for + directly owned signing buffers/strings (`sign_request_fingerprint`, + `sign_message_bytes`, `round_state.session_id`, `round_state.round_id`, + `round_state.message_digest_hex`, `round_state.signing_participants`, + `own_contribution.identifier`, `own_contribution.signature_share_hex`) before + dropping session references. +- Added nonce-lifecycle hardening for in-round ephemeral data: + - zeroized deterministic round nonces for non-own participants immediately + after deriving commitments, + - zeroized own signing nonces immediately after round-2 signing (on both + success and error paths), + - zeroized temporary decoded signature-share byte buffers during finalize + aggregation, + - zeroized temporary DKG key-package byte buffers during persisted-state + decode/encode transitions, + - zeroized temporary serialized signature-share bytes after outbound + contribution encoding, + - zeroized deterministic DKG keygen seed bytes after seeding RNG, + - zeroized transient serialized request/state buffers used for request + fingerprinting, bootstrap synthetic finalize hashing, and persisted-state + load/persist I/O. +- Added durable nonce single-use enforcement: + - tracked consumed sign-round IDs and reject regenerated own contributions + for previously-consumed `(session_id, round_id)` pairs, + - tracked consumed finalize round IDs and reject repeated aggregate signature + production for previously-consumed `(session_id, round_id)` pairs when + finalize idempotency cache is unavailable. +- Added durable finalize replay safeguards: + - tracked consumed finalize request fingerprints and reject replayed finalize + payloads when finalize idempotency cache is unavailable, + - enforced replay rejection before `round_state` access so consumed-request + replays still fail closed after post-finalize signing-material purge. +- Added consolidated nonce-lifecycle replay coverage: + - mapped nonce/replay invariants to enforcement sites and tests, + - added explicit restart-aware replay guard coverage for consumed sign-round + IDs and consumed finalize request fingerprints when idempotency caches are + unavailable after simulated process restart. +- Added fail-closed retention bounds for session-scoped consumed registries: + - bounded consumed sign/finalize round-ID and finalize-request-fingerprint + registries per session to prevent unbounded growth, + - reject over-limit runtime insertions and over-limit persisted payloads + instead of evicting entries (no silent replay-protection weakening). +- Added fail-closed global session-registry bounds: + - bounded total persisted session count via `TBTC_SIGNER_MAX_SESSIONS` + (default `1024`), + - reject over-limit persisted state payloads during decode/encode, and reject + new runtime session creation at capacity while preserving idempotent retries + for existing `session_id` values. +- Bootstrap dealer-model constraint: the current engine holds all generated key + packages for a session in one process. This is temporary bootstrap behavior + and does not provide production threshold key isolation. +- Added unit tests for retry/idempotency and sequencing behavior. + +## Deferred to follow-up increments + +- Harden DKG/signing for production invariants (distributed DKG flow, + cryptographic RNG policy, crash-safe recovery). +- Implement ROAST coordinator semantics. + Implementation roadmap: + `docs/frost-migration/roast-implementation-plan.md`. +- Extend `BuildTaprootTx` with full Taproot script-tree construction/signing + policy semantics (current bootstrap path assembles validated unsigned txs). +- Define canonical serialization rules and compatibility tests beyond JSON. +- Expand persistence crash-matrix coverage beyond current truncated-state, + multi-process lock-contention, and integration restart/reload cases, + including broader filesystem fault-injection and keep-core wiring scenarios. +- Consolidate and document cohort-retry coverage evidence across protocol-level + and runtime-level tests for external review packet updates. + +### Future consideration only (non-committed): true late t-of-n finalize + +- This is a potential future direction, not a committed delivery item, and it + may not be implemented. +- Detailed discussion draft: + `docs/frost-migration/true-late-t-of-n-finalize-considerations.md`. + +- Current posture: we support early subset selection (`signing_participants` at + `StartSignRound`), but not late subset selection after shares are already + produced for a larger cohort. +- Candidate behavior: allow finalize-time selection of any responding subset + `S` where `|S| >= threshold`, with signing packages and commitments bound to + that exact subset. +- Potential benefits: + - improved liveness under mid-round signer drop-off, + - fewer full-round restarts/cohort reselections, + - lower tail latency for retry-heavy signing conditions. +- Tradeoffs: + - requires API/flow redesign (round state and contribution exchange), + - increases nonce lifecycle and persistence complexity, + - expands coordinator policy and test/review surface across Rust + keep-core + integration. + +## Production gates (must close before rollout) + +- Durable session state: complete production hardening around the persistent + backend (crash-safe fsync semantics, path configuration, process lock model, + corruption handling policy, and broader retention/cleanup lifecycle + management across sessions; session-scoped consumed-registry and global + session-registry bounds are implemented) and prove behavior across + integration crash matrix scenarios + (including power-loss during persist and multi-process lock contention). +- Nonce lifecycle controls: complete external security review sign-off for + replay guarantees across retries/restarts and track dependency-level + zeroization limitations (single-use enforcement, finalize replay safeguards, + restart-aware replay audit coverage, and transient-buffer zeroization + hardening are implemented). +- Distributed DKG: bootstrap dealer DKG is fail-closed in the production + profile; replace it with production distributed DKG wiring and evidence before + enabling production activation. +- Threshold signing semantics: replace bootstrap n-of-n finalize strictness with + t-of-n contribution handling and filter signing-package commitments to actual + contributing participants; complete keep-core cohort-selection wiring and + non-full-cohort integration coverage. +- Refresh epoch policy: keep `refresh_epoch` monotonic via internal counter + semantics (do not use wall-clock values for refresh ordering). + +## Validation command + +```bash +cd tools/tbtc-signer +cargo test +``` diff --git a/pkg/tbtc/signer/docs/signer-api-contract-decision-brief.md b/pkg/tbtc/signer/docs/signer-api-contract-decision-brief.md new file mode 100644 index 0000000000..95dfd4990c --- /dev/null +++ b/pkg/tbtc/signer/docs/signer-api-contract-decision-brief.md @@ -0,0 +1,132 @@ +# Signer API Contract Decision Brief + +Date: February 23, 2026 + +Purpose: capture the API-contract direction before further implementation work. + +## Decision to make + +Choose one primary integration contract between keep-core and `tbtc-signer`: + +1. Round-level crypto API (current keep-core shape). +2. Coarse session API (rewrite plan shape). + +## Current mismatch + +### keep-core currently expects round-level calls + +In `threshold-network/keep-core` on +`feat/frost-schnorr-migration-scaffold`, the native FROST engine interface is: + +- `GenerateNoncesAndCommitments(...)` +- `NewSigningPackage(...)` +- `Sign(...)` +- `Aggregate(...)` + +(file: `pkg/frost/signing/native_frost_engine_frost_native.go`) + +`executeNativeFROSTSigning(...)` orchestrates round 1/round 2 around that +interface. + +(file: `pkg/frost/signing/native_frost_protocol_frost_native.go`) + +### Rewrite plan and `tbtc-signer` use coarse session operations + +The rewrite plan defines: + +- `RunDKG(session_id, participants, threshold)` +- `StartSignRound(session_id, message, key_group)` +- `FinalizeSignRound(session_id, round_contributions)` +- `BuildTaprootTx(...)` +- `RefreshShares(...)` + +(plan tracked in `docs/frost-migration/rust-rewrite-bootstrap.md`) + +The bootstrap Rust crate already exposes this coarse C ABI surface: + +(file: `tools/tbtc-signer/src/lib.rs`) + +## Design Alternatives + +### Round-Level API Compatibility + +Pros: + +- Fastest bridge enablement. +- Minimal keep-core refactor. +- Reuses existing round orchestration and tests. + +Cons: + +- Diverges from rewrite-plan contract. +- Keeps nonce/round details crossing the FFI boundary. +- Harder future transport swap (CGO -> sidecar). +- Higher chance of long-term rework. + +### Coarse Session API + +Pros: + +- Aligns with the agreed architecture. +- Better idempotency/retry semantics keyed by `session_id`. +- Smaller and more stable FFI surface for audits. +- Cleaner future sidecar extraction. + +Cons: + +- Requires keep-core orchestration refactor now. +- Higher short-term implementation cost. +- Existing test flows need migration. + +### Temporary Compatibility Layer + +Pros: + +- Unblocks integration while retaining coarse API as the end-state. + +Cons: + +- Temporary adapter debt. +- Risk that temporary path becomes permanent. + +## Recommendation + +Recommend the **coarse session API** as the production direction, with the +temporary compatibility layer only as a tightly scoped bridge if needed for +delivery pace. + +Justification: + +- The rewrite plan explicitly selected coarse, idempotent session operations as + a safety and operability decision, not just an API style preference. +- Custody-critical failure modes (retries, restart boundaries, nonce lifecycle) + are easier to reason about with the session contract. +- Implementing the round-level compatibility path now likely causes a second + refactor later. + +## Immediate implications + +1. Define keep-core adapter contract against coarse calls first (before wiring + full cryptographic paths). +2. Decide where round-state ownership lives during transition. +3. Keep the new `frost_tbtc_signer` keep-core registration scaffold as-is until + the contract choice is finalized. + +## Review Questions + +1. Do you agree Option B should be the production contract? +2. If yes, do you prefer: + - direct keep-core orchestration refactor now, or + - temporary compatibility layer first (Option C)? +3. Any blockers with auditability or operational risk assumptions in this + recommendation? + +## Review Response Summary (2026-02-23) + +- Agrees strongly with Option B as production contract. +- Recommends direct refactor, with Go-side temporary shim only if test + migration blocks timeline. +- Flags three gates: + - persistent state backend before production, + - explicit nonce lifecycle/reuse prevention and audit coverage, + - monotonic refresh epoch counter (not wall-clock derived). diff --git a/pkg/tbtc/signer/docs/tbtc-signer-secret-material-hardening-plan.md b/pkg/tbtc/signer/docs/tbtc-signer-secret-material-hardening-plan.md new file mode 100644 index 0000000000..324fc48d1a --- /dev/null +++ b/pkg/tbtc/signer/docs/tbtc-signer-secret-material-hardening-plan.md @@ -0,0 +1,188 @@ +# tbtc-signer Secret Material Hardening Plan (Long-Term) + +Date: 2026-03-01 +Status: Proposed (pre-implementation) +Owner: Threshold Labs +Scope: `tools/tbtc-signer` persistent secret-material handling before FROST/ROAST +production rollout. + +## Decision + +Adopt the long-term hardening path: + +1. Option 3: secret-aware in-process material handling and serialization + boundaries. +2. Option 4A: encrypted-at-rest state envelope as default. +3. Option 4B: KMS/HSM-backed key provider integration as a pre-production + gate. + +Rationale: +- FROST/ROAST is not yet deployed to production. +- This window allows deeper correctness/security work before operational lock-in. +- It addresses the remaining audit concern around transient plaintext exposure + more directly than incremental zeroization alone. + +## Security Goals + +1. Reduce plaintext lifetime of key material in process memory. +2. Eliminate plaintext-at-rest signer-state payloads by default. +3. Preserve restart/idempotency/replay invariants already established in ROAST + phases. +4. Maintain fail-closed behavior for corrupt/missing/invalid encrypted state. + +## Non-goals + +1. Replacing the signer protocol or FROST/ROAST message semantics. +2. Requiring TEEs as a prerequisite for deployment. +3. Immediate mandatory KMS/HSM dependency for local/dev environments. + +## Architecture Overview + +### A. In-Process Secret Boundary (Option 3) + +- Introduce secret-wrapper types for sensitive payloads (for example serialized + key packages and signing message bytes) so accidental copies are minimized and + explicit extraction is required. +- Keep persisted wire structs separate from runtime secret structs to avoid + broad serde exposure. +- Centralize encode/decode in one `state_codec` boundary that: + - decodes into temporary buffers, + - converts to secret wrappers, + - zeroizes intermediate decode/encode buffers, + - avoids returning secret-bearing `String` values where possible. + +### B. Encrypted-at-Rest Envelope (Option 4A) + +- Replace plaintext JSON state file payload with: + - small plaintext header (schema, algorithm, key-provider metadata, nonce), + - authenticated ciphertext containing serialized state payload. +- Default behavior: encrypted state required unless explicitly in developer + compatibility mode. +- Keep atomic write durability pattern (temp file -> fsync -> rename -> dir + fsync) and state lock semantics unchanged. +- Fix cryptography baseline for implementation: + - AEAD: `XChaCha20-Poly1305` (`xchacha20poly1305`). + - Nonce: 192-bit random value from OS CSPRNG for each write. + - Nonce policy: never reuse a nonce with the same key; do not use counters. + - Authentication tag: stored separately from ciphertext for explicit envelope + validation. + +Suggested envelope fields: +- `schema_version` +- `encryption_algorithm` +- `key_provider` +- `key_id` (opaque) +- `nonce` +- `ciphertext` +- `authentication_tag` + +## Key Management Strategy + +### Phase 1 default provider + +- Env-backed key provider (`TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX`) for + controlled dev/test and pre-production environments only. +- Key must be exactly 32 bytes (64 hex chars); missing, truncated, or invalid + key is fail-closed with startup abort and stable diagnostic output. +- Env provider is not an acceptable long-term production default. + +### Production provider requirement (Option 4B) + +- Provider trait allows later KMS/HSM integration without state-format redesign. +- KMS/HSM-backed provider is required before production FROST/ROAST rollout. +- KMS/HSM key retrieval and rotation semantics are a gated increment after P2. + +## Migration Plan + +1. Add schema version for encrypted envelope while retaining read compatibility + for legacy plaintext state. +2. On successful plaintext load: + - decode with existing path, + - persist back in encrypted format atomically. +3. Add one-way migration guardrails: + - fail-closed on mixed/corrupt envelope metadata, + - explicit diagnostic logging for migration state. +4. Add rollout flag to temporarily permit plaintext for emergency rollback in + non-production profiles only; compile-time disabled in release builds. + +## Phased Work Breakdown + +### P0 (Week 1-2): Secret-boundary refactor + +- Introduce secret wrapper types and centralized state codec module. +- Refactor `PersistedKeyPackage` handling to avoid broad `String` secret spread. +- Preserve behavior and test parity. + +Exit criteria: +- Existing restart/idempotency/replay tests pass unchanged. +- New tests verify intermediate buffer zeroization in codec paths. + +### P1 (Week 3-4): Encrypted envelope + migration + +- Add encrypted envelope schema and codec. +- Add env key provider and strict fail-closed startup behavior. +- Implement plaintext->encrypted migration on first successful load. +- Enforce nonce generation/reuse invariants in codec paths. + +Exit criteria: +- No plaintext payload persisted in normal mode. +- Corruption/missing-key cases fail closed with stable error diagnostics. +- Crash-matrix persists encrypted state safely. +- Encryption algorithm and envelope fields are fixed and implemented as specified + in this plan. + +### P2 (Week 5-6): Operational hardening and review closure + +- Add key-rotation operational docs and runbook hooks, including secure key + provisioning for non-production env-provider deployments. +- Add chaos tests for key unavailability, malformed envelope, and migration + interruptions. +- Complete independent adversarial review and remediation cycle. + +Exit criteria: +- Security review recommendation: GO or Conditional GO with no unresolved + CRITICAL/HIGH findings. +- Runbook and approval records updated with encrypted-state controls. + +### P3 (Week 7+): KMS/HSM provider integration (required pre-production gate) + +- Implement provider adapter(s) for selected KMS/HSM. +- Add bootstrap and outage-handling runbooks. +- Validate key-rotation and recovery procedures in staging. + +Exit criteria: +- Production profile does not permit env-backed encryption key provider. +- KMS/HSM path passes restart/idempotency/replay and fail-closed test suites. + +## Test Matrix + +1. Unit tests: + - codec encode/decode roundtrip for encrypted schema, + - key/provider validation failures, + - migration from plaintext schema, + - zeroization behavior of temporary buffers. +2. Integration tests: + - restart/reload across encrypted-state writes, + - fail-closed startup on missing/invalid encryption key, + - replay/idempotency invariants unchanged after migration. +3. Chaos tests: + - crash between encrypt and rename, + - crash after rename before directory sync, + - malformed envelope metadata/ciphertext tampering. + +## Rollout and Risk Controls + +1. Ship behind explicit feature gate, then flip to default-encrypted mode before + production FROST/ROAST rollout. +2. Preserve emergency rollback path for non-production testing only. +3. Require canary validation on: + - startup reliability, + - state reload success rate, + - signer latency delta vs baseline. +4. Require at least 72h canary soak with zero state-reload failures before + enabling production encrypted-state defaults. + +## Remaining Decisions + +1. Final KMS/HSM implementation target(s) and provider adapter priority. +2. Rotation cadence and key-ID lifecycle policy for production operations. diff --git a/pkg/tbtc/signer/docs/tee-whitelisted-signer-enforcement-plan.md b/pkg/tbtc/signer/docs/tee-whitelisted-signer-enforcement-plan.md new file mode 100644 index 0000000000..f5df4379f8 --- /dev/null +++ b/pkg/tbtc/signer/docs/tee-whitelisted-signer-enforcement-plan.md @@ -0,0 +1,300 @@ +# TEE-Required Signer Plan (DAO-Whitelisted Operators) + +Date: 2026-03-01 +Status: Draft +Owner: Threshold Labs +Scope: operator-admission and runtime enforcement model for TEE-required +signers in a DAO-whitelisted signer set + +## 1. Objective + +Define a production-usable policy and implementation plan for running ROAST/FROST +signers with: + +1. DAO-approved operator whitelist, and +2. TEE-backed signer runtime admission. + +This plan assumes maximizing permissionless signer scale is not the primary +objective; operator accountability and controlled hardening are prioritized. + +This plan is a draft for a future mandatory hardening profile. It is not active +for production rollouts until the activation gate in Section 12 is approved and +recorded in governance artifacts. + +## 2. Design Principles + +1. Cryptographic protocol safety remains primary (ROAST/FROST controls do not + depend on TEEs). +2. TEE is additive hardening for runtime integrity and key protection. +3. Liveness must not depend on a single vendor, verifier, or attestation root. +4. Policy changes are governance-controlled, explicit, and auditable. +5. No silent downgrade from TEE-required to non-TEE operation. + +## 3. Security Goals + +1. Admit only authorized operators running approved signer binaries in approved + TEE environments. +2. Detect and remove revoked/non-compliant signers with bounded exposure time. +3. Preserve quorum liveness during partial TEE/verifier outages. +4. Maintain full audit trail for admissions, revocations, and emergency waivers. + +## 4. Non-Goals + +1. Replacing ROAST/FROST replay/transition protections with attestation logic. +2. Requiring all ecosystem participants to use a single TEE vendor stack. +3. Proving physical side-channel resistance of all enclave platforms. + +## 5. Policy Model + +### 5.1 Operator Admission Record + +Each signer admission record should include: + +1. `operator_id` (DAO-known identity) +2. `signer_identifier` (runtime signer identity/public key) +3. `status` (`active`, `suspended`, `revoked`) +4. `allowed_tee_types` (e.g., SGX/SEV-SNP/TDX) +5. `allowed_measurements` (approved signer binary measurements) +6. `attestation_max_age_seconds` +7. `grace_period_seconds` +8. `effective_from` and optional `effective_until` + +### 5.2 Enforcement Parameters (Initial Defaults) + +| Parameter | Initial Default | Notes | +| --- | --- | --- | +| `attestation_max_age_seconds` | `3600` | re-attestation required hourly | +| `grace_period_seconds` | `900` | temporary verifier/vendor disruptions | +| `min_attested_signers_per_cohort` | `threshold + 1` | avoid edge-of-quorum fragility | +| `max_single_vendor_share_percent` | `40` | cap correlated vendor risk | +| `denylist_max_staleness_seconds` | `60` | session-start denylist freshness bound | +| `break_glass_ttl_seconds` | `21600` | 6-hour emergency override max | +| `break_glass_max_activations_per_7d` | `2` | prevent break-glass chaining abuse | +| `break_glass_cooldown_seconds` | `86400` | 24-hour cooldown between activations | +| `break_glass_scope` | `named_operator_ids_only` | no global suspension in default policy | +| `break_glass_quorum_bps` | `6700` | supermajority quorum for activation | +| `re_attestation_poll_interval_seconds` | `300` | signer refresh cadence | + +Values should be tuned with canary data and incident drills. + +## 6. Control Plane Architecture + +### 6.1 Components + +1. **Governance Whitelist Registry** + - DAO-controlled source of truth for operator admission records. + - every change emits immutable governance event (`add`, `suspend`, `revoke`, + `measurement_update`, `break_glass_activate`, `break_glass_expire`). +2. **Attestation Verifier Service** + - validates evidence against vendor trust roots, + - checks measurement allowlist, + - issues short-lived signed admission tokens. +3. **Revocation and Audit Service** + - immediate denylist propagation, + - immutable audit event stream for admissions/revocations. + +Verifier trust model requirements: + +1. at least two independent verifier instances operated on separate trust roots. +2. admission tokens must be threshold-signed by verifier quorum (`m-of-n`, + initial `2-of-3`) or include equivalent multi-verifier attestations. +3. verifier signing keys rotate every 30 days maximum, with overlap window and + published key-set versioning. +4. verifier compromise response: + - key revocation event published within 15 minutes of detection, + - compromised key removed from accepted verifier set immediately, + - all tokens signed solely by compromised key invalidated. +5. verifier issuance controls: + - per-operator and per-signer token issuance rate limits, + - anomaly alerts on issuance spikes, unknown signer identifiers, or repeated + failed attestation proofs. + +### 6.2 Admission Token Claims + +Tokens should contain at minimum: + +1. `operator_id` +2. `signer_identifier` +3. `tee_type` +4. measurement digest +5. issue/expiry timestamps +6. registry snapshot/version ID +7. verifier key ID +8. `token_id` (unique `jti`) for token-level revocation +9. `token_revocation_epoch` for monotonic revocation checkpoints + +## 7. Runtime Enforcement Model + +### 7.1 Coordinator / Runtime Selection + +1. Select only `active` + currently attested signers. +2. Enforce vendor diversity cap during cohort assembly. +3. Enforce live denylist check for `operator_id` and `signer_identifier` before + cohort selection (freshness <= `denylist_max_staleness_seconds`). +4. Reject cohort construction if policy constraints cannot be met. + +### 7.2 Signer Runtime + +1. Periodically refresh attestation token. +2. Refuse signing when token expired and outside grace period. +3. Refuse signing when token_id or token_revocation_epoch is revoked. +4. Emit structured telemetry on attestation status transitions. + +### 7.3 Session Behavior + +1. Session start requires: + - valid attestation token for all selected signers, + - live denylist check for all selected signers, + - denylist freshness <= `denylist_max_staleness_seconds`. +2. Mid-session expiry within grace window: allow completion, block new sessions. +3. Mid-session expiry beyond grace: fail closed and trigger retry/reselection. +4. Maximum revocation TOCTOU window for new sessions is bounded by + `denylist_max_staleness_seconds`; deployments must not exceed 60 seconds. + +## 8. Governance and Emergency Controls + +### 8.1 Normal Governance Actions + +1. add operator/signer +2. rotate measurement allowlist +3. suspend/revoke operator +4. update enforcement parameters + +### 8.2 Break-Glass Mode + +Break-glass allows temporary policy relaxation only via explicit DAO/quorum +approval and strict TTL. + +Requirements: + +1. explicit incident ticket reference +2. automatic expiry +3. complete audit logs of all sessions admitted under waiver +4. mandatory post-incident review before reactivation +5. maximum activations per 7-day window: + `break_glass_max_activations_per_7d` (default 2) +6. minimum cooldown between activations: + `break_glass_cooldown_seconds` (default 24h) +7. scope limited to named `operator_id` set; global suspension is disallowed in + default policy +8. break-glass quorum explicitly set to `break_glass_quorum_bps` (default 67%) + +## 9. Failure Modes and Handling + +1. **Verifier outage**: + - use grace window, + - if exceeded, stop admitting new sessions. +2. **Single vendor outage**: + - preserve safety-first ordering: + 1. keep `min_attested_signers_per_cohort = threshold + 1` as hard floor + 2. if vendor cap blocks liveness, allow graduated temporary relaxation: + `40% -> 50% -> 60%` (max) during declared vendor outage only + 3. each relaxation step expires automatically in 6 hours unless renewed + by governance action + - if liveness still cannot be restored, require scoped break-glass. +3. **Measurement drift after upgrade**: + - staged rollout with pre-approved next measurements, + - reject unknown measurements by default, + - emergency fast-path for critical fixes: + 1. emergency measurement proposal with incident reference + 2. reduced-latency governance vote (target <= 6 hours) + 3. explicit emergency quorum requirement + 4. automatic rollback of emergency measurement if not ratified in 48 hours + by normal governance flow. +4. **Operator compromise**: + - immediate revoke/suspend, + - denylist propagation and cohort reselection, + - denylist propagation target <= 60 seconds to all coordinators. + +## 10. Implementation Phases + +### Phase A (Policy + Registry) + +1. implement DAO admission schema +2. implement operator status lifecycle +3. define governance workflows and audit events +4. codify activation gate from "draft profile" to "mandatory enforcement" + +### Phase B (Verifier + Tokens) + +1. deploy attestation verifier service +2. issue/validate admission tokens +3. integrate denylist and key rotation +4. implement multi-verifier threshold token issuance +5. implement token-level revocation (`token_id`, `token_revocation_epoch`) + +### Phase C (Runtime Enforcement) + +1. add selection-time and session-time token checks +2. enforce vendor diversity caps +3. add telemetry and alerts +4. enforce live denylist freshness bound at session start +5. implement graduated diversity-cap relaxation controls + +### Phase D (Canary + Hard Enforcement) + +1. monitor-only mode (no blocking) +2. soft enforcement (warnings + exclusion preference) +3. hard enforcement for canary cohort +4. full enforcement after gate pass +5. enforce break-glass abuse controls (activation caps + cooldown + scope) + +### 10.1 Mapping To ROAST Phase 5 Stages + +1. ROAST Stage 1 (5% canary) requires TEE Phase C completed and TEE Phase D in + monitor-only or soft-enforcement mode. +2. ROAST Stage 2 (25% expanded) requires TEE Phase D hard enforcement for the + canary cohort and no unresolved CRITICAL/HIGH findings. +3. ROAST Stage 3 (100% GA) requires TEE Phase D full enforcement after Section + 12 activation gate approval. + +## 11. Validation Matrix + +Minimum required scenarios: + +1. token expiry during active session +2. verifier unavailable for > grace period +3. operator revocation during signing activity +4. mixed-vendor cohort selection under load +5. governance break-glass activation/expiry correctness +6. token non-zero revocation epoch and token_id denylist enforcement +7. verifier key rotation and compromised-key invalidation drill +8. diversity-cap relaxation during declared vendor outage +9. emergency measurement fast-path and automatic rollback path +10. denylist freshness breach (stale denylist should fail closed) + +## 12. Rollout Gates + +Before hard enforcement in production: + +1. all validation scenarios pass +2. no unresolved CRITICAL/HIGH findings in attestation path +3. incident runbook tested in simulation +4. policy and measurements approved by DAO governance process +5. activation gate approved in governance record: + - profile status transitions from `draft` to `mandatory` + +### 12.1 Activation Gate Record Requirements + +Activation gate record must include: + +1. governance proposal/decision identifier +2. effective timestamp (UTC) +3. quorum denominator and achieved quorum +4. approver set with roles: + - security owner + - signer/runtime owner + - governance delegate +5. explicit statement: + - `profile_status_transition = draft -> mandatory` +6. rollback condition and rollback authority + +## 13. Integration With Existing ROAST Phase 5 Artifacts + +This plan is linked from: + +1. `docs/frost-migration/roast-phase-5-security-rollout-gates.md` +2. `docs/frost-migration/roast-phase-5-rollout-runbook.md` + +as a future mandatory TEE hardening profile for permissioned operator deployments +once Section 12 activation gate is approved. diff --git a/pkg/tbtc/signer/docs/true-late-t-of-n-finalize-considerations.md b/pkg/tbtc/signer/docs/true-late-t-of-n-finalize-considerations.md new file mode 100644 index 0000000000..0fc3f0c640 --- /dev/null +++ b/pkg/tbtc/signer/docs/true-late-t-of-n-finalize-considerations.md @@ -0,0 +1,197 @@ +# True Late t-of-n Finalize: Considerations And Tradeoffs + +Date: 2026-02-27 +Status: Discussion draft (future consideration only, not a committed delivery item) +Scope: Rust `tbtc-signer` + keep-core FROST orchestration + +## 1. Context + +Current signer posture supports: + +- early subset selection via `StartSignRound.signing_participants`, +- real finalize over the exact round signing cohort, +- fail-closed replay and nonce lifecycle controls for that model. + +It does **not** support true late subset selection at finalize time after shares +have already been produced for a larger cohort. + +## 2. What "True Late t-of-n Finalize" Means + +Given: + +- DKG participant set `P` with `|P| = n`, +- threshold `t`, +- a round started for some eligible cohort `C` where `t <= |C| <= n`, + +true late finalize means the coordinator can finalize using any responding +subset `S` such that: + +- `S ⊆ C`, +- `|S| >= t`, +- finalize aggregates only shares/commitments from `S`, +- no full round restart is required purely because some members in `C` did not + respond. + +## 3. Why Consider It + +Potential benefits: + +- Better liveness under mid-round signer drop-off. +- Lower tail latency in degraded network conditions. +- Fewer full round restarts and lower coordination churn. +- Reduced wasted work when only a few signers fail late. + +## 4. Tradeoffs And Costs + +### 4.1 Security And Nonce Lifecycle Complexity + +- Nonce safety reasoning becomes more complex because commitments may be + produced for a broader cohort than final contributors. +- Replay/idempotency semantics must bind finalize to an exact subset `S`, + exact commitment map, and exact message context. +- Subset-selection policy mistakes could accidentally widen acceptance in ways + that are hard to audit. + +### 4.2 State And Persistence Complexity + +- Need richer durable round state (commitments, candidate contributors, + contribution receipts, subset decision metadata). +- Larger persisted state and more edge cases around restart/reload recovery. +- More crash-matrix branches (subset chosen before/after partial persistence, + partial contribution arrival, retry storms). + +### 4.3 Coordinator And Retry Semantics + +- Coordinator must choose subset policy: earliest responders, deterministic + ranking, stake/priority policy, etc. +- Must ensure deterministic behavior under retries, restarts, and duplicate + finalize requests. +- Attempt accounting in keep-core becomes more complex (round attempt vs subset + attempt semantics). + +### 4.4 Cross-Component Interface Impact + +- Rust FFI request/response models likely need changes. +- keep-core native bridge operation contracts need updates. +- Integration tests in Go and Rust must expand materially. + +### 4.5 Review And Operational Burden + +- Larger security-review surface across nonce safety, replay safety, and + persistence semantics. +- More observability requirements to debug subset decisions in production. +- More complex incident triage when finalize behavior differs across attempts. + +## 5. Design Alternatives + +### Current Early-Subset Model + +- Subset fixed at `StartSignRound`. +- Finalize requires exact cohort alignment. +- Lowest complexity, strongest determinism, easiest audit posture. + +### Full True Late t-of-n Finalize + +- Start with broader eligible cohort. +- Finalize accepts any valid subset `S` with `|S| >= t`. +- Highest liveness upside, highest engineering/review complexity. + +### Hybrid Bounded Late-Subset + +- Allow late subset only under strict bounded policy (for example: + deterministic fallback from `C` to canonical subset `S` once). +- Attempts to capture some liveness gains while limiting policy explosion. +- Still significantly more complex than the current early-subset model. + +## 6. Required Changes If Implemented + +## 6.1 Rust Signer Engine + +- Extend round state to track: + - full eligible cohort `C`, + - commitment map keyed by participant, + - accepted contribution set, + - finalize subset decision metadata. +- Finalize must: + - validate subset policy and cardinality (`|S| >= t`), + - bind replay/idempotency fingerprints to subset-specific payload shape, + - aggregate using commitments + shares restricted to `S`. +- Update replay guards for subset-sensitive finalize requests. + +## 6.2 keep-core Integration + +- Bridge payloads must carry enough structure for subset-finalize semantics. +- Signing orchestration must define deterministic subset selection policy. +- Retry logic must distinguish: + - new round attempt, + - subset adjustment within same round context. + +## 6.3 Persistence And Crash Matrix + +- Add tests for: + - restart before subset selection, + - restart after subset decision but before finalize cache persistence, + - replay of old finalize payload with different subset, + - idempotent retries for same subset payload. + +## 6.4 Audit And Observability + +- Add telemetry for: + - eligible cohort size, + - selected subset size/identifiers, + - fallback reason (drop-off vs timeout vs policy). +- Add security-review packet specifically for subset/replay/nonce invariants. + +## 7. Threat-Model Considerations + +- Adversarial partial responders can influence which subset is chosen unless + policy is carefully deterministic and bias-resistant. +- Coordinator bugs become higher impact because subset choice affects signature + validity path and retry behavior. +- Any ambiguity in subset binding increases replay/confusion risk. + +## 8. Testing Expectations + +Minimum evidence bar if implemented: + +- Unit tests: + - subset validation matrix (`|S| < t`, `|S| = t`, `|S| > t`), + - replay/idempotency for same subset vs changed subset, + - nonce/reuse invariant preservation. +- Integration tests: + - drop-off liveness scenarios without full restart, + - restart/reload with in-flight subset selection, + - keep-core/Rust bridge consistency. +- Adversarial tests: + - duplicate/forged contributor identifiers, + - subset oscillation across retries, + - malformed subset ordering/canonicalization attempts. + +## 9. Rollout Approach If Pursued + +- Phase 0: design RFC and threat-model review. +- Phase 1: hidden implementation behind explicit runtime gate. +- Phase 2: CI + stress/fault testing with gate off in production. +- Phase 3: limited canary with heavy telemetry and rollback plan. +- Phase 4: broader rollout only after external review sign-off. + +## 10. Recommendation For Current Program + +For the current migration timeline, keep true late t-of-n finalize as +**future consideration only**. + +Rationale: + +- current implementation already supports early subset selection and achieves + required migration path with lower risk, +- true late finalize adds substantial cross-stack complexity and review scope, +- immediate priority is closing existing production gates with strong evidence. + +## 11. Decision Triggers To Revisit + +Reopen this item if one or more are true: + +- observed production liveness/latency pain from full-round restarts, +- clear SLO target cannot be met with early subset model, +- dedicated review bandwidth is available for protocol + persistence expansion, +- rollout risk budget explicitly includes the added complexity. diff --git a/pkg/tbtc/signer/include/frost_tbtc.h b/pkg/tbtc/signer/include/frost_tbtc.h new file mode 100644 index 0000000000..dd798fe7eb --- /dev/null +++ b/pkg/tbtc/signer/include/frost_tbtc.h @@ -0,0 +1,63 @@ +#ifndef FROST_TBTC_H +#define FROST_TBTC_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + uint8_t* ptr; + size_t len; +} TbtcBuffer; + +typedef struct { + int32_t status_code; + TbtcBuffer buffer; +} TbtcSignerResult; + +TbtcSignerResult frost_tbtc_version(void); +TbtcSignerResult frost_tbtc_roast_liveness_policy(void); +TbtcSignerResult frost_tbtc_hardening_metrics(void); +TbtcSignerResult frost_tbtc_roast_transcript_audit(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_verify_blame_proof(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_quarantine_status(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_refresh_cadence_status(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_trigger_emergency_rekey(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_run_differential_fuzzing(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_canary_rollout_status(void); +TbtcSignerResult frost_tbtc_promote_canary(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_rollback_canary(const uint8_t* request_ptr, size_t request_len); +void frost_tbtc_free_buffer(uint8_t* ptr, size_t len); + +TbtcSignerResult frost_tbtc_run_dkg(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_dkg_part1(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_dkg_part2(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_dkg_part3(const uint8_t* request_ptr, size_t request_len); + +/* + * Stateless interactive signing nonce contract: + * + * frost_tbtc_generate_nonces_and_commitments returns `nonces_hex`, a secret + * one-time FROST nonce package. The caller owns that secret after it crosses + * the FFI boundary and must pass it to frost_tbtc_sign_share at most once. + * Reusing the same `nonces_hex` for a different signing package/message can + * reveal the caller's private signing share. The caller should erase its copy + * immediately after the single frost_tbtc_sign_share call. + */ +TbtcSignerResult frost_tbtc_generate_nonces_and_commitments(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_new_signing_package(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_sign_share(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_aggregate(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_start_sign_round(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_finalize_sign_round(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_build_taproot_tx(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_refresh_shares(const uint8_t* request_ptr, size_t request_len); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/pkg/tbtc/signer/scripts/admission-candidate.sample.json b/pkg/tbtc/signer/scripts/admission-candidate.sample.json new file mode 100644 index 0000000000..31a7a5c073 --- /dev/null +++ b/pkg/tbtc/signer/scripts/admission-candidate.sample.json @@ -0,0 +1,9 @@ +{ + "operator_id": "operator-gcp-us-central1-1", + "provider": "gcp", + "region": "us-central1", + "custody_class": "kms", + "attestation_status": "approved", + "patch_sla_expires_at_unix": 2000000000, + "incident_response_contact": "oncall@example.org" +} diff --git a/pkg/tbtc/signer/scripts/admission-existing.sample.json b/pkg/tbtc/signer/scripts/admission-existing.sample.json new file mode 100644 index 0000000000..8a7ce2a6f5 --- /dev/null +++ b/pkg/tbtc/signer/scripts/admission-existing.sample.json @@ -0,0 +1,12 @@ +[ + { + "operator_id": "operator-aws-us-east-1-1", + "provider": "aws", + "region": "us-east-1" + }, + { + "operator_id": "operator-gcp-europe-west1-1", + "provider": "gcp", + "region": "europe-west1" + } +] diff --git a/pkg/tbtc/signer/scripts/admission-override-registry.sample.json b/pkg/tbtc/signer/scripts/admission-override-registry.sample.json new file mode 100644 index 0000000000..eaeb80f3ce --- /dev/null +++ b/pkg/tbtc/signer/scripts/admission-override-registry.sample.json @@ -0,0 +1,3 @@ +{ + "consumed_override_ids": {} +} diff --git a/pkg/tbtc/signer/scripts/admission-override.sample.json b/pkg/tbtc/signer/scripts/admission-override.sample.json new file mode 100644 index 0000000000..39b27fca7f --- /dev/null +++ b/pkg/tbtc/signer/scripts/admission-override.sample.json @@ -0,0 +1,4 @@ +{ + "payload_json": "{\"override_id\":\"override-operator-gcp-us-central1-1-20260302-0001\",\"operator_id\":\"operator-gcp-us-central1-1\",\"decision\":\"allow\",\"reason\":\"emergency governance override for onboarding window\",\"approved_by\":\"dao-multisig-1\",\"approved_at_unix\":1700000000,\"expires_at_unix\":1700003600}", + "signature_hex": "REPLACE_WITH_SCHNORR_SIGNATURE_HEX_OVER_SHA256_OF_PAYLOAD_JSON" +} diff --git a/pkg/tbtc/signer/scripts/admission-policy-v1.sample.json b/pkg/tbtc/signer/scripts/admission-policy-v1.sample.json new file mode 100644 index 0000000000..ef2b85f910 --- /dev/null +++ b/pkg/tbtc/signer/scripts/admission-policy-v1.sample.json @@ -0,0 +1,10 @@ +{ + "max_operators_per_provider": 2, + "max_operators_per_region": 2, + "allowed_custody_classes": ["hsm", "kms"], + "required_attestation_status": "approved", + "min_patch_sla_days_remaining": 14, + "require_incident_response_contact": true, + "dao_override_trust_root_pubkey_hex": "REPLACE_WITH_XONLY_PUBKEY_HEX", + "dao_override_max_ttl_seconds": 604800 +} diff --git a/pkg/tbtc/signer/scripts/formal/check_roast_attempt_context_vectors.mjs b/pkg/tbtc/signer/scripts/formal/check_roast_attempt_context_vectors.mjs new file mode 100644 index 0000000000..b63841e2fa --- /dev/null +++ b/pkg/tbtc/signer/scripts/formal/check_roast_attempt_context_vectors.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node + +import crypto from "crypto" +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +const ROAST_INCLUDED_PARTICIPANTS_FINGERPRINT_DOMAIN = + "FROST-ROAST-INCLUDED-FPR-v1" +const ROAST_ATTEMPT_ID_DOMAIN = "FROST-ROAST-ATTEMPT-ID-v1" +const VECTOR_SCHEMA_VERSION = "roast-attempt-context-v1" + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(scriptDir, "../..") +// Path normalization (allowlisted-divergence per source manifest): +// canonical signer layout places the ROAST attempt context vector at +// `/test/vectors/roast-attempt-context-v1.json` where rootDir +// is `pkg/tbtc/signer/`. Monorepo source path was +// `docs/frost-migration/test-vectors/roast-attempt-context-v1.json` +// relative to monorepo root. +const vectorsPath = path.join( + rootDir, + "test/vectors/roast-attempt-context-v1.json" +) + +const fail = (message) => { + console.error(`[vector-conformance] ${message}`) + process.exit(1) +} + +const pushFramedComponent = (components, component) => { + const componentBuffer = Buffer.isBuffer(component) + ? component + : Buffer.from(component) + if (componentBuffer.length > 0xffffffff) { + fail("component exceeds u32 framing limit") + } + + const lengthBuffer = Buffer.allocUnsafe(4) + lengthBuffer.writeUInt32BE(componentBuffer.length, 0) + components.push(lengthBuffer, componentBuffer) +} + +const hashHex = (payload) => + crypto.createHash("sha256").update(payload).digest("hex") + +const roastHashHexWithComponents = (domain, components) => { + const payloadComponents = [] + pushFramedComponent(payloadComponents, Buffer.from(domain, "utf8")) + for (const component of components) { + pushFramedComponent(payloadComponents, component) + } + return hashHex(Buffer.concat(payloadComponents)) +} + +const canonicalizeParticipants = (participants, vectorId) => { + if (!Array.isArray(participants) || participants.length === 0) { + fail(`vector ${vectorId}: included_participants must be non-empty`) + } + + const canonical = [...participants].sort((left, right) => left - right) + const seen = new Set() + for (const participantIdentifier of canonical) { + if ( + !Number.isInteger(participantIdentifier) || + participantIdentifier <= 0 || + participantIdentifier > 0xffff + ) { + fail(`vector ${vectorId}: invalid participant identifier`) + } + if (seen.has(participantIdentifier)) { + fail( + `vector ${vectorId}: duplicate participant identifier ${participantIdentifier}` + ) + } + seen.add(participantIdentifier) + } + + return canonical +} + +const roastIncludedParticipantsFingerprintHex = (participants) => { + const participantPayloadComponents = [] + for (const participantIdentifier of participants) { + const participantBytes = Buffer.allocUnsafe(2) + participantBytes.writeUInt16BE(participantIdentifier, 0) + pushFramedComponent(participantPayloadComponents, participantBytes) + } + + return roastHashHexWithComponents( + ROAST_INCLUDED_PARTICIPANTS_FINGERPRINT_DOMAIN, + [Buffer.concat(participantPayloadComponents)] + ) +} + +const roastAttemptIdHex = ( + sessionId, + messageDigestHex, + attemptNumber, + coordinatorIdentifier, + includedParticipantsFingerprintHex +) => { + if (!Number.isInteger(attemptNumber) || attemptNumber <= 0) { + fail("attempt_number must be a positive integer") + } + if (!Number.isInteger(coordinatorIdentifier) || coordinatorIdentifier <= 0) { + fail("coordinator_identifier must be a positive integer") + } + + const attemptNumberBytes = Buffer.allocUnsafe(4) + attemptNumberBytes.writeUInt32BE(attemptNumber, 0) + const coordinatorBytes = Buffer.allocUnsafe(2) + coordinatorBytes.writeUInt16BE(coordinatorIdentifier, 0) + + return roastHashHexWithComponents(ROAST_ATTEMPT_ID_DOMAIN, [ + Buffer.from(sessionId, "utf8"), + Buffer.from(messageDigestHex, "utf8"), + attemptNumberBytes, + coordinatorBytes, + Buffer.from(includedParticipantsFingerprintHex, "utf8"), + ]) +} + +const vectors = JSON.parse(fs.readFileSync(vectorsPath, "utf8")) +if (vectors.schema_version !== VECTOR_SCHEMA_VERSION) { + fail( + `unsupported vector schema [${vectors.schema_version}] expected [${VECTOR_SCHEMA_VERSION}]` + ) +} + +const configuredFingerprintDomain = + vectors.hash_domains?.included_participants_fingerprint +const configuredAttemptIdDomain = vectors.hash_domains?.attempt_id +if ( + configuredFingerprintDomain !== ROAST_INCLUDED_PARTICIPANTS_FINGERPRINT_DOMAIN || + configuredAttemptIdDomain !== ROAST_ATTEMPT_ID_DOMAIN +) { + fail("vector hash_domains do not match canonical domain constants") +} + +let verified = 0 +for (const vector of vectors.vectors ?? []) { + const vectorId = vector.id ?? "unknown" + const canonicalParticipants = canonicalizeParticipants( + vector.included_participants, + vectorId + ) + const expectedFingerprint = + vector.expected_included_participants_fingerprint?.toLowerCase() + const expectedAttemptId = vector.expected_attempt_id?.toLowerCase() + const messageDigestHex = vector.message_digest_hex?.toLowerCase() + + const actualFingerprint = roastIncludedParticipantsFingerprintHex( + canonicalParticipants + ) + const actualAttemptId = roastAttemptIdHex( + vector.session_id, + messageDigestHex, + vector.attempt_number, + vector.coordinator_identifier, + actualFingerprint + ) + + if (actualFingerprint !== expectedFingerprint) { + fail( + `vector ${vectorId}: fingerprint mismatch expected [${expectedFingerprint}] got [${actualFingerprint}]` + ) + } + if (actualAttemptId !== expectedAttemptId) { + fail( + `vector ${vectorId}: attempt id mismatch expected [${expectedAttemptId}] got [${actualAttemptId}]` + ) + } + + verified += 1 +} + +if (verified === 0) { + fail("no vectors found") +} + +console.log(`[vector-conformance] verified ${verified} shared vectors`) diff --git a/pkg/tbtc/signer/scripts/formal/run_tla_models.sh b/pkg/tbtc/signer/scripts/formal/run_tla_models.sh new file mode 100755 index 0000000000..3f0b2fbc77 --- /dev/null +++ b/pkg/tbtc/signer/scripts/formal/run_tla_models.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# Path normalization (allowlisted-divergence per source manifest): +# canonical signer layout places TLA+ models at +# `/docs/formal/models` (where ROOT_DIR = pkg/tbtc/signer/). +# Monorepo source path was `docs/frost-migration/formal-verification/models` +# relative to monorepo root. Override via MODELS_PATH env var for +# alternate environments. +MODEL_DIR="${MODELS_PATH:-$ROOT_DIR/docs/formal/models}" +TLA_TOOLS_VERSION="${TLA_TOOLS_VERSION:-v1.8.0}" +TLA_TOOLS_JAR="${TLA_TOOLS_JAR:-/tmp/tla2tools-${TLA_TOOLS_VERSION}.jar}" +TLA_TOOLS_URL="${TLA_TOOLS_URL:-https://github.com/tlaplus/tlaplus/releases/download/${TLA_TOOLS_VERSION}/tla2tools.jar}" +TLA_TOOLS_SHA256="${TLA_TOOLS_SHA256:-237332bdcc79a35c7d26efa7b82c77c85c2744591c5598673a8a45085ff2a4fb}" + +if ! command -v java >/dev/null 2>&1; then + echo "java is required to run TLC model checks" >&2 + exit 1 +fi +if ! java -version >/dev/null 2>&1; then + echo "java runtime is required to run TLC model checks" >&2 + exit 1 +fi + +if [[ ! -d "$MODEL_DIR" ]]; then + echo "model directory not found: $MODEL_DIR" >&2 + exit 1 +fi + +verify_tla_tools_jar_sha256() { + local expected_sha256="$1" + local jar_path="$2" + + if command -v shasum >/dev/null 2>&1; then + local actual_sha256 + actual_sha256="$(shasum -a 256 "$jar_path" | awk '{print $1}')" + if [[ "$actual_sha256" != "$expected_sha256" ]]; then + echo "tla2tools jar checksum mismatch: expected [$expected_sha256], got [$actual_sha256]" >&2 + return 1 + fi + return 0 + fi + + if command -v sha256sum >/dev/null 2>&1; then + local actual_sha256 + actual_sha256="$(sha256sum "$jar_path" | awk '{print $1}')" + if [[ "$actual_sha256" != "$expected_sha256" ]]; then + echo "tla2tools jar checksum mismatch: expected [$expected_sha256], got [$actual_sha256]" >&2 + return 1 + fi + return 0 + fi + + echo "missing checksum tool: install shasum or sha256sum" >&2 + return 1 +} + +if [[ ! -f "$TLA_TOOLS_JAR" ]]; then + echo "downloading tlaplus tools jar to $TLA_TOOLS_JAR" + curl -fsSL "$TLA_TOOLS_URL" -o "$TLA_TOOLS_JAR" +fi + +verify_tla_tools_jar_sha256 "$TLA_TOOLS_SHA256" "$TLA_TOOLS_JAR" + +shopt -s nullglob +cfg_files=("$MODEL_DIR"/*.cfg) +shopt -u nullglob + +if [[ ${#cfg_files[@]} -eq 0 ]]; then + echo "no model cfg files found under $MODEL_DIR" >&2 + exit 1 +fi + +for cfg_path in "${cfg_files[@]}"; do + cfg_name="$(basename "$cfg_path" .cfg)" + module_name="${cfg_name%%.*}" + tla_path="$MODEL_DIR/${module_name}.tla" + if [[ ! -f "$tla_path" ]]; then + echo "missing tla module for cfg [$cfg_path]: expected [$tla_path]" >&2 + exit 1 + fi + + echo "running tlc for ${cfg_name} (${module_name})" + ( + cd "$MODEL_DIR" + java -cp "$TLA_TOOLS_JAR" tlc2.TLC -cleanup -config "$(basename "$cfg_path")" "${module_name}.tla" + ) +done diff --git a/pkg/tbtc/signer/scripts/run_phase5_chaos_suite.sh b/pkg/tbtc/signer/scripts/run_phase5_chaos_suite.sh new file mode 100755 index 0000000000..82ea2e6536 --- /dev/null +++ b/pkg/tbtc/signer/scripts/run_phase5_chaos_suite.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MANIFEST_PATH="$(cd "${SCRIPT_DIR}/.." && pwd)/Cargo.toml" + +SCENARIOS=( + "engine::tests::start_sign_round_rejects_stale_attempt_after_authorized_transition_across_reload|stale_payload_replay_or_duplication|stale attempt payloads remain fail-closed after authorized advancement and reload" + "engine::tests::start_sign_round_allows_next_attempt_with_valid_transition_evidence_after_reload|restart_recovery_authorized_transition|authorized transition succeeds after restart/reload with deterministic attempt context" + "engine::tests::start_sign_round_attempt_replay_guard_survives_process_restart_with_sign_cache_loss|process_crash_active_attempt|consumed-attempt replay guard survives simulated crash and cache loss" + "engine::tests::persist_fault_after_temp_sync_before_rename_preserves_previous_state_on_restart|persist_fault_pre_rename|previous durable state remains intact after injected pre-rename persist fault" + "engine::tests::persist_fault_after_rename_before_directory_sync_keeps_state_loadable_after_restart|persist_fault_post_rename|renamed durable state remains loadable after injected post-rename persist fault" +) + +echo "Phase 5 chaos/failure-injection suite (tbtc-signer)" +echo "Manifest: ${MANIFEST_PATH}" +echo + +for scenario in "${SCENARIOS[@]}"; do + IFS="|" read -r test_name scenario_id pass_criteria <<<"${scenario}" + echo "[RUN] ${scenario_id}" + echo " test: ${test_name}" + echo " pass: ${pass_criteria}" + cargo test --manifest-path "${MANIFEST_PATH}" "${test_name}" -- --exact + echo +done + +echo "PASS: all Phase 5 chaos/failure-injection scenarios satisfied their pass criteria." diff --git a/pkg/tbtc/signer/src/api.rs b/pkg/tbtc/signer/src/api.rs new file mode 100644 index 0000000000..14492609ce --- /dev/null +++ b/pkg/tbtc/signer/src/api.rs @@ -0,0 +1,532 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgParticipant { + pub identifier: u16, + pub public_key_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RunDkgRequest { + pub session_id: String, + pub participants: Vec, + pub threshold: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dkg_seed_hex: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgResult { + pub session_id: String, + pub key_group: String, + pub participant_count: u16, + pub threshold: u16, + pub created_at_unix: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgRound1Package { + pub identifier: String, + pub package_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgRound2Package { + pub identifier: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sender_identifier: Option, + pub package_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart1Request { + pub participant_identifier: String, + pub max_signers: u16, + pub min_signers: u16, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart1Result { + pub secret_package_hex: String, + pub package: DkgRound1Package, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart2Request { + pub secret_package_hex: String, + pub round1_packages: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart2Result { + pub secret_package_hex: String, + pub packages: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NativeFrostKeyPackage { + pub identifier: String, + pub data_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NativeFrostPublicKeyPackage { + pub verifying_shares: std::collections::BTreeMap, + pub verifying_key: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart3Request { + pub secret_package_hex: String, + pub round1_packages: Vec, + pub round2_packages: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart3Result { + pub key_package: NativeFrostKeyPackage, + pub public_key_package: NativeFrostPublicKeyPackage, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NativeFrostCommitment { + pub identifier: String, + pub data_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NativeFrostSignatureShare { + pub identifier: String, + pub data_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct GenerateNoncesAndCommitmentsRequest { + pub key_package_identifier: String, + pub key_package_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct GenerateNoncesAndCommitmentsResult { + /// Secret one-time FROST signing nonces serialized as hex. + /// + /// The caller owns this secret after it crosses the FFI boundary. It must + /// be supplied to `SignShareRequest::nonces_hex` at most once and erased by + /// the caller immediately afterward. Reuse for another signing package or + /// message can reveal the private signing share. + pub nonces_hex: String, + pub commitment: NativeFrostCommitment, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NewSigningPackageRequest { + pub message_hex: String, + pub commitments: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NewSigningPackageResult { + pub signing_package_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct SignShareRequest { + pub signing_package_hex: String, + /// Secret one-time nonces returned by `GenerateNoncesAndCommitmentsResult`. + /// + /// This stateless endpoint cannot remember consumed nonces across FFI + /// calls. The caller is cryptographically responsible for single use. + pub nonces_hex: String, + pub key_package_identifier: String, + pub key_package_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct SignShareResult { + pub signature_share: NativeFrostSignatureShare, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct AggregateRequest { + pub signing_package_hex: String, + pub signature_shares: Vec, + pub public_key_package: NativeFrostPublicKeyPackage, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct AggregateResult { + pub signature_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct StartSignRoundRequest { + pub session_id: String, + pub member_identifier: u16, + pub message_hex: String, + pub key_group: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub taproot_merkle_root_hex: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signing_participants: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attempt_context: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attempt_transition_evidence: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RoundContribution { + pub identifier: u16, + pub signature_share_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct AttemptTransitionTelemetry { + pub from_attempt_number: u32, + pub to_attempt_number: u32, + pub from_coordinator_identifier: u16, + pub to_coordinator_identifier: u16, + pub reason: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub excluded_member_identifiers: Vec, + pub coordinator_rotated: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RoundState { + pub session_id: String, + pub round_id: String, + pub required_contributions: u16, + pub message_digest_hex: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub taproot_merkle_root_hex: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signing_participants: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attempt_transition_telemetry: Option, + pub own_contribution: RoundContribution, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct FinalizeSignRoundRequest { + pub session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub taproot_merkle_root_hex: Option, + pub round_contributions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attempt_context: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct AttemptContext { + pub attempt_number: u32, + pub coordinator_identifier: u16, + pub included_participants: Vec, + pub included_participants_fingerprint: String, + pub attempt_id: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct AttemptExclusionEvidence { + pub reason: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub excluded_member_identifiers: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invalid_share_proof_fingerprint: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct AttemptTransitionEvidence { + pub from_attempt_number: u32, + pub from_attempt_id: String, + pub from_coordinator_identifier: u16, + pub previous_round_id: String, + pub previous_sign_request_fingerprint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exclusion_evidence: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct TranscriptAuditRequest { + pub session_id: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct TranscriptAuditRecord { + pub from_attempt_number: u32, + pub to_attempt_number: u32, + pub from_attempt_id: String, + pub to_attempt_id: String, + pub previous_round_id: String, + pub previous_sign_request_fingerprint: String, + pub from_coordinator_identifier: u16, + pub to_coordinator_identifier: u16, + pub reason: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub excluded_member_identifiers: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invalid_share_proof_fingerprint: Option, + pub transcript_hash: String, + pub recorded_at_unix: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct TranscriptAuditResult { + pub session_id: String, + pub transition_count: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub records: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct VerifyBlameProofRequest { + pub session_id: String, + pub from_attempt_number: u32, + pub accused_member_identifier: u16, + pub reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invalid_share_proof_fingerprint: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct BlameProofVerificationResult { + pub session_id: String, + pub from_attempt_number: u32, + pub accused_member_identifier: u16, + pub reason: String, + pub verified: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transcript_hash: Option, + pub detail: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct QuarantineStatusRequest { + pub operator_identifier: u16, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct QuarantineStatusResult { + pub operator_identifier: u16, + pub auto_quarantine_enabled: bool, + pub fault_score: u64, + pub quarantine_threshold: u64, + pub quarantined: bool, + pub dao_override_allowlisted: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct SignatureResult { + pub session_id: String, + pub round_id: String, + pub signature_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct TxInput { + pub txid_hex: String, + pub vout: u32, + pub value_sats: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct TxOutput { + pub script_pubkey_hex: String, + pub value_sats: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct BuildTaprootTxRequest { + pub session_id: String, + pub inputs: Vec, + pub outputs: Vec, + pub script_tree_hex: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct TransactionResult { + pub session_id: String, + pub tx_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct ShareMaterial { + pub identifier: u16, + pub encrypted_share_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RefreshSharesRequest { + pub session_id: String, + pub current_shares: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RefreshSharesResult { + pub session_id: String, + pub refresh_epoch: u64, + pub new_shares: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RefreshCadenceStatusRequest { + pub session_id: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RefreshCadenceStatusResult { + pub session_id: String, + pub refresh_count: u64, + pub last_refresh_epoch: u64, + pub cadence_seconds: u64, + pub next_refresh_due_unix: u64, + pub overdue: bool, + pub continuity_preserved: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub continuity_reference_key_group: Option, + pub emergency_rekey_required: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub emergency_rekey_reason: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct TriggerEmergencyRekeyRequest { + pub session_id: String, + pub reason: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct TriggerEmergencyRekeyResult { + pub session_id: String, + pub emergency_rekey_required: bool, + pub reason: String, + pub triggered_at_unix: u64, + pub recommended_new_session_id: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DifferentialFuzzRequest { + #[serde(default)] + pub seed: u64, + #[serde(default)] + pub case_count: u32, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DifferentialDivergence { + pub case_index: u32, + pub check: String, + pub severity: String, + pub detail: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DifferentialFuzzResult { + pub seed: u64, + pub case_count: u32, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub divergences: Vec, + pub critical_divergence_count: u32, + pub unresolved_critical_divergence: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct PromoteCanaryRequest { + pub target_percent: u8, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct PromoteCanaryResult { + pub from_percent: u8, + pub to_percent: u8, + pub config_version: u64, + pub promoted_at_unix: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RollbackCanaryRequest { + pub reason: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RollbackCanaryResult { + pub from_percent: u8, + pub to_percent: u8, + pub config_version: u64, + pub reason: String, + pub rolled_back_at_unix: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct CanaryRolloutStatusResult { + pub current_percent: u8, + pub previous_percent: u8, + pub config_version: u64, + pub promotion_gate_passed: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub gate_failures: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recommended_next_percent: Option, + pub last_action_unix: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RoastLivenessPolicyResult { + pub coordinator_timeout_ms: u64, + pub timeout_source: String, + pub advance_trigger: String, + pub exclusion_evidence_policy: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct SignerHardeningMetricsResult { + pub runtime_version: String, + pub provenance_enforced: bool, + pub admission_policy_enforced: bool, + pub signing_policy_firewall_enforced: bool, + pub run_dkg_calls_total: u64, + pub run_dkg_success_total: u64, + pub run_dkg_admission_reject_total: u64, + pub start_sign_round_calls_total: u64, + pub start_sign_round_success_total: u64, + pub build_taproot_tx_calls_total: u64, + pub build_taproot_tx_success_total: u64, + pub build_taproot_tx_policy_reject_total: u64, + pub finalize_sign_round_calls_total: u64, + pub finalize_sign_round_success_total: u64, + pub refresh_shares_calls_total: u64, + pub refresh_shares_success_total: u64, + pub roast_transcript_audit_calls_total: u64, + pub roast_transcript_audit_success_total: u64, + pub verify_blame_proof_calls_total: u64, + pub verify_blame_proof_success_total: u64, + pub attempt_transition_total: u64, + pub coordinator_failover_total: u64, + pub auto_quarantine_fault_events_total: u64, + pub auto_quarantine_enforcements_total: u64, + pub quarantined_operator_count: u64, + pub refresh_cadence_overdue_sessions: u64, + pub emergency_rekey_sessions_total: u64, + pub differential_fuzz_runs_total: u64, + pub differential_fuzz_critical_divergence_total: u64, + pub canary_promotions_total: u64, + pub canary_rollbacks_total: u64, + pub run_dkg_latency_p95_ms: u64, + pub run_dkg_latency_samples: u64, + pub start_sign_round_latency_p95_ms: u64, + pub start_sign_round_latency_samples: u64, + pub build_taproot_tx_latency_p95_ms: u64, + pub build_taproot_tx_latency_samples: u64, + pub finalize_sign_round_latency_p95_ms: u64, + pub finalize_sign_round_latency_samples: u64, + pub refresh_shares_latency_p95_ms: u64, + pub refresh_shares_latency_samples: u64, + pub last_updated_unix: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct ErrorResponse { + pub code: String, + pub message: String, + pub recovery_class: String, +} diff --git a/pkg/tbtc/signer/src/bin/admission_checker.rs b/pkg/tbtc/signer/src/bin/admission_checker.rs new file mode 100644 index 0000000000..41c7c27ea3 --- /dev/null +++ b/pkg/tbtc/signer/src/bin/admission_checker.rs @@ -0,0 +1,1553 @@ +use bitcoin::secp256k1::{ + schnorr::Signature as SchnorrSignature, Message as SecpMessage, Secp256k1, XOnlyPublicKey, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SECONDS_PER_DAY: u64 = 86_400; +const DEFAULT_DAO_OVERRIDE_MAX_TTL_SECONDS: u64 = 7 * SECONDS_PER_DAY; + +#[derive(Clone, Debug, Deserialize)] +struct AdmissionPolicyV1 { + #[serde(default)] + max_operators_per_provider: Option, + #[serde(default)] + max_operators_per_region: Option, + allowed_custody_classes: Vec, + required_attestation_status: String, + min_patch_sla_days_remaining: u64, + require_incident_response_contact: bool, + #[serde(default)] + dao_override_trust_root_pubkey_hex: Option, + #[serde(default)] + dao_override_max_ttl_seconds: Option, +} + +#[derive(Clone, Debug, Deserialize)] +struct AdmissionCandidate { + operator_id: String, + provider: String, + region: String, + custody_class: String, + attestation_status: String, + patch_sla_expires_at_unix: u64, + #[serde(default)] + incident_response_contact: Option, +} + +#[derive(Clone, Debug, Deserialize)] +struct ExistingOperator { + operator_id: String, + provider: String, + region: String, +} + +#[derive(Clone, Debug, Serialize)] +struct AdmissionReason { + code: String, + detail: String, +} + +#[derive(Clone, Debug, Deserialize)] +struct AdmissionOverrideArtifact { + payload_json: String, + signature_hex: String, +} + +#[derive(Clone, Debug, Deserialize)] +struct AdmissionOverridePayload { + override_id: String, + operator_id: String, + decision: String, + reason: String, + approved_by: String, + approved_at_unix: u64, + expires_at_unix: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ConsumedOverrideRecord { + override_id: String, + operator_id: String, + approved_by: String, + approved_at_unix: u64, + expires_at_unix: u64, + consumed_at_unix: u64, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +struct OverrideReplayRegistry { + #[serde(default)] + consumed_override_ids: HashMap, +} + +impl OverrideReplayRegistry { + // Remove expired entries to bound registry growth. + // + // Safety: pruning expired entries does not create a replay window because + // apply_dao_override independently rejects expired overrides via the + // expires_at_unix < now_unix_seconds temporal guard. If validation ordering + // changes, replay protection invariants must be re-evaluated. + fn prune_expired(&mut self, now_unix_seconds: u64) { + self.consumed_override_ids + .retain(|_, record| record.expires_at_unix >= now_unix_seconds); + } + + fn lookup(&self, override_id: &str) -> Option<&ConsumedOverrideRecord> { + self.consumed_override_ids.get(override_id) + } + + fn insert( + &mut self, + override_id: String, + operator_id: String, + approved_by: String, + approved_at_unix: u64, + expires_at_unix: u64, + consumed_at_unix: u64, + ) { + self.consumed_override_ids.insert( + override_id.clone(), + ConsumedOverrideRecord { + override_id, + operator_id, + approved_by, + approved_at_unix, + expires_at_unix, + consumed_at_unix, + }, + ); + } +} + +#[derive(Clone, Debug, Serialize)] +struct AdmissionDecision { + decision: String, + reasons: Vec, + #[serde(default)] + override_applied: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + override_reference: Option, + evaluated_at_unix: u64, +} + +#[derive(Debug)] +struct CliArgs { + policy_path: PathBuf, + candidate_path: PathBuf, + existing_path: Option, + override_path: Option, + override_registry_path: Option, + now_unix_override: Option, +} + +fn usage() -> String { + "Usage: admission_checker --policy --candidate [--existing ] [--override ] [--override-registry ] [--now-unix ]".to_string() +} + +fn parse_args(args: &[String]) -> Result { + let mut policy_path: Option = None; + let mut candidate_path: Option = None; + let mut existing_path: Option = None; + let mut override_path: Option = None; + let mut override_registry_path: Option = None; + let mut now_unix_override: Option = None; + + let mut i = 0usize; + while i < args.len() { + match args[i].as_str() { + "--policy" => { + i += 1; + if i >= args.len() { + return Err("missing value for --policy".to_string()); + } + policy_path = Some(PathBuf::from(&args[i])); + } + "--candidate" => { + i += 1; + if i >= args.len() { + return Err("missing value for --candidate".to_string()); + } + candidate_path = Some(PathBuf::from(&args[i])); + } + "--existing" => { + i += 1; + if i >= args.len() { + return Err("missing value for --existing".to_string()); + } + existing_path = Some(PathBuf::from(&args[i])); + } + "--override" => { + i += 1; + if i >= args.len() { + return Err("missing value for --override".to_string()); + } + override_path = Some(PathBuf::from(&args[i])); + } + "--override-registry" => { + i += 1; + if i >= args.len() { + return Err("missing value for --override-registry".to_string()); + } + override_registry_path = Some(PathBuf::from(&args[i])); + } + "--now-unix" => { + i += 1; + if i >= args.len() { + return Err("missing value for --now-unix".to_string()); + } + let parsed = args[i] + .parse::() + .map_err(|_| "invalid value for --now-unix".to_string())?; + now_unix_override = Some(parsed); + } + "--help" | "-h" => { + return Err(usage()); + } + unknown => { + return Err(format!("unknown argument [{unknown}]")); + } + } + i += 1; + } + + let policy_path = policy_path.ok_or_else(|| "missing required --policy".to_string())?; + let candidate_path = + candidate_path.ok_or_else(|| "missing required --candidate".to_string())?; + if override_path.is_some() && override_registry_path.is_none() { + return Err("--override requires --override-registry for replay protection".to_string()); + } + + Ok(CliArgs { + policy_path, + candidate_path, + existing_path, + override_path, + override_registry_path, + now_unix_override, + }) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn load_json_file Deserialize<'de>>(path: &PathBuf) -> Result { + let bytes = + fs::read(path).map_err(|e| format!("failed to read file [{}]: {e}", path.display()))?; + serde_json::from_slice(&bytes) + .map_err(|e| format!("failed to parse JSON file [{}]: {e}", path.display())) +} + +fn load_override_replay_registry(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(OverrideReplayRegistry::default()); + } + load_json_file(path) +} + +fn persist_override_replay_registry( + path: &PathBuf, + registry: &OverrideReplayRegistry, +) -> Result<(), String> { + let serialized = serde_json::to_vec_pretty(registry) + .map_err(|error| format!("failed to serialize override replay registry: {error}"))?; + let tmp_path = path.with_extension(format!("tmp-{}", std::process::id())); + fs::write(&tmp_path, serialized).map_err(|error| { + format!( + "failed to write override replay registry temp file [{}]: {error}", + tmp_path.display() + ) + })?; + fs::rename(&tmp_path, path).map_err(|error| { + format!( + "failed to persist override replay registry [{}]: {error}", + path.display() + ) + }) +} + +fn trimmed_lowercase(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn push_override_rejection_reason(decision: &mut AdmissionDecision, code: &str, detail: String) { + decision.decision = "reject".to_string(); + decision.reasons.push(AdmissionReason { + code: code.to_string(), + detail, + }); +} + +fn parse_override_trust_root_pubkey(trust_root_hex: &str) -> Result { + let trust_root_hex = trust_root_hex.trim(); + if trust_root_hex.is_empty() { + return Err("dao override trust root pubkey must be non-empty hex".to_string()); + } + + let trust_root_bytes = hex::decode(trust_root_hex) + .map_err(|_| "dao override trust root pubkey must be valid hex".to_string())?; + if trust_root_bytes.len() != 32 { + return Err("dao override trust root pubkey must decode to 32 bytes".to_string()); + } + + XOnlyPublicKey::from_slice(&trust_root_bytes).map_err(|_| { + "dao override trust root pubkey must be valid x-only secp256k1 key".to_string() + }) +} + +fn verify_override_signature( + payload_json: &str, + signature_hex: &str, + trust_root_pubkey: &XOnlyPublicKey, +) -> Result<(), String> { + let signature_bytes = hex::decode(signature_hex.trim()) + .map_err(|_| "dao override signature must be valid hex".to_string())?; + let signature = SchnorrSignature::from_slice(&signature_bytes) + .map_err(|_| "dao override signature must be valid schnorr bytes".to_string())?; + let payload_digest = Sha256::digest(payload_json.as_bytes()); + let message = SecpMessage::from_digest_slice(&payload_digest) + .map_err(|_| "failed to construct override signature digest".to_string())?; + + Secp256k1::verification_only() + .verify_schnorr(&signature, &message, trust_root_pubkey) + .map_err(|_| "dao override signature verification failed".to_string()) +} + +fn apply_dao_override( + policy: &AdmissionPolicyV1, + candidate: &AdmissionCandidate, + now_unix_seconds: u64, + mut decision: AdmissionDecision, + override_artifact: Option<&AdmissionOverrideArtifact>, + replay_registry: Option<&mut OverrideReplayRegistry>, +) -> AdmissionDecision { + if decision.decision == "allow" { + return decision; + } + + let Some(override_artifact) = override_artifact else { + return decision; + }; + + let trust_root_hex = match policy.dao_override_trust_root_pubkey_hex.as_ref() { + Some(trust_root_hex) if !trust_root_hex.trim().is_empty() => trust_root_hex, + _ => { + push_override_rejection_reason( + &mut decision, + "dao_override_policy_not_configured", + "policy must define dao_override_trust_root_pubkey_hex to apply overrides" + .to_string(), + ); + return decision; + } + }; + let trust_root_pubkey = match parse_override_trust_root_pubkey(trust_root_hex) { + Ok(trust_root_pubkey) => trust_root_pubkey, + Err(detail) => { + push_override_rejection_reason( + &mut decision, + "dao_override_invalid_trust_root", + detail, + ); + return decision; + } + }; + + let payload_json = override_artifact.payload_json.trim(); + if payload_json.is_empty() { + push_override_rejection_reason( + &mut decision, + "dao_override_payload_invalid", + "dao override payload_json must be non-empty".to_string(), + ); + return decision; + } + + if let Err(detail) = verify_override_signature( + payload_json, + &override_artifact.signature_hex, + &trust_root_pubkey, + ) { + push_override_rejection_reason(&mut decision, "dao_override_invalid_signature", detail); + return decision; + } + + let override_payload = match serde_json::from_str::(payload_json) { + Ok(override_payload) => override_payload, + Err(error) => { + push_override_rejection_reason( + &mut decision, + "dao_override_payload_invalid", + format!("failed to parse dao override payload_json: {error}"), + ); + return decision; + } + }; + + let override_id = trimmed_lowercase(&override_payload.override_id); + if override_id.is_empty() { + push_override_rejection_reason( + &mut decision, + "dao_override_id_missing", + "override override_id must be non-empty".to_string(), + ); + return decision; + } + + let Some(replay_registry) = replay_registry else { + push_override_rejection_reason( + &mut decision, + "dao_override_replay_registry_not_configured", + "override replay protection requires --override-registry ".to_string(), + ); + return decision; + }; + replay_registry.prune_expired(now_unix_seconds); + if let Some(record) = replay_registry.lookup(&override_id) { + push_override_rejection_reason( + &mut decision, + "dao_override_replay_detected", + format!( + "override_id [{}] already consumed at [{}] for operator_id [{}]", + record.override_id, record.consumed_at_unix, record.operator_id + ), + ); + return decision; + } + + let override_operator_id = trimmed_lowercase(&override_payload.operator_id); + let candidate_operator_id = trimmed_lowercase(&candidate.operator_id); + if override_operator_id.is_empty() || override_operator_id != candidate_operator_id { + push_override_rejection_reason( + &mut decision, + "dao_override_candidate_mismatch", + format!( + "override operator_id [{}] does not match candidate operator_id [{}]", + override_payload.operator_id, candidate.operator_id + ), + ); + return decision; + } + + if trimmed_lowercase(&override_payload.decision) != "allow" { + push_override_rejection_reason( + &mut decision, + "dao_override_decision_not_allow", + format!( + "override decision must be [allow], got [{}]", + override_payload.decision + ), + ); + return decision; + } + + if override_payload.reason.trim().is_empty() { + push_override_rejection_reason( + &mut decision, + "dao_override_reason_missing", + "override reason must be non-empty".to_string(), + ); + return decision; + } + + if override_payload.approved_by.trim().is_empty() { + push_override_rejection_reason( + &mut decision, + "dao_override_approver_missing", + "override approved_by must be non-empty".to_string(), + ); + return decision; + } + + if override_payload.approved_at_unix > now_unix_seconds { + push_override_rejection_reason( + &mut decision, + "dao_override_not_yet_valid", + format!( + "override approved_at_unix [{}] is in the future relative to now [{}]", + override_payload.approved_at_unix, now_unix_seconds + ), + ); + return decision; + } + + if override_payload.expires_at_unix < now_unix_seconds { + push_override_rejection_reason( + &mut decision, + "dao_override_expired", + format!( + "override expired at [{}], now [{}]", + override_payload.expires_at_unix, now_unix_seconds + ), + ); + return decision; + } + + if override_payload.expires_at_unix < override_payload.approved_at_unix { + push_override_rejection_reason( + &mut decision, + "dao_override_expiry_invalid", + format!( + "override expires_at_unix [{}] is before approved_at_unix [{}]", + override_payload.expires_at_unix, override_payload.approved_at_unix + ), + ); + return decision; + } + + let max_ttl_seconds = policy + .dao_override_max_ttl_seconds + .unwrap_or(DEFAULT_DAO_OVERRIDE_MAX_TTL_SECONDS); + let override_ttl_seconds = override_payload.expires_at_unix - override_payload.approved_at_unix; + if override_ttl_seconds > max_ttl_seconds { + push_override_rejection_reason( + &mut decision, + "dao_override_ttl_exceeds_policy", + format!( + "override TTL [{}] exceeds policy max [{}]", + override_ttl_seconds, max_ttl_seconds + ), + ); + return decision; + } + + replay_registry.insert( + override_id, + candidate_operator_id.clone(), + override_payload.approved_by.trim().to_string(), + override_payload.approved_at_unix, + override_payload.expires_at_unix, + now_unix_seconds, + ); + + decision.decision = "allow".to_string(); + decision.override_applied = true; + decision.override_reference = Some(format!( + "{}:{}", + override_payload.approved_by.trim(), + override_payload.approved_at_unix + )); + decision.reasons.push(AdmissionReason { + code: "dao_override_applied".to_string(), + detail: format!( + "governance override applied by [{}] for operator_id [{}]: {}", + override_payload.approved_by.trim(), + override_payload.operator_id.trim(), + override_payload.reason.trim() + ), + }); + decision +} + +fn evaluate_admission( + policy: &AdmissionPolicyV1, + candidate: &AdmissionCandidate, + existing: &[ExistingOperator], + now_unix_seconds: u64, +) -> AdmissionDecision { + let mut reasons: Vec = Vec::new(); + + let candidate_operator_id = trimmed_lowercase(&candidate.operator_id); + if candidate_operator_id.is_empty() { + reasons.push(AdmissionReason { + code: "operator_id_missing".to_string(), + detail: "candidate operator_id must be non-empty".to_string(), + }); + } else if existing + .iter() + .any(|operator| trimmed_lowercase(&operator.operator_id) == candidate_operator_id) + { + reasons.push(AdmissionReason { + code: "operator_id_already_registered".to_string(), + detail: format!( + "operator_id [{}] already exists in operator set", + candidate_operator_id + ), + }); + } + + let candidate_provider = trimmed_lowercase(&candidate.provider); + if candidate_provider.is_empty() { + reasons.push(AdmissionReason { + code: "provider_missing".to_string(), + detail: "candidate provider must be non-empty".to_string(), + }); + } + + let candidate_region = trimmed_lowercase(&candidate.region); + if candidate_region.is_empty() { + reasons.push(AdmissionReason { + code: "region_missing".to_string(), + detail: "candidate region must be non-empty".to_string(), + }); + } + + if let Some(max_per_provider) = policy.max_operators_per_provider { + let mut provider_counts = HashMap::new(); + for operator in existing { + let provider = trimmed_lowercase(&operator.provider); + if provider.is_empty() { + continue; + } + *provider_counts + .entry(provider.to_string()) + .or_insert(0usize) += 1; + } + let current_count = provider_counts + .get(&candidate_provider) + .copied() + .unwrap_or_default(); + if current_count.saturating_add(1) > max_per_provider { + reasons.push(AdmissionReason { + code: "provider_diversity_violation".to_string(), + detail: format!( + "provider [{}] would exceed max_operators_per_provider [{}]", + candidate_provider, max_per_provider + ), + }); + } + } + + if let Some(max_per_region) = policy.max_operators_per_region { + let mut region_counts = HashMap::new(); + for operator in existing { + let region = trimmed_lowercase(&operator.region); + if region.is_empty() { + continue; + } + *region_counts.entry(region.to_string()).or_insert(0usize) += 1; + } + let current_count = region_counts + .get(&candidate_region) + .copied() + .unwrap_or_default(); + if current_count.saturating_add(1) > max_per_region { + reasons.push(AdmissionReason { + code: "geo_diversity_violation".to_string(), + detail: format!( + "region [{}] would exceed max_operators_per_region [{}]", + candidate_region, max_per_region + ), + }); + } + } + + let allowed_custody_classes = policy + .allowed_custody_classes + .iter() + .map(|value| trimmed_lowercase(value)) + .collect::>(); + let candidate_custody_class = trimmed_lowercase(&candidate.custody_class); + if candidate_custody_class.is_empty() { + reasons.push(AdmissionReason { + code: "custody_class_missing".to_string(), + detail: "candidate custody_class must be non-empty".to_string(), + }); + } else if !allowed_custody_classes + .iter() + .any(|allowed| allowed == &candidate_custody_class) + { + reasons.push(AdmissionReason { + code: "custody_class_not_allowed".to_string(), + detail: format!( + "custody_class [{}] not in allowed set {:?}", + candidate_custody_class, policy.allowed_custody_classes + ), + }); + } + + let required_attestation_status = trimmed_lowercase(&policy.required_attestation_status); + let candidate_attestation_status = trimmed_lowercase(&candidate.attestation_status); + if candidate_attestation_status != required_attestation_status { + reasons.push(AdmissionReason { + code: "attestation_status_not_approved".to_string(), + detail: format!( + "candidate attestation_status [{}] does not match required [{}]", + candidate.attestation_status, policy.required_attestation_status + ), + }); + } + + let required_remaining_seconds = policy + .min_patch_sla_days_remaining + .saturating_mul(SECONDS_PER_DAY); + let minimum_expiry = now_unix_seconds.saturating_add(required_remaining_seconds); + if candidate.patch_sla_expires_at_unix < minimum_expiry { + reasons.push(AdmissionReason { + code: "patch_sla_below_minimum_remaining".to_string(), + detail: format!( + "patch_sla_expires_at_unix [{}] is below minimum required [{}] ({} days remaining)", + candidate.patch_sla_expires_at_unix, + minimum_expiry, + policy.min_patch_sla_days_remaining + ), + }); + } + + if policy.require_incident_response_contact { + let has_contact = candidate + .incident_response_contact + .as_ref() + .is_some_and(|value| !value.trim().is_empty()); + if !has_contact { + reasons.push(AdmissionReason { + code: "incident_response_contact_missing".to_string(), + detail: "candidate incident_response_contact is required".to_string(), + }); + } + } + + AdmissionDecision { + decision: if reasons.is_empty() { + "allow".to_string() + } else { + "reject".to_string() + }, + reasons, + override_applied: false, + override_reference: None, + evaluated_at_unix: now_unix_seconds, + } +} + +fn run() -> Result { + let args = env::args().skip(1).collect::>(); + let cli = parse_args(&args)?; + let policy: AdmissionPolicyV1 = load_json_file(&cli.policy_path)?; + let candidate: AdmissionCandidate = load_json_file(&cli.candidate_path)?; + let existing: Vec = match cli.existing_path.as_ref() { + Some(path) => load_json_file(path)?, + None => Vec::new(), + }; + let now_unix_seconds = cli.now_unix_override.unwrap_or_else(now_unix); + let override_artifact: Option = match cli.override_path.as_ref() { + Some(path) => Some(load_json_file(path)?), + None => None, + }; + let mut replay_registry: Option = + match cli.override_registry_path.as_ref() { + Some(path) => Some(load_override_replay_registry(path)?), + None => None, + }; + let decision = evaluate_admission(&policy, &candidate, &existing, now_unix_seconds); + let decision = apply_dao_override( + &policy, + &candidate, + now_unix_seconds, + decision, + override_artifact.as_ref(), + replay_registry.as_mut(), + ); + + if decision.override_applied { + let registry_path = cli.override_registry_path.as_ref().ok_or_else(|| { + "override replay registry path is required when applying override".to_string() + })?; + let registry = replay_registry.as_ref().ok_or_else(|| { + "override replay registry missing while applying override".to_string() + })?; + persist_override_replay_registry(registry_path, registry)?; + } + + Ok(decision) +} + +fn main() { + match run() { + Ok(decision) => { + let json = serde_json::to_string_pretty(&decision) + .unwrap_or_else(|_| "{\"decision\":\"reject\",\"reasons\":[{\"code\":\"serialization_error\",\"detail\":\"failed to encode output\"}],\"evaluated_at_unix\":0}".to_string()); + println!("{json}"); + if decision.decision == "allow" { + std::process::exit(0); + } + std::process::exit(1); + } + Err(error) => { + eprintln!("{error}"); + eprintln!("{}", usage()); + std::process::exit(2); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn baseline_policy() -> AdmissionPolicyV1 { + AdmissionPolicyV1 { + max_operators_per_provider: Some(2), + max_operators_per_region: Some(2), + allowed_custody_classes: vec!["hsm".to_string(), "kms".to_string()], + required_attestation_status: "approved".to_string(), + min_patch_sla_days_remaining: 7, + require_incident_response_contact: true, + dao_override_trust_root_pubkey_hex: None, + dao_override_max_ttl_seconds: None, + } + } + + fn baseline_candidate() -> AdmissionCandidate { + AdmissionCandidate { + operator_id: "operator-3".to_string(), + provider: "gcp".to_string(), + region: "us-central1".to_string(), + custody_class: "kms".to_string(), + attestation_status: "approved".to_string(), + patch_sla_expires_at_unix: 2_000_000_000, + incident_response_contact: Some("ops@example.org".to_string()), + } + } + + fn baseline_existing() -> Vec { + vec![ + ExistingOperator { + operator_id: "operator-1".to_string(), + provider: "aws".to_string(), + region: "us-east-1".to_string(), + }, + ExistingOperator { + operator_id: "operator-2".to_string(), + provider: "gcp".to_string(), + region: "europe-west1".to_string(), + }, + ] + } + + fn sign_override_payload(payload_json: String) -> (String, AdmissionOverrideArtifact) { + let secp = Secp256k1::new(); + let secret_key = + bitcoin::secp256k1::SecretKey::from_slice(&[0x33; 32]).expect("secret key"); + let keypair = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &secret_key); + let (trust_root_pubkey, _) = XOnlyPublicKey::from_keypair(&keypair); + + let payload_digest = Sha256::digest(payload_json.as_bytes()); + let message = SecpMessage::from_digest_slice(&payload_digest).expect("message digest"); + let signature = secp.sign_schnorr_no_aux_rand(&message, &keypair); + let artifact = AdmissionOverrideArtifact { + payload_json, + signature_hex: signature.to_string(), + }; + (trust_root_pubkey.to_string(), artifact) + } + + fn build_signed_override_artifact( + operator_id: &str, + decision: &str, + approved_at_unix: u64, + expires_at_unix: u64, + ) -> (String, AdmissionOverrideArtifact) { + let payload_json = serde_json::json!({ + "override_id": format!( + "override:{}:{}:{}", + trimmed_lowercase(operator_id), + approved_at_unix, + expires_at_unix + ), + "operator_id": operator_id, + "decision": decision, + "reason": "manual governance approval", + "approved_by": "dao-multisig-1", + "approved_at_unix": approved_at_unix, + "expires_at_unix": expires_at_unix, + }) + .to_string(); + sign_override_payload(payload_json) + } + + #[test] + fn evaluate_admission_allows_compliant_candidate() { + let policy = baseline_policy(); + let candidate = baseline_candidate(); + let existing = baseline_existing(); + + let decision = evaluate_admission(&policy, &candidate, &existing, 1_700_000_000); + assert_eq!(decision.decision, "allow"); + assert!(decision.reasons.is_empty()); + } + + #[test] + fn evaluate_admission_rejects_provider_diversity_violation() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "aws".to_string(); + let existing = baseline_existing(); + + let decision = evaluate_admission(&policy, &candidate, &existing, 1_700_000_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "provider_diversity_violation")); + } + + #[test] + fn evaluate_admission_rejects_provider_diversity_violation_case_insensitive() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "AWS".to_string(); + let existing = baseline_existing(); + + let decision = evaluate_admission(&policy, &candidate, &existing, 1_700_000_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "provider_diversity_violation")); + } + + #[test] + fn evaluate_admission_rejects_region_diversity_violation_case_insensitive() { + let mut policy = baseline_policy(); + policy.max_operators_per_region = Some(1); + let mut candidate = baseline_candidate(); + candidate.region = "US-EAST-1".to_string(); + let mut existing = baseline_existing(); + existing.push(ExistingOperator { + operator_id: "operator-99".to_string(), + provider: "azure".to_string(), + region: "us-east-1".to_string(), + }); + + let decision = evaluate_admission(&policy, &candidate, &existing, 1_700_000_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "geo_diversity_violation")); + } + + #[test] + fn evaluate_admission_rejects_duplicate_operator_id_case_insensitive() { + let policy = baseline_policy(); + let mut candidate = baseline_candidate(); + candidate.operator_id = "Operator-1".to_string(); + let existing = baseline_existing(); + + let decision = evaluate_admission(&policy, &candidate, &existing, 1_700_000_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "operator_id_already_registered")); + } + + #[test] + fn evaluate_admission_rejects_missing_contact_and_bad_attestation() { + let policy = baseline_policy(); + let mut candidate = baseline_candidate(); + candidate.incident_response_contact = None; + candidate.attestation_status = "pending".to_string(); + let existing = baseline_existing(); + + let decision = evaluate_admission(&policy, &candidate, &existing, 1_700_000_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "incident_response_contact_missing")); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "attestation_status_not_approved")); + } + + #[test] + fn evaluate_admission_rejects_expired_patch_sla() { + let policy = baseline_policy(); + let mut candidate = baseline_candidate(); + candidate.patch_sla_expires_at_unix = 1_700_000_000; + let existing = baseline_existing(); + + let decision = evaluate_admission(&policy, &candidate, &existing, 1_700_000_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "patch_sla_below_minimum_remaining")); + } + + #[test] + fn apply_dao_override_allows_rejected_candidate_when_signature_is_valid() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "aws".to_string(); + let existing = baseline_existing(); + let now_unix_seconds = 1_700_000_000u64; + + let (trust_root_pubkey_hex, override_artifact) = build_signed_override_artifact( + &candidate.operator_id, + "allow", + now_unix_seconds.saturating_sub(60), + now_unix_seconds.saturating_add(600), + ); + policy.dao_override_trust_root_pubkey_hex = Some(trust_root_pubkey_hex); + policy.dao_override_max_ttl_seconds = Some(3600); + + let base_decision = evaluate_admission(&policy, &candidate, &existing, now_unix_seconds); + assert_eq!(base_decision.decision, "reject"); + assert!(base_decision + .reasons + .iter() + .any(|reason| reason.code == "provider_diversity_violation")); + let mut replay_registry = OverrideReplayRegistry::default(); + + let override_decision = apply_dao_override( + &policy, + &candidate, + now_unix_seconds, + base_decision, + Some(&override_artifact), + Some(&mut replay_registry), + ); + assert_eq!(override_decision.decision, "allow"); + assert!(override_decision.override_applied); + assert!(override_decision.override_reference.is_some()); + assert!(override_decision + .reasons + .iter() + .any(|reason| reason.code == "dao_override_applied")); + } + + #[test] + fn apply_dao_override_rejects_invalid_signature() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "aws".to_string(); + let existing = baseline_existing(); + let now_unix_seconds = 1_700_000_000u64; + + let (trust_root_pubkey_hex, mut override_artifact) = build_signed_override_artifact( + &candidate.operator_id, + "allow", + now_unix_seconds.saturating_sub(60), + now_unix_seconds.saturating_add(600), + ); + override_artifact.signature_hex = "00".repeat(64); + policy.dao_override_trust_root_pubkey_hex = Some(trust_root_pubkey_hex); + + let base_decision = evaluate_admission(&policy, &candidate, &existing, now_unix_seconds); + let mut replay_registry = OverrideReplayRegistry::default(); + let override_decision = apply_dao_override( + &policy, + &candidate, + now_unix_seconds, + base_decision, + Some(&override_artifact), + Some(&mut replay_registry), + ); + assert_eq!(override_decision.decision, "reject"); + assert!(!override_decision.override_applied); + assert!(override_decision + .reasons + .iter() + .any(|reason| reason.code == "dao_override_invalid_signature")); + } + + #[test] + fn apply_dao_override_rejects_candidate_mismatch() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "aws".to_string(); + let existing = baseline_existing(); + let now_unix_seconds = 1_700_000_000u64; + + let (trust_root_pubkey_hex, override_artifact) = build_signed_override_artifact( + "different-operator", + "allow", + now_unix_seconds.saturating_sub(60), + now_unix_seconds.saturating_add(600), + ); + policy.dao_override_trust_root_pubkey_hex = Some(trust_root_pubkey_hex); + + let base_decision = evaluate_admission(&policy, &candidate, &existing, now_unix_seconds); + let mut replay_registry = OverrideReplayRegistry::default(); + let override_decision = apply_dao_override( + &policy, + &candidate, + now_unix_seconds, + base_decision, + Some(&override_artifact), + Some(&mut replay_registry), + ); + assert_eq!(override_decision.decision, "reject"); + assert!(!override_decision.override_applied); + assert!(override_decision + .reasons + .iter() + .any(|reason| reason.code == "dao_override_candidate_mismatch")); + } + + #[test] + fn apply_dao_override_rejects_expired_artifact() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "aws".to_string(); + let existing = baseline_existing(); + let now_unix_seconds = 1_700_000_000u64; + + let (trust_root_pubkey_hex, override_artifact) = build_signed_override_artifact( + &candidate.operator_id, + "allow", + now_unix_seconds.saturating_sub(3600), + now_unix_seconds.saturating_sub(60), + ); + policy.dao_override_trust_root_pubkey_hex = Some(trust_root_pubkey_hex); + + let base_decision = evaluate_admission(&policy, &candidate, &existing, now_unix_seconds); + let mut replay_registry = OverrideReplayRegistry::default(); + let override_decision = apply_dao_override( + &policy, + &candidate, + now_unix_seconds, + base_decision, + Some(&override_artifact), + Some(&mut replay_registry), + ); + assert_eq!(override_decision.decision, "reject"); + assert!(!override_decision.override_applied); + assert!(override_decision + .reasons + .iter() + .any(|reason| reason.code == "dao_override_expired")); + } + + #[test] + fn apply_dao_override_rejects_ttl_exceeding_policy() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "aws".to_string(); + let existing = baseline_existing(); + let now_unix_seconds = 1_700_000_000u64; + + let (trust_root_pubkey_hex, override_artifact) = build_signed_override_artifact( + &candidate.operator_id, + "allow", + now_unix_seconds.saturating_sub(60), + now_unix_seconds.saturating_add(86_400 * 30), + ); + policy.dao_override_trust_root_pubkey_hex = Some(trust_root_pubkey_hex); + policy.dao_override_max_ttl_seconds = Some(3600); + + let base_decision = evaluate_admission(&policy, &candidate, &existing, now_unix_seconds); + let mut replay_registry = OverrideReplayRegistry::default(); + let override_decision = apply_dao_override( + &policy, + &candidate, + now_unix_seconds, + base_decision, + Some(&override_artifact), + Some(&mut replay_registry), + ); + assert_eq!(override_decision.decision, "reject"); + assert!(!override_decision.override_applied); + assert!(override_decision + .reasons + .iter() + .any(|reason| reason.code == "dao_override_ttl_exceeds_policy")); + } + + #[test] + fn apply_dao_override_rejects_not_yet_valid_artifact() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "aws".to_string(); + let existing = baseline_existing(); + let now_unix_seconds = 1_700_000_000u64; + + let (trust_root_pubkey_hex, override_artifact) = build_signed_override_artifact( + &candidate.operator_id, + "allow", + now_unix_seconds.saturating_add(3600), + now_unix_seconds.saturating_add(7200), + ); + policy.dao_override_trust_root_pubkey_hex = Some(trust_root_pubkey_hex); + + let base_decision = evaluate_admission(&policy, &candidate, &existing, now_unix_seconds); + let mut replay_registry = OverrideReplayRegistry::default(); + let override_decision = apply_dao_override( + &policy, + &candidate, + now_unix_seconds, + base_decision, + Some(&override_artifact), + Some(&mut replay_registry), + ); + assert_eq!(override_decision.decision, "reject"); + assert!(!override_decision.override_applied); + assert!(override_decision + .reasons + .iter() + .any(|reason| reason.code == "dao_override_not_yet_valid")); + } + + #[test] + fn apply_dao_override_rejects_when_policy_trust_root_not_configured() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "aws".to_string(); + let existing = baseline_existing(); + let now_unix_seconds = 1_700_000_000u64; + + let (_, override_artifact) = build_signed_override_artifact( + &candidate.operator_id, + "allow", + now_unix_seconds.saturating_sub(60), + now_unix_seconds.saturating_add(600), + ); + + let base_decision = evaluate_admission(&policy, &candidate, &existing, now_unix_seconds); + let mut replay_registry = OverrideReplayRegistry::default(); + let override_decision = apply_dao_override( + &policy, + &candidate, + now_unix_seconds, + base_decision, + Some(&override_artifact), + Some(&mut replay_registry), + ); + assert_eq!(override_decision.decision, "reject"); + assert!(!override_decision.override_applied); + assert!(override_decision + .reasons + .iter() + .any(|reason| reason.code == "dao_override_policy_not_configured")); + } + + #[test] + fn apply_dao_override_rejects_non_allow_decision() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "aws".to_string(); + let existing = baseline_existing(); + let now_unix_seconds = 1_700_000_000u64; + + let (trust_root_pubkey_hex, override_artifact) = build_signed_override_artifact( + &candidate.operator_id, + "deny", + now_unix_seconds.saturating_sub(60), + now_unix_seconds.saturating_add(600), + ); + policy.dao_override_trust_root_pubkey_hex = Some(trust_root_pubkey_hex); + + let base_decision = evaluate_admission(&policy, &candidate, &existing, now_unix_seconds); + let mut replay_registry = OverrideReplayRegistry::default(); + let override_decision = apply_dao_override( + &policy, + &candidate, + now_unix_seconds, + base_decision, + Some(&override_artifact), + Some(&mut replay_registry), + ); + assert_eq!(override_decision.decision, "reject"); + assert!(!override_decision.override_applied); + assert!(override_decision + .reasons + .iter() + .any(|reason| reason.code == "dao_override_decision_not_allow")); + } + + #[test] + fn apply_dao_override_rejects_when_replay_registry_not_configured() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "aws".to_string(); + let existing = baseline_existing(); + let now_unix_seconds = 1_700_000_000u64; + + let (trust_root_pubkey_hex, override_artifact) = build_signed_override_artifact( + &candidate.operator_id, + "allow", + now_unix_seconds.saturating_sub(60), + now_unix_seconds.saturating_add(600), + ); + policy.dao_override_trust_root_pubkey_hex = Some(trust_root_pubkey_hex); + + let base_decision = evaluate_admission(&policy, &candidate, &existing, now_unix_seconds); + let override_decision = apply_dao_override( + &policy, + &candidate, + now_unix_seconds, + base_decision, + Some(&override_artifact), + None, + ); + assert_eq!(override_decision.decision, "reject"); + assert!(!override_decision.override_applied); + assert!(override_decision + .reasons + .iter() + .any(|reason| reason.code == "dao_override_replay_registry_not_configured")); + } + + #[test] + fn apply_dao_override_rejects_replayed_override_id() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "aws".to_string(); + let existing = baseline_existing(); + let now_unix_seconds = 1_700_000_000u64; + + let (trust_root_pubkey_hex, override_artifact) = build_signed_override_artifact( + &candidate.operator_id, + "allow", + now_unix_seconds.saturating_sub(60), + now_unix_seconds.saturating_add(600), + ); + policy.dao_override_trust_root_pubkey_hex = Some(trust_root_pubkey_hex); + let mut replay_registry = OverrideReplayRegistry::default(); + + let base_decision = evaluate_admission(&policy, &candidate, &existing, now_unix_seconds); + let first_decision = apply_dao_override( + &policy, + &candidate, + now_unix_seconds, + base_decision, + Some(&override_artifact), + Some(&mut replay_registry), + ); + assert_eq!(first_decision.decision, "allow"); + assert!(first_decision.override_applied); + + let second_base_decision = + evaluate_admission(&policy, &candidate, &existing, now_unix_seconds); + let second_decision = apply_dao_override( + &policy, + &candidate, + now_unix_seconds, + second_base_decision, + Some(&override_artifact), + Some(&mut replay_registry), + ); + assert_eq!(second_decision.decision, "reject"); + assert!(!second_decision.override_applied); + assert!(second_decision + .reasons + .iter() + .any(|reason| reason.code == "dao_override_replay_detected")); + } + + #[test] + fn apply_dao_override_rejects_missing_override_id() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "aws".to_string(); + let existing = baseline_existing(); + let now_unix_seconds = 1_700_000_000u64; + + let (trust_root_pubkey_hex, override_artifact) = build_signed_override_artifact( + &candidate.operator_id, + "allow", + now_unix_seconds.saturating_sub(60), + now_unix_seconds.saturating_add(600), + ); + policy.dao_override_trust_root_pubkey_hex = Some(trust_root_pubkey_hex); + + let mut override_payload: serde_json::Value = + serde_json::from_str(&override_artifact.payload_json).expect("override payload json"); + override_payload["override_id"] = serde_json::json!(""); + let (_, missing_id_artifact) = sign_override_payload(override_payload.to_string()); + + let base_decision = evaluate_admission(&policy, &candidate, &existing, now_unix_seconds); + let mut replay_registry = OverrideReplayRegistry::default(); + let override_decision = apply_dao_override( + &policy, + &candidate, + now_unix_seconds, + base_decision, + Some(&missing_id_artifact), + Some(&mut replay_registry), + ); + assert_eq!(override_decision.decision, "reject"); + assert!(!override_decision.override_applied); + assert!(override_decision + .reasons + .iter() + .any(|reason| reason.code == "dao_override_id_missing")); + } + + #[test] + fn apply_dao_override_allows_new_override_after_previous_override_expires() { + let mut policy = baseline_policy(); + policy.max_operators_per_provider = Some(1); + let mut candidate = baseline_candidate(); + candidate.provider = "aws".to_string(); + let existing = baseline_existing(); + + let now_first = 1_700_000_000u64; + let (trust_root_pubkey_hex, first_artifact) = build_signed_override_artifact( + &candidate.operator_id, + "allow", + now_first.saturating_sub(60), + now_first.saturating_add(600), + ); + policy.dao_override_trust_root_pubkey_hex = Some(trust_root_pubkey_hex); + policy.dao_override_max_ttl_seconds = Some(86_400); + let mut replay_registry = OverrideReplayRegistry::default(); + + let base_decision = evaluate_admission(&policy, &candidate, &existing, now_first); + let first_decision = apply_dao_override( + &policy, + &candidate, + now_first, + base_decision, + Some(&first_artifact), + Some(&mut replay_registry), + ); + assert_eq!(first_decision.decision, "allow"); + assert!(first_decision.override_applied); + + let now_second = now_first.saturating_add(3_600); + let (_, second_artifact) = build_signed_override_artifact( + &candidate.operator_id, + "allow", + now_second.saturating_sub(60), + now_second.saturating_add(600), + ); + + let second_base_decision = evaluate_admission(&policy, &candidate, &existing, now_second); + let second_decision = apply_dao_override( + &policy, + &candidate, + now_second, + second_base_decision, + Some(&second_artifact), + Some(&mut replay_registry), + ); + assert_eq!(second_decision.decision, "allow"); + assert!(second_decision.override_applied); + } + + #[test] + fn override_replay_registry_persists_and_reloads() { + let tmp_dir = std::env::temp_dir().join(format!( + "admission-override-registry-test-{}-{}", + std::process::id(), + now_unix() + )); + fs::create_dir_all(&tmp_dir).expect("create tmp dir"); + let registry_path = tmp_dir.join("override-registry.json"); + + let mut registry = OverrideReplayRegistry::default(); + registry.insert( + "test-override-id-1".to_string(), + "operator-1".to_string(), + "dao-approver-1".to_string(), + 1_700_000_000, + 1_700_003_600, + 1_700_000_100, + ); + + persist_override_replay_registry(®istry_path, ®istry).expect("persist registry"); + let reloaded = load_override_replay_registry(®istry_path).expect("load registry"); + + assert!(reloaded.lookup("test-override-id-1").is_some()); + assert!(reloaded.lookup("non-existent-override-id").is_none()); + let record = reloaded + .lookup("test-override-id-1") + .expect("reloaded override record"); + assert_eq!(record.operator_id, "operator-1"); + assert_eq!(record.consumed_at_unix, 1_700_000_100); + + let _ = fs::remove_dir_all(tmp_dir); + } + + #[test] + fn parse_args_accepts_required_flags() { + let args = vec![ + "--policy".to_string(), + "policy.json".to_string(), + "--candidate".to_string(), + "candidate.json".to_string(), + ]; + + let parsed = parse_args(&args).expect("parse args"); + assert_eq!(parsed.policy_path, PathBuf::from("policy.json")); + assert_eq!(parsed.candidate_path, PathBuf::from("candidate.json")); + assert!(parsed.existing_path.is_none()); + } + + #[test] + fn parse_args_accepts_override_flag() { + let args = vec![ + "--policy".to_string(), + "policy.json".to_string(), + "--candidate".to_string(), + "candidate.json".to_string(), + "--override".to_string(), + "override.json".to_string(), + "--override-registry".to_string(), + "override-registry.json".to_string(), + "--now-unix".to_string(), + "1700000000".to_string(), + ]; + + let parsed = parse_args(&args).expect("parse args"); + assert_eq!(parsed.override_path, Some(PathBuf::from("override.json"))); + assert_eq!( + parsed.override_registry_path, + Some(PathBuf::from("override-registry.json")) + ); + assert_eq!(parsed.now_unix_override, Some(1_700_000_000)); + } + + #[test] + fn parse_args_accepts_override_registry_flag() { + let args = vec![ + "--policy".to_string(), + "policy.json".to_string(), + "--candidate".to_string(), + "candidate.json".to_string(), + "--override-registry".to_string(), + "override-registry.json".to_string(), + ]; + + let parsed = parse_args(&args).expect("parse args"); + assert_eq!( + parsed.override_registry_path, + Some(PathBuf::from("override-registry.json")) + ); + } + + #[test] + fn parse_args_rejects_override_without_override_registry() { + let args = vec![ + "--policy".to_string(), + "policy.json".to_string(), + "--candidate".to_string(), + "candidate.json".to_string(), + "--override".to_string(), + "override.json".to_string(), + ]; + + let error = parse_args(&args).expect_err("expected parse failure"); + assert_eq!( + error, + "--override requires --override-registry for replay protection" + ); + } +} diff --git a/pkg/tbtc/signer/src/engine.rs b/pkg/tbtc/signer/src/engine.rs new file mode 100644 index 0000000000..e03aad2f27 --- /dev/null +++ b/pkg/tbtc/signer/src/engine.rs @@ -0,0 +1,17517 @@ +use bitcoin::{ + absolute::LockTime, + consensus::encode::{deserialize, serialize_hex}, + secp256k1::{ + schnorr::Signature as SchnorrSignature, Message as SecpMessage, Secp256k1, XOnlyPublicKey, + }, + transaction::Version, + Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, +}; +use chacha20poly1305::aead::{Aead, KeyInit, OsRng, Payload}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +#[cfg(unix)] +use libc::{flock, EAGAIN, EWOULDBLOCK, LOCK_EX, LOCK_NB}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::fs; +use std::io::{Read, Write}; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +#[cfg(unix)] +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Output, Stdio}; +use std::str::FromStr; +use std::sync::{mpsc, Mutex, OnceLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use frost_secp256k1_tr::{ + self as frost, + keys::{EvenY, Tweak}, +}; +use rand_chacha::rand_core::{CryptoRng, Error as RandCoreError, RngCore, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zeroize::{Zeroize, Zeroizing}; + +use crate::api::{ + AggregateRequest, AggregateResult, AttemptContext, AttemptExclusionEvidence, + AttemptTransitionEvidence, AttemptTransitionTelemetry, BlameProofVerificationResult, + BuildTaprootTxRequest, CanaryRolloutStatusResult, DifferentialDivergence, + DifferentialFuzzRequest, DifferentialFuzzResult, DkgPart1Request, DkgPart1Result, + DkgPart2Request, DkgPart2Result, DkgPart3Request, DkgPart3Result, DkgResult, DkgRound1Package, + DkgRound2Package, FinalizeSignRoundRequest, GenerateNoncesAndCommitmentsRequest, + GenerateNoncesAndCommitmentsResult, NativeFrostCommitment, NativeFrostKeyPackage, + NativeFrostPublicKeyPackage, NativeFrostSignatureShare, NewSigningPackageRequest, + NewSigningPackageResult, PromoteCanaryRequest, PromoteCanaryResult, QuarantineStatusRequest, + QuarantineStatusResult, RefreshCadenceStatusRequest, RefreshCadenceStatusResult, + RefreshSharesRequest, RefreshSharesResult, RoastLivenessPolicyResult, RollbackCanaryRequest, + RollbackCanaryResult, RoundContribution, RoundState, RunDkgRequest, ShareMaterial, + SignShareRequest, SignShareResult, SignatureResult, SignerHardeningMetricsResult, + StartSignRoundRequest, TransactionResult, TranscriptAuditRecord, TranscriptAuditRequest, + TranscriptAuditResult, TriggerEmergencyRekeyRequest, TriggerEmergencyRekeyResult, + VerifyBlameProofRequest, +}; +use crate::errors::EngineError; +use crate::go_math_rand::select_coordinator_identifier; + +type SecretString = Zeroizing; +type SecretBytes = Zeroizing>; + +struct ZeroizingChaCha20Rng { + inner: ChaCha20Rng, +} + +impl ZeroizingChaCha20Rng { + fn from_seed(seed: [u8; 32]) -> Self { + Self { + inner: ChaCha20Rng::from_seed(seed), + } + } +} + +impl RngCore for ZeroizingChaCha20Rng { + fn next_u32(&mut self) -> u32 { + self.inner.next_u32() + } + + fn next_u64(&mut self) -> u64 { + self.inner.next_u64() + } + + fn fill_bytes(&mut self, dest: &mut [u8]) { + self.inner.fill_bytes(dest) + } + + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), RandCoreError> { + self.inner.try_fill_bytes(dest) + } +} + +impl CryptoRng for ZeroizingChaCha20Rng {} + +impl Drop for ZeroizingChaCha20Rng { + fn drop(&mut self) { + // ChaCha20Rng does not expose a zeroizing Drop. Wipe its in-memory + // state once the cryptographic operation consuming it has returned. + unsafe { + let rng_bytes = std::slice::from_raw_parts_mut( + (&mut self.inner as *mut ChaCha20Rng).cast::(), + std::mem::size_of::(), + ); + rng_bytes.zeroize(); + } + } +} + +#[derive(Default)] +struct SessionState { + dkg_request_fingerprint: Option, + dkg_key_packages: Option>, + dkg_public_key_package: Option, + dkg_result: Option, + sign_request_fingerprint: Option, + sign_message_bytes: Option, + round_state: Option, + active_attempt_context: Option, + attempt_transition_records: Vec, + consumed_attempt_ids: HashSet, + consumed_sign_round_ids: HashSet, + finalize_request_fingerprint: Option, + signature_result: Option, + consumed_finalize_round_ids: HashSet, + consumed_finalize_request_fingerprints: HashSet, + build_tx_request_fingerprint: Option, + tx_result: Option, + refresh_request_fingerprint: Option, + refresh_result: Option, + refresh_history: Vec, + emergency_rekey_event: Option, +} + +#[derive(Default)] +struct EngineState { + sessions: HashMap, + refresh_epoch_counter: u64, + operator_fault_scores: BTreeMap, + quarantined_operator_identifiers: HashSet, + canary_rollout: CanaryRolloutState, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct RefreshHistoryRecord { + refresh_epoch: u64, + refreshed_at_unix: u64, + share_count: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + key_group: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct EmergencyRekeyEvent { + reason: String, + triggered_at_unix: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct CanaryRolloutState { + current_percent: u8, + previous_percent: u8, + config_version: u64, + last_action_unix: u64, +} + +impl Default for CanaryRolloutState { + fn default() -> Self { + Self { + current_percent: 10, + previous_percent: 10, + config_version: 1, + last_action_unix: now_unix(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct PersistedKeyPackage { + identifier: u16, + key_package_hex: SecretString, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct PersistedSessionState { + dkg_request_fingerprint: Option, + dkg_key_packages: Option>, + dkg_public_key_package_hex: Option, + dkg_result: Option, + sign_request_fingerprint: Option, + sign_message_hex: Option, + round_state: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + active_attempt_context: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + attempt_transition_records: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + consumed_attempt_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + consumed_sign_round_ids: Vec, + finalize_request_fingerprint: Option, + signature_result: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + consumed_finalize_round_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + consumed_finalize_request_fingerprints: Vec, + build_tx_request_fingerprint: Option, + tx_result: Option, + refresh_request_fingerprint: Option, + refresh_result: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + refresh_history: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + emergency_rekey_event: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct PersistedEngineState { + schema_version: u16, + sessions: HashMap, + refresh_epoch_counter: u64, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + operator_fault_scores: BTreeMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + quarantined_operator_identifiers: Vec, + #[serde(default)] + canary_rollout: CanaryRolloutState, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct PersistedEncryptedEngineStateEnvelope { + schema_version: u16, + encryption_algorithm: String, + key_provider: String, + key_id: String, + nonce: String, + ciphertext: String, + authentication_tag: String, +} + +enum PersistedStateStorageFormat { + EncryptedEnvelope { + persisted: PersistedEngineState, + should_rewrite: bool, + }, + LegacyPlaintext(PersistedEngineState), +} + +struct StateEncryptionKeyMaterial { + key: Zeroizing<[u8; 32]>, + key_provider: &'static str, + key_id: String, +} + +const PERSISTED_STATE_SCHEMA_VERSION: u16 = 1; +const PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION_V2: u16 = 2; +const PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION: u16 = 3; +const TBTC_SIGNER_STATE_ENCRYPTION_ALGORITHM_XCHACHA20POLY1305: &str = "xchacha20poly1305"; +const TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT: &str = "env"; +const TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND: &str = "command"; +// Env-var selector for key provider implementation (`env` or `command`). +const TBTC_SIGNER_STATE_KEY_PROVIDER_ENV: &str = "TBTC_SIGNER_STATE_KEY_PROVIDER"; +const TBTC_SIGNER_STATE_KEY_ID_LEGACY_ENV_HEX: &str = "TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX"; +const TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV: &str = "TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX"; +const TBTC_SIGNER_STATE_KEY_COMMAND_ENV: &str = "TBTC_SIGNER_STATE_KEY_COMMAND"; +const TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV: &str = + "TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS"; +const TBTC_SIGNER_DEFAULT_STATE_KEY_COMMAND_TIMEOUT_SECS: u64 = 30; +const TBTC_SIGNER_MIN_STATE_KEY_COMMAND_TIMEOUT_SECS: u64 = 1; +const TBTC_SIGNER_MAX_STATE_KEY_COMMAND_TIMEOUT_SECS: u64 = 300; +const TBTC_SIGNER_PROFILE_ENV: &str = "TBTC_SIGNER_PROFILE"; +const TBTC_SIGNER_PROFILE_PRODUCTION: &str = "production"; +const TBTC_SIGNER_PROFILE_DEVELOPMENT: &str = "development"; +const TBTC_SIGNER_STATE_ENVELOPE_NONCE_BYTES: usize = 24; +const TBTC_SIGNER_STATE_ENVELOPE_AUTH_TAG_BYTES: usize = 16; +#[cfg(test)] +const TEST_STATE_ENCRYPTION_KEY_HEX: &str = + "1111111111111111111111111111111111111111111111111111111111111111"; +const TBTC_SIGNER_STATE_PATH_ENV: &str = "TBTC_SIGNER_STATE_PATH"; +const TBTC_SIGNER_DEFAULT_STATE_FILENAME: &str = "frost_tbtc_engine_state.json"; +const TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV: &str = "TBTC_SIGNER_STATE_CORRUPTION_POLICY"; +const TBTC_SIGNER_STATE_CORRUPTION_POLICY_QUARANTINE_AND_RESET: &str = "quarantine_and_reset"; +const TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV: &str = "TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT"; +const TBTC_SIGNER_DEFAULT_CORRUPT_BACKUP_LIMIT: usize = 5; +const TBTC_SIGNER_MAX_SESSIONS_ENV: &str = "TBTC_SIGNER_MAX_SESSIONS"; +const TBTC_SIGNER_DEFAULT_MAX_SESSIONS: usize = 1024; +const TBTC_SIGNER_STATE_LOCKFILE_SUFFIX: &str = ".lock"; +const TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV: &str = "TBTC_SIGNER_ENABLE_ROAST_STRICT"; +#[cfg(any(test, feature = "bench-restart-hook"))] +const TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK_ENV: &str = "TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK"; +const TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV: &str = + "TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS"; +const TBTC_SIGNER_DEFAULT_ROAST_COORDINATOR_TIMEOUT_MS: u64 = 30_000; +const TBTC_SIGNER_MIN_ROAST_COORDINATOR_TIMEOUT_MS: u64 = 1_000; +const TBTC_SIGNER_MAX_ROAST_COORDINATOR_TIMEOUT_MS: u64 = 300_000; +const TBTC_SIGNER_RUNTIME_VERSION: &str = env!("CARGO_PKG_VERSION"); +const TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV: &str = "TBTC_SIGNER_ENFORCE_PROVENANCE_GATE"; +const TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV: &str = + "TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS"; +const TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV: &str = + "TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD"; +const TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV: &str = + "TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX"; +const TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV: &str = "TBTC_SIGNER_PROVENANCE_TRUST_ROOT"; +const TBTC_SIGNER_MIN_APPROVED_VERSION_ENV: &str = "TBTC_SIGNER_MIN_APPROVED_VERSION"; +const TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED: &str = "approved"; +const TBTC_SIGNER_PROVENANCE_MAX_ATTESTATION_TTL_SECONDS: u64 = 7 * 24 * 3600; +const TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV: &str = "TBTC_SIGNER_ENFORCE_ADMISSION_POLICY"; +const TBTC_SIGNER_ADMISSION_MIN_PARTICIPANTS_ENV: &str = "TBTC_SIGNER_ADMISSION_MIN_PARTICIPANTS"; +const TBTC_SIGNER_ADMISSION_MIN_THRESHOLD_ENV: &str = "TBTC_SIGNER_ADMISSION_MIN_THRESHOLD"; +const TBTC_SIGNER_ADMISSION_REQUIRED_IDENTIFIERS_ENV: &str = + "TBTC_SIGNER_ADMISSION_REQUIRED_IDENTIFIERS"; +const TBTC_SIGNER_ADMISSION_ALLOWLIST_IDENTIFIERS_ENV: &str = + "TBTC_SIGNER_ADMISSION_ALLOWLIST_IDENTIFIERS"; +const TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV: &str = + "TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL"; +const TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV: &str = + "TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES"; +const TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT_ENV: &str = "TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT"; +const TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS_ENV: &str = + "TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS"; +const TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS_ENV: &str = + "TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS"; +const TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR_ENV: &str = + "TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR"; +const TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR_ENV: &str = "TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR"; +const TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE_ENV: &str = + "TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE"; +const TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV: &str = "TBTC_SIGNER_ENABLE_AUTO_QUARANTINE"; +const TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV: &str = + "TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD"; +const TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV: &str = + "TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY"; +const TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV: &str = + "TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY"; +const TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS_ENV: &str = + "TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS"; +const TBTC_SIGNER_DEFAULT_AUTO_QUARANTINE_FAULT_THRESHOLD: u64 = 3; +const TBTC_SIGNER_DEFAULT_AUTO_QUARANTINE_TIMEOUT_PENALTY: u64 = 1; +const TBTC_SIGNER_DEFAULT_AUTO_QUARANTINE_INVALID_SHARE_PENALTY: u64 = 2; +const TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV: &str = "TBTC_SIGNER_REFRESH_CADENCE_SECONDS"; +const TBTC_SIGNER_DEFAULT_REFRESH_CADENCE_SECONDS: u64 = 24 * 60 * 60; +const TBTC_SIGNER_MIN_REFRESH_CADENCE_SECONDS: u64 = 60; +const TBTC_SIGNER_MAX_REFRESH_CADENCE_SECONDS: u64 = 30 * 24 * 60 * 60; +const TBTC_SIGNER_DIFFERENTIAL_FUZZ_MAX_CASES: u32 = 512; +const TBTC_SIGNER_DIFFERENTIAL_FUZZ_DEFAULT_CASES: u32 = 64; +const TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS_ENV: &str = + "TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS"; +const TBTC_SIGNER_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS_ENV: &str = + "TBTC_SIGNER_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS"; +const TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS_ENV: &str = + "TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS"; +const TBTC_SIGNER_DEFAULT_CANARY_MAX_START_SIGN_ROUND_P95_MS: u64 = 5_000; +const TBTC_SIGNER_DEFAULT_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS: u64 = 5_000; +const TBTC_SIGNER_DEFAULT_CANARY_MAX_POLICY_REJECT_RATE_BPS: u64 = 1_000; +const TBTC_SIGNER_MAX_POLICY_REJECT_RATE_BPS: u64 = 10_000; +const BITCOIN_MAX_MONEY_SATS: u64 = 2_100_000_000_000_000; +const TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION: usize = 128; +const TBTC_SIGNER_MAX_ATTEMPT_TRANSITION_RECORDS_PER_SESSION: usize = 256; + +static ENGINE_STATE: OnceLock> = OnceLock::new(); +static STATE_FILE_LOCK: OnceLock>> = OnceLock::new(); +static STATE_PATH_OVERRIDE_WARNED: OnceLock<()> = OnceLock::new(); +static POLICY_GATE_WARNING_EMITTED: OnceLock<()> = OnceLock::new(); +static HARDENING_TELEMETRY: OnceLock> = OnceLock::new(); +static BUILD_TX_RATE_LIMITER: OnceLock> = OnceLock::new(); +#[cfg(test)] +static PERSIST_FAULT_INJECTION_POINT: OnceLock>> = + OnceLock::new(); +const BOOTSTRAP_SYNTHETIC_CONTRIBUTION_DOMAIN: &str = "tbtc-signer-bootstrap-contribution-v1"; +const ROAST_INCLUDED_PARTICIPANTS_FINGERPRINT_DOMAIN: &str = "FROST-ROAST-INCLUDED-FPR-v1"; +const ROAST_ATTEMPT_ID_DOMAIN: &str = "FROST-ROAST-ATTEMPT-ID-v1"; +const ROUND_ID_NO_ATTEMPT_CONTEXT_COMPONENT: &str = "none"; +const ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT: &str = "coordinator_timeout"; +const ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF: &str = "invalid_share_proof"; +const BUILD_TX_RATE_LIMIT_TOKEN_SCALE: u128 = 1_000_000; +const BUILD_TX_RATE_LIMIT_SECONDS_PER_MINUTE: u128 = 60; +const HARDENING_LATENCY_SAMPLE_WINDOW: usize = 256; + +enum CorruptStatePolicy { + FailClosed, + QuarantineAndReset, +} + +#[derive(Default)] +struct HardeningLatencyTracker { + samples_ms: VecDeque, +} + +impl HardeningLatencyTracker { + fn record(&mut self, duration_ms: u64) { + if self.samples_ms.len() >= HARDENING_LATENCY_SAMPLE_WINDOW { + self.samples_ms.pop_front(); + } + self.samples_ms.push_back(duration_ms); + } + + fn p95_ms(&self) -> u64 { + if self.samples_ms.is_empty() { + return 0; + } + + let mut sorted_samples = self.samples_ms.iter().copied().collect::>(); + sorted_samples.sort_unstable(); + let p95_index = (sorted_samples.len() * 95).div_ceil(100).saturating_sub(1); + sorted_samples[p95_index] + } + + fn sample_count(&self) -> u64 { + self.samples_ms.len() as u64 + } +} + +#[derive(Default)] +struct HardeningTelemetryState { + run_dkg_calls_total: u64, + run_dkg_success_total: u64, + run_dkg_admission_reject_total: u64, + start_sign_round_calls_total: u64, + start_sign_round_success_total: u64, + build_taproot_tx_calls_total: u64, + build_taproot_tx_success_total: u64, + build_taproot_tx_policy_reject_total: u64, + finalize_sign_round_calls_total: u64, + finalize_sign_round_success_total: u64, + refresh_shares_calls_total: u64, + refresh_shares_success_total: u64, + roast_transcript_audit_calls_total: u64, + roast_transcript_audit_success_total: u64, + verify_blame_proof_calls_total: u64, + verify_blame_proof_success_total: u64, + attempt_transition_total: u64, + coordinator_failover_total: u64, + auto_quarantine_fault_events_total: u64, + auto_quarantine_enforcements_total: u64, + differential_fuzz_runs_total: u64, + differential_fuzz_critical_divergence_total: u64, + canary_promotions_total: u64, + canary_rollbacks_total: u64, + run_dkg_latency: HardeningLatencyTracker, + start_sign_round_latency: HardeningLatencyTracker, + build_taproot_tx_latency: HardeningLatencyTracker, + finalize_sign_round_latency: HardeningLatencyTracker, + refresh_shares_latency: HardeningLatencyTracker, + last_updated_unix: u64, +} + +#[derive(Clone, Copy)] +enum HardeningOperation { + RunDkg, + StartSignRound, + BuildTaprootTx, + FinalizeSignRound, + RefreshShares, +} + +struct HardeningOperationLatencyGuard { + operation: HardeningOperation, + started_at: Instant, +} + +impl HardeningOperationLatencyGuard { + fn new(operation: HardeningOperation) -> Self { + Self { + operation, + started_at: Instant::now(), + } + } +} + +impl Drop for HardeningOperationLatencyGuard { + fn drop(&mut self) { + // Record latency with millisecond precision and ceil semantics so + // sub-millisecond calls still contribute non-zero samples. + let elapsed_micros = self.started_at.elapsed().as_micros(); + let elapsed_ms = elapsed_micros.div_ceil(1000).clamp(1, u64::MAX as u128) as u64; + record_hardening_operation_latency(self.operation, elapsed_ms); + } +} + +#[derive(Default)] +struct BuildTxRateLimiterState { + last_refill_unix: u64, + token_microunits: u128, + configured_rate_limit_per_minute: u64, +} + +#[derive(Clone, Debug)] +struct AdmissionPolicyConfig { + min_participants: usize, + min_threshold: u16, + required_identifiers: HashSet, + allowlist_identifiers: Option>, +} + +#[derive(Clone, Debug)] +struct SigningPolicyFirewallConfig { + allowed_script_classes: HashSet, + max_output_count: usize, + max_output_value_sats: u64, + max_total_output_value_sats: u64, + allowed_utc_start_hour: Option, + allowed_utc_end_hour: Option, + rate_limit_per_minute: u64, +} + +#[derive(Clone, Debug)] +struct AutoQuarantineConfig { + fault_threshold: u64, + timeout_penalty: u64, + invalid_share_penalty: u64, + dao_allowlist_identifiers: HashSet, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PersistFaultInjectionPoint { + AfterTempSyncBeforeRename, + AfterRenameBeforeDirectorySync, +} + +struct StateFileLock { + _file: fs::File, + state_path: PathBuf, + lock_path: PathBuf, +} + +impl StateFileLock { + fn acquire(state_path: &Path) -> Result { + let lock_path = state_lock_file_path(state_path); + if let Some(parent) = lock_path.parent() { + fs::create_dir_all(parent).map_err(|e| { + EngineError::Internal(format!( + "failed to create signer state lock directory [{}]: {e}", + parent.display() + )) + })?; + } + + let mut lock_file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .map_err(|e| { + EngineError::Internal(format!( + "failed to open signer state lock file [{}]: {e}", + lock_path.display() + )) + })?; + + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + + let rc = unsafe { flock(lock_file.as_raw_fd(), LOCK_EX | LOCK_NB) }; + if rc != 0 { + let lock_error = std::io::Error::last_os_error(); + if lock_error + .raw_os_error() + .is_some_and(is_lock_contention_errno) + { + return Err(EngineError::Internal(format!( + "signer state lock already held by another process [{}]", + lock_path.display() + ))); + } + + return Err(EngineError::Internal(format!( + "failed to lock signer state file [{}]: {lock_error}", + lock_path.display() + ))); + } + } + + lock_file.set_len(0).map_err(|e| { + EngineError::Internal(format!( + "failed to truncate signer state lock file [{}]: {e}", + lock_path.display() + )) + })?; + writeln!( + lock_file, + "pid={}\nstate_path={}", + std::process::id(), + state_path.display() + ) + .map_err(|e| { + EngineError::Internal(format!( + "failed to write signer state lock file [{}]: {e}", + lock_path.display() + )) + })?; + lock_file.sync_all().map_err(|e| { + EngineError::Internal(format!( + "failed to sync signer state lock file [{}]: {e}", + lock_path.display() + )) + })?; + + Ok(Self { + _file: lock_file, + state_path: state_path.to_path_buf(), + lock_path, + }) + } +} + +fn state_file_lock_slot() -> &'static Mutex> { + STATE_FILE_LOCK.get_or_init(|| Mutex::new(None)) +} + +#[cfg(unix)] +fn is_lock_contention_errno(errno: i32) -> bool { + errno == EAGAIN || errno == EWOULDBLOCK +} + +fn state() -> Result<&'static Mutex, EngineError> { + ensure_state_file_lock()?; + warn_disabled_policy_gates(); + + if let Some(state) = ENGINE_STATE.get() { + return Ok(state); + } + + let loaded_state = load_engine_state_from_storage()?; + Ok(ENGINE_STATE.get_or_init(|| Mutex::new(loaded_state))) +} + +fn state_file_path() -> Result { + let configured_path = std::env::var(TBTC_SIGNER_STATE_PATH_ENV) + .ok() + .map(|path| path.trim().to_string()) + .filter(|path| !path.is_empty()) + .map(PathBuf::from); + + if let Some(path) = configured_path { + STATE_PATH_OVERRIDE_WARNED.get_or_init(|| { + eprintln!( + "warning: {} override is set to [{}]; ensure this path is operator-restricted", + TBTC_SIGNER_STATE_PATH_ENV, + path.display() + ); + }); + return Ok(path); + } + + if signer_profile_is_production() { + return Err(EngineError::Internal(format!( + "{} must be set when {}={}; refusing to use the implicit temp-dir signer state path", + TBTC_SIGNER_STATE_PATH_ENV, TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION + ))); + } + + Ok(std::env::temp_dir().join(TBTC_SIGNER_DEFAULT_STATE_FILENAME)) +} + +fn active_state_file_path() -> Result { + let lock_slot = state_file_lock_slot() + .lock() + .map_err(|_| EngineError::Internal("state file lock mutex poisoned".to_string()))?; + + if let Some(lock) = lock_slot.as_ref() { + return Ok(lock.state_path.clone()); + } + + state_file_path() +} + +fn state_lock_file_path(state_path: &Path) -> PathBuf { + let state_filename = state_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| TBTC_SIGNER_DEFAULT_STATE_FILENAME.to_string()); + let lock_filename = format!("{state_filename}{TBTC_SIGNER_STATE_LOCKFILE_SUFFIX}"); + + if let Some(parent) = state_path.parent() { + parent.join(&lock_filename) + } else { + PathBuf::from(lock_filename) + } +} + +fn ensure_state_file_lock() -> Result<(), EngineError> { + let state_path = state_file_path()?; + let mut lock_slot = state_file_lock_slot() + .lock() + .map_err(|_| EngineError::Internal("state file lock mutex poisoned".to_string()))?; + + if let Some(existing_lock) = lock_slot.as_ref() { + if existing_lock.state_path == state_path { + return Ok(()); + } + + return Err(EngineError::Internal(format!( + "state file lock already initialized for [{}] with lock [{}]; refusing to switch to [{}] in-process", + existing_lock.state_path.display(), + existing_lock.lock_path.display(), + state_path.display() + ))); + } + + *lock_slot = Some(StateFileLock::acquire(&state_path)?); + Ok(()) +} + +fn state_corruption_policy() -> CorruptStatePolicy { + let policy = std::env::var(TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV) + .ok() + .map(|value| value.trim().to_ascii_lowercase()) + .unwrap_or_default(); + + if policy == TBTC_SIGNER_STATE_CORRUPTION_POLICY_QUARANTINE_AND_RESET { + CorruptStatePolicy::QuarantineAndReset + } else { + CorruptStatePolicy::FailClosed + } +} + +fn state_corrupt_backup_limit() -> usize { + std::env::var(TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(TBTC_SIGNER_DEFAULT_CORRUPT_BACKUP_LIMIT) +} + +fn max_sessions_limit() -> usize { + std::env::var(TBTC_SIGNER_MAX_SESSIONS_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|limit| *limit > 0) + .unwrap_or(TBTC_SIGNER_DEFAULT_MAX_SESSIONS) +} + +fn roast_coordinator_timeout_ms() -> u64 { + std::env::var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|timeout_ms| { + *timeout_ms >= TBTC_SIGNER_MIN_ROAST_COORDINATOR_TIMEOUT_MS + && *timeout_ms <= TBTC_SIGNER_MAX_ROAST_COORDINATOR_TIMEOUT_MS + }) + .unwrap_or(TBTC_SIGNER_DEFAULT_ROAST_COORDINATOR_TIMEOUT_MS) +} + +fn refresh_cadence_seconds() -> u64 { + std::env::var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| { + *value >= TBTC_SIGNER_MIN_REFRESH_CADENCE_SECONDS + && *value <= TBTC_SIGNER_MAX_REFRESH_CADENCE_SECONDS + }) + .unwrap_or(TBTC_SIGNER_DEFAULT_REFRESH_CADENCE_SECONDS) +} + +fn canary_max_start_sign_round_p95_ms() -> u64 { + std::env::var(TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(TBTC_SIGNER_DEFAULT_CANARY_MAX_START_SIGN_ROUND_P95_MS) +} + +fn canary_max_finalize_sign_round_p95_ms() -> u64 { + std::env::var(TBTC_SIGNER_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(TBTC_SIGNER_DEFAULT_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS) +} + +fn canary_max_policy_reject_rate_bps() -> u64 { + std::env::var(TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value <= TBTC_SIGNER_MAX_POLICY_REJECT_RATE_BPS) + .unwrap_or(TBTC_SIGNER_DEFAULT_CANARY_MAX_POLICY_REJECT_RATE_BPS) +} + +fn next_canary_percent(current_percent: u8) -> Option { + match current_percent { + 10 => Some(50), + 50 => Some(100), + _ => None, + } +} + +fn can_promote_to_target_percent(current_percent: u8, target_percent: u8) -> bool { + next_canary_percent(current_percent).is_some_and(|next| next == target_percent) +} + +pub fn roast_liveness_policy() -> RoastLivenessPolicyResult { + RoastLivenessPolicyResult { + coordinator_timeout_ms: roast_coordinator_timeout_ms(), + timeout_source: "keep_core_wall_clock".to_string(), + advance_trigger: "coordinator_timeout".to_string(), + exclusion_evidence_policy: "timeout_or_invalid_share_proof".to_string(), + } +} + +fn hardening_telemetry_state() -> &'static Mutex { + HARDENING_TELEMETRY.get_or_init(|| Mutex::new(HardeningTelemetryState::default())) +} + +fn build_tx_rate_limiter_state() -> &'static Mutex { + BUILD_TX_RATE_LIMITER.get_or_init(|| Mutex::new(BuildTxRateLimiterState::default())) +} + +fn record_hardening_telemetry(update: F) +where + F: FnOnce(&mut HardeningTelemetryState), +{ + match hardening_telemetry_state().lock() { + Ok(mut telemetry) => { + update(&mut telemetry); + telemetry.last_updated_unix = now_unix(); + } + Err(error) => { + eprintln!("warning: hardening telemetry mutex poisoned: {error}"); + } + } +} + +fn record_hardening_operation_latency(operation: HardeningOperation, duration_ms: u64) { + record_hardening_telemetry(|telemetry| match operation { + HardeningOperation::RunDkg => telemetry.run_dkg_latency.record(duration_ms), + HardeningOperation::StartSignRound => { + telemetry.start_sign_round_latency.record(duration_ms) + } + HardeningOperation::BuildTaprootTx => { + telemetry.build_taproot_tx_latency.record(duration_ms) + } + HardeningOperation::FinalizeSignRound => { + telemetry.finalize_sign_round_latency.record(duration_ms) + } + HardeningOperation::RefreshShares => telemetry.refresh_shares_latency.record(duration_ms), + }); +} + +fn provenance_gate_enforced() -> bool { + if signer_profile_is_production() { + return true; + } + + std::env::var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV) + .map(|raw_value| truthy_env_flag(&raw_value)) + .unwrap_or(false) +} + +fn admission_policy_enforced() -> bool { + std::env::var(TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV) + .map(|raw_value| truthy_env_flag(&raw_value)) + .unwrap_or(false) +} + +fn signing_policy_firewall_enforced() -> bool { + std::env::var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV) + .map(|raw_value| truthy_env_flag(&raw_value)) + .unwrap_or(false) +} + +fn warn_disabled_policy_gates() { + POLICY_GATE_WARNING_EMITTED.get_or_init(|| { + if !provenance_gate_enforced() { + eprintln!( + "warning: provenance gate is DISABLED; set {}=true to enforce signed attestation verification", + TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV + ); + } + if !admission_policy_enforced() { + eprintln!( + "warning: admission policy is DISABLED; set {}=true to enforce DKG admission controls", + TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV + ); + } + if !signing_policy_firewall_enforced() { + eprintln!( + "warning: signing policy firewall is DISABLED; set {}=true to enforce transaction policy controls", + TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV + ); + } + }); +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct ParsedVersionTriplet { + major: u64, + minor: u64, + patch: u64, + has_prerelease_suffix: bool, +} + +fn parse_version_triplet(version: &str) -> Option { + let mut core_version = version.trim(); + if let Some((prefix, _)) = core_version.split_once('+') { + core_version = prefix; + } + let has_prerelease_suffix = core_version.contains('-'); + if let Some((prefix, _)) = core_version.split_once('-') { + core_version = prefix; + } + + let mut segments = core_version.split('.'); + let major = segments.next()?.parse::().ok()?; + let minor = segments.next()?.parse::().ok()?; + let patch = segments.next()?.parse::().ok()?; + if segments.next().is_some() { + return None; + } + + Some(ParsedVersionTriplet { + major, + minor, + patch, + has_prerelease_suffix, + }) +} + +fn runtime_satisfies_minimum_version( + runtime_version: ParsedVersionTriplet, + minimum_version: ParsedVersionTriplet, +) -> bool { + if runtime_version.major != minimum_version.major { + return runtime_version.major > minimum_version.major; + } + if runtime_version.minor != minimum_version.minor { + return runtime_version.minor > minimum_version.minor; + } + if runtime_version.patch != minimum_version.patch { + return runtime_version.patch > minimum_version.patch; + } + + if runtime_version.has_prerelease_suffix && !minimum_version.has_prerelease_suffix { + return false; + } + + true +} + +#[derive(Clone, Debug, Deserialize)] +struct ProvenanceAttestationPayload { + status: String, + runtime_version: String, + #[serde(default)] + expires_at_unix: Option, +} + +fn parse_provenance_trust_root_pubkey(trust_root: &str) -> Result { + let trust_root_bytes = + hex::decode(trust_root).map_err(|_| EngineError::ProvenanceGateRejected { + reason_code: "invalid_trust_root_format".to_string(), + detail: format!( + "env [{}] must be 32-byte x-only public key hex", + TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV + ), + })?; + + if trust_root_bytes.len() != 32 { + return Err(EngineError::ProvenanceGateRejected { + reason_code: "invalid_trust_root_format".to_string(), + detail: format!( + "env [{}] must decode to 32-byte x-only public key", + TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV + ), + }); + } + + XOnlyPublicKey::from_slice(&trust_root_bytes).map_err(|_| EngineError::ProvenanceGateRejected { + reason_code: "invalid_trust_root_format".to_string(), + detail: format!( + "env [{}] must decode to valid x-only secp256k1 public key", + TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV + ), + }) +} + +fn parse_provenance_attestation_payload( + payload: &str, +) -> Result { + serde_json::from_str::(payload).map_err(|_| { + EngineError::ProvenanceGateRejected { + reason_code: "invalid_attestation_payload".to_string(), + detail: format!( + "env [{}] must be JSON with fields [status, runtime_version]", + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV + ), + } + }) +} + +fn verify_provenance_attestation_signature( + attestation_payload: &str, + attestation_signature_hex: &str, + trust_root_pubkey: &XOnlyPublicKey, +) -> Result<(), EngineError> { + let signature_bytes = hex::decode(attestation_signature_hex).map_err(|_| { + EngineError::ProvenanceGateRejected { + reason_code: "invalid_attestation_signature_format".to_string(), + detail: format!( + "env [{}] must be schnorr signature hex", + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV + ), + } + })?; + let signature = SchnorrSignature::from_slice(&signature_bytes).map_err(|_| { + EngineError::ProvenanceGateRejected { + reason_code: "invalid_attestation_signature_format".to_string(), + detail: format!( + "env [{}] must decode to valid schnorr signature bytes", + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV + ), + } + })?; + + let payload_digest = Sha256::digest(attestation_payload.as_bytes()); + let message = SecpMessage::from_digest_slice(&payload_digest).map_err(|e| { + EngineError::Internal(format!( + "failed to construct provenance signature digest: {e}" + )) + })?; + let secp = Secp256k1::verification_only(); + secp.verify_schnorr(&signature, &message, trust_root_pubkey) + .map_err(|e| EngineError::ProvenanceGateRejected { + reason_code: "attestation_signature_verification_failed".to_string(), + detail: format!("failed to verify attestation signature: {e}"), + }) +} + +fn reject_provenance_gate(reason_code: &str, detail: impl Into) -> Result<(), EngineError> { + Err(EngineError::ProvenanceGateRejected { + reason_code: reason_code.to_string(), + detail: detail.into(), + }) +} + +fn enforce_provenance_gate() -> Result<(), EngineError> { + if !provenance_gate_enforced() { + return Ok(()); + } + + let attestation_status = std::env::var(TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV) + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if attestation_status.is_empty() { + return reject_provenance_gate( + "missing_attestation_status", + format!( + "missing required env [{}]", + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV + ), + ); + } + if attestation_status != TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED { + return reject_provenance_gate( + "unapproved_attestation_status", + format!( + "attestation status must be [{}], got [{}]", + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, attestation_status + ), + ); + } + + let trust_root = std::env::var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV) + .unwrap_or_default() + .trim() + .to_string(); + if trust_root.is_empty() { + return reject_provenance_gate( + "missing_trust_root", + format!( + "missing required env [{}]", + TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV + ), + ); + } + let trust_root_pubkey = parse_provenance_trust_root_pubkey(&trust_root)?; + + let raw_attestation_payload = + std::env::var(TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV).unwrap_or_default(); + let attestation_payload = raw_attestation_payload.trim().to_string(); + if attestation_payload.len() != raw_attestation_payload.len() { + eprintln!( + "provenance_gate: warning: env [{}] had leading/trailing whitespace (trimmed {} bytes)", + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, + raw_attestation_payload + .len() + .saturating_sub(attestation_payload.len()) + ); + } + if attestation_payload.is_empty() { + return reject_provenance_gate( + "missing_attestation_payload", + format!( + "missing required env [{}]", + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV + ), + ); + } + + let attestation_signature_hex = + std::env::var(TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV) + .unwrap_or_default() + .trim() + .to_string(); + if attestation_signature_hex.is_empty() { + return reject_provenance_gate( + "missing_attestation_signature", + format!( + "missing required env [{}]", + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV + ), + ); + } + + verify_provenance_attestation_signature( + &attestation_payload, + &attestation_signature_hex, + &trust_root_pubkey, + )?; + let parsed_attestation_payload = parse_provenance_attestation_payload(&attestation_payload)?; + let attestation_payload_status = parsed_attestation_payload + .status + .trim() + .to_ascii_lowercase(); + if attestation_payload_status != attestation_status { + return reject_provenance_gate( + "attestation_status_mismatch", + format!( + "attestation payload status [{}] does not match env status [{}]", + attestation_payload_status, attestation_status + ), + ); + } + if parsed_attestation_payload.runtime_version.trim() != TBTC_SIGNER_RUNTIME_VERSION { + return reject_provenance_gate( + "runtime_version_not_attested", + format!( + "attestation payload runtime version [{}] does not match runtime version [{}]", + parsed_attestation_payload.runtime_version, TBTC_SIGNER_RUNTIME_VERSION + ), + ); + } + let now_unix_seconds = now_unix(); + if now_unix_seconds == 0 { + return reject_provenance_gate( + "clock_unavailable", + "system clock returned epoch zero; cannot verify attestation freshness", + ); + } + + let expires_at_unix = parsed_attestation_payload.expires_at_unix.ok_or_else(|| { + EngineError::ProvenanceGateRejected { + reason_code: "missing_attestation_expiry".to_string(), + detail: format!( + "attestation payload must include expires_at_unix (max TTL: {} seconds)", + TBTC_SIGNER_PROVENANCE_MAX_ATTESTATION_TTL_SECONDS + ), + } + })?; + + if now_unix_seconds > expires_at_unix { + return reject_provenance_gate( + "attestation_expired", + format!( + "attestation expired at [{}], now [{}]", + expires_at_unix, now_unix_seconds + ), + ); + } + + let max_expiry_unix = + now_unix_seconds.saturating_add(TBTC_SIGNER_PROVENANCE_MAX_ATTESTATION_TTL_SECONDS); + if expires_at_unix > max_expiry_unix { + return reject_provenance_gate( + "attestation_expiry_too_far_in_future", + format!( + "attestation expires_at_unix [{}] exceeds max TTL [{} seconds] from now [{}]", + expires_at_unix, + TBTC_SIGNER_PROVENANCE_MAX_ATTESTATION_TTL_SECONDS, + now_unix_seconds + ), + ); + } + + let min_approved_version = std::env::var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV) + .unwrap_or_default() + .trim() + .to_string(); + if min_approved_version.is_empty() { + return reject_provenance_gate( + "missing_minimum_approved_version", + format!( + "missing required env [{}]", + TBTC_SIGNER_MIN_APPROVED_VERSION_ENV + ), + ); + } + + let runtime_version = parse_version_triplet(TBTC_SIGNER_RUNTIME_VERSION).ok_or_else(|| { + EngineError::Internal(format!( + "invalid runtime version format [{}]", + TBTC_SIGNER_RUNTIME_VERSION + )) + })?; + let required_version = parse_version_triplet(&min_approved_version).ok_or_else(|| { + EngineError::ProvenanceGateRejected { + reason_code: "invalid_minimum_approved_version".to_string(), + detail: format!( + "minimum approved version [{}] is not semver triplet", + min_approved_version + ), + } + })?; + + if !runtime_satisfies_minimum_version(runtime_version, required_version) { + return reject_provenance_gate( + "runtime_version_below_minimum", + format!( + "runtime version [{}] below minimum approved version [{}]", + TBTC_SIGNER_RUNTIME_VERSION, min_approved_version + ), + ); + } + + Ok(()) +} + +fn parse_identifier_set_from_env(env_name: &str) -> Result>, EngineError> { + let Ok(raw_value) = std::env::var(env_name) else { + return Ok(None); + }; + + let raw_value = raw_value.trim(); + if raw_value.is_empty() { + return Err(EngineError::Internal(format!( + "identifier list env [{}] must be unset or contain at least one identifier", + env_name + ))); + } + + let mut identifiers = HashSet::new(); + for token in raw_value.split(',') { + let token = token.trim(); + if token.is_empty() { + continue; + } + + let identifier = token.parse::().map_err(|_| { + EngineError::Internal(format!( + "failed to parse identifier [{}] from env [{}]", + token, env_name + )) + })?; + if identifier == 0 { + return Err(EngineError::Internal(format!( + "identifier list env [{}] contains zero identifier", + env_name + ))); + } + identifiers.insert(identifier); + } + + Ok(Some(identifiers)) +} + +fn parse_usize_from_env_with_default( + env_name: &str, + default_value: usize, +) -> Result { + let Ok(raw_value) = std::env::var(env_name) else { + return Ok(default_value); + }; + + let parsed = raw_value.trim().parse::().map_err(|_| { + EngineError::Internal(format!( + "failed to parse usize env [{}] value [{}]", + env_name, raw_value + )) + })?; + Ok(parsed) +} + +fn parse_u64_from_env_with_default(env_name: &str, default_value: u64) -> Result { + let Ok(raw_value) = std::env::var(env_name) else { + return Ok(default_value); + }; + + let parsed = raw_value.trim().parse::().map_err(|_| { + EngineError::Internal(format!( + "failed to parse u64 env [{}] value [{}]", + env_name, raw_value + )) + })?; + Ok(parsed) +} + +fn parse_usize_from_env_required(env_name: &str) -> Result { + let raw_value = std::env::var(env_name) + .map_err(|_| EngineError::Internal(format!("missing required env [{}]", env_name)))?; + raw_value.trim().parse::().map_err(|_| { + EngineError::Internal(format!( + "failed to parse usize env [{}] value [{}]", + env_name, raw_value + )) + }) +} + +fn parse_u64_from_env_required(env_name: &str) -> Result { + let raw_value = std::env::var(env_name) + .map_err(|_| EngineError::Internal(format!("missing required env [{}]", env_name)))?; + raw_value.trim().parse::().map_err(|_| { + EngineError::Internal(format!( + "failed to parse u64 env [{}] value [{}]", + env_name, raw_value + )) + }) +} + +fn parse_u8_from_env_optional(env_name: &str) -> Result, EngineError> { + let Ok(raw_value) = std::env::var(env_name) else { + return Ok(None); + }; + + let parsed = raw_value.trim().parse::().map_err(|_| { + EngineError::Internal(format!( + "failed to parse u8 env [{}] value [{}]", + env_name, raw_value + )) + })?; + if parsed > 23 { + return Err(EngineError::Internal(format!( + "hour env [{}] must be in range 0..=23, got [{}]", + env_name, parsed + ))); + } + Ok(Some(parsed)) +} + +fn parse_script_class_set_required(env_name: &str) -> Result, EngineError> { + let raw_value = std::env::var(env_name) + .map_err(|_| EngineError::Internal(format!("missing required env [{}]", env_name)))?; + let raw_value = raw_value.trim(); + if raw_value.is_empty() { + return Err(EngineError::Internal(format!( + "required env [{}] must not be empty", + env_name + ))); + } + + let mut script_classes = HashSet::new(); + for token in raw_value.split(',') { + let normalized = token.trim().to_ascii_lowercase(); + if normalized.is_empty() { + continue; + } + script_classes.insert(normalized); + } + + if script_classes.is_empty() { + return Err(EngineError::Internal(format!( + "required env [{}] produced an empty script class set", + env_name + ))); + } + + Ok(script_classes) +} + +fn load_admission_policy_config() -> Result, EngineError> { + if !admission_policy_enforced() { + return Ok(None); + } + + let min_participants = + parse_usize_from_env_with_default(TBTC_SIGNER_ADMISSION_MIN_PARTICIPANTS_ENV, 2)?; + let min_threshold = + parse_u64_from_env_with_default(TBTC_SIGNER_ADMISSION_MIN_THRESHOLD_ENV, 2)? + .try_into() + .map_err(|_| { + EngineError::Internal(format!( + "env [{}] exceeds u16 bounds", + TBTC_SIGNER_ADMISSION_MIN_THRESHOLD_ENV + )) + })?; + let required_identifiers = + parse_identifier_set_from_env(TBTC_SIGNER_ADMISSION_REQUIRED_IDENTIFIERS_ENV)? + .unwrap_or_default(); + let allowlist_identifiers = + parse_identifier_set_from_env(TBTC_SIGNER_ADMISSION_ALLOWLIST_IDENTIFIERS_ENV)?; + + Ok(Some(AdmissionPolicyConfig { + min_participants, + min_threshold, + required_identifiers, + allowlist_identifiers, + })) +} + +fn sanitize_policy_log_field(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | ':' | '/') + { + character + } else { + '_' + } + }) + .collect() +} + +fn log_policy_decision(stage: &str, session_id: &str, decision: &str, reason_code: &str) { + let stage = sanitize_policy_log_field(stage); + let session_id = sanitize_policy_log_field(session_id); + let decision = sanitize_policy_log_field(decision); + let reason_code = sanitize_policy_log_field(reason_code); + + eprintln!( + "policy_decision stage={} session_id={} decision={} reason_code={}", + stage, session_id, decision, reason_code + ); +} + +fn reject_admission_policy( + session_id: &str, + reason_code: &str, + detail: impl Into, +) -> Result<(), EngineError> { + let detail = detail.into(); + record_hardening_telemetry(|telemetry| { + telemetry.run_dkg_admission_reject_total = + telemetry.run_dkg_admission_reject_total.saturating_add(1); + }); + log_policy_decision("admission_policy", session_id, "reject", reason_code); + Err(EngineError::AdmissionPolicyRejected { + session_id: session_id.to_string(), + reason_code: reason_code.to_string(), + detail, + }) +} + +fn enforce_admission_policy(request: &RunDkgRequest) -> Result<(), EngineError> { + let policy = match load_admission_policy_config() { + Ok(Some(policy)) => policy, + Ok(None) => return Ok(()), + Err(error) => { + return reject_admission_policy( + &request.session_id, + "invalid_policy_configuration", + error.to_string(), + ) + } + }; + + if request.participants.len() < policy.min_participants { + return reject_admission_policy( + &request.session_id, + "participant_count_below_policy_minimum", + format!( + "participant count [{}] below policy minimum [{}]", + request.participants.len(), + policy.min_participants + ), + ); + } + + if request.threshold < policy.min_threshold { + return reject_admission_policy( + &request.session_id, + "threshold_below_policy_minimum", + format!( + "threshold [{}] below policy minimum [{}]", + request.threshold, policy.min_threshold + ), + ); + } + + let participant_identifiers: HashSet = request + .participants + .iter() + .map(|participant| participant.identifier) + .collect(); + if let Some(required_identifier) = policy + .required_identifiers + .iter() + .find(|identifier| !participant_identifiers.contains(identifier)) + { + return reject_admission_policy( + &request.session_id, + "required_identifier_missing", + format!( + "required identifier [{}] missing from request", + required_identifier + ), + ); + } + + if let Some(allowlist_identifiers) = policy.allowlist_identifiers.as_ref() { + if let Some(unknown_identifier) = participant_identifiers + .iter() + .find(|identifier| !allowlist_identifiers.contains(identifier)) + { + return reject_admission_policy( + &request.session_id, + "participant_identifier_not_allowlisted", + format!( + "participant identifier [{}] not present in configured allowlist", + unknown_identifier + ), + ); + } + } + + log_policy_decision("admission_policy", &request.session_id, "allow", "ok"); + Ok(()) +} + +fn load_signing_policy_firewall_config() -> Result, EngineError> +{ + if !signing_policy_firewall_enforced() { + return Ok(None); + } + + let allowed_script_classes = + parse_script_class_set_required(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV)?; + let max_output_count = parse_usize_from_env_required(TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT_ENV)?; + let max_output_value_sats = + parse_u64_from_env_required(TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS_ENV)?; + let max_total_output_value_sats = + parse_u64_from_env_required(TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS_ENV)?; + let allowed_utc_start_hour = + parse_u8_from_env_optional(TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR_ENV)?; + let allowed_utc_end_hour = + parse_u8_from_env_optional(TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR_ENV)?; + let rate_limit_per_minute = + parse_u64_from_env_with_default(TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE_ENV, 60)?; + + if rate_limit_per_minute == 0 { + return Err(EngineError::Internal(format!( + "env [{}] must be positive", + TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE_ENV + ))); + } + + if allowed_utc_start_hour.is_some() != allowed_utc_end_hour.is_some() { + return Err(EngineError::Internal(format!( + "env [{}] and [{}] must be configured together", + TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR_ENV, + TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR_ENV + ))); + } + + Ok(Some(SigningPolicyFirewallConfig { + allowed_script_classes, + max_output_count, + max_output_value_sats, + max_total_output_value_sats, + allowed_utc_start_hour, + allowed_utc_end_hour, + rate_limit_per_minute, + })) +} + +fn auto_quarantine_enabled() -> bool { + std::env::var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV) + .map(|raw_value| truthy_env_flag(&raw_value)) + .unwrap_or(false) +} + +fn load_auto_quarantine_config() -> Result, EngineError> { + if !auto_quarantine_enabled() { + return Ok(None); + } + + let fault_threshold = parse_u64_from_env_with_default( + TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV, + TBTC_SIGNER_DEFAULT_AUTO_QUARANTINE_FAULT_THRESHOLD, + )?; + let timeout_penalty = parse_u64_from_env_with_default( + TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV, + TBTC_SIGNER_DEFAULT_AUTO_QUARANTINE_TIMEOUT_PENALTY, + )?; + let invalid_share_penalty = parse_u64_from_env_with_default( + TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV, + TBTC_SIGNER_DEFAULT_AUTO_QUARANTINE_INVALID_SHARE_PENALTY, + )?; + let dao_allowlist_identifiers = + parse_identifier_set_from_env(TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS_ENV)? + .unwrap_or_default(); + + if fault_threshold == 0 { + return Err(EngineError::Internal(format!( + "env [{}] must be positive", + TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV + ))); + } + if timeout_penalty == 0 { + return Err(EngineError::Internal(format!( + "env [{}] must be positive", + TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV + ))); + } + if invalid_share_penalty == 0 { + return Err(EngineError::Internal(format!( + "env [{}] must be positive", + TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV + ))); + } + + Ok(Some(AutoQuarantineConfig { + fault_threshold, + timeout_penalty, + invalid_share_penalty, + dao_allowlist_identifiers, + })) +} + +fn reject_quarantine_policy( + session_id: &str, + reason_code: &str, + detail: impl Into, +) -> Result<(), EngineError> { + let detail = detail.into(); + log_policy_decision("auto_quarantine", session_id, "reject", reason_code); + Err(EngineError::QuarantinePolicyRejected { + session_id: session_id.to_string(), + reason_code: reason_code.to_string(), + detail, + }) +} + +fn reject_lifecycle_policy( + session_id: &str, + reason_code: &str, + detail: impl Into, +) -> Result { + let detail = detail.into(); + log_policy_decision("lifecycle_policy", session_id, "reject", reason_code); + Err(EngineError::LifecyclePolicyRejected { + session_id: session_id.to_string(), + reason_code: reason_code.to_string(), + detail, + }) +} + +fn reject_signing_policy( + session_id: &str, + reason_code: &str, + detail: impl Into, +) -> Result<(), EngineError> { + let detail = detail.into(); + record_hardening_telemetry(|telemetry| { + telemetry.build_taproot_tx_policy_reject_total = telemetry + .build_taproot_tx_policy_reject_total + .saturating_add(1); + }); + log_policy_decision("signing_policy_firewall", session_id, "reject", reason_code); + Err(EngineError::SigningPolicyRejected { + session_id: session_id.to_string(), + reason_code: reason_code.to_string(), + detail, + }) +} + +fn current_utc_hour() -> u8 { + ((now_unix() / 3600) % 24) as u8 +} + +fn utc_hour_in_window(hour: u8, start_hour: u8, end_hour: u8) -> bool { + if start_hour == end_hour { + return true; + } + if start_hour < end_hour { + return hour >= start_hour && hour < end_hour; + } + + hour >= start_hour || hour < end_hour +} + +fn enforce_build_tx_rate_limit( + session_id: &str, + rate_limit_per_minute: u64, +) -> Result<(), EngineError> { + let mut limiter = build_tx_rate_limiter_state() + .lock() + .map_err(|_| EngineError::Internal("build tx rate limiter mutex poisoned".to_string()))?; + + let now = now_unix(); + let max_tokens = + (rate_limit_per_minute as u128).saturating_mul(BUILD_TX_RATE_LIMIT_TOKEN_SCALE); + if limiter.last_refill_unix == 0 { + limiter.last_refill_unix = now; + limiter.token_microunits = max_tokens; + limiter.configured_rate_limit_per_minute = rate_limit_per_minute; + } + + if limiter.configured_rate_limit_per_minute != rate_limit_per_minute { + limiter.configured_rate_limit_per_minute = rate_limit_per_minute; + limiter.token_microunits = limiter.token_microunits.min(max_tokens); + } + + let elapsed_seconds = now.saturating_sub(limiter.last_refill_unix); + if elapsed_seconds > 0 { + let refill_microunits = (elapsed_seconds as u128) + .saturating_mul(rate_limit_per_minute as u128) + .saturating_mul(BUILD_TX_RATE_LIMIT_TOKEN_SCALE) + / BUILD_TX_RATE_LIMIT_SECONDS_PER_MINUTE; + limiter.token_microunits = limiter + .token_microunits + .saturating_add(refill_microunits) + .min(max_tokens); + limiter.last_refill_unix = now; + } + + if limiter.token_microunits < BUILD_TX_RATE_LIMIT_TOKEN_SCALE { + return reject_signing_policy( + session_id, + "rate_limit_per_minute_exceeded", + format!("rate limit [{}] per minute exceeded", rate_limit_per_minute), + ); + } + + limiter.token_microunits = limiter + .token_microunits + .saturating_sub(BUILD_TX_RATE_LIMIT_TOKEN_SCALE); + Ok(()) +} + +fn classify_script_pubkey(script_pubkey: &ScriptBuf) -> &'static str { + if script_pubkey.is_p2tr() { + "p2tr" + } else if script_pubkey.is_p2wpkh() { + "p2wpkh" + } else if script_pubkey.is_p2wsh() { + "p2wsh" + } else if script_pubkey.is_p2pkh() { + "p2pkh" + } else if script_pubkey.is_p2sh() { + "p2sh" + } else { + "other" + } +} + +fn enforce_signing_policy_firewall_inner( + session_id: &str, + outputs: &[TxOut], + total_output_value_sats: u64, + charge_rate_limit: bool, +) -> Result<(), EngineError> { + let policy = match load_signing_policy_firewall_config() { + Ok(Some(policy)) => policy, + Ok(None) => return Ok(()), + Err(error) => { + return reject_signing_policy( + session_id, + "invalid_policy_configuration", + error.to_string(), + ) + } + }; + + if outputs.len() > policy.max_output_count { + return reject_signing_policy( + session_id, + "output_count_exceeds_policy_limit", + format!( + "output count [{}] exceeds policy max [{}]", + outputs.len(), + policy.max_output_count + ), + ); + } + + if total_output_value_sats > policy.max_total_output_value_sats { + return reject_signing_policy( + session_id, + "total_output_value_exceeds_policy_limit", + format!( + "total output value [{}] exceeds policy max [{}]", + total_output_value_sats, policy.max_total_output_value_sats + ), + ); + } + + for output in outputs { + let output_value_sats = output.value.to_sat(); + if output_value_sats > policy.max_output_value_sats { + return reject_signing_policy( + session_id, + "single_output_value_exceeds_policy_limit", + format!( + "output value [{}] exceeds policy max [{}]", + output_value_sats, policy.max_output_value_sats + ), + ); + } + + let script_class = classify_script_pubkey(&output.script_pubkey).to_string(); + if !policy.allowed_script_classes.contains(&script_class) { + return reject_signing_policy( + session_id, + "script_class_not_allowlisted", + format!( + "script class [{}] not in allowlist {:?}", + script_class, policy.allowed_script_classes + ), + ); + } + } + + if let (Some(start_hour), Some(end_hour)) = + (policy.allowed_utc_start_hour, policy.allowed_utc_end_hour) + { + let current_hour = current_utc_hour(); + if !utc_hour_in_window(current_hour, start_hour, end_hour) { + return reject_signing_policy( + session_id, + "request_outside_allowed_utc_window", + format!( + "current UTC hour [{}] not in window [{}..{})", + current_hour, start_hour, end_hour + ), + ); + } + } + + if charge_rate_limit { + enforce_build_tx_rate_limit(session_id, policy.rate_limit_per_minute)?; + } + log_policy_decision("signing_policy_firewall", session_id, "allow", "ok"); + Ok(()) +} + +fn enforce_signing_policy_firewall( + session_id: &str, + outputs: &[TxOut], + total_output_value_sats: u64, +) -> Result<(), EngineError> { + enforce_signing_policy_firewall_inner(session_id, outputs, total_output_value_sats, true) +} + +fn recheck_signing_policy_firewall_without_rate_limit( + session_id: &str, + outputs: &[TxOut], + total_output_value_sats: u64, +) -> Result<(), EngineError> { + enforce_signing_policy_firewall_inner(session_id, outputs, total_output_value_sats, false) +} + +fn policy_bound_signing_message_hex(tx_hex: &str) -> Result { + let tx_bytes = hex::decode(tx_hex).map_err(|_| { + EngineError::Internal("policy-checked build tx hex is not valid hex".to_string()) + })?; + Ok(hash_hex(&tx_bytes)) +} + +fn enforce_signing_message_binding_to_policy_checked_build_tx( + session_id: &str, + signing_message_hex: &str, + tx_result: Option<&TransactionResult>, +) -> Result<(), EngineError> { + if !signing_policy_firewall_enforced() { + return Ok(()); + } + + let tx_result = match tx_result { + Some(tx_result) => tx_result, + None => { + return reject_signing_policy( + session_id, + "missing_policy_checked_build_tx", + "signing policy firewall requires build_taproot_tx to run before signing for this session", + ) + } + }; + + let expected_signing_message_hex = policy_bound_signing_message_hex(&tx_result.tx_hex) + .map_err(|error| EngineError::SigningPolicyRejected { + session_id: session_id.to_string(), + reason_code: "invalid_policy_checked_build_tx_artifact".to_string(), + detail: error.to_string(), + })?; + let signing_message_hex = signing_message_hex.trim().to_ascii_lowercase(); + if signing_message_hex != expected_signing_message_hex { + return reject_signing_policy( + session_id, + "signing_message_not_bound_to_policy_checked_build_tx", + format!( + "signing message [{}] does not match policy-checked build tx digest [{}]", + signing_message_hex, expected_signing_message_hex + ), + ); + } + + Ok(()) +} + +pub fn hardening_metrics() -> SignerHardeningMetricsResult { + let mut result = SignerHardeningMetricsResult { + runtime_version: TBTC_SIGNER_RUNTIME_VERSION.to_string(), + provenance_enforced: provenance_gate_enforced(), + admission_policy_enforced: admission_policy_enforced(), + signing_policy_firewall_enforced: signing_policy_firewall_enforced(), + run_dkg_calls_total: 0, + run_dkg_success_total: 0, + run_dkg_admission_reject_total: 0, + start_sign_round_calls_total: 0, + start_sign_round_success_total: 0, + build_taproot_tx_calls_total: 0, + build_taproot_tx_success_total: 0, + build_taproot_tx_policy_reject_total: 0, + finalize_sign_round_calls_total: 0, + finalize_sign_round_success_total: 0, + refresh_shares_calls_total: 0, + refresh_shares_success_total: 0, + roast_transcript_audit_calls_total: 0, + roast_transcript_audit_success_total: 0, + verify_blame_proof_calls_total: 0, + verify_blame_proof_success_total: 0, + attempt_transition_total: 0, + coordinator_failover_total: 0, + auto_quarantine_fault_events_total: 0, + auto_quarantine_enforcements_total: 0, + quarantined_operator_count: 0, + refresh_cadence_overdue_sessions: 0, + emergency_rekey_sessions_total: 0, + differential_fuzz_runs_total: 0, + differential_fuzz_critical_divergence_total: 0, + canary_promotions_total: 0, + canary_rollbacks_total: 0, + run_dkg_latency_p95_ms: 0, + run_dkg_latency_samples: 0, + start_sign_round_latency_p95_ms: 0, + start_sign_round_latency_samples: 0, + build_taproot_tx_latency_p95_ms: 0, + build_taproot_tx_latency_samples: 0, + finalize_sign_round_latency_p95_ms: 0, + finalize_sign_round_latency_samples: 0, + refresh_shares_latency_p95_ms: 0, + refresh_shares_latency_samples: 0, + last_updated_unix: 0, + }; + + match hardening_telemetry_state().lock() { + Ok(telemetry) => { + result.run_dkg_calls_total = telemetry.run_dkg_calls_total; + result.run_dkg_success_total = telemetry.run_dkg_success_total; + result.run_dkg_admission_reject_total = telemetry.run_dkg_admission_reject_total; + result.start_sign_round_calls_total = telemetry.start_sign_round_calls_total; + result.start_sign_round_success_total = telemetry.start_sign_round_success_total; + result.build_taproot_tx_calls_total = telemetry.build_taproot_tx_calls_total; + result.build_taproot_tx_success_total = telemetry.build_taproot_tx_success_total; + result.build_taproot_tx_policy_reject_total = + telemetry.build_taproot_tx_policy_reject_total; + result.finalize_sign_round_calls_total = telemetry.finalize_sign_round_calls_total; + result.finalize_sign_round_success_total = telemetry.finalize_sign_round_success_total; + result.refresh_shares_calls_total = telemetry.refresh_shares_calls_total; + result.refresh_shares_success_total = telemetry.refresh_shares_success_total; + result.roast_transcript_audit_calls_total = + telemetry.roast_transcript_audit_calls_total; + result.roast_transcript_audit_success_total = + telemetry.roast_transcript_audit_success_total; + result.verify_blame_proof_calls_total = telemetry.verify_blame_proof_calls_total; + result.verify_blame_proof_success_total = telemetry.verify_blame_proof_success_total; + result.attempt_transition_total = telemetry.attempt_transition_total; + result.coordinator_failover_total = telemetry.coordinator_failover_total; + result.auto_quarantine_fault_events_total = + telemetry.auto_quarantine_fault_events_total; + result.auto_quarantine_enforcements_total = + telemetry.auto_quarantine_enforcements_total; + result.differential_fuzz_runs_total = telemetry.differential_fuzz_runs_total; + result.differential_fuzz_critical_divergence_total = + telemetry.differential_fuzz_critical_divergence_total; + result.canary_promotions_total = telemetry.canary_promotions_total; + result.canary_rollbacks_total = telemetry.canary_rollbacks_total; + result.run_dkg_latency_p95_ms = telemetry.run_dkg_latency.p95_ms(); + result.run_dkg_latency_samples = telemetry.run_dkg_latency.sample_count(); + result.start_sign_round_latency_p95_ms = telemetry.start_sign_round_latency.p95_ms(); + result.start_sign_round_latency_samples = + telemetry.start_sign_round_latency.sample_count(); + result.build_taproot_tx_latency_p95_ms = telemetry.build_taproot_tx_latency.p95_ms(); + result.build_taproot_tx_latency_samples = + telemetry.build_taproot_tx_latency.sample_count(); + result.finalize_sign_round_latency_p95_ms = + telemetry.finalize_sign_round_latency.p95_ms(); + result.finalize_sign_round_latency_samples = + telemetry.finalize_sign_round_latency.sample_count(); + result.refresh_shares_latency_p95_ms = telemetry.refresh_shares_latency.p95_ms(); + result.refresh_shares_latency_samples = telemetry.refresh_shares_latency.sample_count(); + result.last_updated_unix = telemetry.last_updated_unix; + } + Err(error) => { + eprintln!("warning: hardening telemetry mutex poisoned: {error}"); + } + } + + if let Ok(state) = state() { + if let Ok(engine_state) = state.lock() { + result.quarantined_operator_count = + engine_state.quarantined_operator_identifiers.len() as u64; + result.emergency_rekey_sessions_total = engine_state + .sessions + .values() + .filter(|session| session.emergency_rekey_event.is_some()) + .count() as u64; + result.refresh_cadence_overdue_sessions = engine_state + .sessions + .values() + .filter(|session| { + session.refresh_history.last().is_some_and(|last_refresh| { + now_unix() + > last_refresh + .refreshed_at_unix + .saturating_add(refresh_cadence_seconds()) + }) + }) + .count() as u64; + } + } + + result +} + +fn canary_policy_reject_rate_bps(metrics: &SignerHardeningMetricsResult) -> u64 { + if metrics.build_taproot_tx_calls_total == 0 { + return 0; + } + + metrics + .build_taproot_tx_policy_reject_total + .saturating_mul(TBTC_SIGNER_MAX_POLICY_REJECT_RATE_BPS) + .saturating_div(metrics.build_taproot_tx_calls_total) +} + +fn canary_promotion_gate_failures(metrics: &SignerHardeningMetricsResult) -> Vec { + let mut failures = Vec::new(); + + let max_start_sign_round_p95_ms = canary_max_start_sign_round_p95_ms(); + if metrics.start_sign_round_latency_samples > 0 + && metrics.start_sign_round_latency_p95_ms > max_start_sign_round_p95_ms + { + failures.push(format!( + "start_sign_round p95 latency [{}ms] exceeds canary gate [{}ms]", + metrics.start_sign_round_latency_p95_ms, max_start_sign_round_p95_ms + )); + } + + let max_finalize_sign_round_p95_ms = canary_max_finalize_sign_round_p95_ms(); + if metrics.finalize_sign_round_latency_samples > 0 + && metrics.finalize_sign_round_latency_p95_ms > max_finalize_sign_round_p95_ms + { + failures.push(format!( + "finalize_sign_round p95 latency [{}ms] exceeds canary gate [{}ms]", + metrics.finalize_sign_round_latency_p95_ms, max_finalize_sign_round_p95_ms + )); + } + + let max_policy_reject_rate_bps = canary_max_policy_reject_rate_bps(); + let policy_reject_rate_bps = canary_policy_reject_rate_bps(metrics); + if policy_reject_rate_bps > max_policy_reject_rate_bps { + failures.push(format!( + "build_taproot_tx policy reject rate [{}bps] exceeds canary gate [{}bps]", + policy_reject_rate_bps, max_policy_reject_rate_bps + )); + } + + failures +} + +fn refresh_continuity_reference_key_group(session: &SessionState) -> Option { + session + .dkg_result + .as_ref() + .map(|result| result.key_group.clone()) + .or_else(|| { + session + .refresh_history + .iter() + .find_map(|record| record.key_group.clone()) + }) +} + +fn refresh_history_continuity_preserved(session: &SessionState) -> bool { + let mut last_refresh_epoch = 0_u64; + let mut reference_key_group: Option<&str> = None; + + for refresh_record in &session.refresh_history { + if refresh_record.refresh_epoch == 0 || refresh_record.refresh_epoch <= last_refresh_epoch { + return false; + } + last_refresh_epoch = refresh_record.refresh_epoch; + + if let Some(record_key_group) = refresh_record.key_group.as_deref() { + if let Some(reference_key_group) = reference_key_group { + if !record_key_group.eq_ignore_ascii_case(reference_key_group) { + return false; + } + } else { + reference_key_group = Some(record_key_group); + } + } + } + + true +} + +pub fn refresh_cadence_status( + request: RefreshCadenceStatusRequest, +) -> Result { + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + let guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + let session = + guard + .sessions + .get(&request.session_id) + .ok_or_else(|| EngineError::SessionNotFound { + session_id: request.session_id.clone(), + })?; + let cadence_seconds = refresh_cadence_seconds(); + let last_refresh_record = session.refresh_history.last(); + let now = now_unix(); + let next_refresh_due_unix = last_refresh_record + .map(|record| record.refreshed_at_unix.saturating_add(cadence_seconds)) + .unwrap_or_else(|| now.saturating_add(cadence_seconds)); + let overdue = now > next_refresh_due_unix; + let continuity_reference_key_group = refresh_continuity_reference_key_group(session); + let emergency_rekey_reason = session + .emergency_rekey_event + .as_ref() + .map(|event| event.reason.clone()); + + Ok(RefreshCadenceStatusResult { + session_id: request.session_id, + refresh_count: session.refresh_history.len() as u64, + last_refresh_epoch: last_refresh_record + .map(|record| record.refresh_epoch) + .unwrap_or(0), + cadence_seconds, + next_refresh_due_unix, + overdue, + continuity_preserved: refresh_history_continuity_preserved(session), + continuity_reference_key_group, + emergency_rekey_required: session.emergency_rekey_event.is_some(), + emergency_rekey_reason, + }) +} + +pub fn trigger_emergency_rekey( + request: TriggerEmergencyRekeyRequest, +) -> Result { + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + let reason = request.reason.trim(); + if reason.is_empty() { + return Err(EngineError::Validation( + "reason must not be empty".to_string(), + )); + } + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + let session = guard.sessions.get_mut(&request.session_id).ok_or_else(|| { + EngineError::SessionNotFound { + session_id: request.session_id.clone(), + } + })?; + if session.emergency_rekey_event.is_some() { + return Err(EngineError::Validation(format!( + "emergency rekey already triggered for session [{}]; event is immutable", + request.session_id + ))); + } + let triggered_at_unix = now_unix(); + session.emergency_rekey_event = Some(EmergencyRekeyEvent { + reason: reason.to_string(), + triggered_at_unix, + }); + persist_engine_state_to_storage(&guard)?; + + Ok(TriggerEmergencyRekeyResult { + session_id: request.session_id.clone(), + emergency_rekey_required: true, + reason: reason.to_string(), + triggered_at_unix, + recommended_new_session_id: format!("{}-rekey-{}", request.session_id, triggered_at_unix), + }) +} + +fn reference_roast_hash_hex(domain: &str, components: &[Vec]) -> Result { + let mut payload = Vec::new(); + let domain_bytes = domain.as_bytes(); + let domain_len = u32::try_from(domain_bytes.len()).map_err(|_| { + EngineError::Validation("reference hash domain exceeds u32 framing limit".to_string()) + })?; + payload.extend_from_slice(&domain_len.to_be_bytes()); + payload.extend_from_slice(domain_bytes); + + for component in components { + let component_len = u32::try_from(component.len()).map_err(|_| { + EngineError::Validation( + "reference hash component exceeds u32 framing limit".to_string(), + ) + })?; + payload.extend_from_slice(&component_len.to_be_bytes()); + payload.extend_from_slice(component); + } + + Ok(hash_hex(&payload)) +} + +fn reference_roast_included_participants_fingerprint_hex( + included_participants: &[u16], +) -> Result { + let mut participant_payload = Vec::new(); + for participant_identifier in included_participants { + let participant_component = participant_identifier.to_be_bytes(); + let component_len = u32::try_from(participant_component.len()).map_err(|_| { + EngineError::Validation( + "reference participant component exceeds u32 framing limit".to_string(), + ) + })?; + participant_payload.extend_from_slice(&component_len.to_be_bytes()); + participant_payload.extend_from_slice(&participant_component); + } + + reference_roast_hash_hex( + ROAST_INCLUDED_PARTICIPANTS_FINGERPRINT_DOMAIN, + &[participant_payload], + ) +} + +fn reference_roast_attempt_id_hex( + session_id: &str, + message_digest_hex: &str, + attempt_number: u32, + coordinator_identifier: u16, + included_participants_fingerprint_hex: &str, +) -> Result { + reference_roast_hash_hex( + ROAST_ATTEMPT_ID_DOMAIN, + &[ + session_id.as_bytes().to_vec(), + message_digest_hex.as_bytes().to_vec(), + attempt_number.to_be_bytes().to_vec(), + coordinator_identifier.to_be_bytes().to_vec(), + included_participants_fingerprint_hex.as_bytes().to_vec(), + ], + ) +} + +fn differential_case_count(case_count: u32) -> u32 { + if case_count == 0 { + return TBTC_SIGNER_DIFFERENTIAL_FUZZ_DEFAULT_CASES; + } + + case_count.min(TBTC_SIGNER_DIFFERENTIAL_FUZZ_MAX_CASES) +} + +pub fn run_differential_fuzzing( + request: DifferentialFuzzRequest, +) -> Result { + enforce_provenance_gate()?; + let case_count = differential_case_count(request.case_count); + let seed = if request.seed == 0 { + 0xD1FF_E2E0_A11C_0001 + } else { + request.seed + }; + let mut rng = ChaCha20Rng::seed_from_u64(seed); + let mut divergences = Vec::new(); + let mut critical_divergence_count = 0_u32; + + for case_index in 0..case_count { + let mut participants = Vec::new(); + let participant_count = (rng.next_u32() % 4 + 2) as usize; + while participants.len() < participant_count { + let candidate = (rng.next_u32() % 30 + 1) as u16; + if !participants.contains(&candidate) { + participants.push(candidate); + } + } + if participants.len() > 1 { + let swap_index = (rng.next_u32() as usize) % participants.len(); + participants.swap(0, swap_index); + } + + let mut digest_bytes = [0_u8; 32]; + rng.fill_bytes(&mut digest_bytes); + let message_digest_hex = hex::encode(digest_bytes); + let session_id = format!("differential-session-{seed:016x}-{case_index}"); + let attempt_number = (rng.next_u32() % 16) + 1; + let coordinator_identifier = participants[(rng.next_u32() as usize) % participants.len()]; + + let primary_fingerprint = roast_included_participants_fingerprint_hex(&participants)?; + let reference_fingerprint = + reference_roast_included_participants_fingerprint_hex(&participants)?; + if primary_fingerprint != reference_fingerprint { + critical_divergence_count = critical_divergence_count.saturating_add(1); + divergences.push(DifferentialDivergence { + case_index, + check: "included_participants_fingerprint".to_string(), + severity: "critical".to_string(), + detail: format!( + "primary [{}] != reference [{}]", + primary_fingerprint, reference_fingerprint + ), + }); + } + + let primary_attempt_id = roast_attempt_id_hex( + &session_id, + &message_digest_hex, + attempt_number, + coordinator_identifier, + &primary_fingerprint, + )?; + let reference_attempt_id = reference_roast_attempt_id_hex( + &session_id, + &message_digest_hex, + attempt_number, + coordinator_identifier, + &reference_fingerprint, + )?; + if primary_attempt_id != reference_attempt_id { + critical_divergence_count = critical_divergence_count.saturating_add(1); + divergences.push(DifferentialDivergence { + case_index, + check: "attempt_id".to_string(), + severity: "critical".to_string(), + detail: format!( + "primary [{}] != reference [{}]", + primary_attempt_id, reference_attempt_id + ), + }); + } + + let mut txid_bytes = [0_u8; 32]; + rng.fill_bytes(&mut txid_bytes); + let txid_hex = hex::encode(txid_bytes); + let txid = Txid::from_str(&txid_hex).map_err(|_| { + EngineError::Internal("failed to build differential fuzz txid".to_string()) + })?; + let mut script_pubkey = vec![0x51, 0x20]; + let mut witness_program = [0_u8; 32]; + rng.fill_bytes(&mut witness_program); + script_pubkey.extend_from_slice(&witness_program); + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid, + vout: rng.next_u32() % 4, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::default(), + }], + output: vec![TxOut { + value: Amount::from_sat((rng.next_u32() as u64 % 1_000_000) + 1), + script_pubkey: ScriptBuf::from_bytes(script_pubkey), + }], + }; + let tx_hex = serialize_hex(&tx); + let primary_message_digest_hex = policy_bound_signing_message_hex(&tx_hex)?; + let tx_bytes = hex::decode(&tx_hex).map_err(|_| { + EngineError::Internal("failed to decode differential tx hex".to_string()) + })?; + let tx_roundtrip: Transaction = deserialize(&tx_bytes).map_err(|error| { + EngineError::Internal(format!("failed to deserialize differential tx: {error}")) + })?; + let reference_message_digest_hex = + hash_hex(&bitcoin::consensus::encode::serialize(&tx_roundtrip)); + if primary_message_digest_hex != reference_message_digest_hex { + critical_divergence_count = critical_divergence_count.saturating_add(1); + divergences.push(DifferentialDivergence { + case_index, + check: "policy_bound_message_digest".to_string(), + severity: "critical".to_string(), + detail: format!( + "primary [{}] != reference [{}]", + primary_message_digest_hex, reference_message_digest_hex + ), + }); + } + } + + record_hardening_telemetry(|telemetry| { + telemetry.differential_fuzz_runs_total = + telemetry.differential_fuzz_runs_total.saturating_add(1); + telemetry.differential_fuzz_critical_divergence_total = telemetry + .differential_fuzz_critical_divergence_total + .saturating_add(critical_divergence_count as u64); + }); + + Ok(DifferentialFuzzResult { + seed, + case_count, + divergences, + critical_divergence_count, + unresolved_critical_divergence: critical_divergence_count > 0, + }) +} + +pub fn canary_rollout_status() -> Result { + enforce_provenance_gate()?; + let metrics = hardening_metrics(); + let gate_failures = canary_promotion_gate_failures(&metrics); + let gate_passed = gate_failures.is_empty(); + let (current_percent, previous_percent, config_version, last_action_unix) = + if let Ok(state) = state() { + if let Ok(guard) = state.lock() { + ( + guard.canary_rollout.current_percent, + guard.canary_rollout.previous_percent, + guard.canary_rollout.config_version, + guard.canary_rollout.last_action_unix, + ) + } else { + let default = CanaryRolloutState::default(); + ( + default.current_percent, + default.previous_percent, + default.config_version, + default.last_action_unix, + ) + } + } else { + let default = CanaryRolloutState::default(); + ( + default.current_percent, + default.previous_percent, + default.config_version, + default.last_action_unix, + ) + }; + + Ok(CanaryRolloutStatusResult { + current_percent, + previous_percent, + config_version, + promotion_gate_passed: gate_passed, + gate_failures, + recommended_next_percent: if gate_passed { + next_canary_percent(current_percent) + } else { + None + }, + last_action_unix, + }) +} + +pub fn promote_canary(request: PromoteCanaryRequest) -> Result { + enforce_provenance_gate()?; + if !matches!(request.target_percent, 10 | 50 | 100) { + return Err(EngineError::Validation( + "target_percent must be one of [10, 50, 100]".to_string(), + )); + } + + let metrics = hardening_metrics(); + let gate_failures = canary_promotion_gate_failures(&metrics); + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + let current_percent = guard.canary_rollout.current_percent; + + if request.target_percent == current_percent { + return Ok(PromoteCanaryResult { + from_percent: current_percent, + to_percent: current_percent, + config_version: guard.canary_rollout.config_version, + promoted_at_unix: guard.canary_rollout.last_action_unix, + }); + } + + if !can_promote_to_target_percent(current_percent, request.target_percent) { + return reject_lifecycle_policy( + "canary-rollout", + "invalid_canary_promotion_step", + format!( + "canary promotion must follow 10->50->100 progression; current [{}], target [{}]", + current_percent, request.target_percent + ), + ); + } + if !gate_failures.is_empty() { + return reject_lifecycle_policy( + "canary-rollout", + "canary_slo_gate_failed", + gate_failures.join("; "), + ); + } + + guard.canary_rollout.previous_percent = current_percent; + guard.canary_rollout.current_percent = request.target_percent; + guard.canary_rollout.config_version = guard.canary_rollout.config_version.saturating_add(1); + guard.canary_rollout.last_action_unix = now_unix(); + let result = PromoteCanaryResult { + from_percent: current_percent, + to_percent: request.target_percent, + config_version: guard.canary_rollout.config_version, + promoted_at_unix: guard.canary_rollout.last_action_unix, + }; + persist_engine_state_to_storage(&guard)?; + record_hardening_telemetry(|telemetry| { + telemetry.canary_promotions_total = telemetry.canary_promotions_total.saturating_add(1); + }); + + Ok(result) +} + +pub fn rollback_canary( + request: RollbackCanaryRequest, +) -> Result { + enforce_provenance_gate()?; + let reason = request.reason.trim(); + if reason.is_empty() { + return Err(EngineError::Validation( + "reason must not be empty".to_string(), + )); + } + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + let from_percent = guard.canary_rollout.current_percent; + let to_percent = guard.canary_rollout.previous_percent.min(from_percent); + guard.canary_rollout.current_percent = to_percent; + guard.canary_rollout.previous_percent = to_percent; + guard.canary_rollout.config_version = guard.canary_rollout.config_version.saturating_add(1); + guard.canary_rollout.last_action_unix = now_unix(); + let result = RollbackCanaryResult { + from_percent, + to_percent, + config_version: guard.canary_rollout.config_version, + reason: reason.to_string(), + rolled_back_at_unix: guard.canary_rollout.last_action_unix, + }; + persist_engine_state_to_storage(&guard)?; + record_hardening_telemetry(|telemetry| { + telemetry.canary_rollbacks_total = telemetry.canary_rollbacks_total.saturating_add(1); + }); + + Ok(result) +} + +pub fn roast_transcript_audit( + request: TranscriptAuditRequest, +) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.roast_transcript_audit_calls_total = telemetry + .roast_transcript_audit_calls_total + .saturating_add(1); + }); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + let guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + let session = + guard + .sessions + .get(&request.session_id) + .ok_or_else(|| EngineError::SessionNotFound { + session_id: request.session_id.clone(), + })?; + let records = session.attempt_transition_records.clone(); + + let result = TranscriptAuditResult { + session_id: request.session_id, + transition_count: records.len() as u64, + records, + }; + record_hardening_telemetry(|telemetry| { + telemetry.roast_transcript_audit_success_total = telemetry + .roast_transcript_audit_success_total + .saturating_add(1); + }); + + Ok(result) +} + +pub fn verify_blame_proof( + request: VerifyBlameProofRequest, +) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.verify_blame_proof_calls_total = + telemetry.verify_blame_proof_calls_total.saturating_add(1); + }); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + if request.from_attempt_number == 0 { + return Err(EngineError::Validation( + "from_attempt_number must be at least 1".to_string(), + )); + } + if request.accused_member_identifier == 0 { + return Err(EngineError::Validation( + "accused_member_identifier must be non-zero".to_string(), + )); + } + + let reason = request.reason.trim().to_ascii_lowercase(); + if reason != ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT + && reason != ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF + { + return Err(EngineError::Validation(format!( + "reason [{}] is unsupported", + request.reason + ))); + } + + let requested_invalid_share_proof_fingerprint = request + .invalid_share_proof_fingerprint + .as_deref() + .map(|fingerprint| fingerprint.trim().to_ascii_lowercase()); + let guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + let session = + guard + .sessions + .get(&request.session_id) + .ok_or_else(|| EngineError::SessionNotFound { + session_id: request.session_id.clone(), + })?; + + let maybe_record = session + .attempt_transition_records + .iter() + .find(|record| record.from_attempt_number == request.from_attempt_number); + let (verified, detail, transcript_hash) = if let Some(record) = maybe_record { + if record.reason != reason { + ( + false, + format!( + "reason mismatch: requested [{}], recorded [{}]", + reason, record.reason + ), + Some(record.transcript_hash.clone()), + ) + } else if !record + .excluded_member_identifiers + .contains(&request.accused_member_identifier) + { + ( + false, + format!( + "operator [{}] is not excluded in recorded transition", + request.accused_member_identifier + ), + Some(record.transcript_hash.clone()), + ) + } else if reason == ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF + && record.invalid_share_proof_fingerprint != requested_invalid_share_proof_fingerprint + { + ( + false, + "invalid_share_proof_fingerprint does not match recorded transition evidence" + .to_string(), + Some(record.transcript_hash.clone()), + ) + } else { + ( + true, + "blame proof verified against persisted transcript record".to_string(), + Some(record.transcript_hash.clone()), + ) + } + } else { + ( + false, + format!( + "no persisted transition record for from_attempt_number [{}]", + request.from_attempt_number + ), + None, + ) + }; + + if verified { + record_hardening_telemetry(|telemetry| { + telemetry.verify_blame_proof_success_total = + telemetry.verify_blame_proof_success_total.saturating_add(1); + }); + } + + Ok(BlameProofVerificationResult { + session_id: request.session_id, + from_attempt_number: request.from_attempt_number, + accused_member_identifier: request.accused_member_identifier, + reason, + verified, + transcript_hash, + detail, + }) +} + +pub fn quarantine_status( + request: QuarantineStatusRequest, +) -> Result { + enforce_provenance_gate()?; + if request.operator_identifier == 0 { + return Err(EngineError::Validation( + "operator_identifier must be non-zero".to_string(), + )); + } + + let auto_quarantine_config = load_auto_quarantine_config()?; + let guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + let fault_score = guard + .operator_fault_scores + .get(&request.operator_identifier) + .copied() + .unwrap_or(0); + let quarantined = guard + .quarantined_operator_identifiers + .contains(&request.operator_identifier); + let dao_override_allowlisted = auto_quarantine_config.as_ref().is_some_and(|config| { + config + .dao_allowlist_identifiers + .contains(&request.operator_identifier) + }); + + Ok(QuarantineStatusResult { + operator_identifier: request.operator_identifier, + auto_quarantine_enabled: auto_quarantine_config.is_some(), + fault_score, + quarantine_threshold: auto_quarantine_config + .as_ref() + .map(|config| config.fault_threshold) + .unwrap_or(0), + quarantined: quarantined && !dao_override_allowlisted, + dao_override_allowlisted, + }) +} + +#[cfg(any(test, feature = "bench-restart-hook"))] +pub fn reload_state_from_storage_for_benchmarks() -> Result<(), EngineError> { + if !bench_restart_hook_enabled() { + return Err(EngineError::Validation(format!( + "benchmark restart hook disabled; set {}=true to enable", + TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK_ENV + ))); + } + + if let Ok(mut lock_slot) = state_file_lock_slot().lock() { + *lock_slot = None; + } + ensure_state_file_lock()?; + + let loaded_state = load_engine_state_from_storage()?; + let state = state()?; + let mut guard = state + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + *guard = loaded_state; + Ok(()) +} + +fn corrupted_state_backup_prefix(path: &Path) -> String { + let state_filename = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| TBTC_SIGNER_DEFAULT_STATE_FILENAME.to_string()); + format!("{state_filename}.corrupt-") +} + +fn corrupted_state_backup_path(path: &Path) -> PathBuf { + let backup_prefix = corrupted_state_backup_prefix(path); + let backup_filename = format!( + "{}{}-{}", + backup_prefix, + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0), + std::process::id() + ); + + if let Some(parent) = path.parent() { + parent.join(&backup_filename) + } else { + PathBuf::from(backup_filename) + } +} + +fn sorted_corrupted_state_backups(path: &Path) -> Result, EngineError> { + let Some(parent) = path.parent() else { + return Ok(Vec::new()); + }; + let backup_prefix = corrupted_state_backup_prefix(path); + + let mut backups = fs::read_dir(parent) + .map_err(|e| { + EngineError::Internal(format!( + "failed to read signer state directory [{}] for backup retention: {e}", + parent.display() + )) + })? + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if !file_name.starts_with(&backup_prefix) { + return None; + } + + let modified = entry + .metadata() + .ok() + .and_then(|metadata| metadata.modified().ok()) + .unwrap_or(UNIX_EPOCH); + Some((entry.path(), modified)) + }) + .collect::>(); + + backups.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| right.0.cmp(&left.0))); + + Ok(backups.into_iter().map(|(path, _)| path).collect()) +} + +fn enforce_corrupted_state_backup_retention(path: &Path) -> Result<(), EngineError> { + let backup_limit = state_corrupt_backup_limit(); + if backup_limit == 0 { + return Ok(()); + } + + let backup_paths = sorted_corrupted_state_backups(path)?; + if backup_paths.len() <= backup_limit { + return Ok(()); + } + + for backup_path in backup_paths.into_iter().skip(backup_limit) { + fs::remove_file(&backup_path).map_err(|e| { + EngineError::Internal(format!( + "failed to evict old corrupted signer state backup [{}]: {e}", + backup_path.display() + )) + })?; + } + + Ok(()) +} + +fn recover_or_fail_from_corrupted_state_file( + path: &Path, + reason: String, +) -> Result { + match state_corruption_policy() { + CorruptStatePolicy::FailClosed => Err(EngineError::Internal(format!( + "{reason}; refusing to continue with corrupted signer state file [{}]. \ +set {}={} to quarantine the file and continue with clean state", + path.display(), + TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV, + TBTC_SIGNER_STATE_CORRUPTION_POLICY_QUARANTINE_AND_RESET + ))), + CorruptStatePolicy::QuarantineAndReset => { + let backup_path = corrupted_state_backup_path(path); + fs::rename(path, &backup_path).map_err(|e| { + EngineError::Internal(format!( + "failed to quarantine corrupted signer state file [{}] to [{}]: {e}", + path.display(), + backup_path.display() + )) + })?; + + eprintln!( + "warning: quarantined corrupted signer state file [{}] to [{}]: {}", + path.display(), + backup_path.display(), + reason + ); + enforce_corrupted_state_backup_retention(path)?; + Ok(EngineState::default()) + } + } +} + +fn signer_profile_is_production() -> bool { + let raw = std::env::var(TBTC_SIGNER_PROFILE_ENV).unwrap_or_default(); + let normalized = raw.trim().to_ascii_lowercase(); + match normalized.as_str() { + TBTC_SIGNER_PROFILE_PRODUCTION | "" => true, + TBTC_SIGNER_PROFILE_DEVELOPMENT => false, + other => panic!( + "{} must be '{}' or '{}'; got {:?}", + TBTC_SIGNER_PROFILE_ENV, + TBTC_SIGNER_PROFILE_PRODUCTION, + TBTC_SIGNER_PROFILE_DEVELOPMENT, + other + ), + } +} + +fn state_key_command_timeout_secs() -> u64 { + std::env::var(TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| { + *value >= TBTC_SIGNER_MIN_STATE_KEY_COMMAND_TIMEOUT_SECS + && *value <= TBTC_SIGNER_MAX_STATE_KEY_COMMAND_TIMEOUT_SECS + }) + .unwrap_or(TBTC_SIGNER_DEFAULT_STATE_KEY_COMMAND_TIMEOUT_SECS) +} + +fn decode_state_encryption_key_hex( + mut raw_key_hex: String, + source_label: &str, +) -> Result, EngineError> { + let key_len = raw_key_hex.trim().len(); + if key_len != 64 { + raw_key_hex.zeroize(); + return Err(EngineError::Internal(format!( + "state encryption key from [{}] must be exactly 64 hex chars (32 bytes)", + source_label + ))); + } + let trimmed_key_hex = raw_key_hex.trim().to_string(); + raw_key_hex.zeroize(); + + let decode_result = hex::decode(&trimmed_key_hex); + let mut trimmed_key_hex = trimmed_key_hex; + trimmed_key_hex.zeroize(); + let mut key_bytes = decode_result.map_err(|_| { + EngineError::Internal(format!( + "state encryption key from [{}] must be valid hex", + source_label + )) + })?; + + if key_bytes.len() != 32 { + key_bytes.zeroize(); + return Err(EngineError::Internal(format!( + "state encryption key from [{}] must decode to exactly 32 bytes", + source_label + ))); + } + + let mut key = [0u8; 32]; + key.copy_from_slice(&key_bytes); + key_bytes.zeroize(); + Ok(Zeroizing::new(key)) +} + +fn state_key_identifier(key: &[u8; 32]) -> String { + format!("sha256:{}", hex::encode(hash_bytes(key))) +} + +fn push_aad_field(aad: &mut Vec, label: &[u8], value: &[u8]) { + aad.extend_from_slice(&(label.len() as u32).to_be_bytes()); + aad.extend_from_slice(label); + aad.extend_from_slice(&(value.len() as u32).to_be_bytes()); + aad.extend_from_slice(value); +} + +fn encrypted_state_envelope_aad( + schema_version: u16, + encryption_algorithm: &str, + key_provider: &str, + key_id: &str, + nonce: &str, +) -> Vec { + let mut aad = Vec::new(); + push_aad_field(&mut aad, b"schema_version", &schema_version.to_be_bytes()); + push_aad_field( + &mut aad, + b"encryption_algorithm", + encryption_algorithm.as_bytes(), + ); + push_aad_field(&mut aad, b"key_provider", key_provider.as_bytes()); + push_aad_field(&mut aad, b"key_id", key_id.as_bytes()); + push_aad_field(&mut aad, b"nonce", nonce.as_bytes()); + aad +} + +fn drain_command_pipe(mut pipe: R) -> mpsc::Receiver>> +where + R: Read + Send + 'static, +{ + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let result = match pipe.read_to_end(&mut bytes) { + Ok(_) => Ok(bytes), + Err(err) => { + bytes.zeroize(); + Err(err) + } + }; + if let Err(mpsc::SendError(Ok(mut bytes))) = sender.send(result) { + bytes.zeroize(); + } + }); + receiver +} + +fn read_command_pipe( + receiver: mpsc::Receiver>>, + stream_name: &str, + timeout: Duration, +) -> Result, EngineError> { + match receiver.recv_timeout(timeout) { + Ok(Ok(bytes)) => Ok(bytes), + Ok(Err(e)) => Err(EngineError::Internal(format!( + "failed to read state key command {stream_name}: {e}" + ))), + Err(mpsc::RecvTimeoutError::Timeout) => Err(EngineError::Internal(format!( + "state key command {stream_name} pipe timed out waiting for EOF" + ))), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(EngineError::Internal(format!( + "state key command {stream_name} reader exited without a result" + ))), + } +} + +fn zeroize_command_pipe_if_ready(receiver: mpsc::Receiver>>) { + if let Ok(Ok(mut bytes)) = receiver.try_recv() { + bytes.zeroize(); + } +} + +#[cfg(unix)] +fn configure_state_key_command_process_group(command: &mut std::process::Command) { + unsafe { + command.pre_exec(|| { + if libc::setpgid(0, 0) == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + }); + } +} + +#[cfg(not(unix))] +fn configure_state_key_command_process_group(_command: &mut std::process::Command) {} + +#[cfg(unix)] +fn kill_state_key_command_process_group(child_id: u32) { + let pgid = -(child_id as i32); + unsafe { + let _ = libc::kill(pgid, libc::SIGKILL); + } +} + +#[cfg(not(unix))] +fn kill_state_key_command_process_group(_child_id: u32) {} + +fn terminate_state_key_command(child: &mut std::process::Child, child_id: u32) { + kill_state_key_command_process_group(child_id); + let _ = child.kill(); + let _ = child.wait(); +} + +fn remaining_timeout(deadline: Instant) -> Duration { + deadline + .checked_duration_since(Instant::now()) + .unwrap_or(Duration::ZERO) +} + +fn execute_state_key_command(command_spec: &str) -> Result { + let timeout_secs = state_key_command_timeout_secs(); + let timeout = Duration::from_secs(timeout_secs); + let deadline = Instant::now() + timeout; + let mut command = std::process::Command::new("/bin/sh"); + command + .arg("-c") + .arg(command_spec) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + configure_state_key_command_process_group(&mut command); + + let mut child = command.spawn().map_err(|e| { + EngineError::Internal(format!( + "failed to execute state key command from [{}]: {e}", + TBTC_SIGNER_STATE_KEY_COMMAND_ENV + )) + })?; + let child_id = child.id(); + let stdout = child.stdout.take().ok_or_else(|| { + EngineError::Internal("state key command stdout pipe unavailable".to_string()) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + EngineError::Internal("state key command stderr pipe unavailable".to_string()) + })?; + let stdout_receiver = drain_command_pipe(stdout); + let stderr_receiver = drain_command_pipe(stderr); + let started_at = Instant::now(); + + loop { + match child.try_wait().map_err(|e| { + EngineError::Internal(format!( + "failed while waiting for state key command from [{}]: {e}", + TBTC_SIGNER_STATE_KEY_COMMAND_ENV + )) + })? { + Some(status) => { + let stdout_result = + read_command_pipe(stdout_receiver, "stdout", remaining_timeout(deadline)); + let stdout = match stdout_result { + Ok(stdout) => stdout, + Err(err) => { + terminate_state_key_command(&mut child, child_id); + zeroize_command_pipe_if_ready(stderr_receiver); + return Err(err); + } + }; + let stderr_result = + read_command_pipe(stderr_receiver, "stderr", remaining_timeout(deadline)); + let stderr = match stderr_result { + Ok(stderr) => stderr, + Err(err) => { + let mut stdout = stdout; + stdout.zeroize(); + terminate_state_key_command(&mut child, child_id); + return Err(err); + } + }; + return Ok(Output { + status, + stdout, + stderr, + }); + } + None => { + if started_at.elapsed() >= Duration::from_secs(timeout_secs) { + terminate_state_key_command(&mut child, child_id); + zeroize_command_pipe_if_ready(stdout_receiver); + zeroize_command_pipe_if_ready(stderr_receiver); + return Err(EngineError::Internal(format!( + "state key command from [{}] timed out after [{}] seconds", + TBTC_SIGNER_STATE_KEY_COMMAND_ENV, timeout_secs + ))); + } + std::thread::sleep(Duration::from_millis(25)); + } + } + } +} + +fn state_encryption_key_material() -> Result { + let provider = std::env::var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV) + .map(|value| value.trim().to_ascii_lowercase()) + .unwrap_or_else(|_| TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT.to_string()); + + match provider.as_str() { + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT => { + if signer_profile_is_production() { + return Err(EngineError::Internal(format!( + "state key provider [{}] is not allowed in profile [{}]; configure [{}]={} with [{}] returning a 32-byte hex key sourced from KMS/HSM", + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT, + TBTC_SIGNER_PROFILE_PRODUCTION, + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND, + TBTC_SIGNER_STATE_KEY_COMMAND_ENV + ))); + } + + let raw_key_hex = + std::env::var(TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV).map_err(|_| { + EngineError::Internal(format!( + "missing required state encryption key env [{}]", + TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV + )) + })?; + let key = decode_state_encryption_key_hex( + raw_key_hex, + TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV, + )?; + let key_id = state_key_identifier(&key); + Ok(StateEncryptionKeyMaterial { + key, + key_provider: TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT, + key_id, + }) + } + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND => { + let command_spec = std::env::var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV).map_err(|_| { + EngineError::Internal(format!( + "missing required state key command env [{}]", + TBTC_SIGNER_STATE_KEY_COMMAND_ENV + )) + })?; + if command_spec.trim().is_empty() { + return Err(EngineError::Internal(format!( + "state key command env [{}] must be non-empty", + TBTC_SIGNER_STATE_KEY_COMMAND_ENV + ))); + } + + let mut output = execute_state_key_command(&command_spec)?; + + if !output.status.success() { + output.stdout.zeroize(); + output.stderr.zeroize(); + return Err(EngineError::Internal(format!( + "state key command from [{}] exited with non-zero status [{}]", + TBTC_SIGNER_STATE_KEY_COMMAND_ENV, output.status + ))); + } + + let command_stdout_bytes = std::mem::take(&mut output.stdout); + output.stderr.zeroize(); + let mut command_stdout = String::from_utf8(command_stdout_bytes).map_err(|error| { + let mut command_stdout_raw = error.into_bytes(); + command_stdout_raw.zeroize(); + EngineError::Internal(format!( + "state key command from [{}] must output UTF-8 hex key bytes", + TBTC_SIGNER_STATE_KEY_COMMAND_ENV + )) + })?; + let key = decode_state_encryption_key_hex( + std::mem::take(&mut command_stdout), + TBTC_SIGNER_STATE_KEY_COMMAND_ENV, + )?; + command_stdout.zeroize(); + let key_id = state_key_identifier(&key); + Ok(StateEncryptionKeyMaterial { + key, + key_provider: TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND, + key_id, + }) + } + _ => Err(EngineError::Internal(format!( + "unsupported state key provider [{}]; expected [{}] or [{}]", + provider, + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT, + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND + ))), + } +} + +fn encode_encrypted_state_envelope( + persisted: &PersistedEngineState, +) -> Result>, EngineError> { + let mut plaintext = Zeroizing::new( + serde_json::to_vec(persisted) + .map_err(|e| EngineError::Internal(format!("failed to encode signer state: {e}")))?, + ); + let key_material = state_encryption_key_material()?; + let cipher = XChaCha20Poly1305::new_from_slice(&key_material.key[..]).map_err(|e| { + EngineError::Internal(format!("failed to initialize state encryption cipher: {e}")) + })?; + + let mut nonce_bytes = [0u8; TBTC_SIGNER_STATE_ENVELOPE_NONCE_BYTES]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = XNonce::from_slice(&nonce_bytes); + let nonce_hex = hex::encode(nonce_bytes); + let schema_version = PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION; + let encryption_algorithm = TBTC_SIGNER_STATE_ENCRYPTION_ALGORITHM_XCHACHA20POLY1305; + let key_provider = key_material.key_provider.to_string(); + let key_id = key_material.key_id; + let aad = encrypted_state_envelope_aad( + schema_version, + encryption_algorithm, + &key_provider, + &key_id, + &nonce_hex, + ); + + let mut ciphertext_and_tag = cipher + .encrypt( + nonce, + Payload { + msg: plaintext.as_ref(), + aad: &aad, + }, + ) + .map_err(|e| { + EngineError::Internal(format!("failed to encrypt signer state payload: {e}")) + })?; + plaintext.zeroize(); + + if ciphertext_and_tag.len() < TBTC_SIGNER_STATE_ENVELOPE_AUTH_TAG_BYTES { + ciphertext_and_tag.zeroize(); + return Err(EngineError::Internal( + "encrypted signer state payload shorter than authentication tag".to_string(), + )); + } + + let mut authentication_tag = ciphertext_and_tag + .split_off(ciphertext_and_tag.len() - TBTC_SIGNER_STATE_ENVELOPE_AUTH_TAG_BYTES); + let envelope = PersistedEncryptedEngineStateEnvelope { + schema_version, + encryption_algorithm: encryption_algorithm.to_string(), + key_provider, + key_id, + nonce: nonce_hex, + ciphertext: hex::encode(&ciphertext_and_tag), + authentication_tag: hex::encode(&authentication_tag), + }; + ciphertext_and_tag.zeroize(); + authentication_tag.zeroize(); + + let serialized = serde_json::to_vec(&envelope).map_err(|e| { + EngineError::Internal(format!( + "failed to encode encrypted signer state envelope: {e}" + )) + })?; + Ok(Zeroizing::new(serialized)) +} + +fn decode_encrypted_state_envelope( + mut envelope: PersistedEncryptedEngineStateEnvelope, +) -> Result { + if envelope.schema_version != PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION + && envelope.schema_version != PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION_V2 + { + return Err(EngineError::Internal(format!( + "unsupported encrypted signer state schema version: expected [{}] or [{}], got [{}]", + PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION, + PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION_V2, + envelope.schema_version + ))); + } + if envelope.encryption_algorithm != TBTC_SIGNER_STATE_ENCRYPTION_ALGORITHM_XCHACHA20POLY1305 { + return Err(EngineError::Internal(format!( + "unsupported state encryption algorithm [{}]", + envelope.encryption_algorithm + ))); + } + let envelope_aad = if envelope.schema_version == PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION { + Some(encrypted_state_envelope_aad( + envelope.schema_version, + &envelope.encryption_algorithm, + &envelope.key_provider, + &envelope.key_id, + &envelope.nonce, + )) + } else { + None + }; + let nonce_decode = hex::decode(&envelope.nonce); + envelope.nonce.zeroize(); + let mut nonce_bytes = nonce_decode + .map_err(|_| EngineError::Internal("invalid envelope nonce hex".to_string()))?; + if nonce_bytes.len() != TBTC_SIGNER_STATE_ENVELOPE_NONCE_BYTES { + nonce_bytes.zeroize(); + return Err(EngineError::Internal(format!( + "invalid envelope nonce size: expected [{}], got [{}]", + TBTC_SIGNER_STATE_ENVELOPE_NONCE_BYTES, + nonce_bytes.len() + ))); + } + + let ciphertext_decode = hex::decode(&envelope.ciphertext); + envelope.ciphertext.zeroize(); + let mut ciphertext = ciphertext_decode + .map_err(|_| EngineError::Internal("invalid envelope ciphertext hex".to_string()))?; + let auth_tag_decode = hex::decode(&envelope.authentication_tag); + envelope.authentication_tag.zeroize(); + let mut authentication_tag = auth_tag_decode.map_err(|_| { + EngineError::Internal("invalid envelope authentication_tag hex".to_string()) + })?; + if authentication_tag.len() != TBTC_SIGNER_STATE_ENVELOPE_AUTH_TAG_BYTES { + ciphertext.zeroize(); + authentication_tag.zeroize(); + nonce_bytes.zeroize(); + return Err(EngineError::Internal(format!( + "invalid envelope authentication tag size: expected [{}], got [{}]", + TBTC_SIGNER_STATE_ENVELOPE_AUTH_TAG_BYTES, + authentication_tag.len() + ))); + } + + let key_material = state_encryption_key_material()?; + if envelope.key_provider != key_material.key_provider { + ciphertext.zeroize(); + authentication_tag.zeroize(); + nonce_bytes.zeroize(); + return Err(EngineError::Internal(format!( + "state key provider mismatch: envelope [{}], configured [{}]", + envelope.key_provider, key_material.key_provider + ))); + } + let allows_legacy_env_key_id = envelope.schema_version + == PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION_V2 + && envelope.key_provider == TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT + && envelope.key_id == TBTC_SIGNER_STATE_KEY_ID_LEGACY_ENV_HEX; + if envelope.key_id != key_material.key_id && !allows_legacy_env_key_id { + ciphertext.zeroize(); + authentication_tag.zeroize(); + nonce_bytes.zeroize(); + return Err(EngineError::Internal(format!( + "state key identifier mismatch: envelope [{}], configured [{}]", + envelope.key_id, key_material.key_id + ))); + } + let cipher = XChaCha20Poly1305::new_from_slice(&key_material.key[..]).map_err(|e| { + EngineError::Internal(format!("failed to initialize state encryption cipher: {e}")) + })?; + + ciphertext.extend_from_slice(&authentication_tag); + authentication_tag.zeroize(); + + let nonce = XNonce::from_slice(&nonce_bytes); + let decrypted = if let Some(aad) = envelope_aad { + cipher.decrypt( + nonce, + Payload { + msg: ciphertext.as_ref(), + aad: &aad, + }, + ) + } else { + cipher.decrypt(nonce, ciphertext.as_ref()) + } + .map_err(|e| EngineError::Internal(format!("failed to decrypt signer state envelope: {e}")))?; + ciphertext.zeroize(); + nonce_bytes.zeroize(); + let plaintext = Zeroizing::new(decrypted); + serde_json::from_slice(&plaintext) + .map_err(|e| EngineError::Internal(format!("failed to decode decrypted signer state: {e}"))) +} + +fn decode_persisted_state_storage_format( + bytes: &[u8], +) -> Result { + if let Ok(envelope) = serde_json::from_slice::(bytes) { + let should_rewrite = envelope.schema_version != PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION + || envelope.key_id == TBTC_SIGNER_STATE_KEY_ID_LEGACY_ENV_HEX; + let persisted = decode_encrypted_state_envelope(envelope)?; + return Ok(PersistedStateStorageFormat::EncryptedEnvelope { + persisted, + should_rewrite, + }); + } + + let persisted = serde_json::from_slice::(bytes).map_err(|e| { + EngineError::Internal(format!("failed to decode signer state file payload: {e}")) + })?; + Ok(PersistedStateStorageFormat::LegacyPlaintext(persisted)) +} + +fn load_engine_state_from_storage() -> Result { + let path = active_state_file_path()?; + if !path.exists() { + return Ok(EngineState::default()); + } + + let mut bytes = fs::read(&path).map_err(|e| { + EngineError::Internal(format!( + "failed to read signer state file [{}]: {e}", + path.display() + )) + })?; + if bytes.is_empty() { + eprintln!( + "warning: signer state file [{}] exists but is empty; initializing with clean state", + path.display() + ); + bytes.zeroize(); + return Ok(EngineState::default()); + } + + let decoded_format = decode_persisted_state_storage_format(&bytes); + bytes.zeroize(); + let (persisted, should_rewrite_state): (PersistedEngineState, bool) = match decoded_format { + Ok(PersistedStateStorageFormat::EncryptedEnvelope { + persisted, + should_rewrite, + }) => (persisted, should_rewrite), + Ok(PersistedStateStorageFormat::LegacyPlaintext(persisted)) => (persisted, true), + Err(e) => { + return recover_or_fail_from_corrupted_state_file( + &path, + format!( + "failed to decode signer state file [{}]: {e}", + path.display() + ), + ) + } + }; + + let engine_state: EngineState = persisted.try_into().or_else(|e| { + recover_or_fail_from_corrupted_state_file( + &path, + format!( + "failed to validate signer state file [{}]: {e}", + path.display() + ), + ) + })?; + + if should_rewrite_state && path.exists() { + persist_engine_state_to_storage(&engine_state).map_err(|e| { + EngineError::Internal(format!( + "loaded legacy signer state file [{}] but failed to migrate to current encrypted envelope: {e}", + path.display() + )) + })?; + } + + Ok(engine_state) +} + +#[cfg(test)] +fn persist_fault_injection_label(point: PersistFaultInjectionPoint) -> &'static str { + match point { + PersistFaultInjectionPoint::AfterTempSyncBeforeRename => "after_temp_sync_before_rename", + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync => { + "after_rename_before_directory_sync" + } + } +} + +fn maybe_inject_persist_fault(point: PersistFaultInjectionPoint) -> Result<(), EngineError> { + #[cfg(test)] + { + let slot = PERSIST_FAULT_INJECTION_POINT.get_or_init(|| Mutex::new(None)); + let configured_point = *slot.lock().map_err(|_| { + EngineError::Internal("persist fault injection mutex poisoned".to_string()) + })?; + if configured_point == Some(point) { + return Err(EngineError::Internal(format!( + "injected persist fault at [{}]", + persist_fault_injection_label(point) + ))); + } + } + + #[cfg(not(test))] + let _ = point; + + Ok(()) +} + +#[cfg(test)] +fn set_persist_fault_injection_for_tests(point: PersistFaultInjectionPoint) { + if let Ok(mut slot) = PERSIST_FAULT_INJECTION_POINT + .get_or_init(|| Mutex::new(None)) + .lock() + { + *slot = Some(point); + } +} + +#[cfg(test)] +fn clear_persist_fault_injection_for_tests() { + if let Ok(mut slot) = PERSIST_FAULT_INJECTION_POINT + .get_or_init(|| Mutex::new(None)) + .lock() + { + *slot = None; + } +} + +fn persist_engine_state_to_storage(engine_state: &EngineState) -> Result<(), EngineError> { + let path = active_state_file_path()?; + let persisted: PersistedEngineState = engine_state.try_into()?; + let mut bytes = encode_encrypted_state_envelope(&persisted)?; + drop(persisted); + let temp_path = path.with_extension(format!("tmp-{}", std::process::id())); + let persist_result = (|| -> Result<(), EngineError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| { + EngineError::Internal(format!( + "failed to create signer state directory [{}]: {e}", + parent.display() + )) + })?; + } + + { + let mut temp_file = { + let mut options = fs::OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + options.mode(0o600); + options.open(&temp_path).map_err(|e| { + EngineError::Internal(format!( + "failed to open signer state temp file [{}]: {e}", + temp_path.display() + )) + })? + }; + temp_file.write_all(bytes.as_ref()).map_err(|e| { + EngineError::Internal(format!( + "failed to write signer state temp file [{}]: {e}", + temp_path.display() + )) + })?; + temp_file.sync_all().map_err(|e| { + EngineError::Internal(format!( + "failed to sync signer state temp file [{}]: {e}", + temp_path.display() + )) + })?; + } + maybe_inject_persist_fault(PersistFaultInjectionPoint::AfterTempSyncBeforeRename)?; + + fs::rename(&temp_path, &path).map_err(|e| { + EngineError::Internal(format!( + "failed to move signer state temp file [{}] to [{}]: {e}", + temp_path.display(), + path.display() + )) + })?; + maybe_inject_persist_fault(PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync)?; + + if let Some(parent) = path.parent() { + let directory = fs::File::open(parent).map_err(|e| { + EngineError::Internal(format!( + "failed to open signer state directory [{}] for sync: {e}", + parent.display() + )) + })?; + directory.sync_all().map_err(|e| { + EngineError::Internal(format!( + "failed to sync signer state directory [{}]: {e}", + parent.display() + )) + })?; + } + + Ok(()) + })(); + + if persist_result.is_err() { + let _ = fs::remove_file(&temp_path); + } + + bytes.zeroize(); + persist_result +} + +impl TryFrom for EngineState { + type Error = EngineError; + + fn try_from(persisted: PersistedEngineState) -> Result { + if persisted.schema_version != PERSISTED_STATE_SCHEMA_VERSION { + return Err(EngineError::Internal(format!( + "unsupported signer state schema version: expected [{}], got [{}]", + PERSISTED_STATE_SCHEMA_VERSION, persisted.schema_version + ))); + } + + let mut sessions = HashMap::new(); + for (session_id, session_state) in persisted.sessions { + sessions.insert(session_id, session_state.try_into()?); + } + ensure_session_registry_persisted_bound(sessions.len())?; + let mut quarantined_operator_identifiers = HashSet::new(); + for operator_identifier in persisted.quarantined_operator_identifiers { + if operator_identifier == 0 { + return Err(EngineError::Internal( + "persisted quarantined operator identifier must be non-zero".to_string(), + )); + } + if !quarantined_operator_identifiers.insert(operator_identifier) { + return Err(EngineError::Internal(format!( + "duplicate persisted quarantined operator identifier [{}]", + operator_identifier + ))); + } + } + for operator_identifier in persisted.operator_fault_scores.keys() { + if *operator_identifier == 0 { + return Err(EngineError::Internal( + "persisted operator fault score identifier must be non-zero".to_string(), + )); + } + } + let canary_rollout = persisted.canary_rollout; + if !matches!(canary_rollout.current_percent, 10 | 50 | 100) { + return Err(EngineError::Internal(format!( + "persisted canary current_percent [{}] must be one of [10, 50, 100]", + canary_rollout.current_percent + ))); + } + if !matches!(canary_rollout.previous_percent, 10 | 50 | 100) { + return Err(EngineError::Internal(format!( + "persisted canary previous_percent [{}] must be one of [10, 50, 100]", + canary_rollout.previous_percent + ))); + } + if canary_rollout.config_version == 0 { + return Err(EngineError::Internal( + "persisted canary config_version must be positive".to_string(), + )); + } + + Ok(EngineState { + sessions, + refresh_epoch_counter: persisted.refresh_epoch_counter, + operator_fault_scores: persisted.operator_fault_scores, + quarantined_operator_identifiers, + canary_rollout, + }) + } +} + +impl TryFrom<&EngineState> for PersistedEngineState { + type Error = EngineError; + + fn try_from(engine_state: &EngineState) -> Result { + ensure_session_registry_persisted_bound(engine_state.sessions.len())?; + let mut sessions = HashMap::new(); + for (session_id, session_state) in &engine_state.sessions { + sessions.insert(session_id.clone(), session_state.try_into()?); + } + let mut quarantined_operator_identifiers = engine_state + .quarantined_operator_identifiers + .iter() + .copied() + .collect::>(); + quarantined_operator_identifiers.sort_unstable(); + + Ok(PersistedEngineState { + schema_version: PERSISTED_STATE_SCHEMA_VERSION, + sessions, + refresh_epoch_counter: engine_state.refresh_epoch_counter, + operator_fault_scores: engine_state.operator_fault_scores.clone(), + quarantined_operator_identifiers, + canary_rollout: engine_state.canary_rollout.clone(), + }) + } +} + +impl TryFrom for SessionState { + type Error = EngineError; + + fn try_from(persisted: PersistedSessionState) -> Result { + let dkg_key_packages = persisted + .dkg_key_packages + .map(|persisted_key_packages| { + let mut key_packages = BTreeMap::new(); + + for persisted_key_package in persisted_key_packages { + let identifier = persisted_key_package.identifier; + if identifier == 0 { + return Err(EngineError::Internal( + "persisted key package identifier must be non-zero".to_string(), + )); + } + + let key_package_bytes_result = + hex::decode(persisted_key_package.key_package_hex.as_str()); + let mut key_package_bytes = key_package_bytes_result.map_err(|_| { + EngineError::Internal(format!( + "failed to decode persisted key package for identifier [{}]", + identifier + )) + })?; + let key_package_result = + frost::keys::KeyPackage::deserialize(&key_package_bytes); + key_package_bytes.zeroize(); + let key_package = key_package_result.map_err(|e| { + EngineError::Internal(format!( + "failed to deserialize persisted key package for identifier [{}]: {e}", + identifier + )) + })?; + + if key_packages.insert(identifier, key_package).is_some() { + return Err(EngineError::Internal(format!( + "duplicate persisted key package identifier [{}]", + identifier + ))); + } + } + + Ok(key_packages) + }) + .transpose()?; + + let dkg_public_key_package = persisted + .dkg_public_key_package_hex + .map(|mut public_key_package_hex| { + let public_key_package_bytes_result = hex::decode(&public_key_package_hex); + public_key_package_hex.zeroize(); + let mut public_key_package_bytes = + public_key_package_bytes_result.map_err(|_| { + EngineError::Internal( + "failed to decode persisted DKG public key package".to_string(), + ) + })?; + let public_key_package_result = + frost::keys::PublicKeyPackage::deserialize(&public_key_package_bytes); + public_key_package_bytes.zeroize(); + public_key_package_result.map_err(|e| { + EngineError::Internal(format!( + "failed to deserialize persisted DKG public key package: {e}" + )) + }) + }) + .transpose()?; + + let sign_message_bytes = persisted + .sign_message_hex + .map(|message_hex| { + let mut sign_message_bytes = hex::decode(message_hex.as_str()).map_err(|_| { + EngineError::Internal("failed to decode persisted sign message".to_string()) + })?; + let secret = Zeroizing::new(std::mem::take(&mut sign_message_bytes)); + sign_message_bytes.zeroize(); + Ok(secret) + }) + .transpose()?; + + let mut consumed_attempt_ids = HashSet::new(); + for attempt_id in persisted.consumed_attempt_ids { + if attempt_id.is_empty() { + return Err(EngineError::Internal( + "persisted consumed attempt ID must be non-empty".to_string(), + )); + } + + if !consumed_attempt_ids.insert(attempt_id.clone()) { + return Err(EngineError::Internal(format!( + "duplicate persisted consumed attempt ID [{}]", + attempt_id + ))); + } + } + ensure_consumed_registry_persisted_bound( + consumed_attempt_ids.len(), + "consumed_attempt_ids", + )?; + + let mut consumed_sign_round_ids = HashSet::new(); + for round_id in persisted.consumed_sign_round_ids { + if round_id.is_empty() { + return Err(EngineError::Internal( + "persisted consumed sign round ID must be non-empty".to_string(), + )); + } + + if !consumed_sign_round_ids.insert(round_id.clone()) { + return Err(EngineError::Internal(format!( + "duplicate persisted consumed sign round ID [{}]", + round_id + ))); + } + } + ensure_consumed_registry_persisted_bound( + consumed_sign_round_ids.len(), + "consumed_sign_round_ids", + )?; + + let mut consumed_finalize_round_ids = HashSet::new(); + for round_id in persisted.consumed_finalize_round_ids { + if round_id.is_empty() { + return Err(EngineError::Internal( + "persisted consumed finalize round ID must be non-empty".to_string(), + )); + } + + if !consumed_finalize_round_ids.insert(round_id.clone()) { + return Err(EngineError::Internal(format!( + "duplicate persisted consumed finalize round ID [{}]", + round_id + ))); + } + } + ensure_consumed_registry_persisted_bound( + consumed_finalize_round_ids.len(), + "consumed_finalize_round_ids", + )?; + + let mut consumed_finalize_request_fingerprints = HashSet::new(); + for request_fingerprint in persisted.consumed_finalize_request_fingerprints { + if request_fingerprint.is_empty() { + return Err(EngineError::Internal( + "persisted consumed finalize request fingerprint must be non-empty".to_string(), + )); + } + + if !consumed_finalize_request_fingerprints.insert(request_fingerprint.clone()) { + return Err(EngineError::Internal(format!( + "duplicate persisted consumed finalize request fingerprint [{}]", + request_fingerprint + ))); + } + } + ensure_consumed_registry_persisted_bound( + consumed_finalize_request_fingerprints.len(), + "consumed_finalize_request_fingerprints", + )?; + if persisted.attempt_transition_records.len() + > TBTC_SIGNER_MAX_ATTEMPT_TRANSITION_RECORDS_PER_SESSION + { + return Err(EngineError::Internal(format!( + "persisted attempt_transition_records size [{}] exceeds max [{}]", + persisted.attempt_transition_records.len(), + TBTC_SIGNER_MAX_ATTEMPT_TRANSITION_RECORDS_PER_SESSION + ))); + } + let mut last_refresh_epoch = 0_u64; + for refresh_record in &persisted.refresh_history { + if refresh_record.refresh_epoch == 0 { + return Err(EngineError::Internal( + "persisted refresh_history refresh_epoch must be positive".to_string(), + )); + } + if refresh_record.refresh_epoch <= last_refresh_epoch { + return Err(EngineError::Internal( + "persisted refresh_history refresh_epoch must be strictly increasing" + .to_string(), + )); + } + last_refresh_epoch = refresh_record.refresh_epoch; + } + if let Some(emergency_rekey_event) = persisted.emergency_rekey_event.as_ref() { + if emergency_rekey_event.reason.trim().is_empty() { + return Err(EngineError::Internal( + "persisted emergency_rekey_event reason must be non-empty".to_string(), + )); + } + } + + Ok(SessionState { + dkg_request_fingerprint: persisted.dkg_request_fingerprint, + dkg_key_packages, + dkg_public_key_package, + dkg_result: persisted.dkg_result, + sign_request_fingerprint: persisted.sign_request_fingerprint, + sign_message_bytes, + round_state: persisted.round_state, + active_attempt_context: persisted.active_attempt_context, + attempt_transition_records: persisted.attempt_transition_records, + consumed_attempt_ids, + consumed_sign_round_ids, + finalize_request_fingerprint: persisted.finalize_request_fingerprint, + signature_result: persisted.signature_result, + consumed_finalize_round_ids, + consumed_finalize_request_fingerprints, + build_tx_request_fingerprint: persisted.build_tx_request_fingerprint, + tx_result: persisted.tx_result, + refresh_request_fingerprint: persisted.refresh_request_fingerprint, + refresh_result: persisted.refresh_result, + refresh_history: persisted.refresh_history, + emergency_rekey_event: persisted.emergency_rekey_event, + }) + } +} + +impl TryFrom<&SessionState> for PersistedSessionState { + type Error = EngineError; + + fn try_from(session_state: &SessionState) -> Result { + let dkg_key_packages = session_state + .dkg_key_packages + .as_ref() + .map(|key_packages| { + key_packages + .iter() + .map(|(identifier, key_package)| { + let mut key_package_bytes = key_package.serialize().map_err(|e| { + EngineError::Internal(format!( + "failed to serialize DKG key package for identifier [{}]: {e}", + identifier + )) + })?; + let key_package_hex = Zeroizing::new(hex::encode(&key_package_bytes)); + key_package_bytes.zeroize(); + Ok(PersistedKeyPackage { + identifier: *identifier, + key_package_hex, + }) + }) + .collect::, _>>() + }) + .transpose()?; + + let dkg_public_key_package_hex = session_state + .dkg_public_key_package + .as_ref() + .map(|public_key_package| { + let mut public_key_package_bytes = public_key_package.serialize().map_err(|e| { + EngineError::Internal(format!( + "failed to serialize DKG public key package: {e}" + )) + })?; + let public_key_package_hex = hex::encode(&public_key_package_bytes); + public_key_package_bytes.zeroize(); + Ok(public_key_package_hex) + }) + .transpose()?; + + let sign_message_hex = session_state + .sign_message_bytes + .as_ref() + .map(|sign_message_bytes| Zeroizing::new(hex::encode(sign_message_bytes.as_slice()))); + ensure_consumed_registry_persisted_bound( + session_state.consumed_attempt_ids.len(), + "consumed_attempt_ids", + )?; + ensure_consumed_registry_persisted_bound( + session_state.consumed_sign_round_ids.len(), + "consumed_sign_round_ids", + )?; + ensure_consumed_registry_persisted_bound( + session_state.consumed_finalize_round_ids.len(), + "consumed_finalize_round_ids", + )?; + ensure_consumed_registry_persisted_bound( + session_state.consumed_finalize_request_fingerprints.len(), + "consumed_finalize_request_fingerprints", + )?; + if session_state.attempt_transition_records.len() + > TBTC_SIGNER_MAX_ATTEMPT_TRANSITION_RECORDS_PER_SESSION + { + return Err(EngineError::Internal(format!( + "attempt_transition_records size [{}] exceeds max [{}]", + session_state.attempt_transition_records.len(), + TBTC_SIGNER_MAX_ATTEMPT_TRANSITION_RECORDS_PER_SESSION + ))); + } + let mut consumed_attempt_ids = session_state + .consumed_attempt_ids + .iter() + .cloned() + .collect::>(); + consumed_attempt_ids.sort_unstable(); + let mut consumed_sign_round_ids = session_state + .consumed_sign_round_ids + .iter() + .cloned() + .collect::>(); + consumed_sign_round_ids.sort_unstable(); + let mut consumed_finalize_round_ids = session_state + .consumed_finalize_round_ids + .iter() + .cloned() + .collect::>(); + consumed_finalize_round_ids.sort_unstable(); + let mut consumed_finalize_request_fingerprints = session_state + .consumed_finalize_request_fingerprints + .iter() + .cloned() + .collect::>(); + consumed_finalize_request_fingerprints.sort_unstable(); + + Ok(PersistedSessionState { + dkg_request_fingerprint: session_state.dkg_request_fingerprint.clone(), + dkg_key_packages, + dkg_public_key_package_hex, + dkg_result: session_state.dkg_result.clone(), + sign_request_fingerprint: session_state.sign_request_fingerprint.clone(), + sign_message_hex, + round_state: session_state.round_state.clone(), + active_attempt_context: session_state.active_attempt_context.clone(), + attempt_transition_records: session_state.attempt_transition_records.clone(), + consumed_attempt_ids, + consumed_sign_round_ids, + finalize_request_fingerprint: session_state.finalize_request_fingerprint.clone(), + signature_result: session_state.signature_result.clone(), + consumed_finalize_round_ids, + consumed_finalize_request_fingerprints, + build_tx_request_fingerprint: session_state.build_tx_request_fingerprint.clone(), + tx_result: session_state.tx_result.clone(), + refresh_request_fingerprint: session_state.refresh_request_fingerprint.clone(), + refresh_result: session_state.refresh_result.clone(), + refresh_history: session_state.refresh_history.clone(), + emergency_rekey_event: session_state.emergency_rekey_event.clone(), + }) + } +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn hash_hex(bytes: &[u8]) -> String { + hex::encode(hash_bytes(bytes)) +} + +fn hash_bytes(bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(bytes); + let digest = hasher.finalize(); + + let mut output = [0u8; 32]; + output.copy_from_slice(&digest); + output +} + +fn deterministic_seed(parts: &[&[u8]]) -> [u8; 32] { + let mut hasher = Sha256::new(); + for part in parts { + // Length-prefix each part so embedded 0x00 bytes cannot blur boundaries. + hasher.update((part.len() as u64).to_le_bytes()); + hasher.update(part); + } + + let digest = hasher.finalize(); + let mut output = [0u8; 32]; + output.copy_from_slice(&digest); + output +} + +fn ensure_consumed_registry_persisted_bound( + registry_len: usize, + registry_name: &str, +) -> Result<(), EngineError> { + if registry_len > TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION { + return Err(EngineError::Internal(format!( + "persisted {registry_name} registry size [{registry_len}] exceeds max [{}]", + TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION + ))); + } + + Ok(()) +} + +fn ensure_session_registry_persisted_bound(session_count: usize) -> Result<(), EngineError> { + let max_sessions = max_sessions_limit(); + if session_count > max_sessions { + return Err(EngineError::Internal(format!( + "persisted session registry size [{session_count}] exceeds max [{max_sessions}]" + ))); + } + + Ok(()) +} + +fn ensure_session_insert_capacity( + sessions: &HashMap, + session_id: &str, +) -> Result<(), EngineError> { + if sessions.contains_key(session_id) { + return Ok(()); + } + + let max_sessions = max_sessions_limit(); + if sessions.len() >= max_sessions { + return Err(EngineError::Internal(format!( + "session registry size [{}] reached max [{max_sessions}]; use an existing session_id or increase {}", + sessions.len(), + TBTC_SIGNER_MAX_SESSIONS_ENV + ))); + } + + Ok(()) +} + +fn ensure_consumed_registry_insert_capacity( + registry: &HashSet, + entry: &str, + registry_name: &str, + session_id: &str, +) -> Result<(), EngineError> { + if !registry.contains(entry) + && registry.len() >= TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION + { + return Err(EngineError::Internal(format!( + "{registry_name} registry size [{}] reached max [{}] for session [{}]; use a new session_id", + registry.len(), + TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION, + session_id + ))); + } + + Ok(()) +} + +fn ensure_attempt_transition_record_insert_capacity( + records: &[TranscriptAuditRecord], + session_id: &str, +) -> Result<(), EngineError> { + if records.len() >= TBTC_SIGNER_MAX_ATTEMPT_TRANSITION_RECORDS_PER_SESSION { + return Err(EngineError::Internal(format!( + "attempt_transition_records size [{}] reached max [{}] for session [{}]; use a new session_id", + records.len(), + TBTC_SIGNER_MAX_ATTEMPT_TRANSITION_RECORDS_PER_SESSION, + session_id + ))); + } + + Ok(()) +} + +fn participant_identifier_to_frost_identifier( + participant_identifier: u16, +) -> Result { + participant_identifier.try_into().map_err(|e| { + EngineError::Validation(format!( + "invalid participant identifier [{}]: {e}", + participant_identifier + )) + }) +} + +fn frost_identifier_to_go_string(identifier: frost::Identifier) -> String { + serde_json::to_string(&hex::encode(identifier.serialize())) + .expect("serializing hex identifier as JSON string cannot fail") +} + +fn parse_frost_identifier( + operation: &str, + field_name: &str, + raw_identifier: &str, +) -> Result { + if raw_identifier.trim().is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: {field_name} is empty" + ))); + } + + let trimmed = raw_identifier.trim(); + let normalized_hex = if trimmed.starts_with('"') { + serde_json::from_str::(trimmed).map_err(|e| { + EngineError::Validation(format!( + "{operation}: {field_name} must be a JSON string-wrapped hex identifier: {e}" + )) + })? + } else { + trimmed.to_string() + }; + + let bytes = hex::decode(&normalized_hex).map_err(|_| { + EngineError::Validation(format!( + "{operation}: {field_name} must be a hex-encoded FROST identifier" + )) + })?; + + frost::Identifier::deserialize(&bytes) + .map_err(|e| EngineError::Validation(format!("{operation}: invalid {field_name}: {e}"))) +} + +fn decode_hex_field( + operation: &str, + field_name: &str, + value: &str, +) -> Result, EngineError> { + if value.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: {field_name} is empty" + ))); + } + + hex::decode(value).map_err(|_| { + EngineError::Validation(format!("{operation}: {field_name} must be valid hex")) + }) +} + +fn zeroizing_rng_from_os() -> ZeroizingChaCha20Rng { + let mut seed = [0u8; 32]; + OsRng.fill_bytes(&mut seed); + let rng = ZeroizingChaCha20Rng::from_seed(seed); + seed.zeroize(); + rng +} + +fn decode_round1_package_map( + operation: &str, + packages: &[DkgRound1Package], +) -> Result, EngineError> { + if packages.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: round1_packages must not be empty" + ))); + } + + let mut package_map = BTreeMap::new(); + for (index, package) in packages.iter().enumerate() { + let identifier = parse_frost_identifier( + operation, + &format!("round1_packages[{index}].identifier"), + &package.identifier, + )?; + let package_bytes = decode_hex_field( + operation, + &format!("round1_packages[{index}].package_hex"), + &package.package_hex, + )?; + let round1_package = frost::keys::dkg::round1::Package::deserialize(&package_bytes) + .map_err(|e| { + EngineError::Validation(format!( + "{operation}: invalid round1 package [{index}]: {e}" + )) + })?; + + if package_map.insert(identifier, round1_package).is_some() { + return Err(EngineError::Validation(format!( + "{operation}: duplicate round1 package identifier [{}]", + package.identifier + ))); + } + } + + Ok(package_map) +} + +fn decode_round2_package_map( + operation: &str, + packages: &[DkgRound2Package], + expected_recipient: Option, +) -> Result, EngineError> { + if packages.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: round2_packages must not be empty" + ))); + } + + let mut package_map = BTreeMap::new(); + for (index, package) in packages.iter().enumerate() { + let recipient_identifier = parse_frost_identifier( + operation, + &format!("round2_packages[{index}].identifier"), + &package.identifier, + )?; + if let Some(expected_recipient) = expected_recipient { + if recipient_identifier != expected_recipient { + return Err(EngineError::Validation(format!( + "{operation}: round2 package [{index}] recipient identifier does not match local DKG participant" + ))); + } + } + + let sender_identifier = package.sender_identifier.as_ref().ok_or_else(|| { + EngineError::Validation(format!( + "{operation}: round2_packages[{index}].sender_identifier is empty" + )) + })?; + let sender_identifier = parse_frost_identifier( + operation, + &format!("round2_packages[{index}].sender_identifier"), + sender_identifier, + )?; + let mut package_bytes = decode_hex_field( + operation, + &format!("round2_packages[{index}].package_hex"), + &package.package_hex, + )?; + let round2_package_result = frost::keys::dkg::round2::Package::deserialize(&package_bytes); + package_bytes.zeroize(); + let round2_package = round2_package_result.map_err(|e| { + EngineError::Validation(format!( + "{operation}: invalid round2 package [{index}]: {e}" + )) + })?; + + if package_map + .insert(sender_identifier, round2_package) + .is_some() + { + return Err(EngineError::Validation(format!( + "{operation}: duplicate round2 package sender identifier" + ))); + } + } + + Ok(package_map) +} + +fn x_only_verifying_key_hex( + public_key_package: &frost::keys::PublicKeyPackage, +) -> Result { + let compressed = public_key_package + .verifying_key() + .serialize() + .map_err(|e| EngineError::Internal(format!("failed to serialize verifying key: {e}")))?; + + if compressed.len() != 33 || compressed[0] != 0x02 { + return Err(EngineError::Internal( + "expected even-Y compressed FROST verifying key".to_string(), + )); + } + + Ok(hex::encode(&compressed[1..])) +} + +fn native_public_key_package_from_frost( + public_key_package: &frost::keys::PublicKeyPackage, +) -> Result { + let mut verifying_shares = BTreeMap::new(); + for (identifier, verifying_share) in public_key_package.verifying_shares() { + let share_bytes = verifying_share.serialize().map_err(|e| { + EngineError::Internal(format!("failed to serialize verifying share: {e}")) + })?; + verifying_shares.insert( + frost_identifier_to_go_string(*identifier), + hex::encode(share_bytes), + ); + } + + Ok(NativeFrostPublicKeyPackage { + verifying_shares, + verifying_key: x_only_verifying_key_hex(public_key_package)?, + }) +} + +fn native_public_key_package_to_frost( + operation: &str, + public_key_package: &NativeFrostPublicKeyPackage, +) -> Result { + if public_key_package.verifying_key.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: public_key_package.verifying_key is empty" + ))); + } + if public_key_package.verifying_shares.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: public_key_package.verifying_shares is empty" + ))); + } + + let mut verifying_key_bytes = decode_hex_field( + operation, + "public_key_package.verifying_key", + &public_key_package.verifying_key, + )?; + if verifying_key_bytes.len() != 32 { + verifying_key_bytes.zeroize(); + return Err(EngineError::Validation(format!( + "{operation}: public_key_package.verifying_key must be a 32-byte x-only key" + ))); + } + + let mut compressed_verifying_key = Vec::with_capacity(33); + compressed_verifying_key.push(0x02); + compressed_verifying_key.extend_from_slice(&verifying_key_bytes); + verifying_key_bytes.zeroize(); + let verifying_key = + frost::VerifyingKey::deserialize(&compressed_verifying_key).map_err(|e| { + EngineError::Validation(format!( + "{operation}: invalid public_key_package.verifying_key: {e}" + )) + })?; + compressed_verifying_key.zeroize(); + + let mut verifying_shares = BTreeMap::new(); + for (identifier, share_hex) in &public_key_package.verifying_shares { + let identifier = parse_frost_identifier( + operation, + "public_key_package.verifying_shares identifier", + identifier, + )?; + let share_bytes = decode_hex_field( + operation, + "public_key_package.verifying_shares value", + share_hex, + )?; + let verifying_share = + frost::keys::VerifyingShare::deserialize(&share_bytes).map_err(|e| { + EngineError::Validation(format!( + "{operation}: invalid public_key_package verifying share: {e}" + )) + })?; + if verifying_shares + .insert(identifier, verifying_share) + .is_some() + { + return Err(EngineError::Validation(format!( + "{operation}: duplicate public_key_package verifying share identifier" + ))); + } + } + + Ok(frost::keys::PublicKeyPackage::new( + verifying_shares, + verifying_key, + None, + )) +} + +fn decode_key_package( + operation: &str, + key_package_identifier: &str, + key_package_hex: &str, +) -> Result { + let expected_identifier = + parse_frost_identifier(operation, "key_package_identifier", key_package_identifier)?; + let mut key_package_bytes = decode_hex_field(operation, "key_package_hex", key_package_hex)?; + let key_package_result = frost::keys::KeyPackage::deserialize(&key_package_bytes); + key_package_bytes.zeroize(); + let key_package = key_package_result + .map_err(|e| EngineError::Validation(format!("{operation}: invalid key package: {e}")))?; + + if *key_package.identifier() != expected_identifier { + return Err(EngineError::Validation(format!( + "{operation}: key_package_identifier does not match serialized key package" + ))); + } + + Ok(key_package) +} + +fn decode_signing_commitment_map( + operation: &str, + commitments: &[NativeFrostCommitment], +) -> Result, EngineError> { + if commitments.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: commitments must not be empty" + ))); + } + + let mut commitment_map = BTreeMap::new(); + for (index, commitment) in commitments.iter().enumerate() { + let identifier = parse_frost_identifier( + operation, + &format!("commitments[{index}].identifier"), + &commitment.identifier, + )?; + let commitment_bytes = decode_hex_field( + operation, + &format!("commitments[{index}].data_hex"), + &commitment.data_hex, + )?; + let signing_commitment = frost::round1::SigningCommitments::deserialize(&commitment_bytes) + .map_err(|e| { + EngineError::Validation(format!( + "{operation}: invalid signing commitment [{index}]: {e}" + )) + })?; + if commitment_map + .insert(identifier, signing_commitment) + .is_some() + { + return Err(EngineError::Validation(format!( + "{operation}: duplicate commitment identifier [{}]", + commitment.identifier + ))); + } + } + + Ok(commitment_map) +} + +fn decode_signature_share_map( + operation: &str, + signature_shares: &[NativeFrostSignatureShare], +) -> Result, EngineError> { + if signature_shares.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: signature_shares must not be empty" + ))); + } + + let mut signature_share_map = BTreeMap::new(); + for (index, signature_share) in signature_shares.iter().enumerate() { + let identifier = parse_frost_identifier( + operation, + &format!("signature_shares[{index}].identifier"), + &signature_share.identifier, + )?; + let mut signature_share_bytes = decode_hex_field( + operation, + &format!("signature_shares[{index}].data_hex"), + &signature_share.data_hex, + )?; + let signature_share = frost::round2::SignatureShare::deserialize(&signature_share_bytes) + .map_err(|e| { + EngineError::Validation(format!( + "{operation}: invalid signature share [{index}]: {e}" + )) + })?; + signature_share_bytes.zeroize(); + if signature_share_map + .insert(identifier, signature_share) + .is_some() + { + return Err(EngineError::Validation(format!( + "{operation}: duplicate signature share identifier" + ))); + } + } + + Ok(signature_share_map) +} + +pub fn dkg_part1(request: DkgPart1Request) -> Result { + enforce_provenance_gate()?; + + if request.max_signers == 0 { + return Err(EngineError::Validation( + "DKGPart1: max_signers is zero".to_string(), + )); + } + if request.min_signers == 0 { + return Err(EngineError::Validation( + "DKGPart1: min_signers is zero".to_string(), + )); + } + if request.min_signers > request.max_signers { + return Err(EngineError::Validation( + "DKGPart1: min_signers exceeds max_signers".to_string(), + )); + } + + let identifier = parse_frost_identifier( + "DKGPart1", + "participant_identifier", + &request.participant_identifier, + )?; + let rng = zeroizing_rng_from_os(); + let (mut secret_package, package) = + frost::keys::dkg::part1(identifier, request.max_signers, request.min_signers, rng) + .map_err(|e| EngineError::Validation(format!("DKGPart1 failed: {e}")))?; + + let package_bytes = match package.serialize() { + Ok(package_bytes) => package_bytes, + Err(err) => { + secret_package.zeroize(); + return Err(EngineError::Internal(format!( + "failed to serialize DKG part1 package: {err}" + ))); + } + }; + let secret_package_bytes_result = secret_package.serialize(); + secret_package.zeroize(); + let mut secret_package_bytes = secret_package_bytes_result + .map_err(|e| EngineError::Internal(format!("failed to serialize DKG part1 secret: {e}")))?; + + let result = DkgPart1Result { + secret_package_hex: hex::encode(&secret_package_bytes), + package: DkgRound1Package { + identifier: frost_identifier_to_go_string(identifier), + package_hex: hex::encode(package_bytes), + }, + }; + secret_package_bytes.zeroize(); + + Ok(result) +} + +pub fn dkg_part2(request: DkgPart2Request) -> Result { + enforce_provenance_gate()?; + + let mut secret_package_bytes = decode_hex_field( + "DKGPart2", + "secret_package_hex", + &request.secret_package_hex, + )?; + let secret_package_result = + frost::keys::dkg::round1::SecretPackage::deserialize(&secret_package_bytes); + secret_package_bytes.zeroize(); + let mut secret_package = secret_package_result + .map_err(|e| EngineError::Validation(format!("DKGPart2: invalid secret package: {e}")))?; + + let round1_packages = match decode_round1_package_map("DKGPart2", &request.round1_packages) { + Ok(round1_packages) => round1_packages, + Err(err) => { + secret_package.zeroize(); + return Err(err); + } + }; + let (mut round2_secret_package, round2_packages) = + frost::keys::dkg::part2(secret_package, &round1_packages) + .map_err(|e| EngineError::Validation(format!("DKGPart2 failed: {e}")))?; + + let mut packages = Vec::with_capacity(round2_packages.len()); + for (identifier, package) in round2_packages { + let mut package_bytes = match package.serialize() { + Ok(package_bytes) => package_bytes, + Err(err) => { + round2_secret_package.zeroize(); + return Err(EngineError::Internal(format!( + "failed to serialize DKG part2 package: {err}" + ))); + } + }; + packages.push(DkgRound2Package { + identifier: frost_identifier_to_go_string(identifier), + sender_identifier: None, + package_hex: hex::encode(&package_bytes), + }); + package_bytes.zeroize(); + } + + let round2_secret_package_bytes_result = round2_secret_package.serialize(); + round2_secret_package.zeroize(); + let mut round2_secret_package_bytes = round2_secret_package_bytes_result + .map_err(|e| EngineError::Internal(format!("failed to serialize DKG part2 secret: {e}")))?; + + let result = DkgPart2Result { + secret_package_hex: hex::encode(&round2_secret_package_bytes), + packages, + }; + round2_secret_package_bytes.zeroize(); + + Ok(result) +} + +pub fn dkg_part3(request: DkgPart3Request) -> Result { + enforce_provenance_gate()?; + + let mut secret_package_bytes = decode_hex_field( + "DKGPart3", + "secret_package_hex", + &request.secret_package_hex, + )?; + let secret_package_result = + frost::keys::dkg::round2::SecretPackage::deserialize(&secret_package_bytes); + secret_package_bytes.zeroize(); + let mut secret_package = secret_package_result + .map_err(|e| EngineError::Validation(format!("DKGPart3: invalid secret package: {e}")))?; + + let round1_packages = match decode_round1_package_map("DKGPart3", &request.round1_packages) { + Ok(round1_packages) => round1_packages, + Err(err) => { + secret_package.zeroize(); + return Err(err); + } + }; + let round2_packages = match decode_round2_package_map( + "DKGPart3", + &request.round2_packages, + Some(*secret_package.identifier()), + ) { + Ok(round2_packages) => round2_packages, + Err(err) => { + secret_package.zeroize(); + return Err(err); + } + }; + let dkg_result = frost::keys::dkg::part3(&secret_package, &round1_packages, &round2_packages); + secret_package.zeroize(); + let (key_package, public_key_package) = + dkg_result.map_err(|e| EngineError::Validation(format!("DKGPart3 failed: {e}")))?; + + let is_even_y = public_key_package.has_even_y(); + let key_package = key_package.into_even_y(Some(is_even_y)); + let public_key_package = public_key_package.into_even_y(Some(is_even_y)); + + let native_public_key_package = native_public_key_package_from_frost(&public_key_package)?; + let mut key_package_bytes = key_package + .serialize() + .map_err(|e| EngineError::Internal(format!("failed to serialize DKG key package: {e}")))?; + let result = DkgPart3Result { + key_package: NativeFrostKeyPackage { + identifier: frost_identifier_to_go_string(*key_package.identifier()), + data_hex: hex::encode(&key_package_bytes), + }, + public_key_package: native_public_key_package, + }; + key_package_bytes.zeroize(); + + Ok(result) +} + +pub fn generate_nonces_and_commitments( + request: GenerateNoncesAndCommitmentsRequest, +) -> Result { + enforce_provenance_gate()?; + + let key_package = decode_key_package( + "GenerateNoncesAndCommitments", + &request.key_package_identifier, + &request.key_package_hex, + )?; + let mut rng = zeroizing_rng_from_os(); + let (mut nonces, commitments) = frost::round1::commit(key_package.signing_share(), &mut rng); + let commitment_bytes = match commitments.serialize() { + Ok(commitment_bytes) => commitment_bytes, + Err(err) => { + nonces.zeroize(); + return Err(EngineError::Internal(format!( + "failed to serialize signing commitments: {err}" + ))); + } + }; + let nonces_bytes_result = nonces.serialize(); + nonces.zeroize(); + let mut nonces_bytes = nonces_bytes_result + .map_err(|e| EngineError::Internal(format!("failed to serialize signing nonces: {e}")))?; + + let result = GenerateNoncesAndCommitmentsResult { + nonces_hex: hex::encode(&nonces_bytes), + commitment: NativeFrostCommitment { + identifier: frost_identifier_to_go_string(*key_package.identifier()), + data_hex: hex::encode(commitment_bytes), + }, + }; + nonces_bytes.zeroize(); + + Ok(result) +} + +pub fn new_signing_package( + request: NewSigningPackageRequest, +) -> Result { + enforce_provenance_gate()?; + + let message = if request.message_hex.is_empty() { + Vec::new() + } else { + hex::decode(&request.message_hex).map_err(|_| { + EngineError::Validation("NewSigningPackage: message_hex must be valid hex".to_string()) + })? + }; + let commitments = decode_signing_commitment_map("NewSigningPackage", &request.commitments)?; + let signing_package = frost::SigningPackage::new(commitments, &message); + let signing_package_bytes = signing_package + .serialize() + .map_err(|e| EngineError::Internal(format!("failed to serialize signing package: {e}")))?; + + Ok(NewSigningPackageResult { + signing_package_hex: hex::encode(signing_package_bytes), + }) +} + +pub fn sign_share(request: SignShareRequest) -> Result { + enforce_provenance_gate()?; + + let signing_package_bytes = decode_hex_field( + "SignShare", + "signing_package_hex", + &request.signing_package_hex, + )?; + let signing_package = frost::SigningPackage::deserialize(&signing_package_bytes) + .map_err(|e| EngineError::Validation(format!("SignShare: invalid signing package: {e}")))?; + + let mut nonces_bytes = decode_hex_field("SignShare", "nonces_hex", &request.nonces_hex)?; + let nonces_result = frost::round1::SigningNonces::deserialize(&nonces_bytes); + nonces_bytes.zeroize(); + let mut nonces = nonces_result + .map_err(|e| EngineError::Validation(format!("SignShare: invalid nonces: {e}")))?; + + let key_package = match decode_key_package( + "SignShare", + &request.key_package_identifier, + &request.key_package_hex, + ) { + Ok(key_package) => key_package, + Err(err) => { + nonces.zeroize(); + return Err(err); + } + }; + let signature_share_result = frost::round2::sign(&signing_package, &nonces, &key_package); + nonces.zeroize(); + let signature_share = signature_share_result + .map_err(|e| EngineError::Validation(format!("SignShare failed: {e}")))?; + let mut signature_share_bytes = signature_share.serialize(); + let result = SignShareResult { + signature_share: NativeFrostSignatureShare { + identifier: frost_identifier_to_go_string(*key_package.identifier()), + data_hex: hex::encode(&signature_share_bytes), + }, + }; + signature_share_bytes.zeroize(); + + Ok(result) +} + +pub fn aggregate(request: AggregateRequest) -> Result { + enforce_provenance_gate()?; + + let signing_package_bytes = decode_hex_field( + "Aggregate", + "signing_package_hex", + &request.signing_package_hex, + )?; + let signing_package = frost::SigningPackage::deserialize(&signing_package_bytes) + .map_err(|e| EngineError::Validation(format!("Aggregate: invalid signing package: {e}")))?; + let signature_shares = decode_signature_share_map("Aggregate", &request.signature_shares)?; + let public_key_package = + native_public_key_package_to_frost("Aggregate", &request.public_key_package)?; + let signature = frost::aggregate(&signing_package, &signature_shares, &public_key_package) + .map_err(|e| EngineError::Validation(format!("Aggregate failed: {e}")))?; + let signature_bytes = signature + .serialize() + .map_err(|e| EngineError::Internal(format!("failed to serialize aggregate: {e}")))?; + + Ok(AggregateResult { + signature_hex: hex::encode(signature_bytes), + }) +} + +fn build_deterministic_round_nonce_and_commitment( + key_package: &frost::keys::KeyPackage, + session_id: &str, + round_id: &str, + message_bytes: &[u8], + participant_identifier: u16, +) -> ( + frost::round1::SigningNonces, + frost::round1::SigningCommitments, +) { + // Defense-in-depth: bind nonces directly to message bytes in addition to + // `round_id` so future round ID schema changes cannot weaken nonce safety. + let mut signing_share_bytes = key_package.signing_share().serialize(); + let mut nonce_seed = deterministic_seed(&[ + b"round-nonce", + &signing_share_bytes, + session_id.as_bytes(), + round_id.as_bytes(), + message_bytes, + &participant_identifier.to_le_bytes(), + ]); + signing_share_bytes.zeroize(); + let mut nonce_rng = ZeroizingChaCha20Rng::from_seed(nonce_seed); + nonce_seed.zeroize(); + + frost::round1::commit(key_package.signing_share(), &mut nonce_rng) +} + +fn fingerprint(value: &T) -> Result { + let mut bytes = serde_json::to_vec(value) + .map_err(|e| EngineError::Internal(format!("failed to encode request: {e}")))?; + let value_fingerprint = hash_hex(&bytes); + bytes.zeroize(); + Ok(value_fingerprint) +} + +fn canonicalize_dkg_request_for_fingerprint(request: &RunDkgRequest) -> RunDkgRequest { + let mut canonical_request = request.clone(); + canonical_request + .participants + .sort_unstable_by(|left, right| { + left.identifier + .cmp(&right.identifier) + .then_with(|| left.public_key_hex.cmp(&right.public_key_hex)) + }); + canonical_request +} + +fn canonicalize_refresh_shares_request_for_fingerprint( + request: &RefreshSharesRequest, +) -> RefreshSharesRequest { + let mut canonical_request = request.clone(); + canonical_request + .current_shares + .sort_unstable_by(|left, right| { + left.identifier + .cmp(&right.identifier) + .then_with(|| left.encrypted_share_hex.cmp(&right.encrypted_share_hex)) + }); + canonical_request +} + +fn canonicalize_taproot_merkle_root_hex( + taproot_merkle_root_hex: &mut Option, +) -> Result, EngineError> { + let Some(raw_taproot_merkle_root_hex) = taproot_merkle_root_hex.as_mut() else { + return Ok(None); + }; + + let normalized_taproot_merkle_root_hex = + raw_taproot_merkle_root_hex.trim().to_ascii_lowercase(); + let taproot_merkle_root_bytes = + hex::decode(&normalized_taproot_merkle_root_hex).map_err(|_| { + EngineError::Validation("taproot_merkle_root_hex must be valid hex".to_string()) + })?; + if taproot_merkle_root_bytes.len() != 32 { + return Err(EngineError::Validation( + "taproot_merkle_root_hex must decode to 32 bytes".to_string(), + )); + } + + let mut taproot_merkle_root = [0_u8; 32]; + taproot_merkle_root.copy_from_slice(&taproot_merkle_root_bytes); + *raw_taproot_merkle_root_hex = normalized_taproot_merkle_root_hex; + + Ok(Some(taproot_merkle_root)) +} + +fn truthy_env_flag(raw_value: &str) -> bool { + matches!( + raw_value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +fn roast_strict_mode_enabled() -> bool { + if signer_profile_is_production() { + return true; + } + + std::env::var(TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV) + .map(|raw_value| truthy_env_flag(&raw_value)) + .unwrap_or(false) +} + +#[cfg(any(test, feature = "bench-restart-hook"))] +fn bench_restart_hook_enabled() -> bool { + std::env::var(TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK_ENV) + .map(|raw_value| truthy_env_flag(&raw_value)) + .unwrap_or(false) +} + +fn canonicalize_attempt_context_for_fingerprint(attempt_context: &mut Option) { + if let Some(attempt_context) = attempt_context.as_mut() { + attempt_context.included_participants.sort_unstable(); + attempt_context.included_participants_fingerprint = attempt_context + .included_participants_fingerprint + .to_ascii_lowercase(); + attempt_context.attempt_id = attempt_context.attempt_id.to_ascii_lowercase(); + } +} + +fn canonicalize_attempt_transition_evidence_for_fingerprint( + transition_evidence: &mut Option, +) { + if let Some(transition_evidence) = transition_evidence.as_mut() { + transition_evidence.from_attempt_id = transition_evidence + .from_attempt_id + .trim() + .to_ascii_lowercase(); + if let Some(exclusion_evidence) = transition_evidence.exclusion_evidence.as_mut() { + exclusion_evidence.reason = exclusion_evidence.reason.trim().to_ascii_lowercase(); + exclusion_evidence + .excluded_member_identifiers + .sort_unstable(); + if let Some(proof_fingerprint) = + exclusion_evidence.invalid_share_proof_fingerprint.as_mut() + { + *proof_fingerprint = proof_fingerprint.trim().to_ascii_lowercase(); + } + } + } +} + +fn start_sign_round_request_fingerprint( + request: &StartSignRoundRequest, + member_identifier: u16, +) -> Result { + start_sign_round_request_fingerprint_internal(request, member_identifier, false) +} + +fn start_sign_round_request_fingerprint_including_transition_evidence( + request: &StartSignRoundRequest, + member_identifier: u16, +) -> Result { + start_sign_round_request_fingerprint_internal(request, member_identifier, true) +} + +fn start_sign_round_request_fingerprint_internal( + request: &StartSignRoundRequest, + member_identifier: u16, + include_transition_evidence: bool, +) -> Result { + let mut canonical_request = request.clone(); + canonical_request.member_identifier = member_identifier; + if let Some(signing_participants) = canonical_request.signing_participants.as_mut() { + signing_participants.sort_unstable(); + } + canonicalize_attempt_context_for_fingerprint(&mut canonical_request.attempt_context); + if include_transition_evidence { + canonicalize_attempt_transition_evidence_for_fingerprint( + &mut canonical_request.attempt_transition_evidence, + ); + } else { + // Transition evidence authorizes creation of a new active attempt but is + // one-shot material. Once the active attempt context is established, + // other members may reuse the round without resending the evidence. + canonical_request.attempt_transition_evidence = None; + } + + fingerprint(&canonical_request) +} + +fn round_attempt_id_component(attempt_context: Option<&AttemptContext>) -> String { + attempt_context + .map(|attempt_context| attempt_context.attempt_id.to_ascii_lowercase()) + .unwrap_or_else(|| ROUND_ID_NO_ATTEMPT_CONTEXT_COMPONENT.to_string()) +} + +fn derive_round_id( + session_id: &str, + key_group: &str, + message_hex: &str, + taproot_merkle_root_hex: Option<&str>, + signing_participants_fingerprint: &str, + attempt_context: Option<&AttemptContext>, +) -> String { + let attempt_id_component = round_attempt_id_component(attempt_context); + let taproot_merkle_root_component = taproot_merkle_root_hex.unwrap_or("no-taproot-merkle-root"); + hash_hex( + format!( + "round:{}:{}:{}:{}:{}:{}", + session_id, + key_group, + message_hex, + taproot_merkle_root_component, + signing_participants_fingerprint, + attempt_id_component + ) + .as_bytes(), + ) +} + +fn canonicalize_included_participants( + included_participants: &[u16], +) -> Result, EngineError> { + if included_participants.is_empty() { + return Err(EngineError::Validation( + "attempt_context.included_participants must not be empty".to_string(), + )); + } + + let mut canonical = included_participants.to_vec(); + canonical.sort_unstable(); + + let mut seen = HashSet::new(); + for participant_identifier in &canonical { + if *participant_identifier == 0 { + return Err(EngineError::Validation( + "attempt_context.included_participants must contain non-zero identifiers" + .to_string(), + )); + } + if !seen.insert(*participant_identifier) { + return Err(EngineError::Validation(format!( + "attempt_context.included_participants contains duplicate identifier [{}]", + participant_identifier + ))); + } + } + + Ok(canonical) +} + +fn push_framed_component(payload: &mut Vec, component: &[u8]) -> Result<(), EngineError> { + let component_len = u32::try_from(component.len()).map_err(|_| { + EngineError::Validation("attempt_context component exceeds u32 framing limit".to_string()) + })?; + payload.extend_from_slice(&component_len.to_be_bytes()); + payload.extend_from_slice(component); + Ok(()) +} + +fn roast_hash_hex_with_components( + domain: &str, + components: &[&[u8]], +) -> Result { + let mut payload = Vec::new(); + push_framed_component(&mut payload, domain.as_bytes())?; + for component in components { + push_framed_component(&mut payload, component)?; + } + + Ok(hash_hex(&payload)) +} + +fn roast_attempt_seed_from_message_digest_hex( + message_digest_hex: &str, +) -> Result { + let message_digest_bytes = hex::decode(message_digest_hex).map_err(|_| { + EngineError::Internal("message digest hex must decode for attempt seed".to_string()) + })?; + + if message_digest_bytes.len() < 8 { + return Err(EngineError::Internal( + "message digest must be at least 8 bytes for attempt seed".to_string(), + )); + } + + let mut seed_bytes = [0_u8; 8]; + seed_bytes.copy_from_slice(&message_digest_bytes[..8]); + Ok(i64::from_be_bytes(seed_bytes)) +} + +fn roast_included_participants_fingerprint_hex( + included_participants: &[u16], +) -> Result { + let mut participant_payload = Vec::new(); + for participant_identifier in included_participants { + push_framed_component( + &mut participant_payload, + &participant_identifier.to_be_bytes(), + )?; + } + + roast_hash_hex_with_components( + ROAST_INCLUDED_PARTICIPANTS_FINGERPRINT_DOMAIN, + &[&participant_payload], + ) +} + +fn roast_attempt_id_hex( + session_id: &str, + message_digest_hex: &str, + attempt_number: u32, + coordinator_identifier: u16, + included_participants_fingerprint_hex: &str, +) -> Result { + roast_hash_hex_with_components( + ROAST_ATTEMPT_ID_DOMAIN, + &[ + session_id.as_bytes(), + message_digest_hex.as_bytes(), + &attempt_number.to_be_bytes(), + &coordinator_identifier.to_be_bytes(), + included_participants_fingerprint_hex.as_bytes(), + ], + ) +} + +fn validate_attempt_context( + session_id: &str, + message_digest_hex: &str, + threshold: u16, + attempt_context: Option<&AttemptContext>, + strict_mode_enabled: bool, +) -> Result>, EngineError> { + let Some(attempt_context) = attempt_context else { + if strict_mode_enabled { + return Err(EngineError::Validation( + "attempt_context is required when ROAST strict mode is enabled".to_string(), + )); + } + return Ok(None); + }; + + if attempt_context.attempt_number == 0 { + return Err(EngineError::Validation( + "attempt_context.attempt_number must be at least 1".to_string(), + )); + } + + if attempt_context.coordinator_identifier == 0 { + return Err(EngineError::Validation( + "attempt_context.coordinator_identifier must be non-zero".to_string(), + )); + } + + let canonical_included_participants = + canonicalize_included_participants(&attempt_context.included_participants)?; + + if canonical_included_participants.len() < usize::from(threshold) { + return Err(EngineError::Validation(format!( + "attempt_context.included_participants must contain at least threshold members [{}]", + threshold + ))); + } + + if !canonical_included_participants.contains(&attempt_context.coordinator_identifier) { + return Err(EngineError::Validation( + "attempt_context.coordinator_identifier must be included in attempt_context.included_participants".to_string(), + )); + } + + let attempt_seed = roast_attempt_seed_from_message_digest_hex(message_digest_hex)?; + let expected_coordinator_identifier = select_coordinator_identifier( + &canonical_included_participants, + attempt_seed, + attempt_context.attempt_number, + ) + .ok_or_else(|| { + EngineError::Validation( + "attempt_context.included_participants must not be empty".to_string(), + ) + })?; + if expected_coordinator_identifier != attempt_context.coordinator_identifier { + return Err(EngineError::Validation( + "attempt_context.coordinator_identifier does not match deterministic coordinator selection".to_string(), + )); + } + + let expected_included_participants_fingerprint_hex = + roast_included_participants_fingerprint_hex(&canonical_included_participants)?; + + if !attempt_context + .included_participants_fingerprint + .eq_ignore_ascii_case(&expected_included_participants_fingerprint_hex) + { + return Err(EngineError::Validation( + "attempt_context.included_participants_fingerprint does not match canonical participants".to_string(), + )); + } + + let expected_attempt_id_hex = roast_attempt_id_hex( + session_id, + message_digest_hex, + attempt_context.attempt_number, + attempt_context.coordinator_identifier, + &expected_included_participants_fingerprint_hex, + )?; + + if !attempt_context + .attempt_id + .eq_ignore_ascii_case(&expected_attempt_id_hex) + { + return Err(EngineError::Validation( + "attempt_context.attempt_id does not match canonical attempt context".to_string(), + )); + } + + Ok(Some(canonical_included_participants)) +} + +fn canonical_attempt_context(attempt_context: &AttemptContext) -> AttemptContext { + let mut canonical = Some(attempt_context.clone()); + canonicalize_attempt_context_for_fingerprint(&mut canonical); + canonical.expect("attempt context canonicalization preserves value") +} + +enum ActiveAttemptMatchOutcome { + MatchActive, + AdvanceAuthorized, +} + +fn validate_transition_exclusion_evidence( + exclusion_evidence: Option<&AttemptExclusionEvidence>, + active_attempt_context: &AttemptContext, + incoming_attempt_context: &AttemptContext, +) -> Result<(), EngineError> { + let exclusion_evidence = exclusion_evidence.ok_or_else(|| { + EngineError::Validation( + "attempt_transition_evidence.exclusion_evidence is required for attempt advancement" + .to_string(), + ) + })?; + + let reason = exclusion_evidence.reason.trim().to_ascii_lowercase(); + if reason != ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT + && reason != ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF + { + return Err(EngineError::Validation(format!( + "attempt_transition_evidence.exclusion_evidence.reason [{}] is unsupported", + exclusion_evidence.reason + ))); + } + + let mut excluded_member_identifiers = HashSet::new(); + for member_identifier in &exclusion_evidence.excluded_member_identifiers { + if *member_identifier == 0 { + return Err(EngineError::Validation( + "attempt_transition_evidence.exclusion_evidence.excluded_member_identifiers must contain non-zero identifiers".to_string(), + )); + } + if !excluded_member_identifiers.insert(*member_identifier) { + return Err(EngineError::Validation(format!( + "attempt_transition_evidence.exclusion_evidence.excluded_member_identifiers contains duplicate identifier [{}]", + member_identifier + ))); + } + if !active_attempt_context + .included_participants + .contains(member_identifier) + { + return Err(EngineError::Validation(format!( + "attempt_transition_evidence.exclusion_evidence.excluded_member_identifiers contains identifier [{}] not present in active attempt context", + member_identifier + ))); + } + } + + for member_identifier in &excluded_member_identifiers { + if incoming_attempt_context + .included_participants + .contains(member_identifier) + { + return Err(EngineError::Validation(format!( + "attempt_transition_evidence.exclusion_evidence identifier [{}] must not remain in incoming attempt_context.included_participants", + member_identifier + ))); + } + } + + if excluded_member_identifiers.contains(&incoming_attempt_context.coordinator_identifier) { + return Err(EngineError::Validation( + "attempt_transition_evidence.exclusion_evidence must not exclude incoming attempt_context.coordinator_identifier".to_string(), + )); + } + + match reason.as_str() { + ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT => { + // `coordinator_timeout` may intentionally exclude zero members. + // This models coordinator rotation without participant-level fault + // attribution, so no auto-quarantine penalty is applied. + if exclusion_evidence.invalid_share_proof_fingerprint.is_some() { + return Err(EngineError::Validation( + "attempt_transition_evidence.exclusion_evidence.invalid_share_proof_fingerprint must be omitted for coordinator_timeout reason".to_string(), + )); + } + } + ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF => { + if excluded_member_identifiers.is_empty() { + return Err(EngineError::Validation( + "attempt_transition_evidence.exclusion_evidence.excluded_member_identifiers must contain at least one identifier for invalid_share_proof reason".to_string(), + )); + } + let proof_fingerprint = exclusion_evidence + .invalid_share_proof_fingerprint + .as_deref() + .ok_or_else(|| { + EngineError::Validation( + "attempt_transition_evidence.exclusion_evidence.invalid_share_proof_fingerprint is required for invalid_share_proof reason".to_string(), + ) + })?; + let proof_fingerprint = proof_fingerprint.trim(); + if proof_fingerprint.is_empty() { + return Err(EngineError::Validation( + "attempt_transition_evidence.exclusion_evidence.invalid_share_proof_fingerprint must be non-empty valid hex".to_string(), + )); + } + hex::decode(proof_fingerprint).map_err(|_| { + EngineError::Validation( + "attempt_transition_evidence.exclusion_evidence.invalid_share_proof_fingerprint must be valid hex".to_string(), + ) + })?; + } + _ => unreachable!("reason value filtered above"), + } + + Ok(()) +} + +fn build_attempt_transition_telemetry( + active_attempt_context: &AttemptContext, + incoming_attempt_context: &AttemptContext, + transition_evidence: Option<&AttemptTransitionEvidence>, +) -> Option { + let exclusion_evidence = transition_evidence?.exclusion_evidence.as_ref()?; + let mut excluded_member_identifiers = exclusion_evidence.excluded_member_identifiers.clone(); + excluded_member_identifiers.sort_unstable(); + + Some(AttemptTransitionTelemetry { + from_attempt_number: active_attempt_context.attempt_number, + to_attempt_number: incoming_attempt_context.attempt_number, + from_coordinator_identifier: active_attempt_context.coordinator_identifier, + to_coordinator_identifier: incoming_attempt_context.coordinator_identifier, + reason: exclusion_evidence.reason.trim().to_ascii_lowercase(), + excluded_member_identifiers, + coordinator_rotated: active_attempt_context.coordinator_identifier + != incoming_attempt_context.coordinator_identifier, + }) +} + +fn build_transcript_audit_record( + active_attempt_context: &AttemptContext, + incoming_attempt_context: &AttemptContext, + transition_evidence: &AttemptTransitionEvidence, +) -> Result { + let exclusion_evidence = transition_evidence + .exclusion_evidence + .as_ref() + .ok_or_else(|| { + EngineError::Internal("missing exclusion evidence for transcript record".to_string()) + })?; + + let mut excluded_member_identifiers = exclusion_evidence.excluded_member_identifiers.clone(); + excluded_member_identifiers.sort_unstable(); + + let reason = exclusion_evidence.reason.trim().to_ascii_lowercase(); + let invalid_share_proof_fingerprint = exclusion_evidence + .invalid_share_proof_fingerprint + .as_deref() + .map(|fingerprint| fingerprint.trim().to_ascii_lowercase()); + let mut record = TranscriptAuditRecord { + from_attempt_number: active_attempt_context.attempt_number, + to_attempt_number: incoming_attempt_context.attempt_number, + from_attempt_id: active_attempt_context.attempt_id.to_ascii_lowercase(), + to_attempt_id: incoming_attempt_context.attempt_id.to_ascii_lowercase(), + previous_round_id: transition_evidence.previous_round_id.clone(), + previous_sign_request_fingerprint: transition_evidence + .previous_sign_request_fingerprint + .clone(), + from_coordinator_identifier: active_attempt_context.coordinator_identifier, + to_coordinator_identifier: incoming_attempt_context.coordinator_identifier, + reason, + excluded_member_identifiers, + invalid_share_proof_fingerprint, + transcript_hash: String::new(), + recorded_at_unix: now_unix(), + }; + // Two-pass hash: fingerprint the canonical record with an empty + // `transcript_hash` sentinel, then persist the resulting hash value. + let transcript_hash = fingerprint(&record)?; + record.transcript_hash = transcript_hash; + Ok(record) +} + +fn enforce_not_quarantined_identifiers( + session_id: &str, + member_identifiers: &[u16], + quarantined_operator_identifiers: &HashSet, + auto_quarantine_config: Option<&AutoQuarantineConfig>, +) -> Result<(), EngineError> { + let Some(auto_quarantine_config) = auto_quarantine_config else { + return Ok(()); + }; + + for member_identifier in member_identifiers { + if auto_quarantine_config + .dao_allowlist_identifiers + .contains(member_identifier) + { + continue; + } + if quarantined_operator_identifiers.contains(member_identifier) { + return reject_quarantine_policy( + session_id, + "operator_auto_quarantined", + format!( + "operator identifier [{}] is auto-quarantined and requires DAO allowlist override", + member_identifier + ), + ); + } + } + + Ok(()) +} + +fn auto_quarantine_penalty_for_record( + record: &TranscriptAuditRecord, + auto_quarantine_config: &AutoQuarantineConfig, +) -> u64 { + if record.reason == ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF { + auto_quarantine_config.invalid_share_penalty + } else { + auto_quarantine_config.timeout_penalty + } +} + +fn apply_auto_quarantine_faults_for_transition( + engine_state: &mut EngineState, + session_id: &str, + record: &TranscriptAuditRecord, + auto_quarantine_config: Option<&AutoQuarantineConfig>, +) { + let Some(auto_quarantine_config) = auto_quarantine_config else { + return; + }; + + let penalty = auto_quarantine_penalty_for_record(record, auto_quarantine_config); + for excluded_member_identifier in &record.excluded_member_identifiers { + if auto_quarantine_config + .dao_allowlist_identifiers + .contains(excluded_member_identifier) + { + // Governance allowlist acts as explicit manual re-enable path. + engine_state + .quarantined_operator_identifiers + .remove(excluded_member_identifier); + continue; + } + + let score = engine_state + .operator_fault_scores + .entry(*excluded_member_identifier) + .or_insert(0); + *score = score.saturating_add(penalty); + record_hardening_telemetry(|telemetry| { + telemetry.auto_quarantine_fault_events_total = telemetry + .auto_quarantine_fault_events_total + .saturating_add(1); + }); + + if *score >= auto_quarantine_config.fault_threshold + && engine_state + .quarantined_operator_identifiers + .insert(*excluded_member_identifier) + { + record_hardening_telemetry(|telemetry| { + telemetry.auto_quarantine_enforcements_total = telemetry + .auto_quarantine_enforcements_total + .saturating_add(1); + }); + log_policy_decision( + "auto_quarantine", + session_id, + "quarantine", + "fault_threshold_reached", + ); + } + } +} + +fn validate_attempt_transition_evidence( + active_attempt_context: &AttemptContext, + incoming_attempt_context: &AttemptContext, + transition_evidence: Option<&AttemptTransitionEvidence>, + round_state: Option<&RoundState>, + sign_request_fingerprint: Option<&str>, +) -> Result<(), EngineError> { + let transition_evidence = transition_evidence.ok_or_else(|| { + EngineError::Validation( + "attempt_context.attempt_number advancement requires attempt_transition_evidence" + .to_string(), + ) + })?; + + if incoming_attempt_context.attempt_number != active_attempt_context.attempt_number + 1 { + return Err(EngineError::Validation(format!( + "attempt_context.attempt_number [{}] is ahead of active attempt_number [{}] without transition authorization", + incoming_attempt_context.attempt_number, active_attempt_context.attempt_number + ))); + } + + if transition_evidence.from_attempt_number != active_attempt_context.attempt_number { + return Err(EngineError::Validation( + "attempt_transition_evidence.from_attempt_number does not match active attempt context" + .to_string(), + )); + } + + if !transition_evidence + .from_attempt_id + .eq_ignore_ascii_case(&active_attempt_context.attempt_id) + { + return Err(EngineError::Validation( + "attempt_transition_evidence.from_attempt_id does not match active attempt context" + .to_string(), + )); + } + + if transition_evidence.from_coordinator_identifier + != active_attempt_context.coordinator_identifier + { + return Err(EngineError::Validation( + "attempt_transition_evidence.from_coordinator_identifier does not match active attempt context".to_string(), + )); + } + + validate_transition_exclusion_evidence( + transition_evidence.exclusion_evidence.as_ref(), + active_attempt_context, + incoming_attempt_context, + )?; + + let round_state = round_state.ok_or_else(|| { + EngineError::Validation( + "attempt_transition_evidence requires active round state".to_string(), + ) + })?; + if transition_evidence.previous_round_id != round_state.round_id { + return Err(EngineError::Validation( + "attempt_transition_evidence.previous_round_id does not match active round state" + .to_string(), + )); + } + + let sign_request_fingerprint = sign_request_fingerprint.ok_or_else(|| { + EngineError::Validation( + "attempt_transition_evidence requires active sign request fingerprint".to_string(), + ) + })?; + if transition_evidence.previous_sign_request_fingerprint != sign_request_fingerprint { + return Err(EngineError::Validation( + "attempt_transition_evidence.previous_sign_request_fingerprint does not match active sign request".to_string(), + )); + } + + if incoming_attempt_context + .attempt_id + .eq_ignore_ascii_case(&active_attempt_context.attempt_id) + { + return Err(EngineError::Validation( + "attempt_context.attempt_id must change when advancing attempt_number".to_string(), + )); + } + + Ok(()) +} + +fn enforce_active_attempt_context_match( + active_attempt_context: &AttemptContext, + incoming_attempt_context: Option<&AttemptContext>, + transition_evidence: Option<&AttemptTransitionEvidence>, + round_state: Option<&RoundState>, + sign_request_fingerprint: Option<&str>, + strict_mode_enabled: bool, +) -> Result { + let Some(incoming_attempt_context) = incoming_attempt_context else { + if !strict_mode_enabled { + return Ok(ActiveAttemptMatchOutcome::MatchActive); + } + return Err(EngineError::Validation( + "attempt_context is required when ROAST strict mode is enabled or an active attempt context exists".to_string(), + )); + }; + + let incoming_attempt_context = canonical_attempt_context(incoming_attempt_context); + + if incoming_attempt_context.attempt_number < active_attempt_context.attempt_number { + return Err(EngineError::Validation(format!( + "attempt_context.attempt_number [{}] is stale; active attempt_number is [{}]", + incoming_attempt_context.attempt_number, active_attempt_context.attempt_number + ))); + } + + if incoming_attempt_context.attempt_number > active_attempt_context.attempt_number { + validate_attempt_transition_evidence( + active_attempt_context, + &incoming_attempt_context, + transition_evidence, + round_state, + sign_request_fingerprint, + )?; + + return Ok(ActiveAttemptMatchOutcome::AdvanceAuthorized); + } + + if incoming_attempt_context.coordinator_identifier + != active_attempt_context.coordinator_identifier + { + return Err(EngineError::Validation(format!( + "attempt_context.coordinator_identifier [{}] does not match active coordinator [{}]", + incoming_attempt_context.coordinator_identifier, + active_attempt_context.coordinator_identifier + ))); + } + + if incoming_attempt_context.included_participants + != active_attempt_context.included_participants + { + return Err(EngineError::Validation( + "attempt_context.included_participants does not match active attempt context" + .to_string(), + )); + } + + if incoming_attempt_context.included_participants_fingerprint + != active_attempt_context.included_participants_fingerprint + { + return Err(EngineError::Validation( + "attempt_context.included_participants_fingerprint does not match active attempt context" + .to_string(), + )); + } + + if incoming_attempt_context.attempt_id != active_attempt_context.attempt_id { + return Err(EngineError::Validation( + "attempt_context.attempt_id does not match active attempt context".to_string(), + )); + } + + Ok(ActiveAttemptMatchOutcome::MatchActive) +} + +fn validate_session_id(session_id: &str) -> Result<(), EngineError> { + if session_id.is_empty() { + return Err(EngineError::Validation( + "session_id must be non-empty".to_string(), + )); + } + + if session_id.len() > 128 { + return Err(EngineError::Validation( + "session_id exceeds max length 128 bytes".to_string(), + )); + } + + if session_id.bytes().any(|byte| { + byte.is_ascii_control() || byte == b' ' || byte == b'=' || byte == b'"' || byte == b'\\' + }) { + return Err(EngineError::Validation( + "session_id contains disallowed characters (control, space, =, \", \\)".to_string(), + )); + } + + Ok(()) +} + +fn clear_session_signing_material(session: &mut SessionState) { + // Intentionally retain `dkg_result` and `dkg_request_fingerprint` because + // RefreshShares is an independent post-DKG flow. + // + // Best-effort zeroization: clear byte/string material we own directly + // before dropping Option containers. + if let Some(sign_request_fingerprint) = session.sign_request_fingerprint.as_mut() { + sign_request_fingerprint.zeroize(); + } + if let Some(sign_message_bytes) = session.sign_message_bytes.as_mut() { + sign_message_bytes.zeroize(); + } + if let Some(round_state) = session.round_state.as_mut() { + round_state.session_id.zeroize(); + round_state.round_id.zeroize(); + round_state.message_digest_hex.zeroize(); + if let Some(signing_participants) = round_state.signing_participants.as_mut() { + signing_participants.zeroize(); + } + if let Some(transition_telemetry) = round_state.attempt_transition_telemetry.as_mut() { + transition_telemetry.from_attempt_number.zeroize(); + transition_telemetry.to_attempt_number.zeroize(); + transition_telemetry.from_coordinator_identifier.zeroize(); + transition_telemetry.to_coordinator_identifier.zeroize(); + transition_telemetry.reason.zeroize(); + transition_telemetry.excluded_member_identifiers.zeroize(); + transition_telemetry.coordinator_rotated = false; + } + round_state.own_contribution.identifier.zeroize(); + round_state.own_contribution.signature_share_hex.zeroize(); + } + if let Some(active_attempt_context) = session.active_attempt_context.as_mut() { + active_attempt_context.included_participants.zeroize(); + active_attempt_context + .included_participants_fingerprint + .zeroize(); + active_attempt_context.attempt_id.zeroize(); + } + + session.dkg_key_packages = None; + session.dkg_public_key_package = None; + session.sign_request_fingerprint = None; + session.sign_message_bytes = None; + session.round_state = None; + session.active_attempt_context = None; +} + +fn clear_active_sign_round_for_attempt_transition(session: &mut SessionState) { + if let Some(sign_request_fingerprint) = session.sign_request_fingerprint.as_mut() { + sign_request_fingerprint.zeroize(); + } + if let Some(sign_message_bytes) = session.sign_message_bytes.as_mut() { + sign_message_bytes.zeroize(); + } + if let Some(round_state) = session.round_state.as_mut() { + round_state.session_id.zeroize(); + round_state.round_id.zeroize(); + round_state.message_digest_hex.zeroize(); + if let Some(signing_participants) = round_state.signing_participants.as_mut() { + signing_participants.zeroize(); + } + if let Some(transition_telemetry) = round_state.attempt_transition_telemetry.as_mut() { + transition_telemetry.from_attempt_number.zeroize(); + transition_telemetry.to_attempt_number.zeroize(); + transition_telemetry.from_coordinator_identifier.zeroize(); + transition_telemetry.to_coordinator_identifier.zeroize(); + transition_telemetry.reason.zeroize(); + transition_telemetry.excluded_member_identifiers.zeroize(); + transition_telemetry.coordinator_rotated = false; + } + round_state.own_contribution.identifier.zeroize(); + round_state.own_contribution.signature_share_hex.zeroize(); + } + + session.sign_request_fingerprint = None; + session.sign_message_bytes = None; + session.round_state = None; +} + +pub fn run_dkg(request: RunDkgRequest) -> Result { + let _latency_guard = HardeningOperationLatencyGuard::new(HardeningOperation::RunDkg); + validate_session_id(&request.session_id)?; + enforce_bootstrap_dealer_dkg_disabled_in_production(&request.session_id)?; + + record_hardening_telemetry(|telemetry| { + telemetry.run_dkg_calls_total = telemetry.run_dkg_calls_total.saturating_add(1); + }); + enforce_provenance_gate()?; + enforce_admission_policy(&request)?; + + if request.participants.len() < 2 { + return Err(EngineError::Validation( + "participants must contain at least 2 entries".to_string(), + )); + } + + if request.threshold < 2 || usize::from(request.threshold) > request.participants.len() { + return Err(EngineError::Validation( + "threshold must be between 2 and number of participants".to_string(), + )); + } + + let mut unique_identifiers = HashSet::new(); + for participant in &request.participants { + if participant.identifier == 0 { + return Err(EngineError::Validation( + "participant identifier must be non-zero".to_string(), + )); + } + + if !unique_identifiers.insert(participant.identifier) { + return Err(EngineError::Validation( + "participant identifiers must be unique".to_string(), + )); + } + } + + let request_fingerprint = fingerprint(&canonicalize_dkg_request_for_fingerprint(&request))?; + + { + let guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + if let Some(session) = guard.sessions.get(&request.session_id) { + if let Some(existing) = &session.dkg_request_fingerprint { + if existing == &request_fingerprint { + return session.dkg_result.clone().ok_or_else(|| { + EngineError::Internal("missing DKG result cache".to_string()) + }); + } + + return Err(EngineError::SessionConflict { + session_id: request.session_id, + }); + } + } else { + ensure_session_insert_capacity(&guard.sessions, &request.session_id)?; + } + } + + let mut participant_identifiers: Vec = request + .participants + .iter() + .map(|participant| participant.identifier) + .collect(); + participant_identifiers.sort_unstable(); + + let auto_quarantine_config = load_auto_quarantine_config()?; + let quarantined_operator_identifiers = { + let guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + guard.quarantined_operator_identifiers.clone() + }; + enforce_not_quarantined_identifiers( + &request.session_id, + &participant_identifiers, + &quarantined_operator_identifiers, + auto_quarantine_config.as_ref(), + )?; + + let frost_identifiers: Vec = participant_identifiers + .iter() + .map(|identifier| participant_identifier_to_frost_identifier(*identifier)) + .collect::, _>>()?; + + let mut keygen_rng_seed = development_dealer_dkg_seed(request.dkg_seed_hex.as_deref())?; + let keygen_rng = ZeroizingChaCha20Rng::from_seed(keygen_rng_seed); + keygen_rng_seed.zeroize(); + + let (secret_shares, public_key_package) = frost::keys::generate_with_dealer( + request.participants.len() as u16, + request.threshold, + frost::keys::IdentifierList::Custom(&frost_identifiers), + keygen_rng, + ) + .map_err(|e| EngineError::Internal(format!("failed to generate key shares: {e}")))?; + + let mut participant_identifier_by_frost_identifier = HashMap::new(); + for (participant_identifier, frost_identifier) in + participant_identifiers.iter().zip(frost_identifiers.iter()) + { + participant_identifier_by_frost_identifier.insert( + hex::encode(frost_identifier.serialize()), + *participant_identifier, + ); + } + + let mut key_packages = BTreeMap::new(); + for (frost_identifier, secret_share) in secret_shares { + let participant_identifier = participant_identifier_by_frost_identifier + .get(&hex::encode(frost_identifier.serialize())) + .copied() + .ok_or_else(|| { + EngineError::Internal( + "missing participant identifier mapping for generated key share".to_string(), + ) + })?; + + let key_package = frost::keys::KeyPackage::try_from(secret_share) + .map_err(|e| EngineError::Internal(format!("failed to convert secret share: {e}")))?; + + key_packages.insert(participant_identifier, key_package); + } + + if key_packages.len() != request.participants.len() { + return Err(EngineError::Internal( + "generated key package count mismatch".to_string(), + )); + } + + // The `frost-secp256k1-tr` ciphersuite post-processes DKG output before + // returning these packages. This serialized verifying key is the protocol + // wallet key exported to Go/on-chain; later Taproot tweaks are applied + // relative to this exported key. + let key_group = public_key_package + .verifying_key() + .serialize() + .map(hex::encode) + .map_err(|e| EngineError::Internal(format!("failed to serialize verifying key: {e}")))?; + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + ensure_session_insert_capacity(&guard.sessions, &request.session_id)?; + + let session = guard + .sessions + .entry(request.session_id.clone()) + .or_insert_with(SessionState::default); + + if let Some(existing) = &session.dkg_request_fingerprint { + if existing == &request_fingerprint { + return session + .dkg_result + .clone() + .ok_or_else(|| EngineError::Internal("missing DKG result cache".to_string())); + } + + return Err(EngineError::SessionConflict { + session_id: request.session_id, + }); + } + + let result = DkgResult { + session_id: request.session_id, + key_group, + participant_count: request.participants.len() as u16, + threshold: request.threshold, + created_at_unix: now_unix(), + }; + + session.dkg_request_fingerprint = Some(request_fingerprint); + session.dkg_key_packages = Some(key_packages); + session.dkg_public_key_package = Some(public_key_package); + session.dkg_result = Some(result.clone()); + persist_engine_state_to_storage(&guard)?; + record_hardening_telemetry(|telemetry| { + telemetry.run_dkg_success_total = telemetry.run_dkg_success_total.saturating_add(1); + }); + + Ok(result) +} + +fn enforce_bootstrap_dealer_dkg_disabled_in_production( + session_id: &str, +) -> Result<(), EngineError> { + if signer_profile_is_production() { + return Err(EngineError::LifecyclePolicyRejected { + session_id: session_id.to_string(), + reason_code: "bootstrap_dealer_dkg_disabled_in_production".to_string(), + detail: format!( + "bootstrap dealer DKG is disabled when {TBTC_SIGNER_PROFILE_ENV}={TBTC_SIGNER_PROFILE_PRODUCTION}; production requires distributed DKG wiring" + ), + }); + } + + Ok(()) +} + +fn development_dealer_dkg_seed(dkg_seed_hex: Option<&str>) -> Result<[u8; 32], EngineError> { + let Some(seed_hex) = dkg_seed_hex else { + let mut seed = [0_u8; 32]; + OsRng.fill_bytes(&mut seed); + return Ok(seed); + }; + + let seed = + Zeroizing::new(hex::decode(seed_hex).map_err(|e| { + EngineError::Validation(format!("dkg_seed_hex must be valid hex: {e}")) + })?); + if seed.len() != 32 { + return Err(EngineError::Validation(format!( + "dkg_seed_hex decoded to [{}] bytes, expected 32", + seed.len() + ))); + } + + let mut output = [0u8; 32]; + output.copy_from_slice(&seed); + + Ok(output) +} + +pub fn start_sign_round(mut request: StartSignRoundRequest) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.start_sign_round_calls_total = + telemetry.start_sign_round_calls_total.saturating_add(1); + }); + let _latency_guard = HardeningOperationLatencyGuard::new(HardeningOperation::StartSignRound); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + if request.member_identifier == 0 { + return Err(EngineError::Validation( + "member_identifier must be non-zero".to_string(), + )); + } + + let message_bytes = hex::decode(&request.message_hex) + .map_err(|_| EngineError::Validation("message_hex must be valid hex".to_string()))?; + let message_digest_hex = hash_hex(&message_bytes); + let taproot_merkle_root = + canonicalize_taproot_merkle_root_hex(&mut request.taproot_merkle_root_hex)?; + let strict_roast_mode_enabled = roast_strict_mode_enabled(); + + let request_fingerprint = start_sign_round_request_fingerprint(&request, 0)?; + // Before multi-seat round reuse, persisted active rounds were bound to the + // concrete member identifier. Accept that legacy fingerprint so an upgrade + // does not invalidate an in-flight signing round. + let legacy_member_request_fingerprint = + start_sign_round_request_fingerprint(&request, request.member_identifier)?; + // The previous round-reuse implementation included one-shot transition + // evidence in the persisted active-round fingerprint. Accept that shape + // when callers still resend the evidence, then migrate to the stable form. + let legacy_canonical_with_transition_evidence_fingerprint = + start_sign_round_request_fingerprint_including_transition_evidence(&request, 0)?; + let legacy_member_with_transition_evidence_fingerprint = + start_sign_round_request_fingerprint_including_transition_evidence( + &request, + request.member_identifier, + )?; + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + let auto_quarantine_config = load_auto_quarantine_config()?; + let quarantined_operator_identifiers = guard.quarantined_operator_identifiers.clone(); + + let mut pending_transition_record = None; + let round_state = { + let session = guard.sessions.get_mut(&request.session_id).ok_or_else(|| { + EngineError::SessionNotFound { + session_id: request.session_id.clone(), + } + })?; + + let dkg = session + .dkg_result + .clone() + .ok_or_else(|| EngineError::DkgNotReady { + session_id: request.session_id.clone(), + })?; + + if let Some(emergency_rekey_event) = session.emergency_rekey_event.as_ref() { + return Err(EngineError::LifecyclePolicyRejected { + session_id: request.session_id.clone(), + reason_code: "emergency_rekey_required".to_string(), + detail: format!( + "emergency rekey required for session [{}] since [{}]: {}", + request.session_id, + emergency_rekey_event.triggered_at_unix, + emergency_rekey_event.reason + ), + }); + } + + if session.finalize_request_fingerprint.is_some() { + // Lifecycle terminal state: once finalize succeeds for a session, we + // intentionally return SessionFinalized and require a new session_id + // for any subsequent StartSignRound call on that session ID. + return Err(EngineError::SessionFinalized { + session_id: request.session_id, + }); + } + + if request.key_group != dkg.key_group { + return Err(EngineError::Validation( + "key_group does not match DKG output for this session".to_string(), + )); + } + + { + let dkg_key_packages = session.dkg_key_packages.as_ref().ok_or_else(|| { + EngineError::Internal("missing DKG key package cache".to_string()) + })?; + + if !dkg_key_packages.contains_key(&request.member_identifier) { + return Err(EngineError::Validation( + "member_identifier is not a DKG participant for this session".to_string(), + )); + } + } + enforce_signing_message_binding_to_policy_checked_build_tx( + &request.session_id, + &request.message_hex, + session.tx_result.as_ref(), + )?; + + // Guard against partial legacy state where sign material was cleared but + // active attempt context was not. + if session.sign_request_fingerprint.is_none() || session.round_state.is_none() { + session.active_attempt_context = None; + } + + let canonical_attempt_context = request + .attempt_context + .as_ref() + .map(canonical_attempt_context); + let mut attempt_transition_telemetry = None; + let mut attempt_transition_record = None; + if let Some(active_attempt_context) = session.active_attempt_context.as_ref() { + let active_attempt_match_outcome = enforce_active_attempt_context_match( + active_attempt_context, + canonical_attempt_context.as_ref(), + request.attempt_transition_evidence.as_ref(), + session.round_state.as_ref(), + session.sign_request_fingerprint.as_deref(), + strict_roast_mode_enabled, + )?; + + if let ActiveAttemptMatchOutcome::AdvanceAuthorized = active_attempt_match_outcome { + let incoming_attempt_context = + canonical_attempt_context.as_ref().ok_or_else(|| { + EngineError::Internal( + "missing incoming attempt context for authorized transition" + .to_string(), + ) + })?; + let transition_evidence = + request + .attempt_transition_evidence + .as_ref() + .ok_or_else(|| { + EngineError::Internal( + "missing attempt_transition_evidence for authorized transition" + .to_string(), + ) + })?; + attempt_transition_telemetry = build_attempt_transition_telemetry( + active_attempt_context, + incoming_attempt_context, + Some(transition_evidence), + ); + if attempt_transition_telemetry.is_none() { + return Err(EngineError::Internal( + "missing transition telemetry evidence for authorized transition" + .to_string(), + )); + } + attempt_transition_record = Some(build_transcript_audit_record( + active_attempt_context, + incoming_attempt_context, + transition_evidence, + )?); + clear_active_sign_round_for_attempt_transition(session); + } + } + + if let Some(existing) = &session.sign_request_fingerprint { + let matches_canonical_fingerprint = existing == &request_fingerprint; + let matches_legacy_fingerprint = !matches_canonical_fingerprint + && (existing == &legacy_member_request_fingerprint + || existing == &legacy_canonical_with_transition_evidence_fingerprint + || existing == &legacy_member_with_transition_evidence_fingerprint); + + if matches_canonical_fingerprint || matches_legacy_fingerprint { + let mut round_state = session.round_state.clone().ok_or_else(|| { + EngineError::Internal("missing round state cache".to_string()) + })?; + let sign_message_bytes = session.sign_message_bytes.as_ref().ok_or_else(|| { + EngineError::Internal("missing sign message cache".to_string()) + })?; + let signing_participants = + round_state.signing_participants.clone().ok_or_else(|| { + EngineError::Internal( + "missing round signing participants cache".to_string(), + ) + })?; + let dkg_key_packages = session.dkg_key_packages.as_ref().ok_or_else(|| { + EngineError::Internal("missing DKG key package cache".to_string()) + })?; + + round_state.own_contribution = build_real_signature_share_contribution( + dkg_key_packages, + &signing_participants, + &request, + &round_state.round_id, + sign_message_bytes, + taproot_merkle_root.as_ref(), + )?; + + if matches_legacy_fingerprint { + session.sign_request_fingerprint = Some(request_fingerprint.clone()); + persist_engine_state_to_storage(&guard)?; + } + + return Ok(round_state); + } + + return Err(EngineError::SessionConflict { + session_id: request.session_id, + }); + } + + let signing_participants = { + let dkg_key_packages = session.dkg_key_packages.as_ref().ok_or_else(|| { + EngineError::Internal("missing DKG key package cache".to_string()) + })?; + resolve_signing_participants(&request, dkg.threshold, dkg_key_packages)? + }; + if let Some(canonical_attempt_signing_participants) = validate_attempt_context( + &request.session_id, + &message_digest_hex, + dkg.threshold, + request.attempt_context.as_ref(), + strict_roast_mode_enabled, + )? { + if canonical_attempt_signing_participants != signing_participants { + return Err(EngineError::Validation( + "attempt_context.included_participants must match resolved signing_participants" + .to_string(), + )); + } + } + enforce_not_quarantined_identifiers( + &request.session_id, + &signing_participants, + &quarantined_operator_identifiers, + auto_quarantine_config.as_ref(), + )?; + + let signing_participants_fingerprint = fingerprint(&signing_participants)?; + let consumed_attempt_id = canonical_attempt_context + .as_ref() + .map(|attempt_context| attempt_context.attempt_id.clone()); + if let Some(attempt_id) = consumed_attempt_id.as_ref() { + if session.consumed_attempt_ids.contains(attempt_id) { + return Err(EngineError::ConsumedAttemptReplay { + session_id: request.session_id.clone(), + attempt_id: attempt_id.clone(), + }); + } + ensure_consumed_registry_insert_capacity( + &session.consumed_attempt_ids, + attempt_id, + "consumed_attempt_ids", + &request.session_id, + )?; + } + let round_id = derive_round_id( + &request.session_id, + &request.key_group, + &request.message_hex, + request.taproot_merkle_root_hex.as_deref(), + &signing_participants_fingerprint, + canonical_attempt_context.as_ref(), + ); + if session.consumed_sign_round_ids.contains(&round_id) { + return Err(EngineError::ConsumedRoundReplay { + session_id: request.session_id.clone(), + round_id: round_id.clone(), + }); + } + ensure_consumed_registry_insert_capacity( + &session.consumed_sign_round_ids, + &round_id, + "consumed_sign_round_ids", + &request.session_id, + )?; + let own_contribution = { + let dkg_key_packages = session.dkg_key_packages.as_ref().ok_or_else(|| { + EngineError::Internal("missing DKG key package cache".to_string()) + })?; + build_real_signature_share_contribution( + dkg_key_packages, + &signing_participants, + &request, + &round_id, + &message_bytes, + taproot_merkle_root.as_ref(), + )? + }; + + if let Some(transition_telemetry) = attempt_transition_telemetry.as_ref() { + record_hardening_telemetry(|telemetry| { + telemetry.attempt_transition_total = + telemetry.attempt_transition_total.saturating_add(1); + if transition_telemetry.coordinator_rotated { + telemetry.coordinator_failover_total = + telemetry.coordinator_failover_total.saturating_add(1); + } + }); + } + if let Some(transition_record) = attempt_transition_record.as_ref() { + ensure_attempt_transition_record_insert_capacity( + &session.attempt_transition_records, + &request.session_id, + )?; + session + .attempt_transition_records + .push(transition_record.clone()); + pending_transition_record = Some(transition_record.clone()); + } + + let round_state = RoundState { + session_id: request.session_id.clone(), + round_id: round_id.clone(), + required_contributions: dkg.threshold, + message_digest_hex: message_digest_hex.clone(), + taproot_merkle_root_hex: request.taproot_merkle_root_hex.clone(), + signing_participants: Some(signing_participants), + attempt_transition_telemetry, + own_contribution, + }; + + session.sign_request_fingerprint = Some(request_fingerprint); + session.sign_message_bytes = Some(Zeroizing::new(message_bytes)); + session.round_state = Some(round_state.clone()); + session.active_attempt_context = canonical_attempt_context; + if let Some(attempt_id) = consumed_attempt_id { + session.consumed_attempt_ids.insert(attempt_id); + } + session.consumed_sign_round_ids.insert(round_id); + + round_state + }; + + if let Some(transition_record) = pending_transition_record.as_ref() { + apply_auto_quarantine_faults_for_transition( + &mut guard, + &request.session_id, + transition_record, + auto_quarantine_config.as_ref(), + ); + } + + persist_engine_state_to_storage(&guard)?; + record_hardening_telemetry(|telemetry| { + telemetry.start_sign_round_success_total = + telemetry.start_sign_round_success_total.saturating_add(1); + }); + + Ok(round_state) +} + +fn resolve_signing_participants( + request: &StartSignRoundRequest, + threshold: u16, + dkg_key_packages: &BTreeMap, +) -> Result, EngineError> { + let mut signing_participants = request + .signing_participants + .clone() + .unwrap_or_else(|| dkg_key_packages.keys().copied().collect()); + if signing_participants.is_empty() { + return Err(EngineError::Validation( + "signing_participants must not be empty".to_string(), + )); + } + + signing_participants.sort_unstable(); + let mut unique_signing_participants = HashSet::new(); + + for signing_participant in &signing_participants { + if *signing_participant == 0 { + return Err(EngineError::Validation( + "signing_participants must contain non-zero identifiers".to_string(), + )); + } + + if !unique_signing_participants.insert(*signing_participant) { + return Err(EngineError::Validation(format!( + "signing_participants contains duplicate identifier [{}]", + signing_participant + ))); + } + + if !dkg_key_packages.contains_key(signing_participant) { + return Err(EngineError::Validation(format!( + "signing_participant [{}] is not a DKG participant for this session", + signing_participant + ))); + } + } + + if signing_participants.len() < usize::from(threshold) { + return Err(EngineError::Validation(format!( + "signing_participants must contain at least threshold members [{}]", + threshold + ))); + } + + if !unique_signing_participants.contains(&request.member_identifier) { + return Err(EngineError::Validation( + "member_identifier must be included in signing_participants".to_string(), + )); + } + + Ok(signing_participants) +} + +fn build_real_signature_share_contribution( + dkg_key_packages: &BTreeMap, + signing_participants: &[u16], + request: &StartSignRoundRequest, + round_id: &str, + message_bytes: &[u8], + taproot_merkle_root: Option<&[u8; 32]>, +) -> Result { + let mut commitments = BTreeMap::new(); + let mut own_nonces = None; + + for participant_identifier in signing_participants { + let key_package = dkg_key_packages + .get(participant_identifier) + .ok_or_else(|| { + EngineError::Internal(format!( + "missing DKG key package for signing participant [{}]", + participant_identifier + )) + })?; + let frost_identifier = participant_identifier_to_frost_identifier(*participant_identifier)?; + let (mut nonces, participant_commitments) = build_deterministic_round_nonce_and_commitment( + key_package, + &request.session_id, + round_id, + message_bytes, + *participant_identifier, + ); + commitments.insert(frost_identifier, participant_commitments); + + if *participant_identifier == request.member_identifier { + // `SigningNonces` derives `ZeroizeOnDrop`; if a later `?` returns + // early in this function, this cached own nonce is still wiped + // when `own_nonces` drops during unwind of the error path. + own_nonces = Some(nonces); + } else { + nonces.zeroize(); + } + } + + let mut own_nonces = own_nonces.ok_or_else(|| { + EngineError::Validation( + "member_identifier is missing from generated participant nonces".to_string(), + ) + })?; + + let own_key_package = dkg_key_packages + .get(&request.member_identifier) + .ok_or_else(|| { + EngineError::Validation( + "member_identifier key package is missing from DKG cache".to_string(), + ) + })?; + + let signing_package = frost::SigningPackage::new(commitments, message_bytes); + let signature_share_result = if let Some(taproot_merkle_root) = taproot_merkle_root { + frost::round2::sign_with_tweak( + &signing_package, + &own_nonces, + own_key_package, + Some(taproot_merkle_root.as_slice()), + ) + } else { + frost::round2::sign(&signing_package, &own_nonces, own_key_package) + }; + own_nonces.zeroize(); + let signature_share = signature_share_result + .map_err(|e| EngineError::Internal(format!("failed to create signature share: {e}")))?; + + let mut signature_share_bytes = signature_share.serialize(); + let signature_share_hex = hex::encode(&signature_share_bytes); + signature_share_bytes.zeroize(); + + Ok(RoundContribution { + identifier: request.member_identifier, + signature_share_hex, + }) +} + +pub fn finalize_sign_round( + mut request: FinalizeSignRoundRequest, + bootstrap_mode_enabled: bool, +) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.finalize_sign_round_calls_total = + telemetry.finalize_sign_round_calls_total.saturating_add(1); + }); + let _latency_guard = HardeningOperationLatencyGuard::new(HardeningOperation::FinalizeSignRound); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + let strict_roast_mode_enabled = roast_strict_mode_enabled(); + let finalize_taproot_merkle_root = + canonicalize_taproot_merkle_root_hex(&mut request.taproot_merkle_root_hex)?; + + let request_fingerprint = { + let mut canonical_attempt_context = request.attempt_context.clone(); + canonicalize_attempt_context_for_fingerprint(&mut canonical_attempt_context); + + let mut canonical_contributions = request.round_contributions.clone(); + canonical_contributions.sort_unstable_by(|left, right| { + left.identifier + .cmp(&right.identifier) + .then_with(|| left.signature_share_hex.cmp(&right.signature_share_hex)) + }); + + fingerprint(&FinalizeSignRoundRequest { + session_id: request.session_id.clone(), + taproot_merkle_root_hex: request.taproot_merkle_root_hex.clone(), + round_contributions: canonical_contributions, + attempt_context: canonical_attempt_context, + })? + }; + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + + let session = guard.sessions.get_mut(&request.session_id).ok_or_else(|| { + EngineError::SessionNotFound { + session_id: request.session_id.clone(), + } + })?; + if let Some(emergency_rekey_event) = session.emergency_rekey_event.as_ref() { + return Err(EngineError::LifecyclePolicyRejected { + session_id: request.session_id.clone(), + reason_code: "emergency_rekey_required".to_string(), + detail: format!( + "finalize blocked: emergency rekey required since [{}]: {}", + emergency_rekey_event.triggered_at_unix, emergency_rekey_event.reason + ), + }); + } + + if session.round_state.is_none() { + session.active_attempt_context = None; + } + + let canonical_attempt_context = request + .attempt_context + .as_ref() + .map(canonical_attempt_context); + if let Some(active_attempt_context) = session.active_attempt_context.as_ref() { + enforce_active_attempt_context_match( + active_attempt_context, + canonical_attempt_context.as_ref(), + None, + session.round_state.as_ref(), + session.sign_request_fingerprint.as_deref(), + strict_roast_mode_enabled, + )?; + } + + if let Some(existing) = &session.finalize_request_fingerprint { + if existing == &request_fingerprint { + return session.signature_result.clone().ok_or_else(|| { + EngineError::Internal("missing finalize signature cache".to_string()) + }); + } + + return Err(EngineError::SessionConflict { + session_id: request.session_id, + }); + } + if session + .consumed_finalize_request_fingerprints + .contains(&request_fingerprint) + { + return Err(EngineError::Validation(format!( + "finalize request fingerprint [{}] already consumed in session [{}]", + request_fingerprint, request.session_id + ))); + } + + let round_state = + session + .round_state + .clone() + .ok_or_else(|| EngineError::SignRoundNotStarted { + session_id: request.session_id.clone(), + })?; + if request.taproot_merkle_root_hex != round_state.taproot_merkle_root_hex { + return Err(EngineError::Validation( + "taproot_merkle_root_hex does not match active signing round".to_string(), + )); + } + if signing_policy_firewall_enforced() { + let sign_message_hex = session + .sign_message_bytes + .as_ref() + .map(|bytes| hex::encode(bytes.as_slice())) + .ok_or_else(|| EngineError::Internal("missing sign message cache".to_string()))?; + enforce_signing_message_binding_to_policy_checked_build_tx( + &request.session_id, + &sign_message_hex, + session.tx_result.as_ref(), + )?; + } + // This consumed-round check depends on `round_state` being present to + // recover `round_id`. If prior finalize already purged round_state, + // SignRoundNotStarted fails closed before this branch. + if session + .consumed_finalize_round_ids + .contains(&round_state.round_id) + { + return Err(EngineError::Validation(format!( + "round_id [{}] already consumed for finalize in session [{}]", + round_state.round_id, request.session_id + ))); + } + + if request.round_contributions.is_empty() { + return Err(EngineError::Validation( + "round_contributions must not be empty".to_string(), + )); + } + + if request.round_contributions.len() < usize::from(round_state.required_contributions) { + return Err(EngineError::Validation(format!( + "insufficient round contributions: expected at least {}", + round_state.required_contributions + ))); + } + + if let Some(canonical_attempt_signing_participants) = validate_attempt_context( + &request.session_id, + &round_state.message_digest_hex, + round_state.required_contributions, + request.attempt_context.as_ref(), + strict_roast_mode_enabled, + )? { + let mut canonical_round_signing_participants = + round_state.signing_participants.clone().ok_or_else(|| { + EngineError::Internal( + "missing round signing participants for attempt context validation".to_string(), + ) + })?; + canonical_round_signing_participants.sort_unstable(); + canonical_round_signing_participants.dedup(); + if canonical_attempt_signing_participants != canonical_round_signing_participants { + return Err(EngineError::Validation( + "attempt_context.included_participants must match round signing participants" + .to_string(), + )); + } + } + + let mut ordered_contributions = request.round_contributions; + ordered_contributions.sort_by_key(|contribution| contribution.identifier); + let is_synthetic = uses_bootstrap_synthetic_contributions(&round_state, &ordered_contributions); + + if !bootstrap_mode_enabled && is_synthetic { + return Err(EngineError::SyntheticContributionRejected { + session_id: request.session_id, + }); + } + if is_synthetic && round_state.taproot_merkle_root_hex.is_some() { + return Err(EngineError::Validation( + "synthetic contributions do not support taproot tweaked signing".to_string(), + )); + } + + let signature_result = if is_synthetic { + build_bootstrap_synthetic_signature_result( + &request.session_id, + &round_state, + &ordered_contributions, + )? + } else { + let dkg_key_packages = session + .dkg_key_packages + .as_ref() + .ok_or_else(|| EngineError::Internal("missing DKG key package cache".to_string()))?; + + let dkg_public_key_package = session.dkg_public_key_package.as_ref().ok_or_else(|| { + EngineError::Internal("missing DKG public key package cache".to_string()) + })?; + + let sign_message_bytes = session + .sign_message_bytes + .as_ref() + .ok_or_else(|| EngineError::Internal("missing sign message cache".to_string()))?; + + let signing_participants = round_state + .signing_participants + .clone() + .unwrap_or_else(|| dkg_key_packages.keys().copied().collect()); + + let mut signing_participant_set = HashSet::new(); + for signing_participant in &signing_participants { + if !signing_participant_set.insert(*signing_participant) { + return Err(EngineError::Internal(format!( + "duplicate signing participant identifier [{}] in round state", + signing_participant + ))); + } + } + + let mut commitments = BTreeMap::new(); + for signing_participant in &signing_participants { + let key_package = dkg_key_packages.get(signing_participant).ok_or_else(|| { + EngineError::Internal(format!( + "missing DKG key package for signing participant [{}]", + signing_participant + )) + })?; + let frost_identifier = + participant_identifier_to_frost_identifier(*signing_participant)?; + let (mut participant_nonces, participant_commitments) = + build_deterministic_round_nonce_and_commitment( + key_package, + &round_state.session_id, + &round_state.round_id, + sign_message_bytes, + *signing_participant, + ); + participant_nonces.zeroize(); + commitments.insert(frost_identifier, participant_commitments); + } + + let mut contributing_identifiers = Vec::with_capacity(ordered_contributions.len()); + let mut signature_shares = BTreeMap::new(); + for contribution in &ordered_contributions { + if !signing_participant_set.contains(&contribution.identifier) { + return Err(EngineError::Validation(format!( + "round contribution identifier [{}] is not in signing participant set", + contribution.identifier + ))); + } + + let frost_identifier = + participant_identifier_to_frost_identifier(contribution.identifier)?; + + if signature_shares.contains_key(&frost_identifier) { + return Err(EngineError::Validation(format!( + "duplicate round contribution identifier [{}]", + contribution.identifier + ))); + } + + let mut signature_share_bytes = hex::decode(&contribution.signature_share_hex) + .map_err(|_| { + EngineError::Validation(format!( + "invalid signature_share_hex for identifier [{}]", + contribution.identifier + )) + })?; + let signature_share_result = + frost::round2::SignatureShare::deserialize(&signature_share_bytes); + signature_share_bytes.zeroize(); + let signature_share = signature_share_result.map_err(|e| { + EngineError::Validation(format!( + "invalid signature share for identifier [{}]: {e}", + contribution.identifier + )) + })?; + + contributing_identifiers.push(contribution.identifier); + signature_shares.insert(frost_identifier, signature_share); + } + + if contributing_identifiers.len() != signing_participants.len() { + return Err(EngineError::Validation(format!( + "round contribution identifiers must match signing participants for real finalize: expected {:?}, got {:?}", + signing_participants, contributing_identifiers + ))); + } + + let signing_package = frost::SigningPackage::new(commitments, sign_message_bytes); + let signature = if let Some(taproot_merkle_root) = finalize_taproot_merkle_root.as_ref() { + frost::aggregate_with_tweak( + &signing_package, + &signature_shares, + dkg_public_key_package, + Some(taproot_merkle_root.as_slice()), + ) + } else { + frost::aggregate(&signing_package, &signature_shares, dkg_public_key_package) + } + .map_err(|e| { + EngineError::Validation(format!("failed to aggregate signature shares: {e}")) + })?; + + let verification_key_package = + if let Some(taproot_merkle_root) = finalize_taproot_merkle_root.as_ref() { + dkg_public_key_package + .clone() + .tweak(Some(taproot_merkle_root.as_slice())) + } else { + dkg_public_key_package.clone() + }; + verification_key_package + .verifying_key() + .verify(sign_message_bytes, &signature) + .map_err(|e| { + EngineError::Validation(format!( + "aggregate signature failed self-verification: {e}" + )) + })?; + + let signature_bytes = signature.serialize().map_err(|e| { + EngineError::Internal(format!("failed to serialize aggregate signature: {e}")) + })?; + + SignatureResult { + session_id: request.session_id.clone(), + round_id: round_state.round_id.clone(), + signature_hex: hex::encode(signature_bytes), + } + }; + + let consumed_round_id = round_state.round_id.clone(); + ensure_consumed_registry_insert_capacity( + &session.consumed_finalize_round_ids, + &consumed_round_id, + "consumed_finalize_round_ids", + &request.session_id, + )?; + ensure_consumed_registry_insert_capacity( + &session.consumed_finalize_request_fingerprints, + &request_fingerprint, + "consumed_finalize_request_fingerprints", + &request.session_id, + )?; + + session.finalize_request_fingerprint = Some(request_fingerprint.clone()); + session.signature_result = Some(signature_result.clone()); + session + .consumed_finalize_round_ids + .insert(consumed_round_id); + session + .consumed_finalize_request_fingerprints + .insert(request_fingerprint); + clear_session_signing_material(session); + persist_engine_state_to_storage(&guard)?; + record_hardening_telemetry(|telemetry| { + telemetry.finalize_sign_round_success_total = telemetry + .finalize_sign_round_success_total + .saturating_add(1); + }); + + Ok(signature_result) +} + +fn build_bootstrap_synthetic_signature_result( + session_id: &str, + round_state: &RoundState, + ordered_contributions: &[RoundContribution], +) -> Result { + let mut contribution_bytes = serde_json::to_vec(ordered_contributions) + .map_err(|e| EngineError::Internal(format!("failed to encode contributions: {e}")))?; + let mut contribution_hash = hash_hex(&contribution_bytes); + contribution_bytes.zeroize(); + + let mut signature_material = format!( + "signature:{}:{}:{}", + round_state.session_id, round_state.round_id, contribution_hash + ); + contribution_hash.zeroize(); + let signature_hex = hash_hex(signature_material.as_bytes()); + signature_material.zeroize(); + + Ok(SignatureResult { + session_id: session_id.to_string(), + round_id: round_state.round_id.clone(), + signature_hex, + }) +} + +fn uses_bootstrap_synthetic_contributions( + round_state: &RoundState, + contributions: &[RoundContribution], +) -> bool { + contributions.iter().all(|contribution| { + contribution + .signature_share_hex + .eq_ignore_ascii_case(&bootstrap_synthetic_share_hex( + round_state, + contribution.identifier, + )) + }) +} + +fn bootstrap_synthetic_share_hex(round_state: &RoundState, identifier: u16) -> String { + bootstrap_synthetic_share_hex_for_round( + &round_state.session_id, + &round_state.round_id, + &round_state.message_digest_hex, + identifier, + ) +} + +fn bootstrap_synthetic_share_hex_for_round( + session_id: &str, + round_id: &str, + message_digest_hex: &str, + identifier: u16, +) -> String { + hash_hex( + format!( + "{}:{}:{}:{}:{}", + BOOTSTRAP_SYNTHETIC_CONTRIBUTION_DOMAIN, + session_id, + round_id, + message_digest_hex, + identifier, + ) + .as_bytes(), + ) +} + +pub fn build_taproot_tx(request: BuildTaprootTxRequest) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.build_taproot_tx_calls_total = + telemetry.build_taproot_tx_calls_total.saturating_add(1); + }); + let _latency_guard = HardeningOperationLatencyGuard::new(HardeningOperation::BuildTaprootTx); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + if request.inputs.is_empty() { + return Err(EngineError::Validation( + "inputs must not be empty".to_string(), + )); + } + + if request.outputs.is_empty() { + return Err(EngineError::Validation( + "outputs must not be empty".to_string(), + )); + } + + if request.script_tree_hex.is_some() { + return Err(EngineError::Validation( + "script_tree_hex is not yet supported; provide fully-derived output script_pubkey_hex values".to_string(), + )); + } + + let request_fingerprint = fingerprint(&request)?; + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + + if let Some(session) = guard.sessions.get(&request.session_id) { + if let Some(emergency_rekey_event) = session.emergency_rekey_event.as_ref() { + return Err(EngineError::LifecyclePolicyRejected { + session_id: request.session_id.clone(), + reason_code: "emergency_rekey_required".to_string(), + detail: format!( + "build_taproot_tx blocked: emergency rekey required since [{}]: {}", + emergency_rekey_event.triggered_at_unix, emergency_rekey_event.reason + ), + }); + } + + if let Some(existing) = &session.build_tx_request_fingerprint { + if existing == &request_fingerprint { + let cached_result = session + .tx_result + .clone() + .ok_or_else(|| EngineError::Internal("missing build tx cache".to_string()))?; + let cached_tx_bytes = hex::decode(&cached_result.tx_hex).map_err(|_| { + EngineError::Internal("cached build tx hex is not valid hex".to_string()) + })?; + let cached_tx: Transaction = deserialize(&cached_tx_bytes).map_err(|_| { + EngineError::Internal( + "cached build tx hex is not a valid transaction".to_string(), + ) + })?; + let total_output_value_sats = + cached_tx.output.iter().try_fold(0u64, |total, output| { + total.checked_add(output.value.to_sat()).ok_or_else(|| { + EngineError::Internal( + "cached build tx output total overflowed u64 bounds".to_string(), + ) + }) + })?; + if total_output_value_sats > BITCOIN_MAX_MONEY_SATS { + return Err(EngineError::Internal(format!( + "cached build tx output total [{}] exceeds Bitcoin max money [{}]", + total_output_value_sats, BITCOIN_MAX_MONEY_SATS + ))); + } + + // Idempotent retries return the cached transaction without consuming a + // new rate-limit token, but still re-check current non-rate policy gates. + recheck_signing_policy_firewall_without_rate_limit( + &request.session_id, + &cached_tx.output, + total_output_value_sats, + )?; + return Ok(cached_result); + } + + return Err(EngineError::SessionConflict { + session_id: request.session_id, + }); + } + } + ensure_session_insert_capacity(&guard.sessions, &request.session_id)?; + + // BuildTaprootTx is an assembly-only step. `input.value_sats` values are + // trusted caller-supplied metadata and are not verified against chain state. + let mut total_input_value_sats = 0u64; + let mut seen_input_keys = HashSet::new(); + let mut inputs = Vec::with_capacity(request.inputs.len()); + for input in request.inputs { + if input.value_sats > BITCOIN_MAX_MONEY_SATS { + return Err(EngineError::Validation(format!( + "input value_sats [{}] exceeds Bitcoin max money [{}]", + input.value_sats, BITCOIN_MAX_MONEY_SATS + ))); + } + + total_input_value_sats = total_input_value_sats + .checked_add(input.value_sats) + .ok_or_else(|| { + EngineError::Validation("input value_sats total overflowed u64 bounds".to_string()) + })?; + if total_input_value_sats > BITCOIN_MAX_MONEY_SATS { + return Err(EngineError::Validation(format!( + "input value_sats total [{}] exceeds Bitcoin max money [{}]", + total_input_value_sats, BITCOIN_MAX_MONEY_SATS + ))); + } + + let txid = Txid::from_str(&input.txid_hex).map_err(|_| { + EngineError::Validation(format!("invalid input txid_hex [{}]", input.txid_hex)) + })?; + let input_key = format!("{txid}:{}", input.vout); + if !seen_input_keys.insert(input_key.clone()) { + return Err(EngineError::Validation(format!( + "duplicate input outpoint [{}]", + input_key + ))); + } + + let previous_output = OutPoint { + txid, + vout: input.vout, + }; + + inputs.push(TxIn { + previous_output, + script_sig: ScriptBuf::new(), + // Use final sequence for deterministic non-RBF transaction assembly. + sequence: Sequence::MAX, + witness: Witness::new(), + }); + } + + let mut total_output_value_sats = 0u64; + let mut outputs = Vec::with_capacity(request.outputs.len()); + for output in request.outputs { + if output.value_sats > BITCOIN_MAX_MONEY_SATS { + return Err(EngineError::Validation(format!( + "output value_sats [{}] exceeds Bitcoin max money [{}]", + output.value_sats, BITCOIN_MAX_MONEY_SATS + ))); + } + + total_output_value_sats = total_output_value_sats + .checked_add(output.value_sats) + .ok_or_else(|| { + EngineError::Validation("output value_sats total overflowed u64 bounds".to_string()) + })?; + if total_output_value_sats > BITCOIN_MAX_MONEY_SATS { + return Err(EngineError::Validation(format!( + "output value_sats total [{}] exceeds Bitcoin max money [{}]", + total_output_value_sats, BITCOIN_MAX_MONEY_SATS + ))); + } + + let script_pubkey_bytes = hex::decode(&output.script_pubkey_hex).map_err(|_| { + EngineError::Validation(format!( + "invalid output script_pubkey_hex [{}]", + output.script_pubkey_hex + )) + })?; + let script_pubkey = ScriptBuf::from_bytes(script_pubkey_bytes); + if let Some(script_error) = script_pubkey + .instructions() + .find_map(|instruction| instruction.err()) + { + return Err(EngineError::Validation(format!( + "invalid output script_pubkey_hex [{}]: {script_error}", + output.script_pubkey_hex + ))); + } + outputs.push(TxOut { + value: Amount::from_sat(output.value_sats), + script_pubkey, + }); + } + + if total_output_value_sats > total_input_value_sats { + return Err(EngineError::Validation(format!( + "output value_sats total [{}] exceeds input value_sats total [{}]", + total_output_value_sats, total_input_value_sats + ))); + } + enforce_signing_policy_firewall(&request.session_id, &outputs, total_output_value_sats)?; + + let tx = Transaction { + // Version 2 + zero locktime are bootstrap defaults for immediate-spend txs. + version: Version::TWO, + lock_time: LockTime::ZERO, + input: inputs, + output: outputs, + }; + + let result = TransactionResult { + session_id: request.session_id, + tx_hex: serialize_hex(&tx), + }; + + // BuildTaprootTx is keyed into the shared session namespace for idempotency + // caching only; this session entry may intentionally not have DKG/signing + // state populated. + let session = guard + .sessions + .entry(result.session_id.clone()) + .or_insert_with(SessionState::default); + session.build_tx_request_fingerprint = Some(request_fingerprint); + session.tx_result = Some(result.clone()); + persist_engine_state_to_storage(&guard)?; + record_hardening_telemetry(|telemetry| { + telemetry.build_taproot_tx_success_total = + telemetry.build_taproot_tx_success_total.saturating_add(1); + }); + + Ok(result) +} + +pub fn refresh_shares(request: RefreshSharesRequest) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.refresh_shares_calls_total = + telemetry.refresh_shares_calls_total.saturating_add(1); + }); + let _latency_guard = HardeningOperationLatencyGuard::new(HardeningOperation::RefreshShares); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + if request.current_shares.is_empty() { + return Err(EngineError::Validation( + "current_shares must not be empty".to_string(), + )); + } + let mut unique_share_identifiers = HashSet::new(); + for share in &request.current_shares { + if share.identifier == 0 { + return Err(EngineError::Validation( + "current_shares identifiers must be non-zero".to_string(), + )); + } + if !unique_share_identifiers.insert(share.identifier) { + return Err(EngineError::Validation(format!( + "current_shares contains duplicate identifier [{}]", + share.identifier + ))); + } + } + + let request_fingerprint = fingerprint(&canonicalize_refresh_shares_request_for_fingerprint( + &request, + ))?; + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + + if let Some(session) = guard.sessions.get(&request.session_id) { + if let Some(emergency_rekey_event) = session.emergency_rekey_event.as_ref() { + return Err(EngineError::LifecyclePolicyRejected { + session_id: request.session_id.clone(), + reason_code: "emergency_rekey_required".to_string(), + detail: format!( + "refresh blocked: emergency rekey required since [{}]: {}", + emergency_rekey_event.triggered_at_unix, emergency_rekey_event.reason + ), + }); + } + + if let Some(existing) = &session.refresh_request_fingerprint { + if existing == &request_fingerprint { + return session + .refresh_result + .clone() + .ok_or_else(|| EngineError::Internal("missing refresh cache".to_string())); + } + + return Err(EngineError::SessionConflict { + session_id: request.session_id, + }); + } + } + ensure_session_insert_capacity(&guard.sessions, &request.session_id)?; + + let mut new_shares: Vec = request + .current_shares + .into_iter() + .map(|share| ShareMaterial { + identifier: share.identifier, + encrypted_share_hex: hash_hex( + format!( + "refresh:{}:{}:{}", + request.session_id, share.identifier, share.encrypted_share_hex + ) + .as_bytes(), + ), + }) + .collect(); + + new_shares.sort_by_key(|share| share.identifier); + + guard.refresh_epoch_counter = guard.refresh_epoch_counter.saturating_add(1); + let refresh_epoch = guard.refresh_epoch_counter; + + let result = RefreshSharesResult { + session_id: request.session_id, + refresh_epoch, + new_shares, + }; + + let session = guard + .sessions + .entry(result.session_id.clone()) + .or_insert_with(SessionState::default); + if let Some(emergency_rekey_event) = session.emergency_rekey_event.as_ref() { + return Err(EngineError::LifecyclePolicyRejected { + session_id: result.session_id.clone(), + reason_code: "emergency_rekey_required".to_string(), + detail: format!( + "refresh blocked: emergency rekey required since [{}]: {}", + emergency_rekey_event.triggered_at_unix, emergency_rekey_event.reason + ), + }); + } + session.refresh_request_fingerprint = Some(request_fingerprint); + session.refresh_result = Some(result.clone()); + session.refresh_history.push(RefreshHistoryRecord { + refresh_epoch, + refreshed_at_unix: now_unix(), + share_count: result.new_shares.len().min(u16::MAX as usize) as u16, + key_group: session.dkg_result.as_ref().map(|dkg| dkg.key_group.clone()), + }); + persist_engine_state_to_storage(&guard)?; + record_hardening_telemetry(|telemetry| { + telemetry.refresh_shares_success_total = + telemetry.refresh_shares_success_total.saturating_add(1); + }); + + Ok(result) +} + +#[cfg(test)] +static TEST_MUTEX: OnceLock> = OnceLock::new(); + +#[cfg(test)] +pub fn lock_test_state() -> std::sync::MutexGuard<'static, ()> { + let guard = TEST_MUTEX + .get_or_init(|| Mutex::new(())) + .lock() + .expect("test lock should not be poisoned"); + // Pin the signer profile to development at lock acquisition. Tests that + // need to exercise production-mode behavior set the env explicitly after + // taking the lock; doing this here prevents one test's `set_var` from + // leaking into the next locked test's body and (for example) routing the + // encrypted-state-envelope proptest into the production-rejects-env-key- + // provider gate that #414 introduced. + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_DEVELOPMENT); + guard +} + +#[cfg(test)] +pub fn reset_for_tests() { + clear_persist_fault_injection_for_tests(); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT, + ); + std::env::remove_var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV); + std::env::remove_var(TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV); + // Tests default to the explicit development profile so the production-safe + // missing-env default does not route unrelated tests through production + // policy gates. + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_DEVELOPMENT); + std::env::set_var( + TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV, + TEST_STATE_ENCRYPTION_KEY_HEX, + ); + + if let Ok(mut lock_slot) = state_file_lock_slot().lock() { + *lock_slot = None; + } + if let Ok(mut telemetry) = hardening_telemetry_state().lock() { + *telemetry = HardeningTelemetryState::default(); + } + if let Ok(mut limiter) = build_tx_rate_limiter_state().lock() { + *limiter = BuildTxRateLimiterState::default(); + } + + if let Ok(state) = state() { + if let Ok(mut guard) = state.lock() { + guard.sessions.clear(); + guard.refresh_epoch_counter = 0; + guard.operator_fault_scores.clear(); + guard.quarantined_operator_identifiers.clear(); + guard.canary_rollout = CanaryRolloutState::default(); + let _ = persist_engine_state_to_storage(&guard); + } + } +} + +#[cfg(test)] +pub fn reload_state_from_storage_for_tests() { + let loaded_state = load_engine_state_from_storage().expect("load engine state from storage"); + let state = state().expect("engine state should initialize"); + let mut guard = state.lock().expect("engine lock"); + *guard = loaded_state; +} + +#[cfg(test)] +pub fn simulate_process_restart_for_tests() { + if let Ok(mut lock_slot) = state_file_lock_slot().lock() { + *lock_slot = None; + } + + if let Some(state) = ENGINE_STATE.get() { + if let Ok(mut guard) = state.lock() { + guard.sessions.clear(); + guard.refresh_epoch_counter = 0; + guard.operator_fault_scores.clear(); + guard.quarantined_operator_identifiers.clear(); + guard.canary_rollout = CanaryRolloutState::default(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use serde::Deserialize; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; + #[cfg(unix)] + use std::{ + process::Command, + thread, + time::{Duration, Instant}, + }; + + #[derive(Deserialize)] + struct AttemptContextVectorDomains { + included_participants_fingerprint: String, + attempt_id: String, + } + + #[derive(Deserialize)] + struct AttemptContextVector { + id: String, + session_id: String, + message_digest_hex: String, + attempt_number: u32, + coordinator_identifier: u16, + included_participants: Vec, + expected_included_participants_fingerprint: String, + expected_attempt_id: String, + } + + #[derive(Deserialize)] + struct AttemptContextVectorSuite { + schema_version: String, + hash_domains: AttemptContextVectorDomains, + vectors: Vec, + } + + fn load_attempt_context_vector_suite() -> AttemptContextVectorSuite { + let vectors_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("test/vectors/roast-attempt-context-v1.json"); + let vector_bytes = std::fs::read(&vectors_path).unwrap_or_else(|err| { + panic!( + "failed to read attempt-context vector file [{}]: {err}", + vectors_path.display() + ) + }); + + serde_json::from_slice(&vector_bytes).expect("attempt-context vectors decode") + } + + struct InteractiveDkgFixture { + pre_normalization_even_y: bool, + part3_requests: BTreeMap, + } + + fn deterministic_interactive_dkg_fixture(seed: u8) -> InteractiveDkgFixture { + let participant_ids = [1u16, 2, 3]; + let participant_identifiers: BTreeMap = participant_ids + .iter() + .copied() + .map(|id| { + ( + id, + participant_identifier_to_frost_identifier(id).expect("participant identifier"), + ) + }) + .collect(); + let participant_id_by_identifier_hex: BTreeMap = participant_identifiers + .iter() + .map(|(id, identifier)| (hex::encode(identifier.serialize()), *id)) + .collect(); + + let mut part1_secrets = BTreeMap::new(); + let mut part1_packages = BTreeMap::new(); + for id in participant_ids { + let mut rng_seed = [0u8; 32]; + rng_seed[0] = seed; + rng_seed[1..3].copy_from_slice(&id.to_be_bytes()); + let rng = ZeroizingChaCha20Rng::from_seed(rng_seed); + let (secret_package, package) = frost::keys::dkg::part1( + participant_identifiers[&id], + participant_ids.len() as u16, + 2, + rng, + ) + .expect("DKG part1"); + + part1_secrets.insert(id, secret_package); + part1_packages.insert( + id, + DkgRound1Package { + identifier: frost_identifier_to_go_string(participant_identifiers[&id]), + package_hex: hex::encode(package.serialize().expect("round1 package")), + }, + ); + } + + let round1_packages_for = |recipient_id: u16| -> Vec { + participant_ids + .iter() + .copied() + .filter(|id| *id != recipient_id) + .map(|id| part1_packages[&id].clone()) + .collect() + }; + + let mut part2_secrets = BTreeMap::new(); + let mut round2_packages_by_recipient: BTreeMap> = + BTreeMap::new(); + for sender_id in participant_ids { + let round1_packages = + decode_round1_package_map("TestDKGPart2", &round1_packages_for(sender_id)) + .expect("round1 package map"); + let (round2_secret, round2_packages) = frost::keys::dkg::part2( + part1_secrets + .remove(&sender_id) + .expect("part1 secret package"), + &round1_packages, + ) + .expect("DKG part2"); + + part2_secrets.insert(sender_id, round2_secret); + for (recipient_identifier, package) in round2_packages { + let recipient_id = participant_id_by_identifier_hex + .get(&hex::encode(recipient_identifier.serialize())) + .copied() + .expect("recipient identifier mapping"); + round2_packages_by_recipient + .entry(recipient_id) + .or_default() + .push(DkgRound2Package { + identifier: frost_identifier_to_go_string(recipient_identifier), + sender_identifier: Some(frost_identifier_to_go_string( + participant_identifiers[&sender_id], + )), + package_hex: hex::encode(package.serialize().expect("round2 package")), + }); + } + } + + let first_participant = participant_ids[0]; + let round1_packages = + decode_round1_package_map("TestDKGPart3", &round1_packages_for(first_participant)) + .expect("round1 package map"); + let round2_packages = decode_round2_package_map( + "TestDKGPart3", + &round2_packages_by_recipient[&first_participant], + Some(participant_identifiers[&first_participant]), + ) + .expect("round2 package map"); + let (_, pre_normalization_public_key_package) = frost::keys::dkg::part3( + part2_secrets + .get(&first_participant) + .expect("round2 secret package"), + &round1_packages, + &round2_packages, + ) + .expect("DKG part3"); + + let mut part3_requests = BTreeMap::new(); + for id in participant_ids { + let secret_package = part2_secrets.get(&id).expect("round2 secret package"); + let secret_package_bytes = secret_package.serialize().expect("round2 secret"); + part3_requests.insert( + id, + DkgPart3Request { + secret_package_hex: hex::encode(secret_package_bytes), + round1_packages: round1_packages_for(id), + round2_packages: round2_packages_by_recipient + .get(&id) + .expect("round2 packages") + .clone(), + }, + ); + } + + InteractiveDkgFixture { + pre_normalization_even_y: pre_normalization_public_key_package.has_even_y(), + part3_requests, + } + } + + fn deterministic_odd_y_interactive_dkg_fixture() -> InteractiveDkgFixture { + for seed in 0u8..=u8::MAX { + let fixture = deterministic_interactive_dkg_fixture(seed); + if !fixture.pre_normalization_even_y { + return fixture; + } + } + + panic!("could not find deterministic odd-Y DKG fixture"); + } + + #[test] + fn dkg_part3_normalizes_odd_y_group_key_and_secret_shares() { + let _guard = lock_test_state(); + reset_for_tests(); + + let fixture = deterministic_odd_y_interactive_dkg_fixture(); + assert!( + !fixture.pre_normalization_even_y, + "fixture must exercise the odd-Y normalization branch" + ); + + let mut part3_results = BTreeMap::new(); + for (id, request) in fixture.part3_requests { + let result = dkg_part3(request).expect("DKG part3"); + let expected_identifier = frost_identifier_to_go_string( + participant_identifier_to_frost_identifier(id).unwrap(), + ); + assert_eq!(result.key_package.identifier, expected_identifier); + assert_eq!(result.public_key_package.verifying_key.len(), 64); + part3_results.insert(id, result); + } + + let exported_x_only_key = part3_results[&1].public_key_package.verifying_key.clone(); + for result in part3_results.values() { + assert_eq!(result.public_key_package.verifying_key, exported_x_only_key); + assert_eq!( + result.public_key_package.verifying_shares, + part3_results[&1].public_key_package.verifying_shares + ); + } + + let signing_participants = [1u16, 2]; + let mut commitments = Vec::new(); + let mut nonces_by_participant = BTreeMap::new(); + for id in signing_participants { + let result = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: part3_results[&id].key_package.identifier.clone(), + key_package_hex: part3_results[&id].key_package.data_hex.clone(), + }) + .expect("generate nonces"); + commitments.push(result.commitment); + nonces_by_participant.insert(id, result.nonces_hex); + } + + let message = [0x42u8; 32]; + let signing_package = new_signing_package(NewSigningPackageRequest { + message_hex: hex::encode(message), + commitments, + }) + .expect("new signing package"); + + let mut signature_shares = Vec::new(); + for id in signing_participants { + let result = sign_share(SignShareRequest { + signing_package_hex: signing_package.signing_package_hex.clone(), + nonces_hex: nonces_by_participant + .remove(&id) + .expect("participant nonces"), + key_package_identifier: part3_results[&id].key_package.identifier.clone(), + key_package_hex: part3_results[&id].key_package.data_hex.clone(), + }) + .expect("sign share"); + signature_shares.push(result.signature_share); + } + + let aggregate = aggregate(AggregateRequest { + signing_package_hex: signing_package.signing_package_hex, + signature_shares, + public_key_package: part3_results[&1].public_key_package.clone(), + }) + .expect("aggregate"); + + let signature_bytes = hex::decode(aggregate.signature_hex).expect("signature hex"); + let signature = SchnorrSignature::from_slice(&signature_bytes).expect("BIP340 signature"); + let public_key_bytes = hex::decode(exported_x_only_key).expect("verifying key hex"); + let public_key = XOnlyPublicKey::from_slice(&public_key_bytes).expect("x-only public key"); + let message = SecpMessage::from_digest(message); + Secp256k1::verification_only() + .verify_schnorr(&signature, &message, &public_key) + .expect("aggregate verifies under normalized x-only key"); + } + + fn seeded_round_state(session_id: &str) -> RoundState { + let run_dkg_request = RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + let start_request = StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + + start_sign_round(start_request).expect("start sign round") + } + + fn configure_test_state_path(suffix: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "frost_tbtc_engine_state_{suffix}_{}.json", + std::process::id() + )); + clear_state_storage_policy_overrides(); + cleanup_test_state_artifacts(&path); + std::env::set_var(TBTC_SIGNER_STATE_PATH_ENV, &path); + path + } + + fn clear_state_storage_policy_overrides() { + std::env::remove_var(TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV); + std::env::remove_var(TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV); + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + std::env::remove_var(TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV); + std::env::remove_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV); + std::env::remove_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV); + std::env::remove_var(TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV); + std::env::remove_var(TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV); + std::env::remove_var(TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV); + std::env::remove_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV); + std::env::remove_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV); + std::env::remove_var(TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV); + std::env::remove_var(TBTC_SIGNER_ADMISSION_MIN_PARTICIPANTS_ENV); + std::env::remove_var(TBTC_SIGNER_ADMISSION_MIN_THRESHOLD_ENV); + std::env::remove_var(TBTC_SIGNER_ADMISSION_REQUIRED_IDENTIFIERS_ENV); + std::env::remove_var(TBTC_SIGNER_ADMISSION_ALLOWLIST_IDENTIFIERS_ENV); + std::env::remove_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV); + std::env::remove_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV); + std::env::remove_var(TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT_ENV); + std::env::remove_var(TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS_ENV); + std::env::remove_var(TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS_ENV); + std::env::remove_var(TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR_ENV); + std::env::remove_var(TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR_ENV); + std::env::remove_var(TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE_ENV); + std::env::remove_var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV); + std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV); + std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV); + std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV); + std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS_ENV); + std::env::remove_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV); + std::env::remove_var(TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS_ENV); + std::env::remove_var(TBTC_SIGNER_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS_ENV); + std::env::remove_var(TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS_ENV); + std::env::remove_var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV); + std::env::remove_var(TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV); + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_DEVELOPMENT); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT, + ); + std::env::set_var( + TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV, + TEST_STATE_ENCRYPTION_KEY_HEX, + ); + } + + fn configure_required_signing_policy_limits_for_tests() { + std::env::set_var(TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT_ENV, "64"); + std::env::set_var(TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS_ENV, "100000000"); + std::env::set_var( + TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS_ENV, + "2100000000000000", + ); + } + + fn build_signed_provenance_attestation( + status: &str, + runtime_version: &str, + expires_at_unix: Option, + ) -> (String, String, String) { + let mut payload = serde_json::json!({ + "status": status, + "runtime_version": runtime_version, + }); + if let Some(expires_at_unix) = expires_at_unix { + payload["expires_at_unix"] = serde_json::json!(expires_at_unix); + } + let payload = payload.to_string(); + + let secp = Secp256k1::new(); + let secret_key = + bitcoin::secp256k1::SecretKey::from_slice(&[0x11; 32]).expect("secret key"); + let keypair = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &secret_key); + let (trust_root_pubkey, _) = XOnlyPublicKey::from_keypair(&keypair); + + let payload_digest = Sha256::digest(payload.as_bytes()); + let message = SecpMessage::from_digest_slice(&payload_digest).expect("message digest"); + let signature = secp.sign_schnorr_no_aux_rand(&message, &keypair); + + ( + trust_root_pubkey.to_string(), + payload, + signature.to_string(), + ) + } + + fn configure_valid_provenance_attestation_for_tests() { + let (trust_root, attestation_payload, attestation_signature) = + build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix() + 3600), + ); + + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV, + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + ); + std::env::set_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, trust_root); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, + attestation_payload, + ); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV, + attestation_signature, + ); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + } + + fn cleanup_test_state_artifacts(path: &Path) { + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_file(state_lock_file_path(path)); + let _ = std::fs::remove_file(path.with_extension(format!("tmp-{}", std::process::id()))); + + if let Ok(backups) = sorted_corrupted_state_backups(path) { + for backup in backups { + let _ = std::fs::remove_file(backup); + } + } + } + + fn persisted_session_state_fixture() -> PersistedSessionState { + PersistedSessionState { + dkg_request_fingerprint: None, + dkg_key_packages: None, + dkg_public_key_package_hex: None, + dkg_result: None, + sign_request_fingerprint: None, + sign_message_hex: None, + round_state: None, + active_attempt_context: None, + attempt_transition_records: vec![], + consumed_attempt_ids: vec![], + consumed_sign_round_ids: vec![], + finalize_request_fingerprint: None, + signature_result: None, + consumed_finalize_round_ids: vec![], + consumed_finalize_request_fingerprints: vec![], + build_tx_request_fingerprint: None, + tx_result: None, + refresh_request_fingerprint: None, + refresh_result: None, + refresh_history: vec![], + emergency_rekey_event: None, + } + } + + fn expect_internal_error_contains(err: EngineError, expected_substring: &str) { + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains(expected_substring), + "unexpected internal error message: {message}" + ); + } + + fn state_mutation_request(session_id: &str) -> RefreshSharesRequest { + RefreshSharesRequest { + session_id: session_id.to_string(), + current_shares: vec![ShareMaterial { + identifier: 1, + encrypted_share_hex: "1111".to_string(), + }], + } + } + + fn mutate_state_for_key_provider_test( + session_id: &str, + ) -> Result { + refresh_shares(state_mutation_request(session_id)) + } + + #[test] + fn run_dkg_rejects_bootstrap_dealer_dkg_in_production_profile() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION); + + let err = run_dkg(RunDkgRequest { + session_id: "session-production-bootstrap-dkg".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("production profile should reject bootstrap dealer DKG"); + + let EngineError::LifecyclePolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "bootstrap_dealer_dkg_disabled_in_production"); + } + + #[test] + fn run_dkg_rejects_bootstrap_dealer_dkg_when_profile_is_missing_or_empty() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + for profile_value in [None, Some(" ")] { + match profile_value { + Some(value) => std::env::set_var(TBTC_SIGNER_PROFILE_ENV, value), + None => std::env::remove_var(TBTC_SIGNER_PROFILE_ENV), + } + + let err = run_dkg(RunDkgRequest { + session_id: format!( + "session-default-production-bootstrap-dkg-{}", + profile_value.unwrap_or("missing").trim() + ), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("missing/empty profile should reject bootstrap dealer DKG"); + + let EngineError::LifecyclePolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "bootstrap_dealer_dkg_disabled_in_production"); + } + } + + #[test] + fn production_profile_forces_provenance_gate_without_env_flag() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::remove_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV); + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION); + assert!(provenance_gate_enforced()); + + std::env::remove_var(TBTC_SIGNER_PROFILE_ENV); + assert!(provenance_gate_enforced()); + + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_DEVELOPMENT); + assert!(!provenance_gate_enforced()); + } + + #[test] + fn run_dkg_rejects_malformed_seed_as_validation_input() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + for (index, seed_hex, expected_message) in [ + (1, "not-hex", "dkg_seed_hex must be valid hex"), + (2, "0102", "dkg_seed_hex decoded to [2] bytes, expected 32"), + ] { + let err = run_dkg(RunDkgRequest { + session_id: format!("session-malformed-dkg-seed-{index}"), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: Some(seed_hex.to_string()), + }) + .expect_err("malformed DKG seed should be rejected"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains(expected_message), + "unexpected validation message: {message}" + ); + } + } + + #[test] + fn run_dkg_rejects_when_provenance_gate_requires_attestation() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + std::env::set_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, "sigstore-main"); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + + let err = run_dkg(RunDkgRequest { + session_id: "session-provenance-gate-missing-attestation".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected provenance gate rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "missing_attestation_status"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn canary_rollout_status_rejects_when_provenance_gate_requires_attestation() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + + let err = canary_rollout_status().expect_err("expected provenance gate rejection"); + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "missing_attestation_status"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_accepts_valid_signed_provenance_attestation() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let (trust_root, attestation_payload, attestation_signature_hex) = + build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix().saturating_add(300)), + ); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV, + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + ); + std::env::set_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, &trust_root); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, + &attestation_payload, + ); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV, + &attestation_signature_hex, + ); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + + let result = run_dkg(RunDkgRequest { + session_id: "session-provenance-signed-attestation-accept".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }); + assert!(result.is_ok(), "expected signed attestation acceptance"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_rejects_when_provenance_attestation_signature_missing() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let (trust_root, attestation_payload, _) = build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix().saturating_add(300)), + ); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV, + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + ); + std::env::set_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, &trust_root); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, + &attestation_payload, + ); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + + let err = run_dkg(RunDkgRequest { + session_id: "session-provenance-signed-attestation-missing-signature".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected missing signature rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "missing_attestation_signature"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_rejects_when_provenance_attestation_signature_invalid() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let (trust_root, attestation_payload, mut attestation_signature_hex) = + build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix().saturating_add(300)), + ); + let replacement = if attestation_signature_hex.ends_with('0') { + "1" + } else { + "0" + }; + attestation_signature_hex.replace_range( + attestation_signature_hex.len() - 1..attestation_signature_hex.len(), + replacement, + ); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV, + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + ); + std::env::set_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, &trust_root); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, + &attestation_payload, + ); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV, + &attestation_signature_hex, + ); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + + let err = run_dkg(RunDkgRequest { + session_id: "session-provenance-signed-attestation-invalid-signature".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected signature verification rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "attestation_signature_verification_failed"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_rejects_when_provenance_attestation_expired() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let (trust_root, attestation_payload, attestation_signature_hex) = + build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix().saturating_sub(1)), + ); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV, + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + ); + std::env::set_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, &trust_root); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, + &attestation_payload, + ); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV, + &attestation_signature_hex, + ); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + + let err = run_dkg(RunDkgRequest { + session_id: "session-provenance-signed-attestation-expired".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected attestation expiry rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "attestation_expired"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_rejects_when_provenance_attestation_missing_expiry() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let (trust_root, attestation_payload, attestation_signature_hex) = + build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + None, + ); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV, + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + ); + std::env::set_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, &trust_root); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, + &attestation_payload, + ); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV, + &attestation_signature_hex, + ); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + + let err = run_dkg(RunDkgRequest { + session_id: "session-provenance-signed-attestation-missing-expiry".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected attestation missing expiry rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "missing_attestation_expiry"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_rejects_when_provenance_attestation_expiry_too_far_in_future() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let (trust_root, attestation_payload, attestation_signature_hex) = + build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some( + now_unix().saturating_add(TBTC_SIGNER_PROVENANCE_MAX_ATTESTATION_TTL_SECONDS) + + 1, + ), + ); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV, + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + ); + std::env::set_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, &trust_root); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, + &attestation_payload, + ); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV, + &attestation_signature_hex, + ); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + + let err = run_dkg(RunDkgRequest { + session_id: "session-provenance-expiry-too-far".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected attestation expiry too far rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "attestation_expiry_too_far_in_future"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_rejects_when_provenance_trust_root_mismatches_signature_key() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let (_trust_root, attestation_payload, attestation_signature_hex) = + build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix().saturating_add(300)), + ); + + let secp = Secp256k1::new(); + let wrong_secret_key = + bitcoin::secp256k1::SecretKey::from_slice(&[0x22; 32]).expect("secret key"); + let wrong_keypair = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &wrong_secret_key); + let (wrong_trust_root, _) = XOnlyPublicKey::from_keypair(&wrong_keypair); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV, + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + ); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, + wrong_trust_root.to_string(), + ); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, + &attestation_payload, + ); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV, + &attestation_signature_hex, + ); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + + let err = run_dkg(RunDkgRequest { + session_id: "session-provenance-wrong-trust-root".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected trust-root mismatch rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "attestation_signature_verification_failed"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_rejects_when_signed_attestation_runtime_version_mismatch() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let (trust_root, attestation_payload, attestation_signature_hex) = + build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + "99.99.99", + Some(now_unix().saturating_add(300)), + ); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV, + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + ); + std::env::set_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, &trust_root); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, + &attestation_payload, + ); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV, + &attestation_signature_hex, + ); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + + let err = run_dkg(RunDkgRequest { + session_id: "session-provenance-runtime-version-mismatch".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected runtime version mismatch rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "runtime_version_not_attested"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_rejects_when_signed_attestation_status_mismatches_env() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let (trust_root, attestation_payload, attestation_signature_hex) = + build_signed_provenance_attestation( + "pending", + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix().saturating_add(300)), + ); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV, + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + ); + std::env::set_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, &trust_root); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, + &attestation_payload, + ); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV, + &attestation_signature_hex, + ); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + + let err = run_dkg(RunDkgRequest { + session_id: "session-provenance-status-mismatch".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected status mismatch rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "attestation_status_mismatch"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_rejects_invalid_curve_point_trust_root() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV, + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + ); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, + "0000000000000000000000000000000000000000000000000000000000000000", + ); + std::env::set_var(TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, "{}"); + std::env::set_var( + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV, + "aa".repeat(64), + ); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + + let err = run_dkg(RunDkgRequest { + session_id: "session-provenance-invalid-curve-point-trust-root".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected invalid trust root rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "invalid_trust_root_format"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn provenance_gate_rejects_runtime_prerelease_for_release_minimum() { + let runtime_version = parse_version_triplet("1.2.3-rc1").expect("runtime parse"); + let minimum_version = parse_version_triplet("1.2.3").expect("minimum parse"); + assert!(!runtime_satisfies_minimum_version( + runtime_version, + minimum_version + )); + + let runtime_version = parse_version_triplet("1.2.3").expect("runtime parse"); + let minimum_version = parse_version_triplet("1.2.3-rc1").expect("minimum parse"); + assert!(runtime_satisfies_minimum_version( + runtime_version, + minimum_version + )); + } + + #[test] + fn run_dkg_rejects_session_id_with_disallowed_characters() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let err = run_dkg(RunDkgRequest { + session_id: "session-log\ninject".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected session_id validation rejection"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("session_id contains disallowed characters"), + "unexpected validation message: {message}" + ); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_rejects_non_allowlisted_participant_under_admission_policy() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV, "true"); + std::env::set_var(TBTC_SIGNER_ADMISSION_ALLOWLIST_IDENTIFIERS_ENV, "1,2"); + + let err = run_dkg(RunDkgRequest { + session_id: "session-admission-allowlist-reject".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected admission policy rejection"); + + let EngineError::AdmissionPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "participant_identifier_not_allowlisted"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_maps_admission_policy_config_error_to_rejection_with_counter() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV, "true"); + std::env::set_var(TBTC_SIGNER_ADMISSION_MIN_PARTICIPANTS_ENV, "not-a-number"); + + let err = run_dkg(RunDkgRequest { + session_id: "session-admission-invalid-policy-config".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected admission policy config rejection"); + + let EngineError::AdmissionPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "invalid_policy_configuration"); + + let metrics = hardening_metrics(); + assert_eq!(metrics.run_dkg_calls_total, 1); + assert_eq!(metrics.run_dkg_admission_reject_total, 1); + assert_eq!(metrics.run_dkg_success_total, 0); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_rejects_empty_admission_allowlist_as_invalid_config() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV, "true"); + std::env::set_var(TBTC_SIGNER_ADMISSION_ALLOWLIST_IDENTIFIERS_ENV, ""); + + let err = run_dkg(RunDkgRequest { + session_id: "session-admission-empty-allowlist".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected admission policy config rejection"); + + let EngineError::AdmissionPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "invalid_policy_configuration"); + + let metrics = hardening_metrics(); + assert_eq!(metrics.run_dkg_calls_total, 1); + assert_eq!(metrics.run_dkg_admission_reject_total, 1); + + clear_state_storage_policy_overrides(); + } + + fn build_policy_test_request(session_id: &str) -> BuildTaprootTxRequest { + BuildTaprootTxRequest { + session_id: session_id.to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 0, + value_sats: 10_000, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 9_000, + }], + script_tree_hex: None, + } + } + + fn policy_bound_message_hex_from_tx_result(tx_result: &TransactionResult) -> String { + let tx_bytes = hex::decode(&tx_result.tx_hex).expect("tx hex"); + hash_hex(&tx_bytes) + } + + #[test] + fn build_taproot_tx_signing_policy_firewall_rejects_disallowed_script_class() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2wpkh"); + configure_required_signing_policy_limits_for_tests(); + + let err = build_taproot_tx(build_policy_test_request( + "session-signing-policy-script-class-reject", + )) + .expect_err("expected signing policy rejection"); + + let EngineError::SigningPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "script_class_not_allowlisted"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn build_taproot_tx_signing_policy_firewall_rejects_excess_output_count() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr"); + std::env::set_var(TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT_ENV, "1"); + std::env::set_var(TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS_ENV, "100000000"); + std::env::set_var( + TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS_ENV, + "2100000000000000", + ); + + let mut request = build_policy_test_request("session-signing-policy-output-count-reject"); + request.inputs[0].value_sats = 20_000; + request.outputs.push(crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "33".repeat(32)), + value_sats: 9_000, + }); + + let err = + build_taproot_tx(request).expect_err("expected signing policy output count rejection"); + + let EngineError::SigningPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "output_count_exceeds_policy_limit"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn build_taproot_tx_signing_policy_firewall_rejects_excess_single_output_value() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr,p2wpkh"); + std::env::set_var(TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT_ENV, "64"); + std::env::set_var(TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS_ENV, "5000"); + std::env::set_var( + TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS_ENV, + "2100000000000000", + ); + + let err = build_taproot_tx(build_policy_test_request( + "session-signing-policy-single-output-value-reject", + )) + .expect_err("expected signing policy single output value rejection"); + + let EngineError::SigningPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "single_output_value_exceeds_policy_limit"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn build_taproot_tx_signing_policy_firewall_rejects_excess_total_output_value() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr,p2wpkh"); + std::env::set_var(TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT_ENV, "64"); + std::env::set_var(TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS_ENV, "100000000"); + std::env::set_var(TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS_ENV, "5000"); + + let err = build_taproot_tx(build_policy_test_request( + "session-signing-policy-total-output-value-reject", + )) + .expect_err("expected signing policy total output value rejection"); + + let EngineError::SigningPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "total_output_value_exceeds_policy_limit"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn build_taproot_tx_rejects_total_input_value_above_bitcoin_max_money() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("build_taproot_tx_max_input_total"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let request = BuildTaprootTxRequest { + session_id: "session-build-tx-max-input-total".to_string(), + inputs: vec![ + crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 0, + value_sats: BITCOIN_MAX_MONEY_SATS, + }, + crate::api::TxInput { + txid_hex: "22".repeat(32), + vout: 0, + value_sats: 1, + }, + ], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "33".repeat(32)), + value_sats: 1, + }], + script_tree_hex: None, + }; + + let err = build_taproot_tx(request).expect_err("expected max money rejection"); + let EngineError::Validation(message) = err else { + panic!("unexpected error variant: {err:?}"); + }; + assert!( + message.contains("input value_sats total") + && message.contains("exceeds Bitcoin max money"), + "unexpected validation message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn build_taproot_tx_rejects_total_output_value_above_bitcoin_max_money() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("build_taproot_tx_max_output_total"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let request = BuildTaprootTxRequest { + session_id: "session-build-tx-max-output-total".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 0, + value_sats: BITCOIN_MAX_MONEY_SATS, + }], + outputs: vec![ + crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: BITCOIN_MAX_MONEY_SATS / 2 + 1, + }, + crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "33".repeat(32)), + value_sats: BITCOIN_MAX_MONEY_SATS / 2 + 1, + }, + ], + script_tree_hex: None, + }; + + let err = build_taproot_tx(request).expect_err("expected max money rejection"); + let EngineError::Validation(message) = err else { + panic!("unexpected error variant: {err:?}"); + }; + assert!( + message.contains("output value_sats total") + && message.contains("exceeds Bitcoin max money"), + "unexpected validation message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn build_taproot_tx_signing_policy_firewall_rejects_outside_utc_window() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr,p2wpkh"); + configure_required_signing_policy_limits_for_tests(); + let current_hour = current_utc_hour(); + let start_hour = (current_hour + 1) % 24; + let end_hour = (current_hour + 2) % 24; + std::env::set_var( + TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR_ENV, + start_hour.to_string(), + ); + std::env::set_var( + TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR_ENV, + end_hour.to_string(), + ); + + let err = build_taproot_tx(build_policy_test_request( + "session-signing-policy-utc-window-reject", + )) + .expect_err("expected signing policy UTC window rejection"); + + let EngineError::SigningPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "request_outside_allowed_utc_window"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn hardening_metrics_tracks_policy_rejections() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2wpkh"); + configure_required_signing_policy_limits_for_tests(); + + let _ = build_taproot_tx(build_policy_test_request( + "session-hardening-metrics-policy-reject", + )); + + let metrics = hardening_metrics(); + assert_eq!(metrics.build_taproot_tx_calls_total, 1); + assert_eq!(metrics.build_taproot_tx_policy_reject_total, 1); + assert_eq!(metrics.build_taproot_tx_success_total, 0); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn hardening_metrics_count_calls_before_provenance_gate_rejection() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + std::env::set_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, "sigstore-main"); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + + let run_dkg_err = run_dkg(RunDkgRequest { + session_id: "session-metrics-provenance-run-dkg".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected run_dkg provenance gate rejection"); + assert!(matches!( + run_dkg_err, + EngineError::ProvenanceGateRejected { .. } + )); + + let build_tx_err = build_taproot_tx(BuildTaprootTxRequest { + session_id: "session-metrics-provenance-build-tx".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 0, + value_sats: 10_000, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("0014{}", "33".repeat(20)), + value_sats: 9_000, + }], + script_tree_hex: None, + }) + .expect_err("expected build_taproot_tx provenance gate rejection"); + assert!(matches!( + build_tx_err, + EngineError::ProvenanceGateRejected { .. } + )); + + let finalize_err = finalize_sign_round( + FinalizeSignRoundRequest { + session_id: "session-metrics-provenance-finalize".to_string(), + taproot_merkle_root_hex: None, + round_contributions: vec![], + attempt_context: None, + }, + true, + ) + .expect_err("expected finalize_sign_round provenance gate rejection"); + assert!(matches!( + finalize_err, + EngineError::ProvenanceGateRejected { .. } + )); + + let metrics = hardening_metrics(); + assert_eq!(metrics.run_dkg_calls_total, 1); + assert_eq!(metrics.start_sign_round_calls_total, 0); + assert_eq!(metrics.build_taproot_tx_calls_total, 1); + assert_eq!(metrics.finalize_sign_round_calls_total, 1); + assert_eq!(metrics.refresh_shares_calls_total, 0); + assert_eq!(metrics.run_dkg_success_total, 0); + assert_eq!(metrics.start_sign_round_success_total, 0); + assert_eq!(metrics.build_taproot_tx_success_total, 0); + assert_eq!(metrics.finalize_sign_round_success_total, 0); + assert_eq!(metrics.refresh_shares_success_total, 0); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn hardening_metrics_track_start_sign_round_and_refresh_shares_counters() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let dkg_result = run_dkg(RunDkgRequest { + session_id: "session-metrics-start-refresh".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let _ = start_sign_round(StartSignRoundRequest { + session_id: "session-metrics-start-refresh".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }) + .expect("start sign round"); + + let _ = refresh_shares(RefreshSharesRequest { + session_id: "session-metrics-refresh-only".to_string(), + current_shares: vec![ShareMaterial { + identifier: 1, + encrypted_share_hex: "aaaa".to_string(), + }], + }) + .expect("refresh shares"); + + let metrics = hardening_metrics(); + assert_eq!(metrics.start_sign_round_calls_total, 1); + assert_eq!(metrics.start_sign_round_success_total, 1); + assert_eq!(metrics.refresh_shares_calls_total, 1); + assert_eq!(metrics.refresh_shares_success_total, 1); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn roast_transcript_audit_and_verify_blame_proof_roundtrip() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-transcript-audit-roundtrip"; + let message_hex = "deadbeef"; + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2, 3]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2, 3]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round attempt 1"); + + let mut transition_evidence = + build_attempt_transition_evidence_from_active_session(session_id); + transition_evidence.exclusion_evidence = Some(AttemptExclusionEvidence { + reason: ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF.to_string(), + excluded_member_identifiers: vec![3], + invalid_share_proof_fingerprint: Some("ab".repeat(32)), + }); + + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect("start sign round attempt 2"); + + let audit = roast_transcript_audit(crate::api::TranscriptAuditRequest { + session_id: session_id.to_string(), + }) + .expect("transcript audit"); + assert_eq!(audit.transition_count, 1); + assert_eq!(audit.records.len(), 1); + let record = &audit.records[0]; + assert_eq!(record.from_attempt_number, 1); + assert_eq!(record.to_attempt_number, 2); + assert_eq!(record.reason, ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF); + assert_eq!(record.excluded_member_identifiers, vec![3]); + assert!(!record.transcript_hash.is_empty()); + + let verified = verify_blame_proof(crate::api::VerifyBlameProofRequest { + session_id: session_id.to_string(), + from_attempt_number: 1, + accused_member_identifier: 3, + reason: ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF.to_string(), + invalid_share_proof_fingerprint: Some("ab".repeat(32)), + }) + .expect("verify blame proof"); + assert!(verified.verified); + assert_eq!( + verified.transcript_hash, + Some(record.transcript_hash.clone()) + ); + + let not_verified = verify_blame_proof(crate::api::VerifyBlameProofRequest { + session_id: session_id.to_string(), + from_attempt_number: 1, + accused_member_identifier: 2, + reason: ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF.to_string(), + invalid_share_proof_fingerprint: Some("ab".repeat(32)), + }) + .expect("verify blame proof mismatch"); + assert!(!not_verified.verified); + + let metrics = hardening_metrics(); + assert_eq!(metrics.roast_transcript_audit_calls_total, 1); + assert_eq!(metrics.roast_transcript_audit_success_total, 1); + assert_eq!(metrics.verify_blame_proof_calls_total, 2); + assert_eq!(metrics.verify_blame_proof_success_total, 1); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn roast_transcript_audit_records_persist_across_reload() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("transcript_audit_persist_reload"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-transcript-audit-persist"; + let message_hex = "deadbeef"; + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2, 3]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2, 3]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round attempt 1"); + + let mut transition_evidence = + build_attempt_transition_evidence_from_active_session(session_id); + transition_evidence.exclusion_evidence = Some(AttemptExclusionEvidence { + reason: ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT.to_string(), + excluded_member_identifiers: vec![], + invalid_share_proof_fingerprint: None, + }); + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2, 3]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2, 3]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect("start sign round attempt 2"); + + reload_state_from_storage_for_tests(); + + let audit = roast_transcript_audit(crate::api::TranscriptAuditRequest { + session_id: session_id.to_string(), + }) + .expect("transcript audit after reload"); + assert_eq!(audit.transition_count, 1); + assert_eq!(audit.records.len(), 1); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn auto_quarantine_enforces_threshold_and_honors_dao_allowlist_override() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + std::env::set_var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV, "true"); + std::env::set_var(TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV, "2"); + std::env::set_var(TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV, "1"); + std::env::set_var(TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV, "2"); + + let session_id = "session-auto-quarantine-threshold"; + let message_hex = "deadbeef"; + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2, 3]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2, 3]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round attempt 1"); + + let mut transition_evidence = + build_attempt_transition_evidence_from_active_session(session_id); + transition_evidence.exclusion_evidence = Some(AttemptExclusionEvidence { + reason: ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF.to_string(), + excluded_member_identifiers: vec![3], + invalid_share_proof_fingerprint: Some("cd".repeat(32)), + }); + + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect("start sign round attempt 2"); + + let status = quarantine_status(crate::api::QuarantineStatusRequest { + operator_identifier: 3, + }) + .expect("quarantine status"); + assert!(status.auto_quarantine_enabled); + assert_eq!(status.fault_score, 2); + assert_eq!(status.quarantine_threshold, 2); + assert!(status.quarantined); + assert!(!status.dao_override_allowlisted); + + let err = run_dkg(RunDkgRequest { + session_id: "session-auto-quarantine-rejected".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected auto-quarantine rejection"); + let EngineError::QuarantinePolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "operator_auto_quarantined"); + + std::env::set_var( + TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS_ENV, + "3", + ); + let allowlisted_status = quarantine_status(crate::api::QuarantineStatusRequest { + operator_identifier: 3, + }) + .expect("allowlisted quarantine status"); + assert!(allowlisted_status.dao_override_allowlisted); + assert!(!allowlisted_status.quarantined); + + let _allowlisted_dkg = run_dkg(RunDkgRequest { + session_id: "session-auto-quarantine-allowlisted".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("allowlisted operator should bypass quarantine rejection"); + + let metrics = hardening_metrics(); + assert!(metrics.auto_quarantine_fault_events_total >= 1); + assert!(metrics.auto_quarantine_enforcements_total >= 1); + assert!(metrics.quarantined_operator_count >= 1); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn auto_quarantine_persists_across_reload() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("auto_quarantine_persist_reload"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + std::env::set_var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV, "true"); + std::env::set_var(TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV, "2"); + std::env::set_var(TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV, "1"); + std::env::set_var(TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV, "2"); + + let session_id = "session-auto-quarantine-persist-reload"; + let message_hex = "deadbeef"; + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2, 3]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2, 3]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round attempt 1"); + + let mut transition_evidence = + build_attempt_transition_evidence_from_active_session(session_id); + transition_evidence.exclusion_evidence = Some(AttemptExclusionEvidence { + reason: ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF.to_string(), + excluded_member_identifiers: vec![3], + invalid_share_proof_fingerprint: Some("ef".repeat(32)), + }); + + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect("start sign round attempt 2"); + + let status_before_reload = quarantine_status(crate::api::QuarantineStatusRequest { + operator_identifier: 3, + }) + .expect("quarantine status before reload"); + assert!(status_before_reload.quarantined); + assert_eq!(status_before_reload.fault_score, 2); + + reload_state_from_storage_for_tests(); + + let status_after_reload = quarantine_status(crate::api::QuarantineStatusRequest { + operator_identifier: 3, + }) + .expect("quarantine status after reload"); + assert!(status_after_reload.quarantined); + assert_eq!(status_after_reload.fault_score, 2); + + let err = run_dkg(RunDkgRequest { + session_id: "session-auto-quarantine-persist-reload-reject".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect_err("expected quarantine rejection after reload"); + let EngineError::QuarantinePolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "operator_auto_quarantined"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn refresh_cadence_status_tracks_overdue_and_emergency_rekey_persistence() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("refresh_cadence_status"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV, "60"); + + let session_id = "session-refresh-cadence"; + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let refresh_result = refresh_shares(RefreshSharesRequest { + session_id: session_id.to_string(), + current_shares: vec![ + ShareMaterial { + identifier: 1, + encrypted_share_hex: "11".repeat(16), + }, + ShareMaterial { + identifier: 2, + encrypted_share_hex: "22".repeat(16), + }, + ], + }) + .expect("refresh shares"); + let initial_status = refresh_cadence_status(RefreshCadenceStatusRequest { + session_id: session_id.to_string(), + }) + .expect("refresh cadence status"); + assert_eq!(initial_status.refresh_count, 1); + assert_eq!( + initial_status.last_refresh_epoch, + refresh_result.refresh_epoch + ); + assert_eq!( + initial_status.continuity_reference_key_group, + Some(dkg_result.key_group) + ); + assert!(initial_status.continuity_preserved); + assert!(!initial_status.overdue); + assert!(!initial_status.emergency_rekey_required); + + { + let state = state().expect("state initialization"); + let mut guard = state.lock().expect("engine lock"); + let session = guard.sessions.get_mut(session_id).expect("session state"); + let refresh_record = session + .refresh_history + .last_mut() + .expect("refresh history entry"); + refresh_record.refreshed_at_unix = refresh_record.refreshed_at_unix.saturating_sub(600); + persist_engine_state_to_storage(&guard).expect("persist stale refresh history"); + } + + let stale_status = refresh_cadence_status(RefreshCadenceStatusRequest { + session_id: session_id.to_string(), + }) + .expect("stale refresh cadence status"); + assert!(stale_status.overdue); + + trigger_emergency_rekey(TriggerEmergencyRekeyRequest { + session_id: session_id.to_string(), + reason: "key compromise drill".to_string(), + }) + .expect("trigger emergency rekey"); + reload_state_from_storage_for_tests(); + + let post_rekey_status = refresh_cadence_status(RefreshCadenceStatusRequest { + session_id: session_id.to_string(), + }) + .expect("refresh cadence status after rekey"); + assert!(post_rekey_status.emergency_rekey_required); + assert_eq!( + post_rekey_status.emergency_rekey_reason, + Some("key compromise drill".to_string()) + ); + + let start_err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: post_rekey_status + .continuity_reference_key_group + .expect("continuity reference key group"), + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }) + .expect_err("expected start sign round emergency rekey rejection"); + let EngineError::LifecyclePolicyRejected { reason_code, .. } = start_err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "emergency_rekey_required"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn differential_fuzzing_reports_no_unresolved_critical_divergence() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let result = run_differential_fuzzing(DifferentialFuzzRequest { + seed: 0xD1FF_2026_0302_0001, + case_count: 64, + }) + .expect("run differential fuzzing"); + assert_eq!(result.case_count, 64); + assert_eq!(result.critical_divergence_count, 0); + assert!(!result.unresolved_critical_divergence); + + let metrics = hardening_metrics(); + assert!(metrics.differential_fuzz_runs_total >= 1); + assert_eq!(metrics.differential_fuzz_critical_divergence_total, 0); + } + + #[test] + fn canary_promotion_and_rollback_controls_persist_across_reload() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("canary_rollout_controls"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let initial_status = canary_rollout_status().expect("canary rollout status"); + assert_eq!(initial_status.current_percent, 10); + assert_eq!(initial_status.recommended_next_percent, Some(50)); + + let promoted_50 = promote_canary(PromoteCanaryRequest { target_percent: 50 }) + .expect("promote canary to 50%"); + assert_eq!(promoted_50.from_percent, 10); + assert_eq!(promoted_50.to_percent, 50); + + let promoted_100 = promote_canary(PromoteCanaryRequest { + target_percent: 100, + }) + .expect("promote canary to 100%"); + assert_eq!(promoted_100.from_percent, 50); + assert_eq!(promoted_100.to_percent, 100); + + let rolled_back = rollback_canary(RollbackCanaryRequest { + reason: "slo regression drill".to_string(), + }) + .expect("rollback canary"); + assert_eq!(rolled_back.from_percent, 100); + assert_eq!(rolled_back.to_percent, 50); + + reload_state_from_storage_for_tests(); + let post_reload_status = + canary_rollout_status().expect("canary rollout status after reload"); + assert_eq!(post_reload_status.current_percent, 50); + assert_eq!(post_reload_status.previous_percent, 50); + + let metrics = hardening_metrics(); + assert!(metrics.canary_promotions_total >= 2); + assert!(metrics.canary_rollbacks_total >= 1); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn canary_promotion_halts_when_policy_reject_rate_exceeds_gate() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2wpkh"); + configure_required_signing_policy_limits_for_tests(); + + let rejected = build_taproot_tx(build_policy_test_request("session-canary-gate-fail")) + .expect_err("expected policy rejection"); + let EngineError::SigningPolicyRejected { reason_code, .. } = rejected else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "script_class_not_allowlisted"); + + std::env::set_var(TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS_ENV, "0"); + let err = promote_canary(PromoteCanaryRequest { target_percent: 50 }) + .expect_err("expected canary gate rejection"); + let EngineError::LifecyclePolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "canary_slo_gate_failed"); + } + + #[test] + fn emergency_rekey_blocks_finalize_and_build_taproot_tx_for_session() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let round_state = seeded_round_state("session-emergency-rekey-finalize"); + trigger_emergency_rekey(TriggerEmergencyRekeyRequest { + session_id: round_state.session_id.clone(), + reason: "compromise containment".to_string(), + }) + .expect("trigger emergency rekey"); + + let finalize_err = finalize_sign_round( + FinalizeSignRoundRequest { + session_id: round_state.session_id.clone(), + taproot_merkle_root_hex: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + attempt_context: None, + }, + true, + ) + .expect_err("expected finalize emergency rekey rejection"); + let EngineError::LifecyclePolicyRejected { reason_code, .. } = finalize_err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "emergency_rekey_required"); + + let build_err = build_taproot_tx(build_policy_test_request(&round_state.session_id)) + .expect_err("expected build tx emergency rekey rejection"); + let EngineError::LifecyclePolicyRejected { reason_code, .. } = build_err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "emergency_rekey_required"); + } + + #[test] + fn build_taproot_tx_rate_limiter_uses_token_bucket_refill() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr,p2wpkh"); + std::env::set_var(TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE_ENV, "2"); + configure_required_signing_policy_limits_for_tests(); + + { + let mut limiter = build_tx_rate_limiter_state() + .lock() + .expect("build tx rate limiter lock"); + limiter.last_refill_unix = now_unix().saturating_sub(1); + limiter.token_microunits = 0; + limiter.configured_rate_limit_per_minute = 2; + } + + let rejected = build_taproot_tx(build_policy_test_request("session-rate-limited-initial")) + .expect_err("expected rate-limit rejection with sub-token refill"); + let EngineError::SigningPolicyRejected { reason_code, .. } = rejected else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "rate_limit_per_minute_exceeded"); + + { + let mut limiter = build_tx_rate_limiter_state() + .lock() + .expect("build tx rate limiter lock"); + limiter.last_refill_unix = now_unix().saturating_sub(30); + limiter.token_microunits = 0; + limiter.configured_rate_limit_per_minute = 2; + } + + let allowed = build_taproot_tx(build_policy_test_request("session-rate-limited-refill")); + assert!(allowed.is_ok(), "expected one token after 30s refill"); + + let rejected_again = + build_taproot_tx(build_policy_test_request("session-rate-limited-followup")) + .expect_err("expected immediate follow-up rejection without full refill"); + let EngineError::SigningPolicyRejected { reason_code, .. } = rejected_again else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "rate_limit_per_minute_exceeded"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn build_taproot_tx_cache_hit_rechecks_signing_policy_firewall() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr,p2wpkh"); + configure_required_signing_policy_limits_for_tests(); + + let request = build_policy_test_request("session-build-tx-cache-policy-recheck"); + + let first_result = build_taproot_tx(request.clone()).expect("first build tx"); + assert!(!first_result.tx_hex.is_empty()); + + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2wpkh"); + let err = + build_taproot_tx(request).expect_err("expected cache-hit firewall policy rejection"); + let EngineError::SigningPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "script_class_not_allowlisted"); + + let metrics = hardening_metrics(); + assert_eq!(metrics.build_taproot_tx_calls_total, 2); + assert_eq!(metrics.build_taproot_tx_success_total, 1); + assert_eq!(metrics.build_taproot_tx_policy_reject_total, 1); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn build_taproot_tx_cached_retry_does_not_charge_rate_limit() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr,p2wpkh"); + std::env::set_var(TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE_ENV, "1"); + configure_required_signing_policy_limits_for_tests(); + + let request = build_policy_test_request("session-build-tx-cache-rate-limit"); + + let first_result = build_taproot_tx(request.clone()).expect("first build tx"); + assert!(!first_result.tx_hex.is_empty()); + + { + let mut limiter = build_tx_rate_limiter_state() + .lock() + .expect("build tx rate limiter lock"); + limiter.last_refill_unix = now_unix(); + limiter.token_microunits = 0; + limiter.configured_rate_limit_per_minute = 1; + } + + let retry_result = build_taproot_tx(request).expect("cached retry must not rate-limit"); + assert_eq!(first_result, retry_result); + + let rejected = + build_taproot_tx(build_policy_test_request("session-build-tx-rate-limit-new")) + .expect_err("new build tx should still be rate-limited"); + let EngineError::SigningPolicyRejected { reason_code, .. } = rejected else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "rate_limit_per_minute_exceeded"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_signing_policy_firewall_rejects_without_policy_checked_build_tx() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-signing-policy-start-missing-build-tx"; + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr,p2wpkh"); + configure_required_signing_policy_limits_for_tests(); + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }) + .expect_err("expected signing policy reject without build tx binding"); + let EngineError::SigningPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "missing_policy_checked_build_tx"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_signing_policy_firewall_rejects_message_not_bound_to_build_tx() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-signing-policy-start-message-mismatch"; + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr,p2wpkh"); + configure_required_signing_policy_limits_for_tests(); + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + build_taproot_tx(build_policy_test_request(session_id)).expect("build tx"); + + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }) + .expect_err("expected signing policy reject for message mismatch"); + let EngineError::SigningPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!( + reason_code, + "signing_message_not_bound_to_policy_checked_build_tx" + ); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_signing_policy_firewall_accepts_policy_bound_message() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-signing-policy-start-bound-message"; + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr,p2wpkh"); + configure_required_signing_policy_limits_for_tests(); + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let tx_result = build_taproot_tx(build_policy_test_request(session_id)).expect("build tx"); + let message_hex = policy_bound_message_hex_from_tx_result(&tx_result); + + let round_state = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex, + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }) + .expect("expected start_sign_round allow for policy-bound message"); + assert_eq!(round_state.session_id, session_id); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn finalize_sign_round_signing_policy_firewall_rejects_missing_policy_checked_build_tx() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-signing-policy-finalize-missing-build-tx"; + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr,p2wpkh"); + configure_required_signing_policy_limits_for_tests(); + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let tx_result = build_taproot_tx(build_policy_test_request(session_id)).expect("build tx"); + let message_hex = policy_bound_message_hex_from_tx_result(&tx_result); + let round_state = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex, + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }) + .expect("start sign round"); + + { + let mut guard = state().expect("state").lock().expect("engine lock"); + let session = guard + .sessions + .get_mut(session_id) + .expect("session should exist"); + session.tx_result = None; + session.build_tx_request_fingerprint = None; + } + + let err = finalize_sign_round( + FinalizeSignRoundRequest { + session_id: session_id.to_string(), + taproot_merkle_root_hex: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + attempt_context: None, + }, + true, + ) + .expect_err("expected finalize reject without policy-checked build tx"); + let EngineError::SigningPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "missing_policy_checked_build_tx"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn finalize_sign_round_signing_policy_firewall_rejects_message_mismatch_after_tx_result_swap() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-signing-policy-finalize-tx-result-swap"; + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr,p2wpkh"); + configure_required_signing_policy_limits_for_tests(); + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let tx_result = build_taproot_tx(build_policy_test_request(session_id)).expect("build tx"); + let message_hex = policy_bound_message_hex_from_tx_result(&tx_result); + let round_state = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex, + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }) + .expect("start sign round"); + + { + let mut guard = state().expect("state").lock().expect("engine lock"); + let session = guard + .sessions + .get_mut(session_id) + .expect("session should exist"); + session.tx_result = Some(TransactionResult { + session_id: session_id.to_string(), + tx_hex: "00".to_string(), + }); + } + + let err = finalize_sign_round( + FinalizeSignRoundRequest { + session_id: session_id.to_string(), + taproot_merkle_root_hex: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + attempt_context: None, + }, + true, + ) + .expect_err("expected finalize reject for tx_result swap"); + let EngineError::SigningPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!( + reason_code, + "signing_message_not_bound_to_policy_checked_build_tx" + ); + + clear_state_storage_policy_overrides(); + } + + #[cfg(unix)] + fn wait_for_file(path: &Path, timeout: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < timeout { + if path.exists() { + return true; + } + thread::sleep(Duration::from_millis(50)); + } + path.exists() + } + + #[cfg(unix)] + struct LockHelperProcessGuard { + child: Option, + release_path: PathBuf, + } + + #[cfg(unix)] + impl LockHelperProcessGuard { + fn new(child: std::process::Child, release_path: PathBuf) -> Self { + Self { + child: Some(child), + release_path, + } + } + + fn signal_release(&self) { + let _ = std::fs::write(&self.release_path, b"release"); + } + + fn wait_for_success(mut self) { + self.signal_release(); + let mut child = self.child.take().expect("helper child should be present"); + let child_status = child.wait().expect("wait for lock helper process"); + assert!( + child_status.success(), + "lock helper process failed with status: {child_status}" + ); + } + } + + #[cfg(unix)] + impl Drop for LockHelperProcessGuard { + fn drop(&mut self) { + self.signal_release(); + + let Some(mut child) = self.child.take() else { + return; + }; + + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(2) { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) => thread::sleep(Duration::from_millis(50)), + Err(_) => break, + } + } + + let _ = child.kill(); + let _ = child.wait(); + } + } + + fn build_attempt_context( + session_id: &str, + message_hex: &str, + attempt_number: u32, + coordinator_identifier: u16, + included_participants: Vec, + ) -> AttemptContext { + let canonical_included_participants = + canonicalize_included_participants(&included_participants) + .expect("canonical included participants"); + let message_bytes = hex::decode(message_hex).expect("message hex"); + let message_digest_hex = hash_hex(&message_bytes); + let included_participants_fingerprint = + roast_included_participants_fingerprint_hex(&canonical_included_participants) + .expect("included participants fingerprint"); + let attempt_id = roast_attempt_id_hex( + session_id, + &message_digest_hex, + attempt_number, + coordinator_identifier, + &included_participants_fingerprint, + ) + .expect("attempt id"); + + AttemptContext { + attempt_number, + coordinator_identifier, + included_participants, + included_participants_fingerprint, + attempt_id, + } + } + + fn build_deterministic_attempt_context( + session_id: &str, + message_hex: &str, + attempt_number: u32, + included_participants: Vec, + ) -> AttemptContext { + let canonical_included_participants = + canonicalize_included_participants(&included_participants) + .expect("canonical included participants"); + let message_bytes = hex::decode(message_hex).expect("message hex"); + let message_digest_hex = hash_hex(&message_bytes); + let attempt_seed = roast_attempt_seed_from_message_digest_hex(&message_digest_hex) + .expect("attempt seed from message digest"); + let coordinator_identifier = select_coordinator_identifier( + &canonical_included_participants, + attempt_seed, + attempt_number, + ) + .expect("deterministic coordinator"); + + build_attempt_context( + session_id, + message_hex, + attempt_number, + coordinator_identifier, + included_participants, + ) + } + + fn build_attempt_transition_evidence_from_active_session( + session_id: &str, + ) -> AttemptTransitionEvidence { + let guard = state() + .expect("engine state") + .lock() + .expect("engine state lock"); + let session = guard + .sessions + .get(session_id) + .expect("session should exist for transition evidence"); + let active_attempt_context = session + .active_attempt_context + .as_ref() + .expect("active attempt context should exist"); + let round_state = session + .round_state + .as_ref() + .expect("round state should exist for transition evidence"); + let sign_request_fingerprint = session + .sign_request_fingerprint + .as_ref() + .expect("sign request fingerprint should exist"); + + AttemptTransitionEvidence { + from_attempt_number: active_attempt_context.attempt_number, + from_attempt_id: active_attempt_context.attempt_id.clone(), + from_coordinator_identifier: active_attempt_context.coordinator_identifier, + previous_round_id: round_state.round_id.clone(), + previous_sign_request_fingerprint: sign_request_fingerprint.clone(), + exclusion_evidence: Some(AttemptExclusionEvidence { + reason: ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT.to_string(), + excluded_member_identifiers: vec![], + invalid_share_proof_fingerprint: None, + }), + } + } + + #[test] + fn roast_attempt_context_hash_vectors_match_expected_values() { + let included_participants_fingerprint = + roast_included_participants_fingerprint_hex(&[1, 3, 5]) + .expect("included participants fingerprint"); + assert_eq!( + included_participants_fingerprint, + "0c9258935f0a30c065befcd746cb1564e9f3c91936c0f0f1c78853fa2d6713dc" + ); + + let attempt_id = roast_attempt_id_hex( + "vector-session-1", + "5f78c33274e43fa9de5659265c1d917e25c03722dcb0b8d27db8d5feaa813953", + 7, + 3, + &included_participants_fingerprint, + ) + .expect("attempt id"); + assert_eq!( + attempt_id, + "dbc7a4df9bc3ef8dee3a9f5a47ff519e22e8d6f9b0461dd415077176e4e6ee95" + ); + } + + #[test] + fn formal_verification_roast_attempt_context_shared_vectors_match_expected_values() { + let vector_suite = load_attempt_context_vector_suite(); + assert_eq!(vector_suite.schema_version, "roast-attempt-context-v1"); + assert_eq!( + vector_suite.hash_domains.included_participants_fingerprint, + ROAST_INCLUDED_PARTICIPANTS_FINGERPRINT_DOMAIN + ); + assert_eq!( + vector_suite.hash_domains.attempt_id, + ROAST_ATTEMPT_ID_DOMAIN + ); + assert!( + !vector_suite.vectors.is_empty(), + "expected at least one shared attempt-context vector" + ); + + for vector in vector_suite.vectors { + let canonical_participants = + canonicalize_included_participants(&vector.included_participants) + .expect("vector participants should canonicalize"); + let included_participants_fingerprint = + roast_included_participants_fingerprint_hex(&canonical_participants) + .expect("included participants fingerprint"); + assert_eq!( + included_participants_fingerprint, + vector + .expected_included_participants_fingerprint + .to_ascii_lowercase(), + "included participants fingerprint mismatch for vector [{}]", + vector.id + ); + + let attempt_id = roast_attempt_id_hex( + &vector.session_id, + &vector.message_digest_hex.to_ascii_lowercase(), + vector.attempt_number, + vector.coordinator_identifier, + &included_participants_fingerprint, + ) + .expect("attempt id"); + assert_eq!( + attempt_id, + vector.expected_attempt_id.to_ascii_lowercase(), + "attempt id mismatch for vector [{}]", + vector.id + ); + } + } + + fn participant_set_strategy() -> impl Strategy> { + prop::collection::btree_set(1_u16..=1024_u16, 2..=16) + .prop_map(|participants| participants.into_iter().collect()) + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn formal_verification_attempt_context_is_stable_under_participant_permutations( + session_suffix in any::(), + attempt_number in 1_u32..=16_u32, + participants in participant_set_strategy(), + message_bytes in prop::collection::vec(any::(), 1..=128), + ) { + let session_id = format!("formal-attempt-session-{session_suffix}"); + let message_hex = hex::encode(message_bytes); + let mut reversed_participants = participants.clone(); + reversed_participants.reverse(); + + let canonical_attempt_context = build_deterministic_attempt_context( + &session_id, + &message_hex, + attempt_number, + participants.clone(), + ); + let permuted_attempt_context = build_deterministic_attempt_context( + &session_id, + &message_hex, + attempt_number, + reversed_participants, + ); + + prop_assert_eq!( + &canonical_attempt_context.included_participants_fingerprint, + &permuted_attempt_context.included_participants_fingerprint + ); + prop_assert_eq!( + &canonical_attempt_context.attempt_id, + &permuted_attempt_context.attempt_id + ); + + let message_digest_hex = hash_hex( + &hex::decode(&message_hex).expect("message hex should decode for validation"), + ); + let validated_participants = validate_attempt_context( + &session_id, + &message_digest_hex, + 2, + Some(&permuted_attempt_context), + true, + ) + .expect("attempt context should validate") + .expect("validated attempt context should return canonical participants"); + + let mut expected_canonical_participants = participants; + expected_canonical_participants.sort_unstable(); + prop_assert_eq!(validated_participants, expected_canonical_participants); + } + + #[test] + fn formal_verification_attempt_context_rejects_tampered_attempt_id( + session_suffix in any::(), + attempt_number in 1_u32..=16_u32, + participants in participant_set_strategy(), + message_bytes in prop::collection::vec(any::(), 1..=128), + ) { + let session_id = format!("formal-attempt-tamper-session-{session_suffix}"); + let message_hex = hex::encode(message_bytes); + + let mut tampered_attempt_context = build_deterministic_attempt_context( + &session_id, + &message_hex, + attempt_number, + participants, + ); + tampered_attempt_context.attempt_id = "11".repeat(32); + + let message_digest_hex = hash_hex( + &hex::decode(&message_hex).expect("message hex should decode for validation"), + ); + let err = validate_attempt_context( + &session_id, + &message_digest_hex, + 2, + Some(&tampered_attempt_context), + true, + ) + .expect_err("tampered attempt id must be rejected"); + prop_assert!(matches!( + err, + EngineError::Validation(message) + if message.contains("attempt_context.attempt_id") + )); + } + + #[test] + fn formal_verification_encrypted_state_envelope_fails_closed_on_key_id_mismatch( + refresh_epoch_counter in any::(), + mismatched_key_id_suffix in any::(), + ) { + let _guard = lock_test_state(); + std::env::set_var( + TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV, + TEST_STATE_ENCRYPTION_KEY_HEX, + ); + + let persisted = PersistedEngineState { + schema_version: PERSISTED_STATE_SCHEMA_VERSION, + sessions: HashMap::new(), + refresh_epoch_counter, + operator_fault_scores: BTreeMap::new(), + quarantined_operator_identifiers: vec![], + canary_rollout: CanaryRolloutState::default(), + }; + let encoded = + encode_encrypted_state_envelope(&persisted).expect("state envelope encode"); + let envelope: PersistedEncryptedEngineStateEnvelope = + serde_json::from_slice(encoded.as_ref()).expect("state envelope decode"); + + let decoded = decode_encrypted_state_envelope(envelope.clone()) + .expect("untampered envelope should decode"); + prop_assert_eq!(decoded.schema_version, persisted.schema_version); + prop_assert_eq!(decoded.refresh_epoch_counter, persisted.refresh_epoch_counter); + prop_assert_eq!(decoded.sessions.len(), persisted.sessions.len()); + + let mut tampered_envelope = envelope; + tampered_envelope.key_id = format!( + "{}-{}", + TBTC_SIGNER_STATE_KEY_ID_LEGACY_ENV_HEX, mismatched_key_id_suffix + ); + let err = decode_encrypted_state_envelope(tampered_envelope) + .expect_err("tampered key_id must fail closed"); + prop_assert!(matches!( + err, + EngineError::Internal(message) + if message.contains("state key identifier mismatch") + )); + } + } + + #[test] + fn formal_verification_derive_round_id_binds_attempt_id_case_insensitive_component() { + let request_session_id = "round-id-attempt-case-session"; + let key_group = "key-group"; + let message_hex = "deadbeef"; + let signing_participants_fingerprint = "participants-fingerprint"; + + let lowercase_attempt_context = AttemptContext { + attempt_number: 1, + coordinator_identifier: 1, + included_participants: vec![1, 2], + included_participants_fingerprint: "aa".repeat(32), + attempt_id: "ab".repeat(32), + }; + let uppercase_attempt_context = AttemptContext { + attempt_id: lowercase_attempt_context.attempt_id.to_ascii_uppercase(), + ..lowercase_attempt_context.clone() + }; + + let round_id_lowercase_attempt = derive_round_id( + request_session_id, + key_group, + message_hex, + None, + signing_participants_fingerprint, + Some(&lowercase_attempt_context), + ); + let round_id_uppercase_attempt = derive_round_id( + request_session_id, + key_group, + message_hex, + None, + signing_participants_fingerprint, + Some(&uppercase_attempt_context), + ); + assert_eq!(round_id_lowercase_attempt, round_id_uppercase_attempt); + + let different_attempt_context = AttemptContext { + attempt_id: "cd".repeat(32), + ..lowercase_attempt_context.clone() + }; + let round_id_different_attempt = derive_round_id( + request_session_id, + key_group, + message_hex, + None, + signing_participants_fingerprint, + Some(&different_attempt_context), + ); + assert_ne!(round_id_lowercase_attempt, round_id_different_attempt); + + let round_id_without_attempt = derive_round_id( + request_session_id, + key_group, + message_hex, + None, + signing_participants_fingerprint, + None, + ); + assert_ne!(round_id_lowercase_attempt, round_id_without_attempt); + } + + struct RoastStrictModeGuard { + previous_value: Option, + } + + impl RoastStrictModeGuard { + fn set(value: Option<&str>) -> Self { + let previous_value = std::env::var(TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV).ok(); + match value { + Some(value) => std::env::set_var(TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV, value), + None => std::env::remove_var(TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV), + } + + Self { previous_value } + } + + fn enable() -> Self { + Self::set(Some("true")) + } + } + + impl Drop for RoastStrictModeGuard { + fn drop(&mut self) { + match &self.previous_value { + Some(value) => std::env::set_var(TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV, value), + None => std::env::remove_var(TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV), + } + } + } + + struct SignerProfileGuard { + previous_value: Option, + } + + impl SignerProfileGuard { + fn set(value: Option<&str>) -> Self { + let previous_value = std::env::var(TBTC_SIGNER_PROFILE_ENV).ok(); + match value { + Some(value) => std::env::set_var(TBTC_SIGNER_PROFILE_ENV, value), + None => std::env::remove_var(TBTC_SIGNER_PROFILE_ENV), + } + + Self { previous_value } + } + + fn production() -> Self { + Self::set(Some(TBTC_SIGNER_PROFILE_PRODUCTION)) + } + } + + impl Drop for SignerProfileGuard { + fn drop(&mut self) { + match &self.previous_value { + Some(value) => std::env::set_var(TBTC_SIGNER_PROFILE_ENV, value), + None => std::env::remove_var(TBTC_SIGNER_PROFILE_ENV), + } + } + } + + #[test] + #[cfg(unix)] + #[ignore] + fn state_file_lock_contention_helper() { + if std::env::var("TBTC_SIGNER_LOCK_HELPER").ok().as_deref() != Some("1") { + return; + } + + let state_path = active_state_file_path().expect("resolve helper state path"); + let _lock = StateFileLock::acquire(&state_path).expect("acquire helper lock"); + + let ready_path = std::env::var("TBTC_SIGNER_LOCK_READY_PATH") + .expect("helper ready path env should be set"); + std::fs::write(&ready_path, b"ready").expect("write helper ready file"); + + let release_path = std::env::var("TBTC_SIGNER_LOCK_RELEASE_PATH") + .expect("helper release path env should be set"); + assert!( + wait_for_file(Path::new(&release_path), Duration::from_secs(20)), + "timed out waiting for helper release signal" + ); + } + + #[test] + fn start_sign_round_rejects_missing_attempt_context_in_roast_strict_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let dkg_result = run_dkg(RunDkgRequest { + session_id: "session-roast-strict-start-missing-attempt-context".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let err = start_sign_round(StartSignRoundRequest { + session_id: "session-roast-strict-start-missing-attempt-context".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: None, + attempt_transition_evidence: None, + }) + .expect_err("expected attempt context validation"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("attempt_context is required"), + "unexpected validation message: {message}" + ); + } + + #[test] + fn production_profile_forces_roast_strict_mode_without_env_flag() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("production_forces_roast_strict"); + reset_for_tests(); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND, + ); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_COMMAND_ENV, + format!("printf '{}\\n'", TEST_STATE_ENCRYPTION_KEY_HEX), + ); + + let dkg_result = run_dkg(RunDkgRequest { + session_id: "session-production-forces-roast-strict".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("seed non-production dkg"); + + // RAII guards restore the prior env on Drop so a panic or early return + // does not leak production-profile state into subsequent tests. + configure_valid_provenance_attestation_for_tests(); + let _signer_profile = SignerProfileGuard::production(); + let _roast_strict_mode = RoastStrictModeGuard::set(Some("false")); + + let err = start_sign_round(StartSignRoundRequest { + session_id: "session-production-forces-roast-strict".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: None, + attempt_transition_evidence: None, + }) + .expect_err("production profile should require ROAST attempt context"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("attempt_context is required"), + "unexpected validation message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_accepts_valid_attempt_context_in_roast_strict_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-strict-start-valid-attempt-context"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![2, 1]); + let round_state = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }) + .expect("start sign round"); + + assert_eq!(round_state.required_contributions, 2); + } + + #[test] + fn start_sign_round_rejects_invalid_attempt_context_fingerprint_in_roast_strict_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-strict-start-invalid-attempt-context-fingerprint"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let mut attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + attempt_context.included_participants_fingerprint = "00".repeat(32); + + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }) + .expect_err("expected attempt context fingerprint validation"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("included_participants_fingerprint"), + "unexpected validation message: {message}" + ); + } + + #[test] + fn start_sign_round_rejects_invalid_attempt_context_attempt_id_in_roast_strict_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-strict-start-invalid-attempt-id"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let mut attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + attempt_context.attempt_id = "11".repeat(32); + + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }) + .expect_err("expected attempt context attempt-id validation"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("attempt_context.attempt_id"), + "unexpected validation message: {message}" + ); + } + + #[test] + fn start_sign_round_rejects_attempt_number_zero_in_roast_strict_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-strict-start-attempt-number-zero"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let mut attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + attempt_context.attempt_number = 0; + + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }) + .expect_err("expected attempt number validation"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("attempt_context.attempt_number"), + "unexpected validation message: {message}" + ); + } + + #[test] + fn start_sign_round_rejects_zero_coordinator_identifier_in_roast_strict_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-strict-start-coordinator-zero"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let mut attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + attempt_context.coordinator_identifier = 0; + + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }) + .expect_err("expected coordinator identifier validation"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("attempt_context.coordinator_identifier"), + "unexpected validation message: {message}" + ); + } + + #[test] + fn start_sign_round_rejects_nondeterministic_coordinator_identifier_in_roast_strict_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-strict-start-coordinator-nondeterministic"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let deterministic_attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + let mismatched_coordinator_identifier = + if deterministic_attempt_context.coordinator_identifier == 1 { + 2 + } else { + 1 + }; + let invalid_attempt_context = build_attempt_context( + session_id, + message_hex, + 1, + mismatched_coordinator_identifier, + vec![1, 2], + ); + + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(invalid_attempt_context), + attempt_transition_evidence: None, + }) + .expect_err("expected deterministic coordinator validation"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("deterministic coordinator"), + "unexpected validation message: {message}" + ); + } + + #[test] + fn start_sign_round_rejects_sub_threshold_attempt_participants_in_roast_strict_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-strict-start-sub-threshold-attempt-participants"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1]); + + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }) + .expect_err("expected attempt participants threshold validation"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("at least threshold members"), + "unexpected validation message: {message}" + ); + } + + #[test] + fn start_sign_round_rejects_duplicate_attempt_participants_in_roast_strict_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-strict-start-duplicate-attempt-participants"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_context = AttemptContext { + attempt_number: 1, + coordinator_identifier: 1, + included_participants: vec![1, 1, 2], + included_participants_fingerprint: "00".repeat(32), + attempt_id: "11".repeat(32), + }; + + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }) + .expect_err("expected duplicate attempt participant validation"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("duplicate identifier"), + "unexpected validation message: {message}" + ); + } + + #[test] + fn start_sign_round_accepts_hex_case_variant_attempt_context_idempotent_retry() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-strict-start-case-variant-idempotency"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let mut uppercase_attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![2, 1]); + uppercase_attempt_context.included_participants_fingerprint = uppercase_attempt_context + .included_participants_fingerprint + .to_ascii_uppercase(); + uppercase_attempt_context.attempt_id = + uppercase_attempt_context.attempt_id.to_ascii_uppercase(); + + let first_round_state = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(uppercase_attempt_context), + attempt_transition_evidence: None, + }) + .expect("first start sign round"); + + let lowercase_attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + let second_round_state = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![2, 1]), + attempt_context: Some(lowercase_attempt_context), + attempt_transition_evidence: None, + }) + .expect("second start sign round retry"); + + assert_eq!(first_round_state, second_round_state); + } + + #[test] + fn finalize_sign_round_rejects_missing_attempt_context_in_roast_strict_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-strict-finalize-missing-attempt-context"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![2, 1]); + let round_state = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }) + .expect("start sign round"); + + let err = finalize_sign_round( + FinalizeSignRoundRequest { + session_id: session_id.to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }, + true, + ) + .expect_err("expected attempt context validation"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("attempt_context is required"), + "unexpected validation message: {message}" + ); + } + + #[test] + fn finalize_sign_round_accepts_missing_attempt_context_when_not_strict_with_active_attempt_context( + ) { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-roast-phase2-nonstrict-finalize-missing-attempt-context"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + let round_state = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }) + .expect("start sign round"); + + let signature_result = finalize_sign_round( + FinalizeSignRoundRequest { + session_id: session_id.to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }, + true, + ) + .expect("finalize without attempt context in non-strict mode"); + + assert_eq!(signature_result.round_id, round_state.round_id); + clear_state_storage_policy_overrides(); + } + + #[test] + fn finalize_sign_round_accepts_missing_attempt_context_after_reload_when_not_strict() { + let _guard = lock_test_state(); + let state_path = + configure_test_state_path("phase2_nonstrict_finalize_missing_after_reload"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-roast-phase2-nonstrict-finalize-reload"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![2, 1]); + let round_state = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }) + .expect("start sign round"); + + reload_state_from_storage_for_tests(); + + let signature_result = finalize_sign_round( + FinalizeSignRoundRequest { + session_id: session_id.to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }, + true, + ) + .expect("finalize without attempt context after reload in non-strict mode"); + + assert_eq!(signature_result.round_id, round_state.round_id); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_returns_session_conflict_for_attempt_context_presence_mismatch_in_non_strict_mode( + ) { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-roast-phase2-nonstrict-start-presence-mismatch"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }) + .expect("start sign round with attempt context"); + + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: None, + attempt_transition_evidence: None, + }) + .expect_err("expected session conflict on payload mismatch"); + + assert!(matches!(err, EngineError::SessionConflict { .. })); + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_rejects_stale_attempt_number_against_active_attempt_context() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase2-stale-start-attempt"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: None, + }) + .expect("start sign round for attempt 2"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect_err("expected stale attempt rejection"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("stale"), + "expected stale-attempt validation message, got: {message}" + ); + } + + #[test] + fn start_sign_round_rejects_future_attempt_number_without_transition_authorization() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase2-future-start-attempt"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round for attempt 1"); + + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: None, + }) + .expect_err("expected future attempt rejection"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("attempt_transition_evidence"), + "expected future-attempt validation message, got: {message}" + ); + } + + #[test] + fn start_sign_round_allows_next_attempt_with_valid_transition_evidence() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase2-transition-evidence-valid"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + let round_state_one = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round for attempt 1"); + + let transition_evidence = build_attempt_transition_evidence_from_active_session(session_id); + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + let round_state_two = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect("start sign round for authorized attempt 2"); + + assert_ne!(round_state_one.round_id, round_state_two.round_id); + let transition_telemetry = round_state_two + .attempt_transition_telemetry + .expect("attempt transition telemetry"); + assert_eq!(transition_telemetry.from_attempt_number, 1); + assert_eq!(transition_telemetry.to_attempt_number, 2); + assert_eq!( + transition_telemetry.reason, + ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT + ); + assert!(transition_telemetry.excluded_member_identifiers.is_empty()); + + let stale_attempt = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(stale_attempt), + attempt_transition_evidence: None, + }) + .expect_err("expected stale rejection after authorized advancement"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("stale"), + "expected stale-attempt validation message, got: {message}" + ); + } + + #[test] + fn start_sign_round_allows_member_reuse_after_transition_without_resending_evidence() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-transition-reuse-without-evidence"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round for attempt 1"); + + let transition_evidence = build_attempt_transition_evidence_from_active_session(session_id); + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + let transitioned_round_state = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two.clone()), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect("start sign round for authorized attempt 2"); + + let reused_round_state = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 2, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: None, + }) + .expect("reuse active attempt without transition evidence"); + + assert_eq!( + transitioned_round_state.round_id, + reused_round_state.round_id + ); + assert_eq!(transitioned_round_state.required_contributions, 2); + assert_eq!(reused_round_state.required_contributions, 2); + assert_eq!(transitioned_round_state.own_contribution.identifier, 1); + assert_eq!(reused_round_state.own_contribution.identifier, 2); + assert_ne!( + transitioned_round_state + .own_contribution + .signature_share_hex, + reused_round_state.own_contribution.signature_share_hex + ); + } + + #[test] + fn start_sign_round_allows_next_attempt_with_valid_transition_evidence_after_reload() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("phase2_transition_evidence_valid_reload"); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase2-transition-evidence-valid-reload"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + let round_state_one = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round for attempt 1"); + + reload_state_from_storage_for_tests(); + + let transition_evidence = build_attempt_transition_evidence_from_active_session(session_id); + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + let round_state_two = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect("start sign round for authorized attempt 2 after reload"); + + assert_ne!(round_state_one.round_id, round_state_two.round_id); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_rejects_stale_attempt_after_authorized_transition_across_reload() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("phase2_transition_stale_after_reload"); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase2-transition-stale-after-reload"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_one.clone()), + attempt_transition_evidence: None, + }) + .expect("start sign round for attempt 1"); + + let transition_evidence = build_attempt_transition_evidence_from_active_session(session_id); + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect("start sign round for authorized attempt 2"); + + reload_state_from_storage_for_tests(); + + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect_err("expected stale attempt rejection after reload"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("stale"), + "expected stale-attempt validation message, got: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_rejects_next_attempt_with_invalid_transition_evidence() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase2-transition-evidence-invalid"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round for attempt 1"); + + let mut invalid_transition_evidence = + build_attempt_transition_evidence_from_active_session(session_id); + invalid_transition_evidence.previous_round_id = "invalid-round-id".to_string(); + + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: Some(invalid_transition_evidence), + }) + .expect_err("expected invalid transition evidence rejection"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("previous_round_id"), + "expected transition-evidence previous_round_id validation message, got: {message}" + ); + } + + #[test] + fn start_sign_round_rejects_far_future_attempt_even_with_transition_evidence() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase2-transition-evidence-far-future"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round for attempt 1"); + + let transition_evidence = build_attempt_transition_evidence_from_active_session(session_id); + let attempt_three = + build_deterministic_attempt_context(session_id, message_hex, 3, vec![1, 2]); + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_three), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect_err("expected far-future attempt rejection"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("ahead of active attempt_number"), + "expected far-future validation message, got: {message}" + ); + } + + #[test] + fn start_sign_round_rejects_next_attempt_without_exclusion_evidence() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase4-transition-missing-exclusion-evidence"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round for attempt 1"); + + let mut transition_evidence = + build_attempt_transition_evidence_from_active_session(session_id); + transition_evidence.exclusion_evidence = None; + + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect_err("expected missing exclusion evidence rejection"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("exclusion_evidence"), + "expected exclusion-evidence validation message, got: {message}" + ); + } + + #[test] + fn start_sign_round_rejects_timeout_reason_with_invalid_share_fingerprint() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase4-timeout-reason-fingerprint-rejection"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round for attempt 1"); + + let mut transition_evidence = + build_attempt_transition_evidence_from_active_session(session_id); + transition_evidence.exclusion_evidence = Some(AttemptExclusionEvidence { + reason: ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT.to_string(), + excluded_member_identifiers: vec![], + invalid_share_proof_fingerprint: Some("ab".repeat(32)), + }); + + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect_err("expected timeout-reason proof fingerprint rejection"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("must be omitted"), + "expected timeout-reason proof-fingerprint validation message, got: {message}" + ); + } + + #[test] + fn start_sign_round_accepts_invalid_share_proof_exclusion_evidence() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase4-invalid-share-proof-evidence-valid"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2, 3]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2, 3]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round for attempt 1"); + + let mut transition_evidence = + build_attempt_transition_evidence_from_active_session(session_id); + transition_evidence.exclusion_evidence = Some(AttemptExclusionEvidence { + reason: ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF.to_string(), + excluded_member_identifiers: vec![3], + invalid_share_proof_fingerprint: Some("ab".repeat(32)), + }); + + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + let round_state_two = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect("start sign round for attempt 2 with invalid-share-proof evidence"); + + assert_eq!(round_state_two.required_contributions, 2); + let transition_telemetry = round_state_two + .attempt_transition_telemetry + .expect("attempt transition telemetry"); + assert_eq!(transition_telemetry.from_attempt_number, 1); + assert_eq!(transition_telemetry.to_attempt_number, 2); + assert_eq!( + transition_telemetry.reason, + ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF + ); + assert_eq!(transition_telemetry.excluded_member_identifiers, vec![3]); + } + + #[test] + fn start_sign_round_rejects_invalid_share_proof_without_fingerprint() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase4-invalid-share-proof-fingerprint-required"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2, 3]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2, 3]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round for attempt 1"); + + let mut transition_evidence = + build_attempt_transition_evidence_from_active_session(session_id); + transition_evidence.exclusion_evidence = Some(AttemptExclusionEvidence { + reason: ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF.to_string(), + excluded_member_identifiers: vec![3], + invalid_share_proof_fingerprint: None, + }); + + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect_err("expected invalid-share-proof fingerprint required rejection"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("invalid_share_proof_fingerprint is required"), + "expected invalid-share-proof fingerprint-required message, got: {message}" + ); + } + + #[test] + fn start_sign_round_rejects_invalid_share_proof_with_empty_fingerprint() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase4-invalid-share-proof-empty-fingerprint"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let attempt_one = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2, 3]); + start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2, 3]), + attempt_context: Some(attempt_one), + attempt_transition_evidence: None, + }) + .expect("start sign round for attempt 1"); + + let mut transition_evidence = + build_attempt_transition_evidence_from_active_session(session_id); + transition_evidence.exclusion_evidence = Some(AttemptExclusionEvidence { + reason: ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF.to_string(), + excluded_member_identifiers: vec![3], + invalid_share_proof_fingerprint: Some(" ".to_string()), + }); + + let attempt_two = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + let err = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_two), + attempt_transition_evidence: Some(transition_evidence), + }) + .expect_err("expected invalid-share-proof empty-fingerprint rejection"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("must be non-empty valid hex"), + "expected invalid-share-proof empty-fingerprint message, got: {message}" + ); + } + + #[test] + fn finalize_sign_round_rejects_coordinator_mismatch_against_active_attempt_context() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase2-finalize-coordinator-mismatch"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let start_attempt = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + let round_state = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(start_attempt), + attempt_transition_evidence: None, + }) + .expect("start sign round"); + + let mismatched_attempt = build_attempt_context(session_id, message_hex, 1, 1, vec![1, 2]); + let err = finalize_sign_round( + FinalizeSignRoundRequest { + session_id: session_id.to_string(), + taproot_merkle_root_hex: None, + attempt_context: Some(mismatched_attempt), + round_contributions: vec![ + round_state.own_contribution.clone(), + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }, + true, + ) + .expect_err("expected coordinator mismatch rejection"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("coordinator_identifier"), + "expected coordinator mismatch validation message, got: {message}" + ); + } + + #[test] + fn finalize_sign_round_rejects_stale_attempt_number_against_active_attempt_context() { + let _guard = lock_test_state(); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-roast-phase2-finalize-stale-attempt"; + let message_hex = "deadbeef"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let start_attempt = + build_deterministic_attempt_context(session_id, message_hex, 2, vec![1, 2]); + let round_state = start_sign_round(StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(start_attempt), + attempt_transition_evidence: None, + }) + .expect("start sign round"); + + let stale_attempt = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + let err = finalize_sign_round( + FinalizeSignRoundRequest { + session_id: session_id.to_string(), + taproot_merkle_root_hex: None, + attempt_context: Some(stale_attempt), + round_contributions: vec![ + round_state.own_contribution.clone(), + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }, + true, + ) + .expect_err("expected stale attempt rejection"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("stale"), + "expected stale-attempt validation message, got: {message}" + ); + } + + #[test] + fn finalize_rejects_bootstrap_synthetic_contributions_outside_bootstrap_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let round_state = seeded_round_state("session-synthetic-rejected"); + + let request = FinalizeSignRoundRequest { + session_id: "session-synthetic-rejected".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + + let err = finalize_sign_round(request, false).expect_err("expected synthetic rejection"); + assert!(matches!( + err, + EngineError::SyntheticContributionRejected { .. } + )); + } + + #[test] + fn finalize_accepts_bootstrap_synthetic_contributions_in_bootstrap_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let round_state = seeded_round_state("session-synthetic-accepted"); + + let request = FinalizeSignRoundRequest { + session_id: "session-synthetic-accepted".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + + let result = + finalize_sign_round(request, true).expect("expected bootstrap synthetic acceptance"); + assert_eq!(result.round_id, round_state.round_id); + } + + #[test] + fn finalize_aggregates_real_contributions_outside_bootstrap_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-real-finalize".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-real-finalize".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request.clone()).expect("start sign round"); + let signing_participants = round_state + .signing_participants + .clone() + .expect("round signing participants"); + + let (dkg_key_packages, dkg_public_key_package, sign_message_bytes) = { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get(&start_request.session_id) + .expect("session state"); + + ( + session.dkg_key_packages.clone().expect("dkg key packages"), + session + .dkg_public_key_package + .clone() + .expect("dkg public key package"), + session + .sign_message_bytes + .clone() + .expect("sign message bytes"), + ) + }; + + let member_two_request = StartSignRoundRequest { + member_identifier: 2, + attempt_transition_evidence: None, + ..start_request + }; + let member_two_contribution = build_real_signature_share_contribution( + &dkg_key_packages, + &signing_participants, + &member_two_request, + &round_state.round_id, + &hex::decode(&member_two_request.message_hex).expect("message decode"), + None, + ) + .expect("member two contribution"); + let member_three_request = StartSignRoundRequest { + member_identifier: 3, + attempt_transition_evidence: None, + ..member_two_request.clone() + }; + let member_three_contribution = build_real_signature_share_contribution( + &dkg_key_packages, + &signing_participants, + &member_three_request, + &round_state.round_id, + &hex::decode(&member_three_request.message_hex).expect("message decode"), + None, + ) + .expect("member three contribution"); + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-real-finalize".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + round_state.own_contribution.clone(), + member_two_contribution, + member_three_contribution, + ], + }; + + let first_result = finalize_sign_round(finalize_request.clone(), false).expect("finalize"); + let second_result = finalize_sign_round(finalize_request, false).expect("finalize retry"); + + assert_eq!(first_result, second_result); + assert_eq!(first_result.round_id, round_state.round_id); + let signature_bytes = hex::decode(&first_result.signature_hex).expect("signature decode"); + assert_eq!(signature_bytes.len(), 64); + let signature = frost::Signature::deserialize(&signature_bytes).expect("signature parse"); + let exported_key_group_bytes = + hex::decode(&dkg_result.key_group).expect("decode exported key group"); + let exported_verifying_key = frost::VerifyingKey::deserialize(&exported_key_group_bytes) + .expect("deserialize exported key group"); + assert_eq!( + dkg_result.key_group, + hex::encode( + dkg_public_key_package + .verifying_key() + .serialize() + .expect("serialize DKG verifying key") + ) + ); + dkg_public_key_package + .verifying_key() + .verify(&sign_message_bytes, &signature) + .expect("signature verification"); + exported_verifying_key + .verify(&sign_message_bytes, &signature) + .expect("signature verifies under exported key group"); + assert!( + dkg_public_key_package + .clone() + .tweak::<&[u8]>(None) + .verifying_key() + .verify(&sign_message_bytes, &signature) + .is_err(), + "no-root signature must not verify under an additional BIP-86 empty-root tweak" + ); + } + + #[test] + fn finalize_aggregates_real_taproot_tweaked_contributions() { + let _guard = lock_test_state(); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-real-taproot-tweak".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let taproot_merkle_root_hex = + "37a57b86de2819d2b72a173df46238a7ad295ea1485d3b40e9415daa82b4fdcb"; + let taproot_merkle_root_bytes = + hex::decode(taproot_merkle_root_hex).expect("taproot merkle root"); + let mut taproot_merkle_root = [0_u8; 32]; + taproot_merkle_root.copy_from_slice(&taproot_merkle_root_bytes); + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-real-taproot-tweak".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: Some(taproot_merkle_root_hex.to_string()), + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request.clone()).expect("start sign round"); + assert_eq!( + round_state.taproot_merkle_root_hex.as_deref(), + Some(taproot_merkle_root_hex) + ); + let signing_participants = round_state + .signing_participants + .clone() + .expect("round signing participants"); + + let (dkg_key_packages, dkg_public_key_package, sign_message_bytes) = { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get(&start_request.session_id) + .expect("session state"); + + ( + session.dkg_key_packages.clone().expect("dkg key packages"), + session + .dkg_public_key_package + .clone() + .expect("dkg public key package"), + session + .sign_message_bytes + .clone() + .expect("sign message bytes"), + ) + }; + + let member_two_request = StartSignRoundRequest { + member_identifier: 2, + attempt_transition_evidence: None, + ..start_request.clone() + }; + let member_two_contribution = build_real_signature_share_contribution( + &dkg_key_packages, + &signing_participants, + &member_two_request, + &round_state.round_id, + &hex::decode(&member_two_request.message_hex).expect("message decode"), + Some(&taproot_merkle_root), + ) + .expect("member two contribution"); + let member_three_request = StartSignRoundRequest { + member_identifier: 3, + attempt_transition_evidence: None, + ..member_two_request.clone() + }; + let member_three_contribution = build_real_signature_share_contribution( + &dkg_key_packages, + &signing_participants, + &member_three_request, + &round_state.round_id, + &hex::decode(&member_three_request.message_hex).expect("message decode"), + Some(&taproot_merkle_root), + ) + .expect("member three contribution"); + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-real-taproot-tweak".to_string(), + taproot_merkle_root_hex: Some(taproot_merkle_root_hex.to_string()), + attempt_context: None, + round_contributions: vec![ + round_state.own_contribution.clone(), + member_two_contribution, + member_three_contribution, + ], + }; + + let result = finalize_sign_round(finalize_request, false).expect("finalize"); + + assert_eq!(result.round_id, round_state.round_id); + let signature_bytes = hex::decode(&result.signature_hex).expect("signature decode"); + assert_eq!(signature_bytes.len(), 64); + let signature = frost::Signature::deserialize(&signature_bytes).expect("signature parse"); + let exported_key_group_bytes = + hex::decode(&dkg_result.key_group).expect("decode exported key group"); + let exported_verifying_key = frost::VerifyingKey::deserialize(&exported_key_group_bytes) + .expect("deserialize exported key group"); + let exported_public_key_package = frost::keys::PublicKeyPackage::new( + BTreeMap::::new(), + exported_verifying_key, + Some(dkg_result.threshold), + ); + assert_eq!( + dkg_result.key_group, + hex::encode( + dkg_public_key_package + .verifying_key() + .serialize() + .expect("serialize DKG verifying key") + ) + ); + let tweaked_public_key_package = dkg_public_key_package + .clone() + .tweak(Some(taproot_merkle_root.as_slice())); + tweaked_public_key_package + .verifying_key() + .verify(&sign_message_bytes, &signature) + .expect("tweaked signature verification"); + exported_public_key_package + .tweak(Some(taproot_merkle_root.as_slice())) + .verifying_key() + .verify(&sign_message_bytes, &signature) + .expect("tweaked signature verifies under exported key group"); + assert!( + dkg_public_key_package + .verifying_key() + .verify(&sign_message_bytes, &signature) + .is_err(), + "tweaked signature must not verify under the untweaked key" + ); + } + + #[test] + fn taproot_tweak_matches_cross_repo_deposit_fixture() { + let internal_key = + hex::decode("022336f65004d8f122f1fe947ebd009a8b4add3a0d937356d568e30f7fcc2e4008") + .expect("decode compressed internal key"); + let verifying_key = + frost::VerifyingKey::deserialize(&internal_key).expect("deserialize verifying key"); + let public_key_package = frost::keys::PublicKeyPackage::new( + BTreeMap::::new(), + verifying_key, + Some(1), + ); + + let merkle_root = + hex::decode("3d6f9a2fea1de0a6c260d1fbc0343c9b2ed84307e6a7231139b78438448ee8c0") + .expect("decode taproot merkle root"); + let tweaked_public_key = public_key_package + .tweak(Some(merkle_root.as_slice())) + .verifying_key() + .serialize() + .expect("serialize tweaked verifying key"); + + assert_eq!( + hex::encode(&tweaked_public_key[1..]), + "90e7ce2b6cd476b7a1c2c7f6585c3fd0eae4379a508e981ed422b3e28b9ae8c2" + ); + } + + #[test] + fn finalize_aggregates_real_threshold_subset_outside_bootstrap_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-real-threshold-subset".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-real-threshold-subset".to_string(), + member_identifier: 1, + message_hex: "cafef00d".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request.clone()).expect("start sign round"); + let signing_participants = round_state + .signing_participants + .clone() + .expect("round signing participants"); + + let (dkg_key_packages, dkg_public_key_package, sign_message_bytes) = { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get(&start_request.session_id) + .expect("session state"); + + ( + session.dkg_key_packages.clone().expect("dkg key packages"), + session + .dkg_public_key_package + .clone() + .expect("dkg public key package"), + session + .sign_message_bytes + .clone() + .expect("sign message bytes"), + ) + }; + + let member_two_request = StartSignRoundRequest { + member_identifier: 2, + attempt_transition_evidence: None, + ..start_request + }; + let member_two_contribution = build_real_signature_share_contribution( + &dkg_key_packages, + &signing_participants, + &member_two_request, + &round_state.round_id, + &hex::decode(&member_two_request.message_hex).expect("message decode"), + None, + ) + .expect("member two contribution"); + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-real-threshold-subset".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + round_state.own_contribution.clone(), + member_two_contribution, + ], + }; + + let first_result = finalize_sign_round(finalize_request.clone(), false).expect("finalize"); + let second_result = finalize_sign_round(finalize_request, false).expect("finalize retry"); + + assert_eq!(first_result, second_result); + assert_eq!(first_result.round_id, round_state.round_id); + let signature_bytes = hex::decode(&first_result.signature_hex).expect("signature decode"); + assert_eq!(signature_bytes.len(), 64); + let signature = frost::Signature::deserialize(&signature_bytes).expect("signature parse"); + dkg_public_key_package + .verifying_key() + .verify(&sign_message_bytes, &signature) + .expect("signature verification"); + } + + #[test] + fn start_sign_round_allows_distinct_members_for_same_active_round() { + let _guard = lock_test_state(); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-real-multi-member-process".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-real-multi-member-process".to_string(), + member_identifier: 1, + message_hex: "baddcafe".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: None, + attempt_transition_evidence: None, + }; + let first_round_state = + start_sign_round(start_request.clone()).expect("first member start sign round"); + + let second_round_state = start_sign_round(StartSignRoundRequest { + member_identifier: 2, + ..start_request.clone() + }) + .expect("second member start sign round"); + + assert_eq!(first_round_state.session_id, second_round_state.session_id); + assert_eq!(first_round_state.round_id, second_round_state.round_id); + assert_eq!(first_round_state.required_contributions, 2); + assert_eq!(second_round_state.required_contributions, 2); + assert_eq!(first_round_state.own_contribution.identifier, 1); + assert_eq!(second_round_state.own_contribution.identifier, 2); + assert_ne!( + first_round_state.own_contribution.signature_share_hex, + second_round_state.own_contribution.signature_share_hex + ); + + let (dkg_public_key_package, sign_message_bytes) = { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get(&start_request.session_id) + .expect("session state"); + + ( + session + .dkg_public_key_package + .clone() + .expect("dkg public key package"), + session + .sign_message_bytes + .clone() + .expect("sign message bytes"), + ) + }; + + let finalize_request = FinalizeSignRoundRequest { + session_id: start_request.session_id, + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + first_round_state.own_contribution, + second_round_state.own_contribution, + ], + }; + + let result = finalize_sign_round(finalize_request, false).expect("finalize"); + + assert_eq!(result.round_id, first_round_state.round_id); + let signature_bytes = hex::decode(&result.signature_hex).expect("signature decode"); + let signature = frost::Signature::deserialize(&signature_bytes).expect("signature parse"); + dkg_public_key_package + .verifying_key() + .verify(&sign_message_bytes, &signature) + .expect("signature verification"); + } + + #[test] + fn start_sign_round_allows_taproot_threshold_subset_members_for_same_active_round() { + let _guard = lock_test_state(); + reset_for_tests(); + + let participants = (1_u16..=100) + .map(|identifier| crate::api::DkgParticipant { + identifier, + public_key_hex: format!("02{identifier:02x}"), + }) + .collect::>(); + let signing_participants = vec![ + 2, 3, 4, 8, 11, 13, 14, 17, 19, 21, 22, 25, 27, 29, 30, 31, 32, 33, 35, 37, 38, 39, 42, + 44, 45, 48, 50, 51, 52, 53, 57, 58, 60, 61, 63, 64, 65, 67, 68, 73, 76, 77, 80, 81, 84, + 86, 87, 88, 90, 94, 96, + ]; + let taproot_merkle_root_hex = + "37a57b86de2819d2b72a173df46238a7ad295ea1485d3b40e9415daa82b4fdcb"; + + let dkg_result = run_dkg(RunDkgRequest { + session_id: "session-real-taproot-multi-member-process".to_string(), + participants, + threshold: 51, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let first_request = StartSignRoundRequest { + session_id: "session-real-taproot-multi-member-process".to_string(), + member_identifier: 86, + message_hex: "ac692bb7fddf3f7e1e050a83cf3ffb6e8e69888ce980281aa39da169525750ef" + .to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: Some(taproot_merkle_root_hex.to_string()), + signing_participants: Some(signing_participants.clone()), + attempt_context: None, + attempt_transition_evidence: None, + }; + + let first_round_state = + start_sign_round(first_request.clone()).expect("first member start sign round"); + assert_eq!(first_round_state.required_contributions, 51); + assert_eq!( + first_round_state.signing_participants.as_deref(), + Some(signing_participants.as_slice()) + ); + + let mut contributions = vec![first_round_state.own_contribution.clone()]; + for member_identifier in [76_u16, 39, 53, 3] { + let round_state = start_sign_round(StartSignRoundRequest { + member_identifier, + ..first_request.clone() + }) + .expect("next member start sign round"); + + assert_eq!(round_state.session_id, first_round_state.session_id); + assert_eq!(round_state.round_id, first_round_state.round_id); + assert_eq!(round_state.required_contributions, 51); + assert_eq!(round_state.own_contribution.identifier, member_identifier); + contributions.push(round_state.own_contribution); + } + + let (dkg_key_packages, dkg_public_key_package, sign_message_bytes) = { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get(&first_request.session_id) + .expect("session state"); + + ( + session.dkg_key_packages.clone().expect("dkg key packages"), + session + .dkg_public_key_package + .clone() + .expect("dkg public key package"), + session + .sign_message_bytes + .clone() + .expect("sign message bytes"), + ) + }; + let taproot_merkle_root_bytes = + hex::decode(taproot_merkle_root_hex).expect("taproot merkle root"); + let mut taproot_merkle_root = [0_u8; 32]; + taproot_merkle_root.copy_from_slice(&taproot_merkle_root_bytes); + + for member_identifier in signing_participants + .iter() + .copied() + .filter(|identifier| ![86_u16, 76, 39, 53, 3].contains(identifier)) + .take(46) + { + let member_request = StartSignRoundRequest { + member_identifier, + ..first_request.clone() + }; + contributions.push( + build_real_signature_share_contribution( + &dkg_key_packages, + signing_participants.as_slice(), + &member_request, + &first_round_state.round_id, + &sign_message_bytes, + Some(&taproot_merkle_root), + ) + .expect("additional contribution"), + ); + } + assert_eq!(contributions.len(), 51); + + let result = finalize_sign_round( + FinalizeSignRoundRequest { + session_id: first_request.session_id, + taproot_merkle_root_hex: Some(taproot_merkle_root_hex.to_string()), + attempt_context: None, + round_contributions: contributions, + }, + false, + ) + .expect("finalize"); + + assert_eq!(result.round_id, first_round_state.round_id); + let signature_bytes = hex::decode(&result.signature_hex).expect("signature decode"); + let signature = frost::Signature::deserialize(&signature_bytes).expect("signature parse"); + let tweaked_public_key_package = dkg_public_key_package + .clone() + .tweak(Some(taproot_merkle_root.as_slice())); + tweaked_public_key_package + .verifying_key() + .verify(&sign_message_bytes, &signature) + .expect("tweaked signature verification"); + } + + #[test] + fn deterministic_round_nonce_and_commitment_is_message_bound() { + let _guard = lock_test_state(); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-nonce-message-bound".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + run_dkg(run_dkg_request).expect("run dkg"); + + let key_package = { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get("session-nonce-message-bound") + .expect("session state"); + + session + .dkg_key_packages + .as_ref() + .expect("dkg key packages") + .get(&1) + .expect("key package") + .clone() + }; + + let session_id = "session-nonce-message-bound"; + let round_id = "fixed-round-id"; + let participant_identifier = 1u16; + let message_one = hex::decode("deadbeef").expect("message one decode"); + let message_two = hex::decode("cafebabe").expect("message two decode"); + + let (_, commitments_one) = build_deterministic_round_nonce_and_commitment( + &key_package, + session_id, + round_id, + &message_one, + participant_identifier, + ); + let (_, commitments_one_retry) = build_deterministic_round_nonce_and_commitment( + &key_package, + session_id, + round_id, + &message_one, + participant_identifier, + ); + let (_, commitments_two) = build_deterministic_round_nonce_and_commitment( + &key_package, + session_id, + round_id, + &message_two, + participant_identifier, + ); + + assert_eq!(commitments_one, commitments_one_retry); + assert_ne!(commitments_one, commitments_two); + } + + #[test] + fn deterministic_seed_disambiguates_embedded_zero_bytes() { + let parts_a = [b"\xaa\x00".as_slice(), b"\x01".as_slice()]; + let parts_b = [b"\xaa".as_slice(), b"\x00\x01".as_slice()]; + + assert_ne!(deterministic_seed(&parts_a), deterministic_seed(&parts_b)); + } + + #[test] + fn finalize_rejects_tampered_session_message_bytes() { + let _guard = lock_test_state(); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-finalize-message-tamper".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-finalize-message-tamper".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request.clone()).expect("start sign round"); + let signing_participants = round_state + .signing_participants + .clone() + .expect("round signing participants"); + + let dkg_key_packages = { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get(&start_request.session_id) + .expect("session state"); + + session.dkg_key_packages.clone().expect("dkg key packages") + }; + + let member_two_request = StartSignRoundRequest { + member_identifier: 2, + attempt_transition_evidence: None, + ..start_request.clone() + }; + let member_two_contribution = build_real_signature_share_contribution( + &dkg_key_packages, + &signing_participants, + &member_two_request, + &round_state.round_id, + &hex::decode(&member_two_request.message_hex).expect("message decode"), + None, + ) + .expect("member two contribution"); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get_mut(&start_request.session_id) + .expect("session state"); + + session.sign_message_bytes = Some(Zeroizing::new( + hex::decode("cafebabe").expect("tamper decode"), + )); + } + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-finalize-message-tamper".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + round_state.own_contribution.clone(), + member_two_contribution, + ], + }; + + let err = finalize_sign_round(finalize_request, false).expect_err("expected failure"); + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + + assert!( + message.contains("failed to aggregate signature shares"), + "unexpected validation message: {message}" + ); + } + + #[test] + fn finalize_rejects_real_contributor_set_mismatch_with_explicit_error() { + let _guard = lock_test_state(); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-real-contributor-set-mismatch".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-real-contributor-set-mismatch".to_string(), + member_identifier: 1, + message_hex: "b16b00b5".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request.clone()).expect("start sign round"); + let signing_participants = round_state + .signing_participants + .clone() + .expect("round signing participants"); + + let dkg_key_packages = { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get(&start_request.session_id) + .expect("session state"); + + session.dkg_key_packages.clone().expect("dkg key packages") + }; + + let member_two_request = StartSignRoundRequest { + member_identifier: 2, + attempt_transition_evidence: None, + ..start_request + }; + let member_two_contribution = build_real_signature_share_contribution( + &dkg_key_packages, + &signing_participants, + &member_two_request, + &round_state.round_id, + &hex::decode(&member_two_request.message_hex).expect("message decode"), + None, + ) + .expect("member two contribution"); + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-real-contributor-set-mismatch".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + round_state.own_contribution.clone(), + member_two_contribution, + ], + }; + + let err = finalize_sign_round(finalize_request, false).expect_err("expected mismatch"); + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + + assert!( + message.contains( + "round contribution identifiers must match signing participants for real finalize" + ), + "unexpected validation message: {message}" + ); + assert!( + message.contains("[1, 2, 3]"), + "expected identifier set in message: {message}" + ); + assert!( + message.contains("[1, 2]"), + "expected contributor set in message: {message}" + ); + } + + #[test] + fn finalize_rejects_real_contribution_identifier_outside_signing_cohort() { + let _guard = lock_test_state(); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-real-outside-signing-cohort".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-real-outside-signing-cohort".to_string(), + member_identifier: 1, + message_hex: "facefeed".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request).expect("start sign round"); + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-real-outside-signing-cohort".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + round_state.own_contribution, + RoundContribution { + identifier: 3, + signature_share_hex: "abcd".to_string(), + }, + ], + }; + + let err = finalize_sign_round(finalize_request, false).expect_err("expected rejection"); + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("round contribution identifier [3] is not in signing participant set"), + "unexpected validation message: {message}" + ); + } + + #[test] + fn run_dkg_conflict_persists_across_storage_reload() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("run_dkg_conflict_persists"); + reset_for_tests(); + + let request_a = RunDkgRequest { + session_id: "session-persisted-conflict".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let mut request_b = request_a.clone(); + request_b.participants.push(crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }); + + run_dkg(request_a).expect("initial run dkg"); + reload_state_from_storage_for_tests(); + + let err = run_dkg(request_b).expect_err("expected persisted session conflict"); + assert!(matches!(err, EngineError::SessionConflict { .. })); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn persisted_engine_state_rejects_session_registry_over_limit() { + let _guard = lock_test_state(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "2"); + + let mut sessions = HashMap::new(); + sessions.insert("session-a".to_string(), persisted_session_state_fixture()); + sessions.insert("session-b".to_string(), persisted_session_state_fixture()); + sessions.insert("session-c".to_string(), persisted_session_state_fixture()); + + let persisted = PersistedEngineState { + schema_version: PERSISTED_STATE_SCHEMA_VERSION, + sessions, + refresh_epoch_counter: 0, + operator_fault_scores: BTreeMap::new(), + quarantined_operator_identifiers: vec![], + canary_rollout: CanaryRolloutState::default(), + }; + + let err = match EngineState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, + }; + expect_internal_error_contains(err, "persisted session registry size [3] exceeds max [2]"); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn max_sessions_limit_env_parser_is_strict_positive() { + let _guard = lock_test_state(); + clear_state_storage_policy_overrides(); + + assert_eq!(max_sessions_limit(), TBTC_SIGNER_DEFAULT_MAX_SESSIONS); + + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "not-a-number"); + assert_eq!(max_sessions_limit(), TBTC_SIGNER_DEFAULT_MAX_SESSIONS); + + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "0"); + assert_eq!(max_sessions_limit(), TBTC_SIGNER_DEFAULT_MAX_SESSIONS); + + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "-1"); + assert_eq!(max_sessions_limit(), TBTC_SIGNER_DEFAULT_MAX_SESSIONS); + + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, " 7 "); + assert_eq!(max_sessions_limit(), 7); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn roast_coordinator_timeout_ms_env_parser_is_strict_bounds() { + let _guard = lock_test_state(); + clear_state_storage_policy_overrides(); + + assert_eq!( + roast_coordinator_timeout_ms(), + TBTC_SIGNER_DEFAULT_ROAST_COORDINATOR_TIMEOUT_MS + ); + + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "not-a-number"); + assert_eq!( + roast_coordinator_timeout_ms(), + TBTC_SIGNER_DEFAULT_ROAST_COORDINATOR_TIMEOUT_MS + ); + + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "0"); + assert_eq!( + roast_coordinator_timeout_ms(), + TBTC_SIGNER_DEFAULT_ROAST_COORDINATOR_TIMEOUT_MS + ); + + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "999"); + assert_eq!( + roast_coordinator_timeout_ms(), + TBTC_SIGNER_DEFAULT_ROAST_COORDINATOR_TIMEOUT_MS + ); + + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "300001"); + assert_eq!( + roast_coordinator_timeout_ms(), + TBTC_SIGNER_DEFAULT_ROAST_COORDINATOR_TIMEOUT_MS + ); + + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, " 45000 "); + assert_eq!(roast_coordinator_timeout_ms(), 45_000); + + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_rejects_new_session_when_session_registry_is_at_capacity() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("run_dkg_session_capacity"); + reset_for_tests(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "1"); + + let request_a = RunDkgRequest { + session_id: "session-capacity-a".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + run_dkg(request_a.clone()).expect("initial run dkg"); + run_dkg(request_a).expect("idempotent run dkg at capacity"); + + let request_b = RunDkgRequest { + session_id: "session-capacity-b".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "03aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "03bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let err = run_dkg(request_b).expect_err("expected session cap rejection"); + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("session registry size [1] reached max [1]"), + "unexpected internal message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_uses_secret_entropy_for_new_sessions_and_cache_for_retries() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("run_dkg_secret_entropy"); + reset_for_tests(); + + let request_a = RunDkgRequest { + session_id: "session-secret-entropy-a".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let mut request_b = request_a.clone(); + request_b.session_id = "session-secret-entropy-b".to_string(); + + let result_a = run_dkg(request_a.clone()).expect("run dkg a"); + let retry_a = run_dkg(request_a).expect("retry dkg a"); + let result_b = run_dkg(request_b).expect("run dkg b"); + + assert_eq!(result_a, retry_a); + assert_ne!( + result_a.key_group, result_b.key_group, + "new sessions with the same public DKG request shape must not derive dealer entropy from public request data" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn run_dkg_retry_is_participant_order_insensitive() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("run_dkg_participant_order_retry"); + reset_for_tests(); + + let request = RunDkgRequest { + session_id: "session-dkg-participant-order-retry".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let mut retry_request = request.clone(); + retry_request.participants.reverse(); + + let first_result = run_dkg(request).expect("initial DKG"); + let retry_result = run_dkg(retry_request).expect("equivalent DKG retry"); + + assert_eq!(first_result, retry_result); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn build_taproot_tx_rejects_new_session_when_session_registry_is_at_capacity() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("build_taproot_tx_session_capacity"); + reset_for_tests(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "1"); + + let first_request = BuildTaprootTxRequest { + session_id: "session-build-tx-capacity-a".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 0, + value_sats: 10_000, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 8_000, + }], + script_tree_hex: None, + }; + build_taproot_tx(first_request.clone()).expect("first build tx"); + build_taproot_tx(first_request).expect("idempotent build tx at capacity"); + + let second_request = BuildTaprootTxRequest { + session_id: "session-build-tx-capacity-b".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "33".repeat(32), + vout: 0, + value_sats: 10_000, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "44".repeat(32)), + value_sats: 8_000, + }], + script_tree_hex: None, + }; + let err = build_taproot_tx(second_request).expect_err("expected session cap rejection"); + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("session registry size [1] reached max [1]"), + "unexpected internal message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn refresh_shares_rejects_new_session_when_session_registry_is_at_capacity() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("refresh_session_capacity"); + reset_for_tests(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "1"); + + let first_request = RefreshSharesRequest { + session_id: "session-refresh-capacity-a".to_string(), + current_shares: vec![ShareMaterial { + identifier: 1, + encrypted_share_hex: "aa11".to_string(), + }], + }; + refresh_shares(first_request.clone()).expect("first refresh"); + refresh_shares(first_request).expect("idempotent refresh at capacity"); + + let second_request = RefreshSharesRequest { + session_id: "session-refresh-capacity-b".to_string(), + current_shares: vec![ShareMaterial { + identifier: 1, + encrypted_share_hex: "bb22".to_string(), + }], + }; + let err = refresh_shares(second_request).expect_err("expected session cap rejection"); + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("session registry size [1] reached max [1]"), + "unexpected internal message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn refresh_shares_retry_is_share_order_insensitive() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("refresh_share_order_retry"); + reset_for_tests(); + + let request = RefreshSharesRequest { + session_id: "session-refresh-share-order-retry".to_string(), + current_shares: vec![ + ShareMaterial { + identifier: 3, + encrypted_share_hex: "cccc".to_string(), + }, + ShareMaterial { + identifier: 1, + encrypted_share_hex: "aaaa".to_string(), + }, + ShareMaterial { + identifier: 2, + encrypted_share_hex: "bbbb".to_string(), + }, + ], + }; + let mut retry_request = request.clone(); + retry_request.current_shares.reverse(); + + let first_result = refresh_shares(request).expect("initial refresh"); + let retry_result = refresh_shares(retry_request).expect("equivalent refresh retry"); + + assert_eq!(first_result, retry_result); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn refresh_shares_rejects_duplicate_current_share_identifiers() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("refresh_duplicate_share_identifier"); + reset_for_tests(); + + let err = refresh_shares(RefreshSharesRequest { + session_id: "session-refresh-duplicate-share-id".to_string(), + current_shares: vec![ + ShareMaterial { + identifier: 1, + encrypted_share_hex: "aaaa".to_string(), + }, + ShareMaterial { + identifier: 1, + encrypted_share_hex: "bbbb".to_string(), + }, + ], + }) + .expect_err("expected duplicate share identifier rejection"); + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("current_shares contains duplicate identifier [1]"), + "unexpected validation message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn refresh_shares_rejects_zero_current_share_identifier() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("refresh_zero_share_identifier"); + reset_for_tests(); + + let err = refresh_shares(RefreshSharesRequest { + session_id: "session-refresh-zero-share-id".to_string(), + current_shares: vec![ShareMaterial { + identifier: 0, + encrypted_share_hex: "aaaa".to_string(), + }], + }) + .expect_err("expected zero share identifier rejection"); + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("current_shares identifiers must be non-zero"), + "unexpected validation message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn sign_round_and_finalize_idempotency_persist_across_storage_reload() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("sign_finalize_idempotency"); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-persisted-idempotency".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + let start_request = StartSignRoundRequest { + session_id: "session-persisted-idempotency".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let first_round_state = start_sign_round(start_request.clone()).expect("start sign round"); + + reload_state_from_storage_for_tests(); + let second_round_state = start_sign_round(start_request).expect("persisted start retry"); + assert_eq!(first_round_state, second_round_state); + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-persisted-idempotency".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&first_round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&first_round_state, 2), + }, + ], + }; + + let first_signature = + finalize_sign_round(finalize_request.clone(), true).expect("initial finalize"); + reload_state_from_storage_for_tests(); + let second_signature = + finalize_sign_round(finalize_request, true).expect("persisted finalize retry"); + assert_eq!(first_signature, second_signature); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_accepts_persisted_legacy_member_bound_fingerprint() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("sign_legacy_member_fingerprint"); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-legacy-member-fingerprint".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + let start_request = StartSignRoundRequest { + session_id: "session-legacy-member-fingerprint".to_string(), + member_identifier: 1, + message_hex: "baddcafe".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: None, + attempt_transition_evidence: None, + }; + let first_round_state = start_sign_round(start_request.clone()).expect("start sign round"); + + let canonical_fingerprint = + start_sign_round_request_fingerprint(&start_request, 0).expect("canonical fingerprint"); + let legacy_member_fingerprint = + start_sign_round_request_fingerprint(&start_request, start_request.member_identifier) + .expect("legacy member fingerprint"); + assert_ne!(canonical_fingerprint, legacy_member_fingerprint); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get_mut(&start_request.session_id) + .expect("session state"); + assert_eq!( + session.sign_request_fingerprint.as_deref(), + Some(canonical_fingerprint.as_str()) + ); + session.sign_request_fingerprint = Some(legacy_member_fingerprint.clone()); + persist_engine_state_to_storage(&guard).expect("persist legacy fingerprint"); + } + + reload_state_from_storage_for_tests(); + let retry_round_state = + start_sign_round(start_request.clone()).expect("legacy fingerprint retry"); + assert_eq!(first_round_state, retry_round_state); + + reload_state_from_storage_for_tests(); + { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get(&start_request.session_id) + .expect("session state"); + assert_eq!( + session.sign_request_fingerprint.as_deref(), + Some(canonical_fingerprint.as_str()) + ); + } + + let second_member_round_state = start_sign_round(StartSignRoundRequest { + member_identifier: 2, + ..start_request.clone() + }) + .expect("second member after fingerprint migration"); + assert_eq!( + first_round_state.round_id, + second_member_round_state.round_id + ); + assert_eq!(second_member_round_state.own_contribution.identifier, 2); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn persisted_session_state_rejects_empty_consumed_attempt_id() { + let mut persisted = persisted_session_state_fixture(); + persisted.consumed_attempt_ids = vec!["".to_string()]; + + let err = match SessionState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, + }; + expect_internal_error_contains(err, "persisted consumed attempt ID must be non-empty"); + } + + #[test] + fn persisted_session_state_rejects_duplicate_consumed_attempt_id() { + let mut persisted = persisted_session_state_fixture(); + persisted.consumed_attempt_ids = vec!["attempt-a".to_string(), "attempt-a".to_string()]; + + let err = match SessionState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, + }; + expect_internal_error_contains(err, "duplicate persisted consumed attempt ID [attempt-a]"); + } + + #[test] + fn persisted_session_state_rejects_empty_consumed_sign_round_id() { + let mut persisted = persisted_session_state_fixture(); + persisted.consumed_sign_round_ids = vec!["".to_string()]; + + let err = match SessionState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, + }; + expect_internal_error_contains(err, "persisted consumed sign round ID must be non-empty"); + } + + #[test] + fn persisted_session_state_rejects_duplicate_consumed_sign_round_id() { + let mut persisted = persisted_session_state_fixture(); + persisted.consumed_sign_round_ids = vec!["round-a".to_string(), "round-a".to_string()]; + + let err = match SessionState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, + }; + expect_internal_error_contains(err, "duplicate persisted consumed sign round ID [round-a]"); + } + + #[test] + fn persisted_session_state_rejects_empty_consumed_finalize_round_id() { + let mut persisted = persisted_session_state_fixture(); + persisted.consumed_finalize_round_ids = vec!["".to_string()]; + + let err = match SessionState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, + }; + expect_internal_error_contains( + err, + "persisted consumed finalize round ID must be non-empty", + ); + } + + #[test] + fn persisted_session_state_rejects_duplicate_consumed_finalize_round_id() { + let mut persisted = persisted_session_state_fixture(); + persisted.consumed_finalize_round_ids = vec!["round-b".to_string(), "round-b".to_string()]; + + let err = match SessionState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, + }; + expect_internal_error_contains( + err, + "duplicate persisted consumed finalize round ID [round-b]", + ); + } + + #[test] + fn persisted_session_state_rejects_empty_consumed_finalize_request_fingerprint() { + let mut persisted = persisted_session_state_fixture(); + persisted.consumed_finalize_request_fingerprints = vec!["".to_string()]; + + let err = match SessionState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, + }; + expect_internal_error_contains( + err, + "persisted consumed finalize request fingerprint must be non-empty", + ); + } + + #[test] + fn persisted_session_state_rejects_duplicate_consumed_finalize_request_fingerprint() { + let mut persisted = persisted_session_state_fixture(); + persisted.consumed_finalize_request_fingerprints = + vec!["fp-1".to_string(), "fp-1".to_string()]; + + let err = match SessionState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, + }; + expect_internal_error_contains( + err, + "duplicate persisted consumed finalize request fingerprint [fp-1]", + ); + } + + #[test] + fn persisted_session_state_rejects_consumed_attempt_registry_over_limit() { + let mut persisted = persisted_session_state_fixture(); + persisted.consumed_attempt_ids = (0 + ..=TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION) + .map(|idx| format!("attempt-{idx}")) + .collect(); + + let err = match SessionState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, + }; + expect_internal_error_contains(err, "persisted consumed_attempt_ids registry size"); + } + + #[test] + fn persisted_session_state_rejects_consumed_sign_round_registry_over_limit() { + let mut persisted = persisted_session_state_fixture(); + persisted.consumed_sign_round_ids = (0 + ..=TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION) + .map(|idx| format!("round-{idx}")) + .collect(); + + let err = match SessionState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, + }; + expect_internal_error_contains(err, "persisted consumed_sign_round_ids registry size"); + } + + #[test] + fn persisted_session_state_rejects_consumed_finalize_round_registry_over_limit() { + let mut persisted = persisted_session_state_fixture(); + persisted.consumed_finalize_round_ids = (0 + ..=TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION) + .map(|idx| format!("round-{idx}")) + .collect(); + + let err = match SessionState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, + }; + expect_internal_error_contains(err, "persisted consumed_finalize_round_ids registry size"); + } + + #[test] + fn persisted_session_state_rejects_consumed_finalize_request_registry_over_limit() { + let mut persisted = persisted_session_state_fixture(); + persisted.consumed_finalize_request_fingerprints = (0 + ..=TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION) + .map(|idx| format!("fp-{idx}")) + .collect(); + + let err = match SessionState::try_from(persisted) { + Ok(_) => panic!("expected decode rejection"), + Err(err) => err, + }; + expect_internal_error_contains( + err, + "persisted consumed_finalize_request_fingerprints registry size", + ); + } + + #[test] + fn start_sign_round_rejects_consumed_round_id_when_sign_cache_is_missing() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("sign_round_consumed_nonce_enforcement"); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-sign-round-consumed-nonce".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + let start_request = StartSignRoundRequest { + session_id: "session-sign-round-consumed-nonce".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let first_round_state = start_sign_round(start_request.clone()).expect("start sign round"); + + reload_state_from_storage_for_tests(); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get_mut("session-sign-round-consumed-nonce") + .expect("session state"); + assert!(session + .consumed_sign_round_ids + .contains(&first_round_state.round_id)); + session.sign_request_fingerprint = None; + session.sign_message_bytes = None; + session.round_state = None; + persist_engine_state_to_storage(&guard).expect("persist tampered sign cache state"); + } + + reload_state_from_storage_for_tests(); + let err = start_sign_round(start_request).expect_err("expected consumed round rejection"); + let EngineError::ConsumedRoundReplay { + round_id, + session_id: _, + } = err + else { + panic!("unexpected error variant"); + }; + assert_eq!(round_id, first_round_state.round_id); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_replay_guard_survives_process_restart_with_sign_cache_loss() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("sign_round_consumed_nonce_restart_replay"); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-sign-round-consumed-nonce-restart".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + let start_request = StartSignRoundRequest { + session_id: "session-sign-round-consumed-nonce-restart".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let first_round_state = start_sign_round(start_request.clone()).expect("start sign round"); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get_mut("session-sign-round-consumed-nonce-restart") + .expect("session state"); + assert!(session + .consumed_sign_round_ids + .contains(&first_round_state.round_id)); + session.sign_request_fingerprint = None; + session.sign_message_bytes = None; + session.round_state = None; + persist_engine_state_to_storage(&guard).expect("persist tampered sign cache state"); + } + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let err = start_sign_round(start_request).expect_err("expected consumed round rejection"); + let EngineError::ConsumedRoundReplay { + round_id, + session_id: _, + } = err + else { + panic!("unexpected error variant"); + }; + assert_eq!(round_id, first_round_state.round_id); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_rejects_consumed_attempt_id_when_sign_cache_is_missing() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("sign_round_consumed_attempt_enforcement"); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-sign-round-consumed-attempt"; + let message_hex = "deadbeef"; + let run_dkg_request = RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + let attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![2, 1]); + let expected_attempt_id = attempt_context.attempt_id.clone(); + let start_request = StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }; + start_sign_round(start_request.clone()).expect("start sign round"); + + reload_state_from_storage_for_tests(); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.get_mut(session_id).expect("session state"); + assert!(session.consumed_attempt_ids.contains(&expected_attempt_id)); + session.sign_request_fingerprint = None; + session.sign_message_bytes = None; + session.round_state = None; + persist_engine_state_to_storage(&guard).expect("persist tampered sign cache state"); + } + + reload_state_from_storage_for_tests(); + let err = + start_sign_round(start_request).expect_err("expected consumed attempt-id rejection"); + let EngineError::ConsumedAttemptReplay { + attempt_id, + session_id: _, + } = err + else { + panic!("unexpected error variant"); + }; + assert_eq!(attempt_id, expected_attempt_id); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_attempt_replay_guard_survives_process_restart_with_sign_cache_loss() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("sign_round_consumed_attempt_restart_replay"); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-sign-round-consumed-attempt-restart"; + let message_hex = "deadbeef"; + let run_dkg_request = RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + let attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![2, 1]); + let expected_attempt_id = attempt_context.attempt_id.clone(); + let start_request = StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }; + start_sign_round(start_request.clone()).expect("start sign round"); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.get_mut(session_id).expect("session state"); + assert!(session.consumed_attempt_ids.contains(&expected_attempt_id)); + session.sign_request_fingerprint = None; + session.sign_message_bytes = None; + session.round_state = None; + persist_engine_state_to_storage(&guard).expect("persist tampered sign cache state"); + } + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let err = + start_sign_round(start_request).expect_err("expected consumed attempt-id rejection"); + let EngineError::ConsumedAttemptReplay { + attempt_id, + session_id: _, + } = err + else { + panic!("unexpected error variant"); + }; + assert_eq!(attempt_id, expected_attempt_id); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn persist_fault_after_temp_sync_before_rename_preserves_previous_state_on_restart() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("persist_fault_before_rename"); + reset_for_tests(); + + let existing_request = RunDkgRequest { + session_id: "session-persist-fault-existing".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + run_dkg(existing_request).expect("seed existing persisted session"); + + let failed_request = RunDkgRequest { + session_id: "session-persist-fault-before-rename".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "03aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "03bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterTempSyncBeforeRename, + ); + let err = run_dkg(failed_request).expect_err("expected injected persist failure"); + clear_persist_fault_injection_for_tests(); + + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("injected persist fault at [after_temp_sync_before_rename]"), + "unexpected persist fault message: {message}" + ); + assert!( + !state_path + .with_extension(format!("tmp-{}", std::process::id())) + .exists(), + "persist temp state file should be cleaned up on failure" + ); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + { + let guard = state().expect("engine state").lock().expect("engine lock"); + assert!(guard + .sessions + .contains_key("session-persist-fault-existing")); + assert!(!guard + .sessions + .contains_key("session-persist-fault-before-rename")); + } + + run_dkg(RunDkgRequest { + session_id: "session-persist-fault-recovery".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "04aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "04bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("post-fault recovery run dkg"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_rejects_when_consumed_sign_round_registry_is_at_capacity() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("sign_round_consumed_capacity"); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-sign-round-consumed-capacity".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get_mut("session-sign-round-consumed-capacity") + .expect("session state"); + + for idx in 0..TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION { + session + .consumed_sign_round_ids + .insert(format!("preused-round-{idx}")); + } + persist_engine_state_to_storage(&guard) + .expect("persist prefilled consumed sign rounds"); + } + + let start_request = StartSignRoundRequest { + session_id: "session-sign-round-consumed-capacity".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let err = start_sign_round(start_request).expect_err("expected capacity rejection"); + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("consumed_sign_round_ids registry size"), + "unexpected internal message: {message}" + ); + assert!( + message.contains("reached max"), + "unexpected internal message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_rejects_when_consumed_sign_round_registry_is_at_capacity_with_attempt_context( + ) { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("sign_round_consumed_capacity_attempt_context"); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-sign-round-consumed-capacity-attempt-context"; + let message_hex = "deadbeef"; + let run_dkg_request = RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.get_mut(session_id).expect("session state"); + + for idx in 0..TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION { + session + .consumed_sign_round_ids + .insert(format!("preused-round-{idx}")); + } + persist_engine_state_to_storage(&guard) + .expect("persist prefilled consumed sign rounds"); + } + + let attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![2, 1]); + let start_request = StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }; + let err = start_sign_round(start_request).expect_err("expected capacity rejection"); + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("consumed_sign_round_ids registry size"), + "unexpected internal message: {message}" + ); + assert!( + message.contains("reached max"), + "unexpected internal message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_rejects_when_consumed_attempt_registry_is_at_capacity_with_attempt_context() + { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("sign_round_consumed_attempt_capacity"); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-sign-round-consumed-attempt-capacity"; + let message_hex = "deadbeef"; + let run_dkg_request = RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.get_mut(session_id).expect("session state"); + + for idx in 0..TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION { + session + .consumed_attempt_ids + .insert(format!("preused-attempt-{idx}")); + } + persist_engine_state_to_storage(&guard) + .expect("persist prefilled consumed attempt IDs"); + } + + let attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![2, 1]); + let start_request = StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context), + attempt_transition_evidence: None, + }; + let err = start_sign_round(start_request).expect_err("expected capacity rejection"); + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("consumed_attempt_ids registry size"), + "unexpected internal message: {message}" + ); + assert!( + message.contains("reached max"), + "unexpected internal message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn finalize_sign_round_rejects_consumed_round_id_when_finalize_cache_is_missing() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("finalize_consumed_round_enforcement"); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-finalize-consumed-round".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-finalize-consumed-round".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request).expect("start sign round"); + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-finalize-consumed-round".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + finalize_sign_round(finalize_request.clone(), true).expect("first finalize"); + + reload_state_from_storage_for_tests(); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get_mut("session-finalize-consumed-round") + .expect("session state"); + assert!(session + .consumed_finalize_round_ids + .contains(&round_state.round_id)); + session.finalize_request_fingerprint = None; + session.signature_result = None; + session.round_state = Some(round_state.clone()); + persist_engine_state_to_storage(&guard).expect("persist tampered finalize cache state"); + } + + let round_only_replay_request = FinalizeSignRoundRequest { + session_id: finalize_request.session_id.clone(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: format!( + "{}00", + bootstrap_synthetic_share_hex(&round_state, 1) + ), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + + reload_state_from_storage_for_tests(); + let err = finalize_sign_round(round_only_replay_request, true) + .expect_err("expected consumed round-id rejection"); + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("already consumed for finalize"), + "unexpected validation message: {message}" + ); + assert!( + message.contains(&round_state.round_id), + "unexpected validation message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn persist_fault_after_rename_before_directory_sync_keeps_state_loadable_after_restart() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("persist_fault_after_rename"); + reset_for_tests(); + + let existing_request = RunDkgRequest { + session_id: "session-persist-fault-existing-after-rename".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + run_dkg(existing_request).expect("seed existing persisted session"); + + let renamed_request = RunDkgRequest { + session_id: "session-persist-fault-after-rename".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "03aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "03bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let err = run_dkg(renamed_request.clone()).expect_err("expected injected persist failure"); + clear_persist_fault_injection_for_tests(); + + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("injected persist fault at [after_rename_before_directory_sync]"), + "unexpected persist fault message: {message}" + ); + assert!( + !state_path + .with_extension(format!("tmp-{}", std::process::id())) + .exists(), + "persist temp state file should not remain after post-rename failure" + ); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + { + let guard = state().expect("engine state").lock().expect("engine lock"); + assert!(guard + .sessions + .contains_key("session-persist-fault-existing-after-rename")); + assert!(guard + .sessions + .contains_key("session-persist-fault-after-rename")); + } + + let retry_result = run_dkg(renamed_request).expect("retry request after reload"); + assert_eq!( + retry_result.session_id, + "session-persist-fault-after-rename" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn finalize_sign_round_rejects_when_consumed_request_registry_is_at_capacity() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("finalize_consumed_request_capacity"); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-finalize-consumed-request-capacity".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-finalize-consumed-request-capacity".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request).expect("start sign round"); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get_mut("session-finalize-consumed-request-capacity") + .expect("session state"); + + for idx in 0..TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION { + session + .consumed_finalize_request_fingerprints + .insert(format!("prefilled-fingerprint-{idx}")); + } + persist_engine_state_to_storage(&guard) + .expect("persist prefilled consumed finalize request fingerprints"); + } + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-finalize-consumed-request-capacity".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + let err = + finalize_sign_round(finalize_request, true).expect_err("expected capacity rejection"); + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("consumed_finalize_request_fingerprints registry size"), + "unexpected internal message: {message}" + ); + assert!( + message.contains("reached max"), + "unexpected internal message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn finalize_sign_round_rejects_when_consumed_request_registry_is_at_capacity_with_attempt_context( + ) { + let _guard = lock_test_state(); + let state_path = + configure_test_state_path("finalize_consumed_request_capacity_attempt_context"); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-finalize-consumed-request-capacity-attempt-context"; + let message_hex = "deadbeef"; + let run_dkg_request = RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let mut uppercase_attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + uppercase_attempt_context.included_participants_fingerprint = uppercase_attempt_context + .included_participants_fingerprint + .to_ascii_uppercase(); + uppercase_attempt_context.attempt_id = + uppercase_attempt_context.attempt_id.to_ascii_uppercase(); + + let start_request = StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(uppercase_attempt_context.clone()), + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request).expect("start sign round"); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.get_mut(session_id).expect("session state"); + + for idx in 0..TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION { + session + .consumed_finalize_request_fingerprints + .insert(format!("prefilled-fingerprint-{idx}")); + } + persist_engine_state_to_storage(&guard) + .expect("persist prefilled consumed finalize request fingerprints"); + } + + let finalize_request = FinalizeSignRoundRequest { + session_id: session_id.to_string(), + taproot_merkle_root_hex: None, + attempt_context: Some(uppercase_attempt_context), + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + let err = + finalize_sign_round(finalize_request, true).expect_err("expected capacity rejection"); + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("consumed_finalize_request_fingerprints registry size"), + "unexpected internal message: {message}" + ); + assert!( + message.contains("reached max"), + "unexpected internal message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn finalize_sign_round_rejects_when_consumed_round_registry_is_at_capacity() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("finalize_consumed_round_capacity"); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-finalize-consumed-round-capacity".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-finalize-consumed-round-capacity".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request).expect("start sign round"); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get_mut("session-finalize-consumed-round-capacity") + .expect("session state"); + + for idx in 0..TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION { + session + .consumed_finalize_round_ids + .insert(format!("prefilled-round-{idx}")); + } + persist_engine_state_to_storage(&guard) + .expect("persist prefilled consumed finalize round IDs"); + } + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-finalize-consumed-round-capacity".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + let err = + finalize_sign_round(finalize_request, true).expect_err("expected capacity rejection"); + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("consumed_finalize_round_ids registry size"), + "unexpected internal message: {message}" + ); + assert!( + message.contains("reached max"), + "unexpected internal message: {message}" + ); + + { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get("session-finalize-consumed-round-capacity") + .expect("session state"); + assert!(session.finalize_request_fingerprint.is_none()); + assert!(session.signature_result.is_none()); + } + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn finalize_sign_round_rejects_when_consumed_round_registry_is_at_capacity_with_attempt_context( + ) { + let _guard = lock_test_state(); + let state_path = + configure_test_state_path("finalize_consumed_round_capacity_attempt_context"); + reset_for_tests(); + let _roast_strict_mode = RoastStrictModeGuard::enable(); + + let session_id = "session-finalize-consumed-round-capacity-attempt-context"; + let message_hex = "deadbeef"; + let run_dkg_request = RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let attempt_context = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![2, 1]); + let start_request = StartSignRoundRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: message_hex.to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: Some(attempt_context.clone()), + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request).expect("start sign round"); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.get_mut(session_id).expect("session state"); + + for idx in 0..TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION { + session + .consumed_finalize_round_ids + .insert(format!("prefilled-round-{idx}")); + } + persist_engine_state_to_storage(&guard) + .expect("persist prefilled consumed finalize round IDs"); + } + + let finalize_request = FinalizeSignRoundRequest { + session_id: session_id.to_string(), + taproot_merkle_root_hex: None, + attempt_context: Some(attempt_context), + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + let err = + finalize_sign_round(finalize_request, true).expect_err("expected capacity rejection"); + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("consumed_finalize_round_ids registry size"), + "unexpected internal message: {message}" + ); + assert!( + message.contains("reached max"), + "unexpected internal message: {message}" + ); + + { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.get(session_id).expect("session state"); + assert!(session.finalize_request_fingerprint.is_none()); + assert!(session.signature_result.is_none()); + } + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn finalize_sign_round_rejects_consumed_request_fingerprint_when_round_state_missing() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("finalize_consumed_request_fingerprint"); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-finalize-consumed-request-fingerprint".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-finalize-consumed-request-fingerprint".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request).expect("start sign round"); + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-finalize-consumed-request-fingerprint".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + let mut canonical_contributions = finalize_request.round_contributions.clone(); + canonical_contributions.sort_unstable_by(|left, right| { + left.identifier + .cmp(&right.identifier) + .then_with(|| left.signature_share_hex.cmp(&right.signature_share_hex)) + }); + let expected_request_fingerprint = fingerprint(&FinalizeSignRoundRequest { + session_id: finalize_request.session_id.clone(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: canonical_contributions, + }) + .expect("finalize request fingerprint"); + + finalize_sign_round(finalize_request.clone(), true).expect("first finalize"); + reload_state_from_storage_for_tests(); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get_mut("session-finalize-consumed-request-fingerprint") + .expect("session state"); + assert!(session + .consumed_finalize_request_fingerprints + .contains(&expected_request_fingerprint)); + assert!(session.round_state.is_none()); + session.finalize_request_fingerprint = None; + session.signature_result = None; + persist_engine_state_to_storage(&guard) + .expect("persist tampered finalize request cache state"); + } + + reload_state_from_storage_for_tests(); + let err = finalize_sign_round(finalize_request, true) + .expect_err("expected consumed request fingerprint rejection"); + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("finalize request fingerprint"), + "unexpected validation message: {message}" + ); + assert!( + message.contains("already consumed"), + "unexpected validation message: {message}" + ); + assert!( + message.contains(&expected_request_fingerprint), + "unexpected validation message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn finalize_sign_round_replay_guard_survives_process_restart_with_finalize_cache_loss() { + let _guard = lock_test_state(); + let state_path = + configure_test_state_path("finalize_consumed_request_fingerprint_restart_replay"); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-finalize-consumed-request-fingerprint-restart".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-finalize-consumed-request-fingerprint-restart".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request).expect("start sign round"); + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-finalize-consumed-request-fingerprint-restart".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + let mut canonical_contributions = finalize_request.round_contributions.clone(); + canonical_contributions.sort_unstable_by(|left, right| { + left.identifier + .cmp(&right.identifier) + .then_with(|| left.signature_share_hex.cmp(&right.signature_share_hex)) + }); + let expected_request_fingerprint = fingerprint(&FinalizeSignRoundRequest { + session_id: finalize_request.session_id.clone(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: canonical_contributions, + }) + .expect("finalize request fingerprint"); + + finalize_sign_round(finalize_request.clone(), true).expect("first finalize"); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get_mut("session-finalize-consumed-request-fingerprint-restart") + .expect("session state"); + assert!(session + .consumed_finalize_request_fingerprints + .contains(&expected_request_fingerprint)); + assert!(session.round_state.is_none()); + session.finalize_request_fingerprint = None; + session.signature_result = None; + persist_engine_state_to_storage(&guard) + .expect("persist tampered finalize request cache state"); + } + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let err = finalize_sign_round(finalize_request, true) + .expect_err("expected consumed request fingerprint rejection"); + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("finalize request fingerprint"), + "unexpected validation message: {message}" + ); + assert!( + message.contains("already consumed"), + "unexpected validation message: {message}" + ); + assert!( + message.contains(&expected_request_fingerprint), + "unexpected validation message: {message}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn start_sign_round_accepts_reordered_participant_idempotent_retry() { + let _guard = lock_test_state(); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-start-round-reordered-idempotency".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + let first_request = StartSignRoundRequest { + session_id: "session-start-round-reordered-idempotency".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![3, 1, 2]), + attempt_context: None, + attempt_transition_evidence: None, + }; + let first_round_state = start_sign_round(first_request).expect("first start sign round"); + let consumed_round_ids_after_first = { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get("session-start-round-reordered-idempotency") + .expect("session state"); + session.consumed_sign_round_ids.clone() + }; + assert_eq!(consumed_round_ids_after_first.len(), 1); + assert!(consumed_round_ids_after_first.contains(&first_round_state.round_id)); + + let second_request = StartSignRoundRequest { + session_id: "session-start-round-reordered-idempotency".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![2, 3, 1]), + attempt_context: None, + attempt_transition_evidence: None, + }; + let second_round_state = + start_sign_round(second_request).expect("second start sign round retry"); + + assert_eq!(first_round_state, second_round_state); + let consumed_round_ids_after_second = { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get("session-start-round-reordered-idempotency") + .expect("session state"); + session.consumed_sign_round_ids.clone() + }; + assert_eq!( + consumed_round_ids_after_first, + consumed_round_ids_after_second + ); + } + + #[test] + fn start_sign_round_rejects_materially_different_retry_after_canonicalization() { + let _guard = lock_test_state(); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-start-round-canonicalization-conflict".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + let first_request = StartSignRoundRequest { + session_id: "session-start-round-canonicalization-conflict".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![3, 1, 2]), + attempt_context: None, + attempt_transition_evidence: None, + }; + start_sign_round(first_request).expect("first start sign round"); + + let second_request = StartSignRoundRequest { + session_id: "session-start-round-canonicalization-conflict".to_string(), + member_identifier: 1, + message_hex: "cafebabe".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![2, 3, 1]), + attempt_context: None, + attempt_transition_evidence: None, + }; + let err = start_sign_round(second_request).expect_err("expected session conflict"); + assert!(matches!(err, EngineError::SessionConflict { .. })); + } + + #[test] + fn finalize_sign_round_accepts_reordered_contribution_idempotent_retry() { + let _guard = lock_test_state(); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-finalize-reordered-idempotency".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + let start_request = StartSignRoundRequest { + session_id: "session-finalize-reordered-idempotency".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request).expect("start sign round"); + + let first_finalize_request = FinalizeSignRoundRequest { + session_id: "session-finalize-reordered-idempotency".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + + let second_finalize_request = FinalizeSignRoundRequest { + session_id: "session-finalize-reordered-idempotency".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + ], + }; + + let first_signature = + finalize_sign_round(first_finalize_request, true).expect("first finalize"); + let second_signature = + finalize_sign_round(second_finalize_request, true).expect("second finalize retry"); + + assert_eq!(first_signature, second_signature); + } + + #[test] + fn finalize_sign_round_rejects_materially_different_retry_after_canonicalization() { + let _guard = lock_test_state(); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-finalize-canonicalization-conflict".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + + let start_request = StartSignRoundRequest { + session_id: "session-finalize-canonicalization-conflict".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request).expect("start sign round"); + + let first_finalize_request = FinalizeSignRoundRequest { + session_id: "session-finalize-canonicalization-conflict".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + finalize_sign_round(first_finalize_request, true).expect("first finalize"); + + let second_finalize_request = FinalizeSignRoundRequest { + session_id: "session-finalize-canonicalization-conflict".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + RoundContribution { + identifier: 1, + signature_share_hex: format!( + "00{}", + bootstrap_synthetic_share_hex(&round_state, 1) + ), + }, + ], + }; + let err = + finalize_sign_round(second_finalize_request, true).expect_err("expected conflict"); + assert!(matches!(err, EngineError::SessionConflict { .. })); + } + + #[test] + fn refresh_epoch_counter_persists_across_storage_reload() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("refresh_epoch_counter"); + reset_for_tests(); + + let first_result = refresh_shares(RefreshSharesRequest { + session_id: "session-persisted-refresh-1".to_string(), + current_shares: vec![ShareMaterial { + identifier: 1, + encrypted_share_hex: "aaaa".to_string(), + }], + }) + .expect("first refresh"); + assert_eq!(first_result.refresh_epoch, 1); + + reload_state_from_storage_for_tests(); + + let second_result = refresh_shares(RefreshSharesRequest { + session_id: "session-persisted-refresh-2".to_string(), + current_shares: vec![ShareMaterial { + identifier: 1, + encrypted_share_hex: "bbbb".to_string(), + }], + }) + .expect("second refresh"); + assert_eq!(second_result.refresh_epoch, 2); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn state_lock_path_is_bound_and_rejects_in_process_path_switch() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("state_lock_path_binding"); + let alternate_state_path = std::env::temp_dir().join(format!( + "frost_tbtc_engine_state_state_lock_path_binding_alt_{}.json", + std::process::id() + )); + cleanup_test_state_artifacts(&alternate_state_path); + reset_for_tests(); + + refresh_shares(RefreshSharesRequest { + session_id: "session-lock-path-initial".to_string(), + current_shares: vec![ShareMaterial { + identifier: 1, + encrypted_share_hex: "aaaa".to_string(), + }], + }) + .expect("initial refresh"); + + std::env::set_var(TBTC_SIGNER_STATE_PATH_ENV, &alternate_state_path); + + let err = refresh_shares(RefreshSharesRequest { + session_id: "session-lock-path-switch".to_string(), + current_shares: vec![ShareMaterial { + identifier: 1, + encrypted_share_hex: "bbbb".to_string(), + }], + }) + .expect_err("expected path switch rejection"); + let EngineError::Internal(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("refusing to switch"), + "unexpected lock path switch error: {message}" + ); + + std::env::set_var(TBTC_SIGNER_STATE_PATH_ENV, &state_path); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + cleanup_test_state_artifacts(&alternate_state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn restart_reload_recovers_persisted_state_across_operation_types() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("restart_reload_integration"); + reset_for_tests(); + + let dkg_request = RunDkgRequest { + session_id: "session-restart-dkg".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let dkg_result = run_dkg(dkg_request.clone()).expect("run dkg"); + + let build_request = BuildTaprootTxRequest { + session_id: "session-restart-buildtx".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 0, + value_sats: 10_000, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 9_000, + }], + script_tree_hex: None, + }; + let build_result = build_taproot_tx(build_request.clone()).expect("build taproot tx"); + + let refresh_request = RefreshSharesRequest { + session_id: "session-restart-refresh".to_string(), + current_shares: vec![ShareMaterial { + identifier: 1, + encrypted_share_hex: "abba".to_string(), + }], + }; + let refresh_result = refresh_shares(refresh_request.clone()).expect("refresh shares"); + + let finalize_dkg_request = RunDkgRequest { + session_id: "session-restart-finalize".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "03aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "03bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let finalize_dkg_result = run_dkg(finalize_dkg_request).expect("run finalize dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-restart-finalize".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: finalize_dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request).expect("start sign round"); + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-restart-finalize".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + let finalize_result = + finalize_sign_round(finalize_request.clone(), true).expect("finalize sign round"); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + { + let guard = state().expect("engine state").lock().expect("engine lock"); + assert!(guard.sessions.contains_key("session-restart-dkg")); + assert!(guard.sessions.contains_key("session-restart-buildtx")); + assert!(guard.sessions.contains_key("session-restart-refresh")); + assert!(guard.sessions.contains_key("session-restart-finalize")); + } + + let dkg_retry_result = run_dkg(dkg_request).expect("retry run dkg"); + assert_eq!(dkg_result, dkg_retry_result); + + let build_retry_result = build_taproot_tx(build_request).expect("retry build taproot tx"); + assert_eq!(build_result, build_retry_result); + + let refresh_retry_result = refresh_shares(refresh_request).expect("retry refresh shares"); + assert_eq!(refresh_result, refresh_retry_result); + + let finalize_retry_result = + finalize_sign_round(finalize_request, true).expect("retry finalize sign round"); + assert_eq!(finalize_result, finalize_retry_result); + + let new_session_result = run_dkg(RunDkgRequest { + session_id: "session-restart-new".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "04aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "04bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("post-restart run dkg"); + assert!(!new_session_result.key_group.is_empty()); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + #[cfg(unix)] + fn state_lock_rejects_multi_process_contention() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("state_lock_multi_process_contention"); + let ready_path = std::env::temp_dir().join(format!( + "frost_tbtc_lock_ready_{}_{}.flag", + std::process::id(), + now_unix() + )); + let release_path = std::env::temp_dir().join(format!( + "frost_tbtc_lock_release_{}_{}.flag", + std::process::id(), + now_unix() + )); + let _ = std::fs::remove_file(&ready_path); + let _ = std::fs::remove_file(&release_path); + reset_for_tests(); + + if let Ok(mut lock_slot) = state_file_lock_slot().lock() { + *lock_slot = None; + } + + let child = Command::new(std::env::current_exe().expect("current test binary path")) + .arg("--exact") + .arg("engine::tests::state_file_lock_contention_helper") + .arg("--ignored") + .arg("--nocapture") + .env(TBTC_SIGNER_STATE_PATH_ENV, &state_path) + .env("TBTC_SIGNER_LOCK_HELPER", "1") + .env("TBTC_SIGNER_LOCK_READY_PATH", &ready_path) + .env("TBTC_SIGNER_LOCK_RELEASE_PATH", &release_path) + .spawn() + .expect("spawn lock holder helper process"); + let helper_guard = LockHelperProcessGuard::new(child, release_path.clone()); + + assert!( + wait_for_file(&ready_path, Duration::from_secs(10)), + "helper did not report lock acquisition" + ); + + let err = match ensure_state_file_lock() { + Ok(_) => panic!("expected lock contention error"), + Err(err) => err, + }; + expect_internal_error_contains(err, "signer state lock already held by another process"); + + helper_guard.wait_for_success(); + + let _ = std::fs::remove_file(&ready_path); + let _ = std::fs::remove_file(&release_path); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + #[cfg(unix)] + fn persisted_state_file_uses_owner_only_permissions() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("state_file_permissions"); + reset_for_tests(); + + refresh_shares(RefreshSharesRequest { + session_id: "session-state-file-permissions".to_string(), + current_shares: vec![ShareMaterial { + identifier: 1, + encrypted_share_hex: "aaaa".to_string(), + }], + }) + .expect("persist state via refresh"); + + let mode = std::fs::metadata(&state_path) + .expect("state file metadata") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o600, + "state file should be owner read/write only, got mode {mode:o}" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn build_taproot_tx_idempotency_persists_across_storage_reload() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("build_taproot_tx_idempotency"); + reset_for_tests(); + + let request = BuildTaprootTxRequest { + session_id: "session-build-tx".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 0, + value_sats: 10_000, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 9_000, + }], + script_tree_hex: None, + }; + + let first_result = build_taproot_tx(request.clone()).expect("first build tx"); + assert!(!first_result.tx_hex.is_empty()); + + reload_state_from_storage_for_tests(); + let second_result = build_taproot_tx(request).expect("persisted build tx retry"); + assert_eq!(first_result, second_result); + + let conflict_request = BuildTaprootTxRequest { + session_id: "session-build-tx".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 0, + value_sats: 10_000, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 8_000, + }], + script_tree_hex: None, + }; + + let err = build_taproot_tx(conflict_request).expect_err("expected build tx conflict"); + assert!(matches!(err, EngineError::SessionConflict { .. })); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn finalize_clears_signing_material_and_rejects_sign_round_restart() { + let _guard = lock_test_state(); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-finalize-clears-signing-material".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-finalize-clears-signing-material".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request.clone()).expect("start sign round"); + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-finalize-clears-signing-material".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + + let first_result = finalize_sign_round(finalize_request.clone(), true).expect("finalize"); + + { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get("session-finalize-clears-signing-material") + .expect("session state"); + + assert!(session.finalize_request_fingerprint.is_some()); + assert!(session.signature_result.is_some()); + assert!(session.dkg_key_packages.is_none()); + assert!(session.dkg_public_key_package.is_none()); + assert!(session.sign_request_fingerprint.is_none()); + assert!(session.sign_message_bytes.is_none()); + assert!(session.round_state.is_none()); + } + + let second_result = + finalize_sign_round(finalize_request, true).expect("finalize idempotent retry"); + assert_eq!(first_result, second_result); + + let err = start_sign_round(start_request).expect_err("start sign round should fail"); + assert!(matches!(err, EngineError::SessionFinalized { .. })); + } + + #[test] + fn finalize_purge_persists_across_storage_reload() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("finalize_purge_persist_reload"); + reset_for_tests(); + + let run_dkg_request = RunDkgRequest { + session_id: "session-finalize-purge-persist-reload".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let dkg_result = run_dkg(run_dkg_request).expect("run dkg"); + let start_request = StartSignRoundRequest { + session_id: "session-finalize-purge-persist-reload".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let round_state = start_sign_round(start_request.clone()).expect("start sign round"); + + let finalize_request = FinalizeSignRoundRequest { + session_id: "session-finalize-purge-persist-reload".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + + let first_result = finalize_sign_round(finalize_request.clone(), true).expect("finalize"); + + reload_state_from_storage_for_tests(); + { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get("session-finalize-purge-persist-reload") + .expect("session state"); + + assert!(session.finalize_request_fingerprint.is_some()); + assert!(session.signature_result.is_some()); + assert!(session.dkg_key_packages.is_none()); + assert!(session.dkg_public_key_package.is_none()); + assert!(session.sign_request_fingerprint.is_none()); + assert!(session.sign_message_bytes.is_none()); + assert!(session.round_state.is_none()); + } + + let second_result = + finalize_sign_round(finalize_request, true).expect("persisted finalize retry"); + assert_eq!(first_result, second_result); + + let err = start_sign_round(start_request).expect_err("start sign round should fail"); + assert!(matches!(err, EngineError::SessionFinalized { .. })); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn corrupt_state_file_fails_closed_by_default() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("corrupt_state_fail_closed"); + reset_for_tests(); + + std::fs::write(&state_path, b"{invalid-state").expect("write corrupt state file"); + + let err = match load_engine_state_from_storage() { + Ok(_) => panic!("expected corruption failure"), + Err(err) => err, + }; + assert!(matches!(err, EngineError::Internal(_))); + + let err_message = err.to_string(); + assert!(err_message.contains("refusing to continue with corrupted signer state file")); + assert!(err_message.contains(TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV)); + assert!(state_path.exists()); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn truncated_state_file_fails_closed_by_default() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("truncated_state_fail_closed"); + reset_for_tests(); + + run_dkg(RunDkgRequest { + session_id: "session-truncated-state-fail-closed".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("seed persisted state"); + + let persisted_bytes = std::fs::read(&state_path).expect("read persisted state file"); + assert!( + persisted_bytes.len() > 1, + "persisted state should be larger than one byte" + ); + std::fs::write(&state_path, &persisted_bytes[..persisted_bytes.len() - 1]) + .expect("write truncated state file"); + + let err = match load_engine_state_from_storage() { + Ok(_) => panic!("expected corruption failure"), + Err(err) => err, + }; + assert!(matches!(err, EngineError::Internal(_))); + + let err_message = err.to_string(); + assert!(err_message.contains("refusing to continue with corrupted signer state file")); + assert!(err_message.contains(TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV)); + assert!(state_path.exists()); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn corrupt_state_file_quarantines_and_resets_when_enabled() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("corrupt_state_quarantine_reset"); + reset_for_tests(); + + std::env::set_var( + TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV, + TBTC_SIGNER_STATE_CORRUPTION_POLICY_QUARANTINE_AND_RESET, + ); + std::fs::write(&state_path, b"{invalid-state").expect("write corrupt state file"); + + let loaded = load_engine_state_from_storage().expect("recover from corrupted state file"); + assert!(loaded.sessions.is_empty()); + assert_eq!(loaded.refresh_epoch_counter, 0); + assert!(!state_path.exists()); + + let backups = + sorted_corrupted_state_backups(&state_path).expect("list corrupted state backups"); + assert_eq!(backups.len(), 1); + let backup_contents = std::fs::read(&backups[0]).expect("read backup file contents"); + assert_eq!(backup_contents, b"{invalid-state"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn truncated_state_file_quarantines_and_resets_when_enabled() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("truncated_state_quarantine_reset"); + reset_for_tests(); + + std::env::set_var( + TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV, + TBTC_SIGNER_STATE_CORRUPTION_POLICY_QUARANTINE_AND_RESET, + ); + + run_dkg(RunDkgRequest { + session_id: "session-truncated-state-quarantine-reset".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("seed persisted state"); + + let persisted_bytes = std::fs::read(&state_path).expect("read persisted state file"); + assert!( + persisted_bytes.len() > 1, + "persisted state should be larger than one byte" + ); + let truncated_bytes = persisted_bytes[..persisted_bytes.len() - 1].to_vec(); + std::fs::write(&state_path, &truncated_bytes).expect("write truncated state file"); + + let loaded = load_engine_state_from_storage().expect("recover from truncated state file"); + assert!(loaded.sessions.is_empty()); + assert_eq!(loaded.refresh_epoch_counter, 0); + assert!(!state_path.exists()); + + let backups = + sorted_corrupted_state_backups(&state_path).expect("list corrupted state backups"); + assert_eq!(backups.len(), 1); + let backup_contents = std::fs::read(&backups[0]).expect("read backup file contents"); + assert_eq!(backup_contents, truncated_bytes); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn schema_mismatch_state_file_fails_closed_by_default() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("schema_mismatch_fail_closed"); + reset_for_tests(); + + let unsupported_schema_version = if PERSISTED_STATE_SCHEMA_VERSION == u16::MAX { + 0 + } else { + PERSISTED_STATE_SCHEMA_VERSION + 1 + }; + let persisted = PersistedEngineState { + schema_version: unsupported_schema_version, + sessions: HashMap::new(), + refresh_epoch_counter: 0, + operator_fault_scores: BTreeMap::new(), + quarantined_operator_identifiers: vec![], + canary_rollout: CanaryRolloutState::default(), + }; + let persisted_bytes = serde_json::to_vec(&persisted).expect("encode mismatched schema"); + std::fs::write(&state_path, &persisted_bytes).expect("write mismatched schema state file"); + + let err = match load_engine_state_from_storage() { + Ok(_) => panic!("expected schema mismatch failure"), + Err(err) => err, + }; + assert!(matches!(err, EngineError::Internal(_))); + + let err_message = err.to_string(); + assert!(err_message.contains("failed to validate signer state file")); + assert!(err_message.contains("unsupported signer state schema version")); + assert!(err_message.contains(TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV)); + assert!(state_path.exists()); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn schema_mismatch_state_file_quarantines_and_resets_when_enabled() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("schema_mismatch_quarantine_reset"); + reset_for_tests(); + + std::env::set_var( + TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV, + TBTC_SIGNER_STATE_CORRUPTION_POLICY_QUARANTINE_AND_RESET, + ); + + let unsupported_schema_version = if PERSISTED_STATE_SCHEMA_VERSION == u16::MAX { + 0 + } else { + PERSISTED_STATE_SCHEMA_VERSION + 1 + }; + let persisted = PersistedEngineState { + schema_version: unsupported_schema_version, + sessions: HashMap::new(), + refresh_epoch_counter: 0, + operator_fault_scores: BTreeMap::new(), + quarantined_operator_identifiers: vec![], + canary_rollout: CanaryRolloutState::default(), + }; + let persisted_bytes = serde_json::to_vec(&persisted).expect("encode mismatched schema"); + std::fs::write(&state_path, &persisted_bytes).expect("write mismatched schema state file"); + + let loaded = load_engine_state_from_storage().expect("recover from schema mismatch state"); + assert!(loaded.sessions.is_empty()); + assert_eq!(loaded.refresh_epoch_counter, 0); + assert!(!state_path.exists()); + + let backups = + sorted_corrupted_state_backups(&state_path).expect("list corrupted state backups"); + assert_eq!(backups.len(), 1); + let backup_contents = std::fs::read(&backups[0]).expect("read backup file contents"); + assert_eq!(backup_contents, persisted_bytes); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn corrupt_state_backup_retention_evicts_old_backups() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("corrupt_state_retention"); + reset_for_tests(); + + std::env::set_var( + TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV, + TBTC_SIGNER_STATE_CORRUPTION_POLICY_QUARANTINE_AND_RESET, + ); + std::env::set_var(TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV, "2"); + + for seed in 0..4 { + std::fs::write(&state_path, format!("{{invalid-state-{seed}")) + .expect("write corrupt state"); + let loaded = + load_engine_state_from_storage().expect("recover from corrupt state iteration"); + assert!(loaded.sessions.is_empty()); + } + + let backups = + sorted_corrupted_state_backups(&state_path).expect("list corrupted state backups"); + assert_eq!(backups.len(), 2); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn persisted_state_is_encrypted_envelope() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("encrypted_envelope_persist"); + reset_for_tests(); + + run_dkg(RunDkgRequest { + session_id: "session-encrypted-envelope".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("seed persisted encrypted state"); + + let persisted_bytes = std::fs::read(&state_path).expect("read persisted state file"); + let envelope: PersistedEncryptedEngineStateEnvelope = + serde_json::from_slice(&persisted_bytes).expect("decode encrypted envelope"); + assert_eq!( + envelope.schema_version, + PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION + ); + assert_eq!( + envelope.encryption_algorithm, + TBTC_SIGNER_STATE_ENCRYPTION_ALGORITHM_XCHACHA20POLY1305 + ); + assert_eq!( + envelope.key_provider, + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT + ); + assert!(envelope.key_id.starts_with("sha256:")); + assert_eq!( + envelope.authentication_tag.len(), + TBTC_SIGNER_STATE_ENVELOPE_AUTH_TAG_BYTES * 2 + ); + assert!(!envelope.ciphertext.is_empty()); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn legacy_plaintext_state_migrates_to_encrypted_envelope_on_load() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("legacy_plaintext_migration"); + reset_for_tests(); + + let mut sessions = HashMap::new(); + sessions.insert( + "legacy-session".to_string(), + persisted_session_state_fixture(), + ); + let plaintext_state = PersistedEngineState { + schema_version: PERSISTED_STATE_SCHEMA_VERSION, + sessions, + refresh_epoch_counter: 7, + operator_fault_scores: BTreeMap::new(), + quarantined_operator_identifiers: vec![], + canary_rollout: CanaryRolloutState::default(), + }; + let plaintext_bytes = serde_json::to_vec(&plaintext_state).expect("encode plaintext state"); + std::fs::write(&state_path, &plaintext_bytes).expect("write plaintext state file"); + + let loaded = load_engine_state_from_storage().expect("load and migrate legacy plaintext"); + assert_eq!(loaded.sessions.len(), 1); + assert_eq!(loaded.refresh_epoch_counter, 7); + + let migrated_bytes = std::fs::read(&state_path).expect("read migrated state file"); + let envelope: PersistedEncryptedEngineStateEnvelope = + serde_json::from_slice(&migrated_bytes).expect("decode migrated encrypted envelope"); + assert_eq!( + envelope.schema_version, + PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION + ); + assert!(!envelope.ciphertext.is_empty()); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn encrypted_state_load_fails_closed_when_key_missing() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("encrypted_state_missing_key"); + reset_for_tests(); + + run_dkg(RunDkgRequest { + session_id: "session-encrypted-state-missing-key".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("seed encrypted state file"); + + std::env::remove_var(TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV); + let err = match load_engine_state_from_storage() { + Ok(_) => panic!("expected encrypted state load failure"), + Err(err) => err, + }; + let err_message = err.to_string(); + assert!(err_message.contains("missing required state encryption key env")); + assert!(err_message.contains(TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV)); + assert!(state_path.exists()); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn encrypted_state_load_rejects_tampered_legacy_key_id_format() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("encrypted_state_legacy_key_id"); + reset_for_tests(); + + let session_id = "session-encrypted-state-legacy-key-id"; + run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("seed encrypted state file"); + + let persisted_bytes = std::fs::read(&state_path).expect("read persisted state file"); + let mut envelope: PersistedEncryptedEngineStateEnvelope = + serde_json::from_slice(&persisted_bytes).expect("decode encrypted envelope"); + envelope.key_id = TBTC_SIGNER_STATE_KEY_ID_LEGACY_ENV_HEX.to_string(); + let mutated_bytes = serde_json::to_vec(&envelope).expect("encode legacy key_id envelope"); + std::fs::write(&state_path, mutated_bytes).expect("write legacy key_id envelope"); + + let err = match load_engine_state_from_storage() { + Ok(_) => panic!("tampered legacy key_id envelope should fail closed"), + Err(err) => err, + }; + expect_internal_error_contains(err, "state key identifier mismatch"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn legacy_v2_encrypted_state_rewrites_with_current_key_id() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("encrypted_state_v2_legacy_key_id"); + reset_for_tests(); + + let persisted_state = PersistedEngineState { + schema_version: PERSISTED_STATE_SCHEMA_VERSION, + sessions: HashMap::new(), + refresh_epoch_counter: 11, + operator_fault_scores: BTreeMap::new(), + quarantined_operator_identifiers: vec![], + canary_rollout: CanaryRolloutState::default(), + }; + let mut plaintext = + serde_json::to_vec(&persisted_state).expect("encode persisted state fixture"); + let key_material = state_encryption_key_material().expect("load test state key"); + let cipher = XChaCha20Poly1305::new_from_slice(&key_material.key[..]) + .expect("initialize test cipher"); + let nonce_bytes = [7u8; TBTC_SIGNER_STATE_ENVELOPE_NONCE_BYTES]; + let nonce = XNonce::from_slice(&nonce_bytes); + let mut ciphertext_and_tag = cipher + .encrypt(nonce, plaintext.as_ref()) + .expect("encrypt legacy v2 envelope fixture"); + plaintext.zeroize(); + let mut authentication_tag = ciphertext_and_tag + .split_off(ciphertext_and_tag.len() - TBTC_SIGNER_STATE_ENVELOPE_AUTH_TAG_BYTES); + let envelope = PersistedEncryptedEngineStateEnvelope { + schema_version: PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION_V2, + encryption_algorithm: TBTC_SIGNER_STATE_ENCRYPTION_ALGORITHM_XCHACHA20POLY1305 + .to_string(), + key_provider: TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT.to_string(), + key_id: TBTC_SIGNER_STATE_KEY_ID_LEGACY_ENV_HEX.to_string(), + nonce: hex::encode(nonce_bytes), + ciphertext: hex::encode(&ciphertext_and_tag), + authentication_tag: hex::encode(&authentication_tag), + }; + ciphertext_and_tag.zeroize(); + authentication_tag.zeroize(); + std::fs::write( + &state_path, + serde_json::to_vec(&envelope).expect("encode legacy v2 envelope"), + ) + .expect("write legacy v2 envelope"); + + let loaded = load_engine_state_from_storage().expect("load legacy v2 envelope"); + assert_eq!(loaded.refresh_epoch_counter, 11); + + let rewritten_bytes = std::fs::read(&state_path).expect("read rewritten envelope"); + let rewritten: PersistedEncryptedEngineStateEnvelope = + serde_json::from_slice(&rewritten_bytes).expect("decode rewritten envelope"); + assert_eq!( + rewritten.schema_version, + PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION + ); + assert!(rewritten.key_id.starts_with("sha256:")); + assert_ne!(rewritten.key_id, TBTC_SIGNER_STATE_KEY_ID_LEGACY_ENV_HEX); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn env_key_provider_is_rejected_in_production_profile() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("production_rejects_env_provider"); + reset_for_tests(); + + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION); + configure_valid_provenance_attestation_for_tests(); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT, + ); + + let err = mutate_state_for_key_provider_test("session-production-rejects-env-provider") + .expect_err("production profile should reject env provider"); + expect_internal_error_contains(err, "is not allowed in profile [production]"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn production_profile_rejects_implicit_temp_state_path() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::remove_var(TBTC_SIGNER_STATE_PATH_ENV); + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION); + configure_valid_provenance_attestation_for_tests(); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND, + ); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_COMMAND_ENV, + format!("printf '{}\\n'", TEST_STATE_ENCRYPTION_KEY_HEX), + ); + + let err = + mutate_state_for_key_provider_test("session-production-rejects-implicit-state-path") + .expect_err("production profile should reject implicit state path"); + expect_internal_error_contains( + err, + "refusing to use the implicit temp-dir signer state path", + ); + + reset_for_tests(); + clear_state_storage_policy_overrides(); + } + + #[test] + fn unknown_state_key_provider_is_rejected() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("unknown_state_key_provider"); + reset_for_tests(); + + std::env::set_var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, "hsm"); + + let err = mutate_state_for_key_provider_test("session-unknown-state-key-provider") + .expect_err("unsupported state key provider should fail closed"); + expect_internal_error_contains(err, "unsupported state key provider"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn command_key_provider_rejects_non_zero_exit() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("production_command_provider_non_zero_exit"); + reset_for_tests(); + + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION); + configure_valid_provenance_attestation_for_tests(); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND, + ); + std::env::set_var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV, "exit 17"); + + let err = + mutate_state_for_key_provider_test("session-production-command-provider-non-zero-exit") + .expect_err("non-zero command exit should fail closed"); + expect_internal_error_contains(err, "exited with non-zero status"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn command_key_provider_rejects_bad_output() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("production_command_provider_bad_output"); + reset_for_tests(); + + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION); + configure_valid_provenance_attestation_for_tests(); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND, + ); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_COMMAND_ENV, + "printf 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\\n'", + ); + + let err = + mutate_state_for_key_provider_test("session-production-command-provider-bad-output") + .expect_err("bad command output should fail closed"); + expect_internal_error_contains(err, "must be valid hex"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn command_key_provider_drains_large_stderr_without_deadlock() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("production_command_provider_large_stderr"); + reset_for_tests(); + + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION); + configure_valid_provenance_attestation_for_tests(); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND, + ); + std::env::set_var(TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV, "2"); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_COMMAND_ENV, + format!( + "dd if=/dev/zero bs=70000 count=1 1>&2 2>/dev/null; printf '{}\\n'", + TEST_STATE_ENCRYPTION_KEY_HEX + ), + ); + + mutate_state_for_key_provider_test("session-production-command-provider-large-stderr") + .expect("large stderr from state key command should not deadlock"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn encrypted_state_load_rejects_mismatched_key_id() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("encrypted_state_mismatched_key_id"); + reset_for_tests(); + + run_dkg(RunDkgRequest { + session_id: "session-encrypted-state-mismatched-key-id".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("seed encrypted state file"); + + std::env::set_var( + TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + let err = match load_engine_state_from_storage() { + Ok(_) => panic!("expected key_id mismatch rejection"), + Err(err) => err, + }; + expect_internal_error_contains(err, "state key identifier mismatch"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn command_key_provider_times_out_fail_closed() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("production_command_provider_timeout"); + reset_for_tests(); + + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION); + configure_valid_provenance_attestation_for_tests(); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND, + ); + std::env::set_var(TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV, "1"); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_COMMAND_ENV, + format!("sleep 2; printf '{}\\n'", TEST_STATE_ENCRYPTION_KEY_HEX), + ); + + let err = mutate_state_for_key_provider_test("session-production-command-provider-timeout") + .expect_err("state key command timeout should fail closed"); + expect_internal_error_contains(err, "timed out"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + #[cfg(unix)] + fn command_key_provider_times_out_when_background_descendant_keeps_pipe_open() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("production_command_provider_background_pipe"); + reset_for_tests(); + + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION); + configure_valid_provenance_attestation_for_tests(); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND, + ); + std::env::set_var(TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV, "1"); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_COMMAND_ENV, + format!("sleep 5 & printf '{}\\n'", TEST_STATE_ENCRYPTION_KEY_HEX), + ); + + let started_at = Instant::now(); + let err = mutate_state_for_key_provider_test( + "session-production-command-provider-background-pipe", + ) + .expect_err("state key command pipe timeout should fail closed"); + assert!( + started_at.elapsed() < Duration::from_secs(4), + "state key command should not wait for background descendant pipe EOF" + ); + expect_internal_error_contains(err, "timed out"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn command_key_provider_survives_restart_with_stable_key() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("production_command_provider"); + reset_for_tests(); + + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION); + configure_valid_provenance_attestation_for_tests(); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND, + ); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_COMMAND_ENV, + format!("printf '{}\\n'", TEST_STATE_ENCRYPTION_KEY_HEX), + ); + + mutate_state_for_key_provider_test("session-production-command-provider") + .expect("seed encrypted state with command provider"); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + { + let state = state().expect("engine state should initialize"); + let guard = state.lock().expect("engine lock"); + assert!(guard + .sessions + .contains_key("session-production-command-provider")); + } + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } +} diff --git a/pkg/tbtc/signer/src/errors.rs b/pkg/tbtc/signer/src/errors.rs new file mode 100644 index 0000000000..dd2d6d9999 --- /dev/null +++ b/pkg/tbtc/signer/src/errors.rs @@ -0,0 +1,200 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum EngineError { + #[error("validation failed: {0}")] + Validation(String), + #[error("provenance gate rejected: {reason_code}: {detail}")] + ProvenanceGateRejected { reason_code: String, detail: String }, + #[error("admission policy rejected for session {session_id}: {reason_code}: {detail}")] + AdmissionPolicyRejected { + session_id: String, + reason_code: String, + detail: String, + }, + #[error("signing policy rejected for session {session_id}: {reason_code}: {detail}")] + SigningPolicyRejected { + session_id: String, + reason_code: String, + detail: String, + }, + #[error("quarantine policy rejected for session {session_id}: {reason_code}: {detail}")] + QuarantinePolicyRejected { + session_id: String, + reason_code: String, + detail: String, + }, + #[error("lifecycle policy rejected for session {session_id}: {reason_code}: {detail}")] + LifecyclePolicyRejected { + session_id: String, + reason_code: String, + detail: String, + }, + #[error( + "synthetic contributions rejected for session {session_id}: bootstrap-only finalize payload is not allowed" + )] + SyntheticContributionRejected { session_id: String }, + #[error("session conflict for {session_id}: repeated call must use identical payload")] + SessionConflict { session_id: String }, + #[error("session finalized for {session_id}: start_sign_round requires a new session_id")] + SessionFinalized { session_id: String }, + #[error("session not found: {session_id}")] + SessionNotFound { session_id: String }, + #[error("DKG must be completed before signing session {session_id}")] + DkgNotReady { session_id: String }, + #[error("sign round not started for session {session_id}")] + SignRoundNotStarted { session_id: String }, + /// Returned when an `attempt_id` that has already been consumed for a sign + /// attempt in this session arrives again. Distinct from the generic + /// `Validation` error so cross-language callers can match on the + /// `consumed_attempt_replay` code instead of substring-matching the + /// message wording. + #[error( + "attempt_id [{attempt_id}] already consumed for sign attempt in session [{session_id}]" + )] + ConsumedAttemptReplay { + session_id: String, + attempt_id: String, + }, + /// Returned when a derived `round_id` (a function of session, key group, + /// message digest, signing-participants fingerprint, and attempt context) + /// has already been consumed for a sign contribution. Distinct from + /// `ConsumedAttemptReplay` because a single attempt context can produce + /// multiple round IDs through canonicalization disagreements; callers + /// match on `consumed_round_replay` rather than the message. + #[error( + "round_id [{round_id}] already consumed for sign contribution in session [{session_id}]" + )] + ConsumedRoundReplay { + session_id: String, + round_id: String, + }, + #[error("internal error: {0}")] + Internal(String), +} + +impl EngineError { + pub fn code(&self) -> &'static str { + match self { + Self::Validation(_) => "validation_error", + Self::ProvenanceGateRejected { .. } => "provenance_gate_rejected", + Self::AdmissionPolicyRejected { .. } => "admission_policy_rejected", + Self::SigningPolicyRejected { .. } => "signing_policy_rejected", + Self::QuarantinePolicyRejected { .. } => "quarantine_policy_rejected", + Self::LifecyclePolicyRejected { .. } => "lifecycle_policy_rejected", + Self::SyntheticContributionRejected { .. } => "synthetic_contribution_rejected", + Self::SessionConflict { .. } => "session_conflict", + Self::SessionFinalized { .. } => "session_finalized", + Self::SessionNotFound { .. } => "session_not_found", + Self::DkgNotReady { .. } => "dkg_not_ready", + Self::SignRoundNotStarted { .. } => "sign_round_not_started", + Self::ConsumedAttemptReplay { .. } => "consumed_attempt_replay", + Self::ConsumedRoundReplay { .. } => "consumed_round_replay", + Self::Internal(_) => "internal_error", + } + } + + pub fn recovery_class(&self) -> &'static str { + match self { + Self::Validation(_) => "recoverable", + Self::ProvenanceGateRejected { .. } => "terminal", + Self::AdmissionPolicyRejected { .. } => "recoverable", + Self::SigningPolicyRejected { .. } => "recoverable", + Self::QuarantinePolicyRejected { .. } => "recoverable", + Self::LifecyclePolicyRejected { .. } => "recoverable", + Self::SyntheticContributionRejected { .. } => "recoverable", + Self::SessionConflict { .. } => "recoverable", + Self::DkgNotReady { .. } => "recoverable", + Self::SignRoundNotStarted { .. } => "recoverable", + // ConsumedAttemptReplay / ConsumedRoundReplay are recoverable in + // the sense that a fresh attempt with a new identifier can be + // started. They cannot be retried with the same identifier — the + // consumer (keep-core) treats them as a signal to mint a new + // attempt_id rather than retransmit. + Self::ConsumedAttemptReplay { .. } => "recoverable", + Self::ConsumedRoundReplay { .. } => "recoverable", + Self::SessionFinalized { .. } => "terminal", + Self::SessionNotFound { .. } => "terminal", + Self::Internal(_) => "terminal", + } + } +} + +#[cfg(test)] +mod tests { + use super::EngineError; + + #[test] + fn consumed_attempt_replay_has_stable_code_and_message_format() { + let err = EngineError::ConsumedAttemptReplay { + session_id: "session-a".to_string(), + attempt_id: "attempt-1".to_string(), + }; + assert_eq!(err.code(), "consumed_attempt_replay"); + assert_eq!(err.recovery_class(), "recoverable"); + // Wire wording must remain stable across releases so legacy keep-core + // builds that substring-match the message keep working until they + // migrate to the code field. + assert_eq!( + err.to_string(), + "attempt_id [attempt-1] already consumed for sign attempt in session [session-a]", + ); + } + + #[test] + fn consumed_round_replay_has_stable_code_and_message_format() { + let err = EngineError::ConsumedRoundReplay { + session_id: "session-a".to_string(), + round_id: "round-1".to_string(), + }; + assert_eq!(err.code(), "consumed_round_replay"); + assert_eq!(err.recovery_class(), "recoverable"); + assert_eq!( + err.to_string(), + "round_id [round-1] already consumed for sign contribution in session [session-a]", + ); + } + + #[test] + fn recovery_class_maps_retryable_and_terminal_errors() { + assert_eq!( + EngineError::Validation("bad request".to_string()).recovery_class(), + "recoverable" + ); + assert_eq!( + EngineError::SessionConflict { + session_id: "session-a".to_string(), + } + .recovery_class(), + "recoverable" + ); + assert_eq!( + EngineError::ProvenanceGateRejected { + reason_code: "missing_attestation_status".to_string(), + detail: "missing env".to_string(), + } + .recovery_class(), + "terminal" + ); + assert_eq!( + EngineError::AdmissionPolicyRejected { + session_id: "session-a".to_string(), + reason_code: "required_identifier_missing".to_string(), + detail: "detail".to_string(), + } + .recovery_class(), + "recoverable" + ); + assert_eq!( + EngineError::SessionFinalized { + session_id: "session-a".to_string(), + } + .recovery_class(), + "terminal" + ); + assert_eq!( + EngineError::Internal("panic".to_string()).recovery_class(), + "terminal" + ); + } +} diff --git a/pkg/tbtc/signer/src/ffi.rs b/pkg/tbtc/signer/src/ffi.rs new file mode 100644 index 0000000000..e52a2e5723 --- /dev/null +++ b/pkg/tbtc/signer/src/ffi.rs @@ -0,0 +1,187 @@ +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use serde::de::DeserializeOwned; + +use crate::api::ErrorResponse; +use crate::errors::EngineError; + +#[repr(C)] +pub struct TbtcBuffer { + pub ptr: *mut u8, + pub len: usize, +} + +#[repr(C)] +pub struct TbtcSignerResult { + pub status_code: i32, + pub buffer: TbtcBuffer, +} + +const STATUS_OK: i32 = 0; +const STATUS_ERROR: i32 = 1; +const MAX_REQUEST_BYTES: usize = 16 * 1024 * 1024; + +pub fn success_from_serialized(payload: Vec) -> TbtcSignerResult { + TbtcSignerResult { + status_code: STATUS_OK, + buffer: to_ffi_buffer(payload), + } +} + +pub fn success_from_string(message: String) -> TbtcSignerResult { + success_from_serialized(message.into_bytes()) +} + +pub fn parse_request(ptr: *const u8, len: usize) -> Result { + let bytes = request_bytes(ptr, len)?; + serde_json::from_slice(bytes) + .map_err(|e| EngineError::Validation(format!("invalid JSON request payload: {e}"))) +} + +pub fn serialize_response(response: &T) -> Result, EngineError> { + serde_json::to_vec(response) + .map_err(|e| EngineError::Internal(format!("failed to encode response: {e}"))) +} + +pub fn ffi_entry(f: F) -> TbtcSignerResult +where + F: FnOnce() -> Result, EngineError>, +{ + match catch_unwind(AssertUnwindSafe(f)) { + Ok(Ok(bytes)) => success_from_serialized(bytes), + Ok(Err(err)) => error_result(err), + Err(payload) => error_result(EngineError::Internal(format!( + "panic crossed FFI boundary: {}", + panic_payload_message(payload) + ))), + } +} + +pub fn free_buffer(ptr: *mut u8, len: usize) { + if ptr.is_null() || len == 0 { + return; + } + + unsafe { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len))); + } +} + +fn error_result(error: EngineError) -> TbtcSignerResult { + let payload = ErrorResponse { + code: error.code().to_string(), + message: error.to_string(), + recovery_class: error.recovery_class().to_string(), + }; + + let bytes = serde_json::to_vec(&payload).unwrap_or_else(|_| { + b"{\"code\":\"internal_error\",\"message\":\"failed to encode error\",\"recovery_class\":\"terminal\"}".to_vec() + }); + + TbtcSignerResult { + status_code: STATUS_ERROR, + buffer: to_ffi_buffer(bytes), + } +} + +fn panic_payload_message(payload: Box) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + return (*message).to_string(); + } + if let Some(message) = payload.downcast_ref::() { + return message.clone(); + } + + "non-string panic payload".to_string() +} + +fn request_bytes<'a>(ptr: *const u8, len: usize) -> Result<&'a [u8], EngineError> { + if len > MAX_REQUEST_BYTES { + return Err(EngineError::Validation(format!( + "request buffer length [{}] exceeds maximum [{}]", + len, MAX_REQUEST_BYTES + ))); + } + + if ptr.is_null() { + return Err(EngineError::Validation( + "request buffer pointer must be non-null".to_string(), + )); + } + + unsafe { Ok(std::slice::from_raw_parts(ptr, len)) } +} + +fn to_ffi_buffer(bytes: Vec) -> TbtcBuffer { + let len = bytes.len(); + if len == 0 { + return TbtcBuffer { + ptr: std::ptr::null_mut(), + len: 0, + }; + } + + let boxed = bytes.into_boxed_slice(); + let ptr = Box::into_raw(boxed) as *mut u8; + + TbtcBuffer { ptr, len } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ffi_buffer_free_handles_vec_capacity_greater_than_len() { + let mut payload = Vec::with_capacity(1024); + payload.extend_from_slice(b"ok"); + assert!(payload.capacity() > payload.len()); + + let result = success_from_serialized(payload); + assert_eq!(result.status_code, STATUS_OK); + assert_eq!(result.buffer.len, 2); + + let bytes = unsafe { std::slice::from_raw_parts(result.buffer.ptr, result.buffer.len) }; + assert_eq!(bytes, b"ok"); + + free_buffer(result.buffer.ptr, result.buffer.len); + } + + #[test] + fn request_bytes_rejects_payloads_above_max_without_dereferencing() { + let err = request_bytes( + std::ptr::NonNull::::dangling().as_ptr(), + MAX_REQUEST_BYTES + 1, + ) + .expect_err("oversized request should be rejected"); + + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("exceeds maximum"), + "unexpected validation message: {message}" + ); + } + + #[test] + fn ffi_entry_preserves_string_panic_payload() { + let result = ffi_entry(|| -> Result, EngineError> { + panic!("TBTC_SIGNER_PROFILE must be production or development"); + }); + assert_eq!(result.status_code, STATUS_ERROR); + + let bytes = unsafe { std::slice::from_raw_parts(result.buffer.ptr, result.buffer.len) }; + let response: ErrorResponse = serde_json::from_slice(bytes).expect("decode error response"); + assert_eq!(response.code, "internal_error"); + assert!( + response + .message + .contains("TBTC_SIGNER_PROFILE must be production or development"), + "panic payload was not preserved: {}", + response.message + ); + + free_buffer(result.buffer.ptr, result.buffer.len); + } +} diff --git a/pkg/tbtc/signer/src/go_math_rand.rs b/pkg/tbtc/signer/src/go_math_rand.rs new file mode 100644 index 0000000000..012a481cda --- /dev/null +++ b/pkg/tbtc/signer/src/go_math_rand.rs @@ -0,0 +1,821 @@ +const RNG_LEN: usize = 607; +const RNG_TAP: usize = 273; +const INT32_MAX: i64 = (1_i64 << 31) - 1; +const RNG_MASK: u64 = (1_u64 << 63) - 1; + +const RNG_COOKED: [i64; RNG_LEN] = [ + -4181792142133755926, + -4576982950128230565, + 1395769623340756751, + 5333664234075297259, + -6347679516498800754, + 9033628115061424579, + 7143218595135194537, + 4812947590706362721, + 7937252194349799378, + 5307299880338848416, + 8209348851763925077, + -7107630437535961764, + 4593015457530856296, + 8140875735541888011, + -5903942795589686782, + -603556388664454774, + -7496297993371156308, + 113108499721038619, + 4569519971459345583, + -4160538177779461077, + -6835753265595711384, + -6507240692498089696, + 6559392774825876886, + 7650093201692370310, + 7684323884043752161, + -8965504200858744418, + -2629915517445760644, + 271327514973697897, + -6433985589514657524, + 1065192797246149621, + 3344507881999356393, + -4763574095074709175, + 7465081662728599889, + 1014950805555097187, + -4773931307508785033, + -5742262670416273165, + 2418672789110888383, + 5796562887576294778, + 4484266064449540171, + 3738982361971787048, + -4699774852342421385, + 10530508058128498, + -589538253572429690, + -6598062107225984180, + 8660405965245884302, + 10162832508971942, + -2682657355892958417, + 7031802312784620857, + 6240911277345944669, + 831864355460801054, + -1218937899312622917, + 2116287251661052151, + 2202309800992166967, + 9161020366945053561, + 4069299552407763864, + 4936383537992622449, + 457351505131524928, + -8881176990926596454, + -6375600354038175299, + -7155351920868399290, + 4368649989588021065, + 887231587095185257, + -3659780529968199312, + -2407146836602825512, + 5616972787034086048, + -751562733459939242, + 1686575021641186857, + -5177887698780513806, + -4979215821652996885, + -1375154703071198421, + 5632136521049761902, + -8390088894796940536, + -193645528485698615, + -5979788902190688516, + -4907000935050298721, + -285522056888777828, + -2776431630044341707, + 1679342092332374735, + 6050638460742422078, + -2229851317345194226, + -1582494184340482199, + 5881353426285907985, + 812786550756860885, + 4541845584483343330, + -6497901820577766722, + 4980675660146853729, + -4012602956251539747, + -329088717864244987, + -2896929232104691526, + 1495812843684243920, + -2153620458055647789, + 7370257291860230865, + -2466442761497833547, + 4706794511633873654, + -1398851569026877145, + 8549875090542453214, + -9189721207376179652, + -7894453601103453165, + 7297902601803624459, + 1011190183918857495, + -6985347000036920864, + 5147159997473910359, + -8326859945294252826, + 2659470849286379941, + 6097729358393448602, + -7491646050550022124, + -5117116194870963097, + -896216826133240300, + -745860416168701406, + 5803876044675762232, + -787954255994554146, + -3234519180203704564, + -4507534739750823898, + -1657200065590290694, + 505808562678895611, + -4153273856159712438, + -8381261370078904295, + 572156825025677802, + 1791881013492340891, + 3393267094866038768, + -5444650186382539299, + 2352769483186201278, + -7930912453007408350, + -325464993179687389, + -3441562999710612272, + -6489413242825283295, + 5092019688680754699, + -227247482082248967, + 4234737173186232084, + 5027558287275472836, + 4635198586344772304, + -536033143587636457, + 5907508150730407386, + -8438615781380831356, + 972392927514829904, + -3801314342046600696, + -4064951393885491917, + -174840358296132583, + 2407211146698877100, + -1640089820333676239, + 3940796514530962282, + -5882197405809569433, + 3095313889586102949, + -1818050141166537098, + 5832080132947175283, + 7890064875145919662, + 8184139210799583195, + -8073512175445549678, + -7758774793014564506, + -4581724029666783935, + 3516491885471466898, + -8267083515063118116, + 6657089965014657519, + 5220884358887979358, + 1796677326474620641, + 5340761970648932916, + 1147977171614181568, + 5066037465548252321, + 2574765911837859848, + 1085848279845204775, + -5873264506986385449, + 6116438694366558490, + 2107701075971293812, + -7420077970933506541, + 2469478054175558874, + -1855128755834809824, + -5431463669011098282, + -9038325065738319171, + -6966276280341336160, + 7217693971077460129, + -8314322083775271549, + 7196649268545224266, + -3585711691453906209, + -5267827091426810625, + 8057528650917418961, + -5084103596553648165, + -2601445448341207749, + -7850010900052094367, + 6527366231383600011, + 3507654575162700890, + 9202058512774729859, + 1954818376891585542, + -2582991129724600103, + 8299563319178235687, + -5321504681635821435, + 7046310742295574065, + -2376176645520785576, + -7650733936335907755, + 8850422670118399721, + 3631909142291992901, + 5158881091950831288, + -6340413719511654215, + 4763258931815816403, + 6280052734341785344, + -4979582628649810958, + 2043464728020827976, + -2678071570832690343, + 4562580375758598164, + 5495451168795427352, + -7485059175264624713, + 553004618757816492, + 6895160632757959823, + -989748114590090637, + 7139506338801360852, + -672480814466784139, + 5535668688139305547, + 2430933853350256242, + -3821430778991574732, + -1063731997747047009, + -3065878205254005442, + 7632066283658143750, + 6308328381617103346, + 3681878764086140361, + 3289686137190109749, + 6587997200611086848, + 244714774258135476, + -5143583659437639708, + 8090302575944624335, + 2945117363431356361, + -8359047641006034763, + 3009039260312620700, + -793344576772241777, + 401084700045993341, + -1968749590416080887, + 4707864159563588614, + -3583123505891281857, + -3240864324164777915, + -5908273794572565703, + -3719524458082857382, + -5281400669679581926, + 8118566580304798074, + 3839261274019871296, + 7062410411742090847, + -8481991033874568140, + 6027994129690250817, + -6725542042704711878, + -2971981702428546974, + -7854441788951256975, + 8809096399316380241, + 6492004350391900708, + 2462145737463489636, + -8818543617934476634, + -5070345602623085213, + -8961586321599299868, + -3758656652254704451, + -8630661632476012791, + 6764129236657751224, + -709716318315418359, + -3403028373052861600, + -8838073512170985897, + -3999237033416576341, + -2920240395515973663, + -2073249475545404416, + 368107899140673753, + -6108185202296464250, + -6307735683270494757, + 4782583894627718279, + 6718292300699989587, + 8387085186914375220, + 3387513132024756289, + 4654329375432538231, + -292704475491394206, + -3848998599978456535, + 7623042350483453954, + 7725442901813263321, + 9186225467561587250, + -5132344747257272453, + -6865740430362196008, + 2530936820058611833, + 1636551876240043639, + -3658707362519810009, + 1452244145334316253, + -7161729655835084979, + -7943791770359481772, + 9108481583171221009, + -3200093350120725999, + 5007630032676973346, + 2153168792952589781, + 6720334534964750538, + -3181825545719981703, + 3433922409283786309, + 2285479922797300912, + 3110614940896576130, + -2856812446131932915, + -3804580617188639299, + 7163298419643543757, + 4891138053923696990, + 580618510277907015, + 1684034065251686769, + 4429514767357295841, + -8893025458299325803, + -8103734041042601133, + 7177515271653460134, + 4589042248470800257, + -1530083407795771245, + 143607045258444228, + 246994305896273627, + -8356954712051676521, + 6473547110565816071, + 3092379936208876896, + 2058427839513754051, + -4089587328327907870, + 8785882556301281247, + -3074039370013608197, + -637529855400303673, + 6137678347805511274, + -7152924852417805802, + 5708223427705576541, + -3223714144396531304, + 4358391411789012426, + 325123008708389849, + 6837621693887290924, + 4843721905315627004, + -3212720814705499393, + -3825019837890901156, + 4602025990114250980, + 1044646352569048800, + 9106614159853161675, + -8394115921626182539, + -4304087667751778808, + 2681532557646850893, + 3681559472488511871, + -3915372517896561773, + -2889241648411946534, + -6564663803938238204, + -8060058171802589521, + 581945337509520675, + 3648778920718647903, + -4799698790548231394, + -7602572252857820065, + 220828013409515943, + -1072987336855386047, + 4287360518296753003, + -4633371852008891965, + 5513660857261085186, + -2258542936462001533, + -8744380348503999773, + 8746140185685648781, + 228500091334420247, + 1356187007457302238, + 3019253992034194581, + 3152601605678500003, + -8793219284148773595, + 5559581553696971176, + 4916432985369275664, + -8559797105120221417, + -5802598197927043732, + 2868348622579915573, + -7224052902810357288, + -5894682518218493085, + 2587672709781371173, + -7706116723325376475, + 3092343956317362483, + -5561119517847711700, + 972445599196498113, + -1558506600978816441, + 1708913533482282562, + -2305554874185907314, + -6005743014309462908, + -6653329009633068701, + -483583197311151195, + 2488075924621352812, + -4529369641467339140, + -4663743555056261452, + 2997203966153298104, + 1282559373026354493, + 240113143146674385, + 8665713329246516443, + 628141331766346752, + -4651421219668005332, + -7750560848702540400, + 7596648026010355826, + -3132152619100351065, + 7834161864828164065, + 7103445518877254909, + 4390861237357459201, + -4780718172614204074, + -319889632007444440, + 622261699494173647, + -3186110786557562560, + -8718967088789066690, + -1948156510637662747, + -8212195255998774408, + -7028621931231314745, + 2623071828615234808, + -4066058308780939700, + -5484966924888173764, + -6683604512778046238, + -6756087640505506466, + 5256026990536851868, + 7841086888628396109, + 6640857538655893162, + -8021284697816458310, + -7109857044414059830, + -1689021141511844405, + -4298087301956291063, + -4077748265377282003, + -998231156719803476, + 2719520354384050532, + 9132346697815513771, + 4332154495710163773, + -2085582442760428892, + 6994721091344268833, + -2556143461985726874, + -8567931991128098309, + 59934747298466858, + -3098398008776739403, + -265597256199410390, + 2332206071942466437, + -7522315324568406181, + 3154897383618636503, + -7585605855467168281, + -6762850759087199275, + 197309393502684135, + -8579694182469508493, + 2543179307861934850, + 4350769010207485119, + -4468719947444108136, + -7207776534213261296, + -1224312577878317200, + 4287946071480840813, + 8362686366770308971, + 6486469209321732151, + -5605644191012979782, + -1669018511020473564, + 4450022655153542367, + -7618176296641240059, + -3896357471549267421, + -4596796223304447488, + -6531150016257070659, + -8982326463137525940, + -4125325062227681798, + -1306489741394045544, + -8338554946557245229, + 5329160409530630596, + 7790979528857726136, + 4955070238059373407, + -4304834761432101506, + -6215295852904371179, + 3007769226071157901, + -6753025801236972788, + 8928702772696731736, + 7856187920214445904, + -4748497451462800923, + 7900176660600710914, + -7082800908938549136, + -6797926979589575837, + -6737316883512927978, + 4186670094382025798, + 1883939007446035042, + -414705992779907823, + 3734134241178479257, + 4065968871360089196, + 6953124200385847784, + -7917685222115876751, + -7585632937840318161, + -5567246375906782599, + -5256612402221608788, + 3106378204088556331, + -2894472214076325998, + 4565385105440252958, + 1979884289539493806, + -6891578849933910383, + 3783206694208922581, + 8464961209802336085, + 2843963751609577687, + 3030678195484896323, + -4429654462759003204, + 4459239494808162889, + 402587895800087237, + 8057891408711167515, + 4541888170938985079, + 1042662272908816815, + -3666068979732206850, + 2647678726283249984, + 2144477441549833761, + -3417019821499388721, + -2105601033380872185, + 5916597177708541638, + -8760774321402454447, + 8833658097025758785, + 5970273481425315300, + 563813119381731307, + -6455022486202078793, + 1598828206250873866, + -4016978389451217698, + -2988328551145513985, + -6071154634840136312, + 8469693267274066490, + 125672920241807416, + -3912292412830714870, + -2559617104544284221, + -486523741806024092, + -4735332261862713930, + 5923302823487327109, + -9082480245771672572, + -1808429243461201518, + 7990420780896957397, + 4317817392807076702, + 3625184369705367340, + -6482649271566653105, + -3480272027152017464, + -3225473396345736649, + -368878695502291645, + -3981164001421868007, + -8522033136963788610, + 7609280429197514109, + 3020985755112334161, + -2572049329799262942, + 2635195723621160615, + 5144520864246028816, + -8188285521126945980, + 1567242097116389047, + 8172389260191636581, + -2885551685425483535, + -7060359469858316883, + -6480181133964513127, + -7317004403633452381, + 6011544915663598137, + 5932255307352610768, + 2241128460406315459, + -8327867140638080220, + 3094483003111372717, + 4583857460292963101, + 9079887171656594975, + -384082854924064405, + -3460631649611717935, + 4225072055348026230, + -7385151438465742745, + 3801620336801580414, + -399845416774701952, + -7446754431269675473, + 7899055018877642622, + 5421679761463003041, + 5521102963086275121, + -4975092593295409910, + 8735487530905098534, + -7462844945281082830, + -2080886987197029914, + -1000715163927557685, + -4253840471931071485, + -5828896094657903328, + 6424174453260338141, + 359248545074932887, + -5949720754023045210, + -2426265837057637212, + 3030918217665093212, + -9077771202237461772, + -3186796180789149575, + 740416251634527158, + -2142944401404840226, + 6951781370868335478, + 399922722363687927, + -8928469722407522623, + -1378421100515597285, + -8343051178220066766, + -3030716356046100229, + -8811767350470065420, + 9026808440365124461, + 6440783557497587732, + 4615674634722404292, + 539897290441580544, + 2096238225866883852, + 8751955639408182687, + -7316147128802486205, + 7381039757301768559, + 6157238513393239656, + -1473377804940618233, + 8629571604380892756, + 5280433031239081479, + 7101611890139813254, + 2479018537985767835, + 7169176924412769570, + -1281305539061572506, + -7865612307799218120, + 2278447439451174845, + 3625338785743880657, + 6477479539006708521, + 8976185375579272206, + -3712000482142939688, + 1326024180520890843, + 7537449876596048829, + 5464680203499696154, + 3189671183162196045, + 6346751753565857109, + -8982212049534145501, + -6127578587196093755, + -245039190118465649, + -6320577374581628592, + 7208698530190629697, + 7276901792339343736, + -7490986807540332668, + 4133292154170828382, + 2918308698224194548, + -7703910638917631350, + -3929437324238184044, + -4300543082831323144, + -6344160503358350167, + 5896236396443472108, + -758328221503023383, + -1894351639983151068, + -307900319840287220, + -6278469401177312761, + -2171292963361310674, + 8382142935188824023, + 9103922860780351547, + 4152330101494654406, +]; + +fn seedrand(x: i32) -> i32 { + const A: i32 = 48271; + const Q: i32 = 44488; + const R: i32 = 3399; + + let hi = x / Q; + let lo = x % Q; + let mut value = A * lo - R * hi; + if value < 0 { + value += INT32_MAX as i32; + } + + value +} + +struct GoRngSource { + tap: usize, + feed: usize, + vec: [i64; RNG_LEN], +} + +impl GoRngSource { + fn new(seed: i64) -> Self { + let mut source = Self { + tap: 0, + feed: RNG_LEN - RNG_TAP, + vec: [0_i64; RNG_LEN], + }; + source.seed(seed); + source + } + + fn seed(&mut self, seed: i64) { + self.tap = 0; + self.feed = RNG_LEN - RNG_TAP; + + let mut normalized_seed = seed % INT32_MAX; + if normalized_seed < 0 { + normalized_seed += INT32_MAX; + } + if normalized_seed == 0 { + normalized_seed = 89_482_311; + } + + let mut x = normalized_seed as i32; + for i in -20..(RNG_LEN as isize) { + x = seedrand(x); + if i >= 0 { + let mut u = (x as i64) << 40; + x = seedrand(x); + u ^= (x as i64) << 20; + x = seedrand(x); + u ^= x as i64; + u ^= RNG_COOKED[i as usize]; + self.vec[i as usize] = u; + } + } + } + + fn uint64(&mut self) -> u64 { + self.tap = if self.tap == 0 { + RNG_LEN - 1 + } else { + self.tap - 1 + }; + self.feed = if self.feed == 0 { + RNG_LEN - 1 + } else { + self.feed - 1 + }; + + let x = self.vec[self.feed].wrapping_add(self.vec[self.tap]); + self.vec[self.feed] = x; + x as u64 + } + + fn int63(&mut self) -> i64 { + (self.uint64() & RNG_MASK) as i64 + } + + fn uint32(&mut self) -> u32 { + (self.int63() >> 31) as u32 + } + + fn int63n(&mut self, n: i64) -> i64 { + if n <= 0 { + panic!("invalid argument to int63n"); + } + + if (n & (n - 1)) == 0 { + return self.int63() & (n - 1); + } + + let max = i64::MAX - (((1_u64 << 63) % (n as u64)) as i64); + let mut value = self.int63(); + while value > max { + value = self.int63(); + } + + value % n + } + + fn int31n_fast(&mut self, n: i32) -> i32 { + if n <= 0 { + panic!("invalid argument to int31n_fast"); + } + + let mut value = self.uint32(); + let mut prod = u64::from(value) * u64::from(n as u32); + let mut low = prod as u32; + + if low < n as u32 { + let threshold = (0_u32.wrapping_sub(n as u32)) % (n as u32); + while low < threshold { + value = self.uint32(); + prod = u64::from(value) * u64::from(n as u32); + low = prod as u32; + } + } + + (prod >> 32) as i32 + } + + fn intn_for_shuffle(&mut self, n: usize) -> usize { + if n == 0 { + panic!("invalid argument to intn_for_shuffle"); + } + + if n <= (i32::MAX as usize) { + self.int31n_fast(n as i32) as usize + } else { + self.int63n(n as i64) as usize + } + } + + fn shuffle_u16(&mut self, values: &mut [u16]) { + if values.len() <= 1 { + return; + } + + let mut index = values.len() - 1; + while index > (i32::MAX as usize) - 1 { + let swap_index = self.int63n((index + 1) as i64) as usize; + values.swap(index, swap_index); + index -= 1; + } + + while index > 0 { + let swap_index = self.intn_for_shuffle(index + 1); + values.swap(index, swap_index); + index -= 1; + } + } +} + +// Matches keep-core pkg/frost/roast.SelectCoordinator semantics: +// sort members, then shuffle with Go math/rand source seeded by +// attempt_seed + attempt_number, and pick first coordinator. +pub fn select_coordinator_identifier( + included_member_identifiers: &[u16], + attempt_seed: i64, + attempt_number: u32, +) -> Option { + if included_member_identifiers.is_empty() { + return None; + } + + let mut members = included_member_identifiers.to_vec(); + members.sort_unstable(); + + let mut rng = GoRngSource::new(attempt_seed.wrapping_add(i64::from(attempt_number))); + rng.shuffle_u16(&mut members); + + members.first().copied() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_coordinator_rejects_empty_set() { + assert!(select_coordinator_identifier(&[], 100, 1).is_none()); + } + + #[test] + fn select_coordinator_matches_known_keep_core_vectors() { + let seed = 6_879_463_052_285_329_321_i64; + + assert_eq!(select_coordinator_identifier(&[1, 2], seed, 1), Some(2)); + assert_eq!(select_coordinator_identifier(&[1, 2], seed, 2), Some(1)); + assert_eq!(select_coordinator_identifier(&[1, 2], seed, 3), Some(2)); + + assert_eq!(select_coordinator_identifier(&[1, 2, 3], seed, 1), Some(3)); + assert_eq!(select_coordinator_identifier(&[1, 2, 3], seed, 2), Some(2)); + assert_eq!(select_coordinator_identifier(&[1, 2, 3], seed, 4), Some(1)); + } + + #[test] + fn select_coordinator_is_input_order_independent() { + let left = select_coordinator_identifier(&[1, 2, 3, 4, 5, 6], 333, 4); + let right = select_coordinator_identifier(&[6, 1, 5, 2, 4, 3], 333, 4); + + assert_eq!(left, right); + } +} diff --git a/pkg/tbtc/signer/src/lib.rs b/pkg/tbtc/signer/src/lib.rs new file mode 100644 index 0000000000..17f620e7d2 --- /dev/null +++ b/pkg/tbtc/signer/src/lib.rs @@ -0,0 +1,1925 @@ +mod api; +mod engine; +mod errors; +mod ffi; +mod go_math_rand; + +#[cfg(test)] +use std::sync::OnceLock; + +use api::{ + AggregateRequest, BuildTaprootTxRequest, DifferentialFuzzRequest, DkgPart1Request, + DkgPart2Request, DkgPart3Request, FinalizeSignRoundRequest, + GenerateNoncesAndCommitmentsRequest, NewSigningPackageRequest, PromoteCanaryRequest, + QuarantineStatusRequest, RefreshCadenceStatusRequest, RefreshSharesRequest, + RollbackCanaryRequest, RunDkgRequest, SignShareRequest, StartSignRoundRequest, + TranscriptAuditRequest, TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, +}; +use ffi::{ + ffi_entry, free_buffer, parse_request, serialize_response, success_from_string, + TbtcSignerResult, +}; + +pub use ffi::TbtcBuffer; + +const TBTC_SIGNER_VERSION: &str = "tbtc-signer/0.1.0-bootstrap"; +const TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV: &str = "TBTC_SIGNER_ALLOW_BOOTSTRAP"; +const TBTC_SIGNER_PROFILE_ENV: &str = "TBTC_SIGNER_PROFILE"; +const TBTC_SIGNER_PROFILE_PRODUCTION: &str = "production"; +const TBTC_SIGNER_PROFILE_DEVELOPMENT: &str = "development"; +#[cfg(test)] +static TEST_BOOTSTRAP_MODE_OVERRIDE: OnceLock>> = OnceLock::new(); + +fn bootstrap_mode_flag_enabled(raw_value: &str) -> bool { + matches!( + raw_value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +fn bootstrap_mode_enabled_from_env() -> bool { + if signer_profile_is_production() { + return false; + } + + std::env::var(TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV) + .map(|raw_value| bootstrap_mode_flag_enabled(&raw_value)) + .unwrap_or(false) +} + +fn signer_profile_is_production() -> bool { + let raw = std::env::var(TBTC_SIGNER_PROFILE_ENV).unwrap_or_default(); + let normalized = raw.trim().to_ascii_lowercase(); + match normalized.as_str() { + TBTC_SIGNER_PROFILE_PRODUCTION | "" => true, + TBTC_SIGNER_PROFILE_DEVELOPMENT => false, + other => panic!( + "{} must be '{}' or '{}'; got {:?}", + TBTC_SIGNER_PROFILE_ENV, + TBTC_SIGNER_PROFILE_PRODUCTION, + TBTC_SIGNER_PROFILE_DEVELOPMENT, + other + ), + } +} + +#[cfg(test)] +fn test_bootstrap_mode_override() -> &'static std::sync::Mutex> { + TEST_BOOTSTRAP_MODE_OVERRIDE.get_or_init(|| std::sync::Mutex::new(None)) +} + +fn bootstrap_mode_enabled() -> bool { + #[cfg(test)] + { + if let Some(value) = *test_bootstrap_mode_override() + .lock() + .expect("bootstrap mode override lock poisoned") + { + return value; + } + } + + bootstrap_mode_enabled_from_env() +} + +/// FFI ownership contract: +/// - On return, `TbtcSignerResult.buffer` (if non-null) is owned by the caller. +/// - The caller must release that buffer exactly once via `frost_tbtc_free_buffer`. +#[no_mangle] +pub extern "C" fn frost_tbtc_version() -> TbtcSignerResult { + success_from_string(TBTC_SIGNER_VERSION.to_string()) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_roast_liveness_policy() -> TbtcSignerResult { + ffi_entry(|| serialize_response(&engine::roast_liveness_policy())) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_hardening_metrics() -> TbtcSignerResult { + ffi_entry(|| serialize_response(&engine::hardening_metrics())) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_roast_transcript_audit( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: TranscriptAuditRequest = parse_request(request_ptr, request_len)?; + let response = engine::roast_transcript_audit(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_verify_blame_proof( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: VerifyBlameProofRequest = parse_request(request_ptr, request_len)?; + let response = engine::verify_blame_proof(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_quarantine_status( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: QuarantineStatusRequest = parse_request(request_ptr, request_len)?; + let response = engine::quarantine_status(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_refresh_cadence_status( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: RefreshCadenceStatusRequest = parse_request(request_ptr, request_len)?; + let response = engine::refresh_cadence_status(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_trigger_emergency_rekey( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: TriggerEmergencyRekeyRequest = parse_request(request_ptr, request_len)?; + let response = engine::trigger_emergency_rekey(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_run_differential_fuzzing( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: DifferentialFuzzRequest = parse_request(request_ptr, request_len)?; + let response = engine::run_differential_fuzzing(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_canary_rollout_status() -> TbtcSignerResult { + ffi_entry(|| { + let response = engine::canary_rollout_status()?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_promote_canary( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: PromoteCanaryRequest = parse_request(request_ptr, request_len)?; + let response = engine::promote_canary(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_rollback_canary( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: RollbackCanaryRequest = parse_request(request_ptr, request_len)?; + let response = engine::rollback_canary(request)?; + serialize_response(&response) + }) +} + +#[cfg(any(test, feature = "bench-restart-hook"))] +#[doc(hidden)] +pub fn frost_tbtc_reload_state_from_storage_for_benchmarks() -> Result<(), String> { + engine::reload_state_from_storage_for_benchmarks().map_err(|error| error.to_string()) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_free_buffer(ptr: *mut u8, len: usize) { + free_buffer(ptr, len) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_run_dkg( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: RunDkgRequest = parse_request(request_ptr, request_len)?; + let response = engine::run_dkg(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_dkg_part1( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: DkgPart1Request = parse_request(request_ptr, request_len)?; + let response = engine::dkg_part1(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_dkg_part2( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: DkgPart2Request = parse_request(request_ptr, request_len)?; + let response = engine::dkg_part2(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_dkg_part3( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: DkgPart3Request = parse_request(request_ptr, request_len)?; + let response = engine::dkg_part3(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_generate_nonces_and_commitments( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: GenerateNoncesAndCommitmentsRequest = parse_request(request_ptr, request_len)?; + let response = engine::generate_nonces_and_commitments(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_new_signing_package( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: NewSigningPackageRequest = parse_request(request_ptr, request_len)?; + let response = engine::new_signing_package(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_sign_share( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: SignShareRequest = parse_request(request_ptr, request_len)?; + let response = engine::sign_share(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_aggregate( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: AggregateRequest = parse_request(request_ptr, request_len)?; + let response = engine::aggregate(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_start_sign_round( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: StartSignRoundRequest = parse_request(request_ptr, request_len)?; + let response = engine::start_sign_round(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_finalize_sign_round( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: FinalizeSignRoundRequest = parse_request(request_ptr, request_len)?; + let response = engine::finalize_sign_round(request, bootstrap_mode_enabled())?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_build_taproot_tx( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: BuildTaprootTxRequest = parse_request(request_ptr, request_len)?; + let response = engine::build_taproot_tx(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_refresh_shares( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: RefreshSharesRequest = parse_request(request_ptr, request_len)?; + let response = engine::refresh_shares(request)?; + serialize_response(&response) + }) +} + +#[cfg(test)] +mod tests { + use bitcoin::consensus::encode::deserialize; + use bitcoin::secp256k1::{ + schnorr::Signature as SchnorrSignature, Message as SecpMessage, Secp256k1, XOnlyPublicKey, + }; + use pretty_assertions::assert_eq; + use sha2::{Digest, Sha256}; + + use crate::api::{ + AggregateRequest, AggregateResult, BuildTaprootTxRequest, CanaryRolloutStatusResult, + DifferentialFuzzRequest, DifferentialFuzzResult, DkgPart1Request, DkgPart1Result, + DkgPart2Request, DkgPart2Result, DkgPart3Request, DkgPart3Result, DkgParticipant, + DkgRound1Package, DkgRound2Package, ErrorResponse, FinalizeSignRoundRequest, + GenerateNoncesAndCommitmentsRequest, GenerateNoncesAndCommitmentsResult, + NewSigningPackageRequest, NewSigningPackageResult, PromoteCanaryRequest, + QuarantineStatusRequest, QuarantineStatusResult, RefreshCadenceStatusRequest, + RefreshCadenceStatusResult, RefreshSharesRequest, RoastLivenessPolicyResult, + RollbackCanaryRequest, RoundContribution, RunDkgRequest, ShareMaterial, SignShareRequest, + SignShareResult, SignerHardeningMetricsResult, StartSignRoundRequest, TransactionResult, + TranscriptAuditRequest, TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, + }; + use crate::{ + frost_tbtc_aggregate, frost_tbtc_build_taproot_tx, frost_tbtc_canary_rollout_status, + frost_tbtc_dkg_part1, frost_tbtc_dkg_part2, frost_tbtc_dkg_part3, + frost_tbtc_finalize_sign_round, frost_tbtc_free_buffer, + frost_tbtc_generate_nonces_and_commitments, frost_tbtc_hardening_metrics, + frost_tbtc_new_signing_package, frost_tbtc_promote_canary, frost_tbtc_quarantine_status, + frost_tbtc_refresh_cadence_status, frost_tbtc_refresh_shares, + frost_tbtc_roast_liveness_policy, frost_tbtc_roast_transcript_audit, + frost_tbtc_rollback_canary, frost_tbtc_run_differential_fuzzing, frost_tbtc_run_dkg, + frost_tbtc_sign_share, frost_tbtc_start_sign_round, frost_tbtc_trigger_emergency_rekey, + frost_tbtc_verify_blame_proof, + }; + + fn bootstrap_synthetic_share_hex( + round_state: &crate::api::RoundState, + identifier: u16, + ) -> String { + let mut hasher = Sha256::new(); + hasher.update( + format!( + "tbtc-signer-bootstrap-contribution-v1:{}:{}:{}:{}", + round_state.session_id, + round_state.round_id, + round_state.message_digest_hex, + identifier + ) + .as_bytes(), + ); + hex::encode(hasher.finalize()) + } + + fn call_ffi( + request: &T, + f: extern "C" fn(*const u8, usize) -> crate::ffi::TbtcSignerResult, + ) -> (i32, Vec) { + let bytes = serde_json::to_vec(request).expect("request serialization"); + let result = f(bytes.as_ptr(), bytes.len()); + + let response_bytes = if result.buffer.ptr.is_null() || result.buffer.len == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(result.buffer.ptr, result.buffer.len).to_vec() } + }; + + frost_tbtc_free_buffer(result.buffer.ptr, result.buffer.len); + (result.status_code, response_bytes) + } + + fn call_ffi_no_input(f: extern "C" fn() -> crate::ffi::TbtcSignerResult) -> (i32, Vec) { + let result = f(); + + let response_bytes = if result.buffer.ptr.is_null() || result.buffer.len == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(result.buffer.ptr, result.buffer.len).to_vec() } + }; + + frost_tbtc_free_buffer(result.buffer.ptr, result.buffer.len); + (result.status_code, response_bytes) + } + + struct BootstrapModeGuard { + previous_value: Option, + } + + impl BootstrapModeGuard { + fn set(value: Option) -> Self { + let mut guard = super::test_bootstrap_mode_override() + .lock() + .expect("bootstrap mode override lock poisoned"); + let previous_value = *guard; + *guard = value; + + Self { previous_value } + } + + fn enable() -> Self { + Self::set(Some(true)) + } + + fn disable() -> Self { + Self::set(Some(false)) + } + } + + impl Drop for BootstrapModeGuard { + fn drop(&mut self) { + let mut guard = super::test_bootstrap_mode_override() + .lock() + .expect("bootstrap mode override lock poisoned"); + *guard = self.previous_value; + } + } + + struct EnvVarGuard { + key: &'static str, + previous_value: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous_value = std::env::var(key).ok(); + std::env::set_var(key, value); + + Self { + key, + previous_value, + } + } + + #[allow(dead_code)] + fn unset(key: &'static str) -> Self { + let previous_value = std::env::var(key).ok(); + std::env::remove_var(key); + + Self { + key, + previous_value, + } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.previous_value { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + + #[test] + fn run_dkg_is_idempotent_for_identical_request() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = RunDkgRequest { + session_id: "session-a".to_string(), + participants: vec![ + DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let (status_first, first_payload) = call_ffi(&request, frost_tbtc_run_dkg); + let (status_second, second_payload) = call_ffi(&request, frost_tbtc_run_dkg); + + assert_eq!(status_first, 0); + assert_eq!(status_second, 0); + assert_eq!(first_payload, second_payload); + } + + #[test] + fn run_dkg_uses_fresh_entropy_for_unseeded_request_after_engine_reset() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = RunDkgRequest { + session_id: "session-unseeded-entropy".to_string(), + participants: vec![ + DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let (status_first, first_payload) = call_ffi(&request, frost_tbtc_run_dkg); + crate::engine::reset_for_tests(); + let (status_second, second_payload) = call_ffi(&request, frost_tbtc_run_dkg); + + assert_eq!(status_first, 0); + assert_eq!(status_second, 0); + + let result_first: crate::api::DkgResult = + serde_json::from_slice(&first_payload).expect("decode first DKG result"); + let result_second: crate::api::DkgResult = + serde_json::from_slice(&second_payload).expect("decode second DKG result"); + + assert_eq!(result_first.session_id, result_second.session_id); + assert_ne!(result_first.key_group, result_second.key_group); + } + + #[test] + fn run_dkg_uses_explicit_seed_across_distinct_sessions() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let participants = vec![ + DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ]; + let dkg_seed_hex = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + + let request_a = RunDkgRequest { + session_id: "session-seeded-a".to_string(), + participants: participants.clone(), + threshold: 2, + dkg_seed_hex: Some(dkg_seed_hex.to_string()), + }; + let (status_a, payload_a) = call_ffi(&request_a, frost_tbtc_run_dkg); + + crate::engine::reset_for_tests(); + + let request_b = RunDkgRequest { + session_id: "session-seeded-b".to_string(), + participants, + threshold: 2, + dkg_seed_hex: Some(dkg_seed_hex.to_string()), + }; + let (status_b, payload_b) = call_ffi(&request_b, frost_tbtc_run_dkg); + + assert_eq!(status_a, 0); + assert_eq!(status_b, 0); + + let result_a: crate::api::DkgResult = + serde_json::from_slice(&payload_a).expect("decode first DKG result"); + let result_b: crate::api::DkgResult = + serde_json::from_slice(&payload_b).expect("decode second DKG result"); + + assert_ne!(result_a.session_id, result_b.session_id); + assert_eq!(result_a.key_group, result_b.key_group); + } + + #[test] + fn run_dkg_reports_malformed_seed_as_recoverable_validation_error() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + let _profile = EnvVarGuard::set("TBTC_SIGNER_PROFILE", "development"); + let _provenance_gate = EnvVarGuard::unset("TBTC_SIGNER_ENFORCE_PROVENANCE_GATE"); + let _admission_policy = EnvVarGuard::unset("TBTC_SIGNER_ENFORCE_ADMISSION_POLICY"); + + let request = RunDkgRequest { + session_id: "session-bad-seed".to_string(), + participants: vec![ + DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: Some("not-hex".to_string()), + }; + + let (status, payload) = call_ffi(&request, frost_tbtc_run_dkg); + + assert_eq!(status, 1); + let response: ErrorResponse = + serde_json::from_slice(&payload).expect("decode error response"); + assert_eq!(response.code, "validation_error"); + assert_eq!(response.recovery_class, "recoverable"); + assert!( + response.message.contains("dkg_seed_hex must be valid hex"), + "unexpected error message: {}", + response.message + ); + } + + #[test] + fn run_dkg_rejects_conflicting_repeat_request_for_same_session() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request_a = RunDkgRequest { + session_id: "session-conflict".to_string(), + participants: vec![ + DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let mut request_b = request_a.clone(); + request_b.participants.push(DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }); + + let (status_first, _) = call_ffi(&request_a, frost_tbtc_run_dkg); + let (status_second, payload_second) = call_ffi(&request_b, frost_tbtc_run_dkg); + + assert_eq!(status_first, 0); + assert_eq!(status_second, 1); + + let error: ErrorResponse = + serde_json::from_slice(&payload_second).expect("error payload decode"); + assert_eq!(error.code, "session_conflict"); + assert_eq!(error.recovery_class, "recoverable"); + } + + fn native_frost_identifier(member_index: u8) -> String { + let mut identifier = [0u8; 32]; + identifier[0] = member_index; + serde_json::to_string(&hex::encode(identifier)) + .expect("identifier JSON encoding cannot fail") + } + + #[test] + fn interactive_frost_dkg_and_signing_ffi_roundtrip() { + let _profile_env = EnvVarGuard::set(super::TBTC_SIGNER_PROFILE_ENV, "development"); + let _provenance_env = EnvVarGuard::set("TBTC_SIGNER_ENFORCE_PROVENANCE_GATE", "false"); + + let participant_ids = [1u8, 2u8, 3u8]; + let participant_identifiers: std::collections::BTreeMap = participant_ids + .iter() + .map(|id| (*id, native_frost_identifier(*id))) + .collect(); + + let mut part1_results = std::collections::BTreeMap::new(); + for id in participant_ids { + let request = DkgPart1Request { + participant_identifier: participant_identifiers[&id].clone(), + max_signers: 3, + min_signers: 2, + }; + let (status, payload) = call_ffi(&request, frost_tbtc_dkg_part1); + assert_eq!(status, 0); + let result: DkgPart1Result = + serde_json::from_slice(&payload).expect("part1 response decode"); + assert_eq!(result.package.identifier, participant_identifiers[&id]); + assert!(!result.secret_package_hex.is_empty()); + assert!(!result.package.package_hex.is_empty()); + part1_results.insert(id, result); + } + + let mut part2_results = std::collections::BTreeMap::new(); + for id in participant_ids { + let round1_packages: Vec = participant_ids + .iter() + .filter(|other_id| **other_id != id) + .map(|other_id| part1_results[other_id].package.clone()) + .collect(); + let request = DkgPart2Request { + secret_package_hex: part1_results[&id].secret_package_hex.clone(), + round1_packages, + }; + let (status, payload) = call_ffi(&request, frost_tbtc_dkg_part2); + assert_eq!(status, 0); + let result: DkgPart2Result = + serde_json::from_slice(&payload).expect("part2 response decode"); + assert_eq!(result.packages.len(), 2); + assert!(result + .packages + .iter() + .all(|pkg| pkg.sender_identifier.is_none())); + part2_results.insert(id, result); + } + + let mut part3_results = std::collections::BTreeMap::new(); + for id in participant_ids { + let round1_packages: Vec = participant_ids + .iter() + .filter(|other_id| **other_id != id) + .map(|other_id| part1_results[other_id].package.clone()) + .collect(); + let round2_packages: Vec = participant_ids + .iter() + .filter(|sender_id| **sender_id != id) + .map(|sender_id| { + let mut package = part2_results[sender_id] + .packages + .iter() + .find(|pkg| pkg.identifier == participant_identifiers[&id]) + .expect("round2 package for recipient") + .clone(); + package.sender_identifier = Some(participant_identifiers[sender_id].clone()); + package + }) + .collect(); + let request = DkgPart3Request { + secret_package_hex: part2_results[&id].secret_package_hex.clone(), + round1_packages, + round2_packages, + }; + let (status, payload) = call_ffi(&request, frost_tbtc_dkg_part3); + assert_eq!(status, 0); + let result: DkgPart3Result = + serde_json::from_slice(&payload).expect("part3 response decode"); + assert_eq!(result.key_package.identifier, participant_identifiers[&id]); + assert_eq!(result.public_key_package.verifying_key.len(), 64); + assert_eq!(result.public_key_package.verifying_shares.len(), 3); + part3_results.insert(id, result); + } + + let verifying_key = part3_results[&1].public_key_package.verifying_key.clone(); + for id in participant_ids { + assert_eq!( + part3_results[&id].public_key_package.verifying_key, + verifying_key + ); + assert_eq!( + part3_results[&id].public_key_package.verifying_shares, + part3_results[&1].public_key_package.verifying_shares + ); + } + + let signing_participants = [1u8, 2u8]; + let mut commitments = Vec::new(); + let mut nonces_by_participant = std::collections::BTreeMap::new(); + for id in signing_participants { + let request = GenerateNoncesAndCommitmentsRequest { + key_package_identifier: part3_results[&id].key_package.identifier.clone(), + key_package_hex: part3_results[&id].key_package.data_hex.clone(), + }; + let (status, payload) = call_ffi(&request, frost_tbtc_generate_nonces_and_commitments); + assert_eq!(status, 0); + let result: GenerateNoncesAndCommitmentsResult = + serde_json::from_slice(&payload).expect("nonce response decode"); + commitments.push(result.commitment); + nonces_by_participant.insert(id, result.nonces_hex); + } + + let message = [0x42u8; 32]; + let request = NewSigningPackageRequest { + message_hex: hex::encode(message), + commitments: commitments.clone(), + }; + let (status, payload) = call_ffi(&request, frost_tbtc_new_signing_package); + assert_eq!(status, 0); + let signing_package: NewSigningPackageResult = + serde_json::from_slice(&payload).expect("signing package response decode"); + + let mut signature_shares = Vec::new(); + for id in signing_participants { + let request = SignShareRequest { + signing_package_hex: signing_package.signing_package_hex.clone(), + nonces_hex: nonces_by_participant[&id].clone(), + key_package_identifier: part3_results[&id].key_package.identifier.clone(), + key_package_hex: part3_results[&id].key_package.data_hex.clone(), + }; + let (status, payload) = call_ffi(&request, frost_tbtc_sign_share); + assert_eq!(status, 0); + let result: SignShareResult = + serde_json::from_slice(&payload).expect("signature share response decode"); + signature_shares.push(result.signature_share); + } + + let request = AggregateRequest { + signing_package_hex: signing_package.signing_package_hex, + signature_shares, + public_key_package: part3_results[&1].public_key_package.clone(), + }; + let (status, payload) = call_ffi(&request, frost_tbtc_aggregate); + assert_eq!(status, 0); + let aggregate: AggregateResult = + serde_json::from_slice(&payload).expect("aggregate response decode"); + + let signature_bytes = hex::decode(aggregate.signature_hex).expect("signature hex"); + assert_eq!(signature_bytes.len(), 64); + let signature = SchnorrSignature::from_slice(&signature_bytes).expect("BIP340 signature"); + let public_key_bytes = hex::decode(verifying_key).expect("verifying key hex"); + let public_key = XOnlyPublicKey::from_slice(&public_key_bytes).expect("x-only public key"); + let message = SecpMessage::from_digest(message); + Secp256k1::verification_only() + .verify_schnorr(&signature, &message, &public_key) + .expect("aggregate verifies under DKG x-only key"); + + let commitment_identifiers: Vec = commitments + .into_iter() + .map(|commitment| commitment.identifier) + .collect(); + let share_identifiers: Vec = request + .signature_shares + .into_iter() + .map(|share| share.identifier) + .collect(); + assert_eq!(commitment_identifiers, share_identifiers); + } + + #[test] + fn roast_liveness_policy_reports_default_contract() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let (status, payload) = call_ffi_no_input(frost_tbtc_roast_liveness_policy); + assert_eq!(status, 0); + + let policy: RoastLivenessPolicyResult = + serde_json::from_slice(&payload).expect("policy payload decode"); + assert_eq!(policy.coordinator_timeout_ms, 30_000); + assert_eq!(policy.timeout_source, "keep_core_wall_clock"); + assert_eq!(policy.advance_trigger, "coordinator_timeout"); + assert_eq!( + policy.exclusion_evidence_policy, + "timeout_or_invalid_share_proof" + ); + } + + #[test] + fn hardening_metrics_reports_runtime_and_counters() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let (status_before, payload_before) = call_ffi_no_input(frost_tbtc_hardening_metrics); + assert_eq!(status_before, 0); + let metrics_before: SignerHardeningMetricsResult = + serde_json::from_slice(&payload_before).expect("metrics payload decode"); + assert!(!metrics_before.runtime_version.is_empty()); + assert_eq!(metrics_before.run_dkg_calls_total, 0); + assert_eq!(metrics_before.run_dkg_success_total, 0); + assert_eq!(metrics_before.start_sign_round_calls_total, 0); + assert_eq!(metrics_before.start_sign_round_success_total, 0); + assert_eq!(metrics_before.refresh_shares_calls_total, 0); + assert_eq!(metrics_before.refresh_shares_success_total, 0); + assert_eq!(metrics_before.run_dkg_latency_samples, 0); + assert_eq!(metrics_before.run_dkg_latency_p95_ms, 0); + + let dkg_request = RunDkgRequest { + session_id: "hardening-metrics-session".to_string(), + participants: vec![ + DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let (dkg_status, _) = call_ffi(&dkg_request, frost_tbtc_run_dkg); + assert_eq!(dkg_status, 0); + + let (status_after, payload_after) = call_ffi_no_input(frost_tbtc_hardening_metrics); + assert_eq!(status_after, 0); + let metrics_after: SignerHardeningMetricsResult = + serde_json::from_slice(&payload_after).expect("metrics payload decode"); + assert_eq!(metrics_after.run_dkg_calls_total, 1); + assert_eq!(metrics_after.run_dkg_success_total, 1); + assert_eq!(metrics_after.start_sign_round_calls_total, 0); + assert_eq!(metrics_after.start_sign_round_success_total, 0); + assert_eq!(metrics_after.refresh_shares_calls_total, 0); + assert_eq!(metrics_after.refresh_shares_success_total, 0); + assert_eq!(metrics_after.run_dkg_latency_samples, 1); + assert!(metrics_after.run_dkg_latency_p95_ms >= 1); + } + + #[test] + fn quarantine_status_reports_default_disabled_state() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = QuarantineStatusRequest { + operator_identifier: 1, + }; + let (status, payload) = call_ffi(&request, frost_tbtc_quarantine_status); + assert_eq!(status, 0); + + let result: QuarantineStatusResult = + serde_json::from_slice(&payload).expect("quarantine status payload decode"); + assert_eq!(result.operator_identifier, 1); + assert!(!result.auto_quarantine_enabled); + assert_eq!(result.fault_score, 0); + assert_eq!(result.quarantine_threshold, 0); + assert!(!result.quarantined); + } + + #[test] + fn transcript_endpoints_return_session_not_found_for_unknown_session() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let audit_request = TranscriptAuditRequest { + session_id: "missing-session".to_string(), + }; + let (audit_status, audit_payload) = + call_ffi(&audit_request, frost_tbtc_roast_transcript_audit); + assert_eq!(audit_status, 1); + let audit_error: ErrorResponse = + serde_json::from_slice(&audit_payload).expect("audit error decode"); + assert_eq!(audit_error.code, "session_not_found"); + + let verify_request = VerifyBlameProofRequest { + session_id: "missing-session".to_string(), + from_attempt_number: 1, + accused_member_identifier: 1, + reason: "coordinator_timeout".to_string(), + invalid_share_proof_fingerprint: None, + }; + let (verify_status, verify_payload) = + call_ffi(&verify_request, frost_tbtc_verify_blame_proof); + assert_eq!(verify_status, 1); + let verify_error: ErrorResponse = + serde_json::from_slice(&verify_payload).expect("verify error decode"); + assert_eq!(verify_error.code, "session_not_found"); + } + + #[test] + fn refresh_cadence_status_returns_session_not_found_for_unknown_session() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = RefreshCadenceStatusRequest { + session_id: "missing-refresh-session".to_string(), + }; + let (status, payload) = call_ffi(&request, frost_tbtc_refresh_cadence_status); + assert_eq!(status, 1); + + let error: ErrorResponse = + serde_json::from_slice(&payload).expect("refresh cadence error decode"); + assert_eq!(error.code, "session_not_found"); + } + + #[test] + fn differential_fuzzing_reports_no_critical_divergence() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = DifferentialFuzzRequest { + seed: 0xD1FF_2026_0302_0001, + case_count: 64, + }; + let (status, payload) = call_ffi(&request, frost_tbtc_run_differential_fuzzing); + assert_eq!(status, 0); + + let result: DifferentialFuzzResult = + serde_json::from_slice(&payload).expect("differential fuzz payload decode"); + assert_eq!(result.case_count, 64); + assert_eq!(result.critical_divergence_count, 0); + assert!(!result.unresolved_critical_divergence); + } + + #[test] + fn canary_rollout_promote_and_rollback_roundtrip() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let (status_initial, payload_initial) = call_ffi_no_input(frost_tbtc_canary_rollout_status); + assert_eq!(status_initial, 0); + let initial: CanaryRolloutStatusResult = + serde_json::from_slice(&payload_initial).expect("canary status decode"); + assert_eq!(initial.current_percent, 10); + assert_eq!(initial.recommended_next_percent, Some(50)); + + let promote_50 = PromoteCanaryRequest { target_percent: 50 }; + let (status_promote_50, payload_promote_50) = + call_ffi(&promote_50, frost_tbtc_promote_canary); + assert_eq!(status_promote_50, 0); + let promoted_50: crate::api::PromoteCanaryResult = + serde_json::from_slice(&payload_promote_50).expect("promote 50 decode"); + assert_eq!(promoted_50.from_percent, 10); + assert_eq!(promoted_50.to_percent, 50); + + let promote_100 = PromoteCanaryRequest { + target_percent: 100, + }; + let (status_promote_100, payload_promote_100) = + call_ffi(&promote_100, frost_tbtc_promote_canary); + assert_eq!(status_promote_100, 0); + let promoted_100: crate::api::PromoteCanaryResult = + serde_json::from_slice(&payload_promote_100).expect("promote 100 decode"); + assert_eq!(promoted_100.from_percent, 50); + assert_eq!(promoted_100.to_percent, 100); + + let rollback = RollbackCanaryRequest { + reason: "slo regression".to_string(), + }; + let (status_rollback, payload_rollback) = call_ffi(&rollback, frost_tbtc_rollback_canary); + assert_eq!(status_rollback, 0); + let rolled_back: crate::api::RollbackCanaryResult = + serde_json::from_slice(&payload_rollback).expect("rollback decode"); + assert_eq!(rolled_back.from_percent, 100); + assert_eq!(rolled_back.to_percent, 50); + + let (status_after, payload_after) = call_ffi_no_input(frost_tbtc_canary_rollout_status); + assert_eq!(status_after, 0); + let after: CanaryRolloutStatusResult = + serde_json::from_slice(&payload_after).expect("canary status after rollback decode"); + assert_eq!(after.current_percent, 50); + } + + #[test] + fn emergency_rekey_blocks_start_sign_round_for_session() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let dkg_request = RunDkgRequest { + session_id: "session-emergency-rekey".to_string(), + participants: vec![ + DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let (dkg_status, dkg_payload) = call_ffi(&dkg_request, frost_tbtc_run_dkg); + assert_eq!(dkg_status, 0); + let dkg_result: crate::api::DkgResult = + serde_json::from_slice(&dkg_payload).expect("dkg payload decode"); + + let rekey_request = TriggerEmergencyRekeyRequest { + session_id: "session-emergency-rekey".to_string(), + reason: "key compromise drill".to_string(), + }; + let (rekey_status, rekey_payload) = + call_ffi(&rekey_request, frost_tbtc_trigger_emergency_rekey); + assert_eq!(rekey_status, 0); + let rekey_result: crate::api::TriggerEmergencyRekeyResult = + serde_json::from_slice(&rekey_payload).expect("rekey payload decode"); + assert!(rekey_result.emergency_rekey_required); + + let start_request = StartSignRoundRequest { + session_id: "session-emergency-rekey".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let (start_status, start_payload) = call_ffi(&start_request, frost_tbtc_start_sign_round); + assert_eq!(start_status, 1); + let start_error: ErrorResponse = + serde_json::from_slice(&start_payload).expect("start error decode"); + assert_eq!(start_error.code, "lifecycle_policy_rejected"); + + let cadence_request = RefreshCadenceStatusRequest { + session_id: "session-emergency-rekey".to_string(), + }; + let (cadence_status, cadence_payload) = + call_ffi(&cadence_request, frost_tbtc_refresh_cadence_status); + assert_eq!(cadence_status, 0); + let cadence_result: RefreshCadenceStatusResult = + serde_json::from_slice(&cadence_payload).expect("cadence status payload decode"); + assert!(cadence_result.emergency_rekey_required); + } + + #[test] + fn start_and_finalize_sign_round_support_idempotent_retries() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + let _bootstrap_mode_guard = BootstrapModeGuard::enable(); + + let dkg = RunDkgRequest { + session_id: "session-sign".to_string(), + participants: vec![ + DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let (dkg_status, dkg_payload) = call_ffi(&dkg, frost_tbtc_run_dkg); + assert_eq!(dkg_status, 0); + + let dkg_result: crate::api::DkgResult = + serde_json::from_slice(&dkg_payload).expect("dkg payload decode"); + + let start = StartSignRoundRequest { + session_id: "session-sign".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + + let (start_status, start_payload) = call_ffi(&start, frost_tbtc_start_sign_round); + assert_eq!(start_status, 0); + + let round_state: crate::api::RoundState = + serde_json::from_slice(&start_payload).expect("round payload decode"); + + let finalize = FinalizeSignRoundRequest { + session_id: "session-sign".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + + let (finalize_status_first, finalize_payload_first) = + call_ffi(&finalize, frost_tbtc_finalize_sign_round); + let (finalize_status_second, finalize_payload_second) = + call_ffi(&finalize, frost_tbtc_finalize_sign_round); + + assert_eq!(finalize_status_first, 0); + assert_eq!(finalize_status_second, 0); + assert_eq!(finalize_payload_first, finalize_payload_second); + + let signature: crate::api::SignatureResult = + serde_json::from_slice(&finalize_payload_first).expect("signature payload decode"); + assert_eq!(signature.round_id, round_state.round_id); + } + + #[test] + fn start_and_finalize_sign_round_rejects_synthetic_contributions_when_bootstrap_disabled() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + let _bootstrap_mode_guard = BootstrapModeGuard::disable(); + + let dkg = RunDkgRequest { + session_id: "session-sign-bootstrap-disabled".to_string(), + participants: vec![ + DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + + let (dkg_status, dkg_payload) = call_ffi(&dkg, frost_tbtc_run_dkg); + assert_eq!(dkg_status, 0); + + let dkg_result: crate::api::DkgResult = + serde_json::from_slice(&dkg_payload).expect("dkg payload decode"); + + let start = StartSignRoundRequest { + session_id: "session-sign-bootstrap-disabled".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + + let (start_status, start_payload) = call_ffi(&start, frost_tbtc_start_sign_round); + assert_eq!(start_status, 0); + + let round_state: crate::api::RoundState = + serde_json::from_slice(&start_payload).expect("round payload decode"); + + let finalize = FinalizeSignRoundRequest { + session_id: "session-sign-bootstrap-disabled".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + + let (finalize_status, finalize_payload) = + call_ffi(&finalize, frost_tbtc_finalize_sign_round); + assert_eq!(finalize_status, 1); + + let error: ErrorResponse = + serde_json::from_slice(&finalize_payload).expect("error payload decode"); + assert_eq!(error.code, "synthetic_contribution_rejected"); + assert_eq!(error.recovery_class, "recoverable"); + } + + #[test] + fn start_sign_round_returns_session_conflict_for_non_finalized_payload_mismatch() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let dkg = RunDkgRequest { + session_id: "session-sign-conflict".to_string(), + participants: vec![ + DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let (dkg_status, dkg_payload) = call_ffi(&dkg, frost_tbtc_run_dkg); + assert_eq!(dkg_status, 0); + let dkg_result: crate::api::DkgResult = + serde_json::from_slice(&dkg_payload).expect("dkg payload decode"); + + let start_first = StartSignRoundRequest { + session_id: "session-sign-conflict".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group.clone(), + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: None, + attempt_transition_evidence: None, + }; + let (start_first_status, _) = call_ffi(&start_first, frost_tbtc_start_sign_round); + assert_eq!(start_first_status, 0); + + let start_second = StartSignRoundRequest { + session_id: "session-sign-conflict".to_string(), + member_identifier: 1, + message_hex: "cafebabe".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![2, 1]), + attempt_context: None, + attempt_transition_evidence: None, + }; + let (start_second_status, start_second_payload) = + call_ffi(&start_second, frost_tbtc_start_sign_round); + assert_eq!(start_second_status, 1); + + let error: ErrorResponse = + serde_json::from_slice(&start_second_payload).expect("error payload decode"); + assert_eq!(error.code, "session_conflict"); + assert_eq!(error.recovery_class, "recoverable"); + } + + #[test] + fn start_sign_round_returns_session_finalized_after_finalize() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + let _bootstrap_mode_guard = BootstrapModeGuard::enable(); + + let dkg = RunDkgRequest { + session_id: "session-sign-finalized".to_string(), + participants: vec![ + DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + let (dkg_status, dkg_payload) = call_ffi(&dkg, frost_tbtc_run_dkg); + assert_eq!(dkg_status, 0); + let dkg_result: crate::api::DkgResult = + serde_json::from_slice(&dkg_payload).expect("dkg payload decode"); + + let start = StartSignRoundRequest { + session_id: "session-sign-finalized".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + let (start_status, start_payload) = call_ffi(&start, frost_tbtc_start_sign_round); + assert_eq!(start_status, 0); + let round_state: crate::api::RoundState = + serde_json::from_slice(&start_payload).expect("round payload decode"); + + let finalize = FinalizeSignRoundRequest { + session_id: "session-sign-finalized".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![ + RoundContribution { + identifier: 1, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 1), + }, + RoundContribution { + identifier: 2, + signature_share_hex: bootstrap_synthetic_share_hex(&round_state, 2), + }, + ], + }; + let (finalize_status, _) = call_ffi(&finalize, frost_tbtc_finalize_sign_round); + assert_eq!(finalize_status, 0); + + let (restart_status, restart_payload) = call_ffi(&start, frost_tbtc_start_sign_round); + assert_eq!(restart_status, 1); + let error: ErrorResponse = + serde_json::from_slice(&restart_payload).expect("error payload decode"); + assert_eq!(error.code, "session_finalized"); + assert_eq!(error.recovery_class, "terminal"); + } + + #[test] + fn start_sign_round_returns_session_not_found_for_unknown_session() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let start = StartSignRoundRequest { + session_id: "session-sign-missing".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: "missing".to_string(), + taproot_merkle_root_hex: None, + signing_participants: None, + attempt_context: None, + attempt_transition_evidence: None, + }; + + let (status, payload) = call_ffi(&start, frost_tbtc_start_sign_round); + assert_eq!(status, 1); + let error: ErrorResponse = serde_json::from_slice(&payload).expect("error payload decode"); + assert_eq!(error.code, "session_not_found"); + assert_eq!(error.recovery_class, "terminal"); + } + + #[test] + fn build_taproot_tx_is_idempotent_and_conflict_checked() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = BuildTaprootTxRequest { + session_id: "session-tx".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 1, + value_sats: 10_000, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 9_000, + }], + script_tree_hex: None, + }; + + let (status_first, payload_first) = call_ffi(&request, frost_tbtc_build_taproot_tx); + let (status_second, payload_second) = call_ffi(&request, frost_tbtc_build_taproot_tx); + + assert_eq!(status_first, 0); + assert_eq!(status_second, 0); + assert_eq!(payload_first, payload_second); + + let result: TransactionResult = + serde_json::from_slice(&payload_first).expect("transaction payload decode"); + assert_eq!(result.session_id, "session-tx"); + let tx_bytes = hex::decode(&result.tx_hex).expect("decode tx hex"); + let tx: bitcoin::Transaction = deserialize(&tx_bytes).expect("decode transaction"); + assert_eq!(tx.input.len(), 1); + assert_eq!(tx.output.len(), 1); + + let conflict_request = BuildTaprootTxRequest { + session_id: "session-tx".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 1, + value_sats: 10_000, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 8_000, + }], + script_tree_hex: None, + }; + + let (conflict_status, conflict_payload) = + call_ffi(&conflict_request, frost_tbtc_build_taproot_tx); + assert_eq!(conflict_status, 1); + + let error: ErrorResponse = + serde_json::from_slice(&conflict_payload).expect("conflict error payload"); + assert_eq!(error.code, "session_conflict"); + assert_eq!(error.recovery_class, "recoverable"); + } + + #[test] + fn build_taproot_tx_rejects_script_tree_payload() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = BuildTaprootTxRequest { + session_id: "session-tx-script-tree".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 1, + value_sats: 10_000, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 9_000, + }], + script_tree_hex: Some("00".to_string()), + }; + + let (status, payload) = call_ffi(&request, frost_tbtc_build_taproot_tx); + assert_eq!(status, 1); + + let error: ErrorResponse = serde_json::from_slice(&payload).expect("error payload"); + assert_eq!(error.code, "validation_error"); + assert!(error + .message + .contains("script_tree_hex is not yet supported")); + } + + #[test] + fn build_taproot_tx_rejects_overspend_outputs() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = BuildTaprootTxRequest { + session_id: "session-tx-overspend".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 1, + value_sats: 10_000, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 10_001, + }], + script_tree_hex: None, + }; + + let (status, payload) = call_ffi(&request, frost_tbtc_build_taproot_tx); + assert_eq!(status, 1); + + let error: ErrorResponse = serde_json::from_slice(&payload).expect("error payload"); + assert_eq!(error.code, "validation_error"); + assert!(error.message.contains("exceeds input value_sats total")); + } + + #[test] + fn build_taproot_tx_rejects_output_total_above_bitcoin_max_money() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let max_money_outputs: Vec = (0..9_000) + .map(|index| crate::api::TxOutput { + script_pubkey_hex: format!("5120{:064x}", index + 1), + value_sats: 2_100_000_000_000_000, + }) + .collect(); + + let request = BuildTaprootTxRequest { + session_id: "session-tx-max-money-output-sum".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 1, + value_sats: 2_100_000_000_000_000, + }], + outputs: max_money_outputs, + script_tree_hex: None, + }; + + let (status, payload) = call_ffi(&request, frost_tbtc_build_taproot_tx); + assert_eq!(status, 1); + + let error: ErrorResponse = serde_json::from_slice(&payload).expect("error payload"); + assert_eq!(error.code, "validation_error"); + assert!(error + .message + .contains("output value_sats total [4200000000000000] exceeds Bitcoin max money")); + } + + #[test] + fn build_taproot_tx_rejects_input_total_above_bitcoin_max_money() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let max_money_inputs: Vec = (0..9_000) + .map(|index| crate::api::TxInput { + txid_hex: format!("{:064x}", index + 1), + vout: 0, + value_sats: 2_100_000_000_000_000, + }) + .collect(); + + let request = BuildTaprootTxRequest { + session_id: "session-tx-max-money-input-sum".to_string(), + inputs: max_money_inputs, + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 1, + }], + script_tree_hex: None, + }; + + let (status, payload) = call_ffi(&request, frost_tbtc_build_taproot_tx); + assert_eq!(status, 1); + + let error: ErrorResponse = serde_json::from_slice(&payload).expect("error payload"); + assert_eq!(error.code, "validation_error"); + assert!(error + .message + .contains("input value_sats total [4200000000000000] exceeds Bitcoin max money")); + } + + #[test] + fn build_taproot_tx_rejects_output_value_above_bitcoin_max_money() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = BuildTaprootTxRequest { + session_id: "session-tx-output-above-max-money".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 1, + value_sats: 2_100_000_000_000_000, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 2_100_000_000_000_001, + }], + script_tree_hex: None, + }; + + let (status, payload) = call_ffi(&request, frost_tbtc_build_taproot_tx); + assert_eq!(status, 1); + + let error: ErrorResponse = serde_json::from_slice(&payload).expect("error payload"); + assert_eq!(error.code, "validation_error"); + assert!(error + .message + .contains("output value_sats [2100000000000001] exceeds Bitcoin max money")); + } + + #[test] + fn build_taproot_tx_rejects_input_value_above_bitcoin_max_money() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = BuildTaprootTxRequest { + session_id: "session-tx-input-above-max-money".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 1, + value_sats: 2_100_000_000_000_001, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 1, + }], + script_tree_hex: None, + }; + + let (status, payload) = call_ffi(&request, frost_tbtc_build_taproot_tx); + assert_eq!(status, 1); + + let error: ErrorResponse = serde_json::from_slice(&payload).expect("error payload"); + assert_eq!(error.code, "validation_error"); + assert!(error + .message + .contains("input value_sats [2100000000000001] exceeds Bitcoin max money")); + } + + #[test] + fn build_taproot_tx_rejects_invalid_input_txid_hex() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = BuildTaprootTxRequest { + session_id: "session-tx-invalid-input-txid".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "zz".to_string(), + vout: 1, + value_sats: 10_000, + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 1, + }], + script_tree_hex: None, + }; + + let (status, payload) = call_ffi(&request, frost_tbtc_build_taproot_tx); + assert_eq!(status, 1); + + let error: ErrorResponse = serde_json::from_slice(&payload).expect("error payload"); + assert_eq!(error.code, "validation_error"); + assert_eq!(error.recovery_class, "recoverable"); + assert!(error.message.contains("invalid input txid_hex [zz]")); + } + + #[test] + fn build_taproot_tx_rejects_malformed_output_script() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = BuildTaprootTxRequest { + session_id: "session-tx-malformed-output-script".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 1, + value_sats: 10_000, + }], + outputs: vec![crate::api::TxOutput { + // OP_PUSHDATA1 length=2 with only one data byte. + script_pubkey_hex: "4c02aa".to_string(), + value_sats: 1, + }], + script_tree_hex: None, + }; + + let (status, payload) = call_ffi(&request, frost_tbtc_build_taproot_tx); + assert_eq!(status, 1); + + let error: ErrorResponse = serde_json::from_slice(&payload).expect("error payload"); + assert_eq!(error.code, "validation_error"); + assert!(error + .message + .contains("invalid output script_pubkey_hex [4c02aa]")); + } + + #[test] + fn build_taproot_tx_rejects_duplicate_inputs() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = BuildTaprootTxRequest { + session_id: "session-tx-duplicate-inputs".to_string(), + inputs: vec![ + crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 1, + value_sats: 10_000, + }, + crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 1, + value_sats: 10_000, + }, + ], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 10_000, + }], + script_tree_hex: None, + }; + + let (status, payload) = call_ffi(&request, frost_tbtc_build_taproot_tx); + assert_eq!(status, 1); + + let error: ErrorResponse = serde_json::from_slice(&payload).expect("error payload"); + assert_eq!(error.code, "validation_error"); + assert!(error.message.contains("duplicate input outpoint")); + } + + #[test] + fn refresh_shares_is_idempotent() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = RefreshSharesRequest { + session_id: "session-refresh".to_string(), + current_shares: vec![ + ShareMaterial { + identifier: 1, + encrypted_share_hex: "abcd".to_string(), + }, + ShareMaterial { + identifier: 2, + encrypted_share_hex: "ef01".to_string(), + }, + ], + }; + + let (status_first, payload_first) = call_ffi(&request, frost_tbtc_refresh_shares); + let (status_second, payload_second) = call_ffi(&request, frost_tbtc_refresh_shares); + + assert_eq!(status_first, 0); + assert_eq!(status_second, 0); + assert_eq!(payload_first, payload_second); + } + + #[test] + fn refresh_shares_uses_monotonic_epoch_counter() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request_first = RefreshSharesRequest { + session_id: "session-refresh-epoch-1".to_string(), + current_shares: vec![ShareMaterial { + identifier: 1, + encrypted_share_hex: "1111".to_string(), + }], + }; + + let request_second = RefreshSharesRequest { + session_id: "session-refresh-epoch-2".to_string(), + current_shares: vec![ShareMaterial { + identifier: 1, + encrypted_share_hex: "2222".to_string(), + }], + }; + + let (status_first, payload_first) = call_ffi(&request_first, frost_tbtc_refresh_shares); + let (status_first_retry, payload_first_retry) = + call_ffi(&request_first, frost_tbtc_refresh_shares); + let (status_second, payload_second) = call_ffi(&request_second, frost_tbtc_refresh_shares); + + assert_eq!(status_first, 0); + assert_eq!(status_first_retry, 0); + assert_eq!(payload_first, payload_first_retry); + assert_eq!(status_second, 0); + + let first_result: crate::api::RefreshSharesResult = + serde_json::from_slice(&payload_first).expect("first refresh payload decode"); + let second_result: crate::api::RefreshSharesResult = + serde_json::from_slice(&payload_second).expect("second refresh payload decode"); + + assert_eq!(first_result.refresh_epoch, 1); + assert_eq!(second_result.refresh_epoch, 2); + } + + #[test] + fn bootstrap_mode_flag_parser_is_strict() { + let test_cases = vec![ + ("", false), + ("0", false), + ("false", false), + (" bootstrap ", false), + ("1", true), + ("true", true), + ("TRUE", true), + ("yes", true), + ("on", true), + (" true ", true), + ]; + + for (value, expected) in test_cases { + assert_eq!( + super::bootstrap_mode_flag_enabled(value), + expected, + "unexpected bootstrap-mode flag classification for [{value:?}]", + ); + } + } + + #[test] + fn bootstrap_mode_env_is_ignored_in_production_profile() { + let _guard = crate::engine::lock_test_state(); + let _bootstrap_mode_guard = BootstrapModeGuard::set(None); + let _allow_bootstrap_env = EnvVarGuard::set(super::TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV, "true"); + let _profile_env = EnvVarGuard::set(super::TBTC_SIGNER_PROFILE_ENV, "production"); + + assert!(!super::bootstrap_mode_enabled_from_env()); + } + + #[test] + fn bootstrap_mode_env_is_ignored_when_profile_is_missing_or_empty() { + let _guard = crate::engine::lock_test_state(); + let _bootstrap_mode_guard = BootstrapModeGuard::set(None); + let _allow_bootstrap_env = EnvVarGuard::set(super::TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV, "true"); + let _profile_env = EnvVarGuard::unset(super::TBTC_SIGNER_PROFILE_ENV); + + assert!(super::signer_profile_is_production()); + assert!(!super::bootstrap_mode_enabled_from_env()); + + std::env::set_var(super::TBTC_SIGNER_PROFILE_ENV, " "); + + assert!(super::signer_profile_is_production()); + assert!(!super::bootstrap_mode_enabled_from_env()); + } + + #[test] + fn bootstrap_mode_rechecks_production_profile_each_call() { + let _guard = crate::engine::lock_test_state(); + let _bootstrap_mode_guard = BootstrapModeGuard::set(None); + let _allow_bootstrap_env = EnvVarGuard::set(super::TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV, "true"); + let _profile_env = EnvVarGuard::set(super::TBTC_SIGNER_PROFILE_ENV, "development"); + + assert!(super::bootstrap_mode_enabled()); + + std::env::set_var(super::TBTC_SIGNER_PROFILE_ENV, "production"); + + assert!(!super::bootstrap_mode_enabled()); + } +} diff --git a/pkg/tbtc/signer/test/vectors/p2tr-signature-fraud-v0.json b/pkg/tbtc/signer/test/vectors/p2tr-signature-fraud-v0.json new file mode 100644 index 0000000000..f245626671 --- /dev/null +++ b/pkg/tbtc/signer/test/vectors/p2tr-signature-fraud-v0.json @@ -0,0 +1,598 @@ +{ + "name": "p2tr-signature-fraud-v0", + "status": "draft", + "purpose": "Draft BIP-341 key-path sighash, BIP-340 verification, and structured Bridge challenge-identity vectors for the P2TR signature-fraud model feasibility gate. These vectors are not production activation evidence.", + "generatedAt": "2026-05-20", + "generatedWith": { + "package": "threshold-tbtc", + "bitcoinjs-lib": "6.1.x workspace dependency", + "tiny-secp256k1": "2.2.x workspace dependency", + "customFixtureGenerator": "dependency-free BIP-340 signing fixture generator for the draft SIGHASH_ALL and flow-shaped cases" + }, + "policy": { + "taprootSpendPath": "key-path", + "sighashType": "SIGHASH_DEFAULT and SIGHASH_ALL", + "annex": "absent", + "scriptPath": "unsupported" + }, + "cases": [ + { + "id": "bip341-keypath-sighash-default-single-input", + "description": "Single-input P2TR key-path spend using SIGHASH_DEFAULT. The x-only wallet key is the canonical wallet ID for this draft vector.", + "privateKeyHex": "0000000000000000000000000000000000000000000000000000000000000003", + "walletIDHex": "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + "walletP2trScriptPubKeyHex": "5120f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + "unsignedTransactionHex": "020000000111111111111111111111111111111111111111111111111111111111111111110000000000fdffffff01606b042a01000000225120222222222222222222222222222222222222222222222222222222222222222200000000", + "signedInputIndex": 0, + "prevouts": [ + { + "txidHex": "1111111111111111111111111111111111111111111111111111111111111111", + "vout": 0, + "valueSats": 5000000000, + "scriptPubKeyHex": "5120f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" + } + ], + "outputs": [ + { + "valueSats": 4999900000, + "scriptPubKeyHex": "51202222222222222222222222222222222222222222222222222222222222222222" + } + ], + "sighashType": 0, + "expectedBip341SighashHex": "c7d0470735e6de6626801d2a0f9c1114fdb4c1861e9da13f3b879cc6bbbd006c", + "bip340SignatureHex": "ca8083af26f885b1dc0de3dad7b12785a314cc2eeb74c4386bdc7aeb26d520af7316342e6c0cbffee41362f1f5345d8c54b7e17c4ac034cbaf31f9c737229545", + "witnessSignatureHex": "ca8083af26f885b1dc0de3dad7b12785a314cc2eeb74c4386bdc7aeb26d520af7316342e6c0cbffee41362f1f5345d8c54b7e17c4ac034cbaf31f9c737229545", + "expectedDraftChallengeIdentityHex": "4a216640966a85e119963b0503d52c356b8f0d672937ae2c2bb72222834d2892", + "expectedBridgeChallengeIdentityHex": "35ca366c7d414fe611757529598a443683016078c3da6b7cbd602e278b7d0765", + "expectedVerify": true, + "negativeVerificationCases": [ + { + "id": "wrong-wallet-id", + "walletIDHex": "3333333333333333333333333333333333333333333333333333333333333333", + "expectedVerify": false + }, + { + "id": "wrong-message", + "bip341SighashHex": "0000000000000000000000000000000000000000000000000000000000000000", + "expectedVerify": false + }, + { + "id": "wrong-signature", + "bip340SignatureHex": "ca8083af26f885b1dc0de3dad7b12785a314cc2eeb74c4386bdc7aeb26d520af7316342e6c0cbffee41362f1f5345d8c54b7e17c4ac034cbaf31f9c737229544", + "expectedVerify": false + } + ] + }, + { + "id": "bip341-keypath-sighash-default-nonuniform-txid", + "description": "Single-input P2TR key-path spend using SIGHASH_DEFAULT with a non-uniform previous transaction ID. The serialized transaction commits to the little-endian outpoint while prevout metadata records the display-order txid.", + "privateKeyHex": "0000000000000000000000000000000000000000000000000000000000000006", + "walletIDHex": "fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556", + "walletP2trScriptPubKeyHex": "5120fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556", + "unsignedTransactionHex": "0200000001ffeeddccbbaa998877665544332211001032547698badcfeefcdab89674523010300000000fdffffff010070991400000000225120999999999999999999999999999999999999999999999999999999999999999900000000", + "signedInputIndex": 0, + "prevouts": [ + { + "txidHex": "0123456789abcdeffedcba987654321000112233445566778899aabbccddeeff", + "vout": 3, + "valueSats": 345678901, + "scriptPubKeyHex": "5120fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556" + } + ], + "outputs": [ + { + "valueSats": 345600000, + "scriptPubKeyHex": "51209999999999999999999999999999999999999999999999999999999999999999" + } + ], + "sighashType": 0, + "expectedBip341SighashHex": "adb4b8782ac45dd6a72e0c8b2334e336dbf90c339a937c7157ac54bbc9157413", + "bip340SignatureHex": "774ae7f858a9411e5ef4246b70c65aac5649980be5c17891bbec17895da008cbc4e7182012ab0ca2f7f171bdf5d837c49f359b001555b8d39b5764a7039d602b", + "witnessSignatureHex": "774ae7f858a9411e5ef4246b70c65aac5649980be5c17891bbec17895da008cbc4e7182012ab0ca2f7f171bdf5d837c49f359b001555b8d39b5764a7039d602b", + "expectedDraftChallengeIdentityHex": "9c760526b55cec37ec64dda1acb4b79828ed49dc4405e85c5c57d296d87d8a92", + "expectedBridgeChallengeIdentityHex": "ea5c8e6011d817eb68a0c32c1932e121bf5d7192fc56c16336f694ea63aedd21", + "expectedVerify": true, + "negativeVerificationCases": [ + { + "id": "wrong-message", + "bip341SighashHex": "0000000000000000000000000000000000000000000000000000000000000000", + "expectedVerify": false + } + ], + "negativeSighashCases": [ + { + "id": "wrong-outpoint-byte-order", + "description": "Reversing the serialized transaction outpoint byte order changes the BIP-341 key-path sighash and invalidates the signature.", + "unsignedTransactionHex": "02000000010123456789abcdeffedcba987654321000112233445566778899aabbccddeeff0300000000fdffffff010070991400000000225120999999999999999999999999999999999999999999999999999999999999999900000000", + "prevouts": [ + { + "txidHex": "ffeeddccbbaa998877665544332211001032547698badcfeefcdab8967452301", + "vout": 3, + "valueSats": 345678901, + "scriptPubKeyHex": "5120fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556" + } + ], + "expectedVerify": false + } + ] + }, + { + "id": "bip341-keypath-sighash-default-multi-input-multi-output", + "description": "Two-input, two-output P2TR key-path spend using SIGHASH_DEFAULT. The challenged signature signs input index 1 while committing to all input amounts, scripts, sequences, and output ordering.", + "privateKeyHex": "0000000000000000000000000000000000000000000000000000000000000004", + "walletIDHex": "e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13", + "walletP2trScriptPubKeyHex": "5120e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13", + "unsignedTransactionHex": "0200000002aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000feffffffbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb0200000000fdffffff028098cf580000000022512044444444444444444444444444444444444444444444444444444444444444444054890000000000225120555555555555555555555555555555555555555555555555555555555555555500000000", + "signedInputIndex": 1, + "prevouts": [ + { + "txidHex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "vout": 0, + "valueSats": 600000000, + "scriptPubKeyHex": "5120e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13" + }, + { + "txidHex": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "vout": 2, + "valueSats": 900000000, + "scriptPubKeyHex": "5120e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13" + } + ], + "outputs": [ + { + "valueSats": 1490000000, + "scriptPubKeyHex": "51204444444444444444444444444444444444444444444444444444444444444444" + }, + { + "valueSats": 9000000, + "scriptPubKeyHex": "51205555555555555555555555555555555555555555555555555555555555555555" + } + ], + "sighashType": 0, + "expectedBip341SighashHex": "65e6481b762e48d1dba2f006db532382bd31303970d7c7737356f2c0535ebbae", + "bip340SignatureHex": "a470b987366ad6a5f9e7a5da7a00059ceae7a88a32e21309d8cbd2ef45cb05726d296c050d75bd7a212aee42b008a30df156bb7001be8e128c7f15805069a473", + "witnessSignatureHex": "a470b987366ad6a5f9e7a5da7a00059ceae7a88a32e21309d8cbd2ef45cb05726d296c050d75bd7a212aee42b008a30df156bb7001be8e128c7f15805069a473", + "expectedDraftChallengeIdentityHex": "2c8cfefbe7c6deb101322d5be2adac1ea6515280fb8fd83a11f616b14fb5c52d", + "expectedBridgeChallengeIdentityHex": "bd9cf3ba8341c2d728d748ff1971c4a81e985d4c54698e01652dda9621bb0d24", + "expectedVerify": true, + "negativeVerificationCases": [ + { + "id": "wrong-wallet-id", + "walletIDHex": "6666666666666666666666666666666666666666666666666666666666666666", + "expectedVerify": false + }, + { + "id": "invalid-x-only-wallet-id", + "walletIDHex": "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f", + "expectedVerify": false + }, + { + "id": "wrong-message", + "bip341SighashHex": "0000000000000000000000000000000000000000000000000000000000000000", + "expectedVerify": false + }, + { + "id": "wrong-signature", + "bip340SignatureHex": "a470b987366ad6a5f9e7a5da7a00059ceae7a88a32e21309d8cbd2ef45cb05726d296c050d75bd7a212aee42b008a30df156bb7001be8e128c7f15805069a400", + "expectedVerify": false + }, + { + "id": "invalid-nonce-parity", + "bip340SignatureHex": "a470b987366ad6a5f9e7a5da7a00059ceae7a88a32e21309d8cbd2ef45cb0572568006e920adc0be9235ec14c1a5167842ca33fb40d772b60ca3176e525c406d", + "expectedVerify": false + }, + { + "id": "wrong-challenge-tag", + "bip340SignatureHex": "2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4e682c2d444513072c08d032135c0af46ccd28747267376e787ad862b2c940c31", + "expectedVerify": false + }, + { + "id": "s-scalar-overflow", + "bip340SignatureHex": "a470b987366ad6a5f9e7a5da7a00059ceae7a88a32e21309d8cbd2ef45cb0572fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", + "expectedVerify": false + }, + { + "id": "s-scalar-zero", + "bip340SignatureHex": "a470b987366ad6a5f9e7a5da7a00059ceae7a88a32e21309d8cbd2ef45cb05720000000000000000000000000000000000000000000000000000000000000000", + "expectedVerify": false + }, + { + "id": "r-field-overflow", + "bip340SignatureHex": "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f0000000000000000000000000000000000000000000000000000000000000001", + "expectedVerify": false + } + ], + "negativeSighashCases": [ + { + "id": "wrong-input-amount", + "description": "Changing a previous-output amount changes the BIP-341 key-path sighash and invalidates the signature.", + "prevouts": [ + { + "txidHex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "vout": 0, + "valueSats": 600000001, + "scriptPubKeyHex": "5120e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13" + }, + { + "txidHex": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "vout": 2, + "valueSats": 900000000, + "scriptPubKeyHex": "5120e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13" + } + ], + "expectedVerify": false + }, + { + "id": "wrong-input-script-pubkey", + "description": "Changing a previous-output scriptPubKey changes the BIP-341 key-path sighash and invalidates the signature.", + "prevouts": [ + { + "txidHex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "vout": 0, + "valueSats": 600000000, + "scriptPubKeyHex": "51207777777777777777777777777777777777777777777777777777777777777777" + }, + { + "txidHex": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "vout": 2, + "valueSats": 900000000, + "scriptPubKeyHex": "5120e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13" + } + ], + "expectedVerify": false + }, + { + "id": "wrong-sequence", + "description": "Changing an input sequence changes the BIP-341 key-path sighash and invalidates the signature.", + "unsignedTransactionHex": "0200000002aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000fcffffffbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb0200000000fdffffff028098cf580000000022512044444444444444444444444444444444444444444444444444444444444444444054890000000000225120555555555555555555555555555555555555555555555555555555555555555500000000", + "expectedVerify": false + }, + { + "id": "wrong-output-order", + "description": "Changing output ordering changes the BIP-341 key-path sighash and invalidates the signature.", + "unsignedTransactionHex": "0200000002aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000feffffffbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb0200000000fdffffff02405489000000000022512055555555555555555555555555555555555555555555555555555555555555558098cf5800000000225120444444444444444444444444444444444444444444444444444444444444444400000000", + "outputs": [ + { + "valueSats": 9000000, + "scriptPubKeyHex": "51205555555555555555555555555555555555555555555555555555555555555555" + }, + { + "valueSats": 1490000000, + "scriptPubKeyHex": "51204444444444444444444444444444444444444444444444444444444444444444" + } + ], + "expectedVerify": false + } + ] + }, + { + "id": "bip341-keypath-sighash-all-single-input", + "description": "Single-input P2TR key-path spend using SIGHASH_ALL. The x-only wallet key is the canonical wallet ID for this draft vector.", + "privateKeyHex": "0000000000000000000000000000000000000000000000000000000000000005", + "walletIDHex": "2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4", + "walletP2trScriptPubKeyHex": "51202f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4", + "unsignedTransactionHex": "0200000001cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc0100000000ffffffff0260ee297d00000000225120777777777777777777777777777777777777777777777777777777777777777750c3000000000000225120888888888888888888888888888888888888888888888888888888888888888800000000", + "signedInputIndex": 0, + "prevouts": [ + { + "txidHex": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "vout": 1, + "valueSats": 2100000000, + "scriptPubKeyHex": "51202f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4" + } + ], + "outputs": [ + { + "valueSats": 2099900000, + "scriptPubKeyHex": "51207777777777777777777777777777777777777777777777777777777777777777" + }, + { + "valueSats": 50000, + "scriptPubKeyHex": "51208888888888888888888888888888888888888888888888888888888888888888" + } + ], + "sighashType": 1, + "expectedBip341SighashHex": "5f21427e8c77e1ab5e6f5d866e9f27963ff59e12ca3691ac3a9b8b9f64ff7fd6", + "bip340SignatureHex": "5edb4c17e5b201b76807acf8aca97ceed15ed6f71df48af4d44eca038358228b7a35ccc1043903dab814beec096f19bd34e9529604260988e9cab3d3e3689937", + "witnessSignatureHex": "5edb4c17e5b201b76807acf8aca97ceed15ed6f71df48af4d44eca038358228b7a35ccc1043903dab814beec096f19bd34e9529604260988e9cab3d3e368993701", + "expectedDraftChallengeIdentityHex": "991133e668bfd61c1a9d3383ec395aae1bdbe2dbef03733b9b3f1e4e8950672c", + "expectedBridgeChallengeIdentityHex": "45c94f0cb0537026a7e504d0d6d4bc9c8d2e875fdfab4538ea6f3fd0d9c5a289", + "expectedVerify": true, + "negativeVerificationCases": [ + { + "id": "wrong-message", + "bip341SighashHex": "0000000000000000000000000000000000000000000000000000000000000000", + "expectedVerify": false + } + ], + "negativeSighashCases": [ + { + "id": "wrong-output-ordering", + "unsignedTransactionHex": "0200000001cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc0100000000ffffffff0250c3000000000000225120888888888888888888888888888888888888888888888888888888888888888860ee297d00000000225120777777777777777777777777777777777777777777777777777777777777777700000000", + "outputs": [ + { + "valueSats": 50000, + "scriptPubKeyHex": "51208888888888888888888888888888888888888888888888888888888888888888" + }, + { + "valueSats": 2099900000, + "scriptPubKeyHex": "51207777777777777777777777777777777777777777777777777777777777777777" + } + ], + "expectedVerify": false + } + ] + }, + { + "id": "bip341-keypath-sighash-default-moving-funds-flow", + "description": "Draft moving-funds-shaped P2TR key-path spend using SIGHASH_DEFAULT. The source wallet input spends to an ordered target-wallet P2TR output set; Bridge proof-event correlation remains required before production approval.", + "privateKeyHex": "0000000000000000000000000000000000000000000000000000000000000007", + "walletIDHex": "5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc", + "walletP2trScriptPubKeyHex": "51205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc", + "unsignedTransactionHex": "02000000011032547698badcfeefcdab89674523011032547698badcfeefcdab89674523010500000000fdffffff020027b92900000000225120111122223333444455556666777788889999aaaabbbbccccddddeeeeffff0000e0cec6200000000022512022223333444455556666777788889999aaaabbbbccccddddeeeeffff0000111100000000", + "signedInputIndex": 0, + "prevouts": [ + { + "txidHex": "0123456789abcdeffedcba98765432100123456789abcdeffedcba9876543210", + "vout": 5, + "valueSats": 1250000000, + "scriptPubKeyHex": "51205cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc" + } + ], + "outputs": [ + { + "valueSats": 700000000, + "scriptPubKeyHex": "5120111122223333444455556666777788889999aaaabbbbccccddddeeeeffff0000" + }, + { + "valueSats": 549900000, + "scriptPubKeyHex": "512022223333444455556666777788889999aaaabbbbccccddddeeeeffff00001111" + } + ], + "sighashType": 0, + "flowMetadata": { + "spendType": "moving-funds", + "evidenceLevel": "flow-shaped-draft-vector-seed", + "sourceWalletInput": 0, + "requiredBridgeEvent": "Bridge.submitMovingFundsProof acceptance", + "proofEventCorrelation": "required-not-present", + "positiveAssertions": [ + "source wallet input spends through key-path witness", + "ordered target wallet P2TR output set is committed by the sighash" + ], + "knownLimits": [ + "does not prove Bridge proof acceptance", + "does not prove target-wallet commitment/event correlation" + ] + }, + "expectedBip341SighashHex": "3ccc3a5ccefa3224ec203de64da8c235256b9ea251ab796e21831a0871337296", + "bip340SignatureHex": "53da6538a659e06eade9fe7b8b1203d8b191ce577b9cbb73108264a1c706e51e66f4f6e8e39546a0fb71598f5af023040dfddea51d05bc1e38adf10f3dcdd46e", + "witnessSignatureHex": "53da6538a659e06eade9fe7b8b1203d8b191ce577b9cbb73108264a1c706e51e66f4f6e8e39546a0fb71598f5af023040dfddea51d05bc1e38adf10f3dcdd46e", + "expectedDraftChallengeIdentityHex": "8cdef1d24e332d1251946af6a05e675431ad35ab30f6abf8852c7c9dff34e861", + "expectedBridgeChallengeIdentityHex": "ab10e4df27a990932c3f5d48d3f9e3c48541e7b6b0bf4746a34bebf2f34eda5d", + "expectedVerify": true, + "negativeVerificationCases": [ + { + "id": "wrong-message", + "bip341SighashHex": "0000000000000000000000000000000000000000000000000000000000000000", + "expectedVerify": false + } + ], + "negativeSighashCases": [ + { + "id": "wrong-target-wallet-order", + "description": "Swapping target wallet outputs changes the BIP-341 key-path sighash and invalidates the source-wallet signature.", + "outputs": [ + { + "valueSats": 549900000, + "scriptPubKeyHex": "512022223333444455556666777788889999aaaabbbbccccddddeeeeffff00001111" + }, + { + "valueSats": 700000000, + "scriptPubKeyHex": "5120111122223333444455556666777788889999aaaabbbbccccddddeeeeffff0000" + } + ], + "unsignedTransactionHex": "02000000011032547698badcfeefcdab89674523011032547698badcfeefcdab89674523010500000000fdffffff02e0cec6200000000022512022223333444455556666777788889999aaaabbbbccccddddeeeeffff000011110027b92900000000225120111122223333444455556666777788889999aaaabbbbccccddddeeeeffff000000000000", + "expectedVerify": false + }, + { + "id": "below-dust-target-output", + "description": "Mutating a target output below dust changes the BIP-341 key-path sighash and invalidates the source-wallet signature.", + "outputs": [ + { + "valueSats": 545, + "scriptPubKeyHex": "5120111122223333444455556666777788889999aaaabbbbccccddddeeeeffff0000" + }, + { + "valueSats": 549900000, + "scriptPubKeyHex": "512022223333444455556666777788889999aaaabbbbccccddddeeeeffff00001111" + } + ], + "unsignedTransactionHex": "02000000011032547698badcfeefcdab89674523011032547698badcfeefcdab89674523010500000000fdffffff022102000000000000225120111122223333444455556666777788889999aaaabbbbccccddddeeeeffff0000e0cec6200000000022512022223333444455556666777788889999aaaabbbbccccddddeeeeffff0000111100000000", + "expectedVerify": false + } + ] + }, + { + "id": "bip341-keypath-sighash-all-redemption-flow", + "description": "Draft redemption-shaped P2TR key-path spend using SIGHASH_ALL. The transaction commits to a redeemer P2TR output plus wallet change; Bridge redemption proof-event correlation remains required before production approval.", + "privateKeyHex": "0000000000000000000000000000000000000000000000000000000000000008", + "walletIDHex": "2f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a01", + "walletP2trScriptPubKeyHex": "51202f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a01", + "unsignedTransactionHex": "020000000198badcfe1032547667452301efcdab8998badcfe1032547667452301efcdab890100000000fdffffff020084d717000000002251203333444455556666777788889999aaaabbbbccccddddeeeeffff0000111122221097f305000000002251202f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a0100000000", + "signedInputIndex": 0, + "prevouts": [ + { + "txidHex": "89abcdef0123456776543210fedcba9889abcdef0123456776543210fedcba98", + "vout": 1, + "valueSats": 500000000, + "scriptPubKeyHex": "51202f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a01" + } + ], + "outputs": [ + { + "valueSats": 400000000, + "scriptPubKeyHex": "51203333444455556666777788889999aaaabbbbccccddddeeeeffff000011112222" + }, + { + "valueSats": 99850000, + "scriptPubKeyHex": "51202f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a01" + } + ], + "sighashType": 1, + "flowMetadata": { + "spendType": "redemption", + "evidenceLevel": "flow-shaped-draft-vector-seed", + "sourceWalletInput": 0, + "requiredBridgeEvent": "Bridge.submitRedemptionProof acceptance", + "proofEventCorrelation": "required-not-present", + "positiveAssertions": [ + "source wallet input spends through key-path witness", + "redeemer output script and wallet change output are committed by the sighash" + ], + "knownLimits": [ + "does not prove Bridge redemption proof acceptance", + "does not prove redeemer request/event correlation" + ] + }, + "expectedBip341SighashHex": "344644f591cfd3e031af88c745d9d6edd96baabdf15067a374c20e9c5f749064", + "bip340SignatureHex": "6d3a6f20b1715c3171f886f4dca338fd1d9dfe42ba38a522cbd6f923865323e8e233df766a3dc46c2763f3d3cc09f6c270d44485ed775a9b61ec95bfd27d3f78", + "witnessSignatureHex": "6d3a6f20b1715c3171f886f4dca338fd1d9dfe42ba38a522cbd6f923865323e8e233df766a3dc46c2763f3d3cc09f6c270d44485ed775a9b61ec95bfd27d3f7801", + "expectedDraftChallengeIdentityHex": "2ebe51dc76ae45beb0d6dfcd7f0a8a8ed9e1e8992025e5196629526c155797ae", + "expectedBridgeChallengeIdentityHex": "d054052f51e337ed9f1e3de85e3bc49b8a47c44e3ada67e7806f883266a51c74", + "expectedVerify": true, + "negativeVerificationCases": [ + { + "id": "wrong-message", + "bip341SighashHex": "0000000000000000000000000000000000000000000000000000000000000000", + "expectedVerify": false + } + ], + "negativeSighashCases": [ + { + "id": "wrong-redeemer-output-script", + "description": "Changing the redeemer output script changes the BIP-341 key-path sighash and invalidates the source-wallet signature.", + "outputs": [ + { + "valueSats": 400000000, + "scriptPubKeyHex": "5120444455556666777788889999aaaabbbbccccddddeeeeffff0000111122223333" + }, + { + "valueSats": 99850000, + "scriptPubKeyHex": "51202f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a01" + } + ], + "unsignedTransactionHex": "020000000198badcfe1032547667452301efcdab8998badcfe1032547667452301efcdab890100000000fdffffff020084d71700000000225120444455556666777788889999aaaabbbbccccddddeeeeffff00001111222233331097f305000000002251202f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a0100000000", + "expectedVerify": false + }, + { + "id": "fee-boundary-mutation", + "description": "Changing the redeemer value at the fee boundary changes the BIP-341 key-path sighash and invalidates the source-wallet signature.", + "outputs": [ + { + "valueSats": 399999999, + "scriptPubKeyHex": "51203333444455556666777788889999aaaabbbbccccddddeeeeffff000011112222" + }, + { + "valueSats": 99850000, + "scriptPubKeyHex": "51202f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a01" + } + ], + "unsignedTransactionHex": "020000000198badcfe1032547667452301efcdab8998badcfe1032547667452301efcdab890100000000fdffffff02ff83d717000000002251203333444455556666777788889999aaaabbbbccccddddeeeeffff0000111122221097f305000000002251202f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a0100000000", + "expectedVerify": false + } + ] + } + ], + "negativeWitnessCases": [ + { + "id": "explicit-default-sighash-byte", + "baseCaseId": "bip341-keypath-sighash-default-single-input", + "witnessSignatureHex": "ca8083af26f885b1dc0de3dad7b12785a314cc2eeb74c4386bdc7aeb26d520af7316342e6c0cbffee41362f1f5345d8c54b7e17c4ac034cbaf31f9c73722954500", + "expectedError": "unsupported-sighash" + }, + { + "id": "unsupported-sighash-single", + "baseCaseId": "bip341-keypath-sighash-all-single-input", + "witnessSignatureHex": "5edb4c17e5b201b76807acf8aca97ceed15ed6f71df48af4d44eca038358228b7a35ccc1043903dab814beec096f19bd34e9529604260988e9cab3d3e368993702", + "expectedError": "unsupported-sighash" + }, + { + "id": "short-signature", + "baseCaseId": "bip341-keypath-sighash-default-single-input", + "witnessSignatureHex": "ca8083af26f885b1dc0de3dad7b12785a314cc2eeb74c4386bdc7aeb26d520af7316342e6c0cbffee41362f1f5345d8c54b7e17c4ac034cbaf31f9c7372295", + "expectedError": "invalid-length" + }, + { + "id": "long-signature", + "baseCaseId": "bip341-keypath-sighash-all-single-input", + "witnessSignatureHex": "5edb4c17e5b201b76807acf8aca97ceed15ed6f71df48af4d44eca038358228b7a35ccc1043903dab814beec096f19bd34e9529604260988e9cab3d3e36899370100", + "expectedError": "invalid-length" + } + ], + "spendTypeCoverage": [ + { + "id": "moving-funds", + "status": "open", + "evidenceLevel": "flow-shaped-draft-vector-seed", + "sharedGate": "spentMainUTXOs[utxoKey]", + "currentDraftCaseIds": [ + "bip341-keypath-sighash-default-moving-funds-flow" + ], + "requiredPositiveVectors": [ + "source wallet input spent by accepted moving-funds proof", + "target wallet output set and ordering", + "moving-funds proof event correlated to the exact Bridge challenge identity" + ], + "requiredNegativeVectors": [ + "wrong target wallet ordering", + "below-dust target output", + "timeout or expired moving-funds proof state" + ], + "bridgeCorrelationRequired": [ + "Bridge.submitMovingFundsProof acceptance", + "target wallet commitment ordering", + "challenge identity for the signed source-wallet input" + ], + "draftEvidenceLimits": [ + "current draft vector proves only Bitcoin sighash/signature commitment to the moving-funds-shaped transaction", + "production approval still requires Bridge proof-event correlation" + ] + }, + { + "id": "redemption", + "status": "open", + "evidenceLevel": "flow-shaped-draft-vector-seed", + "sharedGate": "spentMainUTXOs[utxoKey]", + "currentDraftCaseIds": ["bip341-keypath-sighash-all-redemption-flow"], + "requiredPositiveVectors": [ + "one redemption output paid to the requested redeemer script", + "multiple redemption outputs plus optional wallet change", + "redemption proof event correlated to the exact Bridge challenge identity" + ], + "requiredNegativeVectors": [ + "wrong redeemer output script", + "fee-boundary mutation", + "timed-out redemption request" + ], + "bridgeCorrelationRequired": [ + "Bridge.submitRedemptionProof acceptance", + "redeemer output script matching", + "challenge identity for the signed wallet input" + ], + "draftEvidenceLimits": [ + "current draft vector proves only Bitcoin sighash/signature commitment to the redemption-shaped transaction", + "production approval still requires Bridge proof-event correlation" + ] + } + ], + "openCoverageGaps": [ + "independent Rust or Go generator", + "independent TypeScript verifier checked into CI", + "production watchtower service, idempotency storage, and Bridge submission integration", + "all tBTC spend-type vectors", + "additional SIGHASH_ALL vectors for each tBTC spend type before SIGHASH_ALL can be frozen into the final supported set", + "malformed annex and script-path rejection vectors", + "Bridge challenge/defeat/timeout/slashing lifecycle vectors" + ] +} diff --git a/pkg/tbtc/signer/test/vectors/roast-attempt-context-v1.json b/pkg/tbtc/signer/test/vectors/roast-attempt-context-v1.json new file mode 100644 index 0000000000..35c8252e1b --- /dev/null +++ b/pkg/tbtc/signer/test/vectors/roast-attempt-context-v1.json @@ -0,0 +1,70 @@ +{ + "schema_version": "roast-attempt-context-v1", + "description": "Shared cross-language conformance vectors for ROAST attempt-context fingerprint and attempt-id derivation.", + "hash_domains": { + "included_participants_fingerprint": "FROST-ROAST-INCLUDED-FPR-v1", + "attempt_id": "FROST-ROAST-ATTEMPT-ID-v1" + }, + "vectors": [ + { + "id": "vector-session-1", + "session_id": "vector-session-1", + "message_digest_hex": "5f78c33274e43fa9de5659265c1d917e25c03722dcb0b8d27db8d5feaa813953", + "attempt_number": 7, + "coordinator_identifier": 3, + "included_participants": [1, 3, 5], + "expected_included_participants_fingerprint": "0c9258935f0a30c065befcd746cb1564e9f3c91936c0f0f1c78853fa2d6713dc", + "expected_attempt_id": "dbc7a4df9bc3ef8dee3a9f5a47ff519e22e8d6f9b0461dd415077176e4e6ee95" + }, + { + "id": "vector-session-2", + "session_id": "vector-session-2", + "message_digest_hex": "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + "attempt_number": 2, + "coordinator_identifier": 2, + "included_participants": [1, 2, 4, 8], + "expected_included_participants_fingerprint": "40bee0d2446b4e50537c96440650f2a8d3bd7e83c01a49c636b183b8615ac0dd", + "expected_attempt_id": "5e31103617ae3d9b1e86ceaa0e800e0aeb271ee905a1656a17667eb14f94d20e" + }, + { + "id": "vector-session-3", + "session_id": "vector-session-3", + "message_digest_hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "attempt_number": 11, + "coordinator_identifier": 34, + "included_participants": [21, 34, 55, 89, 144], + "expected_included_participants_fingerprint": "cfacf6673c778c95facec3c8ac555c07b73ed96bf55a0b169f3cd58d5f29dd03", + "expected_attempt_id": "d543e3acea05e045fd5eff2e604dd5bf0772728e00e6dc0cf668d6d9a5071f4d" + }, + { + "id": "vector-session-4-unsorted-participants", + "session_id": "vector-session-4", + "message_digest_hex": "8d4f5c4e8ab8336f785f9cd00d8b15696f44fbb4c3e9d1d651d4d77ca04ca31e", + "attempt_number": 4, + "coordinator_identifier": 55, + "included_participants": [144, 89, 55, 34, 21], + "expected_included_participants_fingerprint": "cfacf6673c778c95facec3c8ac555c07b73ed96bf55a0b169f3cd58d5f29dd03", + "expected_attempt_id": "9b59a1a8f47e3f086a80fd591beaea6d265d7f64d82b8f2dac9a0e32983e12f1" + }, + { + "id": "vector-session-5-minimum-bounds", + "session_id": "vector-session-5", + "message_digest_hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "attempt_number": 1, + "coordinator_identifier": 1, + "included_participants": [1], + "expected_included_participants_fingerprint": "057a6aa1767f9345ce9232b7153c86a3ab6cac5b84dd4568b3bf4a467d76fb68", + "expected_attempt_id": "22bcc78202d52ebbd6a986cca7d8006b8e4636dc3c48cd946260a3125dbd2957" + }, + { + "id": "vector-session-6-max-identifier-boundary", + "session_id": "vector-session-6-max-id", + "message_digest_hex": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "attempt_number": 1, + "coordinator_identifier": 65535, + "included_participants": [1, 65535], + "expected_included_participants_fingerprint": "12a15f86ef5412385aa5a6663b4ea9f50d14f70a0397b67efdb6e1132eb1ddf9", + "expected_attempt_id": "c798a895b5a5405898c478965f1e834aec6af4458a4104fdc6bfb8436647b94f" + } + ] +} diff --git a/pkg/tbtc/signer/tests/p2tr_signature_fraud_vectors.rs b/pkg/tbtc/signer/tests/p2tr_signature_fraud_vectors.rs new file mode 100644 index 0000000000..5f10b0f25c --- /dev/null +++ b/pkg/tbtc/signer/tests/p2tr_signature_fraud_vectors.rs @@ -0,0 +1,605 @@ +use bitcoin::{ + consensus::{deserialize, encode::serialize}, + hashes::{sha256, Hash}, + secp256k1::{ + schnorr::Signature as SchnorrSignature, Message as SecpMessage, Secp256k1, XOnlyPublicKey, + }, + sighash::{Prevouts, SighashCache, TapSighashType}, + Amount, ScriptBuf, Transaction, TxOut, +}; +use serde::Deserialize; + +const SIGHASH_DEFAULT: u8 = 0; +const SIGHASH_ALL: u8 = 1; +const WITNESS_ERROR_INVALID_LENGTH: &str = "invalid-length"; +const WITNESS_ERROR_UNSUPPORTED_SIGHASH: &str = "unsupported-sighash"; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct P2trSignatureFraudVectors { + name: String, + cases: Vec, + #[serde(default)] + negative_witness_cases: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct VectorCase { + id: String, + #[serde(rename = "walletIDHex")] + wallet_id_hex: String, + wallet_p2tr_script_pub_key_hex: String, + unsigned_transaction_hex: String, + signed_input_index: usize, + prevouts: Vec, + outputs: Vec, + sighash_type: u8, + expected_bip341_sighash_hex: String, + bip340_signature_hex: String, + witness_signature_hex: String, + expected_draft_challenge_identity_hex: String, + expected_bridge_challenge_identity_hex: String, + expected_verify: bool, + #[serde(default)] + negative_verification_cases: Vec, + #[serde(default)] + negative_sighash_cases: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Prevout { + txid_hex: String, + vout: u32, + value_sats: u64, + script_pub_key_hex: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Output { + value_sats: u64, + script_pub_key_hex: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NegativeVerificationCase { + id: String, + #[serde(rename = "walletIDHex")] + wallet_id_hex: Option, + bip341_sighash_hex: Option, + bip340_signature_hex: Option, + expected_verify: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NegativeSighashCase { + id: String, + unsigned_transaction_hex: Option, + prevouts: Option>, + outputs: Option>, + expected_verify: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NegativeWitnessCase { + id: String, + base_case_id: String, + witness_signature_hex: String, + expected_error: String, +} + +fn decode_hex(hex_value: &str, context: &str) -> [u8; N] { + let bytes = hex::decode(hex_value).unwrap_or_else(|e| panic!("{context}: invalid hex: {e}")); + bytes.try_into().unwrap_or_else(|bytes: Vec| { + panic!("{context}: expected {N} bytes, got {}", bytes.len()) + }) +} + +fn decode_vec(hex_value: &str, context: &str) -> Vec { + hex::decode(hex_value).unwrap_or_else(|e| panic!("{context}: invalid hex: {e}")) +} + +fn tap_sighash_type(raw: u8, context: &str) -> TapSighashType { + match raw { + SIGHASH_DEFAULT => TapSighashType::Default, + SIGHASH_ALL => TapSighashType::All, + _ => panic!("{context}: unsupported Taproot sighash type {raw}"), + } +} + +fn parse_unsigned_transaction(case: &VectorCase) -> Transaction { + let tx_bytes = decode_vec(&case.unsigned_transaction_hex, &case.id); + deserialize(&tx_bytes).unwrap_or_else(|e| panic!("{}: transaction decode failed: {e}", case.id)) +} + +fn validate_prevout_metadata(case: &VectorCase, transaction: &Transaction) { + assert_eq!( + case.prevouts.len(), + transaction.input.len(), + "{}: prevout count must match transaction input count", + case.id + ); + + for (index, (prevout, input)) in case + .prevouts + .iter() + .zip(transaction.input.iter()) + .enumerate() + { + assert_eq!( + prevout.txid_hex, + input.previous_output.txid.to_string(), + "{}: prevout {index} txid mismatch", + case.id + ); + assert_eq!( + prevout.vout, input.previous_output.vout, + "{}: prevout {index} vout mismatch", + case.id + ); + } +} + +fn validate_outputs(case: &VectorCase, transaction: &Transaction) { + assert_eq!( + case.outputs.len(), + transaction.output.len(), + "{}: output count must match transaction output count", + case.id + ); + + for (index, (expected, actual)) in case + .outputs + .iter() + .zip(transaction.output.iter()) + .enumerate() + { + assert_eq!( + expected.value_sats, + actual.value.to_sat(), + "{}: output {index} value mismatch", + case.id + ); + assert_eq!( + decode_vec( + &expected.script_pub_key_hex, + &format!("{} output {index}", case.id) + ), + actual.script_pubkey.as_bytes(), + "{}: output {index} scriptPubKey mismatch", + case.id + ); + } +} + +fn prevout_txouts(case: &VectorCase) -> Vec { + case.prevouts + .iter() + .enumerate() + .map(|(index, prevout)| TxOut { + value: Amount::from_sat(prevout.value_sats), + script_pubkey: ScriptBuf::from_bytes(decode_vec( + &prevout.script_pub_key_hex, + &format!("{} prevout {index}", case.id), + )), + }) + .collect() +} + +fn compute_bip341_key_path_sighash(case: &VectorCase) -> [u8; 32] { + let transaction = parse_unsigned_transaction(case); + validate_prevout_metadata(case, &transaction); + validate_outputs(case, &transaction); + + let prevouts = prevout_txouts(case); + let sighash = SighashCache::new(&transaction) + .taproot_key_spend_signature_hash( + case.signed_input_index, + &Prevouts::All(&prevouts), + tap_sighash_type(case.sighash_type, &case.id), + ) + .unwrap_or_else(|e| panic!("{}: Taproot sighash failed: {e}", case.id)); + + sighash.to_byte_array() +} + +fn encode_compact_size(value: usize) -> Vec { + if value < 0xfd { + return vec![value as u8]; + } + if value <= 0xffff { + let mut bytes = vec![0xfd]; + bytes.extend_from_slice(&(value as u16).to_le_bytes()); + return bytes; + } + if value <= 0xffff_ffff { + let mut bytes = vec![0xfe]; + bytes.extend_from_slice(&(value as u32).to_le_bytes()); + return bytes; + } + + let mut bytes = vec![0xff]; + bytes.extend_from_slice(&(value as u64).to_le_bytes()); + bytes +} + +fn push_len_prefixed(preimage: &mut Vec, bytes: &[u8]) { + preimage.extend_from_slice(&encode_compact_size(bytes.len())); + preimage.extend_from_slice(bytes); +} + +fn derive_draft_challenge_identity( + case: &VectorCase, + sighash: [u8; 32], + signature: &[u8], +) -> [u8; 32] { + let mut preimage = Vec::new(); + preimage.extend_from_slice(b"tbtc-p2tr-signature-fraud-challenge-v0"); + preimage.extend_from_slice(&decode_vec(&case.wallet_id_hex, &case.id)); + preimage.extend_from_slice(&sighash); + preimage.extend_from_slice(signature); + preimage.push(case.sighash_type); + let input_index = u32::try_from(case.signed_input_index) + .unwrap_or_else(|_| panic!("{}: signed input index exceeds u32", case.id)); + preimage.extend_from_slice(&input_index.to_le_bytes()); + + let tx_bytes = decode_vec(&case.unsigned_transaction_hex, &case.id); + push_len_prefixed(&mut preimage, &tx_bytes); + preimage.extend_from_slice(&encode_compact_size(case.prevouts.len())); + + for (index, prevout) in case.prevouts.iter().enumerate() { + preimage.extend_from_slice(&decode_vec( + &prevout.txid_hex, + &format!("{} prevout {index} txid", case.id), + )); + preimage.extend_from_slice(&prevout.vout.to_le_bytes()); + preimage.extend_from_slice(&prevout.value_sats.to_le_bytes()); + push_len_prefixed( + &mut preimage, + &decode_vec( + &prevout.script_pub_key_hex, + &format!("{} prevout {index} script", case.id), + ), + ); + } + + sha256::Hash::hash(&preimage).to_byte_array() +} + +fn derive_bridge_challenge_identity( + case: &VectorCase, + sighash: [u8; 32], + signature: &[u8], +) -> [u8; 32] { + let transaction = parse_unsigned_transaction(case); + let mut preimage = Vec::new(); + preimage.extend_from_slice(b"tbtc-p2tr-signature-fraud-bridge-challenge-v0"); + preimage.extend_from_slice(&decode_vec(&case.wallet_id_hex, &case.id)); + preimage.extend_from_slice(&sighash); + preimage.extend_from_slice(signature); + preimage.push(case.sighash_type); + let input_index = u32::try_from(case.signed_input_index) + .unwrap_or_else(|_| panic!("{}: signed input index exceeds u32", case.id)); + preimage.extend_from_slice(&input_index.to_le_bytes()); + preimage.extend_from_slice(&serialize(&transaction.version)); + preimage.extend_from_slice(&serialize(&transaction.lock_time)); + + preimage.extend_from_slice(&encode_compact_size(transaction.input.len())); + for input in &transaction.input { + preimage.extend_from_slice(&serialize(&input.previous_output)); + preimage.extend_from_slice(&serialize(&input.sequence)); + } + + let prevouts = prevout_txouts(case); + preimage.extend_from_slice(&encode_compact_size(prevouts.len())); + for prevout in &prevouts { + preimage.extend_from_slice(&serialize(prevout)); + } + + preimage.extend_from_slice(&encode_compact_size(transaction.output.len())); + for output in &transaction.output { + preimage.extend_from_slice(&serialize(output)); + } + + sha256::Hash::hash(&preimage).to_byte_array() +} + +fn verify_bip340(message: [u8; 32], wallet_id: &[u8], signature: &[u8]) -> bool { + let Ok(public_key) = XOnlyPublicKey::from_slice(wallet_id) else { + return false; + }; + let Ok(signature) = SchnorrSignature::from_slice(signature) else { + return false; + }; + let Ok(message) = SecpMessage::from_digest_slice(&message) else { + return false; + }; + + Secp256k1::verification_only() + .verify_schnorr(&signature, &message, &public_key) + .is_ok() +} + +fn parse_witness_signature( + witness_signature_hex: &str, + context: &str, +) -> Result<(Vec, u8), &'static str> { + let witness_signature = decode_vec(witness_signature_hex, context); + + if witness_signature.len() == 64 { + return Ok((witness_signature, SIGHASH_DEFAULT)); + } + + if witness_signature.len() != 65 { + return Err(WITNESS_ERROR_INVALID_LENGTH); + } + + let sighash_type = witness_signature[64]; + if sighash_type == SIGHASH_DEFAULT { + return Err(WITNESS_ERROR_UNSUPPORTED_SIGHASH); + } + if sighash_type != SIGHASH_ALL { + return Err(WITNESS_ERROR_UNSUPPORTED_SIGHASH); + } + + Ok((witness_signature[..64].to_vec(), sighash_type)) +} + +fn mutate_last_byte_hex(value: &str) -> String { + let replacement = if value.ends_with("00") { "01" } else { "00" }; + format!("{}{}", &value[..value.len() - 2], replacement) +} + +fn with_negative_sighash_case(base: &VectorCase, negative: &NegativeSighashCase) -> VectorCase { + let mut mutated = base.clone(); + mutated.id = format!("{}/{}", base.id, negative.id); + if let Some(unsigned_transaction_hex) = &negative.unsigned_transaction_hex { + mutated.unsigned_transaction_hex = unsigned_transaction_hex.clone(); + } + if let Some(prevouts) = &negative.prevouts { + mutated.prevouts = prevouts.clone(); + } + if let Some(outputs) = &negative.outputs { + mutated.outputs = outputs.clone(); + } + mutated +} + +#[test] +fn formal_verification_p2tr_signature_fraud_vectors_match_bitcoin_crate() { + let vectors_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("test/vectors/p2tr-signature-fraud-v0.json"); + let vectors_bytes = + std::fs::read(&vectors_path).unwrap_or_else(|e| panic!("read {vectors_path:?}: {e}")); + let vectors: P2trSignatureFraudVectors = + serde_json::from_slice(&vectors_bytes).expect("P2TR signature-fraud vectors decode"); + + assert_eq!(vectors.name, "p2tr-signature-fraud-v0"); + + let mut verified = 0usize; + let mut challenge_identities = std::collections::HashSet::new(); + let mut bridge_challenge_identities = std::collections::HashSet::new(); + let mut sighash_types = std::collections::HashSet::new(); + let mut witness_sighash_types = std::collections::HashSet::new(); + let case_ids: std::collections::HashSet<_> = + vectors.cases.iter().map(|case| case.id.as_str()).collect(); + for case in &vectors.cases { + let wallet_id = decode_vec(&case.wallet_id_hex, &case.id); + let mut expected_wallet_script = vec![0x51, 0x20]; + expected_wallet_script.extend_from_slice(&wallet_id); + assert_eq!( + expected_wallet_script, + decode_vec(&case.wallet_p2tr_script_pub_key_hex, &case.id), + "{}: wallet script must be OP_1 x-only wallet ID", + case.id + ); + + let actual_sighash = compute_bip341_key_path_sighash(case); + sighash_types.insert(case.sighash_type); + let expected_sighash = decode_hex::<32>(&case.expected_bip341_sighash_hex, &case.id); + assert_eq!( + expected_sighash, actual_sighash, + "{}: sighash mismatch", + case.id + ); + + let signature = decode_vec(&case.bip340_signature_hex, &case.id); + let (witness_signature, witness_sighash_type) = + parse_witness_signature(&case.witness_signature_hex, &case.id) + .unwrap_or_else(|e| panic!("{}: witness signature rejected with {e}", case.id)); + assert_eq!( + signature, witness_signature, + "{}: witness signature does not match BIP-340 signature", + case.id + ); + assert_eq!( + case.sighash_type, witness_sighash_type, + "{}: witness sighash type mismatch", + case.id + ); + witness_sighash_types.insert(witness_sighash_type); + + assert_eq!( + case.expected_verify, + verify_bip340(actual_sighash, &wallet_id, &signature), + "{}: BIP-340 verification mismatch", + case.id + ); + verified += 1; + + let challenge_identity = derive_draft_challenge_identity(case, actual_sighash, &signature); + assert_eq!( + decode_hex::<32>(&case.expected_draft_challenge_identity_hex, &case.id), + challenge_identity, + "{}: draft challenge identity mismatch", + case.id + ); + assert!( + challenge_identities.insert(challenge_identity), + "{}: duplicate draft challenge identity", + case.id + ); + + let bridge_challenge_identity = + derive_bridge_challenge_identity(case, actual_sighash, &signature); + assert_eq!( + decode_hex::<32>(&case.expected_bridge_challenge_identity_hex, &case.id), + bridge_challenge_identity, + "{}: Bridge challenge identity mismatch", + case.id + ); + assert!( + bridge_challenge_identities.insert(bridge_challenge_identity), + "{}: duplicate Bridge challenge identity", + case.id + ); + + if case.id == "bip341-keypath-sighash-default-single-input" { + let mut wrong_wallet_case = case.clone(); + wrong_wallet_case.wallet_id_hex = mutate_last_byte_hex(&case.wallet_id_hex); + assert_ne!( + challenge_identity, + derive_draft_challenge_identity(&wrong_wallet_case, actual_sighash, &signature), + "{}: draft challenge identity must commit to wallet ID", + case.id + ); + + let wrong_sighash = decode_hex::<32>( + &mutate_last_byte_hex(&case.expected_bip341_sighash_hex), + &case.id, + ); + assert_ne!( + challenge_identity, + derive_draft_challenge_identity(case, wrong_sighash, &signature), + "{}: draft challenge identity must commit to sighash", + case.id + ); + + let wrong_signature = + decode_vec(&mutate_last_byte_hex(&case.bip340_signature_hex), &case.id); + assert_ne!( + challenge_identity, + derive_draft_challenge_identity(case, actual_sighash, &wrong_signature), + "{}: draft challenge identity must commit to signature", + case.id + ); + + let mut wrong_sighash_type_case = case.clone(); + wrong_sighash_type_case.sighash_type = if case.sighash_type == SIGHASH_DEFAULT { + SIGHASH_ALL + } else { + SIGHASH_DEFAULT + }; + assert_ne!( + challenge_identity, + derive_draft_challenge_identity( + &wrong_sighash_type_case, + actual_sighash, + &signature + ), + "{}: draft challenge identity must commit to sighash type", + case.id + ); + + let mut wrong_transaction_case = case.clone(); + wrong_transaction_case.unsigned_transaction_hex = + mutate_last_byte_hex(&case.unsigned_transaction_hex); + assert_ne!( + challenge_identity, + derive_draft_challenge_identity( + &wrong_transaction_case, + actual_sighash, + &signature + ), + "{}: draft challenge identity must commit to raw transaction", + case.id + ); + } + + for negative in &case.negative_verification_cases { + let negative_wallet_id = negative + .wallet_id_hex + .as_ref() + .map(|value| decode_vec(value, &format!("{}/{}", case.id, negative.id))) + .unwrap_or_else(|| wallet_id.clone()); + let negative_message = negative + .bip341_sighash_hex + .as_ref() + .map(|value| decode_hex::<32>(value, &format!("{}/{}", case.id, negative.id))) + .unwrap_or(actual_sighash); + let negative_signature = negative + .bip340_signature_hex + .as_ref() + .map(|value| decode_vec(value, &format!("{}/{}", case.id, negative.id))) + .unwrap_or_else(|| signature.clone()); + + assert_eq!( + negative.expected_verify, + verify_bip340(negative_message, &negative_wallet_id, &negative_signature), + "{}/{}: negative BIP-340 verification mismatch", + case.id, + negative.id + ); + verified += 1; + } + + for negative in &case.negative_sighash_cases { + let negative_case = with_negative_sighash_case(case, negative); + let negative_sighash = compute_bip341_key_path_sighash(&negative_case); + assert_ne!( + actual_sighash, negative_sighash, + "{}: negative sighash did not change", + negative_case.id + ); + assert_eq!( + negative.expected_verify, + verify_bip340(negative_sighash, &wallet_id, &signature), + "{}: negative sighash verification mismatch", + negative_case.id + ); + verified += 1; + } + } + + let mut negative_witnesses = 0usize; + for negative in &vectors.negative_witness_cases { + assert!( + case_ids.contains(negative.base_case_id.as_str()), + "{}: unknown baseCaseId", + negative.id + ); + + let actual_error = parse_witness_signature(&negative.witness_signature_hex, &negative.id) + .expect_err("negative witness signature was accepted"); + assert_eq!( + negative.expected_error, actual_error, + "{}: negative witness parser error mismatch", + negative.id + ); + negative_witnesses += 1; + } + + assert_eq!(verified, 32); + assert!( + sighash_types.contains(&SIGHASH_DEFAULT), + "missing required SIGHASH_DEFAULT vector" + ); + assert!( + sighash_types.contains(&SIGHASH_ALL), + "missing required SIGHASH_ALL vector" + ); + assert!( + witness_sighash_types.contains(&SIGHASH_DEFAULT), + "missing required SIGHASH_DEFAULT witness vector" + ); + assert!( + witness_sighash_types.contains(&SIGHASH_ALL), + "missing required SIGHASH_ALL witness vector" + ); + assert_eq!(negative_witnesses, 4); +}