Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions pkg/tbtc/signer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
8 changes: 8 additions & 0 deletions pkg/tbtc/signer/docs/permissioned-signer-hardening-rfc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
74 changes: 74 additions & 0 deletions pkg/tbtc/signer/src/engine/anchor_trust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
);
}
}
13 changes: 11 additions & 2 deletions pkg/tbtc/signer/src/engine/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading