Skip to content

fix(tbtc): anchor the emergency rekey, and correct what the certified floor bounds - #4226

Open
mswilkison wants to merge 7 commits into
codex/frost-preauth-outbox-restoredfrom
fix/anchor-integrity-4222
Open

fix(tbtc): anchor the emergency rekey, and correct what the certified floor bounds#4226
mswilkison wants to merge 7 commits into
codex/frost-preauth-outbox-restoredfrom
fix/anchor-integrity-4222

Conversation

@mswilkison

@mswilkison mswilkison commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Six fixes for the FROST native signer anchor path. Stacked on #4199 rather than folded into it, so that PR stays mergeable as-is.

None of these change the Rust signer: frost_tbtc_trigger_emergency_rekey already exists at the pinned ref, so the ABI and ci/frost-signer-pin.env are untouched.

1. Route the emergency rekey through the state anchor (fdbfdd2)

The engine has always exported the kill-switch trigger, but nothing in Go called it — a repo-wide grep for the symbol returns nothing. With no Go caller, the only way to arm the switch is to stop the node and mutate its durable store directly. That write never passes through the anchored operation path, so it is never compare-and-swapped onto the anchor stream. On restart the anchor sees local and remote agreeing, and an operator who restores the pre-rekey state file first has erased a kill switch aimed at them with no evidence anywhere in the stream.

The fix routes the call through callBuildTaggedTBTCSignerOperation, so the durable write is CAS-ed onto the anchor stream before the call returns. That is what makes a later erasure detectable.

Two properties that look like omissions but are deliberate, and are documented at the entrypoint:

  • Not admission-gated. The operation wrapper takes no capacity reservation, and admission refuses all work once headroom reaches the rotation floor (256) while the barrier keeps admitting until the certified window is genuinely exhausted. A kill switch that capacity accounting can veto is not a kill switch, so it runs unreserved in that band.
  • The barrier stays mandatory. A poisoned anchor or an exhausted window fails the trigger. Every barrier refusal predicate is operation-independent, so a state in which this trigger is refused is one in which the node already refuses every signature-producing call — the switch is redundant there, not defeated, and its residual is availability rather than authority.

The exported call is single-flight by contract: the engine treats an armed event as immutable and no export clears it.

No runtime behaviour changes from this PR. The kill switch remains unreachable from any operator-facing CLI; the anchored path is landed so any trigger built on it will be certified. The remaining options for that trigger surface each need a decision the code cannot supply: what names the target session, whether the switch is per-wallet or node-wide, and what the recovery path is for an accidental trigger given the event is immutable. The #4222 exposure stays open until those answers exist. Note that an out-of-process CLI is specifically not a viable path here: with the running node holding the store's exclusive flock it cannot write, and with the node stopped the write is unanchored — the same bug restated — but that constraint is one input to the design, not the whole reason the surface is deferred.

Quarantine, fault scores, and finalize_request_fingerprint have the same missing-wiring shape but no runtime writers in the Rust crate at all — only test-support writers. A Go call into a nonexistent writer is not a fix, so those need a Rust-side writer first and are filed separately.

2. Correct what the anchor certified floor bounds (eb6692c)

docs/development/frost-anchor-rotation.adoc said the floor is "checked at startup against pins that the running node cannot influence." That holds against a compromised signer process, which cannot forge the offline authority's signature. It does not hold against the operator: every artifact naming the floor — certificate chain, init config, activation manifest — is a local file the operator owns, and the certificate sequence and digest are only ever compared against the init config. The activation manifest is a signed envelope read from disk, not fetched from chain.

The doc now says that, and adds what genuinely bounds an operator-side downgrade:

  1. The bundle moves in lockstep — every rotation must change the manifest hash and advance the manifest sequence and service epoch by exactly one, so a downgrade means reverting the whole bundle, not swapping one file.
  2. The durable trust journal is append-only and its head must equal the configured pin, so a downgrade also needs a pre-rotation signer-store snapshot.
  3. The anchor service can retire the previous binding — every signed request already carries the current bindingHash and activationManifestSequence.

Only (3) is a real control, and it lives outside this repository, so it becomes a pre-activation checklist item alongside a decision on where a monotonic floor is published if operator-side rollback is in scope.

