Skip to content

feat(tbtc/signer): install TBTC_SIGNER_* knobs via init-time FFI config#4037

Merged
mswilkison merged 4 commits into
extraction/frost-signer-mirror-2026-05-26from
feat/signer-init-config-2026-06-12
Jun 12, 2026
Merged

feat(tbtc/signer): install TBTC_SIGNER_* knobs via init-time FFI config#4037
mswilkison merged 4 commits into
extraction/frost-signer-mirror-2026-05-26from
feat/signer-init-config-2026-06-12

Conversation

@mswilkison

Copy link
Copy Markdown
Contributor

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

Post-merge follow-up #3 from the June 2026 review stack: shrink the
ops/audit surface by letting the host install the signer's operational
configuration once at startup (frost_tbtc_init_signer_config) instead of
exporting ~40 TBTC_SIGNER_* environment variables.

Design (parity by construction):
- every operational env read now goes through one chokepoint,
  engine::signer_env_var; with no config installed it falls through to
  the process environment, so existing env-driven behavior (and the
  entire pre-existing test suite) is unchanged
- an installed config wins wholesale: the environment is no longer
  consulted for covered knobs, and an unset field means the built-in
  default - no per-knob source mixing
- typed InitSignerConfigRequest (field = lowercased env suffix) converts
  to the same canonical strings the existing parsers consume, so every
  clamp/warn/reject path runs unchanged on identical inputs
- deny_unknown_fields: a typo'd knob fails the init instead of silently
  running on defaults; enforcement-gated policy combinations (admission,
  firewall, auto-quarantine) are validated at install with rollback
- re-init is idempotent for an identical request, rejected on conflict
- secrets never ride the config FFI: TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX
  stays on the dedicated env/command key-provider channel (deliberate
  std::env::var exception, commented)
- deletes lib.rs's duplicated profile/truthy parsing in favor of the
  engine's single implementation

Verified: fmt --check; clippy --all-targets -D warnings; full suite 235
passed + 1 ignored / 24 / 1 (11 new tests incl. FFI round-trip);
--features bench-restart-hook builds; chaos suite and
formal_verification_ filter pass.

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

coderabbitai Bot commented Jun 12, 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: f0e7b2e6-8df1-4d68-8657-1a8d5d5934e4

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 feat/signer-init-config-2026-06-12

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.

Addresses the converged Codex/Gemini review finding plus two findings
from my own review of #4037:

- candidate configs are now validated through a thread-local resolver
  override visible only to the validating thread, and published to the
  global slot only after validation succeeds. Previously the candidate
  was installed first and rolled back on failure, so a concurrent
  identical init could report idempotent success while the twin rolled
  the slot back to None, and concurrent readers could briefly act on a
  config that never legally installed. Both impossible now: failed init
  has no observable side effects, and idempotent success is only ever
  reported against a validated, installed config.
- init now validates state_file_path(): a production config (explicit,
  or by profile-omission default) without state_path fails at init
  instead of installing and then failing at first state access with an
  env-var-oriented message; the state-path error now also names the
  state_path config field.
- new end-to-end test: installed config's state_path is honored by
  run_dkg persistence after a process (re)start, and the existing
  in-process state-path-switch refusal is pinned as the contract for
  installing a config after state has been touched.
- README: do not inline key material into state_key_command (the
  command string rides the config FFI); documented the no-side-effects
  init guarantee and the production state_path requirement.

Suite: 237 passed + 1 ignored / 24 / 1; clippy -D warnings, fmt, chaos
suite, bench-restart-hook feature build all green.

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

Copy link
Copy Markdown
Contributor Author

