Skip to content

hardening(tbtc/signer): bind full transcript into round nonces; gate transitional signing out of production - #4028

Merged
mswilkison merged 3 commits into
extraction/frost-signer-mirror-2026-05-26from
hardening/tbtc-signer-nonce-binding-2026-06-11
Jun 11, 2026
Merged

hardening(tbtc/signer): bind full transcript into round nonces; gate transitional signing out of production#4028
mswilkison merged 3 commits into
extraction/frost-signer-mirror-2026-05-26from
hardening/tbtc-signer-nonce-binding-2026-06-11

Conversation

@mswilkison

@mswilkison mswilkison commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Stacked on #4005 (base: extraction/frost-signer-mirror-2026-05-26). Implements item 1 of the review feedback on the FROST/ROAST stack (nonce rollback safety).

Problem

The transitional StartSignRound/FinalizeSignRound flow derives round-1 nonces deterministically from H("round-nonce", signing_share, session_id, round_id, message, participant_id). Nonce-reuse safety therefore rested on two indirect properties:

  1. round_id schema integrity — the participants set, Taproot tweak, and attempt context were bound only through derive_round_id. Any future change to that derivation (or an encoding collision in it) could let two different FROST transcripts share a nonce seed, which is the share-extraction condition.
  2. Consumed-round registry integrity — the on-disk registries were the replay boundary. A VM snapshot restore, backup restore, or state replicated to a second host silently re-arms consumed rounds.

What changed

1. Total direct binding (RoundNonceBinding, domain round-nonce-v2). The nonce seed now directly binds every value that enters the FROST binding factor, challenge, Lagrange interpolation set, or key-material selection: group verifying key, Taproot merkle root, canonical signing-participant set — in addition to the existing signing share, session, round, message, and participant id. The struct carries a documented invariant so the next transcript input added to this flow gets added to the seed in the same change. Consequence: a rolled-back or cloned state can only ever repeat an identical transcript (producing the identical signature — no new information), never the same nonce under a different challenge. Nonce safety no longer depends on round_id schema or registry integrity at all.

2. Production hard-gate on the deterministic-nonce entry points. Dealer DKG was already blocked in production, but that gate only fires at session creation: persisted state created under a development profile could be carried into a production-profile process and signed with. StartSignRound/FinalizeSignRound now reject in the production profile with reason transitional_deterministic_signing_disabled_in_production, making the OS-random interactive FROST path the only production signing path regardless of how on-disk state was created.

Why not the per-boot RAM-only salt suggested in the review

Exploration showed the transitional flow has every member independently derive all participants' commitments with zero round-1 exchange (members exchange only signature shares), so cross-machine determinism is load-bearing: a per-machine salt would break every multi-member bootstrap session. The two changes above implement the same goal — rollback costs liveness, never keys — within the flow's actual architecture: (1) removes the nonce-reuse class structurally, (2) removes production exposure structurally. The stateless interactive path (GenerateNoncesAndCommitments) already draws from OS randomness and holds nonces only in caller RAM.

Tests

  • deterministic_round_nonce_and_commitment_binds_full_transcript: identical binding re-derives identical commitments; each of 7 binding inputs (message, tweak root, participants set, group key, session, round, participant) independently changes the commitment.
  • start_sign_round_rejects_transitional_signing_in_production_profile / finalize_sign_round_rejects_transitional_signing_in_production_profile: the state-smuggling scenario — dev-created dealer session, production-profile process — rejects at both entry points, even with the strict-mode env flag explicitly disabled.
  • production_profile_forces_roast_strict_mode_without_env_flag repurposed to assert the strict-mode forcing at the helper level (the FFI-level path it previously exercised is now unreachable in production by design).
  • Full suite: 246 passed, 0 failed; clippy and rustfmt clean.

