diff --git a/.github/workflows/tbtc-signer-formal.yml b/.github/workflows/tbtc-signer-formal.yml new file mode 100644 index 0000000000..5952291a85 --- /dev/null +++ b/.github/workflows/tbtc-signer-formal.yml @@ -0,0 +1,116 @@ +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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Setup Rust + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + # Name the toolchain explicitly so it is self-documenting and + # independent of the pinned action's default. (This action version + # already defaults `toolchain` to `stable`; older versions instead + # derived it from the action ref, which would resolve to the SHA + # under this supply-chain pin -- so being explicit is the safe form.) + toolchain: stable + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --manifest-path pkg/tbtc/signer/Cargo.toml -- --check + + - name: Run clippy + run: cargo clippy --locked --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 --locked --manifest-path pkg/tbtc/signer/Cargo.toml + + signer-dependency-audit: + name: Signer dependency audit + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Check RustSec advisories + # Blocking gate: a newly-published advisory against any locked + # dependency fails the build. Accepted/unfixable advisories are + # recorded with rationale in pkg/tbtc/signer/deny.toml. + uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20 + with: + manifest-path: pkg/tbtc/signer/Cargo.toml + command: check advisories + + signer-formal-invariants: + name: Signer formal invariants + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Setup Rust + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + # Explicit toolchain, independent of the pinned action's default + # (see the Setup Rust step above). + 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 --locked --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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Setup Java + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + 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/bitcoin/electrum/electrum_integration_test.go b/pkg/bitcoin/electrum/electrum_integration_test.go index d0f536c458..a8286c4ada 100644 --- a/pkg/bitcoin/electrum/electrum_integration_test.go +++ b/pkg/bitcoin/electrum/electrum_integration_test.go @@ -84,7 +84,7 @@ var testConfigs = map[string]testConfig{ }, "fulcrum tcp": { clientConfig: electrum.Config{ - URL: "tcp://v22019051929289916.bestsrv.de:50001", + URL: "tcp://blackie.c3-soft.com:57005", RequestTimeout: requestTimeout * 2, RequestRetryTimeout: requestRetryTimeout * 2, }, @@ -113,6 +113,7 @@ func init() { URL: server, RequestTimeout: requestTimeout, RequestRetryTimeout: requestRetryTimeout, + Network: network, }, network: network, } @@ -158,9 +159,26 @@ func init() { } } +func TestConfigsCarryNetwork_Integration(t *testing.T) { + for name, testConfig := range testConfigs { + t.Run(name, func(t *testing.T) { + if testConfig.network == bitcoin.Unknown { + t.Fatal("test network must be explicit") + } + if testConfig.clientConfig.Network != testConfig.network { + t.Fatalf( + "unexpected client network [%v]; expected [%v]", + testConfig.clientConfig.Network, + testConfig.network, + ) + } + }) + } +} + func TestConnect_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - _, cancelCtx := newTestConnection(t, testConfig.clientConfig) + _, cancelCtx := newRequiredTestConnection(t, testConfig.clientConfig) defer cancelCtx() }) } @@ -640,9 +658,32 @@ func runParallel(t *testing.T, runFunc func(t *testing.T, testConfig testConfig) } func newTestConnection(t *testing.T, config electrum.Config) (bitcoin.Chain, context.CancelFunc) { + t.Helper() + + return connectTestConnection(t, config, true) +} + +func newRequiredTestConnection(t *testing.T, config electrum.Config) (bitcoin.Chain, context.CancelFunc) { + t.Helper() + + return connectTestConnection(t, config, false) +} + +func connectTestConnection( + t *testing.T, + config electrum.Config, + skipTransientConnectionError bool, +) (bitcoin.Chain, context.CancelFunc) { + t.Helper() + ctx, cancelCtx := context.WithCancel(context.Background()) electrum, err := electrum.Connect(ctx, config) if err != nil { + cancelCtx() + if skipTransientConnectionError && shouldSkipElectrumIntegrationError(err) { + t.Skipf("skipping due to transient electrum connection error: %v", err) + } + t.Fatal(err) } 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..34d2aff98d --- /dev/null +++ b/pkg/tbtc/signer/Cargo.lock @@ -0,0 +1,1732 @@ +# 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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81ef2787af391c7e8bedc037a3b9ea03dde803fbd93e778e6bb369547800e5cd" +dependencies = [ + "byteorder", + "const-crc32-nostd", + "derive-getters", + "document-features", + "hex", + "itertools 0.14.0", + "postcard", + "rand_core 0.6.4", + "serde", + "serdect", + "thiserror", + "visibility", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "frost-rerandomized" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4c5cedd2426728adef2c0b1720f57676354c473836d1ccc50d0f0d1c91942b" +dependencies = [ + "derive-getters", + "document-features", + "frost-core", + "hex", + "rand_core 0.6.4", +] + +[[package]] +name = "frost-secp256k1-tr" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fca4ca9f057cf22066f3eb5bbda59d2af51f429f2aac5982a11a8a841c0993" +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-core", + "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..29e9ff10e2 --- /dev/null +++ b/pkg/tbtc/signer/Cargo.toml @@ -0,0 +1,36 @@ +[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" +# Direct, version-matched access to aggregate_custom + CheaterDetection (the +# frost-secp256k1-tr aggregate wrappers hardcode FirstCheater). Already a +# transitive dependency via frost-secp256k1-tr, pinned to the same 3.0.0. +frost-core = { version = "=3.0.0", default-features = false } +chacha20poly1305 = "0.10" +rand_chacha = "0.3" +libc = "0.2" +zeroize = { version = "1.8", default-features = false, features = ["alloc", "serde"] } +bitcoin = "0.32" + +[dev-dependencies] +criterion = "0.5" +pretty_assertions = "1.4" +proptest = "1.6" diff --git a/pkg/tbtc/signer/README.md b/pkg/tbtc/signer/README.md new file mode 100644 index 0000000000..3306aac44f --- /dev/null +++ b/pkg/tbtc/signer/README.md @@ -0,0 +1,555 @@ +# 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` (symbol retained in ABI 4.0, but fail-closed with + `cryptographic_refresh_not_supported` until a multi-round FROST refresh + protocol is implemented; metadata from the retired synthetic stub cannot + postpone cadence or establish key continuity, and an unanchored legacy + refresh-only session is immediately overdue) +- Exposes fine-grained interactive (member-custodied nonce) signing via: + - `InteractiveSessionOpen` + - `InteractiveRound1` + - `InteractiveRound2` + - `InteractiveSessionAbort` + - `InteractiveAggregate` + + Round-1 nonces live only in engine memory and never persist; the engine + enforces a per-node live-session cap and an inactivity TTL, and the open + path is idempotent per `(session_id, attempt_id, member_identifier)` with + consumption markers as the only durable artifact. +- 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`. + +The `dao_override_trust_root_pubkey_hex` value in +`scripts/admission-policy-v1.sample.json` is a non-functional placeholder +(syntactically valid hex, but not a real key). Replace it with the governance +trust root's real x-only Schnorr public key (32 bytes / 64 hex chars) before +enabling overrides; the placeholder fails closed as an invalid trust root. + +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` + +## Init-Time Configuration (`frost_tbtc_init_signer_config`) + +Hosts should install the signer's operational configuration once at startup +via `frost_tbtc_init_signer_config` instead of exporting `TBTC_SIGNER_*` +environment variables. The request is a JSON object whose field names are the +lowercased `TBTC_SIGNER_*` suffixes (`{"profile": "production", +"roast_coordinator_timeout_ms": 30000, ...}`). + +Semantics: + +- Once installed, the process environment is **not consulted** for any + covered knob: an unset field means the built-in default, not the + environment value. There is no per-knob mixing of the two sources. +- Unknown field names fail the init (typos cannot silently fall back to + defaults in production). +- Re-initialization with an identical request is idempotent; a conflicting + request is rejected. +- The init validates enforcement-gated policy combinations (admission, + signing-policy firewall, auto-quarantine) plus the provenance gate, so a + misconfigured signer fails at startup rather than at first signing. Production + forces both the provenance gate and the signing-policy firewall; the firewall + resolves to conservative built-in defaults (standard tBTC script classes, + permissive numeric caps) so it needs no extra config to boot, while the + provenance gate requires production configs to carry a + complete attestation set (`provenance_attestation_status`/`_payload`/ + `_signature_hex`, `provenance_trust_root`, `min_approved_version`); the + init-time pass does not exempt runtime re-checks — attestation TTL aging + still applies per call. +- **Secrets never ride the config FFI**: `TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX` + is read exclusively from the dedicated key-provider channel below, even + when a config is installed. Do not inline key material into the + `state_key_command` string either — have the command fetch the secret — + because the command string itself is part of the config request. +- A failed init has no observable side effects: the candidate config is + validated privately before it is published, so concurrent callers can + never read a config that is later rejected. +- Production configs (explicitly `"profile": "production"`, or by omission — + production is the default) must set `state_path`; the init rejects them + otherwise. The init also rejects structurally unusable key-provider + settings (production forbids the `env` provider, so production configs + must set `state_key_provider: "command"` plus `state_key_command`) — + validated without reading the secret or executing the key command. Install the config before the first state-touching call: once + the state-file lock is bound, the engine refuses to switch state paths + in-process. + +Without an installed config the signer falls back to reading the +`TBTC_SIGNER_*` environment (development/test behavior); in non-development +profiles this fallback logs a one-time warning. + +## 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. +- Upgrade reports `duplicate persisted DKG key_group [...] owned by sessions [...]`: + stop the signer and preserve the + encrypted state plus its matching key material. ABI-3 deliberately rejects + legacy state that stores one wallet group under multiple session IDs because + automatically choosing a copy could drop seats or a lifecycle kill switch. + There is no online deduplication path. Restore a known-good pre-upgrade backup + and regenerate a single canonical wallet session (re-DKG when necessary), or + run an approved offline migration that explicitly merges all local seats and + lifecycle events before re-encrypting the state. Do not delete an arbitrary + duplicate and restart. + +## Benchmarks + +The coarse-path `phase5_roast` Criterion target was retired with +StartSignRound/FinalizeSignRound. There is currently no supported benchmark +command. Promotion evidence comes from fresh interactive latency windows and the +exact-filter chaos suite below; do not archive a missing benchmark as a passed +rollout gate. + +## 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_interactive_attempt_replay`: a newer member attempt replaces only its + own live state and a stale reopen fails closed. +- `round2_state_key_outage_recovery`: a state-key failure releases no share, + does not burn the attempt, and permits retry. +- `process_restart_consumed_attempt`: a consumed interactive attempt marker + rejects replay across a simulated restart. +- `round2_persist_fault_pre_rename`: a pre-rename persist fault releases no + share, rolls back the marker, and preserves retry. +- `round2_persist_fault_post_rename`: an after-rename persist fault releases no + share, consumes the attempt, destroys live nonces, and survives a simulated + restart before any successful repair. +- `aggregate_persist_fault_post_rename`: an after-rename aggregate fault retains + completion, destroys sibling nonces, and survives a simulated restart before + any successful repair. + +`PersistDistributedDkgKeyPackage` has no cached-success fast path. A +pre-replacement error restores the prior seat/session state; a post-replacement +error is never acknowledged as success, and every caller retry executes another +complete state snapshot. Hosts must retry a reported DKG persistence error before +treating that DKG seat as installed. + +The wider unit suite also injects pre-replacement failures into transaction +builds, distributed-DKG persistence, refresh, rollout, and emergency rekey. It +verifies operation-specific pending results: retries repair persistence before +returning cached success, and one healthy operation cannot erase another +operation's retry record. + +Post-rename fault tests model a process restart after the replacement file is +visible. They do not emulate sudden power loss that also loses an unsynced +directory entry; operators must use the supported local durable filesystem and +storage guarantees for that hardware-level failure boundary. + +## 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`: the current operation/session cannot succeed in this runtime; + retry only after changing runtime capability or starting the documented + replacement flow. +- `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 and the separate + `heartbeat_signing_policy_reject_total` counter + - 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`. The coarse + start/finalize fields are retained for ABI compatibility; production + signing also reports `interactive_round1`, `interactive_round2`, and + `interactive_aggregate` latency over each operation's full retained rolling + window of calls, including failed and idempotent calls, preserving the ABI-3 + metric contract. Canary promotion reads separate internal evidence windows + containing only fresh, successful samples from the current rollout stage. +- 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`; defaults to the standard tBTC output forms + `p2pkh,p2sh,p2wpkh,p2wsh,p2tr` when unset, failing closed on other forms) + - `TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT` (defaults to a conservative built-in + bound when unset) + - `TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS` (defaults to permissive/unbounded + when unset; operators should tighten per wallet sizing) + - `TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS` (defaults to + permissive/unbounded when unset) + - `TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR` / `TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR` + - Equal start/end bounds are rejected; omit both variables for an unrestricted + 24-hour window. + - `TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE` + - `TBTC_SIGNER_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE` (positive per-wallet + accepted-Open limit; defaults to `60` per minute). Heartbeat and build-tx + budgets are independent. A new non-idempotent heartbeat Open charges one + token; its exact Open retry and Round2 policy recheck do not. + - Signing-path binding: each input to `BuildTaprootTx` carries its spent + output's `script_pubkey_hex` alongside `value_sats`. The result records one + BIP-341 key-spend `SIGHASH_DEFAULT` message per input, in input order. + `InteractiveSessionOpen.message_hex` must match one of those messages from + the same per-signing `session_id`, and `InteractiveRound2` reruns the binding + immediately before releasing a share. + - ABI 3.1 adds the only non-transaction authorization: an optional, typed + heartbeat intent on Interactive Open. The signer decodes the raw message as + exactly 16 bytes, requires the protocol's eight-byte `ff` prefix, rejects a + Taproot root or simultaneous transaction artifact, independently derives + its Hash256 signing message, and repeats that check at Round2. The intent is + transient with the live nonce state, so restart requires a fresh Open. + ABI 3.2 adds the independent per-wallet heartbeat rate-limit config and + dedicated heartbeat policy-rejection metric. + - ABI 4.0 reserves `RefreshShares` as fail-closed until a real multi-round, + zero-constant FROST refresh protocol exists. Because valid refresh requests + now return terminal `cryptographic_refresh_not_supported` instead of a + synthetic success result, ABI-3 bridges must reject this library during + compatibility negotiation. + - ABI-3 migration is intentionally fail closed. A pre-ABI-3 in-flight ROAST + session has no stored BIP-341 sighashes and must be abandoned and restarted + under a fresh `session_id`; its cached fingerprint cannot be upgraded in + place. Each transaction input is bound in its own signing session by the Go + host, so an N-input sweep consumes N policy rate-limit tokens. + - The current FROST wallet executor accepts only all-P2TR input sets. This + matches `BuildTaprootTx`'s P2TR-prevout requirement; mixed legacy/Taproot + transactions require a separate hybrid signature-assembly design. + - Open and Round2 also deserialize the cached transaction and rerun the active + non-rate script-class, output-count/value, and UTC-window policy. A restart + under stricter policy therefore cannot sign an older accepted artifact. + The ordered sighash list itself is trusted only as part of the authenticated + AEAD state envelope; its shape is checked at both release gates, while the + input prevouts are not duplicated in persisted session state. + - `BuildTaprootTx` currently accepts caller-derived output scripts and P2TR + prevout scripts; 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_INTERACTIVE_ROUND1_P95_MS` + - `TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND2_P95_MS` + - `TBTC_SIGNER_CANARY_MAX_INTERACTIVE_AGGREGATE_P95_MS` + - `TBTC_SIGNER_CANARY_MIN_SAMPLES` (interactive-operation minimum; + default `100`, maximum `256`) + - `TBTC_SIGNER_CANARY_MIN_POLICY_SAMPLES` (first-time + `BuildTaprootTx` policy-decision minimum; defaults to the interactive + minimum when unset, maximum `256`) + - `TBTC_SIGNER_CANARY_MAX_SAMPLE_AGE_SECONDS` (default `3600`, maximum + `604800`) + - `TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS` + - Legacy `...MAX_START_SIGN_ROUND_P95_MS` and + `...MAX_FINALIZE_SIGN_ROUND_P95_MS` remain fallback aliases when the + corresponding interactive knob is absent, malformed, or non-positive. + The old start threshold is applied independently to Round1 and Round2; + prefer the explicit knobs. Positive sample-count and sample-age values + above their supported maxima clamp to those maxima; malformed and zero + values retain the documented default/fallback behavior. + - Promotion requires independently configured minimum fresh sample counts + for each interactive operation and for first-time `BuildTaprootTx` policy + outcomes. Rejections and idempotent replays do not dilute latency/policy + windows. Evidence age uses the process monotonic clock; Unix timestamps are + retained only for diagnostic reporting. Evidence resets after every + promotion or rollback, and a process restart leaves the next promotion + blocked until the current stage collects fresh evidence. +- Known limitations (P0 scope): + - Policy gates default to disabled in non-production profiles + (provenance/admission/signing enforcement gates require explicit `=true` + env vars). In a production profile the provenance gate and the + signing-policy firewall are force-enabled regardless. +- `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/build.sh b/pkg/tbtc/signer/build.sh new file mode 100644 index 0000000000..7c0fb87b70 --- /dev/null +++ b/pkg/tbtc/signer/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# --locked: build strictly against the committed Cargo.lock so a release +# binary is never produced from an unaudited, re-resolved dependency set. +cargo build --release --locked diff --git a/pkg/tbtc/signer/deny.toml b/pkg/tbtc/signer/deny.toml new file mode 100644 index 0000000000..1a9df7094e --- /dev/null +++ b/pkg/tbtc/signer/deny.toml @@ -0,0 +1,21 @@ +# cargo-deny configuration for the tbtc-signer crate. +# +# CI runs `cargo deny check advisories` as a blocking gate (see +# .github/workflows/tbtc-signer-formal.yml) so a newly-published RustSec +# advisory against any of the ~190 locked dependencies fails the build. This +# is the dependency-scanning gate that complements the external cryptographic +# audit tracked in docs/roast-phase-5-security-rollout-gates.md (Gate 1). +# +# Only the advisories section is enforced here; licenses/bans/sources are +# intentionally left at defaults to keep this gate scoped to security and +# maintenance advisories and avoid unrelated CI churn. + +[advisories] +ignore = [ + # atomic-polyfill is unmaintained (informational advisory, not a security + # vulnerability). It is pulled in transitively via + # frost-core -> postcard -> heapless 0.7 and has no safe upgrade available + # until that chain moves off heapless 0.7. Accepted as a known risk; revisit + # when frost-core updates its heapless dependency. + { id = "RUSTSEC-2023-0089", reason = "unmaintained-only; transitive via frost-core/heapless 0.7; no fix available upstream yet" }, +] 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..ecece91f6f --- /dev/null +++ b/pkg/tbtc/signer/docs/formal/models/README.md @@ -0,0 +1,74 @@ +# 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` in `src/engine/roast.rs`; replay guards in + start/finalize flow in `src/engine/signing.rs`. +- `StateKeyProviderPolicy.tla`: + `LoadSuccessImpliesExactBinding`, `FailClosedDisallowedProvider` -> + `decode_encrypted_state_envelope`, `encode_encrypted_state_envelope` in + `src/engine/persistence.rs`. +- `TeeEnforcementModes.tla`: + `EnforceModeRequiresValidAttestationWithoutOverride`, + `NoDirectDisabledToEnforceTransition` -> policy design in + `pkg/tbtc/signer/docs/tee-whitelisted-signer-enforcement-plan.md`. +- `RoastRolloutPolicy.tla`: + `BroadRequiresCanaryHistory`, `RollbackTransitionRequiresTrigger`, + `CanaryHoldBlocksPromotion`, `BootstrapCannotJumpToBroad`, + `EmergencyStopBlocksForwardProgress`, `HaltedModeIsTerminal` -> + `pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md`. + +Implementation status (read before trusting a green check): + +- **Implemented** — invariants trace to shipped code: + - `RoastAttemptStateMachine.tla` -> `src/engine/roast.rs`, `src/engine/signing.rs`. + - `StateKeyProviderPolicy.tla` -> `src/engine/persistence.rs`. +- **Planned / not yet implemented** — invariants trace to design docs, not code: + - `TeeEnforcementModes.tla` and `RoastRolloutPolicy.tla` model the three-mode + (`disabled`/`audit`/`enforce`) + break-glass enforcement profile and the + staged rollout/rollback policy. The shipped signer implements only a binary + provenance enforce gate (`src/engine/provenance.rs`) and has no audit-mode + ramp, break-glass path, or rollout state machine. Both trace to plans that + are explicitly "not active" future hardening profiles + (`tee-whitelisted-signer-enforcement-plan.md`, + `roast-phase-5-security-rollout-gates.md`). + +A passing TLC run proves each model is internally consistent; for the two +planned models it does **not** prove the shipped signer enforces that behavior. + +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..d92c051427 --- /dev/null +++ b/pkg/tbtc/signer/docs/formal/models/RoastRolloutPolicy.tla @@ -0,0 +1,129 @@ +----------------------------- MODULE RoastRolloutPolicy ----------------------------- +EXTENDS TLC + +\* STATUS: models a PLANNED staged-rollout policy, not yet implemented in the +\* shipped signer. There is no rollout state machine in the crate; the stages +\* and transition guards below are design targets per +\* docs/roast-phase-5-security-rollout-gates.md (a future rollout plan). A +\* passing TLC run verifies this model's internal consistency, NOT that the +\* shipped signer enforces this behavior. + +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..2326335b6d --- /dev/null +++ b/pkg/tbtc/signer/docs/formal/models/TeeEnforcementModes.tla @@ -0,0 +1,97 @@ +------------------------------ MODULE TeeEnforcementModes ------------------------------ +EXTENDS TLC + +\* STATUS: models a PLANNED enforcement profile, not yet implemented in the +\* shipped signer. The crate implements only a binary provenance enforce gate +\* (src/engine/provenance.rs); the three modes below, the +\* disabled->enforce transition guard, and break-glass are design targets per +\* docs/tee-whitelisted-signer-enforcement-plan.md (an explicitly "not active" +\* future hardening profile). A passing TLC run verifies this model's internal +\* consistency, NOT that the shipped signer enforces this behavior. + +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/phase-7-2b-open-questions-discussion.md b/pkg/tbtc/signer/docs/phase-7-2b-open-questions-discussion.md new file mode 100644 index 0000000000..fd44dd35a4 --- /dev/null +++ b/pkg/tbtc/signer/docs/phase-7-2b-open-questions-discussion.md @@ -0,0 +1,277 @@ +# Phase 7.2b Open Questions — Options, Tradeoffs, Recommendations + +Date: 2026-06-13 +Status: SIGNED OFF 2026-06-13 — recorded as gates-doc Decision Log entry +9. Reviewed (Gemini + Codex concurred; recommendations resolved — see the +Decision Log at the end); Q1 was corrected back to the frozen spec on a +converging reviewer P1. +Companion to: `phase-7-2b-package-envelope-design.md` + +This doc works the three open questions that gate the 7.2b +implementation into explicit options with tradeoffs and a recommendation +each. They are now **resolved** (Gemini + Codex reviewed; see the +Decision Log at the end) — each question's "RESOLVED" block records the +decision and its reasoning. This is a decision record, not an open +solicitation. + +A framing distinction that runs through all three, stated once: + +> The coordinator is a node and may itself be the adversary. So +> "detect a bad input" splits into two jobs with different trust +> assumptions: (a) **protect honest members from false blame** when the +> coordinator's inputs don't match what members signed; and (b) **prove +> a malicious coordinator equivocated** so it can be excluded. Neither +> can be entrusted to the coordinator's engine: as Q1 resolves, the +> engine emits only mathematical *candidate* culprits, and BOTH jobs are +> enforced at the members / the f+1 accuser quorum — job (a) by +> re-checking an accused share against the accused member's own retained +> envelope, job (b) by comparing members' retained envelopes. Several +> options below are really about which of these two jobs they serve. + +--- + +## Q1 — How is a failing share bound to what the member signed? + +**Problem.** InteractiveAggregate (7.2a) fails closed without blame +because a coordinator aggregating against a different package/root than +members signed makes honest shares fail and would frame them. To name a +culprit soundly, the engine must distinguish "member produced a bad +share for the agreed package" from "coordinator fed a different +package." A FROST share cryptographically binds to the package it was +produced over, so a failure alone cannot tell these apart — the engine +needs evidence of what each member was told to sign. + +### RESOLVED (post-review) — the engine never touches envelopes; blame adjudication is Go-side + +Both reviewers returned a converging **P1** that my pre-review lean +(Option A: *the engine* verifies member envelopes at aggregate) was +wrong, on two independent grounds: + +- **Gemini (architecture):** the Rust engine holds only FROST/Schnorr + threshold key material — it has no operator-key registry, so it + *cannot* verify a coordinator's operator signature on an envelope. + Byte-comparing bodies without verifying the signature lets a malicious + *member* forge a body to dodge blame. Pushing operator keys into the + engine to fix that would blow the signing-secret boundary the sidecar + exists to hold (gates-doc decision 2). Verification belongs in the Go + host, which owns the registry, the network identities, and the exact + bytes it distributed. +- **Codex (authentication direction):** even if the engine *could* + verify it, `SignedSigningPackage` is signed by the **coordinator, not + the member**. A malicious coordinator distributes P′ to member A, + collects A's share over P′, then aggregates against P with its own + validly-signed P-envelope: A's share fails against P, the envelope body + equals P, and the equality check frames honest A. Sound *member* blame + needs a *member*-authenticated binding of share↔body, never a + coordinator signature. + +The two compose into the design the **frozen spec already specifies** +(§5 item 4 and §6) — my 7.2b design note had drifted from it, so the +reviewers' P1 is really "return to the freeze": + +1. **Engine = pure FROST math.** `InteractiveAggregate` verifies each + share against the member's verifying share (public, from the DKG key + package the engine already holds) and returns the mathematically + failing members as *candidate* `culprits`. No envelopes, no operator + signatures, no network identity in the engine. This makes the 7.2b-3 + engine change *smaller* than the note implied. +2. **Go host = envelope verification + retention.** Members verify the + coordinator's operator signature and retain the exact received + `SignedSigningPackage` bytes (§6, #4040 pattern). The coordinator + signature is the right artifact *here* — for proving *coordinator* + equivocation (job (b)), where coordinator-authentication is exactly + what you want — not for attributing member fault. +3. **Authoritative blame = Go, at the f+1 accuser quorum, against the + member's retained bytes.** The engine's candidate culprits are only a + trigger. Final exclusion re-checks each accused share against *the + package envelope that member signed over* (its retained received + bytes), never a coordinator-submitted or reconstructed package (§6, + verbatim). Under Codex's attack, A's share verifies against its + retained P′-envelope, so A is exonerated and the coordinator's two + divergent signed bodies for one attempt-context convict *it*. + +Soundness comes from **Round2 check (f)** — already shipped in 7.1: a +member signs only over a package carrying its own true commitment — plus +the quorum re-check above. A single malicious coordinator can neither +manufacture an f+1 quorum nor make an honest member's retained-envelope +re-check fail. + +### Corrected options (the real axis is *where adjudication lives*, not *what crosses the FFI*) + +| Option | Engine role | Blame adjudication | Sound vs malicious coordinator? | Verdict | +|---|---|---|---|---| +| **Engine verifies envelopes** (my pre-review A *and* C) | verify operator sigs / body-hashes at aggregate | in-engine | **No** — engine has no registry (member-forgeable) and a coordinator-sig is the wrong auth direction (frames honest member) | **Rejected** (both reviewers, P1) | +| **Engine pure-math + Go quorum adjudication** (frozen §5.4/§6) | verify share vs verifying share → candidate culprits | Go host, at f+1 quorum, vs member's retained bytes | **Yes** — Round2 (f) + quorum re-check; engine stays crypto-only | **Adopted** | + +### Hard prerequisite (Codex P1): member-authenticated share submission + +For the quorum to attribute "this failing share is A's," A's share +submission to the coordinator MUST be member(operator)-authenticated. +This is not a nice-to-have to confirm — it is **load-bearing for blame +soundness**: without it a malicious coordinator submits random bytes +labeled "A's share," the engine returns A as a *candidate* culprit (the +bytes fail share-verification), and the quorum re-checks bytes A never +sent, framing A. Therefore authoritative blame (7.2b-4) MUST NOT be +enabled until member→coordinator share submissions are authenticated +(reuse the existing #4040 sign-what-you-transmit envelope). **The signed +body must cover the attempt context AND the signing-package/envelope hash, +not just the share bytes** (Codex follow-up P1): otherwise a coordinator +replays an old A-signed share into a different attempt/package and the +quorum re-checks bytes A never submitted for that context — framing A. It +is a Go-layer property (not an engine one) and is part of the +implementation sequence (7.2b-2) and acceptance criteria (neither an +unattributable share nor a replayed A-signed share can make A a culprit). + +A second hard prerequisite rides with it (Codex P1, companion design +note §2): members MUST verify `taproot_merkle_root` against their live +session root before signing. The attempt context does not carry the +root, so the retained envelope only faithfully describes "what the member +signed" — the thing the quorum re-checks against — if the member rejected +any envelope whose root diverged. + +--- + +## Q2 — Where does cross-member equivocation comparison run? + +**Problem.** Per the framing above, *proving* a malicious coordinator +equivocated (and excluding it) cannot run in the coordinator's engine. +Members each hold a coordinator-signed envelope; a malicious coordinator +that signed two different bodies is caught only when members pool and +compare their envelopes. The question is the transport for that pooling. + +### Options + +| Option | Mechanism | Detection latency | Complexity | Handles malicious coordinator? | +|---|---|---|---|---| +| **A. Opportunistic gossip** — members broadcast received envelopes on a topic and compare continuously | early, before the attempt times out | high — new topic, async-consistency caveats (RFC-21 warned against assuming synchronous gossip) | yes | +| **B. At the f+1 accuser-quorum step** — retain now; compare only when an exclusion is being decided | late (at exclusion time) | low — reuses the existing #4029 quorum machinery | yes | +| **C. Coordinator-side at aggregate only** | n/a for malicious coordinator | n/a | low | **no** — a malicious coordinator won't self-incriminate | + +### RESOLVED (post-review) — retention now; comparison via Option B (both reviewers concur) + +7.2b lands the *retention* (each member persists the exact +`SignedSigningPackage` it received) regardless. The comparison transport +is **Option B**: feed retained envelopes into the existing f+1 +accuser-quorum exclusion path (#4029) — where an exclusion is actually +decided, and which RFC-21 already chose over synchronous-gossip +assumptions. This is also exactly the adjudication site Q1's resolution +relies on. + +Gemini's confirming argument closes the "what if the coordinator +equivocates but stays silent" gap: a malicious coordinator that +equivocates package bodies but emits no blame merely causes the attempt +to time out — a *liveness* failure indistinguishable from the +coordinator dropping messages, which the existing RFC-21 rotation +machinery already handles by moving to the next attempt. Proof is needed +only when the coordinator emits blame against an honest member, and there +the accused member defends itself by revealing the equivocation at the +exclusion step. So early/opportunistic gossip (Option A) is an +unnecessary optimization on the critical path, not a 7.2b requirement; +Option C (coordinator-side) cannot catch a malicious coordinator at all +and is excluded as a detection mechanism. + +--- + +## Q3 — Shape of the structured FFI error carrying culprits + +**Problem.** `ffi::error_result` serializes only `code`, `message`, +`recovery_class`, so the reintroduced `InvalidSignatureShare { culprits }` +would lose the culprit vector as structured data (#4052 P2). The Go +coordinator needs the culprits machine-readable to exclude the right +members. + +### Options + +| Option | Shape | Typing | Extensible | Churn | +|---|---|---|---|---| +| **A. Typed optional field** — add `culprits: Option>` (a small typed `blame` sub-struct) to `ErrorResponse` | strong | per-kind: a new structured error adds a new field | small, localized | +| **B. Generic `details: map`** | weak — callers parse untyped JSON | any structured error reuses it | medium; defines a general contract | +| **C. Encode culprits in the `message` string** with a parseable convention | none (stringly-typed) | no | tiny code, but it's the anti-pattern #4052 P2 named | + +### RESOLVED (post-review) — Option A (both reviewers concur) + +A typed optional field — `culprits: Option>` (a small typed +`blame` sub-object) on `ErrorResponse` — is safer and clearer than an +untyped JSON map, which would force the Go bridge into dynamic type +assertions and weaken the FFI contract. Culprits are the only structured +detail in flight (YAGNI: a generic `details` map can come later if a +*second* structured error ever needs one). The change touches the Rust +`ErrorResponse`, the C header, and the Go bridge decoder together. Use +the Go member-identifier **u16** form, consistent with the rest of the +wire surface — and note this pins the companion design note, which still +said `[]string` (Codex P2); that note is corrected to `[]u16`. + +--- + +## Decision Log (post-review) + +Gemini and Codex both reviewed this doc and the companion design note. +All three questions are resolved with reviewer concurrence; the only +substantive change from my pre-review lean is Q1, where both reviewers +returned a P1 that corrected it back to the frozen spec. + +| Q | Resolution | Reviewer status | +|---|---|---| +| Q1 binding | Engine does **pure FROST math** → candidate `culprits`; **all** envelope verification, retention, and authoritative blame re-check live in the **Go host at the f+1 accuser quorum**, against the member's retained received bytes (frozen §5.4/§6). Engine never sees envelopes or operator keys. | Gemini P1 + Codex P1 — adopted | +| Q2 equivocation | **Retention now; compare at the f+1 quorum step (B).** Gossip (A) deferred; coordinator-side (C) can't catch a malicious coordinator. | Both concur | +| Q3 FFI error | Typed optional `culprits: Option>` on `ErrorResponse` (A); generic details map deferred (YAGNI); design note pinned to `u16`. | Both concur | + +Re-review (multiple passes) folded in Codex **P1**s and **P2**s plus a +self-review item on the implementation shape — none changes the Q1/Q2/Q3 +decisions above: +- **Taproot root binding** (P1) — members MUST verify `taproot_merkle_root` + against their live session root before signing (the root is not in the + attempt context); otherwise the retained envelope misdescribes what was + signed and the quorum re-check misattributes blame. (design note + §2/§3/§11) +- **Member-authenticated share submission** (P1) is a hard prerequisite, + not a confirmation: authoritative blame must not be enabled until a + share is provably attributable to its member. (this doc, Q1 + prerequisite; design note §9/§11) +- **All-cheater detection** (P2) — 7.2b-3 must aggregate with + `CheaterDetection::AllCheaters`, not the default first-cheater path, or + the `culprits == entire subset` rule is unreachable and multi-share + failures are under-reported. Verified remedy: frost-core 3.0.0 already + collects all culprits in that mode (no reimplementation); the taproot + `aggregate_with_tweak` wrapper does not expose the mode, so apply the + tweak + `aggregate_custom(AllCheaters)`. (design note §4/§9) +- **Elected-coordinator check** (P2) — members must verify `coordinator_id` + is the *elected* coordinator and the signature is under that key; the + attempt hash is public, so any operator could otherwise inject packages + and pollute the equivocation evidence. (design note §2/§3/§9/§11) +- **Retain-on-reject** (P2) — a member that refuses a root-divergent + envelope must retain it first, or §3 loses the bytes proving the + coordinator equivocated. (design note §2/§3/§11) +- **Tweak-aware, engine-delegated quorum re-check** (self-review) — the + quorum's per-share crypto re-verify is FROST math; it must be + tweak-consistent and is delegated to a stateless engine verify-share (Go + owns only the quorum policy), not reimplemented in Go. (design note + §4/§7/§9/§11) +- **Context-bound share authentication** (P1, follow-up) — the + member-authenticated share submission must bind the share to the attempt + context + package/envelope hash, not just the share bytes, or a + coordinator replays an old A-signed share into a different + attempt/package and frames A. (design note §4/§9/§11; this doc, Q1 + prerequisite) +- **Group key + selector in verify-share inputs** (P2, two passes) — the + verify-share FFI needs the **group verifying key** (tweaked for taproot), + not just the per-member verifying share, to compute the challenge/binding + factors, AND a `session_id`/wallet **selector** to resolve the *right* + key in a multi-session engine. That key must come from + **durably-retained wallet DKG material** outliving the signing-session + TTL sweep, since the quorum re-check can run after the session is gone + (or, equivalently, the Go quorum passes the public key package explicitly + — it is public, sound under the f+1-independent-accuser model). (design + note §4/§7/§9/§11) + +These refine 7.2b's implementation shape without changing the frozen +Phase 7 spec — Q1's resolution in fact *realigns* the design docs to it. +Companion design-note corrections applied alongside this doc: §4 (engine +emits candidate culprits, Go adjudicates — not "engine verifies the +coordinator signature"), §5 (`[]u16`), §6 (Round2 record is local +bookkeeping, not the blame-binding artifact), §7 (blame adjudication +moves to the Go column), §10 (superseded body-hash proposal removed). + +**SIGNED OFF** (MacLane, 2026-06-13), recorded as gates-doc Decision Log +entry 9; 7.2b-1 (the InteractiveAggregate completion marker; **no** +envelope plumbing in the engine) followed and is implemented in PR #4055. diff --git a/pkg/tbtc/signer/docs/phase-7-2b-package-envelope-design.md b/pkg/tbtc/signer/docs/phase-7-2b-package-envelope-design.md new file mode 100644 index 0000000000..2d500d9a6f --- /dev/null +++ b/pkg/tbtc/signer/docs/phase-7-2b-package-envelope-design.md @@ -0,0 +1,363 @@ +# Phase 7.2b: Signed Package Envelopes, Bound Blame, and Vectors + +Date: 2026-06-13 +Status: Design note (scopes the 7.2b implementation PRs); open questions +RESOLVED post-review — see §10 and the open-questions discussion doc. Key +correction: the engine does pure FROST math and NEVER verifies envelopes; +all envelope verification + blame adjudication is Go-side. +Owner: Threshold Labs +Scope: the signed-body signing-package envelope (frozen spec section 6), +the envelope-bound attributable blame deferred from 7.2a, the FFI +structured-culprit payload, the InteractiveAggregate completion marker, +and the cross-language vectors. Builds on merged 7.1 (#4051) + 7.2a +(#4052, mirror). + +## 1. Why this phase exists + +7.2a ships InteractiveAggregate but **fails closed without naming +culprits** on an invalid share. That was deliberate: the engine cannot +yet bind the aggregate's public inputs (signing package, taproot root) +to what each member actually signed at Round2, so a coordinator +aggregating against a different package/root would make honest shares +fail verification and frame their members (Codex P1 on #4052). The +frozen spec ties attributable blame to share verification (section 5, +item 4) AND ties the binding that makes it trustworthy to signed +package envelopes (section 6) — with the engine doing pure share-math and +the binding/adjudication living in the Go host (see §4 and the +open-questions doc Q1). 7.2b builds the foundation (the envelopes, +Go-side) and the feature (the bound blame) together, so blame is never +emitted without the evidence that backs it. + +This is the recurring lesson made concrete: do not ship the security +feature (blame) ahead of its foundation (the binding). + +## 2. The signed package envelope + +Mirror the proven #4040 signed-body pattern +(`pkg/frost/roast/gen/pb/evidence.proto`): the bytes are signed once, +embedded verbatim, and re-verified against exactly what was received - +no canonical-form dependence. + +New wire types (Go, `pkg/frost/roast/gen/pb/`): + +``` +// The coordinator-distributed signing package for one attempt. +message SigningPackageBody { + bytes attempt_context_hash = 1; // binds package to the attempt + uint32 coordinator_id = 2; + bytes signing_package = 3; // the frost SigningPackage bytes + bytes taproot_merkle_root = 4; // empty = key-path +} + +// On-wire: exact signed body + the coordinator's operator signature. +message SignedSigningPackage { + bytes body = 1; + bytes coordinator_signature = 2; +} +``` + +The coordinator signs `SigningPackageBody` with its operator key and +distributes `SignedSigningPackage` to the chosen subset. Each member: +1. **authenticates the envelope as genuine coordinator evidence for this + attempt**: `coordinator_id` equals the attempt's *elected* coordinator + (RFC-21 Annex A), the signature verifies under that elected + coordinator's operator key, and `attempt_context_hash` matches the live + attempt. If any fails the envelope is not attributable to the + coordinator — reject WITHOUT retention (forgeable noise, not evidence); +2. **retains the exact received envelope bytes** — for every envelope that + passed step 1, BEFORE the sign/no-sign decision, so a divergent + envelope is still available to the §3 cross-member comparison; +3. checks `taproot_merkle_root` equals the live session/signing root + (empty for key-path); if it diverges, **refuse to sign** and flag the + (already-retained) envelope as reject/equivocation evidence; +4. otherwise produces its Round2 share over the `signing_package` in that + body, under the (now root-verified) session root. + +Three acceptance checks are load-bearing here (Codex P1+P2): +- **Elected-coordinator (step 1):** `attempt_context_hash` is public, so + any operator could sign a body carrying it. Without checking + `coordinator_id` against the elected coordinator AND verifying under + that specific key, a non-elected operator could inject packages and + pollute the retained evidence — and the §3 equivocation proof requires + both divergent bodies to carry the *same elected coordinator's* + signature. +- **Root binding (step 3):** the attempt context does NOT carry the root, + but Round2 signs under the session root, so a divergent root would make + the retained envelope misdescribe what the member signed and the quorum + re-check misattribute blame. +- **Retain-before-refuse (steps 2–3):** the member must retain a divergent + envelope *before* refusing to sign it, or §3 loses the signed bytes that + prove the coordinator equivocated the root. + +## 3. Equivocation detection (extends #4044) + +A coordinator that distributes *different* package bodies to different +members is the framing vector 7.2a could not defend against. With +envelopes it is self-incriminating: each member retains the exact +`SignedSigningPackage` it received (whether or not it signed over it — +§2 step 2). Two members holding +envelopes with the same `attempt_context_hash` but different bodies, each +carrying the coordinator's signature, are a proof of coordinator +equivocation - the same shape as #4044's `EquivocationEvidence`, now +over package envelopes rather than evidence snapshots. Because +`taproot_merkle_root` is a field of the signed body, distributing +different roots is the same equivocation — and a member that refuses a +root-divergent envelope still **retains it as evidence** (§2 step 2), so +the comparison has both signed bodies even though one was never signed +over. Both retained bodies must carry the *elected* coordinator's +signature (§2 step 1) for the proof to convict it. The cross-member +comparison is the RFC-21 Go layer's job (scaffold), not the engine's. + +## 4. Bound attributable blame (reintroduces what 7.2a removed) + +InteractiveAggregate's per-share verification becomes attributable ONLY +when bound to what the member signed: + +- The authoritative binding is the member's **retained envelope**, held + in the Go layer (it carries the package AND the root — §2). The engine + itself does NOT need to record what it signed for the blame flow: + nothing in the corrected design consumes such a record (the quorum + re-checks Go-retained bytes; the completion marker is attempt-keyed). + 7.2b-1 should add an engine-local Round2 record ONLY if a concrete + consumer is identified; absent one, the engine-side state is just the + completion marker (section 6). +- At aggregation the **engine does pure FROST math**: it verifies each + share against the member's verifying share (public, from the DKG key + package it already holds) and returns the mathematically failing + members as *candidate* culprits — + `EngineError::InvalidSignatureShare { culprits }`. The engine does NOT + see envelopes or operator signatures (it has no operator-key registry; + open-questions doc Q1). Authoritative blame is adjudicated in the **Go + host at the f+1 accuser quorum**, which re-checks each accused share + against the `signing_package` AND `taproot_merkle_root` inside *the + envelope that member signed over* (its retained received bytes — both + fields are what the member signed), never a coordinator-submitted or + reconstructed package. The cryptographic part of that re-check — does + this share verify against the signing_package under the **group + verifying key tweaked by taproot_merkle_root**? — is FROST math and is + **delegated to the engine** (a tweak-aware verify). The Go quorum + supplies (signing_package, taproot_merkle_root, the accused member's + identifier, the share) **plus a `session_id`/wallet selector**, so the + engine resolves the *right* canonical public key package (group key + + verifying shares) in a multi-session engine (Codex P2) and applies the + tweak. Because the quorum re-check can run *after* the interactive + session is TTL-swept (frozen §5), the engine MUST resolve that key from + **durably-retained, wallet-scoped DKG material** that outlives the + signing session — never the ephemeral session object. (Sound + alternative: the Go quorum passes the canonical public key package + explicitly; it is public, so only secret material stays off the FFI, and + this keeps verify-share a pure function — sound under the + f+1-independent-accuser model where each accuser verifies with its own + canonical DKG copy.) The Go quorum owns the policy (f+1 threshold, + envelope/equivocation comparison, which shares to re-check), and never + hands the engine the envelope or operator keys. A candidate culprit + becomes authoritative blame ONLY for a share **provably submitted by the + accused member for THIS attempt and package** — the member-authenticated + submission must bind the share to the attempt context + + signing-package/envelope hash, not just the share bytes (see §9/§11, a + hard prerequisite), else a coordinator can replay an old A-signed share + into a different attempt and frame A. A share the coordinator could have + fabricated — or replayed — names no one. The + coordinator's operator signature on the envelope is the artifact that + convicts an *equivocating coordinator* (two divergent signed bodies for + one attempt-context), not what attributes member fault. +- If a member's retained envelope diverges from the aggregated package + (equivocation) or a member signed a different body, the quorum re-check + yields NOT member blame but coordinator/input fault — a distinct + non-attributive outcome. This is the precise fix for the 7.2a + forgeable-blame finding: an honest member whose share fails only + because the coordinator aggregated a package it never signed is + exonerated by its own retained bytes. +- Refinement carried from the #4052 review: `culprits == the entire + subset` is treated as suspect (a coordinator/config error is far more + likely than every member simultaneously cheating) and is reported as a + coordinator-side error, not universal member blame. **For this rule to + be reachable, 7.2b-3 MUST request all-cheater detection** (Codex P2): + the plain `frost::aggregate` / `aggregate_with_tweak` hard-code + `CheaterDetection::FirstCheater` (verified in frost-core 3.0.0), so + they report only the first invalid share and the full-subset condition + could never trigger (multi-share failures would also be under-reported, + forcing one-cheater-per-attempt exclusion grinds). No reimplementation + is needed: frost-core 3.0.0 already collects every culprit under + `CheaterDetection::AllCheaters` (its `detect_cheater` extends an + `all_culprits` vec over all shares). Taproot wrinkle to pin in 7.2b-3: + `frost_secp256k1_tr::aggregate_with_tweak` does NOT expose the mode (it + delegates to first-cheater), so the engine applies the tweak itself + (`public_key_package.tweak(merkle_root)`, as the wrapper does + internally) and calls `frost_core::aggregate_custom(…, + CheaterDetection::AllCheaters)` on the tweaked package — confirm + `.tweak()` is callable from the engine, else reproduce the tweak or + request an upstream `aggregate_with_tweak_custom`. Soundness does not + hinge on this engine heuristic: the authoritative + all-honest-vs-coordinator distinction remains the Go quorum re-check + against retained envelopes (above). + +## 5. FFI structured-culprit payload (Codex P2 on #4052) + +`ffi::error_result` currently serializes only `code`, `message`, +`recovery_class`, so a culprit vector would be lost as structured data +and the Go coordinator would have to parse the Display string. 7.2b adds +a typed optional field — `culprits: Option>` (the Go +member-identifier form — open-questions doc Q3) — to the FFI error +response so the engine's *candidate* culprits are machine-readable. They +are candidates: the Go host adjudicates final blame at the f+1 quorum +(section 4). A typed field, not a generic `details` map, keeps the FFI +contract strong (YAGNI); it must be reflected in the C header and the Go +bridge decoder. + +## 6. InteractiveAggregate completion marker + +Deferred from 7.2a: a persisted per-attempt completion marker +(`aggregated_interactive_attempt_markers: HashSet` on +`SessionState`, mirrored as `Vec` in `PersistedSessionState`, +bounded like the consumed markers). Re-aggregating a completed attempt is +rejected with `InteractiveAttemptAlreadyAggregated` rather than +recomputed — re-aggregation is not a recovery path. A host that loses an +aggregate signature recovers it the way ROAST recovers any failed +attempt: by starting a fresh attempt, not by replaying the engine. So the +engine neither retains nor re-emits the signature; the marker only records +"this attempt is complete" (frozen spec section 6). The marker is durable +(markers-only durability) so a completed attempt stays rejected across a +restart, and an empty attempt_id is rejected before the marker is written +(the reload path rejects an empty key, so a malformed write must not be +able to persist one). Not security-load-bearing — the blame binding is +Go-side (section 4) — so this marker is the only engine-side state 7.2b +adds. + +(A richer "re-emit the stored signature on replay" variant was explored +and dropped after review: keyed by attempt_id it accumulated avoidable +surface — concurrent-race signature divergence, request package/root +binding, and a recovery path that still required the host to have retained +the collected shares — for a benefit ROAST's native retry already +provides.) + +## 7. Go vs Rust split + +- **Go (scaffold branch)**: the `SigningPackageBody`/`SignedSigningPackage` + protos + gen (mind the Dockerfile gen-COPY allowlist - the #4040 CI + gotcha), coordinator-side package signing + distribution, member-side + authenticate (elected-coordinator + signature) / retain (incl. + retain-on-reject), cross-member equivocation comparison (extends + #4044), and the **blame-adjudication policy** at the f+1 accuser quorum + — selecting which shares to re-check and the exclusion decision, but + delegating the per-share crypto re-verify to the engine. The Go bridge + decoder for the new structured FFI error. +- **Rust (mirror branch)**: the FFI candidate-`culprits` payload (pure + FROST math via `CheaterDetection::AllCheaters`, no envelopes) + C + header, a **tweak-aware verify-share** entry the quorum re-check calls + (caller-supplied public inputs: signing_package, taproot_merkle_root, + member identifier, share, + a `session_id`/wallet selector; the engine + resolves the canonical **group verifying key** + verifying shares from + durably-retained wallet DKG material — surviving the session TTL sweep — + and applies the tweak; never the envelope or operator keys), the + completion marker, and the engine-side vectors. The engine never + verifies envelopes or operator signatures. + +## 8. Cross-language vectors (frozen spec item 9) + +Pin the new wire structs across languages: `SigningPackageBody` +canonicalization, `SignedSigningPackage` byte-preservation/verbatim- +embedding, and an equivocation-detection vector. Regenerate via the +established discipline and byte-copy to +`pkg/tbtc/signer/testdata/`; treat regeneration as a protocol-change +event. + +## 9. Suggested sub-PR sequence + +1. **7.2b-1 (mirror)**: the InteractiveAggregate completion marker + (persistence plumbing only; no blame, no envelopes). Self-contained. + (No engine-local Round2 package-hash record unless §4 identifies a + consumer — the corrected design has none.) + **Durable-retention question (below) CONFIRMED satisfied + (2026-06-13):** the group public key package the 7.2b-3 verify-share + FFI needs already outlives the signing session. `SessionState` + holds `dkg_public_key_package` (group key + verifying shares), it is + persisted as `dkg_public_key_package_hex` in `PersistedSessionState` + (rehydrated/serialized in the persistence `TryFrom`s), and + `sweep_expired_interactive_state` clears ONLY the live attempt's + nonces (`interactive_signing`), explicitly retaining the session's + DKG material. So a post-sweep f+1 quorum re-check can still resolve + the canonical group key by `session_id` — no new wallet-key + persistence is needed in 7.2b-1, only the completion marker. +2. **7.2b-2 (scaffold)**: `SignedSigningPackage` protos + gen + + coordinator signing/distribution + member authenticate (elected + `coordinator_id` + signature under that key + attempt hash + the + `taproot_merkle_root` check, §2) / retain (incl. retain-on-reject for + divergent envelopes) + **member-authenticated Round2 share submission + bound to the attempt context + signing-package/envelope hash** (reuse + the #4040 sign-what-you-transmit envelope; the signed body MUST cover + (attempt_context_hash, package/envelope hash, share) so a share is + provably from its claimed member AND not replayable into another + attempt/package — a hard prerequisite for blame, Codex P1×2). Wire + + `wire_test.go` byte-preservation, no engine change yet. +3. **7.2b-3 (mirror)**: FFI structured-error payload (`culprits: []u16`) + + C header + the pure-FROST candidate-`InvalidSignatureShare` in + InteractiveAggregate (no envelope handling in the engine), aggregating + with **`CheaterDetection::AllCheaters`** (tweak-aware — §4) so the full + culprit set is reported, not just the first; plus the **stateless + tweak-aware verify-share** FFI the 7.2b-4 quorum re-check calls + (caller-supplied public inputs: signing_package, taproot_merkle_root, + member identifier, share, + a `session_id`/wallet selector; the engine + resolves the canonical public key package = **group verifying key** + + verifying shares from durably-retained wallet DKG material that outlives + the session TTL sweep, and applies the tweak — the group key is required + for the challenge/binding factors, the selector disambiguates + multi-session lookups, Codex P2). The wallet public key package is + durably retained beyond the signing-session TTL (CONFIRMED in 7.2b-1: + `dkg_public_key_package` persists on the session and survives the + attempt-nonce sweep), so the verify-share FFI resolves it by + `session_id` with no extra persistence. +4. **7.2b-4 (scaffold)**: cross-member equivocation comparison + (extends #4044) + the **blame-adjudication policy** (quorum re-check — + the per-share crypto re-verify delegated to 7.2b-3's tweak-aware + verify-share FFI, the exclusion decision in Go) + the Go bridge decoder + for the structured error. **Gated on 7.2b-2's member-authenticated + share submission** — blame must not be enabled until a share is + provably attributable to its member. +5. **7.2b-5**: cross-language vectors, byte-copied both sides. + +Each is independently reviewable; the engine's candidate culprits +(7.2b-3) become *authoritative* blame only via the Go adjudication in +7.2b-4 (quorum re-check against retained envelopes from 7.2b-2). + +## 10. Open questions + +1. **~~Share carries its envelope, or coordinator collects them?~~ + RESOLVED (open-questions doc Q1, Gemini+Codex P1):** moot — the + *engine* never adjudicates envelopes at all. The engine returns + candidate culprits from pure FROST math; the Go host verifies operator + signatures and re-checks blame against each member's retained envelope + at the f+1 quorum. The earlier "engine binds against the body hash" + proposal is **withdrawn** — it was unsound (the engine has no + operator-key registry, and a coordinator signature is the wrong + authentication direction for member blame). +2. **~~Where does cross-member comparison run?~~ RESOLVED + (open-questions doc Q2, both reviewers concur):** retention now; + comparison at the f+1 accuser-quorum exclusion step (Option B). + Opportunistic gossip deferred. +3. **~~FFI structured-error shape?~~ RESOLVED (open-questions doc Q3, + both reviewers concur):** a typed optional `culprits: Option>` + field on `ErrorResponse` (not a generic `details` map), Go member-id + u16 form. + +## 11. Acceptance + +7.2b is done when: a member refuses to sign a root-divergent envelope but +**retains it as evidence** (test, Codex P1+P2); an envelope from a +non-elected coordinator is rejected without retention (test, Codex P2); a +coordinator equivocating package bodies (incl. the root) cannot produce +member blame, and is itself convicted from two retained +elected-coordinator bodies (Go quorum test); authoritative blame is gated +on context-bound member-authenticated share submission — neither a share +not provably from member A NOR an old A-signed share replayed into a +different attempt/package can make A a culprit (test, Codex P1); the +quorum's per-share re-check is **tweak-consistent**, verifying under the +tweaked group key (a valid taproot share verifies, an invalid one is +blamed; test); the re-check resolves the correct canonical group key via +its session/wallet selector and still succeeds after the signing session +is TTL-swept (multi-session + post-sweep test, Codex P2); a genuine bad +share yields machine-readable +*candidate* culprits over the FFI (engine test, `AllCheaters`) that the Go +quorum confirms as attributable `InvalidSignatureShare`; re-aggregating a +completed attempt is rejected (test) and an empty attempt_id is rejected +(test); and the cross-language vectors are pinned both sides. diff --git a/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md b/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md new file mode 100644 index 0000000000..07f35ba498 --- /dev/null +++ b/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md @@ -0,0 +1,343 @@ +# Phase 7: Interactive Signing Session — Spec Freeze + +Date: 2026-06-12 +Status: FROZEN (2026-06-12 owner sign-off; section 10 decisions +recorded in the gates-doc Decision Log, entry 8; review converged: +adversarial pass findings applied in 73dc594c9, Codex and Gemini +clean) +Owner: Threshold Labs +Scope: the hardened interactive two-round FROST signing session — the +production signing path — with t-of-included finalize native from the +start, and the deletion plan for the transitional deterministic-nonce +flow. + +## 1. Objective + +Make the interactive two-round FROST exchange the production signing +path, carrying the full session hardening that today exists only in +the coarse transitional path, and finalize with the first `t` +responsive members of the included set (t-of-included) so no single +included member can veto an attempt. Redemption signings adopt the +path first (slashing-backed deadlines; gates-doc decision 5). + +Non-goals of this spec: bounded `n-t+1` concurrent attempts +(fast-follow — section 8 reserves the room it needs), DKG redesign +(the interactive DKG primitives ship as-is for now), and the wallet +recovery-leaf question (explicitly open; nothing here may bake in a +key-path-only assumption — the session layer takes the Taproot +merkle root as an input, as today). + +## 2. Inherited decisions (settled; cite, do not relitigate) + +From the Phase 5 gates-doc Decision Log (2026-06-12) and the merged +review stack: + +1. **t-of-included finalize is Phase 7's first engineering item** + (decision 5). The transitional flow cannot be retrofitted: it + derives every participant's commitments against the full included + set at `StartSignRound`, and `finalize_sign_round` rejects any + contribution outside the declared signing-participant set — so + first-t-responsive requires the interactive exchange. +2. **The interactive flow is designed t-of-included-NATIVE** + (decision 6). No deterministic-coexistence constraint: the + transitional deterministic-nonce path is FROZEN (marker at + `src/engine/nonce.rs` header) and committed for deletion when the + trigger in section 7 fires. +3. **Sidecar process boundary is the target architecture** + (decision 2). The session API in section 5 is therefore + transport-shaped: idempotent request/response, no shared-memory + assumptions, every call replayable against a restarted signer + with an identical fail-closed outcome. The dlopen bridge remains + the transitional transport; moving to the sidecar must be a + transport swap, not an API rework. +4. **Production signing is interactive-FROST-only with OS + randomness** (production profile, since the #4028 hardening). +5. **Coordinator-seed derivation is normative** (RFC-21 Annex A); + attempt contexts and their hashes are the cross-layer binding + (RFC-21); evidence rides signed-body envelopes, + sign-what-you-transmit (#4040). +6. **The external audit is a hard gate for ECDSA retirement** + (decision 1) and this session layer is in its scope: the spec + and its vectors are audit inputs. + +## 3. Current state (verified in code, 2026-06-12) + +* Stateless interactive primitives exist in + `src/engine/frost_ops.rs`: `dkg_part1/2/3`, + `generate_nonces_and_commitments`, `new_signing_package`, + `sign_share`, `aggregate`. They enforce the provenance gate but + **bypass every other layer of session hardening**: no replay + registries, no attempt-context validation, no consumed tracking, + no policy gates, no persistence. +* **Secret nonce custody is the host's** in the stateless flow: + `generate_nonces_and_commitments` returns serialized + `SigningNonces` to the caller and `sign_share` accepts them back + as a request field (`nonces_hex`). Between rounds the secret + nonces live in Go memory and cross the FFI twice; single-use is + enforced by caller discipline only — calling `sign_share` twice + with the same nonces and different messages is the canonical + FROST key-extraction failure and nothing in the engine prevents + it today. +* The coarse transitional path holds the hardening inventory to + carry over: consumed sign/finalize round registries with + fail-closed capacity behavior, strict-mode attempt-context + validation (Annex A derivation, Go-parity pinned by test), + policy gates, durable state with restart safety (persist-fault + chaos coverage), audit/telemetry events. +* Go side: the RFC-21 machinery is implemented and dormant behind + `frost_roast_retry` (coordinator state machine, evidence + recorder, Phase 7.1 bundle production, Phase 7.2 bundle-consuming + participant selector). `pkg/tbtc/signing_loop.go` still runs + serial attempts with the legacy `signingAttemptSeed`; its + migration to Annex A is part of this phase. + +## 4. The load-bearing change: nonce custody moves inside the engine + +The session layer's defining property: **secret nonces never cross +the FFI and never persist.** + +* `InteractiveRound1` generates nonces via OS randomness inside the + engine, stores them in session-scoped memory keyed by + `(session_id, attempt_id, member_identifier)`, zeroizes on + consumption, and returns only the public commitments. No opaque + nonce handle is returned across the FFI: the + `(session_id, attempt_id, member_identifier)` tuple that every + request already carries is itself the handle to the held nonces. +* `InteractiveRound2` takes the signing package and that same + `(session_id, attempt_id, member_identifier)` tuple; the engine + resolves the held nonces from it and atomically (a) marks the + attempt's nonces consumed, (b) produces the signature share, + (c) zeroizes the nonces. A second call for the same tuple fails + closed with a structured `consumed_nonce_replay` error. + Consumption-before-release ordering: the consumed marker is durable + (or the nonce irrecoverable) before the share leaves the engine. +* Nonces are **never written to durable state**. Restart loses + in-flight nonces by construction: the attempt fails and the next + attempt generates fresh ones. The persisted artifacts are only + consumption markers and session metadata. This makes the cloned- + state attack class from the transitional threat model structurally + irrelevant: two clones produce *different* fresh nonces; neither + can be induced to sign twice under one nonce pair, because the + pair exists only inside one process's memory and is consumed + atomically. + +This is also the audit story for the FFI boundary — scoped +precisely: after Phase 7, no secret material of the **signing +path** (key shares already env/command-only; now nonces too) +transits the Go/Rust interface in either direction. The interactive +**DKG** primitives are explicitly out of this spec's scope and +still hand secret round packages to the host (`dkg_part1` returns +`secret_package_hex`; `dkg_part2` accepts it back). DKG custody is +a named follow-up with the same design shape as section 4; until it +lands, the audit scope statement must describe the DKG boundary +as-is rather than inheriting this section's claim. + +## 5. Session model and API contract + +An interactive session is identified by `(session_id, +attempt_context)` where the attempt context is the RFC-21 structure +(message digest per Annex A, attempt number, included set, +coordinator) and its hash binds every message, as in the coarse +path's strict mode. + +Engine API (names final at freeze; all requests carry the attempt +context, all calls are idempotent-or-fail-closed, all responses are +self-contained): + +1. `InteractiveSessionOpen` — validates attempt context (strict + mode is the only mode here: no legacy-shape fallback), checks + policy gates and provenance, registers the session. Idempotent + by full-request fingerprint; conflicting reopen fails closed. + **The member's key package is resolved from the session's own DKG + state (run_dkg), NOT carried in the request** — so the session + must already exist with completed DKG, and no signing secret + crosses the FFI/host boundary (section 4). This is a correction to + an earlier draft of this spec that had Open accept the key package + in the request; accepting it would have left key shares outside + the engine and defeated the sidecar's signing-secret boundary. A + request `threshold` is still carried but must equal the DKG + threshold. As a consequence, an interactive session always rides a + DKG-populated session and never creates registry entries of its + own. +2. `InteractiveRound1` — fresh nonces + commitments as in section + 4. Per (session, attempt, member) at most one live handle; + repeat calls return the same commitments (idempotent) until + consumed, then fail closed. +3. `InteractiveRound2` — input: the coordinator's signing package + (the chosen responsive subset's commitment list). The engine + verifies (a) own membership in the subset, (b) the subset is a + subset of the attempt's included set, (c) `|subset| == t` + (exactly `t`, deliberately: deterministic, smallest-possible + package; FROST tolerates more, this spec does not), (d) every + commitment is well-formed, (e) attempt-context binding, and + (f) **the member's own commitment entry in the package is + byte-identical to its `InteractiveRound1` output** (the engine + holds it alongside the nonce handle). Without (f) a malicious + coordinator could substitute an honest member's commitment, + making that member's correctly-computed share fail verification + at aggregation and manufacturing false blame evidence against + it. ALL verification precedes consumption: a package that fails + any check leaves the nonce handle live (an invalid package must + not burn the attempt), while at-most-one-share-per-handle still + holds against two *valid* packages because the second call finds + the handle consumed. +4. `InteractiveAggregate` — coordinator-side: collects shares + against the signing package, verifies each share against the + member's verifying share before aggregation (share verification + is what converts "invalid contribution" into attributable blame + evidence), produces the BIP-340 signature, marks the session + complete in the consumed registry. +5. `InteractiveSessionAbort` — explicit teardown; consumes any + live nonce handles; idempotent. + +Registry semantics: the consumed-registries pattern from the coarse +path applies per call family, with the same fail-closed capacity +behavior; keys carry `(session_id, attempt_id)` so bounded +concurrency (section 8) extends them without weakening replay +protection. + +Live-state bounds: open sessions and unconsumed nonce handles are +engine memory holding secret material, so they get the same +discipline as the registries — a hard cap on concurrently live +sessions (fail closed at capacity: `InteractiveSessionOpen` is +rejected, never silently evicted) and a TTL sweep that aborts +abandoned sessions, zeroizing their nonces, mirroring the Go-side +session-handle registry's TTL. Without this, a flood of +`SessionOpen`/`Round1` calls grows unbounded secret-bearing state. + +## 6. t-of-included semantics and evidence + +* Members submit round-1 commitments to the attempt's coordinator + (Annex A selection). The coordinator forms the signing package + from the **first `t` responsive included members** — arrival + order, no waiting window in v1 (open question 3 proposes the + default). +* **Safety does not depend on the coordinator's honesty.** FROST + binds each share to the exact commitment list in the signing + package; a coordinator equivocating different subsets to + different members yields shares that cannot aggregate — a + liveness failure, not a soundness failure. The engine-side checks + in `InteractiveRound2` (membership, subset-of-included, size + `t`, own-commitment match) bound what a malicious coordinator can + request at all. +* **Liveness failures must be attributable.** The signing package + the coordinator distributes is a signed-body envelope (#4040 + pattern, operator key): members retain the received bytes, so a + coordinator that equivocates packages within one attempt has + produced self-incriminating evidence — the same + `EquivocationEvidence` retention path added by #4044 extends to + package envelopes. A coordinator that stalls is rotated by the + existing RFC-21 transition machinery; a member whose share fails + verification in `InteractiveAggregate` becomes re-checkable blame + evidence (the proof-carrying-blame roadmap consumes this; the + f+1 accuser quorum remains the exclusion gate until then). + Share-verification blame is sound ONLY because of Round2 check + (f): a member signs exclusively over packages that carry its true + commitment, so a share that fails verification against that + package cannot be the product of coordinator substitution — the + member is the only party who could have produced it. Blame + re-checking MUST verify against the package envelope the member + signed over (the retained received bytes), never a reconstructed + package. +* Under these semantics a silent included member costs zero + attempts — it is simply not among the first `t` responders — and + Annex B's sampling table stops binding liveness. The + `performance_signing_attempt_*` gauges stay in place and should + show the regime change on testnet. + +## 7. Transitional-path deletion trigger (decision 6, made precise) + +"Interactive production path validated end to end" means all of: + +1. The interactive session layer passes the Phase-5-equivalent + suites: replay, restart-safety (incl. consumed-nonce-marker + ordering under injected persist faults), and the chaos matrix + extended with coordinator-equivocation and first-t-subset cases. +2. Go orchestration drives interactive signing on a real testnet + deployment through the full retry/rotation machinery, including + at least one attempt that finalizes with a strict subset of the + included set (a real t-of-included finalize, not n-of-n). +3. The cross-language vectors for the new wire structs (signing + package envelope, round-1/round-2 messages) are pinned on both + sides, regen-disciplined like the existing corpora. + +When all three hold: delete the transitional +`StartSignRound`/`FinalizeSignRound` deterministic flow and the +`RoundNonceBinding` machinery (`src/engine/nonce.rs`), migrate the +tests that pin them, and update the gates doc. + +**EXECUTED (2026-07-09):** the deletion has been performed — `nonce.rs` +and `signing.rs` are removed, the trusted-dealer `run_dkg` and the +stateless coarse FFI ops are gone, the six coarse extern-"C" symbols are +removed, and `TBTC_SIGNER_ABI_MAJOR` was bumped 1 → 2 (minor reset to 0) +with the Go bridge and `ci/frost-signer-pin.env` updated in lockstep. +See the EXECUTED note under Decision 6 in +`roast-phase-5-security-rollout-gates.md` for the full inventory. The +former freeze marker lived in the now-deleted `nonce.rs`. + +## 8. Bounded concurrency (reserved, not built) + +Up to `n-t+1` concurrent attempts per session is the fast-follow. +This spec reserves: attempt-scoped registry keys (already the +shape), attempt-scoped nonce handles (section 5), and the rule that +concurrent attempts never share nonce material. What it does NOT +prescribe: concurrent-attempt scheduling policy or cross-attempt +share reuse (forbidden by construction — one handle, one attempt). + +## 9. Phasing (PR-sized, in order) + +* **7.0** — this spec freeze (+ #4007 sidecar scoping addendum: + transport mapping of the section-5 API; separate doc, same + review). +* **7.1** — engine session layer: session registry, nonce custody + (section 4), `InteractiveSessionOpen/Round1/Round2/Abort`, + consumed registries, persistence of markers, unit + restart + tests. (mirror) +* **7.2** — `InteractiveAggregate` + share verification + package + envelope evidence; FFI surface + cross-language vectors. (mirror, + vectors copied per regen discipline) +* **7.3** — Go interactive executor: signing_loop migration to the + session API, Annex A attempt-seed adoption (retiring the legacy + `signingAttemptSeed`), wiring into the RFC-21 retry/selector + machinery; redemptions first behind the existing readiness + gating. (scaffold) +* **7.4** — t-of-included evidence integration: package-envelope + retention, equivocation observer extension, blame-evidence + surfacing. (scaffold + mirror) +* **7.5** — e2e/chaos extension + testnet validation run → + section 7 trigger fires → transitional-path deletion PR + + readiness-manifest flip with attached evidence. (The manifest's + FrostUniFFIV1-migration verification is an independent flip + condition — 7.5's testnet evidence alone does not satisfy it.) +* **7.6** — bounded concurrency (fast-follow, own mini-spec). + +## 10. Open questions this freeze forced (DECIDED 2026-06-12) + +All four decided at freeze sign-off (MacLane; recorded as Decision +Log entry 8 in `roast-phase-5-security-rollout-gates.md`): + +1. **Signing-package distribution channel — DECIDED: dedicated + topic signed with the operator key** (consistent with RFC-21's + resolved coordinator-proposed-aggregation decision), not + piggybacked on the existing session channel. +2. **Round-1 commitment transport — DECIDED: members → coordinator + only** (paper-ROAST shape). Broadcast-to-all is revisited, if at + all, with bounded concurrency. +3. **Responsive-subset policy — DECIDED: strict first-t arrival + order**, no fairness window. Operator-fairness economics are + deferred to testnet telemetry; a gather window may be proposed + later as its own decision. +4. **Session-state durability — DECIDED: markers-only** (per + section 4). Resumable round-1 state contradicts + never-persist-nonces and is rejected; a crashed member misses + that attempt. + +## 11. Freeze acceptance criteria + +* Signer and keep-core owners sign off on sections 4-7 with no + unresolved ambiguity on nonce lifecycle, subset-choice + verification, or the deletion trigger. +* Open questions in section 10 carry decisions (default or + overridden), recorded in the gates-doc Decision Log. +* The audit scope statement references this document and names the + section-5 API as in-scope. diff --git a/pkg/tbtc/signer/docs/phase-7-sidecar-transport-addendum.md b/pkg/tbtc/signer/docs/phase-7-sidecar-transport-addendum.md new file mode 100644 index 0000000000..755cc0232c --- /dev/null +++ b/pkg/tbtc/signer/docs/phase-7-sidecar-transport-addendum.md @@ -0,0 +1,188 @@ +# Phase 7.0 Addendum: Sidecar Transport Mapping + +Date: 2026-06-12 +Status: Proposed (same review process as the Phase 7 spec freeze) +Owner: Threshold Labs +Scope: maps the frozen interactive-session API +(`phase-7-interactive-session-spec-freeze.md`, section 5) onto the +sidecar process boundary chosen in Decision Log entry 2, and scopes +what that boundary means for #4007 (the decision-gated TEE checker +stack). This document changes no contract: the sidecar is a +transport swap by construction, and anything here that would alter +the frozen spec is a defect in this document. + +## 1. What the sidecar is + +A separate OS process that owns the signer engine and every secret +it holds: key-share state, the state-encryption key path, and (after +Phase 7.1) the in-memory interactive nonces. The keep-client host +process — Go runtime, libp2p, Ethereum client, every transitive +dependency — talks to it over local IPC. + +**Boundary scope (important, and a hard prerequisite for #4007).** +The "host holds no signing secrets" property is *scoped to the +signing path* and holds once Phase 7.1's engine-held nonce custody +ships: key shares are env/command-only and nonces never leave the +engine. It does **not** yet hold for **DKG**: the transitional DKG +APIs that section 3 maps unchanged still return and accept +`secret_package_hex` through the host (frozen Phase 7 spec section 4 +names DKG secret-package custody as an out-of-scope follow-up). So in +any deployment that runs DKG through this transport, the host process +still sees DKG secret material. #4007 must therefore treat the +host↔sidecar **signing** interface as a secret boundary but must NOT +treat the DKG interface as one until the DKG-custody follow-up moves +that material inside the sidecar (or DKG is run out-of-band). Closing +that gap is a precondition for the sidecar being a complete secret +boundary. + +The isolation claim, stated precisely: today a memory-disclosure +bug anywhere in the host address space can read whatever the +in-process engine holds, because the dlopen FFI is an API boundary, +not a security boundary. The sidecar makes the boundary an OS +process boundary. It is also the deliberate stepping stone to the +TEE deployment: a sidecar process becomes an enclave process with +the same wire protocol, which is precisely why decision 2 told +isolation-sensitive work to assume this shape. + +## 2. Why the frozen API maps cleanly + +Two prior decisions did the work in advance: + +* The engine API is already coarse JSON request/response over a C + ABI — chosen over round-level FFI compatibility partly FOR + "cleaner future sidecar extraction" + (`signer-api-contract-decision-brief.md`). +* The frozen section-5 calls are idempotent-or-fail-closed, + self-contained request/response with no callbacks and no shared + memory. + +One tension to resolve explicitly: the old decision brief argued +against round-level APIs because they kept "nonce/round details +crossing the FFI boundary" and made the transport swap harder. The +Phase 7 API *is* round-level (`Round1`/`Round2`) — interactivity is +forced by true two-round FROST with a network exchange between +rounds — but the brief's actual objection is dissolved by the +frozen spec's section 4: rounds cross the boundary, **nonces do +not**. What transits is public commitments, signing packages, and +shares. The chattiness objection is inherent to interactive FROST +and is bounded (two round trips per attempt against a ~41-block +attempt budget; the Annex B arithmetic gives ~175x headroom). + +## 3. Transport mapping + +Same JSON envelopes, different carrier: + +| Engine call (frozen spec §5 / existing API) | dlopen transitional | Sidecar | +|---|---|---| +| `InstallNativeTBTCSignerConfig` (init) | `frost_tbtc_init_signer_config` symbol | First request after connect (handshake step 2) | +| `InteractiveSessionOpen/Round1/Round2/Aggregate/Abort` | per-call symbols (Phase 7.1/7.2) | One method each, identical JSON bodies | +| Coarse transitional calls (until deleted per spec §7) | existing symbols | Same mapping rule | + +Carrier (proposed defaults, section 8): a UNIX domain socket with +length-prefixed JSON frames, a small connection pool, and exactly +one in-flight request per connection. No request multiplexing in +v1: the engine's concurrency model and registries are unchanged, +and the pool bounds parallelism exactly as the host's call sites do +today. Errors keep the structured `ErrorResponse` contract +(`consumed_attempt_replay` etc.) — the codes are the cross-version +interface and MUST NOT fork between transports. + +Transport conformance: the contract tests that pin the FFI behavior +become transport-parameterized — the same request/response suites +run against the dlopen bridge and the sidecar, and divergence is a +release blocker. This is the mechanism that keeps "transport swap, +not API rework" true over time. + +## 4. Process model and lifecycle + +* **Spawn/supervision (proposed default)**: keep-client spawns the + sidecar as a child process and supervises it (restart with + backoff). The alternative — independent systemd unit — is open + question (a); the child model keeps the operator surface to one + service and lets the existing init-config demand semantics apply + without a coordination protocol. +* **Handshake**: (1) version exchange — the host refuses to operate + a sidecar outside its supported range, fail closed; (2) init- + config install — the host reads `TBTC_SIGNER_INIT_CONFIG_PATH` + and posts the install request as the first message, exactly the + #4037/#4041 flow. **Decision 7 carries over unchanged**: with the + path set, a sidecar that cannot be spawned, cannot complete the + handshake, or rejects the config is process-fatal for the host, + in every profile. The enforcement point + (`enforceNativeInitConfigDemand`) gains "sidecar unreachable" as + one more member of the same failure family. +* **Crash semantics**: a sidecar crash loses in-flight nonces — by + the frozen spec's section 4 and ratified question 4 + (markers-only), this is exactly the restart story: live attempts + fail safe, durable consumption markers prevent any replay, the + supervisor restarts the sidecar, re-init runs (idempotent by + config fingerprint), and the attestation TTL applies at re-init + (runbook prerequisite 6). No new failure mode is introduced; the + sidecar converts "host process restart" into the strictly smaller + "signer process restart." +* **Shutdown**: host-initiated graceful stop sends `SessionAbort` + for live sessions (zeroize), then terminates. SIGKILL is + equivalent to a crash and is safe by the same argument. + +## 5. Security boundary + +* Socket: filesystem-permission-guarded UDS (owner-only directory), + peer-credential check (`SO_PEERCRED`/`LOCAL_PEERCRED`) pinning + the host UID. Never a network listener — a TCP mode is explicitly + out of scope and should be rejected in review if proposed. +* Authentication beyond UID pinning is deliberately deferred: the + v1 trust model is same-host, same-operator. The TEE phase + replaces this with an attestation-bound channel; designing that + channel is part of #4007's scope, not this addendum's. +* Secrets: the state-encryption key provider (env/command) runs in + the **sidecar's** process environment, not the host's. The config + file may carry `state_key_command` (its 0600 guidance stands); + the command executes sidecar-side. Host environment variables + stop being a secret channel entirely. + +## 6. What does not change + +JSON schema ownership (Rust), the error-code contract, idempotency +and fail-closed semantics, registries and persistence +(sidecar-local files, same formats), provenance gating, the frozen +section-5 verification rules, and the section-7 deletion trigger. +The dlopen bridge remains the shipping transport until the sidecar +lands; Phases 7.1-7.5 build and validate on dlopen without waiting. + +## 7. #4007 (TEE checker stack) scoping + +#4007 gates *whether a signer may register* on TEE attestation +evidence and stays decision-gated on the DAO's TEE policy — this +addendum does not undraft it. What the sidecar decision gives it is +a concrete subject: the artifact whose identity gets attested is +the sidecar binary (later, the enclave image), not the composite +keep-client process. #4007's open scoping questions become: which +measurement (binary hash / enclave MRENCLAVE-equivalent), who +verifies (the DAO-whitelist checker), and how the attestation binds +to the UDS channel. Those land in #4007's own design doc; the +interface contract it must respect is sections 3-5 here. + +## 8. Open questions (proposed defaults; decide at this addendum's +sign-off) + +* (a) **Spawn model**: keep-client child process (default) vs. + independent systemd unit. +* (b) **Wire framing**: length-prefixed JSON frames (default) vs. + newline-delimited JSON. +* (c) **Connection model**: small pool, one in-flight request per + connection (default) vs. request-id multiplexing. +* (d) **Packaging**: sidecar binary ships in the same release + artifact as keep-client (default) vs. separate artifact with its + own version line. + +## 9. Sequencing + +The sidecar is not on the 7.1-7.5 critical path: those phases build +on the dlopen transport, and the frozen API guarantees the swap is +transport-only. The sidecar track runs in parallel and must +converge **before the ECDSA-retirement phases** (decision 1's +timing: take the isolation step before mainnet TVL migrates). +Suggested shape: 7.S1 sidecar process + handshake + conformance +suite; 7.S2 operational hardening (supervision, packaging, +runbook); 7.S3 cutover of the production default with dlopen kept +as the rollback transport for one release. diff --git a/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md b/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md new file mode 100644 index 0000000000..844ff46d1d --- /dev/null +++ b/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md @@ -0,0 +1,68 @@ +# Coordinator-shuffle seed derivation (RFC-21 Annex A mirror) + +The normative definition of the ROAST coordinator-shuffle seed lives in +keep-core's RFC-21, *Annex A (normative): coordinator-shuffle seed +derivation* +(`docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc` +on the `feat/frost-schnorr-migration-scaffold` branch). This file +mirrors the derivation for signer-side readers; if the two ever +disagree, the RFC annex wins. + +## Derivation + +```text +AttemptSeed32 = SHA256(KeyGroupBytes || SessionID || MessageDigest) +ShuffleSeed_i64 = int64_from_be_bytes(AttemptSeed32[0..8]) +SourceSeed_i64 = ShuffleSeed_i64 + int64(AttemptNumber) # two's-complement wrap +Coordinator = GoMathRandShuffle(sort_ascending(IncludedSet), SourceSeed_i64)[0] +``` + +- `KeyGroupBytes`: UTF-8 bytes of the canonical key-group handle. For + this engine that is the lowercase hex encoding of the serialized + group verifying key (the `key_group` string in `DkgResult`), treated + as an opaque string — never decoded to point bytes before hashing. +- `SessionID`: raw UTF-8 bytes. +- `MessageDigest`: the **raw signing message itself**, big-endian + left-padded with zeros to exactly 32 bytes (leading zero bytes are + insignificant; more than 32 significant bytes is rejected). This + mirrors keep-core's `messageDigestFromBigInt`: in BIP-340 production + the message the engine receives *is* the 32-byte sighash. It is + **not** the engine's internal transcript digest + (`SHA256(message_bytes)`), which continues to feed the + `round_id`/`attempt_id` derivations only. Implemented by + `rfc21_message_digest` in `src/engine/roast.rs`; feeding the transcript + digest here instead was the cross-language coordinator divergence + caught in review of the unification PR. +- `AttemptNumber`: the RFC-21 **0-based** attempt number. The FFI + `AttemptContext.attempt_number` carries the **1-based** wire encoding + (`wire = AttemptNumber + 1`, zero rejected); the engine subtracts one + before composing the shuffle source (`validate_attempt_context`). +- `GoMathRandShuffle`: the bit-exact port of Go's legacy `math/rand` + shuffle in `src/go_math_rand.rs`, pinned by keep-core PRs #4026 and + #4027. + +Implemented by `roast_attempt_shuffle_seed` in `src/engine/roast.rs`; the +end-to-end acceptance of a Go-derived context through strict +`StartSignRound` is pinned by +`start_sign_round_accepts_go_derived_attempt_context_in_strict_mode`. + +## Conformance vectors + +`testdata/coordinator_seed_vectors.json` is a byte-identical copy of +the canonical vector file generated from the Go implementation +(`pkg/frost/roast/testdata/coordinator_seed_vectors.json`, regenerated +there with `ROAST_SEED_VECTORS_REGEN=1 go test ./pkg/frost/roast -run +TestRegenerateCoordinatorSeedVectors`). The unit test +`coordinator_seed_derivation_matches_cross_language_vectors` pins the +seed, the selected coordinator, the 0-/1-based wire mapping, and full +`validate_attempt_context` acceptance for every vector. When the Go +side regenerates the file, re-copy it here verbatim. + +## History + +Before this unification the engine derived the seed from the first 8 +bytes of the raw message digest with the 1-based wire attempt number — +the legacy `signingAttemptSeed` convention of the pre-ROAST keep-core +signing loop. The divergence from the RFC-21 layer was flagged in +keep-core PR #4026 and resolved by adopting the Go derivation as +normative. 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..b0875712ee --- /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: + `pkg/tbtc/signer/docs/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: + `pkg/tbtc/signer/docs/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: + - `pkg/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: + `pkg/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: + `pkg/tbtc/signer/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..ab082e88b0 --- /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` (`pkg/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(framed(tag) || framed(sorted_unique_ids))` + +Attempt id: + +- `attempt_id = SHA256(framed(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..d1bfec9e96 --- /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: + `pkg/tbtc/signer/src/engine` (`derive_round_id`, + `round_attempt_id_component`). +- Attempt-context canonicalization fix: + `pkg/tbtc/signer/src/engine` (`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 `pkg/tbtc/signer/docs/rust-rewrite-bootstrap.md` keep-core + integration notes and linked `threshold-network/keep-core` commits). + +## Remaining Work + +1. Continue Phase 2 coordinator policy enforcement: + `pkg/tbtc/signer/docs/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..0149cbd080 --- /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: + `pkg/tbtc/signer/src/engine` (`SessionState.active_attempt_context`, + `PersistedSessionState.active_attempt_context`). +- Policy enforcement helper: + `pkg/tbtc/signer/src/engine` (`enforce_active_attempt_context_match`). +- Deterministic coordinator selector parity helper: + `pkg/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..d4a3fae61a --- /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: + `pkg/tbtc/signer/src/engine` (`SessionState.consumed_attempt_ids`, + `PersistedSessionState.consumed_attempt_ids`, + `SessionState::try_from`, `PersistedSessionState::try_from`). +- Start-path attempt replay enforcement: + `pkg/tbtc/signer/src/engine` (`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: + `pkg/tbtc/signer/docs/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..cc4b128e80 --- /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 + `pkg/tbtc/signer/src/errors.rs` with values: + - `recoverable` + - `terminal` +2. Extended signer FFI error payloads with `ErrorResponse.recovery_class` in + `pkg/tbtc/signer/src/api.rs` and `pkg/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: + - `pkg/tbtc/signer/src/errors.rs` + - `errors::tests::recovery_class_maps_retryable_and_terminal_errors` +- FFI payload extension: + - `pkg/tbtc/signer/src/api.rs` (`ErrorResponse`) + - `pkg/tbtc/signer/src/ffi.rs` (`error_result`) +- API-level assertions: + - `pkg/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: + - `pkg/tbtc/signer/src/engine` + - `roast_coordinator_timeout_ms_env_parser_is_strict_bounds` +- Exclusion/blame evidence validation: + - `pkg/tbtc/signer/src/api.rs` (`AttemptExclusionEvidence`, `AttemptTransitionEvidence`) + - `pkg/tbtc/signer/src/engine` (`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: + - `pkg/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: + - `pkg/tbtc/signer/include/frost_tbtc.h` +- Phase 4 liveness, exclusion-evidence, and transition-telemetry evidence is + summarized in this document. +- Contract documentation alignment: + - `pkg/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..02ffb2fb4f --- /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: + +- `pkg/tbtc/signer/docs/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: + `pkg/tbtc/signer/docs/formal/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..abee12cec0 --- /dev/null +++ b/pkg/tbtc/signer/docs/roast-phase-5-rollout-runbook.md @@ -0,0 +1,162 @@ +# 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: + +- `pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md` +- Future mandatory TEE hardening profile + (activation-gated): + `pkg/tbtc/signer/docs/tee-whitelisted-signer-enforcement-plan.md` + +## 2. Prerequisites + +Before Stage 1 canary: + +1. Security/correctness gate checks are green. +2. Fresh interactive latency-window evidence is available for the stage being + promoted, including the required sample counts and p95 values. The retired + coarse-path `phase5_roast` benchmark is not a rollout gate. +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: + - `pkg/tbtc/signer/docs/roast-phase-5-baseline-calibration.md` +6. Provenance attestation rotation cadence scheduled: a production + signer installs its configuration once at process start (the + init-time config FFI, `frost_tbtc_init_signer_config`) and the + attestation material in it is immutable for the process lifetime, + while attestation TTL is capped at 7 days + (`TBTC_SIGNER_PROVENANCE_MAX_ATTESTATION_TTL_SECONDS`). Operators + MUST restart (re-init) each signer with fresh attestation material + within every attestation window, and rollout stage scheduling must + absorb that restart cadence. Live re-attestation without a restart + is deliberately unsupported: it would require a dedicated, + narrowly-scoped FFI, never general config mutation, which would + reopen the split-brain risk the immutable install design closed. +7. Config-file pushes are canaried node-by-node: an unmet init-config + demand (`TBTC_SIGNER_INIT_CONFIG_PATH` set but the FROST-native + engine did not come up) terminates the process in every profile + (gates-doc Decision Log, decision 7). A bad config template pushed + fleet-wide therefore produces a visible, correlated outage instead + of silent capability loss - push to a single node, confirm a clean + start, then roll out. The same applies to signer-library upgrades + that tighten init-time validation: a config that installed + yesterday can be rejected after an upgrade, so upgrade + config + changes are canaried together. Note this also enforces prerequisite + 6's attestation cadence: a node restarted with expired attestation + material will not start until re-attested. Scope the variable to + the signer service unit (e.g. the systemd unit's `Environment=`), + never the host-global environment: every binary importing the + signing package honors the same demand, so a host-global export + plus a broken config would also kill maintenance tooling and test + binaries run on that host. + +## 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 +`pkg/tbtc/signer/docs/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..451b18853e --- /dev/null +++ b/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md @@ -0,0 +1,389 @@ +# 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: 10% signer fleet / limited wallet cohort, hold for 24h. +2. Stage 2: 50% signer fleet / broader cohort, hold for 24h. +3. Stage 3: 100% rollout after Phase 5 acceptance criteria remain green. + +The executable promotion gate requires, for each stage, at least 100 successful +samples from Interactive Round1, Interactive Round2, and Interactive Aggregate, +plus 100 first-time `BuildTaprootTx` policy decisions. Only samples from the +last hour count by default. Evidence is process-local and resets after every +promotion/rollback; a restart therefore blocks promotion until the current +stage rebuilds its window. Fast failures and idempotent replays are excluded. +Operators can tune the bounded minimum/window and the three per-operation p95 +thresholds with the `TBTC_SIGNER_CANARY_*` knobs documented in `README.md`. + +## Cryptographic Dependency Audit Status (Gate 1 Input) + +The signer pins `frost-secp256k1-tr = "=3.0.0"` (`Cargo.toml`), the Zcash +Foundation FROST implementation's Taproot (BIP-340/341) ciphersuite, +released 2025-04-23. + +External audit coverage of that stack, verified against upstream +statements as of 2026-06-12: + +- **NCC Group, "Zcash FROST Security Assessment"** (report dated + 2023-10-20, published October 2023): audited the **v0.6.0** release + (commit `5fa17ed`) of `frost-core`, `frost-ed25519`, `frost-ed448`, + `frost-p256`, `frost-secp256k1`, and `frost-ristretto255` - key + generation (trusted dealer and DKG) and FROST signing. All findings + were addressed and re-reviewed by NCC. + Report: +- The upstream README states explicitly: *"This does not include + frost-secp256k1-tr and rerandomized FROST."* +- **Least Authority, FROST Demo audit (Q1 2025)**: covered the + `frost-client` and `frostd` demo tooling only - not the library + crates this signer consumes. + +- No 2.x or 3.x release notes mention additional audit coverage. + +**Consequence for Gate 1:** the exact ciphersuite this signer uses for +production signatures (`frost-secp256k1-tr`) and the v0.6.0 → 3.0.0 +evolution of `frost-core` have **no external audit coverage**. The +NCC assessment establishes pedigree for the core protocol +implementation but cannot be cited as covering the pinned version +range. + +**DECIDED (2026-06-12, MacLane): an external audit covering +`frost-core` 3.x and the `frost-secp256k1-tr` ciphersuite is a HARD +GATE for the ECDSA-retirement phases.** Gate 1 sign-off for those +phases requires the completed audit; canary stages before ECDSA +retirement may proceed under the existing gate criteria, but +retirement-phase rollout does not start without the audit report in +hand. + +## Decision Log (2026-06-12) + +Decisions taken on the post-merge follow-up checklist's open +architecture questions: + +1. **External audit = hard gate for ECDSA retirement** (see above). +2. **Sidecar signer process** chosen over in-process cgo as the + target architecture (stepping stone to TEE deployment). The + in-process dlopen bridge remains the transitional integration; new + isolation-sensitive work should assume the sidecar boundary. This + unblocks scoping of the decision-gated TEE checker stack (#4007). +3. **Script-tree commitment vs timelocked recovery leaf for FROST + wallets: explicitly OPEN.** Needs more evaluation time; multiple + open questions remain. No work should bake in either assumption. +4. **Proof-carrying blame (follow-up item 7): deferred until + production**, with a binding retention condition: telemetry and + logging must retain enough signed bytes to diagnose whether + targeted equivocation is occurring, so the revisit decision has + data. **This deferral is contingent on that retention landing.** + Retention of the conflicting signed evidence envelopes at the + detection points is added by keep-core PR #4044 against the + scaffold branch (`EquivocationEvidence` instrumentation); until + that merges, the base Go RFC-21 layer detects a conflict and + returns `ErrSnapshotConflict` but drops the conflicting envelope, + so the retention condition is NOT yet met and the deferral does + not hold. Full cross-member equivocation comparison arrives with + item 7 itself. +5. **t-of-included finalize (follow-up item 6): scheduled as the + first engineering item of Phase 7**, not earlier. The transitional + flow computes each member's signature share at StartSignRound + against binding factors derived from the full included set's + commitment list (finalize enforces contributions == included set), + so first-t-responsive finalize requires computing shares after the + responsive subset is known - the interactive two-round exchange + that IS Phase 7's core. Pulling it earlier would implement the + interactive path without its Go-side consumer. +6. **Transitional deterministic-nonce path: committed for DELETION.** + The path is already production-gated (production signing is + interactive-FROST-only with OS randomness), so it serves + dev/staging only - while its nonce safety rests on the + RoundNonceBinding transcript being *complete*, and the F1 finding + (round-nonce-v3) demonstrated that one missing field is a + key-extraction-class bug that an experienced review missed. + Carrying a binding-completeness invariant indefinitely is a + permanent footgun with no production benefit. + **Deletion trigger: the interactive production path validated end + to end** - at that point the transitional + StartSignRound/FinalizeSignRound deterministic flow and the + round-nonce binding machinery are removed. Until then the path is + FROZEN: no new transcript inputs may be added to the transitional + signing flow, because each addition must extend RoundNonceBinding + and any omission recreates the F1 bug class. + Interaction with item 6: the deletion commitment means the Phase 7 + interactive session flow is designed t-of-included-native from the + start; no first-t-responsive retrofit of the transitional finalize + contract is needed or wanted. + **EXECUTED (2026-07-09).** The transitional coarse-FROST path has + been DELETED (spec §7). Removed from the signer crate: the + `StartSignRound`/`FinalizeSignRound` deterministic flow (`signing.rs`), + the `RoundNonceBinding` machinery (`nonce.rs`, whole file), the + trusted-dealer `run_dkg` + its production gates + the stateless + `generate_nonces_and_commitments`/`sign_share`/`aggregate` FFI ops + (and the #4129 production gate that fenced them), plus the sign-round + persist-pending marker mechanism that only the coarse round used. The + six coarse extern-"C" wrappers are gone. Removing exported symbols is + an incompatible ABI change: **`TBTC_SIGNER_ABI_MAJOR` 1 → 2, minor + reset to 0**; the Go bridge's required-ABI constants and + `ci/frost-signer-pin.env` were bumped in lockstep. Preserved and + unaffected: the interactive path (OS-random nonce custody), the + distributed-DKG persist path (`persist_distributed_dkg_key_package` + + `dkg_part1/2/3`), and the Go tECDSA routing. Coarse-coupled tests were + migrated (interop/firewall coverage onto frost-crate primitives / the + interactive entry point) or removed, and the provenance-gate + status/runtime-version negative-branch coverage was re-established + directly against `enforce_provenance_gate()`. Follow-up: the + transcript-audit/blame FFIs remain as API surface but lost their + coarse-coupled integration tests; re-establish coverage when the + interactive blame instrumentation (Decision 4 / Phase 7.4) lands. +7. **Init-config demand is process-fatal.** Setting + `TBTC_SIGNER_INIT_CONFIG_PATH` demands config-mode FROST operation; + any state in which the FROST-native engine does not come up under a + set path - config-install failure, engine-registration failure + after a successful install, or a binary built without + `frost_native` - terminates the process, in every profile and + environment. This replaces the earlier + continue-on-the-legacy-bridge degradation adopted in keep-core + PR #4041. Rationale: this code ships to production only when FROST + is a production duty, so "running but FROST-dead" is the dangerous + state - a silently half-alive node erodes FROST wallet fault + budgets invisibly, while threshold redundancy is designed to absorb + loud, full, bounded outages; and fatality cannot be + profile-conditional because an unreadable config file cannot reveal + its profile and a missing profile means production + (production-by-omission), so path-set is the only non-circular + trigger. Uniform semantics also mean testnet rehearses exactly the + behavior production will have. Env-fallback mode (path unset) keeps + the safe-by-default degrade posture. Operational consequence: + config-file pushes to config-mode fleets must be canaried + node-by-node (runbook prerequisite 7) because a bad push now + produces visible downtime instead of silent capability loss. + Implemented in keep-core PR #4045 (scaffold), the follow-up to + PR #4041's Go-host adoption. +8. **Phase 7 interactive-session spec FROZEN** (2026-06-12, + MacLane): `docs/phase-7-interactive-session-spec-freeze.md` is + the binding contract for the production interactive signing + path - engine-held nonce custody (no secret signing material on + the FFI), the InteractiveSessionOpen/Round1/Round2/Aggregate/ + Abort API with own-commitment verification at Round2, + t-of-included-native finalize, live-state capacity + TTL bounds, + and the precise transitional-path deletion trigger (its section + 7). The four design questions it forced are decided: signing + packages ride a dedicated operator-key-signed topic; round-1 + commitments go members-to-coordinator only; the responsive + subset is strict first-t arrival order; durability is + markers-only (resumable round-1 state rejected as contradicting + never-persist-nonces). Review converged before freeze: + adversarial-pass findings applied (own-commitment check, + live-state bounds, verify-before-consume, DKG-custody scoping), + Codex and Gemini clean. DKG secret-package custody is a named + follow-up outside this freeze; the audit scope must describe the + DKG boundary as-is. + +## Decision Log: Phase 7.2b Design Sign-Off (2026-06-13) + +9. **Phase 7.2b design SIGNED OFF (2026-06-13, MacLane):** the + package-envelope + bound-blame design note + (`phase-7-2b-package-envelope-design.md`, keep-core PR #4054) is + approved as the binding contract for the 7.2b implementation PRs, + after seven adversarial review passes (Codex + Gemini) whose final + two passes were clean on independent reads. The load-bearing + correction and the implementation gates folded across those passes: + + a. **Engine stays crypto-only (Q1 - the load-bearing correction).** + The Rust engine does pure FROST share-math and returns the + mathematically-failing members as *candidate* culprits; it never + verifies signing-package envelopes or operator signatures (it + holds no operator-key registry). All envelope verification and + *authoritative* blame adjudication live in the Go host at the + f+1 accuser quorum, which re-checks each accused share against + that member's *retained received bytes* - never a + coordinator-submitted or reconstructed package. This is a return + to the frozen spec §5.4/§6, not an amendment; the earlier + engine-binds-the-body-hash proposal is withdrawn as unsound + (wrong authentication direction for member blame). + b. **Cross-member comparison + retention timing (Q2).** Retain + received envelopes now; run the cross-member equivocation + comparison at the f+1 accuser-quorum exclusion step (Option B). + Opportunistic gossip deferred. + c. **FFI culprit payload (Q3).** A typed optional + `culprits: Option>` field on the FFI `ErrorResponse` + (Go member-id u16 form), not a generic `details` map. + d. **All-cheater detection.** 7.2b-3 aggregates with + `CheaterDetection::AllCheaters` so the full candidate-culprit set + is reported, not just the first. Because + `frost_secp256k1_tr::aggregate_with_tweak` hard-codes + first-cheater, the engine applies the taproot tweak itself + (`public_key_package.tweak(merkle_root)`) and calls + `frost_core::aggregate_custom(…, AllCheaters)` on the tweaked + package. + e. **Taproot root binding before signing.** Members verify + `SignedSigningPackage.taproot_merkle_root` equals the live + session root *before* producing a Round2 share (the root is not + in the attempt context but is what Round2 signs under), else the + retained envelope misdescribes what was signed and the quorum + re-check misattributes blame. + f. **Context-bound member-authenticated share submission.** A + candidate culprit becomes authoritative blame only for a share + provably submitted by the accused member for THIS attempt and + package: the member's signed share body must cover + (attempt_context_hash, signing-package/envelope hash, share), so + an old A-signed share cannot be replayed into a different + attempt to frame A. Hard prerequisite - 7.2b-4 must not enable + blame until this exists. + g. **Elected-coordinator check + retain-on-reject.** Members verify + the envelope's `coordinator_id` is the *elected* coordinator + (RFC-21 Annex A) and the signature verifies under that specific + key; a divergent but genuine-coordinator envelope is retained as + equivocation evidence *before* the member refuses to sign it. + h. **verify-share FFI contract.** The quorum's per-share crypto + re-check is delegated to a stateless tweak-aware verify-share FFI + taking only public inputs (signing_package, taproot_merkle_root, + member identifier, share, + a `session_id`/wallet selector); the + engine resolves the canonical group verifying key + verifying + shares from durably-retained, wallet-scoped DKG material that + outlives the signing-session TTL sweep, and applies the tweak - + never the envelope or operator keys. + + Standing gate unchanged: the external audit covering `frost-core` + 3.x + `frost-secp256k1-tr` remains a hard gate before mainnet TVL / + ECDSA retirement (entry 1). Next engineering step: 7.2b-1 - the + InteractiveAggregate completion marker; the design's §9 + durable-wallet-pubkey-package-retention question is confirmed + already satisfied (the DKG public key package lives on the persisted + session and survives the interactive-attempt TTL sweep), so 7.2b-1 + adds no new persistence beyond the marker. + +## 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. Successful interactive Round1/Round2/Aggregate latency-window snapshot for + the stage being promoted (minimum sample count and p95 values included). +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: + - `pkg/tbtc/signer/docs/roast-phase-5-baseline-calibration.md` + +## Benchmark Harness Status + +The former `phase5_roast` Criterion harness was coupled to the deleted coarse +StartSignRound/FinalizeSignRound path and no longer exists. It is not an +executable rollout gate. Current release evidence comes from the exact-filter +interactive chaos suite plus the fresh production-shaped latency windows above. +An interactive Criterion harness may be added separately, but documentation and +release checklists must not invoke the retired command. + +Phase 5 latency-window 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_interactive_attempt_replay`: a newer member attempt replaces only + its own live state and a stale reopen fails closed. + - `round2_state_key_outage_recovery`: a state-key failure releases no share, + does not burn the attempt, and permits retry. + - `process_restart_consumed_attempt`: a consumed interactive attempt marker + rejects replay across a simulated restart. + - `round2_persist_fault_pre_rename`: a pre-rename persist fault releases no + share, rolls back the marker, and preserves retry. + - `round2_persist_fault_post_rename`: an after-rename persist fault releases + no share, consumes the attempt, destroys live nonces, and survives a + simulated restart before any successful repair. + - `aggregate_persist_fault_post_rename`: an after-rename aggregate fault + retains completion, destroys sibling nonces, and survives a simulated + restart before any successful repair. + +## Rollout Runbook (Implemented) + +- Runbook artifact: + `pkg/tbtc/signer/docs/roast-phase-5-rollout-runbook.md` +- Future mandatory TEE hardening profile + (activation-gated): + `pkg/tbtc/signer/docs/tee-whitelisted-signer-enforcement-plan.md` + +## Baseline Calibration Worksheet (Prepared) + +- Worksheet artifact: + `pkg/tbtc/signer/docs/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..beb127b807 --- /dev/null +++ b/pkg/tbtc/signer/docs/rust-rewrite-bootstrap.md @@ -0,0 +1,308 @@ +# 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 + +> Scope note: this section records the broader `tbtc-signer` rust-rewrite +> bootstrap effort across keep-core, not the diff of a single PR. Bullets that +> cite a `threshold-network/keep-core` PR or commit (e.g. the `BuildTaprootTx` +> CGO bridge wiring, the transitional bootstrap-signing orchestration) live in +> those **separate keep-core changes** and are **not part of this crate's PR +> diff**. Within this PR the crate is standalone: it builds the `cdylib` and C +> header, but nothing in keep-core's Go build links it yet (no `cgo`/`libfrost` +> consumer references the crate). See the production gate below before treating +> any of this as wired. + +- Added `pkg/tbtc/signer` Rust crate that builds a `cdylib` named + `libfrost_tbtc`. +- Added a C ABI contract in `pkg/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` (symbol retained, but ABI 4.0 fails closed; the + one-shot request cannot perform cryptographic FROST share refresh, and the + major bump prevents ABI-3 consumers from accepting the changed response + semantics) +- 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-1 transactions: + - validates non-empty input/output sets and parses input txids, P2TR prevout + scripts, and 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 plus the ordered BIP-341 key-spend + `SIGHASH_DEFAULT` messages derived from the transaction and all prevouts, + - adds session-keyed idempotency/conflict semantics for repeated build calls, + - binds interactive Open/Round2 to the Build artifact stored on the fresh + per-signing session while resolving DKG/lifecycle state from the unique + wallet session, and rechecks active non-rate policy before share release, + - 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 `pkg/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 `pkg/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 the total persisted session count via + `TBTC_SIGNER_MAX_SESSIONS` (default `1024`) so schema-1 state remains + readable after an emergency rollback, + - idle per-message interactive sessions use the portion of that shared budget + not occupied by active sessions, retaining delayed-retry routing, policy + artifacts, and replay/aggregate authorization tombstones until active + admission needs the slot; the oldest eligible retired entry is then evicted, + - compact over-limit retired entries during load, reject over-limit state + during encode, and reject new runtime session creation only when the shared + budget has no eligible retired entry, 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: + `pkg/tbtc/signer/docs/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: + `pkg/tbtc/signer/docs/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) + +- Consumer-activation re-review (mandatory): the crate currently has no Go + consumer in keep-core, so its custody-critical surface has only been reviewed + as inert code. Before any PR wires a Go consumer (links the `cdylib` / enables + the `BuildTaprootTx` CGO bridge), the crate must get a dedicated security + re-review as a now-load-bearing dependency. Treat this as a hard, mechanical + gate, not a cultural assumption. The re-review MUST validate at least: + - Exclusion-evidence trust: the signer applies auto-quarantine penalties from + caller-supplied `attempt_transition_evidence` using a fault-*count* threshold + and a hex-only `invalid_share_proof_fingerprint` check (`engine::roast`), with + no accuser-corroboration of its own. It therefore assumes the Go ROAST layer + has already established exclusions via `VerifyBundle` + the f+1 accuser-quorum + `NextAttempt` policy (RFC-21 Layer B) before feeding them in. Confirm the wired + consumer only feeds quorum-established exclusions, or add signer-side + verification. (Coordinator-*selection* grindability is benign: RFC-21 makes a + byzantine coordinator unable to fabricate exclusions.) +- 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. +- Share refresh: add a multi-round, zero-constant FROST refresh protocol before + enabling `RefreshShares`; the retained one-shot ABI must fail closed rather + than manufacture replacement share bytes. Persisted metadata from the retired + synthetic stub is non-authoritative for refresh cadence and key continuity. + +## Validation command + +```bash +cd pkg/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..d5a867c8ba --- /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 `pkg/tbtc/signer/docs/rust-rewrite-bootstrap.md`) + +The bootstrap Rust crate already exposes this coarse C ABI surface: + +(file: `pkg/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..1e80cb1d54 --- /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: `pkg/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..da1702db0e --- /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. `pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md` +2. `pkg/tbtc/signer/docs/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..02ed504ab0 --- /dev/null +++ b/pkg/tbtc/signer/include/frost_tbtc.h @@ -0,0 +1,107 @@ +#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_abi_version(void); +TbtcSignerResult frost_tbtc_init_signer_config(const uint8_t* request_ptr, size_t request_len); +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_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); +TbtcSignerResult frost_tbtc_persist_distributed_dkg_key_package(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_build_taproot_tx(const uint8_t* request_ptr, size_t request_len); +/* + * Reserved ABI: fails closed with terminal error code + * `cryptographic_refresh_not_supported` until a multi-round, zero-constant + * FROST refresh protocol is implemented. + */ +TbtcSignerResult frost_tbtc_refresh_shares(const uint8_t* request_ptr, size_t request_len); + +/* + * Phase 7.1 hardened interactive signing session. + * + * Secret nonces NEVER cross this boundary in either direction: the engine + * generates, holds, consumes, and zeroizes them internally, keyed by + * (session_id, attempt_id). The caller + * exchanges only public commitments, signing packages, and signature shares. + * frost_tbtc_interactive_round2 verifies the coordinator's signing package in + * full and consumes the attempt's nonces exactly once; a repeat call for a + * consumed attempt fails closed with the `consumed_nonce_replay` error code. + */ +TbtcSignerResult frost_tbtc_interactive_session_open(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_interactive_round1(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_interactive_round2(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_interactive_session_abort(const uint8_t* request_ptr, size_t request_len); +/* + * Coordinator-side aggregation: verifies each collected signature share + * against its verifying share (resolved from the session's DKG state) and + * returns the aggregated BIP-340 signature. Operates on public material + * only - no secret crosses here. On any verification failure it fails + * closed with the generic `validation_error` code and returns no + * signature; per-member attributable blame (a structured culprit list) + * is intentionally NOT emitted yet - it requires the signed-package + * envelope binding added in Phase 7.2b, without which the attribution + * would be forgeable by a coordinator using a mismatched package/root. + */ +TbtcSignerResult frost_tbtc_interactive_aggregate(const uint8_t* request_ptr, size_t request_len); +/* + * Phase 7.2b-4 single round-2 signature-share verification: backs the Go + * host's Round2ShareVerifier (member-blame classifier). Verifies ONE retained + * share against the attempt's signing package using the group's own + * (taproot-tweaked) verifying material - public material only, no secret and + * no operator-signed-envelope inspection (the latter is the Go layer's job). + * Returns an explicit tri-state verdict (valid/invalid/indeterminate) so the + * caller never infers member-fault from an FFI error code. Like aggregate's + * culprit list, an `invalid` verdict is framable by a coordinator that + * supplies a mismatched package/root, so it is an INPUT to the Go host's f+1 + * envelope-bound adjudication (Phase 7.2b spec, section 6), NOT authoritative + * blame on its own. + */ +TbtcSignerResult frost_tbtc_verify_signature_share(const uint8_t* request_ptr, size_t request_len); +/* + * Phase 7.3 interactive attempt-context derivation. Stateless and secret-free: + * derives the canonical attempt context (coordinator, included-participants + * fingerprint, attempt id) and the per-participant FROST identifiers the host + * feeds into frost_tbtc_interactive_session_open, so the host never + * re-implements the engine's domain-separated derivations. The returned context + * is re-validated against the same strict-mode check session_open runs, so the + * engine is guaranteed to accept it. No DKG/nonce/session state is touched. + */ +TbtcSignerResult frost_tbtc_derive_interactive_attempt_context(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..ba02ab3671 --- /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": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "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..352dca7dbd --- /dev/null +++ b/pkg/tbtc/signer/scripts/formal/run_tla_models.sh @@ -0,0 +1,93 @@ +#!/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}" +# Pin the SHA-256 of the upstream tla2tools.jar (github.com/tlaplus/tlaplus +# release v1.8.0). Re-pin this when the upstream release asset is rebuilt and the +# download-verification gate below reports a mismatch, after confirming the new +# jar comes from the official release URL. +TLA_TOOLS_SHA256="${TLA_TOOLS_SHA256:-150b0294c3d407c15f0c971351ccd4ae8c6d885397546dff87871a14be2b4ee4}" + +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..4153720995 --- /dev/null +++ b/pkg/tbtc/signer/scripts/run_phase5_chaos_suite.sh @@ -0,0 +1,39 @@ +#!/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::interactive_open_advances_only_the_opening_member_attempt|stale_interactive_attempt_replay|a newer member attempt replaces only its own live state and a stale reopen fails closed" + "engine::tests::interactive_round2_state_key_failure_does_not_burn_attempt|round2_state_key_outage_recovery|a state-key failure releases no share, does not burn the attempt, and permits retry" + "engine::tests::interactive_consumption_marker_survives_restart|process_restart_consumed_attempt|a consumed interactive attempt marker rejects replay across a simulated restart" + "engine::tests::interactive_round2_persist_fault_leaves_nonces_live|round2_persist_fault_pre_rename|a pre-rename persist fault releases no share, rolls back the marker, and preserves retry" + "engine::tests::interactive_round2_post_rename_persist_failure_consumes_attempt_and_retry_flushes|round2_persist_fault_post_rename|an after-rename persist fault releases no share, consumes the attempt, destroys live nonces, and survives restart before any successful repair" + "engine::tests::interactive_aggregate_post_rename_persist_failure_finalizes_attempt_and_retry_flushes|aggregate_persist_fault_post_rename|an after-rename aggregate fault retains completion, destroys sibling nonces, and survives restart before any successful repair" +) + +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}" + if ! test_output="$( + cargo test --color never --manifest-path "${MANIFEST_PATH}" --lib "${test_name}" -- --exact 2>&1 + )"; then + printf '%s\n' "${test_output}" + exit 1 + fi + printf '%s\n' "${test_output}" + if [[ "${test_output}" != *"test result: ok. 1 passed; 0 failed; 0 ignored;"* ]]; then + echo "FAIL: ${scenario_id} expected exactly one passing test for filter [${test_name}]." >&2 + exit 1 + fi + 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..19c11863b5 --- /dev/null +++ b/pkg/tbtc/signer/src/api.rs @@ -0,0 +1,1061 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +/// A hex-encoded secret whose owned Rust allocation is wiped on drop and whose +/// `Debug` representation never exposes its contents. Serde remains transparent +/// so the C-ABI JSON contract continues to carry an ordinary string. +#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct SecretHex(Zeroizing); + +impl SecretHex { + pub fn new(value: String) -> Self { + Self(Zeroizing::new(value)) + } + + /// Borrows the secret for the narrow decode/serialization boundary without + /// creating another unmanaged `String` allocation. + pub fn expose_secret(&self) -> &str { + self.0.as_str() + } +} + +impl From for SecretHex { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl fmt::Debug for SecretHex { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + +impl Zeroize for SecretHex { + fn zeroize(&mut self) { + self.0.zeroize(); + } +} + +impl ZeroizeOnDrop for SecretHex {} + +#[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: SecretHex, +} + +#[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: SecretHex, + pub package: DkgRound1Package, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart2Request { + pub secret_package_hex: SecretHex, + pub round1_packages: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart2Result { + pub secret_package_hex: SecretHex, + pub packages: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NativeFrostKeyPackage { + pub identifier: String, + pub data_hex: SecretHex, +} + +#[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: SecretHex, + 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, +} + +/// Persists the result of a DISTRIBUTED FROST DKG for one seat: this node's own +/// key package (from Part3) plus the group public key package, into the engine +/// session state that the interactive signing path loads. Unlike the dealer +/// `run_dkg`, a distributed node holds only its OWN secret key package. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct PersistDistributedDkgKeyPackageRequest { + pub session_id: String, + pub participant_identifier: u16, + pub threshold: u16, + pub participant_count: u16, + 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 NewSigningPackageRequest { + pub message_hex: String, + pub commitments: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NewSigningPackageResult { + pub signing_package_hex: String, +} + +// Phase 7.1 hardened interactive signing session (frozen spec +// docs/phase-7-interactive-session-spec-freeze.md, section 5). Secret +// nonces NEVER appear in these requests or results: the engine +// generates, holds, consumes, and zeroizes them internally, keyed by +// (session_id, attempt_id). + +/// Narrow non-transaction signing intents accepted by the production signing +/// policy firewall. This enum is internally tagged so the wire shape is +/// explicit and future variants cannot be confused with an arbitrary message +/// allowlist. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InteractiveSigningIntent { + /// A tBTC wallet heartbeat message. `message_hex` is the raw 16-byte + /// heartbeat preimage, not the 32-byte digest being signed. + Heartbeat { message_hex: String }, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveSessionOpenRequest { + pub session_id: String, + pub member_identifier: u16, + pub message_hex: String, + pub key_group: String, + /// Signing threshold; must equal the session's DKG threshold. The + /// key material itself is resolved from the engine's DKG state and + /// is never carried in this request - no signing secret crosses the + /// FFI (frozen spec section 4). + pub threshold: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub taproot_merkle_root_hex: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signing_intent: Option, + /// Required: interactive sessions are strict-mode only; there is + /// no legacy-shape fallback on this path. + pub attempt_context: AttemptContext, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveSessionOpenResult { + pub session_id: String, + pub attempt_id: String, + pub idempotent: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveRound1Request { + pub session_id: String, + pub attempt_id: String, + pub member_identifier: u16, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveRound1Result { + /// The member's public signing commitments. Idempotent until the + /// attempt's nonces are consumed; the secret nonces they + /// correspond to never leave the engine. + pub commitments_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveRound2Request { + pub session_id: String, + pub attempt_id: String, + pub member_identifier: u16, + /// The coordinator's signing package (the chosen responsive + /// subset's commitment list). Verified in full - membership, + /// subset-of-included, exact threshold size, message binding, and + /// byte-identity of this member's own commitment entry - BEFORE + /// the nonces are consumed. + pub signing_package_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveRound2Result { + pub session_id: String, + pub attempt_id: String, + pub signature_share_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveAggregateRequest { + pub session_id: String, + pub attempt_id: String, + /// The signing package the shares were produced over (carries the + /// message and the chosen subset's commitments). + pub signing_package_hex: String, + /// The collected signature shares from the responsive subset. Each is + /// verified against the member's verifying share (resolved from the + /// session's DKG public key package) before aggregation. If any share fails, + /// the call fails closed with no signature and the + /// `aggregate_share_verification_failed` error, which carries the CANDIDATE + /// culprits - every member whose share failed (Phase 7.2b-3). These are + /// pure-crypto candidates for the Go host's envelope-bound blame + /// adjudication (frozen Phase 7.2b spec, section 6); the engine never + /// inspects operator-signed envelopes itself. + pub signature_shares: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub taproot_merkle_root_hex: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveAggregateResult { + pub session_id: String, + pub attempt_id: String, + /// The aggregated BIP-340 Schnorr signature, hex-encoded. + pub signature_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveSessionAbortRequest { + pub session_id: String, + /// When set, abort only if the live attempt matches; when unset, + /// abort whatever attempt is live for the session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attempt_id: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveSessionAbortResult { + pub session_id: String, + pub aborted: bool, +} + +/// The verdict of a single-share verification (VerifySignatureShare). It is a +/// deliberate THREE-way value, not pass/fail: the boundary between a +/// member-attributable failure (blame) and a not-the-member's-fault failure +/// (don't blame) is security-critical, and the engine - the only layer that can +/// tell "the member's signed scalar is malformed" from "the coordinator's +/// package/context is malformed" - decides it here so the Go host never has to. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ShareVerificationVerdict { + /// The share is a valid FROST signature share under the (tweaked) package. + Valid, + /// MEMBER-attributable: the share is mathematically invalid, OR the member's + /// own operator-signed share bytes are undecodable (self-incriminating). + /// NOTE: like InteractiveAggregate's candidate-culprit list, this verdict is + /// framable by a coordinator that verifies an honest share against a + /// mismatched package/root, so it is an INPUT to the Go host's f+1 + /// envelope-bound adjudication (Phase 7.2b spec, section 6), NOT authoritative + /// blame on its own. + Invalid, + /// Not the member's fault: undecodable signing package (coordinator input), + /// missing/unknown verifying share, session not ready, ambiguous context. + /// Fail closed against blame. + Indeterminate, +} + +/// Request to verify ONE retained round-2 signature share against an attempt's +/// authoritative signing package, using FROST share verification. The verifying +/// material is resolved from the session's own DKG state (never the request), +/// and the taproot root is canonicalized + applied exactly as InteractiveAggregate +/// does, so the verdict matches what aggregation would conclude for that share. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct VerifySignatureShareRequest { + pub session_id: String, + pub signing_package_hex: String, + pub signature_share_hex: String, + pub member_identifier: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub taproot_merkle_root_hex: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct VerifySignatureShareResult { + pub verdict: ShareVerificationVerdict, +} + +#[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 AttemptContext { + pub attempt_number: u32, + pub coordinator_identifier: u16, + pub included_participants: Vec, + pub included_participants_fingerprint: String, + pub attempt_id: String, +} + +/// Request to derive the canonical interactive attempt context (plus the +/// per-participant FROST identifiers) from an attempt's public inputs, so the +/// host never re-implements the engine's domain-separated derivations - the +/// cross-language divergence class. Stateless and secret-free: no DKG lookup, +/// no nonce/session state, no policy decision. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DeriveInteractiveAttemptContextRequest { + pub session_id: String, + pub message_hex: String, + pub key_group: String, + /// Validation gate only - the derivation requires `included_participants` + /// to hold at least `threshold` members; it is NOT an input to the + /// fingerprint, attempt-id, or coordinator derivation. + pub threshold: u16, + /// 1-based wire attempt number (the host's 0-based value + 1), matching + /// `AttemptContext.attempt_number`. + pub attempt_number: u32, + pub included_participants: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DeriveInteractiveAttemptContextResult { + /// The canonical attempt context, re-validated against strict-mode + /// `validate_attempt_context` before returning, so the host can pass it + /// verbatim to `InteractiveSessionOpenRequest.attempt_context` and the + /// engine will accept it. + pub attempt_context: AttemptContext, + /// One FROST identifier string per included participant, in canonical + /// (ascending) participant order - the exact key-package encoding the + /// signing-package and aggregate paths expect. + pub frost_identifiers: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct ParticipantFrostIdentifier { + pub participant_identifier: u16, + pub frost_identifier: String, +} + +#[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, + /// Script pubkey of the output being spent. BIP-341 SIGHASH_DEFAULT commits + /// to every input's ordered prevout amount and script pubkey, so the signer + /// cannot derive the messages it is allowed to sign without this metadata. + pub script_pubkey_hex: String, +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script_tree_hex: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct TransactionResult { + pub session_id: String, + pub tx_hex: String, + /// One BIP-341 key-spend SIGHASH_DEFAULT message per transaction input, in + /// input order. `serde(default)` lets pre-ABI-3 persisted state decode so the + /// policy gate can reject its empty legacy artifact explicitly and fail closed. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub taproot_key_spend_sighashes_hex: Vec, +} + +#[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, +} + +/// Reserved response shape for a future cryptographic share-refresh protocol. +/// The current one-shot endpoint always rejects because it cannot safely run +/// the required multi-round FROST refresh, returning the terminal error code +/// `cryptographic_refresh_not_supported`. +#[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, + /// Number of cryptographically valid refreshes. Always zero until the + /// versioned multi-round protocol is implemented. + pub refresh_count: u64, + /// Epoch of the last cryptographically valid refresh. Always zero while + /// `RefreshShares` is reserved and fail-closed. + pub last_refresh_epoch: u64, + pub cadence_seconds: u64, + /// Durable DKG creation deadline, or zero when untrusted legacy refresh + /// metadata has no DKG anchor and must be treated as immediately overdue. + pub next_refresh_due_unix: u64, + pub overdue: bool, + /// False when persisted metadata from the retired synthetic refresh stub is + /// detected; that metadata never establishes key continuity. + 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, +} + +/// The FFI CONTRACT version reported by `frost_tbtc_abi_version`, so a Go bridge can +/// fail closed against an incompatible `libfrost_tbtc` rather than silently +/// misinterpreting a changed contract. +/// +/// `abi_major` covers any INCOMPATIBLE change to the Go<->Rust contract: C signatures, +/// JSON field meaning, required fields, enum/status values, serialization, memory +/// ownership, or crypto transcript/domain semantics the bridge relies on. `abi_minor` +/// covers a cumulative ADDITIVE, backward-compatible change - a new symbol or a new +/// optional field that old bridges safely ignore. A consumer requires +/// `lib.abi_major == its_major` AND `lib.abi_minor >= the_minor_it_needs`. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct FrostTbtcAbiVersionResult { + pub abi_major: u32, + pub abi_minor: u32, +} + +#[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, + #[serde(default)] + pub heartbeat_signing_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, + #[serde(default)] + pub interactive_session_open_calls_total: u64, + #[serde(default)] + pub interactive_session_open_success_total: u64, + #[serde(default)] + pub interactive_round1_calls_total: u64, + #[serde(default)] + pub interactive_round1_success_total: u64, + #[serde(default)] + pub interactive_round2_calls_total: u64, + #[serde(default)] + pub interactive_round2_success_total: u64, + #[serde(default)] + pub interactive_session_abort_calls_total: u64, + #[serde(default)] + pub interactive_session_abort_success_total: u64, + #[serde(default)] + pub interactive_aggregate_calls_total: u64, + #[serde(default)] + pub interactive_aggregate_success_total: u64, + #[serde(default)] + pub interactive_round1_latency_p95_ms: u64, + #[serde(default)] + pub interactive_round1_latency_samples: u64, + #[serde(default)] + pub interactive_round2_latency_p95_ms: u64, + #[serde(default)] + pub interactive_round2_latency_samples: u64, + #[serde(default)] + pub interactive_aggregate_latency_p95_ms: u64, + #[serde(default)] + pub interactive_aggregate_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, + /// CANDIDATE culprits for an `aggregate_share_verification_failed` error: + /// the u16 Go member identifiers whose FROST signature shares failed + /// verification (the same identifier space as `excluded_member_identifiers`). + /// Empty - and omitted from the JSON via skip_serializing_if - for every + /// other error, so existing Go clients are unaffected. These are pure-crypto + /// candidates, not adjudicated blame; the Go host performs the envelope-bound + /// adjudication (frozen Phase 7.2b spec, section 6). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub candidate_culprits: Vec, +} + +/// Init-time signer configuration installed once by the host over FFI. +/// +/// Every field mirrors one `TBTC_SIGNER_*` environment variable (field name = +/// lowercased variable suffix). Once a config is installed the process +/// environment is no longer consulted for any covered knob: unset fields mean +/// the built-in default, not the environment value. The state-encryption key +/// (`TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX`) is deliberately absent — secrets +/// stay on the dedicated env/command key-provider channel and never ride the +/// config FFI. Unknown fields are rejected so a typo'd knob fails the init +/// instead of silently running on a default. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InitSignerConfigRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_bootstrap: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_roast_strict: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_bench_restart_hook: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub roast_coordinator_timeout_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_cadence_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_corruption_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_corrupt_backup_limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permit_plaintext_state_rollback: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_sessions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_live_interactive_sessions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interactive_session_ttl_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_key_provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_key_command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_key_command_timeout_secs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enforce_provenance_gate: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance_attestation_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance_attestation_payload: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance_attestation_signature_hex: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance_trust_root: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_approved_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enforce_admission_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admission_min_participants: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admission_min_threshold: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admission_required_identifiers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admission_allowlist_identifiers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enforce_signing_policy_firewall: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_allowed_script_classes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_max_output_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_max_output_value_sats: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_max_total_output_value_sats: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_allowed_utc_start_hour: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_allowed_utc_end_hour: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_rate_limit_per_minute: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_heartbeat_rate_limit_per_minute: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_auto_quarantine: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_quarantine_fault_threshold: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_quarantine_timeout_penalty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_quarantine_invalid_share_penalty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_quarantine_dao_allowlist_identifiers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canary_max_start_sign_round_p95_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canary_max_finalize_sign_round_p95_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canary_max_policy_reject_rate_bps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canary_max_interactive_round1_p95_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canary_max_interactive_round2_p95_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canary_max_interactive_aggregate_p95_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canary_min_samples: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canary_min_policy_samples: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canary_max_sample_age_seconds: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InitSignerConfigResult { + pub installed: bool, + pub idempotent: bool, + pub config_fingerprint: String, + pub configured_key_count: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET_SENTINEL: &str = + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + + fn secret_hex() -> SecretHex { + SecretHex::new(SECRET_SENTINEL.to_string()) + } + + fn round1_package() -> DkgRound1Package { + DkgRound1Package { + identifier: "round1-identifier".to_string(), + package_hex: "public-round1-package".to_string(), + } + } + + fn round2_package() -> DkgRound2Package { + DkgRound2Package { + identifier: "round2-recipient".to_string(), + sender_identifier: Some("round2-sender".to_string()), + package_hex: secret_hex(), + } + } + + fn public_key_package() -> NativeFrostPublicKeyPackage { + NativeFrostPublicKeyPackage { + verifying_shares: std::collections::BTreeMap::new(), + verifying_key: "public-verifying-key".to_string(), + } + } + + fn key_package() -> NativeFrostKeyPackage { + NativeFrostKeyPackage { + identifier: "key-package-identifier".to_string(), + data_hex: secret_hex(), + } + } + + #[test] + fn dkg_secret_hex_zeroizes_and_is_zeroize_on_drop() { + fn assert_zeroize_on_drop() {} + + assert_zeroize_on_drop::(); + + let mut secret = secret_hex(); + secret.zeroize(); + assert!(secret.expose_secret().is_empty()); + } + + #[test] + fn dkg_secret_fields_preserve_json_string_wire_shape() { + let encoded_holder = + serde_json::to_string(&secret_hex()).expect("secret holder serializes"); + assert_eq!(encoded_holder, format!("\"{SECRET_SENTINEL}\"")); + let decoded_holder: SecretHex = + serde_json::from_str(&encoded_holder).expect("secret holder deserializes"); + assert_eq!(decoded_holder.expose_secret(), SECRET_SENTINEL); + + let serialized_fields = [ + serde_json::to_value(DkgRound2Package { + identifier: "recipient".to_string(), + sender_identifier: Some("sender".to_string()), + package_hex: secret_hex(), + }) + .expect("round2 package serializes")["package_hex"] + .clone(), + serde_json::to_value(DkgPart1Result { + secret_package_hex: secret_hex(), + package: round1_package(), + }) + .expect("part1 result serializes")["secret_package_hex"] + .clone(), + serde_json::to_value(DkgPart2Request { + secret_package_hex: secret_hex(), + round1_packages: vec![round1_package()], + }) + .expect("part2 request serializes")["secret_package_hex"] + .clone(), + serde_json::to_value(DkgPart2Result { + secret_package_hex: secret_hex(), + packages: vec![round2_package()], + }) + .expect("part2 result serializes")["secret_package_hex"] + .clone(), + serde_json::to_value(DkgPart3Request { + secret_package_hex: secret_hex(), + round1_packages: vec![round1_package()], + round2_packages: vec![round2_package()], + }) + .expect("part3 request serializes")["secret_package_hex"] + .clone(), + serde_json::to_value(key_package()).expect("key package serializes")["data_hex"] + .clone(), + ]; + + for serialized_field in serialized_fields { + assert_eq!(serialized_field, serde_json::json!(SECRET_SENTINEL)); + } + } + + #[test] + fn dkg_secret_fields_redact_direct_and_nested_debug_output() { + let rendered = [ + format!("{:?}", round2_package()), + format!( + "{:?}", + DkgPart1Result { + secret_package_hex: secret_hex(), + package: round1_package(), + } + ), + format!( + "{:?}", + DkgPart2Request { + secret_package_hex: secret_hex(), + round1_packages: vec![round1_package()], + } + ), + format!( + "{:?}", + DkgPart2Result { + secret_package_hex: secret_hex(), + packages: vec![round2_package()], + } + ), + format!("{:?}", key_package()), + format!( + "{:?}", + DkgPart3Request { + secret_package_hex: secret_hex(), + round1_packages: vec![round1_package()], + round2_packages: vec![round2_package()], + } + ), + format!( + "{:?}", + DkgPart3Result { + key_package: key_package(), + public_key_package: public_key_package(), + } + ), + format!( + "{:?}", + PersistDistributedDkgKeyPackageRequest { + session_id: "debug-redaction-session".to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: key_package(), + public_key_package: public_key_package(), + } + ), + ]; + + for rendered_value in rendered { + assert!( + !rendered_value.contains(SECRET_SENTINEL), + "Debug output leaked DKG secret material: {rendered_value}" + ); + assert!( + rendered_value.contains(""), + "Debug output did not mark DKG secret material as redacted: {rendered_value}" + ); + } + } +} 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..048f1bfe06 --- /dev/null +++ b/pkg/tbtc/signer/src/bin/admission_checker.rs @@ -0,0 +1,1801 @@ +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::io::Write; +use std::path::{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)] +#[serde(deny_unknown_fields)] +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) +} + +// Acquires an exclusive, non-blocking inter-process lock for the override +// replay registry. Held across load -> validate -> insert -> persist so two +// concurrent checker invocations cannot both consume the same one-time +// override marker. Mirrors the signer state lock (engine::state). +fn acquire_override_registry_lock(registry_path: &Path) -> Result { + let lock_path = registry_path.with_extension("lock"); + let lock_file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .map_err(|error| { + format!( + "failed to open override replay registry lock file [{}]: {error}", + lock_path.display() + ) + })?; + + #[cfg(unix)] + { + use libc::{flock, EAGAIN, EWOULDBLOCK, LOCK_EX, LOCK_NB}; + 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(|errno| errno == EWOULDBLOCK || errno == EAGAIN) + { + return Err(format!( + "override replay registry lock already held by another process [{}]", + lock_path.display() + )); + } + + return Err(format!( + "failed to lock override replay registry [{}]: {lock_error}", + lock_path.display() + )); + } + } + + Ok(lock_file) +} + +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())); + + // Write + fsync the temp file, then atomically rename and fsync the + // parent directory so a consumed-override marker survives power loss. + // Mirrors the signer state persistence path (engine::persistence). + { + let mut tmp_file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&tmp_path) + .map_err(|error| { + format!( + "failed to open override replay registry temp file [{}]: {error}", + tmp_path.display() + ) + })?; + tmp_file.write_all(&serialized).map_err(|error| { + let _ = fs::remove_file(&tmp_path); + format!( + "failed to write override replay registry temp file [{}]: {error}", + tmp_path.display() + ) + })?; + tmp_file.sync_all().map_err(|error| { + let _ = fs::remove_file(&tmp_path); + format!( + "failed to sync override replay registry temp file [{}]: {error}", + tmp_path.display() + ) + })?; + } + + fs::rename(&tmp_path, path).map_err(|error| { + let _ = fs::remove_file(&tmp_path); + format!( + "failed to persist override replay registry [{}]: {error}", + path.display() + ) + })?; + + let directory_path = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map_or_else(|| PathBuf::from("."), |parent| parent.to_path_buf()); + let directory = fs::File::open(&directory_path).map_err(|error| { + format!( + "failed to open override replay registry directory [{}] for sync: {error}", + directory_path.display() + ) + })?; + directory.sync_all().map_err(|error| { + format!( + "failed to sync override replay registry directory [{}]: {error}", + directory_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 required_attestation_status.is_empty() { + reasons.push(AdmissionReason { + code: "required_attestation_status_missing".to_string(), + detail: "policy required_attestation_status must be non-empty".to_string(), + }); + } + if candidate_attestation_status.is_empty() { + reasons.push(AdmissionReason { + code: "attestation_status_missing".to_string(), + detail: "candidate attestation_status must be non-empty".to_string(), + }); + } else if !required_attestation_status.is_empty() + && 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, + }; + // Hold the exclusive registry lock across the whole load -> apply -> + // persist critical section so concurrent invocations cannot both accept + // and consume the same one-time override marker. Bound for the lifetime + // of `run`; released on drop after persistence completes. + let _registry_lock = match cli.override_registry_path.as_ref() { + Some(path) => Some(acquire_override_registry_lock(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(), + }, + ] + } + + #[test] + fn admission_policy_deserialization_rejects_unknown_fields() { + let valid_policy_json = include_str!("../../scripts/admission-policy-v1.sample.json"); + let typo_policy_json = valid_policy_json.replace( + "\"max_operators_per_provider\"", + "\"max_operator_per_provider\"", + ); + assert_ne!(typo_policy_json, valid_policy_json); + + let error = serde_json::from_str::(&typo_policy_json) + .expect_err("a misspelled security policy field must be rejected"); + + assert!( + error + .to_string() + .contains("unknown field `max_operator_per_provider`"), + "unexpected parse error: {error}" + ); + } + + #[test] + fn admission_policy_deserialization_accepts_supported_fields() { + let policy = serde_json::from_str::(include_str!( + "../../scripts/admission-policy-v1.sample.json" + )) + .expect("the shipped admission policy sample should remain valid"); + + assert_eq!(policy.max_operators_per_provider, Some(2)); + assert_eq!(policy.max_operators_per_region, Some(2)); + assert_eq!(policy.dao_override_max_ttl_seconds, Some(604_800)); + } + + 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_empty_required_and_candidate_attestation_statuses() { + let mut policy = baseline_policy(); + policy.required_attestation_status = " \t".to_string(); + let mut candidate = baseline_candidate(); + candidate.attestation_status = " \n ".to_string(); + + let decision = evaluate_admission(&policy, &candidate, &baseline_existing(), 1_700_000_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "required_attestation_status_missing")); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "attestation_status_missing")); + } + + #[test] + fn evaluate_admission_reports_empty_attestation_fields_before_mismatch() { + let mut empty_candidate = baseline_candidate(); + empty_candidate.attestation_status = " ".to_string(); + let candidate_decision = evaluate_admission( + &baseline_policy(), + &empty_candidate, + &baseline_existing(), + 1_700_000_000, + ); + assert!(candidate_decision + .reasons + .iter() + .any(|reason| reason.code == "attestation_status_missing")); + assert!(!candidate_decision + .reasons + .iter() + .any(|reason| reason.code == "attestation_status_not_approved")); + + let mut empty_policy = baseline_policy(); + empty_policy.required_attestation_status = "\t".to_string(); + let policy_decision = evaluate_admission( + &empty_policy, + &baseline_candidate(), + &baseline_existing(), + 1_700_000_000, + ); + assert!(policy_decision + .reasons + .iter() + .any(|reason| reason.code == "required_attestation_status_missing")); + assert!(!policy_decision + .reasons + .iter() + .any(|reason| reason.code == "attestation_status_not_approved")); + } + + #[test] + fn evaluate_admission_normalizes_non_empty_attestation_statuses() { + let mut policy = baseline_policy(); + policy.required_attestation_status = " Approved ".to_string(); + let mut candidate = baseline_candidate(); + candidate.attestation_status = "APPROVED".to_string(); + + let decision = evaluate_admission(&policy, &candidate, &baseline_existing(), 1_700_000_000); + assert_eq!(decision.decision, "allow"); + assert!(decision.reasons.is_empty()); + } + + #[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); + } + + // The inter-process lock must be exclusive: a second acquisition while + // the first is held fails, so two concurrent checker invocations cannot + // both consume the same one-time override marker. flock locks are tied + // to the open file description, so two separate opens contend even in + // one process. Unix-only (the lock is a flock no-op elsewhere). + #[cfg(unix)] + #[test] + fn override_registry_lock_is_exclusive_while_held() { + let tmp_dir = std::env::temp_dir().join(format!( + "admission-override-lock-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 first = acquire_override_registry_lock(®istry_path).expect("first lock acquires"); + let second = acquire_override_registry_lock(®istry_path); + assert!( + second.is_err(), + "second concurrent lock acquisition must fail while the first is held" + ); + + drop(first); + let third = acquire_override_registry_lock(®istry_path); + assert!( + third.is_ok(), + "lock must re-acquire after the holder releases" + ); + drop(third); + + 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/audit.rs b/pkg/tbtc/signer/src/engine/audit.rs new file mode 100644 index 0000000000..6736724e1d --- /dev/null +++ b/pkg/tbtc/signer/src/engine/audit.rs @@ -0,0 +1,432 @@ +// Forensics: transcript audit, blame-proof verification, differential fuzzing references. + +use super::*; + +pub(crate) 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)) +} + +pub(crate) 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], + ) +} + +pub(crate) 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(), + ], + ) +} + +pub(crate) 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) +} + +// Independent BIP-341 SIGHASH_DEFAULT/key-spend construction for the +// differential fuzzer. This deliberately does not call rust-bitcoin's sighash +// module: the production path uses SighashCache, while this reference follows +// the SigMsg field order directly and uses consensus serialization only for the +// committed transaction primitives. +fn reference_bip341_key_spend_sighash_default( + tx: &Transaction, + prevouts: &[TxOut], + input_index: usize, +) -> Result { + if prevouts.len() != tx.input.len() || input_index >= tx.input.len() { + return Err(EngineError::Internal( + "invalid differential BIP-341 reference inputs".to_string(), + )); + } + + let mut prevouts_hasher = Sha256::new(); + let mut amounts_hasher = Sha256::new(); + let mut script_pubkeys_hasher = Sha256::new(); + let mut sequences_hasher = Sha256::new(); + for (input, prevout) in tx.input.iter().zip(prevouts) { + prevouts_hasher.update(bitcoin::consensus::serialize(&input.previous_output)); + amounts_hasher.update(prevout.value.to_sat().to_le_bytes()); + script_pubkeys_hasher.update(bitcoin::consensus::serialize(&prevout.script_pubkey)); + sequences_hasher.update(input.sequence.0.to_le_bytes()); + } + + let mut outputs_hasher = Sha256::new(); + for output in &tx.output { + outputs_hasher.update(bitcoin::consensus::serialize(output)); + } + + let mut sig_msg = Vec::with_capacity(175); + sig_msg.push(0x00); // SIGHASH_DEFAULT. + sig_msg.extend_from_slice(&tx.version.0.to_le_bytes()); + sig_msg.extend_from_slice(&tx.lock_time.to_consensus_u32().to_le_bytes()); + sig_msg.extend_from_slice(&prevouts_hasher.finalize()); + sig_msg.extend_from_slice(&amounts_hasher.finalize()); + sig_msg.extend_from_slice(&script_pubkeys_hasher.finalize()); + sig_msg.extend_from_slice(&sequences_hasher.finalize()); + sig_msg.extend_from_slice(&outputs_hasher.finalize()); + sig_msg.push(0x00); // ext_flag=0, annex_present=0. + sig_msg.extend_from_slice(&(input_index as u32).to_le_bytes()); + + let tag_hash = Sha256::digest(b"TapSighash"); + let mut tagged_hasher = Sha256::new(); + tagged_hasher.update(tag_hash); + tagged_hasher.update(tag_hash); + tagged_hasher.update([0x00]); // Epoch byte. + tagged_hasher.update(sig_msg); + Ok(hex::encode(tagged_hasher.finalize())) +} + +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 script_pubkey = ScriptBuf::from_bytes(script_pubkey); + let output_value_sats = (rng.next_u32() as u64 % 1_000_000) + 1; + let prevouts = vec![TxOut { + value: Amount::from_sat(output_value_sats.saturating_add(1_000)), + script_pubkey: script_pubkey.clone(), + }]; + let tx = Transaction { + // Match the production BuildTaprootTx shape. The reference leg below + // constructs BIP-341 SigMsg directly rather than calling SighashCache. + version: Version::ONE, + 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(output_value_sats), + script_pubkey, + }], + }; + let primary_message_digests_hex = policy_bound_signing_messages_hex(&tx, &prevouts)?; + let primary_message_digest_hex = primary_message_digests_hex + .first() + .ok_or_else(|| EngineError::Internal("missing differential sighash".to_string()))?; + let reference_message_digest_hex = + reference_bip341_key_spend_sighash_default(&tx, &prevouts, 0)?; + 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 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, + }) +} diff --git a/pkg/tbtc/signer/src/engine/codec.rs b/pkg/tbtc/signer/src/engine/codec.rs new file mode 100644 index 0000000000..c1cddba959 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/codec.rs @@ -0,0 +1,470 @@ +// Hex/struct codecs and Go<->frost identifier conversions. + +use super::*; + +pub(crate) fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +pub(crate) fn hash_hex(bytes: &[u8]) -> String { + hex::encode(hash_bytes(bytes)) +} + +pub(crate) 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 +} + +// Test-only: the production coarse-FROST callers (round-id/seed derivation) +// were removed with the transitional signing path; only unit tests still +// exercise this length-prefixed domain-separation helper. +#[cfg(test)] +pub(crate) 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 +} + +pub(crate) 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 + )) + }) +} + +pub(crate) 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") +} + +/// Map a FROST aggregate error to the CANDIDATE culprits it identifies, as u16 +/// Go member identifiers (the same identifier space as +/// `excluded_member_identifiers`, so the Go host consumes them directly). +/// +/// Returns the members FROST flagged for an invalid signature share +/// (`Error::InvalidSignatureShare`, the full set under +/// `CheaterDetection::AllCheaters`). Every other error class - malformed +/// package, wrong share count, group/field errors - yields an empty list: those +/// are not per-member share attributions, so the caller surfaces them as a +/// generic validation failure instead. Identifiers that do not map to a u16 are +/// dropped: they cannot belong to a real group member (every submitted share +/// carries a u16-derived identifier), so they are foreign to the Go host's +/// member set. CANDIDATES only - pure FROST verdicts, not adjudicated fault. +pub(crate) fn aggregate_candidate_culprits(error: &frost::Error) -> Vec { + match error { + frost_core::Error::InvalidSignatureShare { culprits } => culprits + .iter() + .filter_map(|identifier| frost_identifier_to_u16(*identifier)) + .collect(), + _ => Vec::new(), + } +} + +/// Recover the u16 Go member identifier from a FROST participant identifier - +/// the inverse of `participant_identifier_to_frost_identifier`. FROST(secp256k1) +/// serializes the scalar big-endian, so this requires every byte above the low +/// two to be zero and reads the trailing two big-endian. Returns None for an +/// identifier that does not fit a u16. +pub(crate) fn frost_identifier_to_u16(identifier: frost::Identifier) -> Option { + let bytes = identifier.serialize(); + let split = bytes.len().checked_sub(2)?; + if bytes[..split].iter().any(|&b| b != 0) { + return None; + } + Some(u16::from_be_bytes([bytes[split], bytes[split + 1]])) +} + +pub(crate) 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}"))) +} + +pub(crate) 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")) + }) +} + +pub(crate) 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 +} + +pub(crate) 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) +} + +pub(crate) 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.expose_secret(), + )?; + 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) +} + +pub(crate) 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..])) +} + +pub(crate) 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)?, + }) +} + +pub(crate) 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, + )) +} + +pub(crate) 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) +} + +pub(crate) 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) +} + +pub(crate) 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) +} diff --git a/pkg/tbtc/signer/src/engine/config.rs b/pkg/tbtc/signer/src/engine/config.rs new file mode 100644 index 0000000000..c0bd3cd999 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/config.rs @@ -0,0 +1,448 @@ +// TBTC_SIGNER_* environment surface: constant names, defaults, and parsers. + +use super::*; + +pub(crate) const TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT: &str = "env"; + +pub(crate) const TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND: &str = "command"; + +// Env-var selector for key provider implementation (`env` or `command`). +pub(crate) const TBTC_SIGNER_STATE_KEY_PROVIDER_ENV: &str = "TBTC_SIGNER_STATE_KEY_PROVIDER"; + +pub(crate) const TBTC_SIGNER_STATE_KEY_ID_LEGACY_ENV_HEX: &str = + "TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX"; + +pub(crate) const TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV: &str = + "TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX"; + +pub(crate) const TBTC_SIGNER_STATE_KEY_COMMAND_ENV: &str = "TBTC_SIGNER_STATE_KEY_COMMAND"; + +pub(crate) const TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV: &str = + "TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS"; + +pub(crate) const TBTC_SIGNER_DEFAULT_STATE_KEY_COMMAND_TIMEOUT_SECS: u64 = 30; + +pub(crate) const TBTC_SIGNER_MIN_STATE_KEY_COMMAND_TIMEOUT_SECS: u64 = 1; + +pub(crate) const TBTC_SIGNER_MAX_STATE_KEY_COMMAND_TIMEOUT_SECS: u64 = 300; + +pub(crate) const TBTC_SIGNER_PROFILE_ENV: &str = "TBTC_SIGNER_PROFILE"; + +pub(crate) const TBTC_SIGNER_PROFILE_PRODUCTION: &str = "production"; + +pub(crate) const TBTC_SIGNER_PROFILE_DEVELOPMENT: &str = "development"; + +pub(crate) const TBTC_SIGNER_STATE_PATH_ENV: &str = "TBTC_SIGNER_STATE_PATH"; + +pub(crate) const TBTC_SIGNER_DEFAULT_STATE_FILENAME: &str = "frost_tbtc_engine_state.json"; + +pub(crate) const TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV: &str = + "TBTC_SIGNER_STATE_CORRUPTION_POLICY"; + +pub(crate) const TBTC_SIGNER_STATE_CORRUPTION_POLICY_QUARANTINE_AND_RESET: &str = + "quarantine_and_reset"; + +pub(crate) const TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV: &str = + "TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT"; + +pub(crate) const TBTC_SIGNER_DEFAULT_CORRUPT_BACKUP_LIMIT: usize = 5; + +pub(crate) const TBTC_SIGNER_MAX_SESSIONS_ENV: &str = "TBTC_SIGNER_MAX_SESSIONS"; + +pub(crate) const TBTC_SIGNER_DEFAULT_MAX_SESSIONS: usize = 1024; + +// Phase 7.1 interactive session bounds. Live interactive sessions hold +// secret nonces in memory, so they get a dedicated, smaller cap than +// the overall session registry, and a TTL after which an abandoned +// attempt's nonces are destroyed (expiry has abort semantics). +pub(crate) const TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV: &str = + "TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS"; + +pub(crate) const TBTC_SIGNER_DEFAULT_MAX_LIVE_INTERACTIVE_SESSIONS: usize = 64; + +pub(crate) const TBTC_SIGNER_INTERACTIVE_SESSION_TTL_SECONDS_ENV: &str = + "TBTC_SIGNER_INTERACTIVE_SESSION_TTL_SECONDS"; + +pub(crate) const TBTC_SIGNER_DEFAULT_INTERACTIVE_SESSION_TTL_SECONDS: u64 = 3600; + +pub(crate) const TBTC_SIGNER_STATE_LOCKFILE_SUFFIX: &str = ".lock"; + +pub(crate) const TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV: &str = "TBTC_SIGNER_ALLOW_BOOTSTRAP"; + +pub(crate) const TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV: &str = "TBTC_SIGNER_ENABLE_ROAST_STRICT"; + +pub(crate) const TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK_ENV: &str = + "TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK"; + +pub(crate) const TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV: &str = + "TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS"; + +pub(crate) const TBTC_SIGNER_DEFAULT_ROAST_COORDINATOR_TIMEOUT_MS: u64 = 30_000; + +pub(crate) const TBTC_SIGNER_MIN_ROAST_COORDINATOR_TIMEOUT_MS: u64 = 1_000; + +pub(crate) const TBTC_SIGNER_MAX_ROAST_COORDINATOR_TIMEOUT_MS: u64 = 300_000; + +pub(crate) const TBTC_SIGNER_RUNTIME_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub(crate) const TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV: &str = + "TBTC_SIGNER_ENFORCE_PROVENANCE_GATE"; + +pub(crate) const TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV: &str = + "TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS"; + +pub(crate) const TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV: &str = + "TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD"; + +pub(crate) const TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV: &str = + "TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX"; + +pub(crate) const TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV: &str = "TBTC_SIGNER_PROVENANCE_TRUST_ROOT"; + +pub(crate) const TBTC_SIGNER_MIN_APPROVED_VERSION_ENV: &str = "TBTC_SIGNER_MIN_APPROVED_VERSION"; + +pub(crate) const TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED: &str = "approved"; + +pub(crate) const TBTC_SIGNER_PROVENANCE_MAX_ATTESTATION_TTL_SECONDS: u64 = 7 * 24 * 3600; + +pub(crate) const TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV: &str = + "TBTC_SIGNER_ENFORCE_ADMISSION_POLICY"; + +pub(crate) const TBTC_SIGNER_ADMISSION_MIN_PARTICIPANTS_ENV: &str = + "TBTC_SIGNER_ADMISSION_MIN_PARTICIPANTS"; + +pub(crate) const TBTC_SIGNER_ADMISSION_MIN_THRESHOLD_ENV: &str = + "TBTC_SIGNER_ADMISSION_MIN_THRESHOLD"; + +pub(crate) const TBTC_SIGNER_ADMISSION_REQUIRED_IDENTIFIERS_ENV: &str = + "TBTC_SIGNER_ADMISSION_REQUIRED_IDENTIFIERS"; + +pub(crate) const TBTC_SIGNER_ADMISSION_ALLOWLIST_IDENTIFIERS_ENV: &str = + "TBTC_SIGNER_ADMISSION_ALLOWLIST_IDENTIFIERS"; + +pub(crate) const TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV: &str = + "TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL"; + +pub(crate) const TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV: &str = + "TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK"; + +pub(crate) const TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV: &str = + "TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES"; + +pub(crate) const TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT_ENV: &str = + "TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT"; + +pub(crate) const TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS_ENV: &str = + "TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS"; + +pub(crate) const TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS_ENV: &str = + "TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS"; + +pub(crate) const TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR_ENV: &str = + "TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR"; + +pub(crate) const TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR_ENV: &str = + "TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR"; + +pub(crate) const TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE_ENV: &str = + "TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE"; + +pub(crate) const TBTC_SIGNER_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE_ENV: &str = + "TBTC_SIGNER_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE"; + +pub(crate) const TBTC_SIGNER_DEFAULT_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE: u64 = 60; + +pub(crate) const TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV: &str = + "TBTC_SIGNER_ENABLE_AUTO_QUARANTINE"; + +pub(crate) const TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV: &str = + "TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD"; + +pub(crate) const TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV: &str = + "TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY"; + +pub(crate) const TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV: &str = + "TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY"; + +pub(crate) const TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS_ENV: &str = + "TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS"; + +pub(crate) const TBTC_SIGNER_DEFAULT_AUTO_QUARANTINE_FAULT_THRESHOLD: u64 = 3; + +pub(crate) const TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV: &str = + "TBTC_SIGNER_REFRESH_CADENCE_SECONDS"; + +pub(crate) const TBTC_SIGNER_DEFAULT_REFRESH_CADENCE_SECONDS: u64 = 24 * 60 * 60; + +pub(crate) const TBTC_SIGNER_MIN_REFRESH_CADENCE_SECONDS: u64 = 60; + +pub(crate) const TBTC_SIGNER_MAX_REFRESH_CADENCE_SECONDS: u64 = 30 * 24 * 60 * 60; + +pub(crate) const TBTC_SIGNER_DIFFERENTIAL_FUZZ_MAX_CASES: u32 = 512; + +pub(crate) const TBTC_SIGNER_DIFFERENTIAL_FUZZ_DEFAULT_CASES: u32 = 64; + +pub(crate) const TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS_ENV: &str = + "TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS"; + +pub(crate) const TBTC_SIGNER_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS_ENV: &str = + "TBTC_SIGNER_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS"; + +pub(crate) const TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS_ENV: &str = + "TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS"; + +pub(crate) const TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND1_P95_MS_ENV: &str = + "TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND1_P95_MS"; + +pub(crate) const TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND2_P95_MS_ENV: &str = + "TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND2_P95_MS"; + +pub(crate) const TBTC_SIGNER_CANARY_MAX_INTERACTIVE_AGGREGATE_P95_MS_ENV: &str = + "TBTC_SIGNER_CANARY_MAX_INTERACTIVE_AGGREGATE_P95_MS"; + +pub(crate) const TBTC_SIGNER_CANARY_MIN_SAMPLES_ENV: &str = "TBTC_SIGNER_CANARY_MIN_SAMPLES"; + +pub(crate) const TBTC_SIGNER_CANARY_MIN_POLICY_SAMPLES_ENV: &str = + "TBTC_SIGNER_CANARY_MIN_POLICY_SAMPLES"; + +pub(crate) const TBTC_SIGNER_CANARY_MAX_SAMPLE_AGE_SECONDS_ENV: &str = + "TBTC_SIGNER_CANARY_MAX_SAMPLE_AGE_SECONDS"; + +pub(crate) const TBTC_SIGNER_DEFAULT_CANARY_MAX_START_SIGN_ROUND_P95_MS: u64 = 5_000; + +pub(crate) const TBTC_SIGNER_DEFAULT_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS: u64 = 5_000; + +pub(crate) const TBTC_SIGNER_DEFAULT_CANARY_MAX_POLICY_REJECT_RATE_BPS: u64 = 1_000; + +pub(crate) const TBTC_SIGNER_DEFAULT_CANARY_MIN_SAMPLES: u64 = 100; + +pub(crate) const TBTC_SIGNER_MAX_CANARY_MIN_SAMPLES: u64 = HARDENING_LATENCY_SAMPLE_WINDOW as u64; + +pub(crate) const TBTC_SIGNER_DEFAULT_CANARY_MAX_SAMPLE_AGE_SECONDS: u64 = 60 * 60; + +pub(crate) const TBTC_SIGNER_MAX_CANARY_SAMPLE_AGE_SECONDS: u64 = 7 * 24 * 60 * 60; + +pub(crate) const TBTC_SIGNER_MAX_POLICY_REJECT_RATE_BPS: u64 = 10_000; + +pub(crate) fn roast_coordinator_timeout_ms() -> u64 { + signer_env_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV) + .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) +} + +pub(crate) fn refresh_cadence_seconds() -> u64 { + signer_env_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV) + .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) +} + +pub(crate) fn parse_identifier_set_from_env( + env_name: &str, +) -> Result>, EngineError> { + let Some(raw_value) = signer_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)) +} + +pub(crate) fn parse_usize_from_env_with_default( + env_name: &str, + default_value: usize, +) -> Result { + let Some(raw_value) = signer_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) +} + +pub(crate) fn parse_u64_from_env_with_default( + env_name: &str, + default_value: u64, +) -> Result { + let Some(raw_value) = signer_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) +} + +pub(crate) fn parse_u8_from_env_optional(env_name: &str) -> Result, EngineError> { + let Some(raw_value) = signer_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)) +} + +/// Like `parse_script_class_set_required`, but falls back to `default_classes` +/// when the env var is absent. An explicitly-set value is parsed normally (an +/// explicit empty value is still an error). +pub(crate) fn parse_script_class_set_with_default( + env_name: &str, + default_classes: &[&str], +) -> Result, EngineError> { + if signer_env_var(env_name).is_some() { + return parse_script_class_set_required(env_name); + } + Ok(default_classes + .iter() + .map(|class| class.to_ascii_lowercase()) + .collect()) +} + +pub(crate) fn parse_script_class_set_required( + env_name: &str, +) -> Result, EngineError> { + let raw_value = signer_env_var(env_name) + .ok_or_else(|| 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) +} + +pub(crate) fn truthy_env_flag(raw_value: &str) -> bool { + matches!( + raw_value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +// Test-only: the interactive signing path always validates the attempt +// context in strict mode (it hardcodes strict = true), so production no +// longer routes the strict-mode decision through this helper. It remains +// as the tested source of truth for the "production forces strict, else +// honor the env flag" policy. +#[cfg(test)] +pub(crate) fn roast_strict_mode_enabled() -> bool { + if signer_profile_is_production() { + return true; + } + + signer_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"))] +pub(crate) fn bench_restart_hook_enabled() -> bool { + signer_env_var(TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK_ENV) + .map(|raw_value| truthy_env_flag(&raw_value)) + .unwrap_or(false) +} + +static PROFILE_VALUE_WARNING_EMITTED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + +pub(crate) fn signer_profile_is_production() -> bool { + let raw = signer_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 => { + // Fail closed: an unrecognized profile is treated as production + // (the strictest gate set) instead of panicking. The previous + // panic turned a single typo in TBTC_SIGNER_PROFILE into a + // process-wide denial of service, because this is consulted on the + // unvalidated env-fallback path and every profile-gated FFI + // operation would abort and surface as a generic internal error. + // Warn once so the misconfiguration stays visible to operators. + PROFILE_VALUE_WARNING_EMITTED.get_or_init(|| { + eprintln!( + "warning: {} value {:?} is not '{}' or '{}'; treating as '{}' (fail-closed)", + TBTC_SIGNER_PROFILE_ENV, + other, + TBTC_SIGNER_PROFILE_PRODUCTION, + TBTC_SIGNER_PROFILE_DEVELOPMENT, + TBTC_SIGNER_PROFILE_PRODUCTION, + ); + }); + true + } + } +} diff --git a/pkg/tbtc/signer/src/engine/dkg.rs b/pkg/tbtc/signer/src/engine/dkg.rs new file mode 100644 index 0000000000..5479767bb8 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/dkg.rs @@ -0,0 +1,313 @@ +// Distributed-DKG key-package persistence. + +use super::*; + +/// Persists a DISTRIBUTED FROST DKG result for one seat so the interactive +/// signing path can load its key. A distributed DKG runs Part1/2/3 across +/// nodes and each node's Part3 returns only ITS OWN secret key package. +/// This op stores that key package (keyed by this node's participant identifier) +/// together with the group public key package, then persists - the exact session +/// shape interactive signing consumes (own key package by member_identifier; the +/// public key package for the participant set and aggregation). A MULTI-SEAT +/// operator calls it once per local seat and the key packages accumulate under +/// one session (same key group). There is NO production gate: this is the real +/// distributed path, not the transitional dealer one. +pub fn persist_distributed_dkg_key_package( + mut request: PersistDistributedDkgKeyPackageRequest, +) -> Result { + const OP: &str = "persist_distributed_dkg_key_package"; + // data_hex is the serialized SECRET signing share. Move its redacting, + // zeroizing holder out BEFORE any fallible check so it is wiped on every + // return path and the rest of the request no longer retains it. + let data_hex = std::mem::take(&mut request.key_package.data_hex); + validate_session_id(&request.session_id)?; + // Gate BEFORE decoding or persisting any key material: this op writes signing + // material to durable state that interactive signing trusts after restart, so + // an unattested runtime must not be able to install it - the same gate run_dkg + // and every interactive op enforce. + enforce_provenance_gate()?; + + if request.participant_identifier == 0 { + return Err(EngineError::Validation(format!( + "{OP}: participant identifier must be non-zero" + ))); + } + if request.threshold < 2 || request.participant_count < request.threshold { + return Err(EngineError::Validation(format!( + "{OP}: threshold [{}] must be between 2 and participant_count [{}]", + request.threshold, request.participant_count + ))); + } + + let public_key_package = native_public_key_package_to_frost(OP, &request.public_key_package)?; + + // The group public key package is the authoritative participant set. EVERY + // verifying share must have a canonical (u16-derived) identifier: a + // non-canonical one cannot be a real group member, and silently dropping it + // would let it slip past the admission allowlist/required checks below while + // still inflating the participant count. + let mut admission_participant_identifiers = HashSet::new(); + for identifier in public_key_package.verifying_shares().keys() { + match frost_identifier_to_u16(*identifier) { + Some(participant_identifier) => { + admission_participant_identifiers.insert(participant_identifier); + } + None => { + return Err(EngineError::Validation(format!( + "{OP}: public key package contains a non-canonical participant identifier" + ))) + } + } + } + + // The caller's participant_count must match the authoritative public-package + // set, or downstream consumers of the stored DkgResult get the wrong group + // size for this key material. + if request.participant_count as usize != admission_participant_identifiers.len() { + return Err(EngineError::Validation(format!( + "{OP}: participant_count [{}] does not match the public key package [{}]", + request.participant_count, + admission_participant_identifiers.len() + ))); + } + + // Enforce the SAME DKG admission policy the dealer run_dkg enforces, over the + // participant set derived from the public key package. Otherwise a caller could + // persist a package that omits a required participant or includes a + // non-allowlisted one, and interactive signing would later trust it. + enforce_admission_policy_for( + &request.session_id, + admission_participant_identifiers.len(), + &admission_participant_identifiers, + request.threshold, + )?; + + // Enforce operator quarantine over the same derived participant set, exactly + // as the dealer run_dkg does: a distributed DKG whose group includes a + // quarantined operator must not be persisted and then trusted by later + // interactive signing sessions. + 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() + }; + let participant_identifiers: Vec = + admission_participant_identifiers.iter().copied().collect(); + enforce_not_quarantined_identifiers( + &request.session_id, + &participant_identifiers, + &quarantined_operator_identifiers, + auto_quarantine_config.as_ref(), + )?; + + let key_package = decode_key_package( + OP, + &request.key_package.identifier, + data_hex.expose_secret(), + )?; + + // The key package must belong to this participant AND be consistent with the + // group public key package: matching identifier, embedded threshold, group + // verifying key, and this participant's verifying share. An inconsistent + // package (e.g. min_signers 3 vs a stored threshold of 2, or a share from a + // different DKG) would let interactive signing open an attempt it can never + // complete and burn it at share release. + let frost_identifier = + participant_identifier_to_frost_identifier(request.participant_identifier)?; + if *key_package.identifier() != frost_identifier { + return Err(EngineError::Validation(format!( + "{OP}: key package identifier does not match participant_identifier" + ))); + } + if *key_package.min_signers() != request.threshold { + return Err(EngineError::Validation(format!( + "{OP}: key package min_signers [{}] does not match threshold [{}]", + *key_package.min_signers(), + request.threshold + ))); + } + if key_package.verifying_key() != public_key_package.verifying_key() { + return Err(EngineError::Validation(format!( + "{OP}: key package group verifying key does not match the public key package" + ))); + } + match public_key_package.verifying_shares().get(&frost_identifier) { + None => { + return Err(EngineError::Validation(format!( + "{OP}: participant_identifier is not a member of the public key package" + ))) + } + Some(verifying_share) if verifying_share != key_package.verifying_share() => { + return Err(EngineError::Validation(format!( + "{OP}: key package verifying share does not match the public key package" + ))) + } + Some(_) => {} + } + + // The checks above only trust the PUBLIC verifying share embedded in the key + // package; Round2 signs with the embedded SECRET signing share, and + // deserialization does not prove the signing scalar derives to that public + // share. Verify signing_share -> verifying_share, so a corrupt or malformed + // key package cannot be stored and then burn signing attempts producing shares + // that never verify. + // signing_share() is Copy (frost-core SigningShare is Copy + DefaultIsZeroes, NOT + // ZeroizeOnDrop), so bind the extracted copy and zeroize it right after the check - + // otherwise the secret scalar lingers as un-wiped stack residue. (The copy frost's + // own by-value VerifyingShare::from makes internally is beyond our reach.) + let mut signing_share = *key_package.signing_share(); + let derives_to_verifying_share = + frost::keys::VerifyingShare::from(signing_share) == *key_package.verifying_share(); + signing_share.zeroize(); + if !derives_to_verifying_share { + return Err(EngineError::Validation(format!( + "{OP}: key package signing share does not derive to its verifying share" + ))); + } + + let key_group = public_key_package + .verifying_key() + .serialize() + .map(hex::encode) + .map_err(|e| { + EngineError::Internal(format!("{OP}: failed to serialize verifying key: {e}")) + })?; + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + + // A group verifying key identifies one wallet. Keeping the same key_group in + // two sessions would make wallet lookup depend on randomized HashMap order and + // could split local seats or wallet-level kill switches across the copies. + // Reject the second owner before mutating either session; same-session + // multi-seat accumulation remains valid below. + if guard.sessions.iter().any(|(session_id, session)| { + session_id != &request.session_id + && session + .dkg_result + .as_ref() + .is_some_and(|dkg| dkg.key_group == key_group) + }) { + return Err(EngineError::SessionConflict { + session_id: request.session_id, + }); + } + + // A session first created by Interactive Open is a per-signing flow, not a + // wallet owner. Installing unrelated DKG material into that namespace would + // make dkg_result take precedence over bound_key_group during Round2 and + // could route share release around the original wallet's lifecycle gates. + // Keep the two roles disjoint for the full lifetime of the session. + if guard + .sessions + .get(&request.session_id) + .is_some_and(|session| session.dkg_result.is_none() && session.bound_key_group.is_some()) + { + return Err(EngineError::SessionConflict { + session_id: request.session_id, + }); + } + + // Reserve a total-registry slot only after every rejection that can apply + // to a fresh DKG session. If the ensuing durable write fails before file + // replacement, restore any retired tombstone evicted for this slot. + let compacted_retired_sessions = + ensure_session_insert_capacity(&mut guard, &request.session_id)?; + let dkg_session_id = request.session_id.clone(); + let session_existed = guard.sessions.contains_key(&dkg_session_id); + let session = guard + .sessions + .entry(dkg_session_id.clone()) + .or_insert_with(SessionState::default); + let previous_dkg_result = session.dkg_result.clone(); + let previous_dkg_public_key_package = session.dkg_public_key_package.clone(); + let key_package_map_was_absent = session.dkg_key_packages.is_none(); + + // A session may already hold a DKG result: this seat re-persisting (idempotent) + // or, for a MULTI-SEAT operator, a sibling seat of the SAME distributed DKG. + // Same key group -> accumulate this seat's key package into the session; a + // different key group for the same session is a conflict. + if let Some(existing) = &session.dkg_result { + if existing.key_group != key_group { + return Err(EngineError::SessionConflict { + session_id: request.session_id, + }); + } + // Same group key is NOT enough: a sibling seat of the SAME distributed DKG + // must carry the SAME threshold, participant count, and public key package. + // Otherwise a second seat could be validated against a different submitted + // public package while the session keeps the first, so later signing would + // use public material inconsistent with this seat's key. + if existing.threshold != request.threshold + || existing.participant_count != request.participant_count + { + return Err(EngineError::Validation(format!( + "{OP}: threshold/participant_count does not match the stored DKG for this session" + ))); + } + if session.dkg_public_key_package.as_ref() != Some(&public_key_package) { + return Err(EngineError::Validation(format!( + "{OP}: public key package does not match the stored DKG for this session" + ))); + } + } else { + session.dkg_result = Some(DkgResult { + session_id: request.session_id.clone(), + key_group, + participant_count: request.participant_count, + threshold: request.threshold, + created_at_unix: now_unix(), + }); + session.dkg_public_key_package = Some(public_key_package); + } + + let replaced_key_package = session + .dkg_key_packages + .get_or_insert_with(BTreeMap::new) + .insert(request.participant_identifier, key_package); + + // Clone the result before the `&guard` persist call so the mutable `session` + // borrow ends here (mirrors run_dkg's ordering). + let result = session + .dkg_result + .clone() + .expect("dkg_result was just set for this session"); + if let Err(persist_error) = persist_engine_state_to_storage(&guard) { + let state_file_replaced = persist_error.state_file_replaced(); + let persist_error = persist_error.into_engine_error(); + if !state_file_replaced { + if session_existed { + let rollback_session = guard.sessions.get_mut(&dkg_session_id).ok_or_else(|| { + EngineError::Internal(format!( + "distributed DKG session [{dkg_session_id}] disappeared while rolling back a failed persist: {persist_error}" + )) + })?; + rollback_session.dkg_result = previous_dkg_result; + rollback_session.dkg_public_key_package = previous_dkg_public_key_package; + if let Some(key_packages) = rollback_session.dkg_key_packages.as_mut() { + key_packages.remove(&request.participant_identifier); + if let Some(previous_key_package) = replaced_key_package { + key_packages.insert(request.participant_identifier, previous_key_package); + } + } + if key_package_map_was_absent + && rollback_session + .dkg_key_packages + .as_ref() + .is_some_and(BTreeMap::is_empty) + { + rollback_session.dkg_key_packages = None; + } + } else { + guard.sessions.remove(&dkg_session_id); + } + restore_compacted_retired_sessions(&mut guard, compacted_retired_sessions); + } + return Err(persist_error); + } + + Ok(result) +} diff --git a/pkg/tbtc/signer/src/engine/frost_ops.rs b/pkg/tbtc/signer/src/engine/frost_ops.rs new file mode 100644 index 0000000000..0099db7297 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/frost_ops.rs @@ -0,0 +1,196 @@ +// Stateless FROST primitives: dkg_part1..3 and signing-package assembly. + +use super::*; + +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: SecretHex::new(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.expose_secret(), + )?; + 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: SecretHex::new(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: SecretHex::new(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.expose_secret(), + )?; + 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: SecretHex::new(hex::encode(&key_package_bytes)), + }, + public_key_package: native_public_key_package, + }; + key_package_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), + }) +} diff --git a/pkg/tbtc/signer/src/engine/init_config.rs b/pkg/tbtc/signer/src/engine/init_config.rs new file mode 100644 index 0000000000..0668f0c961 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/init_config.rs @@ -0,0 +1,540 @@ +// Init-time signer configuration: a typed, FFI-installed snapshot that +// replaces the process environment as the source of TBTC_SIGNER_* knobs. +// +// Install guarantee: a candidate config is validated through the same +// loaders the runtime gates use while visible only to the validating +// thread (thread-local override below). It is published to the global +// slot only after validation succeeds, so an unvalidated or rejected +// config can never be observed by any other caller, and init results are +// truthful under concurrent initialization (idempotent success is only +// ever reported against a validated, installed config). + +use super::*; +use std::cell::RefCell; +use std::sync::{Arc, RwLock}; + +static INSTALLED_SIGNER_CONFIG: OnceLock>>> = + OnceLock::new(); +static ENV_FALLBACK_WARNING_EMITTED: OnceLock<()> = OnceLock::new(); + +thread_local! { + // Candidate config visible ONLY to the thread running init-time + // validation. Keeping the candidate off the global slot until it + // validates means no other caller can ever observe an unvalidated + // config, and a failed init has no observable side effects. + static VALIDATION_CANDIDATE: RefCell>> = + const { RefCell::new(None) }; +} + +struct ValidationCandidateGuard; + +impl ValidationCandidateGuard { + fn install(candidate: Arc) -> Self { + VALIDATION_CANDIDATE.with(|slot| *slot.borrow_mut() = Some(candidate)); + ValidationCandidateGuard + } +} + +impl Drop for ValidationCandidateGuard { + fn drop(&mut self) { + VALIDATION_CANDIDATE.with(|slot| *slot.borrow_mut() = None); + } +} + +fn validation_candidate() -> Option> { + VALIDATION_CANDIDATE.with(|slot| slot.borrow().clone()) +} + +pub(crate) struct InstalledSignerConfig { + pub(crate) values: HashMap, + pub(crate) fingerprint: String, +} + +fn installed_signer_config_slot() -> &'static RwLock>> { + INSTALLED_SIGNER_CONFIG.get_or_init(|| RwLock::new(None)) +} + +fn installed_signer_config() -> Option> { + installed_signer_config_slot() + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() +} + +/// Single chokepoint for every `TBTC_SIGNER_*` operational read. +/// +/// With an installed init-time config the process environment is NOT +/// consulted: the snapshot is the sole source of truth and an absent key +/// means the built-in default. Without an installed config this falls +/// through to the process environment (test/development behavior, and the +/// transitional path for hosts that have not adopted the init FFI yet). +/// +/// The state-encryption key (`TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX`) is +/// deliberately NOT routed through here: secrets stay on the dedicated +/// env/command key-provider channel and never ride the config FFI. +pub(crate) fn signer_env_var(name: &str) -> Option { + if let Some(candidate) = validation_candidate() { + return candidate.values.get(name).cloned(); + } + if let Some(config) = installed_signer_config() { + return config.values.get(name).cloned(); + } + warn_production_env_fallback_once(name); + std::env::var(name).ok() +} + +fn warn_production_env_fallback_once(name: &str) { + // The production check reads the environment directly: routing it through + // signer_env_var would recurse, and on this path no config is installed + // so the environment is the authoritative source anyway. + if name == TBTC_SIGNER_PROFILE_ENV { + return; + } + ENV_FALLBACK_WARNING_EMITTED.get_or_init(|| { + let raw = std::env::var(TBTC_SIGNER_PROFILE_ENV).unwrap_or_default(); + let normalized = raw.trim().to_ascii_lowercase(); + if normalized.as_str() != TBTC_SIGNER_PROFILE_DEVELOPMENT { + eprintln!( + "warning: TBTC_SIGNER_* knobs are being read from the process \ + environment; production hosts should install an init-time \ + config via frost_tbtc_init_signer_config" + ); + } + }); +} + +pub fn init_signer_config( + request: InitSignerConfigRequest, +) -> Result { + let config_fingerprint = fingerprint(&request)?; + let values = config_values_from_request(&request)?; + let configured_key_count = values.len() as u32; + let candidate = Arc::new(InstalledSignerConfig { + values, + fingerprint: config_fingerprint.clone(), + }); + + // Fast path against an already-installed (and therefore already + // validated) config; the authoritative re-check happens under the write + // lock below. + if let Some(existing) = installed_signer_config() { + return reinit_result(&existing, &config_fingerprint); + } + + // Fail-closed validation BEFORE anything is published: the candidate is + // visible only to this thread's loaders via the thread-local override, + // so no other caller can ever observe an unvalidated config and a failed + // init leaves prior state (installed config or environment fallback) + // untouched. Validation runs the same loaders the runtime gates use plus + // the state-path, key-provider and provenance-gate requirements; knobs the runtime warn-and-defaults on + // keep that behavior. + { + let _candidate_guard = ValidationCandidateGuard::install(Arc::clone(&candidate)); + validate_candidate_config()?; + } + + // Publish, re-checking under the write lock: two threads may have + // validated identical (or conflicting) candidates concurrently. + let mut guard = installed_signer_config_slot() + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(existing) = guard.as_ref() { + let existing = Arc::clone(existing); + drop(guard); + return reinit_result(&existing, &config_fingerprint); + } + *guard = Some(candidate); + + Ok(InitSignerConfigResult { + installed: true, + idempotent: false, + config_fingerprint, + configured_key_count, + }) +} + +fn reinit_result( + existing: &InstalledSignerConfig, + config_fingerprint: &str, +) -> Result { + if existing.fingerprint == config_fingerprint { + return Ok(InitSignerConfigResult { + installed: true, + idempotent: true, + config_fingerprint: config_fingerprint.to_string(), + configured_key_count: existing.values.len() as u32, + }); + } + Err(EngineError::Validation(format!( + "signer config already installed with fingerprint [{}]; \ + conflicting re-initialization rejected", + existing.fingerprint + ))) +} + +fn validate_candidate_config() -> Result<(), EngineError> { + load_admission_policy_config()?; + load_signing_policy_firewall_config()?; + heartbeat_rate_limit_per_minute()?; + load_auto_quarantine_config()?; + // Production (explicit or by profile-omission default) requires an + // explicit state path; surfacing this at init beats failing the first + // state access after a host migrates to the config FFI. + state_file_path()?; + // The key-provider settings must be structurally usable too (production + // forbids the env provider; the command provider requires a command). + // Resolved WITHOUT reading the secret or executing the key command. + resolve_state_key_provider_plan()?; + // Production forces the provenance gate, so a production config without + // a complete, verifiable attestation set is unusable for every protected + // operation - reject it at init. The gate self-gates (no-op when not + // enforced), reads only candidate values plus local crypto, and runtime + // calls still re-check it: an init-time pass does not exempt TTL aging. + enforce_provenance_gate()?; + Ok(()) +} + +pub(crate) fn config_values_from_request( + request: &InitSignerConfigRequest, +) -> Result, EngineError> { + let mut values = HashMap::new(); + + if let Some(profile) = &request.profile { + let normalized = profile.trim().to_ascii_lowercase(); + if normalized != TBTC_SIGNER_PROFILE_PRODUCTION + && normalized != TBTC_SIGNER_PROFILE_DEVELOPMENT + { + return Err(EngineError::Validation(format!( + "profile must be '{}' or '{}'; got [{}]", + TBTC_SIGNER_PROFILE_PRODUCTION, TBTC_SIGNER_PROFILE_DEVELOPMENT, profile + ))); + } + values.insert(TBTC_SIGNER_PROFILE_ENV.to_string(), normalized); + } + + insert_bool( + &mut values, + TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV, + request.allow_bootstrap, + ); + insert_bool( + &mut values, + TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV, + request.enable_roast_strict, + ); + insert_bool( + &mut values, + TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK_ENV, + request.allow_bench_restart_hook, + ); + insert_bool( + &mut values, + TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, + request.enforce_provenance_gate, + ); + insert_bool( + &mut values, + TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV, + request.enforce_admission_policy, + ); + insert_bool( + &mut values, + TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, + request.enforce_signing_policy_firewall, + ); + // Make the emergency plaintext-state rollback opt-in reachable for hosts that + // configure via init-time config (where signer_env_var reads the installed + // config, not the process environment), not just raw env. + insert_bool( + &mut values, + TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV, + request.permit_plaintext_state_rollback, + ); + insert_bool( + &mut values, + TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV, + request.enable_auto_quarantine, + ); + + insert_u64( + &mut values, + TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, + request.roast_coordinator_timeout_ms, + ); + insert_u64( + &mut values, + TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV, + request.refresh_cadence_seconds, + ); + insert_u64( + &mut values, + TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV, + request.state_corrupt_backup_limit, + ); + insert_u64( + &mut values, + TBTC_SIGNER_MAX_SESSIONS_ENV, + request.max_sessions, + ); + insert_u64( + &mut values, + TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV, + request.max_live_interactive_sessions, + ); + insert_u64( + &mut values, + TBTC_SIGNER_INTERACTIVE_SESSION_TTL_SECONDS_ENV, + request.interactive_session_ttl_seconds, + ); + insert_u64( + &mut values, + TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV, + request.state_key_command_timeout_secs, + ); + insert_u64( + &mut values, + TBTC_SIGNER_ADMISSION_MIN_PARTICIPANTS_ENV, + request.admission_min_participants, + ); + insert_u64( + &mut values, + TBTC_SIGNER_ADMISSION_MIN_THRESHOLD_ENV, + request.admission_min_threshold, + ); + insert_u64( + &mut values, + TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT_ENV, + request.policy_max_output_count, + ); + insert_u64( + &mut values, + TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS_ENV, + request.policy_max_output_value_sats, + ); + insert_u64( + &mut values, + TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS_ENV, + request.policy_max_total_output_value_sats, + ); + insert_u64( + &mut values, + TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE_ENV, + request.policy_rate_limit_per_minute, + ); + insert_u64( + &mut values, + TBTC_SIGNER_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE_ENV, + request.policy_heartbeat_rate_limit_per_minute, + ); + insert_u64( + &mut values, + TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV, + request.auto_quarantine_fault_threshold, + ); + insert_u64( + &mut values, + TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV, + request.auto_quarantine_timeout_penalty, + ); + insert_u64( + &mut values, + TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV, + request.auto_quarantine_invalid_share_penalty, + ); + insert_u64( + &mut values, + TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS_ENV, + request.canary_max_start_sign_round_p95_ms, + ); + insert_u64( + &mut values, + TBTC_SIGNER_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS_ENV, + request.canary_max_finalize_sign_round_p95_ms, + ); + insert_u64( + &mut values, + TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS_ENV, + request.canary_max_policy_reject_rate_bps, + ); + insert_u64( + &mut values, + TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND1_P95_MS_ENV, + request.canary_max_interactive_round1_p95_ms, + ); + insert_u64( + &mut values, + TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND2_P95_MS_ENV, + request.canary_max_interactive_round2_p95_ms, + ); + insert_u64( + &mut values, + TBTC_SIGNER_CANARY_MAX_INTERACTIVE_AGGREGATE_P95_MS_ENV, + request.canary_max_interactive_aggregate_p95_ms, + ); + insert_u64( + &mut values, + TBTC_SIGNER_CANARY_MIN_SAMPLES_ENV, + request.canary_min_samples, + ); + insert_u64( + &mut values, + TBTC_SIGNER_CANARY_MIN_POLICY_SAMPLES_ENV, + request.canary_min_policy_samples, + ); + insert_u64( + &mut values, + TBTC_SIGNER_CANARY_MAX_SAMPLE_AGE_SECONDS_ENV, + request.canary_max_sample_age_seconds, + ); + + insert_u64( + &mut values, + TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR_ENV, + request.policy_allowed_utc_start_hour.map(u64::from), + ); + insert_u64( + &mut values, + TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR_ENV, + request.policy_allowed_utc_end_hour.map(u64::from), + ); + + insert_string(&mut values, TBTC_SIGNER_STATE_PATH_ENV, &request.state_path)?; + insert_string( + &mut values, + TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV, + &request.state_corruption_policy, + )?; + insert_string( + &mut values, + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + &request.state_key_provider, + )?; + insert_string( + &mut values, + TBTC_SIGNER_STATE_KEY_COMMAND_ENV, + &request.state_key_command, + )?; + insert_string( + &mut values, + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV, + &request.provenance_attestation_status, + )?; + insert_string( + &mut values, + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, + &request.provenance_attestation_payload, + )?; + insert_string( + &mut values, + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV, + &request.provenance_attestation_signature_hex, + )?; + insert_string( + &mut values, + TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, + &request.provenance_trust_root, + )?; + insert_string( + &mut values, + TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, + &request.min_approved_version, + )?; + + insert_identifier_list( + &mut values, + TBTC_SIGNER_ADMISSION_REQUIRED_IDENTIFIERS_ENV, + &request.admission_required_identifiers, + )?; + insert_identifier_list( + &mut values, + TBTC_SIGNER_ADMISSION_ALLOWLIST_IDENTIFIERS_ENV, + &request.admission_allowlist_identifiers, + )?; + insert_identifier_list( + &mut values, + TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS_ENV, + &request.auto_quarantine_dao_allowlist_identifiers, + )?; + + if let Some(script_classes) = &request.policy_allowed_script_classes { + if script_classes.is_empty() || script_classes.iter().any(|class| class.trim().is_empty()) { + return Err(EngineError::Validation(format!( + "config field for [{}] must contain at least one non-empty script class when set", + TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV + ))); + } + values.insert( + TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV.to_string(), + script_classes.join(","), + ); + } + + Ok(values) +} + +fn insert_bool(values: &mut HashMap, key: &str, value: Option) { + if let Some(value) = value { + values.insert( + key.to_string(), + if value { "true" } else { "false" }.to_string(), + ); + } +} + +fn insert_u64(values: &mut HashMap, key: &str, value: Option) { + if let Some(value) = value { + values.insert(key.to_string(), value.to_string()); + } +} + +fn insert_string( + values: &mut HashMap, + key: &str, + value: &Option, +) -> Result<(), EngineError> { + if let Some(value) = value { + if value.trim().is_empty() { + return Err(EngineError::Validation(format!( + "config field for [{}] must not be empty when set", + key + ))); + } + values.insert(key.to_string(), value.clone()); + } + Ok(()) +} + +fn insert_identifier_list( + values: &mut HashMap, + key: &str, + identifiers: &Option>, +) -> Result<(), EngineError> { + if let Some(identifiers) = identifiers { + if identifiers.is_empty() { + return Err(EngineError::Validation(format!( + "config field for [{}] must contain at least one identifier when set", + key + ))); + } + let mut sorted = identifiers.clone(); + sorted.sort_unstable(); + sorted.dedup(); + values.insert( + key.to_string(), + sorted + .iter() + .map(|identifier| identifier.to_string()) + .collect::>() + .join(","), + ); + } + Ok(()) +} + +#[cfg(test)] +pub(crate) fn clear_installed_signer_config_for_tests() { + let mut guard = installed_signer_config_slot() + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *guard = None; +} diff --git a/pkg/tbtc/signer/src/engine/interactive.rs b/pkg/tbtc/signer/src/engine/interactive.rs new file mode 100644 index 0000000000..7513b8894e --- /dev/null +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -0,0 +1,2171 @@ +// Phase 7.1: the hardened interactive signing session layer. +// +// Implements sections 4-5 of the frozen spec +// (docs/phase-7-interactive-session-spec-freeze.md). The defining +// property is engine-held nonce custody: round-1 nonces are generated +// from OS randomness, live only in in-memory session state bound to +// (session_id, attempt_id), are zeroized on consumption, abort, and +// expiry, and are NEVER serialized into a response or persisted state. +// The only durable artifacts are per-attempt consumption markers, +// written BEFORE a signature share leaves the engine +// (consumption-before-release), so a restart can never lead to a +// second share under the same nonces. +// +// Attempt contexts are strict-mode only: there is no legacy-shape +// fallback on this path. All entry points are idempotent or fail +// closed; none of them can be made to release more than one signature +// share per nonce pair. + +use super::*; + +#[cfg(test)] +static INTERACTIVE_CLOCK_OFFSET_SECONDS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +pub(crate) fn interactive_now() -> Instant { + #[cfg(test)] + { + let offset_seconds = + INTERACTIVE_CLOCK_OFFSET_SECONDS.load(std::sync::atomic::Ordering::SeqCst); + Instant::now() + .checked_add(Duration::from_secs(offset_seconds)) + .expect("interactive test clock offset fits the monotonic clock") + } + #[cfg(not(test))] + { + Instant::now() + } +} + +#[cfg(test)] +pub(crate) fn advance_interactive_clock_for_tests(seconds: u64) { + INTERACTIVE_CLOCK_OFFSET_SECONDS + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |current| current.checked_add(seconds), + ) + .expect("interactive test clock offset does not overflow"); +} + +#[cfg(test)] +pub(crate) fn reset_interactive_clock_for_tests() { + INTERACTIVE_CLOCK_OFFSET_SECONDS.store(0, std::sync::atomic::Ordering::SeqCst); +} + +#[cfg(test)] +static INTERACTIVE_AGGREGATE_HOLD_AFTER_UNLOCK: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); +#[cfg(test)] +static INTERACTIVE_AGGREGATE_UNLOCK_HELD: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); +#[cfg(test)] +static INTERACTIVE_AGGREGATE_RELEASE_AFTER_UNLOCK: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(true); + +#[cfg(test)] +pub(crate) fn arm_interactive_aggregate_unlock_hold_for_tests() { + use std::sync::atomic::Ordering; + INTERACTIVE_AGGREGATE_UNLOCK_HELD.store(false, Ordering::SeqCst); + INTERACTIVE_AGGREGATE_RELEASE_AFTER_UNLOCK.store(false, Ordering::SeqCst); + INTERACTIVE_AGGREGATE_HOLD_AFTER_UNLOCK.store(true, Ordering::SeqCst); +} + +#[cfg(test)] +pub(crate) fn interactive_aggregate_unlock_held_for_tests() -> bool { + INTERACTIVE_AGGREGATE_UNLOCK_HELD.load(std::sync::atomic::Ordering::SeqCst) +} + +#[cfg(test)] +pub(crate) fn release_interactive_aggregate_unlock_for_tests() { + INTERACTIVE_AGGREGATE_RELEASE_AFTER_UNLOCK.store(true, std::sync::atomic::Ordering::SeqCst); +} + +#[cfg(test)] +fn maybe_hold_interactive_aggregate_after_unlock_for_tests() { + use std::sync::atomic::Ordering; + if INTERACTIVE_AGGREGATE_HOLD_AFTER_UNLOCK.swap(false, Ordering::SeqCst) { + INTERACTIVE_AGGREGATE_UNLOCK_HELD.store(true, Ordering::SeqCst); + while !INTERACTIVE_AGGREGATE_RELEASE_AFTER_UNLOCK.load(Ordering::SeqCst) { + std::thread::yield_now(); + } + } +} + +// Multi-seat: a session's interactive consumed-nonce markers are keyed per +// (attempt_id, member_identifier), so independent local seats can each consume +// their own nonces for the same attempt without colliding. The marker is written +// BEFORE a share leaves the engine (consumption-before-release). Legacy bare +// attempt_id markers (written by the pre-multi-seat single-member engine, and +// possibly reloaded from durable state) are honored FAIL-CLOSED on read: a bare +// marker means the attempt is consumed for every member. +pub(crate) fn interactive_consumed_marker(attempt_id: &str, member_identifier: u16) -> String { + // Keep the schema-1 wire representation understood by the immediately + // previous signer. A binary rollback must continue to see the attempt as + // consumed and fail closed rather than releasing a second share. + format!("m{member_identifier}@{attempt_id}") +} + +pub(crate) fn interactive_attempt_consumed( + markers: &HashSet, + attempt_id: &str, + member_identifier: u16, +) -> bool { + markers.contains(&interactive_consumed_marker(attempt_id, member_identifier)) + // Transitional marker written by an unreleased intermediate build. + // Continue honoring it fail-closed when upgrading that state. + || markers.contains(&format!("m{member_identifier}@{attempt_id}@v2")) + || markers.contains(attempt_id) +} + +// Fixed-size, exact authorization written atomically with the Round2 consumed +// marker. Hash canonical package bytes rather than caller-provided hex so +// alternate encodings cannot create distinct persistent records. +pub(crate) fn interactive_aggregate_authorization_marker( + attempt_id: &str, + signing_package: &frost::SigningPackage, + taproot_merkle_root: Option<&[u8; 32]>, +) -> Result { + let signing_package_bytes = signing_package.serialize().map_err(|error| { + EngineError::Internal(format!( + "failed to serialize signing package for Aggregate authorization: {error}" + )) + })?; + let mut hasher = Sha256::new(); + hasher.update(b"tbtc-signer/interactive-aggregate-authorization/v1"); + hasher.update((attempt_id.len() as u64).to_be_bytes()); + hasher.update(attempt_id.as_bytes()); + match taproot_merkle_root { + Some(root) => { + hasher.update([1]); + hasher.update(root); + } + None => hasher.update([0]), + } + hasher.update((signing_package_bytes.len() as u64).to_be_bytes()); + hasher.update(&signing_package_bytes); + Ok(hex::encode(hasher.finalize())) +} + +// Session-scoped identity for a successfully aggregated canonical package. +// It deliberately excludes attempt_id: the inner FROST package does not carry +// one, so a non-signing coordinator can validate only the live coordinator +// context plus package shape. Persisting this identity prevents the same valid +// package/share set from being replayed under fresh canonical attempts to fill +// the bounded completion registry. +pub(crate) fn interactive_aggregate_package_completion_marker( + signing_package: &frost::SigningPackage, + taproot_merkle_root: Option<&[u8; 32]>, +) -> Result { + let signing_package_bytes = signing_package.serialize().map_err(|error| { + EngineError::Internal(format!( + "failed to serialize signing package for Aggregate completion: {error}" + )) + })?; + let mut hasher = Sha256::new(); + hasher.update(b"tbtc-signer/interactive-aggregate-package-completion/v1"); + match taproot_merkle_root { + Some(root) => { + hasher.update([1]); + hasher.update(root); + } + None => hasher.update([0]), + } + hasher.update((signing_package_bytes.len() as u64).to_be_bytes()); + hasher.update(&signing_package_bytes); + Ok(hex::encode(hasher.finalize())) +} + +// A signer proves live authorization with the exact Round1 commitment included +// in the package. The elected coordinator is also allowed to aggregate a strict +// first-t package that omits it: its validated live attempt authorizes the +// common message/threshold/included-subset shape, while the package-completion +// marker above prevents cross-attempt reuse of that otherwise attempt-less +// FROST package. An omitted non-coordinator never receives this fallback. +fn interactive_aggregate_has_live_authorization( + session: &SessionState, + attempt_id: &str, + signing_package: &frost::SigningPackage, + taproot_merkle_root: Option<&[u8; 32]>, +) -> Result { + for interactive in session.interactive_signing.values().filter(|interactive| { + interactive.attempt_context.attempt_id == attempt_id + && interactive.taproot_merkle_root.as_ref() == taproot_merkle_root + && interactive.round1.is_some() + }) { + match verify_round2_signing_package(interactive, signing_package) { + Ok(()) => return Ok(true), + Err(error @ EngineError::Internal(_)) => return Err(error), + Err(_) => { + let own_identifier = + participant_identifier_to_frost_identifier(interactive.member_identifier)?; + let is_omitted_coordinator = interactive.member_identifier + == interactive.attempt_context.coordinator_identifier + && !signing_package + .signing_commitments() + .contains_key(&own_identifier); + if is_omitted_coordinator { + match verify_interactive_signing_package_context(interactive, signing_package) { + Ok(()) => return Ok(true), + Err(error @ EngineError::Internal(_)) => return Err(error), + Err(_) => {} + } + } + } + } + } + Ok(false) +} + +// The aggregate completion marker binds attempt_id to the AGGREGATED message digest, +// so the durable "this attempt is final" record cannot be set for one attempt id via +// a valid aggregate over a DIFFERENT message - which would otherwise let a replayed +// aggregate preempt an unrelated live attempt's Round2 (the Round2 completion gate). +// interactive_aggregate writes it from the package it aggregated; Round2 and the +// re-aggregate guard recompute it from the message they actually hold. +pub(crate) fn interactive_aggregated_marker( + attempt_id: &str, + message_digest_hex: &str, + taproot_merkle_root: Option<&[u8; 32]>, +) -> String { + // The signature differs per taproot tweak, so the completion is per (message, + // root). "keypath" (a key-path / None spend) cannot collide with a 64-hex root. + let root = taproot_merkle_root + .map(hex::encode) + .unwrap_or_else(|| "keypath".to_string()); + format!("{attempt_id}@{message_digest_hex}@{root}") +} + +// Aggregate-completion check honoring legacy markers: the new bound form for THIS +// message, OR a legacy bare attempt_id marker (written by the pre-binding engine and +// reloaded from durable state) - the latter fail-closed, exactly like the consumed +// markers, so a completion persisted before this format change stays final (no repeat +// aggregate, no fresh Round2 share) after an upgrade. +pub(crate) fn interactive_attempt_aggregated( + markers: &HashSet, + attempt_id: &str, + message_digest_hex: &str, + taproot_merkle_root: Option<&[u8; 32]>, +) -> bool { + markers.contains(&interactive_aggregated_marker( + attempt_id, + message_digest_hex, + taproot_merkle_root, + )) || markers.contains(attempt_id) +} + +pub fn interactive_session_open( + mut request: InteractiveSessionOpenRequest, +) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.interactive_session_open_calls_total = telemetry + .interactive_session_open_calls_total + .saturating_add(1); + }); + 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(), + )); + } + if request.threshold == 0 { + return Err(EngineError::Validation( + "threshold 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()))?; + if message_bytes.is_empty() { + return Err(EngineError::Validation( + "message_hex must not be empty".to_string(), + )); + } + // Canonicalize message_hex to lowercase before it feeds the + // open-request fingerprint below. attempt_id and + // taproot_merkle_root_hex are already canonicalized + // case-insensitively (and the coarse signing path lowercases the + // signing message likewise), but the fingerprint serializes + // message_hex verbatim - so without this a re-cased retry of an + // otherwise identical open would mismatch the fingerprint and be + // rejected as a SessionConflict instead of returning idempotent. + // The decoded message_bytes are unaffected by hex casing. + request.message_hex = request.message_hex.to_ascii_lowercase(); + if let Some(InteractiveSigningIntent::Heartbeat { message_hex }) = + request.signing_intent.as_mut() + { + // The intent is part of the Open fingerprint. Treat hex casing as a + // wire-format detail so an otherwise identical retry is idempotent. + *message_hex = message_hex.to_ascii_lowercase(); + } + let message_digest_hex = hash_hex(&message_bytes); + let taproot_merkle_root = + canonicalize_taproot_merkle_root_hex(&mut request.taproot_merkle_root_hex)?; + + // Canonicalize the attempt context before anything keys off it - + // lowercases the hex hash fields and sorts the included set, + // exactly as the coarse start_sign_round path does. The wire + // accepts attempt_id/fingerprint case-insensitively, so the marker + // registry and live-state comparisons MUST run on the canonical + // form or a re-cased retry of a consumed attempt would miss the + // marker and sign again. + request.attempt_context = canonical_attempt_context(&request.attempt_context); + + let request_fingerprint = interactive_open_request_fingerprint(&request)?; + let attempt_id = request.attempt_context.attempt_id.clone(); + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + sweep_expired_interactive_state_durably(&mut guard)?; + + let auto_quarantine_config = load_auto_quarantine_config()?; + + // The session must already exist with completed DKG. Key material + // lives in the engine's own DKG-populated state and is NEVER + // supplied through the request, so no signing secret crosses the + // FFI/host boundary (frozen spec section 4). Resolve the member's + // key package, run the policy gates, and validate the strict + // attempt context against the DKG threshold/key group - mirroring + // the coarse start_sign_round - all under one immutable borrow, + // then do the mutable install. + // The DKG key material is a WALLET-level asset keyed by key_group, not by the + // per-signing session_id: interactive signing runs under a fresh RoastSessionID + // per message, while the wallet key lives under the session its DKG completed in. + // Resolve that wallet session by key_group so ANY signing session can reach the + // material (and the wallet-level policy gates below); the per-signing state + // (consumed markers, live attempt, nonces) still lives under request.session_id, + // and the attempt context is still validated against request.session_id so + // coordinator/attempt derivation is unchanged. DkgNotReady now means "no wallet + // key for this key_group" rather than "this exact session lacks DKG". + let wallet_session_id = + resolve_wallet_session_id(&guard, &request.session_id, &request.key_group).ok_or_else( + || EngineError::DkgNotReady { + session_id: request.session_id.clone(), + }, + )?; + let (key_package, canonical_included_participants) = + { + let session = guard.sessions.get(&wallet_session_id).ok_or_else(|| { + EngineError::SessionNotFound { + session_id: wallet_session_id.clone(), + } + })?; + let dkg = session + .dkg_result + .as_ref() + .ok_or_else(|| EngineError::DkgNotReady { + session_id: request.session_id.clone(), + })?; + if request.key_group != dkg.key_group { + return Err(EngineError::Validation( + "key_group does not match DKG output for this session".to_string(), + )); + } + if request.threshold != dkg.threshold { + return Err(EngineError::Validation(format!( + "threshold [{}] does not match the DKG threshold [{}] for this session", + request.threshold, dkg.threshold + ))); + } + let dkg_key_packages = session.dkg_key_packages.as_ref().ok_or_else(|| { + EngineError::Internal("missing DKG key package cache".to_string()) + })?; + // The public key package carries a verifying share for EVERY DKG + // participant, so it is the authoritative participant set. A distributed + // DKG node holds only its OWN secret key package (dkg_key_packages has a + // single entry), so the included-participants membership check below must + // use the public package, not dkg_key_packages. + let dkg_public_key_package = + session.dkg_public_key_package.as_ref().ok_or_else(|| { + EngineError::Internal("missing DKG public key package".to_string()) + })?; + let key_package = dkg_key_packages + .get(&request.member_identifier) + .ok_or_else(|| { + EngineError::Validation( + "member_identifier is not a DKG participant for this session".to_string(), + ) + })? + .clone(); + + // Lifecycle + quarantine + signing-policy-firewall gates (frozen + // spec section 5: Open "checks policy gates"). The SAME helper + // runs again at Round2 (the share-release moment) so a policy + // change recorded after Open - emergency rekey, finalization, + // quarantine, or a re-bound policy-checked tx - cannot let a + // share escape. At Open only this node's own member is known to + // sign; Round2 re-checks quarantine over the actual chosen + // subset. + enforce_interactive_signing_gates( + &request.session_id, + &[request.member_identifier], + &request.message_hex, + guard + .sessions + .get(&request.session_id) + .and_then(|signing_session| signing_session.emergency_rekey_event.as_ref()), + session.emergency_rekey_event.as_ref(), + session.finalize_request_fingerprint.is_some(), + guard + .sessions + .get(&request.session_id) + .and_then(|signing_session| signing_session.tx_result.as_ref()), + taproot_merkle_root.as_ref(), + request.signing_intent.as_ref(), + &guard.quarantined_operator_identifiers, + auto_quarantine_config.as_ref(), + )?; + + // Strict-mode-only attempt context: required, fully validated + // against the DKG threshold/key group, coordinator recomputed + // per RFC-21 Annex A. + let canonical_included_participants = validate_attempt_context( + &request.session_id, + &dkg.key_group, + &message_bytes, + &message_digest_hex, + dkg.threshold, + Some(&request.attempt_context), + true, + )? + .ok_or_else(|| { + EngineError::Internal( + "strict attempt context validation returned no participants".to_string(), + ) + })?; + if !canonical_included_participants.contains(&request.member_identifier) { + return Err(EngineError::Validation( + "member_identifier must be included in attempt_context.included_participants" + .to_string(), + )); + } + // Every included participant must be a real DKG member of this + // session. Otherwise a caller could pad the included set with + // phantom identifiers to bias the RFC-21 coordinator/attempt + // derivation, and Round2 could release a share under an attempt + // context that is not a genuine DKG subset. Checked against the public + // key package (the full participant set) so it holds for a distributed + // DKG node, which caches only its own secret key package. + for participant in &canonical_included_participants { + let participant_frost_identifier = + participant_identifier_to_frost_identifier(*participant)?; + if !dkg_public_key_package + .verifying_shares() + .contains_key(&participant_frost_identifier) + { + return Err(EngineError::Validation(format!( + "attempt_context.included_participants contains [{participant}], \ + which is not a DKG participant for this session" + ))); + } + } + (key_package, canonical_included_participants) + }; + + // Disposition over the (now-confirmed) existing session: consumed + // marker, idempotent/conflicting reopen of this exact attempt, and + // the live attempt (id + number) for the replacement decision. + let member_identifier = request.member_identifier; + let (already_consumed, matching_attempt_idempotent, live_attempt) = + match guard.sessions.get(&request.session_id) { + Some(session) => { + // Per-member consumed check: this member's composite marker, or a legacy + // bare attempt_id marker (fail-closed for the whole attempt). + let already_consumed = interactive_attempt_consumed( + &session.consumed_interactive_attempt_markers, + &attempt_id, + member_identifier, + ); + // Disposition is scoped to THIS member's live entry; sibling seats are + // independent and on their own attempt timelines. + let live = session.interactive_signing.get(&member_identifier); + let matching_attempt_idempotent = live + .filter(|interactive| interactive.attempt_context.attempt_id == attempt_id) + .map(|interactive| interactive.open_request_fingerprint == request_fingerprint); + let live_attempt = live.map(|interactive| { + ( + interactive.attempt_context.attempt_id.clone(), + interactive.attempt_context.attempt_number, + ) + }); + (already_consumed, matching_attempt_idempotent, live_attempt) + } + // A fresh per-signing session (a distinct RoastSessionID that is not the + // wallet's DKG session) does not exist yet: no consumed markers, no live + // attempt. It is created at the install below. + None => (false, None, None), + }; + + if already_consumed { + return Err(EngineError::ConsumedNonceReplay { + session_id: request.session_id.clone(), + attempt_id, + }); + } + + match matching_attempt_idempotent { + Some(true) => { + let interactive = guard + .sessions + .get_mut(&request.session_id) + .expect("idempotent Open session exists") + .interactive_signing + .get_mut(&member_identifier) + .expect("idempotent Open member exists"); + interactive.last_activity_at = interactive_now(); + return Ok(InteractiveSessionOpenResult { + session_id: request.session_id, + attempt_id, + idempotent: true, + }); + } + Some(false) => { + return Err(EngineError::SessionConflict { + session_id: request.session_id.clone(), + }); + } + None => {} + } + + // A DIFFERENT live attempt FOR THIS MEMBER is replaced ONLY by a strictly + // newer attempt: this seat's retry loop advanced. A stale/delayed open for an + // older or equal attempt must not roll this member back and wipe its newer + // nonces. A sibling seat on a different (even newer) attempt is irrelevant - + // seats advance independently, exactly as separate processes would. + let replacing = live_attempt.is_some(); + if let Some((live_attempt_id, live_attempt_number)) = live_attempt { + // By construction the live attempt here is a DIFFERENT attempt: + // a live attempt with the same attempt_id would have been + // resolved above as idempotent (Some(true)) or conflicting + // (Some(false)) and returned. Assert that invariant instead of + // re-testing it at runtime - the assert stays so that a future + // change to the matching_attempt_idempotent logic which let an + // equal attempt_id reach here trips loudly, rather than silently + // rolling back a live attempt's nonces. + debug_assert_ne!( + live_attempt_id, attempt_id, + "a live attempt with a matching attempt_id must have been resolved above" + ); + if request.attempt_context.attempt_number <= live_attempt_number { + return Err(EngineError::Validation(format!( + "attempt_number [{}] does not advance member [{}]'s live interactive attempt [{}]; \ + refusing to roll back to an older or equal attempt", + request.attempt_context.attempt_number, member_identifier, live_attempt_number + ))); + } + } + + // Capacity counts every live interactive session. When replacing, + // this session already holds one of those slots, so the cap does + // not apply; when not replacing, a new slot is being taken. + // Capacity bounds resident nonce/key exposure, so it counts every live member + // ENTRY across all sessions. Replacing this member's own entry takes no new + // slot; a new member (even in an existing session) takes one. + if !replacing { + let live_interactive_members: usize = guard + .sessions + .values() + .map(|session| session.interactive_signing.len()) + .sum(); + if live_interactive_members >= max_live_interactive_sessions_limit() { + return Err(EngineError::Internal(format!( + "live interactive member count [{live_interactive_members}] reached max [{}]; \ + abort idle sessions or increase {}", + max_live_interactive_sessions_limit(), + TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV + ))); + } + } + + // A per-signing session is keyed by RoastSessionID (message/root/start-block), NOT + // by key_group, so two DIFFERENT wallets signing the same digest at the same block + // on a node that holds members of both could collide on one session id. A session + // belongs to exactly ONE wallet key for its lifetime: reject an Open whose key_group + // differs from the session's ESTABLISHED one - its DKG key group when it is a + // co-located DKG session, else the key group bound by a prior Open. Rejecting + // regardless of live members keeps bound_key_group and dkg_result mutually + // consistent so Round2/Aggregate/verify_share always resolve the right wallet. This + // closes both (a) the rebind window that outlived a member's Round2 (the live-entry + // set is empty in the consumed-but-unaggregated gap) and (b) binding through another + // wallet's idle DKG session (where dkg_result would otherwise win over bound_key_group + // and sign B's share against A's material, bypassing B's rekey/finalization gates). + if let Some(existing) = guard.sessions.get(&request.session_id) { + let established = existing + .dkg_result + .as_ref() + .map(|dkg| dkg.key_group.as_str()) + .or(existing.bound_key_group.as_deref()); + if let Some(established) = established { + if established != request.key_group { + return Err(EngineError::SessionConflict { + session_id: request.session_id.clone(), + }); + } + } + } + + // Admission/reactivation is fallible at active capacity, or when every + // retired slot at the shared total bound is protected. Preflight it before + // charging the wallet's heartbeat budget; the engine lock prevents the + // registry from changing before the actual install below. + ensure_interactive_session_admission_capacity(&guard, &request.session_id)?; + + // A BuildTaprootTx-backed signing shell is persisted before its first Open. + // Make the first wallet binding durable before reporting Open success, or a + // crash would reload that old unbound shell as an active non-retirable entry. + // A co-located DKG session already has a durable wallet role and needs no + // additional write here; a previously bound per-message session likewise + // reuses its durable identity. + let persists_new_per_message_binding = match guard.sessions.get(&request.session_id) { + Some(session) => session.dkg_result.is_none() && session.bound_key_group.is_none(), + None => true, + }; + let resolved_binding_state_key = if persists_new_per_message_binding { + Some(state_encryption_key_material()?) + } else { + None + }; + + // A typed heartbeat never passes through BuildTaprootTx, so charge its own + // per-wallet policy budget only after all validation and the exact-retry + // return above. If the new binding cannot be persisted before replacement, + // the limiter snapshot is restored along with the session registry, so a + // durability failure cannot burn the caller's only legitimate token. + let is_heartbeat = matches!( + request.signing_intent.as_ref(), + Some(InteractiveSigningIntent::Heartbeat { .. }) + ); + let previous_heartbeat_rate_limiter = if is_heartbeat { + let wallet_session = guard.sessions.get_mut(&wallet_session_id).ok_or_else(|| { + EngineError::SessionNotFound { + session_id: wallet_session_id.clone(), + } + })?; + let previous = wallet_session.heartbeat_rate_limiter.clone(); + enforce_heartbeat_rate_limit( + &request.session_id, + &mut wallet_session.heartbeat_rate_limiter, + )?; + Some(previous) + } else { + None + }; + + // Create (or reactivate) the per-signing session only after every other + // fallible gate. A rejected heartbeat must not pull an idle tombstone back + // into the active budget. DKG material remains solely in the wallet session. + let session_existed = guard.sessions.contains_key(&request.session_id); + let (previous_bound_key_group, previous_retired_at) = guard + .sessions + .get(&request.session_id) + .map(|session| { + ( + session.bound_key_group.clone(), + session.retired_interactive_at_unix, + ) + }) + .unwrap_or((None, None)); + reactivate_retired_per_message_session(&mut guard, &request.session_id)?; + let compacted_retired_sessions = + ensure_session_insert_capacity(&mut guard, &request.session_id)?; + + { + let session = guard + .sessions + .entry(request.session_id.clone()) + .or_insert_with(SessionState::default); + // Bind this signing session to the wallet key it signs for, so Round2 and + // Aggregate resolve the same wallet material by key_group. + session.bound_key_group = Some(request.key_group.clone()); + } + + if let Some(resolved_state_key) = resolved_binding_state_key.as_ref() { + if let Err(persist_error) = + persist_engine_state_to_storage_with_key(&guard, resolved_state_key) + { + let state_file_replaced = persist_error.state_file_replaced(); + let persist_error = persist_error.into_engine_error(); + + if let Some(previous) = previous_heartbeat_rate_limiter { + guard + .sessions + .get_mut(&wallet_session_id) + .expect("wallet session existed while rolling back Open") + .heartbeat_rate_limiter = previous; + } + + if state_file_replaced { + let session = guard + .sessions + .get_mut(&request.session_id) + .expect("Open session existed after state-file replacement"); + if session.interactive_signing.is_empty() + && per_message_interactive_session(session) + { + session.retired_interactive_at_unix = Some(now_unix().max(1)); + } + mark_persistence_pending(PersistencePendingOperation::InteractiveState { + session_id: request.session_id.clone(), + }); + } else { + if session_existed { + let session = guard + .sessions + .get_mut(&request.session_id) + .expect("Open session existed while rolling back binding"); + session.bound_key_group = previous_bound_key_group; + session.retired_interactive_at_unix = previous_retired_at; + } else { + guard.sessions.remove(&request.session_id); + } + restore_compacted_retired_sessions(&mut guard, compacted_retired_sessions); + } + return Err(persist_error); + } + } + + let session = guard + .sessions + .get_mut(&request.session_id) + .expect("Open session existed after binding persistence"); + // Replace only THIS member's prior entry (zeroizing its old nonces); sibling + // seats' entries are untouched. + if let Some(mut replaced) = session.interactive_signing.remove(&member_identifier) { + zeroize_interactive_round1(&mut replaced); + } + + session.interactive_signing.insert( + member_identifier, + InteractiveSigningState { + open_request_fingerprint: request_fingerprint, + attempt_context: request.attempt_context, + canonical_included_participants, + member_identifier, + threshold: request.threshold, + message_bytes: Zeroizing::new(message_bytes), + taproot_merkle_root, + signing_intent: request.signing_intent, + key_package, + last_activity_at: interactive_now(), + round1: None, + }, + ); + + record_hardening_telemetry(|telemetry| { + telemetry.interactive_session_open_success_total = telemetry + .interactive_session_open_success_total + .saturating_add(1); + }); + + Ok(InteractiveSessionOpenResult { + session_id: request.session_id, + attempt_id, + idempotent: false, + }) +} + +pub fn interactive_round1( + request: InteractiveRound1Request, +) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.interactive_round1_calls_total = + telemetry.interactive_round1_calls_total.saturating_add(1); + }); + let mut latency_guard = + HardeningOperationLatencyGuard::success_only(HardeningOperation::InteractiveRound1); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + // The live state and markers are keyed on the canonical (lowercase) + // attempt_id; the wire form may differ in casing. + let attempt_id = canonical_attempt_id(&request.attempt_id); + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + sweep_expired_interactive_state_durably(&mut guard)?; + + let session = guard.sessions.get_mut(&request.session_id).ok_or_else(|| { + EngineError::SessionNotFound { + session_id: request.session_id.clone(), + } + })?; + + if interactive_attempt_consumed( + &session.consumed_interactive_attempt_markers, + &attempt_id, + request.member_identifier, + ) { + return Err(EngineError::ConsumedNonceReplay { + session_id: request.session_id.clone(), + attempt_id, + }); + } + + let interactive = interactive_state_for_attempt_mut( + session, + &request.session_id, + &attempt_id, + request.member_identifier, + )?; + + if let Some(commitments_hex) = interactive + .round1 + .as_ref() + .map(|round1| round1.commitments_hex.clone()) + { + // Idempotent until consumed: the commitments are public and + // re-sending them is safe; the nonces never leave. + interactive.last_activity_at = interactive_now(); + return Ok(InteractiveRound1Result { commitments_hex }); + } + + let mut rng = zeroizing_rng_from_os(); + let (nonces, commitments) = + frost::round1::commit(interactive.key_package.signing_share(), &mut rng); + let commitment_bytes = commitments.serialize().map_err(|e| { + EngineError::Internal(format!("failed to serialize signing commitments: {e}")) + })?; + let commitments_hex = hex::encode(commitment_bytes); + + interactive.round1 = Some(InteractiveRound1State { + nonces, + commitments_hex: commitments_hex.clone(), + }); + interactive.last_activity_at = interactive_now(); + + latency_guard.mark_success(); + record_hardening_telemetry(|telemetry| { + telemetry.interactive_round1_success_total = + telemetry.interactive_round1_success_total.saturating_add(1); + }); + + Ok(InteractiveRound1Result { commitments_hex }) +} + +pub fn interactive_round2( + request: InteractiveRound2Request, +) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.interactive_round2_calls_total = + telemetry.interactive_round2_calls_total.saturating_add(1); + }); + let mut latency_guard = + HardeningOperationLatencyGuard::success_only(HardeningOperation::InteractiveRound2); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + let mut signing_package_bytes = decode_hex_field( + "InteractiveRound2", + "signing_package_hex", + &request.signing_package_hex, + )?; + let signing_package_result = frost::SigningPackage::deserialize(&signing_package_bytes); + signing_package_bytes.zeroize(); + let signing_package = signing_package_result.map_err(|e| { + EngineError::Validation(format!("InteractiveRound2: invalid signing package: {e}")) + })?; + + // The live state and markers are keyed on the canonical (lowercase) + // attempt_id; the wire form may differ in casing. + let attempt_id = canonical_attempt_id(&request.attempt_id); + let consumed_marker = interactive_consumed_marker(&attempt_id, request.member_identifier); + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + sweep_expired_interactive_state_durably(&mut guard)?; + + // An earlier marker write may have replaced the state file but failed its + // directory sync. Flush that fail-closed marker before consulting the replay + // gate; after a successful write, the marker below rejects the retry. + if interactive_round2_persistence_pending(&request.session_id, &consumed_marker) { + persist_engine_state_to_storage(&guard) + .map_err(PersistEngineStateError::into_engine_error)?; + } + + // Quarantine inputs must be read before the session is borrowed + // mutably from the same guard below. + let auto_quarantine_config = load_auto_quarantine_config()?; + let quarantined_operator_identifiers = guard.quarantined_operator_identifiers.clone(); + + // Finalization is a WALLET-level gate resolved from the DKG session by key_group. + // Emergency rekey is normally wallet-level too, but an event triggered after + // BuildTaprootTx and before Open binds the signing session must still be read from + // that signing session. The policy-checked transaction is likewise per-signing-flow + // state. Clone both signing-session values before the mutable borrow below. + let (signing_tx_result, signing_emergency_rekey) = guard + .sessions + .get(&request.session_id) + .map(|session| { + ( + session.tx_result.clone(), + session.emergency_rekey_event.clone(), + ) + }) + .unwrap_or((None, None)); + let (wallet_emergency_rekey, wallet_finalized) = { + let bound_key_group = guard.sessions.get(&request.session_id).and_then(|session| { + session + .dkg_result + .as_ref() + .map(|dkg| dkg.key_group.clone()) + .or_else(|| session.bound_key_group.clone()) + }); + match bound_key_group + .and_then(|key_group| { + resolve_wallet_session_id(&guard, &request.session_id, &key_group) + }) + .and_then(|wallet_session_id| guard.sessions.get(&wallet_session_id)) + { + Some(wallet) => ( + wallet.emergency_rekey_event.clone(), + wallet.finalize_request_fingerprint.is_some(), + ), + None => (None, false), + } + }; + + let session = guard.sessions.get_mut(&request.session_id).ok_or_else(|| { + EngineError::SessionNotFound { + session_id: request.session_id.clone(), + } + })?; + + if interactive_attempt_consumed( + &session.consumed_interactive_attempt_markers, + &attempt_id, + request.member_identifier, + ) { + return Err(EngineError::ConsumedNonceReplay { + session_id: request.session_id.clone(), + attempt_id, + }); + } + + // A completed attempt releases no further shares. Once interactive_aggregate has + // produced the attempt's signature, an open sibling seat that never signed has NO + // per-member consumed marker, so the consumed gate above does not cover it. Gate + // on the completion marker too - but matched to the MESSAGE this member opened + // (the marker binds attempt_id to the aggregated message digest), so a replayed + // aggregate carrying a different message for this attempt id cannot preempt this + // member's live Round2. It fires only for a genuine same-message completion; the + // member's entry for that finalized attempt is then dead, so free it (zeroizing + // its nonces) rather than holding a live-member slot until the TTL sweep. + let member_attempt_finalization = session + .interactive_signing + .get(&request.member_identifier) + .filter(|entry| entry.attempt_context.attempt_id == attempt_id) + .map(|entry| (hash_hex(&entry.message_bytes), entry.taproot_merkle_root)); + let attempt_finalized = + member_attempt_finalization + .as_ref() + .is_some_and(|(digest, taproot_merkle_root)| { + interactive_attempt_aggregated( + &session.aggregated_interactive_attempt_markers, + &attempt_id, + digest, + taproot_merkle_root.as_ref(), + ) + }); + if attempt_finalized { + if let Some(mut removed) = session + .interactive_signing + .remove(&request.member_identifier) + { + zeroize_interactive_round1(&mut removed); + } + return Err(EngineError::InteractiveAttemptAlreadyAggregated { + session_id: request.session_id.clone(), + attempt_id, + }); + } + + // Per-member consumed marker (composite): independent seats consume their own + // nonces for the same attempt without colliding. + ensure_consumed_registry_insert_capacity( + &session.consumed_interactive_attempt_markers, + &consumed_marker, + "consumed_interactive_attempt_markers", + &request.session_id, + )?; + + // Re-evaluate the signing gates at the share-release moment. The + // gates checked at Open are stale here: a kill switch recorded + // after Open (emergency rekey, finalization, quarantine, or a + // re-bound policy-checked tx) must stop the share leaving the + // engine. Read via immutable borrows of the live attempt before the + // mutable consume/sign borrow below. Skipped when no matching live + // attempt exists - there is no share to release in that case, and + // interactive_state_for_attempt_mut produces the canonical error. + if let Some(interactive) = session + .interactive_signing + .get(&request.member_identifier) + .filter(|interactive| interactive.attempt_context.attempt_id == attempt_id) + { + let bound_message_hex = hex::encode(interactive.message_bytes.as_slice()); + // Fast-path lifecycle/firewall and this node's own quarantine. + // The full chosen signing subset is quarantine-checked after the + // package is verified (below), once it is known to be a real + // subset of the included set. + enforce_interactive_signing_gates( + &request.session_id, + &[request.member_identifier], + &bound_message_hex, + signing_emergency_rekey.as_ref(), + wallet_emergency_rekey.as_ref(), + wallet_finalized, + signing_tx_result.as_ref(), + interactive.taproot_merkle_root.as_ref(), + interactive.signing_intent.as_ref(), + &quarantined_operator_identifiers, + auto_quarantine_config.as_ref(), + )?; + } + + let interactive = interactive_state_for_attempt_mut( + session, + &request.session_id, + &attempt_id, + request.member_identifier, + )?; + + if interactive.round1.is_none() { + return Err(EngineError::SignRoundNotStarted { + session_id: request.session_id.clone(), + }); + } + + // ALL verification precedes consumption (frozen spec section 5, + // Round2): a package that fails any check leaves the nonce handle + // live, so an invalid package cannot burn the attempt. At most one + // share per handle still holds against two VALID packages because + // the consumption marker is written before the share is released. + verify_round2_signing_package(interactive, &signing_package)?; + + // The package is now confirmed to be a threshold-sized subset of the + // attempt's included set, so the chosen signing subset is known. + // Quarantine-check ALL of it before releasing a share: this node + // must not contribute to a signature whose subset includes a + // locally quarantined co-signer, matching the coarse path's + // all-signing-participants quarantine enforcement. + let signing_subset = round2_signing_subset(interactive, &signing_package)?; + enforce_not_quarantined_identifiers( + &request.session_id, + &signing_subset, + &quarantined_operator_identifiers, + auto_quarantine_config.as_ref(), + )?; + let aggregate_authorization_marker = interactive_aggregate_authorization_marker( + &attempt_id, + &signing_package, + interactive.taproot_merkle_root.as_ref(), + )?; + ensure_consumed_registry_insert_capacity( + &session.authorized_interactive_aggregate_markers, + &aggregate_authorization_marker, + "authorized_interactive_aggregate_markers", + &request.session_id, + )?; + + // Consumption-before-release: the durable marker is persisted BEFORE the + // share is computed and returned. A failure before state-file replacement + // rolls the marker back and leaves the nonces live. A failure after replacement + // keeps the marker fail-closed, destroys the nonces, and records a pending + // retry that must re-persist before the replay gate runs. No failure path + // releases a share. If share computation itself later fails, the already- + // durable marker likewise stays and the nonces are destroyed. + // Resolve the state-encryption key under the held ENGINE_STATE guard, in the + // same serialized order as the write, and BEFORE inserting the marker. + // Resolving under the guard makes key selection match the write order, so the + // last writer encrypts with the then-current key; a key resolved before the + // lock could be stale and lose a rotation race, leaving the persisted envelope + // tagged with an old key id that decode rejects on restart. Resolving before + // the marker also keeps a key-provider outage failing the attempt cleanly (no + // marker written) rather than escaping the rollback below via `?`. + let resolved_state_key = match state_encryption_key_material() { + Ok(key) => key, + Err(error) => { + // Key-provider commands can run long enough to cross the TTL. The + // validated request still leaves this nonce handle retryable, so + // measure its inactivity from failure completion, not command start. + session + .interactive_signing + .get_mut(&request.member_identifier) + .expect("validated Round2 interactive state remains retryable") + .last_activity_at = interactive_now(); + return Err(error); + } + }; + session + .consumed_interactive_attempt_markers + .insert(consumed_marker.clone()); + session + .authorized_interactive_aggregate_markers + .insert(aggregate_authorization_marker.clone()); + let retires_session = + session.interactive_signing.len() == 1 && per_message_interactive_session(session); + let compacted_retired_sessions = if retires_session { + session.retired_interactive_at_unix = Some(now_unix().max(1)); + compact_retired_per_message_sessions(&mut guard, Some(&request.session_id)) + } else { + Vec::new() + }; + if let Err(persist_error) = + persist_engine_state_to_storage_with_key(&guard, &resolved_state_key) + { + let state_file_replaced = persist_error.state_file_replaced(); + let persist_error = persist_error.into_engine_error(); + let session = guard + .sessions + .get_mut(&request.session_id) + .expect("session existed under the held engine lock"); + if state_file_replaced { + mark_persistence_pending(PersistencePendingOperation::InteractiveRound2 { + session_id: request.session_id.clone(), + consumed_marker: consumed_marker.clone(), + }); + if let Some(mut removed) = session + .interactive_signing + .remove(&request.member_identifier) + { + zeroize_interactive_round1(&mut removed); + } + } else { + session + .consumed_interactive_attempt_markers + .remove(&consumed_marker); + session + .authorized_interactive_aggregate_markers + .remove(&aggregate_authorization_marker); + if retires_session { + session.retired_interactive_at_unix = None; + } + // A pre-replacement failure deliberately leaves the nonce handle + // retryable. Persistence may itself be slow, so restart inactivity + // at failure completion before releasing the engine lock. + session + .interactive_signing + .get_mut(&request.member_identifier) + .expect("pre-replacement Round2 failure leaves interactive state") + .last_activity_at = interactive_now(); + restore_compacted_retired_sessions(&mut guard, compacted_retired_sessions); + } + return Err(persist_error); + } + + let session = guard + .sessions + .get_mut(&request.session_id) + .expect("session existed under the held engine lock"); + let interactive = session + .interactive_signing + .get_mut(&request.member_identifier) + .expect("interactive state existed under the held engine lock"); + + let mut round1 = interactive + .round1 + .take() + .expect("round1 state existed under the held engine lock"); + + let signature_share_result = + if let Some(taproot_merkle_root) = interactive.taproot_merkle_root.as_ref() { + frost::round2::sign_with_tweak( + &signing_package, + &round1.nonces, + &interactive.key_package, + Some(taproot_merkle_root.as_slice()), + ) + } else { + frost::round2::sign(&signing_package, &round1.nonces, &interactive.key_package) + }; + round1.nonces.zeroize(); + drop(round1); + + // Round2 is terminal for THIS member's participation in the attempt: the + // marker is durable and the nonces are gone, so free this member's entry now + // rather than letting it (and its resident key package + message) linger until + // the TTL sweep. This also returns its capacity slot immediately; sibling seats + // stay live. Done on both the success and share-computation-failure paths: the + // attempt is consumed for this member either way, and the durable marker + // carries all further replay protection. + session + .interactive_signing + .remove(&request.member_identifier); + + 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(); + + latency_guard.mark_success(); + record_hardening_telemetry(|telemetry| { + telemetry.interactive_round2_success_total = + telemetry.interactive_round2_success_total.saturating_add(1); + }); + + Ok(InteractiveRound2Result { + session_id: request.session_id, + attempt_id, + signature_share_hex, + }) +} + +pub fn interactive_aggregate( + request: InteractiveAggregateRequest, +) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.interactive_aggregate_calls_total = telemetry + .interactive_aggregate_calls_total + .saturating_add(1); + }); + let mut latency_guard = + HardeningOperationLatencyGuard::success_only(HardeningOperation::InteractiveAggregate); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + let attempt_id = canonical_aggregate_attempt_id(&request.attempt_id)?; + + let mut signing_package_bytes = decode_hex_field( + "InteractiveAggregate", + "signing_package_hex", + &request.signing_package_hex, + )?; + let signing_package_result = frost::SigningPackage::deserialize(&signing_package_bytes); + signing_package_bytes.zeroize(); + let signing_package = signing_package_result.map_err(|e| { + EngineError::Validation(format!( + "InteractiveAggregate: invalid signing package: {e}" + )) + })?; + let signature_shares = + decode_signature_share_map("InteractiveAggregate", &request.signature_shares)?; + let mut taproot_merkle_root_hex = request.taproot_merkle_root_hex.clone(); + let taproot_merkle_root = canonicalize_taproot_merkle_root_hex(&mut taproot_merkle_root_hex)?; + // The completion marker binds attempt_id to THIS aggregated message digest AND the + // canonical taproot root, so a valid aggregate over a different message or root + // cannot finalize this attempt id (and so cannot, via the Round2 completion gate, + // preempt an unrelated live attempt or root - the signature differs per tweak). + let aggregated_message_digest = hash_hex(signing_package.message().as_slice()); + let aggregated_marker = interactive_aggregated_marker( + &attempt_id, + &aggregated_message_digest, + taproot_merkle_root.as_ref(), + ); + let aggregate_authorization_marker = interactive_aggregate_authorization_marker( + &attempt_id, + &signing_package, + taproot_merkle_root.as_ref(), + )?; + let aggregate_package_completion_marker = interactive_aggregate_package_completion_marker( + &signing_package, + taproot_merkle_root.as_ref(), + )?; + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + // Sweep first so a failure while repairing any prior completion marker can + // never postpone destruction of newly expired nonce handles. + sweep_expired_interactive_state_durably(&mut guard)?; + // A prior completion-marker write may have replaced the state file but failed + // its directory sync. Re-persist that fail-closed marker before the completed + // attempt check so a retry repairs durability and is then rejected normally. + if interactive_aggregate_persistence_pending(&request.session_id, &aggregated_marker) { + persist_engine_state_to_storage(&guard) + .map_err(PersistEngineStateError::into_engine_error)?; + } + // Resolve the group's public key package (the verifying shares used + // to check each contribution) from the session's own DKG state, not + // the request - consistent with the no-secret-on-the-FFI discipline + // and so a caller cannot substitute verifying material. The session + // must exist with completed DKG. + let (public_key_package, aggregate_eviction_pin) = { + // The completion marker is per-signing-session state; read it - and the wallet + // key_group this session serves - from request.session_id. + let (key_group, aggregate_eviction_pin) = { + let session = guard.sessions.get(&request.session_id).ok_or_else(|| { + EngineError::SessionNotFound { + session_id: request.session_id.clone(), + } + })?; + // Reject a completed attempt: re-aggregation is not a recovery path (a + // lost signature is recovered with a fresh attempt), and the marker is + // durable so a completed attempt stays rejected across a restart. + if interactive_attempt_aggregated( + &session.aggregated_interactive_attempt_markers, + &attempt_id, + &aggregated_message_digest, + taproot_merkle_root.as_ref(), + ) { + return Err(EngineError::InteractiveAttemptAlreadyAggregated { + session_id: request.session_id.clone(), + attempt_id, + }); + } + let authorized = session + .authorized_interactive_aggregate_markers + .contains(&aggregate_authorization_marker) + || interactive_aggregate_has_live_authorization( + session, + &attempt_id, + &signing_package, + taproot_merkle_root.as_ref(), + )?; + if !authorized { + return Err(EngineError::Validation(format!( + "InteractiveAggregate: package is not authorized for attempt_id [{attempt_id}] in session [{}]", + request.session_id + ))); + } + if session + .authorized_interactive_aggregate_markers + .contains(&aggregate_package_completion_marker) + { + return Err(EngineError::Validation(format!( + "InteractiveAggregate: signing package was already aggregated in session [{}]", + request.session_id + ))); + } + // The wallet key this signing session serves: its own DKG (co-located) or + // the key_group bound at Open (distinct per-signing RoastSessionID). + let key_group = session + .dkg_result + .as_ref() + .map(|dkg| dkg.key_group.clone()) + .or_else(|| session.bound_key_group.clone()) + .ok_or_else(|| EngineError::DkgNotReady { + session_id: request.session_id.clone(), + })?; + (key_group, Arc::clone(&session.aggregate_eviction_pin)) + }; + // The group's public key package (the verifying shares used to check each + // contribution) is a WALLET-level asset resolved by key_group, so a per-signing + // session can verify shares. Read from the engine's own DKG state, not the + // request, so a caller cannot substitute verifying material. + let wallet_session_id = resolve_wallet_session_id(&guard, &request.session_id, &key_group) + .ok_or_else(|| EngineError::DkgNotReady { + session_id: request.session_id.clone(), + })?; + let session = + guard + .sessions + .get(&wallet_session_id) + .ok_or_else(|| EngineError::SessionNotFound { + session_id: wallet_session_id.clone(), + })?; + let public_key_package = session + .dkg_public_key_package + .as_ref() + .ok_or_else(|| { + EngineError::Internal("missing DKG public key package cache".to_string()) + })? + .clone(); + (public_key_package, aggregate_eviction_pin) + }; + drop(guard); + #[cfg(test)] + maybe_hold_interactive_aggregate_after_unlock_for_tests(); + + // Aggregation uses only public material (commitments, shares, + // verifying shares), so no policy gate runs here - the secret-bearing + // step is each signer's Round2, where lifecycle/quarantine/firewall + // were already enforced (including the full-subset quarantine check). + // + // frost verifies every share and names which failed. This path now surfaces + // those as CANDIDATE culprits (Phase 7.2b-3): the engine reports the members + // whose shares did not verify against the group's own verifying material, + // but it does NOT adjudicate fault. The engine cannot bind these public + // inputs (signing package, taproot root) to what each member signed at + // Round2, so a coordinator that aggregated honest shares against a + // substituted package/root would make those honest shares fail and appear + // here. Authoritative, envelope-bound blame is the Go host's job at an f+1 + // accuser quorum (frozen Phase 7.2b spec, section 6), using the signed + // signing-package envelopes; this candidate list is its input. Fail-closed + // either way: no signature leaves on a verification failure. + let verification_key_package = match taproot_merkle_root.as_ref() { + Some(root) => public_key_package.clone().tweak(Some(root.as_slice())), + None => public_key_package.clone(), + }; + + // Aggregate with AllCheaters detection. The frost-secp256k1-tr + // aggregate/aggregate_with_tweak wrappers hardcode FirstCheater, so a + // failure would name only one member; AllCheaters names EVERY member whose + // share failed. verification_key_package is the (taproot-tweaked, when a + // root is set) public key package - exactly what aggregate_with_tweak + // derives internally - so this is equivalent to those wrappers on the + // success path. Cheater detection only runs after the aggregate signature + // itself fails to verify, so there is no happy-path cost. + let signature = match frost_core::aggregate_custom( + &signing_package, + &signature_shares, + &verification_key_package, + frost_core::CheaterDetection::AllCheaters, + ) { + Ok(signature) => signature, + Err(error) => { + let candidate_culprits = aggregate_candidate_culprits(&error); + if candidate_culprits.is_empty() { + // Not a per-member share attribution (malformed package, wrong + // share count, group/field error): fail closed with the generic + // validation error, no blame. + return Err(EngineError::Validation(format!( + "InteractiveAggregate: failed to aggregate: {error}" + ))); + } + return Err(EngineError::AggregateShareVerificationFailed { + session_id: request.session_id.clone(), + attempt_id, + candidate_culprits, + }); + } + }; + + // Self-verify the aggregate against the (tweaked) group verifying + // key before releasing it, matching the coarse finalize path. + verification_key_package + .verifying_key() + .verify(signing_package.message().as_slice(), &signature) + .map_err(|e| { + EngineError::Validation(format!( + "InteractiveAggregate: aggregate signature failed self-verification: {e}" + )) + })?; + + let signature_bytes = signature + .serialize() + .map_err(|e| EngineError::Internal(format!("failed to serialize aggregate: {e}")))?; + let signature_hex = hex::encode(signature_bytes); + + // Mark the attempt complete before reporting success, so a repeat + // InteractiveAggregate is rejected rather than recomputed (Phase 7.2b design + // section 6). The engine lock was dropped for the aggregation crypto above; + // re-acquire it, re-check the marker (a concurrent aggregate may have + // completed first), insert it, and persist before reporting success. A + // pre-replacement failure rolls the marker back; a post-replacement failure + // retains it, destroys matching live nonces, and records a pending retry that + // is flushed before the next completion-marker check. + 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(), + } + })?; + // A concurrent aggregate that raced past the pre-check may have completed + // this attempt first; if the marker is now present, reject this call's + // re-aggregation - the winner already produced the attempt's signature. + if interactive_attempt_aggregated( + &session.aggregated_interactive_attempt_markers, + &attempt_id, + &aggregated_message_digest, + taproot_merkle_root.as_ref(), + ) { + return Err(EngineError::InteractiveAttemptAlreadyAggregated { + session_id: request.session_id.clone(), + attempt_id, + }); + } + let authorized = session + .authorized_interactive_aggregate_markers + .contains(&aggregate_authorization_marker) + || interactive_aggregate_has_live_authorization( + session, + &attempt_id, + &signing_package, + taproot_merkle_root.as_ref(), + )?; + if !authorized { + return Err(EngineError::Validation(format!( + "InteractiveAggregate: package authorization changed before completion for attempt_id [{attempt_id}] in session [{}]", + request.session_id + ))); + } + if session + .authorized_interactive_aggregate_markers + .contains(&aggregate_package_completion_marker) + { + return Err(EngineError::Validation(format!( + "InteractiveAggregate: signing package was already aggregated in session [{}]", + request.session_id + ))); + } + ensure_consumed_registry_insert_capacity( + &session.aggregated_interactive_attempt_markers, + &aggregated_marker, + "aggregated_interactive_attempt_markers", + &request.session_id, + )?; + // A Round2 authorization is no longer needed once its exact package has + // completed, so replace it in-place with the package replay marker. A + // coordinator omitted from the signing subset has no Round2 authorization + // to replace and therefore consumes one normal bounded slot. + let replaces_aggregate_authorization = session + .authorized_interactive_aggregate_markers + .contains(&aggregate_authorization_marker); + if !replaces_aggregate_authorization { + ensure_consumed_registry_insert_capacity( + &session.authorized_interactive_aggregate_markers, + &aggregate_package_completion_marker, + "authorized_interactive_aggregate_markers", + &request.session_id, + )?; + } + // Resolve the state-encryption key under the held ENGINE_STATE guard, in the + // same serialized order as the write, and BEFORE inserting the marker. + // Resolving under the guard makes key selection match the write order, so the + // last writer encrypts with the then-current key; a key resolved before the + // lock could be stale and lose a rotation race, leaving the persisted envelope + // tagged with an old key id that decode rejects on restart. Resolving before + // the marker also keeps a key-provider outage failing the attempt cleanly (no + // marker written) rather than escaping the rollback below via `?`. + let resolved_state_key = state_encryption_key_material()?; + session + .aggregated_interactive_attempt_markers + .insert(aggregated_marker.clone()); + let removed_aggregate_authorization = session + .authorized_interactive_aggregate_markers + .remove(&aggregate_authorization_marker); + session + .authorized_interactive_aggregate_markers + .insert(aggregate_package_completion_marker.clone()); + if let Err(persist_error) = + persist_engine_state_to_storage_with_key(&guard, &resolved_state_key) + { + let state_file_replaced = persist_error.state_file_replaced(); + let persist_error = persist_error.into_engine_error(); + let session = guard + .sessions + .get_mut(&request.session_id) + .expect("session existed under the held engine lock"); + if state_file_replaced { + remove_finalized_interactive_members( + session, + &attempt_id, + &aggregated_message_digest, + taproot_merkle_root.as_ref(), + ); + // Stage retirement BEFORE registering the pending operation. Generic + // retirement deliberately skips pending sessions to protect uncertain + // markers from eviction; registering first would therefore strand this + // now-idle shell as active. With retirement staged first, the pending + // operation protects the tombstone and any later successful full-state + // snapshot (the exact retry or an unrelated writer) durably covers both + // the completion marker and retirement before clearing pending. + if session.interactive_signing.is_empty() && per_message_interactive_session(session) { + session.retired_interactive_at_unix = Some(now_unix().max(1)); + } + mark_persistence_pending(PersistencePendingOperation::InteractiveAggregate { + session_id: request.session_id.clone(), + aggregated_marker: aggregated_marker.clone(), + }); + } else { + session + .aggregated_interactive_attempt_markers + .remove(&aggregated_marker); + session + .authorized_interactive_aggregate_markers + .remove(&aggregate_package_completion_marker); + if removed_aggregate_authorization { + session + .authorized_interactive_aggregate_markers + .insert(aggregate_authorization_marker.clone()); + } + } + if state_file_replaced { + retire_idle_per_message_sessions(&mut guard, Some(&request.session_id)); + } + return Err(persist_error); + } + + // The attempt is now final for (attempt_id, message, root). A LOCAL sibling seat + // that opened/Round1'd this same attempt + root but is NOT in the signing subset + // never calls Round2, so free those entries now - zeroizing their nonces and + // returning their live-member slots - rather than leaving them resident until the + // TTL sweep. The signers' own entries were already removed at their Round2; a + // sibling on a DIFFERENT root is a distinct signing task and is left untouched. + let session = guard + .sessions + .get_mut(&request.session_id) + .expect("session existed under the held engine lock"); + remove_finalized_interactive_members( + session, + &attempt_id, + &aggregated_message_digest, + taproot_merkle_root.as_ref(), + ); + retire_idle_per_message_sessions(&mut guard, Some(&request.session_id)); + drop(guard); + // Keep the target unevictable through both lock sections and the durable + // completion write. Error returns and unwinding release the clone by RAII. + drop(aggregate_eviction_pin); + + latency_guard.mark_success(); + record_hardening_telemetry(|telemetry| { + telemetry.interactive_aggregate_success_total = telemetry + .interactive_aggregate_success_total + .saturating_add(1); + }); + + Ok(InteractiveAggregateResult { + session_id: request.session_id, + attempt_id, + signature_hex, + }) +} + +pub fn interactive_session_abort( + request: InteractiveSessionAbortRequest, +) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.interactive_session_abort_calls_total = telemetry + .interactive_session_abort_calls_total + .saturating_add(1); + }); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + // Canonicalize the optional attempt_id filter to match the + // canonical form the live state is keyed on. + let attempt_id_filter = request.attempt_id.as_deref().map(canonical_attempt_id); + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + // Abort takes the lock like every other entry point, so it sweeps + // expired interactive state too: the TTL guarantee (nonces gone + // within the TTL of inactivity) must hold even when the only + // post-expiry traffic is aborts for other sessions. The durable helper also + // repairs a prior post-rename Abort snapshot before an idempotent retry can + // return `aborted: false` without writing. + sweep_expired_interactive_state_durably(&mut guard)?; + + let members_to_abort = match guard.sessions.get(&request.session_id) { + Some(session) => { + // Abort has no member parameter, so it is session-level over the map: + // select every member entry matching the optional attempt filter. + // Keep the nonce-bearing entries live until the durable retirement + // snapshot has replaced the state file, so a pre-replacement failure + // remains cleanly retryable. + session + .interactive_signing + .iter() + .filter(|(_, interactive)| { + attempt_id_filter.is_none() + || attempt_id_filter.as_deref() + == Some(interactive.attempt_context.attempt_id.as_str()) + }) + .map(|(member, _)| *member) + .collect::>() + } + None => Vec::new(), + }; + + if members_to_abort.is_empty() { + return Ok(InteractiveSessionAbortResult { + session_id: request.session_id, + aborted: false, + }); + } + + // Resolve the key before staging retirement. A key-provider outage must not + // consume the live nonces or turn a retryable attempt into an inert shell. + let resolved_state_key = state_encryption_key_material()?; + let (retires_session, previous_retired_at) = { + let session = guard + .sessions + .get_mut(&request.session_id) + .expect("selected abort session existed under the held engine lock"); + let retires_session = members_to_abort.len() == session.interactive_signing.len() + && per_message_interactive_session(session); + let previous_retired_at = session.retired_interactive_at_unix; + if retires_session { + session.retired_interactive_at_unix = Some(now_unix().max(1)); + } + (retires_session, previous_retired_at) + }; + let compacted_retired_sessions = + compact_retired_per_message_sessions(&mut guard, Some(&request.session_id)); + + // Interactive nonce state is intentionally never serialized. Persist while + // it is still held in memory: the snapshot durably carries the Open binding, + // policy artifact, and (for a last-member Abort) retirement timestamp. Only + // after replacement is it safe to destroy the selected nonce handles and + // report success. + if let Err(persist_error) = + persist_engine_state_to_storage_with_key(&guard, &resolved_state_key) + { + let state_file_replaced = persist_error.state_file_replaced(); + let persist_error = persist_error.into_engine_error(); + if state_file_replaced { + let session = guard + .sessions + .get_mut(&request.session_id) + .expect("abort session existed after state-file replacement"); + for member in &members_to_abort { + if let Some(mut removed) = session.interactive_signing.remove(member) { + zeroize_interactive_round1(&mut removed); + } + } + mark_persistence_pending(PersistencePendingOperation::InteractiveState { + session_id: request.session_id.clone(), + }); + } else { + if retires_session { + guard + .sessions + .get_mut(&request.session_id) + .expect("abort session existed while rolling back retirement") + .retired_interactive_at_unix = previous_retired_at; + } + restore_compacted_retired_sessions(&mut guard, compacted_retired_sessions); + } + return Err(persist_error); + } + + let session = guard + .sessions + .get_mut(&request.session_id) + .expect("abort session existed after durable retirement"); + for member in &members_to_abort { + if let Some(mut removed) = session.interactive_signing.remove(member) { + zeroize_interactive_round1(&mut removed); + } + } + + // Only count a success when live interactive state was actually + // aborted. A no-op call (no session, or an attempt_id filter that + // matched nothing) returns aborted == false and must not inflate the + // success counter - the calls_total counter at the top already + // records that the entry point ran. + record_hardening_telemetry(|telemetry| { + telemetry.interactive_session_abort_success_total = telemetry + .interactive_session_abort_success_total + .saturating_add(1); + }); + + Ok(InteractiveSessionAbortResult { + session_id: request.session_id, + aborted: true, + }) +} + +// Looks up the live interactive state and pins the +// (attempt_id, member_identifier) binding every round call must carry. +fn interactive_state_for_attempt_mut<'session>( + session: &'session mut SessionState, + session_id: &str, + attempt_id: &str, + member_identifier: u16, +) -> Result<&'session mut InteractiveSigningState, EngineError> { + let interactive = session + .interactive_signing + .get_mut(&member_identifier) + .ok_or_else(|| EngineError::SessionNotFound { + session_id: format!( + "{session_id} (no live interactive attempt for member {member_identifier})" + ), + })?; + + if interactive.attempt_context.attempt_id != attempt_id { + return Err(EngineError::Validation(format!( + "attempt_id [{attempt_id}] does not match member [{member_identifier}]'s \ + live interactive attempt [{}]", + interactive.attempt_context.attempt_id + ))); + } + + Ok(interactive) +} + +// Checks shared by a signer releasing a share and an elected coordinator +// aggregating a strict first-t package that does not include itself. +fn verify_interactive_signing_package_context( + interactive: &InteractiveSigningState, + signing_package: &frost::SigningPackage, +) -> Result<(), EngineError> { + // (d) part 2 (deserialization already succeeded): the package must + // target exactly the session's message. A package for any other + // message - including the same message with different framing - + // must never reach the nonces. + if signing_package.message().as_slice() != interactive.message_bytes.as_slice() { + return Err(EngineError::Validation( + "signing package message does not match the open interactive session".to_string(), + )); + } + + let package_commitments = signing_package.signing_commitments(); + + // (c) exactly threshold-many participants, deliberately not + // at-least (frozen spec section 5). + if package_commitments.len() != usize::from(interactive.threshold) { + return Err(EngineError::Validation(format!( + "signing package carries [{}] commitments; expected exactly threshold [{}]", + package_commitments.len(), + interactive.threshold + ))); + } + + // (b) the chosen subset must be inside the attempt's included set. + let included_identifiers = interactive + .canonical_included_participants + .iter() + .map(|participant| participant_identifier_to_frost_identifier(*participant)) + .collect::, _>>()?; + for package_identifier in package_commitments.keys() { + if !included_identifiers.contains(package_identifier) { + return Err(EngineError::Validation( + "signing package contains a participant outside the attempt's included set" + .to_string(), + )); + } + } + + Ok(()) +} + +// The frozen spec's Round2 checks (a)-(f). Returns Ok only when every +// check passes; the caller consumes the nonces strictly afterwards. +fn verify_round2_signing_package( + interactive: &InteractiveSigningState, + signing_package: &frost::SigningPackage, +) -> Result<(), EngineError> { + verify_interactive_signing_package_context(interactive, signing_package)?; + let package_commitments = signing_package.signing_commitments(); + + // (a) this member must be in the chosen subset. + let own_identifier = participant_identifier_to_frost_identifier(interactive.member_identifier)?; + let own_package_commitments = package_commitments.get(&own_identifier).ok_or_else(|| { + EngineError::Validation( + "signing package does not include this member's commitment".to_string(), + ) + })?; + + // (f) the member's own commitment entry must be byte-identical to + // its round-1 output. Without this, a malicious coordinator could + // substitute the commitment, make this member's correctly-computed + // share fail verification at aggregation, and manufacture false + // blame evidence against an honest member. + let own_package_commitment_bytes = own_package_commitments.serialize().map_err(|e| { + EngineError::Internal(format!("failed to serialize package commitment: {e}")) + })?; + let round1 = interactive + .round1 + .as_ref() + .expect("caller verified round1 state exists"); + if hex::encode(own_package_commitment_bytes) != round1.commitments_hex { + return Err(EngineError::Validation( + "signing package commitment for this member does not match its round-1 output" + .to_string(), + )); + } + + Ok(()) +} + +// The signing gates the interactive path enforces at BOTH Open and +// the Round2 share-release moment, mirroring the coarse +// start_sign_round: emergency-rekey and finalized lifecycle, quarantine +// of the signing participants, and the signing-policy firewall binding +// of the message to a policy-checked build_taproot_tx. Centralized in +// one function so the two call sites cannot drift apart. +// +// quarantine_identifiers is the set to quarantine-check: at Open only +// this node's own member is known to sign; at Round2 it is the full +// chosen signing subset (the package's participants), so this node +// refuses to contribute a share to a package that includes any +// quarantined co-signer - the same all-participants check the coarse +// path applies. +#[allow(clippy::too_many_arguments)] +fn enforce_interactive_signing_gates( + session_id: &str, + quarantine_identifiers: &[u16], + message_hex: &str, + signing_emergency_rekey_event: Option<&EmergencyRekeyEvent>, + wallet_emergency_rekey_event: Option<&EmergencyRekeyEvent>, + session_finalized: bool, + tx_result: Option<&TransactionResult>, + taproot_merkle_root: Option<&[u8; 32]>, + signing_intent: Option<&InteractiveSigningIntent>, + quarantined_operator_identifiers: &HashSet, + auto_quarantine_config: Option<&AutoQuarantineConfig>, +) -> Result<(), EngineError> { + // A rekey triggered before Open may live on the per-signing session because + // BuildTaprootTx has created it but Open has not yet bound it to a key_group. + // Once bound, new events are redirected to the wallet session. Treat either + // location as authoritative so the transition between ownership domains can + // never clear the kill switch. + if let Some(emergency_rekey_event) = + signing_emergency_rekey_event.or(wallet_emergency_rekey_event) + { + return Err(EngineError::LifecyclePolicyRejected { + session_id: session_id.to_string(), + reason_code: "emergency_rekey_required".to_string(), + detail: format!( + "emergency rekey required for session [{}] since [{}]: {}", + session_id, emergency_rekey_event.triggered_at_unix, emergency_rekey_event.reason + ), + }); + } + if session_finalized { + return Err(EngineError::SessionFinalized { + session_id: session_id.to_string(), + }); + } + enforce_not_quarantined_identifiers( + session_id, + quarantine_identifiers, + quarantined_operator_identifiers, + auto_quarantine_config, + )?; + enforce_signing_message_binding_to_policy_checked_build_tx( + session_id, + message_hex, + taproot_merkle_root, + tx_result, + signing_intent, + ) +} + +// Canonical key form for an attempt_id at the round entry points, +// matching canonicalize_attempt_context_for_fingerprint (which +// lowercases attempt_id). The wire accepts attempt_id case- +// insensitively, so the marker registry and live-state lookups must +// operate on this form to be replay-safe. +fn canonical_attempt_id(attempt_id: &str) -> String { + attempt_id.to_ascii_lowercase() +} + +fn canonical_aggregate_attempt_id(attempt_id: &str) -> Result { + if attempt_id.len() != 64 || !attempt_id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(EngineError::Validation( + "InteractiveAggregate: attempt_id must be exactly 64 hexadecimal characters" + .to_string(), + )); + } + + Ok(canonical_attempt_id(attempt_id)) +} + +// The chosen signing subset as Go u16 identifiers: the included +// participants whose commitment appears in the signing package. The +// caller MUST have run verify_round2_signing_package first (which +// confirms the package is a threshold-sized subset of the included +// set), so every package participant maps back to an included member. +fn round2_signing_subset( + interactive: &InteractiveSigningState, + signing_package: &frost::SigningPackage, +) -> Result, EngineError> { + let package_identifiers = signing_package + .signing_commitments() + .keys() + .copied() + .collect::>(); + let mut subset = Vec::with_capacity(package_identifiers.len()); + for participant in &interactive.canonical_included_participants { + let frost_identifier = participant_identifier_to_frost_identifier(*participant)?; + if package_identifiers.contains(&frost_identifier) { + subset.push(*participant); + } + } + Ok(subset) +} + +fn remove_finalized_interactive_members( + session: &mut SessionState, + attempt_id: &str, + message_digest: &str, + taproot_merkle_root: Option<&[u8; 32]>, +) { + let finalized_members: Vec = session + .interactive_signing + .iter() + .filter(|(_, entry)| { + // Match the FULL finalized identity (attempt_id + message + root), not + // just (attempt_id, root): a mismatched aggregate must not destroy the + // live nonce state of a differently-messaged attempt. + entry.attempt_context.attempt_id == attempt_id + && hash_hex(&entry.message_bytes) == message_digest + && entry.taproot_merkle_root.as_ref() == taproot_merkle_root + }) + .map(|(member, _)| *member) + .collect(); + for member in finalized_members { + if let Some(mut removed) = session.interactive_signing.remove(&member) { + zeroize_interactive_round1(&mut removed); + } + } +} + +pub(crate) fn zeroize_interactive_round1(interactive: &mut InteractiveSigningState) { + if let Some(mut round1) = interactive.round1.take() { + round1.nonces.zeroize(); + } +} + +// Lazy TTL enforcement: every interactive entry point sweeps before +// acting, so an abandoned session's nonces are destroyed the first +// time anything touches the engine after expiry. Expiry has abort +// semantics - the durable consumption markers are untouched. +/// Resolve the session that holds the DKG key material for `key_group`. +/// +/// Interactive signing runs under a fresh RoastSessionID per message, but a wallet's +/// DKG key material is a WALLET-level asset that lives under the session its DKG +/// completed in. This returns that wallet session so any per-signing session can reach +/// the material by key_group: +/// - prefer `session_id` itself if it already holds this wallet's DKG output (the +/// co-located case: DKG and signing share one session, as in the coarse path and +/// the single-session tests); +/// - otherwise find the session whose completed DKG produced `key_group`. +/// +/// Returns None when no completed DKG for `key_group` exists (i.e. no wallet key), which +/// callers map to DkgNotReady. +pub(crate) fn resolve_wallet_session_id( + engine_state: &EngineState, + session_id: &str, + key_group: &str, +) -> Option { + // Prefer the request's own session (the co-located DKG+signing case). + if let Some(session) = engine_state.sessions.get(session_id) { + if session + .dkg_result + .as_ref() + .is_some_and(|dkg| dkg.key_group == key_group) + { + return Some(session_id.to_string()); + } + } + // Otherwise find the wallet session whose completed DKG produced this key_group. + engine_state + .sessions + .iter() + .find(|(_, session)| { + session + .dkg_result + .as_ref() + .is_some_and(|dkg| dkg.key_group == key_group) + }) + .map(|(id, _)| id.clone()) +} + +pub(crate) fn sweep_expired_interactive_state(engine_state: &mut EngineState) -> Vec { + let ttl = Duration::from_secs(interactive_session_ttl_seconds()); + let now = interactive_now(); + let mut changed_session_ids = HashSet::new(); + // Open requires an existing wallet DKG, but production signing normally + // uses a distinct per-message session bound to that wallet. Expiry clears + // each live attempt's nonces; retirement below retains its bounded policy + // and replay state until active admission needs the shared slot. + for (session_id, session) in &mut engine_state.sessions { + // Per-member expiry: each seat's entry expires independently by its own + // last successful activity; non-expired sibling seats in the same session + // survive. Rejected traffic cannot keep nonce state resident. + let expired_members: Vec = session + .interactive_signing + .iter() + .filter(|(_, interactive)| { + now.saturating_duration_since(interactive.last_activity_at) > ttl + }) + .map(|(member, _)| *member) + .collect(); + if !expired_members.is_empty() { + changed_session_ids.insert(session_id.clone()); + } + for member in &expired_members { + if let Some(mut removed) = session.interactive_signing.remove(member) { + zeroize_interactive_round1(&mut removed); + } + } + } + // Expiry has abort semantics. Retire idle per-message entries while + // retaining their bounded policy/replay tombstones for delayed retries. + changed_session_ids.extend(retire_idle_per_message_session_ids(engine_state, None)); + changed_session_ids.retain(|session_id| engine_state.sessions.contains_key(session_id)); + changed_session_ids.into_iter().collect() +} + +pub(crate) fn sweep_expired_interactive_state_durably( + engine_state: &mut EngineState, +) -> Result<(), EngineError> { + // Persist every session whose live member set changed, not only sessions + // whose last member expired. Open binds a Build-backed per-message shell + // in memory, while live interactive state is intentionally omitted from + // snapshots. If one sibling expired and another remained live, skipping + // this write would let a crash reload the old unbound shell; persisting the + // binding lets load classify it as retired once the nonces disappear. + let changed_session_ids = sweep_expired_interactive_state(engine_state); + let repairs_pending_interactive_state = interactive_state_persistence_pending(); + // An existing Abort/expiry repair must be retried even when this sweep found + // no additional expiry. Avoid key-provider/filesystem work on the ordinary + // no-op fast path. + if changed_session_ids.is_empty() && !repairs_pending_interactive_state { + return Ok(()); + } + + if let Err(persist_error) = persist_engine_state_to_storage(engine_state) { + for session_id in changed_session_ids { + mark_persistence_pending(PersistencePendingOperation::InteractiveState { session_id }); + } + return Err(persist_error.into_engine_error()); + } + + // Pending sessions are deliberately excluded from retirement until a + // successful snapshot covers their uncertain post-rename state. If this + // sweep expired the last surviving sibling of such an Abort, the write above + // cleared its protection; classify and persist that now-idle session before + // returning from the same entry point. + if repairs_pending_interactive_state { + let retired_after_repair = sweep_expired_interactive_state(engine_state); + if let Err(persist_error) = if retired_after_repair.is_empty() { + Ok(()) + } else { + persist_engine_state_to_storage(engine_state) + } { + for session_id in retired_after_repair { + mark_persistence_pending(PersistencePendingOperation::InteractiveState { + session_id, + }); + } + return Err(persist_error.into_engine_error()); + } + } + + Ok(()) +} + +pub(crate) fn max_live_interactive_sessions_limit() -> usize { + signer_env_var(TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV) + .and_then(|value| value.trim().parse::().ok()) + .filter(|limit| *limit > 0) + .unwrap_or(TBTC_SIGNER_DEFAULT_MAX_LIVE_INTERACTIVE_SESSIONS) +} + +pub(crate) fn interactive_session_ttl_seconds() -> u64 { + signer_env_var(TBTC_SIGNER_INTERACTIVE_SESSION_TTL_SECONDS_ENV) + .and_then(|value| value.trim().parse::().ok()) + .filter(|ttl| *ttl > 0) + .unwrap_or(TBTC_SIGNER_DEFAULT_INTERACTIVE_SESSION_TTL_SECONDS) +} + +fn interactive_open_request_fingerprint( + request: &InteractiveSessionOpenRequest, +) -> Result { + // The serialized request transiently holds the signing inputs + // (message_hex and the rest of the request) in plaintext; wipe the + // buffer once the fingerprint digest is taken. No key material is + // carried in the request - it is resolved from DKG state - so only + // the request inputs are exposed here. + let mut canonical = serde_json::to_vec(request).map_err(|e| { + EngineError::Internal(format!( + "failed to serialize InteractiveSessionOpen request for fingerprint: {e}" + )) + })?; + let fingerprint = hash_hex(&canonical); + canonical.zeroize(); + Ok(fingerprint) +} diff --git a/pkg/tbtc/signer/src/engine/lifecycle.rs b/pkg/tbtc/signer/src/engine/lifecycle.rs new file mode 100644 index 0000000000..93c3dda07e --- /dev/null +++ b/pkg/tbtc/signer/src/engine/lifecycle.rs @@ -0,0 +1,600 @@ +// Operational lifecycle: canary rollout, refresh cadence/shares, emergency rekey, quarantine status. + +use super::*; + +#[cfg(test)] +static CANARY_PROMOTION_HOLD_NEXT_LOCK: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); +#[cfg(test)] +static CANARY_PROMOTION_LOCK_HELD: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); +#[cfg(test)] +static CANARY_PROMOTION_RELEASE_LOCK: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(true); +#[cfg(test)] +static CANARY_PROMOTION_LOCK_ATTEMPTS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +#[cfg(test)] +pub(crate) fn arm_canary_promotion_lock_hold_for_tests() { + use std::sync::atomic::Ordering; + CANARY_PROMOTION_LOCK_ATTEMPTS.store(0, Ordering::SeqCst); + CANARY_PROMOTION_LOCK_HELD.store(false, Ordering::SeqCst); + CANARY_PROMOTION_RELEASE_LOCK.store(false, Ordering::SeqCst); + CANARY_PROMOTION_HOLD_NEXT_LOCK.store(true, Ordering::SeqCst); +} + +#[cfg(test)] +pub(crate) fn canary_promotion_lock_attempts_for_tests() -> usize { + CANARY_PROMOTION_LOCK_ATTEMPTS.load(std::sync::atomic::Ordering::SeqCst) +} + +#[cfg(test)] +pub(crate) fn canary_promotion_lock_held_for_tests() -> bool { + CANARY_PROMOTION_LOCK_HELD.load(std::sync::atomic::Ordering::SeqCst) +} + +#[cfg(test)] +pub(crate) fn release_canary_promotion_lock_for_tests() { + CANARY_PROMOTION_RELEASE_LOCK.store(true, std::sync::atomic::Ordering::SeqCst); +} + +#[cfg(test)] +fn maybe_hold_canary_promotion_lock_for_tests() { + use std::sync::atomic::Ordering; + if CANARY_PROMOTION_HOLD_NEXT_LOCK.swap(false, Ordering::SeqCst) { + CANARY_PROMOTION_LOCK_HELD.store(true, Ordering::SeqCst); + while !CANARY_PROMOTION_RELEASE_LOCK.load(Ordering::SeqCst) { + std::thread::yield_now(); + } + } +} + +fn positive_signer_env_u64(name: &str) -> Option { + signer_env_var(name) + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value > 0) +} + +fn canary_latency_threshold_ms(primary: &str, legacy: &str, default: u64) -> u64 { + positive_signer_env_u64(primary) + .or_else(|| positive_signer_env_u64(legacy)) + .unwrap_or(default) +} + +pub(crate) fn canary_max_interactive_round1_p95_ms() -> u64 { + canary_latency_threshold_ms( + TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND1_P95_MS_ENV, + TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS_ENV, + TBTC_SIGNER_DEFAULT_CANARY_MAX_START_SIGN_ROUND_P95_MS, + ) +} + +pub(crate) fn canary_max_interactive_round2_p95_ms() -> u64 { + canary_latency_threshold_ms( + TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND2_P95_MS_ENV, + TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS_ENV, + TBTC_SIGNER_DEFAULT_CANARY_MAX_START_SIGN_ROUND_P95_MS, + ) +} + +pub(crate) fn canary_max_interactive_aggregate_p95_ms() -> u64 { + canary_latency_threshold_ms( + TBTC_SIGNER_CANARY_MAX_INTERACTIVE_AGGREGATE_P95_MS_ENV, + TBTC_SIGNER_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS_ENV, + TBTC_SIGNER_DEFAULT_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS, + ) +} + +pub(crate) fn canary_max_policy_reject_rate_bps() -> u64 { + signer_env_var(TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS_ENV) + .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) +} + +pub(crate) fn canary_min_samples() -> u64 { + positive_signer_env_u64(TBTC_SIGNER_CANARY_MIN_SAMPLES_ENV) + .map(|value| value.min(TBTC_SIGNER_MAX_CANARY_MIN_SAMPLES)) + .unwrap_or(TBTC_SIGNER_DEFAULT_CANARY_MIN_SAMPLES) +} + +pub(crate) fn canary_min_policy_samples() -> u64 { + positive_signer_env_u64(TBTC_SIGNER_CANARY_MIN_POLICY_SAMPLES_ENV) + .map(|value| value.min(TBTC_SIGNER_MAX_CANARY_MIN_SAMPLES)) + // Preserve the pre-knob safety posture unless an operator explicitly + // tunes the lower-volume policy evidence window independently. + .unwrap_or_else(canary_min_samples) +} + +pub(crate) fn canary_max_sample_age_seconds() -> u64 { + positive_signer_env_u64(TBTC_SIGNER_CANARY_MAX_SAMPLE_AGE_SECONDS_ENV) + .map(|value| value.min(TBTC_SIGNER_MAX_CANARY_SAMPLE_AGE_SECONDS)) + .unwrap_or(TBTC_SIGNER_DEFAULT_CANARY_MAX_SAMPLE_AGE_SECONDS) +} + +pub(crate) fn next_canary_percent(current_percent: u8) -> Option { + match current_percent { + 10 => Some(50), + 50 => Some(100), + _ => None, + } +} + +pub(crate) 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(crate) fn refresh_continuity_reference_key_group(session: &SessionState) -> Option { + session + .dkg_result + .as_ref() + .map(|result| result.key_group.clone()) +} + +/// Returns whether this session contains metadata written by the retired +/// synthetic `RefreshShares` implementation. No cryptographically valid refresh +/// record can exist until a versioned multi-round protocol is implemented, so +/// these fields are retained only for persisted-schema compatibility and must +/// never establish cadence or continuity. +pub(crate) fn legacy_synthetic_refresh_artifacts_present(session: &SessionState) -> bool { + session.refresh_request_fingerprint.is_some() + || session.refresh_result.is_some() + || !session.refresh_history.is_empty() + || session.refresh_count != 0 +} + +pub(crate) fn refresh_history_continuity_preserved(session: &SessionState) -> bool { + !legacy_synthetic_refresh_artifacts_present(session) +} + +pub(crate) fn refresh_cadence_due_unix( + session: &SessionState, + cadence_seconds: u64, +) -> Option { + if let Some(dkg_result) = session.dkg_result.as_ref() { + return Some(dkg_result.created_at_unix.saturating_add(cadence_seconds)); + } + + // The retired synthetic RefreshShares path could create a persisted session + // without ever running DKG. Such state has no trustworthy cadence anchor and + // must fail closed instead of receiving a new `now + cadence` deadline on + // every query. Unix epoch is the explicit "already due" sentinel shared by + // status and telemetry. + legacy_synthetic_refresh_artifacts_present(session).then_some(0) +} + +pub(crate) fn refresh_cadence_is_overdue(now_unix: u64, due_unix: u64) -> bool { + // A zero deadline is the explicit sentinel for unanchored legacy synthetic + // refresh state. It remains overdue even if the system clock rolls back to + // or before UNIX_EPOCH and `now_unix()` saturates to zero. + due_unix == 0 || now_unix > due_unix +} + +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 now = now_unix(); + let next_refresh_due_unix = refresh_cadence_due_unix(session, cadence_seconds) + .unwrap_or_else(|| now.saturating_add(cadence_seconds)); + let overdue = refresh_cadence_is_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, + // No cryptographically valid refresh can be reported by this build. + // Legacy synthetic metadata is deliberately ignored. + refresh_count: 0, + last_refresh_epoch: 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()))?; + + // Emergency rekey is a WALLET-level kill switch, and interactive Round2 reads it + // from the wallet (DKG) session resolved by key_group. Defense in depth: if a + // caller passes a per-signing session id (a distinct RoastSessionID bound to a + // wallet key but holding no DKG of its own), record the event on the WALLET session + // it serves, so the writer lands the kill switch exactly where every reader looks - + // the writer and reader can never diverge. A session that already holds the DKG + // resolves to itself, so co-located callers are unchanged. + let target_session_id = guard + .sessions + .get(&request.session_id) + .and_then(|session| { + if session.dkg_result.is_some() { + None + } else { + session.bound_key_group.clone() + } + }) + .and_then(|key_group| resolve_wallet_session_id(&guard, &request.session_id, &key_group)) + .unwrap_or_else(|| request.session_id.clone()); + + if let Some(pending_operation) = pending_emergency_rekey_operation(&target_session_id) { + let matching_result = match &pending_operation { + PersistencePendingOperation::EmergencyRekey { result } if result.reason == reason => { + Some(result.clone()) + } + _ => None, + }; + persist_engine_state_to_storage(&guard) + .map_err(PersistEngineStateError::into_engine_error)?; + clear_persistence_pending_operation(&pending_operation); + if let Some(result) = matching_result { + return Ok(result); + } + } + + let session = + guard + .sessions + .get_mut(&target_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 [{target_session_id}]; event is immutable" + ))); + } + let triggered_at_unix = now_unix(); + let previous_emergency_rekey_event = + session.emergency_rekey_event.replace(EmergencyRekeyEvent { + reason: reason.to_string(), + triggered_at_unix, + }); + let result = TriggerEmergencyRekeyResult { + session_id: target_session_id.clone(), + emergency_rekey_required: true, + reason: reason.to_string(), + triggered_at_unix, + recommended_new_session_id: format!("{target_session_id}-rekey-{triggered_at_unix}"), + }; + if let Err(persist_error) = persist_engine_state_to_storage(&guard) { + let state_file_replaced = persist_error.state_file_replaced(); + let persist_error = persist_error.into_engine_error(); + if state_file_replaced { + mark_persistence_pending(PersistencePendingOperation::EmergencyRekey { + result: result.clone(), + }); + } else { + let rollback_session = guard.sessions.get_mut(&target_session_id).ok_or_else(|| { + EngineError::Internal(format!( + "emergency rekey session [{target_session_id}] disappeared while rolling back a failed persist: {persist_error}" + )) + })?; + rollback_session.emergency_rekey_event = previous_emergency_rekey_event; + } + return Err(persist_error); + } + + Ok(result) +} + +pub fn canary_rollout_status() -> Result { + enforce_provenance_gate()?; + let guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + // Snapshot rollout state and its process-local evidence under the same + // engine lock used by PromoteCanary. This avoids reporting one stage with + // another stage's evidence during a concurrent transition. + let gate_failures = canary_promotion_gate_failures(); + let gate_passed = gate_failures.is_empty(); + let current_percent = guard.canary_rollout.current_percent; + let previous_percent = guard.canary_rollout.previous_percent; + let config_version = guard.canary_rollout.config_version; + let last_action_unix = guard.canary_rollout.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, + }) +} + +fn record_recovered_canary_transition(pending_operation: &PersistencePendingOperation) { + record_hardening_telemetry(|telemetry| match pending_operation { + PersistencePendingOperation::CanaryPromotion { .. } => { + telemetry.canary_promotions_total = telemetry.canary_promotions_total.saturating_add(1); + } + PersistencePendingOperation::CanaryRollback { .. } => { + telemetry.canary_rollbacks_total = telemetry.canary_rollbacks_total.saturating_add(1); + } + _ => {} + }); +} + +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(), + )); + } + + #[cfg(test)] + CANARY_PROMOTION_LOCK_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + #[cfg(test)] + maybe_hold_canary_promotion_lock_for_tests(); + if let Some(pending_operation) = pending_canary_operation() { + let matching_result = match &pending_operation { + PersistencePendingOperation::CanaryPromotion { result } + if result.to_percent == request.target_percent => + { + Some(result.clone()) + } + _ => None, + }; + persist_engine_state_to_storage(&guard) + .map_err(PersistEngineStateError::into_engine_error)?; + // The post-replacement error path already activated the new stage and + // reset prior-stage evidence. This retry confirms durability only, so + // preserve evidence accumulated since that transition and count the + // recovered operation exactly once before clearing its pending marker. + record_recovered_canary_transition(&pending_operation); + clear_persistence_pending_operation(&pending_operation); + if let Some(result) = matching_result { + return Ok(result); + } + } + 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 + ), + ); + } + // This snapshot is deliberately taken while the rollout-state lock is held + // and immediately before mutation. Concurrent 50%/100% requests therefore + // cannot reuse one stage's evidence, and samples cannot age out while a + // request waits for the state lock. + let gate_failures = canary_promotion_gate_failures(); + if !gate_failures.is_empty() { + return reject_lifecycle_policy( + "canary-rollout", + "canary_slo_gate_failed", + gate_failures.join("; "), + ); + } + + let previous_canary_rollout = guard.canary_rollout.clone(); + 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, + }; + if let Err(persist_error) = persist_engine_state_to_storage(&guard) { + let state_file_replaced = persist_error.state_file_replaced(); + let persist_error = persist_error.into_engine_error(); + if state_file_replaced { + mark_persistence_pending(PersistencePendingOperation::CanaryPromotion { + result: result.clone(), + }); + // The replacement state already moved to the next cohort. Treat it + // as the active stage immediately even though directory durability + // still needs repair; prior-stage evidence must not authorize the + // following promotion while the retry is pending. + reset_canary_promotion_evidence(); + } else { + guard.canary_rollout = previous_canary_rollout; + } + return Err(persist_error); + } + record_hardening_telemetry(|telemetry| { + telemetry.canary_promotions_total = telemetry.canary_promotions_total.saturating_add(1); + }); + reset_canary_promotion_evidence(); + + 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()))?; + if let Some(pending_operation) = pending_canary_operation() { + let matching_result = match &pending_operation { + PersistencePendingOperation::CanaryRollback { result } if result.reason == reason => { + Some(result.clone()) + } + _ => None, + }; + persist_engine_state_to_storage(&guard) + .map_err(PersistEngineStateError::into_engine_error)?; + // See promote_canary: repairing directory durability does not start a + // second rollout stage and must not discard current-stage evidence. + record_recovered_canary_transition(&pending_operation); + clear_persistence_pending_operation(&pending_operation); + if let Some(result) = matching_result { + return Ok(result); + } + } + let from_percent = guard.canary_rollout.current_percent; + let to_percent = guard.canary_rollout.previous_percent.min(from_percent); + + if to_percent == from_percent { + let state_path = active_state_file_path()?; + sync_existing_state_file_parent_directory(&state_path)?; + return Ok(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, + }); + } + + let previous_canary_rollout = guard.canary_rollout.clone(); + 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, + }; + if let Err(persist_error) = persist_engine_state_to_storage(&guard) { + let state_file_replaced = persist_error.state_file_replaced(); + let persist_error = persist_error.into_engine_error(); + if state_file_replaced { + mark_persistence_pending(PersistencePendingOperation::CanaryRollback { + result: result.clone(), + }); + reset_canary_promotion_evidence(); + } else { + guard.canary_rollout = previous_canary_rollout; + } + return Err(persist_error); + } + record_hardening_telemetry(|telemetry| { + telemetry.canary_rollbacks_total = telemetry.canary_rollbacks_total.saturating_add(1); + }); + reset_canary_promotion_evidence(); + + Ok(result) +} + +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, + }) +} + +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)?; + + log_policy_decision( + "lifecycle_policy", + &request.session_id, + "reject", + "cryptographic_refresh_not_supported", + ); + Err(EngineError::CryptographicRefreshNotSupported { + session_id: request.session_id, + }) +} diff --git a/pkg/tbtc/signer/src/engine/mod.rs b/pkg/tbtc/signer/src/engine/mod.rs new file mode 100644 index 0000000000..00acf364f0 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/mod.rs @@ -0,0 +1,125 @@ +//! tBTC FROST/ROAST signer engine. +//! +//! Split from a single 18k-line `engine.rs` (June 2026) as a pure code +//! move: behavior, the `engine::*` API consumed by lib.rs, and the +//! `engine::tests::*` test paths are unchanged. Submodule items are +//! `pub(crate)` and glob re-exported here; `mod engine` itself remains +//! private to the crate, so the crate-external surface is identical. +//! +//! - [`audit`] — Forensics: transcript audit, blame-proof verification, differential fuzzing references. +//! - [`codec`] — Hex/struct codecs and Go<->frost identifier conversions. +//! - [`config`] — TBTC_SIGNER_* environment surface: constant names, defaults, and parsers. +//! - [`dkg`] — distributed-DKG key-package persistence (`persist_distributed_dkg_key_package`). +//! - [`frost_ops`] — Stateless FROST primitives: dkg_part1..3 and signing-package assembly. +//! - [`interactive`] — Phase 7.1 hardened interactive signing session: engine-held nonce custody, Round1/Round2, consumption markers. +//! - [`lifecycle`] — Operational lifecycle: canary rollout, refresh cadence/shares, emergency rekey, quarantine status. +//! - [`persistence`] — Encrypted state-file persistence: envelope codec, key providers, corruption recovery, persisted<->live conversions. +//! - [`policy`] — Admission, signing-policy firewall, rate limiting, and auto-quarantine enforcement. +//! - [`provenance`] — Runtime provenance attestation gate. +//! - [`roast`] — ROAST/RFC-21 attempt machinery: request fingerprints, round/attempt ids, attempt-context and transition-evidence validation. +//! - [`state`] — In-memory engine/session state, the state-file lock, and registry capacity guards. +//! - [`telemetry`] — Hardening telemetry: latency trackers and metrics reporting. +//! - [`transaction`] — Taproot transaction building. +//! - [`testsupport`] — Cross-module test helpers (cfg(test)): state lock, reset, restart simulation. +//! - [`tests`] — the full engine test suite (single module, stable paths). + +use bitcoin::{ + absolute::LockTime, + consensus::encode::{deserialize, serialize_hex}, + hashes::Hash as BitcoinHash, + secp256k1::{ + schnorr::Signature as SchnorrSignature, Message as SecpMessage, Secp256k1, XOnlyPublicKey, + }, + sighash::{Prevouts, SighashCache, TapSighashType}, + 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, BTreeSet, 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, Arc, 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::{ + AttemptContext, BlameProofVerificationResult, BuildTaprootTxRequest, CanaryRolloutStatusResult, + DeriveInteractiveAttemptContextRequest, DeriveInteractiveAttemptContextResult, + DifferentialDivergence, DifferentialFuzzRequest, DifferentialFuzzResult, DkgPart1Request, + DkgPart1Result, DkgPart2Request, DkgPart2Result, DkgPart3Request, DkgPart3Result, DkgResult, + DkgRound1Package, DkgRound2Package, InitSignerConfigRequest, InitSignerConfigResult, + InteractiveAggregateRequest, InteractiveAggregateResult, InteractiveRound1Request, + InteractiveRound1Result, InteractiveRound2Request, InteractiveRound2Result, + InteractiveSessionAbortRequest, InteractiveSessionAbortResult, InteractiveSessionOpenRequest, + InteractiveSessionOpenResult, InteractiveSigningIntent, NativeFrostCommitment, + NativeFrostKeyPackage, NativeFrostPublicKeyPackage, NativeFrostSignatureShare, + NewSigningPackageRequest, NewSigningPackageResult, ParticipantFrostIdentifier, + PersistDistributedDkgKeyPackageRequest, PromoteCanaryRequest, PromoteCanaryResult, + QuarantineStatusRequest, QuarantineStatusResult, RefreshCadenceStatusRequest, + RefreshCadenceStatusResult, RefreshSharesRequest, RefreshSharesResult, + RoastLivenessPolicyResult, RollbackCanaryRequest, RollbackCanaryResult, RoundState, SecretHex, + SignatureResult, SignerHardeningMetricsResult, TransactionResult, TranscriptAuditRecord, + TranscriptAuditRequest, TranscriptAuditResult, TriggerEmergencyRekeyRequest, + TriggerEmergencyRekeyResult, VerifyBlameProofRequest, +}; +use crate::errors::EngineError; +use crate::go_math_rand::select_coordinator_identifier; + +mod audit; +mod codec; +mod config; +mod dkg; +mod frost_ops; +mod init_config; +mod interactive; +mod lifecycle; +mod persistence; +mod policy; +mod provenance; +mod roast; +mod state; +mod telemetry; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod testsupport; +mod transaction; +mod verify_share; + +pub(crate) use audit::*; +pub(crate) use codec::*; +pub(crate) use config::*; +pub(crate) use dkg::*; +pub(crate) use frost_ops::*; +pub(crate) use init_config::*; +pub(crate) use interactive::*; +pub(crate) use lifecycle::*; +pub(crate) use persistence::*; +pub(crate) use policy::*; +pub(crate) use provenance::*; +pub(crate) use roast::*; +pub(crate) use state::*; +pub(crate) use telemetry::*; +#[cfg(test)] +pub(crate) use testsupport::*; +pub(crate) use transaction::*; +pub(crate) use verify_share::*; diff --git a/pkg/tbtc/signer/src/engine/persistence.rs b/pkg/tbtc/signer/src/engine/persistence.rs new file mode 100644 index 0000000000..fbaeda71a9 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/persistence.rs @@ -0,0 +1,2224 @@ +// Encrypted state-file persistence: envelope codec, key providers, corruption recovery, persisted<->live conversions. + +use super::*; + +#[derive(Clone, Deserialize, Serialize)] +pub(crate) struct PersistedKeyPackage { + pub(crate) identifier: u16, + pub(crate) key_package_hex: SecretString, +} + +// Hand-written Debug: `SecretString` is `Zeroizing`, whose +// derived Debug prints the inner string verbatim. `key_package_hex` +// holds serialized signing-share material, so it MUST be redacted - +// otherwise any `{:?}` of this struct (log line, panic, the derived +// Debug of an enclosing struct) spills a key share. +impl std::fmt::Debug for PersistedKeyPackage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PersistedKeyPackage") + .field("identifier", &self.identifier) + .field("key_package_hex", &"") + .finish() + } +} + +#[derive(Clone, Deserialize, Serialize)] +pub(crate) struct PersistedSessionState { + pub(crate) dkg_request_fingerprint: Option, + pub(crate) dkg_key_packages: Option>, + pub(crate) dkg_public_key_package_hex: Option, + pub(crate) dkg_result: Option, + pub(crate) sign_request_fingerprint: Option, + pub(crate) sign_message_hex: Option, + pub(crate) round_state: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) active_attempt_context: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) attempt_transition_records: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) consumed_attempt_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) consumed_sign_round_ids: Vec, + pub(crate) finalize_request_fingerprint: Option, + pub(crate) signature_result: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) consumed_finalize_round_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) consumed_finalize_request_fingerprints: Vec, + pub(crate) build_tx_request_fingerprint: Option, + pub(crate) tx_result: Option, + pub(crate) refresh_request_fingerprint: Option, + pub(crate) refresh_result: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) refresh_history: Vec, + #[serde(default)] + pub(crate) refresh_count: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) emergency_rekey_event: Option, + // Phase 7.1 interactive consumption markers - the ONLY durable + // artifact of interactive sessions (markers-only durability: live + // interactive state, including nonces, never persists). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) consumed_interactive_attempt_markers: Vec, + // Phase 7.2b InteractiveAggregate completion markers (see SessionState). + // serde(default) keeps state written before 7.2b loadable: an absent field + // deserializes to an empty set. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) aggregated_interactive_attempt_markers: Vec, + // The wallet key_group a per-signing (cross-session) attempt is bound to. Durable + // ALONGSIDE the interactive markers: for a distributed-DKG wallet the signing + // session has no dkg_result, so this is the ONLY link back to the wallet DKG. It + // must survive a restart between Round2 (shares consumed, markers written) and + // InteractiveAggregate, or Aggregate/verify_share would resolve neither dkg_result + // nor bound_key_group and return DkgNotReady, stranding the collected shares. Public + // (a key group id), not secret. serde(default) keeps pre-existing state loadable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) bound_key_group: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) retired_interactive_at_unix: Option, + // Fixed-size exact Aggregate authorizations and successful-package replay + // identities (see SessionState). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) authorized_interactive_aggregate_markers: Vec, +} + +// Hand-written Debug: `sign_message_hex` is `SecretString` +// (`Zeroizing`), whose derived Debug renders the inner value +// verbatim. It is redacted here (presence preserved, content hidden); +// `dkg_key_packages` redacts via PersistedKeyPackage's own Debug. +// NOTE: any future secret-bearing field MUST be redacted here too. +impl std::fmt::Debug for PersistedSessionState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PersistedSessionState") + .field("dkg_request_fingerprint", &self.dkg_request_fingerprint) + .field("dkg_key_packages", &self.dkg_key_packages) + .field( + "dkg_public_key_package_hex", + &self.dkg_public_key_package_hex, + ) + .field("dkg_result", &self.dkg_result) + .field("sign_request_fingerprint", &self.sign_request_fingerprint) + .field( + "sign_message_hex", + &self.sign_message_hex.as_ref().map(|_| ""), + ) + .field("round_state", &self.round_state) + .field("active_attempt_context", &self.active_attempt_context) + .field( + "attempt_transition_records", + &self.attempt_transition_records, + ) + .field("consumed_attempt_ids", &self.consumed_attempt_ids) + .field("consumed_sign_round_ids", &self.consumed_sign_round_ids) + .field( + "finalize_request_fingerprint", + &self.finalize_request_fingerprint, + ) + .field("signature_result", &self.signature_result) + .field( + "consumed_finalize_round_ids", + &self.consumed_finalize_round_ids, + ) + .field( + "consumed_finalize_request_fingerprints", + &self.consumed_finalize_request_fingerprints, + ) + .field( + "build_tx_request_fingerprint", + &self.build_tx_request_fingerprint, + ) + .field("tx_result", &self.tx_result) + .field( + "refresh_request_fingerprint", + &self.refresh_request_fingerprint, + ) + .field("refresh_result", &self.refresh_result) + .field("refresh_history", &self.refresh_history) + .field("emergency_rekey_event", &self.emergency_rekey_event) + .field( + "consumed_interactive_attempt_markers", + &self.consumed_interactive_attempt_markers, + ) + .field( + "aggregated_interactive_attempt_markers", + &self.aggregated_interactive_attempt_markers, + ) + .field("bound_key_group", &self.bound_key_group) + .field( + "retired_interactive_at_unix", + &self.retired_interactive_at_unix, + ) + .field( + "authorized_interactive_aggregate_markers", + &self.authorized_interactive_aggregate_markers, + ) + .finish() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct PersistedEngineState { + pub(crate) schema_version: u16, + pub(crate) sessions: HashMap, + pub(crate) refresh_epoch_counter: u64, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) operator_fault_scores: BTreeMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) quarantined_operator_identifiers: Vec, + #[serde(default)] + pub(crate) canary_rollout: CanaryRolloutState, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct PersistedEncryptedEngineStateEnvelope { + pub(crate) schema_version: u16, + pub(crate) encryption_algorithm: String, + pub(crate) key_provider: String, + pub(crate) key_id: String, + pub(crate) nonce: String, + pub(crate) ciphertext: String, + pub(crate) authentication_tag: String, +} + +pub(crate) enum PersistedStateStorageFormat { + EncryptedEnvelope { + persisted: PersistedEngineState, + should_rewrite: bool, + }, + LegacyPlaintext(PersistedEngineState), +} + +pub(crate) struct StateEncryptionKeyMaterial { + pub(crate) key: Zeroizing<[u8; 32]>, + pub(crate) key_provider: &'static str, + pub(crate) key_id: String, +} + +pub(crate) const PERSISTED_STATE_SCHEMA_VERSION: u16 = 1; + +pub(crate) const PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION_V2: u16 = 2; + +pub(crate) const PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION: u16 = 3; + +pub(crate) const TBTC_SIGNER_STATE_ENCRYPTION_ALGORITHM_XCHACHA20POLY1305: &str = + "xchacha20poly1305"; + +pub(crate) const TBTC_SIGNER_STATE_ENVELOPE_NONCE_BYTES: usize = 24; + +pub(crate) const TBTC_SIGNER_STATE_ENVELOPE_AUTH_TAG_BYTES: usize = 16; + +#[cfg(test)] +pub(crate) const TEST_STATE_ENCRYPTION_KEY_HEX: &str = + "1111111111111111111111111111111111111111111111111111111111111111"; + +#[cfg(test)] +pub(crate) static PERSIST_FAULT_INJECTION_POINT: OnceLock< + Mutex>, +> = OnceLock::new(); + +#[cfg(test)] +static STATE_FILE_PARENT_DIRECTORY_SYNCS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PersistFaultInjectionPoint { + AfterTempSyncBeforeRename, + AfterRenameBeforeDirectorySync, +} + +#[derive(Debug)] +pub(crate) struct PersistEngineStateError { + error: EngineError, + state_file_replaced: bool, +} + +impl PersistEngineStateError { + fn before_state_file_replacement(error: EngineError) -> Self { + Self { + error, + state_file_replaced: false, + } + } + + fn after_state_file_replacement(error: EngineError) -> Self { + Self { + error, + state_file_replaced: true, + } + } + + pub(crate) fn state_file_replaced(&self) -> bool { + self.state_file_replaced + } + + pub(crate) fn into_engine_error(self) -> EngineError { + self.error + } +} + +impl std::fmt::Display for PersistEngineStateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.error.fmt(f) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum PersistencePendingOperation { + BuildTaprootTx { + session_id: String, + request_fingerprint: String, + }, + EmergencyRekey { + result: TriggerEmergencyRekeyResult, + }, + CanaryPromotion { + result: PromoteCanaryResult, + }, + CanaryRollback { + result: RollbackCanaryResult, + }, + InteractiveRound2 { + session_id: String, + consumed_marker: String, + }, + InteractiveAggregate { + session_id: String, + aggregated_marker: String, + }, + InteractiveState { + session_id: String, + }, +} + +static PERSISTENCE_PENDING_OPERATIONS: OnceLock>> = + OnceLock::new(); + +fn persistence_pending_operations() -> &'static Mutex> { + PERSISTENCE_PENDING_OPERATIONS.get_or_init(|| Mutex::new(Vec::new())) +} + +fn persistence_pending_same_slot( + existing: &PersistencePendingOperation, + replacement: &PersistencePendingOperation, +) -> bool { + match (existing, replacement) { + ( + PersistencePendingOperation::BuildTaprootTx { + session_id: existing, + .. + }, + PersistencePendingOperation::BuildTaprootTx { + session_id: replacement, + .. + }, + ) => existing == replacement, + ( + PersistencePendingOperation::EmergencyRekey { result: existing }, + PersistencePendingOperation::EmergencyRekey { + result: replacement, + }, + ) => existing.session_id == replacement.session_id, + ( + PersistencePendingOperation::CanaryPromotion { .. } + | PersistencePendingOperation::CanaryRollback { .. }, + PersistencePendingOperation::CanaryPromotion { .. } + | PersistencePendingOperation::CanaryRollback { .. }, + ) => true, + ( + PersistencePendingOperation::InteractiveRound2 { + session_id: existing_session, + consumed_marker: existing_marker, + }, + PersistencePendingOperation::InteractiveRound2 { + session_id: replacement_session, + consumed_marker: replacement_marker, + }, + ) => existing_session == replacement_session && existing_marker == replacement_marker, + ( + PersistencePendingOperation::InteractiveAggregate { + session_id: existing_session, + aggregated_marker: existing_marker, + }, + PersistencePendingOperation::InteractiveAggregate { + session_id: replacement_session, + aggregated_marker: replacement_marker, + }, + ) => existing_session == replacement_session && existing_marker == replacement_marker, + ( + PersistencePendingOperation::InteractiveState { + session_id: existing_session, + }, + PersistencePendingOperation::InteractiveState { + session_id: replacement_session, + }, + ) => existing_session == replacement_session, + _ => false, + } +} + +pub(crate) fn mark_persistence_pending(operation: PersistencePendingOperation) { + let mut pending = persistence_pending_operations() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + pending.retain(|existing| !persistence_pending_same_slot(existing, &operation)); + pending.push(operation); +} + +#[cfg(any(test, feature = "bench-restart-hook"))] +pub(crate) fn clear_persistence_pending_operations() { + persistence_pending_operations() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); +} + +pub(crate) fn clear_persistence_pending_operation(operation: &PersistencePendingOperation) { + persistence_pending_operations() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|pending| pending != operation); +} + +pub(crate) fn persistence_pending_session_ids() -> HashSet { + persistence_pending_operations() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter_map(|operation| match operation { + PersistencePendingOperation::BuildTaprootTx { session_id, .. } + | PersistencePendingOperation::InteractiveRound2 { session_id, .. } + | PersistencePendingOperation::InteractiveAggregate { session_id, .. } + | PersistencePendingOperation::InteractiveState { session_id } => { + Some(session_id.clone()) + } + PersistencePendingOperation::EmergencyRekey { result } => { + Some(result.session_id.clone()) + } + PersistencePendingOperation::CanaryPromotion { .. } + | PersistencePendingOperation::CanaryRollback { .. } => None, + }) + .collect() +} + +fn clear_snapshot_covered_operations(engine_state: &EngineState) { + // Round2/Aggregate pending entries cache no result. Clear one only when the + // successful snapshot actually contains its fail-closed marker; merely + // writing some other snapshot must never erase a repair obligation. + // InteractiveState carries no replay marker, so any snapshot still containing + // its protected session covers the binding/retirement state that was uncertain + // after an Open, Abort, or expiry write replaced the file. + // Lifecycle/build/refresh entries additionally preserve the original + // operation result, so keep those until that caller retries (one bounded + // slot per session, plus one canary slot). + persistence_pending_operations() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|pending| match pending { + PersistencePendingOperation::InteractiveRound2 { + session_id, + consumed_marker, + } => !engine_state + .sessions + .get(session_id) + .is_some_and(|session| { + session + .consumed_interactive_attempt_markers + .contains(consumed_marker) + }), + PersistencePendingOperation::InteractiveAggregate { + session_id, + aggregated_marker, + } => !engine_state + .sessions + .get(session_id) + .is_some_and(|session| { + session + .aggregated_interactive_attempt_markers + .contains(aggregated_marker) + }), + PersistencePendingOperation::InteractiveState { session_id } => { + !engine_state.sessions.contains_key(session_id) + } + _ => true, + }); +} + +pub(crate) fn pending_build_taproot_tx_operation( + session_id: &str, +) -> Option { + persistence_pending_operations() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .find(|operation| { + matches!( + operation, + PersistencePendingOperation::BuildTaprootTx { + session_id: pending_session, + .. + } if pending_session == session_id + ) + }) + .cloned() +} + +pub(crate) fn pending_emergency_rekey_operation( + session_id: &str, +) -> Option { + persistence_pending_operations() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .find(|operation| match operation { + PersistencePendingOperation::EmergencyRekey { result } => { + result.session_id == session_id + } + _ => false, + }) + .cloned() +} + +#[cfg(test)] +pub(crate) fn pending_emergency_rekey_result( + session_id: &str, + reason: &str, +) -> Option { + match pending_emergency_rekey_operation(session_id) { + Some(PersistencePendingOperation::EmergencyRekey { result }) if result.reason == reason => { + Some(result) + } + _ => None, + } +} + +pub(crate) fn pending_canary_operation() -> Option { + persistence_pending_operations() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .find(|operation| { + matches!( + operation, + PersistencePendingOperation::CanaryPromotion { .. } + | PersistencePendingOperation::CanaryRollback { .. } + ) + }) + .cloned() +} + +#[cfg(test)] +pub(crate) fn pending_canary_promotion_result(target_percent: u8) -> Option { + match pending_canary_operation() { + Some(PersistencePendingOperation::CanaryPromotion { result }) + if result.to_percent == target_percent => + { + Some(result) + } + _ => None, + } +} + +#[cfg(test)] +pub(crate) fn pending_canary_rollback_result(reason: &str) -> Option { + match pending_canary_operation() { + Some(PersistencePendingOperation::CanaryRollback { result }) if result.reason == reason => { + Some(result) + } + _ => None, + } +} + +pub(crate) fn interactive_round2_persistence_pending( + session_id: &str, + consumed_marker: &str, +) -> bool { + persistence_pending_operations() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .any(|operation| { + matches!( + operation, + PersistencePendingOperation::InteractiveRound2 { + session_id: pending_session, + consumed_marker: pending_marker, + } if pending_session == session_id && pending_marker == consumed_marker + ) + }) +} + +pub(crate) fn interactive_aggregate_persistence_pending( + session_id: &str, + aggregated_marker: &str, +) -> bool { + persistence_pending_operations() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .any(|operation| { + matches!( + operation, + PersistencePendingOperation::InteractiveAggregate { + session_id: pending_session, + aggregated_marker: pending_marker, + } if pending_session == session_id && pending_marker == aggregated_marker + ) + }) +} + +pub(crate) fn interactive_state_persistence_pending() -> bool { + persistence_pending_operations() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .any(|operation| { + matches!( + operation, + PersistencePendingOperation::InteractiveState { .. } + ) + }) +} + +#[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; + } + clear_persistence_pending_operations(); + 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(()) +} + +pub(crate) 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-") +} + +/// Returns the state file's parent directory in a form suitable for filesystem +/// directory operations. `Path::parent` represents a one-component relative +/// path's parent as an empty path, but APIs such as `File::open` and `read_dir` +/// do not interpret that empty path as the current directory. +pub(crate) fn state_file_parent_directory(path: &Path) -> Option<&Path> { + path.parent().map(|parent| { + if parent.as_os_str().is_empty() { + Path::new(".") + } else { + parent + } + }) +} + +/// Synchronizes the directory entry containing the state file. Callers use +/// this after an atomic replacement, or when replay proves the replacement +/// already happened but its directory sync was not acknowledged. +pub(crate) fn sync_state_file_parent_directory(path: &Path) -> Result<(), EngineError> { + let Some(parent) = state_file_parent_directory(path) else { + return Ok(()); + }; + 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() + )) + })?; + #[cfg(test)] + STATE_FILE_PARENT_DIRECTORY_SYNCS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(()) +} + +/// Repairs directory durability for an existing state-file entry while +/// preserving no-op behavior before the first state file has been created. +pub(crate) fn sync_existing_state_file_parent_directory(path: &Path) -> Result<(), EngineError> { + match fs::symlink_metadata(path) { + Ok(_) => sync_state_file_parent_directory(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(EngineError::Internal(format!( + "failed to inspect signer state file [{}] before directory sync: {error}", + path.display() + ))), + } +} + +#[cfg(test)] +pub(crate) fn state_file_parent_directory_syncs_for_tests() -> usize { + STATE_FILE_PARENT_DIRECTORY_SYNCS.load(std::sync::atomic::Ordering::SeqCst) +} + +pub(crate) 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) + } +} + +pub(crate) fn sorted_corrupted_state_backups(path: &Path) -> Result, EngineError> { + let Some(parent) = state_file_parent_directory(path) 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()) +} + +pub(crate) 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(()) +} + +pub(crate) 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()) + } + } +} + +pub(crate) fn state_key_command_timeout_secs() -> u64 { + signer_env_var(TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV) + .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) +} + +pub(crate) 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)) +} + +pub(crate) fn state_key_identifier(key: &[u8; 32]) -> String { + format!("sha256:{}", hex::encode(hash_bytes(key))) +} + +pub(crate) 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); +} + +pub(crate) 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 +} + +pub(crate) 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 +} + +pub(crate) 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" + ))), + } +} + +pub(crate) fn zeroize_command_pipe_if_ready(receiver: mpsc::Receiver>>) { + if let Ok(Ok(mut bytes)) = receiver.try_recv() { + bytes.zeroize(); + } +} + +#[cfg(unix)] +pub(crate) 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))] +pub(crate) fn configure_state_key_command_process_group(_command: &mut std::process::Command) {} + +#[cfg(unix)] +pub(crate) 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))] +pub(crate) fn kill_state_key_command_process_group(_child_id: u32) {} + +pub(crate) 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(); +} + +pub(crate) fn remaining_timeout(deadline: Instant) -> Duration { + deadline + .checked_duration_since(Instant::now()) + .unwrap_or(Duration::ZERO) +} + +pub(crate) 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)); + } + } + } +} + +/// Where the state-encryption key will come from, validated structurally: +/// provider selection, the production prohibition on the env provider, and +/// command-spec presence. Shared by `state_encryption_key_material` (which +/// goes on to read the secret or execute the command) and init-time config +/// validation (which deliberately must NOT touch the secret channel or run +/// commands). +pub(crate) enum StateKeyProviderPlan { + Env, + Command { command_spec: String }, +} + +pub(crate) fn resolve_state_key_provider_plan() -> Result { + let provider = signer_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 + ))); + } + Ok(StateKeyProviderPlan::Env) + } + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND => { + let command_spec = + signer_env_var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV).ok_or_else(|| { + 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 + ))); + } + Ok(StateKeyProviderPlan::Command { command_spec }) + } + _ => Err(EngineError::Internal(format!( + "unsupported state key provider [{}]; expected [{}] or [{}]", + provider, + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT, + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND + ))), + } +} + +pub(crate) fn state_encryption_key_material() -> Result { + match resolve_state_key_provider_plan()? { + StateKeyProviderPlan::Env => { + let raw_key_hex = + // Deliberately read from the real environment even when an init-time + // config is installed: the state-encryption key is a secret and the + // config FFI carries operational knobs only (see signer_env_var). + 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, + }) + } + StateKeyProviderPlan::Command { command_spec } => { + 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, + }) + } + } +} + +pub(crate) fn encode_encrypted_state_envelope( + persisted: &PersistedEngineState, + key_material: &StateEncryptionKeyMaterial, +) -> 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 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.clone(); + 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)) +} + +pub(crate) 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}"))) +} + +/// Whether the legacy unencrypted plaintext state path may be accepted. +/// +/// Plaintext state is UNAUTHENTICATED, so accepting it would let anyone who can +/// write the state file forge it (cleared replay markers, attacker key material) +/// without holding the state-encryption key. Per the secret-material hardening +/// plan this is an emergency-rollback-only path. The load-bearing guard is the +/// runtime non-production check (a production profile NEVER accepts plaintext, +/// regardless of build); it is additionally gated off in optimized builds via +/// `debug_assertions` and behind an explicit opt-in env flag. (Note: a release +/// build compiled with `debug-assertions = on` would still require both the +/// non-production profile and the opt-in flag.) +fn legacy_plaintext_state_permitted() -> bool { + cfg!(debug_assertions) + && !signer_profile_is_production() + && signer_env_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV) + .map(|raw_value| truthy_env_flag(&raw_value)) + .unwrap_or(false) +} + +pub(crate) 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, + }); + } + + // The bytes are not an encrypted envelope. Only fall back to the legacy + // UNAUTHENTICATED plaintext format on the gated emergency-rollback path; + // otherwise refuse, so an attacker who can write the state file cannot + // bypass the AEAD envelope (forged replay markers / key material) without + // the state-encryption key. + if !legacy_plaintext_state_permitted() { + return Err(EngineError::Internal( + "refusing to load unauthenticated plaintext signer state; an \ + encrypted state envelope is required (legacy plaintext is an \ + emergency-rollback-only path, disabled in production and release \ + builds)" + .to_string(), + )); + } + + 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)) +} + +pub(crate) fn load_engine_state_from_storage() -> Result { + let path = active_state_file_path()?; + match fs::symlink_metadata(&path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(EngineState::default()) + } + Err(error) => { + return Err(EngineError::Internal(format!( + "failed to inspect signer state file [{}]: {error}", + path.display() + ))) + } + } + + 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() { + bytes.zeroize(); + return recover_or_fail_from_corrupted_state_file( + &path, + format!("signer state file [{}] exists but is empty", path.display()), + ); + } + + 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() + ), + ) + } + }; + + // An intermediate schema-1 writer allowed independent active and retired + // allowances. Conversion below can safely compact its retired excess, but + // the old oversized envelope must also be replaced immediately; otherwise + // an emergency rollback before the next ordinary write still fails the + // previous reader's total-count validation. + let session_registry_requires_rewrite = persisted.sessions.len() > max_sessions_limit(); + let (engine_state, recovered_from_corruption): (EngineState, bool) = match persisted.try_into() + { + Ok(engine_state) => (engine_state, false), + Err(error) => ( + recover_or_fail_from_corrupted_state_file( + &path, + format!( + "failed to validate signer state file [{}]: {error}", + path.display() + ), + )?, + true, + ), + }; + + // Quarantine-and-reset intentionally renames the corrupt file away. Do not + // recreate it as a migrated clean state during the same load; the next real + // mutation will create a fresh encrypted state file. This explicit recovery + // outcome replaces the former `Path::exists` probe without hiding metadata + // errors or treating dangling symlinks as first initialization. + if (should_rewrite_state || session_registry_requires_rewrite) && !recovered_from_corruption { + persist_engine_state_to_storage(&engine_state).map_err(|e| { + EngineError::Internal(format!( + "loaded signer state file [{}] but failed to rewrite its migrated state: {e}", + path.display() + )) + })?; + } + + Ok(engine_state) +} + +#[cfg(test)] +pub(crate) 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" + } + } +} + +pub(crate) 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)] +pub(crate) 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)] +pub(crate) 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; + } +} + +// Hot paths resolve the state-encryption key (a KMS/HSM subprocess for the +// `command` provider) UNDER the held ENGINE_STATE guard, at the persist site -- +// after any idempotent-replay/pre-persist rejection has already returned (so a +// transient key-provider outage never fails a non-persisting call) and, where a +// durable marker is written, before that marker (so an outage fails the attempt +// cleanly with no marker). Resolving under the guard keeps key selection in the +// same serialized order as the writes the guard already serializes, so the last +// writer encrypts with the then-current key. Resolving the key BEFORE the lock +// instead would let a caller that resolved an older key win the final write after +// a rotation, tagging the persisted envelope with a stale key id that decode +// rejects on restart. +pub(crate) fn persist_engine_state_to_storage( + engine_state: &EngineState, +) -> Result<(), PersistEngineStateError> { + // Resolves the state-encryption key (which, for the `command` provider, + // spawns the KMS/HSM subprocess) and then persists. Hot paths call this at + // the persist site WITH the ENGINE_STATE guard held, so key resolution is + // ordered with the write (see the note above). The startup legacy-envelope + // rewrite in load_engine_state_from_storage also calls it, off-guard, before + // the engine serves concurrent operations -- there is no concurrent write to + // lose a rotation race against there. Sites that write a durable marker before + // persisting instead resolve the key explicitly before the marker and call + // persist_engine_state_to_storage_with_key. + let key_material = state_encryption_key_material() + .map_err(PersistEngineStateError::before_state_file_replacement)?; + persist_engine_state_to_storage_with_key(engine_state, &key_material) +} + +pub(crate) fn persist_engine_state_to_storage_with_key( + engine_state: &EngineState, + key_material: &StateEncryptionKeyMaterial, +) -> Result<(), PersistEngineStateError> { + let path = + active_state_file_path().map_err(PersistEngineStateError::before_state_file_replacement)?; + let persisted: PersistedEngineState = engine_state + .try_into() + .map_err(PersistEngineStateError::before_state_file_replacement)?; + let mut bytes = encode_encrypted_state_envelope(&persisted, key_material) + .map_err(PersistEngineStateError::before_state_file_replacement)?; + drop(persisted); + let temp_path = path.with_extension(format!("tmp-{}", std::process::id())); + let mut state_file_replaced = false; + let persist_result = (|| -> Result<(), EngineError> { + if let Some(parent) = state_file_parent_directory(&path) { + 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() + )) + })?; + state_file_replaced = true; + maybe_inject_persist_fault(PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync)?; + + sync_state_file_parent_directory(&path)?; + + Ok(()) + })(); + + if persist_result.is_err() { + let _ = fs::remove_file(&temp_path); + } + + bytes.zeroize(); + match persist_result { + Ok(()) => { + clear_snapshot_covered_operations(engine_state); + Ok(()) + } + Err(error) if state_file_replaced => { + Err(PersistEngineStateError::after_state_file_replacement(error)) + } + Err(error) => Err(PersistEngineStateError::before_state_file_replacement( + error, + )), + } +} + +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(); + let mut key_group_owners = HashMap::::new(); + for (session_id, session_state) in persisted.sessions { + let session_state: SessionState = session_state.try_into()?; + if let Some(dkg_result) = session_state.dkg_result.as_ref() { + if let Some(existing_owner) = + key_group_owners.insert(dkg_result.key_group.clone(), session_id.clone()) + { + return Err(EngineError::Internal(format!( + "duplicate persisted DKG key_group [{}] owned by sessions [{}] and [{}]", + dkg_result.key_group, existing_owner, session_id + ))); + } + } + sessions.insert(session_id, session_state); + } + // State written before the retirement tier existed restores no live + // interactive nonces by construction. Classify its idle, bound + // per-message entries into the retired tier during load so a full + // legacy registry cannot remain permanently wedged after upgrade. + let migration_retired_at = now_unix().max(1); + for session in sessions.values_mut() { + if session.retired_interactive_at_unix.is_none() + && session.interactive_signing.is_empty() + && per_message_interactive_session(session) + { + session.retired_interactive_at_unix = Some(migration_retired_at); + } + } + 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(), + )); + } + + let mut engine_state = EngineState { + sessions, + refresh_epoch_counter: persisted.refresh_epoch_counter, + operator_fault_scores: persisted.operator_fault_scores, + quarantined_operator_identifiers, + canary_rollout, + }; + drop(compact_retired_per_message_sessions( + &mut engine_state, + None, + )); + ensure_session_registry_persisted_bound(&engine_state.sessions)?; + Ok(engine_state) + } +} + +impl TryFrom<&EngineState> for PersistedEngineState { + type Error = EngineError; + + fn try_from(engine_state: &EngineState) -> Result { + ensure_session_registry_persisted_bound(&engine_state.sessions)?; + 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", + )?; + + let mut consumed_interactive_attempt_markers = HashSet::new(); + for attempt_marker in persisted.consumed_interactive_attempt_markers { + if attempt_marker.is_empty() { + return Err(EngineError::Internal( + "persisted consumed interactive attempt marker must be non-empty".to_string(), + )); + } + + if !consumed_interactive_attempt_markers.insert(attempt_marker.clone()) { + return Err(EngineError::Internal(format!( + "duplicate persisted consumed interactive attempt marker [{}]", + attempt_marker + ))); + } + } + ensure_consumed_registry_persisted_bound( + consumed_interactive_attempt_markers.len(), + "consumed_interactive_attempt_markers", + )?; + + let mut authorized_interactive_aggregate_markers = HashSet::new(); + for authorization_marker in persisted.authorized_interactive_aggregate_markers { + let canonical_sha256 = authorization_marker.len() == 64 + && authorization_marker + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)); + if !canonical_sha256 { + return Err(EngineError::Internal( + "persisted interactive Aggregate authorization marker must be canonical 64-character lowercase hex" + .to_string(), + )); + } + if !authorized_interactive_aggregate_markers.insert(authorization_marker.clone()) { + return Err(EngineError::Internal(format!( + "duplicate persisted interactive Aggregate authorization marker [{authorization_marker}]" + ))); + } + } + ensure_consumed_registry_persisted_bound( + authorized_interactive_aggregate_markers.len(), + "authorized_interactive_aggregate_markers", + )?; + + let mut aggregated_interactive_attempt_markers = HashSet::new(); + for attempt_marker in persisted.aggregated_interactive_attempt_markers { + if attempt_marker.is_empty() { + return Err(EngineError::Internal( + "persisted aggregated interactive attempt marker must be non-empty".to_string(), + )); + } + + if !aggregated_interactive_attempt_markers.insert(attempt_marker.clone()) { + return Err(EngineError::Internal(format!( + "duplicate persisted aggregated interactive attempt marker [{}]", + attempt_marker + ))); + } + } + ensure_consumed_registry_persisted_bound( + aggregated_interactive_attempt_markers.len(), + "aggregated_interactive_attempt_markers", + )?; + 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(), + )); + } + } + + if persisted.retired_interactive_at_unix == Some(0) { + return Err(EngineError::Internal( + "persisted retired_interactive_at_unix must be positive".to_string(), + )); + } + + let session = 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, + // Preserve the legacy synthetic count losslessly for schema + // compatibility and diagnostics. Lifecycle status deliberately + // ignores it until a versioned cryptographic refresh protocol exists. + // Evaluated before refresh_history is moved below. + refresh_count: persisted + .refresh_count + .max(persisted.refresh_history.len() as u64), + refresh_history: persisted.refresh_history, + emergency_rekey_event: persisted.emergency_rekey_event, + heartbeat_rate_limiter: PolicyRateLimiterState::default(), + // Live interactive state never restores: nonces are gone by + // construction after a restart, so the attempt fails safe and + // only the consumption markers survive. Empty map (no live members). + interactive_signing: BTreeMap::new(), + // Restore the wallet binding: for a cross-session signing session it is the + // only durable link to the wallet DKG, needed so an InteractiveAggregate that + // runs after a restart (past a member's Round2) can still resolve the wallet + // by key_group. Public data; survives with the consumed/aggregate markers. + bound_key_group: persisted.bound_key_group, + retired_interactive_at_unix: persisted.retired_interactive_at_unix, + aggregate_eviction_pin: Arc::new(()), + consumed_interactive_attempt_markers, + authorized_interactive_aggregate_markers, + aggregated_interactive_attempt_markers, + }; + if session.retired_interactive_at_unix.is_some() + && !per_message_interactive_session(&session) + { + return Err(EngineError::Internal( + "persisted retired interactive session must have the per-message role".to_string(), + )); + } + Ok(session) + } +} + +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", + )?; + ensure_consumed_registry_persisted_bound( + session_state.consumed_interactive_attempt_markers.len(), + "consumed_interactive_attempt_markers", + )?; + ensure_consumed_registry_persisted_bound( + session_state.authorized_interactive_aggregate_markers.len(), + "authorized_interactive_aggregate_markers", + )?; + 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(); + let mut consumed_interactive_attempt_markers = session_state + .consumed_interactive_attempt_markers + .iter() + .cloned() + .collect::>(); + consumed_interactive_attempt_markers.sort_unstable(); + let mut aggregated_interactive_attempt_markers = session_state + .aggregated_interactive_attempt_markers + .iter() + .cloned() + .collect::>(); + aggregated_interactive_attempt_markers.sort_unstable(); + let mut authorized_interactive_aggregate_markers = session_state + .authorized_interactive_aggregate_markers + .iter() + .cloned() + .collect::>(); + authorized_interactive_aggregate_markers.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(), + refresh_count: session_state.refresh_count, + emergency_rekey_event: session_state.emergency_rekey_event.clone(), + consumed_interactive_attempt_markers, + aggregated_interactive_attempt_markers, + bound_key_group: session_state.bound_key_group.clone(), + retired_interactive_at_unix: session_state.retired_interactive_at_unix, + authorized_interactive_aggregate_markers, + }) + } +} diff --git a/pkg/tbtc/signer/src/engine/policy.rs b/pkg/tbtc/signer/src/engine/policy.rs new file mode 100644 index 0000000000..65018c6b24 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/policy.rs @@ -0,0 +1,978 @@ +// Admission, signing-policy firewall, rate limiting, and auto-quarantine enforcement. + +use super::*; + +pub(crate) const BITCOIN_MAX_MONEY_SATS: u64 = 2_100_000_000_000_000; + +/// Conservative built-in signing-policy-firewall defaults, applied when the +/// firewall is enforced (always in production, see `signing_policy_firewall_enforced`) +/// but the operator has not set explicit policy env. The script-class allowlist is +/// the meaningful default: it fails closed on any non-standard output form +/// (`classify_script_pubkey` returns "other"), which is the on-signer guard against +/// an authorized coordinator getting an unusual/unauthorized script signed. The +/// numeric caps default to permissive bounds (operators tighten them per wallet +/// sizing) -- a too-tight static cap would false-reject legitimate large +/// redemptions/sweeps. Transaction signing remains bound to a policy-checked +/// build artifact; the only non-transaction alternative is the narrow heartbeat +/// intent validated independently at Open and Round2. +pub(crate) const DEFAULT_ALLOWED_SCRIPT_CLASSES: &[&str] = + &["p2pkh", "p2sh", "p2wpkh", "p2wsh", "p2tr"]; +pub(crate) const DEFAULT_MAX_OUTPUT_COUNT: usize = 10_000; + +pub(crate) static POLICY_GATE_WARNING_EMITTED: OnceLock<()> = OnceLock::new(); + +pub(crate) static BUILD_TX_RATE_LIMITER: OnceLock> = OnceLock::new(); + +pub(crate) const BUILD_TX_RATE_LIMIT_TOKEN_SCALE: u128 = 1_000_000; + +pub(crate) const BUILD_TX_RATE_LIMIT_SECONDS_PER_MINUTE: u128 = 60; + +#[derive(Clone, Default)] +pub(crate) struct PolicyRateLimiterState { + pub(crate) last_refill_unix: u64, + pub(crate) token_microunits: u128, + pub(crate) configured_rate_limit_per_minute: u64, +} + +#[derive(Clone, Debug)] +pub(crate) struct AdmissionPolicyConfig { + pub(crate) min_participants: usize, + pub(crate) min_threshold: u16, + pub(crate) required_identifiers: HashSet, + pub(crate) allowlist_identifiers: Option>, +} + +#[derive(Clone, Debug)] +pub(crate) struct SigningPolicyFirewallConfig { + pub(crate) allowed_script_classes: HashSet, + pub(crate) max_output_count: usize, + pub(crate) max_output_value_sats: u64, + pub(crate) max_total_output_value_sats: u64, + pub(crate) allowed_utc_start_hour: Option, + pub(crate) allowed_utc_end_hour: Option, + pub(crate) rate_limit_per_minute: u64, +} + +#[derive(Clone, Debug)] +pub(crate) struct AutoQuarantineConfig { + pub(crate) fault_threshold: u64, + pub(crate) dao_allowlist_identifiers: HashSet, +} + +pub(crate) fn build_tx_rate_limiter_state() -> &'static Mutex { + BUILD_TX_RATE_LIMITER.get_or_init(|| Mutex::new(PolicyRateLimiterState::default())) +} + +pub(crate) fn provenance_gate_enforced() -> bool { + if signer_profile_is_production() { + return true; + } + + signer_env_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV) + .map(|raw_value| truthy_env_flag(&raw_value)) + .unwrap_or(false) +} + +pub(crate) fn admission_policy_enforced() -> bool { + signer_env_var(TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV) + .map(|raw_value| truthy_env_flag(&raw_value)) + .unwrap_or(false) +} + +pub(crate) fn signing_policy_firewall_enforced() -> bool { + // Mirror provenance_gate_enforced(): the signing-policy firewall is always + // enforced in production. It resolves to conservative built-in policy + // defaults (see load_signing_policy_firewall_config) so production does not + // depend on every operator shipping explicit policy config -- closing the + // fail-open default without making firewall config mandatory to boot. + if signer_profile_is_production() { + return true; + } + + signer_env_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV) + .map(|raw_value| truthy_env_flag(&raw_value)) + .unwrap_or(false) +} + +pub(crate) 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 + ); + } + }); +} + +pub(crate) 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, + })) +} + +pub(crate) fn sanitize_policy_log_field(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | ':' | '/') + { + character + } else { + '_' + } + }) + .collect() +} + +pub(crate) 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 + ); +} + +pub(crate) 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, + }) +} + +/// Admission checks over the raw participant primitives, used by the +/// distributed-DKG persist path (which derives the participant identifiers from +/// the group public key package). +pub(crate) fn enforce_admission_policy_for( + session_id: &str, + participant_count: usize, + participant_identifiers: &HashSet, + threshold: u16, +) -> Result<(), EngineError> { + let policy = match load_admission_policy_config() { + Ok(Some(policy)) => policy, + Ok(None) => return Ok(()), + Err(error) => { + return reject_admission_policy( + session_id, + "invalid_policy_configuration", + error.to_string(), + ) + } + }; + + if participant_count < policy.min_participants { + return reject_admission_policy( + session_id, + "participant_count_below_policy_minimum", + format!( + "participant count [{}] below policy minimum [{}]", + participant_count, policy.min_participants + ), + ); + } + + if threshold < policy.min_threshold { + return reject_admission_policy( + session_id, + "threshold_below_policy_minimum", + format!( + "threshold [{}] below policy minimum [{}]", + threshold, policy.min_threshold + ), + ); + } + + if let Some(required_identifier) = policy + .required_identifiers + .iter() + .find(|identifier| !participant_identifiers.contains(identifier)) + { + return reject_admission_policy( + 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( + session_id, + "participant_identifier_not_allowlisted", + format!( + "participant identifier [{}] not present in configured allowlist", + unknown_identifier + ), + ); + } + } + + log_policy_decision("admission_policy", session_id, "allow", "ok"); + Ok(()) +} + +pub(crate) fn load_signing_policy_firewall_config( +) -> Result, EngineError> { + if !signing_policy_firewall_enforced() { + return Ok(None); + } + + // Resolve to conservative built-in defaults when explicit policy env is not + // set, so an enforced firewall (always on in production) does not require + // every operator to ship full policy config to boot. The script-class + // allowlist fails closed on non-standard forms; the numeric caps default + // permissive and are operator-tunable. + let allowed_script_classes = parse_script_class_set_with_default( + TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, + DEFAULT_ALLOWED_SCRIPT_CLASSES, + )?; + let max_output_count = parse_usize_from_env_with_default( + TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT_ENV, + DEFAULT_MAX_OUTPUT_COUNT, + )?; + let max_output_value_sats = parse_u64_from_env_with_default( + TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS_ENV, + BITCOIN_MAX_MONEY_SATS, + )?; + let max_total_output_value_sats = parse_u64_from_env_with_default( + TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS_ENV, + BITCOIN_MAX_MONEY_SATS, + )?; + 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 + ))); + } + + if let (Some(start_hour), Some(end_hour)) = (allowed_utc_start_hour, allowed_utc_end_hour) { + if start_hour >= 24 || end_hour >= 24 { + return Err(EngineError::Internal(format!( + "env [{}] and [{}] must be hours in the range 0..=23", + TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR_ENV, + TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR_ENV + ))); + } + // Reject a zero-width window. utc_hour_in_window treats start == end as + // "always in window", so silently accepting equal bounds would disable + // the time-of-day control entirely (fail-open) rather than restricting + // it. An operator who wants no time restriction must leave both unset. + if start_hour == end_hour { + return Err(EngineError::Internal(format!( + "env [{}] and [{}] must differ; an equal start and end hour does not define a restricted window", + 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, + })) +} + +pub(crate) fn heartbeat_rate_limit_per_minute() -> Result { + let rate_limit_per_minute = parse_u64_from_env_with_default( + TBTC_SIGNER_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE_ENV, + TBTC_SIGNER_DEFAULT_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE, + )?; + if rate_limit_per_minute == 0 { + return Err(EngineError::Internal(format!( + "env [{}] must be positive", + TBTC_SIGNER_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE_ENV + ))); + } + + Ok(rate_limit_per_minute) +} + +pub(crate) fn auto_quarantine_enabled() -> bool { + signer_env_var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV) + .map(|raw_value| truthy_env_flag(&raw_value)) + .unwrap_or(false) +} + +pub(crate) 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 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 + ))); + } + + Ok(Some(AutoQuarantineConfig { + fault_threshold, + dao_allowlist_identifiers, + })) +} + +pub(crate) 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, + }) +} + +pub(crate) 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_with_metric( + session_id: &str, + reason_code: &str, + detail: impl Into, + heartbeat: bool, +) -> Result<(), EngineError> { + let detail = detail.into(); + record_hardening_telemetry(|telemetry| { + if heartbeat { + telemetry.heartbeat_signing_policy_reject_total = telemetry + .heartbeat_signing_policy_reject_total + .saturating_add(1); + } else { + 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, + }) +} + +pub(crate) fn reject_signing_policy( + session_id: &str, + reason_code: &str, + detail: impl Into, +) -> Result<(), EngineError> { + reject_signing_policy_with_metric(session_id, reason_code, detail, false) +} + +fn reject_heartbeat_signing_policy( + session_id: &str, + reason_code: &str, + detail: impl Into, +) -> Result<(), EngineError> { + reject_signing_policy_with_metric(session_id, reason_code, detail, true) +} + +pub(crate) fn current_utc_hour() -> u8 { + ((now_unix() / 3600) % 24) as u8 +} + +pub(crate) 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 consume_policy_rate_limit_token( + limiter: &mut PolicyRateLimiterState, + rate_limit_per_minute: u64, +) -> bool { + 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 false; + } + + limiter.token_microunits = limiter + .token_microunits + .saturating_sub(BUILD_TX_RATE_LIMIT_TOKEN_SCALE); + true +} + +pub(crate) 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()))?; + + if !consume_policy_rate_limit_token(&mut limiter, rate_limit_per_minute) { + return reject_signing_policy( + session_id, + "rate_limit_per_minute_exceeded", + format!("rate limit [{}] per minute exceeded", rate_limit_per_minute), + ); + } + + Ok(()) +} + +pub(crate) fn enforce_heartbeat_rate_limit( + session_id: &str, + limiter: &mut PolicyRateLimiterState, +) -> Result<(), EngineError> { + let rate_limit_per_minute = match heartbeat_rate_limit_per_minute() { + Ok(rate_limit_per_minute) => rate_limit_per_minute, + Err(error) => { + return reject_heartbeat_signing_policy( + session_id, + "invalid_policy_configuration", + error.to_string(), + ) + } + }; + + if !consume_policy_rate_limit_token(limiter, rate_limit_per_minute) { + return reject_heartbeat_signing_policy( + session_id, + "heartbeat_rate_limit_per_minute_exceeded", + format!( + "heartbeat rate limit [{}] per minute exceeded", + rate_limit_per_minute + ), + ); + } + + Ok(()) +} + +pub(crate) 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" + } +} + +pub(crate) 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(()) +} + +pub(crate) 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) +} + +pub(crate) 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) +} + +pub(crate) fn policy_bound_signing_messages_hex( + tx: &Transaction, + prevouts: &[TxOut], +) -> Result, EngineError> { + if prevouts.len() != tx.input.len() { + return Err(EngineError::Internal(format!( + "BIP-341 prevout count [{}] does not match transaction input count [{}]", + prevouts.len(), + tx.input.len() + ))); + } + + let prevouts = Prevouts::All(prevouts); + let mut sighash_cache = SighashCache::new(tx); + (0..tx.input.len()) + .map(|input_index| { + sighash_cache + .taproot_key_spend_signature_hash( + input_index, + &prevouts, + TapSighashType::Default, + ) + .map(|sighash| hex::encode(sighash.to_byte_array())) + .map_err(|error| { + EngineError::Internal(format!( + "failed to derive BIP-341 key-spend sighash for input [{input_index}]: {error}" + )) + }) + }) + .collect() +} + +fn invalid_policy_checked_build_tx_artifact( + session_id: &str, + detail: impl Into, +) -> 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", + "invalid_policy_checked_build_tx_artifact", + ); + EngineError::SigningPolicyRejected { + session_id: session_id.to_string(), + reason_code: "invalid_policy_checked_build_tx_artifact".to_string(), + detail, + } +} + +fn enforce_heartbeat_signing_intent( + session_id: &str, + signing_message_hex: &str, + taproot_merkle_root: Option<&[u8; 32]>, + heartbeat_message_hex: &str, +) -> Result<(), EngineError> { + if taproot_merkle_root.is_some() { + return reject_heartbeat_signing_policy( + session_id, + "invalid_heartbeat_signing_intent", + "heartbeat signing intent must not carry a Taproot merkle root", + ); + } + + let heartbeat_message = match hex::decode(heartbeat_message_hex) { + Ok(message) => message, + Err(_) => { + return reject_heartbeat_signing_policy( + session_id, + "invalid_heartbeat_signing_intent", + "heartbeat signing intent message_hex must be valid hex", + ) + } + }; + if heartbeat_message.len() != 16 { + return reject_heartbeat_signing_policy( + session_id, + "invalid_heartbeat_signing_intent", + format!( + "heartbeat signing intent must decode to exactly 16 bytes, got [{}]", + heartbeat_message.len() + ), + ); + } + if heartbeat_message[..8] != [0xff; 8] { + return reject_heartbeat_signing_policy( + session_id, + "invalid_heartbeat_signing_intent", + "heartbeat signing intent must start with eight 0xff bytes", + ); + } + + let signing_message = match hex::decode(signing_message_hex) { + Ok(message) => message, + Err(_) => { + return reject_heartbeat_signing_policy( + session_id, + "invalid_heartbeat_signing_intent", + "heartbeat signing message must be valid hex", + ) + } + }; + if signing_message.len() != 32 { + return reject_heartbeat_signing_policy( + session_id, + "invalid_heartbeat_signing_intent", + format!( + "heartbeat signing message must be exactly 32 bytes, got [{}]", + signing_message.len() + ), + ); + } + + // Match Bitcoin's Hash256 convention used by the Go host and the on-chain + // heartbeat contract: SHA256(SHA256(raw 16-byte heartbeat message)). Rust + // derives this independently from the narrow preimage instead of trusting a + // caller-supplied digest allowlist. + let first_digest = Sha256::digest(&heartbeat_message); + let heartbeat_digest = Sha256::digest(first_digest); + if signing_message.as_slice() != heartbeat_digest.as_slice() { + return reject_heartbeat_signing_policy( + session_id, + "heartbeat_signing_message_mismatch", + "signing message does not equal Hash256 of the authorized heartbeat message", + ); + } + + Ok(()) +} + +pub(crate) fn enforce_signing_message_binding_to_policy_checked_build_tx( + session_id: &str, + signing_message_hex: &str, + taproot_merkle_root: Option<&[u8; 32]>, + tx_result: Option<&TransactionResult>, + signing_intent: Option<&InteractiveSigningIntent>, +) -> Result<(), EngineError> { + if let Some(signing_intent) = signing_intent { + if tx_result.is_some() { + return reject_heartbeat_signing_policy( + session_id, + "ambiguous_signing_policy_artifact", + "a signing session cannot carry both a transaction artifact and a non-transaction signing intent", + ); + } + + return match signing_intent { + InteractiveSigningIntent::Heartbeat { message_hex } => { + enforce_heartbeat_signing_intent( + session_id, + signing_message_hex, + taproot_merkle_root, + message_hex, + ) + } + }; + } + + 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", + ) + } + }; + + if tx_result.session_id != session_id { + return Err(invalid_policy_checked_build_tx_artifact( + session_id, + format!( + "policy-checked build tx belongs to session [{}], not signing session [{}]", + tx_result.session_id, session_id + ), + )); + } + + let tx_bytes = hex::decode(&tx_result.tx_hex).map_err(|_| { + invalid_policy_checked_build_tx_artifact( + session_id, + "policy-checked build tx hex is not valid hex", + ) + })?; + let tx: Transaction = deserialize(&tx_bytes).map_err(|error| { + invalid_policy_checked_build_tx_artifact( + session_id, + format!("policy-checked build tx is not a valid transaction: {error}"), + ) + })?; + + if tx_result.taproot_key_spend_sighashes_hex.len() != tx.input.len() + || tx_result.taproot_key_spend_sighashes_hex.is_empty() + { + return Err(invalid_policy_checked_build_tx_artifact( + session_id, + format!( + "policy-checked BIP-341 sighash count [{}] does not match transaction input count [{}]", + tx_result.taproot_key_spend_sighashes_hex.len(), + tx.input.len() + ), + )); + } + for sighash_hex in &tx_result.taproot_key_spend_sighashes_hex { + let sighash_bytes = hex::decode(sighash_hex).map_err(|_| { + invalid_policy_checked_build_tx_artifact( + session_id, + "policy-checked BIP-341 sighash is not valid hex", + ) + })?; + if sighash_bytes.len() != 32 { + return Err(invalid_policy_checked_build_tx_artifact( + session_id, + format!( + "policy-checked BIP-341 sighash length [{}] is not 32 bytes", + sighash_bytes.len() + ), + )); + } + } + + // The ordered sighashes are trusted because the complete persisted state is + // authenticated by the encrypted AEAD envelope. Prevouts are intentionally not + // duplicated in SessionState, so Open/Round2 shape-check this sealed list rather + // than re-deriving it. A forged plaintext artifact is rejected at state load. + // Re-run every current non-rate policy gate at Open and again at Round2 so a + // restart with stricter script/value/time policy cannot authorize stale state. + let total_output_value_sats = tx.output.iter().try_fold(0u64, |total, output| { + total.checked_add(output.value.to_sat()).ok_or_else(|| { + invalid_policy_checked_build_tx_artifact( + session_id, + "policy-checked build tx output total overflowed u64 bounds", + ) + }) + })?; + if total_output_value_sats > BITCOIN_MAX_MONEY_SATS { + return Err(invalid_policy_checked_build_tx_artifact( + session_id, + format!( + "policy-checked build tx output total [{}] exceeds Bitcoin max money [{}]", + total_output_value_sats, BITCOIN_MAX_MONEY_SATS + ), + )); + } + recheck_signing_policy_firewall_without_rate_limit( + session_id, + &tx.output, + total_output_value_sats, + )?; + + let signing_message_hex = signing_message_hex.trim().to_ascii_lowercase(); + if !tx_result + .taproot_key_spend_sighashes_hex + .iter() + .any(|expected| expected.eq_ignore_ascii_case(&signing_message_hex)) + { + return reject_signing_policy( + session_id, + "signing_message_not_bound_to_policy_checked_build_tx", + format!( + "signing message [{}] is not an authorized BIP-341 key-spend sighash for the policy-checked transaction", + signing_message_hex + ), + ); + } + + Ok(()) +} diff --git a/pkg/tbtc/signer/src/engine/provenance.rs b/pkg/tbtc/signer/src/engine/provenance.rs new file mode 100644 index 0000000000..631ba40c85 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/provenance.rs @@ -0,0 +1,352 @@ +// Runtime provenance attestation gate. + +use super::*; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) struct ParsedVersionTriplet { + pub(crate) major: u64, + pub(crate) minor: u64, + pub(crate) patch: u64, + pub(crate) has_prerelease_suffix: bool, +} + +pub(crate) 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, + }) +} + +pub(crate) 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)] +pub(crate) struct ProvenanceAttestationPayload { + pub(crate) status: String, + pub(crate) runtime_version: String, + #[serde(default)] + pub(crate) expires_at_unix: Option, +} + +pub(crate) 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 + ), + }) +} + +pub(crate) 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 + ), + } + }) +} + +pub(crate) 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}"), + }) +} + +pub(crate) fn reject_provenance_gate( + reason_code: &str, + detail: impl Into, +) -> Result<(), EngineError> { + Err(EngineError::ProvenanceGateRejected { + reason_code: reason_code.to_string(), + detail: detail.into(), + }) +} + +pub(crate) fn enforce_provenance_gate() -> Result<(), EngineError> { + if !provenance_gate_enforced() { + return Ok(()); + } + + let attestation_status = signer_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 = signer_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 = + signer_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 = + signer_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 = signer_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(()) +} diff --git a/pkg/tbtc/signer/src/engine/roast.rs b/pkg/tbtc/signer/src/engine/roast.rs new file mode 100644 index 0000000000..dd674bc238 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/roast.rs @@ -0,0 +1,521 @@ +// ROAST/RFC-21 attempt machinery: request fingerprints, round/attempt ids, attempt-context and transition-evidence validation. + +use super::*; + +pub(crate) const ROAST_INCLUDED_PARTICIPANTS_FINGERPRINT_DOMAIN: &str = + "FROST-ROAST-INCLUDED-FPR-v1"; + +pub(crate) const ROAST_ATTEMPT_ID_DOMAIN: &str = "FROST-ROAST-ATTEMPT-ID-v1"; + +pub(crate) const ROAST_EXCLUSION_REASON_COORDINATOR_TIMEOUT: &str = "coordinator_timeout"; + +pub(crate) const ROAST_EXCLUSION_REASON_INVALID_SHARE_PROOF: &str = "invalid_share_proof"; + +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(), + } +} + +pub(crate) 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) +} + +pub(crate) 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)) +} + +pub(crate) 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(); + } +} + +pub(crate) 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) +} + +pub(crate) 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(()) +} + +pub(crate) 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)) +} + +/// Computes the RFC-21 `MessageDigest` from the raw signing message +/// bytes, mirroring keep-core's `messageDigestFromBigInt` exactly: the +/// message **is** the digest (in BIP-340 production the signed message is +/// already a 32-byte sighash), leading zero bytes are insignificant +/// (Go round-trips through `big.Int`, which strips them), the value is +/// big-endian left-padded with zeros to exactly 32 bytes, and anything +/// longer than 32 significant bytes is rejected. +/// +/// This is deliberately NOT the engine's internal transcript digest +/// (`hash_hex(message_bytes)` = SHA256 of the message), which continues +/// to feed `round_id`/`attempt_id` derivations. Feeding the transcript +/// digest into the shuffle seed was the cross-language divergence this +/// helper exists to prevent: the Go RFC-21 layer seeds from the padded +/// message itself. +pub(crate) fn rfc21_message_digest(message_bytes: &[u8]) -> Result<[u8; 32], EngineError> { + let significant_bytes = { + let first_significant_index = message_bytes + .iter() + .position(|byte| *byte != 0) + .unwrap_or(message_bytes.len()); + &message_bytes[first_significant_index..] + }; + + if significant_bytes.len() > 32 { + return Err(EngineError::Validation(format!( + "message length [{}] exceeds the RFC-21 32-byte message digest; \ + attempt contexts only bind 32-byte signing digests", + significant_bytes.len() + ))); + } + + let mut digest = [0_u8; 32]; + digest[32 - significant_bytes.len()..].copy_from_slice(significant_bytes); + Ok(digest) +} + +/// Derives the legacy `i64` coordinator-shuffle seed per RFC-21 Annex A +/// (normative; see `docs/roast-coordinator-seed-derivation.md`): +/// +/// ```text +/// AttemptSeed32 = SHA256(KeyGroupBytes || SessionID || MessageDigest) +/// ShuffleSeed_i64 = int64_from_be_bytes(AttemptSeed32[0..8]) +/// ``` +/// +/// `key_group` is the canonical FROST key-group handle (for this engine: +/// the lowercase hex encoding of the serialized group verifying key); its +/// UTF-8 bytes feed the hash as an opaque string, matching keep-core's +/// `attempt.DeriveAttemptSeed` + `foldAttemptSeed` composition exactly. +/// `rfc21_message_digest` is the padded raw signing message (see +/// `rfc21_message_digest`), NOT the engine's SHA256 transcript digest. +/// The shuffle-source composition adds the RFC-21 **0-based** attempt +/// number; callers holding the 1-based wire attempt number must subtract +/// one before composing. +/// +/// Cross-language agreement is pinned by +/// `testdata/coordinator_seed_vectors.json`, a byte-identical copy of the +/// canonical file generated from the Go implementation in +/// `pkg/frost/roast` on the RFC-21 branch. +pub(crate) fn roast_attempt_shuffle_seed( + key_group: &str, + session_id: &str, + rfc21_message_digest: &[u8; 32], +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(key_group.as_bytes()); + hasher.update(session_id.as_bytes()); + hasher.update(rfc21_message_digest); + let attempt_seed = hasher.finalize(); + + let mut seed_bytes = [0_u8; 8]; + seed_bytes.copy_from_slice(&attempt_seed[..8]); + Ok(i64::from_be_bytes(seed_bytes)) +} + +pub(crate) 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], + ) +} + +pub(crate) 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(), + ], + ) +} + +pub(crate) fn validate_attempt_context( + session_id: &str, + key_group: &str, + message_bytes: &[u8], + 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(), + )); + } + + // The shuffle seed binds the RFC-21 MessageDigest -- the padded raw + // signing message, exactly as the Go layer's + // `messageDigestFromBigInt` produces it -- NOT the engine's SHA256 + // transcript digest (`message_digest_hex`), which feeds only the + // `attempt_id` derivation below. Mixing the two was the + // coordinator-selection divergence flagged on the seed-unification + // review. + let attempt_seed = + roast_attempt_shuffle_seed(key_group, session_id, &rfc21_message_digest(message_bytes)?)?; + // The wire attempt_number is 1-based (enforced above); the RFC-21 + // Annex A shuffle composition uses the 0-based attempt number. + let expected_coordinator_identifier = select_coordinator_identifier( + &canonical_included_participants, + attempt_seed, + attempt_context.attempt_number - 1, + ) + .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)) +} + +/// Derives the canonical interactive attempt context for an attempt from its +/// public inputs, so the host never re-implements the engine's domain-separated +/// derivations (the cross-language divergence class that bit the coordinator +/// seed). Stateless and secret-free: it touches no DKG, nonce, or session state. +/// +/// The returned context is re-validated against strict-mode +/// `validate_attempt_context` for the same inputs before returning, so it is +/// guaranteed to be accepted by `interactive_session_open`; the per-participant +/// FROST identifiers use the canonical key-package encoding the +/// signing-package/aggregate paths require. +pub(crate) fn derive_interactive_attempt_context( + request: DeriveInteractiveAttemptContextRequest, +) -> Result { + // Mirror interactive_session_open's front door (and every other engine + // endpoint, including the public-material-only verify_signature_share): an + // unattested engine, or a session_id open would reject, must fail closed + // here too rather than hand back a context the real open refuses. + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + let message_bytes = hex::decode(&request.message_hex) + .map_err(|e| EngineError::Validation(format!("message_hex is not valid hex: {e}")))?; + if message_bytes.is_empty() { + return Err(EngineError::Validation( + "message_hex must not be empty".to_string(), + )); + } + if request.attempt_number == 0 { + return Err(EngineError::Validation( + "attempt_number must be at least 1".to_string(), + )); + } + // interactive_session_open rejects threshold == 0 BEFORE validating the + // context, and validate_attempt_context only checks len >= threshold (always + // true for 0). Reject it here too so the helper never hands the host a + // context open would reject - a missing/uninitialized threshold fails at the + // derivation seam, not later at open. + if request.threshold == 0 { + return Err(EngineError::Validation( + "threshold must be non-zero".to_string(), + )); + } + + let canonical_included_participants = + canonicalize_included_participants(&request.included_participants)?; + if canonical_included_participants.len() < usize::from(request.threshold) { + return Err(EngineError::Validation(format!( + "included_participants must contain at least threshold members [{}]", + request.threshold + ))); + } + + // Coordinator: the RFC-21 Annex A shuffle binds the padded raw message + // digest and uses the 0-based attempt number. + let attempt_seed = roast_attempt_shuffle_seed( + &request.key_group, + &request.session_id, + &rfc21_message_digest(&message_bytes)?, + )?; + let coordinator_identifier = select_coordinator_identifier( + &canonical_included_participants, + attempt_seed, + request.attempt_number - 1, + ) + .ok_or_else(|| { + EngineError::Validation("included_participants must not be empty".to_string()) + })?; + + // Fingerprint over the canonical set; the attempt_id binds the engine's + // SHA256 transcript digest of the message (NOT the RFC-21 shuffle digest). + let included_participants_fingerprint = + roast_included_participants_fingerprint_hex(&canonical_included_participants)?; + let message_digest_hex = hash_hex(&message_bytes); + let attempt_id = roast_attempt_id_hex( + &request.session_id, + &message_digest_hex, + request.attempt_number, + coordinator_identifier, + &included_participants_fingerprint, + )?; + + let attempt_context = AttemptContext { + attempt_number: request.attempt_number, + coordinator_identifier, + included_participants: canonical_included_participants.clone(), + included_participants_fingerprint, + attempt_id, + }; + + // Post-condition: the derived context MUST satisfy the same strict-mode + // validator `interactive_session_open` runs, so the host can never be handed + // a context the engine would later reject. A failure here is an internal + // derivation inconsistency, surfaced rather than shipped. + validate_attempt_context( + &request.session_id, + &request.key_group, + &message_bytes, + &message_digest_hex, + request.threshold, + Some(&attempt_context), + true, + )?; + + let frost_identifiers = canonical_included_participants + .iter() + .map(|participant| { + Ok(ParticipantFrostIdentifier { + participant_identifier: *participant, + frost_identifier: frost_identifier_to_go_string( + participant_identifier_to_frost_identifier(*participant)?, + ), + }) + }) + .collect::, EngineError>>()?; + + Ok(DeriveInteractiveAttemptContextResult { + attempt_context, + frost_identifiers, + }) +} + +pub(crate) 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") +} + +pub(crate) 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(()) +} + +pub(crate) 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(()) +} diff --git a/pkg/tbtc/signer/src/engine/state.rs b/pkg/tbtc/signer/src/engine/state.rs new file mode 100644 index 0000000000..4ad0ffa106 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/state.rs @@ -0,0 +1,850 @@ +// In-memory engine/session state, the state-file lock, and registry capacity guards. + +use super::*; + +pub(crate) type SecretString = Zeroizing; + +pub(crate) type SecretBytes = Zeroizing>; + +pub(crate) struct ZeroizingChaCha20Rng { + pub(crate) inner: ChaCha20Rng, +} + +impl ZeroizingChaCha20Rng { + pub(crate) 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(); + } + } +} + +// Phase 7.1 interactive session state. Lives ONLY in memory: the +// nonces must never persist (frozen spec, markers-only durability), +// and without them the rest of this struct is useless after a +// restart, so none of it is mirrored into PersistedSessionState. +// The durable artifact is SessionState.consumed_interactive_attempt_markers. +pub(crate) struct InteractiveSigningState { + pub(crate) open_request_fingerprint: String, + pub(crate) attempt_context: AttemptContext, + pub(crate) canonical_included_participants: Vec, + pub(crate) member_identifier: u16, + pub(crate) threshold: u16, + pub(crate) message_bytes: SecretBytes, + pub(crate) taproot_merkle_root: Option<[u8; 32]>, + /// Validated non-transaction authorization for this live attempt. It is + /// deliberately transient alongside the nonce state: after a restart no + /// Round2 share can be released, so the host must open and validate a fresh + /// intent rather than relying on a durable generic-message allowlist. + pub(crate) signing_intent: Option, + pub(crate) key_package: frost::keys::KeyPackage, + /// Monotonic time of the last successful activity for this member's live + /// attempt. Exact Open and Round1 retries refresh it, as does a validated + /// Round2 whose retry-preserving durability work fails. Rejected traffic + /// does not extend nonce residency. This state is transient, so `Instant` + /// never crosses the persistence boundary. + pub(crate) last_activity_at: Instant, + pub(crate) round1: Option, +} + +// Secret round-1 nonces and the public commitments they correspond +// to. The nonces are zeroized at every exit path (consumption, abort, +// expiry, replacement) by the interactive module; the Drop impl is +// the backstop for paths that drop the struct without going through +// one of those. +pub(crate) struct InteractiveRound1State { + pub(crate) nonces: frost::round1::SigningNonces, + pub(crate) commitments_hex: String, +} + +impl Drop for InteractiveRound1State { + fn drop(&mut self) { + self.nonces.zeroize(); + } +} + +#[derive(Default)] +pub(crate) struct SessionState { + pub(crate) dkg_request_fingerprint: Option, + pub(crate) dkg_key_packages: Option>, + pub(crate) dkg_public_key_package: Option, + pub(crate) dkg_result: Option, + pub(crate) sign_request_fingerprint: Option, + pub(crate) sign_message_bytes: Option, + pub(crate) round_state: Option, + pub(crate) active_attempt_context: Option, + pub(crate) attempt_transition_records: Vec, + pub(crate) consumed_attempt_ids: HashSet, + pub(crate) consumed_sign_round_ids: HashSet, + pub(crate) finalize_request_fingerprint: Option, + pub(crate) signature_result: Option, + pub(crate) consumed_finalize_round_ids: HashSet, + pub(crate) consumed_finalize_request_fingerprints: HashSet, + pub(crate) build_tx_request_fingerprint: Option, + pub(crate) tx_result: Option, + pub(crate) refresh_request_fingerprint: Option, + pub(crate) refresh_result: Option, + pub(crate) refresh_history: Vec, + /// Legacy count written by the retired synthetic refresh implementation. + /// Retained only so existing pre-release state remains decodable; lifecycle + /// status deliberately treats it as non-authoritative. + pub(crate) refresh_count: u64, + pub(crate) emergency_rekey_event: Option, + /// Transient per-wallet budget for accepted heartbeat Opens. Like the + /// process-global BuildTaprootTx limiter, this operational throttle resets on + /// restart and is never written into the encrypted session state. + pub(crate) heartbeat_rate_limiter: PolicyRateLimiterState, + // Multi-seat: a process-global engine may hold several LOCAL members (seats) + // signing the same session concurrently, each on its own attempt timeline. + // Keyed by member_identifier; each entry is independent (own attempt, nonces, + // replace/round2/expiry). Was Option (one member per session). + pub(crate) interactive_signing: BTreeMap, + // The key_group this per-signing session signs for, set at InteractiveSessionOpen. + // Interactive signing runs under a fresh RoastSessionID per message, so a wallet's + // DKG material lives under a DIFFERENT (wallet/DKG) session; this binds the signing + // session to its wallet key so Round2/Aggregate resolve the same material by + // key_group. Persisted even though live nonce state is not: Aggregate may run + // after restart using only public material, and the full-lifetime role binding + // prevents this per-signing session from later becoming an unrelated DKG owner. + pub(crate) bound_key_group: Option, + // Idle per-message entries use the unoccupied portion of the shared persisted + // session budget. The full entry is retained temporarily so delayed + // Aggregate/verify-share calls and an outer retry's BuildTaprootTx policy + // artifact keep working. Old retired entries are evicted FIFO-by-time when a + // new active session needs their slot. + pub(crate) retired_interactive_at_unix: Option, + // Transient refcount pin for Aggregate's unlocked cryptographic section. + // The session owns one reference; an in-flight Aggregate clones it while + // holding the engine lock, and compaction skips any session with a clone. + // Never persisted: no operation can remain in flight across a restart. + pub(crate) aggregate_eviction_pin: Arc<()>, + pub(crate) consumed_interactive_attempt_markers: HashSet, + // Fixed-size SHA-256 bindings. Round2 writes an exact + // (attempt_id, signing package, taproot root) authorization; successful + // Aggregate replaces it with a package/root completion identity so the + // same attempt-less FROST package cannot fill completion storage under + // fresh canonical attempt ids. Both survive restart. + pub(crate) authorized_interactive_aggregate_markers: HashSet, + // Phase 7.2b InteractiveAggregate completion markers: an attempt whose + // aggregate signature has been produced is recorded here so a repeat + // InteractiveAggregate is rejected (re-aggregation is not a recovery path; + // a lost signature is recovered with a fresh attempt). Durable like the + // consumed markers (markers-only durability) and bounded the same way; not + // security-load-bearing (the aggregate is deterministic over public data), + // but the frozen Phase 7 spec marks the session complete. + pub(crate) aggregated_interactive_attempt_markers: HashSet, +} + +#[derive(Default)] +pub(crate) struct EngineState { + pub(crate) sessions: HashMap, + pub(crate) refresh_epoch_counter: u64, + pub(crate) operator_fault_scores: BTreeMap, + pub(crate) quarantined_operator_identifiers: HashSet, + pub(crate) canary_rollout: CanaryRolloutState, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct RefreshHistoryRecord { + pub(crate) refresh_epoch: u64, + pub(crate) refreshed_at_unix: u64, + pub(crate) share_count: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) key_group: Option, + /// Legacy request fingerprint retained for persisted-schema compatibility. + /// No record produced by the retired one-shot implementation represents a + /// cryptographically valid share refresh. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) request_fingerprint: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct EmergencyRekeyEvent { + pub(crate) reason: String, + pub(crate) triggered_at_unix: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct CanaryRolloutState { + pub(crate) current_percent: u8, + pub(crate) previous_percent: u8, + pub(crate) config_version: u64, + pub(crate) 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(), + } + } +} + +pub(crate) const TBTC_SIGNER_MAX_CONSUMED_REGISTRY_ENTRIES_PER_SESSION: usize = 128; + +pub(crate) const TBTC_SIGNER_MAX_ATTEMPT_TRANSITION_RECORDS_PER_SESSION: usize = 256; + +pub(crate) static ENGINE_STATE: OnceLock> = OnceLock::new(); + +// Loading can rewrite a legacy or stale encrypted envelope in place. A +// OnceLock serializes only the final in-memory installation, so it does not by +// itself prevent concurrent first callers from racing those fallible storage +// reads and migrations. Keep that entire path behind a process-local mutex +// that is deliberately separate from STATE_FILE_LOCK: the loader resolves the +// active path through STATE_FILE_LOCK and would deadlock if its slot guard were +// held here. +static ENGINE_STATE_INITIALIZATION_LOCK: Mutex<()> = Mutex::new(()); + +pub(crate) static STATE_FILE_LOCK: OnceLock>> = OnceLock::new(); + +pub(crate) static STATE_PATH_OVERRIDE_WARNED: OnceLock<()> = OnceLock::new(); + +pub(crate) enum CorruptStatePolicy { + FailClosed, + QuarantineAndReset, +} + +pub(crate) struct StateFileLock { + pub(crate) _file: fs::File, + pub(crate) state_path: PathBuf, + pub(crate) lock_path: PathBuf, +} + +impl StateFileLock { + pub(crate) 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, + }) + } +} + +pub(crate) fn state_file_lock_slot() -> &'static Mutex> { + STATE_FILE_LOCK.get_or_init(|| Mutex::new(None)) +} + +#[cfg(unix)] +pub(crate) fn is_lock_contention_errno(errno: i32) -> bool { + errno == EAGAIN || errno == EWOULDBLOCK +} + +pub(crate) fn state() -> Result<&'static Mutex, EngineError> { + ensure_state_file_lock()?; + warn_disabled_policy_gates(); + + initialize_engine_state_with_loader( + &ENGINE_STATE, + &ENGINE_STATE_INITIALIZATION_LOCK, + || {}, + load_engine_state_from_storage, + ) +} + +/// Installs the first engine state while serializing the complete fallible load +/// path, not just the final OnceLock write. +/// +/// `after_initial_miss` is a no-op in production and lets concurrency tests +/// deterministically place multiple callers past the optimistic fast path. +/// The loader runs under `initialization_lock`; a failed load leaves +/// `engine_state` unset so a later call can retry. +pub(crate) fn initialize_engine_state_with_loader<'state, AfterInitialMiss, Load>( + engine_state: &'state OnceLock>, + initialization_lock: &Mutex<()>, + after_initial_miss: AfterInitialMiss, + load: Load, +) -> Result<&'state Mutex, EngineError> +where + AfterInitialMiss: FnOnce(), + Load: FnOnce() -> Result, +{ + if let Some(state) = engine_state.get() { + return Ok(state); + } + + after_initial_miss(); + + // The mutex protects no data of its own. Recovering its guard after a + // panic is safe, and the second OnceLock check determines whether the + // previous caller completed installation before panicking. + let _initialization_guard = initialization_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + if let Some(state) = engine_state.get() { + return Ok(state); + } + + let loaded_state = load()?; + Ok(engine_state.get_or_init(|| Mutex::new(loaded_state))) +} + +pub(crate) fn state_file_path() -> Result { + let configured_path = signer_env_var(TBTC_SIGNER_STATE_PATH_ENV) + .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!( + "{} (or the state_path field of the init-time signer config) 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)) +} + +pub(crate) 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() +} + +pub(crate) 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) + } +} + +pub(crate) 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(()) +} + +pub(crate) fn state_corruption_policy() -> CorruptStatePolicy { + let policy = signer_env_var(TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV) + .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 + } +} + +pub(crate) fn state_corrupt_backup_limit() -> usize { + signer_env_var(TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(TBTC_SIGNER_DEFAULT_CORRUPT_BACKUP_LIMIT) +} + +pub(crate) fn max_sessions_limit() -> usize { + signer_env_var(TBTC_SIGNER_MAX_SESSIONS_ENV) + .and_then(|value| value.trim().parse::().ok()) + .filter(|limit| *limit > 0) + .unwrap_or(TBTC_SIGNER_DEFAULT_MAX_SESSIONS) +} + +pub(crate) 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(()) +} + +pub(crate) fn active_session_count(sessions: &HashMap) -> usize { + sessions + .values() + .filter(|session| session.retired_interactive_at_unix.is_none()) + .count() +} + +#[cfg(test)] +pub(crate) fn retired_interactive_session_count(sessions: &HashMap) -> usize { + sessions + .values() + .filter(|session| session.retired_interactive_at_unix.is_some()) + .count() +} + +pub(crate) fn ensure_session_registry_persisted_bound( + sessions: &HashMap, +) -> Result<(), EngineError> { + let max_sessions = max_sessions_limit(); + let session_count = sessions.len(); + if session_count > max_sessions { + return Err(EngineError::Internal(format!( + "persisted session registry size [{session_count}] exceeds max [{max_sessions}]" + ))); + } + + Ok(()) +} + +// Production interactive signing uses one outer session per message. Such a +// session is bound to a wallet key but never owns DKG material; DKG installation +// enforces that role split. Keep this match exhaustive so a future SessionState +// field forces an explicit retirement-safety decision here. +pub(crate) fn per_message_interactive_session(session: &SessionState) -> bool { + let SessionState { + dkg_request_fingerprint, + dkg_key_packages, + dkg_public_key_package, + dkg_result, + sign_request_fingerprint, + sign_message_bytes, + round_state, + active_attempt_context, + attempt_transition_records, + consumed_attempt_ids, + consumed_sign_round_ids, + finalize_request_fingerprint, + signature_result, + consumed_finalize_round_ids, + consumed_finalize_request_fingerprints, + build_tx_request_fingerprint, + tx_result, + refresh_request_fingerprint, + refresh_result, + refresh_history, + refresh_count, + emergency_rekey_event, + heartbeat_rate_limiter, + interactive_signing, + bound_key_group, + retired_interactive_at_unix, + aggregate_eviction_pin, + consumed_interactive_attempt_markers, + authorized_interactive_aggregate_markers, + aggregated_interactive_attempt_markers, + } = session; + + let _ = ( + sign_request_fingerprint, + sign_message_bytes, + round_state, + active_attempt_context, + attempt_transition_records, + consumed_attempt_ids, + consumed_sign_round_ids, + finalize_request_fingerprint, + signature_result, + consumed_finalize_round_ids, + consumed_finalize_request_fingerprints, + build_tx_request_fingerprint, + tx_result, + refresh_request_fingerprint, + refresh_result, + refresh_history, + refresh_count, + emergency_rekey_event, + heartbeat_rate_limiter, + interactive_signing, + retired_interactive_at_unix, + aggregate_eviction_pin, + consumed_interactive_attempt_markers, + authorized_interactive_aggregate_markers, + aggregated_interactive_attempt_markers, + ); + + bound_key_group.is_some() + && dkg_request_fingerprint.is_none() + && dkg_key_packages.is_none() + && dkg_public_key_package.is_none() + && dkg_result.is_none() +} + +pub(crate) fn retire_idle_per_message_sessions( + engine_state: &mut EngineState, + protected_session_id: Option<&str>, +) -> usize { + retire_idle_per_message_session_ids(engine_state, protected_session_id).len() +} + +pub(crate) fn retire_idle_per_message_session_ids( + engine_state: &mut EngineState, + protected_session_id: Option<&str>, +) -> Vec { + let retired_at = now_unix().max(1); + let pending_session_ids = persistence_pending_session_ids(); + let mut newly_retired = Vec::new(); + for (session_id, session) in &mut engine_state.sessions { + if !pending_session_ids.contains(session_id) + && session.retired_interactive_at_unix.is_none() + && session.interactive_signing.is_empty() + && per_message_interactive_session(session) + { + session.retired_interactive_at_unix = Some(retired_at); + newly_retired.push(session_id.clone()); + } + } + + drop(compact_retired_per_message_sessions( + engine_state, + protected_session_id, + )); + newly_retired.retain(|session_id| engine_state.sessions.contains_key(session_id)); + newly_retired +} + +pub(crate) fn compact_retired_per_message_sessions( + engine_state: &mut EngineState, + protected_session_id: Option<&str>, +) -> Vec<(String, SessionState)> { + compact_retired_per_message_sessions_to_total( + engine_state, + max_sessions_limit(), + protected_session_id, + ) +} + +fn compact_retired_per_message_sessions_to_total( + engine_state: &mut EngineState, + max_total_sessions: usize, + protected_session_id: Option<&str>, +) -> Vec<(String, SessionState)> { + // A post-replacement persistence failure leaves the replacement snapshot's + // marker in memory and records a process-local repair operation. Evicting + // that session before a later successful snapshot would persist the + // marker's absence and then clear the repair record. Protect every + // session-scoped pending operation until a successful snapshot covers it. + let pending_session_ids = persistence_pending_session_ids(); + let mut removed = Vec::new(); + // Schema version 1 readers predating retirement enforce this same bound on + // the TOTAL map. Retired tombstones therefore consume only the portion of + // the shared budget not occupied by active sessions; preserving a separate + // retired allowance would make an emergency binary rollback fail at load. + while engine_state.sessions.len() > max_total_sessions { + let oldest = engine_state + .sessions + .iter() + .filter_map(|(session_id, session)| { + if protected_session_id == Some(session_id.as_str()) + || pending_session_ids.contains(session_id) + || Arc::strong_count(&session.aggregate_eviction_pin) > 1 + { + return None; + } + session + .retired_interactive_at_unix + .map(|retired_at| (retired_at, session_id.clone())) + }) + .min(); + let Some((_, oldest_session_id)) = oldest else { + break; + }; + let removed_session = engine_state + .sessions + .remove(&oldest_session_id) + .expect("selected retired session existed under the held engine lock"); + removed.push((oldest_session_id, removed_session)); + } + removed +} + +pub(crate) fn restore_compacted_retired_sessions( + engine_state: &mut EngineState, + removed: Vec<(String, SessionState)>, +) { + for (session_id, session) in removed { + let previous = engine_state.sessions.insert(session_id, session); + debug_assert!( + previous.is_none(), + "a compacted retired session must not be recreated while the engine lock is held" + ); + } +} + +fn has_evictable_retired_session(engine_state: &EngineState) -> bool { + let pending_session_ids = persistence_pending_session_ids(); + engine_state.sessions.iter().any(|(session_id, session)| { + session.retired_interactive_at_unix.is_some() + && !pending_session_ids.contains(session_id) + && Arc::strong_count(&session.aggregate_eviction_pin) == 1 + }) +} + +pub(crate) fn ensure_session_insert_admission_capacity( + engine_state: &EngineState, + session_id: &str, +) -> Result<(), EngineError> { + if engine_state.sessions.contains_key(session_id) { + return Ok(()); + } + + let max_sessions = max_sessions_limit(); + let active_count = active_session_count(&engine_state.sessions); + if active_count >= max_sessions { + return Err(EngineError::Internal(format!( + "active session registry size [{active_count}] reached max [{max_sessions}]; use an existing session_id or increase {}", + TBTC_SIGNER_MAX_SESSIONS_ENV + ))); + } + if engine_state.sessions.len() >= max_sessions && !has_evictable_retired_session(engine_state) { + return Err(EngineError::Internal(format!( + "session registry size [{}] reached max [{max_sessions}] and no retired session is available for eviction; use an existing session_id or increase {}", + engine_state.sessions.len(), + TBTC_SIGNER_MAX_SESSIONS_ENV + ))); + } + + Ok(()) +} + +pub(crate) fn ensure_interactive_session_admission_capacity( + engine_state: &EngineState, + session_id: &str, +) -> Result<(), EngineError> { + let existing_session = engine_state.sessions.get(session_id); + let needs_active_slot = existing_session + .map(|session| session.retired_interactive_at_unix.is_some()) + .unwrap_or(true); + if !needs_active_slot { + return Ok(()); + } + + if existing_session.is_none() { + return ensure_session_insert_admission_capacity(engine_state, session_id); + } + + let max_sessions = max_sessions_limit(); + let active_count = active_session_count(&engine_state.sessions); + if active_count >= max_sessions { + return Err(EngineError::Internal(format!( + "active session registry size [{active_count}] reached max [{max_sessions}]; abort idle sessions or increase {}", + TBTC_SIGNER_MAX_SESSIONS_ENV + ))); + } + + Ok(()) +} + +pub(crate) fn reactivate_retired_per_message_session( + engine_state: &mut EngineState, + session_id: &str, +) -> Result<(), EngineError> { + let is_retired = engine_state + .sessions + .get(session_id) + .is_some_and(|session| session.retired_interactive_at_unix.is_some()); + if !is_retired { + return Ok(()); + } + + let max_sessions = max_sessions_limit(); + let active_count = active_session_count(&engine_state.sessions); + if active_count >= max_sessions { + return Err(EngineError::Internal(format!( + "active session registry size [{active_count}] reached max [{max_sessions}]; abort idle sessions or increase {}", + TBTC_SIGNER_MAX_SESSIONS_ENV + ))); + } + + engine_state + .sessions + .get_mut(session_id) + .expect("retired session existed under the held engine lock") + .retired_interactive_at_unix = None; + Ok(()) +} + +pub(crate) fn ensure_session_insert_capacity( + engine_state: &mut EngineState, + session_id: &str, +) -> Result, EngineError> { + if engine_state.sessions.contains_key(session_id) { + return Ok(Vec::new()); + } + + ensure_session_insert_admission_capacity(engine_state, session_id)?; + let max_sessions = max_sessions_limit(); + // Reserve one slot for the caller's insertion. The returned tombstones let + // durable callers restore the exact pre-call map if persistence fails before + // replacing the state file. + let compacted = compact_retired_per_message_sessions_to_total( + engine_state, + max_sessions.saturating_sub(1), + None, + ); + if engine_state.sessions.len() >= max_sessions { + restore_compacted_retired_sessions(engine_state, compacted); + return Err(EngineError::Internal(format!( + "session registry size [{}] reached max [{max_sessions}] and no retired session is available for eviction; use an existing session_id or increase {}", + engine_state.sessions.len(), + TBTC_SIGNER_MAX_SESSIONS_ENV + ))); + } + + Ok(compacted) +} + +pub(crate) 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(()) +} diff --git a/pkg/tbtc/signer/src/engine/telemetry.rs b/pkg/tbtc/signer/src/engine/telemetry.rs new file mode 100644 index 0000000000..1adda3ac87 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/telemetry.rs @@ -0,0 +1,677 @@ +// Hardening telemetry: latency trackers and metrics reporting. + +use super::*; + +pub(crate) static HARDENING_TELEMETRY: OnceLock> = OnceLock::new(); + +pub(crate) const HARDENING_LATENCY_SAMPLE_WINDOW: usize = 256; + +fn canary_sample_is_fresh( + observed_at: Instant, + evaluated_at: Instant, + max_age_seconds: u64, +) -> bool { + evaluated_at + .checked_duration_since(observed_at) + .is_some_and(|age| age <= Duration::from_secs(max_age_seconds)) +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct HardeningLatencySample { + pub(crate) duration_ms: u64, + pub(crate) observed_at: Instant, +} + +#[derive(Default)] +pub(crate) struct HardeningLatencyTracker { + pub(crate) samples: VecDeque, +} + +impl HardeningLatencyTracker { + pub(crate) fn record(&mut self, duration_ms: u64) { + self.record_at(duration_ms, Instant::now()); + } + + pub(crate) fn record_at(&mut self, duration_ms: u64, observed_at: Instant) { + if self.samples.len() >= HARDENING_LATENCY_SAMPLE_WINDOW { + self.samples.pop_front(); + } + self.samples.push_back(HardeningLatencySample { + duration_ms, + observed_at, + }); + } + + pub(crate) fn p95_ms(&self) -> u64 { + Self::p95(self.samples.iter().map(|sample| sample.duration_ms)) + } + + pub(crate) fn fresh_p95_ms(&self, evaluated_at: Instant, max_age_seconds: u64) -> u64 { + Self::p95( + self.samples + .iter() + .filter(|sample| { + canary_sample_is_fresh(sample.observed_at, evaluated_at, max_age_seconds) + }) + .map(|sample| sample.duration_ms), + ) + } + + fn p95(samples: impl Iterator) -> u64 { + let mut sorted_samples = samples.collect::>(); + if sorted_samples.is_empty() { + return 0; + } + sorted_samples.sort_unstable(); + let p95_index = (sorted_samples.len() * 95).div_ceil(100).saturating_sub(1); + sorted_samples[p95_index] + } + + pub(crate) fn sample_count(&self) -> u64 { + self.samples.len() as u64 + } + + pub(crate) fn fresh_sample_count(&self, evaluated_at: Instant, max_age_seconds: u64) -> u64 { + self.samples + .iter() + .filter(|sample| { + canary_sample_is_fresh(sample.observed_at, evaluated_at, max_age_seconds) + }) + .count() as u64 + } + + pub(crate) fn clear(&mut self) { + self.samples.clear(); + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct HardeningPolicyOutcomeSample { + pub(crate) rejected: bool, + pub(crate) observed_at: Instant, +} + +#[derive(Default)] +pub(crate) struct HardeningPolicyOutcomeTracker { + pub(crate) samples: VecDeque, +} + +impl HardeningPolicyOutcomeTracker { + pub(crate) fn record(&mut self, rejected: bool) { + self.record_at(rejected, Instant::now()); + } + + pub(crate) fn record_at(&mut self, rejected: bool, observed_at: Instant) { + if self.samples.len() >= HARDENING_LATENCY_SAMPLE_WINDOW { + self.samples.pop_front(); + } + self.samples.push_back(HardeningPolicyOutcomeSample { + rejected, + observed_at, + }); + } + + pub(crate) fn fresh_snapshot(&self, evaluated_at: Instant, max_age_seconds: u64) -> (u64, u64) { + let mut sample_count = 0u64; + let mut rejected_count = 0u64; + for sample in self.samples.iter().filter(|sample| { + canary_sample_is_fresh(sample.observed_at, evaluated_at, max_age_seconds) + }) { + sample_count = sample_count.saturating_add(1); + if sample.rejected { + rejected_count = rejected_count.saturating_add(1); + } + } + (sample_count, rejected_count) + } + + pub(crate) fn clear(&mut self) { + self.samples.clear(); + } +} + +#[derive(Default)] +pub(crate) struct HardeningTelemetryState { + pub(crate) run_dkg_calls_total: u64, + pub(crate) run_dkg_success_total: u64, + pub(crate) run_dkg_admission_reject_total: u64, + pub(crate) start_sign_round_calls_total: u64, + pub(crate) start_sign_round_success_total: u64, + pub(crate) build_taproot_tx_calls_total: u64, + pub(crate) build_taproot_tx_success_total: u64, + pub(crate) build_taproot_tx_policy_reject_total: u64, + pub(crate) heartbeat_signing_policy_reject_total: u64, + pub(crate) finalize_sign_round_calls_total: u64, + pub(crate) finalize_sign_round_success_total: u64, + pub(crate) refresh_shares_calls_total: u64, + pub(crate) refresh_shares_success_total: u64, + pub(crate) roast_transcript_audit_calls_total: u64, + pub(crate) roast_transcript_audit_success_total: u64, + pub(crate) verify_blame_proof_calls_total: u64, + pub(crate) verify_blame_proof_success_total: u64, + pub(crate) attempt_transition_total: u64, + pub(crate) coordinator_failover_total: u64, + pub(crate) auto_quarantine_fault_events_total: u64, + pub(crate) auto_quarantine_enforcements_total: u64, + pub(crate) differential_fuzz_runs_total: u64, + pub(crate) differential_fuzz_critical_divergence_total: u64, + pub(crate) canary_promotions_total: u64, + pub(crate) canary_rollbacks_total: u64, + pub(crate) interactive_session_open_calls_total: u64, + pub(crate) interactive_session_open_success_total: u64, + pub(crate) interactive_round1_calls_total: u64, + pub(crate) interactive_round1_success_total: u64, + pub(crate) interactive_round2_calls_total: u64, + pub(crate) interactive_round2_success_total: u64, + pub(crate) interactive_session_abort_calls_total: u64, + pub(crate) interactive_session_abort_success_total: u64, + pub(crate) interactive_aggregate_calls_total: u64, + pub(crate) interactive_aggregate_success_total: u64, + pub(crate) run_dkg_latency: HardeningLatencyTracker, + pub(crate) start_sign_round_latency: HardeningLatencyTracker, + pub(crate) build_taproot_tx_latency: HardeningLatencyTracker, + pub(crate) finalize_sign_round_latency: HardeningLatencyTracker, + pub(crate) refresh_shares_latency: HardeningLatencyTracker, + pub(crate) interactive_round1_latency: HardeningLatencyTracker, + pub(crate) interactive_round2_latency: HardeningLatencyTracker, + pub(crate) interactive_aggregate_latency: HardeningLatencyTracker, + // Promotion evidence is intentionally separate from the ABI-3 latency + // metrics above. The public metrics retain the full rolling window of all + // calls, while these trackers contain only successful operations from the + // current rollout stage and may be cleared between stages. + pub(crate) canary_interactive_round1_latency: HardeningLatencyTracker, + pub(crate) canary_interactive_round2_latency: HardeningLatencyTracker, + pub(crate) canary_interactive_aggregate_latency: HardeningLatencyTracker, + pub(crate) canary_policy_outcomes: HardeningPolicyOutcomeTracker, + // Incremented whenever rollout-stage evidence is reset. A successful + // interactive operation that straddles that boundary must not be credited + // to the new stage. + pub(crate) canary_evidence_epoch: u64, + pub(crate) last_updated_unix: u64, +} + +#[derive(Clone, Copy)] +pub(crate) enum HardeningOperation { + BuildTaprootTx, + RefreshShares, + // Interactive Open/Abort are O(1) registry mutations and record + // call/success counters only; the cryptographic rounds and the + // aggregation get latency tracking. + InteractiveRound1, + InteractiveRound2, + InteractiveAggregate, +} + +pub(crate) struct HardeningOperationLatencyGuard { + pub(crate) operation: HardeningOperation, + pub(crate) started_at: Instant, + pub(crate) record_canary_on_drop: bool, + pub(crate) canary_evidence_epoch: Option, +} + +impl HardeningOperationLatencyGuard { + pub(crate) fn new(operation: HardeningOperation) -> Self { + Self { + operation, + started_at: Instant::now(), + record_canary_on_drop: false, + canary_evidence_epoch: None, + } + } + + pub(crate) fn success_only(operation: HardeningOperation) -> Self { + Self { + operation, + started_at: Instant::now(), + record_canary_on_drop: false, + canary_evidence_epoch: current_canary_evidence_epoch(), + } + } + + pub(crate) fn mark_success(&mut self) { + self.record_canary_on_drop = true; + } +} + +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_for_epoch( + self.operation, + elapsed_ms, + self.record_canary_on_drop, + self.canary_evidence_epoch, + ); + } +} + +pub(crate) fn hardening_telemetry_state() -> &'static Mutex { + HARDENING_TELEMETRY.get_or_init(|| Mutex::new(HardeningTelemetryState::default())) +} + +pub(crate) 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 current_canary_evidence_epoch() -> Option { + match hardening_telemetry_state().lock() { + Ok(telemetry) => Some(telemetry.canary_evidence_epoch), + Err(error) => { + eprintln!("warning: hardening telemetry mutex poisoned: {error}"); + None + } + } +} + +#[cfg(test)] +pub(crate) fn record_hardening_operation_latency(operation: HardeningOperation, duration_ms: u64) { + record_hardening_operation_latency_for_epoch(operation, duration_ms, true, None); +} + +fn record_hardening_operation_latency_for_epoch( + operation: HardeningOperation, + duration_ms: u64, + record_canary_success: bool, + expected_canary_evidence_epoch: Option, +) { + record_hardening_telemetry(|telemetry| { + match operation { + HardeningOperation::BuildTaprootTx => { + telemetry.build_taproot_tx_latency.record(duration_ms) + } + HardeningOperation::RefreshShares => { + telemetry.refresh_shares_latency.record(duration_ms) + } + HardeningOperation::InteractiveRound1 => { + telemetry.interactive_round1_latency.record(duration_ms) + } + HardeningOperation::InteractiveRound2 => { + telemetry.interactive_round2_latency.record(duration_ms) + } + HardeningOperation::InteractiveAggregate => { + telemetry.interactive_aggregate_latency.record(duration_ms) + } + } + + if !record_canary_success + || expected_canary_evidence_epoch + .is_some_and(|expected| expected != telemetry.canary_evidence_epoch) + { + return; + } + + match operation { + HardeningOperation::InteractiveRound1 => telemetry + .canary_interactive_round1_latency + .record(duration_ms), + HardeningOperation::InteractiveRound2 => telemetry + .canary_interactive_round2_latency + .record(duration_ms), + HardeningOperation::InteractiveAggregate => telemetry + .canary_interactive_aggregate_latency + .record(duration_ms), + HardeningOperation::BuildTaprootTx | HardeningOperation::RefreshShares => {} + } + }); +} + +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, + heartbeat_signing_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, + interactive_session_open_calls_total: 0, + interactive_session_open_success_total: 0, + interactive_round1_calls_total: 0, + interactive_round1_success_total: 0, + interactive_round2_calls_total: 0, + interactive_round2_success_total: 0, + interactive_session_abort_calls_total: 0, + interactive_session_abort_success_total: 0, + interactive_aggregate_calls_total: 0, + interactive_aggregate_success_total: 0, + interactive_round1_latency_p95_ms: 0, + interactive_round1_latency_samples: 0, + interactive_round2_latency_p95_ms: 0, + interactive_round2_latency_samples: 0, + interactive_aggregate_latency_p95_ms: 0, + interactive_aggregate_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.heartbeat_signing_policy_reject_total = + telemetry.heartbeat_signing_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.interactive_session_open_calls_total = + telemetry.interactive_session_open_calls_total; + result.interactive_session_open_success_total = + telemetry.interactive_session_open_success_total; + result.interactive_round1_calls_total = telemetry.interactive_round1_calls_total; + result.interactive_round1_success_total = telemetry.interactive_round1_success_total; + result.interactive_round2_calls_total = telemetry.interactive_round2_calls_total; + result.interactive_round2_success_total = telemetry.interactive_round2_success_total; + result.interactive_session_abort_calls_total = + telemetry.interactive_session_abort_calls_total; + result.interactive_session_abort_success_total = + telemetry.interactive_session_abort_success_total; + result.interactive_aggregate_calls_total = telemetry.interactive_aggregate_calls_total; + result.interactive_aggregate_success_total = + telemetry.interactive_aggregate_success_total; + result.interactive_round1_latency_p95_ms = + telemetry.interactive_round1_latency.p95_ms(); + result.interactive_round1_latency_samples = + telemetry.interactive_round1_latency.sample_count(); + result.interactive_round2_latency_p95_ms = + telemetry.interactive_round2_latency.p95_ms(); + result.interactive_round2_latency_samples = + telemetry.interactive_round2_latency.sample_count(); + result.interactive_aggregate_latency_p95_ms = + telemetry.interactive_aggregate_latency.p95_ms(); + result.interactive_aggregate_latency_samples = + telemetry.interactive_aggregate_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; + let now = now_unix(); + let cadence_seconds = refresh_cadence_seconds(); + result.refresh_cadence_overdue_sessions = engine_state + .sessions + .values() + .filter(|session| { + refresh_cadence_due_unix(session, cadence_seconds) + .is_some_and(|due_unix| refresh_cadence_is_overdue(now, due_unix)) + }) + .count() as u64; + } + } + + result +} + +pub(crate) fn record_canary_policy_outcome(rejected: bool) { + record_hardening_telemetry(|telemetry| { + telemetry.canary_policy_outcomes.record(rejected); + }); +} + +pub(crate) fn reset_canary_promotion_evidence() { + record_hardening_telemetry(|telemetry| { + telemetry.canary_interactive_round1_latency.clear(); + telemetry.canary_interactive_round2_latency.clear(); + telemetry.canary_interactive_aggregate_latency.clear(); + telemetry.canary_policy_outcomes.clear(); + telemetry.canary_evidence_epoch = telemetry.canary_evidence_epoch.saturating_add(1); + }); +} + +#[cfg(test)] +pub(crate) fn seed_canary_promotion_evidence_for_tests( + round1_latency_ms: u64, + round2_latency_ms: u64, + aggregate_latency_ms: u64, + policy_rejects: u64, +) { + let interactive_sample_count = canary_min_samples(); + let policy_sample_count = canary_min_policy_samples(); + record_hardening_telemetry(|telemetry| { + for _ in 0..interactive_sample_count { + telemetry + .interactive_round1_latency + .record(round1_latency_ms); + telemetry + .canary_interactive_round1_latency + .record(round1_latency_ms); + telemetry + .interactive_round2_latency + .record(round2_latency_ms); + telemetry + .canary_interactive_round2_latency + .record(round2_latency_ms); + telemetry + .interactive_aggregate_latency + .record(aggregate_latency_ms); + telemetry + .canary_interactive_aggregate_latency + .record(aggregate_latency_ms); + } + for index in 0..policy_sample_count { + telemetry + .canary_policy_outcomes + .record(index < policy_rejects); + } + }); +} + +pub(crate) fn canary_promotion_gate_failures() -> Vec { + let mut failures = Vec::new(); + + // Each rollout stage needs its own non-vacuous window of recent successful + // production operations. Telemetry is process-local by design, so a restart + // clears the window and blocks promotion until fresh evidence accumulates. + let minimum_samples = canary_min_samples(); + let minimum_policy_samples = canary_min_policy_samples(); + let now = Instant::now(); + let max_age = canary_max_sample_age_seconds(); + let ( + round1_sample_count, + round1_p95_ms, + round2_sample_count, + round2_p95_ms, + aggregate_sample_count, + aggregate_p95_ms, + policy_sample_count, + policy_reject_count, + ) = hardening_telemetry_state() + .lock() + .map(|telemetry| { + let (policy_sample_count, policy_reject_count) = telemetry + .canary_policy_outcomes + .fresh_snapshot(now, max_age); + ( + telemetry + .canary_interactive_round1_latency + .fresh_sample_count(now, max_age), + telemetry + .canary_interactive_round1_latency + .fresh_p95_ms(now, max_age), + telemetry + .canary_interactive_round2_latency + .fresh_sample_count(now, max_age), + telemetry + .canary_interactive_round2_latency + .fresh_p95_ms(now, max_age), + telemetry + .canary_interactive_aggregate_latency + .fresh_sample_count(now, max_age), + telemetry + .canary_interactive_aggregate_latency + .fresh_p95_ms(now, max_age), + policy_sample_count, + policy_reject_count, + ) + }) + .unwrap_or_else(|error| { + eprintln!("warning: hardening telemetry mutex poisoned: {error}"); + (0, 0, 0, 0, 0, 0, 0, 0) + }); + for (operation, sample_count, p95_ms, max_p95_ms) in [ + ( + "interactive_round1", + round1_sample_count, + round1_p95_ms, + canary_max_interactive_round1_p95_ms(), + ), + ( + "interactive_round2", + round2_sample_count, + round2_p95_ms, + canary_max_interactive_round2_p95_ms(), + ), + ( + "interactive_aggregate", + aggregate_sample_count, + aggregate_p95_ms, + canary_max_interactive_aggregate_p95_ms(), + ), + ] { + if sample_count < minimum_samples { + failures.push(format!( + "{operation} fresh successful samples [{sample_count}] below canary minimum [{minimum_samples}]" + )); + } else if p95_ms > max_p95_ms { + failures.push(format!( + "{operation} p95 latency [{p95_ms}ms] exceeds canary gate [{max_p95_ms}ms]" + )); + } + } + + let max_policy_reject_rate_bps = canary_max_policy_reject_rate_bps(); + if policy_sample_count < minimum_policy_samples { + failures.push(format!( + "build_taproot_tx fresh policy samples [{policy_sample_count}] below canary policy minimum [{minimum_policy_samples}]" + )); + } else { + let policy_reject_rate_bps = policy_reject_count + .saturating_mul(TBTC_SIGNER_MAX_POLICY_REJECT_RATE_BPS) + .saturating_div(policy_sample_count); + 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 +} + +#[cfg(test)] +pub(crate) fn canary_missing_evidence_gate_failures() -> Vec { + let minimum_samples = canary_min_samples(); + let minimum_policy_samples = canary_min_policy_samples(); + vec![ + format!( + "interactive_round1 fresh successful samples [0] below canary minimum [{minimum_samples}]" + ), + format!( + "interactive_round2 fresh successful samples [0] below canary minimum [{minimum_samples}]" + ), + format!( + "interactive_aggregate fresh successful samples [0] below canary minimum [{minimum_samples}]" + ), + format!( + "build_taproot_tx fresh policy samples [0] below canary policy minimum [{minimum_policy_samples}]" + ), + ] +} diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs new file mode 100644 index 0000000000..dac089d1ee --- /dev/null +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -0,0 +1,14005 @@ +// Kept as a single module on purpose: scripts/run_phase5_chaos_suite.sh pins +// `engine::tests::` paths via `cargo test -- --exact`, and the phase +// docs reference them; splitting this file would break those contracts. + +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}, +}; + +// Test-only reimplementations of the removed stateless FROST primitives. +// +// The transitional coarse-FROST signing path (run_dkg dealer, start/finalize +// sign round, and these stateless generate/sign/aggregate ops) was deleted from +// the engine, API, and FFI surfaces. A handful of preserved tests still need a +// co-signer ("member 2") to sign through raw FROST while the hardened session +// API drives member 1, proving the two custody models interoperate. These +// helpers drive the frost crate directly (frost::round1::commit, +// frost::round2::sign, frost::aggregate) instead of the deleted engine ops, so +// coverage of the go-forward primitives is unchanged. They live here, gated to +// tests, and never re-enter the production/FFI surface. + +#[derive(Clone, Debug)] +struct GenerateNoncesAndCommitmentsRequest { + key_package_identifier: String, + key_package_hex: SecretHex, +} + +#[derive(Clone, Debug)] +struct GenerateNoncesAndCommitmentsResult { + nonces_hex: String, + commitment: NativeFrostCommitment, +} + +#[derive(Clone, Debug)] +struct SignShareRequest { + signing_package_hex: String, + nonces_hex: String, + key_package_identifier: String, + key_package_hex: SecretHex, +} + +#[derive(Clone, Debug)] +struct SignShareResult { + signature_share: NativeFrostSignatureShare, +} + +#[derive(Clone, Debug)] +struct AggregateRequest { + signing_package_hex: String, + signature_shares: Vec, + public_key_package: NativeFrostPublicKeyPackage, +} + +#[derive(Clone, Debug)] +struct AggregateResult { + signature_hex: String, +} + +fn generate_nonces_and_commitments( + mut request: GenerateNoncesAndCommitmentsRequest, +) -> Result { + let key_package_hex = std::mem::take(&mut request.key_package_hex); + enforce_provenance_gate()?; + + let key_package = decode_key_package( + "GenerateNoncesAndCommitments", + &request.key_package_identifier, + key_package_hex.expose_secret(), + )?; + 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) +} + +fn sign_share(mut request: SignShareRequest) -> Result { + let nonces_hex = Zeroizing::new(std::mem::take(&mut request.nonces_hex)); + let key_package_hex = std::mem::take(&mut request.key_package_hex); + 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", &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_result = decode_key_package( + "SignShare", + &request.key_package_identifier, + key_package_hex.expose_secret(), + ); + let key_package = match key_package_result { + 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) +} + +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), + }) +} + +#[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") +} + +#[derive(Deserialize)] +struct CoordinatorSeedVectorFile { + #[allow(dead_code)] + description: String, + vectors: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoordinatorSeedVector { + name: String, + key_group: String, + #[serde(rename = "sessionID")] + session_id: String, + message_digest_hex: String, + included_members: Vec, + attempt_number: u32, + wire_attempt_number: u32, + expected_shuffle_seed_int64: String, + expected_coordinator: u16, +} + +// Byte-identical copy of the canonical cross-language vector file +// generated from the Go implementation +// (pkg/frost/roast/testdata/coordinator_seed_vectors.json on the +// RFC-21 branch; regenerate there with ROAST_SEED_VECTORS_REGEN=1 +// and re-copy). Pins the RFC-21 Annex A derivation end to end so a +// semantic change on either side fails that side's own suite +// instead of fracturing coordinator agreement in a mixed +// deployment. +#[test] +fn coordinator_seed_derivation_matches_cross_language_vectors() { + let raw = include_str!("../../testdata/coordinator_seed_vectors.json"); + let file: CoordinatorSeedVectorFile = + serde_json::from_str(raw).expect("coordinator seed vector file decodes"); + assert!( + !file.vectors.is_empty(), + "expected at least one coordinator seed vector" + ); + + let mut saw_negative_seed = false; + for vector in &file.vectors { + assert_eq!( + vector.wire_attempt_number, + vector.attempt_number + 1, + "wire attempt number must be the 1-based encoding in vector [{}]", + vector.name + ); + + // In production the engine receives the 32-byte signing + // digest AS its raw message; the seed binds that padded + // message directly. Treat the vector digest as the message + // so this test exercises the exact production relationship. + let message_bytes = hex::decode(&vector.message_digest_hex).expect("vector digest decodes"); + let vector_rfc21_digest = + rfc21_message_digest(&message_bytes).expect("rfc21 message digest"); + assert_eq!( + hex::encode(vector_rfc21_digest), + vector.message_digest_hex.to_ascii_lowercase(), + "32-byte vector digest must round-trip the rfc21 padding in [{}]", + vector.name + ); + let shuffle_seed = + roast_attempt_shuffle_seed(&vector.key_group, &vector.session_id, &vector_rfc21_digest) + .expect("shuffle seed derives"); + let expected_shuffle_seed: i64 = vector + .expected_shuffle_seed_int64 + .parse() + .expect("expected shuffle seed parses as i64"); + assert_eq!( + shuffle_seed, expected_shuffle_seed, + "shuffle seed mismatch in vector [{}]", + vector.name + ); + if expected_shuffle_seed < 0 { + saw_negative_seed = true; + } + + // The shuffle-source composition uses the RFC-21 0-based + // attempt number, exactly as `validate_attempt_context` + // composes it from the 1-based wire encoding. + let coordinator = select_coordinator_identifier( + &vector.included_members, + shuffle_seed, + vector.wire_attempt_number - 1, + ) + .expect("coordinator selects"); + assert_eq!( + coordinator, vector.expected_coordinator, + "coordinator mismatch in vector [{}]", + vector.name + ); + + // End to end: an attempt context carrying the wire-encoded + // attempt number and the vector's coordinator passes the + // engine's strict validation under the vector's key group. + // The attempt_id is bound to the engine's SHA256 transcript + // digest of the message, while the seed above bound the + // padded message itself -- the two-digest split the Go layer + // relies on. + let engine_message_digest_hex = hash_hex(&message_bytes); + let fingerprint = roast_included_participants_fingerprint_hex(&vector.included_members) + .expect("fingerprint"); + let attempt_id = roast_attempt_id_hex( + &vector.session_id, + &engine_message_digest_hex, + vector.wire_attempt_number, + coordinator, + &fingerprint, + ) + .expect("attempt id"); + let attempt_context = AttemptContext { + attempt_number: vector.wire_attempt_number, + coordinator_identifier: coordinator, + included_participants: vector.included_members.clone(), + included_participants_fingerprint: fingerprint, + attempt_id, + }; + validate_attempt_context( + &vector.session_id, + &vector.key_group, + &message_bytes, + &engine_message_digest_hex, + 2, + Some(&attempt_context), + true, + ) + .unwrap_or_else(|err| { + panic!( + "vector [{}] context failed engine validation: {err:?}", + vector.name + ) + }); + } + + assert!( + saw_negative_seed, + "vector file must pin at least one negative shuffle seed" + ); +} + +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")).into(), + }); + } + } + + 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).into(), + 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 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_POLICY_HEARTBEAT_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_CANARY_MAX_INTERACTIVE_ROUND1_P95_MS_ENV); + std::env::remove_var(TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND2_P95_MS_ENV); + std::env::remove_var(TBTC_SIGNER_CANARY_MAX_INTERACTIVE_AGGREGATE_P95_MS_ENV); + std::env::remove_var(TBTC_SIGNER_CANARY_MIN_SAMPLES_ENV); + std::env::remove_var(TBTC_SIGNER_CANARY_MIN_POLICY_SAMPLES_ENV); + std::env::remove_var(TBTC_SIGNER_CANARY_MAX_SAMPLE_AGE_SECONDS_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![], + refresh_count: 0, + emergency_rekey_event: None, + consumed_interactive_attempt_markers: vec![], + aggregated_interactive_attempt_markers: vec![], + bound_key_group: None, + retired_interactive_at_unix: None, + authorized_interactive_aggregate_markers: vec![], + } +} + +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 persist_state_for_key_provider_test(session_id: &str) -> Result<(), EngineError> { + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + guard + .sessions + .entry(session_id.to_string()) + .or_default() + .bound_key_group = Some("state-key-provider-test".to_string()); + persist_engine_state_to_storage(&guard).map_err(PersistEngineStateError::into_engine_error) +} + +// Regression for resolving the state-key (a KMS/HSM subprocess for the `command` +// provider) only at the actual persist site, under the held ENGINE_STATE lock: an +// idempotent replay returns its cached result WITHOUT ever resolving the key, so a +// transient key-provider outage cannot turn a cached read into a failure. Here a +// first build persists with the working provider; a replay then succeeds even +// though the state-key command now fails. +#[test] +fn idempotent_build_tx_replay_survives_state_key_outage() { + let _guard = lock_test_state(); + let _state_path = configure_test_state_path("build_tx_replay_state_key_outage"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-build-tx-replay-key-outage"; + + // First build persists with the default (working) state-key provider. + let first = build_taproot_tx(build_policy_test_request(session_id)) + .expect("first build_taproot_tx should persist and succeed"); + + // Now make the `command` state-key provider fail. The idempotent replay + // returns the cached result without persisting, so it must not depend on the + // key command succeeding. + 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 7"); + + let replay = build_taproot_tx(build_policy_test_request(session_id)).expect( + "idempotent build_taproot_tx replay must succeed despite a failing state-key command", + ); + assert_eq!( + replay.tx_hex, first.tx_hex, + "replay must return the cached transaction" + ); + + std::env::remove_var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV); + std::env::remove_var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV); + clear_state_storage_policy_overrides(); +} + +#[test] +fn build_taproot_tx_persist_failures_roll_back_or_retry_durably() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("build_tx_persist_durability"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let existing_session = "session-build-tx-existing-state-rollback"; + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + guard.sessions.insert( + existing_session.to_string(), + SessionState { + bound_key_group: Some("existing-wallet-binding".to_string()), + ..Default::default() + }, + ); + } + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + build_taproot_tx(build_policy_test_request(existing_session)) + .expect_err("pre-replacement failure must restore an existing session's build fields"); + clear_persist_fault_injection_for_tests(); + { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = &guard.sessions[existing_session]; + assert_eq!( + session.bound_key_group.as_deref(), + Some("existing-wallet-binding") + ); + assert!(session.build_tx_request_fingerprint.is_none()); + assert!(session.tx_result.is_none()); + } + + let pre_replace_session = "session-build-tx-pre-replace-failure"; + let pre_replace_request = build_policy_test_request(pre_replace_session); + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let pre_replace_error = build_taproot_tx(pre_replace_request.clone()) + .expect_err("a pre-replacement failure must not cache success"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + pre_replace_error, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + { + let guard = state().expect("engine state").lock().expect("engine lock"); + assert!( + !guard.sessions.contains_key(pre_replace_session), + "a first-use BuildTaprootTx session must be removed on rollback" + ); + } + let pre_replace_result = + build_taproot_tx(pre_replace_request).expect("retry performs and persists the build"); + + let post_replace_session = "session-build-tx-post-replace-failure"; + let post_replace_request = build_policy_test_request(post_replace_session); + let post_replace_fingerprint = fingerprint(&post_replace_request).expect("request fingerprint"); + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let post_replace_error = build_taproot_tx(post_replace_request.clone()) + .expect_err("a post-replacement failure must report unconfirmed durability"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + post_replace_error, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + assert!(matches!( + pending_build_taproot_tx_operation(post_replace_session), + Some(PersistencePendingOperation::BuildTaprootTx { + request_fingerprint, + .. + }) if request_fingerprint == post_replace_fingerprint + )); + + // Prove the cache-hit retry attempts persistence before returning success. + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let retry_error = build_taproot_tx(post_replace_request.clone()) + .expect_err("retry must not return cached success while persistence still fails"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + retry_error, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + assert!(pending_build_taproot_tx_operation(post_replace_session).is_some()); + + let post_replace_result = build_taproot_tx(post_replace_request.clone()) + .expect("retry repairs persistence then returns the cached artifact"); + assert!(pending_build_taproot_tx_operation(post_replace_session).is_none()); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + assert_eq!( + build_taproot_tx(post_replace_request).expect("durable cached retry after restart"), + post_replace_result + ); + assert_eq!( + build_taproot_tx(build_policy_test_request(pre_replace_session)) + .expect("pre-replacement retry also survived restart"), + pre_replace_result + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn build_taproot_tx_restores_evicted_retirement_on_pre_replace_failure() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("build_tx_retired_slot_rollback"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "2"); + + let retired_session = "build-slot-retired-replay-tombstone"; + let consumed_marker = interactive_consumed_marker(&hash_hex(b"build-slot-attempt"), 1); + { + let mut guard = state().expect("state").lock().expect("engine lock"); + guard.sessions.insert( + "build-slot-active-owner".to_string(), + SessionState::default(), + ); + guard.sessions.insert( + retired_session.to_string(), + SessionState { + bound_key_group: Some("build-slot-wallet-key".to_string()), + retired_interactive_at_unix: Some(1), + consumed_interactive_attempt_markers: HashSet::from([consumed_marker.clone()]), + ..Default::default() + }, + ); + persist_engine_state_to_storage(&guard).expect("persist full shared session budget"); + } + + let newcomer = "build-slot-new-active"; + let request = build_policy_test_request(newcomer); + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let faulted = build_taproot_tx(request.clone()) + .expect_err("pre-replacement Build fault rolls back slot reservation"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + faulted, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + { + let guard = state().expect("state").lock().expect("engine lock"); + assert_eq!(guard.sessions.len(), 2); + assert!(!guard.sessions.contains_key(newcomer)); + assert!(guard.sessions[retired_session] + .consumed_interactive_attempt_markers + .contains(&consumed_marker)); + } + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + build_taproot_tx(request).expect("healthy retry evicts the retired slot and persists"); + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + { + let guard = state().expect("state").lock().expect("engine lock"); + assert_eq!(guard.sessions.len(), 2); + assert!(guard.sessions.contains_key("build-slot-active-owner")); + assert!(guard.sessions.contains_key(newcomer)); + assert!(!guard.sessions.contains_key(retired_session)); + } + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn build_taproot_tx_capacity_preflight_does_not_consume_policy_rate_token() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("build_tx_capacity_rate_preflight"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "2"); + std::env::set_var(TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE_ENV, "1"); + + let retired_session = "build-rate-protected-retired"; + let aggregate_pin = { + let mut guard = state().expect("state").lock().expect("engine lock"); + guard.sessions.insert( + "build-rate-active-owner".to_string(), + SessionState::default(), + ); + guard.sessions.insert( + retired_session.to_string(), + SessionState { + bound_key_group: Some("build-rate-wallet-key".to_string()), + retired_interactive_at_unix: Some(1), + ..Default::default() + }, + ); + Arc::clone(&guard.sessions[retired_session].aggregate_eviction_pin) + }; + + let request = build_policy_test_request("build-rate-new-active"); + let rejected = build_taproot_tx(request.clone()) + .expect_err("a pinned full registry must reject before policy charging"); + assert!(matches!( + rejected, + EngineError::Internal(ref message) + if message.contains("no retired session is available for eviction") + )); + + drop(aggregate_pin); + build_taproot_tx(request) + .expect("the first policy token remains available after capacity recovers"); + + std::env::remove_var(TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE_ENV); + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[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 unknown_profile_value_fails_closed_to_production() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + // A typo / unknown profile value must be treated as production + // (fail-closed) rather than panicking. The previous panic on the + // unvalidated env-fallback path turned one typo into a process-wide DoS. + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, "staging"); + assert!( + signer_profile_is_production(), + "unrecognized profile must fail closed to production" + ); + + std::env::remove_var(TBTC_SIGNER_PROFILE_ENV); +} + +#[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(); +} + +// Real per-seat distributed-DKG material in the native (Go-facing) form the +// persist op takes: each member's OWN key package plus the shared public key +// package. Dealer-generated here for brevity, but shaped like a distributed +// Part3 output (one key package per member + one public key package). +fn sample_distributed_dkg_native_material( + seed: u8, +) -> ( + crate::api::NativeFrostPublicKeyPackage, + BTreeMap, +) { + let identifiers = [1_u16, 2, 3] + .iter() + .map(|m| participant_identifier_to_frost_identifier(*m).expect("frost identifier")) + .collect::>(); + let rng = ZeroizingChaCha20Rng::from_seed([seed; 32]); + let (shares, public_key_package) = frost::keys::generate_with_dealer( + 3, + 2, + frost::keys::IdentifierList::Custom(&identifiers), + rng, + ) + .expect("generate_with_dealer"); + + // Normalize to even-Y exactly as dkg_part3 does, so this material matches a + // real distributed DKG's output (the x-only verifying key is even-Y per + // BIP-340); raw generate_with_dealer output is odd-Y for some seeds. + let is_even_y = public_key_package.has_even_y(); + let public_key_package = public_key_package.into_even_y(Some(is_even_y)); + let native_public = + native_public_key_package_from_frost(&public_key_package).expect("native public package"); + + let mut native_key_packages = BTreeMap::new(); + for member in [1_u16, 2, 3] { + let frost_id = + participant_identifier_to_frost_identifier(member).expect("frost identifier"); + let share = shares.get(&frost_id).expect("share for member").clone(); + let key_package = frost::keys::KeyPackage::try_from(share) + .expect("key package") + .into_even_y(Some(is_even_y)); + native_key_packages.insert( + member, + crate::api::NativeFrostKeyPackage { + identifier: frost_identifier_to_go_string(*key_package.identifier()), + data_hex: hex::encode(key_package.serialize().expect("serialize key package")) + .into(), + }, + ); + } + + (native_public, native_key_packages) +} + +// A multi-seat operator persists several local seats of the SAME distributed DKG +// into one session; the key packages must accumulate (not overwrite), so every +// local seat can later open an interactive signing session. +#[test] +fn persist_distributed_dkg_key_package_accumulates_seats_under_one_session() { + let _guard = lock_test_state(); + reset_for_tests(); + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(7); + let session_id = "session-distributed-persist-accumulate".to_string(); + + let result1 = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public.clone(), + }) + .expect("persist seat 1"); + assert_eq!(result1.threshold, 2); + assert_eq!(result1.participant_count, 3); + assert!(!result1.key_group.is_empty()); + + let result2 = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 2, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&2).expect("seat 2").clone(), + public_key_package: native_public.clone(), + }) + .expect("persist seat 2"); + assert_eq!( + result2.key_group, result1.key_group, + "sibling seats of one DKG must share the key group" + ); + + let guard = state().expect("state").lock().expect("engine lock"); + let session = guard.sessions.get(&session_id).expect("session exists"); + let key_packages = session + .dkg_key_packages + .as_ref() + .expect("key packages present"); + assert!( + key_packages.contains_key(&1) && key_packages.contains_key(&2), + "both accumulated seats must be stored (got {:?})", + key_packages.keys().collect::>() + ); + assert!(session.dkg_public_key_package.is_some()); +} + +#[test] +fn persist_distributed_dkg_key_package_rejects_second_key_group_owner() { + let _guard = lock_test_state(); + reset_for_tests(); + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(17); + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "wallet-owner-a".to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public.clone(), + }) + .expect("first wallet owner persists"); + + let duplicate = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "wallet-owner-b".to_string(), + participant_identifier: 2, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&2).expect("seat 2").clone(), + public_key_package: native_public, + }) + .expect_err("one key_group must not have two wallet-session owners"); + assert!( + matches!(duplicate, EngineError::SessionConflict { ref session_id } + if session_id == "wallet-owner-b"), + "unexpected error: {duplicate:?}" + ); + + let guard = state().expect("state").lock().expect("engine lock"); + assert!(guard.sessions.contains_key("wallet-owner-a")); + assert!(!guard.sessions.contains_key("wallet-owner-b")); +} + +#[test] +fn persist_distributed_dkg_key_package_rejects_a_bound_signing_session() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "roast-session-already-bound"; + { + let mut guard = state().expect("state").lock().expect("engine lock"); + guard.sessions.insert( + session_id.to_string(), + SessionState { + bound_key_group: Some("original-wallet-key-group".to_string()), + ..Default::default() + }, + ); + } + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(19); + let error = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public, + }) + .expect_err("a per-signing session must never become a DKG wallet owner"); + assert!( + matches!(error, EngineError::SessionConflict { session_id: ref rejected } + if rejected == session_id), + "unexpected error: {error:?}" + ); + + let guard = state().expect("state").lock().expect("engine lock"); + let session = guard + .sessions + .get(session_id) + .expect("bound session remains"); + assert!(session.dkg_result.is_none()); + assert!(session.dkg_key_packages.is_none()); + assert_eq!( + session.bound_key_group.as_deref(), + Some("original-wallet-key-group") + ); +} + +#[test] +fn persist_distributed_dkg_key_package_pre_replace_failure_restores_retired_slot() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("distributed_dkg_persist_rollback"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "2"); + + let retired_session = "distributed-dkg-retired-replay-tombstone"; + let consumed_marker = interactive_consumed_marker(&hash_hex(b"distributed-dkg-attempt"), 1); + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + guard.sessions.insert( + "distributed-dkg-active-owner".to_string(), + SessionState::default(), + ); + guard.sessions.insert( + retired_session.to_string(), + SessionState { + bound_key_group: Some("distributed-dkg-retired-key".to_string()), + retired_interactive_at_unix: Some(1), + consumed_interactive_attempt_markers: HashSet::from([consumed_marker.clone()]), + ..Default::default() + }, + ); + persist_engine_state_to_storage(&guard).expect("persist full shared session budget"); + } + + let session_id = "session-distributed-dkg-persist-rollback"; + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(23); + let request = crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public, + }; + + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let error = persist_distributed_dkg_key_package(request.clone()) + .expect_err("pre-replacement DKG persist fault must roll back"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + error, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + { + let guard = state().expect("engine state").lock().expect("engine lock"); + assert!( + !guard.sessions.contains_key(session_id), + "failed first persistence must not leave in-memory-only DKG material" + ); + assert!(guard.sessions[retired_session] + .consumed_interactive_attempt_markers + .contains(&consumed_marker)); + assert_eq!(guard.sessions.len(), 2); + } + + let result = persist_distributed_dkg_key_package(request) + .expect("retry installs and persists the distributed DKG package"); + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard + .sessions + .get(session_id) + .expect("reloaded DKG session"); + assert_eq!(session.dkg_result.as_ref(), Some(&result)); + assert!(session + .dkg_key_packages + .as_ref() + .is_some_and(|packages| packages.contains_key(&1))); + assert_eq!(guard.sessions.len(), 2); + assert!(guard.sessions.contains_key("distributed-dkg-active-owner")); + assert!(!guard.sessions.contains_key(retired_session)); + drop(guard); + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn distributed_dkg_pre_replace_rollback_preserves_existing_seats() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("distributed_dkg_existing_seat_rollback"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-distributed-dkg-existing-seat-rollback"; + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(29); + let request_for = |participant_identifier| crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.to_string(), + participant_identifier, + threshold: 2, + participant_count: 3, + key_package: native_key_packages + .get(&participant_identifier) + .expect("local seat") + .clone(), + public_key_package: native_public.clone(), + }; + + let baseline = persist_distributed_dkg_key_package(request_for(1)) + .expect("persist baseline distributed-DKG seat"); + let baseline_key_package = { + let guard = state().expect("engine state").lock().expect("engine lock"); + guard.sessions[session_id] + .dkg_key_packages + .as_ref() + .expect("key packages")[&1] + .serialize() + .expect("serialize baseline key package") + }; + + // Replacing the same seat before a failed write must restore the prior + // secret package rather than dropping the session's only local seat. + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + persist_distributed_dkg_key_package(request_for(1)) + .expect_err("same-seat persist fault must restore the existing package"); + clear_persist_fault_injection_for_tests(); + { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = &guard.sessions[session_id]; + assert_eq!(session.dkg_result.as_ref(), Some(&baseline)); + assert_eq!( + session + .dkg_key_packages + .as_ref() + .expect("restored key packages")[&1] + .serialize() + .expect("serialize restored key package"), + baseline_key_package + ); + } + + // Adding a sibling seat before a failed write must leave the prior seat set + // untouched; a subsequent healthy retry can then add it normally. + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + persist_distributed_dkg_key_package(request_for(2)) + .expect_err("sibling-seat persist fault must roll back only the new seat"); + clear_persist_fault_injection_for_tests(); + { + let guard = state().expect("engine state").lock().expect("engine lock"); + let packages = guard.sessions[session_id] + .dkg_key_packages + .as_ref() + .expect("baseline key packages"); + assert!(packages.contains_key(&1)); + assert!(!packages.contains_key(&2)); + } + persist_distributed_dkg_key_package(request_for(2)) + .expect("healthy retry adds the sibling seat"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +// The op rejects a key package whose own identifier does not match the claimed +// participant, and refuses to install a DIFFERENT DKG's key group over a session +// that already holds one. +#[test] +fn persist_distributed_dkg_key_package_rejects_mismatched_and_conflicting_inputs() { + let _guard = lock_test_state(); + reset_for_tests(); + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(7); + + let mismatch = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-persist-mismatch".to_string(), + participant_identifier: 2, // the key package below is seat 1's + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public.clone(), + }) + .expect_err("identifier mismatch must be rejected"); + assert!( + matches!(mismatch, EngineError::Validation(_)), + "got {mismatch:?}" + ); + + // A threshold that disagrees with the key package's embedded min_signers (the + // material is 2-of-3) must be rejected at persist, not burned at share release. + let threshold_mismatch = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-persist-threshold-mismatch".to_string(), + participant_identifier: 1, + threshold: 3, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public.clone(), + }) + .expect_err("a threshold disagreeing with the key package must be rejected"); + assert!( + matches!(threshold_mismatch, EngineError::Validation(_)), + "got {threshold_mismatch:?}" + ); + + let session_id = "session-persist-conflict".to_string(); + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public.clone(), + }) + .expect("first DKG persists"); + + let (other_public, other_key_packages) = sample_distributed_dkg_native_material(9); + let conflict = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: other_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: other_public, + }) + .expect_err("a different key group for the same session must conflict"); + assert!( + matches!(conflict, EngineError::SessionConflict { .. }), + "got {conflict:?}" + ); +} + +// The op writes durable signing material, so it enforces the DKG admission policy +// over the participant set DERIVED from the public key package: a group that +// includes a non-allowlisted participant is rejected before anything is stored. +#[test] +fn persist_distributed_dkg_key_package_enforces_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"); + + // The group's members are 1, 2, 3 (from the public key package); member 3 is + // not on the allowlist, so persistence must be refused. + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(7); + let err = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-persist-admission".to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public, + }) + .expect_err("a group with a non-allowlisted participant must be rejected"); + + let EngineError::AdmissionPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant: {err:?}"); + }; + assert_eq!(reason_code, "participant_identifier_not_allowlisted"); + + // Restore the global policy env so later tests see the default configuration + // (reset_for_tests does not clear the admission overrides). + clear_state_storage_policy_overrides(); +} + +// A distributed DKG whose group includes an auto-quarantined operator must be +// refused before persistence, exactly as the dealer run_dkg refuses it. +#[test] +fn persist_distributed_dkg_key_package_rejects_quarantined_participant() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + 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"); + + // Operator 3, a member of the group below (members 1,2,3), is auto-quarantined. + { + let mut guard = state().expect("state").lock().expect("engine lock"); + guard.quarantined_operator_identifiers.insert(3); + } + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(7); + let err = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-persist-quarantine".to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public, + }) + .expect_err("a group including a quarantined operator must be rejected"); + assert!( + matches!(err, EngineError::QuarantinePolicyRejected { ref reason_code, .. } + if reason_code == "operator_auto_quarantined"), + "unexpected error: {err:?}" + ); + + clear_state_storage_policy_overrides(); +} + +// The op writes signing material to durable state, so it must enforce the same +// provenance gate run_dkg and the interactive path do: an unattested runtime +// cannot install distributed-DKG signing material. +#[test] +fn persist_distributed_dkg_key_package_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 (native_public, native_key_packages) = sample_distributed_dkg_native_material(7); + let err = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-persist-provenance".to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public, + }) + .expect_err("expected provenance gate rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant: {err:?}"); + }; + assert_eq!(reason_code, "missing_attestation_status"); + + clear_state_storage_policy_overrides(); +} + +// A key package whose SECRET signing share does not derive to its (public) +// verifying share must be rejected: it would open interactive attempts and burn +// them, producing shares that never verify. Crafted with seat 1's identity and +// verifying share (so it matches the public package) but seat 2's signing share. +#[test] +fn persist_distributed_dkg_key_package_rejects_signing_share_not_deriving_to_public() { + let _guard = lock_test_state(); + reset_for_tests(); + + let identifiers = [1_u16, 2, 3] + .iter() + .map(|m| participant_identifier_to_frost_identifier(*m).expect("frost id")) + .collect::>(); + let rng = ZeroizingChaCha20Rng::from_seed([7_u8; 32]); + let (shares, public_key_package) = frost::keys::generate_with_dealer( + 3, + 2, + frost::keys::IdentifierList::Custom(&identifiers), + rng, + ) + .expect("generate_with_dealer"); + let is_even_y = public_key_package.has_even_y(); + let public_key_package = public_key_package.into_even_y(Some(is_even_y)); + let native_public = + native_public_key_package_from_frost(&public_key_package).expect("native public"); + + let key_package_of = |member: u16| { + let id = participant_identifier_to_frost_identifier(member).expect("frost id"); + frost::keys::KeyPackage::try_from(shares.get(&id).expect("share").clone()) + .expect("key package") + .into_even_y(Some(is_even_y)) + }; + let key_package_1 = key_package_of(1); + let key_package_2 = key_package_of(2); + + // Seat 1's identity + verifying share, but seat 2's signing share. + let corrupt = frost::keys::KeyPackage::new( + *key_package_1.identifier(), + *key_package_2.signing_share(), + *key_package_1.verifying_share(), + *key_package_1.verifying_key(), + *key_package_1.min_signers(), + ); + let corrupt_data = corrupt.serialize().expect("serialize corrupt key package"); + + let err = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-persist-share-mismatch".to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: crate::api::NativeFrostKeyPackage { + identifier: frost_identifier_to_go_string(*key_package_1.identifier()), + data_hex: hex::encode(corrupt_data).into(), + }, + public_key_package: native_public, + }) + .expect_err("a signing share not deriving to its verifying share must be rejected"); + assert!(matches!(err, EngineError::Validation(_)), "got {err:?}"); +} + +// A second seat of the SAME session (same group key) must carry the SAME public +// key package. A package with the same group verifying key but a different +// verifying-shares map must be rejected on accumulate, or later signing would use +// public material inconsistent with the newly stored key. +#[test] +fn persist_distributed_dkg_key_package_rejects_mismatched_public_package_on_accumulate() { + let _guard = lock_test_state(); + reset_for_tests(); + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(7); + let session_id = "session-persist-accumulate-mismatch".to_string(); + + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public.clone(), + }) + .expect("first seat persists"); + + // Same group verifying key (so the same key group), but a different shares map: + // give seat 3 seat 2's verifying share. Seat 1's own share is unchanged, so its + // key package still validates - only the accumulate public-package check fails. + let mut tampered = native_public.clone(); + let id2 = frost_identifier_to_go_string( + participant_identifier_to_frost_identifier(2).expect("frost id"), + ); + let id3 = frost_identifier_to_go_string( + participant_identifier_to_frost_identifier(3).expect("frost id"), + ); + let share2 = tampered + .verifying_shares + .get(&id2) + .expect("share 2") + .clone(); + tampered.verifying_shares.insert(id3, share2); + + let err = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: tampered, + }) + .expect_err("a mismatched public package for the same session must be rejected"); + assert!(matches!(err, EngineError::Validation(_)), "got {err:?}"); +} + +#[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 + )); +} + +fn taproot_prevout_script_hex() -> String { + format!("5120{}", "33".repeat(32)) +} + +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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 9_000, + }], + script_tree_hex: None, + } +} + +#[test] +fn build_taproot_tx_rejects_invalid_or_non_p2tr_prevout_scripts() { + let _guard = lock_test_state(); + reset_for_tests(); + + for (case, script_pubkey_hex, expected_detail) in [ + ("empty", "", "not a P2TR prevout"), + ("non-hex", "zz", "invalid input script_pubkey_hex"), + ("malformed", "4c", "invalid input script_pubkey_hex"), + ( + "non-p2tr", + "00141111111111111111111111111111111111111111", + "not a P2TR prevout", + ), + ] { + let mut request = build_policy_test_request(&format!("session-prevout-{case}")); + request.inputs[0].script_pubkey_hex = script_pubkey_hex.to_string(); + + let error = build_taproot_tx(request) + .expect_err("an invalid or non-P2TR prevout script must fail closed"); + let EngineError::Validation(detail) = error else { + panic!("unexpected error variant: {error:?}"); + }; + assert!( + detail.contains(expected_detail), + "case [{case}] returned unexpected validation detail: {detail}" + ); + } +} + +#[test] +fn signing_policy_firewall_rejects_every_malformed_cached_artifact_shape() { + 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"); + configure_required_signing_policy_limits_for_tests(); + + let session_id = "session-invalid-policy-artifact"; + let valid = build_taproot_tx(build_policy_test_request(session_id)) + .expect("build a valid policy artifact"); + let message = valid.taproot_key_spend_sighashes_hex[0].clone(); + + let mut cases = Vec::new(); + + let mut wrong_session = valid.clone(); + wrong_session.session_id = "different-session".to_string(); + cases.push(("wrong session", wrong_session)); + + let mut non_hex_tx = valid.clone(); + non_hex_tx.tx_hex = "zz".to_string(); + cases.push(("non-hex transaction", non_hex_tx)); + + let mut invalid_tx = valid.clone(); + invalid_tx.tx_hex = "00".to_string(); + cases.push(("invalid transaction", invalid_tx)); + + let mut legacy_empty_sighashes = valid.clone(); + legacy_empty_sighashes + .taproot_key_spend_sighashes_hex + .clear(); + cases.push(("pre-ABI-3 empty sighash list", legacy_empty_sighashes)); + + let mut non_hex_sighash = valid.clone(); + non_hex_sighash.taproot_key_spend_sighashes_hex[0] = "zz".to_string(); + cases.push(("non-hex sighash", non_hex_sighash)); + + let mut short_sighash = valid; + short_sighash.taproot_key_spend_sighashes_hex[0] = "00".to_string(); + cases.push(("short sighash", short_sighash)); + + for (case, artifact) in cases { + let error = enforce_signing_message_binding_to_policy_checked_build_tx( + session_id, + &message, + None, + Some(&artifact), + None, + ) + .expect_err("a malformed policy artifact must fail closed"); + assert!( + matches!(error, EngineError::SigningPolicyRejected { ref reason_code, .. } + if reason_code == "invalid_policy_checked_build_tx_artifact"), + "case [{case}] returned unexpected error: {error:?}" + ); + } + + let metrics = hardening_metrics(); + assert_eq!(metrics.build_taproot_tx_policy_reject_total, 6); + clear_state_storage_policy_overrides(); +} + +#[test] +fn build_taproot_tx_records_ordered_bip341_key_spend_sighashes() { + let _guard = lock_test_state(); + reset_for_tests(); + + let request = BuildTaprootTxRequest { + session_id: "session-bip341-policy-messages".to_string(), + inputs: vec![ + crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 0, + value_sats: 10_000, + script_pubkey_hex: taproot_prevout_script_hex(), + }, + crate::api::TxInput { + txid_hex: "22".repeat(32), + vout: 1, + value_sats: 11_000, + script_pubkey_hex: format!("5120{}", "44".repeat(32)), + }, + ], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "55".repeat(32)), + value_sats: 20_000, + }], + script_tree_hex: None, + }; + let result = build_taproot_tx(request.clone()).expect("build policy transaction"); + + assert_eq!(result.taproot_key_spend_sighashes_hex.len(), 2); + assert_ne!( + result.taproot_key_spend_sighashes_hex[0], result.taproot_key_spend_sighashes_hex[1], + "BIP-341 commits to the input index" + ); + assert!(result + .taproot_key_spend_sighashes_hex + .iter() + .all(|sighash| sighash.len() == 64 && hex::decode(sighash).is_ok())); + + let tx_bytes = hex::decode(&result.tx_hex).expect("transaction hex"); + let tx: Transaction = deserialize(&tx_bytes).expect("transaction decode"); + assert_eq!( + tx.version, + Version::ONE, + "BuildTaprootTx must match the Go host's canonical transaction version" + ); + let prevouts = request + .inputs + .iter() + .map(|input| TxOut { + value: Amount::from_sat(input.value_sats), + script_pubkey: ScriptBuf::from_bytes( + hex::decode(&input.script_pubkey_hex).expect("prevout script hex"), + ), + }) + .collect::>(); + let mut cache = SighashCache::new(&tx); + for (input_index, recorded) in result.taproot_key_spend_sighashes_hex.iter().enumerate() { + let expected = cache + .taproot_key_spend_signature_hash( + input_index, + &Prevouts::All(&prevouts), + TapSighashType::Default, + ) + .expect("BIP-341 sighash"); + assert_eq!(recorded, &hex::encode(expected.to_byte_array())); + } + assert!( + !result + .taproot_key_spend_sighashes_hex + .contains(&hash_hex(&tx_bytes)), + "SHA256(unsigned_tx) is not a BIP-341 signing message" + ); +} + +#[test] +fn signing_policy_firewall_is_enforced_in_production_by_default() { + let _guard = lock_test_state(); + reset_for_tests(); + + // Development without the opt-in flag: not enforced (unchanged default). + std::env::remove_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV); + assert!( + !signing_policy_firewall_enforced(), + "firewall must stay opt-in outside production" + ); + + // Production: always enforced, no flag required (mirrors the provenance gate). + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION); + assert!( + signing_policy_firewall_enforced(), + "firewall must be force-enabled in production" + ); + + reset_for_tests(); +} + +#[test] +fn signing_policy_firewall_config_uses_builtin_defaults_in_production() { + let _guard = lock_test_state(); + reset_for_tests(); + std::env::set_var(TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION); + + // No explicit firewall policy env -> conservative built-in defaults, so a + // production signer boots without shipping full policy config. + for env in [ + TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, + TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT_ENV, + TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS_ENV, + TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS_ENV, + ] { + std::env::remove_var(env); + } + + let config = load_signing_policy_firewall_config() + .expect("firewall config loads with built-in defaults") + .expect("firewall is enforced in production"); + + let expected_classes: std::collections::HashSet = DEFAULT_ALLOWED_SCRIPT_CLASSES + .iter() + .map(|class| class.to_string()) + .collect(); + assert_eq!(config.allowed_script_classes, expected_classes); + // "other" (non-standard) is not in the default allowlist -> fails closed. + assert!(!config.allowed_script_classes.contains("other")); + assert_eq!(config.max_output_count, DEFAULT_MAX_OUTPUT_COUNT); + assert_eq!(config.max_output_value_sats, BITCOIN_MAX_MONEY_SATS); + assert_eq!(config.max_total_output_value_sats, BITCOIN_MAX_MONEY_SATS); + + reset_for_tests(); +} + +#[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, + script_pubkey_hex: taproot_prevout_script_hex(), + }, + crate::api::TxInput { + txid_hex: "22".repeat(32), + vout: 0, + value_sats: 1, + script_pubkey_hex: taproot_prevout_script_hex(), + }, + ], + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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 signing_policy_firewall_rejects_equal_utc_window_bounds() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + // A degenerate equal-bounds window (start == end) must be rejected at load + // time. utc_hour_in_window treats start == end as "always in window", so + // accepting it would silently disable the time-of-day control (fail-open). + 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(); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR_ENV, "12"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR_ENV, "12"); + + let err = load_signing_policy_firewall_config() + .expect_err("equal-bounds UTC window must be rejected at load time"); + let EngineError::Internal(message) = err else { + panic!("unexpected error variant: {err:?}"); + }; + assert!( + message.contains("must differ"), + "unexpected validation message: {message}" + ); + + 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"); + + // build_taproot_tx is counted the moment it is called, BEFORE the + // provenance gate rejects it: the call counter increments but the + // success counter does not. + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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 metrics = hardening_metrics(); + assert_eq!(metrics.build_taproot_tx_calls_total, 1); + assert_eq!(metrics.build_taproot_tx_success_total, 0); + assert_eq!(metrics.refresh_shares_calls_total, 0); + assert_eq!(metrics.refresh_shares_success_total, 0); + + clear_state_storage_policy_overrides(); +} + +#[test] +fn refresh_shares_fails_closed_without_mutating_wallet_state() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("refresh_shares_fail_closed"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-refresh-fail-closed"; + ensure_interactive_dkg_session(session_id, "refresh-fail-closed-key-group"); + let persisted_state_before = std::fs::read(&state_path).expect("read baseline state"); + let (key_packages_before, public_key_package_before, dkg_result_before) = { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.get(session_id).expect("wallet session"); + ( + session.dkg_key_packages.clone(), + session.dkg_public_key_package.clone(), + session.dkg_result.clone(), + ) + }; + + let error = refresh_shares(RefreshSharesRequest { + session_id: session_id.to_string(), + current_shares: vec![crate::api::ShareMaterial { + identifier: 1, + encrypted_share_hex: "aaaa".to_string(), + }], + }) + .expect_err("RefreshShares must reject until a cryptographic protocol is implemented"); + assert!(matches!( + error, + EngineError::CryptographicRefreshNotSupported { ref session_id } + if session_id == "session-refresh-fail-closed" + )); + + { + let guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.get(session_id).expect("wallet session"); + assert_eq!(guard.refresh_epoch_counter, 0); + assert!(session.refresh_request_fingerprint.is_none()); + assert!(session.refresh_result.is_none()); + assert!(session.refresh_history.is_empty()); + assert_eq!(session.refresh_count, 0); + assert_eq!(session.dkg_key_packages, key_packages_before); + assert_eq!(session.dkg_public_key_package, public_key_package_before); + assert_eq!(session.dkg_result, dkg_result_before); + } + assert_eq!( + std::fs::read(&state_path).expect("read state after rejection"), + persisted_state_before, + ); + + let metrics = hardening_metrics(); + assert_eq!(metrics.refresh_shares_calls_total, 1); + assert_eq!(metrics.refresh_shares_success_total, 0); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn first_refresh_deadline_survives_restart_and_becomes_overdue() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("first_refresh_deadline"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV, "60"); + + let overdue_session_id = "session-first-refresh-overdue"; + let fresh_session_id = "session-first-refresh-fresh"; + ensure_interactive_dkg_session(overdue_session_id, "first-refresh-overdue-key-group"); + ensure_interactive_dkg_session(fresh_session_id, "first-refresh-fresh-key-group"); + + let created_at_unix = now_unix().saturating_sub(600); + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + guard + .sessions + .get_mut(overdue_session_id) + .expect("overdue wallet session") + .dkg_result + .as_mut() + .expect("DKG result") + .created_at_unix = created_at_unix; + persist_engine_state_to_storage(&guard).expect("persist DKG creation anchors"); + } + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + let status = refresh_cadence_status(RefreshCadenceStatusRequest { + session_id: overdue_session_id.to_string(), + }) + .expect("refresh cadence status"); + assert_eq!(status.refresh_count, 0); + assert_eq!(status.last_refresh_epoch, 0); + assert_eq!(status.next_refresh_due_unix, created_at_unix + 60); + assert!(status.overdue); + + let fresh_status = refresh_cadence_status(RefreshCadenceStatusRequest { + session_id: fresh_session_id.to_string(), + }) + .expect("fresh refresh cadence status"); + assert!(!fresh_status.overdue); + assert_eq!(hardening_metrics().refresh_cadence_overdue_sessions, 1); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn legacy_synthetic_refresh_metadata_cannot_postpone_cadence_or_claim_continuity() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("legacy_synthetic_refresh_metadata"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV, "60"); + + let session_id = "session-legacy-synthetic-refresh"; + let key_group = "legacy-synthetic-refresh-key-group"; + ensure_interactive_dkg_session(session_id, key_group); + let created_at_unix = now_unix().saturating_sub(600); + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.get_mut(session_id).expect("wallet session"); + session + .dkg_result + .as_mut() + .expect("DKG result") + .created_at_unix = created_at_unix; + session.refresh_request_fingerprint = Some("legacy-synthetic-request".to_string()); + session.refresh_result = Some(RefreshSharesResult { + session_id: session_id.to_string(), + refresh_epoch: 1, + new_shares: vec![crate::api::ShareMaterial { + identifier: 1, + encrypted_share_hex: "synthetic-hash".to_string(), + }], + }); + session.refresh_history = vec![RefreshHistoryRecord { + refresh_epoch: 1, + refreshed_at_unix: now_unix(), + share_count: 1, + key_group: Some(key_group.to_string()), + request_fingerprint: Some("legacy-synthetic-request".to_string()), + }]; + session.refresh_count = 1; + guard.refresh_epoch_counter = 1; + persist_engine_state_to_storage(&guard).expect("persist legacy synthetic metadata"); + } + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + let status = refresh_cadence_status(RefreshCadenceStatusRequest { + session_id: session_id.to_string(), + }) + .expect("refresh cadence status"); + assert_eq!(status.refresh_count, 0); + assert_eq!(status.last_refresh_epoch, 0); + assert_eq!(status.next_refresh_due_unix, created_at_unix + 60); + assert!(status.overdue); + assert!(!status.continuity_preserved); + assert_eq!( + status.continuity_reference_key_group.as_deref(), + Some(key_group) + ); + assert_eq!(hardening_metrics().refresh_cadence_overdue_sessions, 1); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn unanchored_legacy_refresh_session_is_immediately_overdue_after_restart() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("unanchored_legacy_refresh"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV, "60"); + + let session_id = "session-unanchored-legacy-refresh"; + let plain_session_id = "session-unanchored-without-refresh-artifacts"; + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.entry(session_id.to_string()).or_default(); + assert!(session.dkg_result.is_none()); + session.refresh_request_fingerprint = Some("legacy-refresh-only-request".to_string()); + session.refresh_result = Some(RefreshSharesResult { + session_id: session_id.to_string(), + refresh_epoch: 1, + new_shares: vec![crate::api::ShareMaterial { + identifier: 1, + encrypted_share_hex: "aa".repeat(32), + }], + }); + session.refresh_history = vec![RefreshHistoryRecord { + refresh_epoch: 1, + refreshed_at_unix: now_unix(), + share_count: 1, + key_group: None, + request_fingerprint: Some("legacy-refresh-only-request".to_string()), + }]; + session.refresh_count = 1; + guard.refresh_epoch_counter = 1; + guard + .sessions + .entry(plain_session_id.to_string()) + .or_default(); + persist_engine_state_to_storage(&guard).expect("persist refresh-only legacy session"); + } + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + let status = refresh_cadence_status(RefreshCadenceStatusRequest { + session_id: session_id.to_string(), + }) + .expect("refresh cadence status"); + assert_eq!(status.refresh_count, 0); + assert_eq!(status.last_refresh_epoch, 0); + assert_eq!(status.next_refresh_due_unix, 0); + assert!(status.overdue); + assert!(!status.continuity_preserved); + assert!(status.continuity_reference_key_group.is_none()); + + let plain_status = refresh_cadence_status(RefreshCadenceStatusRequest { + session_id: plain_session_id.to_string(), + }) + .expect("plain session cadence status"); + assert!(!plain_status.overdue); + assert!(plain_status.continuity_preserved); + assert_eq!(hardening_metrics().refresh_cadence_overdue_sessions, 1); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn refresh_cadence_overdue_sentinel_survives_clock_rollback() { + assert!(refresh_cadence_is_overdue(0, 0)); + assert!(refresh_cadence_is_overdue(101, 100)); + assert!(!refresh_cadence_is_overdue(100, 100)); + assert!(!refresh_cadence_is_overdue(99, 100)); +} + +#[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!(!initial_status.promotion_gate_passed); + assert_eq!(initial_status.recommended_next_percent, None); + + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + let ready_10 = canary_rollout_status().expect("10% stage has fresh evidence"); + assert!(ready_10.promotion_gate_passed); + assert_eq!(ready_10.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 after_50 = canary_rollout_status().expect("promotion resets stage evidence"); + assert!(!after_50.promotion_gate_passed); + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + 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 completed_canary_rollback_retries_are_idempotent() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("completed_canary_rollback_retry"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + promote_canary(PromoteCanaryRequest { target_percent: 50 }) + .expect("promote canary before rollback"); + let request = RollbackCanaryRequest { + reason: "lost rollback response".to_string(), + }; + let completed = rollback_canary(request.clone()).expect("complete canary rollback"); + assert_eq!(completed.from_percent, 50); + assert_eq!(completed.to_percent, 10); + + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + let status_before_retry = canary_rollout_status().expect("canary status before rollback retry"); + assert!(status_before_retry.promotion_gate_passed); + let rollback_count_before_retry = hardening_metrics().canary_rollbacks_total; + + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let first_retry = rollback_canary(request.clone()) + .expect("completed rollback retry must not attempt persistence"); + let second_retry = + rollback_canary(request).expect("repeated completed rollback retry is stable"); + clear_persist_fault_injection_for_tests(); + + assert_eq!(first_retry.from_percent, 10); + assert_eq!(first_retry.to_percent, 10); + assert_eq!(first_retry.config_version, completed.config_version); + assert_eq!( + first_retry.rolled_back_at_unix, + completed.rolled_back_at_unix + ); + assert_eq!(second_retry, first_retry); + assert_eq!( + canary_rollout_status().expect("canary status after rollback retries"), + status_before_retry, + "rollback retries must not mutate rollout state or reset promotion evidence" + ); + assert_eq!( + hardening_metrics().canary_rollbacks_total, + rollback_count_before_retry, + "rollback retries must not be counted again" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn fresh_canary_rollback_noop_does_not_require_a_state_parent_directory() { + let _guard = lock_test_state(); + clear_state_storage_policy_overrides(); + + let missing_parent = std::env::temp_dir().join(format!( + "frost_tbtc_missing_state_parent_{}", + std::process::id() + )); + let state_path = missing_parent.join("state.json"); + cleanup_test_state_artifacts(&state_path); + let _ = std::fs::remove_dir(&missing_parent); + std::env::set_var(TBTC_SIGNER_STATE_PATH_ENV, &state_path); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + let _ = std::fs::remove_dir(&missing_parent); + assert!(!missing_parent.exists()); + + let status_before = canary_rollout_status().expect("fresh canary rollout status"); + let directory_syncs_before = state_file_parent_directory_syncs_for_tests(); + let rollback = rollback_canary(RollbackCanaryRequest { + reason: "fresh state no-op".to_string(), + }) + .expect("fresh-state rollback no-op"); + + assert_eq!(rollback.from_percent, 10); + assert_eq!(rollback.to_percent, 10); + assert_eq!(rollback.config_version, status_before.config_version); + assert_eq!( + canary_rollout_status().expect("fresh canary status after no-op"), + status_before + ); + assert_eq!( + state_file_parent_directory_syncs_for_tests(), + directory_syncs_before, + "an absent state file must not trigger a parent-directory sync" + ); + assert!( + !missing_parent.exists(), + "a fresh-state no-op must not create persistence directories" + ); + + std::env::remove_var(TBTC_SIGNER_STATE_PATH_ENV); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + let _ = std::fs::remove_dir(&missing_parent); + clear_state_storage_policy_overrides(); +} + +#[test] +fn completed_canary_rollback_retry_after_restart_repairs_directory_durability() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("completed_canary_rollback_restart_retry"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + promote_canary(PromoteCanaryRequest { target_percent: 50 }) + .expect("promote canary before rollback"); + let request = RollbackCanaryRequest { + reason: "lost rollback response across restart".to_string(), + }; + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let rollback_error = rollback_canary(request.clone()) + .expect_err("post-rename rollback failure must remain unacknowledged"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + rollback_error, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + assert!(pending_canary_rollback_result(&request.reason).is_some()); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + assert!( + pending_canary_rollback_result(&request.reason).is_none(), + "the process-local pending marker must be absent after restart" + ); + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + let status_before_retry = + canary_rollout_status().expect("reloaded rollback state before retry"); + assert_eq!(status_before_retry.current_percent, 10); + assert_eq!(status_before_retry.previous_percent, 10); + assert_eq!(status_before_retry.config_version, 3); + assert!(status_before_retry.promotion_gate_passed); + let rollback_count_before_retry = hardening_metrics().canary_rollbacks_total; + let persisted_bytes_before_retry = + std::fs::read(&state_path).expect("read replacement state before retry"); + let directory_syncs_before_retry = state_file_parent_directory_syncs_for_tests(); + + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let retry_result = rollback_canary(request); + clear_persist_fault_injection_for_tests(); + let retry = retry_result.expect("state-based retry repairs parent-directory durability"); + + assert_eq!(retry.from_percent, 10); + assert_eq!(retry.to_percent, 10); + assert_eq!(retry.config_version, status_before_retry.config_version); + assert_eq!( + retry.rolled_back_at_unix, + status_before_retry.last_action_unix + ); + assert_eq!( + state_file_parent_directory_syncs_for_tests(), + directory_syncs_before_retry.saturating_add(1), + "the retry must sync the existing state file's parent directory" + ); + assert_eq!( + std::fs::read(&state_path).expect("read state after retry"), + persisted_bytes_before_retry, + "the directory durability repair must not rewrite the state file" + ); + assert_eq!( + canary_rollout_status().expect("canary status after restarted rollback retry"), + status_before_retry, + "the retry must not mutate rollout state or reset promotion evidence" + ); + assert_eq!( + hardening_metrics().canary_rollbacks_total, + rollback_count_before_retry, + "the retry must not count another rollback" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn emergency_rekey_persist_failure_rolls_back_and_retry_is_durable() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("emergency_rekey_persist_retry"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-emergency-rekey-persist-retry"; + ensure_interactive_dkg_session(session_id, "emergency-rekey-persist-key-group"); + { + let guard = state().expect("engine state").lock().expect("engine lock"); + persist_engine_state_to_storage(&guard).expect("persist baseline wallet session"); + } + + let request = TriggerEmergencyRekeyRequest { + session_id: session_id.to_string(), + reason: "compromise containment".to_string(), + }; + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let err = trigger_emergency_rekey(request.clone()) + .expect_err("injected persist fault must fail emergency rekey"); + clear_persist_fault_injection_for_tests(); + assert!( + matches!(err, EngineError::Internal(ref message) if message.contains("injected persist fault")), + "unexpected error: {err:?}" + ); + + { + let guard = state().expect("engine state").lock().expect("engine lock"); + assert!( + guard + .sessions + .get(session_id) + .expect("wallet session") + .emergency_rekey_event + .is_none(), + "a failed persist must not strand an in-memory-only kill switch" + ); + } + + trigger_emergency_rekey(request).expect("retry persists emergency rekey"); + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + { + let guard = state().expect("engine state").lock().expect("engine lock"); + let event = guard + .sessions + .get(session_id) + .expect("reloaded wallet session") + .emergency_rekey_event + .as_ref() + .expect("durable emergency rekey event"); + assert_eq!(event.reason, "compromise containment"); + } + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn emergency_rekey_different_reason_retry_repairs_pending_persistence() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("emergency_rekey_pending_different_reason"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-emergency-rekey-pending-different-reason"; + ensure_interactive_dkg_session(session_id, "emergency-rekey-different-reason-key-group"); + let original_request = TriggerEmergencyRekeyRequest { + session_id: session_id.to_string(), + reason: "key compromise".to_string(), + }; + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + trigger_emergency_rekey(original_request) + .expect_err("post-replacement fault leaves a pending emergency rekey"); + clear_persist_fault_injection_for_tests(); + assert!(pending_emergency_rekey_operation(session_id).is_some()); + + // A healthy full-state write makes the state durable, but must not erase the + // original operation's cached retry result. + { + let guard = state().expect("engine state").lock().expect("engine lock"); + persist_engine_state_to_storage(&guard).expect("unrelated full-state write"); + } + assert!(pending_emergency_rekey_operation(session_id).is_some()); + + let changed_reason_request = TriggerEmergencyRekeyRequest { + session_id: session_id.to_string(), + reason: "key-compromise".to_string(), + }; + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let retry_error = trigger_emergency_rekey(changed_reason_request.clone()) + .expect_err("different-reason retry must attempt the pending durability repair"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + retry_error, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + assert!(pending_emergency_rekey_operation(session_id).is_some()); + + let immutable_error = trigger_emergency_rekey(changed_reason_request) + .expect_err("after repair, a changed reason remains an immutable-event conflict"); + assert!(matches!( + immutable_error, + EngineError::Validation(ref message) if message.contains("already triggered") + )); + assert!(pending_emergency_rekey_operation(session_id).is_none()); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let guard = state().expect("engine state").lock().expect("engine lock"); + assert_eq!( + guard.sessions[session_id] + .emergency_rekey_event + .as_ref() + .expect("durable rekey event") + .reason, + "key compromise" + ); + drop(guard); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn emergency_rekey_post_replace_state_survives_immediate_process_restart() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("emergency_rekey_post_replace_restart"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "session-emergency-rekey-post-replace-restart"; + ensure_interactive_dkg_session(session_id, "emergency-rekey-restart-key-group"); + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + trigger_emergency_rekey(TriggerEmergencyRekeyRequest { + session_id: session_id.to_string(), + reason: "restart-window containment".to_string(), + }) + .expect_err("post-replacement fault must report failure before restart"); + clear_persist_fault_injection_for_tests(); + + // Simulate process death before an in-process retry can flush the pending + // registry. On filesystems where rename completed, the replacement image is + // loaded and the kill switch remains active. + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let guard = state().expect("engine state").lock().expect("engine lock"); + assert_eq!( + guard.sessions[session_id] + .emergency_rekey_event + .as_ref() + .expect("post-replacement event survives restart") + .reason, + "restart-window containment" + ); + drop(guard); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn canary_promotion_persist_failure_rolls_back_and_retry_is_durable() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("canary_promotion_persist_retry"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + + let request = PromoteCanaryRequest { target_percent: 50 }; + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let err = promote_canary(request.clone()) + .expect_err("injected persist fault must fail canary promotion"); + clear_persist_fault_injection_for_tests(); + assert!( + matches!(err, EngineError::Internal(ref message) if message.contains("injected persist fault")), + "unexpected error: {err:?}" + ); + + let rolled_back_status = canary_rollout_status().expect("canary status after failed persist"); + assert_eq!(rolled_back_status.current_percent, 10); + assert_eq!(rolled_back_status.previous_percent, 10); + assert_eq!(rolled_back_status.config_version, 1); + + let promoted = promote_canary(request).expect("retry persists canary promotion"); + assert_eq!(promoted.from_percent, 10); + assert_eq!(promoted.to_percent, 50); + assert_eq!(promoted.config_version, 2); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let reloaded_status = canary_rollout_status().expect("reloaded canary status"); + assert_eq!(reloaded_status.current_percent, 50); + assert_eq!(reloaded_status.previous_percent, 10); + assert_eq!(reloaded_status.config_version, 2); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn canary_rollback_persist_failure_rolls_back_and_retry_is_durable() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("canary_rollback_persist_retry"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + promote_canary(PromoteCanaryRequest { target_percent: 50 }) + .expect("persist baseline canary promotion"); + let request = RollbackCanaryRequest { + reason: "rollback persist drill".to_string(), + }; + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let err = rollback_canary(request.clone()) + .expect_err("injected persist fault must fail canary rollback"); + clear_persist_fault_injection_for_tests(); + assert!( + matches!(err, EngineError::Internal(ref message) if message.contains("injected persist fault")), + "unexpected error: {err:?}" + ); + + let rolled_back_status = canary_rollout_status().expect("canary status after failed rollback"); + assert_eq!(rolled_back_status.current_percent, 50); + assert_eq!(rolled_back_status.previous_percent, 10); + assert_eq!(rolled_back_status.config_version, 2); + + let rollback = rollback_canary(request).expect("retry persists canary rollback"); + assert_eq!(rollback.from_percent, 50); + assert_eq!(rollback.to_percent, 10); + assert_eq!(rollback.config_version, 3); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let reloaded_status = canary_rollout_status().expect("reloaded rollback status"); + assert_eq!(reloaded_status.current_percent, 10); + assert_eq!(reloaded_status.previous_percent, 10); + assert_eq!(reloaded_status.config_version, 3); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn canary_post_rename_recovery_preserves_evidence_and_records_transitions() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("canary_post_rename_recovery_bookkeeping"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let baseline_metrics = hardening_metrics(); + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + let promotion_request = PromoteCanaryRequest { target_percent: 50 }; + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + promote_canary(promotion_request.clone()) + .expect_err("post-rename fault must leave the promotion pending"); + clear_persist_fault_injection_for_tests(); + + assert!(pending_canary_promotion_result(50).is_some()); + assert_eq!( + hardening_metrics().canary_promotions_total, + baseline_metrics.canary_promotions_total, + "an unconfirmed transition must not be counted yet" + ); + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + assert!( + canary_rollout_status() + .expect("active 50% stage status before recovery") + .promotion_gate_passed, + "operations after the rename must count toward the active 50% stage" + ); + + let promotion = + promote_canary(promotion_request).expect("matching retry repairs promotion durability"); + assert_eq!(promotion.from_percent, 10); + assert_eq!(promotion.to_percent, 50); + assert!(pending_canary_promotion_result(50).is_none()); + assert!( + canary_rollout_status() + .expect("active 50% stage status after recovery") + .promotion_gate_passed, + "confirming durability must preserve current-stage evidence" + ); + assert_eq!( + hardening_metrics().canary_promotions_total, + baseline_metrics.canary_promotions_total.saturating_add(1), + "recovery must record the completed promotion exactly once" + ); + + let rollback_request = RollbackCanaryRequest { + reason: "post-rename recovery bookkeeping".to_string(), + }; + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + rollback_canary(rollback_request.clone()) + .expect_err("post-rename fault must leave the rollback pending"); + clear_persist_fault_injection_for_tests(); + + assert!(pending_canary_rollback_result("post-rename recovery bookkeeping").is_some()); + assert_eq!( + hardening_metrics().canary_rollbacks_total, + baseline_metrics.canary_rollbacks_total, + "an unconfirmed rollback must not be counted yet" + ); + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + assert!( + canary_rollout_status() + .expect("active rolled-back stage status before recovery") + .promotion_gate_passed, + "operations after the rename must count toward the rolled-back stage" + ); + + let rollback = + rollback_canary(rollback_request).expect("matching retry repairs rollback durability"); + assert_eq!(rollback.from_percent, 50); + assert_eq!(rollback.to_percent, 10); + assert!(pending_canary_rollback_result("post-rename recovery bookkeeping").is_none()); + assert!( + canary_rollout_status() + .expect("active rolled-back stage status after recovery") + .promotion_gate_passed, + "confirming rollback durability must preserve current-stage evidence" + ); + let recovered_metrics = hardening_metrics(); + assert_eq!( + recovered_metrics.canary_promotions_total, + baseline_metrics.canary_promotions_total.saturating_add(1) + ); + assert_eq!( + recovered_metrics.canary_rollbacks_total, + baseline_metrics.canary_rollbacks_total.saturating_add(1), + "recovery must record the completed rollback exactly once" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn lifecycle_post_rename_persist_failures_remain_fail_closed_and_retry_durably() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("lifecycle_post_rename_retry"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let rekey_session = "session-emergency-rekey-post-rename"; + ensure_interactive_dkg_session(rekey_session, "emergency-rekey-post-rename-key-group"); + { + let guard = state().expect("engine state").lock().expect("engine lock"); + persist_engine_state_to_storage(&guard).expect("persist baseline wallet session"); + } + let rekey_request = TriggerEmergencyRekeyRequest { + session_id: rekey_session.to_string(), + reason: "post-rename containment".to_string(), + }; + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let rekey_error = trigger_emergency_rekey(rekey_request.clone()) + .expect_err("post-rename fault must report emergency rekey failure"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + rekey_error, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + assert!(pending_emergency_rekey_result(rekey_session, "post-rename containment").is_some()); + { + let guard = state().expect("engine state").lock().expect("engine lock"); + assert!( + guard.sessions[rekey_session] + .emergency_rekey_event + .is_some(), + "a replaced state file keeps the in-memory kill switch fail closed" + ); + } + let rekey_result = + trigger_emergency_rekey(rekey_request).expect("same rekey retry flushes pending state"); + assert_eq!(rekey_result.session_id, rekey_session); + assert!(pending_emergency_rekey_result(rekey_session, "post-rename containment").is_none()); + + let promotion_request = PromoteCanaryRequest { target_percent: 50 }; + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let promotion_error = promote_canary(promotion_request.clone()) + .expect_err("post-rename fault must report promotion failure"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + promotion_error, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + assert!(pending_canary_promotion_result(50).is_some()); + let pending_promotion_status = canary_rollout_status().expect("pending promotion status"); + assert_eq!(pending_promotion_status.current_percent, 50); + assert_eq!(pending_promotion_status.config_version, 2); + assert!(!pending_promotion_status.promotion_gate_passed); + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + promote_canary(promotion_request.clone()) + .expect_err("a failed pending promotion flush must not report success"); + clear_persist_fault_injection_for_tests(); + assert!(pending_canary_promotion_result(50).is_some()); + let promotion_result = + promote_canary(promotion_request).expect("same promotion retry flushes pending state"); + assert_eq!(promotion_result.from_percent, 10); + assert_eq!(promotion_result.to_percent, 50); + assert_eq!(promotion_result.config_version, 2); + assert!(pending_canary_promotion_result(50).is_none()); + + let rollback_request = RollbackCanaryRequest { + reason: "post-rename rollback".to_string(), + }; + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let rollback_error = rollback_canary(rollback_request.clone()) + .expect_err("post-rename fault must report rollback failure"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + rollback_error, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + assert!(pending_canary_rollback_result("post-rename rollback").is_some()); + let pending_rollback_status = canary_rollout_status().expect("pending rollback status"); + assert_eq!(pending_rollback_status.current_percent, 10); + assert_eq!(pending_rollback_status.config_version, 3); + assert!(!pending_rollback_status.promotion_gate_passed); + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + rollback_canary(rollback_request.clone()) + .expect_err("a failed pending rollback flush must not report success"); + clear_persist_fault_injection_for_tests(); + assert!(pending_canary_rollback_result("post-rename rollback").is_some()); + let rollback_result = + rollback_canary(rollback_request).expect("same rollback retry flushes pending state"); + assert_eq!(rollback_result.from_percent, 50); + assert_eq!(rollback_result.to_percent, 10); + assert_eq!(rollback_result.config_version, 3); + assert!(pending_canary_rollback_result("post-rename rollback").is_none()); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let guard = state().expect("engine state").lock().expect("engine lock"); + assert!(guard.sessions[rekey_session] + .emergency_rekey_event + .is_some()); + assert_eq!(guard.canary_rollout.current_percent, 10); + assert_eq!(guard.canary_rollout.config_version, 3); + drop(guard); + + 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_CANARY_MIN_SAMPLES_ENV, "1"); + record_hardening_operation_latency(HardeningOperation::InteractiveRound1, 1); + record_hardening_operation_latency(HardeningOperation::InteractiveRound2, 1); + record_hardening_operation_latency(HardeningOperation::InteractiveAggregate, 1); + + 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 canary_promotion_halts_when_interactive_latency_exceeds_gate() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("canary_interactive_latency_gate"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_CANARY_MIN_SAMPLES_ENV, "1"); + std::env::set_var(TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND1_P95_MS_ENV, "10"); + std::env::set_var(TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND2_P95_MS_ENV, "10"); + std::env::set_var( + TBTC_SIGNER_CANARY_MAX_INTERACTIVE_AGGREGATE_P95_MS_ENV, + "20", + ); + record_hardening_operation_latency(HardeningOperation::InteractiveRound1, 11); + record_hardening_operation_latency(HardeningOperation::InteractiveRound2, 12); + record_hardening_operation_latency(HardeningOperation::InteractiveAggregate, 21); + record_canary_policy_outcome(false); + + let failures = canary_promotion_gate_failures(); + assert_eq!( + failures, + vec![ + "interactive_round1 p95 latency [11ms] exceeds canary gate [10ms]", + "interactive_round2 p95 latency [12ms] exceeds canary gate [10ms]", + "interactive_aggregate p95 latency [21ms] exceeds canary gate [20ms]", + ] + ); + + let err = promote_canary(PromoteCanaryRequest { target_percent: 50 }) + .expect_err("interactive signing latency must block canary promotion"); + let EngineError::LifecyclePolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!(reason_code, "canary_slo_gate_failed"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn canary_interactive_knobs_override_legacy_threshold_aliases() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS_ENV, "10"); + std::env::set_var(TBTC_SIGNER_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS_ENV, "20"); + assert_eq!(canary_max_interactive_round1_p95_ms(), 10); + assert_eq!(canary_max_interactive_round2_p95_ms(), 10); + assert_eq!(canary_max_interactive_aggregate_p95_ms(), 20); + + std::env::set_var(TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND1_P95_MS_ENV, "11"); + std::env::set_var(TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND2_P95_MS_ENV, "12"); + std::env::set_var( + TBTC_SIGNER_CANARY_MAX_INTERACTIVE_AGGREGATE_P95_MS_ENV, + "21", + ); + assert_eq!(canary_max_interactive_round1_p95_ms(), 11); + assert_eq!(canary_max_interactive_round2_p95_ms(), 12); + assert_eq!(canary_max_interactive_aggregate_p95_ms(), 21); + + // A malformed or non-positive explicit knob must not shadow a valid + // legacy alias. Operators can therefore recover from a bad new-name + // value without silently falling back to the much looser built-in + // latency threshold. + std::env::set_var( + TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND1_P95_MS_ENV, + "not-a-number", + ); + std::env::set_var(TBTC_SIGNER_CANARY_MAX_INTERACTIVE_ROUND2_P95_MS_ENV, "0"); + std::env::set_var( + TBTC_SIGNER_CANARY_MAX_INTERACTIVE_AGGREGATE_P95_MS_ENV, + "not-a-number", + ); + assert_eq!(canary_max_interactive_round1_p95_ms(), 10); + assert_eq!(canary_max_interactive_round2_p95_ms(), 10); + assert_eq!(canary_max_interactive_aggregate_p95_ms(), 20); + + std::env::set_var(TBTC_SIGNER_CANARY_MIN_SAMPLES_ENV, "0"); + assert_eq!(canary_min_samples(), TBTC_SIGNER_DEFAULT_CANARY_MIN_SAMPLES); + std::env::set_var(TBTC_SIGNER_CANARY_MIN_SAMPLES_ENV, "256"); + assert_eq!(canary_min_samples(), 256); + + std::env::set_var(TBTC_SIGNER_CANARY_MIN_SAMPLES_ENV, "257"); + assert_eq!(canary_min_samples(), TBTC_SIGNER_MAX_CANARY_MIN_SAMPLES); + std::env::set_var(TBTC_SIGNER_CANARY_MIN_POLICY_SAMPLES_ENV, "0"); + assert_eq!( + canary_min_policy_samples(), + TBTC_SIGNER_MAX_CANARY_MIN_SAMPLES, + "a zero policy minimum retains the interactive fallback", + ); + std::env::set_var(TBTC_SIGNER_CANARY_MIN_POLICY_SAMPLES_ENV, "257"); + assert_eq!( + canary_min_policy_samples(), + TBTC_SIGNER_MAX_CANARY_MIN_SAMPLES + ); + std::env::set_var(TBTC_SIGNER_CANARY_MAX_SAMPLE_AGE_SECONDS_ENV, "604801"); + assert_eq!( + canary_max_sample_age_seconds(), + TBTC_SIGNER_MAX_CANARY_SAMPLE_AGE_SECONDS + ); +} + +#[test] +fn canary_policy_evidence_minimum_is_independent_from_interactive_minimum() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_CANARY_MIN_SAMPLES_ENV, "2"); + assert_eq!( + canary_min_policy_samples(), + 2, + "an absent policy minimum must preserve the prior fail-closed minimum", + ); + std::env::set_var(TBTC_SIGNER_CANARY_MIN_POLICY_SAMPLES_ENV, "1"); + + record_hardening_operation_latency(HardeningOperation::InteractiveRound1, 1); + record_hardening_operation_latency(HardeningOperation::InteractiveRound2, 1); + record_hardening_operation_latency(HardeningOperation::InteractiveAggregate, 1); + record_canary_policy_outcome(false); + + assert_eq!( + canary_promotion_gate_failures(), + vec![ + "interactive_round1 fresh successful samples [1] below canary minimum [2]", + "interactive_round2 fresh successful samples [1] below canary minimum [2]", + "interactive_aggregate fresh successful samples [1] below canary minimum [2]", + ], + "the lower policy minimum must not weaken interactive evidence", + ); + + record_hardening_operation_latency(HardeningOperation::InteractiveRound1, 1); + record_hardening_operation_latency(HardeningOperation::InteractiveRound2, 1); + record_hardening_operation_latency(HardeningOperation::InteractiveAggregate, 1); + assert!( + canary_promotion_gate_failures().is_empty(), + "one policy outcome should remain sufficient after interactive evidence reaches its own minimum", + ); +} + +#[test] +fn canary_evidence_freshness_fails_closed_when_clock_precedes_observation() { + let evaluated_at = Instant::now(); + let observed_at = evaluated_at + Duration::from_secs(1); + + let mut latency = HardeningLatencyTracker::default(); + latency.record_at(7, observed_at); + assert_eq!(latency.fresh_sample_count(evaluated_at, 60), 0); + assert_eq!(latency.fresh_p95_ms(evaluated_at, 60), 0); + + let mut policy = HardeningPolicyOutcomeTracker::default(); + policy.record_at(false, observed_at); + assert_eq!(policy.fresh_snapshot(evaluated_at, 60), (0, 0)); +} + +#[test] +fn canary_promotion_requires_fresh_minimum_evidence_after_restart() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("canary_restart_evidence_gate"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let empty = canary_rollout_status().expect("empty-evidence status"); + assert!(!empty.promotion_gate_passed); + assert_eq!(empty.gate_failures, canary_missing_evidence_gate_failures()); + + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + promote_canary(PromoteCanaryRequest { target_percent: 50 }) + .expect("fresh minimum evidence promotes to 50%"); + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + assert!( + canary_rollout_status() + .expect("50% stage evidence") + .promotion_gate_passed + ); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let restarted = canary_rollout_status().expect("status after process restart"); + assert_eq!(restarted.current_percent, 50); + assert!(!restarted.promotion_gate_passed); + assert_eq!( + restarted.gate_failures, + canary_missing_evidence_gate_failures() + ); + let error = promote_canary(PromoteCanaryRequest { + target_percent: 100, + }) + .expect_err("persisted rollout state must not promote on empty post-restart telemetry"); + assert!(matches!( + error, + EngineError::LifecyclePolicyRejected { ref reason_code, .. } + if reason_code == "canary_slo_gate_failed" + )); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn concurrent_canary_promotions_cannot_reuse_prior_stage_evidence() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + + struct CanaryPromotionLockReleaseGuard; + impl Drop for CanaryPromotionLockReleaseGuard { + fn drop(&mut self) { + release_canary_promotion_lock_for_tests(); + } + } + + arm_canary_promotion_lock_hold_for_tests(); + let release_guard = CanaryPromotionLockReleaseGuard; + let promote_50 = + std::thread::spawn(|| promote_canary(PromoteCanaryRequest { target_percent: 50 })); + + let deadline = Instant::now() + Duration::from_secs(5); + while !canary_promotion_lock_held_for_tests() { + assert!( + Instant::now() < deadline, + "50% promotion did not acquire the rollout-state lock" + ); + std::thread::yield_now(); + } + + let promote_100 = std::thread::spawn(|| { + promote_canary(PromoteCanaryRequest { + target_percent: 100, + }) + }); + while canary_promotion_lock_attempts_for_tests() < 2 { + assert!( + Instant::now() < deadline, + "100% promotion did not reach the rollout-state lock" + ); + std::thread::yield_now(); + } + + release_canary_promotion_lock_for_tests(); + drop(release_guard); + let first = promote_50 + .join() + .expect("50% promotion thread") + .expect("50% promotion succeeds with stage-10 evidence"); + assert_eq!(first.to_percent, 50); + + let second = promote_100 + .join() + .expect("100% promotion thread") + .expect_err("100% promotion must wait for fresh stage-50 evidence"); + assert!(matches!( + second, + EngineError::LifecyclePolicyRejected { ref reason_code, .. } + if reason_code == "canary_slo_gate_failed" + )); + let status = canary_rollout_status().expect("rollout status after concurrent requests"); + assert_eq!(status.current_percent, 50); + assert!(!status.promotion_gate_passed); +} + +#[test] +fn canary_promotion_ignores_samples_older_than_the_configured_window() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + + std::env::set_var(TBTC_SIGNER_CANARY_MAX_SAMPLE_AGE_SECONDS_ENV, "1"); + let stale_at = Instant::now() + .checked_sub(Duration::from_secs(2)) + .expect("monotonic clock must support a two-second test offset"); + { + let mut telemetry = hardening_telemetry_state() + .lock() + .expect("hardening telemetry lock"); + for sample in &mut telemetry.canary_interactive_round1_latency.samples { + sample.observed_at = stale_at; + } + for sample in &mut telemetry.canary_interactive_round2_latency.samples { + sample.observed_at = stale_at; + } + for sample in &mut telemetry.canary_interactive_aggregate_latency.samples { + sample.observed_at = stale_at; + } + for sample in &mut telemetry.canary_policy_outcomes.samples { + sample.observed_at = stale_at; + } + } + + let metrics = hardening_metrics(); + assert_eq!( + metrics.interactive_round1_latency_samples, + canary_min_samples() + ); + assert_eq!( + metrics.interactive_round2_latency_samples, + canary_min_samples() + ); + assert_eq!( + metrics.interactive_aggregate_latency_samples, + canary_min_samples() + ); + assert_eq!(metrics.interactive_round1_latency_p95_ms, 1); + assert_eq!(metrics.interactive_round2_latency_p95_ms, 1); + assert_eq!(metrics.interactive_aggregate_latency_p95_ms, 1); + assert_eq!( + canary_promotion_gate_failures(), + canary_missing_evidence_gate_failures() + ); +} + +#[test] +fn canary_evidence_reset_preserves_abi_latency_metrics() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_CANARY_MIN_SAMPLES_ENV, "1"); + + seed_canary_promotion_evidence_for_tests(7, 8, 9, 0); + let before = hardening_metrics(); + assert!(canary_promotion_gate_failures().is_empty()); + + reset_canary_promotion_evidence(); + + let after = hardening_metrics(); + assert_eq!(after.interactive_round1_latency_samples, 1); + assert_eq!(after.interactive_round1_latency_p95_ms, 7); + assert_eq!(after.interactive_round2_latency_samples, 1); + assert_eq!(after.interactive_round2_latency_p95_ms, 8); + assert_eq!(after.interactive_aggregate_latency_samples, 1); + assert_eq!(after.interactive_aggregate_latency_p95_ms, 9); + assert_eq!( + ( + after.interactive_round1_latency_samples, + after.interactive_round1_latency_p95_ms, + after.interactive_round2_latency_samples, + after.interactive_round2_latency_p95_ms, + after.interactive_aggregate_latency_samples, + after.interactive_aggregate_latency_p95_ms, + ), + ( + before.interactive_round1_latency_samples, + before.interactive_round1_latency_p95_ms, + before.interactive_round2_latency_samples, + before.interactive_round2_latency_p95_ms, + before.interactive_aggregate_latency_samples, + before.interactive_aggregate_latency_p95_ms, + ), + "rollout evidence reset must not change established ABI-3 metrics", + ); + assert_eq!( + canary_promotion_gate_failures(), + canary_missing_evidence_gate_failures(), + "the separate promotion window must still reset fail closed", + ); +} + +#[test] +fn canary_promotion_ignores_interactive_latency_from_the_prior_stage() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + // Model an operation that began in the current rollout stage, completed + // while promotion reset the evidence window, and dropped its success guard + // only after the new stage began collecting samples. + let mut operation = + HardeningOperationLatencyGuard::success_only(HardeningOperation::InteractiveRound1); + reset_canary_promotion_evidence(); + operation.mark_success(); + drop(operation); + + assert_eq!( + hardening_metrics().interactive_round1_latency_samples, + 1, + "the completed call remains visible through the ABI-3 rolling metric", + ); + let telemetry = hardening_telemetry_state() + .lock() + .expect("hardening telemetry lock"); + assert_eq!( + telemetry.canary_interactive_round1_latency.sample_count(), + 0, + "a completion from the prior stage must not enter new-stage evidence", + ); +} + +#[test] +fn idempotent_build_replays_do_not_dilute_canary_policy_outcomes() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let request = build_policy_test_request("session-canary-policy-cache-dilution"); + build_taproot_tx(request.clone()).expect("first artifact decision"); + for _ in 0..HARDENING_LATENCY_SAMPLE_WINDOW { + build_taproot_tx(request.clone()).expect("idempotent build replay"); + } + + let telemetry = hardening_telemetry_state() + .lock() + .expect("hardening telemetry lock"); + let (sample_count, rejected_count) = telemetry + .canary_policy_outcomes + .fresh_snapshot(Instant::now(), canary_max_sample_age_seconds()); + assert_eq!(sample_count, 1); + assert_eq!(rejected_count, 0); +} + +#[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 session_id = "session-emergency-rekey-finalize"; + ensure_interactive_dkg_session(session_id, "emergency-rekey-key-group"); + trigger_emergency_rekey(TriggerEmergencyRekeyRequest { + session_id: session_id.to_string(), + reason: "compromise containment".to_string(), + }) + .expect("trigger emergency rekey"); + + let build_err = build_taproot_tx(build_policy_test_request(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(); +} + +#[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, + } +} + +// Resolves the key group the engine will use when validating attempt +// contexts for `session_id`: the session's dealer-DKG key group when +// the session exists, otherwise a deterministic per-session stand-in +// for tests that exercise `validate_attempt_context` directly without +// creating a session. Construction and validation must use the same +// resolver or the RFC-21 Annex A seed derivation diverges. +fn attempt_context_key_group_for_tests(session_id: &str) -> String { + if let Ok(state) = state() { + if let Ok(guard) = state.lock() { + if let Some(key_group) = guard + .sessions + .get(session_id) + .and_then(|session| session.dkg_result.as_ref()) + .map(|dkg| dkg.key_group.clone()) + { + return key_group; + } + } + } + + format!("test-key-group:{session_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 key_group = attempt_context_key_group_for_tests(session_id); + // RFC-21 seed input: the padded raw message, not the SHA256 + // transcript digest -- mirroring the Go layer's derivation. + let attempt_seed = roast_attempt_shuffle_seed( + &key_group, + session_id, + &rfc21_message_digest(&message_bytes).expect("rfc21 message digest"), + ) + .expect("attempt shuffle seed"); + assert!( + attempt_number >= 1, + "attempt_number is the 1-based wire encoding", + ); + let coordinator_identifier = select_coordinator_identifier( + &canonical_included_participants, + attempt_seed, + attempt_number - 1, + ) + .expect("deterministic coordinator"); + + build_attempt_context( + session_id, + message_hex, + attempt_number, + coordinator_identifier, + included_participants, + ) +} + +#[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 derive_interactive_attempt_context_matches_standalone_derivations() { + let _guard = lock_test_state(); // hermetic env: development profile, provenance gate off + let session_id = "derive-session-1"; + let key_group = "derive-key-group"; + let message_hex = "77".repeat(32); // 32-byte signing digest + let message_bytes = hex::decode(&message_hex).expect("message hex"); + let threshold = 2u16; + let attempt_number = 3u32; // 1-based wire + let included = vec![5u16, 1, 3]; // unsorted -> exercises canonical (ascending) ordering + + let result = derive_interactive_attempt_context(DeriveInteractiveAttemptContextRequest { + session_id: session_id.to_string(), + message_hex: message_hex.clone(), + key_group: key_group.to_string(), + threshold, + attempt_number, + included_participants: included.clone(), + }) + .expect("derivation should succeed"); + + let canonical = canonicalize_included_participants(&included).expect("canonical"); + assert_eq!(canonical, vec![1, 3, 5]); + assert_eq!(result.attempt_context.included_participants, canonical); + assert_eq!(result.attempt_context.attempt_number, attempt_number); + + // Coordinator matches the standalone shuffle selection (0-based attempt). + let seed = roast_attempt_shuffle_seed( + key_group, + session_id, + &rfc21_message_digest(&message_bytes).expect("rfc21 digest"), + ) + .expect("seed"); + let expected_coordinator = + select_coordinator_identifier(&canonical, seed, attempt_number - 1).expect("coordinator"); + assert_eq!( + result.attempt_context.coordinator_identifier, + expected_coordinator + ); + + // Fingerprint + attempt_id match the standalone domain-separated derivations. + let expected_fingerprint = + roast_included_participants_fingerprint_hex(&canonical).expect("fingerprint"); + assert_eq!( + result.attempt_context.included_participants_fingerprint, + expected_fingerprint + ); + let expected_attempt_id = roast_attempt_id_hex( + session_id, + &hash_hex(&message_bytes), + attempt_number, + expected_coordinator, + &expected_fingerprint, + ) + .expect("attempt id"); + assert_eq!(result.attempt_context.attempt_id, expected_attempt_id); + + // The derived context is accepted by the same strict validator open runs. + validate_attempt_context( + session_id, + key_group, + &message_bytes, + &hash_hex(&message_bytes), + threshold, + Some(&result.attempt_context), + true, + ) + .expect("derived context must satisfy strict validation"); + + // One FROST identifier per participant, canonical order + encoding. + assert_eq!(result.frost_identifiers.len(), canonical.len()); + for (entry, participant) in result.frost_identifiers.iter().zip(canonical.iter()) { + assert_eq!(entry.participant_identifier, *participant); + let expected = frost_identifier_to_go_string( + participant_identifier_to_frost_identifier(*participant).expect("frost id"), + ); + assert_eq!(entry.frost_identifier, expected); + } +} + +#[test] +fn derive_interactive_attempt_context_is_deterministic() { + let _guard = lock_test_state(); + let request = DeriveInteractiveAttemptContextRequest { + session_id: "s".to_string(), + message_hex: "ab".repeat(32), + key_group: "kg".to_string(), + threshold: 2, + attempt_number: 1, + included_participants: vec![1, 2, 3], + }; + let first = derive_interactive_attempt_context(request.clone()).expect("first"); + let second = derive_interactive_attempt_context(request).expect("second"); + assert_eq!(first, second); +} + +#[test] +fn derive_interactive_attempt_context_rejects_invalid_inputs() { + let _guard = lock_test_state(); + let base = DeriveInteractiveAttemptContextRequest { + session_id: "s".to_string(), + message_hex: "cd".repeat(32), + key_group: "kg".to_string(), + threshold: 2, + attempt_number: 1, + included_participants: vec![1, 2, 3], + }; + + let mut empty_message = base.clone(); + empty_message.message_hex = String::new(); + assert!(derive_interactive_attempt_context(empty_message).is_err()); + + // session_id is validated (and hashed into attempt_id), so an empty/malformed + // one must fail here exactly as interactive_session_open's validate_session_id + // would reject it. + let mut empty_session = base.clone(); + empty_session.session_id = String::new(); + assert!(derive_interactive_attempt_context(empty_session).is_err()); + + let mut zero_attempt = base.clone(); + zero_attempt.attempt_number = 0; + assert!(derive_interactive_attempt_context(zero_attempt).is_err()); + + // threshold == 0 is vacuously >= len, but interactive_session_open rejects + // it, so the helper must too rather than hand back a context open refuses. + let mut zero_threshold = base.clone(); + zero_threshold.threshold = 0; + assert!(derive_interactive_attempt_context(zero_threshold).is_err()); + + let mut threshold_too_large = base.clone(); + threshold_too_large.threshold = 5; + threshold_too_large.included_participants = vec![1, 2]; + assert!(derive_interactive_attempt_context(threshold_too_large).is_err()); + + let mut duplicate_participant = base.clone(); + duplicate_participant.included_participants = vec![1, 2, 2]; + assert!(derive_interactive_attempt_context(duplicate_participant).is_err()); + + let mut no_participants = base; + no_participants.included_participants = vec![]; + assert!(derive_interactive_attempt_context(no_participants).is_err()); +} + +#[test] +fn derive_interactive_attempt_context_enforces_provenance_gate() { + let _guard = lock_test_state(); + // Enable the provenance gate with no attestation configured: the helper must + // fail closed exactly like interactive_session_open's front door, never + // returning a derived context on an unattested engine. + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + + let result = derive_interactive_attempt_context(DeriveInteractiveAttemptContextRequest { + session_id: "s".to_string(), + message_hex: "ef".repeat(32), + key_group: "kg".to_string(), + threshold: 2, + attempt_number: 1, + included_participants: vec![1, 2, 3], + }); + assert!(matches!( + result, + Err(EngineError::ProvenanceGateRejected { .. }) + )); +} + +#[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(), + // RFC-21 attempt contexts only bind 32-byte signing digests + // (rfc21_message_digest rejects longer messages), so the + // strategy stays within that bound. + message_bytes in prop::collection::vec(any::(), 1..=32), + ) { + 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 validation_message_bytes = + hex::decode(&message_hex).expect("message hex should decode for validation"); + let message_digest_hex = hash_hex(&validation_message_bytes); + let validated_participants = validate_attempt_context( + &session_id, + &attempt_context_key_group_for_tests(&session_id), + &validation_message_bytes, + &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(), + // RFC-21 attempt contexts only bind 32-byte signing digests + // (rfc21_message_digest rejects longer messages), so the + // strategy stays within that bound. + message_bytes in prop::collection::vec(any::(), 1..=32), + ) { + 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 validation_message_bytes = + hex::decode(&message_hex).expect("message hex should decode for validation"); + let message_digest_hex = hash_hex(&validation_message_bytes); + let err = validate_attempt_context( + &session_id, + &attempt_context_key_group_for_tests(&session_id), + &validation_message_bytes, + &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 key_material = + state_encryption_key_material().expect("state encryption key material"); + let encoded = encode_encrypted_state_envelope(&persisted, &key_material) + .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") + )); + } +} + +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 } + } +} + +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 production_profile_forces_roast_strict_mode_without_env_flag() { + let _guard = lock_test_state(); + reset_for_tests(); + + { + let _signer_profile = SignerProfileGuard::production(); + let _roast_strict_mode = RoastStrictModeGuard::set(Some("false")); + assert!( + roast_strict_mode_enabled(), + "production profile must force ROAST strict mode regardless of env flag", + ); + } + + let _roast_strict_mode = RoastStrictModeGuard::set(Some("false")); + assert!( + !roast_strict_mode_enabled(), + "development profile must honor the disabled strict-mode env flag", + ); +} + +#[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 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 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 persisted_engine_state_compacts_migrated_idle_entries_to_legacy_total_bound() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("migrated_idle_total_bound_rewrite"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "2"); + + let mut wallet = persisted_session_state_fixture(); + wallet.dkg_result = Some(DkgResult { + session_id: "wallet".to_string(), + key_group: "wallet-key-group".to_string(), + participant_count: 3, + threshold: 2, + created_at_unix: 1, + }); + let mut consumed_message = persisted_session_state_fixture(); + consumed_message.bound_key_group = Some("wallet-key-group".to_string()); + consumed_message.consumed_interactive_attempt_markers = + vec![interactive_consumed_marker(&"11".repeat(32), 1)]; + consumed_message.authorized_interactive_aggregate_markers = vec!["22".repeat(32)]; + let mut aborted_message = persisted_session_state_fixture(); + aborted_message.bound_key_group = Some("wallet-key-group".to_string()); + aborted_message.build_tx_request_fingerprint = Some("policy-fingerprint".to_string()); + + let persisted = PersistedEngineState { + schema_version: PERSISTED_STATE_SCHEMA_VERSION, + sessions: HashMap::from([ + ("wallet".to_string(), wallet), + ("consumed-message".to_string(), consumed_message), + ("aborted-message".to_string(), aborted_message), + ]), + refresh_epoch_counter: 0, + operator_fault_scores: BTreeMap::new(), + quarantined_operator_identifiers: vec![], + canary_rollout: CanaryRolloutState::default(), + }; + + let loaded = EngineState::try_from(persisted.clone()) + .expect("idle per-message entries migrate and compact to the shared total budget"); + assert_eq!(loaded.sessions.len(), 2); + assert_eq!(active_session_count(&loaded.sessions), 1); + assert_eq!(retired_interactive_session_count(&loaded.sessions), 1); + assert!(loaded.sessions["wallet"] + .retired_interactive_at_unix + .is_none()); + assert!(!loaded.sessions.contains_key("aborted-message")); + assert!(loaded.sessions["consumed-message"] + .retired_interactive_at_unix + .is_some()); + + let encoded = PersistedEngineState::try_from(&loaded).expect("compacted state encodes"); + assert!( + encoded.sessions.len() <= 2, + "the immediately previous schema-1 reader enforces this total bound" + ); + + // Exercise the real load path with a current encrypted envelope emitted by + // the flawed intermediate writer. Startup must replace the oversized file, + // not merely compact its in-memory copy, so an immediate rollback can read it. + let key_material = state_encryption_key_material().expect("test state key"); + let oversized_envelope = + encode_encrypted_state_envelope(&persisted, &key_material).expect("oversized envelope"); + std::fs::write(&state_path, oversized_envelope.as_slice()) + .expect("write intermediate oversized state"); + let reloaded = load_engine_state_from_storage().expect("load compacts and rewrites state"); + assert_eq!(reloaded.sessions.len(), 2); + + let rewritten_bytes = std::fs::read(&state_path).expect("read rewritten state"); + let rewritten = match decode_persisted_state_storage_format(&rewritten_bytes) + .expect("decode rewritten state") + { + PersistedStateStorageFormat::EncryptedEnvelope { persisted, .. } => persisted, + PersistedStateStorageFormat::LegacyPlaintext(_) => { + panic!("rewrite must retain the encrypted envelope") + } + }; + assert_eq!(rewritten.sessions.len(), 2); + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn persisted_engine_state_rejects_duplicate_dkg_key_group_owners() { + let mut owner_a = persisted_session_state_fixture(); + owner_a.dkg_result = Some(DkgResult { + session_id: "persisted-wallet-a".to_string(), + key_group: "duplicate-wallet-key-group".to_string(), + participant_count: 3, + threshold: 2, + created_at_unix: 1, + }); + let mut owner_b = persisted_session_state_fixture(); + owner_b.dkg_result = Some(DkgResult { + session_id: "persisted-wallet-b".to_string(), + key_group: "duplicate-wallet-key-group".to_string(), + participant_count: 3, + threshold: 2, + created_at_unix: 1, + }); + + let persisted = PersistedEngineState { + schema_version: PERSISTED_STATE_SCHEMA_VERSION, + sessions: HashMap::from([ + ("persisted-wallet-a".to_string(), owner_a), + ("persisted-wallet-b".to_string(), owner_b), + ]), + refresh_epoch_counter: 0, + operator_fault_scores: BTreeMap::new(), + quarantined_operator_identifiers: vec![], + canary_rollout: CanaryRolloutState::default(), + }; + + let err = EngineState::try_from(persisted) + .err() + .expect("duplicate persisted key_group owners must fail closed"); + expect_internal_error_contains(err, "duplicate persisted DKG key_group"); +} + +#[test] +fn persisted_session_state_round_trip_preserves_bound_key_group() { + // A cross-session signing session has no dkg_result, so bound_key_group is the only + // durable link back to the wallet DKG. It MUST survive a persist/reload: otherwise an + // InteractiveAggregate/verify_share that runs after a restart (past a member's Round2, + // where the live state is already gone) would resolve neither dkg_result nor + // bound_key_group and return DkgNotReady, stranding the collected shares. + let session = SessionState { + bound_key_group: Some("wallet-key-group".to_string()), + ..Default::default() + }; + let persisted = PersistedSessionState::try_from(&session).expect("serialize"); + assert_eq!( + persisted.bound_key_group.as_deref(), + Some("wallet-key-group") + ); + let restored = SessionState::try_from(persisted).expect("deserialize"); + assert_eq!( + restored.bound_key_group.as_deref(), + Some("wallet-key-group"), + "bound_key_group must survive persist/reload for cross-session signing" + ); +} + +#[test] +fn interactive_open_cross_session_respects_the_session_cap() { + // A fresh RoastSessionID per message must not let Open grow the session registry + // past TBTC_SIGNER_MAX_SESSIONS: otherwise the cross-session path could build an + // over-limit registry that the reload path (see the test above) then rejects, + // stranding the node's persisted state. Open enforces the SAME total-session cap as + // every other session-creating path; a reopen of an existing session stays exempt. + let _guard = lock_test_state(); + reset_for_tests(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "1"); + + let wallet_session = "wallet-dkg-session-cap"; + let key_group = "cross-session-cap-key-group"; + let message = [0x23u8; 32]; + let included = [1u16, 2]; + + // The wallet DKG session fills the cap (1 of 1). + ensure_interactive_dkg_session(wallet_session, key_group); + + // A distinct signing session would be a SECOND session -> rejected by the cap, + // BEFORE any per-signing state is installed. + let attempt_context = + interactive_test_attempt_context("roast-over-cap", key_group, &message, &included, 1); + let err = interactive_session_open(InteractiveSessionOpenRequest { + session_id: "roast-over-cap".to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context, + }) + .expect_err("a new cross-session Open at the session cap must be rejected"); + assert!( + matches!(err, EngineError::Internal(ref m) if m.contains("reached max")), + "unexpected error: {err:?}" + ); + // The over-cap session must NOT have been created. + { + let guard = state().expect("state").lock().expect("lock"); + assert!( + !guard.sessions.contains_key("roast-over-cap"), + "a capped-out Open must not create the session" + ); + } + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); +} + +#[test] +fn interactive_open_refuses_to_rebind_a_live_session_to_a_different_key_group() { + // A per-signing session is keyed by RoastSessionID (message/root/start-block), NOT + // key_group, so two wallets can collide on one session id. While a member is + // mid-signing under one wallet key, an Open for a DIFFERENT key group on the same + // session id must be REJECTED - not silently rebind bound_key_group and make the + // live member's Round2/Aggregate resolve the wrong wallet material. + let _guard = lock_test_state(); + reset_for_tests(); + + let wallet_a = "wallet-dkg-a-rebind"; + let wallet_b = "wallet-dkg-b-rebind"; + let key_group_a = "key-group-a-rebind"; + let key_group_b = "key-group-b-rebind"; + let shared_session = "roast-collision-session"; + let message = [0x24u8; 32]; + let included = [1u16, 2]; + + ensure_interactive_dkg_session(wallet_a, key_group_a); + ensure_interactive_dkg_session(wallet_b, key_group_b); + + // Member 1 opens under wallet A on the shared session and runs Round1 (a LIVE entry). + let ctx_a = + interactive_test_attempt_context(shared_session, key_group_a, &message, &included, 1); + let opened_a = interactive_session_open(InteractiveSessionOpenRequest { + session_id: shared_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group_a.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: ctx_a, + }) + .expect("wallet A opens on the shared session"); + interactive_round1(InteractiveRound1Request { + session_id: shared_session.to_string(), + attempt_id: opened_a.attempt_id.clone(), + member_identifier: 1, + }) + .expect("wallet A round 1 leaves a live entry"); + + // Wallet B tries to open the SAME session id while A is live -> rejected. + let ctx_b = + interactive_test_attempt_context(shared_session, key_group_b, &message, &included, 1); + let err = interactive_session_open(InteractiveSessionOpenRequest { + session_id: shared_session.to_string(), + member_identifier: 2, + message_hex: hex::encode(message), + key_group: key_group_b.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: ctx_b, + }) + .expect_err("a different key group must not rebind a live session"); + assert!( + matches!(err, EngineError::SessionConflict { .. }), + "unexpected error: {err:?}" + ); + + // Wallet A's binding and live entry are intact - not corrupted by B's attempt. + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(shared_session).expect("shared session"); + assert_eq!(session.bound_key_group.as_deref(), Some(key_group_a)); + assert!( + session.interactive_signing.contains_key(&1), + "wallet A's live member entry must survive B's rejected open" + ); + } +} + +#[test] +fn interactive_open_refuses_to_bind_through_another_wallets_dkg_session() { + // If request.session_id names wallet A's (idle) DKG session but the request's + // key_group is wallet B, Open must NOT install B into A's session: with dkg_result + // A present, later Round2/Aggregate/verify_share would resolve A's material while + // signing B's share (wrong wallet, bypassing B's rekey/finalization gates). A + // session belongs to ONE key group for its lifetime, so the mismatch is rejected - + // even with no live members (dkg_result establishes the binding). + let _guard = lock_test_state(); + reset_for_tests(); + + let wallet_a = "wallet-a-dkg-bind"; + let wallet_b = "wallet-b-dkg-bind"; + let key_group_a = "key-group-a-dkg-bind"; + let key_group_b = "key-group-b-dkg-bind"; + let message = [0x25u8; 32]; + let included = [1u16, 2]; + + // wallet_a is an IDLE DKG session (dkg_result A, no live interactive entries); + // wallet_b provides key_group B's material so its wallet resolution succeeds. + ensure_interactive_dkg_session(wallet_a, key_group_a); + ensure_interactive_dkg_session(wallet_b, key_group_b); + + // Open wallet A's DKG session id but for key_group B -> rejected. + let ctx = interactive_test_attempt_context(wallet_a, key_group_b, &message, &included, 1); + let err = interactive_session_open(InteractiveSessionOpenRequest { + session_id: wallet_a.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group_b.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: ctx, + }) + .expect_err("binding through another wallet's DKG session must be rejected"); + assert!( + matches!(err, EngineError::SessionConflict { .. }), + "unexpected error: {err:?}" + ); + + // Wallet A's DKG session is untouched: no B binding installed. + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(wallet_a).expect("wallet A session"); + assert_eq!( + session + .dkg_result + .as_ref() + .map(|dkg| dkg.key_group.as_str()), + Some(key_group_a) + ); + assert!( + session.bound_key_group.is_none(), + "no cross-wallet binding may be installed on wallet A's session" + ); + } +} + +#[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 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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 per_message_session_retirement_preserves_wallet_routing_and_retry_state() { + let _guard = lock_test_state(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "5"); + + let mut engine_state = EngineState::default(); + engine_state.sessions.insert( + "wallet".to_string(), + SessionState { + dkg_result: Some(DkgResult { + session_id: "wallet".to_string(), + key_group: "wallet-key-group".to_string(), + participant_count: 3, + threshold: 2, + created_at_unix: now_unix(), + }), + ..Default::default() + }, + ); + engine_state.sessions.insert( + "open-only".to_string(), + SessionState { + bound_key_group: Some("wallet-key-group".to_string()), + ..Default::default() + }, + ); + engine_state.sessions.insert( + "consumed".to_string(), + SessionState { + bound_key_group: Some("wallet-key-group".to_string()), + consumed_interactive_attempt_markers: HashSet::from([interactive_consumed_marker( + &"11".repeat(32), + 1, + )]), + authorized_interactive_aggregate_markers: HashSet::from(["22".repeat(32)]), + ..Default::default() + }, + ); + engine_state.sessions.insert( + "completed".to_string(), + SessionState { + bound_key_group: Some("wallet-key-group".to_string()), + aggregated_interactive_attempt_markers: HashSet::from([format!( + "{}@{}@keypath", + "33".repeat(32), + "44".repeat(32) + )]), + ..Default::default() + }, + ); + engine_state.sessions.insert( + "retry-policy".to_string(), + SessionState { + bound_key_group: Some("wallet-key-group".to_string()), + build_tx_request_fingerprint: Some("policy-fingerprint".to_string()), + ..Default::default() + }, + ); + + assert_eq!(retire_idle_per_message_sessions(&mut engine_state, None), 4); + assert_eq!(active_session_count(&engine_state.sessions), 1); + assert_eq!(retired_interactive_session_count(&engine_state.sessions), 4); + assert!(engine_state.sessions["wallet"] + .retired_interactive_at_unix + .is_none()); + assert_eq!( + engine_state.sessions["retry-policy"].build_tx_request_fingerprint, + Some("policy-fingerprint".to_string()) + ); + assert!(engine_state.sessions["consumed"] + .authorized_interactive_aggregate_markers + .contains(&"22".repeat(32))); + + reactivate_retired_per_message_session(&mut engine_state, "retry-policy") + .expect("retired retry state reactivates"); + assert_eq!(active_session_count(&engine_state.sessions), 2); + assert!(engine_state.sessions["retry-policy"] + .retired_interactive_at_unix + .is_none()); + assert_eq!( + engine_state.sessions["retry-policy"].build_tx_request_fingerprint, + Some("policy-fingerprint".to_string()) + ); + + clear_state_storage_policy_overrides(); +} + +#[test] +fn retired_per_message_sessions_share_the_total_bound_and_yield_to_admission() { + let _guard = lock_test_state(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "2"); + + let mut engine_state = EngineState::default(); + for (session_id, retired_at) in [("oldest", 1), ("middle", 2), ("newest", 3)] { + engine_state.sessions.insert( + session_id.to_string(), + SessionState { + bound_key_group: Some("wallet-key-group".to_string()), + retired_interactive_at_unix: Some(retired_at), + consumed_interactive_attempt_markers: HashSet::from([interactive_consumed_marker( + &hash_hex(session_id.as_bytes()), + 1, + )]), + ..Default::default() + }, + ); + } + + assert_eq!( + compact_retired_per_message_sessions(&mut engine_state, Some("newest")).len(), + 1 + ); + assert!(!engine_state.sessions.contains_key("oldest")); + assert!(engine_state.sessions.contains_key("middle")); + assert!(engine_state.sessions.contains_key("newest")); + assert_eq!(active_session_count(&engine_state.sessions), 0); + assert_eq!(retired_interactive_session_count(&engine_state.sessions), 2); + let reserved = ensure_session_insert_capacity(&mut engine_state, "fresh-active") + .expect("bounded retirement cannot block active admission"); + assert_eq!( + reserved + .iter() + .map(|(session_id, _)| session_id.as_str()) + .collect::>(), + vec!["middle"] + ); + engine_state + .sessions + .insert("fresh-active".to_string(), SessionState::default()); + assert_eq!(engine_state.sessions.len(), 2); + assert!(engine_state.sessions.contains_key("newest")); + assert!(engine_state.sessions.contains_key("fresh-active")); + + clear_state_storage_policy_overrides(); +} + +#[test] +fn session_slot_reservation_preserves_pinned_and_persistence_pending_tombstones() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "1"); + + let retired_session = "protected-retired-session"; + let aggregated_marker = interactive_aggregated_marker( + &hash_hex(b"protected-attempt"), + &hash_hex(b"protected-message"), + None, + ); + let mut engine_state = EngineState::default(); + engine_state.sessions.insert( + retired_session.to_string(), + SessionState { + bound_key_group: Some("protected-retired-key".to_string()), + retired_interactive_at_unix: Some(1), + aggregated_interactive_attempt_markers: HashSet::from([aggregated_marker.clone()]), + ..Default::default() + }, + ); + + let aggregate_pin = Arc::clone(&engine_state.sessions[retired_session].aggregate_eviction_pin); + let pinned_error = match ensure_session_insert_capacity(&mut engine_state, "new-active") { + Ok(_) => panic!("an in-flight Aggregate pin must block eviction"), + Err(error) => error, + }; + assert!(matches!(pinned_error, EngineError::Internal(_))); + assert!(engine_state.sessions.contains_key(retired_session)); + drop(aggregate_pin); + + let pending = PersistencePendingOperation::InteractiveAggregate { + session_id: retired_session.to_string(), + aggregated_marker, + }; + mark_persistence_pending(pending.clone()); + let pending_error = match ensure_session_insert_capacity(&mut engine_state, "new-active") { + Ok(_) => panic!("an uncovered persistence marker must block eviction"), + Err(error) => error, + }; + assert!(matches!(pending_error, EngineError::Internal(_))); + assert!(engine_state.sessions.contains_key(retired_session)); + clear_persistence_pending_operation(&pending); + + let removed = ensure_session_insert_capacity(&mut engine_state, "new-active") + .expect("the slot becomes available after both protections release"); + assert_eq!(removed.len(), 1); + assert_eq!(removed[0].0, retired_session); + assert!(engine_state.sessions.is_empty()); + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + clear_state_storage_policy_overrides(); +} + +#[test] +fn idle_per_message_session_stays_active_while_marker_persistence_is_pending() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "pending-idle-per-message"; + let aggregated_marker = interactive_aggregated_marker( + &hash_hex(b"pending-idle-attempt"), + &hash_hex(b"pending-idle-message"), + None, + ); + let pending_operation = PersistencePendingOperation::InteractiveAggregate { + session_id: session_id.to_string(), + aggregated_marker: aggregated_marker.clone(), + }; + let mut engine_state = EngineState::default(); + engine_state.sessions.insert( + session_id.to_string(), + SessionState { + bound_key_group: Some("pending-idle-key-group".to_string()), + aggregated_interactive_attempt_markers: HashSet::from([aggregated_marker]), + ..Default::default() + }, + ); + mark_persistence_pending(pending_operation.clone()); + + assert_eq!(retire_idle_per_message_sessions(&mut engine_state, None), 0); + assert!(engine_state.sessions[session_id] + .retired_interactive_at_unix + .is_none()); + + clear_persistence_pending_operation(&pending_operation); + assert_eq!(retire_idle_per_message_sessions(&mut engine_state, None), 1); + assert!(engine_state.sessions[session_id] + .retired_interactive_at_unix + .is_some()); +} + +#[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 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(); + + persist_state_for_key_provider_test("session-lock-path-initial") + .expect("initial state persist"); + + std::env::set_var(TBTC_SIGNER_STATE_PATH_ENV, &alternate_state_path); + + let err = persist_state_for_key_provider_test("session-lock-path-switch") + .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(); + + // Operation type 1: distributed-DKG key-package persistence. + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(9); + let persist_result = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-restart-dkg".to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public.clone(), + }) + .expect("persist distributed dkg key package"); + + // Operation type 2: taproot transaction building. + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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"); + + 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")); + } + + // The persisted DKG session survives the restart: a sibling seat + // accumulates into the reloaded session and shares its key group. + let persist_sibling = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-restart-dkg".to_string(), + participant_identifier: 2, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&2).expect("seat 2").clone(), + public_key_package: native_public.clone(), + }) + .expect("persist sibling seat after reload"); + assert_eq!(persist_sibling.key_group, persist_result.key_group); + + // Idempotent retries of the persisted operations return the same result. + let build_retry_result = build_taproot_tx(build_request).expect("retry build taproot tx"); + assert_eq!(build_result, build_retry_result); + + // A brand-new operation on a fresh session works post-restart. + let (new_public, new_key_packages) = sample_distributed_dkg_native_material(11); + let new_session_result = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-restart-new".to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: new_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: new_public, + }) + .expect("post-restart persist"); + assert!(!new_session_result.key_group.is_empty()); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn first_engine_state_load_is_serialized_across_callers() { + let engine_state = std::sync::Arc::new(OnceLock::>::new()); + let initialization_lock = std::sync::Arc::new(Mutex::new(())); + let initial_miss_barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let load_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + + let callers = (0..2) + .map(|_| { + let engine_state = std::sync::Arc::clone(&engine_state); + let initialization_lock = std::sync::Arc::clone(&initialization_lock); + let initial_miss_barrier = std::sync::Arc::clone(&initial_miss_barrier); + let load_count = std::sync::Arc::clone(&load_count); + + std::thread::spawn(move || { + let initialized = initialize_engine_state_with_loader( + &engine_state, + &initialization_lock, + || { + // Both callers must pass the optimistic OnceLock check + // before either may enter the serialized loader path. + initial_miss_barrier.wait(); + }, + || { + let invocation = + load_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(EngineState { + refresh_epoch_counter: invocation as u64 + 41, + ..EngineState::default() + }) + }, + ) + .expect("concurrent initialization"); + + let state_pointer = std::ptr::from_ref(initialized) as usize; + let refresh_epoch_counter = initialized + .lock() + .expect("initialized engine state lock") + .refresh_epoch_counter; + (state_pointer, refresh_epoch_counter) + }) + }) + .collect::>(); + + let results = callers + .into_iter() + .map(|caller| caller.join().expect("initialization caller")) + .collect::>(); + + assert_eq!( + load_count.load(std::sync::atomic::Ordering::SeqCst), + 1, + "only one first caller may load or migrate persistent state" + ); + assert_eq!(results[0].0, results[1].0); + assert_eq!(results[0].1, 41); + assert_eq!(results[1].1, 41); +} + +#[test] +fn failed_engine_state_load_remains_retryable() { + let engine_state = OnceLock::>::new(); + let initialization_lock = Mutex::new(()); + let load_count = std::sync::atomic::AtomicUsize::new(0); + + let first_error = match initialize_engine_state_with_loader( + &engine_state, + &initialization_lock, + || {}, + || { + load_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err(EngineError::Internal( + "intentional first-load failure".to_string(), + )) + }, + ) { + Ok(_) => panic!("failed loader must not initialize engine state"), + Err(error) => error, + }; + expect_internal_error_contains(first_error, "intentional first-load failure"); + assert!( + engine_state.get().is_none(), + "a fallible load must leave the OnceLock unset" + ); + + let initialized = initialize_engine_state_with_loader( + &engine_state, + &initialization_lock, + || {}, + || { + load_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(EngineState { + refresh_epoch_counter: 73, + ..EngineState::default() + }) + }, + ) + .expect("retry after failed state load"); + + assert_eq!(load_count.load(std::sync::atomic::Ordering::SeqCst), 2); + assert_eq!( + initialized + .lock() + .expect("initialized engine state lock") + .refresh_epoch_counter, + 73 + ); +} + +#[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(); + + persist_state_for_key_provider_test("session-state-file-permissions") + .expect("persist signer state"); + + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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 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 state_file_parent_directory_normalizes_only_bare_paths() { + assert_eq!( + state_file_parent_directory(Path::new("state.json")), + Some(Path::new(".")) + ); + + let nested_state_path = Path::new("nested/state.json"); + assert_eq!( + state_file_parent_directory(nested_state_path), + nested_state_path.parent() + ); + + let absolute_state_path = std::env::temp_dir().join("nested").join("state.json"); + assert!(absolute_state_path.is_absolute()); + assert_eq!( + state_file_parent_directory(&absolute_state_path), + absolute_state_path.parent() + ); +} + +#[test] +fn bare_state_path_persists_and_syncs_current_directory() { + let _guard = lock_test_state(); + let state_path = PathBuf::from(format!( + "frost_tbtc_engine_state_bare_persist_{}.json", + std::process::id() + )); + assert_eq!(state_path.parent(), Some(Path::new(""))); + + clear_state_storage_policy_overrides(); + cleanup_test_state_artifacts(&state_path); + std::env::set_var(TBTC_SIGNER_STATE_PATH_ENV, &state_path); + reset_for_tests(); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + guard.refresh_epoch_counter = 41; + persist_engine_state_to_storage(&guard) + .expect("persist state through a one-component path"); + } + + assert!(state_path.exists(), "bare state path should be replaced"); + let loaded = load_engine_state_from_storage().expect("load persisted bare-path state"); + assert_eq!(loaded.refresh_epoch_counter, 41); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn bare_state_path_corruption_quarantine_enumerates_current_directory() { + let _guard = lock_test_state(); + let state_path = PathBuf::from(format!( + "frost_tbtc_engine_state_bare_corrupt_{}.json", + std::process::id() + )); + assert_eq!(state_path.parent(), Some(Path::new(""))); + + clear_state_storage_policy_overrides(); + cleanup_test_state_artifacts(&state_path); + std::env::set_var(TBTC_SIGNER_STATE_PATH_ENV, &state_path); + 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-bare-state") + .expect("write corrupt one-component state path"); + + let loaded = load_engine_state_from_storage().expect("quarantine corrupt bare-path state"); + assert!(loaded.sessions.is_empty()); + assert!(!state_path.exists()); + + let backups = sorted_corrupted_state_backups(&state_path).expect("enumerate bare-path backups"); + assert_eq!(backups.len(), 1); + assert_eq!( + std::fs::read(&backups[0]).expect("read quarantined bare-path state"), + b"{invalid-bare-state" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn empty_state_file_fails_closed_by_default() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("empty_state_fail_closed"); + reset_for_tests(); + + std::fs::write(&state_path, b"").expect("truncate state file to zero bytes"); + + let err = match load_engine_state_from_storage() { + Ok(_) => panic!("empty state must be corruption"), + Err(err) => err, + }; + assert!(matches!(err, EngineError::Internal(_))); + let err_message = err.to_string(); + assert!(err_message.contains("exists but is empty")); + assert!(err_message.contains("refusing to continue with corrupted signer state file")); + assert!(err_message.contains(TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV)); + assert_eq!( + std::fs::metadata(&state_path) + .expect("empty state file remains") + .len(), + 0 + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[cfg(unix)] +#[test] +fn dangling_state_file_symlink_fails_closed() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("dangling_state_symlink"); + reset_for_tests(); + + let missing_target = state_path.with_extension("missing-target"); + let _ = std::fs::remove_file(&missing_target); + std::fs::remove_file(&state_path).expect("remove initialized state file"); + std::os::unix::fs::symlink(&missing_target, &state_path) + .expect("create dangling state symlink"); + + let err = match load_engine_state_from_storage() { + Ok(_) => panic!("a dangling state symlink must not initialize clean state"), + Err(err) => err, + }; + assert!( + matches!(err, EngineError::Internal(ref message) if message.contains("failed to read signer state file")), + "unexpected error: {err:?}" + ); + assert!( + std::fs::symlink_metadata(&state_path) + .expect("dangling symlink metadata") + .file_type() + .is_symlink(), + "the failed load must not replace or reinterpret the dangling symlink" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + let _ = std::fs::remove_file(&missing_target); + 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 empty_state_file_quarantines_and_resets_when_enabled() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("empty_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"").expect("truncate state file to zero bytes"); + + let loaded = load_engine_state_from_storage().expect("quarantine empty 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); + assert_eq!( + std::fs::metadata(&backups[0]) + .expect("empty backup exists") + .len(), + 0 + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +// The plaintext-acceptance path is debug-only (legacy_plaintext_state_permitted +// gates on cfg!(debug_assertions)), so this rollback-path test is too; in a +// release build the bytes are always refused before schema validation is reached. +#[cfg(debug_assertions)] +#[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"); + + // Schema validation runs only after the plaintext gate, so opt into the + // legacy plaintext rollback path (development profile + flag) to reach it. + std::env::set_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV, "true"); + let err = match load_engine_state_from_storage() { + Ok(_) => panic!("expected schema mismatch failure"), + Err(err) => err, + }; + std::env::remove_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV); + 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(); +} + +#[cfg(debug_assertions)] // plaintext rollback path is debug-only; see legacy_plaintext_state_permitted +#[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"); + + // Reach schema validation (the test's intent) by opting into the plaintext + // rollback path; otherwise the plaintext gate refuses the bytes before the + // schema check and the quarantine-and-reset would fire for the wrong reason. + std::env::set_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV, "true"); + let loaded = load_engine_state_from_storage().expect("recover from schema mismatch state"); + std::env::remove_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV); + 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(); +} + +#[cfg(debug_assertions)] // plaintext rollback path is debug-only; see legacy_plaintext_state_permitted +#[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"); + + // Without the opt-in rollback flag the unauthenticated plaintext is refused + // (fail-closed), even in a non-production profile. + assert!( + load_engine_state_from_storage().is_err(), + "plaintext signer state must be rejected without the rollback opt-in" + ); + + // Plaintext load is an opt-in emergency-rollback path: development profile + // (selected by reset_for_tests) + this flag. + std::env::set_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV, "true"); + let loaded = load_engine_state_from_storage().expect("load and migrate legacy plaintext"); + std::env::remove_var(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV); + 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 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 = persist_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 = persist_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 = persist_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 = + persist_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 = persist_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 + ), + ); + + persist_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 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 = persist_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 = + persist_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), + ); + + persist_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(); +} + +// --- init-time signer config (frost_tbtc_init_signer_config) --- + +/// Clears the installed config on drop so a panicking test cannot leak an +/// installed snapshot into unrelated tests that expect env-fallback mode. +struct InstalledConfigClearGuard; + +impl Drop for InstalledConfigClearGuard { + fn drop(&mut self) { + clear_installed_signer_config_for_tests(); + } +} + +#[test] +fn init_signer_config_overrides_environment_for_covered_knobs() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "120000"); + assert_eq!(roast_coordinator_timeout_ms(), 120_000); + assert_eq!( + heartbeat_rate_limit_per_minute().unwrap(), + TBTC_SIGNER_DEFAULT_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE + ); + + let result = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + roast_coordinator_timeout_ms: Some(60_000), + policy_heartbeat_rate_limit_per_minute: Some(7), + canary_max_interactive_round1_p95_ms: Some(101), + canary_max_interactive_round2_p95_ms: Some(202), + canary_max_interactive_aggregate_p95_ms: Some(303), + canary_min_samples: Some(42), + canary_min_policy_samples: Some(7), + canary_max_sample_age_seconds: Some(900), + ..InitSignerConfigRequest::default() + }) + .expect("install config"); + assert!(result.installed); + assert!(!result.idempotent); + assert_eq!(result.configured_key_count, 9); + + assert_eq!(roast_coordinator_timeout_ms(), 60_000); + assert_eq!(heartbeat_rate_limit_per_minute().unwrap(), 7); + assert_eq!(canary_max_interactive_round1_p95_ms(), 101); + assert_eq!(canary_max_interactive_round2_p95_ms(), 202); + assert_eq!(canary_max_interactive_aggregate_p95_ms(), 303); + assert_eq!(canary_min_samples(), 42); + assert_eq!(canary_min_policy_samples(), 7); + assert_eq!(canary_max_sample_age_seconds(), 900); + std::env::remove_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV); +} + +#[test] +fn init_signer_config_rejects_zero_heartbeat_rate_limit_without_installing() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + policy_heartbeat_rate_limit_per_minute: Some(0), + ..InitSignerConfigRequest::default() + }) + .expect_err("a zero heartbeat rate limit must fail init"); + assert!( + error + .to_string() + .contains(TBTC_SIGNER_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE_ENV), + "unexpected error: {error}" + ); + + // Failed candidate validation must not install a snapshot. + std::env::set_var(TBTC_SIGNER_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE_ENV, "9"); + assert_eq!(heartbeat_rate_limit_per_minute().unwrap(), 9); + std::env::remove_var(TBTC_SIGNER_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE_ENV); +} + +#[test] +fn init_signer_config_ignores_environment_wholesale_for_unset_fields() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // A valid env override that would normally win... + std::env::set_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV, "120"); + assert_eq!(refresh_cadence_seconds(), 120); + + // ...is ignored once a config is installed, even though the installed + // config does not set that field: absent field = built-in default. + init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + roast_coordinator_timeout_ms: Some(60_000), + ..InitSignerConfigRequest::default() + }) + .expect("install config"); + + assert_eq!( + refresh_cadence_seconds(), + TBTC_SIGNER_DEFAULT_REFRESH_CADENCE_SECONDS + ); + std::env::remove_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV); +} + +#[test] +fn init_signer_config_is_idempotent_for_identical_request_and_rejects_conflicts() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let request = InitSignerConfigRequest { + profile: Some("development".to_string()), + max_sessions: Some(64), + ..InitSignerConfigRequest::default() + }; + let first = init_signer_config(request.clone()).expect("first install"); + assert!(!first.idempotent); + + let second = init_signer_config(request).expect("identical re-init"); + assert!(second.idempotent); + assert_eq!(second.config_fingerprint, first.config_fingerprint); + + let conflict = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + max_sessions: Some(128), + ..InitSignerConfigRequest::default() + }) + .expect_err("conflicting re-init must be rejected"); + let message = conflict.to_string(); + assert!( + message.contains("conflicting re-initialization rejected"), + "unexpected error: {message}" + ); +} + +#[test] +fn init_signer_config_rejects_invalid_profile_without_installing() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("staging".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect_err("invalid profile must be rejected"); + assert!(error.to_string().contains("profile"), "{error}"); + + // Nothing installed: environment reads still apply. + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "120000"); + assert_eq!(roast_coordinator_timeout_ms(), 120_000); + std::env::remove_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV); +} + +#[test] +fn init_signer_config_rolls_back_install_when_policy_validation_fails() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // Firewall enforcement on with an INVALID policy (a UTC start hour without a + // matching end hour) -> the loader rejects and the install must roll back. + // Absent firewall knobs no longer trip this: the loader now falls back to + // conservative built-in defaults, so only an explicitly-invalid value fails. + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + enforce_signing_policy_firewall: Some(true), + policy_allowed_utc_start_hour: Some(8), + ..InitSignerConfigRequest::default() + }) + .expect_err("invalid firewall policy must fail the init"); + assert!( + error.to_string().contains("must be configured together"), + "unexpected error: {error}" + ); + + // Rolled back: env fallback is live again. + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "120000"); + assert_eq!(roast_coordinator_timeout_ms(), 120_000); + std::env::remove_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV); +} + +#[test] +fn init_signer_config_validates_complete_admission_policy_at_install() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let result = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + enforce_admission_policy: Some(true), + admission_min_participants: Some(3), + admission_min_threshold: Some(2), + ..InitSignerConfigRequest::default() + }) + .expect("complete admission policy installs"); + assert_eq!(result.configured_key_count, 4); + + let config = load_admission_policy_config() + .expect("load admission policy") + .expect("admission policy enforced"); + assert_eq!(config.min_participants, 3); + assert_eq!(config.min_threshold, 2); +} + +#[test] +fn init_signer_config_keeps_state_encryption_key_on_environment_channel() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // reset_for_tests points the env key at TEST_STATE_ENCRYPTION_KEY_HEX. + // Installing a config that selects the env provider but (by design) + // cannot carry the key itself must still resolve the key from the real + // environment. + init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + state_key_provider: Some("env".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect("install config"); + + let material = state_encryption_key_material().expect("key material resolves from env"); + assert_eq!( + material.key_provider, + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT + ); +} + +#[test] +fn init_signer_config_production_profile_forces_roast_strict_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let (test_trust_root, test_payload, test_signature) = build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix() + 3600), + ); + + // lock_test_state pins the env profile to development; the installed + // config must override it wholesale. + init_signer_config(InitSignerConfigRequest { + profile: Some("production".to_string()), + state_path: Some( + std::env::temp_dir() + .join("frost_init_config_prod_profile_state.json") + .to_string_lossy() + .into_owned(), + ), + state_key_provider: Some("command".to_string()), + state_key_command: Some("/nonexistent/key-helper-never-run-at-init".to_string()), + provenance_attestation_status: Some("approved".to_string()), + provenance_trust_root: Some(test_trust_root.clone()), + provenance_attestation_payload: Some(test_payload.clone()), + provenance_attestation_signature_hex: Some(test_signature.clone()), + min_approved_version: Some(TBTC_SIGNER_RUNTIME_VERSION.to_string()), + ..InitSignerConfigRequest::default() + }) + .expect("install config"); + + assert!(signer_profile_is_production()); + assert!(roast_strict_mode_enabled()); +} + +#[test] +fn reset_for_tests_clears_installed_signer_config() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + roast_coordinator_timeout_ms: Some(60_000), + ..InitSignerConfigRequest::default() + }) + .expect("install config"); + assert_eq!(roast_coordinator_timeout_ms(), 60_000); + + reset_for_tests(); + + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "120000"); + assert_eq!(roast_coordinator_timeout_ms(), 120_000); + std::env::remove_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV); +} + +#[test] +fn init_signer_config_request_rejects_unknown_fields() { + let parsed: Result = + serde_json::from_str(r#"{"polciy_max_output_count": 1}"#); + assert!(parsed.is_err(), "typo'd field names must fail the parse"); +} + +#[test] +fn init_signer_config_canonicalizes_list_and_bool_encodings() { + let values = config_values_from_request(&InitSignerConfigRequest { + enable_auto_quarantine: Some(false), + auto_quarantine_dao_allowlist_identifiers: Some(vec![3, 1, 2, 2]), + policy_allowed_script_classes: Some(vec!["P2TR".to_string(), "p2wpkh".to_string()]), + permit_plaintext_state_rollback: Some(true), + ..InitSignerConfigRequest::default() + }) + .expect("convert request"); + + assert_eq!( + values + .get(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV) + .map(String::as_str), + Some("false") + ); + assert_eq!( + values + .get(TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS_ENV) + .map(String::as_str), + Some("1,2,3") + ); + // Raw values are inserted; the existing loader normalizes case exactly as + // it does for environment values. + assert_eq!( + values + .get(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV) + .map(String::as_str), + Some("P2TR,p2wpkh") + ); + // The plaintext rollback opt-in is reachable via init-time config, not only + // the process environment. + assert_eq!( + values + .get(TBTC_SIGNER_PERMIT_PLAINTEXT_STATE_ROLLBACK_ENV) + .map(String::as_str), + Some("true") + ); + + let empty_list = config_values_from_request(&InitSignerConfigRequest { + admission_required_identifiers: Some(Vec::new()), + ..InitSignerConfigRequest::default() + }); + assert!( + empty_list.is_err(), + "empty identifier list must be rejected" + ); +} + +#[test] +fn init_signer_config_rejects_production_config_without_state_path() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // Explicit production AND production-by-omission (the wholesale default + // when the profile field is unset) must both fail the init when no + // state_path is configured, instead of installing and then failing at + // the first state access. + for request in [ + InitSignerConfigRequest { + profile: Some("production".to_string()), + ..InitSignerConfigRequest::default() + }, + InitSignerConfigRequest { + roast_coordinator_timeout_ms: Some(60_000), + ..InitSignerConfigRequest::default() + }, + ] { + let error = init_signer_config(request) + .expect_err("production config without state_path must fail the init"); + assert!( + error + .to_string() + .contains("refusing to use the implicit temp-dir signer state path"), + "unexpected error: {error}" + ); + } + + // Nothing installed: environment reads still apply. + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "120000"); + assert_eq!(roast_coordinator_timeout_ms(), 120_000); + std::env::remove_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV); +} + +#[test] +fn init_signer_config_rejects_production_config_defaulting_to_env_key_provider() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // Wholesale semantics: leaving state_key_provider unset in the config + // defaults to the env provider even if the environment exported + // "command" - and production forbids the env provider. This must fail + // the init, not the first state access. + std::env::set_var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, "command"); + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("production".to_string()), + state_path: Some("/var/lib/tbtc/signer-state.json".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect_err("production config defaulting to the env key provider must fail the init"); + assert!( + error.to_string().contains("is not allowed in profile"), + "unexpected error: {error}" + ); + std::env::remove_var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV); +} + +#[test] +fn init_signer_config_rejects_command_key_provider_without_command() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + state_key_provider: Some("command".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect_err("command key provider without a command must fail the init"); + assert!( + error + .to_string() + .contains("missing required state key command env"), + "unexpected error: {error}" + ); +} + +#[test] +fn init_signer_config_rejects_unknown_state_key_provider() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + state_key_provider: Some("kms".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect_err("unknown key provider must fail the init"); + assert!( + error.to_string().contains("unsupported state key provider"), + "unexpected error: {error}" + ); +} + +#[test] +fn init_signer_config_validates_command_key_provider_without_executing_it() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let (test_trust_root, test_payload, test_signature) = build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix() + 3600), + ); + + // The command path deliberately points at a binary that cannot succeed: + // if init executed the key command, this install would fail. Structural + // validation must accept it without running it. + let result = init_signer_config(InitSignerConfigRequest { + profile: Some("production".to_string()), + state_path: Some("/var/lib/tbtc/signer-state.json".to_string()), + state_key_provider: Some("command".to_string()), + state_key_command: Some("/nonexistent/key-helper-never-run-at-init".to_string()), + provenance_attestation_status: Some("approved".to_string()), + provenance_trust_root: Some(test_trust_root.clone()), + provenance_attestation_payload: Some(test_payload.clone()), + provenance_attestation_signature_hex: Some(test_signature.clone()), + min_approved_version: Some(TBTC_SIGNER_RUNTIME_VERSION.to_string()), + ..InitSignerConfigRequest::default() + }) + .expect("structurally valid production config installs without running the key command"); + assert!(result.installed); +} + +#[test] +fn init_signer_config_rejects_production_config_without_provenance_attestation() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // Production forces the provenance gate; a production config that is + // otherwise complete but carries no attestation set is unusable for + // every protected operation and must fail the init, not the first call. + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("production".to_string()), + state_path: Some("/var/lib/tbtc/signer-state.json".to_string()), + state_key_provider: Some("command".to_string()), + state_key_command: Some("/nonexistent/key-helper-never-run-at-init".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect_err("production config without provenance attestation must fail the init"); + assert!( + error + .to_string() + .contains(TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV), + "unexpected error: {error}" + ); +} + +#[test] +fn init_signer_config_rejects_enforced_gate_with_unparseable_trust_root() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let (_, test_payload, test_signature) = build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix() + 3600), + ); + + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + enforce_provenance_gate: Some(true), + provenance_attestation_status: Some("approved".to_string()), + provenance_trust_root: Some("not-a-pubkey".to_string()), + provenance_attestation_payload: Some(test_payload), + provenance_attestation_signature_hex: Some(test_signature), + ..InitSignerConfigRequest::default() + }) + .expect_err("enforced gate with unparseable trust root must fail the init"); + assert!( + error + .to_string() + .to_ascii_lowercase() + .contains("trust_root"), + "unexpected error: {error}" + ); +} + +#[test] +fn init_signer_config_installs_production_config_with_valid_provenance() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let (test_trust_root, test_payload, test_signature) = build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix() + 3600), + ); + + let result = init_signer_config(InitSignerConfigRequest { + profile: Some("production".to_string()), + state_path: Some("/var/lib/tbtc/signer-state.json".to_string()), + state_key_provider: Some("command".to_string()), + state_key_command: Some("/nonexistent/key-helper-never-run-at-init".to_string()), + provenance_attestation_status: Some("approved".to_string()), + provenance_trust_root: Some(test_trust_root), + provenance_attestation_payload: Some(test_payload), + provenance_attestation_signature_hex: Some(test_signature), + min_approved_version: Some(TBTC_SIGNER_RUNTIME_VERSION.to_string()), + ..InitSignerConfigRequest::default() + }) + .expect("complete production config installs"); + assert!(result.installed); + assert!(signer_profile_is_production()); + assert!(provenance_gate_enforced()); +} + +// --- Phase 7.1: hardened interactive signing session --- +// +// These tests pin the frozen-spec contracts (sections 4-5 of +// docs/phase-7-interactive-session-spec-freeze.md): engine-held nonce +// custody, Round2 verification (a)-(f) including the own-commitment +// framing defense, verify-before-consume, consumption-before-release +// marker durability, and abort/expiry/capacity semantics. + +fn interactive_test_key_packages() -> BTreeMap { + let fixture = deterministic_interactive_dkg_fixture(0); + fixture + .part3_requests + .into_iter() + .map(|(id, request)| { + ( + id, + dkg_part3(request) + .expect("DKG part3 for fixture") + .key_package, + ) + }) + .collect() +} + +// Seed a session with DKG state (threshold 2, members 1..3) from the +// deterministic fixture, so the interactive path can resolve key +// material from engine state - the request never carries it. Idempotent +// per session_id. Returns the fixture's native key packages for tests +// that also drive the stateless primitive (e.g. the non-interactive +// member in an aggregation). +fn ensure_interactive_dkg_session( + session_id: &str, + key_group: &str, +) -> BTreeMap { + let fixture = deterministic_interactive_dkg_fixture(0); + let mut native = BTreeMap::new(); + let mut public_key_package_native = None; + for (id, request) in fixture.part3_requests { + let result = dkg_part3(request).expect("DKG part3 for fixture"); + if public_key_package_native.is_none() { + public_key_package_native = Some(result.public_key_package.clone()); + } + native.insert(id, result.key_package); + } + let public_key_package_native = + public_key_package_native.expect("fixture has at least one participant"); + + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.entry(session_id.to_string()).or_default(); + if session.dkg_result.is_none() { + let mut frost_key_packages = BTreeMap::new(); + for (id, key_package) in &native { + let deserialized = frost::keys::KeyPackage::deserialize( + &hex::decode(key_package.data_hex.expose_secret()) + .expect("fixture key package hex decodes"), + ) + .expect("fixture key package deserializes"); + frost_key_packages.insert(*id, deserialized); + } + let public_key_package = + native_public_key_package_to_frost("interactive-dkg-seed", &public_key_package_native) + .expect("fixture public key package converts"); + session.dkg_result = Some(DkgResult { + session_id: session_id.to_string(), + key_group: key_group.to_string(), + participant_count: native.len() as u16, + threshold: 2, + created_at_unix: now_unix(), + }); + session.dkg_key_packages = Some(frost_key_packages); + session.dkg_public_key_package = Some(public_key_package); + } + + native +} + +fn interactive_test_attempt_context( + session_id: &str, + key_group: &str, + message_bytes: &[u8], + included_participants: &[u16], + wire_attempt_number: u32, +) -> AttemptContext { + let shuffle_seed = roast_attempt_shuffle_seed( + key_group, + session_id, + &rfc21_message_digest(message_bytes).expect("rfc21 message digest"), + ) + .expect("shuffle seed"); + let coordinator = + select_coordinator_identifier(included_participants, shuffle_seed, wire_attempt_number - 1) + .expect("coordinator selects"); + let fingerprint = roast_included_participants_fingerprint_hex(included_participants) + .expect("included participants fingerprint"); + let attempt_id = roast_attempt_id_hex( + session_id, + &hash_hex(message_bytes), + wire_attempt_number, + coordinator, + &fingerprint, + ) + .expect("attempt id"); + + AttemptContext { + attempt_number: wire_attempt_number, + coordinator_identifier: coordinator, + included_participants: included_participants.to_vec(), + included_participants_fingerprint: fingerprint, + attempt_id, + } +} + +fn heartbeat_message_for_test(nonce: u64) -> [u8; 16] { + let mut message = [0xff; 16]; + message[8..].copy_from_slice(&nonce.to_be_bytes()); + message +} + +fn heartbeat_signing_message_for_test(heartbeat_message: &[u8]) -> [u8; 32] { + let first_digest = Sha256::digest(heartbeat_message); + Sha256::digest(first_digest).into() +} + +fn heartbeat_signing_intent_for_test(message: &[u8]) -> InteractiveSigningIntent { + InteractiveSigningIntent::Heartbeat { + message_hex: hex::encode(message), + } +} + +#[allow(clippy::too_many_arguments)] +fn open_interactive_for_test( + session_id: &str, + key_group: &str, + message_bytes: &[u8], + included_participants: &[u16], + wire_attempt_number: u32, + member_identifier: u16, + threshold: u16, +) -> Result { + // Key material is resolved from the session's DKG state, never the + // request, so seed that state first (idempotent). + ensure_interactive_dkg_session(session_id, key_group); + let attempt_context = interactive_test_attempt_context( + session_id, + key_group, + message_bytes, + included_participants, + wire_attempt_number, + ); + interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier, + message_hex: hex::encode(message_bytes), + key_group: key_group.to_string(), + threshold, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context, + }) +} + +fn interactive_package_for_test( + message_bytes: &[u8], + commitments: Vec, +) -> String { + new_signing_package(NewSigningPackageRequest { + message_hex: hex::encode(message_bytes), + commitments, + }) + .expect("signing package builds") + .signing_package_hex +} + +fn interactive_last_activity_at_for_test(session_id: &str, member_identifier: u16) -> Instant { + let guard = state().expect("state").lock().expect("lock"); + guard.sessions[session_id].interactive_signing[&member_identifier].last_activity_at +} + +fn stateless_package_and_shares_for_test( + message_bytes: &[u8], + signer_ids: &[u16], + key_packages: &BTreeMap, +) -> (String, Vec) { + let generated = signer_ids + .iter() + .map(|signer_id| { + let key_package = &key_packages[signer_id]; + let nonces = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_package.identifier.clone(), + key_package_hex: key_package.data_hex.clone(), + }) + .expect("stateless signer nonces"); + (*signer_id, nonces) + }) + .collect::>(); + let signing_package_hex = interactive_package_for_test( + message_bytes, + generated + .iter() + .map(|(_, generated)| generated.commitment.clone()) + .collect(), + ); + let signature_shares = generated + .into_iter() + .map(|(signer_id, generated)| { + let key_package = &key_packages[&signer_id]; + sign_share(SignShareRequest { + signing_package_hex: signing_package_hex.clone(), + nonces_hex: generated.nonces_hex, + key_package_identifier: key_package.identifier.clone(), + key_package_hex: key_package.data_hex.clone(), + }) + .expect("stateless signature share") + .signature_share + }) + .collect(); + (signing_package_hex, signature_shares) +} + +// Regression for the deferred state-key resolution: a Round2 whose persist +// fails because the state-key command fails must NOT leave the consumption +// marker set (which would burn the attempt in-process). The key is resolved +// before the marker insert, so the failure returns cleanly with the nonces +// still live, and a retry after the key recovers still releases the share. +#[test] +fn interactive_round2_state_key_failure_does_not_burn_attempt() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-round2-key-failure"; + let key_group = "interactive-round2-key-failure-group"; + let message = [0x42u8; 32]; + let included = [1u16, 2]; + let ttl_seconds = interactive_session_ttl_seconds(); + let margin_seconds = ttl_seconds / 4; + assert!( + margin_seconds > 0, + "the configured interactive TTL must provide a synthetic test margin" + ); + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("interactive session opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("interactive round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 stateless nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex.clone(), + }, + member2.commitment.clone(), + ], + ); + let rejected_signing_package_hex = interactive_package_for_test( + &[0x43u8; 32], + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + + // Advance well within the TTL. Invalid traffic must not refresh this live + // handle, while the later retry-preserving key failure must refresh it at + // failure completion. + let prior_activity = interactive_last_activity_at_for_test(session_id, 1); + advance_interactive_clock_for_tests(ttl_seconds / 2); + + let rejected = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: rejected_signing_package_hex, + }) + .expect_err("a wrong-message Round2 package must be rejected"); + assert!(matches!(rejected, EngineError::Validation(_))); + assert_eq!( + interactive_last_activity_at_for_test(session_id, 1), + prior_activity, + "invalid Round2 traffic must not refresh activity" + ); + + // Make the state-key command fail, then attempt Round2. + 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 7"); + + let round2_started_at = interactive_now(); + let err = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect_err("round 2 must fail when the state-key command fails"); + let failure_returned_at = interactive_now(); + assert!(matches!(err, EngineError::Internal(_)), "got {err:?}"); + + // The consumption marker must NOT be set: the failed Round2 must not burn + // the attempt. + let refreshed_activity = { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + !session + .consumed_interactive_attempt_markers + .contains(&interactive_consumed_marker(&opened.attempt_id, 1)), + "a Round2 that failed at the state-key step must not leave a consumption marker" + ); + let interactive = session + .interactive_signing + .get(&1) + .expect("key failure leaves the nonce handle retryable"); + assert!(interactive.round1.is_some(), "Round1 nonces remain live"); + interactive.last_activity_at + }; + assert!( + refreshed_activity > prior_activity, + "validated retry-preserving Round2 failure must advance activity" + ); + assert!( + refreshed_activity >= round2_started_at && refreshed_activity <= failure_returned_at, + "Round2 failure activity must fall within the failed call" + ); + + // Model substantial but sub-TTL inactivity from the failed call without + // sleeping, then restore a working key. The same attempt must release its + // share rather than being swept on retry. + advance_interactive_clock_for_tests(margin_seconds); + assert!( + interactive_now().saturating_duration_since(refreshed_activity) + < Duration::from_secs(ttl_seconds) + ); + std::env::remove_var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV); + std::env::remove_var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV); + + let round2 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }) + .expect("round 2 retry must succeed after the key recovers"); + assert_eq!(round2.attempt_id, opened.attempt_id); +} + +#[test] +fn interactive_session_full_round_trip_aggregates_bip340() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-e2e"; + let key_group = "interactive-e2e-key-group"; + let message = [0x42u8; 32]; + let included = [1u16, 2]; + + // Member 1 signs through the hardened session API; member 2 signs + // through the stateless primitive. The shares must interoperate: + // the session layer changes custody, not cryptography. + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("interactive session opens"); + assert!(!opened.idempotent); + + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("interactive round 1"); + + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 stateless nonces"); + + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex.clone(), + }, + member2.commitment.clone(), + ], + ); + + let round2 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("interactive round 2 releases the share"); + assert_eq!(round2.attempt_id, opened.attempt_id); + + // A completed Round2 frees the live session state immediately: the + // resident key package + message must not linger to the TTL sweep, + // and the capacity slot must be returned. Only the durable marker + // remains. + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + session.interactive_signing.is_empty(), + "completed Round2 must free the live interactive session state" + ); + assert!( + session + .consumed_interactive_attempt_markers + .contains(&interactive_consumed_marker(&opened.attempt_id, 1)), + "the durable consumption marker must remain after Round2" + ); + } + + let member2_share = sign_share(SignShareRequest { + signing_package_hex: signing_package_hex.clone(), + nonces_hex: member2.nonces_hex, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 stateless share"); + + let public_key_package = dkg_part3( + deterministic_interactive_dkg_fixture(0) + .part3_requests + .remove(&1) + .expect("fixture participant 1"), + ) + .expect("public key package") + .public_key_package; + + let aggregate = aggregate(AggregateRequest { + signing_package_hex, + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: round2.signature_share_hex, + }, + member2_share.signature_share, + ], + public_key_package: 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(public_key_package.verifying_key).expect("key hex"); + let public_key = XOnlyPublicKey::from_slice(&public_key_bytes).expect("x-only key"); + Secp256k1::verification_only() + .verify_schnorr(&signature, &SecpMessage::from_digest(message), &public_key) + .expect("interactive + stateless shares aggregate to a valid BIP-340 signature"); +} + +#[test] +fn interactive_signs_across_sessions_by_key_group() { + // PRODUCTION SHAPE: a wallet's DKG material is persisted under its DKG session, + // but interactive ROAST signing runs under a DIFFERENT, per-message session (the + // RoastSessionID). The engine must resolve the wallet key by key_group so signing + // under a distinct session still works - otherwise distributed-DKG wallets, which + // are signable ONLY via the interactive path, could never sign. The single-session + // tests miss this because they persist and sign under one id. + let _guard = lock_test_state(); + let state_path = configure_test_state_path("interactive_cross_session_compaction"); + reset_for_tests(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "3"); + + let key_packages = interactive_test_key_packages(); + let wallet_session = "wallet-dkg-session"; + let signing_session = "roast-signing-session"; + let key_group = "cross-session-key-group"; + let message = [0x5au8; 32]; + let included = [1u16, 2]; + + // The DKG material lives ONLY under the wallet (DKG) session. + ensure_interactive_dkg_session(wallet_session, key_group); + + // All signing runs under a DISTINCT session id, with the attempt context derived + // from THAT signing session (coordinator/attempt id bind to the RoastSessionID, + // unchanged by this fix). + let open_under_signing_session = |member: u16| { + let attempt_context = + interactive_test_attempt_context(signing_session, key_group, &message, &included, 1); + interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: member, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context, + }) + .unwrap_or_else(|e| panic!("member {member} opens under the signing session: {e:?}")) + }; + let opened1 = open_under_signing_session(1); + let opened2 = open_under_signing_session(2); + assert_eq!(opened1.attempt_id, opened2.attempt_id); + + // The wallet material must NOT be copied into the signing session (no secret + // duplication): the signing session holds only per-signing state, bound to the + // wallet key by key_group. + { + let guard = state().expect("state").lock().expect("lock"); + let signing = guard + .sessions + .get(signing_session) + .expect("signing session created on open"); + assert!( + signing.dkg_key_packages.is_none() && signing.dkg_result.is_none(), + "signing session must not hold a copy of the wallet DKG material" + ); + assert_eq!( + signing.bound_key_group.as_deref(), + Some(key_group), + "signing session is bound to the wallet key it signs for" + ); + } + + let round1_m1 = interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: opened1.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 round 1 under the signing session"); + let round1_m2 = interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: opened2.attempt_id.clone(), + member_identifier: 2, + }) + .expect("member 2 round 1 under the signing session"); + + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1_m1.commitments_hex.clone(), + }, + NativeFrostCommitment { + identifier: key_packages[&2].identifier.clone(), + data_hex: round1_m2.commitments_hex.clone(), + }, + ], + ); + + let round2_m1 = interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened1.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("member 1 round 2 under the signing session"); + let round2_m2 = interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened2.attempt_id.clone(), + member_identifier: 2, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("member 2 round 2 under the signing session"); + + // Non-coordinator signers stop after Round2. The now-idle outer entry must + // leave the active budget immediately, while its exact package + // authorization remains durable for a delayed coordinator Aggregate. + let next_session = "next-roast-signing-session"; + build_taproot_tx(build_policy_test_request(next_session)) + .expect("a new message uses the active slot freed after Round2"); + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + { + let guard = state().expect("state").lock().expect("lock"); + assert_eq!(active_session_count(&guard.sessions), 2); + assert_eq!(retired_interactive_session_count(&guard.sessions), 1); + assert!(guard.sessions[signing_session] + .retired_interactive_at_unix + .is_some()); + assert_eq!( + guard.sessions[signing_session] + .authorized_interactive_aggregate_markers + .len(), + 1 + ); + } + + // interactive_aggregate resolves the group public key by key_group from the wallet + // session and produces a valid BIP-340 signature over the distinct signing session. + let aggregated = interactive_aggregate(InteractiveAggregateRequest { + session_id: signing_session.to_string(), + attempt_id: opened1.attempt_id.clone(), + signing_package_hex: signing_package_hex.clone(), + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: round2_m1.signature_share_hex, + }, + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&2].identifier.clone(), + data_hex: round2_m2.signature_share_hex, + }, + ], + taproot_merkle_root_hex: None, + }) + .expect("interactive aggregate resolves wallet material by key_group across sessions"); + + let public_key_package = dkg_part3( + deterministic_interactive_dkg_fixture(0) + .part3_requests + .remove(&1) + .expect("fixture participant 1"), + ) + .expect("public key package") + .public_key_package; + + let signature_bytes = hex::decode(aggregated.signature_hex).expect("signature hex"); + let signature = SchnorrSignature::from_slice(&signature_bytes).expect("BIP340 signature"); + let public_key_bytes = hex::decode(public_key_package.verifying_key).expect("key hex"); + let public_key = XOnlyPublicKey::from_slice(&public_key_bytes).expect("x-only key"); + Secp256k1::verification_only() + .verify_schnorr(&signature, &SecpMessage::from_digest(message), &public_key) + .expect("cross-session interactive signing produces a valid BIP-340 signature"); + + // With spare room in the shared total budget, the completed per-message + // entry retains wallet routing, exact Aggregate authorization, and typed + // replay markers across restart while the next message is admitted. + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + { + let guard = state().expect("state").lock().expect("lock"); + assert_eq!(guard.sessions.len(), 3); + assert!(guard.sessions.contains_key(wallet_session)); + assert!(guard.sessions.contains_key(signing_session)); + assert!(guard.sessions.contains_key(next_session)); + assert!(guard.sessions[signing_session] + .retired_interactive_at_unix + .is_some()); + assert_eq!( + guard.sessions[signing_session] + .authorized_interactive_aggregate_markers + .len(), + 1 + ); + assert_eq!(active_session_count(&guard.sessions), 2); + assert_eq!(retired_interactive_session_count(&guard.sessions), 1); + } + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn per_message_abort_and_expiry_retire_without_losing_policy_artifacts() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("interactive_cross_session_abort_expiry_retirement"); + reset_for_tests(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "4"); + + let wallet_session = "retirement-wallet"; + let key_group = "retirement-wallet-key-group"; + let included = [1u16, 2]; + ensure_interactive_dkg_session(wallet_session, key_group); + + let open = |session_id: &str, message: &[u8; 32]| { + interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + session_id, key_group, message, &included, 1, + ), + }) + }; + + let aborted_session = "retired-aborted-message"; + let aborted_build_request = build_policy_test_request(aborted_session); + build_taproot_tx(aborted_build_request.clone()).expect("aborted flow builds policy artifact"); + let aborted_open = open(aborted_session, &[0x71; 32]).expect("aborted flow opens"); + interactive_round1(InteractiveRound1Request { + session_id: aborted_session.to_string(), + attempt_id: aborted_open.attempt_id.clone(), + member_identifier: 1, + }) + .expect("aborted flow round 1"); + let aborted = interactive_session_abort(InteractiveSessionAbortRequest { + session_id: aborted_session.to_string(), + attempt_id: Some(aborted_open.attempt_id), + }) + .expect("abort retires the idle per-message entry"); + assert!(aborted.aborted); + + // The successful Abort itself is the durability boundary. Restart before + // any unrelated writer can accidentally flush the in-memory binding and + // retirement metadata; the Build-only shell must not return as an unbound + // active entry that consumes the shared session budget. + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + { + let guard = state().expect("state").lock().expect("lock"); + let session = &guard.sessions[aborted_session]; + assert_eq!(session.bound_key_group.as_deref(), Some(key_group)); + assert!(session.retired_interactive_at_unix.is_some()); + assert!(session.build_tx_request_fingerprint.is_some()); + assert!(session.tx_result.is_some()); + assert!(session.interactive_signing.is_empty()); + } + build_taproot_tx(aborted_build_request) + .expect("retired abort entry retains its BuildTaprootTx cache"); + + let expired_session = "retired-expired-message"; + build_taproot_tx(build_policy_test_request(expired_session)) + .expect("expired flow builds policy artifact"); + let expired_open = open(expired_session, &[0x72; 32]).expect("expired flow opens"); + interactive_round1(InteractiveRound1Request { + session_id: expired_session.to_string(), + attempt_id: expired_open.attempt_id.clone(), + member_identifier: 1, + }) + .expect("expired flow round 1"); + advance_interactive_clock_for_tests(interactive_session_ttl_seconds().saturating_add(1)); + let expired = interactive_round1(InteractiveRound1Request { + session_id: expired_session.to_string(), + attempt_id: expired_open.attempt_id, + member_identifier: 1, + }) + .expect_err("Round1 sweeps and rejects the expired flow"); + assert!(matches!(expired, EngineError::SessionNotFound { .. })); + + // Sweep-triggered expiry has Abort semantics and must be durable without a + // later Build/Round2 write accidentally closing the crash window. + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + { + let guard = state().expect("state").lock().expect("lock"); + let session = &guard.sessions[expired_session]; + assert_eq!(session.bound_key_group.as_deref(), Some(key_group)); + assert!(session.retired_interactive_at_unix.is_some()); + assert!(session.build_tx_request_fingerprint.is_some()); + assert!(session.tx_result.is_some()); + assert!(session.interactive_signing.is_empty()); + } + + let next_session = "retirement-next-message"; + build_taproot_tx(build_policy_test_request(next_session)) + .expect("retired abort and expiry entries do not exhaust active admission"); + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + { + let guard = state().expect("state").lock().expect("lock"); + assert_eq!(active_session_count(&guard.sessions), 2); + assert_eq!(retired_interactive_session_count(&guard.sessions), 2); + for retired_session in [aborted_session, expired_session] { + let session = &guard.sessions[retired_session]; + assert!(session.retired_interactive_at_unix.is_some()); + assert!(session.build_tx_request_fingerprint.is_some()); + assert!(session.tx_result.is_some()); + assert!(session.interactive_signing.is_empty()); + } + assert!(guard.sessions.contains_key(wallet_session)); + assert!(guard.sessions.contains_key(next_session)); + } + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn first_open_persists_per_message_binding_before_restart() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("interactive_first_open_binding_restart"); + reset_for_tests(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "2"); + + let wallet_session = "first-open-wallet"; + let signing_session = "first-open-message"; + let next_session = "first-open-next-message"; + let key_group = "first-open-key-group"; + let message = [0x79u8; 32]; + let included = [1u16, 2]; + ensure_interactive_dkg_session(wallet_session, key_group); + build_taproot_tx(build_policy_test_request(signing_session)) + .expect("Build persists the initially unbound per-message shell"); + + interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + signing_session, + key_group, + &message, + &included, + 1, + ), + }) + .expect("the first Open durably binds the Build shell"); + + // No Round1, Abort, expiry, or unrelated writer closes this window: Open + // itself must be the durability boundary for the session's per-message role. + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + { + let guard = state().expect("state").lock().expect("lock"); + let session = &guard.sessions[signing_session]; + assert_eq!(session.bound_key_group.as_deref(), Some(key_group)); + assert!(session.retired_interactive_at_unix.is_some()); + assert!(session.interactive_signing.is_empty()); + assert!(session.build_tx_request_fingerprint.is_some()); + assert!(session.tx_result.is_some()); + } + + build_taproot_tx(build_policy_test_request(next_session)) + .expect("the restarted Open shell yields its retired registry slot"); + { + let guard = state().expect("state").lock().expect("lock"); + assert!(guard.sessions.contains_key(wallet_session)); + assert!(guard.sessions.contains_key(next_session)); + assert!(!guard.sessions.contains_key(signing_session)); + assert_eq!(guard.sessions.len(), 2); + } + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn first_open_binding_persist_failures_are_transactional_and_repairable() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("interactive_first_open_binding_faults"); + reset_for_tests(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "3"); + + let wallet_session = "first-open-fault-wallet"; + let pre_replace_session = "first-open-fault-pre"; + let post_replace_session = "first-open-fault-post"; + let retired_session = "first-open-fault-retired"; + let key_group = "first-open-fault-key-group"; + let included = [1u16, 2]; + ensure_interactive_dkg_session(wallet_session, key_group); + let retired_marker = interactive_consumed_marker(&hash_hex(b"retired-attempt"), 1); + { + let mut guard = state().expect("state").lock().expect("lock"); + guard.sessions.insert( + retired_session.to_string(), + SessionState { + bound_key_group: Some(key_group.to_string()), + retired_interactive_at_unix: Some(1), + consumed_interactive_attempt_markers: HashSet::from([retired_marker.clone()]), + ..Default::default() + }, + ); + } + build_taproot_tx(build_policy_test_request(post_replace_session)) + .expect("persist baseline wallet, retired tombstone, and Build shell"); + + let request_for = |session_id: &str, message: [u8; 32]| InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + session_id, key_group, &message, &included, 1, + ), + }; + + let pre_replace_request = request_for(pre_replace_session, [0x81; 32]); + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let pre_replace = interactive_session_open(pre_replace_request.clone()) + .expect_err("a pre-replacement binding fault must roll Open back"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + pre_replace, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + { + let guard = state().expect("state").lock().expect("lock"); + assert!(!guard.sessions.contains_key(pre_replace_session)); + let restored = &guard.sessions[retired_session]; + assert_eq!(restored.retired_interactive_at_unix, Some(1)); + assert!(restored + .consumed_interactive_attempt_markers + .contains(&retired_marker)); + assert_eq!(guard.sessions.len(), 3); + } + interactive_session_open(pre_replace_request) + .expect("a healthy retry evicts the restored tombstone and opens"); + { + let guard = state().expect("state").lock().expect("lock"); + assert!(!guard.sessions.contains_key(retired_session)); + assert!(guard.sessions[pre_replace_session] + .interactive_signing + .contains_key(&1)); + assert_eq!(guard.sessions.len(), 3); + } + + let post_replace_request = request_for(post_replace_session, [0x82; 32]); + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let post_replace = interactive_session_open(post_replace_request.clone()) + .expect_err("a post-replacement binding fault reports uncertain durability"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + post_replace, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + { + let guard = state().expect("state").lock().expect("lock"); + let session = &guard.sessions[post_replace_session]; + assert_eq!(session.bound_key_group.as_deref(), Some(key_group)); + assert!(session.retired_interactive_at_unix.is_some()); + assert!(session.interactive_signing.is_empty()); + } + assert!(interactive_state_persistence_pending()); + + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + interactive_session_open(post_replace_request.clone()) + .expect_err("retry must repair the uncertain binding before reopening"); + clear_persist_fault_injection_for_tests(); + assert!(interactive_state_persistence_pending()); + interactive_session_open(post_replace_request) + .expect("a healthy retry repairs, reactivates, and opens the session"); + assert!(!interactive_state_persistence_pending()); + { + let guard = state().expect("state").lock().expect("lock"); + let session = &guard.sessions[post_replace_session]; + assert_eq!(session.bound_key_group.as_deref(), Some(key_group)); + assert!(session.retired_interactive_at_unix.is_none()); + assert!(session.interactive_signing.contains_key(&1)); + assert_eq!(guard.sessions.len(), 3); + } + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn partial_member_expiry_persists_binding_before_restart() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("interactive_partial_expiry_binding_restart"); + reset_for_tests(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "2"); + + let wallet_session = "partial-expiry-wallet"; + let signing_session = "partial-expiry-message"; + let next_session = "partial-expiry-next-message"; + let key_group = "partial-expiry-key-group"; + let message = [0x7au8; 32]; + let included = [1u16, 2]; + ensure_interactive_dkg_session(wallet_session, key_group); + build_taproot_tx(build_policy_test_request(signing_session)) + .expect("Build persists the initially unbound per-message shell"); + + let open = |member_identifier| { + interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + signing_session, + key_group, + &message, + &included, + 1, + ), + }) + }; + let member_1 = open(1).expect("member 1 opens"); + let member_2 = open(2).expect("member 2 opens"); + assert_eq!(member_1.attempt_id, member_2.attempt_id); + for member_identifier in included { + interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: member_1.attempt_id.clone(), + member_identifier, + }) + .expect("both members create nonce state"); + } + + let ttl_seconds = interactive_session_ttl_seconds(); + advance_interactive_clock_for_tests(ttl_seconds / 2); + interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: member_1.attempt_id.clone(), + member_identifier: 2, + }) + .expect("member 2 activity refreshes independently"); + advance_interactive_clock_for_tests(ttl_seconds / 2 + 1); + let expired = interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: member_1.attempt_id, + member_identifier: 1, + }) + .expect_err("the expired member is removed before Round1 lookup"); + assert!(matches!(expired, EngineError::SessionNotFound { .. })); + { + let guard = state().expect("state").lock().expect("lock"); + let session = &guard.sessions[signing_session]; + assert_eq!(session.bound_key_group.as_deref(), Some(key_group)); + assert!(session.retired_interactive_at_unix.is_none()); + assert!(!session.interactive_signing.contains_key(&1)); + assert!(session.interactive_signing.contains_key(&2)); + } + + // The partial sweep is itself a durability boundary. Although member 2 is + // still live in memory, live nonces intentionally disappear at restart; + // the persisted binding lets load classify the shell as retired instead + // of restoring the old unbound Build entry as an active capacity leak. + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + { + let guard = state().expect("state").lock().expect("lock"); + let session = &guard.sessions[signing_session]; + assert_eq!(session.bound_key_group.as_deref(), Some(key_group)); + assert!(session.retired_interactive_at_unix.is_some()); + assert!(session.interactive_signing.is_empty()); + assert!(session.build_tx_request_fingerprint.is_some()); + assert!(session.tx_result.is_some()); + } + + build_taproot_tx(build_policy_test_request(next_session)) + .expect("retired partial-expiry shell yields its shared registry slot"); + { + let guard = state().expect("state").lock().expect("lock"); + assert!(guard.sessions.contains_key(wallet_session)); + assert!(guard.sessions.contains_key(next_session)); + assert!(!guard.sessions.contains_key(signing_session)); + assert_eq!(guard.sessions.len(), 2); + } + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn interactive_round2_pre_replace_failure_restores_staged_retirement() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("round2_retirement_compaction_rollback"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "3"); + + let key_packages = interactive_test_key_packages(); + let wallet_session = "wallet-round2-compaction-rollback"; + let signing_session = "roast-round2-compaction-rollback"; + let key_group = "round2-compaction-rollback-key-group"; + let message = [0x73u8; 32]; + let included = [1u16, 2]; + ensure_interactive_dkg_session(wallet_session, key_group); + { + let mut guard = state().expect("state").lock().expect("engine lock"); + for (session_id, retired_at) in [("retired-oldest", 1), ("retired-newer", 2)] { + guard.sessions.insert( + session_id.to_string(), + SessionState { + bound_key_group: Some(key_group.to_string()), + retired_interactive_at_unix: Some(retired_at), + ..Default::default() + }, + ); + } + persist_engine_state_to_storage(&guard).expect("persist initial retired tier"); + } + + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + signing_session, + key_group, + &message, + &included, + 1, + ), + }) + .expect("signing session opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 commitments"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + let round2_request = InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }; + + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let faulted = interactive_round2(round2_request.clone()) + .expect_err("the injected pre-replacement fault must fail Round2"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + faulted, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + { + let guard = state().expect("state").lock().expect("engine lock"); + assert!(!guard.sessions.contains_key("retired-oldest")); + assert!(guard.sessions.contains_key("retired-newer")); + assert_eq!(retired_interactive_session_count(&guard.sessions), 1); + let signing = &guard.sessions[signing_session]; + assert!(signing.retired_interactive_at_unix.is_none()); + assert!(signing.interactive_signing.contains_key(&1)); + assert!(!interactive_attempt_consumed( + &signing.consumed_interactive_attempt_markers, + &opened.attempt_id, + 1, + )); + } + + interactive_round2(round2_request) + .expect("the same live nonces remain usable once persistence recovers"); + { + let guard = state().expect("state").lock().expect("engine lock"); + assert!(!guard.sessions.contains_key("retired-oldest")); + assert!(guard.sessions.contains_key("retired-newer")); + assert!(guard.sessions[signing_session] + .retired_interactive_at_unix + .is_some()); + assert_eq!(retired_interactive_session_count(&guard.sessions), 2); + } + + clear_state_storage_policy_overrides(); + cleanup_test_state_artifacts(&state_path); +} + +#[test] +fn interactive_aggregate_pins_a_retired_session_while_the_engine_lock_is_released() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("interactive_aggregate_retirement_pin"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "3"); + + let wallet_session = "wallet-aggregate-retirement-pin"; + let signing_session = "roast-aggregate-retirement-pin"; + let filler_session = "retired-aggregate-pin-filler"; + let newcomer_session = "retired-aggregate-pin-newcomer"; + let key_group = "aggregate-retirement-pin-key-group"; + let message = [0x75u8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(wallet_session, key_group); + + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + signing_session, + key_group, + &message, + &included, + 1, + ), + }) + .expect("per-message signing session opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment.clone(), + ], + ); + let round2 = interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("member 1 round 2"); + let member2_share = sign_share(SignShareRequest { + signing_package_hex: signing_package_hex.clone(), + nonces_hex: member2.nonces_hex, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 share"); + let aggregate_request = InteractiveAggregateRequest { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id, + signing_package_hex, + signature_shares: vec![ + NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: round2.signature_share_hex, + }, + member2_share.signature_share, + ], + taproot_merkle_root_hex: None, + }; + + { + let mut guard = state().expect("state").lock().expect("engine lock"); + let target = guard + .sessions + .get_mut(signing_session) + .expect("Round2 retains the retired signing tombstone"); + assert!(target.retired_interactive_at_unix.is_some()); + target.retired_interactive_at_unix = Some(1); + guard.sessions.insert( + filler_session.to_string(), + SessionState { + bound_key_group: Some(key_group.to_string()), + retired_interactive_at_unix: Some(2), + ..Default::default() + }, + ); + persist_engine_state_to_storage(&guard).expect("persist full retired tier"); + } + + struct AggregateReleaseGuard( + Option>>, + ); + impl Drop for AggregateReleaseGuard { + fn drop(&mut self) { + release_interactive_aggregate_unlock_for_tests(); + if let Some(handle) = self.0.take() { + let _ = handle.join(); + } + } + } + + arm_interactive_aggregate_unlock_hold_for_tests(); + let aggregate = std::thread::spawn(move || interactive_aggregate(aggregate_request)); + let mut release_guard = AggregateReleaseGuard(Some(aggregate)); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !interactive_aggregate_unlock_held_for_tests() { + assert!( + std::time::Instant::now() < deadline, + "Aggregate did not reach its unlocked cryptographic section" + ); + std::thread::yield_now(); + } + + { + let mut guard = state().expect("state").lock().expect("engine lock"); + assert_eq!( + Arc::strong_count(&guard.sessions[signing_session].aggregate_eviction_pin), + 2, + "the in-flight Aggregate must hold the transient eviction pin" + ); + guard.sessions.insert( + newcomer_session.to_string(), + SessionState { + bound_key_group: Some(key_group.to_string()), + retired_interactive_at_unix: Some(3), + ..Default::default() + }, + ); + let removed = compact_retired_per_message_sessions(&mut guard, Some(newcomer_session)); + assert_eq!( + removed + .iter() + .map(|(id, _)| id.as_str()) + .collect::>(), + vec![filler_session], + "compaction must skip the older but in-flight Aggregate target" + ); + assert!(guard.sessions.contains_key(signing_session)); + } + + release_interactive_aggregate_unlock_for_tests(); + let aggregate = release_guard + .0 + .take() + .expect("aggregate thread handle") + .join() + .expect("aggregate thread does not panic") + .expect("aggregate succeeds after concurrent retirement compaction"); + drop(release_guard); + assert_eq!(aggregate.session_id, signing_session); + { + let guard = state().expect("state").lock().expect("engine lock"); + let target = &guard.sessions[signing_session]; + assert_eq!( + Arc::strong_count(&target.aggregate_eviction_pin), + 1, + "the transient eviction pin releases after Aggregate completes" + ); + assert_eq!(target.aggregated_interactive_attempt_markers.len(), 1); + } + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn retired_compaction_preserves_pending_marker_sessions_until_snapshot_covers_them() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("retired_pending_marker_compaction"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "3"); + + let key_packages = interactive_test_key_packages(); + let wallet_session = "wallet-retired-pending-marker"; + let round2_session = "a-round2-pending-marker"; + let aggregate_session = "b-aggregate-pending-marker"; + let evictable_session = "c-evictable-retired-marker"; + let key_group = "retired-pending-marker-key-group"; + let message = [0x74u8; 32]; + let included = [1u16, 2]; + ensure_interactive_dkg_session(wallet_session, key_group); + + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: round2_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + round2_session, + key_group, + &message, + &included, + 1, + ), + }) + .expect("pending-marker session opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: round2_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("pending-marker member round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("pending-marker member 2 commitments"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + let round2_request = InteractiveRound2Request { + session_id: round2_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }; + let consumed_marker = interactive_consumed_marker(&opened.attempt_id, 1); + + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let faulted = interactive_round2(round2_request.clone()) + .expect_err("the injected post-replacement fault must leave a pending marker"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + faulted, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + assert!(interactive_round2_persistence_pending( + round2_session, + &consumed_marker + )); + + let aggregated_marker = interactive_aggregated_marker( + &hash_hex(b"aggregate-attempt"), + &hash_hex(b"aggregate-message"), + None, + ); + { + let mut guard = state().expect("state").lock().expect("engine lock"); + guard + .sessions + .get_mut(round2_session) + .expect("Round2 pending session exists") + .retired_interactive_at_unix = Some(1); + guard.sessions.insert( + aggregate_session.to_string(), + SessionState { + bound_key_group: Some(key_group.to_string()), + retired_interactive_at_unix: Some(2), + aggregated_interactive_attempt_markers: HashSet::from([aggregated_marker.clone()]), + ..Default::default() + }, + ); + guard.sessions.insert( + evictable_session.to_string(), + SessionState { + bound_key_group: Some(key_group.to_string()), + retired_interactive_at_unix: Some(3), + ..Default::default() + }, + ); + } + mark_persistence_pending(PersistencePendingOperation::InteractiveAggregate { + session_id: aggregate_session.to_string(), + aggregated_marker: aggregated_marker.clone(), + }); + assert!(interactive_aggregate_persistence_pending( + aggregate_session, + &aggregated_marker + )); + + { + let mut guard = state().expect("state").lock().expect("engine lock"); + let removed = compact_retired_per_message_sessions(&mut guard, None); + assert_eq!(removed.len(), 1); + assert_eq!(removed[0].0, evictable_session); + assert!(guard.sessions.contains_key(round2_session)); + assert!(guard.sessions.contains_key(aggregate_session)); + persist_engine_state_to_storage(&guard) + .expect("a successful snapshot covers both protected markers"); + } + assert!(!interactive_round2_persistence_pending( + round2_session, + &consumed_marker + )); + assert!(!interactive_aggregate_persistence_pending( + aggregate_session, + &aggregated_marker + )); + + let uncovered_session = "missing-pending-marker-session"; + let uncovered_marker = interactive_consumed_marker(&hash_hex(b"missing-attempt"), 1); + let uncovered_operation = PersistencePendingOperation::InteractiveRound2 { + session_id: uncovered_session.to_string(), + consumed_marker: uncovered_marker.clone(), + }; + mark_persistence_pending(uncovered_operation.clone()); + { + let guard = state().expect("state").lock().expect("engine lock"); + persist_engine_state_to_storage(&guard) + .expect("an unrelated snapshot can succeed without the missing marker"); + } + assert!( + interactive_round2_persistence_pending(uncovered_session, &uncovered_marker), + "a snapshot that omits the exact marker must not clear its repair obligation" + ); + clear_persistence_pending_operation(&uncovered_operation); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + { + let guard = state().expect("state").lock().expect("engine lock"); + assert!(guard.sessions[round2_session] + .consumed_interactive_attempt_markers + .contains(&consumed_marker)); + assert!(guard.sessions[aggregate_session] + .aggregated_interactive_attempt_markers + .contains(&aggregated_marker)); + } + let replay = interactive_round2(round2_request) + .expect_err("the covered Round2 marker remains fail-closed after restart"); + assert!(matches!(replay, EngineError::ConsumedNonceReplay { .. })); + + clear_state_storage_policy_overrides(); + cleanup_test_state_artifacts(&state_path); +} + +#[test] +fn interactive_multi_seat_two_members_one_process_aggregate_bip340() { + // Multi-seat: ONE process drives TWO local members through the interactive + // session API for the SAME session and attempt - the case the pre-multi-seat + // engine rejected with SessionConflict. Both produce real shares; member 1's + // Round2 must NOT disturb member 2's live entry, and member 1's consumed + // marker must NOT block member 2 for the same attempt. The two interactive + // shares aggregate to a valid BIP-340 signature. + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-multi-seat"; + let key_group = "interactive-multi-seat-key-group"; + let message = [0x77u8; 32]; + let included = [1u16, 2]; + + // Both seats open the SAME session + attempt. With the per-member map this + // succeeds for both (was SessionConflict for the second seat). + let opened1 = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("member 1 opens"); + let opened2 = open_interactive_for_test(session_id, key_group, &message, &included, 1, 2, 2) + .expect("member 2 opens the same attempt (multi-seat)"); + assert_eq!( + opened1.attempt_id, opened2.attempt_id, + "both local seats sign the same attempt" + ); + + // Independent Round1 per member. + let round1_m1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened1.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 round 1"); + let round1_m2 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened2.attempt_id.clone(), + member_identifier: 2, + }) + .expect("member 2 round 1"); + + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1_m1.commitments_hex.clone(), + }, + NativeFrostCommitment { + identifier: key_packages[&2].identifier.clone(), + data_hex: round1_m2.commitments_hex.clone(), + }, + ], + ); + + // Member 1's Round2 releases its share and frees ONLY its own entry. + let round2_m1 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened1.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("member 1 round 2"); + + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + !session.interactive_signing.contains_key(&1), + "member 1's entry is freed after its Round2" + ); + assert!( + session.interactive_signing.contains_key(&2), + "member 2's entry stays live - a sibling seat's Round2 must not free it" + ); + assert!( + session + .consumed_interactive_attempt_markers + .contains(&interactive_consumed_marker(&opened1.attempt_id, 1)), + "member 1's consumed marker is written" + ); + assert!( + !session + .consumed_interactive_attempt_markers + .contains(&interactive_consumed_marker(&opened2.attempt_id, 2)), + "member 2's marker is NOT written by member 1's Round2" + ); + } + + // Member 2's Round2 is NOT blocked by member 1's same-attempt marker. + let round2_m2 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened2.attempt_id.clone(), + member_identifier: 2, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("member 2 round 2 is independent of member 1's consumed marker"); + + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + session.interactive_signing.is_empty(), + "both members' entries are freed after their Round2s" + ); + } + + // The two interactive shares aggregate to a valid BIP-340 signature: real + // multi-seat interactive signing, end to end in one process. + let public_key_package = dkg_part3( + deterministic_interactive_dkg_fixture(0) + .part3_requests + .remove(&1) + .expect("fixture participant 1"), + ) + .expect("public key package") + .public_key_package; + + let aggregate = aggregate(AggregateRequest { + signing_package_hex, + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: round2_m1.signature_share_hex, + }, + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&2].identifier.clone(), + data_hex: round2_m2.signature_share_hex, + }, + ], + public_key_package: 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(public_key_package.verifying_key).expect("key hex"); + let public_key = XOnlyPublicKey::from_slice(&public_key_bytes).expect("x-only key"); + Secp256k1::verification_only() + .verify_schnorr(&signature, &SecpMessage::from_digest(message), &public_key) + .expect("two interactive multi-seat shares aggregate to a valid BIP-340 signature"); +} + +#[test] +fn interactive_round2_refused_after_aggregate_for_unsigned_sibling() { + // Multi-seat: an attempt completes (interactive_aggregate) with one threshold + // subset {1,2} while a third local seat is open but never signed - so it has NO + // per-member consumed marker. Round2 must still refuse to release seat 3's share + // for the finished attempt: completion is final (recovery is a fresh attempt), + // and otherwise seat 3's share could combine with a signer's into a SECOND valid + // signature over the same message. Also exercises interactive_aggregate over two + // interactive multi-seat shares. + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-multi-seat-completed"; + let key_group = "interactive-multi-seat-completed-key-group"; + let message = [0x55u8; 32]; + let included = [1u16, 2, 3]; + + // Three local seats open the same attempt. + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("member 1 opens"); + open_interactive_for_test(session_id, key_group, &message, &included, 1, 2, 2) + .expect("member 2 opens"); + open_interactive_for_test(session_id, key_group, &message, &included, 1, 3, 2) + .expect("member 3 opens"); + + let c1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 round 1"); + let c2 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 2, + }) + .expect("member 2 round 1"); + // Seat 3 opens + Round1s the same attempt but is NOT in the {1,2} signing subset. + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 3, + }) + .expect("member 3 round 1"); + + // Complete the attempt with the {1,2} subset. + let package_12 = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: c1.commitments_hex.clone(), + }, + NativeFrostCommitment { + identifier: key_packages[&2].identifier.clone(), + data_hex: c2.commitments_hex.clone(), + }, + ], + ); + let share1 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: package_12.clone(), + }) + .expect("member 1 round 2"); + let share2 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 2, + signing_package_hex: package_12.clone(), + }) + .expect("member 2 round 2"); + interactive_aggregate(InteractiveAggregateRequest { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + signing_package_hex: package_12, + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: share1.signature_share_hex, + }, + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&2].identifier.clone(), + data_hex: share2.signature_share_hex, + }, + ], + taproot_merkle_root_hex: None, + }) + .expect("interactive aggregate completes the attempt"); + + // The completion marker is MESSAGE-BOUND (attempt_id@digest), not the bare + // attempt_id - so it cannot be set for this attempt id via an aggregate over a + // different message (which would otherwise preempt this attempt's live Round2). + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + session + .aggregated_interactive_attempt_markers + .iter() + .any(|marker| marker.starts_with(&format!("{}@", opened.attempt_id))), + "completion marker binds attempt_id to the aggregated message digest" + ); + assert!( + !session + .aggregated_interactive_attempt_markers + .contains(&opened.attempt_id), + "the bare (unbound) attempt_id marker is not written" + ); + } + + // Aggregation proactively frees the LOCAL non-signing sibling (seat 3): it never + // calls Round2, so its entry must not linger to the TTL sweep. + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + !session.interactive_signing.contains_key(&3), + "the non-signing sibling is freed when the attempt aggregates" + ); + } + + // If seat 3 RE-OPENS the finalized attempt and tries to release a share (in a + // {1,3} subset it would otherwise be valid for), the completion gate refuses it: + // the bound marker makes the attempt/message/root final. + open_interactive_for_test(session_id, key_group, &message, &included, 1, 3, 2) + .expect("seat 3 re-opens the finalized attempt"); + let c3_reopened = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 3, + }) + .expect("seat 3 round 1 after re-open"); + let package_13 = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: c1.commitments_hex.clone(), + }, + NativeFrostCommitment { + identifier: key_packages[&3].identifier.clone(), + data_hex: c3_reopened.commitments_hex.clone(), + }, + ], + ); + let refused = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 3, + signing_package_hex: package_13, + }) + .expect_err("round 2 must refuse a share for an already-aggregated attempt"); + assert!( + matches!( + refused, + EngineError::InteractiveAttemptAlreadyAggregated { .. } + ), + "unexpected error: {refused:?}" + ); + + // The refused sibling's now-dead entry is freed (its nonces zeroized) rather + // than lingering against the live-member cap until the TTL sweep. + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + !session.interactive_signing.contains_key(&3), + "seat 3's dead entry is freed when Round2 is refused for the finalized attempt" + ); + } +} + +#[test] +fn interactive_open_advances_only_the_opening_member_attempt() { + // Per-member live attempt: seat 1 advancing to a newer attempt replaces ONLY its + // own entry (with fresh nonce state); a sibling seat on an older attempt is + // untouched - seats advance independently, exactly as separate processes would. + // A stale re-open is rejected for the member that advanced, but an idempotent + // re-open is accepted for a sibling still on that attempt. + let _guard = lock_test_state(); + reset_for_tests(); + + let key_group = "interactive-multi-seat-advance-key-group"; + let session_id = "interactive-multi-seat-advance"; + let message = [0x33u8; 32]; + let included = [1u16, 2]; + + // Seat 1 and seat 2 open attempt 1; seat 1 takes its round-1 nonces. + let a1_m1 = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("member 1 opens attempt 1"); + open_interactive_for_test(session_id, key_group, &message, &included, 1, 2, 2) + .expect("member 2 opens attempt 1"); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: a1_m1.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 round 1 on attempt 1"); + + // Seat 1 advances to attempt 2; only its entry is replaced. + let a2_m1 = open_interactive_for_test(session_id, key_group, &message, &included, 2, 1, 2) + .expect("member 1 opens attempt 2"); + assert_ne!(a2_m1.attempt_id, a1_m1.attempt_id); + + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert_eq!( + session.interactive_signing[&1].attempt_context.attempt_id, a2_m1.attempt_id, + "seat 1 advanced to attempt 2" + ); + assert!( + session.interactive_signing[&1].round1.is_none(), + "seat 1's attempt-2 entry starts fresh (old round-1 nonces replaced)" + ); + assert_eq!( + session.interactive_signing[&2].attempt_context.attempt_id, a1_m1.attempt_id, + "seat 2's attempt-1 entry is untouched by seat 1's advance" + ); + } + + // A stale re-open of attempt 1 is rejected for seat 1 (it advanced) ... + let stale = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect_err("seat 1 cannot roll back to attempt 1"); + assert!( + matches!(stale, EngineError::Validation(_)), + "unexpected error: {stale:?}" + ); + // ... but seat 2 re-opening attempt 1 is idempotent (it never advanced). + let m2_reopen = open_interactive_for_test(session_id, key_group, &message, &included, 1, 2, 2) + .expect("seat 2 idempotent re-open of its live attempt"); + assert!(m2_reopen.idempotent); +} + +#[test] +fn interactive_honors_legacy_bare_aggregate_completion_marker() { + // Backward compat: a completion persisted by the pre-binding engine is the BARE + // attempt_id (not attempt_id@digest). After an upgrade it must still finalize the + // attempt fail-closed - the Round2 completion gate refuses a fresh share for it. + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-legacy-aggregate-marker"; + let key_group = "interactive-test-key-group"; + let message = [0x66u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("member 1 opens"); + let c1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 round 1"); + + // Simulate a completion persisted by the pre-binding engine: the BARE attempt_id. + { + let mut guard = state().expect("state").lock().expect("lock"); + guard + .sessions + .get_mut(session_id) + .expect("session") + .aggregated_interactive_attempt_markers + .insert(opened.attempt_id.clone()); + } + + // Round2 must treat the bare legacy marker as a completed attempt and fail closed + // before any share is released. + let package = interactive_package_for_test( + &message, + vec![NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: c1.commitments_hex.clone(), + }], + ); + let refused = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: package, + }) + .expect_err("a legacy bare completion marker must finalize the attempt"); + assert!( + matches!( + refused, + EngineError::InteractiveAttemptAlreadyAggregated { .. } + ), + "unexpected error: {refused:?}" + ); +} + +#[test] +fn interactive_round2_completion_marker_binds_taproot_root() { + // The completion marker binds the taproot root: a completion recorded for one + // root must NOT finalize the same attempt/message for a member opened with a + // different root (the signature differs per tweak), else Round2 for the live root + // is wrongly preempted. + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-taproot-root-binding"; + let key_group = "interactive-test-key-group"; + let message = [0x44u8; 32]; + let included = [1u16, 2]; + + // Member 1 opens key-path (no taproot root). + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("member 1 opens key-path"); + let c1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 round 1"); + + let digest = hash_hex(&message); + let package = interactive_package_for_test( + &message, + vec![NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: c1.commitments_hex.clone(), + }], + ); + + // A completion recorded for a DIFFERENT taproot root must not finalize this + // member's key-path attempt: Round2 gets past the completion gate (and then fails + // on the deliberately sub-threshold package, not as already-aggregated). + { + let mut guard = state().expect("state").lock().expect("lock"); + guard + .sessions + .get_mut(session_id) + .expect("session") + .aggregated_interactive_attempt_markers + .insert(interactive_aggregated_marker( + &opened.attempt_id, + &digest, + Some(&[0x22u8; 32]), + )); + } + let not_preempted = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: package.clone(), + }); + assert!( + !matches!( + not_preempted, + Err(EngineError::InteractiveAttemptAlreadyAggregated { .. }) + ), + "a different-root completion must not finalize this attempt: {not_preempted:?}" + ); + + // A completion for THIS member's root (key-path) does finalize it. + { + let mut guard = state().expect("state").lock().expect("lock"); + guard + .sessions + .get_mut(session_id) + .expect("session") + .aggregated_interactive_attempt_markers + .insert(interactive_aggregated_marker( + &opened.attempt_id, + &digest, + None, + )); + } + let preempted = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: package, + }) + .expect_err("a same-root completion finalizes the attempt"); + assert!( + matches!( + preempted, + EngineError::InteractiveAttemptAlreadyAggregated { .. } + ), + "unexpected error: {preempted:?}" + ); +} + +#[test] +fn interactive_aggregate_rejects_mismatched_message_without_cleanup() { + // Aggregate authorization binds the canonical signing package, including + // its message. A valid package for another message cannot create completion + // state or delete the authorized attempt's live nonce state. + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-cleanup-message-binding"; + let key_group = "interactive-test-key-group"; + let message_a = [0x88u8; 32]; + let message_b = [0x99u8; 32]; + let included = [1u16, 2]; + + // A live interactive attempt over message A (attempt_id derives from message A). + let opened = open_interactive_for_test(session_id, key_group, &message_a, &included, 1, 1, 2) + .expect("member 1 opens message-A attempt"); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 round 1 (message A)"); + + // A valid aggregate over a DIFFERENT message B, submitted under message A's + // attempt id - via stateless shares, so it does not touch the live attempt. + let m1_b = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&1].identifier.clone(), + key_package_hex: key_packages[&1].data_hex.clone(), + }) + .expect("stateless nonces 1"); + let m2_b = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("stateless nonces 2"); + let package_b = interactive_package_for_test( + &message_b, + vec![m1_b.commitment.clone(), m2_b.commitment.clone()], + ); + let share1_b = sign_share(SignShareRequest { + signing_package_hex: package_b.clone(), + nonces_hex: m1_b.nonces_hex, + key_package_identifier: key_packages[&1].identifier.clone(), + key_package_hex: key_packages[&1].data_hex.clone(), + }) + .expect("stateless share 1 over B"); + let share2_b = sign_share(SignShareRequest { + signing_package_hex: package_b.clone(), + nonces_hex: m2_b.nonces_hex, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("stateless share 2 over B"); + let err = interactive_aggregate(InteractiveAggregateRequest { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + signing_package_hex: package_b, + signature_shares: vec![share1_b.signature_share, share2_b.signature_share], + taproot_merkle_root_hex: None, + }) + .expect_err("aggregate over message B must not use message A's attempt id"); + assert!( + matches!(err, EngineError::Validation(ref message) if message.contains("not authorized")), + "unexpected error: {err:?}" + ); + + // The live message-A seat must survive: the cleanup is message-bound. + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + session.interactive_signing.contains_key(&1), + "a mismatched-message aggregate must not delete the live message-A seat" + ); + assert!(session.aggregated_interactive_attempt_markers.is_empty()); + } +} + +#[test] +fn interactive_capacity_counts_new_members_not_replacements() { + // The live-member cap counts member ENTRIES: a new member takes a slot, but a + // same-member replacement (re-open on a newer attempt) reuses its own slot. + let _guard = lock_test_state(); + reset_for_tests(); + + let key_group = "interactive-test-key-group"; + let message = [0xc4u8; 32]; + let included = [1u16, 2]; + let session_id = "interactive-cap-multiseat"; + + std::env::set_var(TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV, "1"); + let outcome = (|| -> Result<(), EngineError> { + // Member 1 takes the one live-member slot. + open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2)?; + + // Member 1 advancing to a NEWER attempt replaces its own entry - no new slot, + // so it succeeds even at capacity 1 (a replacement, not an idempotent reopen). + let advanced = + open_interactive_for_test(session_id, key_group, &message, &included, 2, 1, 2)?; + assert!( + !advanced.idempotent, + "a newer attempt is a replacement, not idempotent" + ); + + // A DIFFERENT member is a new entry, so it trips the cap and fails closed. + let at_capacity = + open_interactive_for_test(session_id, key_group, &message, &included, 2, 2, 2) + .expect_err("a new member must trip the live-member cap"); + assert!( + matches!(at_capacity, EngineError::Internal(ref m) + if m.contains("live interactive member count")), + "unexpected error: {at_capacity:?}" + ); + Ok(()) + })(); + std::env::remove_var(TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV); + outcome.expect("capacity new-vs-replacement lifecycle"); +} + +#[test] +fn interactive_abort_by_attempt_removes_all_members_on_that_attempt() { + // Abort with an attempt_id filter is session-level over the member map: it removes + // EVERY local seat on that attempt, while a sibling seat on a different attempt + // survives. + let _guard = lock_test_state(); + reset_for_tests(); + + let key_group = "interactive-test-key-group"; + let message = [0xa4u8; 32]; + let included = [1u16, 2, 3]; + let session_id = "interactive-abort-multiseat"; + + // Members 1 and 2 on attempt 1; member 3 on attempt 2 (a different attempt id). + let opened1 = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("member 1 opens attempt 1"); + open_interactive_for_test(session_id, key_group, &message, &included, 1, 2, 2) + .expect("member 2 opens attempt 1"); + open_interactive_for_test(session_id, key_group, &message, &included, 2, 3, 2) + .expect("member 3 opens attempt 2"); + + // Abort attempt 1: removes BOTH members on it; member 3 (attempt 2) is untouched. + let result = interactive_session_abort(InteractiveSessionAbortRequest { + session_id: session_id.to_string(), + attempt_id: Some(opened1.attempt_id.clone()), + }) + .expect("abort attempt 1"); + assert!(result.aborted, "abort removed live state"); + + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + !session.interactive_signing.contains_key(&1), + "member 1 (attempt 1) is aborted" + ); + assert!( + !session.interactive_signing.contains_key(&2), + "member 2 (attempt 1) is aborted" + ); + assert!( + session.interactive_signing.contains_key(&3), + "member 3 (attempt 2) survives the attempt-1 abort" + ); +} + +#[test] +fn interactive_round1_is_idempotent_until_consumed() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-round1-idempotent"; + let key_group = "interactive-test-key-group"; + let message = [0x21u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + + let first = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + assert_eq!(hardening_metrics().interactive_round1_latency_samples, 1); + assert_eq!( + hardening_telemetry_state() + .lock() + .expect("hardening telemetry lock") + .canary_interactive_round1_latency + .sample_count(), + 1, + ); + let second = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("repeat round 1"); + assert_eq!( + first.commitments_hex, second.commitments_hex, + "round 1 must be idempotent until the nonces are consumed" + ); + assert_eq!( + hardening_metrics().interactive_round1_latency_samples, + 2, + "the ABI-3 rolling metric includes the idempotent call" + ); + assert_eq!( + hardening_telemetry_state() + .lock() + .expect("hardening telemetry lock") + .canary_interactive_round1_latency + .sample_count(), + 1, + "an idempotent replay must not dilute the promotion latency window", + ); + + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: first.commitments_hex.clone(), + }, + member2.commitment, + ], + ); + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }) + .expect("round 2 consumes"); + + let replay = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect_err("round 1 after consumption must fail closed"); + assert!( + matches!(replay, EngineError::ConsumedNonceReplay { .. }), + "unexpected error: {replay:?}" + ); + assert_eq!(replay.code(), "consumed_nonce_replay"); + assert_eq!( + hardening_metrics().interactive_round1_latency_samples, + 3, + "the ABI-3 rolling metric includes the rejected call" + ); + assert_eq!( + hardening_telemetry_state() + .lock() + .expect("hardening telemetry lock") + .canary_interactive_round1_latency + .sample_count(), + 1, + "a fail-fast rejection must not enter the successful promotion window", + ); +} + +#[test] +fn interactive_round2_rejects_substituted_own_commitment_then_accepts_corrected() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-framing-defense"; + let key_group = "interactive-test-key-group"; + let message = [0x33u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + + // A malicious coordinator substitutes member 1's commitment with a + // different (validly formed) commitment for the same key package. + // Without the own-commitment check the member would sign with its + // true nonces over a package misrepresenting its commitment - the + // share then fails verification at aggregation and becomes false + // blame evidence against an honest member. + let substituted = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&1].identifier.clone(), + key_package_hex: key_packages[&1].data_hex.clone(), + }) + .expect("substituted commitment"); + assert_ne!( + substituted.commitment.data_hex, round1.commitments_hex, + "fixture sanity: substituted commitment differs" + ); + + let framed_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: substituted.commitment.data_hex, + }, + member2.commitment.clone(), + ], + ); + let framed = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: framed_package_hex, + }) + .expect_err("substituted own commitment must be rejected"); + assert!( + matches!(framed, EngineError::Validation(ref message) + if message.contains("does not match its round-1 output")), + "unexpected error: {framed:?}" + ); + + // Verify-before-consume: the rejected package must NOT have burned + // the nonces; the honest package still succeeds. + let honest_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex.clone(), + }, + member2.commitment, + ], + ); + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id, + member_identifier: 1, + signing_package_hex: honest_package_hex, + }) + .expect("honest package succeeds after the framed one was rejected"); +} + +#[test] +fn interactive_round2_package_shape_rejections() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let key_group = "interactive-test-key-group"; + let message = [0x44u8; 32]; + + // Session A: included {1,2} - outside-set and message-mismatch. + let session_a = "interactive-shape-a"; + let opened_a = open_interactive_for_test(session_a, key_group, &message, &[1, 2], 1, 1, 2) + .expect("session A opens"); + let round1_a = interactive_round1(InteractiveRound1Request { + session_id: session_a.to_string(), + attempt_id: opened_a.attempt_id.clone(), + member_identifier: 1, + }) + .expect("session A round 1"); + + let member3 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&3].identifier.clone(), + key_package_hex: key_packages[&3].data_hex.clone(), + }) + .expect("member 3 nonces"); + + let outside_set_package = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1_a.commitments_hex.clone(), + }, + member3.commitment.clone(), + ], + ); + let outside = interactive_round2(InteractiveRound2Request { + session_id: session_a.to_string(), + attempt_id: opened_a.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: outside_set_package, + }) + .expect_err("participant outside the included set must be rejected"); + assert!( + matches!(outside, EngineError::Validation(ref m) if m.contains("included set")), + "unexpected error: {outside:?}" + ); + + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let wrong_message = [0x55u8; 32]; + let wrong_message_package = interactive_package_for_test( + &wrong_message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1_a.commitments_hex.clone(), + }, + member2.commitment.clone(), + ], + ); + let mismatch = interactive_round2(InteractiveRound2Request { + session_id: session_a.to_string(), + attempt_id: opened_a.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: wrong_message_package, + }) + .expect_err("package over a different message must be rejected"); + assert!( + matches!(mismatch, EngineError::Validation(ref m) if m.contains("message")), + "unexpected error: {mismatch:?}" + ); + + // Session B: included {1,2,3}, threshold 2 - size and self-missing. + let session_b = "interactive-shape-b"; + let opened_b = open_interactive_for_test(session_b, key_group, &message, &[1, 2, 3], 1, 1, 2) + .expect("session B opens"); + let round1_b = interactive_round1(InteractiveRound1Request { + session_id: session_b.to_string(), + attempt_id: opened_b.attempt_id.clone(), + member_identifier: 1, + }) + .expect("session B round 1"); + + let oversized_package = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1_b.commitments_hex.clone(), + }, + member2.commitment.clone(), + member3.commitment.clone(), + ], + ); + let oversized = interactive_round2(InteractiveRound2Request { + session_id: session_b.to_string(), + attempt_id: opened_b.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: oversized_package, + }) + .expect_err("more than exactly-threshold commitments must be rejected"); + assert!( + matches!(oversized, EngineError::Validation(ref m) if m.contains("exactly threshold")), + "unexpected error: {oversized:?}" + ); + + let self_missing_package = + interactive_package_for_test(&message, vec![member2.commitment, member3.commitment]); + let self_missing = interactive_round2(InteractiveRound2Request { + session_id: session_b.to_string(), + attempt_id: opened_b.attempt_id, + member_identifier: 1, + signing_package_hex: self_missing_package, + }) + .expect_err("a package excluding this member must be rejected"); + assert!( + matches!(self_missing, EngineError::Validation(ref m) + if m.contains("does not include this member")), + "unexpected error: {self_missing:?}" + ); +} + +#[test] +fn interactive_consumption_marker_survives_restart() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-restart-marker"; + let key_group = "interactive-test-key-group"; + let message = [0x61u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }) + .expect("round 2 consumes"); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + // The durable marker must reject the consumed attempt across a + // restart at every entry point, even though the live interactive + // state (and its nonces) did not survive by construction. + let reopen = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect_err("reopening a consumed attempt after restart must fail closed"); + assert!( + matches!(reopen, EngineError::ConsumedNonceReplay { .. }), + "unexpected error: {reopen:?}" + ); + + // A fresh attempt for the same session proceeds: the marker is + // attempt-scoped, not session-scoped. + let second_attempt = + open_interactive_for_test(session_id, key_group, &message, &included, 2, 1, 2) + .expect("a new attempt opens after restart"); + let round2_without_round1 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: second_attempt.attempt_id, + member_identifier: 1, + signing_package_hex: "00".repeat(8), + }) + .expect_err("round 2 without round 1 must fail"); + assert!( + matches!( + round2_without_round1, + EngineError::Validation(_) | EngineError::SignRoundNotStarted { .. } + ), + "unexpected error: {round2_without_round1:?}" + ); +} + +#[test] +fn interactive_round2_persist_fault_leaves_nonces_live() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-persist-fault"; + let key_group = "interactive-test-key-group"; + let message = [0x71u8; 32]; + let included = [1u16, 2]; + let ttl_seconds = interactive_session_ttl_seconds(); + let margin_seconds = ttl_seconds / 4; + assert!(margin_seconds > 0); + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex.clone(), + }, + member2.commitment, + ], + ); + let prior_activity = interactive_last_activity_at_for_test(session_id, 1); + advance_interactive_clock_for_tests(ttl_seconds / 2); + + // Consumption-before-release: if the durable marker cannot be + // persisted, NO share leaves the engine and the nonces stay live. + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let fault_started_at = interactive_now(); + let faulted = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect_err("injected persist fault must fail round 2"); + let fault_returned_at = interactive_now(); + clear_persist_fault_injection_for_tests(); + assert!( + matches!(faulted, EngineError::Internal(ref m) if m.contains("injected persist fault")), + "unexpected error: {faulted:?}" + ); + + let refreshed_activity = { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + !session + .consumed_interactive_attempt_markers + .contains(&interactive_consumed_marker(&opened.attempt_id, 1)), + "a failed persist must roll the consumption marker back" + ); + let interactive = session + .interactive_signing + .get(&1) + .expect("pre-replacement failure leaves the nonce retryable"); + assert!(interactive.round1.is_some(), "Round1 nonces remain live"); + interactive.last_activity_at + }; + assert!( + refreshed_activity > prior_activity, + "pre-replacement failure completion must advance activity" + ); + assert!( + refreshed_activity >= fault_started_at && refreshed_activity <= fault_returned_at, + "persistence-failure activity must fall within the failed call" + ); + + // The same attempt completes once persistence recovers - the + // nonces were never consumed by the failed call. + advance_interactive_clock_for_tests(margin_seconds); + assert!( + interactive_now().saturating_duration_since(refreshed_activity) + < Duration::from_secs(ttl_seconds) + ); + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }) + .expect("round 2 succeeds after the persist fault clears"); + + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + session + .consumed_interactive_attempt_markers + .contains(&interactive_consumed_marker(&opened.attempt_id, 1)), + "successful round 2 must leave the durable marker" + ); + } +} + +#[test] +fn interactive_round2_post_rename_persist_failure_consumes_attempt_and_retry_flushes() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("interactive_round2_post_rename"); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-round2-post-rename"; + let key_group = "interactive-test-key-group"; + let message = [0x72u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + let round2_request = InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }; + let consumed_marker = interactive_consumed_marker(&opened.attempt_id, 1); + + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let faulted = interactive_round2(round2_request.clone()) + .expect_err("post-rename persist fault must release no share"); + clear_persist_fault_injection_for_tests(); + assert!( + matches!(faulted, EngineError::Internal(ref message) if message.contains("injected persist fault")), + "unexpected error: {faulted:?}" + ); + + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!(session + .consumed_interactive_attempt_markers + .contains(&consumed_marker)); + assert!( + !session.interactive_signing.contains_key(&1), + "post-rename failure must destroy the live nonce-bearing member state" + ); + } + assert!(interactive_round2_persistence_pending( + session_id, + &consumed_marker + )); + + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let failed_flush = interactive_round2(round2_request.clone()) + .expect_err("a failed pending-marker flush must not reach the replay gate"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + failed_flush, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + assert!(interactive_round2_persistence_pending( + session_id, + &consumed_marker + )); + + // Crash before any successful in-process repair. The pending registry is + // memory-only and disappears; the replacement image must carry the marker. + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + { + let guard = state().expect("state").lock().expect("lock"); + assert!(guard.sessions[session_id] + .consumed_interactive_attempt_markers + .contains(&consumed_marker)); + assert!(guard.sessions[session_id].interactive_signing.is_empty()); + } + + let retry = interactive_round2(round2_request) + .expect_err("restart retry rejects the durable consumed attempt"); + assert!( + matches!(retry, EngineError::ConsumedNonceReplay { .. }), + "unexpected retry error: {retry:?}" + ); + assert!(!interactive_round2_persistence_pending( + session_id, + &consumed_marker + )); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn interactive_open_idempotency_conflict_and_replacement() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-open-lifecycle"; + let key_group = "interactive-test-key-group"; + let message = [0x81u8; 32]; + let included = [1u16, 2]; + + let first = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + assert!(!first.idempotent); + + let repeat = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("identical reopen is idempotent"); + assert!(repeat.idempotent); + assert_eq!(repeat.attempt_id, first.attempt_id); + + // Same attempt, different request: conflicting reopen fails closed. + let attempt_context = + interactive_test_attempt_context(session_id, key_group, &message, &included, 1); + let conflicting = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: Some( + "1111111111111111111111111111111111111111111111111111111111111111".to_string(), + ), + signing_intent: None, + attempt_context, + }) + .expect_err("conflicting reopen of a live attempt must fail closed"); + assert!( + matches!(conflicting, EngineError::SessionConflict { .. }), + "unexpected error: {conflicting:?}" + ); + + // Round 1 for attempt 1, then open attempt 2: the retry loop has + // moved on, so the newer attempt implicitly aborts the older one + // and its nonces. + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: first.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1 for attempt 1"); + let second = open_interactive_for_test(session_id, key_group, &message, &included, 2, 1, 2) + .expect("a newer attempt replaces the live one"); + assert_ne!(second.attempt_id, first.attempt_id); + + let stale = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: first.attempt_id, + member_identifier: 1, + }) + .expect_err("the replaced attempt must no longer be live"); + assert!( + matches!(stale, EngineError::Validation(ref m) if m.contains("does not match")), + "unexpected error: {stale:?}" + ); +} + +#[test] +fn interactive_abort_destroys_nonces_and_is_idempotent() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-abort"; + let key_group = "interactive-test-key-group"; + let message = [0x91u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + + let aborted = interactive_session_abort(InteractiveSessionAbortRequest { + session_id: session_id.to_string(), + attempt_id: Some(opened.attempt_id.clone()), + }) + .expect("abort"); + assert!(aborted.aborted); + + let again = interactive_session_abort(InteractiveSessionAbortRequest { + session_id: session_id.to_string(), + attempt_id: Some(opened.attempt_id.clone()), + }) + .expect("abort is idempotent"); + assert!(!again.aborted); + + let dead = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect_err("an aborted attempt must not serve round 1"); + assert!( + matches!(dead, EngineError::SessionNotFound { .. }), + "unexpected error: {dead:?}" + ); + + // Abort destroyed the nonces WITHOUT a consumption marker: the + // attempt was never consumed, so reopening it is allowed and gets + // FRESH nonces (the old ones are gone forever). + let reopened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("an aborted (never consumed) attempt may reopen"); + assert_eq!(reopened.attempt_id, opened.attempt_id); +} + +#[test] +fn interactive_abort_persist_failures_are_retryable_before_replace_and_fail_closed_after() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("interactive_abort_persist_durability"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let wallet_session = "interactive-abort-persist-wallet"; + let key_group = "interactive-abort-persist-key-group"; + let included = [1u16, 2]; + ensure_interactive_dkg_session(wallet_session, key_group); + + let open_round1 = |session_id: &str, message: [u8; 32]| { + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + session_id, key_group, &message, &included, 1, + ), + }) + .expect("per-message session opens"); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("per-message session reaches Round1"); + opened + }; + + let retryable_session = "interactive-abort-pre-replace"; + let retryable = open_round1(retryable_session, [0xa2; 32]); + let retryable_request = InteractiveSessionAbortRequest { + session_id: retryable_session.to_string(), + attempt_id: Some(retryable.attempt_id), + }; + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let pre_replace = interactive_session_abort(retryable_request.clone()) + .expect_err("a pre-replacement Abort fault must not consume live nonces"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + pre_replace, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + { + let guard = state().expect("state").lock().expect("engine lock"); + let session = &guard.sessions[retryable_session]; + assert!(session.interactive_signing.contains_key(&1)); + assert!(session.retired_interactive_at_unix.is_none()); + } + assert!( + interactive_session_abort(retryable_request) + .expect("Abort retry persists and succeeds") + .aborted + ); + + let fail_closed_session = "interactive-abort-post-replace"; + let fail_closed = open_round1(fail_closed_session, [0xa3; 32]); + let fail_closed_request = InteractiveSessionAbortRequest { + session_id: fail_closed_session.to_string(), + attempt_id: Some(fail_closed.attempt_id), + }; + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let post_replace = interactive_session_abort(fail_closed_request.clone()) + .expect_err("a post-replacement Abort fault reports uncertain directory durability"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + post_replace, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + { + let guard = state().expect("state").lock().expect("engine lock"); + let session = &guard.sessions[fail_closed_session]; + assert!(session.interactive_signing.is_empty()); + assert!(session.retired_interactive_at_unix.is_some()); + } + assert!(interactive_state_persistence_pending()); + + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + interactive_session_abort(fail_closed_request.clone()) + .expect_err("an idempotent retry must attempt the pending durability repair"); + clear_persist_fault_injection_for_tests(); + assert!(interactive_state_persistence_pending()); + let repaired = interactive_session_abort(fail_closed_request) + .expect("a healthy idempotent retry repairs Abort durability"); + assert!(!repaired.aborted); + assert!(!interactive_state_persistence_pending()); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + { + let guard = state().expect("state").lock().expect("engine lock"); + let session = &guard.sessions[fail_closed_session]; + assert_eq!(session.bound_key_group.as_deref(), Some(key_group)); + assert!(session.retired_interactive_at_unix.is_some()); + assert!(session.interactive_signing.is_empty()); + } + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn reset_for_tests_clears_interactive_clock_offset() { + let _guard = lock_test_state(); + reset_for_tests(); + + let baseline = interactive_now(); + advance_interactive_clock_for_tests(3_600); + let advanced = interactive_now(); + assert!(advanced.saturating_duration_since(baseline) >= Duration::from_secs(3_600)); + + reset_for_tests(); + assert!( + interactive_now() < advanced, + "engine reset must clear the test-only interactive clock offset" + ); +} + +#[test] +fn interactive_session_ttl_expiry_has_abort_semantics() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-ttl"; + let key_group = "interactive-test-key-group"; + let message = [0xa1u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + + // Age the session past the TTL directly; the next entry point's + // lazy sweep must destroy the nonces with abort semantics. + advance_interactive_clock_for_tests(interactive_session_ttl_seconds().saturating_add(1)); + + let expired = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect_err("an expired attempt must not serve round 1"); + assert!( + matches!(expired, EngineError::SessionNotFound { .. }), + "unexpected error: {expired:?}" + ); + + // Expiry, like abort, leaves no consumption marker: the attempt + // never released a share, so reopening is allowed. + open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("an expired (never consumed) attempt may reopen"); +} + +#[test] +fn interactive_inactivity_ttl_refreshes_on_idempotent_open() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-ttl-idempotent-open"; + let key_group = "interactive-test-key-group"; + let message = [0xa2u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let ttl_seconds = interactive_session_ttl_seconds(); + let recent_margin_seconds = ttl_seconds / 4; + assert!( + recent_margin_seconds > 0, + "the configured interactive TTL must provide a synthetic test margin" + ); + let prior_activity = interactive_last_activity_at_for_test(session_id, 1); + + // Advance by half the TTL without sleeping. The exact retry is legitimate + // activity and must advance the monotonic timestamp. + advance_interactive_clock_for_tests(ttl_seconds / 2); + + let retry_started_at = interactive_now(); + let retry = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("an exact Open retry remains live"); + assert!(retry.idempotent); + let refreshed_activity = { + let guard = state().expect("state").lock().expect("lock"); + guard.sessions[session_id].interactive_signing[&1].last_activity_at + }; + assert!( + refreshed_activity > prior_activity, + "an idempotent Open must advance the member's activity instant" + ); + assert!( + refreshed_activity >= retry_started_at, + "the refreshed activity instant must belong to the retry" + ); + + // Model substantial but still sub-TTL inactivity from the observed retry + // instant. This stays far from the expiry boundary and requires no sleep. + advance_interactive_clock_for_tests(recent_margin_seconds); + assert!( + interactive_now().saturating_duration_since(refreshed_activity) + < Duration::from_secs(ttl_seconds), + "the synthetic activity must remain comfortably inside the TTL" + ); + + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id, + member_identifier: 1, + }) + .expect("recent idempotent Open activity keeps the attempt live"); +} + +#[test] +fn interactive_inactivity_ttl_refreshes_fresh_round1_per_member_before_round2() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-ttl-round1-round2"; + let key_group = "interactive-test-key-group"; + let message = [0xa3u8; 32]; + let included = [1u16, 2]; + let key_packages = interactive_test_key_packages(); + let ttl_seconds = interactive_session_ttl_seconds(); + let margin_seconds = ttl_seconds / 4; + assert!( + margin_seconds > 0, + "the configured interactive TTL must provide a synthetic test margin" + ); + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("member 1 opens"); + open_interactive_for_test(session_id, key_group, &message, &included, 1, 2, 2) + .expect("member 2 opens"); + let round1_member_2 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 2, + }) + .expect("member 2 round 1"); + + // Member 1 has not run Round1 yet. Advance it well within the TTL, then + // prove rejected traffic cannot refresh its original Open activity. + let prior_activity = interactive_last_activity_at_for_test(session_id, 1); + advance_interactive_clock_for_tests(ttl_seconds / 2); + let rejected_attempt_id = hash_hex(b"rejected-ttl-round1-attempt"); + assert_ne!(rejected_attempt_id, opened.attempt_id); + let rejected = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: rejected_attempt_id, + member_identifier: 1, + }) + .expect_err("a wrong-attempt Round1 must be rejected"); + assert_eq!( + interactive_last_activity_at_for_test(session_id, 1), + prior_activity, + "rejected traffic must not refresh the member's activity instant" + ); + assert!(matches!(rejected, EngineError::Validation(_))); + + // A first, successful Round1 must advance the monotonic activity instant. + let round1_started_at = interactive_now(); + let round1_member_1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 fresh round 1"); + let refreshed_activity = interactive_last_activity_at_for_test(session_id, 1); + assert!( + refreshed_activity > prior_activity, + "fresh Round1 must advance the member's activity instant" + ); + assert!( + refreshed_activity >= round1_started_at, + "the refreshed activity instant must belong to fresh Round1" + ); + + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1_member_1.commitments_hex, + }, + NativeFrostCommitment { + identifier: key_packages[&2].identifier.clone(), + data_hex: round1_member_2.commitments_hex, + }, + ], + ); + + // Advance far enough that member 2, idle since offset zero, is beyond the + // TTL while member 1's fresh Round1 remains comfortably live. + advance_interactive_clock_for_tests(ttl_seconds / 2 + margin_seconds); + let synthetic_now = interactive_now(); + let idle_activity = interactive_last_activity_at_for_test(session_id, 2); + assert!( + synthetic_now.saturating_duration_since(refreshed_activity) + < Duration::from_secs(ttl_seconds) + ); + assert!( + synthetic_now.saturating_duration_since(idle_activity) > Duration::from_secs(ttl_seconds) + ); + + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id, + member_identifier: 1, + signing_package_hex, + }) + .expect("recent Round1 activity keeps member 1 live through Round2"); + + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session remains"); + assert!( + !session.interactive_signing.contains_key(&2), + "the genuinely idle sibling expires on the same sweep" + ); +} + +#[test] +fn interactive_live_session_capacity_fails_closed() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_group = "interactive-test-key-group"; + let message = [0xb1u8; 32]; + let included = [1u16, 2]; + + std::env::set_var(TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV, "1"); + + let outcome = (|| -> Result<(), EngineError> { + open_interactive_for_test("interactive-cap-a", key_group, &message, &included, 1, 1, 2)?; + + let at_capacity = + open_interactive_for_test("interactive-cap-b", key_group, &message, &included, 1, 1, 2) + .expect_err("the live-session cap must fail closed"); + assert!( + matches!(at_capacity, EngineError::Internal(ref m) + if m.contains("live interactive member count")), + "unexpected error: {at_capacity:?}" + ); + + // An idempotent reopen of the live session does not trip the cap. + let reopen = open_interactive_for_test( + "interactive-cap-a", + key_group, + &message, + &included, + 1, + 1, + 2, + )?; + assert!(reopen.idempotent); + + // Aborting frees the slot. + interactive_session_abort(InteractiveSessionAbortRequest { + session_id: "interactive-cap-a".to_string(), + attempt_id: None, + })?; + open_interactive_for_test("interactive-cap-b", key_group, &message, &included, 1, 1, 2)?; + Ok(()) + })(); + + std::env::remove_var(TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV); + outcome.expect("capacity lifecycle"); +} + +#[test] +fn interactive_open_signing_policy_firewall_rejects_without_policy_checked_build_tx() { + 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(); + + // The critical security assertion: with the firewall enabled, a + // fresh interactive session with no prior policy-checked + // build_taproot_tx must NOT be able to open and sign an arbitrary + // message. It fails closed at the same gate the coarse path uses. + let outcome = open_interactive_for_test( + "interactive-firewall-no-build-tx", + "interactive-firewall-key-group", + &[0xc1u8; 32], + &[1u16, 2], + 1, + 1, + 2, + ); + + std::env::remove_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV); + std::env::remove_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV); + clear_state_storage_policy_overrides(); + + let err = outcome.expect_err("interactive open must fail closed under the firewall"); + let EngineError::SigningPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant: {err:?}"); + }; + assert_eq!(reason_code, "missing_policy_checked_build_tx"); +} + +#[test] +fn interactive_heartbeat_intent_opens_and_releases_round2_share_under_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_HEARTBEAT_RATE_LIMIT_PER_MINUTE_ENV, "1"); + + let wallet_session = "wallet-heartbeat-intent-success"; + let signing_session = "roast-heartbeat-intent-success"; + let key_group = "heartbeat-intent-success-key-group"; + let included = [1u16, 2]; + let heartbeat_message = heartbeat_message_for_test(1); + let signing_message = heartbeat_signing_message_for_test(&heartbeat_message); + let key_packages = ensure_interactive_dkg_session(wallet_session, key_group); + + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(signing_message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: Some(heartbeat_signing_intent_for_test(&heartbeat_message)), + attempt_context: interactive_test_attempt_context( + signing_session, + key_group, + &signing_message, + &included, + 1, + ), + }) + .expect("a valid heartbeat intent authorizes Open without a transaction artifact"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("heartbeat round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 heartbeat commitments"); + let signing_package_hex = interactive_package_for_test( + &signing_message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + + let round2 = interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id, + member_identifier: 1, + signing_package_hex, + }) + .expect("Round2 rechecks and accepts the stored heartbeat intent"); + assert!(!round2.signature_share_hex.is_empty()); + + let guard = state().expect("state").lock().expect("engine lock"); + let session = guard + .sessions + .get(signing_session) + .expect("heartbeat signing session"); + assert!(session.tx_result.is_none()); + assert!(session.interactive_signing.is_empty()); + drop(guard); + + clear_state_storage_policy_overrides(); +} + +#[test] +fn interactive_heartbeat_capacity_rejection_does_not_consume_wallet_token() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("heartbeat_capacity_preflight"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "2"); + std::env::set_var(TBTC_SIGNER_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE_ENV, "1"); + + let wallet_session = "wallet-heartbeat-capacity-preflight"; + let key_group = "heartbeat-capacity-preflight-key-group"; + let signing_session = "roast-heartbeat-capacity-preflight"; + let included = [1u16, 2]; + ensure_interactive_dkg_session(wallet_session, key_group); + { + let mut guard = state().expect("state").lock().expect("engine lock"); + guard.sessions.insert( + "active-capacity-filler".to_string(), + SessionState::default(), + ); + assert_eq!(active_session_count(&guard.sessions), 2); + } + + let heartbeat_message = heartbeat_message_for_test(100); + let signing_message = heartbeat_signing_message_for_test(&heartbeat_message); + let request = InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(signing_message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: Some(heartbeat_signing_intent_for_test(&heartbeat_message)), + attempt_context: interactive_test_attempt_context( + signing_session, + key_group, + &signing_message, + &included, + 1, + ), + }; + + let rejected = interactive_session_open(request.clone()) + .expect_err("a full active tier must reject a fresh heartbeat session"); + assert!(matches!( + rejected, + EngineError::Internal(ref message) if message.contains("active session registry size") + )); + { + let mut guard = state().expect("state").lock().expect("engine lock"); + let limiter = &guard.sessions[wallet_session].heartbeat_rate_limiter; + assert_eq!(limiter.last_refill_unix, 0); + assert_eq!(limiter.token_microunits, 0); + assert_eq!(limiter.configured_rate_limit_per_minute, 0); + guard.sessions.remove("active-capacity-filler"); + } + + interactive_session_open(request) + .expect("the capacity-rejected call must leave the wallet's token available"); + { + let guard = state().expect("state").lock().expect("engine lock"); + let limiter = &guard.sessions[wallet_session].heartbeat_rate_limiter; + assert_eq!(limiter.configured_rate_limit_per_minute, 1); + assert_eq!(limiter.token_microunits, 0); + } + + clear_state_storage_policy_overrides(); + cleanup_test_state_artifacts(&state_path); +} + +#[test] +fn interactive_heartbeat_rate_limit_is_per_wallet_and_retry_safe() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("heartbeat_rate_limit"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + // Heartbeat authorization is independently rate-limited even when the + // transaction policy firewall is disabled. Keep the BuildTaprootTx bucket at + // one token too so the final assertion proves the two budgets are disjoint. + std::env::set_var(TBTC_SIGNER_POLICY_HEARTBEAT_RATE_LIMIT_PER_MINUTE_ENV, "1"); + std::env::set_var(TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE_ENV, "1"); + + let wallet_a_session = "wallet-heartbeat-rate-limit-a"; + let wallet_a_key_group = "heartbeat-rate-limit-key-group-a"; + let wallet_b_session = "wallet-heartbeat-rate-limit-b"; + let wallet_b_key_group = "heartbeat-rate-limit-key-group-b"; + let included = [1u16, 2]; + ensure_interactive_dkg_session(wallet_a_session, wallet_a_key_group); + ensure_interactive_dkg_session(wallet_b_session, wallet_b_key_group); + + let heartbeat_open_request = |session_id: &str, key_group: &str, nonce: u64| { + let heartbeat_message = heartbeat_message_for_test(nonce); + let signing_message = heartbeat_signing_message_for_test(&heartbeat_message); + InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: hex::encode(signing_message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: Some(heartbeat_signing_intent_for_test(&heartbeat_message)), + attempt_context: interactive_test_attempt_context( + session_id, + key_group, + &signing_message, + &included, + 1, + ), + } + }; + + let first_wallet_a_request = + heartbeat_open_request("roast-heartbeat-rate-limit-a-1", wallet_a_key_group, 10); + let first_wallet_a = interactive_session_open(first_wallet_a_request.clone()) + .expect("the first wallet-A heartbeat Open has one token"); + assert!(!first_wallet_a.idempotent); + + let wallet_a_retry = interactive_session_open(first_wallet_a_request) + .expect("an exact Open retry must not charge another heartbeat token"); + assert!(wallet_a_retry.idempotent); + + let wallet_a_limited = interactive_session_open(heartbeat_open_request( + "roast-heartbeat-rate-limit-a-2", + wallet_a_key_group, + 11, + )) + .expect_err("a fresh wallet-A heartbeat Open must exhaust its one-token budget"); + assert!(matches!( + wallet_a_limited, + EngineError::SigningPolicyRejected { ref reason_code, .. } + if reason_code == "heartbeat_rate_limit_per_minute_exceeded" + )); + let metrics_after_limit = hardening_metrics(); + assert_eq!(metrics_after_limit.heartbeat_signing_policy_reject_total, 1); + assert_eq!(metrics_after_limit.build_taproot_tx_policy_reject_total, 0); + + let wallet_b = interactive_session_open(heartbeat_open_request( + "roast-heartbeat-rate-limit-b-1", + wallet_b_key_group, + 12, + )) + .expect("wallet B must have an independent heartbeat budget"); + assert!(!wallet_b.idempotent); + + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr"); + configure_required_signing_policy_limits_for_tests(); + build_taproot_tx(build_policy_test_request( + "session-heartbeat-rate-limit-build-tx-budget", + )) + .expect("heartbeat Opens must not consume the BuildTaprootTx token bucket"); + let metrics_after_build = hardening_metrics(); + assert_eq!(metrics_after_build.heartbeat_signing_policy_reject_total, 1); + assert_eq!(metrics_after_build.build_taproot_tx_policy_reject_total, 0); + + clear_state_storage_policy_overrides(); + cleanup_test_state_artifacts(&state_path); +} + +#[test] +fn interactive_heartbeat_intent_rejects_malformed_ambiguous_or_tweaked_requests() { + 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"); + configure_required_signing_policy_limits_for_tests(); + + let wallet_session = "wallet-heartbeat-intent-negative"; + let key_group = "heartbeat-intent-negative-key-group"; + let included = [1u16, 2]; + ensure_interactive_dkg_session(wallet_session, key_group); + let heartbeat_message = heartbeat_message_for_test(2); + let signing_message = heartbeat_signing_message_for_test(&heartbeat_message); + + let mut wrong_prefix = heartbeat_message; + wrong_prefix[0] = 0; + let mismatched_message = heartbeat_message_for_test(3); + let cases = vec![ + ( + "non-hex", + InteractiveSigningIntent::Heartbeat { + message_hex: "zz".repeat(16), + }, + None, + "invalid_heartbeat_signing_intent", + ), + ( + "wrong-prefix", + heartbeat_signing_intent_for_test(&wrong_prefix), + None, + "invalid_heartbeat_signing_intent", + ), + ( + "short-message", + heartbeat_signing_intent_for_test(&heartbeat_message[..15]), + None, + "invalid_heartbeat_signing_intent", + ), + ( + "digest-mismatch", + heartbeat_signing_intent_for_test(&mismatched_message), + None, + "heartbeat_signing_message_mismatch", + ), + ( + "taproot-root", + heartbeat_signing_intent_for_test(&heartbeat_message), + Some("11".repeat(32)), + "invalid_heartbeat_signing_intent", + ), + ]; + + for (case, signing_intent, taproot_merkle_root_hex, expected_reason) in cases { + let signing_session = format!("roast-heartbeat-intent-{case}"); + let error = interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.clone(), + member_identifier: 1, + message_hex: hex::encode(signing_message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex, + signing_intent: Some(signing_intent), + attempt_context: interactive_test_attempt_context( + &signing_session, + key_group, + &signing_message, + &included, + 1, + ), + }) + .expect_err("invalid heartbeat intent must fail closed at Open"); + assert!( + matches!(error, EngineError::SigningPolicyRejected { ref reason_code, .. } + if reason_code == expected_reason), + "case [{case}] returned unexpected error: {error:?}" + ); + } + + let ambiguous_session = "roast-heartbeat-intent-ambiguous"; + build_taproot_tx(build_policy_test_request(ambiguous_session)) + .expect("seed a transaction artifact on the ambiguous session"); + let ambiguous = interactive_session_open(InteractiveSessionOpenRequest { + session_id: ambiguous_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(signing_message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: Some(heartbeat_signing_intent_for_test(&heartbeat_message)), + attempt_context: interactive_test_attempt_context( + ambiguous_session, + key_group, + &signing_message, + &included, + 1, + ), + }) + .expect_err("transaction and heartbeat authorizations must not coexist"); + assert!(matches!( + ambiguous, + EngineError::SigningPolicyRejected { ref reason_code, .. } + if reason_code == "ambiguous_signing_policy_artifact" + )); + + let metrics = hardening_metrics(); + assert_eq!(metrics.heartbeat_signing_policy_reject_total, 6); + assert_eq!(metrics.build_taproot_tx_policy_reject_total, 0); + + clear_state_storage_policy_overrides(); +} + +#[test] +fn interactive_round2_rechecks_stored_heartbeat_intent_before_share_release() { + 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"); + + let wallet_session = "wallet-heartbeat-intent-round2-recheck"; + let signing_session = "roast-heartbeat-intent-round2-recheck"; + let key_group = "heartbeat-intent-round2-recheck-key-group"; + let included = [1u16, 2]; + let heartbeat_message = heartbeat_message_for_test(4); + let signing_message = heartbeat_signing_message_for_test(&heartbeat_message); + let key_packages = ensure_interactive_dkg_session(wallet_session, key_group); + + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(signing_message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: Some(heartbeat_signing_intent_for_test(&heartbeat_message)), + attempt_context: interactive_test_attempt_context( + signing_session, + key_group, + &signing_message, + &included, + 1, + ), + }) + .expect("valid heartbeat Open"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("heartbeat round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 heartbeat commitments"); + let signing_package_hex = interactive_package_for_test( + &signing_message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + + { + let mut guard = state().expect("state").lock().expect("engine lock"); + guard + .sessions + .get_mut(signing_session) + .expect("heartbeat signing session") + .interactive_signing + .get_mut(&1) + .expect("live heartbeat attempt") + .signing_intent = Some(heartbeat_signing_intent_for_test( + &heartbeat_message_for_test(5), + )); + } + + let error = interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }) + .expect_err("Round2 must recheck the stored heartbeat intent"); + assert!(matches!( + error, + EngineError::SigningPolicyRejected { ref reason_code, .. } + if reason_code == "heartbeat_signing_message_mismatch" + )); + let guard = state().expect("state").lock().expect("engine lock"); + let session = &guard.sessions[signing_session]; + assert!(session.interactive_signing[&1].round1.is_some()); + assert!(!interactive_attempt_consumed( + &session.consumed_interactive_attempt_markers, + &opened.attempt_id, + 1, + )); + drop(guard); + + let metrics = hardening_metrics(); + assert_eq!(metrics.heartbeat_signing_policy_reject_total, 1); + assert_eq!(metrics.build_taproot_tx_policy_reject_total, 0); + + clear_state_storage_policy_overrides(); +} + +#[test] +fn interactive_open_cross_session_respects_wallet_emergency_rekey() { + let _guard = lock_test_state(); + reset_for_tests(); + + let wallet_session = "wallet-rekeyed-before-cross-session-open"; + let signing_session = "roast-blocked-before-open"; + let key_group = "cross-session-open-rekey-key-group"; + let message = [0xc2u8; 32]; + let included = [1u16, 2]; + ensure_interactive_dkg_session(wallet_session, key_group); + { + let mut guard = state().expect("state").lock().expect("engine lock"); + guard + .sessions + .get_mut(wallet_session) + .expect("wallet session") + .emergency_rekey_event = Some(EmergencyRekeyEvent { + reason: "wallet compromised before Open".to_string(), + triggered_at_unix: now_unix(), + }); + } + + let error = interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + signing_session, + key_group, + &message, + &included, + 1, + ), + }) + .expect_err("a wallet emergency rekey must block a distinct signing session at Open"); + assert!( + matches!(error, EngineError::LifecyclePolicyRejected { ref reason_code, .. } + if reason_code == "emergency_rekey_required"), + "unexpected error: {error:?}" + ); + + let guard = state().expect("state").lock().expect("engine lock"); + assert!( + !guard.sessions.contains_key(signing_session), + "Open must reject before allocating or burning a signing-session nonce" + ); +} + +#[test] +fn interactive_open_uses_signing_session_bip341_artifact_and_current_policy() { + 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"); + configure_required_signing_policy_limits_for_tests(); + + let wallet_session = "wallet-policy-artifact-owner"; + let signing_session = "roast-policy-artifact-signing"; + let key_group = "policy-artifact-key-group"; + let included = [1u16, 2]; + ensure_interactive_dkg_session(wallet_session, key_group); + + let tx_result = build_taproot_tx(build_policy_test_request(signing_session)) + .expect("BuildTaprootTx stores the artifact on the signing session"); + reload_state_from_storage_for_tests(); + let signing_message = + hex::decode(&tx_result.taproot_key_spend_sighashes_hex[0]).expect("BIP-341 sighash hex"); + let open_request = InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(&signing_message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + signing_session, + key_group, + &signing_message, + &included, + 1, + ), + }; + interactive_session_open(open_request.clone()) + .expect("cross-session Open uses the signing session's Build artifact"); + + let unsigned_tx_hash = hash_hex(&hex::decode(&tx_result.tx_hex).expect("tx hex")); + let old_binding = enforce_signing_message_binding_to_policy_checked_build_tx( + signing_session, + &unsigned_tx_hash, + None, + Some(&tx_result), + None, + ) + .expect_err("SHA256(unsigned_tx) must not authorize a BIP-341 signature"); + assert!(matches!( + old_binding, + EngineError::SigningPolicyRejected { ref reason_code, .. } + if reason_code == "signing_message_not_bound_to_policy_checked_build_tx" + )); + + // The artifact was accepted under p2tr, but every Open rechecks the active + // non-rate policy before returning even for an otherwise idempotent retry. + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2wpkh"); + let tightened = interactive_session_open(open_request) + .expect_err("a stricter active policy must reject the cached transaction"); + assert!(matches!( + tightened, + EngineError::SigningPolicyRejected { ref reason_code, .. } + if reason_code == "script_class_not_allowlisted" + )); + + clear_state_storage_policy_overrides(); +} + +#[test] +fn interactive_open_does_not_use_wallet_session_transaction_artifact() { + 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"); + configure_required_signing_policy_limits_for_tests(); + + let wallet_session = "wallet-with-wrong-scope-artifact"; + let signing_session = "roast-without-own-artifact"; + let key_group = "wrong-scope-artifact-key-group"; + let included = [1u16, 2]; + ensure_interactive_dkg_session(wallet_session, key_group); + let wallet_tx = build_taproot_tx(build_policy_test_request(wallet_session)) + .expect("wallet-scoped Build artifact"); + let message = + hex::decode(&wallet_tx.taproot_key_spend_sighashes_hex[0]).expect("BIP-341 sighash hex"); + + let err = interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(&message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + signing_session, + key_group, + &message, + &included, + 1, + ), + }) + .expect_err("a wallet-session artifact must not authorize a fresh signing flow"); + assert!(matches!( + err, + EngineError::SigningPolicyRejected { ref reason_code, .. } + if reason_code == "missing_policy_checked_build_tx" + )); + + clear_state_storage_policy_overrides(); +} + +#[test] +fn interactive_round2_writes_a_consumed_marker_readable_by_the_previous_schema1_binary() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("interactive_round2_rollback_marker"); + reset_for_tests(); + + let session_id = "interactive-round2-rollback-marker"; + let key_group = "interactive-test-key-group"; + let message = [0xe2u8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("interactive attempt opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }) + .expect("round 2 persists consumption before releasing the share"); + + let previous_schema1_marker = format!("m1@{}", opened.attempt_id); + assert_eq!( + interactive_consumed_marker(&opened.attempt_id, 1), + previous_schema1_marker, + "schema-1 state must keep the marker representation understood by the prior binary" + ); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let guard = state().expect("state").lock().expect("lock"); + let markers = &guard.sessions[session_id].consumed_interactive_attempt_markers; + let previous_binary_reports_consumed = + markers.contains(&previous_schema1_marker) || markers.contains(&opened.attempt_id); + assert!( + previous_binary_reports_consumed, + "the immediately previous schema-1 reader must fail closed after rollback" + ); + drop(guard); + + let transitional_v2 = HashSet::from([format!("{previous_schema1_marker}@v2")]); + assert!( + interactive_attempt_consumed(&transitional_v2, &opened.attempt_id, 1), + "current readers retain fail-closed support for transitional @v2 markers" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn interactive_consumed_marker_is_case_insensitive() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-attempt-id-casing"; + let key_group = "interactive-test-key-group"; + let message = [0xe3u8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + + // Build the canonical (lowercase) attempt context, consume it, then + // retry the SAME logical attempt with the attempt_id upper-cased. + // validate_attempt_context accepts the hash fields case- + // insensitively, so a raw-keyed marker would miss and re-sign; + // the canonical keying must reject it as consumed. + let canonical = interactive_test_attempt_context(session_id, key_group, &message, &included, 1); + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: canonical.clone(), + }) + .expect("canonical open"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + // Round2 with an UPPER-cased attempt_id must still consume the + // canonical attempt (proves round entry points canonicalize). + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.to_ascii_uppercase(), + member_identifier: 1, + signing_package_hex, + }) + .expect("round 2 under an upper-cased attempt_id consumes the canonical attempt"); + + // Reopen the SAME attempt with an upper-cased attempt_id: the + // consumed marker must catch it. + let mut recased_context = canonical; + recased_context.attempt_id = recased_context.attempt_id.to_ascii_uppercase(); + let replay = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: recased_context, + }) + .expect_err("a re-cased consumed attempt must fail closed"); + assert!( + matches!(replay, EngineError::ConsumedNonceReplay { .. }), + "unexpected error: {replay:?}" + ); +} + +#[test] +fn interactive_abort_sweeps_expired_sessions() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_group = "interactive-test-key-group"; + let message = [0xf4u8; 32]; + let included = [1u16, 2]; + + // Open a live attempt on session A, then age it past the TTL. + let opened = open_interactive_for_test( + "interactive-abort-sweep-a", + key_group, + &message, + &included, + 1, + 1, + 2, + ) + .expect("session A opens"); + interactive_round1(InteractiveRound1Request { + session_id: "interactive-abort-sweep-a".to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + advance_interactive_clock_for_tests(interactive_session_ttl_seconds().saturating_add(1)); + + // An abort for a DIFFERENT session is the only post-expiry traffic; + // it must still sweep session A's expired nonces (the TTL guarantee + // holds regardless of which entry point takes the lock). + interactive_session_abort(InteractiveSessionAbortRequest { + session_id: "interactive-abort-sweep-other".to_string(), + attempt_id: None, + }) + .expect("abort for an unrelated session"); + + // The sweep clears session A's expired live attempt (and its + // nonces) even though the only post-expiry traffic was an abort for + // an unrelated session. The session itself is retained - it rides + // DKG state that persists for future signing. + let guard = state().expect("state").lock().expect("lock"); + let session = guard + .sessions + .get("interactive-abort-sweep-a") + .expect("session A (DKG state) is retained"); + assert!( + session.interactive_signing.is_empty(), + "an abort elsewhere must still sweep an expired interactive attempt" + ); +} + +#[test] +fn interactive_open_rejected_on_session_lifecycle_states() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_group = "interactive-test-key-group"; + let message = [0x17u8; 32]; + let included = [1u16, 2]; + + // A session under an emergency rekey must refuse interactive opens, + // exactly as start_sign_round does. + { + let mut guard = state().expect("state").lock().expect("lock"); + guard.sessions.insert( + "interactive-lifecycle-rekey".to_string(), + SessionState { + emergency_rekey_event: Some(EmergencyRekeyEvent { + reason: "test rekey".to_string(), + triggered_at_unix: now_unix(), + }), + ..Default::default() + }, + ); + } + let rekey = open_interactive_for_test( + "interactive-lifecycle-rekey", + key_group, + &message, + &included, + 1, + 1, + 2, + ) + .expect_err("an emergency-rekey session must refuse interactive open"); + assert!( + matches!(rekey, EngineError::LifecyclePolicyRejected { ref reason_code, .. } + if reason_code == "emergency_rekey_required"), + "unexpected error: {rekey:?}" + ); + + // A terminally finalized session must refuse interactive opens. + { + let mut guard = state().expect("state").lock().expect("lock"); + guard.sessions.insert( + "interactive-lifecycle-finalized".to_string(), + SessionState { + finalize_request_fingerprint: Some("already-finalized".to_string()), + ..Default::default() + }, + ); + } + let finalized = open_interactive_for_test( + "interactive-lifecycle-finalized", + key_group, + &message, + &included, + 1, + 1, + 2, + ) + .expect_err("a finalized session must refuse interactive open"); + assert!( + matches!(finalized, EngineError::SessionFinalized { .. }), + "unexpected error: {finalized:?}" + ); +} + +#[test] +fn interactive_open_rejected_for_quarantined_member_honors_dao_allowlist() { + let _guard = lock_test_state(); + reset_for_tests(); + + 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"); + + // Member 1 is auto-quarantined. + { + let mut guard = state().expect("state").lock().expect("lock"); + guard.quarantined_operator_identifiers.insert(1); + } + + let key_group = "interactive-test-key-group"; + let message = [0x18u8; 32]; + let included = [1u16, 2]; + + let outcome = (|| -> Result<(), EngineError> { + let quarantined = open_interactive_for_test( + "interactive-quarantine", + key_group, + &message, + &included, + 1, + 1, + 2, + ) + .expect_err("a quarantined member must not open an interactive session"); + assert!( + matches!(quarantined, EngineError::QuarantinePolicyRejected { ref reason_code, .. } + if reason_code == "operator_auto_quarantined"), + "unexpected error: {quarantined:?}" + ); + + // A DAO allowlist override restores the member's ability to sign. + std::env::set_var( + TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS_ENV, + "1", + ); + let allowlisted = open_interactive_for_test( + "interactive-quarantine-allowlisted", + key_group, + &message, + &included, + 1, + 1, + 2, + )?; + assert!(!allowlisted.idempotent); + Ok(()) + })(); + + 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); + + outcome.expect("quarantine gate lifecycle"); +} + +#[test] +fn interactive_round2_rechecks_gates_at_share_release() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let key_group = "interactive-test-key-group"; + let message = [0x19u8; 32]; + let included = [1u16, 2]; + + // Open + Round1 normally (gates pass at Open), build the package, + // THEN record an emergency rekey before Round2. The share must not + // leave the engine: Round2 re-evaluates the gates at release time. + let session_id = "interactive-toctou-rekey"; + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + + // Kill switch recorded AFTER Open/Round1. + { + let mut guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get_mut(session_id).expect("session exists"); + session.emergency_rekey_event = Some(EmergencyRekeyEvent { + reason: "post-open rekey".to_string(), + triggered_at_unix: now_unix(), + }); + } + + let blocked = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect_err("a post-open emergency rekey must block the Round2 share"); + assert!( + matches!(blocked, EngineError::LifecyclePolicyRejected { ref reason_code, .. } + if reason_code == "emergency_rekey_required"), + "unexpected error: {blocked:?}" + ); + + // The block at release time must be fail-closed WITHOUT consuming + // the nonces: no marker was written (verify-before-consume applies + // to the gate recheck too), so clearing the kill switch lets the + // same attempt complete. This proves the recheck rejects before + // consumption rather than after. + { + let mut guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get_mut(session_id).expect("session exists"); + assert!( + !session + .consumed_interactive_attempt_markers + .contains(&interactive_consumed_marker(&opened.attempt_id, 1)), + "a gate rejection must not consume the attempt" + ); + session.emergency_rekey_event = None; + } + + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id, + member_identifier: 1, + signing_package_hex, + }) + .expect("the same attempt completes once the kill switch clears"); +} + +#[test] +fn interactive_round2_rechecks_signing_session_transaction_against_current_policy() { + 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"); + configure_required_signing_policy_limits_for_tests(); + + let wallet_session = "wallet-round2-policy-owner"; + let signing_session = "roast-round2-policy-signing"; + let key_group = "round2-policy-key-group"; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(wallet_session, key_group); + let tx_result = build_taproot_tx(build_policy_test_request(signing_session)) + .expect("signing-session Build artifact"); + let message = + hex::decode(&tx_result.taproot_key_spend_sighashes_hex[0]).expect("BIP-341 sighash hex"); + + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(&message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + signing_session, + key_group, + &message, + &included, + 1, + ), + }) + .expect("Open accepts current policy"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2wpkh"); + let blocked = interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect_err("Round2 must reject a transaction disallowed by current policy"); + assert!(matches!( + blocked, + EngineError::SigningPolicyRejected { ref reason_code, .. } + if reason_code == "script_class_not_allowlisted" + )); + { + let guard = state().expect("state").lock().expect("lock"); + let signing = guard + .sessions + .get(signing_session) + .expect("signing session"); + assert!(!interactive_attempt_consumed( + &signing.consumed_interactive_attempt_markers, + &opened.attempt_id, + 1, + )); + } + + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr"); + interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id, + member_identifier: 1, + signing_package_hex, + }) + .expect("same live nonces complete once policy allows the transaction"); + + clear_state_storage_policy_overrides(); +} + +#[test] +fn interactive_round2_rechecks_gates_at_share_release_across_sessions() { + // The cross-session counterpart of the above: the emergency-rekey kill switch is + // recorded on the WALLET (DKG) session, but signing runs under a DISTINCT + // per-message session. Round2 must STILL block the share - the wallet-level gate + // has to be resolved by key_group, not read from the (empty) per-signing session. + // This is the exact fail-open the state-split risked for distributed-DKG wallets, + // whose only signing path is interactive. + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let wallet_session = "wallet-dkg-session-rekey"; + let signing_session = "roast-signing-session-rekey"; + let key_group = "cross-session-rekey-key-group"; + let message = [0x21u8; 32]; + let included = [1u16, 2]; + + // DKG material lives ONLY under the wallet session. + ensure_interactive_dkg_session(wallet_session, key_group); + + // Open + Round1 under the distinct signing session (gates clear at Open). + let attempt_context = + interactive_test_attempt_context(signing_session, key_group, &message, &included, 1); + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context, + }) + .expect("opens under the signing session"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1 under the signing session"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + + // Kill switch recorded on the WALLET session AFTER Open/Round1 - NOT on the + // signing session the operator is driving. + { + let mut guard = state().expect("state").lock().expect("lock"); + let session = guard + .sessions + .get_mut(wallet_session) + .expect("wallet session exists"); + session.emergency_rekey_event = Some(EmergencyRekeyEvent { + reason: "post-open rekey on the wallet session".to_string(), + triggered_at_unix: now_unix(), + }); + } + + // Round2 under the signing session MUST block - the wallet-level rekey gate is + // resolved by key_group from the wallet session, not the empty signing session. + let blocked = interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect_err("a wallet-session emergency rekey must block a cross-session Round2 share"); + assert!( + matches!(blocked, EngineError::LifecyclePolicyRejected { ref reason_code, .. } + if reason_code == "emergency_rekey_required"), + "unexpected error: {blocked:?}" + ); + + // The rejection must be fail-closed WITHOUT consuming the nonce. Clear the wallet + // event, then prove that the same reader also honors an event stored directly on + // the per-signing session. + { + let mut guard = state().expect("state").lock().expect("lock"); + assert!( + !guard + .sessions + .get(signing_session) + .expect("signing session exists") + .consumed_interactive_attempt_markers + .contains(&interactive_consumed_marker(&opened.attempt_id, 1)), + "a cross-session gate rejection must not consume the attempt" + ); + guard + .sessions + .get_mut(wallet_session) + .expect("wallet session exists") + .emergency_rekey_event = None; + let signing = guard + .sessions + .get_mut(signing_session) + .expect("signing session exists"); + signing.emergency_rekey_event = Some(EmergencyRekeyEvent { + reason: "post-open rekey on the signing session".to_string(), + triggered_at_unix: now_unix(), + }); + } + + let signing_session_blocked = interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect_err("a signing-session emergency rekey must also block Round2"); + assert!( + matches!(signing_session_blocked, EngineError::LifecyclePolicyRejected { ref reason_code, .. } + if reason_code == "emergency_rekey_required"), + "unexpected error: {signing_session_blocked:?}" + ); + + { + let mut guard = state().expect("state").lock().expect("lock"); + let signing = guard + .sessions + .get_mut(signing_session) + .expect("signing session exists"); + assert!( + !signing + .consumed_interactive_attempt_markers + .contains(&interactive_consumed_marker(&opened.attempt_id, 1)), + "a signing-session gate rejection must not consume the attempt" + ); + signing.emergency_rekey_event = None; + } + + interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id, + member_identifier: 1, + signing_package_hex, + }) + .expect("the cross-session attempt completes once both kill switches clear"); +} + +#[test] +fn interactive_open_rejects_signing_session_rekey_before_wallet_binding() { + let _guard = lock_test_state(); + reset_for_tests(); + + let wallet_session = "wallet-dkg-session-pre-open-rekey"; + let signing_session = "roast-signing-session-pre-open-rekey"; + let key_group = "pre-open-rekey-key-group"; + let included = [1u16, 2]; + + ensure_interactive_dkg_session(wallet_session, key_group); + let tx_result = build_taproot_tx(build_policy_test_request(signing_session)) + .expect("BuildTaprootTx creates the per-signing session"); + let message = hex::decode(&tx_result.taproot_key_spend_sighashes_hex[0]) + .expect("BIP-341 sighash decodes"); + + { + let guard = state().expect("state").lock().expect("lock"); + let signing = guard + .sessions + .get(signing_session) + .expect("BuildTaprootTx signing session exists"); + assert!( + signing.bound_key_group.is_none(), + "BuildTaprootTx must precede the Open wallet binding" + ); + } + + let rekey = trigger_emergency_rekey(TriggerEmergencyRekeyRequest { + session_id: signing_session.to_string(), + reason: "compromise detected before Open".to_string(), + }) + .expect("emergency rekey triggers on the unbound signing session"); + assert_eq!( + rekey.session_id, signing_session, + "without a wallet binding, the event remains on the signing session" + ); + + let attempt_context = + interactive_test_attempt_context(signing_session, key_group, &message, &included, 1); + let blocked = interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context, + }) + .expect_err("the signing-session emergency rekey must block Open"); + assert!( + matches!(blocked, EngineError::LifecyclePolicyRejected { ref reason_code, .. } + if reason_code == "emergency_rekey_required"), + "unexpected error: {blocked:?}" + ); +} + +#[test] +fn trigger_emergency_rekey_on_signing_session_records_on_wallet_session() { + // Defense in depth (writer side): emergency rekey is a WALLET-level kill switch, + // and interactive Round2 resolves it from the wallet session by key_group. If a + // caller triggers it on a per-signing session (a distinct RoastSessionID bound to a + // wallet key), the event must land on the WALLET session - where a reader looks - + // not on the ephemeral signing session. This makes the writer and reader keying + // impossible to diverge. + let _guard = lock_test_state(); + reset_for_tests(); + + let wallet_session = "wallet-dkg-session-rekey-writer"; + let signing_session = "roast-signing-session-rekey-writer"; + let key_group = "cross-session-rekey-writer-key-group"; + let message = [0x22u8; 32]; + let included = [1u16, 2]; + + ensure_interactive_dkg_session(wallet_session, key_group); + + // Open under the distinct signing session so it is bound to the wallet key. + let attempt_context = + interactive_test_attempt_context(signing_session, key_group, &message, &included, 1); + interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context, + }) + .expect("opens under the signing session"); + + // Trigger the kill switch on the SIGNING session id. + let result = trigger_emergency_rekey(TriggerEmergencyRekeyRequest { + session_id: signing_session.to_string(), + reason: "compromise detected".to_string(), + }) + .expect("emergency rekey triggers"); + + // It must have been recorded on the resolved WALLET session, where Round2 reads it. + assert_eq!( + result.session_id, wallet_session, + "the rekey must be recorded on the resolved wallet session" + ); + let guard = state().expect("state").lock().expect("lock"); + assert!( + guard + .sessions + .get(wallet_session) + .expect("wallet session") + .emergency_rekey_event + .is_some(), + "the wallet session must hold the kill switch" + ); + assert!( + guard + .sessions + .get(signing_session) + .expect("signing session") + .emergency_rekey_event + .is_none(), + "the ephemeral signing session must NOT hold the kill switch" + ); +} + +#[test] +fn interactive_open_rejects_threshold_below_key_package_min_signers() { + let _guard = lock_test_state(); + reset_for_tests(); + + // The fixture key packages are min_signers = 2. A request threshold + // of 3 must be rejected at Open: otherwise Round2 would accept a + // 3-commitment package, persist the marker, and only then have + // frost::round2::sign fail on the count - burning the nonce for a + // validation error. + let mismatch = open_interactive_for_test( + "interactive-threshold-mismatch", + "interactive-test-key-group", + &[0x1au8; 32], + &[1u16, 2, 3], + 1, + 1, + 3, + ) + .expect_err("a threshold below the key package min_signers must be rejected"); + assert!( + matches!(mismatch, EngineError::Validation(ref m) + if m.contains("does not match the DKG threshold")), + "unexpected error: {mismatch:?}" + ); + + // The matching threshold (2) opens. + open_interactive_for_test( + "interactive-threshold-match", + "interactive-test-key-group", + &[0x1au8; 32], + &[1u16, 2], + 1, + 1, + 2, + ) + .expect("the key-package-matching threshold opens"); +} + +#[test] +fn interactive_open_requires_an_existing_dkg_session() { + let _guard = lock_test_state(); + reset_for_tests(); + + // Key material is resolved from engine DKG state by key_group, never the + // request, so an interactive open when NO wallet key exists for that + // key_group fails closed with DkgNotReady - the interactive path never + // signs with caller-supplied material. (It MAY create a per-signing session + // bound to an EXISTING wallet key, but only then.) + let attempt_context = interactive_test_attempt_context( + "interactive-no-dkg", + "interactive-test-key-group", + &[0x1bu8; 32], + &[1u16, 2], + 1, + ); + let err = interactive_session_open(InteractiveSessionOpenRequest { + session_id: "interactive-no-dkg".to_string(), + member_identifier: 1, + message_hex: hex::encode([0x1bu8; 32]), + key_group: "interactive-test-key-group".to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context, + }) + .expect_err("interactive open with no wallet key for the key_group must fail closed"); + assert!( + matches!(err, EngineError::DkgNotReady { .. }), + "unexpected error: {err:?}" + ); + + // A member not in the session's DKG group is rejected even once DKG + // exists (the group has members 1..3, so member 4 is absent). + ensure_interactive_dkg_session("interactive-dkg-present", "interactive-test-key-group"); + let absent_member = interactive_test_attempt_context( + "interactive-dkg-present", + "interactive-test-key-group", + &[0x1bu8; 32], + &[1u16, 2], + 1, + ); + let absent = interactive_session_open(InteractiveSessionOpenRequest { + session_id: "interactive-dkg-present".to_string(), + member_identifier: 4, + message_hex: hex::encode([0x1bu8; 32]), + key_group: "interactive-test-key-group".to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: absent_member, + }) + .expect_err("a non-DKG-participant member must be rejected"); + assert!( + matches!(absent, EngineError::Validation(ref m) + if m.contains("not a DKG participant") + || m.contains("included_participants")), + "unexpected error: {absent:?}" + ); +} + +#[test] +fn interactive_round2_rejects_quarantined_co_signer_in_package() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-round2-quarantined-cosigner"; + let key_group = "interactive-test-key-group"; + let message = [0x1cu8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + + 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 outcome = (|| -> Result<(), EngineError> { + // This member (1) opens and runs round 1 while no one is + // quarantined; the co-signer (2) is quarantined afterward. + let opened = + open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2)?; + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + })?; + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + })?; + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + + // Quarantine the co-signer (member 2) after round 1. + { + let mut guard = state().expect("state").lock().expect("lock"); + guard.quarantined_operator_identifiers.insert(2); + } + + // Round2 must refuse: this node will not contribute a share to a + // package whose subset includes a quarantined co-signer, even + // though this node (member 1) is not itself quarantined. + let blocked = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }) + .expect_err("a quarantined co-signer in the package must block the share"); + assert!( + matches!(blocked, EngineError::QuarantinePolicyRejected { ref reason_code, .. } + if reason_code == "operator_auto_quarantined"), + "unexpected error: {blocked:?}" + ); + + // Fail-closed without consuming: clearing the quarantine lets the + // same attempt complete (the rejection preceded consumption). + { + let mut guard = state().expect("state").lock().expect("lock"); + assert!( + !guard + .sessions + .get(session_id) + .expect("session") + .consumed_interactive_attempt_markers + .contains(&interactive_consumed_marker(&opened.attempt_id, 1)), + "a quarantine rejection must not consume the attempt" + ); + guard.quarantined_operator_identifiers.remove(&2); + } + Ok(()) + })(); + + 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); + outcome.expect("round2 co-signer quarantine lifecycle"); +} + +#[test] +fn interactive_open_rejects_phantom_included_participant() { + let _guard = lock_test_state(); + reset_for_tests(); + + // The session's DKG group is members 1..3. An attempt context whose + // included set names a phantom id (99) must be rejected even though + // the local member (1) is a real participant - otherwise a caller + // could bias the RFC-21 coordinator/attempt derivation with + // non-participants. + let session_id = "interactive-phantom-included"; + let key_group = "interactive-test-key-group"; + let message = [0x1du8; 32]; + ensure_interactive_dkg_session(session_id, key_group); + + let attempt_context = + interactive_test_attempt_context(session_id, key_group, &message, &[1u16, 99], 1); + let err = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context, + }) + .expect_err("a phantom included participant must be rejected"); + assert!( + matches!(err, EngineError::Validation(ref m) + if m.contains("not a DKG participant for this session")), + "unexpected error: {err:?}" + ); +} + +#[test] +fn interactive_aggregate_allows_an_elected_coordinator_outside_the_signing_subset() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-nonsigning-coordinator"; + let key_group = "interactive-test-key-group"; + let message = [0x49u8; 32]; + let included = [1u16, 2, 3]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + let attempt_context = + interactive_test_attempt_context(session_id, key_group, &message, &included, 1); + let coordinator = attempt_context.coordinator_identifier; + let signing_subset = included + .iter() + .copied() + .filter(|member| *member != coordinator) + .collect::>(); + assert_eq!(signing_subset.len(), 2); + + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: coordinator, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context, + }) + .expect("elected coordinator opens the attempt"); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: coordinator, + }) + .expect("elected coordinator joins commitment collection"); + + let (signing_package_hex, signature_shares) = + stateless_package_and_shares_for_test(&message, &signing_subset, &key_packages); + let aggregate_request = InteractiveAggregateRequest { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + signing_package_hex, + signature_shares, + taproot_merkle_root_hex: None, + }; + let aggregate = interactive_aggregate(aggregate_request.clone()) + .expect("an elected coordinator need not be one of the first threshold responders"); + assert_eq!(aggregate.attempt_id, opened.attempt_id); + + let guard = state().expect("state").lock().expect("lock"); + let session = &guard.sessions[session_id]; + assert!( + !interactive_attempt_consumed( + &session.consumed_interactive_attempt_markers, + &opened.attempt_id, + coordinator, + ), + "the coordinator did not release a share" + ); + assert!( + session.interactive_signing.is_empty(), + "successful aggregation retires the coordinator's unused nonce handle" + ); + assert_eq!( + session.aggregated_interactive_attempt_markers.len(), + 1, + "the successful attempt consumes one completion slot" + ); + drop(guard); + + // The coordinator-only fallback cannot bind an inner FROST package to an + // attempt id because the package carries no such field. Its durable + // package identity must therefore reject the same valid package/share set + // under a fresh canonical coordinator attempt, including after restart. + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let next_attempt_number = (2..=32) + .find(|attempt_number| { + interactive_test_attempt_context( + session_id, + key_group, + &message, + &included, + *attempt_number, + ) + .coordinator_identifier + == coordinator + }) + .expect("coordinator recurs within bounded RFC-21 rotation"); + let next_context = interactive_test_attempt_context( + session_id, + key_group, + &message, + &included, + next_attempt_number, + ); + let reopened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: coordinator, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: next_context, + }) + .expect("fresh canonical coordinator attempt opens after restart"); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: reopened.attempt_id.clone(), + member_identifier: coordinator, + }) + .expect("fresh coordinator attempt reaches live authorization"); + + let mut replay = aggregate_request; + replay.attempt_id = reopened.attempt_id; + let error = interactive_aggregate(replay) + .expect_err("a completed package cannot consume a second attempt marker"); + assert!( + matches!(error, EngineError::Validation(ref message) if message.contains("already aggregated")), + "unexpected replay error: {error:?}" + ); + let guard = state().expect("state").lock().expect("lock"); + assert_eq!( + guard.sessions[session_id] + .aggregated_interactive_attempt_markers + .len(), + 1, + "cross-attempt package replay must not amplify persistent completion state" + ); +} + +#[test] +fn interactive_aggregate_does_not_authorize_an_omitted_noncoordinator_observer() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-omitted-observer"; + let key_group = "interactive-test-key-group"; + let message = [0x48u8; 32]; + let included = [1u16, 2, 3]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + let attempt_context = + interactive_test_attempt_context(session_id, key_group, &message, &included, 1); + let observer = included + .iter() + .copied() + .find(|member| *member != attempt_context.coordinator_identifier) + .expect("included set has a non-coordinator"); + let signing_subset = included + .iter() + .copied() + .filter(|member| *member != observer) + .collect::>(); + + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: observer, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context, + }) + .expect("observer opens the attempt"); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: observer, + }) + .expect("observer produces a commitment before the first-t subset freezes"); + let (signing_package_hex, signature_shares) = + stateless_package_and_shares_for_test(&message, &signing_subset, &key_packages); + + let error = interactive_aggregate(InteractiveAggregateRequest { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id, + signing_package_hex, + signature_shares, + taproot_merkle_root_hex: None, + }) + .expect_err("an omitted observer cannot claim coordinator authorization"); + assert!( + matches!(error, EngineError::Validation(ref message) if message.contains("not authorized")), + "unexpected error: {error:?}" + ); +} + +#[test] +fn interactive_aggregate_produces_and_self_verifies_bip340() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-aggregate-e2e"; + let key_group = "interactive-test-key-group"; + let message = [0x4au8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + + // Member 1 signs through the hardened session API; member 2 through + // the stateless primitive. Both shares feed the coordinator's + // InteractiveAggregate, which resolves the verifying shares from the + // session's own DKG state. + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment.clone(), + ], + ); + let round2 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("round 2 share"); + let member2_share = sign_share(SignShareRequest { + signing_package_hex: signing_package_hex.clone(), + nonces_hex: member2.nonces_hex, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 share"); + + let aggregate = interactive_aggregate(InteractiveAggregateRequest { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + signing_package_hex, + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: round2.signature_share_hex, + }, + member2_share.signature_share, + ], + taproot_merkle_root_hex: None, + }) + .expect("interactive aggregate"); + assert_eq!(aggregate.attempt_id, opened.attempt_id); + + // The engine already self-verified; re-verify here against the DKG + // group key to pin the round trip. + let public_key_package = { + let guard = state().expect("state").lock().expect("lock"); + guard + .sessions + .get(session_id) + .expect("session") + .dkg_public_key_package + .clone() + .expect("public key package") + }; + let verifying_key_bytes = public_key_package.verifying_key().serialize().expect("vk"); + let signature_bytes = hex::decode(aggregate.signature_hex).expect("sig hex"); + let signature = SchnorrSignature::from_slice(&signature_bytes).expect("BIP340 signature"); + let public_key = XOnlyPublicKey::from_slice(&verifying_key_bytes[1..]).expect("x-only key"); + Secp256k1::verification_only() + .verify_schnorr(&signature, &SecpMessage::from_digest(message), &public_key) + .expect("interactive aggregate yields a valid BIP-340 signature"); +} + +#[test] +fn interactive_aggregate_rejects_repeat_aggregate_of_completed_attempt() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-aggregate-repeat"; + let key_group = "interactive-test-key-group"; + let message = [0x4eu8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment.clone(), + ], + ); + let round2 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("round 2 share"); + let member2_share = sign_share(SignShareRequest { + signing_package_hex: signing_package_hex.clone(), + nonces_hex: member2.nonces_hex, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 share"); + + let aggregate_request = InteractiveAggregateRequest { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + signing_package_hex, + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: round2.signature_share_hex, + }, + member2_share.signature_share, + ], + taproot_merkle_root_hex: None, + }; + + // First aggregate completes the attempt. The wire remains case-insensitive: + // a canonical 64-hex id is normalized before lookup and response emission. + let mut recased_request = aggregate_request.clone(); + recased_request.attempt_id = recased_request.attempt_id.to_ascii_uppercase(); + let aggregate = interactive_aggregate(recased_request).expect("first interactive aggregate"); + assert_eq!(aggregate.attempt_id, opened.attempt_id); + + // A valid package/share set cannot be replayed under a different, merely + // well-formed id. + let mut unbound_request = aggregate_request.clone(); + unbound_request.attempt_id = "aa".repeat(32); + assert_ne!(unbound_request.attempt_id, opened.attempt_id); + let err = interactive_aggregate(unbound_request) + .expect_err("an unbound aggregate attempt id must be rejected"); + assert!( + matches!(err, EngineError::Validation(ref message) if message.contains("not authorized")), + "unexpected error: {err:?}" + ); + + // Merely opening the next canonical attempt (and even producing fresh + // Round1 commitments) must not authorize the prior attempt's package. The + // old ID-only binding allowed this loop to add one completion marker per + // Open until the 128-entry cap blocked legitimate work. + let reopened = open_interactive_for_test(session_id, key_group, &message, &included, 2, 1, 2) + .expect("next canonical attempt opens"); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: reopened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("next canonical attempt produces fresh commitments"); + let mut rebound_replay = aggregate_request.clone(); + rebound_replay.attempt_id = reopened.attempt_id; + let err = interactive_aggregate(rebound_replay) + .expect_err("a fresh Open must not authorize an old signing package"); + assert!( + matches!(err, EngineError::Validation(ref message) if message.contains("not authorized")), + "unexpected error: {err:?}" + ); + { + let guard = state().expect("state").lock().expect("lock"); + assert_eq!( + guard.sessions[session_id] + .aggregated_interactive_attempt_markers + .len(), + 1, + "a replay under a freshly opened canonical id must not consume marker capacity" + ); + } + + // Re-aggregating a completed attempt is rejected by the durable completion + // marker rather than recomputed (re-aggregation is not a recovery path; a + // lost signature is recovered with a fresh attempt). Phase 7.2b design + // section 6. + let err = interactive_aggregate(aggregate_request) + .expect_err("re-aggregating a completed attempt must be rejected"); + assert!( + matches!( + err, + EngineError::InteractiveAttemptAlreadyAggregated { ref attempt_id, .. } + if *attempt_id == opened.attempt_id + ), + "unexpected error: {err:?}" + ); + assert_eq!(err.code(), "interactive_attempt_already_aggregated"); + assert_eq!(err.recovery_class(), "recoverable"); +} + +#[test] +fn interactive_aggregate_rejects_noncanonical_attempt_ids() { + let _guard = lock_test_state(); + reset_for_tests(); + + // Completion markers persist attempt_id verbatim as part of their key. The + // canonical derivation is a SHA-256 digest, so reject empty, oversized, and + // non-hex ids before decoding the other aggregate inputs or touching state. + for attempt_id in [ + String::new(), + "a".repeat(63), + "a".repeat(65), + format!("0x{}", "aa".repeat(31)), + "zz".repeat(32), + ] { + let err = interactive_aggregate(InteractiveAggregateRequest { + session_id: "interactive-aggregate-invalid-attempt".to_string(), + attempt_id, + signing_package_hex: String::new(), + signature_shares: vec![], + taproot_merkle_root_hex: None, + }) + .expect_err("a noncanonical attempt_id must be rejected"); + assert!( + matches!(err, EngineError::Validation(ref message) + if message.contains("exactly 64 hexadecimal characters")), + "unexpected error: {err:?}" + ); + } +} + +#[test] +fn interactive_aggregate_completion_marker_survives_process_restart() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("interactive_aggregate_marker_restart"); + reset_for_tests(); + + let session_id = "interactive-aggregate-marker-restart"; + let key_group = "interactive-test-key-group"; + let message = [0x4fu8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment.clone(), + ], + ); + let round2 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("round 2 share"); + let member2_share = sign_share(SignShareRequest { + signing_package_hex: signing_package_hex.clone(), + nonces_hex: member2.nonces_hex, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 share"); + + let aggregate_request = InteractiveAggregateRequest { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + signing_package_hex, + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: round2.signature_share_hex, + }, + member2_share.signature_share, + ], + taproot_merkle_root_hex: None, + }; + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let faulted = interactive_aggregate(aggregate_request.clone()) + .expect_err("a pre-replacement persistence fault must roll Aggregate state back"); + clear_persist_fault_injection_for_tests(); + assert!( + matches!(faulted, EngineError::Internal(ref message) if message.contains("injected persist fault")), + "unexpected fault: {faulted:?}" + ); + interactive_aggregate(aggregate_request.clone()) + .expect("the exact authorization and package remain retryable after rollback"); + + // The completion marker is the only durable interactive artifact (live + // nonce state is gone after restart by construction). It must survive a + // reload so a replayed aggregate is still rejected - this also exercises + // the marker's persistence round-trip (serialize + reload validation). + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + let err = interactive_aggregate(aggregate_request) + .expect_err("a completed attempt must stay completed across restart"); + assert!( + matches!(err, EngineError::InteractiveAttemptAlreadyAggregated { .. }), + "unexpected error: {err:?}" + ); + assert_eq!(err.code(), "interactive_attempt_already_aggregated"); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn interactive_aggregate_post_rename_persist_failure_finalizes_attempt_and_retry_flushes() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("interactive_aggregate_post_rename"); + reset_for_tests(); + + let session_id = "interactive-aggregate-post-rename"; + let key_group = "interactive-test-key-group"; + let message = [0x50u8; 32]; + let included = [1u16, 2, 3]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("member 1 opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 round 1"); + + let sibling = open_interactive_for_test(session_id, key_group, &message, &included, 1, 3, 2) + .expect("unsigned sibling opens"); + assert_eq!(sibling.attempt_id, opened.attempt_id); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: sibling.attempt_id, + member_identifier: 3, + }) + .expect("unsigned sibling creates live nonces"); + + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment.clone(), + ], + ); + let round2 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("member 1 round 2 share"); + let member2_share = sign_share(SignShareRequest { + signing_package_hex: signing_package_hex.clone(), + nonces_hex: member2.nonces_hex, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 share"); + + let aggregate_request = InteractiveAggregateRequest { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + signing_package_hex, + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: round2.signature_share_hex, + }, + member2_share.signature_share, + ], + taproot_merkle_root_hex: None, + }; + let aggregated_marker = + interactive_aggregated_marker(&opened.attempt_id, &hash_hex(&message), None); + + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let faulted = interactive_aggregate(aggregate_request.clone()) + .expect_err("post-rename persist fault must report aggregate failure"); + clear_persist_fault_injection_for_tests(); + assert!( + matches!(faulted, EngineError::Internal(ref text) if text.contains("injected persist fault")), + "unexpected error: {faulted:?}" + ); + + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!(session + .aggregated_interactive_attempt_markers + .contains(&aggregated_marker)); + assert!( + !session.interactive_signing.contains_key(&3), + "a retained completion marker must destroy an unsigned sibling's live nonces" + ); + } + assert!(interactive_aggregate_persistence_pending( + session_id, + &aggregated_marker + )); + + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let failed_flush = interactive_aggregate(aggregate_request.clone()) + .expect_err("a failed pending completion flush must not reach the completion gate"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + failed_flush, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + assert!(interactive_aggregate_persistence_pending( + session_id, + &aggregated_marker + )); + + // Crash before a successful in-process repair. Reload must retain the + // completion marker and cannot resurrect the unsigned sibling's nonces. + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + { + let guard = state().expect("state").lock().expect("lock"); + assert!(guard.sessions[session_id] + .aggregated_interactive_attempt_markers + .contains(&aggregated_marker)); + assert!(guard.sessions[session_id].interactive_signing.is_empty()); + } + + let retry = interactive_aggregate(aggregate_request) + .expect_err("restart retry rejects the durable completed attempt"); + assert!( + matches!( + retry, + EngineError::InteractiveAttemptAlreadyAggregated { .. } + ), + "unexpected retry error: {retry:?}" + ); + assert!(!interactive_aggregate_persistence_pending( + session_id, + &aggregated_marker + )); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn interactive_aggregate_post_rename_repair_retires_session_and_releases_capacity() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("interactive_aggregate_post_rename"); + reset_for_tests(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "2"); + + let wallet_session_id = "interactive-aggregate-post-rename-wallet"; + let session_id = "interactive-aggregate-post-rename"; + let next_session_id = "interactive-aggregate-post-rename-next"; + let key_group = "interactive-test-key-group"; + let message = [0x50u8; 32]; + let included = [1u16, 2, 3]; + let key_packages = ensure_interactive_dkg_session(wallet_session_id, key_group); + build_taproot_tx(build_policy_test_request(session_id)) + .expect("Build persists the cross-session policy shell"); + + let open_member = |member_identifier| { + interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + session_id, key_group, &message, &included, 1, + ), + }) + }; + let opened = open_member(1).expect("member 1 opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 round 1"); + + let sibling = open_member(3).expect("unsigned sibling opens"); + assert_eq!(sibling.attempt_id, opened.attempt_id); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: sibling.attempt_id, + member_identifier: 3, + }) + .expect("unsigned sibling creates live nonces"); + + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment.clone(), + ], + ); + let round2 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("member 1 round 2 share"); + let member2_share = sign_share(SignShareRequest { + signing_package_hex: signing_package_hex.clone(), + nonces_hex: member2.nonces_hex, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 share"); + + let aggregate_request = InteractiveAggregateRequest { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + signing_package_hex, + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: round2.signature_share_hex, + }, + member2_share.signature_share, + ], + taproot_merkle_root_hex: None, + }; + let aggregated_marker = + interactive_aggregated_marker(&opened.attempt_id, &hash_hex(&message), None); + + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let faulted = interactive_aggregate(aggregate_request.clone()) + .expect_err("post-rename persist fault must report aggregate failure"); + clear_persist_fault_injection_for_tests(); + assert!( + matches!(faulted, EngineError::Internal(ref text) if text.contains("injected persist fault")), + "unexpected error: {faulted:?}" + ); + + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!(session + .aggregated_interactive_attempt_markers + .contains(&aggregated_marker)); + assert!( + !session.interactive_signing.contains_key(&3), + "a retained completion marker must destroy an unsigned sibling's live nonces" + ); + } + assert!(interactive_aggregate_persistence_pending( + session_id, + &aggregated_marker + )); + + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let failed_flush = interactive_aggregate(aggregate_request.clone()) + .expect_err("a failed pending completion flush must not reach the completion gate"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + failed_flush, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + assert!(interactive_aggregate_persistence_pending( + session_id, + &aggregated_marker + )); + + // A different successful full-state writer is allowed to cover and clear + // the Aggregate repair. Retirement must already be staged so this unrelated + // snapshot cannot clear pending while leaving an active, nonce-free shell. + build_taproot_tx(build_policy_test_request(wallet_session_id)) + .expect("an unrelated wallet Build persists the full engine snapshot"); + assert!(!interactive_aggregate_persistence_pending( + session_id, + &aggregated_marker + )); + + // With pending already cleared by the unrelated writer, the exact retry + // still rejects the completed attempt and must not duplicate its marker. + let retry = interactive_aggregate(aggregate_request) + .expect_err("in-process retry rejects the completed attempt"); + assert!( + matches!( + retry, + EngineError::InteractiveAttemptAlreadyAggregated { .. } + ), + "unexpected retry error: {retry:?}" + ); + { + let guard = state().expect("state").lock().expect("lock"); + let session = &guard.sessions[session_id]; + assert!(session + .aggregated_interactive_attempt_markers + .contains(&aggregated_marker)); + assert_eq!(session.aggregated_interactive_attempt_markers.len(), 1); + assert!(session.interactive_signing.is_empty()); + assert!( + session.retired_interactive_at_unix.is_some(), + "repair must retire the completed per-message session immediately" + ); + assert_eq!(active_session_count(&guard.sessions), 1); + } + build_taproot_tx(build_policy_test_request(next_session_id)) + .expect("the repaired Aggregate session yields its shared registry slot"); + { + let guard = state().expect("state").lock().expect("lock"); + assert!(guard.sessions.contains_key(wallet_session_id)); + assert!(guard.sessions.contains_key(next_session_id)); + assert!(!guard.sessions.contains_key(session_id)); + assert_eq!(guard.sessions.len(), 2); + } + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn interactive_aggregate_rejects_invalid_share_fail_closed() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-aggregate-blame"; + let key_group = "interactive-test-key-group"; + let message = [0x4bu8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + let round2 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("round 2 share"); + + // Member 2 contributes a structurally valid but WRONG share (a fresh + // share over a different signing package). Aggregation must fail with + // attributable blame naming member 2, not an opaque error. + let bogus_member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("bogus member 2 nonces"); + let other_message = [0x4cu8; 32]; + let other_package = interactive_package_for_test( + &other_message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&2].identifier.clone(), + data_hex: bogus_member2.commitment.data_hex.clone(), + }, + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: bogus_member2.commitment.data_hex, + }, + ], + ); + let bogus_share = sign_share(SignShareRequest { + signing_package_hex: other_package, + nonces_hex: bogus_member2.nonces_hex, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("bogus member 2 share"); + + let err = interactive_aggregate(InteractiveAggregateRequest { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + signing_package_hex, + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: round2.signature_share_hex, + }, + bogus_share.signature_share, + ], + taproot_merkle_root_hex: None, + }) + .expect_err("an invalid share must fail aggregation closed"); + // 7.2b-3: the aggregate now fails closed WITH attributable CANDIDATE blame. + // Member 2 submitted a structurally valid share over a different package, so + // its share fails verification against the group's verifying material and is + // named a candidate culprit (its u16 Go member id); member 1's honest share + // is not. The engine surfaces candidates only - envelope-bound adjudication + // is the Go host's job (frozen Phase 7.2b spec, section 6). + let candidate_culprits = match err { + EngineError::AggregateShareVerificationFailed { + candidate_culprits, .. + } => candidate_culprits, + other => panic!("expected AggregateShareVerificationFailed, got {other:?}"), + }; + assert_eq!( + candidate_culprits, + vec![2], + "only the cheating member 2 must be named: {candidate_culprits:?}" + ); + let guard = state().expect("state").lock().expect("lock"); + assert_eq!( + Arc::strong_count(&guard.sessions[session_id].aggregate_eviction_pin), + 1, + "an Aggregate crypto error must release its transient eviction pin" + ); +} + +#[test] +fn frost_identifier_to_u16_inverts_participant_mapping() { + // The culprit list reports u16 Go member identifiers, so the inverse of + // participant_identifier_to_frost_identifier must round-trip - including + // across the low/high byte boundary (255 -> 256). + for id in [1u16, 2, 3, 255, 256, 65535] { + let identifier = participant_identifier_to_frost_identifier(id).expect("identifier"); + assert_eq!(frost_identifier_to_u16(identifier), Some(id), "id {id}"); + } +} + +#[test] +fn interactive_aggregate_names_all_invalid_share_culprits() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-aggregate-multi-blame"; + let key_group = "interactive-test-key-group"; + let message = [0x5au8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + + // Both members of the threshold-2 signing subset cheat: each signs a + // DIFFERENT package, so both shares fail verification against the + // authoritative package. Aggregation must name BOTH (AllCheaters), not just + // the first cheater. (The signing package carries exactly `threshold` + // commitments, so a multi-culprit case needs every subset member to cheat.) + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + + let real1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 live authorization commitment"); + let real2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: real1.commitments_hex, + }, + real2.commitment.clone(), + ], + ); + + // Each member signs a different (2-party) package over another message, so + // both shares fail verification against the authoritative package. + let other_message = [0x5bu8; 32]; + let bogus1 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&1].identifier.clone(), + key_package_hex: key_packages[&1].data_hex.clone(), + }) + .expect("bogus member 1 nonces"); + let bogus1_package = interactive_package_for_test( + &other_message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: bogus1.commitment.data_hex.clone(), + }, + NativeFrostCommitment { + identifier: key_packages[&2].identifier.clone(), + data_hex: bogus1.commitment.data_hex.clone(), + }, + ], + ); + let bogus1_share = sign_share(SignShareRequest { + signing_package_hex: bogus1_package, + nonces_hex: bogus1.nonces_hex, + key_package_identifier: key_packages[&1].identifier.clone(), + key_package_hex: key_packages[&1].data_hex.clone(), + }) + .expect("bogus member 1 share"); + let bogus2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("bogus member 2 nonces"); + let bogus2_package = interactive_package_for_test( + &other_message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&2].identifier.clone(), + data_hex: bogus2.commitment.data_hex.clone(), + }, + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: bogus2.commitment.data_hex.clone(), + }, + ], + ); + let bogus2_share = sign_share(SignShareRequest { + signing_package_hex: bogus2_package, + nonces_hex: bogus2.nonces_hex, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("bogus member 2 share"); + + let err = interactive_aggregate(InteractiveAggregateRequest { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + signing_package_hex, + signature_shares: vec![bogus1_share.signature_share, bogus2_share.signature_share], + taproot_merkle_root_hex: None, + }) + .expect_err("two invalid shares must fail aggregation closed"); + + let mut candidate_culprits = match err { + EngineError::AggregateShareVerificationFailed { + candidate_culprits, .. + } => candidate_culprits, + other => panic!("expected AggregateShareVerificationFailed, got {other:?}"), + }; + candidate_culprits.sort_unstable(); + // AllCheaters, not FirstCheater: BOTH cheating members are named. + assert_eq!(candidate_culprits, vec![1, 2], "{candidate_culprits:?}"); +} + +#[test] +fn interactive_aggregate_sweeps_expired_sessions() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_group = "interactive-test-key-group"; + let message = [0x4du8; 32]; + let included = [1u16, 2]; + + // An interactive attempt is opened + round-1'd on session A, then + // aged past the TTL. + let key_packages = ensure_interactive_dkg_session("interactive-aggregate-sweep-a", key_group); + let opened = open_interactive_for_test( + "interactive-aggregate-sweep-a", + key_group, + &message, + &included, + 1, + 1, + 2, + ) + .expect("session A opens"); + interactive_round1(InteractiveRound1Request { + session_id: "interactive-aggregate-sweep-a".to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + advance_interactive_clock_for_tests(interactive_session_ttl_seconds().saturating_add(1)); + + // A parseable threshold-sized package + share so the aggregate call + // reaches the lock and the sweep (it then fails on the missing + // target session). The package needs `threshold` (2) commitments for + // sign_share to produce a share. + let member1 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&1].identifier.clone(), + key_package_hex: key_packages[&1].data_hex.clone(), + }) + .expect("member 1 nonces"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let parseable_package = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: member1.commitment.data_hex.clone(), + }, + member2.commitment, + ], + ); + let parseable_share = sign_share(SignShareRequest { + signing_package_hex: parseable_package.clone(), + nonces_hex: member1.nonces_hex, + key_package_identifier: key_packages[&1].identifier.clone(), + key_package_hex: key_packages[&1].data_hex.clone(), + }) + .expect("member 1 share"); + + // Aggregate against a session that does not exist: the inputs parse, + // so the call reaches the lock and the sweep before failing with + // SessionNotFound. + let err = interactive_aggregate(InteractiveAggregateRequest { + session_id: "interactive-aggregate-sweep-missing".to_string(), + attempt_id: "00".repeat(32), + signing_package_hex: parseable_package, + signature_shares: vec![parseable_share.signature_share], + taproot_merkle_root_hex: None, + }) + .expect_err("aggregate against a missing session fails closed"); + assert!( + matches!(err, EngineError::SessionNotFound { .. }), + "unexpected error: {err:?}" + ); + + // The aggregate call's sweep must have cleared session A's expired + // nonce handle even though the call targeted a different session. + let guard = state().expect("state").lock().expect("lock"); + let session_a = guard + .sessions + .get("interactive-aggregate-sweep-a") + .expect("session A (DKG state) retained"); + assert!( + session_a.interactive_signing.is_empty(), + "an aggregate call must sweep expired interactive state in other sessions" + ); +} + +#[test] +fn lock_test_state_recovers_from_a_poisoned_mutex() { + // A test that panics while holding the test lock must not cascade + // into every subsequent test. Poison the lock from a child thread, + // then confirm lock_test_state still hands out the guard. + // + // The poisoning thread prints an "intentional poison" panic message + // (plus a backtrace note) to stderr - this is expected test output, + // not a failure. It is deliberately not suppressed with a panic + // hook: the hook is process-global, so silencing it here could + // swallow the message of a genuinely-failing test running in + // parallel. + let poisoner = std::thread::spawn(|| { + let _guard = lock_test_state(); + panic!("intentional poison to exercise lock recovery"); + }); + assert!( + poisoner.join().is_err(), + "poisoner thread was expected to panic" + ); + + // Recovers the guard despite the poison (would panic before the fix). + let _guard = lock_test_state(); +} + +#[test] +fn establish_clean_signer_test_env_clears_leaked_toggles() { + let _guard = lock_test_state(); + + // Simulate a prior test that set toggle vars and "panicked" before + // its own cleanup. The next lock acquisition's baseline reset must + // remove them. + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV, "true"); + std::env::set_var(TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV, "1"); + + establish_clean_signer_test_env(); + + assert!(std::env::var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV).is_err()); + assert!(std::env::var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV).is_err()); + assert!(std::env::var(TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV).is_err()); + + // The baseline the engine needs is re-established. + assert_eq!( + std::env::var(TBTC_SIGNER_PROFILE_ENV).as_deref(), + Ok(TBTC_SIGNER_PROFILE_DEVELOPMENT) + ); + assert_eq!( + std::env::var(TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV).as_deref(), + Ok(TEST_STATE_ENCRYPTION_KEY_HEX) + ); +} + +// Persisted structs hold serialized signing-share material in +// `SecretString` (`Zeroizing`) fields. `Debug` MUST NOT +// render that material: any future log/panic that `{:?}`-formats one +// of these structs would otherwise spill key shares or the signing +// message. Guards both the top-level field and the nested key-package +// vec. +#[test] +fn persisted_secret_structs_redact_debug_output() { + let secret_key_package = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + let secret_message = "cafebabecafebabecafebabecafebabe"; + + let key_package = PersistedKeyPackage { + identifier: 1, + key_package_hex: Zeroizing::new(secret_key_package.to_string()), + }; + let rendered = format!("{key_package:?}"); + assert!( + !rendered.contains(secret_key_package), + "PersistedKeyPackage Debug leaked key share material: {rendered}" + ); + + let mut session = persisted_session_state_fixture(); + session.sign_message_hex = Some(Zeroizing::new(secret_message.to_string())); + session.dkg_key_packages = Some(vec![key_package]); + let rendered = format!("{session:?}"); + assert!( + !rendered.contains(secret_message), + "PersistedSessionState Debug leaked sign message material: {rendered}" + ); + assert!( + !rendered.contains(secret_key_package), + "PersistedSessionState Debug leaked nested key share material: {rendered}" + ); +} + +// The open-request fingerprint serializes message_hex verbatim, so a +// retry of an identical open that only differs in message_hex casing +// must still be recognized as idempotent (the engine accepts hex +// case-insensitively elsewhere). Without canonicalization it would be +// rejected as a SessionConflict. +#[test] +fn interactive_session_open_is_idempotent_across_message_hex_casing() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-msg-hex-casing"; + let key_group = "interactive-msg-hex-casing-key-group"; + let message = [0x42u8; 32]; + let included = [1u16, 2]; + + let first = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("first interactive open succeeds"); + assert!( + !first.idempotent, + "a fresh open must not be reported as idempotent" + ); + + // Reopen with message_hex upper-cased; every other field (including + // the attempt context, which derives from the decoded bytes) is + // identical. This must be idempotent, not a SessionConflict. + ensure_interactive_dkg_session(session_id, key_group); + let attempt_context = + interactive_test_attempt_context(session_id, key_group, &message, &included, 1); + let reopened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: hex::encode(message).to_uppercase(), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context, + }) + .expect("re-cased reopen of an identical attempt must be accepted"); + assert!( + reopened.idempotent, + "re-cased message_hex reopen of an identical attempt must be idempotent" + ); +} + +// interactive_session_abort_success_total must count only aborts that +// actually destroyed live state. No-op calls (unknown session, or an +// attempt_id filter that matched nothing) still bump calls_total but +// must not inflate the success counter. +#[test] +fn interactive_session_abort_success_metric_counts_only_real_aborts() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-abort-metric"; + let key_group = "interactive-test-key-group"; + let message = [0x73u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + + // No-op: unknown session. + let noop = interactive_session_abort(InteractiveSessionAbortRequest { + session_id: "no-such-session".to_string(), + attempt_id: None, + }) + .expect("no-op abort still returns Ok"); + assert!(!noop.aborted); + + // No-op: right session, attempt_id filter that matches nothing. + let noop_filter = interactive_session_abort(InteractiveSessionAbortRequest { + session_id: session_id.to_string(), + attempt_id: Some("deadbeef".to_string()), + }) + .expect("filtered no-op abort returns Ok"); + assert!(!noop_filter.aborted); + + let after_noops = hardening_metrics(); + assert_eq!( + after_noops.interactive_session_abort_calls_total, 2, + "every abort entry, including no-ops, increments calls_total" + ); + assert_eq!( + after_noops.interactive_session_abort_success_total, 0, + "no-op aborts must not increment success_total" + ); + + // Real abort of the live attempt. + let aborted = interactive_session_abort(InteractiveSessionAbortRequest { + session_id: session_id.to_string(), + attempt_id: Some(opened.attempt_id.clone()), + }) + .expect("real abort"); + assert!(aborted.aborted); + + let after_real = hardening_metrics(); + assert_eq!(after_real.interactive_session_abort_calls_total, 3); + assert_eq!( + after_real.interactive_session_abort_success_total, 1, + "a real abort increments success_total exactly once" + ); +} + +// An absent optional request field must be omitted from the serialized +// form (not emitted as null), matching every other Option field in the +// API surface, and a payload that omits it must still deserialize. +#[test] +fn build_taproot_tx_request_omits_absent_script_tree_hex() { + let request = BuildTaprootTxRequest { + session_id: "s".to_string(), + inputs: vec![], + outputs: vec![], + script_tree_hex: None, + }; + let json = serde_json::to_string(&request).expect("serialize"); + assert!( + !json.contains("script_tree_hex"), + "absent script_tree_hex must be omitted, not serialized as null: {json}" + ); + + let round_trip: BuildTaprootTxRequest = + serde_json::from_str(r#"{"session_id":"s","inputs":[],"outputs":[]}"#) + .expect("a payload omitting script_tree_hex must deserialize"); + assert!(round_trip.script_tree_hex.is_none()); +} + +#[test] +fn verify_signature_share_verdicts_match_aggregate_and_handle_edges() { + use crate::api::ShareVerificationVerdict; + + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "verify-share-session"; + let key_group = "interactive-test-key-group"; + let message = [0x77u8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let member2_nonces = member2.nonces_hex.clone(); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + // Member 1's valid share (engine round2); member 2's valid share (stateless). + let round2 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("round 2 share"); + let member2_valid = sign_share(SignShareRequest { + signing_package_hex: signing_package_hex.clone(), + nonces_hex: member2_nonces, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 valid share"); + + // Member 2's INVALID share: validly signed over a DIFFERENT package. + let bogus_member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("bogus member 2 nonces"); + let other_message = [0x88u8; 32]; + let other_package = interactive_package_for_test( + &other_message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&2].identifier.clone(), + data_hex: bogus_member2.commitment.data_hex.clone(), + }, + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: bogus_member2.commitment.data_hex, + }, + ], + ); + let bogus_share = sign_share(SignShareRequest { + signing_package_hex: other_package, + nonces_hex: bogus_member2.nonces_hex, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("bogus member 2 share"); + + let verdict = |share_hex: String, member: u16| { + verify_signature_share(crate::api::VerifySignatureShareRequest { + session_id: session_id.to_string(), + signing_package_hex: signing_package_hex.clone(), + signature_share_hex: share_hex, + member_identifier: member, + taproot_merkle_root_hex: None, + }) + .expect("verify") + .verdict + }; + + // Valid shares verify Valid; the wrong-package share is Invalid. + assert_eq!( + verdict(round2.signature_share_hex.clone(), 1), + ShareVerificationVerdict::Valid + ); + assert_eq!( + verdict(member2_valid.signature_share.data_hex.clone(), 2), + ShareVerificationVerdict::Valid + ); + assert_eq!( + verdict(bogus_share.signature_share.data_hex.clone(), 2), + ShareVerificationVerdict::Invalid + ); + + // Undecodable member share bytes, WITH established context (member 2 is in + // this group, session ready) -> Invalid (self-incriminating member fault). + assert_eq!( + verdict("ee".to_string(), 2), + ShareVerificationVerdict::Invalid + ); + // A member with no verifying share in the group -> Indeterminate. + assert_eq!( + verdict(round2.signature_share_hex.clone(), 9), + ShareVerificationVerdict::Indeterminate + ); + // Ordering contract: undecodable share bytes for a member NOT in the group + // are Indeterminate, NOT Invalid - the share is only judged once session / + // DKG / membership are established, so blame never precedes that context. + assert_eq!( + verdict("ee".to_string(), 9), + ShareVerificationVerdict::Indeterminate + ); + // Package-membership contract: member 3 is in the GROUP (threshold 2 of {1,2,3}) + // but OMITTED from this attempt's package (commitments {1,2}). The package + // omission is coordinator/context input, so neither a decodable share NOR + // undecodable bytes may blame member 3 - both are Indeterminate, never Invalid. + assert_eq!( + verdict(round2.signature_share_hex.clone(), 3), + ShareVerificationVerdict::Indeterminate + ); + assert_eq!( + verdict("ee".to_string(), 3), + ShareVerificationVerdict::Indeterminate + ); + // Undecodable signing package (coordinator input) -> Indeterminate. + assert_eq!( + verify_signature_share(crate::api::VerifySignatureShareRequest { + session_id: session_id.to_string(), + signing_package_hex: "ee".to_string(), + signature_share_hex: round2.signature_share_hex.clone(), + member_identifier: 1, + taproot_merkle_root_hex: None, + }) + .expect("verify") + .verdict, + ShareVerificationVerdict::Indeterminate + ); + // Unknown session -> Indeterminate. + assert_eq!( + verify_signature_share(crate::api::VerifySignatureShareRequest { + session_id: "no-such-session".to_string(), + signing_package_hex: signing_package_hex.clone(), + signature_share_hex: round2.signature_share_hex.clone(), + member_identifier: 1, + taproot_merkle_root_hex: None, + }) + .expect("verify") + .verdict, + ShareVerificationVerdict::Indeterminate + ); + // Ordering contract: even undecodable share bytes for an unknown session are + // Indeterminate (session context is resolved before the share is judged). + assert_eq!( + verify_signature_share(crate::api::VerifySignatureShareRequest { + session_id: "no-such-session".to_string(), + signing_package_hex: signing_package_hex.clone(), + signature_share_hex: "ee".to_string(), + member_identifier: 1, + taproot_merkle_root_hex: None, + }) + .expect("verify") + .verdict, + ShareVerificationVerdict::Indeterminate + ); + // Malformed taproot root (coordinator/wallet context) -> Indeterminate, + // returned in-band, NOT escaped to the error channel (verdict contract). + assert_eq!( + verify_signature_share(crate::api::VerifySignatureShareRequest { + session_id: session_id.to_string(), + signing_package_hex: signing_package_hex.clone(), + signature_share_hex: round2.signature_share_hex.clone(), + member_identifier: 1, + taproot_merkle_root_hex: Some("not-hex".to_string()), + }) + .expect("malformed taproot root must not error out-of-band") + .verdict, + ShareVerificationVerdict::Indeterminate + ); + + // Equivalence guard: aggregate's AllCheaters verdict over [member 1 valid, + // member 2 bogus] must name exactly the share verify_signature_share calls + // Invalid (member 2), and not the one it calls Valid (member 1). + let err = interactive_aggregate(InteractiveAggregateRequest { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + signing_package_hex: signing_package_hex.clone(), + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: round2.signature_share_hex.clone(), + }, + bogus_share.signature_share, + ], + taproot_merkle_root_hex: None, + }) + .expect_err("an invalid share must fail aggregation closed"); + let candidate_culprits = match err { + EngineError::AggregateShareVerificationFailed { + candidate_culprits, .. + } => candidate_culprits, + other => panic!("expected AggregateShareVerificationFailed, got {other:?}"), + }; + assert_eq!( + candidate_culprits, + vec![2], + "aggregate's culprit verdict must match verify_signature_share: {candidate_culprits:?}" + ); +} + +// Script-path (tweaked-root) companion to the equivalence test above: the +// None-root case pins parity on the untweaked path; this pins it on the taproot +// tweak path, where the even-Y/tweak machinery is exercised. Shares are produced +// with sign_with_tweak (== sign under a taproot-tweaked key package, the +// production taproot signing path), and verify_signature_share / aggregate are +// driven with Some(root). The clinching assertion is that the SAME tweaked share +// is Invalid under None: the root must be materially applied, not ignored. +#[test] +fn verify_signature_share_tweaked_root_matches_aggregate() { + use crate::api::ShareVerificationVerdict; + + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "verify-share-tweaked-session"; + let key_group = "interactive-test-key-group"; + let message = [0x55u8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + + let taproot_merkle_root = [0x11u8; 32]; + let taproot_merkle_root_hex = hex::encode(taproot_merkle_root); + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: Some(taproot_merkle_root_hex.clone()), + signing_intent: None, + attempt_context: interactive_test_attempt_context( + session_id, key_group, &message, &included, 1, + ), + }) + .expect("opens with the tweaked root"); + let member1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 interactive nonces"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: member1.commitments_hex.clone(), + }, + member2.commitment.clone(), + ], + ); + + // Produce a TWEAKED round-2 share: sign_with_tweak signs under a + // taproot-tweaked key package, exactly the production taproot signing path. + let sign_tweaked = |key_package_hex: &str, nonces_hex: &str, package_hex: &str| -> String { + let key_package = frost::keys::KeyPackage::deserialize( + &hex::decode(key_package_hex).expect("key package hex"), + ) + .expect("key package"); + let nonces = frost::round1::SigningNonces::deserialize( + &hex::decode(nonces_hex).expect("nonces hex"), + ) + .expect("nonces"); + let package = + frost::SigningPackage::deserialize(&hex::decode(package_hex).expect("package hex")) + .expect("signing package"); + let share = frost::round2::sign_with_tweak( + &package, + &nonces, + &key_package, + Some(taproot_merkle_root.as_slice()), + ) + .expect("sign_with_tweak"); + hex::encode(share.serialize()) + }; + + let share1 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("member 1 tweaked Round2 share") + .signature_share_hex; + let share2 = sign_tweaked( + key_packages[&2].data_hex.expose_secret(), + &member2.nonces_hex, + &signing_package_hex, + ); + + let verdict = |share_hex: String, member: u16, root: Option| { + verify_signature_share(crate::api::VerifySignatureShareRequest { + session_id: session_id.to_string(), + signing_package_hex: signing_package_hex.clone(), + signature_share_hex: share_hex, + member_identifier: member, + taproot_merkle_root_hex: root, + }) + .expect("verify") + .verdict + }; + + // Each tweaked share verifies Valid UNDER THE ROOT... + assert_eq!( + verdict(share1.clone(), 1, Some(taproot_merkle_root_hex.clone())), + ShareVerificationVerdict::Valid + ); + assert_eq!( + verdict(share2.clone(), 2, Some(taproot_merkle_root_hex.clone())), + ShareVerificationVerdict::Valid + ); + // ...and the SAME tweaked share is Invalid WITHOUT the root: the taproot + // tweak is materially applied to the verifying material, not ignored. (This + // is what makes the Some(root) Valid verdicts meaningful.) + assert_eq!( + verdict(share1.clone(), 1, None), + ShareVerificationVerdict::Invalid + ); + + // A bogus tweaked share for member 2: validly signed (with the root) over a + // DIFFERENT package, so it fails verification against this package. + let other_message = [0x66u8; 32]; + let bogus_member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("bogus member 2 nonces"); + let other_package_hex = interactive_package_for_test( + &other_message, + vec![ + bogus_member2.commitment.clone(), + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: member1.commitments_hex, + }, + ], + ); + let bogus_share2 = sign_tweaked( + key_packages[&2].data_hex.expose_secret(), + &bogus_member2.nonces_hex, + &other_package_hex, + ); + assert_eq!( + verdict( + bogus_share2.clone(), + 2, + Some(taproot_merkle_root_hex.clone()) + ), + ShareVerificationVerdict::Invalid + ); + + // Equivalence on the TWEAKED path: aggregate's AllCheaters verdict over + // [member 1 valid, member 2 bogus] with the same root must name exactly the + // share verify_signature_share calls Invalid (member 2), and not member 1. + let err = interactive_aggregate(InteractiveAggregateRequest { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + signing_package_hex: signing_package_hex.clone(), + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: share1.clone(), + }, + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&2].identifier.clone(), + data_hex: bogus_share2, + }, + ], + taproot_merkle_root_hex: Some(taproot_merkle_root_hex), + }) + .expect_err("an invalid tweaked share must fail aggregation closed"); + let candidate_culprits = match err { + EngineError::AggregateShareVerificationFailed { + candidate_culprits, .. + } => candidate_culprits, + other => panic!("expected AggregateShareVerificationFailed, got {other:?}"), + }; + assert_eq!( + candidate_culprits, + vec![2], + "tweaked aggregate culprit must match verify_signature_share: {candidate_culprits:?}" + ); +} + +// Migrated from the deleted run_dkg_rejects_when_signed_attestation_status_mismatches_env: +// the coarse dealer run_dkg was removed, so drive the PRESERVED shared provenance gate +// directly. A validly signed attestation whose payload status disagrees with the required +// env status must be rejected with reason_code "attestation_status_mismatch". +#[test] +fn enforce_provenance_gate_rejects_signed_attestation_status_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( + "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 = enforce_provenance_gate().expect_err("expected status mismatch rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant: {err:?}"); + }; + assert_eq!(reason_code, "attestation_status_mismatch"); + + clear_state_storage_policy_overrides(); +} + +// Migrated from the deleted run_dkg_rejects_when_signed_attestation_runtime_version_mismatch: +// a validly signed, status-APPROVED attestation whose runtime_version disagrees with the +// build's TBTC_SIGNER_RUNTIME_VERSION must be rejected with "runtime_version_not_attested". +#[test] +fn enforce_provenance_gate_rejects_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 = enforce_provenance_gate().expect_err("expected runtime version mismatch rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant: {err:?}"); + }; + assert_eq!(reason_code, "runtime_version_not_attested"); + + clear_state_storage_policy_overrides(); +} diff --git a/pkg/tbtc/signer/src/engine/testsupport.rs b/pkg/tbtc/signer/src/engine/testsupport.rs new file mode 100644 index 0000000000..b0fc7b2b52 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/testsupport.rs @@ -0,0 +1,132 @@ +// Cross-module test helpers (cfg(test)): state lock, reset, restart simulation. + +use super::*; + +#[cfg(test)] +pub(crate) static TEST_MUTEX: OnceLock> = OnceLock::new(); + +#[cfg(test)] +pub fn lock_test_state() -> std::sync::MutexGuard<'static, ()> { + // Recover from poisoning rather than propagating it. The guarded + // value is `()` - the mutex only serializes tests, it protects no + // invariant - so a test that panics while holding it leaves nothing + // corrupt. Without this, that panic poisons the mutex and every + // subsequent test's lock acquisition panics too, turning one real + // failure into a cascade of dozens that masks the original (and even + // makes proptest record spurious regression seeds). Each test calls + // reset_for_tests() next to clear engine state. + let guard = TEST_MUTEX + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + establish_clean_signer_test_env(); + guard +} + +// Reset the process to a clean, hermetic TBTC_SIGNER_* environment at +// every test-lock acquisition. Any TBTC_SIGNER_* var a prior test set +// is removed - even if that test panicked before its own cleanup ran - +// so one test's `set_var` (firewall/quarantine/cap/policy toggles, etc.) +// cannot leak into the next locked test. The three baseline vars the +// engine needs to function in tests are then re-established (profile, +// state-key provider, state-encryption key); tests that need a +// production profile or other toggles set them explicitly after taking +// the lock. This centralizes leak-prevention so individual tests can use +// raw set_var without per-site teardown. +#[cfg(test)] +pub(crate) fn establish_clean_signer_test_env() { + // Iterate with vars_os, not vars: std::env::vars panics if ANY env + // var in the process (name or value) is not valid UTF-8 - even one + // unrelated to the signer - which would abort every locked test in + // such an environment. TBTC_SIGNER_* names are ASCII, so a key that + // fails to_str cannot be one of ours. + for (key_os, _) in std::env::vars_os() { + if let Some(key) = key_os.to_str() { + if key.starts_with("TBTC_SIGNER_") { + std::env::remove_var(&key_os); + } + } + } + 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, + ); +} + +#[cfg(test)] +pub fn reset_for_tests() { + reset_interactive_clock_for_tests(); + clear_installed_signer_config_for_tests(); + clear_persist_fault_injection_for_tests(); + clear_persistence_pending_operations(); + 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 = PolicyRateLimiterState::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() { + clear_persistence_pending_operations(); + 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() { + clear_persistence_pending_operations(); + if let Ok(mut telemetry) = hardening_telemetry_state().lock() { + *telemetry = HardeningTelemetryState::default(); + } + 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(); + } + } +} diff --git a/pkg/tbtc/signer/src/engine/transaction.rs b/pkg/tbtc/signer/src/engine/transaction.rs new file mode 100644 index 0000000000..e38f9066e9 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/transaction.rs @@ -0,0 +1,318 @@ +// Taproot transaction building. + +use super::*; + +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()))?; + + // A post-replacement failure left the accepted artifact fail-closed in + // memory and on the replacement state file, but directory durability is + // unconfirmed. Flush that exact state before either the cache-hit return or + // a conflicting request is considered. + if let Some(pending_operation) = pending_build_taproot_tx_operation(&request.session_id) { + persist_engine_state_to_storage(&guard) + .map_err(PersistEngineStateError::into_engine_error)?; + clear_persistence_pending_operation(&pending_operation); + } + + 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, + }); + } + } + // Capacity rejection must precede policy rate-limit consumption. This is a + // read-only preflight; the transactional reservation still occurs after all + // request validation, immediately before the durable mutation. + ensure_session_insert_admission_capacity(&guard, &request.session_id)?; + // BuildTaprootTx is an assembly-only step. Input prevout values and scripts + // are trusted caller-supplied metadata and are not verified against chain + // state. Both are nevertheless required because BIP-341 SIGHASH_DEFAULT + // commits to the ordered prevout TxOuts for every input. + let mut total_input_value_sats = 0u64; + let mut seen_input_keys = HashSet::new(); + let mut inputs = Vec::with_capacity(request.inputs.len()); + let mut prevouts = 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, + }; + + let script_pubkey_bytes = hex::decode(&input.script_pubkey_hex).map_err(|_| { + EngineError::Validation(format!( + "invalid input script_pubkey_hex [{}]", + input.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 input script_pubkey_hex [{}]: {script_error}", + input.script_pubkey_hex + ))); + } + if !script_pubkey.is_p2tr() { + return Err(EngineError::Validation(format!( + "input script_pubkey_hex [{}] is not a P2TR prevout", + input.script_pubkey_hex + ))); + } + + inputs.push(TxIn { + previous_output, + script_sig: ScriptBuf::new(), + // Use final sequence for deterministic non-RBF transaction assembly. + sequence: Sequence::MAX, + witness: Witness::new(), + }); + prevouts.push(TxOut { + value: Amount::from_sat(input.value_sats), + script_pubkey, + }); + } + + 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 + ))); + } + if let Err(error) = + enforce_signing_policy_firewall(&request.session_id, &outputs, total_output_value_sats) + { + if matches!(error, EngineError::SigningPolicyRejected { .. }) { + record_canary_policy_outcome(true); + } + return Err(error); + } + // Count only first-time artifact decisions. Cache hits are intentionally + // excluded so cheap idempotent replays cannot dilute the rolling policy + // rejection rate used by promotion control. + record_canary_policy_outcome(false); + + let tx = Transaction { + // Match the Go host TransactionBuilder's canonical unsigned transaction. + // Transaction-version drift changes every BIP-341 sighash and makes the + // policy artifact unusable even when all inputs and outputs agree. + version: Version::ONE, + lock_time: LockTime::ZERO, + input: inputs, + output: outputs, + }; + + let taproot_key_spend_sighashes_hex = policy_bound_signing_messages_hex(&tx, &prevouts)?; + + let result = TransactionResult { + session_id: request.session_id, + tx_hex: serialize_hex(&tx), + taproot_key_spend_sighashes_hex, + }; + + // BuildTaprootTx is keyed into the shared session namespace for idempotency + // caching only; this session entry may intentionally not have DKG/signing + // state populated. + let build_session_id = result.session_id.clone(); + // All request and policy validation is complete. Reserve the durable + // session slot now so a rejected request cannot evict a replay tombstone. + let compacted_retired_sessions = ensure_session_insert_capacity(&mut guard, &build_session_id)?; + let accepted_request_fingerprint = request_fingerprint.clone(); + let previous_build_state = guard.sessions.get(&build_session_id).map(|session| { + ( + session.build_tx_request_fingerprint.clone(), + session.tx_result.clone(), + ) + }); + let session = guard + .sessions + .entry(build_session_id.clone()) + .or_insert_with(SessionState::default); + session.build_tx_request_fingerprint = Some(request_fingerprint); + session.tx_result = Some(result.clone()); + if let Err(persist_error) = persist_engine_state_to_storage(&guard) { + let state_file_replaced = persist_error.state_file_replaced(); + let persist_error = persist_error.into_engine_error(); + if state_file_replaced { + mark_persistence_pending(PersistencePendingOperation::BuildTaprootTx { + session_id: build_session_id, + request_fingerprint: accepted_request_fingerprint, + }); + } else if let Some((previous_fingerprint, previous_result)) = previous_build_state { + let session = guard.sessions.get_mut(&build_session_id).ok_or_else(|| { + EngineError::Internal(format!( + "build transaction session [{build_session_id}] disappeared while rolling back a failed persist: {persist_error}" + )) + })?; + session.build_tx_request_fingerprint = previous_fingerprint; + session.tx_result = previous_result; + } else { + guard.sessions.remove(&build_session_id); + } + if !state_file_replaced { + restore_compacted_retired_sessions(&mut guard, compacted_retired_sessions); + } + return Err(persist_error); + } + record_hardening_telemetry(|telemetry| { + telemetry.build_taproot_tx_success_total = + telemetry.build_taproot_tx_success_total.saturating_add(1); + }); + + Ok(result) +} diff --git a/pkg/tbtc/signer/src/engine/verify_share.rs b/pkg/tbtc/signer/src/engine/verify_share.rs new file mode 100644 index 0000000000..a62bcd97ec --- /dev/null +++ b/pkg/tbtc/signer/src/engine/verify_share.rs @@ -0,0 +1,193 @@ +// Phase 7.2b-4: single round-2 signature-share verification. +// +// Backs the Go host's Round2ShareVerifier (member-blame classifier, 4a). Given +// one retained share + the attempt's signing package, it answers whether the +// share verifies against the group's own (taproot-tweaked) verifying material - +// pure FROST share verification, no envelope/operator-signature inspection +// (that is the Go layer's job; frozen Q1 boundary). +// +// As with InteractiveAggregate's candidate culprits, an `Invalid` verdict is +// framable: this endpoint verifies the share against WHATEVER package/root the +// caller supplies, so a coordinator that verifies an honest share against a +// mismatched package/root would get `Invalid`. The engine cannot bind these +// public inputs to what the member signed at Round2; authoritative, +// envelope-bound blame is the Go host's job at an f+1 accuser quorum (frozen +// Phase 7.2b spec, section 6). This verdict is that adjudication's INPUT. +// +// It returns an explicit tri-state verdict (Valid / Invalid / Indeterminate), +// not a pass/fail + error: only the engine can distinguish a member's malformed +// signed scalar (blame) from a malformed package/context (don't blame), so it +// makes that call here rather than forcing the Go host to infer it from an error +// string. The verifying material + taproot tweak are resolved EXACTLY as +// InteractiveAggregate does (same session DKG package, same +// canonicalize_taproot_merkle_root_hex, same `.tweak()`), so the verdict matches +// what aggregation would conclude for the same share - pinned by the +// standalone-vs-aggregate equivalence tests. + +use super::*; + +use crate::api::{ + ShareVerificationVerdict, VerifySignatureShareRequest, VerifySignatureShareResult, +}; + +fn verdict(v: ShareVerificationVerdict) -> VerifySignatureShareResult { + VerifySignatureShareResult { verdict: v } +} + +pub fn verify_signature_share( + request: VerifySignatureShareRequest, +) -> Result { + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + // An undecodable signing package is COORDINATOR-authored input, not the + // member's fault -> indeterminate (never blame the member for it). + let signing_package_bytes = match decode_hex_field( + "VerifySignatureShare", + "signing_package_hex", + &request.signing_package_hex, + ) { + Ok(bytes) => bytes, + Err(_) => return Ok(verdict(ShareVerificationVerdict::Indeterminate)), + }; + let signing_package = match frost::SigningPackage::deserialize(&signing_package_bytes) { + Ok(package) => package, + Err(_) => return Ok(verdict(ShareVerificationVerdict::Indeterminate)), + }; + + // Canonicalize + apply the taproot root EXACTLY as InteractiveAggregate (the + // tweak path must not drift; None vs Some([]) must resolve identically). A + // malformed root (non-hex / not 32 bytes) is COORDINATOR/wallet-context + // input, never the member's signed-share fault, so it returns an in-band + // Indeterminate verdict rather than escaping to the FFI error channel: the + // contract is "a tri-state verdict for every input", so the Go host never + // has to infer "don't blame" from an error code. + let mut taproot_merkle_root_hex = request.taproot_merkle_root_hex.clone(); + let taproot_merkle_root = + match canonicalize_taproot_merkle_root_hex(&mut taproot_merkle_root_hex) { + Ok(root) => root, + Err(_) => return Ok(verdict(ShareVerificationVerdict::Indeterminate)), + }; + + let member_identifier = + match participant_identifier_to_frost_identifier(request.member_identifier) { + Ok(identifier) => identifier, + // An out-of-range member id is a caller/package issue, not the + // member's signed-share fault. + Err(_) => return Ok(verdict(ShareVerificationVerdict::Indeterminate)), + }; + + // Resolve the group's public key package from the session's own DKG state - + // never the request - mirroring InteractiveAggregate. A missing session or + // incomplete DKG is not the member's fault -> indeterminate. + let public_key_package = { + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + // Verify-share takes the engine lock like every other interactive entry + // point, so it sweeps expired interactive state too: the nonce-TTL + // guarantee (an abandoned interactive nonce handle gone within the TTL of + // inactivity) must hold even when the only post-expiry traffic is + // verify-share blame rechecks. Mirrors InteractiveAggregate. + sweep_expired_interactive_state_durably(&mut guard)?; + // The public key package is a WALLET-level asset resolved by key_group, so a + // per-signing session (a distinct RoastSessionID) can be blame-checked. The + // key_group is this signing session's own DKG (co-located) or the one bound at + // Open; a missing session/binding/DKG is not the member's fault -> indeterminate. + let key_group = match guard.sessions.get(&request.session_id) { + Some(session) => session + .dkg_result + .as_ref() + .map(|dkg| dkg.key_group.clone()) + .or_else(|| session.bound_key_group.clone()), + None => None, + }; + let key_group = match key_group { + Some(key_group) => key_group, + None => return Ok(verdict(ShareVerificationVerdict::Indeterminate)), + }; + let wallet_session_id = + match resolve_wallet_session_id(&guard, &request.session_id, &key_group) { + Some(id) => id, + None => return Ok(verdict(ShareVerificationVerdict::Indeterminate)), + }; + match guard + .sessions + .get(&wallet_session_id) + .and_then(|session| session.dkg_public_key_package.as_ref()) + { + Some(package) => package.clone(), + None => return Ok(verdict(ShareVerificationVerdict::Indeterminate)), + } + }; + + // Same tweak expression as InteractiveAggregate (strict input parity). + let verification_key_package = match taproot_merkle_root.as_ref() { + Some(root) => public_key_package.clone().tweak(Some(root.as_slice())), + None => public_key_package.clone(), + }; + + let verifying_share = match verification_key_package + .verifying_shares() + .get(&member_identifier) + { + Some(verifying_share) => verifying_share, + // No verifying share in this GROUP (the member never received a DKG + // share) - a coordinator/caller matter, not member-share fault. + None => return Ok(verdict(ShareVerificationVerdict::Indeterminate)), + }; + + // The member must ALSO be a participant in THIS attempt's signing package + // (its commitment set), not merely a group member. A package that omits the + // member is coordinator/context input - the member never signed a share for + // a package they are not in - so undecodable share bytes paired with such a + // package must NOT read as self-incriminating. Without this guard the + // omitted-member + undecodable-share case would hit the Invalid decode- + // failure branch below before frost_core's own UnknownIdentifier check (which + // already maps a DECODABLE omitted-member share to Indeterminate) is reached. + // Fail closed against blame: Indeterminate. + if !signing_package + .signing_commitments() + .contains_key(&member_identifier) + { + return Ok(verdict(ShareVerificationVerdict::Indeterminate)); + } + + // Only now that the session, completed DKG, the member's group membership, + // AND the member's inclusion in this attempt's package are all established do + // we judge the member's signed share bytes: if they are undecodable here that + // is self-incriminating member fault -> invalid (the Go layer already + // authenticated the envelope; the inner FROST scalar is the member's). + // Decoding any earlier would let a malformed share for an unknown / not-ready + // session, a non-member id, or a package that omits the member return Invalid + // (blame) before the member context exists - it must be Indeterminate then. + let signature_share_bytes = match decode_hex_field( + "VerifySignatureShare", + "signature_share_hex", + &request.signature_share_hex, + ) { + Ok(bytes) => bytes, + Err(_) => return Ok(verdict(ShareVerificationVerdict::Invalid)), + }; + let signature_share = match frost::round2::SignatureShare::deserialize(&signature_share_bytes) { + Ok(share) => share, + Err(_) => return Ok(verdict(ShareVerificationVerdict::Invalid)), + }; + + match frost_core::verify_signature_share( + member_identifier, + verifying_share, + &signature_share, + &signing_package, + verification_key_package.verifying_key(), + ) { + Ok(()) => Ok(verdict(ShareVerificationVerdict::Valid)), + // The member's signed share fails the FROST verification equation. + Err(frost_core::Error::InvalidSignatureShare { .. }) => { + Ok(verdict(ShareVerificationVerdict::Invalid)) + } + // UnknownIdentifier / commitment / other: a package or context issue, not + // attributable member-share fault. + Err(_) => Ok(verdict(ShareVerificationVerdict::Indeterminate)), + } +} diff --git a/pkg/tbtc/signer/src/errors.rs b/pkg/tbtc/signer/src/errors.rs new file mode 100644 index 0000000000..e82e333162 --- /dev/null +++ b/pkg/tbtc/signer/src/errors.rs @@ -0,0 +1,262 @@ +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( + "cryptographic share refresh is not supported for session {session_id}: a multi-round, zero-constant FROST refresh protocol is required" + )] + CryptographicRefreshNotSupported { 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 interactive attempt whose nonce handle was already + /// consumed (a signature share was released, or release was durably + /// committed) is touched again - a second Round2 with the same handle, + /// or Round1/SessionOpen for a consumed attempt. The caller must mint a + /// new attempt; the engine will never release a second share under one + /// nonce pair (frozen Phase 7 spec, section 4). + #[error( + "interactive attempt [{attempt_id}] already consumed its nonces in session [{session_id}]" + )] + ConsumedNonceReplay { + session_id: String, + attempt_id: String, + }, + /// Returned when InteractiveAggregate is invoked again for an attempt that + /// already produced an aggregate signature in this session. The per-attempt + /// "aggregated" marker is durable, so a completed attempt stays completed + /// across restart; re-aggregation is rejected rather than recomputed + /// (a lost signature is recovered with a fresh attempt, not by replay). + /// Distinct code so callers match on + /// `interactive_attempt_already_aggregated` rather than the message. + #[error("interactive attempt [{attempt_id}] already aggregated in session [{session_id}]")] + InteractiveAttemptAlreadyAggregated { + session_id: String, + attempt_id: String, + }, + /// Returned when InteractiveAggregate fails because one or more signature + /// shares did not verify against the (tweaked) group verifying material. + /// Unlike the generic `Validation` failure, this carries the FROST-identified + /// CANDIDATE culprits as u16 Go member identifiers - every member whose share + /// failed, via `CheaterDetection::AllCheaters` - so the Go host can feed them + /// into envelope-bound blame adjudication. CANDIDATES, not a verdict: the + /// engine verifies pure FROST shares against the group's own verifying + /// material and never inspects operator-signed envelopes (frozen Q1 + /// boundary); a coordinator that aggregated honest shares against a + /// substituted package or root would make those honest shares appear here. + /// Authoritative blame is the Go host's at an f+1 accuser quorum. Fail-closed: + /// no signature is produced. Distinct code so callers match on + /// `aggregate_share_verification_failed` rather than the message. + #[error( + "InteractiveAggregate: {} signature share(s) failed verification for attempt [{attempt_id}] in session [{session_id}]", + candidate_culprits.len() + )] + AggregateShareVerificationFailed { + session_id: String, + attempt_id: String, + candidate_culprits: Vec, + }, + #[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::CryptographicRefreshNotSupported { .. } => "cryptographic_refresh_not_supported", + 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::ConsumedNonceReplay { .. } => "consumed_nonce_replay", + Self::InteractiveAttemptAlreadyAggregated { .. } => { + "interactive_attempt_already_aggregated" + } + Self::AggregateShareVerificationFailed { .. } => "aggregate_share_verification_failed", + 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::CryptographicRefreshNotSupported { .. } => "terminal", + Self::SessionConflict { .. } => "recoverable", + Self::DkgNotReady { .. } => "recoverable", + Self::SignRoundNotStarted { .. } => "recoverable", + // ConsumedNonceReplay is recoverable in the sense that a fresh + // attempt with a new identifier can be started. It cannot be + // retried with the same identifier — the consumer (keep-core) + // treats it as a signal to mint a new attempt_id rather than + // retransmit. + Self::ConsumedNonceReplay { .. } => "recoverable", + // The aggregate is deterministic over public data and the attempt + // is durably marked complete; a re-aggregation request is a benign + // duplicate the caller should not retry, not an engine fault. + Self::InteractiveAttemptAlreadyAggregated { .. } => "recoverable", + // A fresh attempt that excludes the candidate culprits can still + // produce a signature, so this is recoverable: the caller mints a + // new attempt after the Go host adjudicates blame. + Self::AggregateShareVerificationFailed { .. } => "recoverable", + Self::SessionFinalized { .. } => "terminal", + Self::SessionNotFound { .. } => "terminal", + Self::Internal(_) => "terminal", + } + } + + /// The CANDIDATE culprits carried by this error. Non-empty only for + /// `AggregateShareVerificationFailed`; empty for every other variant. The + /// FFI layer uses this to surface the list to the Go host without matching + /// the variant inline. + pub fn candidate_culprits(&self) -> &[u16] { + match self { + Self::AggregateShareVerificationFailed { + candidate_culprits, .. + } => candidate_culprits, + _ => &[], + } + } +} + +#[cfg(test)] +mod tests { + use super::EngineError; + + #[test] + fn interactive_attempt_already_aggregated_has_stable_code_and_message_format() { + let err = EngineError::InteractiveAttemptAlreadyAggregated { + session_id: "session-a".to_string(), + attempt_id: "attempt-1".to_string(), + }; + assert_eq!(err.code(), "interactive_attempt_already_aggregated"); + assert_eq!(err.recovery_class(), "recoverable"); + assert_eq!( + err.to_string(), + "interactive attempt [attempt-1] already aggregated 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" + ); + let unsupported_refresh = EngineError::CryptographicRefreshNotSupported { + session_id: "session-refresh".to_string(), + }; + assert_eq!( + unsupported_refresh.code(), + "cryptographic_refresh_not_supported" + ); + assert_eq!(unsupported_refresh.recovery_class(), "terminal"); + assert!(unsupported_refresh + .to_string() + .contains("multi-round, zero-constant FROST refresh protocol")); + } + + #[test] + fn aggregate_share_verification_failed_code_message_and_culprits() { + let err = EngineError::AggregateShareVerificationFailed { + session_id: "session-a".to_string(), + attempt_id: "attempt-1".to_string(), + candidate_culprits: vec![2, 3], + }; + assert_eq!(err.code(), "aggregate_share_verification_failed"); + assert_eq!(err.recovery_class(), "recoverable"); + // The count is rendered; the member identifiers travel in the structured + // candidate_culprits list, not the message string. + assert_eq!( + err.to_string(), + "InteractiveAggregate: 2 signature share(s) failed verification for attempt [attempt-1] in session [session-a]", + ); + assert_eq!(err.candidate_culprits(), &[2, 3]); + + // Every non-aggregate error exposes no culprits. + assert!(EngineError::Validation("x".to_string()) + .candidate_culprits() + .is_empty()); + } +} diff --git a/pkg/tbtc/signer/src/ffi.rs b/pkg/tbtc/signer/src/ffi.rs new file mode 100644 index 0000000000..ac9567d078 --- /dev/null +++ b/pkg/tbtc/signer/src/ffi.rs @@ -0,0 +1,291 @@ +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use serde::de::DeserializeOwned; + +use zeroize::Zeroize; + +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}"))) +} + +// Install a panic hook that redacts the panic payload outside the development +// profile. `catch_unwind` does not suppress Rust's default hook, so a panic +// carrying a path / config value / secret would otherwise print verbatim to +// stderr before it is converted to a redacted ErrorResponse. Installed once. +fn install_redacting_panic_hook() { + static INSTALLED: std::sync::Once = std::sync::Once::new(); + INSTALLED.call_once(|| { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let development_profile = + crate::engine::signer_env_var(crate::engine::TBTC_SIGNER_PROFILE_ENV) + .map(|raw| { + raw.trim() + .eq_ignore_ascii_case(crate::engine::TBTC_SIGNER_PROFILE_DEVELOPMENT) + }) + .unwrap_or(false); + if development_profile { + default_hook(info); + } else if let Some(location) = info.location() { + eprintln!( + "panic at {}:{} (payload redacted)", + location.file(), + location.line() + ); + } else { + eprintln!("panic (payload redacted)"); + } + })); + }); +} + +pub fn ffi_entry(f: F) -> TbtcSignerResult +where + F: FnOnce() -> Result, EngineError>, +{ + install_redacting_panic_hook(); + match catch_unwind(AssertUnwindSafe(f)) { + Ok(Ok(bytes)) => success_from_serialized(bytes), + Ok(Err(err)) => error_result(err), + Err(payload) => error_result(EngineError::Internal(panic_boundary_message(payload))), + } +} + +// A panic crossing the FFI boundary must not reflect its raw payload to the +// host in production: the panic message could carry a filesystem path, a +// config value, or other internal detail. Only the development profile keeps +// the payload, for operator diagnostics. +// +// This reads the profile directly and fails CLOSED (redacts) for any +// non-development value - including a missing or malformed profile - rather +// than calling signer_profile_is_production(), which panics on a malformed +// profile. Re-validating the profile here could otherwise turn a handled +// panic into a second panic on the FFI error path and unwind into C. +fn panic_boundary_message(payload: Box) -> String { + let development_profile = crate::engine::signer_env_var(crate::engine::TBTC_SIGNER_PROFILE_ENV) + .map(|raw| { + raw.trim() + .eq_ignore_ascii_case(crate::engine::TBTC_SIGNER_PROFILE_DEVELOPMENT) + }) + .unwrap_or(false); + + if development_profile { + format!( + "panic crossed FFI boundary: {}", + panic_payload_message(payload) + ) + } else { + "panic crossed FFI boundary".to_string() + } +} + +pub fn free_buffer(ptr: *mut u8, len: usize) { + if ptr.is_null() || len == 0 { + return; + } + + unsafe { + let mut buffer = Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len)); + // Wipe any plaintext secret material (e.g. FROST nonces, DKG/key-package + // bytes) before deallocation rather than trusting every FFI caller to do + // it correctly. Leaking a nonce after a share is produced can expose the + // signing share. `zeroize` is a volatile wipe the optimizer cannot elide. + buffer.zeroize(); + drop(buffer); + } +} + +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(), + candidate_culprits: error.candidate_culprits().to_vec(), + }; + + 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(mut bytes: Vec) -> TbtcBuffer { + let len = bytes.len(); + if len == 0 { + return TbtcBuffer { + ptr: std::ptr::null_mut(), + len: 0, + }; + } + + // Copy into an exact-capacity boxed slice, then wipe the source Vec. + // `bytes.into_boxed_slice()` shrink-to-fits and reallocates when capacity > len + // (serde_json::to_vec over-allocates secret-bearing JSON), which would free the + // original secret buffer WITHOUT zeroizing it. free_buffer wipes the boxed + // slice on free; we wipe the source here so no un-zeroized copy survives. + let boxed: Box<[u8]> = bytes.as_slice().into(); + bytes.zeroize(); + 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}" + ); + } + + // A panic payload may carry internal detail (paths, config). It must be + // withheld from the host in production and only surfaced under the + // development profile. Serialized under the shared test-state lock because + // the profile is read from a process-global env var. + #[test] + fn ffi_entry_redacts_panic_payload_in_production_and_preserves_in_development() { + let _guard = crate::engine::lock_test_state(); + let secret_detail = "panic detail with /secret/path and a config value"; + + let decode_message = |result: &TbtcSignerResult| -> String { + 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"); + response.message + }; + + // Production (and any non-development profile): payload withheld. + std::env::set_var( + crate::engine::TBTC_SIGNER_PROFILE_ENV, + crate::engine::TBTC_SIGNER_PROFILE_PRODUCTION, + ); + let result = ffi_entry(|| -> Result, EngineError> { + panic!("{secret_detail}"); + }); + let message = decode_message(&result); + assert!( + !message.contains(secret_detail), + "production must not reflect the panic payload to the host: {message}" + ); + assert!( + message.contains("panic crossed FFI boundary"), + "production should still report a generic boundary panic: {message}" + ); + free_buffer(result.buffer.ptr, result.buffer.len); + + // Development: payload preserved for operator diagnostics. + std::env::set_var( + crate::engine::TBTC_SIGNER_PROFILE_ENV, + crate::engine::TBTC_SIGNER_PROFILE_DEVELOPMENT, + ); + let result = ffi_entry(|| -> Result, EngineError> { + panic!("{secret_detail}"); + }); + let message = decode_message(&result); + assert!( + message.contains(secret_detail), + "development must preserve the panic payload: {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..5c8fbde2e4 --- /dev/null +++ b/pkg/tbtc/signer/src/go_math_rand.rs @@ -0,0 +1,944 @@ +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"); + } + + int31n_fast_lemire(n, || self.uint32()) + } + + 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; + } + } +} + +// Lemire bounded reduction with rejection, mirroring Go `math/rand`'s +// internal `(*Rand).int31n` (the fast shuffle path) byte-for-byte: the +// rejection threshold `2^32 mod n` is computed lazily, only after a draw +// lands in `[0, n)`, then the loop redraws while `low < threshold`. The +// `next_u32` source is abstracted only so the rejection branch -- which +// the differential corpus statistically never reaches -- can be exercised +// by a forced-state unit test; `int31n_fast` always passes `self.uint32`. +fn int31n_fast_lemire(n: i32, mut next_u32: impl FnMut() -> u32) -> i32 { + let mut value = next_u32(); + 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 = next_u32(); + prod = u64::from(value) * u64::from(n as u32); + low = prod as u32; + } + } + + (prod >> 32) as i32 +} + +// 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()); + } + + // Drives the real bounded-reduction helper with a forced u32 stream so + // the rejection branch -- which the differential corpus statistically + // never reaches -- is pinned against Go math/rand's `(*Rand).int31n`. + // + // n = 3 => threshold = 2^32 mod 3 = 1, so the loop rejects exactly when + // `low == 0`: + // draw #1: v = 0 -> prod = 0, low = 0 + // (0 < 3 enters the branch; 0 < threshold(1) -> REJECT, redraw) + // draw #2: v = 0x8000_0000 -> prod = 0x1_8000_0000, low = 0x8000_0000 + // (0x8000_0000 >= threshold -> accept) + // result = prod >> 32 = 1 + #[test] + fn int31n_fast_lemire_rejection_loop_redraws_like_go() { + let mut stream = [0_u32, 0x8000_0000].into_iter(); + let mut draws = 0_u32; + let result = int31n_fast_lemire(3, || { + draws += 1; + stream.next().expect("forced stream exhausted") + }); + + assert_eq!(draws, 2, "rejection branch must consume a second draw"); + assert_eq!(result, 1); + } + + // A draw whose low half is >= n never enters the rejection branch: + // v = 0x8000_0000, n = 3 -> prod = 0x1_8000_0000, low = 0x8000_0000 + // (>= 3) -> single draw, result = prod >> 32 = 1. + #[test] + fn int31n_fast_lemire_accepts_first_draw_without_rejection() { + let mut draws = 0_u32; + let result = int31n_fast_lemire(3, || { + draws += 1; + 0x8000_0000 + }); + + assert_eq!(draws, 1, "no rejection expected when low >= n"); + assert_eq!(result, 1); + } + + #[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); + // Pin the concrete result, not just the equality: the Go side + // (keep-core pkg/frost/roast, + // TestSelectCoordinator_CrossLanguagePinnedVectors) asserts the + // same value, so either implementation drifting fails its own + // suite instead of fracturing coordinator agreement at runtime. + assert_eq!(left, Some(4)); + } + + #[derive(serde::Deserialize)] + struct CoordinatorShuffleCorpusFile { + #[allow(dead_code)] + description: String, + cases: Vec, + } + + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct CoordinatorShuffleCase { + name: String, + seed_int64: String, + attempt_number: u32, + members: Vec, + expected_coordinator: u16, + } + + // Byte-identical copy of the canonical differential corpus + // generated from the Go implementation (keep-core + // pkg/frost/roast/testdata/coordinator_shuffle_corpus.json; + // regenerate there with ROAST_SHUFFLE_CORPUS_REGEN=1 and re-copy). + // Covers integer-boundary seeds (0, +/-1, i64 MIN/MAX, +/-MaxInt32 + // for the source-seed normalization collision, the #4026 pin seed), + // the wrapping seed+attempt composition up to u32::MAX attempts, + // unsorted/reversed member inputs, and generated sweeps over set + // sizes 1..255 with full-range seeds. Any drift in source seeding, + // Fisher-Yates order, int31n bounds, sign handling, wrapping, or + // internal sorting fails this test on the drifting side instead of + // fracturing coordinator agreement in a mixed deployment. + // + // Two port branches are unreachable by differential cases and are + // accepted as faithful 1:1 ports of Go's math/rand, covered by Go's + // own stdlib tests: (1) `int63n` (the index > i32::MAX shuffle path) + // is dead for any u16 member set; (2) the `int31n_fast` rejection + // loop fires with probability ~set_size/2^31 per draw, so the corpus + // statistically never exercises it -- the rejection branch itself is + // pinned against Go's `(*Rand).int31n` separately by + // `int31n_fast_lemire_rejection_loop_redraws_like_go`. Pinning the + // `int63n` output differentially would require Go-instrumented forced + // RNG states, out of scope for a corpus that rides the unit-test CI. + #[test] + fn select_coordinator_matches_cross_language_differential_corpus() { + let raw = include_str!("../testdata/coordinator_shuffle_corpus.json"); + let file: CoordinatorShuffleCorpusFile = + serde_json::from_str(raw).expect("corpus file decodes"); + assert!( + file.cases.len() >= 600, + "expected at least the 600-case corpus, found {}", + file.cases.len() + ); + + for case in &file.cases { + let seed: i64 = case + .seed_int64 + .parse() + .unwrap_or_else(|err| panic!("case [{}] seed parse: {err}", case.name)); + let coordinator = + select_coordinator_identifier(&case.members, seed, case.attempt_number) + .unwrap_or_else(|| panic!("case [{}] selected no coordinator", case.name)); + assert_eq!( + coordinator, case.expected_coordinator, + "coordinator mismatch in case [{}]", + case.name + ); + } + } +} diff --git a/pkg/tbtc/signer/src/lib.rs b/pkg/tbtc/signer/src/lib.rs new file mode 100644 index 0000000000..cc90f231c3 --- /dev/null +++ b/pkg/tbtc/signer/src/lib.rs @@ -0,0 +1,1444 @@ +mod api; +mod engine; +mod errors; +mod ffi; +mod go_math_rand; + +use api::{ + BuildTaprootTxRequest, DeriveInteractiveAttemptContextRequest, DifferentialFuzzRequest, + DkgPart1Request, DkgPart2Request, DkgPart3Request, FrostTbtcAbiVersionResult, + InitSignerConfigRequest, InteractiveAggregateRequest, InteractiveRound1Request, + InteractiveRound2Request, InteractiveSessionAbortRequest, InteractiveSessionOpenRequest, + NewSigningPackageRequest, PersistDistributedDkgKeyPackageRequest, PromoteCanaryRequest, + QuarantineStatusRequest, RefreshCadenceStatusRequest, RefreshSharesRequest, + RollbackCanaryRequest, 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"; + +/// The FFI CONTRACT version (see api::FrostTbtcAbiVersionResult), reported by +/// frost_tbtc_abi_version so a Go bridge can fail closed against an incompatible lib. +/// Starts at 1.0 - NOT 0.x, to avoid semver pre-1.0 ambiguity - distinct from the +/// human-readable TBTC_SIGNER_VERSION string above, which stays informational only. +/// +/// Bump rules: bump TBTC_SIGNER_ABI_MAJOR on ANY incompatible change to the Go<->Rust +/// contract; bump TBTC_SIGNER_ABI_MINOR on a purely ADDITIVE, backward-compatible +/// change. A minor bump is valid ONLY if old consumers safely ignore the addition - if +/// a new field or enum value can appear in an existing response that an old bridge does +/// not tolerate, that is a MAJOR bump. +// Major 3: BuildTaprootTx inputs now require the spent output's script_pubkey_hex +// and results carry the ordered BIP-341 key-spend SIGHASH_DEFAULT messages. The +// required request field is an incompatible wire-contract change, so bridges and +// the signer library must move from major 2 to major 3 in lockstep. +// Major 4: RefreshShares no longer returns synthetic replacement material for a +// valid request. It fails closed with a terminal +// cryptographic_refresh_not_supported error until a real multi-round protocol +// exists. Changing status_code from success to error and replacing the response +// JSON meaning is incompatible, so ABI-3 bridges must reject the library during +// negotiation rather than discovering the change at refresh time. +const TBTC_SIGNER_ABI_MAJOR: u32 = 4; +// Major bumps reset the additive minor version. ABI 3.1-3.3 introduced the typed +// heartbeat intent, its rate-limit configuration/metric, and optional canary +// evidence configuration; all remain present in ABI 4.0. +const TBTC_SIGNER_ABI_MINOR: u32 = 0; +#[cfg(test)] +use engine::TBTC_SIGNER_PROFILE_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()) +} + +/// Reports the structured FFI CONTRACT version (api::FrostTbtcAbiVersionResult JSON) +/// so a Go bridge can fail closed on an incompatible libfrost_tbtc. See +/// TBTC_SIGNER_ABI_MAJOR / TBTC_SIGNER_ABI_MINOR for the bump rules. This response +/// shape is the ROOT compatibility surface and must stay stable: {abi_major, abi_minor} +/// as unsigned integers. +#[no_mangle] +pub extern "C" fn frost_tbtc_abi_version() -> TbtcSignerResult { + ffi_entry(|| { + serialize_response(&FrostTbtcAbiVersionResult { + abi_major: TBTC_SIGNER_ABI_MAJOR, + abi_minor: TBTC_SIGNER_ABI_MINOR, + }) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_init_signer_config( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: InitSignerConfigRequest = parse_request(request_ptr, request_len)?; + let response = engine::init_signer_config(request)?; + serialize_response(&response) + }) +} + +#[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_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_persist_distributed_dkg_key_package( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: PersistDistributedDkgKeyPackageRequest = + parse_request(request_ptr, request_len)?; + let response = engine::persist_distributed_dkg_key_package(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_verify_signature_share( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: crate::api::VerifySignatureShareRequest = + parse_request(request_ptr, request_len)?; + let response = engine::verify_signature_share(request)?; + serialize_response(&response) + }) +} + +// Phase 7.1 hardened interactive signing session (frozen spec +// docs/phase-7-interactive-session-spec-freeze.md). Additive ABI: the +// Go host adopts these in Phase 7.3; nothing breaks until it calls +// them. Secret nonces never cross this boundary in either direction. + +#[no_mangle] +pub extern "C" fn frost_tbtc_interactive_session_open( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: InteractiveSessionOpenRequest = parse_request(request_ptr, request_len)?; + let response = engine::interactive_session_open(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_interactive_round1( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: InteractiveRound1Request = parse_request(request_ptr, request_len)?; + let response = engine::interactive_round1(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_interactive_round2( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: InteractiveRound2Request = parse_request(request_ptr, request_len)?; + let response = engine::interactive_round2(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_interactive_session_abort( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: InteractiveSessionAbortRequest = parse_request(request_ptr, request_len)?; + let response = engine::interactive_session_abort(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_interactive_aggregate( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: InteractiveAggregateRequest = parse_request(request_ptr, request_len)?; + let response = engine::interactive_aggregate(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_derive_interactive_attempt_context( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: DeriveInteractiveAttemptContextRequest = + parse_request(request_ptr, request_len)?; + let response = engine::derive_interactive_attempt_context(request)?; + 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::XOnlyPublicKey; + use pretty_assertions::assert_eq; + + use crate::api::{ + BuildTaprootTxRequest, CanaryRolloutStatusResult, DifferentialFuzzRequest, + DifferentialFuzzResult, DkgPart1Request, DkgPart1Result, DkgPart2Request, DkgPart2Result, + DkgPart3Request, DkgPart3Result, DkgRound1Package, DkgRound2Package, ErrorResponse, + FrostTbtcAbiVersionResult, PromoteCanaryRequest, QuarantineStatusRequest, + QuarantineStatusResult, RefreshCadenceStatusRequest, RefreshCadenceStatusResult, + RefreshSharesRequest, RoastLivenessPolicyResult, RollbackCanaryRequest, + SignerHardeningMetricsResult, TransactionResult, TranscriptAuditRequest, + TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, + }; + use crate::{ + frost_tbtc_abi_version, frost_tbtc_build_taproot_tx, frost_tbtc_canary_rollout_status, + frost_tbtc_dkg_part1, frost_tbtc_dkg_part2, frost_tbtc_dkg_part3, frost_tbtc_free_buffer, + frost_tbtc_hardening_metrics, 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_trigger_emergency_rekey, frost_tbtc_verify_blame_proof, + }; + + 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) + } + + fn taproot_prevout_script_hex() -> String { + format!("5120{}", "33".repeat(32)) + } + + 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 interactive_session_ffi_dispatch_smoke() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + let _profile_env = EnvVarGuard::set(super::TBTC_SIGNER_PROFILE_ENV, "development"); + let _provenance_env = EnvVarGuard::set("TBTC_SIGNER_ENFORCE_PROVENANCE_GATE", "false"); + + // Structurally valid requests whose semantics fail: proves + // symbol -> parse -> engine -> structured-error dispatch for + // every Phase 7.1 export without standing up a signing fixture + // (the engine tests own the cryptographic contracts). + let open = crate::api::InteractiveSessionOpenRequest { + session_id: "ffi-interactive-smoke".to_string(), + member_identifier: 1, + message_hex: "11".repeat(32), + key_group: "ffi-smoke-key-group".to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: crate::api::AttemptContext { + attempt_number: 1, + coordinator_identifier: 1, + included_participants: vec![1, 2], + included_participants_fingerprint: "00".to_string(), + attempt_id: "ffi-smoke-attempt".to_string(), + }, + }; + // No wallet key exists for this key_group, so Open fails closed with + // dkg_not_ready (key material is resolved from engine DKG state by + // key_group, never the request). + let (status, payload) = call_ffi(&open, super::frost_tbtc_interactive_session_open); + assert_ne!(status, 0); + let error: ErrorResponse = serde_json::from_slice(&payload).expect("open error payload"); + assert_eq!(error.code, "dkg_not_ready"); + + let round1 = crate::api::InteractiveRound1Request { + session_id: "ffi-interactive-smoke-missing".to_string(), + attempt_id: "missing".to_string(), + member_identifier: 1, + }; + let (status, payload) = call_ffi(&round1, super::frost_tbtc_interactive_round1); + assert_ne!(status, 0); + let error: ErrorResponse = serde_json::from_slice(&payload).expect("round1 error payload"); + assert_eq!(error.code, "session_not_found"); + + let round2 = crate::api::InteractiveRound2Request { + session_id: "ffi-interactive-smoke-missing".to_string(), + attempt_id: "missing".to_string(), + member_identifier: 1, + signing_package_hex: "00".to_string(), + }; + let (status, payload) = call_ffi(&round2, super::frost_tbtc_interactive_round2); + assert_ne!(status, 0); + let error: ErrorResponse = serde_json::from_slice(&payload).expect("round2 error payload"); + assert_eq!(error.code, "validation_error"); + + let abort = crate::api::InteractiveSessionAbortRequest { + session_id: "ffi-interactive-smoke-missing".to_string(), + attempt_id: None, + }; + let (status, payload) = call_ffi(&abort, super::frost_tbtc_interactive_session_abort); + assert_eq!(status, 0); + let result: crate::api::InteractiveSessionAbortResult = + serde_json::from_slice(&payload).expect("abort result payload"); + assert!(!result.aborted); + + // Aggregate fails closed: the malformed signing package is + // rejected at parse (before the session lookup), proving the + // symbol -> parse -> engine -> structured-error dispatch. + let aggregate = crate::api::InteractiveAggregateRequest { + session_id: "ffi-interactive-smoke-missing".to_string(), + attempt_id: "missing".to_string(), + signing_package_hex: "00".to_string(), + signature_shares: vec![crate::api::NativeFrostSignatureShare { + identifier: "00".to_string(), + data_hex: "00".to_string(), + }], + taproot_merkle_root_hex: None, + }; + let (status, payload) = call_ffi(&aggregate, super::frost_tbtc_interactive_aggregate); + assert_ne!(status, 0); + let error: ErrorResponse = + serde_json::from_slice(&payload).expect("aggregate error payload"); + assert_eq!(error.code, "validation_error"); + } + + #[test] + fn derive_interactive_attempt_context_ffi_roundtrip() { + // Hermetic env (development profile, provenance gate off) so the new + // front-door gate does not reject; the engine tests own the gate's + // fail-closed behavior. + let _guard = crate::engine::lock_test_state(); + // Stateless + secret-free: unlike the session calls above, a valid + // request SUCCEEDS with no DKG fixture, proving the + // symbol -> parse -> engine -> serialize_response SUCCESS path for the + // derivation export (the engine tests own the derivation contract). + let request = crate::api::DeriveInteractiveAttemptContextRequest { + session_id: "ffi-derive-smoke".to_string(), + message_hex: "11".repeat(32), + key_group: "ffi-derive-key-group".to_string(), + threshold: 2, + attempt_number: 1, + included_participants: vec![3, 1, 2], + }; + let (status, payload) = call_ffi( + &request, + super::frost_tbtc_derive_interactive_attempt_context, + ); + assert_eq!(status, 0); + let result: crate::api::DeriveInteractiveAttemptContextResult = + serde_json::from_slice(&payload).expect("derive result payload"); + assert_eq!(result.attempt_context.included_participants, vec![1, 2, 3]); + assert_eq!(result.attempt_context.attempt_number, 1); + assert!(result.attempt_context.coordinator_identifier >= 1); + assert_eq!( + result + .attempt_context + .included_participants_fingerprint + .len(), + 64 + ); + assert_eq!(result.attempt_context.attempt_id.len(), 64); + assert_eq!(result.frost_identifiers.len(), 3); + } + + 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 dkg_part_ffi_roundtrip_produces_consistent_key_material() { + // Serialize with every other env-touching test. This test mutates + // process-global TBTC_SIGNER_* env vars (profile, provenance gate), + // and env is shared across all parallel test threads; without the + // lock its EnvVarGuard set/restore races with the serialized tests + // and can leak a `production` profile into a concurrent state test, + // which then panics while holding the engine lock and poisons it. + // Declared first so it drops last - after the EnvVarGuards restore. + let _guard = crate::engine::lock_test_state(); + 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.expose_secret().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 + ); + } + + // The exported DKG group key is a valid BIP-340 x-only public key. + // The signing round trip that used to consume it through the removed + // stateless FFI ops now lives in the engine tests, which drive the + // frost primitives directly and verify a BIP-340 signature end to end. + let public_key_bytes = hex::decode(verifying_key).expect("verifying key hex"); + assert_eq!(public_key_bytes.len(), 32); + XOnlyPublicKey::from_slice(&public_key_bytes).expect("x-only public key"); + } + + #[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 abi_version_reports_the_contract_version() { + let (status, payload) = call_ffi_no_input(frost_tbtc_abi_version); + assert_eq!(status, 0); + + let abi: FrostTbtcAbiVersionResult = + serde_json::from_slice(&payload).expect("abi version payload decode"); + // The enforced FFI contract starts at 1.0; bump deliberately per the + // TBTC_SIGNER_ABI_MAJOR / TBTC_SIGNER_ABI_MINOR rules. This test pins the + // current value so an accidental bump is caught. ABI 4 changes a valid + // RefreshShares call from a synthetic success response to a terminal error, + // forcing ABI-3 consumers to fail closed during compatibility negotiation. + assert_eq!(abi.abi_major, 4); + assert_eq!(abi.abi_minor, 0); + } + + #[test] + fn interactive_heartbeat_intent_has_the_pinned_wire_shape() { + let intent = crate::api::InteractiveSigningIntent::Heartbeat { + message_hex: "ff".repeat(8) + &"00".repeat(8), + }; + assert_eq!( + serde_json::to_value(intent).expect("heartbeat intent serializes"), + serde_json::json!({ + "type": "heartbeat", + "message_hex": "ffffffffffffffff0000000000000000" + }) + ); + } + + #[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.build_taproot_tx_calls_total, 0); + assert_eq!(metrics_before.build_taproot_tx_success_total, 0); + assert_eq!(metrics_before.heartbeat_signing_policy_reject_total, 0); + assert_eq!(metrics_before.refresh_shares_calls_total, 0); + assert_eq!(metrics_before.refresh_shares_success_total, 0); + assert_eq!(metrics_before.build_taproot_tx_latency_samples, 0); + assert_eq!(metrics_before.build_taproot_tx_latency_p95_ms, 0); + + let build_request = BuildTaprootTxRequest { + session_id: "hardening-metrics-session".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 0, + value_sats: 10_000, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 9_000, + }], + script_tree_hex: None, + }; + let (build_status, _) = call_ffi(&build_request, frost_tbtc_build_taproot_tx); + assert_eq!(build_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.build_taproot_tx_calls_total, 1); + assert_eq!(metrics_after.build_taproot_tx_success_total, 1); + assert_eq!(metrics_after.refresh_shares_calls_total, 0); + assert_eq!(metrics_after.refresh_shares_success_total, 0); + assert_eq!(metrics_after.build_taproot_tx_latency_samples, 1); + assert!(metrics_after.build_taproot_tx_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(); + crate::engine::seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + + 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); + + crate::engine::seed_canary_promotion_evidence_for_tests(1, 1, 1, 0); + 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_build_taproot_tx_for_session() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let build_request = BuildTaprootTxRequest { + session_id: "session-emergency-rekey".to_string(), + inputs: vec![crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 0, + value_sats: 10_000, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + outputs: vec![crate::api::TxOutput { + script_pubkey_hex: format!("5120{}", "22".repeat(32)), + value_sats: 9_000, + }], + script_tree_hex: None, + }; + // A successful build creates+persists the session so the emergency + // rekey trigger has a target to mark. + let (build_status, _) = call_ffi(&build_request, frost_tbtc_build_taproot_tx); + assert_eq!(build_status, 0); + + 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); + + // Once rekey is required, build_taproot_tx for the session is blocked + // before it can reach the cached result. + let (blocked_status, blocked_payload) = + call_ffi(&build_request, frost_tbtc_build_taproot_tx); + assert_eq!(blocked_status, 1); + let blocked_error: ErrorResponse = + serde_json::from_slice(&blocked_payload).expect("blocked error decode"); + assert_eq!(blocked_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 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }) + .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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }], + 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, + script_pubkey_hex: taproot_prevout_script_hex(), + }, + crate::api::TxInput { + txid_hex: "11".repeat(32), + vout: 1, + value_sats: 10_000, + script_pubkey_hex: taproot_prevout_script_hex(), + }, + ], + 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_rejects_without_returning_synthetic_material() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = RefreshSharesRequest { + session_id: "session-refresh".to_string(), + current_shares: vec![crate::api::ShareMaterial { + identifier: 1, + encrypted_share_hex: "abcd".to_string(), + }], + }; + + let (status, payload) = call_ffi(&request, frost_tbtc_refresh_shares); + assert_eq!(status, 1); + + let error: ErrorResponse = serde_json::from_slice(&payload).expect("error payload"); + assert_eq!(error.code, "cryptographic_refresh_not_supported"); + assert_eq!(error.recovery_class, "terminal"); + assert!(error + .message + .contains("cryptographic share refresh is not supported")); + assert!(error + .message + .contains("zero-constant FROST refresh protocol")); + } + + #[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::engine::truthy_env_flag(value), + expected, + "unexpected bootstrap-mode flag classification for [{value:?}]", + ); + } + } + + #[test] + fn init_signer_config_ffi_round_trip_installs_and_reports_fingerprint() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = crate::api::InitSignerConfigRequest { + profile: Some("development".to_string()), + roast_coordinator_timeout_ms: Some(45_000), + policy_heartbeat_rate_limit_per_minute: Some(12), + ..crate::api::InitSignerConfigRequest::default() + }; + let (status, response_bytes) = call_ffi(&request, crate::frost_tbtc_init_signer_config); + assert_eq!( + status, + 0, + "init must succeed: {:?}", + String::from_utf8_lossy(&response_bytes) + ); + + let response: crate::api::InitSignerConfigResult = + serde_json::from_slice(&response_bytes).expect("response parses"); + assert!(response.installed); + assert!(!response.idempotent); + assert_eq!(response.configured_key_count, 3); + assert!(!response.config_fingerprint.is_empty()); + + // Clear the installed snapshot so env-driven tests are unaffected. + crate::engine::reset_for_tests(); + } +} 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/testdata/coordinator_seed_vectors.json b/pkg/tbtc/signer/testdata/coordinator_seed_vectors.json new file mode 100644 index 0000000000..6e9a722083 --- /dev/null +++ b/pkg/tbtc/signer/testdata/coordinator_seed_vectors.json @@ -0,0 +1,459 @@ +{ + "description": "Cross-language conformance vectors for the RFC-21 Annex A coordinator-shuffle seed derivation: ShuffleSeed_i64 = int64_be(SHA256(KeyGroupBytes || SessionID || MessageDigest)[0:8]); coordinator = GoMathRandShuffle(sorted(IncludedMembers), ShuffleSeed_i64 + int64(AttemptNumber))[0] with the 0-based RFC-21 AttemptNumber. wireAttemptNumber is the 1-based tbtc-signer FFI encoding of the same attempt. Canonical copy: pkg/frost/roast/testdata/coordinator_seed_vectors.json (Go); mirrored byte-identically to pkg/tbtc/signer/testdata/coordinator_seed_vectors.json (Rust).", + "vectors": [ + { + "name": "five-members-attempt-0", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 3 + }, + { + "name": "five-members-attempt-1", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 1, + "wireAttemptNumber": 2, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 4 + }, + { + "name": "five-members-attempt-5", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 5, + "wireAttemptNumber": 6, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 5 + }, + { + "name": "sparse-members-attempt-0", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 2, + 7, + 9, + 11 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 11 + }, + { + "name": "different-session-changes-seed", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-2", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "-4761519992160790326", + "expectedCoordinator": 4 + }, + { + "name": "different-digest-changes-seed", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "f0efeeedecebeae9e8e7e6e5e4e3e2e1e0dfdedddcdbdad9d8d7d6d5d4d3d2d1", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "4077301444353698382", + "expectedCoordinator": 1 + }, + { + "name": "opaque-key-group-handle", + "keyGroup": "roast-vector-opaque-key-group-handle", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "8068678687664591308", + "expectedCoordinator": 5 + }, + { + "name": "production-group-size-attempt-0", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 55 + }, + { + "name": "production-group-size-attempt-3", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "f0efeeedecebeae9e8e7e6e5e4e3e2e1e0dfdedddcdbdad9d8d7d6d5d4d3d2d1", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100 + ], + "attemptNumber": 3, + "wireAttemptNumber": 4, + "expectedShuffleSeedInt64": "4077301444353698382", + "expectedCoordinator": 15 + }, + { + "name": "opaque-key-group-wide-set-attempt-7", + "keyGroup": "roast-vector-opaque-key-group-handle", + "sessionID": "session-roast-seed-vector-2", + "messageDigestHex": "f0efeeedecebeae9e8e7e6e5e4e3e2e1e0dfdedddcdbdad9d8d7d6d5d4d3d2d1", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100 + ], + "attemptNumber": 7, + "wireAttemptNumber": 8, + "expectedShuffleSeedInt64": "-6872856820098921194", + "expectedCoordinator": 18 + } + ] +} diff --git a/pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json b/pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json new file mode 100644 index 0000000000..0672967760 --- /dev/null +++ b/pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json @@ -0,0 +1 @@ +{"description":"Cross-language differential corpus for the legacy Go math/rand coordinator shuffle (SelectCoordinator / go_math_rand.rs select_coordinator_identifier): source seed = seedInt64 + int64(attemptNumber) with two's-complement wrapping; members sorted ascending internally before the Fisher-Yates shuffle; first element after shuffling is the coordinator. Canonical copy: pkg/frost/roast/testdata/coordinator_shuffle_corpus.json (Go); mirrored byte-identically to pkg/tbtc/signer/testdata/coordinator_shuffle_corpus.json (Rust). Regenerate with ROAST_SHUFFLE_CORPUS_REGEN=1.","cases":[{"name":"boundary-seed-0-attempt-0-set-0","seedInt64":"0","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-0-set-1","seedInt64":"0","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-0-set-2","seedInt64":"0","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-0-set-3","seedInt64":"0","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-0-set-4","seedInt64":"0","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-0-set-5","seedInt64":"0","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":9},{"name":"boundary-seed-0-attempt-1-set-0","seedInt64":"0","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-1-set-1","seedInt64":"0","attemptNumber":1,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-1-set-2","seedInt64":"0","attemptNumber":1,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-1-set-3","seedInt64":"0","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-1-set-4","seedInt64":"0","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-1-set-5","seedInt64":"0","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-0-attempt-7-set-0","seedInt64":"0","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-7-set-1","seedInt64":"0","attemptNumber":7,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-7-set-2","seedInt64":"0","attemptNumber":7,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-7-set-3","seedInt64":"0","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-7-set-4","seedInt64":"0","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-7-set-5","seedInt64":"0","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed-0-attempt-4294967295-set-0","seedInt64":"0","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-4294967295-set-1","seedInt64":"0","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-4294967295-set-2","seedInt64":"0","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-0-attempt-4294967295-set-3","seedInt64":"0","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-4294967295-set-4","seedInt64":"0","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-0-attempt-4294967295-set-5","seedInt64":"0","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-0-set-0","seedInt64":"1","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-1-attempt-0-set-1","seedInt64":"1","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-1-attempt-0-set-2","seedInt64":"1","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-1-attempt-0-set-3","seedInt64":"1","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-1-attempt-0-set-4","seedInt64":"1","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-1-attempt-0-set-5","seedInt64":"1","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-1-set-0","seedInt64":"1","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-1-attempt-1-set-1","seedInt64":"1","attemptNumber":1,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-1-set-2","seedInt64":"1","attemptNumber":1,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-1-set-3","seedInt64":"1","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed-1-attempt-1-set-4","seedInt64":"1","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed-1-attempt-1-set-5","seedInt64":"1","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed-1-attempt-7-set-0","seedInt64":"1","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-1-attempt-7-set-1","seedInt64":"1","attemptNumber":7,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-7-set-2","seedInt64":"1","attemptNumber":7,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-7-set-3","seedInt64":"1","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-7-set-4","seedInt64":"1","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-7-set-5","seedInt64":"1","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-4294967295-set-0","seedInt64":"1","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-1-attempt-4294967295-set-1","seedInt64":"1","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-4294967295-set-2","seedInt64":"1","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-1-attempt-4294967295-set-3","seedInt64":"1","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed-1-attempt-4294967295-set-4","seedInt64":"1","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed-1-attempt-4294967295-set-5","seedInt64":"1","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed--1-attempt-0-set-0","seedInt64":"-1","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-0-set-1","seedInt64":"-1","attemptNumber":0,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--1-attempt-0-set-2","seedInt64":"-1","attemptNumber":0,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--1-attempt-0-set-3","seedInt64":"-1","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed--1-attempt-0-set-4","seedInt64":"-1","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed--1-attempt-0-set-5","seedInt64":"-1","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed--1-attempt-1-set-0","seedInt64":"-1","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-1-set-1","seedInt64":"-1","attemptNumber":1,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-1-set-2","seedInt64":"-1","attemptNumber":1,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-1-set-3","seedInt64":"-1","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--1-attempt-1-set-4","seedInt64":"-1","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--1-attempt-1-set-5","seedInt64":"-1","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":9},{"name":"boundary-seed--1-attempt-7-set-0","seedInt64":"-1","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-7-set-1","seedInt64":"-1","attemptNumber":7,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--1-attempt-7-set-2","seedInt64":"-1","attemptNumber":7,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--1-attempt-7-set-3","seedInt64":"-1","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--1-attempt-7-set-4","seedInt64":"-1","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--1-attempt-7-set-5","seedInt64":"-1","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--1-attempt-4294967295-set-0","seedInt64":"-1","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-4294967295-set-1","seedInt64":"-1","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-4294967295-set-2","seedInt64":"-1","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--1-attempt-4294967295-set-3","seedInt64":"-1","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--1-attempt-4294967295-set-4","seedInt64":"-1","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--1-attempt-4294967295-set-5","seedInt64":"-1","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":9},{"name":"boundary-seed-9223372036854775807-attempt-0-set-0","seedInt64":"9223372036854775807","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-0-set-1","seedInt64":"9223372036854775807","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-0-set-2","seedInt64":"9223372036854775807","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-0-set-3","seedInt64":"9223372036854775807","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-9223372036854775807-attempt-0-set-4","seedInt64":"9223372036854775807","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-9223372036854775807-attempt-0-set-5","seedInt64":"9223372036854775807","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775807-attempt-1-set-0","seedInt64":"9223372036854775807","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-1-set-1","seedInt64":"9223372036854775807","attemptNumber":1,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-1-set-2","seedInt64":"9223372036854775807","attemptNumber":1,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-1-set-3","seedInt64":"9223372036854775807","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-1-set-4","seedInt64":"9223372036854775807","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-1-set-5","seedInt64":"9223372036854775807","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775807-attempt-7-set-0","seedInt64":"9223372036854775807","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-7-set-1","seedInt64":"9223372036854775807","attemptNumber":7,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775807-attempt-7-set-2","seedInt64":"9223372036854775807","attemptNumber":7,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775807-attempt-7-set-3","seedInt64":"9223372036854775807","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed-9223372036854775807-attempt-7-set-4","seedInt64":"9223372036854775807","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed-9223372036854775807-attempt-7-set-5","seedInt64":"9223372036854775807","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed-9223372036854775807-attempt-4294967295-set-0","seedInt64":"9223372036854775807","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-4294967295-set-1","seedInt64":"9223372036854775807","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-4294967295-set-2","seedInt64":"9223372036854775807","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-4294967295-set-3","seedInt64":"9223372036854775807","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-4294967295-set-4","seedInt64":"9223372036854775807","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775807-attempt-4294967295-set-5","seedInt64":"9223372036854775807","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-0-set-0","seedInt64":"-9223372036854775808","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-0-set-1","seedInt64":"-9223372036854775808","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-0-set-2","seedInt64":"-9223372036854775808","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-0-set-3","seedInt64":"-9223372036854775808","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-0-set-4","seedInt64":"-9223372036854775808","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-0-set-5","seedInt64":"-9223372036854775808","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-1-set-0","seedInt64":"-9223372036854775808","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-1-set-1","seedInt64":"-9223372036854775808","attemptNumber":1,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-1-set-2","seedInt64":"-9223372036854775808","attemptNumber":1,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-1-set-3","seedInt64":"-9223372036854775808","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775808-attempt-1-set-4","seedInt64":"-9223372036854775808","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775808-attempt-1-set-5","seedInt64":"-9223372036854775808","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed--9223372036854775808-attempt-7-set-0","seedInt64":"-9223372036854775808","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-7-set-1","seedInt64":"-9223372036854775808","attemptNumber":7,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-7-set-2","seedInt64":"-9223372036854775808","attemptNumber":7,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-7-set-3","seedInt64":"-9223372036854775808","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-7-set-4","seedInt64":"-9223372036854775808","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-7-set-5","seedInt64":"-9223372036854775808","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-4294967295-set-0","seedInt64":"-9223372036854775808","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775808-attempt-4294967295-set-1","seedInt64":"-9223372036854775808","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-4294967295-set-2","seedInt64":"-9223372036854775808","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775808-attempt-4294967295-set-3","seedInt64":"-9223372036854775808","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775808-attempt-4294967295-set-4","seedInt64":"-9223372036854775808","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775808-attempt-4294967295-set-5","seedInt64":"-9223372036854775808","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed-9223372036854775804-attempt-0-set-0","seedInt64":"9223372036854775804","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-0-set-1","seedInt64":"9223372036854775804","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-0-set-2","seedInt64":"9223372036854775804","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-0-set-3","seedInt64":"9223372036854775804","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-0-set-4","seedInt64":"9223372036854775804","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-0-set-5","seedInt64":"9223372036854775804","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775804-attempt-1-set-0","seedInt64":"9223372036854775804","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-1-set-1","seedInt64":"9223372036854775804","attemptNumber":1,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775804-attempt-1-set-2","seedInt64":"9223372036854775804","attemptNumber":1,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775804-attempt-1-set-3","seedInt64":"9223372036854775804","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed-9223372036854775804-attempt-1-set-4","seedInt64":"9223372036854775804","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed-9223372036854775804-attempt-1-set-5","seedInt64":"9223372036854775804","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed-9223372036854775804-attempt-7-set-0","seedInt64":"9223372036854775804","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-7-set-1","seedInt64":"9223372036854775804","attemptNumber":7,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-7-set-2","seedInt64":"9223372036854775804","attemptNumber":7,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-7-set-3","seedInt64":"9223372036854775804","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-9223372036854775804-attempt-7-set-4","seedInt64":"9223372036854775804","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-9223372036854775804-attempt-7-set-5","seedInt64":"9223372036854775804","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775804-attempt-4294967295-set-0","seedInt64":"9223372036854775804","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-9223372036854775804-attempt-4294967295-set-1","seedInt64":"9223372036854775804","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775804-attempt-4294967295-set-2","seedInt64":"9223372036854775804","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-9223372036854775804-attempt-4294967295-set-3","seedInt64":"9223372036854775804","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed-9223372036854775804-attempt-4294967295-set-4","seedInt64":"9223372036854775804","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed-9223372036854775804-attempt-4294967295-set-5","seedInt64":"9223372036854775804","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":9},{"name":"boundary-seed--9223372036854775805-attempt-0-set-0","seedInt64":"-9223372036854775805","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775805-attempt-0-set-1","seedInt64":"-9223372036854775805","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775805-attempt-0-set-2","seedInt64":"-9223372036854775805","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775805-attempt-0-set-3","seedInt64":"-9223372036854775805","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--9223372036854775805-attempt-0-set-4","seedInt64":"-9223372036854775805","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--9223372036854775805-attempt-0-set-5","seedInt64":"-9223372036854775805","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-1-set-0","seedInt64":"-9223372036854775805","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775805-attempt-1-set-1","seedInt64":"-9223372036854775805","attemptNumber":1,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-1-set-2","seedInt64":"-9223372036854775805","attemptNumber":1,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-1-set-3","seedInt64":"-9223372036854775805","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775805-attempt-1-set-4","seedInt64":"-9223372036854775805","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775805-attempt-1-set-5","seedInt64":"-9223372036854775805","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed--9223372036854775805-attempt-7-set-0","seedInt64":"-9223372036854775805","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775805-attempt-7-set-1","seedInt64":"-9223372036854775805","attemptNumber":7,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-7-set-2","seedInt64":"-9223372036854775805","attemptNumber":7,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-7-set-3","seedInt64":"-9223372036854775805","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-7-set-4","seedInt64":"-9223372036854775805","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-7-set-5","seedInt64":"-9223372036854775805","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-4294967295-set-0","seedInt64":"-9223372036854775805","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--9223372036854775805-attempt-4294967295-set-1","seedInt64":"-9223372036854775805","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-4294967295-set-2","seedInt64":"-9223372036854775805","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--9223372036854775805-attempt-4294967295-set-3","seedInt64":"-9223372036854775805","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775805-attempt-4294967295-set-4","seedInt64":"-9223372036854775805","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":4},{"name":"boundary-seed--9223372036854775805-attempt-4294967295-set-5","seedInt64":"-9223372036854775805","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed-2147483647-attempt-0-set-0","seedInt64":"2147483647","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-0-set-1","seedInt64":"2147483647","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-0-set-2","seedInt64":"2147483647","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-0-set-3","seedInt64":"2147483647","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-0-set-4","seedInt64":"2147483647","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-0-set-5","seedInt64":"2147483647","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":9},{"name":"boundary-seed-2147483647-attempt-1-set-0","seedInt64":"2147483647","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-1-set-1","seedInt64":"2147483647","attemptNumber":1,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-1-set-2","seedInt64":"2147483647","attemptNumber":1,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-1-set-3","seedInt64":"2147483647","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-1-set-4","seedInt64":"2147483647","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-1-set-5","seedInt64":"2147483647","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-2147483647-attempt-7-set-0","seedInt64":"2147483647","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-7-set-1","seedInt64":"2147483647","attemptNumber":7,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-7-set-2","seedInt64":"2147483647","attemptNumber":7,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-7-set-3","seedInt64":"2147483647","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-7-set-4","seedInt64":"2147483647","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-7-set-5","seedInt64":"2147483647","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed-2147483647-attempt-4294967295-set-0","seedInt64":"2147483647","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-4294967295-set-1","seedInt64":"2147483647","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-4294967295-set-2","seedInt64":"2147483647","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-2147483647-attempt-4294967295-set-3","seedInt64":"2147483647","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-4294967295-set-4","seedInt64":"2147483647","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed-2147483647-attempt-4294967295-set-5","seedInt64":"2147483647","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--2147483647-attempt-0-set-0","seedInt64":"-2147483647","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-0-set-1","seedInt64":"-2147483647","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-0-set-2","seedInt64":"-2147483647","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-0-set-3","seedInt64":"-2147483647","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-0-set-4","seedInt64":"-2147483647","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-0-set-5","seedInt64":"-2147483647","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":9},{"name":"boundary-seed--2147483647-attempt-1-set-0","seedInt64":"-2147483647","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-1-set-1","seedInt64":"-2147483647","attemptNumber":1,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-1-set-2","seedInt64":"-2147483647","attemptNumber":1,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-1-set-3","seedInt64":"-2147483647","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-1-set-4","seedInt64":"-2147483647","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-1-set-5","seedInt64":"-2147483647","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed--2147483647-attempt-7-set-0","seedInt64":"-2147483647","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-7-set-1","seedInt64":"-2147483647","attemptNumber":7,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-7-set-2","seedInt64":"-2147483647","attemptNumber":7,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-7-set-3","seedInt64":"-2147483647","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-7-set-4","seedInt64":"-2147483647","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-7-set-5","seedInt64":"-2147483647","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed--2147483647-attempt-4294967295-set-0","seedInt64":"-2147483647","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-4294967295-set-1","seedInt64":"-2147483647","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-4294967295-set-2","seedInt64":"-2147483647","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--2147483647-attempt-4294967295-set-3","seedInt64":"-2147483647","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-4294967295-set-4","seedInt64":"-2147483647","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":3},{"name":"boundary-seed--2147483647-attempt-4294967295-set-5","seedInt64":"-2147483647","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-0-set-0","seedInt64":"6879463052285329321","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-0-set-1","seedInt64":"6879463052285329321","attemptNumber":0,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-0-set-2","seedInt64":"6879463052285329321","attemptNumber":0,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-0-set-3","seedInt64":"6879463052285329321","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":5},{"name":"boundary-seed-6879463052285329321-attempt-0-set-4","seedInt64":"6879463052285329321","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":5},{"name":"boundary-seed-6879463052285329321-attempt-0-set-5","seedInt64":"6879463052285329321","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed-6879463052285329321-attempt-1-set-0","seedInt64":"6879463052285329321","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-1-set-1","seedInt64":"6879463052285329321","attemptNumber":1,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-1-set-2","seedInt64":"6879463052285329321","attemptNumber":1,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-1-set-3","seedInt64":"6879463052285329321","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":5},{"name":"boundary-seed-6879463052285329321-attempt-1-set-4","seedInt64":"6879463052285329321","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":5},{"name":"boundary-seed-6879463052285329321-attempt-1-set-5","seedInt64":"6879463052285329321","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed-6879463052285329321-attempt-7-set-0","seedInt64":"6879463052285329321","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-7-set-1","seedInt64":"6879463052285329321","attemptNumber":7,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-7-set-2","seedInt64":"6879463052285329321","attemptNumber":7,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-7-set-3","seedInt64":"6879463052285329321","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-7-set-4","seedInt64":"6879463052285329321","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-7-set-5","seedInt64":"6879463052285329321","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed-6879463052285329321-attempt-4294967295-set-0","seedInt64":"6879463052285329321","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed-6879463052285329321-attempt-4294967295-set-1","seedInt64":"6879463052285329321","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-4294967295-set-2","seedInt64":"6879463052285329321","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed-6879463052285329321-attempt-4294967295-set-3","seedInt64":"6879463052285329321","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":5},{"name":"boundary-seed-6879463052285329321-attempt-4294967295-set-4","seedInt64":"6879463052285329321","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":5},{"name":"boundary-seed-6879463052285329321-attempt-4294967295-set-5","seedInt64":"6879463052285329321","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed--6879463052285329321-attempt-0-set-0","seedInt64":"-6879463052285329321","attemptNumber":0,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-0-set-1","seedInt64":"-6879463052285329321","attemptNumber":0,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-0-set-2","seedInt64":"-6879463052285329321","attemptNumber":0,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-0-set-3","seedInt64":"-6879463052285329321","attemptNumber":0,"members":[5,4,3,2,1],"expectedCoordinator":5},{"name":"boundary-seed--6879463052285329321-attempt-0-set-4","seedInt64":"-6879463052285329321","attemptNumber":0,"members":[1,2,3,4,5],"expectedCoordinator":5},{"name":"boundary-seed--6879463052285329321-attempt-0-set-5","seedInt64":"-6879463052285329321","attemptNumber":0,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed--6879463052285329321-attempt-1-set-0","seedInt64":"-6879463052285329321","attemptNumber":1,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-1-set-1","seedInt64":"-6879463052285329321","attemptNumber":1,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-1-set-2","seedInt64":"-6879463052285329321","attemptNumber":1,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-1-set-3","seedInt64":"-6879463052285329321","attemptNumber":1,"members":[5,4,3,2,1],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-1-set-4","seedInt64":"-6879463052285329321","attemptNumber":1,"members":[1,2,3,4,5],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-1-set-5","seedInt64":"-6879463052285329321","attemptNumber":1,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"boundary-seed--6879463052285329321-attempt-7-set-0","seedInt64":"-6879463052285329321","attemptNumber":7,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-7-set-1","seedInt64":"-6879463052285329321","attemptNumber":7,"members":[1,2],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-7-set-2","seedInt64":"-6879463052285329321","attemptNumber":7,"members":[2,1],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-7-set-3","seedInt64":"-6879463052285329321","attemptNumber":7,"members":[5,4,3,2,1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-7-set-4","seedInt64":"-6879463052285329321","attemptNumber":7,"members":[1,2,3,4,5],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-7-set-5","seedInt64":"-6879463052285329321","attemptNumber":7,"members":[7,2,11,9],"expectedCoordinator":11},{"name":"boundary-seed--6879463052285329321-attempt-4294967295-set-0","seedInt64":"-6879463052285329321","attemptNumber":4294967295,"members":[1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-4294967295-set-1","seedInt64":"-6879463052285329321","attemptNumber":4294967295,"members":[1,2],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-4294967295-set-2","seedInt64":"-6879463052285329321","attemptNumber":4294967295,"members":[2,1],"expectedCoordinator":1},{"name":"boundary-seed--6879463052285329321-attempt-4294967295-set-3","seedInt64":"-6879463052285329321","attemptNumber":4294967295,"members":[5,4,3,2,1],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-4294967295-set-4","seedInt64":"-6879463052285329321","attemptNumber":4294967295,"members":[1,2,3,4,5],"expectedCoordinator":2},{"name":"boundary-seed--6879463052285329321-attempt-4294967295-set-5","seedInt64":"-6879463052285329321","attemptNumber":4294967295,"members":[7,2,11,9],"expectedCoordinator":7},{"name":"generated-000-size-1","seedInt64":"6295792554059532962","attemptNumber":5,"members":[41],"expectedCoordinator":41},{"name":"generated-001-size-46","seedInt64":"3683221734673789976","attemptNumber":25023,"members":[245,146,229,247,218,221,74,189,27,209,214,200,183,77,114,184,203,34,141,161,253,21,123,47,243,111,228,68,196,244,93,33,24,80,222,82,102,152,66,4,232,190,198,89,15,10],"expectedCoordinator":247},{"name":"generated-002-size-72","seedInt64":"5813114611858962076","attemptNumber":2051719421,"members":[162,201,231,8,101,229,245,148,124,187,222,255,238,27,26,4,157,145,35,134,55,105,129,150,90,37,214,218,189,3,60,45,217,247,63,115,250,248,120,192,138,190,130,132,51,96,34,74,24,122,169,137,69,85,242,62,78,54,178,243,103,184,23,128,241,237,86,252,160,89,15,234],"expectedCoordinator":189},{"name":"generated-003-size-126","seedInt64":"-5707769007413102449","attemptNumber":2,"members":[152,127,2,14,96,4,199,46,55,57,116,79,222,196,148,228,169,215,207,39,202,61,223,33,59,38,171,135,43,163,71,134,122,110,35,76,112,125,120,84,64,40,208,238,195,81,191,88,124,31,94,183,214,137,180,197,254,140,78,237,205,170,138,15,19,145,153,159,231,219,85,74,16,200,119,18,216,128,181,227,86,155,32,192,89,206,47,165,106,253,24,198,184,13,162,7,69,17,95,56,23,132,166,136,60,210,182,118,72,221,156,98,229,62,104,49,115,97,34,41,26,65,22,204,111,179],"expectedCoordinator":153},{"name":"generated-004-size-4","seedInt64":"-4719280119571122224","attemptNumber":61267,"members":[78,72,53,100],"expectedCoordinator":78},{"name":"generated-005-size-43","seedInt64":"8691200825180697649","attemptNumber":2383742276,"members":[191,161,254,211,152,248,123,137,143,17,40,223,196,185,23,158,105,198,76,62,124,183,37,114,31,236,176,89,237,98,22,15,7,11,70,118,233,242,44,48,127,132,66],"expectedCoordinator":237},{"name":"generated-006-size-73","seedInt64":"6233619617998794694","attemptNumber":2,"members":[27,171,221,158,57,198,124,36,68,161,163,53,217,212,26,83,135,235,21,155,137,128,172,167,141,43,76,145,102,214,211,183,160,178,175,199,54,16,72,18,143,206,193,32,231,127,185,114,41,14,55,203,104,180,12,92,8,120,177,117,119,222,208,113,204,213,191,218,246,31,166,187,253],"expectedCoordinator":185},{"name":"generated-007-size-123","seedInt64":"5434904758717572839","attemptNumber":65179,"members":[98,188,13,15,231,17,77,26,180,187,12,248,218,119,70,215,147,93,198,179,195,67,41,164,214,6,206,84,94,47,5,110,192,169,217,44,48,128,60,3,141,138,116,58,151,88,81,72,105,246,205,172,189,9,144,37,34,42,154,165,20,253,167,161,45,143,66,125,96,229,50,115,78,153,250,85,54,82,53,97,182,46,14,131,55,74,1,33,107,221,251,136,184,118,243,51,171,36,127,75,19,135,87,150,235,249,49,31,57,139,226,76,22,43,236,156,244,183,219,101,30,18,83],"expectedCoordinator":6},{"name":"generated-008-size-5","seedInt64":"6796119988171906023","attemptNumber":3146658771,"members":[171,70,74,130,99],"expectedCoordinator":74},{"name":"generated-009-size-6","seedInt64":"-6337975409404583314","attemptNumber":3,"members":[56,130,93,250,149,19],"expectedCoordinator":149},{"name":"generated-010-size-89","seedInt64":"-771078845577344560","attemptNumber":31710,"members":[221,39,206,244,108,88,103,118,75,4,114,168,28,7,32,178,62,255,100,238,153,146,135,188,30,154,139,97,184,254,174,145,216,235,10,129,5,121,209,172,152,25,6,21,149,230,29,246,148,99,49,164,156,214,41,9,15,61,210,72,242,27,24,98,201,126,57,127,181,96,225,186,19,200,177,217,70,249,176,130,69,17,131,226,48,52,46,197,236],"expectedCoordinator":9},{"name":"generated-011-size-165","seedInt64":"-1612711212753147549","attemptNumber":3039875824,"members":[235,78,215,108,100,88,19,43,104,76,59,231,141,21,157,96,27,204,3,134,57,24,64,173,253,125,45,14,40,26,191,111,246,121,9,239,193,183,155,84,176,48,63,54,162,52,47,252,110,33,166,115,194,120,202,127,148,58,62,168,116,140,216,65,153,6,220,80,124,133,99,236,101,177,74,196,77,159,17,156,117,41,11,102,163,199,86,91,20,12,187,81,233,158,218,79,213,238,10,56,69,73,49,130,42,97,192,161,128,25,72,16,234,214,219,13,186,83,1,223,5,126,182,114,87,170,228,212,229,85,152,29,197,180,248,144,243,23,35,174,227,175,71,203,98,50,167,44,122,94,53,103,210,112,60,18,209,4,230,205,31,132,245,222,75],"expectedCoordinator":23},{"name":"generated-012-size-2","seedInt64":"-6750454712375239825","attemptNumber":7,"members":[29,55],"expectedCoordinator":29},{"name":"generated-013-size-32","seedInt64":"-2220607361023621506","attemptNumber":25246,"members":[125,200,178,122,117,167,15,127,67,228,237,163,233,191,72,138,188,31,217,141,54,204,41,118,225,119,152,40,9,100,185,129],"expectedCoordinator":127},{"name":"generated-014-size-96","seedInt64":"5840308848227394044","attemptNumber":1144450877,"members":[142,106,146,172,235,61,93,239,122,20,17,22,2,151,97,255,224,222,188,205,29,187,11,126,39,170,173,94,65,33,91,60,207,241,234,32,157,63,70,150,76,78,183,195,245,140,104,143,247,179,123,26,82,41,152,124,116,119,148,62,18,192,68,87,252,28,176,127,154,171,54,240,105,49,67,112,66,166,153,232,233,138,236,55,100,57,47,46,168,15,21,6,177,137,130,189],"expectedCoordinator":143},{"name":"generated-015-size-175","seedInt64":"-4937924503753896750","attemptNumber":1,"members":[243,172,77,137,3,97,132,48,61,235,213,38,149,70,18,93,144,109,67,4,147,107,187,84,125,155,46,222,140,211,19,26,36,151,66,88,40,139,28,173,112,62,113,45,22,244,119,73,51,35,17,108,250,176,91,63,195,43,221,154,57,183,135,27,162,71,182,53,231,216,12,21,143,236,181,209,141,126,42,129,72,7,83,136,215,50,90,153,175,210,207,118,82,239,39,185,8,37,106,124,114,223,58,168,248,80,79,227,230,165,190,25,85,212,150,157,78,164,159,68,201,133,160,229,254,199,198,2,189,59,123,47,110,174,233,251,111,60,69,75,237,101,65,226,6,202,177,186,86,24,204,219,81,127,96,100,9,89,208,76,117,179,33,252,255,224,214,238,206,29,14,103,130,92,241],"expectedCoordinator":7},{"name":"generated-016-size-4","seedInt64":"-4123447390068133991","attemptNumber":29338,"members":[244,170,225,171],"expectedCoordinator":244},{"name":"generated-017-size-50","seedInt64":"1172508388763733295","attemptNumber":1209681019,"members":[122,117,142,158,98,7,163,118,230,242,137,21,209,5,40,108,103,162,237,153,13,18,215,70,221,185,60,131,59,107,197,114,245,78,124,236,154,112,77,52,176,46,88,217,172,218,19,212,31,228],"expectedCoordinator":98},{"name":"generated-018-size-84","seedInt64":"318580645635970852","attemptNumber":5,"members":[131,142,183,197,76,11,68,209,52,23,136,21,215,139,120,91,226,177,173,164,98,140,53,194,72,169,47,192,100,75,163,217,240,141,144,223,17,36,14,151,178,181,172,255,6,108,251,154,234,225,38,67,143,58,44,242,237,210,104,166,61,39,200,96,213,10,195,148,254,113,5,45,204,252,233,174,250,27,51,247,31,248,15,64],"expectedCoordinator":183},{"name":"generated-019-size-123","seedInt64":"6410882182387587974","attemptNumber":53440,"members":[232,203,212,108,67,91,237,154,111,127,99,244,147,27,81,133,101,14,145,100,215,158,75,68,213,221,24,249,226,207,159,58,211,36,57,174,77,119,162,39,20,54,218,242,63,248,234,86,6,250,46,61,121,255,172,130,38,104,71,34,52,93,107,33,105,43,16,135,82,126,202,73,141,156,153,223,241,243,102,49,109,66,8,125,113,178,148,115,173,239,114,151,78,26,139,245,30,74,92,210,247,230,222,80,165,3,225,195,198,238,217,10,183,94,96,175,252,44,227,84,116,85,166],"expectedCoordinator":27},{"name":"generated-020-size-1","seedInt64":"5763849915000817686","attemptNumber":3899514617,"members":[226],"expectedCoordinator":226},{"name":"generated-021-size-15","seedInt64":"-5876902395680547245","attemptNumber":7,"members":[131,21,211,177,132,63,83,252,82,104,41,122,231,191,221],"expectedCoordinator":83},{"name":"generated-022-size-53","seedInt64":"-4289660314772571792","attemptNumber":58396,"members":[194,206,243,99,130,78,47,165,220,76,42,246,140,218,224,17,124,201,36,192,64,104,233,212,152,49,134,109,145,254,160,88,67,18,20,26,203,235,28,227,98,170,138,87,169,133,154,77,56,150,118,239,62],"expectedCoordinator":212},{"name":"generated-023-size-168","seedInt64":"-8553401789794906947","attemptNumber":608250899,"members":[222,82,51,173,81,245,47,50,228,26,209,163,32,83,114,172,77,16,15,98,140,160,149,49,234,91,201,88,252,185,136,206,23,237,38,12,72,142,184,156,69,57,33,96,63,103,248,90,29,122,186,97,85,211,111,174,46,95,28,183,219,249,6,223,255,11,220,137,166,199,7,132,214,56,177,124,62,130,117,235,203,109,167,18,4,192,100,162,105,197,229,102,236,120,251,182,65,93,74,190,5,20,31,121,43,227,94,230,158,238,150,193,106,242,14,116,66,161,152,196,178,60,135,2,64,101,188,133,108,115,218,92,170,143,112,134,52,68,53,75,86,54,168,41,35,169,225,126,113,153,104,233,36,48,250,17,27,39,123,200,204,59,3,84,145,212,127,246],"expectedCoordinator":245},{"name":"generated-024-size-2","seedInt64":"-5658622308577573375","attemptNumber":2,"members":[200,220],"expectedCoordinator":200},{"name":"generated-025-size-30","seedInt64":"-1797060320633428062","attemptNumber":30275,"members":[171,89,34,152,230,40,125,134,140,32,224,196,188,150,240,92,197,249,17,199,228,155,243,48,1,176,25,235,255,72],"expectedCoordinator":176},{"name":"generated-026-size-84","seedInt64":"5250808479438135811","attemptNumber":2549501700,"members":[60,40,134,39,74,140,71,135,105,239,130,203,22,8,219,160,232,187,227,154,75,66,241,229,4,12,127,150,182,42,1,139,44,226,90,183,207,96,121,91,224,103,118,13,170,136,163,193,123,56,53,98,20,120,47,161,158,234,3,167,54,113,243,145,250,100,225,240,116,188,124,14,237,106,104,77,48,65,247,180,67,133,11,115],"expectedCoordinator":130},{"name":"generated-027-size-126","seedInt64":"-5969063946257651826","attemptNumber":0,"members":[245,204,228,250,182,193,236,55,30,4,92,173,205,103,1,199,66,168,72,80,155,18,118,143,76,192,127,123,165,67,138,8,145,86,161,222,242,70,11,115,56,142,137,15,233,249,71,21,2,7,210,237,50,116,94,130,109,33,227,169,175,181,246,60,97,121,120,140,185,134,83,57,223,107,240,110,78,35,12,200,114,141,112,215,111,75,232,59,150,64,22,133,154,124,149,41,69,63,178,186,183,174,16,99,231,125,14,162,148,179,189,214,139,91,53,20,251,219,84,255,68,48,147,197,184,119],"expectedCoordinator":68},{"name":"generated-028-size-4","seedInt64":"-6448027447294810852","attemptNumber":16135,"members":[123,111,99,46],"expectedCoordinator":123},{"name":"generated-029-size-12","seedInt64":"-9187419539375737694","attemptNumber":26833155,"members":[161,200,181,155,55,118,172,159,110,134,233,87],"expectedCoordinator":233},{"name":"generated-030-size-54","seedInt64":"8330553618221158036","attemptNumber":0,"members":[41,221,231,159,254,40,93,168,200,157,150,68,243,215,140,158,42,117,172,137,213,126,85,226,14,7,37,183,116,246,237,52,222,17,242,10,239,24,121,191,46,207,113,13,62,165,166,219,209,178,188,205,39,92],"expectedCoordinator":205},{"name":"generated-031-size-243","seedInt64":"8971795945033806564","attemptNumber":4203,"members":[106,16,177,18,193,189,227,41,5,164,64,4,241,49,52,194,60,31,117,142,233,221,187,219,159,151,115,110,202,12,74,68,73,136,108,3,181,58,168,127,155,161,61,126,70,180,226,56,103,29,89,222,203,100,67,47,46,27,40,250,130,234,251,71,57,90,165,143,231,76,26,55,20,179,229,72,131,149,254,101,82,78,132,98,62,218,178,105,238,25,1,154,141,119,114,14,242,152,209,183,63,211,174,48,128,207,145,45,237,236,182,198,21,225,170,87,30,28,129,36,210,124,107,195,99,93,125,13,11,111,113,252,247,220,133,65,95,24,208,160,205,188,54,217,22,102,191,167,135,169,253,171,212,84,81,158,186,150,35,134,94,85,172,79,147,53,34,156,109,204,196,228,39,9,148,86,112,43,224,157,118,216,122,15,91,139,197,239,215,10,213,37,235,19,92,175,66,17,244,138,50,243,6,163,245,146,33,104,153,246,80,116,248,192,173,176,240,140,42,75,162,32,96,83,2,166,44,51,255,137,77,23,185,200,97,120,199,38,223,69,230,88,59],"expectedCoordinator":247},{"name":"generated-032-size-4","seedInt64":"-833771324673364962","attemptNumber":2036923630,"members":[60,128,110,216],"expectedCoordinator":110},{"name":"generated-033-size-16","seedInt64":"-5267224248859110678","attemptNumber":2,"members":[37,6,55,255,183,118,1,195,78,210,164,240,156,35,70,253],"expectedCoordinator":1},{"name":"generated-034-size-71","seedInt64":"-1680130775691242466","attemptNumber":60241,"members":[101,94,162,109,167,104,126,103,92,138,137,56,208,148,61,84,125,180,246,145,60,201,147,195,98,252,229,113,211,207,44,244,6,10,99,160,36,33,71,133,135,203,25,88,255,58,210,219,173,3,200,146,185,90,27,163,190,128,12,230,159,206,40,151,91,89,194,51,46,102,23],"expectedCoordinator":252},{"name":"generated-035-size-212","seedInt64":"-6235766049191007581","attemptNumber":299683510,"members":[26,176,204,98,10,100,54,55,173,72,162,196,146,93,18,234,1,88,21,42,114,206,96,83,246,49,24,37,27,80,64,78,31,137,164,242,235,14,180,69,39,48,221,103,115,6,111,185,8,243,127,63,190,136,165,125,145,67,25,60,77,52,247,191,2,94,132,175,177,15,222,117,84,195,155,61,167,122,156,3,154,205,90,74,142,58,7,159,23,158,250,101,248,231,119,211,189,28,239,66,129,65,220,238,200,47,174,16,134,244,163,76,89,226,253,85,133,151,75,141,135,126,150,160,120,147,138,44,184,86,36,34,46,215,107,139,223,92,109,110,41,170,33,149,118,123,40,57,161,245,108,241,59,38,166,71,157,95,251,192,186,32,144,227,219,124,70,152,73,45,188,194,218,128,30,169,29,199,207,113,22,201,82,178,193,102,68,81,104,168,79,143,62,121,19,240,43,116,197,216,252,225,202,11,237,106,255,53,9,50,214,254],"expectedCoordinator":121},{"name":"generated-036-size-1","seedInt64":"6087355440257665262","attemptNumber":0,"members":[188],"expectedCoordinator":188},{"name":"generated-037-size-47","seedInt64":"-6783650387694307171","attemptNumber":61642,"members":[25,229,240,26,235,249,149,106,101,31,237,80,163,23,60,89,108,220,85,70,158,109,78,128,37,182,51,181,153,253,196,225,234,140,160,117,118,40,103,161,68,179,157,192,2,207,16],"expectedCoordinator":70},{"name":"generated-038-size-60","seedInt64":"4098794603937378604","attemptNumber":4204848657,"members":[152,220,171,234,34,190,23,123,5,125,53,50,42,54,37,43,176,213,92,186,164,86,87,226,183,97,13,91,7,163,24,60,74,61,82,47,144,3,151,98,4,88,205,146,90,36,71,27,212,126,66,52,128,206,218,103,68,127,44,75],"expectedCoordinator":52},{"name":"generated-039-size-254","seedInt64":"-4758197881576074366","attemptNumber":5,"members":[49,59,78,75,162,77,246,186,227,250,140,167,228,201,177,90,99,55,159,243,10,176,252,164,158,247,223,170,135,94,171,80,193,235,141,168,203,249,115,36,62,38,155,106,183,12,93,185,224,233,148,239,127,163,197,66,147,81,23,238,132,234,9,192,25,18,149,31,22,184,20,42,215,153,173,120,130,180,210,58,124,53,79,73,101,138,134,83,40,166,56,112,178,91,214,199,15,52,229,16,204,182,248,236,103,181,6,108,3,19,85,198,194,139,100,150,137,21,196,107,133,70,245,76,231,218,209,47,41,65,123,8,222,237,208,240,74,129,126,1,213,154,118,102,39,27,136,113,142,219,11,165,207,212,121,217,48,57,82,96,28,161,50,92,61,205,195,110,67,33,128,89,32,87,14,45,169,88,190,145,255,191,226,29,160,146,220,17,24,7,13,37,244,189,241,30,187,230,54,225,43,174,26,68,179,254,72,202,119,44,97,117,122,105,206,216,98,172,5,2,125,95,46,131,232,104,188,116,84,35,86,51,69,111,63,114,34,211,156,152,144,151,157,221,64,253,143,60,109,242,175,251,200,4],"expectedCoordinator":36},{"name":"generated-040-size-4","seedInt64":"-1271822745184727659","attemptNumber":32493,"members":[232,141,115,166],"expectedCoordinator":166},{"name":"generated-041-size-25","seedInt64":"8287361681381761758","attemptNumber":1987866319,"members":[242,169,166,13,255,154,44,155,197,84,212,244,235,59,170,131,142,185,138,191,98,85,64,156,19],"expectedCoordinator":166},{"name":"generated-042-size-60","seedInt64":"2776996516729268456","attemptNumber":1,"members":[121,182,41,159,99,209,246,188,55,214,82,189,13,15,7,107,215,94,128,163,237,104,18,250,49,32,124,218,223,211,175,12,28,187,110,116,85,38,177,135,141,10,102,239,84,5,253,252,25,147,103,216,158,169,35,166,149,90,108,241],"expectedCoordinator":25},{"name":"generated-043-size-236","seedInt64":"-3363036736625666256","attemptNumber":28183,"members":[39,36,207,141,247,112,102,228,105,115,64,18,165,146,82,26,227,34,139,28,126,177,8,35,4,27,255,145,248,77,130,32,187,81,121,52,246,133,245,123,191,149,235,41,230,154,137,226,132,201,14,109,162,244,31,3,193,100,232,170,58,171,45,202,135,175,124,208,94,166,189,90,197,37,78,53,215,234,103,1,239,106,151,57,15,184,110,190,203,71,33,74,89,161,72,6,250,125,80,168,55,66,92,117,224,252,30,195,93,222,214,251,210,181,84,174,43,76,211,20,167,147,192,240,221,218,153,131,25,229,61,65,233,241,60,163,182,173,86,40,136,238,13,176,220,73,91,127,180,122,50,148,128,186,47,87,172,5,48,155,68,114,44,56,62,143,51,54,216,200,183,219,113,67,101,120,169,10,199,138,188,205,88,157,223,70,63,178,209,21,96,85,194,23,144,118,16,142,69,79,104,204,11,150,243,49,198,38,231,46,22,19,42,116,107,160,75,111,185,237,9,253,95,179,24,225,29,242,108,119,254,212,140,152,98,249],"expectedCoordinator":219},{"name":"generated-044-size-1","seedInt64":"-6879629767712052636","attemptNumber":344504451,"members":[184],"expectedCoordinator":184},{"name":"generated-045-size-51","seedInt64":"7961474955424747536","attemptNumber":1,"members":[21,232,244,79,107,127,54,111,100,181,148,67,240,160,195,33,99,162,179,117,200,235,118,36,154,169,60,88,205,208,191,243,13,134,115,136,199,40,159,146,77,212,178,58,113,217,138,142,173,163,114],"expectedCoordinator":195},{"name":"generated-046-size-55","seedInt64":"-8318809590425626998","attemptNumber":16696,"members":[62,18,155,187,106,79,11,176,33,65,44,59,21,3,99,45,205,112,180,251,184,189,234,231,139,82,57,229,175,17,178,28,7,163,135,40,13,202,84,164,214,100,158,249,215,55,250,160,67,74,194,95,162,182,9],"expectedCoordinator":28},{"name":"generated-047-size-101","seedInt64":"-2022353939353041033","attemptNumber":1484793676,"members":[90,6,158,120,241,209,21,146,119,102,9,107,39,169,125,4,182,183,175,233,57,231,154,81,238,187,255,74,80,181,51,215,29,113,194,246,56,73,196,244,213,235,48,223,61,179,60,108,127,30,19,174,180,218,243,220,16,230,14,70,136,79,96,177,53,207,168,249,166,211,242,162,159,216,160,78,88,101,208,54,201,141,63,5,149,49,240,114,239,98,253,105,155,86,254,134,93,106,75,118,18],"expectedCoordinator":78},{"name":"generated-048-size-4","seedInt64":"-6135348390758098046","attemptNumber":5,"members":[179,205,13,1],"expectedCoordinator":13},{"name":"generated-049-size-11","seedInt64":"-8240015165915168050","attemptNumber":53824,"members":[40,1,75,13,149,165,38,28,188,151,81],"expectedCoordinator":165},{"name":"generated-050-size-88","seedInt64":"-6499412993579667673","attemptNumber":2758221587,"members":[22,183,101,60,167,93,243,150,250,17,225,7,159,127,192,205,34,190,2,15,181,236,249,96,27,134,197,86,77,72,90,48,222,156,151,61,128,133,155,116,28,16,215,168,139,57,110,218,196,131,135,114,237,53,234,245,213,70,76,89,217,202,71,62,232,193,54,230,6,198,104,88,68,251,21,123,141,49,23,67,235,200,223,189,206,165,80,98],"expectedCoordinator":57},{"name":"generated-051-size-123","seedInt64":"-8615701537821470870","attemptNumber":5,"members":[82,228,64,92,79,245,25,119,194,93,162,204,74,121,53,137,190,169,196,71,201,76,221,183,212,186,180,150,30,170,242,24,218,62,200,207,72,161,34,146,114,152,13,52,178,145,29,166,193,253,239,141,107,43,236,191,154,205,118,219,100,173,65,108,51,117,73,98,149,247,23,237,44,110,158,85,17,177,41,155,97,99,56,206,21,33,163,116,87,133,214,3,49,188,32,36,104,31,68,1,225,179,4,46,127,246,109,86,70,254,69,39,26,20,138,123,135,176,18,229,209,11,9],"expectedCoordinator":44},{"name":"generated-052-size-4","seedInt64":"6085274250026097870","attemptNumber":47471,"members":[190,240,44,201],"expectedCoordinator":201},{"name":"generated-053-size-6","seedInt64":"-7983661871081620375","attemptNumber":1925663801,"members":[60,34,146,64,90,208],"expectedCoordinator":146},{"name":"generated-054-size-80","seedInt64":"-2109680148525604779","attemptNumber":1,"members":[160,231,10,247,49,78,208,248,31,170,216,134,174,52,152,233,232,179,103,149,235,166,252,145,70,158,22,220,127,210,112,147,137,76,96,80,202,243,94,229,211,2,66,159,107,142,122,242,180,37,114,25,95,27,62,71,47,140,193,165,217,148,244,46,254,13,156,81,223,177,226,59,176,90,124,246,89,172,182,238],"expectedCoordinator":179},{"name":"generated-055-size-210","seedInt64":"6285159151798525360","attemptNumber":35344,"members":[157,155,235,83,46,210,87,188,191,196,229,65,180,119,79,101,21,193,26,27,200,216,243,255,198,175,126,56,104,187,6,111,19,241,18,135,8,42,39,173,232,51,253,192,141,189,211,234,118,176,184,55,190,62,209,148,163,167,236,166,91,53,63,38,68,244,22,133,248,76,218,165,31,213,114,227,47,144,158,136,162,149,146,4,138,85,80,154,48,124,215,220,161,99,54,43,106,233,71,230,69,7,105,123,3,64,30,29,89,67,109,70,61,24,102,181,41,246,150,249,152,171,172,12,203,116,251,219,97,151,212,170,185,174,147,40,140,50,45,224,32,182,100,247,34,195,228,204,2,59,237,81,92,137,78,226,125,239,96,9,139,214,134,60,145,103,115,164,74,23,16,159,93,72,20,1,108,121,231,82,128,127,143,206,201,86,242,33,90,15,88,217,132,95,202,225,168,112,5,179,156,245,35,84,160,58,37,14,207,110],"expectedCoordinator":55},{"name":"generated-056-size-5","seedInt64":"3281694088511105297","attemptNumber":1737793213,"members":[33,232,16,56,225],"expectedCoordinator":232},{"name":"generated-057-size-36","seedInt64":"-4037002270589620632","attemptNumber":4,"members":[213,2,228,143,254,70,16,200,230,116,65,245,142,33,141,166,246,237,120,26,36,34,234,168,108,186,121,170,177,215,149,173,232,109,148,132],"expectedCoordinator":200},{"name":"generated-058-size-69","seedInt64":"-3215270095027476444","attemptNumber":13598,"members":[120,248,225,217,108,191,156,46,161,160,1,87,142,214,234,193,73,203,60,122,116,112,202,170,151,123,45,107,132,92,182,37,139,43,26,208,244,223,181,117,189,33,162,211,72,36,18,249,166,190,153,69,24,53,238,199,179,118,220,51,247,119,105,100,130,29,49,206,106],"expectedCoordinator":60},{"name":"generated-059-size-215","seedInt64":"-5479145113724476750","attemptNumber":2937994337,"members":[128,244,137,172,14,165,96,123,138,176,217,140,242,224,119,177,250,185,150,166,226,113,47,232,161,187,141,192,120,182,33,55,174,220,198,74,168,4,239,227,229,79,68,67,107,135,25,6,142,28,164,7,125,156,204,215,19,233,31,88,44,15,173,155,153,110,189,151,126,160,254,221,178,114,81,116,201,48,210,222,23,20,251,72,56,97,71,57,200,194,92,26,10,248,77,184,181,218,32,60,112,93,234,129,98,188,49,1,78,152,11,34,179,171,139,216,53,52,228,70,195,82,106,238,43,102,183,65,3,105,63,186,205,46,253,45,41,115,130,223,83,66,180,2,131,91,207,109,59,35,87,246,209,211,145,9,162,95,147,85,219,84,136,231,69,12,203,146,90,75,37,22,124,29,154,235,255,158,38,103,245,76,213,94,117,159,132,16,100,148,30,149,214,111,212,199,121,61,240,163,80,99,169,8,206,144,243,50,252,236,27,196,202,18,134],"expectedCoordinator":41},{"name":"generated-060-size-4","seedInt64":"-403496108953467505","attemptNumber":7,"members":[34,117,232,153],"expectedCoordinator":153},{"name":"generated-061-size-34","seedInt64":"5506762533289306830","attemptNumber":47726,"members":[63,87,47,9,61,71,104,125,147,38,83,146,194,105,67,166,62,46,231,84,75,132,113,43,69,187,22,170,73,139,95,164,89,138],"expectedCoordinator":105},{"name":"generated-062-size-85","seedInt64":"-4191138463886145665","attemptNumber":3556973975,"members":[142,71,223,171,134,150,72,125,11,251,38,30,88,33,199,163,204,120,5,211,60,112,43,96,189,213,93,217,227,65,232,58,113,89,85,253,228,50,45,200,158,172,20,82,240,139,151,122,26,169,42,153,174,73,167,78,46,47,246,70,140,59,210,235,87,208,234,133,17,248,1,54,124,14,249,205,97,216,51,106,123,61,57,219,148],"expectedCoordinator":51},{"name":"generated-063-size-210","seedInt64":"-201899272265178044","attemptNumber":6,"members":[16,122,42,177,154,224,198,195,23,111,233,26,118,183,67,167,171,194,152,144,91,123,134,13,51,199,39,18,8,237,31,95,214,35,242,89,150,179,82,4,229,208,44,176,15,206,79,185,228,108,33,255,25,52,46,253,182,29,187,114,101,153,71,157,139,249,142,169,43,243,197,66,164,68,207,132,75,160,119,170,219,128,184,19,166,6,9,48,50,148,173,213,93,116,203,70,222,64,190,136,49,90,28,47,1,40,110,191,115,34,104,159,103,10,172,3,161,211,215,41,32,162,193,2,30,77,24,251,252,17,57,196,245,45,178,149,58,146,143,192,60,72,92,155,59,145,225,189,186,201,210,244,12,133,247,241,127,85,137,81,220,61,239,231,107,100,73,223,250,65,7,138,212,62,105,180,200,112,37,218,226,63,27,21,130,181,234,5,204,106,109,156,126,175,124,168,120,88,230,209,76,121,96,217,238,140,87,53,165,141],"expectedCoordinator":161},{"name":"generated-064-size-3","seedInt64":"-5111265844278360500","attemptNumber":20775,"members":[243,95,245],"expectedCoordinator":95},{"name":"generated-065-size-35","seedInt64":"-394007837480341028","attemptNumber":2878526896,"members":[49,173,43,141,38,22,172,37,90,58,119,190,80,28,144,112,24,17,1,85,15,45,176,220,105,245,16,77,237,65,123,154,255,227,83],"expectedCoordinator":105},{"name":"generated-066-size-78","seedInt64":"-1519648350433715887","attemptNumber":6,"members":[67,86,171,186,215,63,213,152,222,147,231,169,225,140,154,241,211,72,196,80,104,216,96,76,133,89,111,157,114,34,181,214,165,230,8,71,247,88,35,53,177,244,11,44,69,26,118,159,224,18,55,75,9,170,190,239,16,91,19,167,24,200,207,250,84,93,233,184,1,107,17,188,139,252,90,57,61,130],"expectedCoordinator":225},{"name":"generated-067-size-239","seedInt64":"777876564701688755","attemptNumber":64197,"members":[111,11,188,57,51,144,160,182,249,113,49,238,52,70,152,254,168,143,130,132,175,208,4,47,83,24,140,27,206,43,8,109,252,248,104,219,231,250,184,232,171,116,119,74,234,58,102,255,107,176,19,56,88,67,120,181,72,147,156,189,30,235,38,1,170,129,92,159,18,216,69,139,59,90,3,94,141,40,203,71,12,205,108,227,86,136,200,29,61,229,41,53,50,212,16,214,35,134,101,217,225,26,218,190,2,211,60,73,131,23,68,78,76,209,93,128,197,100,9,221,150,98,55,245,154,82,213,115,48,122,103,177,237,201,222,75,99,194,145,65,137,114,97,193,96,125,228,240,172,63,149,253,246,110,186,239,161,210,22,118,44,25,123,117,14,185,155,251,87,5,89,167,223,187,54,162,135,112,230,183,37,233,158,79,192,198,33,195,164,148,215,146,62,31,236,220,180,13,163,244,127,224,46,81,28,17,34,126,247,178,166,142,15,169,91,106,207,151,36,153,77,66,173,202,6,45,84,32,20,199,243,179,64,105,7,121,157,242,95],"expectedCoordinator":59},{"name":"generated-068-size-1","seedInt64":"5361330154551456030","attemptNumber":285010537,"members":[231],"expectedCoordinator":231},{"name":"generated-069-size-51","seedInt64":"5954310445796927439","attemptNumber":2,"members":[156,27,153,70,23,59,247,43,146,223,250,241,129,244,159,234,51,205,155,109,170,187,147,131,211,185,182,149,53,32,119,61,81,198,1,171,11,100,8,116,202,210,22,5,103,206,229,34,86,37,97],"expectedCoordinator":97},{"name":"generated-070-size-76","seedInt64":"-982993966154804886","attemptNumber":62245,"members":[1,228,114,255,20,56,87,50,104,76,96,248,166,69,121,233,117,106,175,129,172,55,237,252,201,73,217,170,239,158,159,178,47,38,65,131,66,219,44,234,226,10,86,245,155,112,134,249,179,74,82,247,119,207,198,191,59,4,156,203,71,176,70,187,43,194,36,78,212,222,29,240,127,133,157,154],"expectedCoordinator":255},{"name":"generated-071-size-148","seedInt64":"1792812700471506768","attemptNumber":4021902631,"members":[153,113,241,120,76,57,211,151,56,8,219,204,28,13,51,66,185,94,87,128,217,17,107,137,158,101,198,226,1,233,172,207,160,125,91,103,178,230,12,71,78,250,216,183,215,30,19,149,209,6,210,194,150,236,156,121,202,152,201,197,114,65,45,44,38,42,74,69,248,24,118,174,147,40,39,22,251,254,164,155,224,23,243,163,189,232,165,99,177,171,188,52,98,228,252,139,221,104,234,247,21,82,55,218,64,159,186,111,146,79,32,223,246,187,43,41,7,206,140,244,245,4,110,50,167,196,142,136,242,166,9,77,84,75,145,67,27,14,143,92,5,83,88,138,53,48,60,108],"expectedCoordinator":189},{"name":"generated-072-size-2","seedInt64":"739432769285166422","attemptNumber":4,"members":[170,248],"expectedCoordinator":248},{"name":"generated-073-size-7","seedInt64":"-5799010697825483380","attemptNumber":58325,"members":[164,177,92,45,51,49,154],"expectedCoordinator":164},{"name":"generated-074-size-52","seedInt64":"4276450972845508716","attemptNumber":1628425789,"members":[248,235,12,152,173,133,114,243,130,91,197,210,34,56,111,27,17,253,126,75,172,55,141,215,134,73,23,37,128,19,61,26,38,162,166,36,131,127,41,153,49,222,249,218,191,53,206,177,145,189,43,138],"expectedCoordinator":37},{"name":"generated-075-size-131","seedInt64":"-9116009320288614256","attemptNumber":2,"members":[133,31,129,225,204,101,216,146,72,183,117,205,143,202,14,252,74,170,88,30,34,156,219,87,195,135,18,56,9,144,212,197,21,142,218,168,201,76,179,36,86,236,1,113,237,75,17,66,85,196,238,4,60,121,255,15,161,159,130,63,167,149,244,124,194,160,190,107,210,62,223,166,235,46,186,3,246,114,116,5,61,13,10,209,68,180,148,47,71,27,175,54,43,16,208,242,23,37,91,200,213,145,134,182,53,77,64,229,228,172,226,127,177,140,45,11,191,211,155,243,7,52,227,222,89,19,185,138,174,251,128],"expectedCoordinator":15},{"name":"generated-076-size-5","seedInt64":"1967037392293916423","attemptNumber":32183,"members":[143,17,140,73,200],"expectedCoordinator":143},{"name":"generated-077-size-47","seedInt64":"722756409953594652","attemptNumber":736868784,"members":[65,45,99,248,34,9,118,80,111,24,18,195,134,154,220,231,40,46,200,4,233,196,75,216,243,12,77,29,74,1,15,89,205,22,182,247,186,174,147,253,68,213,119,151,107,106,153],"expectedCoordinator":118},{"name":"generated-078-size-90","seedInt64":"5847791647644196462","attemptNumber":3,"members":[25,54,190,142,15,72,33,226,146,3,46,11,28,106,202,89,110,30,116,162,132,102,151,168,124,31,49,167,88,180,63,217,155,58,179,79,67,232,196,56,38,225,52,13,20,208,42,153,134,70,59,191,238,18,60,214,139,178,48,198,235,118,157,81,97,19,184,23,144,253,40,100,204,5,161,176,227,130,188,205,87,82,212,27,147,21,186,145,174,194],"expectedCoordinator":161},{"name":"generated-079-size-237","seedInt64":"-7318126917914551043","attemptNumber":30384,"members":[230,80,103,8,205,237,225,181,53,174,60,123,125,94,110,220,238,55,128,101,195,239,29,97,247,134,154,46,249,163,27,165,175,3,100,219,81,197,199,51,88,76,236,73,244,71,59,38,90,130,206,234,4,75,95,117,215,1,93,250,66,135,16,43,18,189,151,105,137,202,99,177,119,107,115,42,158,2,191,226,211,201,180,157,169,227,186,179,49,11,13,104,192,245,252,207,58,166,10,19,142,224,147,102,96,89,32,233,254,86,61,23,84,240,183,25,116,79,22,156,143,50,72,70,153,41,113,98,160,37,204,111,188,222,196,228,255,92,30,203,35,118,248,243,20,15,17,83,167,12,246,210,164,168,129,194,48,26,241,223,171,184,155,132,36,9,136,6,149,200,87,7,108,112,218,231,57,54,82,47,213,216,214,68,40,52,212,91,144,124,64,161,31,193,221,253,127,85,65,63,150,24,242,148,173,5,162,138,209,176,131,146,170,45,74,121,34,44,109,77,14,56,152,140,145,120,251,114,185,141,126,178,67,39,217,62,78],"expectedCoordinator":45},{"name":"generated-080-size-1","seedInt64":"-5553512206672994256","attemptNumber":2001471067,"members":[25],"expectedCoordinator":25},{"name":"generated-081-size-34","seedInt64":"-754910302847408272","attemptNumber":7,"members":[158,115,10,219,111,61,163,90,59,53,222,194,91,13,5,155,189,198,146,179,126,226,132,232,201,167,70,87,143,98,212,35,156,154],"expectedCoordinator":156},{"name":"generated-082-size-63","seedInt64":"499045964822816795","attemptNumber":27768,"members":[199,92,35,212,238,226,179,65,227,59,1,236,108,132,196,220,215,5,245,139,194,209,101,32,81,96,68,152,82,31,62,151,4,8,90,150,15,239,200,154,42,137,69,124,129,180,105,134,250,178,89,253,115,21,64,207,246,95,162,78,203,252,153],"expectedCoordinator":162},{"name":"generated-083-size-197","seedInt64":"8854687612890123515","attemptNumber":3363899948,"members":[61,193,113,35,149,43,100,26,195,151,254,79,83,192,24,146,161,167,18,102,28,197,46,48,216,203,1,198,227,96,67,248,199,59,29,16,94,81,162,65,51,7,139,157,171,68,206,165,101,137,239,23,242,19,148,106,240,55,20,44,49,135,82,191,234,22,170,190,178,214,230,50,189,84,174,78,36,109,215,152,217,221,183,211,213,186,154,210,229,40,110,52,70,134,243,91,88,150,188,232,219,117,114,104,54,173,12,136,255,182,6,41,231,76,246,176,120,237,64,15,241,233,252,209,251,138,45,87,204,111,247,3,69,175,66,205,53,85,212,143,74,31,108,144,75,63,56,223,220,145,92,14,58,156,177,97,25,245,235,153,95,2,86,131,201,184,112,140,90,194,128,124,38,253,163,39,155,236,130,141,238,72,9,168,226,42,250,37,119,107,244,122,34,5,60,228,159],"expectedCoordinator":90},{"name":"generated-084-size-3","seedInt64":"-808564650378332400","attemptNumber":0,"members":[139,102,159],"expectedCoordinator":159},{"name":"generated-085-size-18","seedInt64":"6937620626493891812","attemptNumber":55636,"members":[246,37,253,177,182,220,59,48,247,69,191,133,146,242,172,189,107,139],"expectedCoordinator":247},{"name":"generated-086-size-61","seedInt64":"-3080056330586482310","attemptNumber":951733443,"members":[138,174,215,18,69,47,79,136,9,23,96,182,7,194,90,16,10,108,106,40,86,232,74,200,157,228,77,12,203,148,152,248,110,253,132,20,143,14,244,123,45,225,137,168,210,85,1,92,17,145,153,82,87,187,61,181,127,176,246,233,109],"expectedCoordinator":109},{"name":"generated-087-size-174","seedInt64":"7923155091191227573","attemptNumber":6,"members":[128,31,100,172,22,219,234,9,249,85,21,73,202,242,255,200,253,207,138,15,51,74,189,8,115,193,238,105,159,246,162,168,240,101,95,18,212,217,120,169,104,245,58,218,223,216,49,68,129,97,125,183,130,241,24,45,121,171,206,210,224,61,243,225,41,186,62,194,14,93,67,182,250,233,174,39,244,44,63,70,136,209,1,140,30,235,131,46,126,111,10,220,197,196,208,60,213,237,215,26,191,153,6,91,198,25,116,148,92,86,64,82,75,199,11,222,231,119,180,150,33,179,12,170,158,110,55,7,4,23,36,142,2,139,149,188,203,107,146,42,94,228,211,201,71,59,34,143,90,108,205,72,16,137,166,127,66,221,98,161,29,147,167,112,187,190,252,53,84,173,20,229,144,124],"expectedCoordinator":25},{"name":"generated-088-size-4","seedInt64":"-7772075196162702918","attemptNumber":6933,"members":[151,174,219,138],"expectedCoordinator":219},{"name":"generated-089-size-6","seedInt64":"4324138335998437962","attemptNumber":2520292755,"members":[171,148,196,51,43,120],"expectedCoordinator":43},{"name":"generated-090-size-96","seedInt64":"-7108590396456967036","attemptNumber":3,"members":[251,138,241,224,56,247,213,237,4,40,57,124,122,104,131,173,254,90,22,92,79,81,232,64,221,242,140,248,121,148,154,27,217,142,151,46,86,106,202,170,190,74,50,83,207,105,132,212,66,14,156,200,116,228,205,54,150,88,69,52,186,35,112,62,162,165,225,39,109,126,214,249,169,177,172,123,243,137,128,78,181,10,159,171,178,70,28,146,115,114,176,204,185,51,3,25],"expectedCoordinator":207},{"name":"generated-091-size-206","seedInt64":"5699283716282044127","attemptNumber":9927,"members":[98,201,195,209,239,160,62,166,87,26,93,127,50,52,197,72,39,255,163,92,154,44,227,205,204,141,6,222,200,155,46,144,175,250,104,213,220,19,53,61,86,229,108,188,171,123,69,238,36,178,225,216,81,115,34,212,210,13,106,192,32,194,3,45,137,139,147,206,226,151,97,136,116,90,189,78,186,254,143,111,203,237,198,109,94,15,242,161,10,83,101,122,95,253,132,114,24,102,134,54,133,77,23,40,60,57,89,28,135,162,183,219,48,241,215,180,76,187,142,21,172,14,156,145,252,177,16,121,35,228,193,25,55,174,51,190,199,221,173,7,17,131,125,224,236,249,31,202,70,58,66,150,240,128,41,164,71,84,4,149,38,231,27,244,82,246,74,158,105,184,185,11,233,168,159,8,165,218,18,42,2,179,112,124,75,148,33,22,117,37,85,110,79,214,29,5,113,129,65,59,217,20,88,47,99,196],"expectedCoordinator":218},{"name":"generated-092-size-1","seedInt64":"3308329852372527413","attemptNumber":459300022,"members":[242],"expectedCoordinator":242},{"name":"generated-093-size-36","seedInt64":"869278551896482336","attemptNumber":1,"members":[235,213,111,63,29,199,232,46,86,91,233,17,228,71,204,189,141,52,38,25,138,173,27,96,150,175,20,54,203,238,208,37,182,60,83,99],"expectedCoordinator":91},{"name":"generated-094-size-64","seedInt64":"437181317485813772","attemptNumber":5250,"members":[162,23,151,25,223,99,121,18,108,28,220,101,31,147,22,154,239,113,187,251,143,89,82,118,214,148,175,69,123,95,180,97,111,38,227,16,3,81,224,98,240,209,141,7,65,21,189,248,39,188,1,152,145,73,156,237,32,139,46,11,234,160,75,62],"expectedCoordinator":1},{"name":"generated-095-size-158","seedInt64":"-2342632002888957506","attemptNumber":430509936,"members":[24,96,93,89,194,139,195,207,13,236,5,47,100,131,238,220,204,164,176,247,144,92,233,64,46,199,19,237,3,103,248,198,241,254,42,115,185,158,75,128,1,25,52,181,107,188,145,67,180,151,224,81,23,66,252,132,149,40,79,250,44,187,99,255,170,148,57,174,49,116,172,37,135,130,183,136,163,166,157,203,206,77,16,30,161,14,41,182,6,213,4,110,21,78,125,35,74,73,222,245,189,226,138,221,160,55,31,87,200,231,8,208,196,253,83,127,118,65,212,119,7,142,165,91,225,234,85,134,229,239,104,223,159,28,43,106,53,56,88,10,129,137,249,95,211,230,242,39,147,178,216,32,11,63,175,9,251,27],"expectedCoordinator":110},{"name":"generated-096-size-3","seedInt64":"-1409214535466672362","attemptNumber":1,"members":[165,41,46],"expectedCoordinator":165},{"name":"generated-097-size-41","seedInt64":"-9040160217759173814","attemptNumber":34752,"members":[81,117,58,120,238,237,78,141,223,110,51,177,64,243,229,170,164,160,73,252,233,126,184,130,128,98,240,175,217,86,129,9,80,59,91,79,144,134,215,104,63],"expectedCoordinator":243},{"name":"generated-098-size-86","seedInt64":"-8297759448010871175","attemptNumber":1077414318,"members":[143,226,37,155,68,28,241,252,224,205,72,139,19,56,61,174,165,159,71,3,14,63,66,39,98,178,197,181,228,134,55,157,160,91,245,38,207,120,195,58,43,196,18,220,109,93,10,24,221,133,249,189,129,127,239,192,17,246,218,113,73,136,41,13,46,214,119,31,244,21,51,149,243,121,9,227,202,199,59,188,251,67,242,219,99,74],"expectedCoordinator":14},{"name":"generated-099-size-167","seedInt64":"-2284973494040658","attemptNumber":3,"members":[43,65,179,97,105,69,162,48,194,114,135,115,174,192,167,93,163,200,112,242,36,15,236,46,23,95,88,238,155,215,30,4,134,128,124,233,217,224,229,156,29,10,143,152,90,237,211,232,188,35,111,172,118,182,16,132,171,26,67,81,187,5,178,117,253,51,186,38,140,235,185,116,198,119,212,193,52,49,168,55,108,154,37,71,122,208,133,254,47,54,76,33,214,21,78,153,68,209,50,165,234,113,216,191,196,62,96,137,89,40,125,158,225,197,164,70,84,255,32,19,80,218,22,110,31,166,189,184,3,7,202,157,123,120,219,2,121,11,241,42,20,204,213,75,53,138,8,83,136,131,14,63,222,9,126,87,17,227,245,141,248,18,86,34,72,66,94],"expectedCoordinator":134},{"name":"generated-100-size-5","seedInt64":"896812837700587291","attemptNumber":10942,"members":[41,48,24,186,153],"expectedCoordinator":41},{"name":"generated-101-size-47","seedInt64":"5195256321549385485","attemptNumber":1557219140,"members":[159,96,103,11,17,151,99,83,226,119,82,8,169,233,89,113,12,108,65,142,136,239,211,42,207,46,171,18,164,38,57,107,51,191,114,22,241,123,237,170,139,138,23,131,173,210,182],"expectedCoordinator":151},{"name":"generated-102-size-96","seedInt64":"-5544863465523421947","attemptNumber":4,"members":[170,14,222,124,65,7,111,99,31,156,255,17,40,60,235,226,132,85,239,96,137,238,50,169,219,92,172,22,176,110,43,192,135,212,144,61,47,173,42,159,105,228,220,8,204,218,177,25,195,143,236,38,76,209,32,215,126,145,91,165,141,44,21,16,207,28,48,72,243,161,214,4,90,86,114,191,73,232,196,245,1,174,211,93,149,162,155,112,130,67,53,12,185,57,80,2],"expectedCoordinator":1},{"name":"generated-103-size-123","seedInt64":"8770007098653717238","attemptNumber":35670,"members":[207,221,8,146,173,199,15,238,110,151,239,3,227,89,59,128,114,147,192,37,232,104,98,249,88,48,121,138,201,150,170,165,131,160,167,235,198,25,224,174,86,214,123,53,196,217,51,245,253,6,124,153,190,20,12,36,139,50,92,90,96,74,41,108,205,109,234,80,68,56,130,180,209,168,52,233,156,181,117,72,134,45,229,191,66,171,226,29,169,145,251,75,120,95,115,255,172,149,76,61,43,63,84,143,21,2,133,175,236,49,144,125,159,230,200,17,242,33,247,211,179,97,193],"expectedCoordinator":109},{"name":"generated-104-size-5","seedInt64":"4264855667354723790","attemptNumber":3035280916,"members":[221,88,172,148,222],"expectedCoordinator":222},{"name":"generated-105-size-16","seedInt64":"-489523979497492863","attemptNumber":6,"members":[40,83,165,97,187,76,60,12,63,175,172,182,134,179,91,68],"expectedCoordinator":68},{"name":"generated-106-size-86","seedInt64":"3927585140628679105","attemptNumber":11163,"members":[145,132,48,250,90,69,22,78,67,240,8,167,20,140,4,166,41,154,57,208,39,215,43,89,2,80,170,185,189,173,106,203,42,210,125,212,217,34,33,247,116,225,47,242,35,172,241,244,161,26,18,207,252,92,144,6,152,227,176,59,138,23,214,65,180,87,142,29,237,130,71,148,112,239,158,14,127,134,156,85,9,12,55,162,169,164],"expectedCoordinator":207},{"name":"generated-107-size-211","seedInt64":"-3282780776666564491","attemptNumber":2427916040,"members":[117,74,1,232,62,178,143,36,173,245,142,42,254,189,126,220,57,201,50,108,243,16,10,112,182,41,52,47,193,104,151,125,146,9,44,138,155,11,29,78,94,239,197,93,58,21,200,145,95,37,237,19,60,70,137,233,128,221,76,46,170,149,32,26,215,124,121,48,14,135,248,250,97,6,30,116,5,51,213,205,98,199,53,177,7,206,227,63,216,153,234,238,105,67,22,255,107,241,157,136,20,84,165,31,150,152,166,219,171,168,54,83,167,65,34,59,49,129,88,69,38,127,45,15,130,224,180,87,188,208,66,71,2,148,101,55,120,114,86,79,28,103,123,225,3,164,147,252,172,195,203,160,122,240,222,242,113,247,176,226,25,18,192,186,218,204,132,119,115,35,12,109,61,175,75,183,39,68,141,181,174,89,131,23,223,210,246,13,163,158,249,190,217,229,40,179,77,56,228,198,196,17,144,73,81,169,161,96,85,106,118],"expectedCoordinator":217},{"name":"generated-108-size-4","seedInt64":"468282769009480890","attemptNumber":0,"members":[197,109,127,113],"expectedCoordinator":113},{"name":"generated-109-size-40","seedInt64":"589500209168864818","attemptNumber":49220,"members":[66,235,110,98,141,114,88,167,221,178,100,123,93,86,181,246,3,199,254,8,13,147,180,59,244,82,51,223,96,240,71,26,10,30,108,95,151,155,19,138],"expectedCoordinator":244},{"name":"generated-110-size-58","seedInt64":"6453479717062776292","attemptNumber":3429615405,"members":[180,253,78,217,239,53,65,172,118,234,32,225,192,83,46,244,135,165,7,25,190,246,70,199,74,181,95,232,104,50,91,159,122,48,139,93,90,28,110,250,143,126,103,150,204,44,169,147,243,175,157,81,37,168,179,41,203,163],"expectedCoordinator":172},{"name":"generated-111-size-240","seedInt64":"-2015854576496395857","attemptNumber":1,"members":[52,170,81,134,250,31,246,69,84,183,216,22,232,35,50,107,102,171,161,56,2,207,242,197,119,227,126,198,238,180,68,41,116,9,151,101,159,51,192,113,143,44,43,82,49,48,16,150,79,133,166,33,181,193,202,91,165,90,114,230,186,39,110,83,162,249,184,154,214,203,67,174,15,147,125,241,240,47,245,46,191,153,190,169,12,80,37,205,213,132,252,77,172,120,226,25,253,219,177,72,88,176,211,14,30,146,78,164,225,229,70,179,152,63,206,212,158,106,105,149,29,53,59,71,104,243,248,188,86,135,167,121,117,18,208,237,36,209,144,60,76,194,185,220,1,62,142,6,201,8,128,95,115,233,148,66,92,93,195,54,57,127,4,136,155,217,251,23,247,223,168,140,122,131,157,204,75,74,124,231,100,28,108,218,85,118,65,236,175,199,17,32,139,87,89,38,21,210,255,55,145,123,224,96,156,26,215,235,160,109,244,5,45,182,163,99,221,34,189,20,196,3,64,222,239,24,234,42,98,27,97,112,129,11,130,254,200,111,61,141],"expectedCoordinator":235},{"name":"generated-112-size-3","seedInt64":"7956552151715933926","attemptNumber":31123,"members":[188,149,223],"expectedCoordinator":149},{"name":"generated-113-size-47","seedInt64":"-4536866646715362967","attemptNumber":710310363,"members":[245,37,91,199,210,137,30,25,220,215,166,224,164,56,170,133,242,190,6,235,157,40,148,254,180,5,7,240,70,181,62,19,179,134,29,66,80,165,48,167,145,72,21,205,155,239,168],"expectedCoordinator":242},{"name":"generated-114-size-90","seedInt64":"6873027339573335067","attemptNumber":4,"members":[37,145,192,241,210,87,81,75,154,89,29,163,42,208,201,49,231,97,165,185,238,107,138,243,184,214,14,59,237,195,255,84,94,62,35,125,90,68,150,215,141,10,15,226,9,50,53,98,105,254,136,24,17,253,76,71,38,235,174,22,188,179,219,55,91,124,20,252,246,172,113,61,160,27,86,43,171,186,12,85,199,187,116,135,106,193,190,16,131,93],"expectedCoordinator":190},{"name":"generated-115-size-232","seedInt64":"-2372759547836105748","attemptNumber":64637,"members":[102,159,251,70,84,131,51,79,63,90,234,203,98,107,95,10,137,83,142,166,118,91,116,103,226,129,29,175,158,239,14,61,149,66,69,141,106,156,254,112,223,8,12,93,170,186,201,222,115,76,120,232,92,13,188,143,212,139,130,133,77,18,50,85,44,72,128,147,110,82,26,157,20,245,196,127,126,48,42,179,15,252,58,68,86,108,224,140,236,78,65,168,4,241,207,231,153,88,225,155,216,31,2,161,111,197,52,3,43,11,101,160,67,198,1,174,183,100,117,238,134,244,192,33,138,119,248,144,173,167,255,230,47,6,89,215,57,195,75,55,104,181,228,210,94,36,124,152,56,176,209,227,182,240,109,123,220,229,146,49,23,60,59,46,189,202,27,7,39,73,64,193,22,122,9,148,30,178,218,214,243,81,35,250,145,237,37,28,99,24,38,74,249,165,5,208,221,62,194,150,199,71,135,21,190,136,80,219,184,41,97,121,87,19,154,213,32,217,211,105,185,180,114,233,96,34,151,177,45,206,17,205],"expectedCoordinator":241},{"name":"generated-116-size-1","seedInt64":"2987736078746814364","attemptNumber":3403786515,"members":[163],"expectedCoordinator":163},{"name":"generated-117-size-20","seedInt64":"-3262731345082553583","attemptNumber":0,"members":[119,215,113,10,116,118,127,146,189,142,190,202,25,47,31,174,80,77,165,129],"expectedCoordinator":165},{"name":"generated-118-size-96","seedInt64":"-4255045493980341817","attemptNumber":47439,"members":[130,249,161,168,196,218,157,67,21,20,90,139,30,80,187,224,38,203,205,144,28,242,12,226,158,159,209,82,106,207,29,165,79,41,184,115,36,133,68,33,35,93,234,78,177,137,175,171,11,253,180,160,225,197,98,101,235,240,5,173,189,233,166,131,119,210,65,71,49,60,26,75,151,51,117,238,181,58,59,103,54,152,251,40,135,37,52,194,204,47,179,15,143,136,170,34],"expectedCoordinator":189},{"name":"generated-119-size-131","seedInt64":"-2630668850182605260","attemptNumber":3538401091,"members":[220,214,190,133,109,35,117,96,161,206,223,143,62,116,89,134,225,1,218,72,240,182,249,237,123,11,127,121,129,197,175,231,154,157,26,13,196,106,144,104,171,12,139,194,120,235,77,9,212,93,228,39,158,6,126,245,189,178,167,66,204,34,99,172,40,53,57,41,163,246,124,141,68,30,17,253,71,37,54,222,165,105,138,227,151,65,135,122,70,209,184,111,19,46,236,76,16,215,200,83,32,119,224,226,29,131,98,146,250,176,170,149,5,43,103,73,110,48,202,248,203,113,118,50,64,2,179,128,148,181,92],"expectedCoordinator":40},{"name":"generated-120-size-3","seedInt64":"4007936685134953","attemptNumber":1,"members":[40,155,220],"expectedCoordinator":220},{"name":"generated-121-size-20","seedInt64":"-6866019563340355907","attemptNumber":62275,"members":[17,243,92,123,111,150,168,189,241,139,180,86,130,142,136,78,157,204,205,96],"expectedCoordinator":92},{"name":"generated-122-size-91","seedInt64":"3118597197668961341","attemptNumber":3226070506,"members":[22,108,35,138,152,39,54,153,219,178,128,119,106,5,148,139,249,151,125,43,230,129,58,107,252,78,42,206,6,65,30,246,77,240,23,134,117,51,60,198,150,158,241,157,31,55,204,166,75,122,174,100,26,69,203,248,221,25,33,40,92,238,91,133,154,1,19,200,88,226,27,87,149,57,189,38,4,59,89,233,210,131,121,41,9,141,165,61,13,232,137],"expectedCoordinator":249},{"name":"generated-123-size-141","seedInt64":"1664817802804734561","attemptNumber":2,"members":[27,200,152,124,5,143,92,242,247,236,231,171,226,233,245,181,189,149,4,216,155,178,196,157,243,217,132,214,235,28,61,213,141,71,59,250,246,72,97,12,142,193,160,86,136,234,45,230,206,253,110,20,106,48,197,182,203,1,37,111,146,115,129,22,215,79,239,210,70,173,88,201,225,90,162,53,118,16,60,164,135,8,42,39,109,126,89,237,184,188,148,100,238,207,229,19,147,98,145,117,31,113,169,222,224,119,50,58,163,56,77,47,174,63,209,49,15,93,13,183,104,116,170,83,66,137,80,151,177,251,108,228,219,192,175,64,186,76,40,254,227],"expectedCoordinator":200},{"name":"generated-124-size-2","seedInt64":"-2806538414172921050","attemptNumber":49055,"members":[213,42],"expectedCoordinator":213},{"name":"generated-125-size-46","seedInt64":"-7671032942243247828","attemptNumber":4007891092,"members":[94,87,236,60,192,51,32,123,170,59,99,45,37,226,18,34,141,197,135,159,17,63,81,9,61,64,27,215,29,36,108,214,219,119,112,151,208,82,209,12,181,253,65,243,143,92],"expectedCoordinator":81},{"name":"generated-126-size-86","seedInt64":"-679015987674837067","attemptNumber":1,"members":[55,221,30,255,35,179,6,150,117,131,133,63,174,210,211,119,86,87,142,254,156,193,58,3,178,171,190,52,204,67,64,9,225,19,224,132,200,20,138,109,115,53,189,110,146,246,219,227,29,71,21,158,166,195,12,54,50,101,103,141,56,167,207,184,47,82,148,39,69,147,239,222,155,154,244,41,93,46,202,237,187,78,243,196,197,245],"expectedCoordinator":53},{"name":"generated-127-size-114","seedInt64":"-1110729648211256631","attemptNumber":34208,"members":[153,162,216,121,129,252,106,193,112,13,78,171,35,21,192,82,152,34,18,197,48,203,194,80,23,185,108,39,94,81,4,188,99,199,137,234,176,247,17,95,102,231,66,132,246,179,86,67,44,167,30,68,84,151,180,240,187,114,134,62,207,211,92,227,8,232,100,46,169,65,172,156,204,191,205,85,116,64,235,170,159,29,175,3,213,24,54,52,177,107,51,14,76,37,174,138,115,245,241,206,160,242,122,83,27,58,183,228,209,142,161,75,38,255],"expectedCoordinator":162},{"name":"generated-128-size-5","seedInt64":"-2399282708863319109","attemptNumber":3534437779,"members":[227,108,18,251,73],"expectedCoordinator":108},{"name":"generated-129-size-29","seedInt64":"441644865197136334","attemptNumber":5,"members":[66,184,25,202,152,146,83,210,52,220,198,93,6,26,76,91,233,80,185,218,19,241,45,209,31,69,192,82,157],"expectedCoordinator":83},{"name":"generated-130-size-70","seedInt64":"-2963717690369327290","attemptNumber":43347,"members":[79,85,211,198,36,186,130,231,234,112,195,208,243,166,87,209,246,60,132,235,68,167,5,177,39,237,183,233,170,142,218,155,173,126,191,175,56,116,80,96,217,48,229,194,70,255,105,1,144,160,121,49,125,102,32,76,129,77,109,74,225,192,201,133,75,239,92,185,165,242],"expectedCoordinator":166},{"name":"generated-131-size-232","seedInt64":"2220690960821665440","attemptNumber":1622707130,"members":[101,69,143,247,177,42,250,150,217,19,21,61,162,199,28,95,216,119,73,115,248,108,213,170,99,141,165,215,59,74,106,54,229,110,195,172,179,241,17,2,144,45,222,171,124,55,34,26,206,223,90,32,168,81,109,79,107,18,20,173,201,193,129,23,75,133,227,70,57,76,218,50,130,205,185,146,139,191,98,104,183,198,87,102,194,175,243,167,204,72,132,3,121,64,37,84,118,169,105,94,16,156,161,12,196,164,125,58,8,138,113,116,252,112,6,10,221,127,160,97,214,52,65,31,231,53,14,147,49,24,253,255,60,123,157,4,44,78,39,9,100,111,203,237,239,174,197,77,89,80,245,184,140,153,85,68,163,224,82,225,5,48,211,91,249,178,1,145,251,137,158,155,219,92,62,136,208,93,230,83,151,30,38,189,33,180,212,96,7,232,36,159,190,207,27,41,242,120,47,228,29,56,148,202,11,22,43,15,67,88,246,86,220,40,63,135,51,154,126,35,114,181,25,188,233,152,238,236,192,234,235,122],"expectedCoordinator":218},{"name":"generated-132-size-4","seedInt64":"3936019971243938880","attemptNumber":6,"members":[7,184,24,163],"expectedCoordinator":7},{"name":"generated-133-size-34","seedInt64":"6002883366149145476","attemptNumber":27992,"members":[101,53,169,241,2,233,49,119,74,171,215,160,36,77,108,222,14,6,207,95,176,238,192,91,100,80,168,29,229,153,205,120,112,109],"expectedCoordinator":153},{"name":"generated-134-size-86","seedInt64":"560321468927931761","attemptNumber":3614533710,"members":[162,90,177,163,150,128,196,114,226,93,109,174,249,14,63,218,166,132,185,60,231,16,77,85,153,200,105,161,239,125,70,67,120,44,84,91,141,39,82,165,159,142,237,86,87,9,219,169,156,80,208,40,192,68,181,244,252,24,225,25,250,173,65,213,79,42,20,10,28,116,221,130,41,118,233,143,188,230,136,95,214,178,148,209,15,186],"expectedCoordinator":178},{"name":"generated-135-size-229","seedInt64":"-4738644874070653480","attemptNumber":7,"members":[238,224,194,31,62,64,27,137,201,126,149,221,235,188,107,37,182,206,152,155,246,12,162,24,227,115,118,139,106,53,113,123,134,132,141,93,197,54,236,63,100,180,192,40,229,193,168,214,5,13,25,17,161,77,245,184,81,34,219,75,217,239,213,156,97,82,251,187,243,87,178,211,29,207,181,19,95,209,116,230,249,196,127,11,232,101,52,226,189,183,74,250,198,151,216,1,14,253,7,231,167,96,35,103,190,208,240,2,90,210,70,120,6,218,195,104,254,124,147,140,88,55,110,72,22,83,170,172,122,244,142,68,154,121,18,42,71,38,28,128,255,109,146,94,86,242,133,203,45,15,98,205,212,200,3,166,102,56,76,164,175,99,36,159,204,10,39,163,89,131,111,92,233,73,61,153,114,23,30,148,50,241,157,51,169,248,191,58,135,176,179,160,247,117,8,199,185,158,33,186,225,145,215,237,41,222,144,78,59,16,91,4,143,43,202,150,66,26,20,125,119,138,79,136,84,171,223,105,47],"expectedCoordinator":133},{"name":"generated-136-size-5","seedInt64":"-8441601396195031722","attemptNumber":24454,"members":[119,232,132,198,15],"expectedCoordinator":119},{"name":"generated-137-size-20","seedInt64":"5384059453162266222","attemptNumber":900032409,"members":[135,190,20,125,221,189,37,172,136,131,101,27,15,228,226,60,244,67,230,71],"expectedCoordinator":189},{"name":"generated-138-size-96","seedInt64":"-4889012937073914130","attemptNumber":3,"members":[42,210,38,5,8,56,128,39,83,212,188,110,253,116,157,71,2,3,120,43,96,88,132,23,21,100,93,9,46,230,102,97,80,199,187,205,245,33,36,81,235,86,191,166,249,179,53,178,18,59,87,250,233,239,255,219,74,19,13,15,138,69,121,254,52,14,237,16,85,231,203,213,24,224,31,106,209,28,227,202,48,142,105,170,66,11,49,20,122,27,90,41,232,240,113,78],"expectedCoordinator":138},{"name":"generated-139-size-197","seedInt64":"-3996304714434985212","attemptNumber":47596,"members":[95,21,209,225,244,124,63,82,191,8,31,89,212,30,144,162,250,105,137,153,228,126,157,152,223,255,40,52,159,218,9,198,83,87,207,2,140,211,133,177,182,99,142,116,164,248,43,123,64,23,109,143,70,51,145,136,187,61,178,156,104,19,119,219,201,195,102,176,190,131,73,80,138,125,100,220,132,7,58,38,171,130,39,33,76,4,217,215,135,245,236,175,246,158,36,69,221,150,128,108,17,117,53,237,68,118,37,66,115,110,181,75,41,253,251,149,97,86,185,59,13,111,189,243,139,134,122,18,231,165,179,25,224,27,197,196,88,114,168,5,49,214,235,167,229,113,186,249,54,163,239,72,155,24,180,199,46,173,79,15,1,184,226,208,121,254,28,120,232,240,20,90,172,47,56,193,107,26,148,106,103,11,85,169,233,129,78,45,6,10,192,14,141,222,32,42,200],"expectedCoordinator":129},{"name":"generated-140-size-4","seedInt64":"6941898763687336332","attemptNumber":4180205159,"members":[181,114,109,236],"expectedCoordinator":181},{"name":"generated-141-size-21","seedInt64":"3745590442053443273","attemptNumber":6,"members":[166,255,68,45,254,150,203,154,168,186,200,67,42,227,159,108,69,221,213,113,88],"expectedCoordinator":227},{"name":"generated-142-size-72","seedInt64":"-1934711817258154969","attemptNumber":49030,"members":[23,55,216,43,221,64,117,192,166,75,86,82,106,31,108,67,156,220,32,104,65,98,172,229,115,167,110,53,130,228,142,84,213,247,231,159,144,153,158,17,30,223,243,233,122,226,93,21,69,198,203,180,78,3,125,42,79,145,25,196,24,97,185,136,46,39,94,87,89,59,129,100],"expectedCoordinator":196},{"name":"generated-143-size-236","seedInt64":"7576952677858728614","attemptNumber":2905859179,"members":[65,124,188,2,223,66,57,158,174,108,162,93,30,228,219,121,100,144,36,18,242,182,169,87,119,73,246,107,151,216,135,64,71,189,104,229,178,114,96,252,125,173,163,101,215,14,35,139,192,232,112,105,210,131,167,177,196,165,160,225,44,195,95,141,198,166,164,148,184,147,54,128,58,159,157,122,175,39,152,208,238,202,118,155,5,113,218,133,224,76,15,209,120,21,4,149,103,1,204,47,161,92,75,6,129,180,16,67,136,91,111,123,243,83,27,37,146,38,183,41,212,72,211,191,213,172,52,154,26,142,34,187,221,194,49,60,10,168,3,190,179,241,62,42,51,235,98,84,12,31,185,63,82,134,170,77,70,156,171,78,214,176,200,199,24,110,205,240,29,48,234,17,239,150,13,143,88,245,244,116,11,236,109,255,81,50,186,89,46,249,237,7,220,137,79,74,53,22,68,145,140,55,203,19,153,90,126,193,20,248,59,45,250,227,230,99,43,181,97,80,102,25,206,138,32,226,247,8,61,127,69,40,117,28,201,233],"expectedCoordinator":28},{"name":"generated-144-size-4","seedInt64":"-282390920246923885","attemptNumber":1,"members":[192,43,110,252],"expectedCoordinator":43},{"name":"generated-145-size-22","seedInt64":"1878532552470394837","attemptNumber":58977,"members":[139,236,29,131,32,17,196,214,198,180,49,96,152,71,159,202,11,119,185,135,145,62],"expectedCoordinator":71},{"name":"generated-146-size-72","seedInt64":"-8248802214909604400","attemptNumber":3972382412,"members":[197,145,25,13,173,251,82,132,189,78,214,101,236,230,187,15,188,121,56,74,140,108,239,136,222,192,34,126,8,141,223,16,59,213,46,165,106,209,150,134,139,242,50,193,109,191,196,23,71,7,5,39,35,86,75,89,253,163,28,112,44,254,162,103,240,33,143,161,130,171,238,248],"expectedCoordinator":15},{"name":"generated-147-size-110","seedInt64":"1353775832355132400","attemptNumber":2,"members":[104,123,82,195,155,186,38,110,223,55,8,193,90,28,225,113,126,145,72,85,194,77,4,244,135,35,102,210,7,176,169,192,74,215,116,222,216,220,105,11,214,29,9,173,132,255,198,109,147,26,229,57,190,2,171,133,42,62,114,130,44,111,154,117,25,5,66,121,167,189,94,141,92,177,165,157,17,70,218,128,148,172,236,91,150,245,137,144,181,95,46,230,161,127,63,122,32,185,164,50,40,224,134,180,3,84,59,64,143,131],"expectedCoordinator":137},{"name":"generated-148-size-4","seedInt64":"-704719671913888052","attemptNumber":32746,"members":[141,24,200,148],"expectedCoordinator":200},{"name":"generated-149-size-45","seedInt64":"-8265908843381039538","attemptNumber":950210701,"members":[226,148,219,98,172,12,230,192,3,169,36,170,106,88,35,111,97,74,89,68,75,247,25,63,112,122,186,28,57,26,218,99,156,173,47,215,82,128,179,30,189,142,185,116,24],"expectedCoordinator":170},{"name":"generated-150-size-56","seedInt64":"3065043156261386358","attemptNumber":7,"members":[49,154,13,139,244,251,185,1,253,246,219,95,23,186,237,161,172,166,70,212,75,74,14,188,94,221,55,183,197,205,236,44,90,98,78,69,106,100,105,102,16,109,241,104,22,142,20,121,248,226,141,196,135,28,89,243],"expectedCoordinator":188},{"name":"generated-151-size-127","seedInt64":"-1414513725948302602","attemptNumber":8881,"members":[153,110,132,180,101,8,59,131,92,226,231,196,228,152,56,129,120,201,224,197,96,190,93,89,145,5,192,240,123,127,166,122,65,85,162,177,98,41,30,248,74,12,13,90,188,33,99,31,87,164,3,239,42,168,107,252,202,57,242,97,91,76,173,108,172,206,20,150,118,214,199,198,64,46,243,37,203,84,24,17,135,112,67,14,241,119,128,66,106,149,95,157,73,155,116,11,80,103,225,193,209,191,215,189,94,176,61,19,62,78,50,255,216,71,146,124,82,175,210,83,53,26,170,181,254,22,184],"expectedCoordinator":192},{"name":"generated-152-size-2","seedInt64":"-602398437191585362","attemptNumber":3233414658,"members":[165,158],"expectedCoordinator":165},{"name":"generated-153-size-27","seedInt64":"-6883469786163592992","attemptNumber":7,"members":[116,8,121,197,125,107,53,102,109,118,48,171,78,156,127,98,62,72,17,136,90,155,193,100,97,22,198],"expectedCoordinator":109},{"name":"generated-154-size-95","seedInt64":"-6340230278701012813","attemptNumber":35881,"members":[207,30,73,70,80,16,139,195,102,94,183,252,69,125,137,104,249,236,232,229,187,13,154,203,244,91,170,34,242,43,174,196,82,152,245,81,168,109,138,32,188,71,191,103,189,136,173,72,112,122,126,141,4,48,47,164,28,180,96,92,5,108,64,151,210,44,156,37,186,206,225,110,18,254,2,89,85,119,199,211,228,114,132,145,163,162,178,15,24,239,66,171,158,175,253],"expectedCoordinator":13},{"name":"generated-155-size-177","seedInt64":"9180814183342714081","attemptNumber":3492885651,"members":[85,92,96,90,174,74,163,254,157,53,95,192,191,201,6,239,111,60,229,68,61,22,150,153,219,52,66,69,147,42,248,57,125,119,209,110,47,165,141,26,149,88,8,223,207,86,224,70,124,200,120,234,122,102,121,216,25,215,50,24,218,123,135,236,55,16,142,211,128,89,244,190,7,59,56,34,15,173,183,205,175,251,225,65,255,253,166,181,193,31,235,197,245,180,233,45,131,247,220,129,23,5,217,27,113,127,79,168,252,21,115,126,232,29,182,75,10,13,167,3,228,49,161,176,43,162,143,154,187,202,105,101,118,152,41,246,14,30,138,148,73,51,107,76,2,4,226,109,37,28,99,199,91,81,189,208,194,93,137,132,54,140,1,186,48,249,94,130,195,221,97,231,83,185,160,100,210],"expectedCoordinator":79},{"name":"generated-156-size-5","seedInt64":"-298163913933788100","attemptNumber":2,"members":[157,61,241,162,126],"expectedCoordinator":157},{"name":"generated-157-size-21","seedInt64":"2056457369471652497","attemptNumber":27731,"members":[170,143,98,134,144,121,78,44,47,77,193,154,183,67,235,101,88,220,252,213,233],"expectedCoordinator":143},{"name":"generated-158-size-55","seedInt64":"6552520946031498069","attemptNumber":225110310,"members":[105,172,64,152,175,212,55,88,204,228,80,51,7,108,144,174,177,32,26,234,103,255,115,128,164,161,15,2,192,3,124,20,181,138,93,76,232,77,141,22,127,9,236,86,221,132,117,130,140,179,13,21,205,29,12],"expectedCoordinator":172},{"name":"generated-159-size-208","seedInt64":"-1472061768355741956","attemptNumber":3,"members":[88,109,103,213,204,162,36,210,116,112,90,44,205,89,144,81,226,234,239,60,117,147,85,191,105,57,232,208,211,11,138,189,55,86,33,28,78,5,58,130,70,176,123,252,73,99,110,25,48,66,139,188,197,94,22,74,246,120,167,194,253,238,184,141,93,182,140,212,125,83,225,31,45,169,49,16,76,187,248,98,203,221,160,161,209,174,37,175,240,115,61,222,202,97,54,152,131,143,71,122,41,18,35,12,108,214,23,50,163,77,228,251,198,142,255,95,19,235,247,17,39,224,64,242,119,72,106,241,216,151,38,121,177,193,126,244,40,7,14,146,192,207,56,157,87,185,186,164,172,26,82,218,127,168,199,75,243,173,133,155,4,46,206,111,69,15,104,237,30,79,100,114,190,84,118,107,29,47,63,128,92,3,178,68,236,2,124,233,230,245,215,156,51,231,249,42,137,180,53,32,113,62,170,149,181,80,67,158],"expectedCoordinator":140},{"name":"generated-160-size-1","seedInt64":"681655156400984660","attemptNumber":13218,"members":[127],"expectedCoordinator":127},{"name":"generated-161-size-22","seedInt64":"-5822253159313495463","attemptNumber":2225385698,"members":[154,87,67,143,17,248,34,52,149,93,43,122,182,160,137,121,85,63,78,159,49,230],"expectedCoordinator":122},{"name":"generated-162-size-67","seedInt64":"3310535431795534626","attemptNumber":0,"members":[159,126,29,44,163,24,39,34,105,186,228,10,70,161,150,235,51,204,11,224,47,7,73,178,160,207,226,46,28,239,201,95,120,168,157,223,58,194,192,247,211,57,166,144,78,195,215,100,137,119,220,23,53,62,151,222,181,49,147,42,149,35,140,66,229,169,219],"expectedCoordinator":73},{"name":"generated-163-size-123","seedInt64":"2743252143534095320","attemptNumber":22126,"members":[9,96,34,146,43,172,37,51,95,57,142,36,114,19,190,56,27,52,241,129,47,196,236,213,147,221,104,88,29,15,187,30,94,209,181,107,197,244,110,194,198,155,189,230,71,212,226,228,177,127,125,120,92,225,150,4,49,250,32,130,231,149,217,161,206,246,48,109,38,81,175,106,159,122,100,6,160,132,45,59,202,91,23,208,154,151,63,232,233,222,251,20,252,141,243,170,140,124,166,164,240,55,214,178,12,253,28,138,70,13,210,97,33,227,135,237,25,35,162,11,75,40,211],"expectedCoordinator":124},{"name":"generated-164-size-4","seedInt64":"-6913987789750835670","attemptNumber":4004014375,"members":[32,61,206,131],"expectedCoordinator":61},{"name":"generated-165-size-37","seedInt64":"4704084464270051707","attemptNumber":2,"members":[180,54,234,90,107,154,63,133,145,134,212,57,46,39,73,80,94,192,243,235,64,240,237,170,76,182,91,123,18,82,67,87,32,167,226,1,172],"expectedCoordinator":243},{"name":"generated-166-size-82","seedInt64":"7493457394091395981","attemptNumber":52541,"members":[167,183,36,206,221,236,247,223,131,37,142,152,146,159,213,25,168,101,92,203,243,59,217,88,125,83,198,116,165,33,197,51,143,196,199,173,154,228,12,28,115,151,30,8,231,60,240,218,110,29,144,46,164,73,78,86,16,193,161,85,136,108,38,80,63,128,27,9,24,11,232,99,82,244,190,57,56,68,104,74,226,180],"expectedCoordinator":85},{"name":"generated-167-size-144","seedInt64":"-356356534523922542","attemptNumber":3417387800,"members":[11,244,147,78,226,215,27,203,64,89,65,133,213,158,207,47,12,83,84,206,197,225,249,212,151,243,195,156,170,123,85,172,155,113,198,59,45,68,205,164,30,41,7,189,236,20,196,49,18,22,224,234,101,176,163,46,179,141,51,235,148,2,160,254,221,72,204,192,146,181,16,107,124,104,230,80,154,255,169,122,69,233,102,173,9,93,13,120,177,129,82,23,103,191,199,208,222,106,245,42,227,178,201,214,5,40,251,138,26,248,202,81,57,238,165,127,162,166,28,216,157,150,232,37,183,60,132,217,194,39,86,99,188,61,175,112,218,98,200,130,8,219,228,242],"expectedCoordinator":234},{"name":"generated-168-size-4","seedInt64":"-289654501694959618","attemptNumber":6,"members":[237,200,242,156],"expectedCoordinator":242},{"name":"generated-169-size-14","seedInt64":"3462692091532748907","attemptNumber":4601,"members":[218,62,173,138,5,191,23,199,20,47,221,205,38,154],"expectedCoordinator":205},{"name":"generated-170-size-70","seedInt64":"4146950138448183747","attemptNumber":817442080,"members":[101,54,98,42,119,198,44,34,183,205,90,202,81,12,112,243,128,222,230,80,184,17,216,251,97,209,192,145,93,233,122,190,221,161,228,39,162,144,19,130,176,242,52,117,174,7,133,210,208,170,41,159,22,219,254,102,156,253,215,38,67,73,154,236,59,24,141,57,108,137],"expectedCoordinator":190},{"name":"generated-171-size-222","seedInt64":"-1632780058821013484","attemptNumber":2,"members":[207,7,242,39,212,124,38,37,227,142,101,126,234,148,253,173,136,198,117,158,51,93,34,209,161,180,141,125,210,55,171,14,144,252,58,45,15,135,146,165,213,151,237,21,107,25,98,87,76,120,143,147,83,43,188,85,92,170,74,167,208,128,119,172,175,236,100,32,118,216,154,77,1,26,241,230,232,19,192,60,72,89,224,94,134,233,228,9,130,247,62,184,49,157,152,186,246,112,91,90,235,57,64,244,155,103,5,205,204,139,191,18,250,217,10,177,40,81,56,137,174,17,105,196,181,166,218,156,99,245,109,28,13,122,4,225,201,115,176,59,65,88,44,106,150,197,80,159,33,221,66,178,162,23,84,16,223,104,12,193,226,30,121,50,202,220,95,35,153,108,111,52,2,42,110,163,46,53,36,222,249,82,29,54,97,8,96,145,67,248,27,160,63,255,200,123,183,114,199,187,219,113,86,238,240,149,168,190,75,132,194,24,251,71,47,215,127,116,31,140,254,69],"expectedCoordinator":89},{"name":"generated-172-size-2","seedInt64":"5638754981721726777","attemptNumber":51454,"members":[35,222],"expectedCoordinator":35},{"name":"generated-173-size-28","seedInt64":"-4974233506603298602","attemptNumber":1531003781,"members":[152,117,128,186,203,244,217,248,84,222,95,38,126,23,50,104,56,229,71,150,18,162,220,118,99,121,138,10],"expectedCoordinator":84},{"name":"generated-174-size-62","seedInt64":"-1320490978783509885","attemptNumber":7,"members":[33,231,222,40,199,28,12,194,232,203,46,41,58,255,27,229,126,125,81,2,163,204,15,61,228,173,251,107,162,200,14,4,136,154,24,54,182,165,172,217,152,135,129,3,226,22,25,188,161,122,115,242,121,224,184,215,252,175,119,247,43,211],"expectedCoordinator":126},{"name":"generated-175-size-208","seedInt64":"-118113626857321878","attemptNumber":63168,"members":[63,198,2,35,4,249,166,88,34,41,173,202,224,196,31,205,226,27,98,75,236,28,128,122,174,54,85,223,188,38,237,82,96,20,94,181,68,60,104,127,245,5,144,239,183,219,207,199,44,74,184,158,241,193,117,55,59,208,149,229,214,79,238,80,56,195,26,253,176,123,92,106,100,16,46,1,133,52,40,248,209,185,101,57,152,66,231,49,242,194,246,155,121,29,235,72,131,240,23,212,225,201,83,187,145,19,151,217,107,87,7,53,97,161,190,103,17,37,143,130,159,140,150,105,168,12,179,178,250,228,247,148,65,77,170,113,43,13,15,50,78,108,99,120,138,134,252,70,32,162,135,3,33,160,137,132,251,124,218,118,156,22,192,206,42,220,215,86,73,186,233,84,167,58,62,116,157,71,95,81,111,222,180,24,230,243,36,119,154,165,6,164,244,109,25,163,169,8,182,18,171,125,76,30,142,203,197,175],"expectedCoordinator":122},{"name":"generated-176-size-3","seedInt64":"993162481201941121","attemptNumber":464118810,"members":[108,44,93],"expectedCoordinator":108},{"name":"generated-177-size-6","seedInt64":"-6826145883722901905","attemptNumber":0,"members":[110,229,51,211,1,38],"expectedCoordinator":38},{"name":"generated-178-size-51","seedInt64":"-1737530234147444458","attemptNumber":16429,"members":[75,11,195,43,76,170,80,69,52,183,231,83,145,114,214,138,227,189,57,237,225,101,165,31,223,200,244,46,211,246,109,247,160,15,132,42,176,110,123,19,131,228,2,196,124,10,51,147,134,175,26],"expectedCoordinator":227},{"name":"generated-179-size-248","seedInt64":"-8976107252718925492","attemptNumber":3055357771,"members":[117,111,145,222,199,184,109,151,147,141,210,164,113,223,144,134,209,28,228,69,119,97,62,52,106,247,249,59,105,232,123,27,153,38,110,30,61,50,218,44,60,195,20,26,238,154,99,31,137,135,67,239,89,86,216,83,186,104,63,131,130,107,124,16,25,114,220,227,193,133,162,36,163,129,1,211,206,180,14,196,157,138,51,166,204,54,252,46,217,132,140,47,161,64,48,19,187,126,42,43,244,100,73,37,229,240,245,231,225,148,213,84,10,178,198,253,139,172,96,90,150,158,95,156,81,183,33,248,190,55,88,101,103,35,250,41,2,201,169,6,56,170,78,7,177,112,146,224,68,234,11,243,155,92,93,85,203,71,212,251,200,3,173,75,122,94,167,254,80,98,202,23,136,57,171,65,125,152,9,191,91,4,237,12,214,221,197,17,181,175,45,149,40,255,179,185,5,18,127,34,24,165,174,208,242,32,168,21,207,205,108,121,76,116,192,241,236,77,66,226,246,118,176,160,79,182,194,120,82,233,22,58,39,188,70,8,13,87,49,29,53,128,74,159,143,142,15,72],"expectedCoordinator":126},{"name":"generated-180-size-1","seedInt64":"-8921232748599113321","attemptNumber":2,"members":[42],"expectedCoordinator":42},{"name":"generated-181-size-16","seedInt64":"-8162888834982691457","attemptNumber":30924,"members":[130,88,186,223,229,169,45,100,13,1,166,80,213,114,205,187],"expectedCoordinator":169},{"name":"generated-182-size-54","seedInt64":"6633691383179599504","attemptNumber":1276020348,"members":[136,59,41,97,195,182,44,142,51,49,114,225,231,167,181,163,83,46,228,253,242,3,161,9,71,102,64,205,13,10,224,192,112,197,133,180,1,82,74,117,111,65,250,29,154,7,208,30,52,222,24,98,62,5],"expectedCoordinator":41},{"name":"generated-183-size-166","seedInt64":"-2262985641463040413","attemptNumber":7,"members":[154,50,146,52,16,225,68,25,126,82,47,75,53,4,204,20,123,187,184,235,169,144,234,131,94,218,85,244,73,158,170,200,241,69,99,201,7,59,103,46,14,89,237,125,173,8,51,130,206,49,95,214,133,117,202,19,216,253,228,34,221,79,210,166,90,193,108,124,134,84,129,22,116,165,163,71,246,62,28,203,118,222,61,29,3,185,13,132,243,23,248,63,213,190,74,157,128,109,60,140,179,41,198,122,174,249,155,27,78,254,223,12,251,141,33,121,227,127,181,35,143,250,15,40,83,196,104,182,115,229,178,162,119,106,6,10,137,147,212,194,58,92,56,48,98,245,230,215,101,217,220,39,18,113,55,172,159,153,102,91,189,207,70,36,191,72],"expectedCoordinator":108},{"name":"generated-184-size-3","seedInt64":"975428083526443235","attemptNumber":55207,"members":[126,16,247],"expectedCoordinator":126},{"name":"generated-185-size-28","seedInt64":"-8228073379596709837","attemptNumber":214293528,"members":[100,198,8,79,180,13,31,18,171,75,76,81,255,232,220,130,178,142,239,122,99,182,107,52,250,86,47,209],"expectedCoordinator":232},{"name":"generated-186-size-74","seedInt64":"3209321565861461592","attemptNumber":7,"members":[137,226,211,11,129,250,152,140,159,7,153,22,237,149,51,145,175,216,148,166,170,185,12,238,156,68,16,124,50,67,119,201,183,107,110,178,65,163,97,77,184,80,242,34,203,23,106,136,200,169,224,173,25,139,72,99,81,135,59,193,104,108,35,28,96,74,252,82,122,126,144,89,125,171],"expectedCoordinator":149},{"name":"generated-187-size-158","seedInt64":"1147271317910824068","attemptNumber":15318,"members":[226,143,238,116,134,108,185,113,216,16,57,202,255,59,70,229,88,189,28,140,220,204,128,198,171,36,177,17,12,159,62,48,96,123,124,19,147,138,219,46,230,25,158,213,74,211,170,117,14,146,104,197,218,49,190,244,232,136,191,217,174,165,91,44,21,47,111,45,80,121,139,13,187,18,3,51,196,65,72,236,242,76,179,39,154,79,126,246,125,193,239,87,11,30,8,152,167,160,253,172,83,90,200,225,175,42,43,110,233,26,114,61,93,109,29,40,227,10,141,135,119,20,178,100,82,148,206,155,78,201,60,50,192,183,194,58,223,132,203,77,252,173,137,207,6,35,4,41,32,66,180,133,115,144,131,181,157,251],"expectedCoordinator":46},{"name":"generated-188-size-3","seedInt64":"8626384896298636603","attemptNumber":1945142325,"members":[252,249,32],"expectedCoordinator":249},{"name":"generated-189-size-27","seedInt64":"-2884192324852082523","attemptNumber":5,"members":[8,137,146,188,179,228,1,249,46,175,34,216,36,81,248,43,108,143,174,70,83,199,77,244,215,190,53],"expectedCoordinator":143},{"name":"generated-190-size-60","seedInt64":"1295608195474163702","attemptNumber":33305,"members":[17,94,158,90,237,123,57,129,60,130,126,54,113,181,2,168,92,47,45,218,174,176,23,82,21,219,136,106,97,238,56,178,13,99,190,157,227,125,121,169,188,10,39,26,216,29,148,230,205,207,63,156,213,221,112,254,179,163,241,16],"expectedCoordinator":10},{"name":"generated-191-size-102","seedInt64":"-7028840328211646751","attemptNumber":4205783693,"members":[213,8,200,85,94,57,147,135,145,150,22,65,211,251,225,255,3,140,163,54,238,81,242,151,159,231,253,134,51,166,195,254,36,201,227,214,17,178,79,104,95,245,205,19,148,235,45,78,106,16,34,127,202,126,59,174,33,187,105,75,236,89,244,53,6,93,132,189,157,208,7,121,103,172,117,194,46,13,247,243,82,192,161,86,91,160,252,77,158,5,191,100,218,39,2,27,120,30,167,111,169,102],"expectedCoordinator":205},{"name":"generated-192-size-3","seedInt64":"543025063281693162","attemptNumber":6,"members":[132,105,233],"expectedCoordinator":132},{"name":"generated-193-size-32","seedInt64":"8878370375918224043","attemptNumber":23628,"members":[232,196,72,44,84,102,224,88,49,138,127,175,65,166,81,21,33,167,141,225,97,108,73,139,152,136,105,178,160,18,158,51],"expectedCoordinator":108},{"name":"generated-194-size-100","seedInt64":"4927575092754923835","attemptNumber":1252619687,"members":[16,87,137,77,144,165,33,190,129,160,176,89,31,180,233,206,123,120,83,142,181,35,130,100,128,245,92,198,50,238,57,154,184,74,197,112,247,118,59,172,64,200,23,97,68,121,219,111,226,28,104,251,93,155,63,171,65,151,136,26,211,86,146,69,167,22,43,96,208,232,54,5,239,102,242,131,199,18,85,209,91,145,203,76,173,243,3,105,37,215,248,17,122,148,166,95,114,113,38,48],"expectedCoordinator":211},{"name":"generated-195-size-143","seedInt64":"5715653900797331802","attemptNumber":6,"members":[171,56,10,174,124,36,67,241,31,49,116,123,147,19,136,59,22,1,148,166,27,62,244,219,91,177,199,55,34,163,57,193,138,103,89,63,16,66,20,6,133,7,246,42,159,225,182,92,226,4,75,29,130,242,236,203,221,212,95,175,239,155,162,5,33,28,122,157,197,78,238,153,23,14,15,254,32,50,11,110,41,168,65,222,158,51,178,109,74,104,160,253,129,220,247,121,52,233,216,68,154,119,40,60,140,176,142,146,21,164,192,13,17,79,70,234,26,48,240,161,150,120,114,111,170,115,201,108,61,206,101,224,85,128,106,208,223,209,99,47,172,100,249],"expectedCoordinator":219},{"name":"generated-196-size-3","seedInt64":"2588217503720539712","attemptNumber":52720,"members":[27,237,80],"expectedCoordinator":27},{"name":"generated-197-size-20","seedInt64":"-2614392867029198303","attemptNumber":1297361721,"members":[201,206,111,103,86,231,121,200,181,66,156,229,76,25,99,104,59,177,210,69],"expectedCoordinator":201},{"name":"generated-198-size-76","seedInt64":"-2131164544493598318","attemptNumber":7,"members":[161,220,18,245,254,120,15,104,157,145,140,64,226,58,150,180,231,55,200,248,225,190,130,11,230,214,115,195,234,38,53,235,155,237,198,40,12,41,49,67,133,101,88,47,26,111,229,10,123,103,80,194,116,74,151,52,197,171,218,96,114,78,93,106,34,253,206,142,158,183,20,181,189,205,44,102],"expectedCoordinator":231},{"name":"generated-199-size-123","seedInt64":"-8102576855027398969","attemptNumber":24238,"members":[254,157,124,105,122,70,125,219,144,121,76,211,116,96,170,50,221,74,214,126,57,142,218,222,212,190,3,11,146,67,81,235,134,192,55,255,237,234,66,177,236,155,109,63,248,199,195,215,53,85,202,64,90,4,176,178,103,75,139,162,86,226,152,87,249,71,41,91,120,239,16,132,33,173,9,99,22,250,141,242,45,46,5,10,194,206,23,32,102,84,31,30,97,227,13,186,111,175,19,201,184,168,127,29,140,52,56,145,12,209,37,34,244,251,69,210,232,115,185,174,183,83,161],"expectedCoordinator":115},{"name":"generated-200-size-1","seedInt64":"-5391680750099403716","attemptNumber":1367176450,"members":[155],"expectedCoordinator":155},{"name":"generated-201-size-12","seedInt64":"9116950518898689401","attemptNumber":7,"members":[181,9,102,234,15,206,201,104,237,31,213,137],"expectedCoordinator":201},{"name":"generated-202-size-77","seedInt64":"-7543824559515626642","attemptNumber":28250,"members":[39,113,17,212,111,171,29,106,120,249,18,232,83,37,6,178,15,46,125,71,12,34,223,149,117,195,30,110,179,208,219,14,69,139,121,245,78,116,238,103,211,53,79,207,20,197,230,59,166,247,216,88,49,137,221,222,191,175,148,186,104,85,70,133,147,215,183,24,41,164,68,99,109,209,52,63,233],"expectedCoordinator":116},{"name":"generated-203-size-172","seedInt64":"-899848874885150603","attemptNumber":2117475975,"members":[115,172,120,68,81,181,113,227,198,168,170,129,38,10,243,188,41,167,110,24,202,214,150,133,57,17,199,131,176,42,21,143,30,218,62,106,108,48,134,248,204,153,203,191,91,52,73,8,255,187,166,44,89,210,180,141,78,105,53,208,60,101,92,222,69,217,45,206,75,194,154,76,6,192,178,171,186,121,148,82,151,175,34,43,234,33,65,96,156,182,211,232,66,32,147,159,239,219,93,39,213,50,90,197,49,228,59,251,56,246,233,29,111,5,179,26,47,13,7,118,22,146,140,184,142,253,104,132,20,223,155,235,70,126,138,125,16,221,160,183,116,145,35,195,72,152,114,67,231,119,31,40,117,136,149,250,247,74,189,144,137,163,157,135,226,229,46,88,238,1,127,71],"expectedCoordinator":184},{"name":"generated-204-size-1","seedInt64":"-3965154114514443","attemptNumber":4,"members":[118],"expectedCoordinator":118},{"name":"generated-205-size-38","seedInt64":"-1149877508151121822","attemptNumber":2217,"members":[69,28,194,171,145,151,197,200,127,207,38,162,5,25,139,241,152,249,26,71,27,135,16,247,221,154,1,107,253,179,50,177,73,87,108,209,170,191],"expectedCoordinator":108},{"name":"generated-206-size-61","seedInt64":"4591983772327981692","attemptNumber":1881220302,"members":[110,26,42,193,239,186,208,222,182,47,57,190,169,205,85,83,238,146,220,138,76,160,188,231,155,40,75,28,198,82,171,118,253,191,132,109,227,143,21,230,180,131,254,64,10,214,245,78,32,86,149,255,94,247,178,31,99,36,234,218,4],"expectedCoordinator":247},{"name":"generated-207-size-142","seedInt64":"6410417397216878394","attemptNumber":1,"members":[83,166,107,32,90,73,96,174,69,145,130,211,112,221,117,126,53,125,25,159,142,62,245,173,118,38,108,111,93,20,21,75,140,114,172,95,54,67,177,255,244,184,48,60,179,207,50,5,15,209,51,176,149,40,84,196,29,101,253,68,204,143,248,120,216,150,192,87,229,42,116,243,30,141,63,94,106,227,137,127,66,88,26,80,214,6,102,187,34,100,8,210,17,103,203,198,11,180,175,46,240,249,59,122,146,3,86,19,228,181,28,232,57,9,44,37,47,230,161,194,136,200,33,247,158,191,162,113,225,98,78,206,186,36,183,10,154,89,56,237,220,76],"expectedCoordinator":184},{"name":"generated-208-size-4","seedInt64":"-8086997236217718750","attemptNumber":42960,"members":[76,137,7,67],"expectedCoordinator":7},{"name":"generated-209-size-22","seedInt64":"3929635371483476119","attemptNumber":3383479143,"members":[46,212,196,205,188,51,43,151,32,9,5,118,71,139,231,55,78,211,160,4,246,134],"expectedCoordinator":9},{"name":"generated-210-size-56","seedInt64":"-7641947915435895600","attemptNumber":2,"members":[59,130,241,185,115,171,245,178,250,65,4,19,212,149,213,92,109,220,83,145,217,167,195,222,136,175,121,34,192,14,79,247,154,13,249,226,94,215,53,224,216,29,210,42,164,132,7,133,173,155,93,111,230,242,203,41],"expectedCoordinator":4},{"name":"generated-211-size-102","seedInt64":"739576942796342027","attemptNumber":16644,"members":[60,229,203,252,244,43,122,139,41,183,86,35,95,9,6,61,77,81,92,120,114,63,226,154,32,3,151,24,201,221,17,136,159,83,217,34,74,250,48,170,169,189,232,242,36,87,110,184,214,223,31,130,239,206,188,121,72,148,248,125,196,88,220,128,225,54,219,18,245,80,162,135,23,91,187,123,39,247,20,240,200,243,177,143,76,172,26,152,71,30,191,57,55,15,224,205,75,227,97,11,166,101],"expectedCoordinator":121},{"name":"generated-212-size-1","seedInt64":"6149971385321529657","attemptNumber":570738057,"members":[84],"expectedCoordinator":84},{"name":"generated-213-size-33","seedInt64":"-3569057797224295675","attemptNumber":1,"members":[26,70,162,205,80,79,218,52,82,181,244,132,223,95,185,65,12,53,189,229,252,199,112,186,8,196,50,220,204,131,114,110,243],"expectedCoordinator":82},{"name":"generated-214-size-58","seedInt64":"-8009124812602160081","attemptNumber":9719,"members":[80,132,76,204,190,104,72,126,171,46,88,208,242,68,246,26,194,130,192,62,59,100,58,160,245,131,122,210,35,147,248,2,203,139,101,183,109,137,63,5,227,134,18,85,110,175,133,189,120,249,185,4,99,123,159,28,115,129],"expectedCoordinator":109},{"name":"generated-215-size-231","seedInt64":"-4883368307391505744","attemptNumber":611732897,"members":[51,72,36,19,147,16,76,38,86,149,135,112,162,248,253,130,192,9,104,132,31,37,103,12,207,46,184,165,22,33,217,85,27,43,139,90,163,87,120,84,254,189,108,182,136,179,122,215,190,29,115,202,89,41,239,74,241,255,70,211,8,69,80,110,59,21,194,159,82,1,88,11,134,28,129,205,203,77,137,5,242,151,127,105,102,61,14,140,96,251,131,56,208,183,58,219,39,197,23,174,81,200,152,54,153,168,181,250,53,66,175,252,146,240,249,109,100,114,128,172,222,180,169,95,40,67,230,119,75,93,138,173,62,233,223,30,246,191,143,167,17,157,49,106,13,201,231,199,237,18,188,154,2,26,117,7,44,214,225,73,247,92,166,78,216,111,126,160,161,99,123,24,244,228,141,50,94,158,71,6,156,170,206,186,125,164,221,47,107,34,48,118,210,204,224,97,218,133,32,148,155,235,65,234,57,3,52,150,226,113,116,144,177,171,45,220,213,15,101,236,64,227,145,195,4,63,124,187,245,243,176],"expectedCoordinator":143},{"name":"generated-216-size-3","seedInt64":"3127750597862717190","attemptNumber":0,"members":[76,54,138],"expectedCoordinator":54},{"name":"generated-217-size-17","seedInt64":"-7313622320999402745","attemptNumber":18383,"members":[86,136,83,158,77,140,47,240,231,176,71,227,123,78,211,15,224],"expectedCoordinator":86},{"name":"generated-218-size-97","seedInt64":"-7901460277287155755","attemptNumber":4204861836,"members":[104,18,142,210,34,45,46,55,107,205,35,16,73,5,223,84,112,186,41,170,222,86,92,40,17,3,195,59,194,150,67,90,241,141,232,139,135,109,163,52,119,158,87,23,122,208,152,215,26,225,182,6,121,191,79,68,44,247,162,47,218,31,33,29,164,196,192,204,176,131,140,183,36,228,197,138,213,250,50,249,236,85,32,10,56,184,61,161,103,168,233,209,143,153,220,19,181],"expectedCoordinator":220},{"name":"generated-219-size-250","seedInt64":"8748606308652553825","attemptNumber":4,"members":[185,165,28,133,173,40,195,128,6,15,13,161,190,46,42,206,124,37,227,111,219,20,89,125,149,249,108,144,8,21,201,35,71,169,209,16,146,224,112,86,147,172,106,39,123,189,87,65,228,229,218,79,122,239,221,50,217,139,142,101,107,193,34,168,23,117,241,238,83,81,245,204,191,113,177,131,167,119,187,47,136,135,225,18,44,202,105,199,54,26,208,148,61,174,29,196,115,51,110,75,114,231,247,181,180,70,1,97,59,212,137,48,129,102,80,255,157,240,109,236,214,99,56,11,19,118,27,100,198,213,32,216,90,31,242,162,152,192,67,91,234,5,140,244,85,235,176,226,205,194,158,145,175,141,52,24,160,210,88,127,178,186,103,143,179,154,223,4,182,138,41,126,77,230,166,252,17,10,254,82,134,14,171,43,63,45,120,232,183,248,170,188,153,68,215,246,7,116,96,98,243,92,36,84,156,49,66,30,33,164,12,163,197,72,78,60,94,25,184,250,207,237,251,73,104,132,159,211,130,3,57,233,53,253,121,64,69,38,58,203,222,95,200,151,55,74,220,62,76,9],"expectedCoordinator":26},{"name":"generated-220-size-2","seedInt64":"-5246193501503078313","attemptNumber":24237,"members":[46,144],"expectedCoordinator":144},{"name":"generated-221-size-6","seedInt64":"-4758205372089020226","attemptNumber":3481607519,"members":[249,254,155,105,102,49],"expectedCoordinator":254},{"name":"generated-222-size-100","seedInt64":"-3531465967748470332","attemptNumber":2,"members":[73,249,222,17,63,198,27,167,71,126,187,111,58,186,204,103,43,83,132,18,122,211,102,179,164,149,208,176,14,128,242,138,30,116,235,8,255,26,31,49,9,155,85,32,21,7,251,10,197,227,124,188,228,157,112,189,119,98,252,219,93,91,72,135,234,76,99,136,41,29,69,159,81,165,153,156,241,19,148,108,42,118,141,162,243,94,34,107,3,152,247,90,88,101,182,174,147,96,35,53],"expectedCoordinator":179},{"name":"generated-223-size-107","seedInt64":"-7553141419980881475","attemptNumber":18294,"members":[183,130,221,178,248,208,54,140,213,8,81,115,229,112,51,133,143,150,1,128,67,240,7,100,233,157,165,160,241,70,187,204,182,84,148,162,17,216,45,25,37,31,5,245,163,39,107,217,167,56,93,202,30,164,152,101,106,146,90,94,158,76,223,239,34,35,68,49,66,238,18,95,242,80,207,50,194,171,230,98,231,85,250,243,215,147,14,77,252,206,188,74,191,89,177,124,251,20,71,123,99,82,144,161,61,11,3],"expectedCoordinator":37},{"name":"generated-224-size-1","seedInt64":"-1256379817844886986","attemptNumber":1954377663,"members":[101],"expectedCoordinator":101},{"name":"generated-225-size-21","seedInt64":"-8595065637633132556","attemptNumber":5,"members":[246,54,102,221,107,82,38,169,219,120,142,117,252,80,139,57,125,162,36,232,255],"expectedCoordinator":54},{"name":"generated-226-size-52","seedInt64":"8777751530411258240","attemptNumber":38989,"members":[99,170,196,214,7,225,40,58,28,84,4,146,193,166,142,171,211,251,45,207,54,95,23,88,197,2,128,32,121,90,227,141,205,172,222,221,9,253,78,92,178,8,158,91,71,74,254,203,160,57,213,43],"expectedCoordinator":4},{"name":"generated-227-size-230","seedInt64":"-504433708010591143","attemptNumber":1554434193,"members":[14,152,191,64,28,79,9,91,217,245,74,136,148,241,158,47,99,184,85,50,213,163,156,94,143,15,93,227,104,124,247,214,205,23,172,18,145,151,204,19,251,10,177,167,33,43,112,250,187,13,68,236,115,6,131,56,221,149,42,88,201,240,196,154,120,179,96,67,183,178,147,168,218,95,150,166,216,27,40,89,165,45,198,11,86,78,144,239,238,39,211,103,71,121,51,76,65,75,244,174,237,53,7,210,146,70,248,180,209,126,38,212,122,129,189,52,137,164,128,44,49,87,32,116,107,228,199,83,229,24,169,69,246,16,252,21,159,77,157,30,175,102,195,207,141,222,73,100,225,25,46,81,57,62,208,203,125,114,232,63,242,108,80,170,48,66,249,197,193,254,118,215,153,161,58,234,140,72,139,84,8,31,155,200,181,219,92,176,230,97,101,82,182,224,12,106,185,111,206,1,255,186,235,59,171,243,188,142,5,127,54,29,109,220,113,4,233,60,231,173,117,20,130,3,132,202,37,223,253,41],"expectedCoordinator":171},{"name":"generated-228-size-1","seedInt64":"273832933421146336","attemptNumber":3,"members":[110],"expectedCoordinator":110},{"name":"generated-229-size-30","seedInt64":"7093212679147128367","attemptNumber":16507,"members":[38,65,171,190,32,244,20,199,130,101,213,200,56,139,183,193,243,245,145,46,252,231,29,112,203,188,99,5,41,153],"expectedCoordinator":153},{"name":"generated-230-size-98","seedInt64":"-6994348950673903000","attemptNumber":874710904,"members":[117,181,201,66,197,69,12,140,196,126,136,134,36,163,98,236,112,104,100,82,160,150,18,14,42,164,152,213,162,195,50,220,3,198,25,49,122,19,119,73,71,143,218,204,225,30,54,148,129,246,238,125,94,221,37,189,231,228,38,222,83,40,175,166,76,56,159,192,165,114,41,55,233,110,186,106,184,62,177,183,210,52,29,207,74,120,116,235,171,232,123,34,77,172,156,239,22,200],"expectedCoordinator":184},{"name":"generated-231-size-181","seedInt64":"-2390953809965168301","attemptNumber":7,"members":[140,170,85,222,232,213,154,64,51,31,40,30,20,243,114,34,120,179,252,169,108,188,206,46,10,195,175,173,97,126,151,142,56,202,81,187,230,203,44,165,47,21,145,186,171,172,134,212,137,104,177,189,153,146,135,22,90,183,218,129,226,4,63,70,76,228,17,208,72,249,185,106,235,110,224,98,23,246,57,199,234,119,77,205,8,217,75,58,105,192,174,5,111,74,60,201,19,231,159,37,65,220,109,178,143,194,117,225,123,27,101,99,136,71,164,227,240,39,82,61,250,45,91,54,33,12,229,87,79,118,156,233,62,86,115,32,89,36,121,102,100,9,48,191,236,83,28,15,245,190,147,24,113,93,160,7,139,88,127,181,152,141,130,42,161,73,107,50,78,182,254,133,239,255,66,103,1,180,116,92,214],"expectedCoordinator":239},{"name":"generated-232-size-2","seedInt64":"-4796086228382859032","attemptNumber":54289,"members":[160,115],"expectedCoordinator":160},{"name":"generated-233-size-27","seedInt64":"491858744630001380","attemptNumber":3643407001,"members":[22,79,117,103,242,220,38,10,252,73,1,28,221,145,131,136,51,105,210,8,194,99,183,80,90,212,168],"expectedCoordinator":183},{"name":"generated-234-size-71","seedInt64":"231013778177039729","attemptNumber":0,"members":[123,152,149,8,216,178,93,251,45,87,175,83,227,34,103,195,119,11,19,217,17,169,98,30,9,127,102,133,112,181,25,213,197,179,41,22,233,43,230,79,44,118,142,101,228,155,90,78,130,55,165,186,237,126,70,159,14,85,157,232,40,67,226,214,167,76,105,21,37,240,131],"expectedCoordinator":85},{"name":"generated-235-size-157","seedInt64":"-202916297728017719","attemptNumber":42918,"members":[121,163,136,34,131,135,204,191,244,149,69,12,110,210,180,73,148,227,14,232,28,200,254,147,177,29,55,157,33,50,102,68,19,186,236,214,22,216,151,242,160,95,75,13,152,138,41,189,76,219,117,32,175,45,61,182,150,205,203,130,230,8,16,143,234,40,91,167,165,212,127,185,38,77,70,164,118,26,88,197,57,208,36,99,86,66,104,84,2,120,82,237,89,248,133,162,228,245,96,246,188,173,141,1,43,64,144,223,201,207,6,241,215,37,206,226,79,20,154,80,58,233,108,231,179,53,198,255,56,209,94,87,59,21,113,124,46,170,119,85,243,15,93,218,109,60,103,24,239,111,100,195,42,146,128,199,62],"expectedCoordinator":111},{"name":"generated-236-size-2","seedInt64":"7034622083423148509","attemptNumber":1380728834,"members":[255,242],"expectedCoordinator":255},{"name":"generated-237-size-10","seedInt64":"8236413080765525256","attemptNumber":3,"members":[172,82,155,163,101,248,113,61,123,32],"expectedCoordinator":32},{"name":"generated-238-size-75","seedInt64":"-6134270894593845958","attemptNumber":4174,"members":[29,135,150,4,210,24,91,197,235,85,102,94,37,17,242,136,31,20,188,229,240,47,74,113,48,50,119,219,118,105,185,182,194,10,142,27,162,212,253,45,184,187,151,34,100,152,76,8,82,205,15,92,90,6,77,131,181,200,109,83,179,123,120,207,117,216,2,232,89,43,69,101,169,84,144],"expectedCoordinator":91},{"name":"generated-239-size-181","seedInt64":"4270392664048416871","attemptNumber":2729361208,"members":[147,51,156,229,45,141,209,228,221,219,249,80,125,129,202,238,252,101,21,206,255,187,108,36,114,195,78,105,31,116,130,184,164,135,1,168,81,111,37,212,87,137,180,76,117,143,234,68,145,98,39,34,133,235,22,197,54,49,66,150,227,42,213,23,193,84,162,29,100,154,48,56,176,8,231,65,194,90,25,82,94,236,188,182,163,232,97,32,142,75,60,134,106,7,64,52,3,41,132,72,146,181,214,17,230,151,47,69,192,35,11,217,33,118,92,140,205,201,124,57,13,99,74,165,16,110,159,179,218,149,102,95,10,190,174,254,136,153,196,226,28,250,38,198,104,175,178,88,157,113,77,96,119,62,223,70,59,224,109,63,12,123,86,73,203,200,53,215,85,50,166,71,103,24,138,46,2,89,239,44,220],"expectedCoordinator":64},{"name":"generated-240-size-1","seedInt64":"-6081062665632825552","attemptNumber":0,"members":[20],"expectedCoordinator":20},{"name":"generated-241-size-34","seedInt64":"6237684551520271382","attemptNumber":10821,"members":[207,75,193,254,146,2,58,223,216,44,248,93,247,90,199,159,234,91,56,169,162,218,245,177,209,249,112,111,67,195,128,232,97,33],"expectedCoordinator":245},{"name":"generated-242-size-100","seedInt64":"6852822680341952090","attemptNumber":4099209259,"members":[35,113,50,10,9,190,104,131,126,212,182,246,97,125,83,241,30,110,148,200,191,111,244,93,146,36,206,96,66,39,231,124,44,180,229,243,59,20,207,106,142,32,147,215,80,112,233,85,13,127,26,122,221,226,78,64,172,234,79,119,203,175,253,171,181,33,247,23,153,103,252,5,25,141,102,158,108,62,204,154,70,114,248,43,195,251,117,136,250,245,63,72,54,101,77,61,75,51,60,129],"expectedCoordinator":153},{"name":"generated-243-size-165","seedInt64":"1222296780750165622","attemptNumber":6,"members":[114,124,96,187,167,24,194,153,88,103,63,108,159,150,234,165,179,220,81,143,39,55,135,229,79,218,162,156,176,214,199,84,139,189,221,98,13,208,161,72,10,95,54,241,101,26,82,181,132,197,1,33,203,30,9,41,136,15,121,71,246,251,67,249,73,217,92,37,250,28,142,129,144,190,127,233,222,207,36,206,66,193,196,25,205,200,6,44,51,186,38,111,122,100,64,219,215,125,198,117,86,62,22,141,107,76,236,5,151,23,247,4,228,59,131,75,182,128,56,70,43,248,155,77,149,74,34,19,120,245,244,240,216,113,119,48,239,254,104,42,177,180,168,147,191,29,195,18,163,21,58,115,140,47,175,173,185,145,237,242,169,164,99,40,130],"expectedCoordinator":79},{"name":"generated-244-size-3","seedInt64":"6825655268162647627","attemptNumber":13938,"members":[239,140,38],"expectedCoordinator":140},{"name":"generated-245-size-23","seedInt64":"4714309448685408082","attemptNumber":767269866,"members":[230,204,43,157,238,139,31,234,239,163,11,45,126,64,96,127,33,58,154,148,160,165,135],"expectedCoordinator":154},{"name":"generated-246-size-71","seedInt64":"-748218304704662432","attemptNumber":6,"members":[178,2,133,84,196,14,72,222,245,252,40,68,123,165,45,213,179,135,116,32,197,230,251,60,95,241,44,194,217,118,20,35,131,151,56,175,163,215,29,205,201,203,173,103,186,79,152,39,192,227,147,144,74,174,162,200,229,43,237,100,125,89,9,202,12,19,233,195,181,94,248],"expectedCoordinator":131},{"name":"generated-247-size-254","seedInt64":"5965969807358470671","attemptNumber":48555,"members":[222,234,141,71,56,42,97,84,116,14,177,118,146,175,186,95,242,221,139,105,98,201,29,161,100,125,78,113,160,72,80,91,40,231,103,76,171,9,130,185,204,236,251,197,115,252,209,147,60,109,214,69,63,120,226,128,206,44,172,166,159,79,30,151,3,87,112,117,249,164,8,155,178,122,12,110,1,33,18,34,182,121,213,75,10,199,215,85,154,180,237,232,253,247,52,124,148,133,67,227,55,212,81,208,43,167,48,250,7,89,223,101,196,220,24,27,140,77,142,20,174,179,127,129,192,152,153,176,202,156,65,158,254,90,93,21,16,143,82,211,19,224,246,54,2,194,36,198,11,244,230,46,31,248,28,35,132,57,203,137,38,96,94,68,210,255,200,83,184,126,183,70,106,123,193,32,173,47,104,50,243,131,169,17,41,102,239,26,99,165,66,5,111,191,39,241,188,207,233,64,59,219,136,4,58,190,149,108,51,238,163,73,23,245,107,187,195,170,45,135,216,162,88,144,61,217,49,86,228,225,62,157,205,53,138,6,15,189,150,235,74,92,37,25,119,22,168,114,181,134,229,218,240,145],"expectedCoordinator":34},{"name":"generated-248-size-4","seedInt64":"-4943718522498609792","attemptNumber":3883215660,"members":[36,189,148,228],"expectedCoordinator":36},{"name":"generated-249-size-31","seedInt64":"8791949043565888271","attemptNumber":3,"members":[192,235,114,5,242,23,176,72,198,120,52,177,204,193,201,75,60,217,77,66,238,184,130,142,252,253,223,194,162,88,127],"expectedCoordinator":184},{"name":"generated-250-size-59","seedInt64":"7664190085675562762","attemptNumber":36001,"members":[42,231,160,163,76,110,177,211,208,171,11,130,233,118,180,52,210,61,133,65,60,46,97,107,84,40,8,94,226,132,206,166,56,54,245,31,253,119,212,73,157,195,108,93,99,50,198,23,220,20,190,22,203,29,178,183,70,140,167],"expectedCoordinator":61},{"name":"generated-251-size-187","seedInt64":"-8805967620924864177","attemptNumber":294827977,"members":[68,228,85,15,28,171,164,181,196,111,118,147,115,33,37,90,31,184,250,253,173,166,242,49,24,134,148,126,29,202,101,237,60,39,212,74,11,216,84,14,135,205,136,220,36,2,43,129,91,97,170,203,239,139,19,89,122,248,67,142,251,217,105,182,87,76,121,42,127,82,99,6,180,190,149,222,152,34,123,23,230,59,88,240,193,162,209,96,199,155,32,106,235,104,110,172,191,58,195,62,38,7,198,8,47,186,151,78,108,92,145,18,133,65,40,160,179,3,4,140,219,27,70,168,192,86,189,73,81,16,156,201,197,83,207,163,35,218,225,107,95,249,175,100,20,64,154,231,185,146,112,130,245,161,80,159,143,167,153,187,56,57,125,1,150,158,10,25,13,120,211,243,98,224,103,229,116,48,9,246,241,114,94,254,46,69,61],"expectedCoordinator":10},{"name":"generated-252-size-2","seedInt64":"2212339530805772484","attemptNumber":1,"members":[241,82],"expectedCoordinator":82},{"name":"generated-253-size-30","seedInt64":"7913033632405333059","attemptNumber":56226,"members":[247,17,202,72,143,231,191,170,165,88,158,96,240,22,177,29,130,129,176,67,59,138,104,223,50,169,221,74,205,84],"expectedCoordinator":221},{"name":"generated-254-size-82","seedInt64":"-4281287842980213524","attemptNumber":1565376498,"members":[22,213,83,145,164,163,212,226,32,205,26,126,157,231,131,12,57,238,232,224,49,106,142,30,255,20,87,44,28,182,228,209,160,103,119,219,217,171,88,54,229,135,69,237,179,97,191,94,225,45,99,75,199,172,168,4,123,112,96,3,239,122,16,72,133,101,62,79,81,118,236,178,184,210,143,190,124,154,138,1,198,6],"expectedCoordinator":224},{"name":"generated-255-size-156","seedInt64":"8220282613324234144","attemptNumber":6,"members":[189,77,16,165,216,148,115,142,78,205,17,87,239,218,104,98,63,209,51,202,152,185,79,170,116,57,223,56,151,143,74,254,232,30,1,229,225,210,5,80,69,37,207,168,100,29,175,181,128,213,247,72,12,97,127,113,53,6,188,89,245,52,200,221,147,111,71,179,106,92,219,250,83,88,120,163,156,227,214,94,73,217,158,231,183,60,22,58,166,21,171,118,135,211,122,10,124,31,224,233,242,91,246,27,162,252,2,176,138,173,155,197,145,208,49,212,150,117,149,129,39,141,43,103,240,59,109,96,24,68,132,131,154,110,169,228,186,204,45,206,15,14,38,75,82,182,55,7,198,48,64,194,121,19,164,180],"expectedCoordinator":252},{"name":"generated-256-size-3","seedInt64":"807015637862885264","attemptNumber":48830,"members":[145,147,124],"expectedCoordinator":145},{"name":"generated-257-size-34","seedInt64":"-3361867704914039858","attemptNumber":1061350313,"members":[144,158,1,51,98,112,233,101,59,77,2,49,249,41,149,99,119,96,191,22,194,21,254,240,196,242,130,177,19,34,70,78,192,16],"expectedCoordinator":112},{"name":"generated-258-size-60","seedInt64":"-3866289711898701202","attemptNumber":3,"members":[164,211,153,197,49,115,120,84,93,114,107,71,14,104,78,236,23,58,149,48,177,135,26,94,217,106,36,232,138,220,102,87,131,98,123,61,29,105,128,189,222,204,75,40,228,10,188,176,51,8,231,227,77,70,59,172,108,168,207,161],"expectedCoordinator":107},{"name":"generated-259-size-231","seedInt64":"-8524482668430366388","attemptNumber":12118,"members":[90,220,194,185,15,114,115,102,93,153,111,99,151,25,213,191,175,187,92,40,230,105,211,58,82,48,228,76,250,144,7,161,207,240,106,242,143,119,30,170,238,70,8,113,61,226,72,198,141,121,122,118,66,55,133,16,236,154,145,74,179,36,1,94,6,203,202,41,165,14,142,199,2,128,13,164,155,190,32,24,208,216,29,239,197,62,200,65,38,28,231,71,42,245,129,17,221,57,223,80,125,31,178,162,68,112,215,222,176,140,174,130,166,89,86,47,91,135,173,43,123,100,67,195,177,109,108,137,156,83,251,75,167,241,181,23,77,59,158,37,26,85,196,56,210,78,96,193,84,150,136,247,252,169,138,186,146,22,171,182,12,33,60,49,4,246,73,46,120,88,160,254,152,235,98,255,180,39,139,79,163,217,148,172,159,10,244,97,81,35,20,237,27,219,132,233,214,189,95,5,53,21,212,184,54,229,69,63,192,149,204,104,205,157,87,11,227,127,45,253,224,9,107,225,18,168,218,183,116,201,209],"expectedCoordinator":154},{"name":"generated-260-size-2","seedInt64":"2084582661572346352","attemptNumber":3618570068,"members":[50,255],"expectedCoordinator":50},{"name":"generated-261-size-23","seedInt64":"-7666449982017874002","attemptNumber":5,"members":[255,61,65,25,247,165,225,155,179,110,109,108,23,59,37,152,120,88,48,220,222,200,219],"expectedCoordinator":219},{"name":"generated-262-size-57","seedInt64":"7199986430354044602","attemptNumber":8752,"members":[126,99,19,57,104,160,44,27,130,169,2,114,117,60,164,215,166,159,211,70,241,235,21,240,227,128,30,171,185,217,224,178,138,42,146,12,77,85,71,101,218,55,67,143,131,96,3,210,87,246,154,76,198,244,31,75,234],"expectedCoordinator":224},{"name":"generated-263-size-223","seedInt64":"6320475293786642821","attemptNumber":1451797712,"members":[232,63,132,107,142,203,3,43,225,62,50,111,7,31,14,138,92,248,135,136,231,165,17,15,20,202,196,126,183,61,155,238,38,235,137,30,101,141,247,227,28,51,189,8,106,213,120,69,35,167,10,242,161,255,85,86,93,160,57,199,214,164,47,217,90,158,194,74,112,210,125,119,53,29,130,48,83,241,25,78,84,212,180,223,58,145,9,45,206,103,98,13,237,56,71,186,240,222,100,200,168,19,40,59,52,173,208,34,149,177,104,204,230,140,123,87,67,94,157,6,4,95,221,113,129,70,54,41,179,198,252,65,159,73,201,172,150,197,192,216,218,169,207,24,233,226,114,2,89,66,82,12,236,170,32,148,110,211,174,184,195,22,153,55,185,16,5,246,144,109,253,154,81,134,21,228,108,99,27,245,175,49,171,176,215,205,166,163,33,254,209,243,182,251,224,151,124,133,72,97,23,181,75,127,1,143,156,80,39,26,146,250,96,128,131,79,229,234,147,68,77,76,37],"expectedCoordinator":198},{"name":"generated-264-size-4","seedInt64":"-5674409287784918354","attemptNumber":0,"members":[157,170,217,48],"expectedCoordinator":48},{"name":"generated-265-size-26","seedInt64":"-7647140400065609575","attemptNumber":27543,"members":[159,64,235,158,130,76,152,4,101,37,13,216,36,6,230,47,215,193,118,226,199,141,184,210,187,195],"expectedCoordinator":215},{"name":"generated-266-size-94","seedInt64":"7245527749130508584","attemptNumber":3814224688,"members":[165,179,74,13,181,11,73,192,101,180,231,85,14,10,103,120,157,190,234,86,4,241,95,49,6,81,195,142,124,69,5,166,137,59,136,116,72,191,122,184,127,100,177,78,25,80,108,93,193,79,67,197,24,208,233,113,232,253,112,70,146,129,66,238,150,250,244,109,105,36,64,149,94,248,198,189,87,57,33,139,140,15,151,110,154,215,35,128,104,97,135,175,210,159],"expectedCoordinator":4},{"name":"generated-267-size-253","seedInt64":"3341259619691440291","attemptNumber":6,"members":[8,161,231,110,38,88,169,67,79,77,189,173,78,232,98,241,143,159,64,80,18,113,170,141,160,82,11,71,172,33,101,218,31,133,43,193,211,99,207,198,7,94,55,162,75,126,72,213,27,125,217,57,63,46,187,149,45,152,52,108,66,196,255,208,130,36,136,60,122,233,84,219,76,17,41,137,229,139,24,2,135,106,253,190,48,179,13,251,30,158,104,102,210,220,9,167,34,14,221,132,144,178,112,240,153,129,247,21,195,47,242,216,203,180,58,194,146,154,127,157,142,164,200,10,90,238,89,212,19,111,223,156,6,117,29,120,56,206,49,83,201,54,32,121,227,248,65,103,165,246,177,197,37,150,151,22,69,70,226,109,181,184,26,91,155,188,5,140,186,85,145,16,250,182,192,230,20,28,183,148,235,244,131,185,237,243,81,128,15,222,62,53,40,228,23,115,95,204,249,124,25,97,168,245,138,39,92,202,105,234,209,239,225,59,254,116,35,252,175,191,96,214,44,171,147,93,42,114,119,86,61,3,107,163,205,118,1,68,87,134,12,215,51,224,199,73,123,100,74,50,236,166,176],"expectedCoordinator":85},{"name":"generated-268-size-3","seedInt64":"-5643143841159192762","attemptNumber":24331,"members":[211,150,178],"expectedCoordinator":178},{"name":"generated-269-size-12","seedInt64":"-715432384258311090","attemptNumber":2849882877,"members":[76,49,141,219,4,208,63,26,238,213,160,232],"expectedCoordinator":49},{"name":"generated-270-size-70","seedInt64":"7645027225218768417","attemptNumber":3,"members":[171,192,95,238,31,60,188,67,97,47,222,180,53,185,4,154,186,57,117,167,147,198,43,139,219,204,76,120,189,170,134,202,121,163,253,165,194,49,201,6,197,130,159,241,38,83,14,103,236,44,195,54,105,24,111,126,79,77,254,132,86,200,160,42,32,99,255,51,71,168],"expectedCoordinator":24},{"name":"generated-271-size-217","seedInt64":"5324154017756291778","attemptNumber":39169,"members":[66,140,25,72,13,205,245,105,135,138,154,177,21,127,57,94,228,99,7,145,64,91,166,80,234,180,125,35,209,184,39,201,141,230,114,206,5,134,9,165,248,36,112,74,240,88,73,139,4,27,189,81,225,183,149,196,78,34,1,28,222,202,122,188,131,241,251,104,47,8,132,63,152,10,226,204,108,40,129,29,17,110,150,142,211,153,30,89,128,244,37,50,246,219,198,193,164,117,249,192,176,55,77,123,199,214,147,58,26,255,133,93,238,220,217,221,252,59,52,200,96,185,68,178,67,62,242,174,33,109,100,111,194,161,167,148,215,231,70,16,38,49,179,186,146,32,42,76,250,233,247,75,69,158,103,90,83,2,162,170,102,243,31,156,126,24,175,44,239,106,15,107,12,20,98,187,60,229,144,14,95,137,87,237,101,11,54,218,235,84,160,143,120,92,61,212,23,197,6,3,46,56,119,172,86,71,82,173,224,236,136,22,116,124,157,223,43],"expectedCoordinator":70},{"name":"generated-272-size-2","seedInt64":"-8566485568532469585","attemptNumber":2639785519,"members":[45,87],"expectedCoordinator":45},{"name":"generated-273-size-42","seedInt64":"-1353653275728657925","attemptNumber":6,"members":[41,86,26,101,223,210,72,154,126,207,252,134,155,63,194,135,236,34,212,170,42,75,117,209,187,49,15,202,124,39,177,83,253,133,221,29,248,150,100,143,57,227],"expectedCoordinator":170},{"name":"generated-274-size-71","seedInt64":"-6820740860954741270","attemptNumber":4325,"members":[5,12,32,66,200,225,219,164,205,215,123,80,190,79,216,142,120,15,59,93,74,147,21,248,116,202,136,14,31,37,57,13,114,127,167,237,217,23,137,109,98,55,124,102,145,174,247,159,155,212,128,139,51,134,204,22,58,194,157,72,3,192,193,223,101,89,115,162,96,158,188],"expectedCoordinator":204},{"name":"generated-275-size-162","seedInt64":"4465252252970958867","attemptNumber":3641493425,"members":[209,36,214,21,15,243,151,230,180,133,17,185,1,7,227,213,221,193,244,188,161,91,126,16,240,139,132,157,235,86,112,75,163,137,28,217,30,178,88,63,170,119,46,45,99,66,190,111,74,60,171,43,113,248,174,189,3,129,147,238,54,33,177,59,229,32,122,37,159,205,31,110,44,65,150,200,255,62,175,187,196,239,56,89,48,10,69,192,120,165,107,26,108,218,29,40,184,191,4,13,162,253,71,121,123,8,105,149,52,72,90,203,233,20,228,97,160,237,103,23,5,106,117,220,250,245,102,202,222,176,61,70,104,116,68,78,57,39,6,11,2,58,101,53,173,118,27,42,81,201,204,77,125,83,9,168,247,156,195,142,47,84],"expectedCoordinator":43},{"name":"generated-276-size-3","seedInt64":"1534232423866477505","attemptNumber":7,"members":[16,135,122],"expectedCoordinator":135},{"name":"generated-277-size-9","seedInt64":"1519742999391803657","attemptNumber":23587,"members":[121,92,31,39,246,252,41,16,95],"expectedCoordinator":95},{"name":"generated-278-size-86","seedInt64":"-4248083624589171413","attemptNumber":1342592760,"members":[136,48,162,20,35,216,129,39,51,141,49,94,192,40,164,29,27,105,186,208,33,165,213,137,255,227,196,134,8,181,252,139,84,110,1,235,251,179,175,187,116,239,182,131,200,113,61,229,124,109,199,246,219,207,218,197,171,221,194,115,232,188,159,248,166,18,11,153,202,28,3,92,37,117,95,204,7,102,247,238,170,19,193,87,212,59],"expectedCoordinator":51},{"name":"generated-279-size-189","seedInt64":"-8344731829951945426","attemptNumber":5,"members":[2,129,120,197,90,213,248,221,207,21,127,118,147,20,178,163,102,255,189,47,196,249,10,106,162,16,153,75,208,131,240,46,130,151,88,66,64,19,105,85,135,214,170,28,53,15,157,73,192,3,154,58,109,152,35,33,225,166,68,216,94,233,45,185,218,89,230,4,188,117,113,167,134,22,87,98,6,148,72,229,160,124,217,155,202,119,164,226,69,142,144,139,222,49,186,201,107,234,50,99,211,43,199,195,96,251,116,48,18,93,133,126,212,236,63,237,146,235,42,26,224,149,169,122,25,227,145,11,38,171,67,198,34,239,205,220,209,246,190,241,92,61,231,252,187,79,138,97,60,174,219,115,9,80,57,24,125,168,86,191,84,31,182,91,7,78,245,175,59,232,40,39,228,244,82,161,36,74,123,128,141,76,13,41,200,250,150,110,184],"expectedCoordinator":127},{"name":"generated-280-size-2","seedInt64":"2033526897056424498","attemptNumber":64224,"members":[144,11],"expectedCoordinator":11},{"name":"generated-281-size-28","seedInt64":"8502842446050635631","attemptNumber":1828138660,"members":[228,192,11,39,231,226,70,74,127,121,117,153,240,158,78,65,119,216,56,107,196,102,34,143,255,81,47,182],"expectedCoordinator":226},{"name":"generated-282-size-83","seedInt64":"6488488242795270046","attemptNumber":5,"members":[137,156,102,57,168,157,202,136,233,59,165,143,154,245,190,65,248,4,139,43,101,34,11,51,178,247,99,116,126,131,170,217,164,221,231,219,71,255,84,35,19,175,238,241,3,124,87,29,40,36,37,1,215,198,93,26,45,138,151,115,69,30,117,246,239,253,150,147,205,162,172,94,250,167,197,163,232,194,149,108,237,208,185],"expectedCoordinator":108},{"name":"generated-283-size-208","seedInt64":"346586956335163999","attemptNumber":55487,"members":[131,1,226,122,100,35,103,207,248,203,107,40,220,64,99,197,128,32,177,219,81,20,191,199,124,102,184,123,51,252,228,156,233,223,85,169,214,97,127,109,189,231,164,90,238,62,69,167,229,19,104,162,158,221,116,212,37,173,53,208,12,65,172,152,119,227,5,92,11,45,41,26,24,155,237,213,121,36,193,255,57,138,52,160,66,80,101,98,34,141,58,117,72,118,126,132,142,157,29,125,28,2,75,161,94,246,25,120,136,170,68,15,114,182,111,113,249,192,215,21,236,254,163,105,250,133,190,149,91,96,78,59,244,181,165,23,234,71,247,245,79,30,67,194,201,147,110,89,60,16,151,74,115,224,43,217,218,176,18,54,178,134,188,210,202,200,171,168,216,145,73,205,232,225,82,148,27,70,33,135,106,4,174,241,14,9,137,50,185,204,63,13,183,61,93,187,95,84,140,38,251,31,240,195,83,8,48,186],"expectedCoordinator":127},{"name":"generated-284-size-1","seedInt64":"-849178398913758776","attemptNumber":1122602558,"members":[42],"expectedCoordinator":42},{"name":"generated-285-size-42","seedInt64":"-5252790474996594144","attemptNumber":2,"members":[173,12,45,230,28,95,151,29,208,150,59,138,246,227,188,14,20,68,13,222,245,81,200,5,39,131,242,90,87,147,244,57,44,10,3,117,54,231,198,205,88,238],"expectedCoordinator":39},{"name":"generated-286-size-87","seedInt64":"-1301748709969340648","attemptNumber":27282,"members":[53,244,248,57,80,219,38,246,153,1,234,181,11,88,77,170,201,193,4,71,166,72,112,139,115,154,83,229,10,42,5,96,24,16,179,171,141,235,254,142,30,28,45,138,43,31,65,209,132,199,2,78,174,243,224,128,86,108,168,245,70,75,125,47,58,127,8,159,200,175,210,23,231,220,92,196,211,161,103,85,129,7,95,60,66,250,120],"expectedCoordinator":86},{"name":"generated-287-size-212","seedInt64":"-6191604708764174130","attemptNumber":2612205887,"members":[161,190,93,226,25,150,148,46,109,16,106,212,198,118,136,59,47,32,243,222,246,37,28,8,12,120,130,129,201,85,55,143,180,160,2,22,10,58,239,82,111,61,113,234,185,189,100,154,172,131,233,125,202,195,103,231,184,181,221,101,107,91,7,98,142,123,199,35,137,13,193,227,140,144,76,149,146,30,63,75,114,51,157,139,248,210,45,71,50,79,14,124,205,204,96,62,238,44,119,41,250,206,207,169,70,64,173,241,252,24,57,127,164,15,42,29,38,135,255,170,3,194,245,97,56,242,49,81,86,20,9,67,18,95,54,203,69,249,166,19,244,165,1,112,196,104,163,134,65,102,251,94,6,108,176,39,168,155,36,31,4,183,159,99,158,53,178,73,128,77,228,23,105,152,11,187,5,40,232,133,132,236,220,122,219,167,162,151,80,48,33,153,145,213,192,229,90,224,217,83,223,174,78,216,254,121,230,92,21,52,88,66],"expectedCoordinator":42},{"name":"generated-288-size-5","seedInt64":"8460265521305944609","attemptNumber":0,"members":[120,104,212,55,198],"expectedCoordinator":120},{"name":"generated-289-size-45","seedInt64":"1334925036902726330","attemptNumber":44757,"members":[91,83,88,104,236,169,246,94,101,214,96,212,227,7,92,10,6,126,243,1,203,11,16,235,158,40,112,160,138,142,47,248,109,80,90,232,127,140,3,129,222,22,146,62,73],"expectedCoordinator":140},{"name":"generated-290-size-58","seedInt64":"7963897732361553443","attemptNumber":3379668956,"members":[4,73,113,13,144,16,7,26,156,35,22,61,147,234,14,30,209,117,218,74,247,141,190,158,8,106,60,204,29,50,176,64,230,215,48,116,199,225,112,188,194,107,136,139,3,76,233,254,154,145,102,130,11,42,245,111,21,57],"expectedCoordinator":60},{"name":"generated-291-size-183","seedInt64":"3743747289024107697","attemptNumber":4,"members":[120,6,236,168,158,244,166,152,248,198,148,86,34,117,186,222,122,232,139,108,16,208,20,150,254,121,199,131,233,220,39,26,223,194,189,83,212,195,71,191,176,190,181,165,193,174,96,123,5,147,237,115,51,41,46,4,235,98,73,14,243,250,206,179,30,192,72,91,197,128,228,90,153,160,31,238,35,210,203,69,133,113,178,89,55,36,217,229,252,241,196,105,100,12,10,253,99,118,149,114,87,77,60,80,227,211,70,58,247,135,40,67,38,234,126,29,132,159,188,151,22,200,219,175,205,183,19,103,33,184,215,145,17,107,109,92,93,119,161,216,44,110,11,164,18,221,66,239,204,7,82,79,49,173,146,101,97,167,75,65,185,62,224,37,61,2,162,141,209,111,54,201,25,42,231,154,95,3,142,52,138,48,53],"expectedCoordinator":61},{"name":"generated-292-size-5","seedInt64":"-6769660257035880930","attemptNumber":9711,"members":[133,10,56,3,186],"expectedCoordinator":133},{"name":"generated-293-size-36","seedInt64":"-3698874580135930524","attemptNumber":4233378993,"members":[247,226,29,207,86,151,47,122,23,230,27,160,127,24,61,214,63,178,59,33,155,202,115,141,211,223,91,195,5,117,236,39,21,176,180,185],"expectedCoordinator":47},{"name":"generated-294-size-88","seedInt64":"-4109064334437528177","attemptNumber":2,"members":[160,222,29,243,68,155,4,153,37,235,196,107,141,194,242,63,199,41,90,115,188,65,24,87,149,198,97,209,75,73,213,171,165,74,38,59,134,26,11,33,142,232,239,1,53,207,187,45,70,19,224,30,181,135,173,168,246,191,105,227,225,10,249,7,179,166,60,154,125,61,248,158,228,148,129,25,223,113,178,151,100,88,21,140,159,183,123,2],"expectedCoordinator":97},{"name":"generated-295-size-209","seedInt64":"6060606102012395678","attemptNumber":60587,"members":[77,81,108,237,100,207,39,61,168,23,230,105,227,243,122,40,89,197,206,199,151,233,5,16,229,119,220,134,171,236,83,31,30,54,126,20,234,24,160,6,161,10,115,103,2,210,208,135,204,58,240,224,144,59,241,149,170,245,244,33,8,11,214,18,212,99,4,142,111,97,78,205,201,123,9,95,91,22,55,1,202,238,113,130,147,68,66,3,70,82,128,219,29,116,80,56,94,42,74,153,41,154,158,194,184,143,76,159,114,117,166,193,180,131,182,246,226,146,249,221,165,63,129,87,19,152,136,92,26,192,121,13,176,28,86,191,90,62,223,188,181,231,53,49,7,196,169,15,109,36,255,213,174,65,248,209,162,178,228,60,85,120,133,163,27,52,127,172,200,35,225,21,32,71,118,164,232,88,96,106,186,72,104,148,93,51,167,218,137,79,14,183,252,50,198,45,179,145,215,150,175,75,141,12,132,102,187,251,195],"expectedCoordinator":214},{"name":"generated-296-size-2","seedInt64":"5765277026270687903","attemptNumber":1858507268,"members":[77,61],"expectedCoordinator":77},{"name":"generated-297-size-26","seedInt64":"4034657053873729993","attemptNumber":6,"members":[211,120,84,8,59,239,158,58,62,245,56,81,46,90,244,133,238,89,30,228,14,203,61,164,167,142],"expectedCoordinator":8},{"name":"generated-298-size-69","seedInt64":"7520671523198270765","attemptNumber":42658,"members":[173,219,104,199,46,245,237,94,131,17,32,185,22,30,151,93,133,246,26,115,71,111,208,9,254,96,63,155,12,154,213,100,157,196,175,18,6,83,92,105,176,139,126,135,43,27,207,73,23,169,113,2,134,56,242,160,114,51,191,117,90,109,243,77,164,62,80,189,36],"expectedCoordinator":134},{"name":"generated-299-size-154","seedInt64":"-1835676456601308670","attemptNumber":2386789891,"members":[230,121,107,180,19,112,83,105,108,48,226,12,129,57,219,143,34,65,50,110,215,123,223,64,148,227,32,66,185,43,8,248,174,186,183,189,140,199,11,191,147,15,60,104,13,119,41,152,134,79,27,239,73,158,241,76,225,150,113,49,162,86,229,52,161,151,224,101,197,106,87,181,92,58,251,179,133,95,160,242,172,167,206,252,70,100,155,214,145,157,156,96,44,132,124,85,200,28,175,128,35,163,9,247,20,116,137,173,117,166,46,196,10,94,187,122,4,208,125,63,26,149,5,246,236,103,74,159,221,135,29,210,17,78,16,18,211,255,88,75,22,3,81,99,115,245,47,178,89,220,235,198,114,203],"expectedCoordinator":85},{"name":"generated-300-size-4","seedInt64":"-4631034722845140786","attemptNumber":2,"members":[32,252,253,77],"expectedCoordinator":77},{"name":"generated-301-size-13","seedInt64":"4597630699952046607","attemptNumber":26797,"members":[152,74,239,156,215,115,114,36,106,209,121,6,109],"expectedCoordinator":6},{"name":"generated-302-size-66","seedInt64":"5654958411379756865","attemptNumber":205760467,"members":[103,212,20,230,211,104,70,252,25,163,164,174,224,63,31,225,130,153,43,127,217,41,183,161,21,238,160,243,77,156,189,66,169,237,4,82,155,236,86,181,157,57,154,151,23,122,107,75,162,172,213,95,206,219,51,102,144,204,129,170,138,244,215,22,180,106],"expectedCoordinator":41},{"name":"generated-303-size-161","seedInt64":"3640417374877905127","attemptNumber":2,"members":[32,75,93,6,211,111,33,17,94,176,221,175,103,80,185,57,28,63,87,50,206,67,38,41,68,123,51,253,78,72,96,203,60,55,88,250,117,71,218,208,158,132,234,213,73,201,237,140,26,54,189,148,113,65,116,195,151,49,130,205,154,194,241,220,91,4,172,39,207,169,164,192,252,14,126,239,173,254,120,31,52,133,141,92,183,81,95,20,198,124,108,118,82,36,112,1,24,48,209,12,85,35,150,134,236,235,217,197,191,46,163,162,84,69,121,246,227,19,10,23,187,180,15,97,204,30,240,244,230,125,29,229,99,53,219,242,199,59,181,3,115,216,228,66,105,223,156,102,243,167,138,42,232,149,147,129,89,160,76,21,139],"expectedCoordinator":33},{"name":"generated-304-size-1","seedInt64":"-533587930471524142","attemptNumber":34964,"members":[194],"expectedCoordinator":194},{"name":"generated-305-size-35","seedInt64":"-286205457714084574","attemptNumber":2874851459,"members":[216,81,189,38,229,116,100,27,30,24,186,211,232,165,124,235,166,83,108,156,251,163,42,79,44,138,143,97,121,66,53,102,206,71,201],"expectedCoordinator":79},{"name":"generated-306-size-65","seedInt64":"-7529918287902757072","attemptNumber":4,"members":[15,189,249,167,182,35,232,170,156,101,185,138,171,13,230,81,96,192,243,153,237,233,235,78,126,105,12,150,70,234,28,20,107,190,59,128,36,43,39,203,74,183,45,216,251,157,87,103,40,80,214,68,98,4,54,186,172,176,14,92,241,211,162,18,106],"expectedCoordinator":237},{"name":"generated-307-size-115","seedInt64":"1147489243025262916","attemptNumber":38889,"members":[254,136,4,12,82,137,25,148,43,123,96,1,108,199,117,185,8,48,222,223,186,52,146,197,216,206,250,156,252,28,19,29,160,158,81,6,75,87,59,165,215,198,134,143,32,21,168,144,88,24,203,221,33,20,44,129,177,235,3,74,38,30,119,72,17,225,97,106,212,255,209,22,107,175,193,31,37,253,200,227,58,246,92,50,95,178,167,150,34,84,207,18,23,172,191,135,247,112,237,111,51,110,130,121,79,80,9,114,180,242,226,147,232,210,170],"expectedCoordinator":255},{"name":"generated-308-size-1","seedInt64":"1154028153563691726","attemptNumber":1598436598,"members":[218],"expectedCoordinator":218},{"name":"generated-309-size-48","seedInt64":"8129216280207610659","attemptNumber":5,"members":[164,201,57,202,255,234,169,176,227,55,150,225,248,31,104,208,70,124,194,212,88,122,154,103,121,26,79,91,24,203,41,253,68,120,136,63,179,49,198,127,156,1,42,60,229,250,232,118],"expectedCoordinator":91},{"name":"generated-310-size-63","seedInt64":"-3675960736892375051","attemptNumber":10369,"members":[101,174,252,96,226,185,31,63,146,190,40,42,198,80,71,56,153,164,155,163,137,64,186,68,177,82,41,249,118,95,241,23,65,50,197,169,231,165,229,211,98,11,15,157,66,144,140,225,2,106,73,160,24,131,9,187,33,75,52,69,91,193,78],"expectedCoordinator":75},{"name":"generated-311-size-235","seedInt64":"4394581351719267346","attemptNumber":2749065642,"members":[3,200,100,156,220,185,203,244,140,186,194,169,106,78,44,249,5,150,165,207,136,218,240,87,113,28,253,46,161,33,196,34,176,22,215,245,177,31,209,10,184,202,4,88,67,211,20,174,12,236,198,49,14,55,61,104,183,149,65,111,201,94,7,81,32,82,92,74,229,26,91,168,255,6,118,210,227,50,173,181,208,224,62,127,162,41,89,252,204,146,188,248,58,45,36,27,76,246,192,29,23,8,130,63,190,153,226,154,193,39,90,129,70,64,143,17,172,235,199,166,212,2,171,9,195,132,237,108,175,223,139,79,231,52,73,40,155,167,145,101,213,243,1,230,121,42,232,35,96,98,59,247,86,115,148,122,221,24,142,219,123,103,71,241,48,53,75,205,13,170,135,21,119,57,206,60,56,151,217,102,251,147,197,11,97,15,233,191,160,158,164,250,84,117,157,138,163,112,110,214,66,116,54,187,80,239,179,159,43,126,152,68,47,133,180,137,19,107,134,85,124,30,83,234,238,141,228,69,254,128,178,109,93,114,95],"expectedCoordinator":4},{"name":"generated-312-size-4","seedInt64":"-1182911492953643829","attemptNumber":1,"members":[210,191,204,142],"expectedCoordinator":191},{"name":"generated-313-size-39","seedInt64":"-7927097093280803673","attemptNumber":39974,"members":[220,243,181,214,32,112,18,33,221,111,250,171,6,10,235,145,144,73,228,120,22,2,202,23,136,15,35,242,200,104,8,158,82,81,234,197,199,115,17],"expectedCoordinator":35},{"name":"generated-314-size-98","seedInt64":"8177957496488430895","attemptNumber":287707117,"members":[101,151,246,9,177,146,208,103,155,179,40,227,6,161,165,95,245,145,94,111,223,47,99,18,92,240,114,144,43,67,237,121,162,5,174,206,30,61,124,159,158,26,248,25,60,23,243,41,239,68,73,10,8,45,118,53,115,187,195,119,38,190,49,130,171,232,186,7,105,2,28,148,112,250,222,62,71,86,203,217,136,228,226,59,181,1,167,152,70,42,3,196,229,189,97,129,238,192],"expectedCoordinator":206},{"name":"generated-315-size-223","seedInt64":"-3318448994886996528","attemptNumber":1,"members":[161,218,176,180,121,137,3,97,154,150,73,94,223,10,28,168,59,27,11,142,41,53,102,77,170,76,24,193,23,4,224,206,26,55,175,179,238,131,215,188,209,66,124,108,5,200,109,254,136,185,49,1,178,119,52,222,207,194,104,247,69,64,226,227,62,173,166,50,13,71,151,7,67,14,93,134,72,141,234,183,253,205,91,159,61,246,177,33,51,140,241,114,196,129,212,220,181,239,155,38,112,84,117,231,63,19,132,88,79,9,250,252,235,123,65,89,190,248,197,43,192,232,152,6,171,92,82,31,182,195,130,153,216,213,158,100,133,169,245,44,164,225,163,118,54,174,255,40,122,244,110,2,242,113,45,42,202,125,105,156,22,35,85,30,243,17,165,219,162,96,229,237,201,75,98,78,25,138,16,240,160,12,15,120,116,167,204,86,221,249,103,184,48,18,186,233,214,148,139,210,211,32,143,46,39,101,20,127,81,236,251,21,57,145,56,146,90,228,189,187,60,29,191],"expectedCoordinator":202},{"name":"generated-316-size-5","seedInt64":"2794696337260855277","attemptNumber":61408,"members":[233,251,131,204,137],"expectedCoordinator":251},{"name":"generated-317-size-21","seedInt64":"-442699949838094251","attemptNumber":1225504949,"members":[5,152,240,81,221,164,173,72,4,122,248,214,189,6,20,182,66,3,28,207,108],"expectedCoordinator":221},{"name":"generated-318-size-72","seedInt64":"-2375301963124701923","attemptNumber":1,"members":[107,142,43,169,206,255,2,129,152,47,62,252,125,45,12,143,157,115,59,150,144,162,226,254,242,46,22,78,147,179,48,217,151,21,20,153,229,82,145,234,16,191,120,211,227,38,230,139,26,215,213,244,238,155,188,173,10,186,223,104,103,66,85,210,118,5,14,228,149,49,181,79],"expectedCoordinator":48},{"name":"generated-319-size-213","seedInt64":"7602520889459848657","attemptNumber":8276,"members":[250,5,16,120,138,231,69,170,221,246,251,89,64,158,66,166,245,63,106,252,143,116,186,60,31,205,244,37,227,52,198,196,219,96,146,132,29,19,214,140,181,25,2,18,133,145,49,88,46,206,100,207,113,169,51,82,6,192,155,223,197,151,165,122,17,134,177,144,85,73,248,83,80,220,150,38,179,237,56,28,204,203,139,128,92,24,215,10,90,65,199,33,185,12,131,7,180,15,176,97,191,208,239,125,23,61,225,156,45,218,195,228,226,48,241,59,95,213,148,200,13,240,235,135,21,163,91,50,35,108,175,126,1,58,71,72,124,118,20,232,224,102,94,84,117,41,202,43,47,173,154,101,254,152,127,211,189,130,86,187,247,27,110,39,193,53,111,76,238,174,236,44,114,164,172,142,230,93,190,107,119,54,160,212,137,57,11,103,182,121,98,8,159,9,78,171,253,104,75,222,149,161,217,147,184,210,112,26,188,81,4,216,136],"expectedCoordinator":161},{"name":"generated-320-size-5","seedInt64":"5829511963767178350","attemptNumber":2079359527,"members":[180,231,32,226,229],"expectedCoordinator":229},{"name":"generated-321-size-17","seedInt64":"-4037747378539566058","attemptNumber":7,"members":[132,108,4,91,228,158,14,68,55,197,23,82,255,190,32,89,163],"expectedCoordinator":255},{"name":"generated-322-size-73","seedInt64":"9151737678042439299","attemptNumber":64013,"members":[146,253,47,229,193,154,139,239,136,30,246,42,190,201,31,84,177,187,120,34,46,100,211,37,179,8,33,80,28,111,243,164,196,50,81,159,236,91,7,249,217,89,145,226,97,188,163,69,241,238,225,167,144,75,160,153,181,53,101,85,213,64,184,247,4,148,23,122,79,180,195,59,66],"expectedCoordinator":111},{"name":"generated-323-size-140","seedInt64":"7704055951887796161","attemptNumber":2741488147,"members":[69,225,151,204,63,144,180,155,240,166,17,203,121,32,89,53,107,199,37,146,84,48,133,243,132,224,7,160,223,58,170,116,148,64,65,101,216,213,61,104,222,19,248,208,226,130,6,202,113,43,154,3,254,26,188,186,111,52,245,156,201,198,92,31,211,206,209,165,80,18,185,13,183,190,242,122,20,187,218,115,126,192,99,66,233,221,252,2,182,41,174,10,255,114,167,24,38,25,159,145,175,172,142,128,103,157,249,86,162,135,94,12,106,46,120,215,164,178,195,143,214,109,98,23,118,158,139,40,217,149,27,177,253,16,77,219,5,150,244,131],"expectedCoordinator":224},{"name":"generated-324-size-3","seedInt64":"-3001124718866607244","attemptNumber":6,"members":[78,83,38],"expectedCoordinator":78},{"name":"generated-325-size-8","seedInt64":"44880964751921170","attemptNumber":40954,"members":[218,221,41,232,240,155,28,112],"expectedCoordinator":221},{"name":"generated-326-size-74","seedInt64":"3324970582664224964","attemptNumber":2991928361,"members":[99,17,52,9,104,42,118,218,220,175,96,11,105,132,34,167,53,77,228,129,114,158,92,133,85,223,83,241,38,134,135,121,62,177,163,188,64,122,149,28,182,48,102,89,6,237,198,36,45,206,66,57,243,12,16,93,174,204,55,51,44,207,172,106,226,22,183,202,68,63,209,130,165,154],"expectedCoordinator":104},{"name":"generated-327-size-212","seedInt64":"8282963999938701238","attemptNumber":7,"members":[170,42,124,125,65,105,122,13,87,81,106,102,165,205,110,149,132,115,57,141,113,216,238,68,90,235,38,227,215,61,210,114,197,70,251,116,211,232,231,176,155,244,139,111,174,84,127,92,224,55,20,221,48,4,17,39,133,158,30,250,83,147,218,5,74,130,178,255,6,69,22,187,219,120,123,52,207,15,131,96,240,75,142,121,24,76,103,195,186,151,45,51,18,181,249,25,3,11,108,64,145,27,28,88,208,37,31,230,77,14,254,126,164,233,246,184,237,58,229,93,8,29,118,252,89,222,59,239,173,82,191,241,198,99,72,223,50,245,242,53,185,86,32,36,23,156,152,80,62,19,67,94,104,129,16,135,46,162,98,12,10,128,157,79,107,73,169,136,34,26,148,183,63,226,2,200,213,101,179,95,167,7,234,194,202,153,143,253,236,180,206,228,119,91,192,168,220,248,209,100,190,54,189,33,163,243,201,193,171,41,56,196],"expectedCoordinator":132},{"name":"generated-328-size-2","seedInt64":"1664367199510311429","attemptNumber":54177,"members":[209,235],"expectedCoordinator":235},{"name":"generated-329-size-48","seedInt64":"4077385515459304567","attemptNumber":2966919393,"members":[237,197,36,98,109,182,242,217,40,162,223,210,120,130,105,79,184,115,55,126,2,65,195,43,173,102,44,187,249,174,238,192,245,51,75,196,64,111,26,119,34,85,159,246,13,213,122,177],"expectedCoordinator":115},{"name":"generated-330-size-79","seedInt64":"4067167176216684183","attemptNumber":2,"members":[206,34,35,86,122,199,22,139,222,255,234,123,166,137,103,33,56,142,169,64,101,89,42,186,43,165,63,99,200,170,173,223,2,67,91,192,236,153,216,176,66,155,141,21,7,129,140,163,243,93,15,237,13,149,87,3,174,152,69,175,17,180,32,240,229,213,127,20,135,226,72,228,195,9,88,79,92,144,77],"expectedCoordinator":2},{"name":"generated-331-size-195","seedInt64":"-3734345061815279098","attemptNumber":20358,"members":[9,89,65,171,176,130,113,181,97,57,12,54,221,201,144,53,126,112,143,61,156,160,150,211,235,239,111,90,80,207,4,40,81,186,175,91,210,128,47,192,74,88,165,233,220,240,104,247,138,63,159,213,18,226,122,139,70,223,169,217,136,131,154,120,208,62,206,95,66,23,14,69,151,58,152,15,212,1,108,145,185,142,2,109,25,114,6,43,214,72,46,168,10,11,92,147,118,157,167,202,162,121,229,24,3,79,190,110,22,64,245,163,87,203,7,219,243,191,231,83,51,222,59,205,158,31,204,140,44,209,182,166,255,101,137,27,29,76,133,36,75,224,32,132,37,71,215,135,78,33,100,193,8,196,48,164,249,246,67,106,41,35,178,252,84,198,170,96,98,197,125,189,56,55,200,199,124,20,248,187,85,119,129,179,153,39,5,134,86,103,188,73,172,194,30],"expectedCoordinator":178},{"name":"generated-332-size-5","seedInt64":"7180813223174769649","attemptNumber":3777703075,"members":[61,204,238,6,3],"expectedCoordinator":238},{"name":"generated-333-size-8","seedInt64":"-5987923548488851421","attemptNumber":0,"members":[196,99,244,10,182,70,133,250],"expectedCoordinator":182},{"name":"generated-334-size-52","seedInt64":"9139848762656414059","attemptNumber":22672,"members":[250,251,220,73,26,113,105,191,14,144,225,95,51,28,202,219,32,47,167,134,61,175,171,183,229,139,240,190,103,98,196,164,20,54,52,185,108,106,255,78,253,189,242,200,136,114,159,57,12,146,184,30],"expectedCoordinator":175},{"name":"generated-335-size-108","seedInt64":"6244580102525754611","attemptNumber":491295725,"members":[97,172,245,255,53,224,67,99,185,226,206,89,131,144,64,4,223,163,19,35,106,36,54,186,175,134,145,152,158,249,129,93,66,221,40,108,125,130,244,119,205,137,14,253,21,154,170,142,73,32,113,233,151,17,183,218,123,1,62,47,96,182,208,104,132,10,179,77,107,173,49,88,103,176,198,37,216,70,84,201,79,168,149,199,153,220,138,157,8,146,124,202,34,133,150,38,181,94,117,44,26,114,65,28,242,194,215,39],"expectedCoordinator":119},{"name":"generated-336-size-4","seedInt64":"-3129885752828963201","attemptNumber":1,"members":[139,8,75,41],"expectedCoordinator":75},{"name":"generated-337-size-12","seedInt64":"-1496785616394005518","attemptNumber":63273,"members":[91,82,250,12,18,73,150,118,244,193,181,143],"expectedCoordinator":181},{"name":"generated-338-size-60","seedInt64":"8051397413930387474","attemptNumber":4291044044,"members":[13,61,161,229,47,137,253,176,40,119,207,95,90,189,23,234,206,78,16,247,52,51,215,3,155,48,233,220,198,29,58,244,243,212,77,86,46,85,115,123,56,109,211,144,82,110,188,98,165,238,226,6,156,57,79,10,180,21,5,111],"expectedCoordinator":215},{"name":"generated-339-size-226","seedInt64":"1869831488665591720","attemptNumber":0,"members":[21,230,85,45,87,82,106,16,107,86,115,228,54,233,49,34,179,116,64,242,93,133,223,132,248,103,111,209,213,63,229,19,31,122,78,12,129,15,216,74,170,252,8,155,161,147,98,232,196,56,220,113,148,197,70,57,203,226,33,20,62,169,96,236,48,71,237,77,251,255,253,92,130,204,139,143,35,66,191,141,121,99,120,181,25,206,4,231,52,41,11,210,46,24,14,205,126,249,160,167,190,108,53,247,188,10,241,42,138,202,246,218,90,165,27,152,222,61,119,22,183,89,244,37,254,144,168,38,32,67,240,50,164,219,201,80,221,149,91,3,234,13,6,105,5,217,110,30,127,193,177,65,26,114,73,174,215,1,60,146,207,171,2,125,145,154,102,172,192,135,200,250,180,40,23,157,187,59,79,235,238,9,118,51,208,28,69,76,166,182,136,142,94,163,173,68,225,212,88,109,55,156,7,239,184,198,101,153,211,189,134,123,175,245,29,128,214,100,178,117,58,137,194,140,44,185],"expectedCoordinator":173},{"name":"generated-340-size-1","seedInt64":"-4447057605273643094","attemptNumber":28748,"members":[165],"expectedCoordinator":165},{"name":"generated-341-size-11","seedInt64":"704873975442180234","attemptNumber":669213940,"members":[101,94,250,191,200,100,180,153,6,213,212],"expectedCoordinator":6},{"name":"generated-342-size-85","seedInt64":"2137791194985341945","attemptNumber":7,"members":[84,167,249,94,181,119,90,162,248,208,239,14,17,33,216,146,203,26,159,108,253,132,67,147,52,31,177,12,195,186,212,151,224,137,59,34,194,210,11,226,222,110,225,204,29,85,245,144,95,5,27,246,30,109,241,125,199,23,242,189,230,64,46,15,152,135,130,66,200,16,60,56,2,91,45,171,218,18,118,138,21,116,13,213,105],"expectedCoordinator":13},{"name":"generated-343-size-110","seedInt64":"818697496696182558","attemptNumber":47134,"members":[2,84,161,147,119,11,16,174,206,21,176,202,102,54,178,1,104,190,40,146,51,214,103,46,236,19,254,70,141,169,28,239,166,74,26,25,188,235,135,150,72,226,118,52,66,9,38,210,250,71,173,216,49,248,163,201,230,196,243,217,88,237,197,27,101,191,123,53,85,224,116,78,122,112,94,184,117,213,31,108,8,86,149,140,47,165,44,87,179,187,64,41,95,186,181,107,157,76,164,109,144,205,148,200,180,83,111,89,137,106],"expectedCoordinator":72},{"name":"generated-344-size-4","seedInt64":"955598320770736980","attemptNumber":3247897681,"members":[91,38,61,79],"expectedCoordinator":38},{"name":"generated-345-size-14","seedInt64":"7204740131636628832","attemptNumber":2,"members":[32,43,78,30,75,67,218,148,212,183,48,235,54,181],"expectedCoordinator":212},{"name":"generated-346-size-62","seedInt64":"2887492199662702091","attemptNumber":56610,"members":[210,119,22,251,27,75,21,124,71,255,221,168,236,245,149,114,197,99,98,150,142,24,218,60,123,96,55,122,184,162,67,107,62,250,113,182,233,65,81,216,134,248,30,219,155,54,188,5,165,120,192,249,95,242,191,6,212,57,117,226,13,2],"expectedCoordinator":120},{"name":"generated-347-size-181","seedInt64":"3906008827320911801","attemptNumber":1738218105,"members":[28,166,73,196,93,133,132,87,25,178,30,14,78,141,34,213,168,184,198,11,60,183,71,74,2,119,187,144,223,44,102,154,126,221,232,9,194,214,64,19,241,89,127,108,55,17,90,248,116,185,173,193,80,230,122,227,15,91,82,208,250,151,69,182,181,254,100,16,204,147,83,135,129,96,10,43,118,210,245,81,143,72,48,46,110,112,97,8,45,138,113,5,157,160,136,41,242,247,140,35,215,209,188,211,51,169,4,38,39,234,197,155,217,123,105,63,7,176,131,224,177,88,165,146,18,3,190,23,236,22,128,52,171,252,164,121,251,159,192,149,202,47,12,92,216,114,115,243,84,174,228,156,240,233,255,170,124,148,20,58,152,67,134,244,99,79,13,249,212,226,253,201,76,238,239,186,103,57,98,231,145],"expectedCoordinator":51},{"name":"generated-348-size-5","seedInt64":"-1016237783980719671","attemptNumber":4,"members":[192,19,92,30,131],"expectedCoordinator":131},{"name":"generated-349-size-35","seedInt64":"8107214548653462612","attemptNumber":14987,"members":[81,25,56,236,88,176,252,50,141,84,169,70,13,137,101,189,224,164,177,206,112,168,214,219,11,79,181,111,131,185,108,172,39,218,249],"expectedCoordinator":137},{"name":"generated-350-size-90","seedInt64":"1613344499385377859","attemptNumber":591566116,"members":[64,73,52,120,136,211,174,135,91,147,246,144,190,227,134,19,94,194,50,141,179,186,212,234,180,32,205,36,193,59,47,232,209,238,253,218,197,225,112,142,11,78,243,23,173,203,54,89,133,221,118,38,146,131,164,13,150,122,233,154,251,177,65,195,9,226,252,123,25,41,110,102,207,220,53,57,6,101,188,42,153,178,199,111,62,76,39,172,245,196],"expectedCoordinator":57},{"name":"generated-351-size-198","seedInt64":"-1171288928880102437","attemptNumber":1,"members":[205,50,168,226,139,23,136,213,113,251,197,212,142,18,214,124,241,218,240,60,145,83,185,161,233,250,64,15,183,252,57,224,6,211,81,219,38,162,97,172,206,41,112,55,95,8,70,24,106,173,94,229,107,192,1,199,20,91,105,78,69,67,28,189,165,147,181,188,148,174,230,47,154,27,65,14,30,187,75,220,209,239,150,163,193,242,85,146,90,244,110,21,243,133,54,104,144,149,177,87,202,227,176,237,43,77,222,4,167,92,228,236,215,66,33,254,26,122,138,153,249,223,120,16,200,152,22,34,116,88,102,128,103,151,221,3,36,37,186,76,201,39,86,89,72,58,10,160,61,134,137,115,25,248,198,195,96,44,71,121,247,125,52,235,194,129,178,207,82,98,49,182,45,180,62,7,246,79,63,93,40,31,208,179,245,118,35,169,135,32,159,19,99,232,123,175,132,238],"expectedCoordinator":249},{"name":"generated-352-size-1","seedInt64":"8646868891905212189","attemptNumber":1835,"members":[65],"expectedCoordinator":65},{"name":"generated-353-size-41","seedInt64":"8044389130235457331","attemptNumber":2495527618,"members":[36,56,96,95,13,110,239,26,57,198,6,22,67,34,31,240,183,245,191,181,213,225,203,185,77,236,12,153,61,212,202,255,241,106,100,237,94,139,149,66,52],"expectedCoordinator":153},{"name":"generated-354-size-69","seedInt64":"-4981818601732735306","attemptNumber":7,"members":[22,176,32,131,218,73,120,47,196,159,141,98,94,99,241,25,101,197,121,55,9,136,243,112,107,35,147,113,103,81,172,140,102,255,16,204,17,56,26,70,42,207,79,191,224,91,119,177,51,27,72,149,125,23,18,187,62,166,165,4,139,238,221,89,213,173,97,122,144],"expectedCoordinator":243},{"name":"generated-355-size-167","seedInt64":"2537126541657727730","attemptNumber":7800,"members":[172,65,245,11,134,111,161,50,230,184,231,185,218,157,167,162,183,80,16,182,135,255,103,73,64,150,128,49,252,102,126,60,72,108,4,221,98,240,223,212,89,15,121,235,18,31,27,70,34,116,55,146,207,122,195,176,127,177,242,254,79,97,214,165,22,83,48,44,93,200,190,241,198,117,227,8,74,99,229,35,210,180,57,14,141,104,118,213,234,153,151,222,205,237,239,232,63,39,21,152,155,24,43,246,154,36,90,42,106,52,112,9,56,58,139,206,95,51,38,147,47,143,84,25,215,199,204,107,145,5,144,236,219,131,208,203,148,160,187,216,77,109,29,194,130,88,33,119,181,129,10,53,17,251,40,226,238,249,228,202,179,247,123,91,225,92,191],"expectedCoordinator":33},{"name":"generated-356-size-4","seedInt64":"4025575101090010839","attemptNumber":3185209672,"members":[137,19,204,67],"expectedCoordinator":19},{"name":"generated-357-size-27","seedInt64":"3680595689841125018","attemptNumber":0,"members":[95,166,147,90,1,57,214,184,55,242,197,52,247,39,159,140,25,34,222,71,84,103,125,168,251,213,130],"expectedCoordinator":251},{"name":"generated-358-size-83","seedInt64":"1023207033875735643","attemptNumber":8655,"members":[32,168,95,129,143,106,6,253,225,165,110,172,70,66,116,72,188,223,212,2,62,48,115,216,92,227,12,27,233,245,33,155,123,20,193,107,57,46,42,121,23,13,61,44,139,136,133,51,16,119,53,200,211,114,112,148,67,79,214,249,8,22,126,141,140,231,234,52,77,103,14,189,215,86,199,254,31,246,142,161,167,43,63],"expectedCoordinator":165},{"name":"generated-359-size-172","seedInt64":"4473865898076328431","attemptNumber":567286057,"members":[49,107,52,55,14,204,82,164,15,70,237,137,27,207,43,78,20,3,161,192,57,195,8,238,203,105,6,119,251,147,37,151,131,11,17,156,21,240,152,80,9,39,83,143,56,244,191,51,42,25,62,255,118,254,197,218,235,171,28,175,222,29,63,242,189,114,76,53,89,40,69,68,196,210,241,71,41,86,132,124,219,165,228,168,172,72,245,193,106,247,215,202,65,159,232,93,225,50,209,178,97,176,248,208,155,103,211,220,18,26,167,64,38,214,47,141,61,7,226,4,184,32,10,30,212,34,234,146,249,94,98,253,182,252,31,185,144,77,22,239,233,112,179,12,188,101,100,88,59,246,85,223,183,54,130,243,5,109,136,148,23,125,200,150,163,2,201,111,99,81,44,1],"expectedCoordinator":159},{"name":"generated-360-size-3","seedInt64":"-6286753937138067290","attemptNumber":0,"members":[195,248,170],"expectedCoordinator":248},{"name":"generated-361-size-17","seedInt64":"3766005706151771389","attemptNumber":5309,"members":[94,78,157,244,183,186,5,174,119,176,210,211,100,85,166,29,23],"expectedCoordinator":210},{"name":"generated-362-size-79","seedInt64":"-8868431670605151783","attemptNumber":1789228427,"members":[81,28,210,53,40,59,219,254,85,161,49,71,2,18,221,200,12,230,216,8,57,244,84,215,163,245,191,29,238,1,229,193,164,25,168,105,122,32,208,217,15,17,170,139,188,46,235,9,4,101,167,98,236,171,11,68,33,129,253,56,165,162,130,176,179,92,24,104,211,202,16,30,206,72,48,112,62,178,241],"expectedCoordinator":24},{"name":"generated-363-size-170","seedInt64":"2949963395171182635","attemptNumber":4,"members":[189,21,147,103,185,113,181,183,119,24,30,148,210,75,239,83,153,116,88,182,160,100,67,78,255,158,213,190,130,234,77,95,80,211,169,84,19,85,115,35,123,107,13,125,154,90,34,225,202,20,5,226,4,39,82,201,249,46,50,66,250,58,187,136,33,40,176,52,252,171,241,42,8,191,128,9,117,254,112,247,118,60,173,253,37,251,218,29,135,167,54,94,192,164,120,133,134,61,244,7,56,65,57,198,1,41,17,93,204,197,236,10,177,15,70,131,126,138,2,195,91,142,73,76,22,227,159,28,51,6,63,240,72,150,243,206,96,55,246,26,208,215,237,59,106,18,232,11,188,178,179,143,43,110,222,212,141,233,23,122,194,89,109,124,111,14,184,27,102,223],"expectedCoordinator":184},{"name":"generated-364-size-2","seedInt64":"1249634355724197413","attemptNumber":48661,"members":[210,189],"expectedCoordinator":210},{"name":"generated-365-size-17","seedInt64":"479785467363067588","attemptNumber":3253273160,"members":[58,85,69,131,59,234,46,7,199,30,88,150,220,213,163,84,101],"expectedCoordinator":58},{"name":"generated-366-size-93","seedInt64":"381802547119599493","attemptNumber":3,"members":[48,220,15,176,203,117,246,152,211,168,4,233,29,109,153,8,155,46,118,249,43,234,253,81,172,66,226,228,89,99,52,27,205,201,103,218,195,136,202,108,86,42,16,232,214,53,222,208,51,116,194,6,123,219,217,230,241,197,94,147,134,235,45,184,39,88,255,25,112,216,31,36,68,72,248,229,95,240,148,80,177,21,87,59,158,146,13,132,182,119,245,180,60],"expectedCoordinator":229},{"name":"generated-367-size-141","seedInt64":"1977220599232138269","attemptNumber":43837,"members":[164,192,189,122,32,145,83,127,201,52,171,205,163,174,119,135,51,243,232,94,181,137,142,242,203,231,250,3,78,187,176,97,182,150,219,117,30,214,112,208,148,79,173,162,212,244,207,16,27,128,25,31,53,146,9,183,186,188,38,41,220,114,84,123,96,23,132,61,249,108,24,246,76,18,175,109,190,247,120,206,196,2,22,179,1,248,134,81,80,169,124,143,233,236,66,115,91,111,151,202,54,48,144,154,240,177,193,131,218,191,147,165,35,255,19,69,44,49,90,222,59,160,42,118,86,170,26,67,184,226,56,126,241,227,235,253,213,110,178,65,34],"expectedCoordinator":134},{"name":"generated-368-size-2","seedInt64":"-2696281396346576550","attemptNumber":271014858,"members":[85,161],"expectedCoordinator":85},{"name":"generated-369-size-39","seedInt64":"-1455850212059492874","attemptNumber":6,"members":[43,191,239,139,166,143,254,141,155,55,187,123,110,35,5,196,19,95,212,161,151,41,45,247,215,20,64,183,51,127,85,214,149,119,204,107,90,240,182],"expectedCoordinator":191},{"name":"generated-370-size-59","seedInt64":"3762855937347766611","attemptNumber":5947,"members":[19,61,245,158,121,24,29,166,129,93,66,206,56,48,124,153,59,76,234,52,13,77,223,227,232,172,213,3,28,188,233,82,160,195,49,70,91,38,112,41,100,236,239,83,196,224,130,11,242,240,115,185,184,110,94,122,57,87,36],"expectedCoordinator":76},{"name":"generated-371-size-230","seedInt64":"-5198814667512864160","attemptNumber":3013291409,"members":[252,224,169,154,64,134,178,223,10,46,218,216,221,184,40,148,136,91,185,97,59,241,92,180,239,191,18,33,107,116,8,177,143,49,103,255,77,236,158,238,174,16,226,88,168,12,5,6,118,201,205,42,108,20,2,248,44,139,102,246,62,19,157,132,145,119,36,3,156,163,172,186,202,70,124,183,94,78,249,123,63,65,179,47,106,197,23,230,254,147,52,187,210,67,74,115,138,203,211,207,71,84,152,57,213,200,141,208,105,26,39,217,196,41,135,34,188,117,86,66,79,127,222,190,126,161,182,120,113,193,170,110,29,111,82,237,240,112,242,114,35,17,15,149,72,153,121,96,54,45,225,100,24,232,231,48,164,87,214,53,251,192,133,7,233,229,204,175,253,101,104,215,137,69,220,247,38,27,206,131,176,95,68,61,85,55,125,165,209,194,244,155,146,195,219,37,144,173,130,80,21,159,199,160,140,151,228,22,32,89,90,28,56,122,243,198,171,60,25,51,4,189,109,93,50,58,11,14,245,227],"expectedCoordinator":245},{"name":"generated-372-size-2","seedInt64":"3103415963261310271","attemptNumber":0,"members":[186,40],"expectedCoordinator":40},{"name":"generated-373-size-26","seedInt64":"3090710981723483928","attemptNumber":29882,"members":[228,183,132,146,213,145,209,56,191,232,165,252,253,124,22,216,244,195,72,74,251,225,188,239,174,208],"expectedCoordinator":124},{"name":"generated-374-size-58","seedInt64":"-5485097948226718265","attemptNumber":2339049414,"members":[125,252,88,206,171,157,13,194,218,204,12,166,91,45,99,253,3,209,176,141,23,116,4,247,159,104,52,133,130,77,231,90,155,188,59,244,145,202,121,39,58,29,128,43,76,80,63,44,107,34,230,198,112,36,137,25,2,26],"expectedCoordinator":157},{"name":"generated-375-size-245","seedInt64":"6681917197792807262","attemptNumber":6,"members":[215,106,86,116,174,132,145,30,194,25,6,170,228,19,34,222,131,74,227,120,115,16,73,245,13,206,242,232,2,169,50,44,96,21,64,210,252,111,162,133,188,135,248,155,59,136,76,229,36,125,141,1,32,179,209,7,253,46,137,163,53,219,207,230,159,15,119,20,173,18,200,114,251,55,71,108,3,208,226,82,78,93,54,195,246,197,77,113,225,88,178,40,217,233,189,184,243,168,56,35,171,121,235,205,153,142,118,223,87,103,216,193,187,101,39,51,112,146,158,52,83,126,95,143,48,69,247,75,220,183,72,49,98,104,237,110,22,109,134,138,211,161,182,99,180,147,238,66,224,79,148,167,249,154,152,11,176,105,198,181,151,42,23,37,236,31,240,58,29,10,140,117,84,250,14,157,172,127,144,185,100,218,12,43,213,97,160,122,156,203,123,231,26,244,212,239,214,91,61,202,234,150,165,186,130,70,201,33,90,128,129,166,221,175,190,241,254,47,9,192,57,102,80,24,94,27,149,199,63,62,8,89,107,17,65,4,5,191,38,45,164,85,68,92,255],"expectedCoordinator":158},{"name":"generated-376-size-2","seedInt64":"7218074998969327634","attemptNumber":53985,"members":[222,32],"expectedCoordinator":32},{"name":"generated-377-size-20","seedInt64":"2183938391142827029","attemptNumber":506187988,"members":[211,168,173,86,98,204,107,175,170,183,41,242,48,126,63,154,169,78,1,92],"expectedCoordinator":170},{"name":"generated-378-size-92","seedInt64":"-4492447877611075623","attemptNumber":7,"members":[239,240,68,78,4,105,84,51,192,216,190,86,149,8,22,106,76,114,241,54,131,187,39,245,250,207,155,93,202,193,98,183,208,120,65,179,130,75,18,27,74,171,236,132,162,198,36,58,194,197,225,243,176,196,247,137,1,7,21,9,6,79,231,61,203,163,133,81,154,227,53,3,147,185,80,223,12,119,230,229,40,175,26,142,174,151,87,242,213,63,221,219],"expectedCoordinator":114},{"name":"generated-379-size-251","seedInt64":"-7310650097399260988","attemptNumber":6539,"members":[134,160,71,12,180,201,82,23,249,81,52,46,200,61,203,192,245,119,178,79,6,35,122,7,95,213,186,32,22,143,75,62,211,124,156,157,182,8,174,181,103,116,65,31,197,48,239,17,51,42,105,166,219,78,2,183,4,254,126,86,146,142,96,247,252,128,99,222,177,141,152,135,229,189,251,161,29,37,129,50,250,121,104,127,227,140,53,216,98,199,85,206,74,238,215,83,111,84,93,179,137,194,207,164,113,89,136,230,175,144,87,209,188,248,148,246,170,66,60,214,176,27,44,13,38,153,243,187,173,97,241,155,100,139,255,114,232,235,208,72,226,80,240,59,47,55,36,19,9,115,90,244,147,138,10,231,70,14,253,94,198,125,76,88,101,92,171,149,236,165,68,154,106,212,151,3,163,43,25,21,107,234,67,28,223,130,210,73,202,20,132,237,16,162,64,57,193,172,242,54,34,69,184,41,196,39,225,15,24,217,40,167,58,117,56,159,168,102,1,205,190,26,109,30,5,110,91,191,228,150,218,131,112,133,45,63,11,123,195,33,221,224,220,49,204,120,233,158,18,118,169],"expectedCoordinator":148},{"name":"generated-380-size-2","seedInt64":"7781628491165608908","attemptNumber":4262402584,"members":[17,215],"expectedCoordinator":215},{"name":"generated-381-size-50","seedInt64":"7233224353024129826","attemptNumber":3,"members":[184,83,85,68,53,14,84,137,75,47,254,198,138,39,211,90,12,242,11,209,78,239,231,176,26,245,145,232,79,208,212,174,177,193,170,229,95,252,28,3,38,143,248,23,25,160,22,63,16,118],"expectedCoordinator":11},{"name":"generated-382-size-91","seedInt64":"-8323035444829997457","attemptNumber":61339,"members":[211,151,59,79,221,37,76,52,121,167,127,252,47,200,182,220,213,177,74,30,142,187,136,94,238,244,131,96,169,135,40,217,71,201,218,240,237,155,45,148,216,91,106,145,19,165,31,97,68,63,27,236,60,17,128,154,54,57,77,207,36,205,33,171,24,130,126,129,194,203,247,29,193,219,191,58,228,232,11,199,189,204,41,178,227,158,122,166,184,157,48],"expectedCoordinator":47},{"name":"generated-383-size-156","seedInt64":"-4122981267297444425","attemptNumber":78372358,"members":[34,31,32,123,75,182,55,66,126,224,106,219,227,15,137,86,52,215,251,56,175,62,253,96,184,211,46,115,47,82,168,4,64,110,152,246,119,102,41,53,88,17,214,73,151,208,230,113,81,99,252,26,109,43,142,29,139,79,245,193,160,213,6,183,159,158,185,125,90,87,21,250,13,138,149,112,35,194,85,45,181,163,135,48,153,243,100,89,16,144,7,37,77,212,248,174,150,148,165,83,192,187,131,209,186,33,8,141,191,57,101,107,18,225,98,217,3,236,167,91,116,238,140,50,136,201,180,74,220,68,172,117,60,147,240,59,122,206,176,223,235,200,69,61,65,22,177,241,202,23,72,166,71,210,190,27],"expectedCoordinator":230}]} 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); +}