Review follow-ups addressed in 9226be1 (findings from three reviews: mine, Codex, Gemini — the latter two converged on one root cause):

  1. Publish-before-validate race (Codex P2 / Gemini P1) — fixed structurally. Candidates are now validated through a thread-local resolver override visible only to the validating thread, and published only after validation succeeds. The previously possible outcomes — a concurrent identical init returning idempotent: true while the twin rolls the slot back to None (Codex), or concurrent readers briefly acting on a config that never legally installed (Gemini) — are now impossible by construction, not by contract: a failed init has zero observable side effects. (Holding the write lock across validation was not an option: the validators read through signer_env_var, which takes the read lock — std RwLock is not reentrant.)
  2. State-path init validation (my F1). validate_candidate_config now includes state_file_path(): a production config — explicit, or by omission (production is the profile default) — without state_path fails at init instead of installing and then failing at first state access. The state-path error message now also names the state_path config field, since the old text pointed operators at an env var the installed config would ignore.
  3. End-to-end coverage (my F3). New test proves the config-sourced state_path is honored by run_dkg persistence after a process (re)start — and pins the discovered interaction with the existing state-lock hardening: installing a config after state has been touched is refused by the in-process path-switch guard, which is exactly the init-before-first-state-access contract being enforced.
  4. Docs (my F5 + guarantee update). README: don't inline key material into state_key_command (the command string rides the config FFI); documented the no-side-effects init guarantee and the production state_path requirement.

Note for test authors: configs in tests must now be explicit about profile — omission means production, which (correctly) demands a state_path.

Suite: 237 passed + 1 ignored / 24 / 1; clippy -D warnings, fmt, chaos suite, bench-restart-hook build all green.

Addresses Codex's re-review P2: the init validated state_path but not
the adjacent key-provider knobs, so a production config that omitted
state_key_provider (wholesale-defaulting to the env provider, which
production forbids) - or that selected the command provider without a
command - installed successfully and then failed at the first state
access.

- extract the structural head of state_encryption_key_material into
  resolve_state_key_provider_plan (provider selection, the production
  env-provider prohibition, command-spec presence; error strings
  unchanged) and have both the runtime key path and init validation
  consume it - one source of truth, no drift
- init validation rejects: production defaulting to the env provider,
  command provider without state_key_command, unknown provider values;
  all WITHOUT reading the secret or executing the key command (pinned
  by a test whose key command points at a nonexistent binary)
- README documents that production configs must carry the command
  key-provider pair

Suite: 241 passed + 1 ignored / 24 / 1 (4 new tests); clippy
-D warnings, fmt, chaos suite, bench-restart-hook build all green.

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

Copy link
Copy Markdown
Contributor Author

Re-review responses — Gemini: approved, no action. Codex's P2 was valid and is fixed in 5fdb09642:

The finding was my own state-path pattern applied to a knob family the previous fix only partially covered: a production config omitting state_key_provider wholesale-defaults to the env provider — which production forbids at first state access — and command without state_key_command had the same install-then-fail shape.

Fix: the structural head of state_encryption_key_material (provider selection, the production env-provider prohibition, command-spec presence — error strings unchanged) is extracted into resolve_state_key_provider_plan, consumed by both the runtime key path and init validation, so there is one source of truth and no drift. Init now rejects: production-defaulting-to-env, command-without-command, and unknown provider values — all without reading the secret or executing the key command. The no-execution property is pinned by a test whose state_key_command points at a nonexistent binary: if init ever ran it, that install would fail.

Suite: 241 passed + 1 ignored / 24 / 1 (4 new tests); clippy -D warnings, fmt, chaos suite, feature build all green.

Addresses Codex's third-round P2 (the family member my own review had
deferred): production forces the provenance gate, so a production config
without a complete, verifiable attestation set installed successfully
and then failed every protected operation.

- validate_candidate_config now runs enforce_provenance_gate(): self-
  gating (no-op when unenforced, so dev configs are unaffected), reads
  only candidate values plus local crypto - no secrets, no command
  execution, no network. Full verification at init (status, payload
  signature against trust root, runtime-version minimum, TTL); runtime
  calls still re-check, so an init-time pass does not exempt TTL aging.
- production-config tests now carry a complete signed attestation
  (reusing the existing build_signed_provenance_attestation fixture);
  new tests pin: production-without-attestation rejected at init,
  enforced-gate-with-unparseable-trust-root rejected, and a complete
  production config (state path + command key provider + valid
  attestation + min version) installs.
- README documents the production attestation requirement and the TTL
  caveat.

Suite: 244 passed + 1 ignored / 24 / 1 (3 new tests); clippy
-D warnings, fmt, chaos suite all green.

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