No code change closes this gap, and the PR does not pretend otherwise: every candidate in-repo pin is downstream of an operator-owned file. Putting the floor reference into the activation manifest is not merely awkward but unsatisfiable — manifestHash feeds bindingHash feeds the event root, which is the floor, so the manifest would have to commit to a hash of itself. A plain minimum-sequence integer would dodge that circularity but is security-null here, because an old certificate is only presentable together with its own old manifest, which would carry the old minimum.

Worth noting for whoever picks up the checklist item: the signed activation-handshake attestation already exports trustCertificateSequence, anchorServiceEpoch and the certified floor revision/generation, so an external monitor retaining the maximum ever seen per operator detects a downgrade today with no protocol change.

3. Diagnose a durable trust head ahead of the configured pin (eaae1fc)

A durable trust head ahead of the configured pin now says the artifacts are older than the store, rather than surfacing later as an opaque endpoint-identity mismatch. The comment states explicitly that this is not a bound on operator rollback — reverting the bundle and the store together leaves the two equal and fires nothing — so a later reader does not mistake it for a fix.

4. Publish restartable headroom on every acknowledged commit (cd83062)

The restartable headroom mirror and the rotation warning were previously published only on a full readiness reconciliation, so a steadily signing node and a stalled node looked identical at the scrape until the next reconciliation. Since pre-sign authorization caches readiness per finality window, an entire authorized batch could burn window invisibly inside one window. Every successful compare-and-swap already holds an authenticated tip, so the publication moves onto the commit path and the rotation warning follows the seat count that travelled with the previous publication (seat count only changes on DKG and retirement, both of which force a full reconciliation).

5. Correct the runbook cadence and seat-scaling framing (c07b475)

docs/development/frost-anchor-rotation.adoc carried stale workload numbers in several places. The corrected table derives each cell from the fault-free range k+3..3k+2 generations per input: at 4 seats the worst case is 294 generations (was 588 — the retracted figure), the range at the 5-seat expected holder is 13–26 sweeps per epoch (was 6–13), and at 20 seats 2–7 sweeps (was 1–2). The seat-sizing directive was also rewritten: the original framing assumed equal-weighted signers and recommended sizing against the 5-seat average; this PR removes that assumption, names the sortition as stake-weighted with replacement, and reframes the high-seat rows as the upper tail of the distribution reachable by operators with concentrated stake.

6. Measure real anchor burn rate (69a24d0)

Every workflow that reaches the shared reserve() path now increments its own counter, so the published burn-rate ratio has a single well-defined unit. The single admitted_total counter is replaced by three: admitted_presign_input_total, admitted_presign_relay_gate_total, and admitted_dkg_total. The pre-sign reservation counts the per-input work; the relay-gate reservation counts the up-front authorization; DKG counts the per-local-seat reservation. The reset helper, var-block comment, and registration function doc were updated to match.

Verification

go build ./...
go vet ./pkg/tbtc/... ./pkg/frost/signing/...
go test -run 'FrostNativeSignerAnchor' ./pkg/tbtc/
go test -run 'EmergencyRekey' ./pkg/frost/signing/                                    # default build: stub fails closed
CGO_ENABLED=1 go test -tags "frost_native frost_tbtc_signer" -run 'EmergencyRekey' ./pkg/frost/signing/

New tests cover the payload builder (empty/whitespace reason, over-long and malformed session IDs), the response decoder (six rejection cases, including the engine retargeting a per-signing session to its wallet session), the default-build stub failing closed, the rotation warning gauge value in both healthy and near-floor scenarios, the observer threading warning and seat count through to the accessors, the consumption and admission counters advancing on commit and staying flat on the skip-CAS path, the new cgo-tagged wiring and barrier-gating for the trigger, and both the honest and rejected paths of the new trust-head assertion.

@coderabbitai

coderabbitai Bot commented Aug 6, 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: 43c46d7a-50f7-44e0-bfff-bc686d6c2ecb

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

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.

@mswilkison

Copy link
Copy Markdown
Contributor Author

Two further commits, both from the analysis in #4222 and both required under every candidate design there.

eaae1fcd8 — pin the store fingerprint in the descendant validator. The Go and Rust anchor-trust descendant validators disagreed about which references are admissible, in both directions: Rust alone refused a checkpoint store-fingerprint change across generations, Go alone required the certified floor to be revision 1 of its service epoch. Neither gap is reachable today, but the more permissive tree would accept a chain the other refuses on every store open, with no truncation or rebase path back — a fail-closed brick of a signer store. This adds Rust's rule here; the mirrored genesis-floor rule lands in #4227, stacked on #4198. Union, not intersection.

cd83062ad — publish headroom on acknowledgement, and export the rotation warning. The headroom mirror refreshed only on successful readiness reconciliation, so a node signing steadily and a node that had stalled were indistinguishable at the scrape — and because pre-sign authorization caches readiness per finalized point on a branch that deliberately publishes nothing, an entire authorized batch could burn window with the gauge unchanged throughout. Publication now happens at the acknowledgement install, which every successful CAS already passes through with an authenticated tip in hand; that single choke point covers the ordinary commit, the startup local-ahead commit, and read-recovery.

The rotation warning gets its own gauge. It was computed but reachable only via readiness snapshots and the activation handshake — a hand-assembled loopback challenge that hard-errors once headroom reaches zero, which is exactly when an operator needs it. It can't be derived at scrape time from the two headroom gauges, because the workload term needs the node's largest local seat count, so the seat count now travels with the pair; that also lets the commit path recompute the warning without an inventory of its own.

A partial computation leaves the previous reading standing rather than publishing half a pair — the two dimensions are only meaningful together, and a stale pair is more honest than a half-fresh one.

ci/frost-signer-pin.env is deliberately not bumped. The pin must not point at an unmerged side branch; it should move once #4227 merges into #4198.

Verified: go build ./..., go vet, full pkg/tbtc and pkg/frost/signing suites. New tests cover the mirrored fingerprint rejection, the rotation-warning gauge (including that an unpublished mirror reads 0 exactly as the headroom gauges do, so alerts must still qualify on headroom_observations_total), and that an acknowledged tip refreshes the mirror while an unauthenticated one leaves it untouched. The registered-source-count pin test caught the new gauge on the first run, which is the test working as intended.

@mswilkison

Copy link
Copy Markdown
Contributor Author

Follow-up DR work is available in #4241. It is intentionally stacked on this
PR rather than appended here, so the anchor-integrity review remains focused.
Its native ABI/store dependency is #4240.

mswilkison and others added 6 commits August 14, 2026 09:17
The engine has always exported frost_tbtc_trigger_emergency_rekey, but
nothing in Go called it. With no Go caller the only way to arm the
wallet kill switch was to stop the node and mutate its durable store out
of band, which produces an uncertified local write: on restart the anchor
sees local and remote agreeing, so an operator who restores the pre-rekey
state file erases the kill switch aimed at them with no evidence anywhere
in the anchor stream.

Wire the call through callBuildTaggedTBTCSignerOperation so the durable
write is compare-and-swapped onto the anchor stream before the call
returns, which is what makes a later erasure detectable.

Two properties worth stating, because both look like omissions:

The path is deliberately not admission-gated. The operation wrapper takes
no capacity reservation, and admission refuses all work once headroom
reaches the rotation floor while the barrier keeps admitting until the
certified window is genuinely exhausted. A kill switch that capacity
accounting can veto is not a kill switch, so it runs unreserved in that
band.

The barrier, by contrast, stays mandatory: a poisoned anchor or an
exhausted window fails the trigger. Every barrier refusal predicate is
operation-independent, so a state in which this trigger is refused is one
in which the node already refuses every signature-producing call - the
switch is redundant there, not defeated, and its residual is availability
rather than authority.

No Rust change: the export exists at the pinned ref, so the ABI and
ci/frost-signer-pin.env are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rotation runbook said the certified floor is "checked at startup
against pins that the running node cannot influence." That holds against
a compromised signer process, which cannot forge the offline authority's
signature. It does not hold against the operator: every artifact naming
the floor - certificate chain, init config, activation manifest - is a
local file the operator owns, and the certificate sequence and digest are
only ever compared against the init config.

