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/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" + ); + } } 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