Notes for the mirror

  • The seed domain bump (round-nonceround-nonce-v2) changes derived commitments for identical inputs; a mixed-version fleet cannot co-sign transitional rounds mid-rollout. Dev/staging-only flow, so the cost is a failed attempt until the fleet converges.
  • Port back to the tBTC monorepo signer alongside the next extraction sync.

…transitional signing out of production

The transitional StartSignRound/FinalizeSignRound flow derives round-1
nonces deterministically. Its nonce-reuse safety previously rested on two
indirect properties: (1) every transcript-affecting input staying bound
into the seed via the round_id derivation schema, and (2) consumed-round
registry integrity on durable state, which rollback/restore/replication
can silently violate.

Remove the failure class instead of guarding it:

- Introduce RoundNonceBinding with a documented invariant: every value
  entering the FROST binding factor, challenge, Lagrange set, or key
  material selection feeds the nonce seed directly. The seed now also
  binds the group verifying key, the Taproot tweak root, and the
  canonical signing-participant set (domain bumped to round-nonce-v2).
  Nonce safety no longer depends on round_id schema evolution or on
  registry integrity: any transcript variation yields a fresh nonce, so
  state rollback can only repeat identical transcripts (yielding the
  identical signature), never the same nonce under a new challenge.

- Gate the deterministic-nonce entry points out of the production
  profile. Dealer DKG was already production-blocked, but persisted
  state created under a development profile could be carried into a
  production-profile process and signed with. StartSignRound and
  FinalizeSignRound now reject with
  transitional_deterministic_signing_disabled_in_production; production
  signing is the interactive FROST path with OS-random nonces only.

A per-boot RAM-only salt was considered and rejected: the transitional
flow has every member independently derive all participants' commitments
with no round-1 exchange, so cross-machine determinism is load-bearing;
a per-machine salt would break every multi-member bootstrap session.

Mirror note: port back to the tBTC monorepo signer alongside the next
extraction sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fd0f82d8-00e6-42f1-adfa-8946815a80a1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hardening/tbtc-signer-nonce-binding-2026-06-11

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

mswilkison and others added 2 commits June 11, 2026 17:49
The widened round-nonce-v2 binding mixes encodings: the participants
set serializes big-endian while participant_identifier keeps the v1
little-endian encoding. Harmless (fixed-width parts, length-framed by
deterministic_seed) but part of the derived value -- note that any
encoding change requires a new seed domain, never an in-place edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes a completeness gap in the v2 RoundNonceBinding found in third-pass
review (P1). The seed bound only the *group* verifying key, not the
individual verifying shares. In the transitional flow every member
re-derives ALL participants' round-1 commitments from the held key
packages, so each other participant's verifying share enters the
commitment list -> this member's binding factor and challenge. Two key
packages can share a group verifying key while differing in a non-target
share (any threshold t>=3 admits two polynomials with identical f(0) and
target share but a different non-target share).