Replace the overclaim, and add a section stating plainly what does bound
an operator-side downgrade: the artifact bundle moves in lockstep (each
rotation must change the manifest hash and advance the manifest sequence
and service epoch by one), the durable trust journal is append-only so a
downgrade also needs a pre-rotation store snapshot, and the anchor service
can retire the previous binding. Only the third is a real control, and it
lives outside this repository.

Two checklist items follow from that: the anchor-service binding-retirement
rule, and a decision on where a monotonic floor is published if operator
rollback is in scope. Nothing in keep-core can close that gap - every
candidate pin is downstream of an operator-owned file - so the honest
deliverable is the requirement, not a local check. The signed activation
handshake already exports the values an external monitor needs to detect a
downgrade today.

Also name one condition that already failed closed obliquely: a durable
trust head ahead of the configured pin now reports that the artifacts are
older than the store, instead of surfacing as an endpoint identity
mismatch. That is diagnosability only - reverting the bundle and the store
together leaves the two equal and fires nothing - and the comment says so
to stop a later reader mistaking it for a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Go and Rust descendant validators disagreed about which references
are admissible, in both directions. Rust alone refused a checkpoint store
fingerprint change across generations; Go alone required the certified
floor to be revision 1 of its service epoch.

Neither gap is reachable today, since certificate endpoints force a
revision-1 To reference and the fingerprint is pinned per certificate.
But a divergence between the trees is not a latent nicety: the more
permissive tree would accept a chain the other refuses on every store
open, with no truncation or rebase path back. That is fail-closed, which
is the safe direction, and still a dead store.

Take the union rather than the intersection. This adds Rust's rule here;
the mirrored genesis-floor check lands on the Rust side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion warning

The headroom mirror refreshed only when a full readiness reconciliation
succeeded. That left two blind spots. A node signing steadily and a node
that had stalled looked identical at the scrape, because nothing
republished between reconciliations. And pre-sign authorization caches
the readiness snapshot per finalized point, taking a branch that
deliberately publishes nothing, so an entire authorized batch could burn
window inside one finality window with the gauge unchanged throughout.

Publish from the acknowledgement install instead. Every successful
compare-and-swap already holds an authenticated tip, which is exactly
where these numbers are both fresh and free; the recorder's own comment
says it was built for a caller holding the anchor mutex, which is this
one. Both install paths funnel through the same readback, so the ordinary
commit, the startup local-ahead commit, and read-recovery are all
covered.

Also export the rotation warning as its own gauge. It was computed but
reachable only through readiness snapshots and the activation handshake -
a hand-assembled loopback challenge that refuses to answer once headroom
hits zero, which is when an operator most needs it. It cannot be derived
at scrape time from the two headroom gauges, because the workload term
needs the node's largest local seat count, so the seat count now travels
with the pair. That also lets the commit path recompute the warning
without an inventory: seat count only changes on DKG and retirement, both
of which force a reconciliation that republishes it.

Failure to compute either half leaves the previous reading standing
rather than publishing a partial pair - the two are only meaningful
together, and a stale pair is more honest than a half-fresh one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…raming

The rotation runbook's cadence table and its lifetime warning were built
from a single burn figure per input. Real fault-free burn is a range -
k+3 to 3k+2 generations - because a call whose sweep prologue mutates
state advances more than one generation, and the document's own note
already retracted an earlier set of figures for a related reason without
correcting the table itself. Any capacity number taken from a point
value understates the window by roughly a factor of two, and these are
the figures that feed anchor-window sizing decisions.

Rebuild the table from the range, and correct the lifetime warning: at
the expected seat count a store admits roughly 630-1400 full-size sweeps
over its 63 rotations, not the 64-128 previously stated. Also record
that the 64-certificate cap is a size artifact - each certificate must
fit a bounded journal record - rather than a security property, so a
reader does not mistake the ceiling for something the chain proves.

Signers are equal-weighted, so seats per operator is groupSize divided
by operatorCount and the expected holder is around five. The high-seat
rows are sensitivity checks on operator-set size, not a stake
concentration tail, and two comments that motivated their reasoning from
a fifty- or hundred-seat holder now cite the real distribution. The
admission comment also read better than the truth: the superseded
four-seat ceiling excluded every operator, not just the larger ones.