Copy link
Copy Markdown
Contributor Author

Codex round-3 P2 — valid, fixed in 7beceecb2. This was the family member my own last review had flagged but deferred; Codex's premise check settles it: production forces the provenance gate, so a production config without a complete attestation set isn't conditionally degraded — it's deterministically unusable for every protected operation. That's the same install-then-fail-late shape as state_path and the key provider, and it belongs in init validation.

validate_candidate_config now runs enforce_provenance_gate() itself: self-gating (no-op when unenforced — every dev config is unaffected), reads only candidate values plus local crypto — no secrets, no command execution, no network. I went with the full gate rather than a parse-only subset deliberately: parse-only would still admit status: "revoked" or a wrong-key signature, leaving the same class open for invalid (vs missing) fields. The one thing an init-time pass cannot promise is freshness — runtime calls still re-check the gate, and the README now states that TTL aging applies per call.

New tests pin: production-without-attestation rejected at init (message names the missing knob), enforced-gate-with-unparseable-trust-root rejected, and a complete production config (state path + command key provider + valid signed attestation + min version) installs — the production-config tests now carry real signed attestations via the existing build_signed_provenance_attestation fixture.

Suite: 244 passed + 1 ignored / 24 / 1; clippy -D warnings, fmt, chaos suite green.

With this, the install-then-fail-late family is closed for every knob the config can express: state path, key provider/command, admission/firewall/auto-quarantine combos, and the provenance gate. The remaining deliberate-lazy items are external-world checks (KMS reachability, secret presence, TTL aging) that no init-time validation can honestly promise.

@mswilkison mswilkison merged commit f77154b into extraction/frost-signer-mirror-2026-05-26 Jun 12, 2026
19 checks passed
@mswilkison mswilkison deleted the feat/signer-init-config-2026-06-12 branch June 12, 2026 13:27
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)
mswilkison added a commit that referenced this pull request Jun 12, 2026
…ence (#4042)

Post-merge follow-up **#5** from the June 2026 review stack — the
remaining half of #4033's item: cite the exact external audit report and
version range covering the pinned FROST stack in the readiness/rollout
docs. Also records the attestation-rotation operational requirement that
#4037's review surfaced.

## Audit status (researched against upstream sources, 2026-06-12)

The new "Cryptographic Dependency Audit Status" section in
`roast-phase-5-security-rollout-gates.md` records, with citations:

- **NCC Group, "Zcash FROST Security Assessment"** (report 2023-10-20):
audited **v0.6.0** (commit `5fa17ed`) of `frost-core` + five
ciphersuites — trusted-dealer and DKG key generation plus signing; all
findings addressed and re-reviewed.
- The upstream README's explicit exclusion, quoted verbatim: *"This does
not include frost-secp256k1-tr and rerandomized FROST."*
- **Least Authority's Q1 2025 FROST Demo audit** covered
`frost-client`/`frostd` tooling only — not the library crates this
signer consumes.
- No 2.x/3.x release notes mention further audit coverage.

**The honest bottom line, now on the record:** the exact ciphersuite
this signer uses for production signatures (`frost-secp256k1-tr =3.0.0`,
released 2025-04-23) and the v0.6.0 → 3.0.0 evolution of `frost-core`
have **no external audit coverage**. Gate 1 sign-off must therefore
either commission/await an audit covering that range (the checklist
item-8 "audit as ECDSA-retirement merge gate" decision) or record a
written, canary-scoped risk acceptance. The section gives that team
decision its factual basis instead of letting "FROST was audited" stand
unqualified.

## Attestation rotation cadence (runbook prerequisite 6)

From #4037's design: init-time config is immutable for the process
lifetime and attestation TTL caps at 7 days, so production signers must
restart with fresh attestation material within every window — rollout
stage scheduling has to absorb that cadence. Live re-attestation without
restart is deliberately unsupported (it would need a dedicated narrow
FFI; general config mutation reopens the split-brain risk the immutable
design closed).

Doc-only change; no code. Sources verified via the upstream README,
NCC's published report PDF, zfnd.org announcements, and the
ZcashFoundation/frost releases page.

🤖 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