From c88632095a9a1be272b2aaeeb44ef49681bab4a4 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 6 Aug 2026 15:46:25 -0400 Subject: [PATCH 1/3] fix(tbtc/signer): require a genesis floor in the descendant validator The Go and Rust descendant validators disagreed about which references are admissible, in both directions. Go alone required the certified floor to be revision 1 of its service epoch; Rust alone required the checkpoint store fingerprint to stay fixed across generations. Neither gap is reachable today - bootstrap and rotation endpoints both force a revision-1 To reference, so every call site here already passes one, and the full suite passes unchanged with the check added. But a divergence between the trees is not a latent nicety: whichever tree is more permissive would accept a chain the other refuses on every store open, and there is no truncation or rebase path back from that. It is a fail-closed brick, which is the safe direction and still a dead store. Take the union rather than the intersection. This adds Go's rule here; the mirrored fingerprint check lands on the Go side separately. Validation tightening only - no wire, encoding or ABI change. Co-Authored-By: Claude Opus 5 --- pkg/tbtc/signer/src/engine/anchor_trust.rs | 74 ++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/pkg/tbtc/signer/src/engine/anchor_trust.rs b/pkg/tbtc/signer/src/engine/anchor_trust.rs index f57089c357..08777252a5 100644 --- a/pkg/tbtc/signer/src/engine/anchor_trust.rs +++ b/pkg/tbtc/signer/src/engine/anchor_trust.rs @@ -1331,6 +1331,17 @@ pub(crate) fn validate_state_anchor_trust_reference_descendant( candidate: &StateAnchorTrustReferenceModel, label: &str, ) -> Result<(), EngineError> { + // A certified floor is always revision 1 of its service epoch: bootstrap + // and rotation endpoints both force it, so every call site here already + // passes one. Asserting it matches the Go validator, which rejects a + // non-revision-1 floor outright. Where the two disagree about which + // references are admissible, one tree accepts a chain the other refuses on + // every store open - a fail-closed brick with no truncation path back. + if floor.revision != 1 { + return Err(EngineError::Validation(format!( + "{label} is measured against a floor that is not its service-epoch genesis" + ))); + } if candidate.service_epoch != floor.service_epoch || candidate.revision < floor.revision { return Err(EngineError::Validation(format!( "{label} must remain in the certified epoch and not precede its floor" @@ -2588,4 +2599,67 @@ mod tests { validate_state_anchor_trust_reference_descendant(&floor, &too_far, "too-far").is_err() ); } + + // Parity with the Go validator, which rejects a floor whose revision is not + // 1. Both trees must admit the same references: where they disagree, one + // accepts a chain the other refuses on every store open, and there is no + // truncation path back from that. + #[test] + fn descendant_reference_requires_a_service_epoch_genesis_floor() { + let vector = shared_valid_vectors().remove(0); + let wire: StateAnchorTrustCertificate = + serde_json::from_slice(vector.canonical_json.as_bytes()).expect("bootstrap JSON"); + let certificate = verify_state_anchor_trust_certificate(wire).expect("bootstrap verifies"); + let floor = certificate.to.reference; + assert_eq!(floor.revision, 1, "certificate endpoints pin revision 1"); + + let mut later = floor.clone(); + later.revision += 1; + later.previous_event_root = floor.event_root; + later.event_root = [0x71; 32]; + later.checkpoint_ack_digest = [0x72; 32]; + + let mut mid_epoch_floor = floor.clone(); + mid_epoch_floor.revision = 2; + let mut beyond = later.clone(); + beyond.revision = 3; + assert!( + validate_state_anchor_trust_reference_descendant( + &mid_epoch_floor, + &beyond, + "mid-epoch-floor" + ) + .is_err(), + "a floor that is not its service-epoch genesis must be refused" + ); + + // The genesis floor still accepts the same candidate, so the new rule + // rejects only the floor shape and not ordinary descendants. + validate_state_anchor_trust_reference_descendant(&floor, &later, "genesis-floor") + .expect("a revision-1 floor still admits its descendants"); + } + + // The store fingerprint may not change across generations. Go gained the + // mirrored check; this pins the Rust half so the pair cannot drift apart + // again. + #[test] + fn descendant_reference_rejects_a_store_fingerprint_change() { + let vector = shared_valid_vectors().remove(0); + let wire: StateAnchorTrustCertificate = + serde_json::from_slice(vector.canonical_json.as_bytes()).expect("bootstrap JSON"); + let certificate = verify_state_anchor_trust_certificate(wire).expect("bootstrap verifies"); + let floor = certificate.to.reference; + + let mut rehomed = floor.clone(); + rehomed.revision += 1; + rehomed.previous_event_root = floor.event_root; + rehomed.event_root = [0x73; 32]; + rehomed.checkpoint_ack_digest = [0x74; 32]; + rehomed.checkpoint.generation += 1; + rehomed.checkpoint.store_fingerprint[0] ^= 1; + assert!( + validate_state_anchor_trust_reference_descendant(&floor, &rehomed, "rehomed").is_err(), + "a descendant may not move to a different signer store" + ); + } } From ea2aebce62c989277e22907d693a279256ccd9c2 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 6 Aug 2026 16:33:54 -0400 Subject: [PATCH 2/3] docs(tbtc/signer): correct what the consumption markers guarantee Two claims a maintainer or operator would act on. The interactive header credited the durable consumption markers with preventing a second share under the same nonces. That guarantee is real but comes from somewhere else: nonces live only in memory, are zeroized at first use, and are never restored on load, so no restart and no durable-state rollback can hand a process a usable nonce at all. The markers give at-most-once re-execution of an attempt, and evidence of release once acknowledged. Crediting them with nonce-reuse prevention invites a future change to treat the marker as the load-bearing defense when it is not. The README advertised TBTC_SIGNER_ENABLE_AUTO_QUARANTINE and its threshold and penalty knobs without saying that nothing at runtime ever writes a fault score or a quarantine entry. Enforcement reads both wherever they appear and honors persisted state, but an operator enabling the flag gets validated configuration and a silent no-op. The failure direction is safe - no operator can be falsely quarantined - but the advertisement is not. The hardening RFC's P1-M2 exit criterion has the same problem and now carries a status note. Comment and documentation only; no ABI or behaviour change. Co-Authored-By: Claude Opus 5 --- pkg/tbtc/signer/README.md | 9 +++++++++ .../docs/permissioned-signer-hardening-rfc.md | 8 ++++++++ pkg/tbtc/signer/src/engine/interactive.rs | 13 +++++++++++-- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/pkg/tbtc/signer/README.md b/pkg/tbtc/signer/README.md index 3306aac44f..f1ea1d5d4f 100644 --- a/pkg/tbtc/signer/README.md +++ b/pkg/tbtc/signer/README.md @@ -472,6 +472,15 @@ storage guarantees for that hardware-level failure boundary. firewall enabled and restrict `TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES` to the intended output classes, such as `p2tr`. - Transcript accountability / quarantine config: + - **Runtime fault recording is not implemented.** The engine enforces a + quarantine set and fault scores wherever they are read, but nothing at + runtime ever writes them: they are only loaded from persisted state. So + enabling `TBTC_SIGNER_ENABLE_AUTO_QUARANTINE` validates its configuration + and then does nothing - no operator is ever scored, and no operator is ever + automatically quarantined. Enforcement of a quarantine set that is already + present in the store does work, and the failure direction is safe (no + operator can be falsely quarantined), but do not rely on automatic + exclusion. Operator removal is out-of-band today. - `TBTC_SIGNER_ENABLE_AUTO_QUARANTINE` - `TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD` - `TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY` diff --git a/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md b/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md index 487d993aec..a9dc8ada89 100644 --- a/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md +++ b/pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md @@ -48,6 +48,14 @@ TEEs as prerequisites, while remaining compatible with either in future. | `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 | +> **Status note on `P1-M2`.** Only the enforcement half has landed. The engine +> honors a quarantine set and fault scores wherever it reads them, but no +> runtime path writes either — they are populated from persisted state only, so +> the scoring model and auto-exclusion threshold are unimplemented and the exit +> criterion above is not met. `TBTC_SIGNER_ENABLE_AUTO_QUARANTINE` therefore +> configures a mechanism that cannot fire. Operator exclusion is out-of-band +> until the scoring writers exist. + ### Phase P2 (Weeks 12-20): Lifecycle + Deployment Safety | Milestone | Deliverables | Primary owners | Exit criteria | diff --git a/pkg/tbtc/signer/src/engine/interactive.rs b/pkg/tbtc/signer/src/engine/interactive.rs index 6c546cb538..b06f3f716d 100644 --- a/pkg/tbtc/signer/src/engine/interactive.rs +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -8,8 +8,17 @@ // 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. +// (consumption-before-release). +// +// Be precise about what those markers buy, because it is easy to credit +// them with the wrong guarantee. A second share under the SAME nonces is +// impossible regardless of them: nonces live only in memory, are +// zeroized at first use, and are never restored on load, so no restart +// and no durable-state rollback can hand a process a usable nonce. What +// the markers give is at-most-once re-execution of an ATTEMPT - a +// consumed attempt_id cannot be re-opened to mint fresh nonces against +// the same coordinator-visible attempt - and, once externally +// acknowledged, evidence that the release happened. // // Attempt contexts are strict-mode only: there is no legacy-shape // fallback on this path. All entry points are idempotent or fail From 9cedf856b2d3229048f381861c2a950e5c32e386 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 11 Aug 2026 15:24:50 -0400 Subject: [PATCH 3/3] chore(ci): repin rolling TLA tools artifact --- pkg/tbtc/signer/scripts/formal/run_tla_models.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/tbtc/signer/scripts/formal/run_tla_models.sh b/pkg/tbtc/signer/scripts/formal/run_tla_models.sh index 21a97ae395..9b3ace5ea7 100755 --- a/pkg/tbtc/signer/scripts/formal/run_tla_models.sh +++ b/pkg/tbtc/signer/scripts/formal/run_tla_models.sh @@ -13,10 +13,10 @@ 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:-cc4803dce2a8ffaf0f5920a9dc39df4b5ee34ab4cb53fb58ac557277a7e516b3}" +# rolling release v1.8.0 asset). Upstream may delete and rebuild this asset from +# master. Re-pin only after confirming the replacement digest in GitHub's +# official release metadata; the gate below must continue to fail closed. +TLA_TOOLS_SHA256="${TLA_TOOLS_SHA256:-ab323b79802aedc3203b3f9af37c6aca3ed43f4e0225b36f2aa77b26de46c05f}" if ! command -v java >/dev/null 2>&1; then echo "java is required to run TLC model checks" >&2