Documentation and comments only; no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every anchor-window sizing and rotation-cadence figure this project has
is a code reading, not an observation. Fault-free burn is only bounded -
between k+3 and 3k+2 durable generations per signed input, because a call
whose sweep prologue mutates state advances more than one generation - and
a factor of two decides how much window a node actually has and how often
it must stop for an offline ceremony.

Nothing published today closes that. The headroom gauges report a level,
and that level resets at every rotation, so no rate can be taken across
one; and no counter records how much work produced the consumption.

Add both halves. In pkg/frost/signing, monotonic totals for generations
and revisions consumed, incremented at the barrier only after an
acknowledgement is validated and durably read back - so they count work
the anchor witnessed rather than work attempted. An operation that
advanced no generation spends no revision either, since the barrier skips
the compare-and-swap when the tip is unchanged, and is counted as
neither.

In pkg/tbtc, a counter of admitted workflows. Every other admission
counter records a refusal; this one records work allowed to proceed,
which is what gives the consumption totals a unit. For pre-sign it ticks
once per input, so consumption divided by admissions is generations and
revisions per signed input directly.

The measurement matters beyond capacity planning: the runbook now
documents cadence as a range pending live-testnet measurement, and the
threshold for whether the anchor design needs to change at all is a burn
rate nobody has yet observed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@piotr-roslaniec
piotr-roslaniec force-pushed the fix/anchor-integrity-4222 branch from 69a24d0 to f200d71 Compare August 14, 2026 09:24
Resolves all 20 confirmed findings from the multi-agent review of #4226.

Documentation (docs/development/frost-anchor-rotation.adoc):
- Propagate the corrected cadence figures from c07b475 to four stale sites
  that still cited the retracted 588-generation worst case at 4 seats.
- Replace the equal-weighted sizing directive with stake-weighted framing
  and consistent stake-weighted wording throughout (also fixed in
  wallet.go and frost_native_signer_anchor_admission.go comments).
- Drop the false no-rotation-warning claim from the Gaps section and mark
  the pre-activation checklist item done, naming the three existing gauges.
- Add a subsection for the anchored emergency-rekey kill switch, recording
  its admission bypass, immutability, and absent operator-facing trigger.
- Remove the stray bare + line and correct the bindingHash attribution
  in the pre-activation checklist parenthetical.

Engine (native_frost_engine_tbtc_signer_registration_frost_native.go):
- Qualify the TriggerNativeTBTCSignerEmergencyRekey doc comment to state
  that the CAS-before-return property holds only on the success path,
  documenting the invoke-then-commit window and the recovery condition.
- Add ErrNativeTBTCSignerEmergencyRekeyArmedButUnverified sentinel for
  post-commit decode failures so the caller can distinguish 'switch armed
  but unverifiable' from 'did not run'.
- Builder now returns the normalized reason string so the echo comparison
  in the entrypoint consumes a single source of truth.

Admission (frost_native_signer_anchor_admission.go):
- Split the single admissions counter into three workflow-specific
  counters (presign input, presign relay gate, native DKG) and remove the
  shared reserve() increment so each ratio has a defined unit. Update the
  reset helper, var-block, and registration docs to match.

Inline:
- recoverAcknowledgementLocked now publishes the refreshed headroom mirror
  before returning, matching the install path.
- Fix the burn-rate numerator/denominator vocabulary mismatch at the skip-CAS
  branch comment.

Tests:
- New cgo-tagged test for the trigger wiring and barrier gating (success
  advances the anchor tip once; poisoned and exhausted-window cases refuse).
- Extended barrier-commit tests to assert consumption advances on success
  and stays flat on the skip-CAS path; added zero-counter assertions for
  the three new admission counters across admission tests.
- Added healthy and near-floor rotation-warning assertions to the headroom
  publication test.
- Extended the readiness-observer test to thread rotationWarning and
  largestLocalSeatCount through readinessSnapshotWithHeadroom and assert
  the accessors.

PR description updated to describe all six commits and soften the
flock argument for the deferred trigger surface.
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.

2 participants