Consequence under the old binding: a rolled-back/restored/cloned state
(exactly #4028's threat model) could present an identical nonce seed
under a *different* challenge -> the same member signs two different
challenges with one deterministic nonce -> share extraction. The
in-process run_dkg SessionConflict guard does not cover this, by design:
nonce safety must not depend on registry integrity, since durable state
can be rolled back or replicated. The production hard-gate still blocks
this transitional flow in production, so the exposure is confined to the
dealer-DKG dev/staging path; the interactive production path draws from
OS randomness and is unaffected.

Fix: bind the full serialized PublicKeyPackage (group key AND every
verifying share); domain round-nonce-v2 -> round-nonce-v3.

Regression: deterministic_round_nonce_and_commitment_binds_full_transcript
now includes a variant with the baseline group key but a non-target
verifying share swapped; it produces an identical seed (and asserts an
identical group key) under the old binding, a different commitment under
the new one. Full signer suite 246 pass; clippy/rustfmt clean.

Mirror note: v3 domain + the widened binding port back to the tBTC
monorepo signer with the next extraction sync, alongside the rest of #4028.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mswilkison
mswilkison merged commit d47f009 into extraction/frost-signer-mirror-2026-05-26 Jun 11, 2026
19 checks passed
@mswilkison
mswilkison deleted the hardening/tbtc-signer-nonce-binding-2026-06-11 branch June 11, 2026 23:10
mswilkison added a commit that referenced this pull request Jun 12, 2026
…submodules (#4036)

Post-merge follow-up #2 from the June 2026 review stack (#4028#4035).
`engine.rs` was deferred-split to avoid conflicting with the open stack;
with the stack merged it had grown to 18,248 lines (absorbing four
merges plus the round-nonce-v3 fix), and every new PR contends for the
same file. This lands the split before anything new piles onto the
monolith.

## What this is

A **pure code move** — no behavior change, no API change, no test-path
change.

| Module | Lines | Contents |
|---|---|---|
| `state` | 434 | in-memory engine/session state, state-file lock,
registry capacity guards |
| `persistence` | 1,421 | encrypted state envelope, key
providers/commands, corruption recovery, persisted↔live conversions |
| `config` | 392 | the `TBTC_SIGNER_*` env surface: const names,
defaults, parsers, profile detection |
| `policy` | 633 | admission, signing-policy firewall, rate limiting,
auto-quarantine config |
| `provenance` | 353 | runtime provenance attestation gate |
| `telemetry` | 313 | hardening latency trackers + metrics |
| `lifecycle` | 468 | canary rollout, refresh cadence/shares, emergency
rekey, quarantine status |
| `audit` | 376 | transcript audit, blame-proof verification,
differential fuzzing |
| `codec` | 430 | hex/struct codecs, Go↔frost identifier conversions |
| `frost_ops` | 303 | stateless `dkg_part1..3`, nonces, signing package,
share, aggregate |
| `nonce` | 99 | **`RoundNonceBinding` + deterministic round-nonce
derivation (round-nonce-v3), isolated for audit** |
| `roast` | 1,003 | RFC-21 attempt machinery: request fingerprints,
round/attempt ids, attempt-context + transition-evidence validation |
| `dkg` | 257 | `run_dkg` flow + transitional-dealer production gates |
| `signing` | 970 | `start_sign_round` / `finalize_sign_round` flows,
bootstrap synthetic contributions |
| `transaction` | 227 | taproot tx building |
| `testsupport` | 88 | cfg(test) cross-module helpers
(`lock_test_state`, `reset_for_tests`, …) |
| `tests` | 10,558 | the former inline `mod tests`, moved **verbatim** |

## Design decisions

- **`engine::tests::*` paths are preserved.** `mod tests` moved as a
single child module (`engine/tests.rs`), so
`scripts/run_phase5_chaos_suite.sh`'s five `cargo test … -- --exact`
filters and every `engine::tests::<name>` reference in the phase docs
remain valid. Splitting tests further would force rewriting those
contracts — left as an explicit team decision.
- **Visibility:** formerly-private items are now `pub(crate)`; each
submodule opens with `use super::*;` against glob re-exports in
`mod.rs`. Since `lib.rs` keeps `mod engine;` **private**, the
crate-external surface is byte-identical. Per-module visibility
tightening can happen incrementally later.
- **`config.rs` deliberately concentrates the env surface** — it
pre-stages follow-up #3 (move `TBTC_SIGNER_*` env vars into an init-time
FFI config struct) as a mostly-one-file change.
- **Only semantic edit in the whole diff:** the
`include_str!("../testdata/coordinator_seed_vectors.json")` in the tests
gains one `../` because the file now sits one directory deeper.

## Verification

- `cargo fmt --check` ✅, `cargo clippy --all-targets -- -D warnings` ✅
- Full suite: **223 passed + 1 ignored / 24 / 1 — counts identical to
the pre-split HEAD** (verified by stashing the split and re-running on
d47f009)
- `cargo test formal_verification_` ✅ (5/5); all five chaos-suite
`--exact` paths ✅
- `testdata/` untouched — seed vectors and shuffle corpus remain
byte-identical
- Review aid: `git diff d47f009 --color-moved=zebra
--color-moved-ws=ignore-all-space` renders nearly the entire diff as
moved lines; `git blame -C -C` follows history across the split.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
mswilkison added a commit that referenced this pull request Jun 12, 2026
…ig (#4037)

Post-merge follow-up **#3** from the June 2026 review stack
(#4028#4035; #4036 landed the engine split that stages this change):
move the `TBTC_SIGNER_*` env-var surface into an init-time FFI config
struct, shrinking the ops/audit surface from ~40 scattered
`std::env::var` reads to one explicit, validated installation at
startup.

## What this adds

New FFI entry `frost_tbtc_init_signer_config(request_ptr, request_len)`
taking a typed JSON `InitSignerConfigRequest` (40 optional fields; field
name = lowercased `TBTC_SIGNER_*` suffix). The host installs it once at
startup.

## Semantics

- **Wholesale source of truth.** Once installed, the environment is *not
consulted* for any covered knob; an unset field means the built-in
default. No per-knob mixing of config and env — split-brain configs
can't exist.
- **Fail-closed init.** `deny_unknown_fields` rejects typo'd knobs;
enforcement-gated policy combinations (admission, signing-policy
firewall, auto-quarantine) are validated at install by running the same
loaders the runtime gates use, with rollback on rejection — a
misconfigured signer fails at startup, not at first signing.
- **Idempotent re-init** for an identical request (fingerprint match);
conflicting re-init rejected.
- **Secrets never ride the config FFI.**
`TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX` is read exclusively from the
dedicated env/command key-provider channel even when a config is
installed (the one deliberate `std::env::var` left outside the
chokepoint, commented at the read).
- **Transitional compatibility.** With no config installed,
`engine::signer_env_var` falls through to the process environment —
existing hosts and the entire pre-existing test suite run unchanged;
non-development profiles log a one-time warning suggesting the init FFI.

## Why parity is safe by construction

The typed request converts to the same canonical strings the existing
env parsers consume (`"true"`/`"false"`, decimal ints, comma-joined
identifier lists), and every existing clamp/warn/reject path runs
unchanged on identical inputs. The diff swaps `std::env::var(X)` →
`signer_env_var(X)` at 31 sites and changes nothing else about how
values are interpreted. Also deletes `lib.rs`'s duplicated
profile/truthy parsing in favor of the engine's single implementation.

Thanks to #4036, this lands as one new ~400-line module
(`engine/init_config.rs`) plus one-line touches across
`config/lifecycle/persistence/policy/provenance/state` — not an
18k-line-file churn. `engine/config.rs` remains the single home of the
env-name constants.

## Verification

- `cargo fmt --check` ✅; `cargo clippy --all-targets -- -D warnings` ✅
- Full suite **235 passed + 1 ignored / 24 / 1** — all 224 pre-existing
tests pass unchanged (env-fallback parity), plus 11 new tests:
config-over-env precedence, wholesale env-ignoring for unset fields,
idempotent/conflicting re-init, invalid-profile rejection, install
rollback on incomplete firewall policy, complete-admission-policy
validation, secret-stays-on-env-channel,
production-profile-forces-strict via config, `reset_for_tests` clearing,
`deny_unknown_fields`, list/bool canonicalization, and an FFI round-trip
- `--features bench-restart-hook` builds; chaos suite (5/5 `--exact`
paths) and `formal_verification_` filter pass
- `include/frost_tbtc.h` gains the symbol; README documents the contract

## Notes for reviewers

- Knobs the runtime warn-and-defaults on (e.g. out-of-range timeouts)
keep that behavior under config values — init validation only rejects
what the runtime gates would reject. Tightening init further is possible
later without breaking the contract.
- Go-host adoption is a follow-up: this is additive ABI; nothing changes
for hosts until they call the new entry.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
mswilkison added a commit that referenced this pull request Jun 12, 2026
…ence (#4040)

Post-merge follow-up **#4** from the June 2026 review stack
(#4028#4035): replace `json.Marshal` as the canonical signed-bytes
encoding for evidence snapshots/bundles — explicitly scheduled to land
**before Phase 7 wiring ossifies the format**. (Items 2 and 3 landed as
#4036/#4037 on the mirror branch; this is the Go-side sibling on the
scaffold branch.)

## Why now

The RFC-21 Layer B evidence signatures were computed over canonical
JSON. That byte stability is a Go-implementation accident —
field-order-stable `encoding/json` output — not a portable contract. The
moment Phase 7 wires evidence verification into the Rust signer (or any
second implementation appears), every verifier would need to replicate
Go's exact JSON emission. No persisted or cross-component evidence
exists yet, so the format can still change for free.

## Design: sign what you transmit, verify what you received

New `pkg/frost/roast/gen/pb/evidence.proto`:

- A snapshot travels as `SignedLocalEvidenceSnapshot{body,
operator_signature}` where `body` is the serialized
`LocalEvidenceSnapshotBody` — the operator signs those exact bytes.
- A transition message travels as `SignedTransitionMessage{body,
coordinator_signature}` whose `TransitionMessageBody` embeds every
member's signed snapshot envelope **verbatim** (`repeated bytes
signed_snapshots`) — the coordinator attests to the exact signed
snapshots it assembled, in order.
- Producers marshal a body **exactly once**, at signing time, and cache
it; parsed messages retain received body/envelope bytes verbatim;
verification always runs over exact received bytes. **Nothing in the
evidence chain is ever re-encoded**, so signature validity never depends
on any serializer's canonical form — across protobuf library versions or
across languages. This deliberately sidesteps protobuf's own caveat that
deterministic serialization is not canonical across implementations.
- `Marshal` of a received message returns the received envelope verbatim
— evidence bytes survive re-broadcast, including wire-legal but
non-canonical encodings (pinned by a handcrafted reversed-field-order
test).
- `CanonicalSnapshotBytes`/`CanonicalBundleBytes` → `SignableBytes()`
accessors; the coordinator's first-write-wins conflict check now
compares exact signed bytes.

## Tests

- Existing suite migrated off JSON fixtures: test-only encode helpers
bypass production signing so every structural-rejection path (zero
sender, bad hash length, unsorted/duplicate entries, oversize caps,
bundle ordering/hash-binding) is still exercised at the wire level.
- New `wire_test.go` pins the format's core properties:
byte-preservation through unmarshal→re-marshal, verbatim
snapshot-envelope embedding inside bundle bodies, producer-signed bytes
== receiver-verified bytes, non-canonical-encoding survival,
tampered-body verification failure.
- `go build ./...`, `go vet`, `gofmt` clean; frost + tbtc package tests
green. Generated with protoc 33.4 / protoc-gen-go v1.36.3 (matches the
go.mod protobuf runtime v1.36.3).

## Docs

RFC-21 "Evidence message format" decision rewritten: signed-body
protobuf envelopes, with the retirement rationale for canonical JSON
recorded.

## Notes for reviewers

- The in-memory model types (`LocalEvidenceSnapshot`,
`TransitionMessage`) are unchanged apart from two unexported byte
caches; all call sites kept their shapes.
- Immutability contract: evidence fields must not be mutated after
`SignableBytes()` is first computed (documented on the cache fields);
the aggregation flow already treats snapshots as immutable post-receipt.
- Phase 7 cross-language note: the Rust signer will verify
operator/coordinator signatures over `body` bytes and parse them with
any protobuf implementation — no canonicalization requirements transfer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant