diff --git a/pkg/tbtc/signer/Cargo.lock b/pkg/tbtc/signer/Cargo.lock index 34d2aff98d..21c6f61447 100644 --- a/pkg/tbtc/signer/Cargo.lock +++ b/pkg/tbtc/signer/Cargo.lock @@ -76,6 +76,12 @@ dependencies = [ "bitcoin_hashes", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bech32" version = "0.11.1" @@ -407,6 +413,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -453,6 +486,30 @@ dependencies = [ "litrs", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -521,6 +578,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -882,6 +945,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "plotters" version = "0.3.7" @@ -1287,6 +1360,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "spin" version = "0.9.8" @@ -1296,6 +1378,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1323,9 +1415,12 @@ dependencies = [ name = "tbtc-signer" version = "0.1.0" dependencies = [ + "base64ct", "bitcoin", "chacha20poly1305", "criterion", + "curve25519-dalek", + "ed25519-dalek", "frost-core", "frost-secp256k1-tr", "hex", diff --git a/pkg/tbtc/signer/Cargo.toml b/pkg/tbtc/signer/Cargo.toml index 29e9ff10e2..1aa5f892d2 100644 --- a/pkg/tbtc/signer/Cargo.toml +++ b/pkg/tbtc/signer/Cargo.toml @@ -15,7 +15,7 @@ bench-restart-hook = [] [dependencies] serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde_json = { version = "1.0", features = ["raw_value"] } sha2 = "0.10" hex = "0.4" thiserror = "2.0" @@ -29,6 +29,9 @@ rand_chacha = "0.3" libc = "0.2" zeroize = { version = "1.8", default-features = false, features = ["alloc", "serde"] } bitcoin = "0.32" +ed25519-dalek = { version = "2.1", default-features = false, features = ["std"] } +curve25519-dalek = { version = "=4.1.3", default-features = false } +base64ct = { version = "1.8", features = ["alloc"] } [dev-dependencies] criterion = "0.5" diff --git a/pkg/tbtc/signer/include/frost_tbtc.h b/pkg/tbtc/signer/include/frost_tbtc.h index 02ed504ab0..92bc64d083 100644 --- a/pkg/tbtc/signer/include/frost_tbtc.h +++ b/pkg/tbtc/signer/include/frost_tbtc.h @@ -20,6 +20,96 @@ typedef struct { TbtcSignerResult frost_tbtc_version(void); TbtcSignerResult frost_tbtc_abi_version(void); +/* + * Returns the exact descriptor-bound durable session-store identity using the + * tbtc-signer-durable-session-store-identity/v2 JSON schema. The call opens and + * exclusively locks the store before reading any signer state and fails closed + * if a live path, lock, store-ID, or state entry no longer matches its held + * no-follow descriptor. This stable v2 identity does not attest pre-start + * state freshness or the installed key-package inventory. + */ +TbtcSignerResult frost_tbtc_durable_store_identity(void); +/* + * Returns the validated public-only retained FROST key-package inventory and + * the exact dynamic state-witness tip using + * tbtc-signer-retained-key-package-inventory/v1. + */ +TbtcSignerResult frost_tbtc_retained_key_package_inventory(void); +/* + * Returns up to maximumEntries contiguous witness transitions from a known + * ancestor to an exact historical target. Callers must persist accepted tips + * independently of this signer store to detect a coordinated local rollback. + */ +TbtcSignerResult frost_tbtc_state_witness_proof(const uint8_t* request_ptr, size_t request_len); +/* + * Returns tbtc-signer-state-witness-tip/v1 JSON. Decimal counters are strings. + * The mandatory anchor fields are anchorBindingHash, anchorServiceEpoch, + * anchorRevision, anchorEventRoot, and anchorAcknowledgementDigest; all five + * are zero before an acknowledgement is durably accepted. + */ +TbtcSignerResult frost_tbtc_state_witness_tip(void); +/* + * Accepts strict tbtc-signer-state-witness-checkpoint-ack/v1 camelCase JSON, + * verifies the pinned Ed25519 service response, expiry and monotonic CAS, and + * returns tbtc-signer-state-witness-checkpoint-ack-result/v1. The request's + * operation identifier is spelled exactly `operationID`. + */ +TbtcSignerResult frost_tbtc_acknowledge_state_witness_checkpoint( + const uint8_t* request_ptr, + size_t request_len +); + +/* + * Recovers a remotely committed checkpoint from an unexpired + * tbtc-frost-native-signer-state-anchor-read-response/v1 wrapper. The wrapper + * must bind the exact raw nested historical acknowledgement. + */ +TbtcSignerResult frost_tbtc_recover_state_witness_checkpoint( + const uint8_t* request_ptr, + size_t request_len +); +/* + * Verifies and durably applies a strict + * tbtc-signer-state-anchor-trust-transition/v1 request. This operation is + * startup-only: it must complete before ordinary signer engine/store access. + * Certificate-chain and Read bytes are retained in a durable intent until the + * transition completes. The full verified certificate chain and each + * certificate's raw embedded target acknowledgement remain in the durable + * audit journal. + * Callers MUST invoke frost_tbtc_state_anchor_trust_head first on every + * startup. If it reports state_anchor_trust_recovery_required, use its bounded + * selector to choose the exact configured certificate chain, obtain a newly + * signed target Read wrapper, and resubmit this request. Local intent bytes + * never waive external freshness. + * Returns tbtc-signer-state-anchor-trust-transition-result/v1. + */ +TbtcSignerResult frost_tbtc_transition_state_witness_anchor( + const uint8_t* request_ptr, + size_t request_len +); +/* + * Required startup preflight returning the committed + * tbtc-signer-state-anchor-trust-head/v1 record. Before ordinary store + * initialization this performs an ephemeral descriptor-bound inspection, so a + * preflight read does not consume the startup-only transition window. It + * reports any durable in-progress transition without mutation as + * state_anchor_trust_recovery_required. An unbootstrapped store returns + * state_anchor_trust_head_absent. + */ +TbtcSignerResult frost_tbtc_state_anchor_trust_head(void); +/* + * Provisioning-only startup preflight returning + * tbtc-signer-state-anchor-bootstrap-facts/v1: the stable store fingerprint + * and exact pristine genesis checkpoint needed for the first offline trust + * certificate. Requires a production init config whose purpose is + * state_anchor_bootstrap_provisioning and whose only other populated fields + * are state_path and state_witness_max_records=4. Every signer, key, session, + * policy, network, and anchor/trust field is forbidden. + * The call is ephemeral, does not consume the normal signer startup window, + * and rejects any store containing state, anchor/trust data, a segmented + * witness, or witness history beyond the exact genesis image. + */ +TbtcSignerResult frost_tbtc_state_anchor_bootstrap_facts(void); TbtcSignerResult frost_tbtc_init_signer_config(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_roast_liveness_policy(void); TbtcSignerResult frost_tbtc_hardening_metrics(void); @@ -38,6 +128,7 @@ TbtcSignerResult frost_tbtc_dkg_part1(const uint8_t* request_ptr, size_t request TbtcSignerResult frost_tbtc_dkg_part2(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_dkg_part3(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_persist_distributed_dkg_key_package(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_retire_distributed_dkg_key_packages(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_new_signing_package(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_build_taproot_tx(const uint8_t* request_ptr, size_t request_len); diff --git a/pkg/tbtc/signer/src/api.rs b/pkg/tbtc/signer/src/api.rs index 19c11863b5..f18f56262c 100644 --- a/pkg/tbtc/signer/src/api.rs +++ b/pkg/tbtc/signer/src/api.rs @@ -129,6 +129,22 @@ pub struct PersistDistributedDkgKeyPackageRequest { pub public_key_package: NativeFrostPublicKeyPackage, } +/// Durably removes all locally retained key packages for an exact distributed +/// DKG key group. The operation is idempotent so startup reconciliation can +/// safely repeat it after a crash between native retirement and Go registry +/// archival. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RetireDistributedDkgKeyPackagesRequest { + pub key_group: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RetireDistributedDkgKeyPackagesResult { + pub key_group: String, + pub retired: bool, + pub retired_key_package_count: u16, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct NativeFrostCommitment { pub identifier: String, @@ -672,6 +688,358 @@ pub struct FrostTbtcAbiVersionResult { pub abi_minor: u32, } +/// Runtime identity of the exact durable session store the signer opened and +/// locked. The affirmative safety claims are mandatory on the Go side; this +/// response is emitted only after descriptor/path revalidation succeeds. +/// This stable identity does not attest state freshness or key inventory. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DurableStoreIdentityResult { + pub schema: String, + pub backend: String, + pub store_id: String, + pub canonical_path_fingerprint: String, + pub filesystem_fingerprint: String, + pub lock_fingerprint: String, + pub fingerprint: String, + pub durable: bool, + pub exclusive_lock_held: bool, + pub symlink_free: bool, + pub replacement_protected: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RetainedKeyPackageInventoryPackage { + pub participant_seat: u16, + pub key_package_commitment: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RetainedKeyPackageInventoryEntry { + pub wallet_id: String, + pub key_group: String, + pub threshold: u16, + pub participant_count: u16, + pub share_epoch: u64, + pub public_key_package_commitment: String, + pub key_packages: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RetainedKeyPackageInventoryResult { + pub schema: String, + pub store_fingerprint: String, + pub state_generation: u64, + pub state_commitment: String, + pub previous_state_commitment: String, + pub state_image_digest: String, + pub inventory_commitment: String, + pub entries: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct StateWitnessProofRequest { + pub schema: String, + pub store_fingerprint: String, + pub ancestor_generation: u64, + pub ancestor_commitment: String, + pub target_generation: u64, + pub target_commitment: String, + pub maximum_entries: u16, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StateWitnessProofEntry { + pub generation: u64, + pub previous_state_commitment: String, + pub state_commitment: String, + pub state_image_digest: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StateWitnessProofResult { + pub schema: String, + pub store_fingerprint: String, + pub ancestor_generation: u64, + pub ancestor_commitment: String, + pub target_generation: u64, + pub target_commitment: String, + pub complete: bool, + pub entries: Vec, +} + +/// Exact committed state-witness tip plus the latest independently signed +/// anchor acknowledgement retained by the signer. All counters are canonical +/// decimal strings so the JSON contract is lossless for non-Rust consumers. +/// Before the first accepted acknowledgement, every `anchor*` field is zero. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StateWitnessTipResult { + pub schema: String, + pub store_fingerprint: String, + pub generation: String, + pub previous_state_commitment: String, + pub state_image_digest: String, + pub state_commitment: String, + pub witness_base_generation: String, + pub witness_base_commitment: String, + pub anchor_binding_hash: String, + pub anchor_service_epoch: String, + pub anchor_revision: String, + pub anchor_event_root: String, + pub anchor_acknowledgement_digest: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct StateWitnessCheckpointRequest { + pub store_fingerprint: String, + pub generation: String, + pub previous_state_commitment: String, + pub state_image_digest: String, + pub state_commitment: String, +} + +/// Signed response from the independent state-anchor service. Canonical +/// bytes/decimal parsing, expiry, pin, signature, monotonic-CAS, and idempotency +/// checks are performed by the engine. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AcknowledgeStateWitnessCheckpointRequest { + pub schema: String, + pub binding_hash: String, + pub request_digest: String, + pub nonce: String, + pub status: String, + pub service_epoch: String, + pub revision: String, + pub previous_event_root: String, + pub event_root: String, + pub checkpoint: StateWitnessCheckpointRequest, + #[serde(rename = "operationID")] + pub operation_id: String, + pub transition_digest: String, + pub committed_at_unix_ms: String, + pub expires_at_unix_ms: String, + pub signature: String, +} + +/// Fresh signed read wrapper used only to recover a history-service CAS whose +/// original acknowledgement may have expired before the signer could persist +/// it. `checkpoint_ack` retains the exact nested JSON bytes so the wrapper's +/// raw acknowledgement hash cannot be changed by parsing or reserialization. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RecoverStateWitnessCheckpointRequest { + pub schema: String, + pub binding_hash: String, + pub request_digest: String, + pub nonce: String, + pub status: String, + pub service_epoch: String, + pub revision: String, + pub event_root: String, + pub checkpoint: StateWitnessCheckpointRequest, + #[serde(rename = "operationID")] + pub operation_id: String, + pub transition_digest: String, + pub committed_at_unix_ms: String, + pub expires_at_unix_ms: String, + pub checkpoint_ack: Box, + pub checkpoint_ack_digest: String, + pub signature: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AcknowledgeStateWitnessCheckpointResult { + pub schema: String, + pub acknowledged: bool, + pub idempotent: bool, + pub rotated: bool, + pub store_fingerprint: String, + pub generation: String, + pub state_commitment: String, + pub witness_base_generation: String, + pub witness_base_commitment: String, + pub anchor_service_epoch: String, + pub anchor_service_revision: String, + pub anchor_event_root: String, + pub anchor_acknowledgement_digest: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecoverStateWitnessCheckpointResult { + pub schema: String, + pub recovered: bool, + pub idempotent: bool, + pub rotated: bool, + pub store_fingerprint: String, + pub generation: String, + pub state_commitment: String, + pub witness_base_generation: String, + pub witness_base_commitment: String, + pub anchor_service_epoch: String, + pub anchor_service_revision: String, + pub anchor_event_root: String, + pub anchor_acknowledgement_digest: String, +} + +/// Exact durable-state checkpoint used by the offline-certified state-anchor +/// trust-transition contract. Every counter is a canonical decimal string so +/// the JSON remains lossless across the Go/Rust boundary. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct StateAnchorTrustCheckpoint { + pub store_fingerprint: String, + pub generation: String, + pub previous_state_commitment: String, + pub state_image_digest: String, + pub state_commitment: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct StateAnchorTrustReference { + pub service_epoch: String, + pub revision: String, + pub previous_event_root: String, + pub event_root: String, + pub checkpoint_ack_digest: String, + pub checkpoint: StateAnchorTrustCheckpoint, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct StateAnchorTrustEndpoint { + pub activation_manifest_hash: String, + pub activation_manifest_sequence: String, + pub binding_hash: String, + pub response_public_key: String, + pub response_public_key_spki_sha256: String, + pub offline_authority_public_key: String, + pub offline_authority_spki_sha256: String, + pub witness_maximum_records: String, + pub witness_rotation_threshold_records: String, + pub reference: StateAnchorTrustReference, +} + +/// A required nullable field. Unlike `Option`'s normal serde behavior, +/// wrapping the option makes an omitted `from` field an error while still +/// accepting the explicit JSON `null` required for bootstrap certificates. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct RequiredNullableStateAnchorTrustEndpoint(pub Option); + +impl<'de> Deserialize<'de> for RequiredNullableStateAnchorTrustEndpoint { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Option::::deserialize(deserializer).map(Self) + } +} + +/// Two-stage offline-authority certificate. The JSON organization is nested, +/// but its signed transcripts are the frozen fixed-width direct concatenations +/// implemented in `engine::anchor_trust`. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct StateAnchorTrustCertificate { + pub schema: String, + pub kind: String, + pub certificate_sequence: String, + pub previous_certificate_digest: String, + #[serde(rename = "protocolID")] + pub protocol_id: String, + #[serde(rename = "streamID")] + pub stream_id: String, + pub signer_store_fingerprint: String, + pub from: RequiredNullableStateAnchorTrustEndpoint, + pub to: StateAnchorTrustEndpoint, + pub core_digest: String, + pub core_signature: String, + #[serde(rename = "operationID")] + pub operation_id: String, + pub transition_digest: String, + pub target_acknowledgement_base64: String, + #[serde(rename = "targetAcknowledgementSHA256")] + pub target_acknowledgement_sha256: String, + pub final_signature: String, + pub certificate_digest: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TransitionStateWitnessAnchorRequest { + pub schema: String, + pub certificate_chain: Vec, + pub target_read_response_base64: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct StateAnchorTrustHeadResult { + pub schema: String, + pub certificate_sequence: String, + pub certificate_digest: String, + pub activation_manifest_sequence: String, + pub activation_manifest_hash: String, + pub binding_hash: String, + pub response_public_key_spki_sha256: String, + pub offline_authority_spki_sha256: String, + pub service_epoch: String, + pub certified_floor: StateAnchorTrustReference, + pub witness_maximum_records: String, + pub witness_rotation_threshold_records: String, +} + +/// Bounded, unauthoritative selector for a durable trust-transition intent. +/// The host must match it against its configured certificate artifact and +/// obtain a new signed Read; these fields never authorize local recovery. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct StateAnchorTrustRecoveryRequired { + pub schema: String, + pub store_fingerprint: String, + pub certificate_count: String, + pub first_certificate_sequence: String, + pub ordered_certificate_digests: Vec, + pub final_certificate_sequence: String, + pub final_certificate_digest: String, + pub target_binding_hash: String, + pub target_service_epoch: String, + pub target_revision: String, + pub target_checkpoint: StateAnchorTrustCheckpoint, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct StateAnchorBootstrapFactsResult { + pub schema: String, + pub store_fingerprint: String, + pub current_checkpoint: StateAnchorTrustCheckpoint, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TransitionStateWitnessAnchorResult { + pub schema: String, + pub installed: bool, + pub idempotent: bool, + pub applied_certificate_count: String, + pub trust_head: StateAnchorTrustHeadResult, + pub current_checkpoint: StateAnchorTrustCheckpoint, + pub witness_base_checkpoint: StateAnchorTrustCheckpoint, + pub current_anchor_reference: StateAnchorTrustReference, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct SignerHardeningMetricsResult { pub runtime_version: String, @@ -757,6 +1125,12 @@ pub struct ErrorResponse { pub code: String, pub message: String, pub recovery_class: String, + /// Populated only for `history_pruned`, so callers can recover against the + /// independently anchored retained base without parsing a human message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub witness_base_generation: Option, /// CANDIDATE culprits for an `aggregate_share_verification_failed` error: /// the u16 Go member identifiers whose FROST signature shares failed /// verification (the same identifier space as `excluded_member_identifiers`). @@ -766,6 +1140,11 @@ pub struct ErrorResponse { /// adjudication (frozen Phase 7.2b spec, section 6). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub candidate_culprits: Vec, + /// Present only for `state_anchor_trust_recovery_required`. This is a + /// selector for the configured certificate artifact, not authorization to + /// recover without a newly verified signed Read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_anchor_trust_recovery: Option, } /// Init-time signer configuration installed once by the host over FFI. @@ -781,6 +1160,12 @@ pub struct ErrorResponse { #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] #[serde(deny_unknown_fields)] pub struct InitSignerConfigRequest { + /// Startup authority for this installed snapshot. Omitted preserves the + /// normal signer purpose; `state_anchor_bootstrap_provisioning` is a + /// production-only, minimal config exposing only the pristine + /// bootstrap-facts preflight. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub purpose: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -800,6 +1185,32 @@ pub struct InitSignerConfigRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub state_corrupt_backup_limit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_witness_max_records: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_anchor_binding_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_anchor_response_public_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_anchor_response_public_key_spki_sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_witness_rotation_threshold_records: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_anchor_protocol_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_anchor_stream_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_anchor_activation_manifest_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_anchor_activation_manifest_sequence: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_anchor_offline_authority_public_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_anchor_offline_authority_public_key_spki_sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_anchor_trust_certificate_sequence: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_anchor_trust_certificate_digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub permit_plaintext_state_rollback: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub max_sessions: Option, diff --git a/pkg/tbtc/signer/src/engine/anchor.rs b/pkg/tbtc/signer/src/engine/anchor.rs new file mode 100644 index 0000000000..405a4daf5b --- /dev/null +++ b/pkg/tbtc/signer/src/engine/anchor.rs @@ -0,0 +1,1895 @@ +//! Manifest-pinned state-anchor acknowledgement verification. +//! +//! The online anchor acknowledgement is deliberately persisted outside +//! `EngineState`: accepting it must not advance the state witness whose exact +//! tip the service just acknowledged. The durable store owns the descriptor- +//! bound metadata and segment rotation; this module owns strict wire parsing, +//! pin validation, the frozen signing transcript, and Ed25519 verification. + +use super::*; + +use crate::api::{ + AcknowledgeStateWitnessCheckpointRequest, AcknowledgeStateWitnessCheckpointResult, + RecoverStateWitnessCheckpointRequest, RecoverStateWitnessCheckpointResult, + StateWitnessTipResult, +}; +use ed25519_dalek::{Signature, VerifyingKey}; + +pub(crate) const TBTC_SIGNER_STATE_WITNESS_TIP_SCHEMA: &str = "tbtc-signer-state-witness-tip/v1"; +pub(crate) const TBTC_SIGNER_STATE_WITNESS_CHECKPOINT_ACK_SCHEMA: &str = + "tbtc-signer-state-witness-checkpoint-ack/v1"; +pub(crate) const TBTC_SIGNER_STATE_WITNESS_CHECKPOINT_ACK_RESULT_SCHEMA: &str = + "tbtc-signer-state-witness-checkpoint-ack-result/v1"; +pub(crate) const TBTC_SIGNER_STATE_WITNESS_CHECKPOINT_RECOVERY_RESULT_SCHEMA: &str = + "tbtc-signer-state-witness-checkpoint-recovery-result/v1"; +const STATE_ANCHOR_READ_RESPONSE_SCHEMA: &str = + "tbtc-frost-native-signer-state-anchor-read-response/v1"; + +const STATE_ANCHOR_SERVICE_RESPONSE_DOMAIN: &[u8] = + b"tbtc-native-signer-state-anchor-service-response/v1\0"; +const STATE_ANCHOR_ACKNOWLEDGEMENT_DOMAIN: &[u8] = b"tbtc-signer-state-anchor-acknowledgement/v1\0"; +const STATE_ANCHOR_READ_RESPONSE_DOMAIN: &[u8] = + b"tbtc-native-signer-state-anchor-read-response/v1\0"; +const STATE_ANCHOR_EVENT_ROOT_DOMAIN: &[u8] = b"tbtc-native-signer-state-anchor-event/v1\0"; +const ED25519_SPKI_PREFIX: &[u8] = + &hex_literal_ed25519_spki_prefix::ED25519_SUBJECT_PUBLIC_KEY_INFO_PREFIX; +const ACKNOWLEDGEMENT_MAX_TTL_MILLISECONDS: u64 = 30_000; +const ACKNOWLEDGEMENT_MAX_FUTURE_SKEW_MILLISECONDS: u64 = 5_000; + +// Kept in a private module so the byte literal is compile-time checked without +// adding a second hex-decoding dependency or a runtime parse. +mod hex_literal_ed25519_spki_prefix { + pub(super) const ED25519_SUBJECT_PUBLIC_KEY_INFO_PREFIX: [u8; 12] = [ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ]; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StateAnchorConfiguration { + pub(crate) binding_hash: [u8; 32], + pub(crate) response_public_key: [u8; 32], + pub(crate) response_public_key_spki_sha256: [u8; 32], + pub(crate) rotation_threshold_records: usize, + pub(crate) trust: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StateAnchorTrustConfiguration { + pub(crate) protocol_id: [u8; 32], + pub(crate) stream_id: [u8; 32], + pub(crate) activation_manifest_hash: [u8; 32], + pub(crate) activation_manifest_sequence: u64, + pub(crate) offline_authority_public_key: [u8; 32], + pub(crate) offline_authority_public_key_spki_sha256: [u8; 32], + pub(crate) certificate_sequence: u64, + pub(crate) certificate_digest: [u8; 32], +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StateAnchorAcknowledgement { + pub(crate) binding_hash: [u8; 32], + pub(crate) request_digest: [u8; 32], + pub(crate) nonce: [u8; 32], + pub(crate) status: u8, + pub(crate) service_epoch: u64, + pub(crate) revision: u64, + pub(crate) previous_event_root: [u8; 32], + pub(crate) event_root: [u8; 32], + pub(crate) checkpoint_store_fingerprint: [u8; 32], + pub(crate) checkpoint_generation: u64, + pub(crate) checkpoint_previous_commitment: [u8; 32], + pub(crate) checkpoint_state_image_digest: [u8; 32], + pub(crate) checkpoint_state_commitment: [u8; 32], + pub(crate) operation_id: [u8; 32], + pub(crate) transition_digest: [u8; 32], + pub(crate) committed_at_unix_ms: u64, + pub(crate) expires_at_unix_ms: u64, + pub(crate) signing_digest: [u8; 32], + pub(crate) signature: [u8; 64], + pub(crate) configured_spki_hash: [u8; 32], + pub(crate) acknowledgement_digest: [u8; 32], +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StateAnchorMetadata { + pub(crate) latest: StateAnchorAcknowledgement, + /// The acknowledgement that authorized the current witness-segment base. + /// `None` means the original unpruned v2 genesis journal is still active. + pub(crate) witness_base: Option, + /// A durably accepted acknowledgement authorizing an in-progress rotation. + /// Retaining both old and next bases makes every rename boundary + /// independently recoverable; it is promoted to `witness_base` only after + /// the new current segment is durable and verified. + pub(crate) pending_witness_base: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StateWitnessTipSnapshot { + pub(crate) store_fingerprint: [u8; 32], + pub(crate) tip: StateWitness, + pub(crate) base: StateWitness, + pub(crate) anchor: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AnchorAcknowledgeOutcome { + pub(crate) idempotent: bool, + pub(crate) rotated: bool, + pub(crate) snapshot: StateWitnessTipSnapshot, +} + +pub(crate) fn validate_state_anchor_configuration() -> Result<(), EngineError> { + configured_state_anchor().map(|_| ()) +} + +pub(crate) fn configured_state_anchor() -> Result, EngineError> { + let binding_hash = signer_env_var(TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH_ENV); + let response_public_key = signer_env_var(TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_ENV); + let spki_hash = signer_env_var(TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256_ENV); + let rotation_threshold = + signer_env_var(TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS_ENV); + let protocol_id = signer_env_var(TBTC_SIGNER_STATE_ANCHOR_PROTOCOL_ID_ENV); + let stream_id = signer_env_var(TBTC_SIGNER_STATE_ANCHOR_STREAM_ID_ENV); + let activation_manifest_hash = + signer_env_var(TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_HASH_ENV); + let activation_manifest_sequence = + signer_env_var(TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_SEQUENCE_ENV); + let offline_authority_public_key = + signer_env_var(TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_ENV); + let offline_authority_spki_hash = + signer_env_var(TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_SPKI_SHA256_ENV); + let certificate_sequence = + signer_env_var(TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_SEQUENCE_ENV); + let certificate_digest = signer_env_var(TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_DIGEST_ENV); + + if binding_hash.is_none() + && response_public_key.is_none() + && spki_hash.is_none() + && rotation_threshold.is_none() + && protocol_id.is_none() + && stream_id.is_none() + && activation_manifest_hash.is_none() + && activation_manifest_sequence.is_none() + && offline_authority_public_key.is_none() + && offline_authority_spki_hash.is_none() + && certificate_sequence.is_none() + && certificate_digest.is_none() + { + return Ok(None); + } + let require = |value: Option, name: &str| { + value.ok_or_else(|| { + EngineError::Validation(format!( + "state-anchor configuration is partial; missing [{name}]" + )) + }) + }; + let binding_hash = parse_canonical_bytes32( + &require(binding_hash, TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH_ENV)?, + TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH_ENV, + )?; + if binding_hash == [0u8; 32] { + return Err(EngineError::Validation(format!( + "{} must be nonzero", + TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH_ENV + ))); + } + let response_public_key = parse_canonical_bytes32( + &require( + response_public_key, + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_ENV, + )?, + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_ENV, + )?; + let response_public_key_spki_sha256 = parse_canonical_bytes32( + &require( + spki_hash, + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256_ENV, + )?, + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256_ENV, + )?; + validate_strong_ed25519_verifying_key( + &response_public_key, + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_ENV, + )?; + let computed_spki_hash = ed25519_spki_sha256(&response_public_key); + if computed_spki_hash != response_public_key_spki_sha256 { + return Err(EngineError::Validation(format!( + "{} does not match the configured raw Ed25519 public key", + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256_ENV + ))); + } + + let threshold_raw = require( + rotation_threshold, + TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS_ENV, + )?; + let rotation_threshold_records = parse_canonical_usize( + &threshold_raw, + TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS_ENV, + )?; + let maximum_records = state_witness_max_records()?; + // The terminal band lets an interrupted multi-snapshot retry finish, and + // the quarantine pair above it keeps corruption recovery available once + // the journal parks there. Both must fit below the hard record ceiling: + // exceeding that ceiling would leave a journal that no longer parses on + // reopen, so a geometry without the reserve has no supported exit from a + // corrupt state image. + let threshold_with_reservations = rotation_threshold_records + .checked_add(TBTC_SIGNER_STATE_WITNESS_ROTATION_TERMINAL_RECORD_RESERVATION) + .and_then(|reserved| { + reserved.checked_add(TBTC_SIGNER_STATE_WITNESS_QUARANTINE_RECORD_RESERVATION) + }) + .ok_or_else(|| { + EngineError::Validation( + "state witness rotation threshold overflows its terminal-record reservation" + .to_string(), + ) + })?; + if rotation_threshold_records < 2 || threshold_with_reservations > maximum_records { + return Err(EngineError::Validation(format!( + "{} must be at least 2 and leave eight records below {} [{}]; got [{}]", + TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS_ENV, + TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, + maximum_records, + rotation_threshold_records + ))); + } + + let trust_values_are_absent = protocol_id.is_none() + && stream_id.is_none() + && activation_manifest_hash.is_none() + && activation_manifest_sequence.is_none() + && offline_authority_public_key.is_none() + && offline_authority_spki_hash.is_none() + && certificate_sequence.is_none() + && certificate_digest.is_none(); + let trust = if trust_values_are_absent && !signer_profile_is_production() { + // Development-only compatibility for the crate's pre-transition + // low-level fixtures. Production and every FFI-installed anchor config + // must pin the complete trust head and therefore cannot enter this path. + None + } else { + let protocol_id = + parse_required_nonzero_bytes32(protocol_id, TBTC_SIGNER_STATE_ANCHOR_PROTOCOL_ID_ENV)?; + let stream_id = + parse_required_nonzero_bytes32(stream_id, TBTC_SIGNER_STATE_ANCHOR_STREAM_ID_ENV)?; + let activation_manifest_hash = parse_required_nonzero_bytes32( + activation_manifest_hash, + TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_HASH_ENV, + )?; + let activation_manifest_sequence = parse_required_nonzero_u64( + activation_manifest_sequence, + TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_SEQUENCE_ENV, + )?; + let offline_authority_public_key = parse_required_nonzero_bytes32( + offline_authority_public_key, + TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_ENV, + )?; + validate_strong_ed25519_verifying_key( + &offline_authority_public_key, + TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_ENV, + )?; + let offline_authority_public_key_spki_sha256 = parse_required_nonzero_bytes32( + offline_authority_spki_hash, + TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_SPKI_SHA256_ENV, + )?; + if ed25519_spki_sha256(&offline_authority_public_key) + != offline_authority_public_key_spki_sha256 + { + return Err(EngineError::Validation(format!( + "{} does not match the configured raw Ed25519 public key", + TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_SPKI_SHA256_ENV + ))); + } + if offline_authority_public_key == response_public_key { + return Err(EngineError::Validation( + "state-anchor offline authority and online response keys must be role-distinct" + .to_string(), + )); + } + let certificate_sequence = parse_required_nonzero_u64( + certificate_sequence, + TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_SEQUENCE_ENV, + )?; + let certificate_digest = parse_required_nonzero_bytes32( + certificate_digest, + TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_DIGEST_ENV, + )?; + Some(StateAnchorTrustConfiguration { + protocol_id, + stream_id, + activation_manifest_hash, + activation_manifest_sequence, + offline_authority_public_key, + offline_authority_public_key_spki_sha256, + certificate_sequence, + certificate_digest, + }) + }; + + Ok(Some(StateAnchorConfiguration { + binding_hash, + response_public_key, + response_public_key_spki_sha256, + rotation_threshold_records, + trust, + })) +} + +fn parse_required_nonzero_bytes32( + value: Option, + name: &str, +) -> Result<[u8; 32], EngineError> { + let value = value.ok_or_else(|| { + EngineError::Validation(format!( + "state-anchor trust configuration is partial; missing [{name}]" + )) + })?; + let parsed = parse_canonical_bytes32(&value, name)?; + if parsed == [0u8; 32] { + return Err(EngineError::Validation(format!("{name} must be nonzero"))); + } + Ok(parsed) +} + +fn parse_required_nonzero_u64(value: Option, name: &str) -> Result { + let value = value.ok_or_else(|| { + EngineError::Validation(format!( + "state-anchor trust configuration is partial; missing [{name}]" + )) + })?; + let parsed = parse_canonical_u64(&value, name)?; + if parsed == 0 { + return Err(EngineError::Validation(format!("{name} must be nonzero"))); + } + Ok(parsed) +} + +pub(crate) fn state_witness_tip() -> Result { + // First load/migration can advance the witness. Take the same + // ENGINE_STATE -> durable-store lock order as every mutation and keep the + // engine guard through tip capture so a caller cannot observe a + // pre-migration or concurrently superseded tip. + let engine = state()?; + let _guard = engine + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + let snapshot = with_state_file_lock(|store| store.state_witness_tip_snapshot())?; + Ok(state_witness_tip_result(&snapshot)) +} + +pub(crate) fn acknowledge_state_witness_checkpoint( + request: AcknowledgeStateWitnessCheckpointRequest, +) -> Result { + let configuration = configured_state_anchor()?.ok_or_else(|| { + EngineError::Validation( + "state-anchor acknowledgement is disabled because its manifest pins are not configured" + .to_string(), + ) + })?; + let acknowledgement = validate_acknowledgement_request(request, &configuration, true)?; + let admission_expires_at_unix_ms = acknowledgement.expires_at_unix_ms; + let outcome = with_state_file_lock_before_startup_rewrite(|store| { + store.acknowledge_state_witness_checkpoint( + acknowledgement, + configuration.rotation_threshold_records, + false, + admission_expires_at_unix_ms, + ) + })?; + let snapshot = &outcome.snapshot; + let anchor = snapshot.anchor.as_ref().ok_or_else(|| { + EngineError::Internal( + "durable store accepted an acknowledgement without retaining anchor metadata" + .to_string(), + ) + })?; + Ok(AcknowledgeStateWitnessCheckpointResult { + schema: TBTC_SIGNER_STATE_WITNESS_CHECKPOINT_ACK_RESULT_SCHEMA.to_string(), + acknowledged: true, + idempotent: outcome.idempotent, + rotated: outcome.rotated, + store_fingerprint: bytes32_hex(snapshot.store_fingerprint), + generation: snapshot.tip.generation.to_string(), + state_commitment: bytes32_hex(snapshot.tip.commitment), + witness_base_generation: snapshot.base.generation.to_string(), + witness_base_commitment: bytes32_hex(snapshot.base.commitment), + anchor_service_epoch: anchor.latest.service_epoch.to_string(), + anchor_service_revision: anchor.latest.revision.to_string(), + anchor_event_root: bytes32_hex(anchor.latest.event_root), + anchor_acknowledgement_digest: bytes32_hex(anchor.latest.acknowledgement_digest), + }) +} + +pub(crate) fn recover_state_witness_checkpoint( + request: RecoverStateWitnessCheckpointRequest, +) -> Result { + let configuration = configured_state_anchor()?.ok_or_else(|| { + EngineError::Validation( + "state-anchor recovery is disabled because its manifest pins are not configured" + .to_string(), + ) + })?; + let (acknowledgement, wrapper_expires_at_unix_ms) = + validate_recovery_request(request, &configuration)?; + let outcome = with_state_file_lock_before_startup_rewrite(|store| { + store.acknowledge_state_witness_checkpoint( + acknowledgement, + configuration.rotation_threshold_records, + true, + wrapper_expires_at_unix_ms, + ) + })?; + let snapshot = &outcome.snapshot; + let anchor = snapshot.anchor.as_ref().ok_or_else(|| { + EngineError::Internal( + "durable store recovered a checkpoint without retaining anchor metadata".to_string(), + ) + })?; + Ok(RecoverStateWitnessCheckpointResult { + schema: TBTC_SIGNER_STATE_WITNESS_CHECKPOINT_RECOVERY_RESULT_SCHEMA.to_string(), + recovered: true, + idempotent: outcome.idempotent, + rotated: outcome.rotated, + store_fingerprint: bytes32_hex(snapshot.store_fingerprint), + generation: snapshot.tip.generation.to_string(), + state_commitment: bytes32_hex(snapshot.tip.commitment), + witness_base_generation: snapshot.base.generation.to_string(), + witness_base_commitment: bytes32_hex(snapshot.base.commitment), + anchor_service_epoch: anchor.latest.service_epoch.to_string(), + anchor_service_revision: anchor.latest.revision.to_string(), + anchor_event_root: bytes32_hex(anchor.latest.event_root), + anchor_acknowledgement_digest: bytes32_hex(anchor.latest.acknowledgement_digest), + }) +} + +fn state_witness_tip_result(snapshot: &StateWitnessTipSnapshot) -> StateWitnessTipResult { + let zero = [0u8; 32]; + let (binding_hash, epoch, revision, event_root, acknowledgement_digest) = + match snapshot.anchor.as_ref() { + Some(anchor) => ( + anchor.latest.binding_hash, + anchor.latest.service_epoch, + anchor.latest.revision, + anchor.latest.event_root, + anchor.latest.acknowledgement_digest, + ), + None => (zero, 0, 0, zero, zero), + }; + StateWitnessTipResult { + schema: TBTC_SIGNER_STATE_WITNESS_TIP_SCHEMA.to_string(), + store_fingerprint: bytes32_hex(snapshot.store_fingerprint), + generation: snapshot.tip.generation.to_string(), + previous_state_commitment: bytes32_hex(snapshot.tip.previous_commitment), + state_image_digest: bytes32_hex(snapshot.tip.state_image_digest), + state_commitment: bytes32_hex(snapshot.tip.commitment), + witness_base_generation: snapshot.base.generation.to_string(), + witness_base_commitment: bytes32_hex(snapshot.base.commitment), + anchor_binding_hash: bytes32_hex(binding_hash), + anchor_service_epoch: epoch.to_string(), + anchor_revision: revision.to_string(), + anchor_event_root: bytes32_hex(event_root), + anchor_acknowledgement_digest: bytes32_hex(acknowledgement_digest), + } +} + +fn validate_recovery_request( + request: RecoverStateWitnessCheckpointRequest, + configuration: &StateAnchorConfiguration, +) -> Result<(StateAnchorAcknowledgement, u64), EngineError> { + let (acknowledgement, expires_at, _) = validate_read_response_with_rules( + request, + configuration, + AcknowledgementParentRule::Ordinary, + AcknowledgementTimeMode::Fresh, + AcknowledgementTimeMode::Recovery, + )?; + Ok((acknowledgement, expires_at)) +} + +pub(crate) fn validate_certified_transition_read_response( + request: RecoverStateWitnessCheckpointRequest, + configuration: &StateAnchorConfiguration, + certified_previous_event_root: [u8; 32], + require_fresh: bool, +) -> Result<(StateAnchorAcknowledgement, u64, Vec), EngineError> { + validate_read_response_with_rules( + request, + configuration, + AcknowledgementParentRule::CertifiedEpochGenesis(certified_previous_event_root), + if require_fresh { + AcknowledgementTimeMode::Fresh + } else { + // A persisted intent is parsed intrinsically only to authenticate + // its bounded certificate selector. It never authorizes recovery: + // mutation resumes only through a separately verified fresh Read. + AcknowledgementTimeMode::IntrinsicOnly + }, + AcknowledgementTimeMode::IntrinsicOnly, + ) +} + +fn validate_read_response_with_rules( + request: RecoverStateWitnessCheckpointRequest, + configuration: &StateAnchorConfiguration, + parent_rule: AcknowledgementParentRule, + wrapper_time_mode: AcknowledgementTimeMode, + nested_time_mode: AcknowledgementTimeMode, +) -> Result<(StateAnchorAcknowledgement, u64, Vec), EngineError> { + if request.schema != STATE_ANCHOR_READ_RESPONSE_SCHEMA { + return Err(EngineError::Validation(format!( + "state-anchor recovery schema must be [{STATE_ANCHOR_READ_RESPONSE_SCHEMA}]" + ))); + } + if request.status != "present" { + return Err(EngineError::Validation( + "state-anchor recovery read status must be 'present'".to_string(), + )); + } + let binding_hash = parse_canonical_bytes32(&request.binding_hash, "bindingHash")?; + if binding_hash != configuration.binding_hash { + return Err(EngineError::Validation( + "state-anchor recovery bindingHash does not match the manifest pin".to_string(), + )); + } + let request_digest = parse_canonical_bytes32(&request.request_digest, "requestDigest")?; + let nonce = parse_canonical_bytes32(&request.nonce, "nonce")?; + let service_epoch = parse_canonical_u64(&request.service_epoch, "serviceEpoch")?; + let revision = parse_canonical_u64(&request.revision, "revision")?; + let event_root = parse_canonical_bytes32(&request.event_root, "eventRoot")?; + let checkpoint_store_fingerprint = parse_canonical_bytes32( + &request.checkpoint.store_fingerprint, + "checkpoint.storeFingerprint", + )?; + let checkpoint_generation = + parse_canonical_u64(&request.checkpoint.generation, "checkpoint.generation")?; + let checkpoint_previous_commitment = parse_canonical_bytes32( + &request.checkpoint.previous_state_commitment, + "checkpoint.previousStateCommitment", + )?; + let checkpoint_state_image_digest = parse_canonical_bytes32( + &request.checkpoint.state_image_digest, + "checkpoint.stateImageDigest", + )?; + let checkpoint_state_commitment = parse_canonical_bytes32( + &request.checkpoint.state_commitment, + "checkpoint.stateCommitment", + )?; + let operation_id = parse_canonical_bytes32(&request.operation_id, "operationID")?; + let transition_digest = + parse_canonical_bytes32(&request.transition_digest, "transitionDigest")?; + let committed_at_unix_ms = + parse_canonical_u64(&request.committed_at_unix_ms, "committedAtUnixMs")?; + let expires_at_unix_ms = parse_canonical_u64(&request.expires_at_unix_ms, "expiresAtUnixMs")?; + validate_acknowledgement_lifetime(committed_at_unix_ms, expires_at_unix_ms, wrapper_time_mode)?; + let checkpoint_ack_digest = + parse_canonical_bytes32(&request.checkpoint_ack_digest, "checkpointAckDigest")?; + let signature = parse_canonical_signature(&request.signature)?; + if request_digest == [0u8; 32] + || nonce == [0u8; 32] + || service_epoch == 0 + || revision == 0 + || event_root == [0u8; 32] + || checkpoint_store_fingerprint == [0u8; 32] + || checkpoint_generation == 0 + || checkpoint_state_image_digest == [0u8; 32] + || checkpoint_state_commitment == [0u8; 32] + || operation_id == [0u8; 32] + || transition_digest == [0u8; 32] + || checkpoint_ack_digest == [0u8; 32] + { + return Err(EngineError::Validation( + "state-anchor recovery read contains an incomplete authenticated summary".to_string(), + )); + } + if checkpoint_state_commitment + != state_commitment( + &checkpoint_store_fingerprint, + checkpoint_generation, + &checkpoint_previous_commitment, + &checkpoint_state_image_digest, + ) + { + return Err(EngineError::Validation( + "state-anchor recovery checkpoint commitment is invalid".to_string(), + )); + } + + let raw_acknowledgement = request.checkpoint_ack.get().as_bytes(); + let raw_acknowledgement_digest: [u8; 32] = Sha256::digest(raw_acknowledgement).into(); + let signing_digest = state_anchor_read_response_signing_digest( + &binding_hash, + &request_digest, + &nonce, + service_epoch, + revision, + &event_root, + &checkpoint_store_fingerprint, + checkpoint_generation, + &checkpoint_previous_commitment, + &checkpoint_state_image_digest, + &checkpoint_state_commitment, + &operation_id, + &transition_digest, + committed_at_unix_ms, + expires_at_unix_ms, + &checkpoint_ack_digest, + &raw_acknowledgement_digest, + ); + let verifying_key = + VerifyingKey::from_bytes(&configuration.response_public_key).map_err(|error| { + EngineError::Internal(format!( + "configured Ed25519 state-anchor response key became invalid: {error}" + )) + })?; + verifying_key + .verify_strict(&signing_digest, &Signature::from_bytes(&signature)) + .map_err(|_| { + EngineError::Validation("state-anchor recovery read signature is invalid".to_string()) + })?; + + let nested_request: AcknowledgeStateWitnessCheckpointRequest = + serde_json::from_slice(raw_acknowledgement).map_err(|error| { + EngineError::Validation(format!( + "state-anchor recovery nested acknowledgement is invalid: {error}" + )) + })?; + // The fresh read wrapper, not the historical nested response, supplies + // replay freshness. Every intrinsic timestamp, signature, pin, transcript, + // event-root, and checkpoint rule on the original response still applies. + let acknowledgement = validate_acknowledgement_request_with_rules( + nested_request, + configuration, + nested_time_mode, + parent_rule, + AcknowledgementStatusRule::AllowAppliedOrReplay, + )?; + if acknowledgement.service_epoch != service_epoch + || acknowledgement.revision != revision + || acknowledgement.event_root != event_root + || acknowledgement.checkpoint_store_fingerprint != checkpoint_store_fingerprint + || acknowledgement.checkpoint_generation != checkpoint_generation + || acknowledgement.checkpoint_previous_commitment != checkpoint_previous_commitment + || acknowledgement.checkpoint_state_image_digest != checkpoint_state_image_digest + || acknowledgement.checkpoint_state_commitment != checkpoint_state_commitment + || acknowledgement.operation_id != operation_id + || acknowledgement.transition_digest != transition_digest + || acknowledgement.acknowledgement_digest != checkpoint_ack_digest + { + return Err(EngineError::Validation( + "state-anchor recovery read summary differs from its exact nested acknowledgement" + .to_string(), + )); + } + Ok(( + acknowledgement, + expires_at_unix_ms, + raw_acknowledgement.to_vec(), + )) +} + +#[allow(clippy::too_many_arguments)] +fn state_anchor_read_response_signing_digest( + binding_hash: &[u8; 32], + request_digest: &[u8; 32], + nonce: &[u8; 32], + service_epoch: u64, + revision: u64, + event_root: &[u8; 32], + checkpoint_store_fingerprint: &[u8; 32], + checkpoint_generation: u64, + checkpoint_previous_commitment: &[u8; 32], + checkpoint_state_image_digest: &[u8; 32], + checkpoint_state_commitment: &[u8; 32], + operation_id: &[u8; 32], + transition_digest: &[u8; 32], + committed_at_unix_ms: u64, + expires_at_unix_ms: u64, + checkpoint_ack_digest: &[u8; 32], + raw_acknowledgement_digest: &[u8; 32], +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(STATE_ANCHOR_READ_RESPONSE_DOMAIN); + digest.update(binding_hash); + digest.update(request_digest); + digest.update(nonce); + digest.update([1u8]); // present + digest.update(service_epoch.to_be_bytes()); + digest.update(revision.to_be_bytes()); + digest.update(event_root); + digest.update(checkpoint_store_fingerprint); + digest.update(checkpoint_generation.to_be_bytes()); + digest.update(checkpoint_previous_commitment); + digest.update(checkpoint_state_image_digest); + digest.update(checkpoint_state_commitment); + digest.update(operation_id); + digest.update(transition_digest); + digest.update(committed_at_unix_ms.to_be_bytes()); + digest.update(expires_at_unix_ms.to_be_bytes()); + digest.update(checkpoint_ack_digest); + digest.update(raw_acknowledgement_digest); + digest.finalize().into() +} + +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +pub(crate) fn state_anchor_read_response_signing_digest_for_tests( + binding_hash: &[u8; 32], + request_digest: &[u8; 32], + nonce: &[u8; 32], + service_epoch: u64, + revision: u64, + event_root: &[u8; 32], + checkpoint_store_fingerprint: &[u8; 32], + checkpoint_generation: u64, + checkpoint_previous_commitment: &[u8; 32], + checkpoint_state_image_digest: &[u8; 32], + checkpoint_state_commitment: &[u8; 32], + operation_id: &[u8; 32], + transition_digest: &[u8; 32], + committed_at_unix_ms: u64, + expires_at_unix_ms: u64, + checkpoint_ack_digest: &[u8; 32], + raw_acknowledgement_digest: &[u8; 32], +) -> [u8; 32] { + state_anchor_read_response_signing_digest( + binding_hash, + request_digest, + nonce, + service_epoch, + revision, + event_root, + checkpoint_store_fingerprint, + checkpoint_generation, + checkpoint_previous_commitment, + checkpoint_state_image_digest, + checkpoint_state_commitment, + operation_id, + transition_digest, + committed_at_unix_ms, + expires_at_unix_ms, + checkpoint_ack_digest, + raw_acknowledgement_digest, + ) +} + +fn validate_acknowledgement_request( + request: AcknowledgeStateWitnessCheckpointRequest, + configuration: &StateAnchorConfiguration, + require_fresh: bool, +) -> Result { + validate_acknowledgement_request_with_rules( + request, + configuration, + if require_fresh { + AcknowledgementTimeMode::Fresh + } else { + AcknowledgementTimeMode::Recovery + }, + AcknowledgementParentRule::Ordinary, + AcknowledgementStatusRule::AllowAppliedOrReplay, + ) +} + +pub(crate) fn validate_certified_transition_acknowledgement( + request: AcknowledgeStateWitnessCheckpointRequest, + configuration: &StateAnchorConfiguration, + certified_previous_event_root: [u8; 32], +) -> Result { + validate_acknowledgement_request_with_rules( + request, + configuration, + AcknowledgementTimeMode::IntrinsicOnly, + AcknowledgementParentRule::CertifiedEpochGenesis(certified_previous_event_root), + AcknowledgementStatusRule::AppliedOnly, + ) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AcknowledgementParentRule { + Ordinary, + CertifiedEpochGenesis([u8; 32]), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AcknowledgementStatusRule { + AllowAppliedOrReplay, + AppliedOnly, +} + +fn validate_acknowledgement_request_with_rules( + request: AcknowledgeStateWitnessCheckpointRequest, + configuration: &StateAnchorConfiguration, + time_mode: AcknowledgementTimeMode, + parent_rule: AcknowledgementParentRule, + status_rule: AcknowledgementStatusRule, +) -> Result { + if request.schema != TBTC_SIGNER_STATE_WITNESS_CHECKPOINT_ACK_SCHEMA { + return Err(EngineError::Validation(format!( + "state-anchor acknowledgement schema must be [{}]", + TBTC_SIGNER_STATE_WITNESS_CHECKPOINT_ACK_SCHEMA + ))); + } + let binding_hash = parse_canonical_bytes32(&request.binding_hash, "bindingHash")?; + if binding_hash != configuration.binding_hash { + return Err(EngineError::Validation( + "state-anchor acknowledgement bindingHash does not match the manifest pin".to_string(), + )); + } + let request_digest = parse_canonical_bytes32(&request.request_digest, "requestDigest")?; + let nonce = parse_canonical_bytes32(&request.nonce, "nonce")?; + let status = match request.status.as_str() { + "applied" => 1, + "already-applied" if status_rule == AcknowledgementStatusRule::AllowAppliedOrReplay => 2, + _ => { + let allowed = if status_rule == AcknowledgementStatusRule::AppliedOnly { + "'applied'" + } else { + "'applied' or 'already-applied'" + }; + return Err(EngineError::Validation(format!( + "state-anchor acknowledgement status must be {allowed}" + ))); + } + }; + let service_epoch = parse_canonical_u64(&request.service_epoch, "serviceEpoch")?; + let revision = parse_canonical_u64(&request.revision, "revision")?; + let previous_event_root = + parse_canonical_bytes32(&request.previous_event_root, "previousEventRoot")?; + let event_root = parse_canonical_bytes32(&request.event_root, "eventRoot")?; + let checkpoint_store_fingerprint = parse_canonical_bytes32( + &request.checkpoint.store_fingerprint, + "checkpoint.storeFingerprint", + )?; + let checkpoint_generation = + parse_canonical_u64(&request.checkpoint.generation, "checkpoint.generation")?; + let checkpoint_previous_commitment = parse_canonical_bytes32( + &request.checkpoint.previous_state_commitment, + "checkpoint.previousStateCommitment", + )?; + let checkpoint_state_image_digest = parse_canonical_bytes32( + &request.checkpoint.state_image_digest, + "checkpoint.stateImageDigest", + )?; + let checkpoint_state_commitment = parse_canonical_bytes32( + &request.checkpoint.state_commitment, + "checkpoint.stateCommitment", + )?; + let operation_id = parse_canonical_bytes32(&request.operation_id, "operationID")?; + let transition_digest = + parse_canonical_bytes32(&request.transition_digest, "transitionDigest")?; + let committed_at_unix_ms = + parse_canonical_u64(&request.committed_at_unix_ms, "committedAtUnixMs")?; + let expires_at_unix_ms = parse_canonical_u64(&request.expires_at_unix_ms, "expiresAtUnixMs")?; + if request_digest == [0u8; 32] + || nonce == [0u8; 32] + || service_epoch == 0 + || revision == 0 + || event_root == [0u8; 32] + || checkpoint_store_fingerprint == [0u8; 32] + || checkpoint_generation == 0 + || checkpoint_state_image_digest == [0u8; 32] + || checkpoint_state_commitment == [0u8; 32] + || operation_id == [0u8; 32] + || transition_digest == [0u8; 32] + || !match parent_rule { + AcknowledgementParentRule::Ordinary => { + (revision == 1 && previous_event_root == [0u8; 32]) + || (revision > 1 && previous_event_root != [0u8; 32]) + } + AcknowledgementParentRule::CertifiedEpochGenesis(certified_parent) => { + (revision == 1 && previous_event_root == certified_parent) + || (revision > 1 && previous_event_root != [0u8; 32]) + } + } + { + return Err(EngineError::Validation( + "state-anchor acknowledgement contains an incomplete authenticated summary".to_string(), + )); + } + if checkpoint_state_commitment + != state_commitment( + &checkpoint_store_fingerprint, + checkpoint_generation, + &checkpoint_previous_commitment, + &checkpoint_state_image_digest, + ) + { + return Err(EngineError::Validation( + "state-anchor acknowledgement checkpoint commitment is invalid".to_string(), + )); + } + validate_acknowledgement_lifetime(committed_at_unix_ms, expires_at_unix_ms, time_mode)?; + let signature = parse_canonical_signature(&request.signature)?; + + let signing_digest = state_anchor_signing_digest( + &binding_hash, + &request_digest, + &nonce, + status, + service_epoch, + revision, + &previous_event_root, + &event_root, + &checkpoint_store_fingerprint, + checkpoint_generation, + &checkpoint_previous_commitment, + &checkpoint_state_image_digest, + &checkpoint_state_commitment, + &operation_id, + &transition_digest, + committed_at_unix_ms, + expires_at_unix_ms, + ); + let verifying_key = + VerifyingKey::from_bytes(&configuration.response_public_key).map_err(|error| { + EngineError::Internal(format!( + "configured Ed25519 state-anchor response key became invalid: {error}" + )) + })?; + let signature_value = Signature::from_bytes(&signature); + verifying_key + .verify_strict(&signing_digest, &signature_value) + .map_err(|_| { + EngineError::Validation( + "state-anchor acknowledgement Ed25519 signature is invalid".to_string(), + ) + })?; + let acknowledgement_digest = state_anchor_acknowledgement_digest( + &signing_digest, + &signature, + &configuration.response_public_key_spki_sha256, + ); + + let acknowledgement = StateAnchorAcknowledgement { + binding_hash, + request_digest, + nonce, + status, + service_epoch, + revision, + previous_event_root, + event_root, + checkpoint_store_fingerprint, + checkpoint_generation, + checkpoint_previous_commitment, + checkpoint_state_image_digest, + checkpoint_state_commitment, + operation_id, + transition_digest, + committed_at_unix_ms, + expires_at_unix_ms, + signing_digest, + signature, + configured_spki_hash: configuration.response_public_key_spki_sha256, + acknowledgement_digest, + }; + validate_state_anchor_event_root(&acknowledgement)?; + Ok(acknowledgement) +} + +pub(crate) fn validate_persisted_state_anchor_acknowledgement( + acknowledgement: &StateAnchorAcknowledgement, + configuration: &StateAnchorConfiguration, +) -> Result<(), EngineError> { + if acknowledgement.binding_hash != configuration.binding_hash + || acknowledgement.configured_spki_hash != configuration.response_public_key_spki_sha256 + { + return Err(EngineError::Internal( + "persisted state-anchor acknowledgement does not match manifest pins".to_string(), + )); + } + validate_acknowledgement_lifetime( + acknowledgement.committed_at_unix_ms, + acknowledgement.expires_at_unix_ms, + AcknowledgementTimeMode::IntrinsicOnly, + ) + .map_err(|error| { + EngineError::Internal(format!( + "persisted state-anchor acknowledgement lifetime is invalid: {error}" + )) + })?; + validate_state_anchor_event_root(acknowledgement).map_err(|error| { + EngineError::Internal(format!( + "persisted state-anchor acknowledgement event root is invalid: {error}" + )) + })?; + let signing_digest = state_anchor_signing_digest( + &acknowledgement.binding_hash, + &acknowledgement.request_digest, + &acknowledgement.nonce, + acknowledgement.status, + acknowledgement.service_epoch, + acknowledgement.revision, + &acknowledgement.previous_event_root, + &acknowledgement.event_root, + &acknowledgement.checkpoint_store_fingerprint, + acknowledgement.checkpoint_generation, + &acknowledgement.checkpoint_previous_commitment, + &acknowledgement.checkpoint_state_image_digest, + &acknowledgement.checkpoint_state_commitment, + &acknowledgement.operation_id, + &acknowledgement.transition_digest, + acknowledgement.committed_at_unix_ms, + acknowledgement.expires_at_unix_ms, + ); + if signing_digest != acknowledgement.signing_digest { + return Err(EngineError::Internal( + "persisted state-anchor signing digest is invalid".to_string(), + )); + } + let verifying_key = + VerifyingKey::from_bytes(&configuration.response_public_key).map_err(|error| { + EngineError::Internal(format!( + "configured Ed25519 state-anchor response key became invalid: {error}" + )) + })?; + verifying_key + .verify_strict( + &signing_digest, + &Signature::from_bytes(&acknowledgement.signature), + ) + .map_err(|_| { + EngineError::Internal( + "persisted state-anchor acknowledgement signature is invalid".to_string(), + ) + })?; + let acknowledgement_digest = state_anchor_acknowledgement_digest( + &signing_digest, + &acknowledgement.signature, + &configuration.response_public_key_spki_sha256, + ); + if acknowledgement_digest != acknowledgement.acknowledgement_digest { + return Err(EngineError::Internal( + "persisted state-anchor acknowledgement digest is invalid".to_string(), + )); + } + Ok(()) +} + +fn validate_state_anchor_event_root( + acknowledgement: &StateAnchorAcknowledgement, +) -> Result<(), EngineError> { + let expected = state_anchor_event_root(acknowledgement); + if expected != acknowledgement.event_root { + return Err(EngineError::Validation( + "state-anchor acknowledgement eventRoot is invalid".to_string(), + )); + } + Ok(()) +} + +fn state_anchor_event_root(acknowledgement: &StateAnchorAcknowledgement) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(STATE_ANCHOR_EVENT_ROOT_DOMAIN); + digest.update(acknowledgement.binding_hash); + digest.update(acknowledgement.service_epoch.to_be_bytes()); + digest.update(acknowledgement.revision.to_be_bytes()); + digest.update(acknowledgement.previous_event_root); + digest.update(acknowledgement.request_digest); + digest.update(acknowledgement.nonce); + digest.update([acknowledgement.status]); + digest.update(acknowledgement.checkpoint_store_fingerprint); + digest.update(acknowledgement.checkpoint_generation.to_be_bytes()); + digest.update(acknowledgement.checkpoint_previous_commitment); + digest.update(acknowledgement.checkpoint_state_image_digest); + digest.update(acknowledgement.checkpoint_state_commitment); + digest.update(acknowledgement.operation_id); + digest.update(acknowledgement.transition_digest); + digest.update(acknowledgement.committed_at_unix_ms.to_be_bytes()); + digest.update(acknowledgement.expires_at_unix_ms.to_be_bytes()); + digest.finalize().into() +} + +#[cfg(test)] +pub(crate) fn state_anchor_event_root_for_tests( + acknowledgement: &StateAnchorAcknowledgement, +) -> [u8; 32] { + state_anchor_event_root(acknowledgement) +} + +#[cfg(test)] +fn validate_acknowledgement_time( + committed_at_unix_ms: u64, + expires_at_unix_ms: u64, +) -> Result<(), EngineError> { + validate_acknowledgement_lifetime( + committed_at_unix_ms, + expires_at_unix_ms, + AcknowledgementTimeMode::Fresh, + ) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AcknowledgementTimeMode { + IntrinsicOnly, + Recovery, + Fresh, +} + +fn validate_acknowledgement_lifetime( + committed_at_unix_ms: u64, + expires_at_unix_ms: u64, + mode: AcknowledgementTimeMode, +) -> Result<(), EngineError> { + if committed_at_unix_ms == 0 || committed_at_unix_ms >= expires_at_unix_ms { + return Err(EngineError::Validation( + "state-anchor acknowledgement committedAtUnixMs must be nonzero and precede \ + expiresAtUnixMs" + .to_string(), + )); + } + let ttl = expires_at_unix_ms + .checked_sub(committed_at_unix_ms) + .ok_or_else(|| { + EngineError::Validation( + "state-anchor acknowledgement timestamp subtraction overflowed".to_string(), + ) + })?; + if ttl > ACKNOWLEDGEMENT_MAX_TTL_MILLISECONDS { + return Err(EngineError::Validation(format!( + "state-anchor acknowledgement TTL [{ttl}] exceeds 30000 milliseconds" + ))); + } + if mode == AcknowledgementTimeMode::IntrinsicOnly { + return Ok(()); + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| { + EngineError::Internal( + "system clock is before the Unix epoch while validating state-anchor acknowledgement" + .to_string(), + ) + })? + .as_millis(); + let now = u64::try_from(now).map_err(|_| { + EngineError::Internal("system Unix-millisecond clock does not fit in u64".to_string()) + })?; + let latest_committed = now + .checked_add(ACKNOWLEDGEMENT_MAX_FUTURE_SKEW_MILLISECONDS) + .ok_or_else(|| { + EngineError::Internal( + "system clock overflowed the state-anchor future-skew window".to_string(), + ) + })?; + if committed_at_unix_ms > latest_committed { + return Err(EngineError::Validation( + "state-anchor acknowledgement committedAtUnixMs exceeds the 5000ms future-skew window" + .to_string(), + )); + } + if mode == AcknowledgementTimeMode::Fresh && now >= expires_at_unix_ms { + return Err(EngineError::Validation( + "state-anchor acknowledgement is expired".to_string(), + )); + } + Ok(()) +} + +pub(crate) fn recheck_state_anchor_admission_expiry( + expires_at_unix_ms: u64, +) -> Result<(), EngineError> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| { + EngineError::Internal( + "system clock is before the Unix epoch while rechecking state-anchor freshness" + .to_string(), + ) + })? + .as_millis(); + let now = u64::try_from(now).map_err(|_| { + EngineError::Internal("system Unix-millisecond clock does not fit in u64".to_string()) + })?; + if now >= expires_at_unix_ms { + return Err(EngineError::Validation( + "state-anchor admission expired while waiting for serialized persistence".to_string(), + )); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn state_anchor_signing_digest( + binding_hash: &[u8; 32], + request_digest: &[u8; 32], + nonce: &[u8; 32], + status: u8, + service_epoch: u64, + revision: u64, + previous_event_root: &[u8; 32], + event_root: &[u8; 32], + checkpoint_store_fingerprint: &[u8; 32], + checkpoint_generation: u64, + checkpoint_previous_commitment: &[u8; 32], + checkpoint_state_image_digest: &[u8; 32], + checkpoint_state_commitment: &[u8; 32], + operation_id: &[u8; 32], + transition_digest: &[u8; 32], + committed_at_unix_ms: u64, + expires_at_unix_ms: u64, +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(STATE_ANCHOR_SERVICE_RESPONSE_DOMAIN); + digest.update(binding_hash); + digest.update(request_digest); + digest.update(nonce); + digest.update([status]); + digest.update(service_epoch.to_be_bytes()); + digest.update(revision.to_be_bytes()); + digest.update(previous_event_root); + digest.update(event_root); + digest.update(checkpoint_store_fingerprint); + digest.update(checkpoint_generation.to_be_bytes()); + digest.update(checkpoint_previous_commitment); + digest.update(checkpoint_state_image_digest); + digest.update(checkpoint_state_commitment); + digest.update(operation_id); + digest.update(transition_digest); + digest.update(committed_at_unix_ms.to_be_bytes()); + digest.update(expires_at_unix_ms.to_be_bytes()); + digest.finalize().into() +} + +fn state_anchor_acknowledgement_digest( + signing_digest: &[u8; 32], + signature: &[u8; 64], + configured_spki_hash: &[u8; 32], +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(STATE_ANCHOR_ACKNOWLEDGEMENT_DOMAIN); + digest.update(signing_digest); + digest.update(signature); + digest.update(configured_spki_hash); + digest.finalize().into() +} + +#[cfg(test)] +pub(crate) fn state_anchor_acknowledgement_digest_for_tests( + signing_digest: &[u8; 32], + signature: &[u8; 64], + configured_spki_hash: &[u8; 32], +) -> [u8; 32] { + state_anchor_acknowledgement_digest(signing_digest, signature, configured_spki_hash) +} + +pub(crate) fn ed25519_spki_sha256(public_key: &[u8; 32]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(ED25519_SPKI_PREFIX); + digest.update(public_key); + digest.finalize().into() +} + +pub(crate) fn validate_strong_ed25519_verifying_key( + public_key: &[u8; 32], + label: &str, +) -> Result { + use curve25519_dalek::edwards::CompressedEdwardsY; + + let point = CompressedEdwardsY(*public_key) + .decompress() + .ok_or_else(|| { + EngineError::Validation(format!( + "{label} is not a canonical compressed Edwards25519 point" + )) + })?; + if point.compress().to_bytes() != *public_key { + return Err(EngineError::Validation(format!( + "{label} is not a canonical compressed Edwards25519 point" + ))); + } + if point.is_small_order() || !point.is_torsion_free() { + return Err(EngineError::Validation(format!( + "{label} must be a non-identity prime-subgroup Ed25519 public key" + ))); + } + VerifyingKey::from_bytes(public_key).map_err(|error| { + EngineError::Validation(format!( + "{label} is not a valid Ed25519 public key: {error}" + )) + }) +} + +pub(crate) fn parse_canonical_bytes32(value: &str, label: &str) -> Result<[u8; 32], EngineError> { + if value.len() != 66 + || !value.starts_with("0x") + || value.as_bytes().iter().any(u8::is_ascii_uppercase) + { + return Err(EngineError::Validation(format!( + "{label} must be canonical lowercase 0x-prefixed bytes32" + ))); + } + let bytes = hex::decode(&value[2..]).map_err(|_| { + EngineError::Validation(format!( + "{label} must be canonical lowercase 0x-prefixed bytes32" + )) + })?; + let mut result = [0u8; 32]; + result.copy_from_slice(&bytes); + Ok(result) +} + +pub(crate) fn parse_canonical_signature(value: &str) -> Result<[u8; 64], EngineError> { + if value.len() != 130 + || !value.starts_with("0x") + || value.as_bytes().iter().any(u8::is_ascii_uppercase) + { + return Err(EngineError::Validation( + "signature must be canonical lowercase 0x-prefixed 64-byte hex".to_string(), + )); + } + let bytes = hex::decode(&value[2..]).map_err(|_| { + EngineError::Validation( + "signature must be canonical lowercase 0x-prefixed 64-byte hex".to_string(), + ) + })?; + let mut signature = [0u8; 64]; + signature.copy_from_slice(&bytes); + Ok(signature) +} + +pub(crate) fn parse_canonical_u64(value: &str, label: &str) -> Result { + if value.is_empty() + || (value.len() > 1 && value.starts_with('0')) + || !value.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(EngineError::Validation(format!( + "{label} must be a canonical unsigned decimal string" + ))); + } + value.parse::().map_err(|_| { + EngineError::Validation(format!( + "{label} must be a canonical unsigned decimal u64 string" + )) + }) +} + +fn parse_canonical_usize(value: &str, label: &str) -> Result { + let parsed = parse_canonical_u64(value, label)?; + usize::try_from(parsed) + .map_err(|_| EngineError::Validation(format!("{label} does not fit this platform"))) +} + +#[cfg(test)] +pub(crate) fn state_anchor_signing_digest_for_tests( + acknowledgement: &StateAnchorAcknowledgement, +) -> [u8; 32] { + state_anchor_signing_digest( + &acknowledgement.binding_hash, + &acknowledgement.request_digest, + &acknowledgement.nonce, + acknowledgement.status, + acknowledgement.service_epoch, + acknowledgement.revision, + &acknowledgement.previous_event_root, + &acknowledgement.event_root, + &acknowledgement.checkpoint_store_fingerprint, + acknowledgement.checkpoint_generation, + &acknowledgement.checkpoint_previous_commitment, + &acknowledgement.checkpoint_state_image_digest, + &acknowledgement.checkpoint_state_commitment, + &acknowledgement.operation_id, + &acknowledgement.transition_digest, + acknowledgement.committed_at_unix_ms, + acknowledgement.expires_at_unix_ms, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + fn acknowledgement_wire( + acknowledgement: &StateAnchorAcknowledgement, + ) -> AcknowledgeStateWitnessCheckpointRequest { + AcknowledgeStateWitnessCheckpointRequest { + schema: TBTC_SIGNER_STATE_WITNESS_CHECKPOINT_ACK_SCHEMA.to_string(), + binding_hash: bytes32_hex(acknowledgement.binding_hash), + request_digest: bytes32_hex(acknowledgement.request_digest), + nonce: bytes32_hex(acknowledgement.nonce), + status: match acknowledgement.status { + 1 => "applied", + 2 => "already-applied", + _ => panic!("invalid fixture status"), + } + .to_string(), + service_epoch: acknowledgement.service_epoch.to_string(), + revision: acknowledgement.revision.to_string(), + previous_event_root: bytes32_hex(acknowledgement.previous_event_root), + event_root: bytes32_hex(acknowledgement.event_root), + checkpoint: crate::api::StateWitnessCheckpointRequest { + store_fingerprint: bytes32_hex(acknowledgement.checkpoint_store_fingerprint), + generation: acknowledgement.checkpoint_generation.to_string(), + previous_state_commitment: bytes32_hex( + acknowledgement.checkpoint_previous_commitment, + ), + state_image_digest: bytes32_hex(acknowledgement.checkpoint_state_image_digest), + state_commitment: bytes32_hex(acknowledgement.checkpoint_state_commitment), + }, + operation_id: bytes32_hex(acknowledgement.operation_id), + transition_digest: bytes32_hex(acknowledgement.transition_digest), + committed_at_unix_ms: acknowledgement.committed_at_unix_ms.to_string(), + expires_at_unix_ms: acknowledgement.expires_at_unix_ms.to_string(), + signature: format!("0x{}", hex::encode(acknowledgement.signature)), + } + } + + fn signed_acknowledgement_fixture( + now: u64, + ) -> ( + SigningKey, + StateAnchorConfiguration, + StateAnchorAcknowledgement, + ) { + let signing_key = SigningKey::from_bytes(&[0x01; 32]); + let response_public_key = signing_key.verifying_key().to_bytes(); + let configured_spki_hash = ed25519_spki_sha256(&response_public_key); + let checkpoint_store_fingerprint = [0x66; 32]; + let checkpoint_generation = 7; + let checkpoint_previous_commitment = [0x77; 32]; + let checkpoint_state_image_digest = [0x88; 32]; + let checkpoint_state_commitment = state_commitment( + &checkpoint_store_fingerprint, + checkpoint_generation, + &checkpoint_previous_commitment, + &checkpoint_state_image_digest, + ); + let mut acknowledgement = StateAnchorAcknowledgement { + binding_hash: [0x11; 32], + request_digest: [0x22; 32], + nonce: [0x33; 32], + status: 1, + service_epoch: 2, + revision: 3, + previous_event_root: [0x44; 32], + event_root: [0; 32], + checkpoint_store_fingerprint, + checkpoint_generation, + checkpoint_previous_commitment, + checkpoint_state_image_digest, + checkpoint_state_commitment, + operation_id: [0xaa; 32], + transition_digest: [0xbb; 32], + committed_at_unix_ms: now - 60_000, + expires_at_unix_ms: now - 30_000, + signing_digest: [0; 32], + signature: [0; 64], + configured_spki_hash, + acknowledgement_digest: [0; 32], + }; + resign_acknowledgement(&mut acknowledgement, &signing_key); + let configuration = StateAnchorConfiguration { + binding_hash: acknowledgement.binding_hash, + response_public_key, + response_public_key_spki_sha256: configured_spki_hash, + rotation_threshold_records: 8, + trust: None, + }; + (signing_key, configuration, acknowledgement) + } + + fn resign_acknowledgement( + acknowledgement: &mut StateAnchorAcknowledgement, + signing_key: &SigningKey, + ) { + acknowledgement.event_root = state_anchor_event_root(acknowledgement); + acknowledgement.signing_digest = state_anchor_signing_digest_for_tests(acknowledgement); + acknowledgement.signature = signing_key.sign(&acknowledgement.signing_digest).to_bytes(); + acknowledgement.acknowledgement_digest = state_anchor_acknowledgement_digest( + &acknowledgement.signing_digest, + &acknowledgement.signature, + &acknowledgement.configured_spki_hash, + ); + } + + fn recovery_wire( + acknowledgement: &StateAnchorAcknowledgement, + signing_key: &SigningKey, + raw_acknowledgement: String, + committed_at_unix_ms: u64, + expires_at_unix_ms: u64, + ) -> RecoverStateWitnessCheckpointRequest { + let request_digest = [0xcc; 32]; + let nonce = [0xdd; 32]; + let raw_acknowledgement_digest: [u8; 32] = + Sha256::digest(raw_acknowledgement.as_bytes()).into(); + let signing_digest = state_anchor_read_response_signing_digest( + &acknowledgement.binding_hash, + &request_digest, + &nonce, + acknowledgement.service_epoch, + acknowledgement.revision, + &acknowledgement.event_root, + &acknowledgement.checkpoint_store_fingerprint, + acknowledgement.checkpoint_generation, + &acknowledgement.checkpoint_previous_commitment, + &acknowledgement.checkpoint_state_image_digest, + &acknowledgement.checkpoint_state_commitment, + &acknowledgement.operation_id, + &acknowledgement.transition_digest, + committed_at_unix_ms, + expires_at_unix_ms, + &acknowledgement.acknowledgement_digest, + &raw_acknowledgement_digest, + ); + RecoverStateWitnessCheckpointRequest { + schema: STATE_ANCHOR_READ_RESPONSE_SCHEMA.to_string(), + binding_hash: bytes32_hex(acknowledgement.binding_hash), + request_digest: bytes32_hex(request_digest), + nonce: bytes32_hex(nonce), + status: "present".to_string(), + service_epoch: acknowledgement.service_epoch.to_string(), + revision: acknowledgement.revision.to_string(), + event_root: bytes32_hex(acknowledgement.event_root), + checkpoint: crate::api::StateWitnessCheckpointRequest { + store_fingerprint: bytes32_hex(acknowledgement.checkpoint_store_fingerprint), + generation: acknowledgement.checkpoint_generation.to_string(), + previous_state_commitment: bytes32_hex( + acknowledgement.checkpoint_previous_commitment, + ), + state_image_digest: bytes32_hex(acknowledgement.checkpoint_state_image_digest), + state_commitment: bytes32_hex(acknowledgement.checkpoint_state_commitment), + }, + operation_id: bytes32_hex(acknowledgement.operation_id), + transition_digest: bytes32_hex(acknowledgement.transition_digest), + committed_at_unix_ms: committed_at_unix_ms.to_string(), + expires_at_unix_ms: expires_at_unix_ms.to_string(), + checkpoint_ack: serde_json::value::RawValue::from_string(raw_acknowledgement) + .expect("valid raw acknowledgement"), + checkpoint_ack_digest: bytes32_hex(acknowledgement.acknowledgement_digest), + signature: format!( + "0x{}", + hex::encode(signing_key.sign(&signing_digest).to_bytes()) + ), + } + } + + fn now_milliseconds() -> u64 { + u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_millis(), + ) + .expect("clock fits u64") + } + + #[test] + fn acknowledgement_time_window_rejects_expired_future_and_oversized_ttl() { + let now = now_milliseconds(); + validate_acknowledgement_time(now.saturating_sub(1), now + 1_000) + .expect("fresh acknowledgement"); + assert!( + validate_acknowledgement_time(now.saturating_sub(2_000), now.saturating_sub(1)) + .is_err() + ); + assert!(validate_acknowledgement_time(now + 6_000, now + 7_000).is_err()); + assert!(validate_acknowledgement_time(now, now + 30_001).is_err()); + assert!(validate_acknowledgement_time(now, now).is_err()); + } + + #[test] + fn canonical_wire_scalars_reject_alias_encodings() { + assert_eq!(parse_canonical_u64("0", "value").expect("zero"), 0); + assert!(parse_canonical_u64("00", "value").is_err()); + assert!(parse_canonical_u64("+1", "value").is_err()); + assert!(parse_canonical_bytes32(&format!("0x{}", "ab".repeat(32)), "hash").is_ok()); + assert!(parse_canonical_bytes32(&format!("0x{}", "AB".repeat(32)), "hash").is_err()); + assert!(parse_canonical_signature(&format!("0x{}", "ab".repeat(64))).is_ok()); + assert!(parse_canonical_signature(&format!("0x{}", "ab".repeat(63))).is_err()); + } + + #[test] + fn anchor_transcripts_match_frozen_go_vectors() { + let acknowledgement = StateAnchorAcknowledgement { + binding_hash: [0x11; 32], + request_digest: [0x22; 32], + nonce: [0x33; 32], + status: 1, + service_epoch: 2, + revision: 3, + previous_event_root: [0x44; 32], + event_root: [0x55; 32], + checkpoint_store_fingerprint: [0x66; 32], + checkpoint_generation: 7, + checkpoint_previous_commitment: [0x77; 32], + checkpoint_state_image_digest: [0x88; 32], + checkpoint_state_commitment: [0x99; 32], + operation_id: [0xaa; 32], + transition_digest: [0xbb; 32], + committed_at_unix_ms: 1_700_000_000_000, + expires_at_unix_ms: 1_700_000_030_000, + signing_digest: [0; 32], + signature: [0; 64], + configured_spki_hash: [0; 32], + acknowledgement_digest: [0; 32], + }; + let signing_digest = state_anchor_signing_digest_for_tests(&acknowledgement); + assert_eq!( + hex::encode(signing_digest), + "55f88c32a0b168003cedfb88cf47a467b607dbd1f2ab6f20ddc7976bd396b239" + ); + let signing_key = SigningKey::from_bytes(&[0x01; 32]); + let signature = signing_key.sign(&signing_digest).to_bytes(); + assert_eq!( + hex::encode(signature), + concat!( + "0a60e68808285197c4ddb4b68dc10439aad6cbde085fd93b7cf863b7abf819713", + "1d73f35304862ea80dc5cfd88d0cac80f9fa42b54efa036b0a82956c62f0608" + ) + ); + let spki_hash = ed25519_spki_sha256(&signing_key.verifying_key().to_bytes()); + assert_eq!( + hex::encode(state_anchor_acknowledgement_digest( + &signing_digest, + &signature, + &spki_hash, + )), + "4c30e2aa6a048993fede1a754a0567a6faef8180544398ff284567f722c6ad01" + ); + assert_eq!( + hex::encode(state_anchor_event_root(&acknowledgement)), + "251cf2f635ea82533f55d323104232ecfd47a748a45fbbe16e8ed212c8c69a90" + ); + + let raw_acknowledgement_digest: [u8; 32] = Sha256::digest(br#"{"x":1}"#).into(); + let read_digest = state_anchor_read_response_signing_digest( + &[0x11; 32], + &[0x22; 32], + &[0x33; 32], + 2, + 3, + &[0x44; 32], + &[0x55; 32], + 6, + &[0x66; 32], + &[0x77; 32], + &[0x88; 32], + &[0x99; 32], + &[0xaa; 32], + 1_700_000_000_000, + 1_700_000_030_000, + &[0xbb; 32], + &raw_acknowledgement_digest, + ); + assert_eq!( + hex::encode(read_digest), + "bc595335e39a91bdaf49fc749f6df910be31385ad394089ba633bec359f47a20" + ); + } + + #[test] + fn fresh_read_recovers_expired_nested_ack_and_binds_its_exact_bytes() { + let now = now_milliseconds(); + let (signing_key, configuration, acknowledgement) = signed_acknowledgement_fixture(now); + let raw_acknowledgement = serde_json::to_string(&acknowledgement_wire(&acknowledgement)) + .expect("serialize acknowledgement"); + let request = recovery_wire( + &acknowledgement, + &signing_key, + raw_acknowledgement, + now - 1, + now + 10_000, + ); + let (validated, wrapper_expiry) = + validate_recovery_request(request, &configuration).expect("fresh recovery"); + assert_eq!(validated, acknowledgement); + assert_eq!(wrapper_expiry, now + 10_000); + + let mut tampered = recovery_wire( + &acknowledgement, + &signing_key, + serde_json::to_string(&acknowledgement_wire(&acknowledgement)) + .expect("serialize acknowledgement"), + now - 1, + now + 10_000, + ); + let raw = tampered.checkpoint_ack.get().replacen('{', "{ ", 1); + tampered.checkpoint_ack = + serde_json::value::RawValue::from_string(raw).expect("valid whitespace JSON"); + assert!(validate_recovery_request(tampered, &configuration).is_err()); + + let expired_wrapper = recovery_wire( + &acknowledgement, + &signing_key, + serde_json::to_string(&acknowledgement_wire(&acknowledgement)) + .expect("serialize acknowledgement"), + now - 20_000, + now - 10_000, + ); + assert!(validate_recovery_request(expired_wrapper, &configuration).is_err()); + } + + #[test] + fn acknowledgement_shape_rejects_signed_zero_audit_fields() { + let now = now_milliseconds(); + let (signing_key, configuration, mut acknowledgement) = signed_acknowledgement_fixture(now); + acknowledgement.committed_at_unix_ms = now - 1; + acknowledgement.expires_at_unix_ms = now + 10_000; + resign_acknowledgement(&mut acknowledgement, &signing_key); + validate_acknowledgement_request( + acknowledgement_wire(&acknowledgement), + &configuration, + true, + ) + .expect("positive control"); + + for mutate in [ + |value: &mut StateAnchorAcknowledgement| value.request_digest = [0; 32], + |value: &mut StateAnchorAcknowledgement| value.nonce = [0; 32], + |value: &mut StateAnchorAcknowledgement| value.operation_id = [0; 32], + |value: &mut StateAnchorAcknowledgement| value.transition_digest = [0; 32], + ] { + let mut invalid = acknowledgement.clone(); + mutate(&mut invalid); + resign_acknowledgement(&mut invalid, &signing_key); + assert!(validate_acknowledgement_request( + acknowledgement_wire(&invalid), + &configuration, + true, + ) + .is_err()); + } + } + + #[test] + fn configured_anchor_rejects_zero_binding_hash() { + let _guard = lock_test_state(); + let signing_key = SigningKey::from_bytes(&[0x01; 32]); + let public_key = signing_key.verifying_key().to_bytes(); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH_ENV, + bytes32_hex([0; 32]), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_ENV, + bytes32_hex(public_key), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256_ENV, + bytes32_hex(ed25519_spki_sha256(&public_key)), + ); + std::env::set_var( + TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS_ENV, + "8", + ); + let error = configured_state_anchor().expect_err("zero binding hash"); + assert!(error.to_string().contains("must be nonzero")); + } + + #[test] + fn configured_anchor_reserves_terminal_and_quarantine_witness_records() { + let _guard = lock_test_state(); + let signing_key = SigningKey::from_bytes(&[0x01; 32]); + let public_key = signing_key.verifying_key().to_bytes(); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH_ENV, + bytes32_hex([0x11; 32]), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_ENV, + bytes32_hex(public_key), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256_ENV, + bytes32_hex(ed25519_spki_sha256(&public_key)), + ); + std::env::set_var( + TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS_ENV, + "2", + ); + // Eight records above the threshold: six terminal records for an + // interrupted multi-snapshot retry, then the quarantine pair that keeps + // corruption recovery available once the journal parks there. + std::env::set_var(TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, "9"); + let error = configured_state_anchor() + .expect_err("a terminal band without a quarantine reserve is insufficient"); + assert!(error.to_string().contains("leave eight records")); + + std::env::set_var(TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, "10"); + assert_eq!( + configured_state_anchor() + .expect("ten records satisfy the configured reservations") + .expect("anchor configuration") + .rotation_threshold_records, + 2 + ); + } + + #[test] + fn configured_anchor_rejects_non_prime_subgroup_online_and_offline_keys() { + let _guard = lock_test_state(); + let corpus = [ + "0100000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000000", + "9970c93c125fd998ebc1642abe30619e2fd971dbcbeaeb8ccfe919cbfd13b6cf", + ]; + for weak_role_is_online in [true, false] { + for encoded in corpus { + establish_clean_signer_test_env(); + let weak: [u8; 32] = hex::decode(encoded) + .expect("vector hex") + .try_into() + .expect("32-byte vector"); + let online_key = SigningKey::from_bytes(&[0x21; 32]) + .verifying_key() + .to_bytes(); + let offline_key = SigningKey::from_bytes(&[0x22; 32]) + .verifying_key() + .to_bytes(); + let response = if weak_role_is_online { + weak + } else { + online_key + }; + let authority = if weak_role_is_online { + offline_key + } else { + weak + }; + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH_ENV, + bytes32_hex([0x31; 32]), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_ENV, + bytes32_hex(response), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256_ENV, + bytes32_hex(ed25519_spki_sha256(&response)), + ); + std::env::set_var( + TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS_ENV, + "8", + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_PROTOCOL_ID_ENV, + bytes32_hex([0x32; 32]), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_STREAM_ID_ENV, + bytes32_hex([0x33; 32]), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_HASH_ENV, + bytes32_hex([0x34; 32]), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_SEQUENCE_ENV, + "1", + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_ENV, + bytes32_hex(authority), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_SPKI_SHA256_ENV, + bytes32_hex(ed25519_spki_sha256(&authority)), + ); + std::env::set_var(TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_SEQUENCE_ENV, "1"); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_DIGEST_ENV, + bytes32_hex([0x35; 32]), + ); + let error = configured_state_anchor() + .expect_err("non-prime-subgroup configured key rejected"); + let error_text = error.to_string(); + // The all-zero encoding is rejected either by a reserved-value + // nonzero pre-check (offline authority) or, where it still + // decompresses to a small-order point, by the prime-subgroup + // check (response key). Every other vector must fail the + // prime-subgroup check itself. + let accepted = if weak.iter().all(|&byte| byte == 0) { + error_text.contains("must be nonzero") || error_text.contains("prime-subgroup") + } else { + error_text.contains("prime-subgroup") + }; + assert!(accepted, "unexpected configured-key error: {error_text}"); + } + } + } +} diff --git a/pkg/tbtc/signer/src/engine/anchor_trust.rs b/pkg/tbtc/signer/src/engine/anchor_trust.rs new file mode 100644 index 0000000000..f57089c357 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/anchor_trust.rs @@ -0,0 +1,2591 @@ +//! Offline-authority state-anchor trust transitions. +//! +//! The activation manifest is static policy, not a self-referential trust +//! floor. A two-stage Ed25519 certificate first authorizes the immutable core, +//! then ratifies the exact successor acknowledgement. The signed transcript is +//! a fixed-width direct concatenation shared byte-for-byte with the Go anchor +//! service. Durable journal and crash-recovery mechanics live in `store`. + +use super::*; + +use crate::api::{AcknowledgeStateWitnessCheckpointRequest, RecoverStateWitnessCheckpointRequest}; +#[cfg(test)] +use crate::api::{RequiredNullableStateAnchorTrustEndpoint, StateWitnessCheckpointRequest}; +use base64ct::{Base64, Encoding}; +use ed25519_dalek::{Signature, VerifyingKey}; +#[cfg(test)] +use ed25519_dalek::{Signer, SigningKey}; + +pub(crate) const STATE_ANCHOR_TRUST_CERTIFICATE_SCHEMA: &str = + "tbtc-frost-native-signer-state-anchor-trust-certificate/v1"; +pub(crate) const STATE_ANCHOR_TRUST_TRANSITION_SCHEMA: &str = + "tbtc-signer-state-anchor-trust-transition/v1"; +pub(crate) const STATE_ANCHOR_TRUST_TRANSITION_RESULT_SCHEMA: &str = + "tbtc-signer-state-anchor-trust-transition-result/v1"; +pub(crate) const STATE_ANCHOR_TRUST_HEAD_SCHEMA: &str = "tbtc-signer-state-anchor-trust-head/v1"; +pub(crate) const STATE_ANCHOR_BOOTSTRAP_FACTS_SCHEMA: &str = + "tbtc-signer-state-anchor-bootstrap-facts/v1"; + +const TRUST_CORE_DOMAIN: &[u8] = + b"tbtc-frost-native-signer-state-anchor-trust-transition-core/v1\0"; +const TRUST_OPERATION_ID_DOMAIN: &[u8] = + b"tbtc-frost-native-signer-state-anchor-trust-transition-operation-id/v1\0"; +const TRUST_TRANSITION_DIGEST_DOMAIN: &[u8] = + b"tbtc-frost-native-signer-state-anchor-trust-transition-digest/v1\0"; +const TRUST_FINAL_DOMAIN: &[u8] = b"tbtc-frost-native-signer-state-anchor-trust-certificate/v1\0"; +const TRUST_CERTIFICATE_DIGEST_DOMAIN: &[u8] = + b"tbtc-frost-native-signer-state-anchor-trust-certificate-digest/v1\0"; +const TRUST_JOURNAL_HEADER_DOMAIN: &[u8] = b"tbtc-signer-state-anchor-trust-journal-header/v1\0"; +const TRUST_JOURNAL_RECORD_DOMAIN: &[u8] = b"tbtc-signer-state-anchor-trust-journal-record/v1\0"; +const TRUST_TRANSITION_INTENT_DOMAIN: &[u8] = + b"tbtc-signer-state-anchor-trust-transition-intent/v1\0"; +const TRUST_JOURNAL_MAGIC: &[u8; 16] = b"TBTCTRUSTJOURN1\0"; +const TRUST_JOURNAL_VERSION: u32 = 1; +pub(crate) const STATE_ANCHOR_TRUST_JOURNAL_HEADER_LENGTH: usize = 84; +const TRUST_JOURNAL_RECORD_PREPARE: u8 = 1; +const TRUST_JOURNAL_RECORD_COMMIT: u8 = 2; +const TRUST_JOURNAL_RECORD_FIXED_LENGTH: usize = 116; +pub(crate) const STATE_ANCHOR_TRUST_MAX_RECORD_LENGTH: usize = 128 * 1024; +pub(crate) const STATE_ANCHOR_TRUST_MAX_CERTIFICATE_JSON_LENGTH: usize = 120 * 1024; +pub(crate) const STATE_ANCHOR_TRUST_MAX_JOURNAL_LENGTH: usize = 256 * 1024 * 1024; +const TRUST_INTENT_MAGIC: &[u8; 16] = b"TBTCTRUSTINTNT1\0"; +const TRUST_INTENT_VERSION: u32 = 1; +const TRUST_INTENT_HEADER_LENGTH: usize = 56; +const TRUST_INTENT_TRAILER_LENGTH: usize = 32; +pub(crate) const STATE_ANCHOR_TRUST_MAX_INTENT_LENGTH: usize = 16 * 1024 * 1024; + +pub(crate) const STATE_ANCHOR_TRUST_MAX_CERTIFICATES_PER_REQUEST: usize = 64; +pub(crate) const STATE_ANCHOR_TRUST_MAX_ACKNOWLEDGEMENT_BYTES: usize = 64 * 1024; +pub(crate) const STATE_ANCHOR_TRUST_MAX_READ_RESPONSE_BYTES: usize = 128 * 1024; +pub(crate) const STATE_ANCHOR_TRUST_MAX_REVISION_DISTANCE: u64 = 4_096; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum StateAnchorTrustCertificateKind { + Bootstrap, + Rotation, +} + +impl StateAnchorTrustCertificateKind { + fn parse(value: &str) -> Result { + match value { + "bootstrap" => Ok(Self::Bootstrap), + "rotation" => Ok(Self::Rotation), + _ => Err(EngineError::Validation( + "state-anchor trust certificate kind must be 'bootstrap' or 'rotation'".to_string(), + )), + } + } + + fn transcript_byte(self) -> u8 { + match self { + Self::Bootstrap => 1, + Self::Rotation => 2, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StateAnchorTrustCheckpointModel { + pub(crate) store_fingerprint: [u8; 32], + pub(crate) generation: u64, + pub(crate) previous_state_commitment: [u8; 32], + pub(crate) state_image_digest: [u8; 32], + pub(crate) state_commitment: [u8; 32], +} + +impl StateAnchorTrustCheckpointModel { + pub(crate) fn from_witness(store_fingerprint: [u8; 32], witness: &StateWitness) -> Self { + Self { + store_fingerprint, + generation: witness.generation, + previous_state_commitment: witness.previous_commitment, + state_image_digest: witness.state_image_digest, + state_commitment: witness.commitment, + } + } + + pub(crate) fn to_wire(&self) -> StateAnchorTrustCheckpoint { + StateAnchorTrustCheckpoint { + store_fingerprint: bytes32_hex(self.store_fingerprint), + generation: self.generation.to_string(), + previous_state_commitment: bytes32_hex(self.previous_state_commitment), + state_image_digest: bytes32_hex(self.state_image_digest), + state_commitment: bytes32_hex(self.state_commitment), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StateAnchorTrustReferenceModel { + pub(crate) service_epoch: u64, + pub(crate) revision: u64, + pub(crate) previous_event_root: [u8; 32], + pub(crate) event_root: [u8; 32], + pub(crate) checkpoint_ack_digest: [u8; 32], + pub(crate) checkpoint: StateAnchorTrustCheckpointModel, +} + +impl StateAnchorTrustReferenceModel { + pub(crate) fn from_acknowledgement(ack: &StateAnchorAcknowledgement) -> Self { + Self { + service_epoch: ack.service_epoch, + revision: ack.revision, + previous_event_root: ack.previous_event_root, + event_root: ack.event_root, + checkpoint_ack_digest: ack.acknowledgement_digest, + checkpoint: StateAnchorTrustCheckpointModel { + store_fingerprint: ack.checkpoint_store_fingerprint, + generation: ack.checkpoint_generation, + previous_state_commitment: ack.checkpoint_previous_commitment, + state_image_digest: ack.checkpoint_state_image_digest, + state_commitment: ack.checkpoint_state_commitment, + }, + } + } + + pub(crate) fn to_wire(&self) -> StateAnchorTrustReference { + StateAnchorTrustReference { + service_epoch: self.service_epoch.to_string(), + revision: self.revision.to_string(), + previous_event_root: bytes32_hex(self.previous_event_root), + event_root: bytes32_hex(self.event_root), + checkpoint_ack_digest: bytes32_hex(self.checkpoint_ack_digest), + checkpoint: self.checkpoint.to_wire(), + } + } + + pub(crate) fn matches_acknowledgement(&self, ack: &StateAnchorAcknowledgement) -> bool { + self == &Self::from_acknowledgement(ack) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StateAnchorTrustEndpointModel { + pub(crate) activation_manifest_hash: [u8; 32], + pub(crate) activation_manifest_sequence: u64, + pub(crate) binding_hash: [u8; 32], + pub(crate) response_public_key: [u8; 32], + pub(crate) response_public_key_spki_sha256: [u8; 32], + pub(crate) offline_authority_public_key: [u8; 32], + pub(crate) offline_authority_spki_sha256: [u8; 32], + pub(crate) witness_maximum_records: u64, + pub(crate) witness_rotation_threshold_records: u64, + pub(crate) reference: StateAnchorTrustReferenceModel, +} + +impl StateAnchorTrustEndpointModel { + pub(crate) fn anchor_configuration(&self) -> Result { + Ok(StateAnchorConfiguration { + binding_hash: self.binding_hash, + response_public_key: self.response_public_key, + response_public_key_spki_sha256: self.response_public_key_spki_sha256, + rotation_threshold_records: usize::try_from(self.witness_rotation_threshold_records) + .map_err(|_| { + EngineError::Validation( + "certificate witnessRotationThresholdRecords does not fit this platform" + .to_string(), + ) + })?, + trust: None, + }) + } +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) struct VerifiedStateAnchorTrustCertificate { + pub(crate) wire: StateAnchorTrustCertificate, + pub(crate) kind: StateAnchorTrustCertificateKind, + pub(crate) certificate_sequence: u64, + pub(crate) previous_certificate_digest: [u8; 32], + pub(crate) protocol_id: [u8; 32], + pub(crate) stream_id: [u8; 32], + pub(crate) signer_store_fingerprint: [u8; 32], + pub(crate) from: Option, + pub(crate) to: StateAnchorTrustEndpointModel, + pub(crate) core_digest: [u8; 32], + pub(crate) core_signature: [u8; 64], + pub(crate) operation_id: [u8; 32], + pub(crate) transition_digest: [u8; 32], + pub(crate) target_acknowledgement_bytes: Vec, + pub(crate) target_acknowledgement_sha256: [u8; 32], + pub(crate) target_acknowledgement: StateAnchorAcknowledgement, + pub(crate) final_signature: [u8; 64], + pub(crate) certificate_digest: [u8; 32], +} + +#[derive(Clone, Debug)] +pub(crate) struct VerifiedStateAnchorTrustTransition { + pub(crate) request: TransitionStateWitnessAnchorRequest, + pub(crate) certificates: Vec, + pub(crate) target_read_acknowledgement_bytes: Vec, + pub(crate) target_read_acknowledgement: StateAnchorAcknowledgement, + pub(crate) target_read_expires_at_unix_ms: u64, +} + +#[derive(Clone, Debug)] +pub(crate) struct StateAnchorTrustJournalModel { + pub(crate) committed: Vec, + pub(crate) pending: Vec, + pub(crate) last_record_commitment: [u8; 32], +} + +impl StateAnchorTrustJournalModel { + pub(crate) fn head(&self) -> Option<&VerifiedStateAnchorTrustCertificate> { + self.committed.last() + } + + pub(crate) fn certified_floors(&self) -> Vec { + self.committed + .iter() + .chain(self.pending.iter()) + .map(|certificate| certificate.to.reference.clone()) + .collect() + } +} + +pub(crate) fn validate_state_anchor_trust_journal_head( + journal: &StateAnchorTrustJournalModel, + configuration: &StateAnchorConfiguration, + store_fingerprint: &[u8; 32], +) -> Result<(), EngineError> { + if !journal.pending.is_empty() { + return Err(EngineError::Internal( + "state-anchor trust journal has PREPARE records without a durable transition intent" + .to_string(), + )); + } + let trust = configuration.trust.as_ref().ok_or_else(|| { + EngineError::Internal( + "state-anchor trust journal exists without complete installed trust pins".to_string(), + ) + })?; + let head = journal.head().ok_or_else(|| { + EngineError::Internal("state-anchor trust journal has no committed certificate".to_string()) + })?; + let to = &head.to; + if head.certificate_sequence != trust.certificate_sequence + || head.certificate_digest != trust.certificate_digest + || head.protocol_id != trust.protocol_id + || head.stream_id != trust.stream_id + || head.signer_store_fingerprint != *store_fingerprint + || to.reference.checkpoint.store_fingerprint != *store_fingerprint + || to.activation_manifest_hash != trust.activation_manifest_hash + || to.activation_manifest_sequence != trust.activation_manifest_sequence + || to.binding_hash != configuration.binding_hash + || to.response_public_key != configuration.response_public_key + || to.response_public_key_spki_sha256 != configuration.response_public_key_spki_sha256 + || to.offline_authority_public_key != trust.offline_authority_public_key + || to.offline_authority_spki_sha256 != trust.offline_authority_public_key_spki_sha256 + || to.witness_maximum_records != state_witness_max_records()? as u64 + || to.witness_rotation_threshold_records != configuration.rotation_threshold_records as u64 + { + return Err(EngineError::Internal( + "durable state-anchor trust head does not exactly match installed config pins" + .to_string(), + )); + } + Ok(()) +} + +/// Authenticates a pre-transition journal under only the pins that are +/// immutable across an authorized service rotation. The installed config +/// already describes the target endpoint at this point, so requiring its +/// rotating manifest, binding, response key, sequence, or digest here would +/// make it impossible to read the prior head and select the missing suffix. +pub(crate) fn validate_state_anchor_trust_journal_stable_pins( + journal: &StateAnchorTrustJournalModel, + target_configuration: &StateAnchorConfiguration, + store_fingerprint: &[u8; 32], +) -> Result<(), EngineError> { + if !journal.pending.is_empty() { + return Err(EngineError::Internal( + "state-anchor trust journal has PREPARE records without recovery intent".to_string(), + )); + } + let target_trust = target_configuration.trust.as_ref().ok_or_else(|| { + EngineError::Internal( + "state-anchor trust-head inspection requires complete installed trust pins".to_string(), + ) + })?; + let head = journal.head().ok_or_else(|| { + EngineError::Internal("state-anchor trust journal has no committed certificate".to_string()) + })?; + if head.protocol_id != target_trust.protocol_id + || head.stream_id != target_trust.stream_id + || head.signer_store_fingerprint != *store_fingerprint + || head.to.reference.checkpoint.store_fingerprint != *store_fingerprint + || head.to.offline_authority_public_key != target_trust.offline_authority_public_key + || head.to.offline_authority_spki_sha256 + != target_trust.offline_authority_public_key_spki_sha256 + || head.to.witness_maximum_records != state_witness_max_records()? as u64 + || head.to.witness_rotation_threshold_records + != target_configuration.rotation_threshold_records as u64 + { + return Err(EngineError::Internal( + "durable state-anchor trust head violates installed immutable trust pins".to_string(), + )); + } + Ok(()) +} + +pub(crate) fn encode_state_anchor_trust_journal_header(store_fingerprint: &[u8; 32]) -> Vec { + let mut bytes = Vec::with_capacity(STATE_ANCHOR_TRUST_JOURNAL_HEADER_LENGTH); + bytes.extend_from_slice(TRUST_JOURNAL_MAGIC); + bytes.extend_from_slice(&TRUST_JOURNAL_VERSION.to_be_bytes()); + bytes.extend_from_slice(store_fingerprint); + let mut digest = Sha256::new(); + digest.update(TRUST_JOURNAL_HEADER_DOMAIN); + digest.update(&bytes); + bytes.extend_from_slice(&<[u8; 32]>::from(digest.finalize())); + debug_assert_eq!(bytes.len(), STATE_ANCHOR_TRUST_JOURNAL_HEADER_LENGTH); + bytes +} + +pub(crate) fn parse_state_anchor_trust_journal( + bytes: &[u8], + expected_store_fingerprint: &[u8; 32], +) -> Result { + if bytes.len() < STATE_ANCHOR_TRUST_JOURNAL_HEADER_LENGTH + || bytes.len() > STATE_ANCHOR_TRUST_MAX_JOURNAL_LENGTH + { + return Err(EngineError::Internal(format!( + "state-anchor trust journal length [{}] is outside its durable bounds", + bytes.len() + ))); + } + if &bytes[..16] != TRUST_JOURNAL_MAGIC + || u32::from_be_bytes( + bytes[16..20] + .try_into() + .expect("fixed trust journal version"), + ) != TRUST_JOURNAL_VERSION + || &bytes[20..52] != expected_store_fingerprint + { + return Err(EngineError::Internal( + "state-anchor trust journal header or store fingerprint is invalid".to_string(), + )); + } + let mut header_digest = Sha256::new(); + header_digest.update(TRUST_JOURNAL_HEADER_DOMAIN); + header_digest.update(&bytes[..52]); + let expected_header_digest: [u8; 32] = header_digest.finalize().into(); + if bytes[52..84] != expected_header_digest { + return Err(EngineError::Internal( + "state-anchor trust journal header commitment is invalid".to_string(), + )); + } + + let mut offset = STATE_ANCHOR_TRUST_JOURNAL_HEADER_LENGTH; + let mut previous_record_commitment = expected_header_digest; + let mut committed: Vec = Vec::new(); + let mut pending: Vec = Vec::new(); + let mut next_commit_index = 0usize; + while offset < bytes.len() { + if bytes.len() - offset < 4 { + return Err(EngineError::Internal( + "state-anchor trust journal has a truncated record length".to_string(), + )); + } + let record_length = u32::from_be_bytes( + bytes[offset..offset + 4] + .try_into() + .expect("fixed record length"), + ) as usize; + if !(TRUST_JOURNAL_RECORD_FIXED_LENGTH..=STATE_ANCHOR_TRUST_MAX_RECORD_LENGTH) + .contains(&record_length) + || offset + .checked_add(record_length) + .is_none_or(|end| end > bytes.len()) + { + return Err(EngineError::Internal( + "state-anchor trust journal record length is invalid or truncated".to_string(), + )); + } + let record = &bytes[offset..offset + record_length]; + let kind = record[4]; + if record[5..8] != [0u8; 3] { + return Err(EngineError::Internal( + "state-anchor trust journal record reserved bytes are nonzero".to_string(), + )); + } + let sequence = u64::from_be_bytes(record[8..16].try_into().expect("fixed record sequence")); + if record[16..48] != previous_record_commitment { + return Err(EngineError::Internal( + "state-anchor trust journal record chain is invalid".to_string(), + )); + } + let payload_length = + u32::from_be_bytes(record[48..52].try_into().expect("fixed payload length")) as usize; + if TRUST_JOURNAL_RECORD_FIXED_LENGTH + .checked_add(payload_length) + .is_none_or(|expected| expected != record_length) + { + return Err(EngineError::Internal( + "state-anchor trust journal payload length is invalid".to_string(), + )); + } + let payload_end = 52 + payload_length; + let payload = &record[52..payload_end]; + let payload_digest: [u8; 32] = record[payload_end..payload_end + 32] + .try_into() + .expect("fixed payload digest"); + if <[u8; 32]>::from(Sha256::digest(payload)) != payload_digest { + return Err(EngineError::Internal( + "state-anchor trust journal payload digest is invalid".to_string(), + )); + } + let record_commitment: [u8; 32] = record[payload_end + 32..payload_end + 64] + .try_into() + .expect("fixed record commitment"); + let expected_record_commitment = state_anchor_trust_record_commitment( + expected_store_fingerprint, + kind, + sequence, + &previous_record_commitment, + &payload_digest, + ); + if record_commitment != expected_record_commitment { + return Err(EngineError::Internal( + "state-anchor trust journal record commitment is invalid".to_string(), + )); + } + + match kind { + TRUST_JOURNAL_RECORD_PREPARE => { + if next_commit_index != 0 { + return Err(EngineError::Internal( + "state-anchor trust journal interleaves PREPARE after COMMIT".to_string(), + )); + } + let wire: StateAnchorTrustCertificate = + serde_json::from_slice(payload).map_err(|error| { + EngineError::Internal(format!( + "state-anchor trust journal certificate is invalid JSON: {error}" + )) + })?; + let certificate = verify_state_anchor_trust_certificate(wire).map_err(|error| { + EngineError::Internal(format!( + "state-anchor trust journal certificate is invalid: {error}" + )) + })?; + let expected_sequence = committed + .last() + .map(|head| head.certificate_sequence) + .unwrap_or(0) + .checked_add(pending.len() as u64) + .and_then(|value| value.checked_add(1)) + .ok_or_else(|| { + EngineError::Internal( + "state-anchor trust journal sequence overflows u64".to_string(), + ) + })?; + if sequence != expected_sequence + || certificate.certificate_sequence != expected_sequence + { + return Err(EngineError::Internal( + "state-anchor trust journal PREPARE sequence is invalid".to_string(), + )); + } + if let Some(previous) = pending.last().or_else(|| committed.last()) { + validate_certificate_link(previous, &certificate).map_err(|error| { + EngineError::Internal(format!( + "state-anchor trust journal PREPARE link is invalid: {error}" + )) + })?; + } else if certificate.certificate_sequence != 1 { + return Err(EngineError::Internal( + "state-anchor trust journal must begin at certificate sequence 1" + .to_string(), + )); + } + pending.push(certificate); + } + TRUST_JOURNAL_RECORD_COMMIT => { + let pending_certificate = pending.get(next_commit_index).ok_or_else(|| { + EngineError::Internal( + "state-anchor trust journal COMMIT has no matching PREPARE".to_string(), + ) + })?; + if sequence != pending_certificate.certificate_sequence + || payload != pending_certificate.certificate_digest + { + return Err(EngineError::Internal( + "state-anchor trust journal COMMIT differs from its PREPARE".to_string(), + )); + } + next_commit_index += 1; + if next_commit_index == pending.len() { + committed.append(&mut pending); + next_commit_index = 0; + } + } + _ => { + return Err(EngineError::Internal( + "state-anchor trust journal record type is invalid".to_string(), + )) + } + } + previous_record_commitment = record_commitment; + offset += record_length; + } + if next_commit_index != 0 { + // A power loss between copy-on-write COMMIT publications leaves a + // complete authenticated prefix. Keep the committed prefix active only + // for transition recovery; the nonempty `pending` tail makes every + // ordinary open fail closed until the durable intent completes it. + let remaining = pending.split_off(next_commit_index); + committed.append(&mut pending); + pending = remaining; + } + Ok(StateAnchorTrustJournalModel { + committed, + pending, + last_record_commitment: previous_record_commitment, + }) +} + +pub(crate) fn encode_state_anchor_trust_prepare_record( + store_fingerprint: &[u8; 32], + previous_record_commitment: &[u8; 32], + certificate: &VerifiedStateAnchorTrustCertificate, +) -> Result, EngineError> { + let payload = serde_json::to_vec(&certificate.wire).map_err(|error| { + EngineError::Internal(format!( + "failed to encode verified state-anchor trust certificate: {error}" + )) + })?; + encode_state_anchor_trust_record( + store_fingerprint, + TRUST_JOURNAL_RECORD_PREPARE, + certificate.certificate_sequence, + previous_record_commitment, + &payload, + ) +} + +pub(crate) fn encode_state_anchor_trust_commit_record( + store_fingerprint: &[u8; 32], + previous_record_commitment: &[u8; 32], + certificate: &VerifiedStateAnchorTrustCertificate, +) -> Result, EngineError> { + encode_state_anchor_trust_record( + store_fingerprint, + TRUST_JOURNAL_RECORD_COMMIT, + certificate.certificate_sequence, + previous_record_commitment, + &certificate.certificate_digest, + ) +} + +fn encode_state_anchor_trust_record( + store_fingerprint: &[u8; 32], + kind: u8, + sequence: u64, + previous_record_commitment: &[u8; 32], + payload: &[u8], +) -> Result, EngineError> { + let record_length = TRUST_JOURNAL_RECORD_FIXED_LENGTH + .checked_add(payload.len()) + .ok_or_else(|| { + EngineError::Internal("state-anchor trust record length overflowed".to_string()) + })?; + if record_length > STATE_ANCHOR_TRUST_MAX_RECORD_LENGTH { + return Err(EngineError::Validation( + "state-anchor trust certificate exceeds the durable record bound".to_string(), + )); + } + let mut record = Vec::with_capacity(record_length); + record.extend_from_slice(&(record_length as u32).to_be_bytes()); + record.push(kind); + record.extend_from_slice(&[0u8; 3]); + record.extend_from_slice(&sequence.to_be_bytes()); + record.extend_from_slice(previous_record_commitment); + record.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + record.extend_from_slice(payload); + let payload_digest: [u8; 32] = Sha256::digest(payload).into(); + record.extend_from_slice(&payload_digest); + record.extend_from_slice(&state_anchor_trust_record_commitment( + store_fingerprint, + kind, + sequence, + previous_record_commitment, + &payload_digest, + )); + debug_assert_eq!(record.len(), record_length); + Ok(record) +} + +fn state_anchor_trust_record_commitment( + store_fingerprint: &[u8; 32], + kind: u8, + sequence: u64, + previous_record_commitment: &[u8; 32], + payload_digest: &[u8; 32], +) -> [u8; 32] { + hash_direct( + TRUST_JOURNAL_RECORD_DOMAIN, + &[ + store_fingerprint, + &[kind], + &sequence.to_be_bytes(), + previous_record_commitment, + payload_digest, + ], + ) +} + +pub(crate) fn encode_state_anchor_trust_transition_intent( + store_fingerprint: &[u8; 32], + request: &TransitionStateWitnessAnchorRequest, +) -> Result, EngineError> { + let payload = serde_json::to_vec(request).map_err(|error| { + EngineError::Internal(format!( + "failed to encode state-anchor trust transition intent: {error}" + )) + })?; + let total_length = TRUST_INTENT_HEADER_LENGTH + .checked_add(payload.len()) + .and_then(|value| value.checked_add(TRUST_INTENT_TRAILER_LENGTH)) + .ok_or_else(|| { + EngineError::Internal("state-anchor trust intent length overflowed".to_string()) + })?; + if total_length > STATE_ANCHOR_TRUST_MAX_INTENT_LENGTH { + return Err(EngineError::Validation( + "state-anchor trust transition intent exceeds its durable bound".to_string(), + )); + } + let mut bytes = Vec::with_capacity(total_length); + bytes.extend_from_slice(TRUST_INTENT_MAGIC); + bytes.extend_from_slice(&TRUST_INTENT_VERSION.to_be_bytes()); + bytes.extend_from_slice(store_fingerprint); + bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + bytes.extend_from_slice(payload.as_slice()); + let mut digest = Sha256::new(); + digest.update(TRUST_TRANSITION_INTENT_DOMAIN); + digest.update(&bytes); + bytes.extend_from_slice(&<[u8; 32]>::from(digest.finalize())); + Ok(bytes) +} + +pub(crate) fn parse_state_anchor_trust_transition_intent( + bytes: &[u8], + expected_store_fingerprint: &[u8; 32], +) -> Result { + if bytes.len() < TRUST_INTENT_HEADER_LENGTH + TRUST_INTENT_TRAILER_LENGTH + || bytes.len() > STATE_ANCHOR_TRUST_MAX_INTENT_LENGTH + { + return Err(EngineError::Internal( + "state-anchor trust transition intent length is invalid".to_string(), + )); + } + if &bytes[..16] != TRUST_INTENT_MAGIC + || u32::from_be_bytes(bytes[16..20].try_into().expect("fixed intent version")) + != TRUST_INTENT_VERSION + || &bytes[20..52] != expected_store_fingerprint + { + return Err(EngineError::Internal( + "state-anchor trust transition intent header is invalid".to_string(), + )); + } + let payload_length = u32::from_be_bytes( + bytes[52..56] + .try_into() + .expect("fixed intent payload length"), + ) as usize; + if TRUST_INTENT_HEADER_LENGTH + .checked_add(payload_length) + .and_then(|value| value.checked_add(TRUST_INTENT_TRAILER_LENGTH)) + != Some(bytes.len()) + { + return Err(EngineError::Internal( + "state-anchor trust transition intent payload length is invalid".to_string(), + )); + } + let commitment_offset = bytes.len() - TRUST_INTENT_TRAILER_LENGTH; + let mut digest = Sha256::new(); + digest.update(TRUST_TRANSITION_INTENT_DOMAIN); + digest.update(&bytes[..commitment_offset]); + if bytes[commitment_offset..] != <[u8; 32]>::from(digest.finalize()) { + return Err(EngineError::Internal( + "state-anchor trust transition intent commitment is invalid".to_string(), + )); + } + serde_json::from_slice(&bytes[TRUST_INTENT_HEADER_LENGTH..commitment_offset]).map_err(|error| { + EngineError::Internal(format!( + "state-anchor trust transition intent request is invalid: {error}" + )) + }) +} + +pub(crate) fn verify_state_anchor_trust_certificate( + wire: StateAnchorTrustCertificate, +) -> Result { + let canonical_length = serde_json::to_vec(&wire) + .map_err(|error| { + EngineError::Validation(format!( + "state-anchor trust certificate cannot be canonically encoded: {error}" + )) + })? + .len(); + if canonical_length > STATE_ANCHOR_TRUST_MAX_CERTIFICATE_JSON_LENGTH { + return Err(EngineError::Validation(format!( + "canonical state-anchor trust certificate exceeds {} bytes", + STATE_ANCHOR_TRUST_MAX_CERTIFICATE_JSON_LENGTH + ))); + } + if wire.schema != STATE_ANCHOR_TRUST_CERTIFICATE_SCHEMA { + return Err(EngineError::Validation(format!( + "state-anchor trust certificate schema must be [{STATE_ANCHOR_TRUST_CERTIFICATE_SCHEMA}]" + ))); + } + + let kind = StateAnchorTrustCertificateKind::parse(&wire.kind)?; + let certificate_sequence = + parse_canonical_u64(&wire.certificate_sequence, "certificateSequence")?; + if certificate_sequence == 0 { + return Err(EngineError::Validation( + "certificateSequence must be nonzero".to_string(), + )); + } + let previous_certificate_digest = parse_canonical_bytes32( + &wire.previous_certificate_digest, + "previousCertificateDigest", + )?; + let protocol_id = parse_nonzero_bytes32(&wire.protocol_id, "protocolID")?; + let stream_id = parse_nonzero_bytes32(&wire.stream_id, "streamID")?; + let signer_store_fingerprint = + parse_nonzero_bytes32(&wire.signer_store_fingerprint, "signerStoreFingerprint")?; + let from = wire + .from + .0 + .as_ref() + .map(|endpoint| parse_endpoint(endpoint, "from")) + .transpose()?; + let to = parse_endpoint(&wire.to, "to")?; + if to.reference.checkpoint.store_fingerprint != signer_store_fingerprint + || from.as_ref().is_some_and(|endpoint| { + endpoint.reference.checkpoint.store_fingerprint != signer_store_fingerprint + }) + { + return Err(EngineError::Validation( + "state-anchor trust certificate endpoint checkpoints must target \ + signerStoreFingerprint" + .to_string(), + )); + } + + match (kind, from.as_ref()) { + (StateAnchorTrustCertificateKind::Bootstrap, None) => { + if certificate_sequence != 1 || previous_certificate_digest != [0u8; 32] { + return Err(EngineError::Validation( + "bootstrap certificate must use sequence 1 and a zero previous digest" + .to_string(), + )); + } + if to.reference.service_epoch != 1 + || to.reference.revision != 1 + || to.reference.previous_event_root != [0u8; 32] + { + return Err(EngineError::Validation( + "bootstrap target must be epoch 1 revision 1 with a zero previous event root" + .to_string(), + )); + } + } + (StateAnchorTrustCertificateKind::Rotation, Some(from)) => { + if certificate_sequence == 1 { + if previous_certificate_digest != [0u8; 32] { + return Err(EngineError::Validation( + "sequence-1 legacy adoption must use a zero previous certificate digest" + .to_string(), + )); + } + } else if previous_certificate_digest == [0u8; 32] { + return Err(EngineError::Validation( + "rotation certificate after sequence 1 must chain a nonzero previous digest" + .to_string(), + )); + } + validate_rotation_endpoints(from, &to)?; + } + (StateAnchorTrustCertificateKind::Bootstrap, Some(_)) => { + return Err(EngineError::Validation( + "bootstrap certificate from must be explicit null".to_string(), + )) + } + (StateAnchorTrustCertificateKind::Rotation, None) => { + return Err(EngineError::Validation( + "rotation certificate requires a non-null from endpoint".to_string(), + )) + } + } + + let core_digest = trust_certificate_core_digest( + kind, + certificate_sequence, + &previous_certificate_digest, + &protocol_id, + &stream_id, + &signer_store_fingerprint, + from.as_ref(), + &to, + ); + require_declared_digest(&wire.core_digest, "coreDigest", &core_digest)?; + let core_signature = parse_canonical_base64_signature(&wire.core_signature, "coreSignature")?; + let authority_public_key = from + .as_ref() + .map(|endpoint| endpoint.offline_authority_public_key) + .unwrap_or(to.offline_authority_public_key); + verify_ed25519_signature( + &authority_public_key, + &core_digest, + &core_signature, + "state-anchor trust core signature", + )?; + + let operation_id = hash_direct(TRUST_OPERATION_ID_DOMAIN, &[&core_digest]); + require_declared_digest(&wire.operation_id, "operationID", &operation_id)?; + if operation_id == [0u8; 32] { + return Err(EngineError::Validation( + "derived state-anchor trust operationID is zero".to_string(), + )); + } + let transition_digest = hash_direct( + TRUST_TRANSITION_DIGEST_DOMAIN, + &[&core_digest, &operation_id], + ); + require_declared_digest( + &wire.transition_digest, + "transitionDigest", + &transition_digest, + )?; + if transition_digest == [0u8; 32] { + return Err(EngineError::Validation( + "derived state-anchor trust transitionDigest is zero".to_string(), + )); + } + + let target_acknowledgement_bytes = decode_canonical_base64( + &wire.target_acknowledgement_base64, + STATE_ANCHOR_TRUST_MAX_ACKNOWLEDGEMENT_BYTES, + "targetAcknowledgementBase64", + )?; + std::str::from_utf8(&target_acknowledgement_bytes).map_err(|_| { + EngineError::Validation( + "targetAcknowledgementBase64 must decode to UTF-8 JSON bytes".to_string(), + ) + })?; + let target_acknowledgement_sha256: [u8; 32] = + Sha256::digest(&target_acknowledgement_bytes).into(); + require_declared_digest( + &wire.target_acknowledgement_sha256, + "targetAcknowledgementSHA256", + &target_acknowledgement_sha256, + )?; + let target_ack_wire: AcknowledgeStateWitnessCheckpointRequest = + serde_json::from_slice(&target_acknowledgement_bytes).map_err(|error| { + EngineError::Validation(format!( + "targetAcknowledgementBase64 contains invalid acknowledgement JSON: {error}" + )) + })?; + let target_configuration = to.anchor_configuration()?; + let target_acknowledgement = validate_certified_transition_acknowledgement( + target_ack_wire, + &target_configuration, + to.reference.previous_event_root, + )?; + if target_acknowledgement.status != 1 + || target_acknowledgement.operation_id != operation_id + || target_acknowledgement.transition_digest != transition_digest + || !to + .reference + .matches_acknowledgement(&target_acknowledgement) + { + return Err(EngineError::Validation( + "certified target acknowledgement differs from the exact target reference".to_string(), + )); + } + + let final_digest = trust_certificate_final_digest( + &core_digest, + &core_signature, + &operation_id, + &transition_digest, + &to.reference, + &target_acknowledgement_sha256, + ); + let final_signature = + parse_canonical_base64_signature(&wire.final_signature, "finalSignature")?; + verify_ed25519_signature( + &authority_public_key, + &final_digest, + &final_signature, + "state-anchor trust final signature", + )?; + let certificate_digest = hash_direct( + TRUST_CERTIFICATE_DIGEST_DOMAIN, + &[ + &final_digest, + &final_signature, + &to.offline_authority_spki_sha256, + ], + ); + require_declared_digest( + &wire.certificate_digest, + "certificateDigest", + &certificate_digest, + )?; + if certificate_digest == [0u8; 32] { + return Err(EngineError::Validation( + "derived state-anchor trust certificateDigest is zero".to_string(), + )); + } + + Ok(VerifiedStateAnchorTrustCertificate { + wire, + kind, + certificate_sequence, + previous_certificate_digest, + protocol_id, + stream_id, + signer_store_fingerprint, + from, + to, + core_digest, + core_signature, + operation_id, + transition_digest, + target_acknowledgement_bytes, + target_acknowledgement_sha256, + target_acknowledgement, + final_signature, + certificate_digest, + }) +} + +pub(crate) fn verify_state_anchor_trust_transition_request( + request: TransitionStateWitnessAnchorRequest, + require_fresh_read: bool, +) -> Result { + let canonical_request_length = serde_json::to_vec(&request) + .map_err(|error| { + EngineError::Validation(format!( + "state-anchor trust transition cannot be canonically encoded: {error}" + )) + })? + .len(); + if canonical_request_length + .checked_add(TRUST_INTENT_HEADER_LENGTH + TRUST_INTENT_TRAILER_LENGTH) + .is_none_or(|intent_length| intent_length > STATE_ANCHOR_TRUST_MAX_INTENT_LENGTH) + { + return Err(EngineError::Validation( + "state-anchor trust transition exceeds the 16 MiB durable request bound".to_string(), + )); + } + if request.schema != STATE_ANCHOR_TRUST_TRANSITION_SCHEMA { + return Err(EngineError::Validation(format!( + "state-anchor trust transition schema must be [{STATE_ANCHOR_TRUST_TRANSITION_SCHEMA}]" + ))); + } + if request.certificate_chain.is_empty() + || request.certificate_chain.len() > STATE_ANCHOR_TRUST_MAX_CERTIFICATES_PER_REQUEST + { + return Err(EngineError::Validation(format!( + "certificateChain must contain between 1 and {} certificates", + STATE_ANCHOR_TRUST_MAX_CERTIFICATES_PER_REQUEST + ))); + } + let mut certificates = Vec::with_capacity(request.certificate_chain.len()); + for wire in request.certificate_chain.iter().cloned() { + let certificate = verify_state_anchor_trust_certificate(wire)?; + if let Some(previous) = certificates.last() { + validate_certificate_link(previous, &certificate)?; + } + certificates.push(certificate); + } + let final_certificate = certificates + .last() + .expect("nonempty certificate chain checked above"); + validate_transition_chain_against_installed_config(&certificates)?; + + let target_read_response_bytes = decode_canonical_base64( + &request.target_read_response_base64, + STATE_ANCHOR_TRUST_MAX_READ_RESPONSE_BYTES, + "targetReadResponseBase64", + )?; + std::str::from_utf8(&target_read_response_bytes).map_err(|_| { + EngineError::Validation( + "targetReadResponseBase64 must decode to UTF-8 JSON bytes".to_string(), + ) + })?; + let read_wire: RecoverStateWitnessCheckpointRequest = + serde_json::from_slice(&target_read_response_bytes).map_err(|error| { + EngineError::Validation(format!( + "targetReadResponseBase64 contains invalid read-response JSON: {error}" + )) + })?; + let final_configuration = final_certificate.to.anchor_configuration()?; + let ( + target_read_acknowledgement, + target_read_expires_at_unix_ms, + target_read_acknowledgement_bytes, + ) = validate_certified_transition_read_response( + read_wire, + &final_configuration, + final_certificate.to.reference.previous_event_root, + require_fresh_read, + )?; + if target_read_acknowledgement.binding_hash != final_certificate.to.binding_hash + || target_read_acknowledgement.service_epoch != final_certificate.to.reference.service_epoch + || target_read_acknowledgement.revision < final_certificate.to.reference.revision + { + return Err(EngineError::Validation( + "final state-anchor Read is outside the certified target epoch".to_string(), + )); + } + + Ok(VerifiedStateAnchorTrustTransition { + request, + certificates, + target_read_acknowledgement_bytes, + target_read_acknowledgement, + target_read_expires_at_unix_ms, + }) +} + +fn parse_endpoint( + wire: &StateAnchorTrustEndpoint, + label: &str, +) -> Result { + let activation_manifest_hash = parse_nonzero_bytes32( + &wire.activation_manifest_hash, + &format!("{label}.activationManifestHash"), + )?; + let activation_manifest_sequence = parse_nonzero_u64( + &wire.activation_manifest_sequence, + &format!("{label}.activationManifestSequence"), + )?; + let binding_hash = parse_nonzero_bytes32(&wire.binding_hash, &format!("{label}.bindingHash"))?; + let response_public_key = parse_nonzero_bytes32( + &wire.response_public_key, + &format!("{label}.responsePublicKey"), + )?; + validate_strong_ed25519_verifying_key( + &response_public_key, + &format!("{label}.responsePublicKey"), + )?; + let response_public_key_spki_sha256 = parse_nonzero_bytes32( + &wire.response_public_key_spki_sha256, + &format!("{label}.responsePublicKeySpkiSha256"), + )?; + if ed25519_spki_sha256(&response_public_key) != response_public_key_spki_sha256 { + return Err(EngineError::Validation(format!( + "{label}.responsePublicKeySpkiSha256 does not match its raw Ed25519 key" + ))); + } + let offline_authority_public_key = parse_nonzero_bytes32( + &wire.offline_authority_public_key, + &format!("{label}.offlineAuthorityPublicKey"), + )?; + validate_strong_ed25519_verifying_key( + &offline_authority_public_key, + &format!("{label}.offlineAuthorityPublicKey"), + )?; + let offline_authority_spki_sha256 = parse_nonzero_bytes32( + &wire.offline_authority_spki_sha256, + &format!("{label}.offlineAuthoritySpkiSha256"), + )?; + if ed25519_spki_sha256(&offline_authority_public_key) != offline_authority_spki_sha256 { + return Err(EngineError::Validation(format!( + "{label}.offlineAuthoritySpkiSha256 does not match its raw Ed25519 key" + ))); + } + if response_public_key == offline_authority_public_key { + return Err(EngineError::Validation(format!( + "{label} online and offline Ed25519 keys must be role-distinct" + ))); + } + let witness_maximum_records = parse_nonzero_u64( + &wire.witness_maximum_records, + &format!("{label}.witnessMaximumRecords"), + )?; + if witness_maximum_records > TBTC_SIGNER_HARD_MAX_STATE_WITNESS_MAX_RECORDS as u64 { + return Err(EngineError::Validation(format!( + "{label}.witnessMaximumRecords exceeds the signer hard maximum" + ))); + } + let witness_rotation_threshold_records = parse_nonzero_u64( + &wire.witness_rotation_threshold_records, + &format!("{label}.witnessRotationThresholdRecords"), + )?; + // Same geometry rule the local configuration enforces: six terminal + // records for an interrupted multi-snapshot retry plus the two-record + // quarantine reserve that keeps corruption recovery available once the + // journal parks at the terminal band. A certified endpoint that omitted + // the reserve would pin a store with no supported exit from a corrupt + // state image. + if witness_rotation_threshold_records < 2 + || witness_rotation_threshold_records + .checked_add(TBTC_SIGNER_STATE_WITNESS_ROTATION_TERMINAL_RECORD_RESERVATION as u64) + .and_then(|reserved| { + reserved.checked_add(TBTC_SIGNER_STATE_WITNESS_QUARANTINE_RECORD_RESERVATION as u64) + }) + .is_none_or(|reserved| reserved > witness_maximum_records) + { + return Err(EngineError::Validation(format!( + "{label}.witnessRotationThresholdRecords must be at least 2 and reserve six terminal \ + records and the quarantine pair" + ))); + } + let reference = parse_reference(&wire.reference, &format!("{label}.reference"))?; + Ok(StateAnchorTrustEndpointModel { + activation_manifest_hash, + activation_manifest_sequence, + binding_hash, + response_public_key, + response_public_key_spki_sha256, + offline_authority_public_key, + offline_authority_spki_sha256, + witness_maximum_records, + witness_rotation_threshold_records, + reference, + }) +} + +fn parse_reference( + wire: &StateAnchorTrustReference, + label: &str, +) -> Result { + let service_epoch = parse_nonzero_u64(&wire.service_epoch, &format!("{label}.serviceEpoch"))?; + let revision = parse_nonzero_u64(&wire.revision, &format!("{label}.revision"))?; + let previous_event_root = parse_canonical_bytes32( + &wire.previous_event_root, + &format!("{label}.previousEventRoot"), + )?; + let event_root = parse_nonzero_bytes32(&wire.event_root, &format!("{label}.eventRoot"))?; + let checkpoint_ack_digest = parse_nonzero_bytes32( + &wire.checkpoint_ack_digest, + &format!("{label}.checkpointAckDigest"), + )?; + Ok(StateAnchorTrustReferenceModel { + service_epoch, + revision, + previous_event_root, + event_root, + checkpoint_ack_digest, + checkpoint: parse_checkpoint(&wire.checkpoint, &format!("{label}.checkpoint"))?, + }) +} + +fn parse_checkpoint( + wire: &StateAnchorTrustCheckpoint, + label: &str, +) -> Result { + let store_fingerprint = parse_nonzero_bytes32( + &wire.store_fingerprint, + &format!("{label}.storeFingerprint"), + )?; + let generation = parse_nonzero_u64(&wire.generation, &format!("{label}.generation"))?; + let previous_state_commitment = parse_nonzero_bytes32( + &wire.previous_state_commitment, + &format!("{label}.previousStateCommitment"), + )?; + let state_image_digest = parse_nonzero_bytes32( + &wire.state_image_digest, + &format!("{label}.stateImageDigest"), + )?; + let parsed_state_commitment = + parse_nonzero_bytes32(&wire.state_commitment, &format!("{label}.stateCommitment"))?; + if parsed_state_commitment + != state_commitment( + &store_fingerprint, + generation, + &previous_state_commitment, + &state_image_digest, + ) + { + return Err(EngineError::Validation(format!( + "{label}.stateCommitment is invalid" + ))); + } + Ok(StateAnchorTrustCheckpointModel { + store_fingerprint, + generation, + previous_state_commitment, + state_image_digest, + state_commitment: parsed_state_commitment, + }) +} + +fn validate_rotation_endpoints( + from: &StateAnchorTrustEndpointModel, + to: &StateAnchorTrustEndpointModel, +) -> Result<(), EngineError> { + if to.activation_manifest_hash == from.activation_manifest_hash + || to.binding_hash == from.binding_hash + { + return Err(EngineError::Validation( + "rotation must change both activationManifestHash and bindingHash".to_string(), + )); + } + if to.activation_manifest_sequence + != from + .activation_manifest_sequence + .checked_add(1) + .ok_or_else(|| { + EngineError::Validation( + "rotation activation manifest sequence overflows u64".to_string(), + ) + })? + { + return Err(EngineError::Validation( + "rotation activationManifestSequence must advance by exactly one".to_string(), + )); + } + if to.reference.service_epoch + != from.reference.service_epoch.checked_add(1).ok_or_else(|| { + EngineError::Validation("rotation service epoch overflows u64".to_string()) + })? + || to.reference.revision != 1 + || to.reference.previous_event_root != from.reference.event_root + { + return Err(EngineError::Validation( + "rotation target must be the linked revision-1 genesis of the next epoch".to_string(), + )); + } + if to.reference.checkpoint != from.reference.checkpoint { + return Err(EngineError::Validation( + "rotation target checkpoint must exactly equal its predecessor".to_string(), + )); + } + if from.offline_authority_public_key != to.offline_authority_public_key + || from.offline_authority_spki_sha256 != to.offline_authority_spki_sha256 + { + return Err(EngineError::Validation( + "offline state-anchor authority rotation is unsupported".to_string(), + )); + } + if from.witness_maximum_records != to.witness_maximum_records + || from.witness_rotation_threshold_records != to.witness_rotation_threshold_records + { + return Err(EngineError::Validation( + "state-anchor trust rotation cannot change witness geometry".to_string(), + )); + } + Ok(()) +} + +fn validate_certificate_link( + previous: &VerifiedStateAnchorTrustCertificate, + next: &VerifiedStateAnchorTrustCertificate, +) -> Result<(), EngineError> { + let expected_sequence = previous + .certificate_sequence + .checked_add(1) + .ok_or_else(|| { + EngineError::Validation("state-anchor certificate sequence overflows u64".to_string()) + })?; + let next_from = next.from.as_ref().ok_or_else(|| { + EngineError::Validation( + "certificateChain successor must be a rotation with a from endpoint".to_string(), + ) + })?; + if next.kind != StateAnchorTrustCertificateKind::Rotation + || next.certificate_sequence != expected_sequence + || next.previous_certificate_digest != previous.certificate_digest + || next.protocol_id != previous.protocol_id + || next.stream_id != previous.stream_id + || next.signer_store_fingerprint != previous.signer_store_fingerprint + || !state_anchor_trust_endpoint_static_identity_eq(next_from, &previous.to) + { + return Err(EngineError::Validation( + "certificateChain is not an exact contiguous trust transition".to_string(), + )); + } + validate_state_anchor_trust_reference_descendant( + &previous.to.reference, + &next_from.reference, + "certificateChain successor from.reference", + )?; + Ok(()) +} + +pub(crate) fn state_anchor_trust_endpoint_static_identity_eq( + left: &StateAnchorTrustEndpointModel, + right: &StateAnchorTrustEndpointModel, +) -> bool { + left.activation_manifest_hash == right.activation_manifest_hash + && left.activation_manifest_sequence == right.activation_manifest_sequence + && left.binding_hash == right.binding_hash + && left.response_public_key == right.response_public_key + && left.response_public_key_spki_sha256 == right.response_public_key_spki_sha256 + && left.offline_authority_public_key == right.offline_authority_public_key + && left.offline_authority_spki_sha256 == right.offline_authority_spki_sha256 + && left.witness_maximum_records == right.witness_maximum_records + && left.witness_rotation_threshold_records == right.witness_rotation_threshold_records +} + +pub(crate) fn validate_state_anchor_trust_reference_descendant( + floor: &StateAnchorTrustReferenceModel, + candidate: &StateAnchorTrustReferenceModel, + label: &str, +) -> Result<(), EngineError> { + 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" + ))); + } + if candidate.revision - floor.revision > STATE_ANCHOR_TRUST_MAX_REVISION_DISTANCE { + return Err(EngineError::Validation(format!( + "{label} exceeds the certified floor by more than \ + {STATE_ANCHOR_TRUST_MAX_REVISION_DISTANCE} revisions" + ))); + } + if candidate.revision == floor.revision { + if candidate != floor { + return Err(EngineError::Validation(format!( + "{label} equivocates at the certified floor revision" + ))); + } + return Ok(()); + } + if candidate.previous_event_root == [0u8; 32] + || candidate.checkpoint.generation < floor.checkpoint.generation + || candidate.checkpoint.store_fingerprint != floor.checkpoint.store_fingerprint + { + return Err(EngineError::Validation(format!( + "{label} is not a monotonic same-epoch descendant of its certified floor" + ))); + } + if candidate.checkpoint.generation == floor.checkpoint.generation + && candidate.checkpoint != floor.checkpoint + { + return Err(EngineError::Validation(format!( + "{label} changes a checkpoint without advancing its generation" + ))); + } + Ok(()) +} + +fn validate_transition_chain_against_installed_config( + certificates: &[VerifiedStateAnchorTrustCertificate], +) -> Result<(), EngineError> { + let configured = configured_state_anchor()?.ok_or_else(|| { + EngineError::Validation( + "state-anchor trust transition requires configured anchor pins".to_string(), + ) + })?; + let trust = configured.trust.as_ref().ok_or_else(|| { + EngineError::Validation( + "state-anchor trust transition requires complete trust-head pins".to_string(), + ) + })?; + let final_certificate = certificates + .last() + .expect("caller guarantees a nonempty chain"); + let to = &final_certificate.to; + if final_certificate.protocol_id != trust.protocol_id + || final_certificate.stream_id != trust.stream_id + || final_certificate.certificate_sequence != trust.certificate_sequence + || final_certificate.certificate_digest != trust.certificate_digest + || to.activation_manifest_hash != trust.activation_manifest_hash + || to.activation_manifest_sequence != trust.activation_manifest_sequence + || to.binding_hash != configured.binding_hash + || to.response_public_key != configured.response_public_key + || to.response_public_key_spki_sha256 != configured.response_public_key_spki_sha256 + || to.offline_authority_public_key != trust.offline_authority_public_key + || to.offline_authority_spki_sha256 != trust.offline_authority_public_key_spki_sha256 + || to.witness_maximum_records != state_witness_max_records()? as u64 + || to.witness_rotation_threshold_records != configured.rotation_threshold_records as u64 + { + return Err(EngineError::Validation( + "final state-anchor trust certificate does not exactly match installed config pins" + .to_string(), + )); + } + for certificate in certificates { + if certificate.protocol_id != trust.protocol_id + || certificate.stream_id != trust.stream_id + || certificate.to.offline_authority_public_key != trust.offline_authority_public_key + || certificate.to.offline_authority_spki_sha256 + != trust.offline_authority_public_key_spki_sha256 + { + return Err(EngineError::Validation( + "certificateChain changes a pinned protocol, stream, or offline authority" + .to_string(), + )); + } + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn trust_certificate_core_digest( + kind: StateAnchorTrustCertificateKind, + certificate_sequence: u64, + previous_certificate_digest: &[u8; 32], + protocol_id: &[u8; 32], + stream_id: &[u8; 32], + signer_store_fingerprint: &[u8; 32], + from: Option<&StateAnchorTrustEndpointModel>, + to: &StateAnchorTrustEndpointModel, +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(TRUST_CORE_DOMAIN); + digest.update([kind.transcript_byte()]); + digest.update(certificate_sequence.to_be_bytes()); + digest.update(previous_certificate_digest); + digest.update(protocol_id); + digest.update(stream_id); + digest.update(signer_store_fingerprint); + update_core_from_endpoint(&mut digest, from); + update_core_to_endpoint(&mut digest, to); + digest.finalize().into() +} + +fn update_core_from_endpoint( + digest: &mut Sha256, + endpoint: Option<&StateAnchorTrustEndpointModel>, +) { + let Some(endpoint) = endpoint else { + // Frozen bootstrap transcript: every fixed-width FROM slot is zero. + digest.update([0u8; 32]); // manifest hash + digest.update([0u8; 8]); // manifest sequence + digest.update([0u8; 32]); // binding + digest.update([0u8; 32]); // online raw + digest.update([0u8; 32]); // online SPKI hash + digest.update([0u8; 32]); // authority raw + digest.update([0u8; 32]); // authority SPKI hash + digest.update([0u8; 8]); // max records + digest.update([0u8; 8]); // rotation threshold + digest.update([0u8; 8]); // epoch + digest.update([0u8; 8]); // revision + digest.update([0u8; 32]); // previous event root + digest.update([0u8; 32]); // event root + digest.update([0u8; 32]); // acknowledgement digest + digest.update([0u8; 32]); // checkpoint store + digest.update([0u8; 8]); // checkpoint generation + digest.update([0u8; 32]); // checkpoint previous commitment + digest.update([0u8; 32]); // checkpoint image + digest.update([0u8; 32]); // checkpoint commitment + return; + }; + update_endpoint_identity_geometry(digest, endpoint); + let reference = &endpoint.reference; + digest.update(reference.service_epoch.to_be_bytes()); + digest.update(reference.revision.to_be_bytes()); + digest.update(reference.previous_event_root); + digest.update(reference.event_root); + digest.update(reference.checkpoint_ack_digest); + update_checkpoint(digest, &reference.checkpoint); +} + +fn update_core_to_endpoint(digest: &mut Sha256, endpoint: &StateAnchorTrustEndpointModel) { + update_endpoint_identity_geometry(digest, endpoint); + digest.update(endpoint.reference.service_epoch.to_be_bytes()); + update_checkpoint(digest, &endpoint.reference.checkpoint); +} + +fn update_endpoint_identity_geometry( + digest: &mut Sha256, + endpoint: &StateAnchorTrustEndpointModel, +) { + digest.update(endpoint.activation_manifest_hash); + digest.update(endpoint.activation_manifest_sequence.to_be_bytes()); + digest.update(endpoint.binding_hash); + digest.update(endpoint.response_public_key); + digest.update(endpoint.response_public_key_spki_sha256); + digest.update(endpoint.offline_authority_public_key); + digest.update(endpoint.offline_authority_spki_sha256); + digest.update(endpoint.witness_maximum_records.to_be_bytes()); + digest.update(endpoint.witness_rotation_threshold_records.to_be_bytes()); +} + +fn update_checkpoint(digest: &mut Sha256, checkpoint: &StateAnchorTrustCheckpointModel) { + digest.update(checkpoint.store_fingerprint); + digest.update(checkpoint.generation.to_be_bytes()); + digest.update(checkpoint.previous_state_commitment); + digest.update(checkpoint.state_image_digest); + digest.update(checkpoint.state_commitment); +} + +fn trust_certificate_final_digest( + core_digest: &[u8; 32], + core_signature: &[u8; 64], + operation_id: &[u8; 32], + transition_digest: &[u8; 32], + to: &StateAnchorTrustReferenceModel, + target_acknowledgement_sha256: &[u8; 32], +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(TRUST_FINAL_DOMAIN); + digest.update(core_digest); + digest.update(core_signature); + digest.update(operation_id); + digest.update(transition_digest); + digest.update(to.service_epoch.to_be_bytes()); + digest.update(to.revision.to_be_bytes()); + digest.update(to.previous_event_root); + digest.update(to.event_root); + digest.update(to.checkpoint_ack_digest); + update_checkpoint(&mut digest, &to.checkpoint); + digest.update(target_acknowledgement_sha256); + digest.finalize().into() +} + +fn decode_canonical_base64( + encoded: &str, + maximum_decoded_length: usize, + label: &str, +) -> Result, EngineError> { + // A padded RFC 4648 string cannot decode to more than 3/4 its bytes. Check + // before allocating, then require exact re-encoding to reject whitespace, + // unpadded aliases, and noncanonical trailing bits. + let maximum_encoded_length = maximum_decoded_length + .checked_add(2) + .and_then(|value| value.checked_div(3)) + .and_then(|value| value.checked_mul(4)) + .ok_or_else(|| EngineError::Internal("Base64 length bound overflowed".to_string()))?; + if encoded.len() > maximum_encoded_length { + return Err(EngineError::Validation(format!( + "{label} exceeds the maximum decoded length" + ))); + } + let decoded = Base64::decode_vec(encoded).map_err(|_| { + EngineError::Validation(format!( + "{label} must be strict canonical padded RFC 4648 Base64" + )) + })?; + if decoded.len() > maximum_decoded_length || Base64::encode_string(&decoded) != encoded { + return Err(EngineError::Validation(format!( + "{label} must be strict canonical padded RFC 4648 Base64" + ))); + } + Ok(decoded) +} + +fn parse_canonical_base64_signature(encoded: &str, label: &str) -> Result<[u8; 64], EngineError> { + let decoded = decode_canonical_base64(encoded, 64, label)?; + decoded.try_into().map_err(|_| { + EngineError::Validation(format!( + "{label} must be canonical padded Base64 encoding exactly 64 bytes" + )) + }) +} + +fn parse_nonzero_bytes32(value: &str, label: &str) -> Result<[u8; 32], EngineError> { + let parsed = parse_canonical_bytes32(value, label)?; + if parsed == [0u8; 32] { + return Err(EngineError::Validation(format!("{label} must be nonzero"))); + } + Ok(parsed) +} + +fn parse_nonzero_u64(value: &str, label: &str) -> Result { + let parsed = parse_canonical_u64(value, label)?; + if parsed == 0 { + return Err(EngineError::Validation(format!("{label} must be nonzero"))); + } + Ok(parsed) +} + +fn require_declared_digest( + wire: &str, + label: &str, + expected: &[u8; 32], +) -> Result<(), EngineError> { + if parse_canonical_bytes32(wire, label)? != *expected { + return Err(EngineError::Validation(format!( + "{label} does not match the frozen transcript" + ))); + } + Ok(()) +} + +fn verify_ed25519_signature( + public_key: &[u8; 32], + digest: &[u8; 32], + signature: &[u8; 64], + label: &str, +) -> Result<(), EngineError> { + let verifying_key = VerifyingKey::from_bytes(public_key).map_err(|error| { + EngineError::Validation(format!("{label} public key is invalid: {error}")) + })?; + verifying_key + .verify_strict(digest, &Signature::from_bytes(signature)) + .map_err(|_| EngineError::Validation(format!("{label} is invalid"))) +} + +fn hash_direct(domain: &[u8], fields: &[&[u8]]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(domain); + for field in fields { + digest.update(field); + } + digest.finalize().into() +} + +pub(crate) fn state_anchor_trust_head_result( + sequence: u64, + digest: [u8; 32], + endpoint: &StateAnchorTrustEndpointModel, +) -> StateAnchorTrustHeadResult { + StateAnchorTrustHeadResult { + schema: STATE_ANCHOR_TRUST_HEAD_SCHEMA.to_string(), + certificate_sequence: sequence.to_string(), + certificate_digest: bytes32_hex(digest), + activation_manifest_sequence: endpoint.activation_manifest_sequence.to_string(), + activation_manifest_hash: bytes32_hex(endpoint.activation_manifest_hash), + binding_hash: bytes32_hex(endpoint.binding_hash), + response_public_key_spki_sha256: bytes32_hex(endpoint.response_public_key_spki_sha256), + offline_authority_spki_sha256: bytes32_hex(endpoint.offline_authority_spki_sha256), + service_epoch: endpoint.reference.service_epoch.to_string(), + certified_floor: endpoint.reference.to_wire(), + witness_maximum_records: endpoint.witness_maximum_records.to_string(), + witness_rotation_threshold_records: endpoint.witness_rotation_threshold_records.to_string(), + } +} + +fn transition_state_witness_anchor_result( + outcome: StateAnchorTrustTransitionStoreOutcome, +) -> TransitionStateWitnessAnchorResult { + let store_fingerprint = outcome.trust_head.signer_store_fingerprint; + let trust_head = state_anchor_trust_head_result( + outcome.trust_head.certificate_sequence, + outcome.trust_head.certificate_digest, + &outcome.trust_head.to, + ); + + TransitionStateWitnessAnchorResult { + schema: STATE_ANCHOR_TRUST_TRANSITION_RESULT_SCHEMA.to_string(), + installed: true, + idempotent: outcome.idempotent, + applied_certificate_count: outcome.applied_certificate_count.to_string(), + trust_head, + current_checkpoint: StateAnchorTrustCheckpointModel::from_witness( + store_fingerprint, + &outcome.tip, + ) + .to_wire(), + witness_base_checkpoint: StateAnchorTrustCheckpointModel::from_witness( + store_fingerprint, + &outcome.base, + ) + .to_wire(), + current_anchor_reference: StateAnchorTrustReferenceModel::from_acknowledgement( + &outcome.anchor.latest, + ) + .to_wire(), + } +} + +/// Verifies a fresh, offline-certified trust-transition request before opening +/// the durable store, then executes the crash-safe transition under the +/// startup gate. Trust replacement is intentionally unavailable once normal +/// engine/store initialization has begun. +pub(crate) fn transition_state_witness_anchor( + request: TransitionStateWitnessAnchorRequest, +) -> Result { + let transition = verify_state_anchor_trust_transition_request(request, true)?; + let outcome = with_startup_state_anchor_trust_transition(&transition, |store| { + store.transition_state_witness_anchor(&transition) + })?; + Ok(transition_state_witness_anchor_result(outcome)) +} + +/// Returns the committed offline-certified trust head. A preflight call uses +/// an ephemeral, descriptor-bound inspection acquisition so observing the +/// head does not prevent the startup-only transition symbol from running. +pub(crate) fn state_anchor_trust_head() -> Result { + let outcome = with_startup_state_anchor_trust_head_inspection(|store| { + store.state_anchor_trust_head_snapshot() + })?; + Ok(state_anchor_trust_head_result( + outcome.trust_head.certificate_sequence, + outcome.trust_head.certificate_digest, + &outcome.trust_head.to, + )) +} + +pub(crate) fn state_anchor_bootstrap_facts() -> Result +{ + let (store_fingerprint, checkpoint) = with_startup_state_anchor_bootstrap_facts(|store| { + store.state_anchor_bootstrap_facts_snapshot() + })?; + Ok(StateAnchorBootstrapFactsResult { + schema: STATE_ANCHOR_BOOTSTRAP_FACTS_SCHEMA.to_string(), + store_fingerprint: bytes32_hex(store_fingerprint), + current_checkpoint: StateAnchorTrustCheckpointModel::from_witness( + store_fingerprint, + &checkpoint, + ) + .to_wire(), + }) +} + +#[cfg(test)] +fn state_anchor_trust_endpoint_wire_for_tests( + endpoint: &StateAnchorTrustEndpointModel, +) -> StateAnchorTrustEndpoint { + StateAnchorTrustEndpoint { + activation_manifest_hash: bytes32_hex(endpoint.activation_manifest_hash), + activation_manifest_sequence: endpoint.activation_manifest_sequence.to_string(), + binding_hash: bytes32_hex(endpoint.binding_hash), + response_public_key: bytes32_hex(endpoint.response_public_key), + response_public_key_spki_sha256: bytes32_hex(endpoint.response_public_key_spki_sha256), + offline_authority_public_key: bytes32_hex(endpoint.offline_authority_public_key), + offline_authority_spki_sha256: bytes32_hex(endpoint.offline_authority_spki_sha256), + witness_maximum_records: endpoint.witness_maximum_records.to_string(), + witness_rotation_threshold_records: endpoint.witness_rotation_threshold_records.to_string(), + reference: endpoint.reference.to_wire(), + } +} + +#[cfg(test)] +fn state_anchor_acknowledgement_wire_for_trust_tests( + acknowledgement: &StateAnchorAcknowledgement, +) -> AcknowledgeStateWitnessCheckpointRequest { + AcknowledgeStateWitnessCheckpointRequest { + schema: TBTC_SIGNER_STATE_WITNESS_CHECKPOINT_ACK_SCHEMA.to_string(), + binding_hash: bytes32_hex(acknowledgement.binding_hash), + request_digest: bytes32_hex(acknowledgement.request_digest), + nonce: bytes32_hex(acknowledgement.nonce), + status: "applied".to_string(), + service_epoch: acknowledgement.service_epoch.to_string(), + revision: acknowledgement.revision.to_string(), + previous_event_root: bytes32_hex(acknowledgement.previous_event_root), + event_root: bytes32_hex(acknowledgement.event_root), + checkpoint: StateWitnessCheckpointRequest { + store_fingerprint: bytes32_hex(acknowledgement.checkpoint_store_fingerprint), + generation: acknowledgement.checkpoint_generation.to_string(), + previous_state_commitment: bytes32_hex(acknowledgement.checkpoint_previous_commitment), + state_image_digest: bytes32_hex(acknowledgement.checkpoint_state_image_digest), + state_commitment: bytes32_hex(acknowledgement.checkpoint_state_commitment), + }, + operation_id: bytes32_hex(acknowledgement.operation_id), + transition_digest: bytes32_hex(acknowledgement.transition_digest), + committed_at_unix_ms: acknowledgement.committed_at_unix_ms.to_string(), + expires_at_unix_ms: acknowledgement.expires_at_unix_ms.to_string(), + signature: format!("0x{}", hex::encode(acknowledgement.signature)), + } +} + +/// Builds and verifies a complete bootstrap certificate plus outer Read around +/// an actual store checkpoint. Keeping this in the verifier module lets +/// end-to-end store tests exercise production parsing and every frozen +/// transcript instead of constructing privileged `Verified*` values by hand. +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +pub(crate) fn bootstrap_state_anchor_trust_transition_for_tests( + store_fingerprint: [u8; 32], + tip: &StateWitness, + acknowledgement_committed_at_unix_ms: u64, + acknowledgement_expires_at_unix_ms: u64, + read_committed_at_unix_ms: u64, + read_expires_at_unix_ms: u64, + require_fresh_read: bool, +) -> Result { + let online_signing_key = SigningKey::from_bytes(&[0x61; 32]); + let offline_signing_key = SigningKey::from_bytes(&[0x62; 32]); + let response_public_key = online_signing_key.verifying_key().to_bytes(); + let offline_authority_public_key = offline_signing_key.verifying_key().to_bytes(); + let response_public_key_spki_sha256 = ed25519_spki_sha256(&response_public_key); + let offline_authority_spki_sha256 = ed25519_spki_sha256(&offline_authority_public_key); + let protocol_id = [0x63; 32]; + let stream_id = [0x64; 32]; + let binding_hash = [0x65; 32]; + let checkpoint = StateAnchorTrustCheckpointModel::from_witness(store_fingerprint, tip); + let mut target = StateAnchorTrustEndpointModel { + activation_manifest_hash: [0x66; 32], + activation_manifest_sequence: 1, + binding_hash, + response_public_key, + response_public_key_spki_sha256, + offline_authority_public_key, + offline_authority_spki_sha256, + witness_maximum_records: 10, + witness_rotation_threshold_records: 2, + reference: StateAnchorTrustReferenceModel { + service_epoch: 1, + revision: 1, + previous_event_root: [0u8; 32], + event_root: [0u8; 32], + checkpoint_ack_digest: [0u8; 32], + checkpoint, + }, + }; + let previous_certificate_digest = [0u8; 32]; + let core_digest = trust_certificate_core_digest( + StateAnchorTrustCertificateKind::Bootstrap, + 1, + &previous_certificate_digest, + &protocol_id, + &stream_id, + &store_fingerprint, + None, + &target, + ); + let core_signature = offline_signing_key.sign(&core_digest).to_bytes(); + let operation_id = hash_direct(TRUST_OPERATION_ID_DOMAIN, &[&core_digest]); + let transition_digest = hash_direct( + TRUST_TRANSITION_DIGEST_DOMAIN, + &[&core_digest, &operation_id], + ); + + let mut acknowledgement = StateAnchorAcknowledgement { + binding_hash, + request_digest: [0x67; 32], + nonce: [0x68; 32], + status: 1, + service_epoch: 1, + revision: 1, + previous_event_root: [0u8; 32], + event_root: [0u8; 32], + checkpoint_store_fingerprint: store_fingerprint, + checkpoint_generation: tip.generation, + checkpoint_previous_commitment: tip.previous_commitment, + checkpoint_state_image_digest: tip.state_image_digest, + checkpoint_state_commitment: tip.commitment, + operation_id, + transition_digest, + committed_at_unix_ms: acknowledgement_committed_at_unix_ms, + expires_at_unix_ms: acknowledgement_expires_at_unix_ms, + signing_digest: [0u8; 32], + signature: [0u8; 64], + configured_spki_hash: response_public_key_spki_sha256, + acknowledgement_digest: [0u8; 32], + }; + acknowledgement.event_root = state_anchor_event_root_for_tests(&acknowledgement); + acknowledgement.signing_digest = state_anchor_signing_digest_for_tests(&acknowledgement); + acknowledgement.signature = online_signing_key + .sign(&acknowledgement.signing_digest) + .to_bytes(); + acknowledgement.acknowledgement_digest = state_anchor_acknowledgement_digest_for_tests( + &acknowledgement.signing_digest, + &acknowledgement.signature, + &response_public_key_spki_sha256, + ); + target.reference = StateAnchorTrustReferenceModel::from_acknowledgement(&acknowledgement); + + let acknowledgement_wire = state_anchor_acknowledgement_wire_for_trust_tests(&acknowledgement); + let acknowledgement_bytes = serde_json::to_vec(&acknowledgement_wire).map_err(|error| { + EngineError::Internal(format!( + "failed to encode test target acknowledgement: {error}" + )) + })?; + let target_acknowledgement_sha256: [u8; 32] = Sha256::digest(&acknowledgement_bytes).into(); + let final_digest = trust_certificate_final_digest( + &core_digest, + &core_signature, + &operation_id, + &transition_digest, + &target.reference, + &target_acknowledgement_sha256, + ); + let final_signature = offline_signing_key.sign(&final_digest).to_bytes(); + let certificate_digest = hash_direct( + TRUST_CERTIFICATE_DIGEST_DOMAIN, + &[ + &final_digest, + &final_signature, + &offline_authority_spki_sha256, + ], + ); + let certificate = StateAnchorTrustCertificate { + schema: STATE_ANCHOR_TRUST_CERTIFICATE_SCHEMA.to_string(), + kind: "bootstrap".to_string(), + certificate_sequence: "1".to_string(), + previous_certificate_digest: bytes32_hex(previous_certificate_digest), + protocol_id: bytes32_hex(protocol_id), + stream_id: bytes32_hex(stream_id), + signer_store_fingerprint: bytes32_hex(store_fingerprint), + from: RequiredNullableStateAnchorTrustEndpoint(None), + to: state_anchor_trust_endpoint_wire_for_tests(&target), + core_digest: bytes32_hex(core_digest), + core_signature: Base64::encode_string(&core_signature), + operation_id: bytes32_hex(operation_id), + transition_digest: bytes32_hex(transition_digest), + target_acknowledgement_base64: Base64::encode_string(&acknowledgement_bytes), + target_acknowledgement_sha256: bytes32_hex(target_acknowledgement_sha256), + final_signature: Base64::encode_string(&final_signature), + certificate_digest: bytes32_hex(certificate_digest), + }; + + let read_request_digest = [0x69; 32]; + let read_nonce = [0x6a; 32]; + let raw_acknowledgement_digest: [u8; 32] = Sha256::digest(&acknowledgement_bytes).into(); + let read_signing_digest = state_anchor_read_response_signing_digest_for_tests( + &binding_hash, + &read_request_digest, + &read_nonce, + acknowledgement.service_epoch, + acknowledgement.revision, + &acknowledgement.event_root, + &store_fingerprint, + tip.generation, + &tip.previous_commitment, + &tip.state_image_digest, + &tip.commitment, + &operation_id, + &transition_digest, + read_committed_at_unix_ms, + read_expires_at_unix_ms, + &acknowledgement.acknowledgement_digest, + &raw_acknowledgement_digest, + ); + let read_signature = online_signing_key.sign(&read_signing_digest).to_bytes(); + let raw_acknowledgement = serde_json::value::RawValue::from_string( + String::from_utf8(acknowledgement_bytes).map_err(|error| { + EngineError::Internal(format!("test acknowledgement JSON was not UTF-8: {error}")) + })?, + ) + .map_err(|error| { + EngineError::Internal(format!( + "test acknowledgement JSON was not a raw JSON value: {error}" + )) + })?; + let read = RecoverStateWitnessCheckpointRequest { + schema: "tbtc-frost-native-signer-state-anchor-read-response/v1".to_string(), + binding_hash: bytes32_hex(binding_hash), + request_digest: bytes32_hex(read_request_digest), + nonce: bytes32_hex(read_nonce), + status: "present".to_string(), + service_epoch: acknowledgement.service_epoch.to_string(), + revision: acknowledgement.revision.to_string(), + event_root: bytes32_hex(acknowledgement.event_root), + checkpoint: StateWitnessCheckpointRequest { + store_fingerprint: bytes32_hex(store_fingerprint), + generation: tip.generation.to_string(), + previous_state_commitment: bytes32_hex(tip.previous_commitment), + state_image_digest: bytes32_hex(tip.state_image_digest), + state_commitment: bytes32_hex(tip.commitment), + }, + operation_id: bytes32_hex(operation_id), + transition_digest: bytes32_hex(transition_digest), + committed_at_unix_ms: read_committed_at_unix_ms.to_string(), + expires_at_unix_ms: read_expires_at_unix_ms.to_string(), + checkpoint_ack: raw_acknowledgement, + checkpoint_ack_digest: bytes32_hex(acknowledgement.acknowledgement_digest), + signature: format!("0x{}", hex::encode(read_signature)), + }; + let read_bytes = serde_json::to_vec(&read).map_err(|error| { + EngineError::Internal(format!("failed to encode test target Read: {error}")) + })?; + + std::env::set_var(TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, "10"); + std::env::set_var( + TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS_ENV, + "2", + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH_ENV, + bytes32_hex(binding_hash), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_ENV, + bytes32_hex(response_public_key), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256_ENV, + bytes32_hex(response_public_key_spki_sha256), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_PROTOCOL_ID_ENV, + bytes32_hex(protocol_id), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_STREAM_ID_ENV, + bytes32_hex(stream_id), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_HASH_ENV, + bytes32_hex(target.activation_manifest_hash), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_SEQUENCE_ENV, + target.activation_manifest_sequence.to_string(), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_ENV, + bytes32_hex(offline_authority_public_key), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_SPKI_SHA256_ENV, + bytes32_hex(offline_authority_spki_sha256), + ); + std::env::set_var(TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_SEQUENCE_ENV, "1"); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_DIGEST_ENV, + bytes32_hex(certificate_digest), + ); + + verify_state_anchor_trust_transition_request( + TransitionStateWitnessAnchorRequest { + schema: STATE_ANCHOR_TRUST_TRANSITION_SCHEMA.to_string(), + certificate_chain: vec![certificate], + target_read_response_base64: Base64::encode_string(&read_bytes), + }, + require_fresh_read, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct SharedValidVector { + name: &'static str, + canonical_json: String, + core_digest: &'static str, + operation_id: &'static str, + transition_digest: &'static str, + final_digest: &'static str, + certificate_digest: &'static str, + canonical_json_sha256: &'static str, + } + + fn repeated_bytes32(value: u8) -> String { + format!("0x{}", format!("{value:02x}").repeat(32)) + } + + #[allow(clippy::too_many_arguments)] + fn endpoint_json( + activation_manifest_hash: &str, + activation_manifest_sequence: u64, + binding_hash: &str, + response_public_key: &str, + response_public_key_spki_sha256: &str, + service_epoch: u64, + previous_event_root: &str, + event_root: &str, + checkpoint_ack_digest: &str, + ) -> String { + format!( + concat!( + r#"{{"activationManifestHash":"{activation_manifest_hash}","#, + r#""activationManifestSequence":"{activation_manifest_sequence}","#, + r#""bindingHash":"{binding_hash}","#, + r#""responsePublicKey":"{response_public_key}","#, + r#""responsePublicKeySpkiSha256":"{response_public_key_spki_sha256}","#, + r#""offlineAuthorityPublicKey":"0xd04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737","#, + r#""offlineAuthoritySpkiSha256":"0x503d8ee46d581c649966569ae095e27041de83322e8e0480e5539e00d2d5d874","#, + r#""witnessMaximumRecords":"1000","#, + r#""witnessRotationThresholdRecords":"900","#, + r#""reference":{{"serviceEpoch":"{service_epoch}","revision":"1","#, + r#""previousEventRoot":"{previous_event_root}","#, + r#""eventRoot":"{event_root}","#, + r#""checkpointAckDigest":"{checkpoint_ack_digest}","#, + r#""checkpoint":{{"storeFingerprint":"0x0303030303030303030303030303030303030303030303030303030303030303","#, + r#""generation":"7","#, + r#""previousStateCommitment":"0x3131313131313131313131313131313131313131313131313131313131313131","#, + r#""stateImageDigest":"0x3232323232323232323232323232323232323232323232323232323232323232","#, + r#""stateCommitment":"0xb6c6224de6df25c6fa82e94a76fee50897c3c16b863f4f3d8b67eb41982e481a"}}}}}}"# + ), + activation_manifest_hash = activation_manifest_hash, + activation_manifest_sequence = activation_manifest_sequence, + binding_hash = binding_hash, + response_public_key = response_public_key, + response_public_key_spki_sha256 = response_public_key_spki_sha256, + service_epoch = service_epoch, + previous_event_root = previous_event_root, + event_root = event_root, + checkpoint_ack_digest = checkpoint_ack_digest, + ) + } + + #[allow(clippy::too_many_arguments)] + fn target_acknowledgement_base64( + binding_hash: &str, + request_digest: &str, + nonce: &str, + service_epoch: u64, + previous_event_root: &str, + event_root: &str, + operation_id: &str, + transition_digest: &str, + committed_at_unix_ms: u64, + expires_at_unix_ms: u64, + signature: &str, + ) -> String { + let acknowledgement = format!( + concat!( + r#"{{"schema":"tbtc-signer-state-witness-checkpoint-ack/v1","#, + r#""bindingHash":"{binding_hash}","#, + r#""requestDigest":"{request_digest}","#, + r#""nonce":"{nonce}","status":"applied","#, + r#""serviceEpoch":"{service_epoch}","revision":"1","#, + r#""previousEventRoot":"{previous_event_root}","#, + r#""eventRoot":"{event_root}","#, + r#""checkpoint":{{"storeFingerprint":"0x0303030303030303030303030303030303030303030303030303030303030303","#, + r#""generation":"7","#, + r#""previousStateCommitment":"0x3131313131313131313131313131313131313131313131313131313131313131","#, + r#""stateImageDigest":"0x3232323232323232323232323232323232323232323232323232323232323232","#, + r#""stateCommitment":"0xb6c6224de6df25c6fa82e94a76fee50897c3c16b863f4f3d8b67eb41982e481a"}},"#, + r#""operationID":"{operation_id}","#, + r#""transitionDigest":"{transition_digest}","#, + r#""committedAtUnixMs":"{committed_at_unix_ms}","#, + r#""expiresAtUnixMs":"{expires_at_unix_ms}","#, + r#""signature":"{signature}"}}"# + ), + binding_hash = binding_hash, + request_digest = request_digest, + nonce = nonce, + service_epoch = service_epoch, + previous_event_root = previous_event_root, + event_root = event_root, + operation_id = operation_id, + transition_digest = transition_digest, + committed_at_unix_ms = committed_at_unix_ms, + expires_at_unix_ms = expires_at_unix_ms, + signature = signature, + ); + Base64::encode_string(acknowledgement.as_bytes()) + } + + #[allow(clippy::too_many_arguments)] + fn certificate_json( + kind: &str, + certificate_sequence: u64, + previous_certificate_digest: &str, + from: &str, + to: &str, + core_digest: &str, + core_signature: &str, + operation_id: &str, + transition_digest: &str, + target_acknowledgement_base64: &str, + target_acknowledgement_sha256: &str, + final_signature: &str, + certificate_digest: &str, + ) -> String { + format!( + concat!( + r#"{{"schema":"tbtc-frost-native-signer-state-anchor-trust-certificate/v1","#, + r#""kind":"{kind}","certificateSequence":"{certificate_sequence}","#, + r#""previousCertificateDigest":"{previous_certificate_digest}","#, + r#""protocolID":"0x0101010101010101010101010101010101010101010101010101010101010101","#, + r#""streamID":"0x0202020202020202020202020202020202020202020202020202020202020202","#, + r#""signerStoreFingerprint":"0x0303030303030303030303030303030303030303030303030303030303030303","#, + r#""from":{from},"to":{to},"coreDigest":"{core_digest}","#, + r#""coreSignature":"{core_signature}","#, + r#""operationID":"{operation_id}","#, + r#""transitionDigest":"{transition_digest}","#, + r#""targetAcknowledgementBase64":"{target_acknowledgement_base64}","#, + r#""targetAcknowledgementSHA256":"{target_acknowledgement_sha256}","#, + r#""finalSignature":"{final_signature}","#, + r#""certificateDigest":"{certificate_digest}"}}"# + ), + kind = kind, + certificate_sequence = certificate_sequence, + previous_certificate_digest = previous_certificate_digest, + from = from, + to = to, + core_digest = core_digest, + core_signature = core_signature, + operation_id = operation_id, + transition_digest = transition_digest, + target_acknowledgement_base64 = target_acknowledgement_base64, + target_acknowledgement_sha256 = target_acknowledgement_sha256, + final_signature = final_signature, + certificate_digest = certificate_digest, + ) + } + + fn shared_valid_vectors() -> Vec { + let zero = repeated_bytes32(0); + let bootstrap_event_root = + "0x5a2313f20d5a31c960106cc54a0068ed03b8b51eed74ea73cf6a014241a9108e"; + let bootstrap_endpoint = endpoint_json( + &repeated_bytes32(0x04), + 9, + &repeated_bytes32(0x05), + "0xa09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0", + "0x744f36cca67eb1912cb282e08e86bd2fe0d09004a28cf6e7c6bd56cdd6bf65aa", + 1, + &zero, + bootstrap_event_root, + "0x2f4a5b931123b7ef3e7837ac2adfdd0bc7731bbd084c53ee5a9f02de864670d9", + ); + + let bootstrap_core = "0xd3b5ea2a8c29dc4f4ef5250fc75efb3fb6bcd87167d523661c4a55e7898fb90d"; + let bootstrap_operation = + "0x49351644f18779575f614ab4b50c14c6454d8bd93d0a287edf41c801826edea0"; + let bootstrap_transition = + "0x50e2372f4008f0bd47c4e17c517fe5b5dc6062736492a1414ada049878447979"; + let bootstrap_ack = target_acknowledgement_base64( + &repeated_bytes32(0x05), + &repeated_bytes32(0x52), + &repeated_bytes32(0x53), + 1, + &zero, + bootstrap_event_root, + bootstrap_operation, + bootstrap_transition, + 1_700_000_001_000, + 1_700_000_031_000, + "0x8762cd1008f09417f766ee880f2900d663ea9ebc583d1af2cc9cad805b321090c173dfb48b3092ca2c9423f36053391792fa4dd8c8f56dc1248feb647b66e707", + ); + let bootstrap_certificate_digest = + "0x059967c2178a72c178e894fe54ac74fccd657aec73f3b2ce48b9e68bae098a0b"; + let bootstrap = certificate_json( + "bootstrap", + 1, + &zero, + "null", + &bootstrap_endpoint, + bootstrap_core, + "miRHGGKJkFCxOqCOg7mUp9JHrUZhr8p4v9iQ19ScnBTFwHz+AdVd7d265usGMEw8t3K5zjBzxSQNErq5gCthAg==", + bootstrap_operation, + bootstrap_transition, + &bootstrap_ack, + "0x9605e695983da86a907c2a4b32ae322aa0c242fa0884d9049150007bc463c91b", + "Frwgl6F5VvKIimo3DIrTHnbA8wyi/vZtfCtsyszLDPb2YKEOkg0aXZ/k0GJZ7of5VV8r4zCqsZ0T+9zri99qAg==", + bootstrap_certificate_digest, + ); + + let rotation_event_root = + "0x700dc376026a7a07f57de52b6fd6435dda77e94eebabd0af4b20ad86fd5a1ec1"; + let rotation_endpoint = endpoint_json( + &repeated_bytes32(0x34), + 10, + &repeated_bytes32(0x35), + "0x17cb79fb2b4120f2b1ec65e4198d6e08b28e813feb01e4a400839b85e18080ce", + "0x3532fb2b134488a2a18f2a07cd6db85b4c1b808ddb10972f0ad5526856177bd4", + 2, + bootstrap_event_root, + rotation_event_root, + "0x6f3e877f41bbe4f8d09a91fc6644222c9eeca34abc4888e95ba7fe579d193c8a", + ); + let rotation_core = "0x87ce928827c85744e9a01422c83e72ea1cf6f27c2694e4b1d93f519f8a905866"; + let rotation_operation = + "0x39f50c5787e4decb56b87979062aa7e75f773b1693e584e8872a860da55fec93"; + let rotation_transition = + "0x1a130d6da974f81cdbfdd923d9e53cf46cd9d81455b3c75e4cad1fe9c15b4385"; + let rotation_ack = target_acknowledgement_base64( + &repeated_bytes32(0x35), + &repeated_bytes32(0x34), + &repeated_bytes32(0x35), + 2, + bootstrap_event_root, + rotation_event_root, + rotation_operation, + rotation_transition, + 1_700_000_002_000, + 1_700_000_032_000, + "0x8db44faf1906059574c4b89e3120f3c4ea17ef2ab79f1c1dedeb5fb009910fa9932840bb4024884af8eee1272bfc96349140d896876f1f811d55995d29fd5809", + ); + let rotation_certificate_digest = + "0x0d571f120304487645bad248d866f7455ea50bf9b2bc6521de47151b7d671f35"; + let rotation = certificate_json( + "rotation", + 2, + bootstrap_certificate_digest, + &bootstrap_endpoint, + &rotation_endpoint, + rotation_core, + "t2+gDyrgsnCPICB2pQXjuMuN8Hpi98tm62jEnBjQU2C9i++dd2kKkWy3ZOBROgo5poxrJ2FdJvqxKgo24Xg/BQ==", + rotation_operation, + rotation_transition, + &rotation_ack, + "0x4e0cf22084defa424302981a0b24f62c5f2e8c214df242b47e6b96ce55715a82", + "JJqfkvtE0sptpDowjfGhmYOZ6qR0XgGayEBH1zrlXaUXD+bwq+yZlolJ9T68ddXBjlZx1/xyELsjn8lhBIqLDA==", + rotation_certificate_digest, + ); + + let adoption_event_root = + "0x41c8323190639f9933e170e869a5bd322776e0928e24adfd4cfc9ae7829d6971"; + let adoption_endpoint = endpoint_json( + &repeated_bytes32(0x45), + 10, + &repeated_bytes32(0x46), + "0xd759793bbc13a2819a827c76adb6fba8a49aee007f49f2d0992d99b825ad2c48", + "0xf98d04c078515fd84c171fd3bb49a676cf3731d990aca999176b4f8796bac3a4", + 2, + bootstrap_event_root, + adoption_event_root, + "0xd956aa1efb4412ce56c8d41ff1e356c5d01ac4355f7a947c9355c29cc18bcaec", + ); + let adoption_core = "0xb8028e79579f400c80cd76101473e9a8e02be0644c5c4544a7a582103d5320a4"; + let adoption_operation = + "0xc4dc774b78b391f173756130abb5412e62958f174783302396df673cfb70c30d"; + let adoption_transition = + "0xbc3bc28337f870c07f41353d597ea38c96a2dd7f9f2f80536404e1e043296991"; + let adoption_ack = target_acknowledgement_base64( + &repeated_bytes32(0x46), + &repeated_bytes32(0x45), + &repeated_bytes32(0x46), + 2, + bootstrap_event_root, + adoption_event_root, + adoption_operation, + adoption_transition, + 1_700_000_001_000, + 1_700_000_031_000, + "0xf4a348469d714a26cc79bd54545e0ab98bab23c8082201e1dc050227a9dc61b2221f30861f07c63546aff73d9c07aed2758552d53634d76242c0f93e474a4c00", + ); + let adoption_certificate_digest = + "0xa8af21e3da145313f4f0357aa5de40a99e01af9faa8ff2cb680f9531ccb271dd"; + let adoption = certificate_json( + "rotation", + 1, + &zero, + &bootstrap_endpoint, + &adoption_endpoint, + adoption_core, + "lG+bbynkFb7ucEVrJfdmWjRt21uZeVm4ppC8XyRXAqa7tJ2B7a4hiPVWBLco8AvO6zrqFpGoiT4uIQHZZNxqBA==", + adoption_operation, + adoption_transition, + &adoption_ack, + "0x5ea3c16df6ad7ae8b5abbbe53d466fea5a5f1b3b4750d8def650647ca2ab2567", + "avYz3s+8I6kZ0xizacvbdE9LqkF36Ddps+DkBp8wriWiBT4x2KNv623//ZLVMwZsWVh6CWbiyQM/XBamcKvmDw==", + adoption_certificate_digest, + ); + + vec![ + SharedValidVector { + name: "bootstrap", + canonical_json: bootstrap, + core_digest: &bootstrap_core[2..], + operation_id: &bootstrap_operation[2..], + transition_digest: &bootstrap_transition[2..], + final_digest: "0ae35d27aa3b74817a5dd99dcb1355975a74df6d5bcdda14288382eb8067535e", + certificate_digest: &bootstrap_certificate_digest[2..], + canonical_json_sha256: + "ea80af7129eb2a52d3116007d9caab2f5bf9df2edbc7d29811ff275b7f6d0412", + }, + SharedValidVector { + name: "rotation", + canonical_json: rotation, + core_digest: &rotation_core[2..], + operation_id: &rotation_operation[2..], + transition_digest: &rotation_transition[2..], + final_digest: "19f98af5fb9e017470594782c632eeaabf352c681523c626d6f6bb59b43318a2", + certificate_digest: &rotation_certificate_digest[2..], + canonical_json_sha256: + "8f834f9b335854218f07fcd2af0fafb311ac046b026f5436c7fe89ff24c9ade8", + }, + SharedValidVector { + name: "adoption", + canonical_json: adoption, + core_digest: &adoption_core[2..], + operation_id: &adoption_operation[2..], + transition_digest: &adoption_transition[2..], + final_digest: "aa69c3894ad0118c71189352565122950d2dfa5a20337f48c53c750dddae5969", + certificate_digest: &adoption_certificate_digest[2..], + canonical_json_sha256: + "e8b0fe13efbb0c667576411836c2f6ac22df1eb70904cb0d796ae0b3dfc06cdb", + }, + ] + } + + #[test] + fn go_shared_valid_certificate_vectors_match_rust_verification_and_transcripts() { + for vector in shared_valid_vectors() { + let wire: StateAnchorTrustCertificate = + serde_json::from_slice(vector.canonical_json.as_bytes()).unwrap_or_else(|error| { + panic!( + "strict {} certificate JSON failed to decode: {error}", + vector.name + ) + }); + assert_eq!( + serde_json::to_vec(&wire).expect("certificate serialization"), + vector.canonical_json.as_bytes(), + "{} canonical certificate JSON changed", + vector.name + ); + + let verified = verify_state_anchor_trust_certificate(wire).unwrap_or_else(|error| { + panic!( + "Go shared {} certificate was rejected: {error}", + vector.name + ) + }); + assert_eq!(hex::encode(verified.core_digest), vector.core_digest); + assert_eq!(hex::encode(verified.operation_id), vector.operation_id); + assert_eq!( + hex::encode(verified.transition_digest), + vector.transition_digest + ); + assert_eq!( + hex::encode(trust_certificate_final_digest( + &verified.core_digest, + &verified.core_signature, + &verified.operation_id, + &verified.transition_digest, + &verified.to.reference, + &verified.target_acknowledgement_sha256, + )), + vector.final_digest + ); + assert_eq!( + hex::encode(verified.certificate_digest), + vector.certificate_digest + ); + assert_eq!( + hex::encode(Sha256::digest(vector.canonical_json.as_bytes())), + vector.canonical_json_sha256, + "{} canonical JSON SHA-256 changed", + vector.name + ); + } + } + + #[test] + fn certificate_endpoints_reject_non_prime_subgroup_online_and_offline_keys() { + let vector = shared_valid_vectors().remove(0); + let template: StateAnchorTrustCertificate = + serde_json::from_slice(vector.canonical_json.as_bytes()) + .expect("shared bootstrap certificate"); + let corpus = [ + ( + "identity", + "0100000000000000000000000000000000000000000000000000000000000000", + ), + ( + "order-four", + "0000000000000000000000000000000000000000000000000000000000000000", + ), + ( + "mixed-order", + "9970c93c125fd998ebc1642abe30619e2fd971dbcbeaeb8ccfe919cbfd13b6cf", + ), + ]; + for (name, encoded) in corpus { + let bytes: [u8; 32] = hex::decode(encoded) + .expect("vector hex") + .try_into() + .expect("32-byte vector"); + let spki = bytes32_hex(ed25519_spki_sha256(&bytes)); + // The all-zero encoding is rejected by the reserved-value check + // before point decompression; the other vectors decompress and + // fail the prime-subgroup check. + let expected_fragment = if name == "order-four" { + "must be nonzero" + } else { + "prime-subgroup" + }; + + let mut weak_online = template.clone(); + weak_online.to.response_public_key = bytes32_hex(bytes); + weak_online.to.response_public_key_spki_sha256 = spki.clone(); + let error = verify_state_anchor_trust_certificate(weak_online) + .err() + .unwrap_or_else(|| panic!("{name} online key must be rejected")); + assert!( + error.to_string().contains(expected_fragment), + "unexpected {name} online error: {error}" + ); + + let mut weak_offline = template.clone(); + weak_offline.to.offline_authority_public_key = bytes32_hex(bytes); + weak_offline.to.offline_authority_spki_sha256 = spki; + let error = verify_state_anchor_trust_certificate(weak_offline) + .err() + .unwrap_or_else(|| panic!("{name} offline key must be rejected")); + assert!( + error.to_string().contains(expected_fragment), + "unexpected {name} offline error: {error}" + ); + } + } + + #[test] + fn multi_certificate_journal_recovers_every_cow_commit_prefix() { + let vectors = shared_valid_vectors(); + let certificates: Vec<_> = vectors[..2] + .iter() + .map(|vector| { + let wire: StateAnchorTrustCertificate = + serde_json::from_slice(vector.canonical_json.as_bytes()) + .expect("shared certificate JSON"); + verify_state_anchor_trust_certificate(wire).expect("shared certificate verifies") + }) + .collect(); + let store_fingerprint = certificates[0].signer_store_fingerprint; + let mut bytes = encode_state_anchor_trust_journal_header(&store_fingerprint); + let mut journal = + parse_state_anchor_trust_journal(&bytes, &store_fingerprint).expect("header"); + assert!(journal.committed.is_empty()); + assert!(journal.pending.is_empty()); + + for (index, certificate) in certificates.iter().enumerate() { + let record = encode_state_anchor_trust_prepare_record( + &store_fingerprint, + &journal.last_record_commitment, + certificate, + ) + .expect("PREPARE record"); + bytes.extend_from_slice(&record); + journal = parse_state_anchor_trust_journal(&bytes, &store_fingerprint) + .expect("PREPARE prefix parses"); + assert_eq!(journal.committed.len(), 0); + assert_eq!(journal.pending.len(), index + 1); + } + + let first_commit = encode_state_anchor_trust_commit_record( + &store_fingerprint, + &journal.last_record_commitment, + &certificates[0], + ) + .expect("first COMMIT"); + bytes.extend_from_slice(&first_commit); + journal = parse_state_anchor_trust_journal(&bytes, &store_fingerprint) + .expect("partial COMMIT prefix parses"); + assert_eq!(journal.committed.len(), 1); + assert_eq!(journal.pending.len(), 1); + assert_eq!(journal.committed[0].wire, certificates[0].wire); + assert_eq!(journal.pending[0].wire, certificates[1].wire); + + let second_commit = encode_state_anchor_trust_commit_record( + &store_fingerprint, + &journal.last_record_commitment, + &certificates[1], + ) + .expect("second COMMIT"); + bytes.extend_from_slice(&second_commit); + journal = parse_state_anchor_trust_journal(&bytes, &store_fingerprint) + .expect("complete COMMIT batch parses"); + assert_eq!(journal.committed.len(), 2); + assert!(journal.pending.is_empty()); + assert_eq!(journal.committed[1].wire, certificates[1].wire); + } + + #[test] + fn descendant_reference_allows_later_revision_but_bounds_restart_history() { + 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 later = floor.clone(); + later.revision += 1; + later.previous_event_root = floor.event_root; + later.event_root = [0x71; 32]; + later.checkpoint_ack_digest = [0x72; 32]; + validate_state_anchor_trust_reference_descendant(&floor, &later, "later") + .expect("same-checkpoint later revision is a descendant"); + + let mut forked_checkpoint = later.clone(); + forked_checkpoint.checkpoint.state_image_digest[0] ^= 1; + assert!(validate_state_anchor_trust_reference_descendant( + &floor, + &forked_checkpoint, + "fork" + ) + .is_err()); + + let mut at_bound = later.clone(); + at_bound.revision = floor.revision + STATE_ANCHOR_TRUST_MAX_REVISION_DISTANCE; + validate_state_anchor_trust_reference_descendant(&floor, &at_bound, "at-bound") + .expect("revision exactly at the certified restart-history bound is accepted"); + + let mut too_far = later; + too_far.revision = floor.revision + STATE_ANCHOR_TRUST_MAX_REVISION_DISTANCE + 1; + assert!( + validate_state_anchor_trust_reference_descendant(&floor, &too_far, "too-far").is_err() + ); + } +} diff --git a/pkg/tbtc/signer/src/engine/config.rs b/pkg/tbtc/signer/src/engine/config.rs index c0bd3cd999..f456c13f75 100644 --- a/pkg/tbtc/signer/src/engine/config.rs +++ b/pkg/tbtc/signer/src/engine/config.rs @@ -47,6 +47,43 @@ pub(crate) const TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV: &str = pub(crate) const TBTC_SIGNER_DEFAULT_CORRUPT_BACKUP_LIMIT: usize = 5; +/// Maximum number of fixed-width PREPARE/COMMIT/ABORT records retained in the +/// active state-witness segment. The signer currently has no authority-signed +/// checkpoint rotation protocol, so reaching this ceiling fails closed instead +/// of silently compacting or re-genesis-ing the anti-rollback chain. +pub(crate) const TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV: &str = + "TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS"; +pub(crate) const TBTC_SIGNER_DEFAULT_STATE_WITNESS_MAX_RECORDS: usize = 262_144; +pub(crate) const TBTC_SIGNER_MIN_STATE_WITNESS_MAX_RECORDS: usize = 2; +pub(crate) const TBTC_SIGNER_HARD_MAX_STATE_WITNESS_MAX_RECORDS: usize = 1_000_000; + +/// Manifest-pinned independent state-anchor service identity. These four +/// values are an all-or-none configuration validated before init publication. +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH_ENV: &str = + "TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH"; +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_ENV: &str = + "TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY"; +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256_ENV: &str = + "TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256"; +pub(crate) const TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS_ENV: &str = + "TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS"; +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_PROTOCOL_ID_ENV: &str = + "TBTC_SIGNER_STATE_ANCHOR_PROTOCOL_ID"; +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_STREAM_ID_ENV: &str = + "TBTC_SIGNER_STATE_ANCHOR_STREAM_ID"; +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_HASH_ENV: &str = + "TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_HASH"; +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_SEQUENCE_ENV: &str = + "TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_SEQUENCE"; +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_ENV: &str = + "TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY"; +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_SPKI_SHA256_ENV: &str = + "TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_SPKI_SHA256"; +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_SEQUENCE_ENV: &str = + "TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_SEQUENCE"; +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_DIGEST_ENV: &str = + "TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_DIGEST"; + pub(crate) const TBTC_SIGNER_MAX_SESSIONS_ENV: &str = "TBTC_SIGNER_MAX_SESSIONS"; pub(crate) const TBTC_SIGNER_DEFAULT_MAX_SESSIONS: usize = 1024; @@ -318,6 +355,25 @@ pub(crate) fn parse_u64_from_env_with_default( Ok(parsed) } +pub(crate) fn state_witness_max_records() -> Result { + let value = parse_usize_from_env_with_default( + TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, + TBTC_SIGNER_DEFAULT_STATE_WITNESS_MAX_RECORDS, + )?; + if !(TBTC_SIGNER_MIN_STATE_WITNESS_MAX_RECORDS..=TBTC_SIGNER_HARD_MAX_STATE_WITNESS_MAX_RECORDS) + .contains(&value) + { + return Err(EngineError::Validation(format!( + "{} must be between {} and {}; got [{}]", + TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, + TBTC_SIGNER_MIN_STATE_WITNESS_MAX_RECORDS, + TBTC_SIGNER_HARD_MAX_STATE_WITNESS_MAX_RECORDS, + value + ))); + } + Ok(value) +} + pub(crate) fn parse_u8_from_env_optional(env_name: &str) -> Result, EngineError> { let Some(raw_value) = signer_env_var(env_name) else { return Ok(None); diff --git a/pkg/tbtc/signer/src/engine/dkg.rs b/pkg/tbtc/signer/src/engine/dkg.rs index 5479767bb8..85ad935b0e 100644 --- a/pkg/tbtc/signer/src/engine/dkg.rs +++ b/pkg/tbtc/signer/src/engine/dkg.rs @@ -311,3 +311,102 @@ pub fn persist_distributed_dkg_key_package( Ok(result) } + +/// Durably retires the wallet-owner session holding the exact distributed-DKG +/// key group. Removing the owner session atomically removes every local seat's +/// secret package and the corresponding public package. Absence is a successful +/// no-op, which makes recovery safe after a crash that committed the native +/// removal before the caller archived its local wallet registry entry. +pub fn retire_distributed_dkg_key_packages( + request: RetireDistributedDkgKeyPackagesRequest, +) -> Result { + const OP: &str = "retire_distributed_dkg_key_packages"; + + enforce_provenance_gate()?; + super::inventory::parse_key_group(&request.key_group).map_err(|error| { + EngineError::Validation(format!( + "{OP}: key_group is not canonical compressed SEC1: {error}" + )) + })?; + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + + let mut owner_session_id = None; + for (session_id, session) in &guard.sessions { + if session + .dkg_result + .as_ref() + .is_some_and(|result| result.key_group == request.key_group) + { + if owner_session_id.is_some() { + return Err(EngineError::Internal(format!( + "{OP}: key group has multiple owner sessions" + ))); + } + owner_session_id = Some(session_id.clone()); + } + } + + let Some(owner_session_id) = owner_session_id else { + return Ok(RetireDistributedDkgKeyPackagesResult { + key_group: request.key_group, + retired: false, + retired_key_package_count: 0, + }); + }; + + let owner_session = guard + .sessions + .get(&owner_session_id) + .ok_or_else(|| EngineError::Internal(format!("{OP}: owner session disappeared")))?; + let dkg_result = owner_session + .dkg_result + .as_ref() + .ok_or_else(|| EngineError::Internal(format!("{OP}: owner has no DKG result")))?; + if dkg_result.session_id != owner_session_id { + return Err(EngineError::Internal(format!( + "{OP}: DKG result session does not match its owner" + ))); + } + if owner_session.bound_key_group.is_some() || !owner_session.interactive_signing.is_empty() { + return Err(EngineError::SessionConflict { + session_id: owner_session_id, + }); + } + let retired_key_package_count = u16::try_from( + owner_session + .dkg_key_packages + .as_ref() + .ok_or_else(|| { + EngineError::Internal(format!("{OP}: owner has no retained key packages")) + })? + .len(), + ) + .map_err(|_| EngineError::Internal(format!("{OP}: retained key-package count overflow")))?; + if retired_key_package_count == 0 { + return Err(EngineError::Internal(format!( + "{OP}: owner has an empty retained key-package set" + ))); + } + + let removed_session = guard + .sessions + .remove(&owner_session_id) + .ok_or_else(|| EngineError::Internal(format!("{OP}: owner session disappeared")))?; + if let Err(persist_error) = persist_engine_state_to_storage(&guard) { + let state_file_replaced = persist_error.state_file_replaced(); + let persist_error = persist_error.into_engine_error(); + if !state_file_replaced { + guard.sessions.insert(owner_session_id, removed_session); + } + return Err(persist_error); + } + + Ok(RetireDistributedDkgKeyPackagesResult { + key_group: request.key_group, + retired: true, + retired_key_package_count, + }) +} diff --git a/pkg/tbtc/signer/src/engine/init_config.rs b/pkg/tbtc/signer/src/engine/init_config.rs index 0668f0c961..df2424dc26 100644 --- a/pkg/tbtc/signer/src/engine/init_config.rs +++ b/pkg/tbtc/signer/src/engine/init_config.rs @@ -48,6 +48,74 @@ fn validation_candidate() -> Option> { pub(crate) struct InstalledSignerConfig { pub(crate) values: HashMap, pub(crate) fingerprint: String, + purpose: SignerConfigPurpose, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SignerConfigPurpose { + NormalSigner, + StateAnchorBootstrapProvisioning, +} + +const STATE_ANCHOR_BOOTSTRAP_PROVISIONING_WITNESS_MAX_RECORDS: u64 = 4; + +fn parse_signer_config_purpose(purpose: Option<&str>) -> Result { + match purpose.unwrap_or("normal_signer") { + "normal_signer" => Ok(SignerConfigPurpose::NormalSigner), + "state_anchor_bootstrap_provisioning" => { + Ok(SignerConfigPurpose::StateAnchorBootstrapProvisioning) + } + value => Err(EngineError::Validation(format!( + "signer config purpose must be 'normal_signer' or \ + 'state_anchor_bootstrap_provisioning'; got [{value}]" + ))), + } +} + +/// Trust transitions are deliberately unavailable through the transitional +/// environment fallback. The offline-certified operation must run only after +/// the host has atomically installed and validated its complete static policy. +pub(crate) fn require_installed_signer_config() -> Result<(), EngineError> { + match installed_signer_config() { + Some(config) if config.purpose == SignerConfigPurpose::NormalSigner => Ok(()), + Some(_) => Err(EngineError::Validation( + "operation requires a normal_signer config; bootstrap provisioning config exposes \ + only state-anchor bootstrap facts" + .to_string(), + )), + None => Err(EngineError::Validation( + "operation requires an installed normal_signer config".to_string(), + )), + } +} + +pub(crate) fn require_normal_signer_purpose() -> Result<(), EngineError> { + match installed_signer_config() { + Some(config) if config.purpose != SignerConfigPurpose::NormalSigner => { + Err(EngineError::Validation( + "operation requires a normal_signer config; bootstrap provisioning config \ + exposes only state-anchor bootstrap facts" + .to_string(), + )) + } + _ => Ok(()), + } +} + +pub(crate) fn require_state_anchor_bootstrap_provisioning_config() -> Result<(), EngineError> { + match installed_signer_config() { + Some(config) if config.purpose == SignerConfigPurpose::StateAnchorBootstrapProvisioning => { + Ok(()) + } + Some(_) => Err(EngineError::Validation( + "state-anchor bootstrap facts require a \ + state_anchor_bootstrap_provisioning config" + .to_string(), + )), + None => Err(EngineError::Validation( + "state-anchor bootstrap facts require an installed provisioning config".to_string(), + )), + } } fn installed_signer_config_slot() -> &'static RwLock>> { @@ -107,11 +175,14 @@ pub fn init_signer_config( request: InitSignerConfigRequest, ) -> Result { let config_fingerprint = fingerprint(&request)?; + let purpose = parse_signer_config_purpose(request.purpose.as_deref())?; + validate_provisioning_request_shape(&request, purpose)?; let values = config_values_from_request(&request)?; let configured_key_count = values.len() as u32; let candidate = Arc::new(InstalledSignerConfig { values, fingerprint: config_fingerprint.clone(), + purpose, }); // Fast path against an already-installed (and therefore already @@ -153,6 +224,65 @@ pub fn init_signer_config( }) } +fn validate_provisioning_request_shape( + request: &InitSignerConfigRequest, + purpose: SignerConfigPurpose, +) -> Result<(), EngineError> { + if purpose != SignerConfigPurpose::StateAnchorBootstrapProvisioning { + return Ok(()); + } + + if request.profile.as_deref() != Some("production") { + return Err(EngineError::Validation( + "state-anchor bootstrap provisioning requires profile 'production'".to_string(), + )); + } + if request + .state_path + .as_deref() + .is_none_or(|path| path.trim().is_empty()) + { + return Err(EngineError::Validation( + "state-anchor bootstrap provisioning requires an explicit non-empty state_path" + .to_string(), + )); + } + if request.state_witness_max_records + != Some(STATE_ANCHOR_BOOTSTRAP_PROVISIONING_WITNESS_MAX_RECORDS) + { + return Err(EngineError::Validation(format!( + "state-anchor bootstrap provisioning requires state_witness_max_records [{}]", + STATE_ANCHOR_BOOTSTRAP_PROVISIONING_WITNESS_MAX_RECORDS + ))); + } + + let serialized = serde_json::to_value(request).map_err(|error| { + EngineError::Internal(format!( + "failed to inspect bootstrap provisioning config fields: {error}" + )) + })?; + let object = serialized.as_object().ok_or_else(|| { + EngineError::Internal( + "bootstrap provisioning config did not serialize as an object".to_string(), + ) + })?; + const ALLOWED_FIELDS: [&str; 4] = [ + "purpose", + "profile", + "state_path", + "state_witness_max_records", + ]; + if let Some(field) = object + .keys() + .find(|field| !ALLOWED_FIELDS.contains(&field.as_str())) + { + return Err(EngineError::Validation(format!( + "state-anchor bootstrap provisioning config forbids populated field [{field}]" + ))); + } + Ok(()) +} + fn reinit_result( existing: &InstalledSignerConfig, config_fingerprint: &str, @@ -173,6 +303,29 @@ fn reinit_result( } fn validate_candidate_config() -> Result<(), EngineError> { + let purpose = validation_candidate() + .map(|candidate| candidate.purpose) + .ok_or_else(|| { + EngineError::Internal( + "signer config validation ran without a candidate purpose".to_string(), + ) + })?; + if purpose == SignerConfigPurpose::StateAnchorBootstrapProvisioning { + // Provisioning is intentionally incapable of loading signer state or + // accepting online/offline anchor policy. It may only establish the + // stable store ID plus pristine genesis witness needed by the offline + // bootstrap ceremony. + state_file_path()?; + state_witness_max_records()?; + if configured_state_anchor()?.is_some() { + return Err(EngineError::Validation( + "state-anchor bootstrap provisioning config must omit every anchor/trust pin" + .to_string(), + )); + } + return Ok(()); + } + load_admission_policy_config()?; load_signing_policy_firewall_config()?; heartbeat_rate_limit_per_minute()?; @@ -181,6 +334,20 @@ fn validate_candidate_config() -> Result<(), EngineError> { // explicit state path; surfacing this at init beats failing the first // state access after a host migrates to the config FFI. state_file_path()?; + // The append-only witness has no unsigned/local-only compaction path. + // Reject unusable ceilings at init instead of discovering them after the + // signer has begun serving stateful operations. + state_witness_max_records()?; + // Validate the complete anchor pin set and rotation threshold while the + // candidate remains thread-local; a partial/non-canonical set must never + // become the process-global signer configuration. + validate_state_anchor_configuration()?; + if configured_state_anchor()?.is_some_and(|configuration| configuration.trust.is_none()) { + return Err(EngineError::Validation( + "init-time state-anchor configuration requires the complete trust-head pin set" + .to_string(), + )); + } // The key-provider settings must be structurally usable too (production // forbids the env provider; the command provider requires a command). // Resolved WITHOUT reading the secret or executing the key command. @@ -271,6 +438,16 @@ pub(crate) fn config_values_from_request( TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV, request.state_corrupt_backup_limit, ); + insert_u64( + &mut values, + TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, + request.state_witness_max_records, + ); + insert_u64( + &mut values, + TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS_ENV, + request.state_witness_rotation_threshold_records, + ); insert_u64( &mut values, TBTC_SIGNER_MAX_SESSIONS_ENV, @@ -399,6 +576,61 @@ pub(crate) fn config_values_from_request( ); insert_string(&mut values, TBTC_SIGNER_STATE_PATH_ENV, &request.state_path)?; + insert_string( + &mut values, + TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH_ENV, + &request.state_anchor_binding_hash, + )?; + insert_string( + &mut values, + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_ENV, + &request.state_anchor_response_public_key, + )?; + insert_string( + &mut values, + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256_ENV, + &request.state_anchor_response_public_key_spki_sha256, + )?; + insert_string( + &mut values, + TBTC_SIGNER_STATE_ANCHOR_PROTOCOL_ID_ENV, + &request.state_anchor_protocol_id, + )?; + insert_string( + &mut values, + TBTC_SIGNER_STATE_ANCHOR_STREAM_ID_ENV, + &request.state_anchor_stream_id, + )?; + insert_string( + &mut values, + TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_HASH_ENV, + &request.state_anchor_activation_manifest_hash, + )?; + insert_u64( + &mut values, + TBTC_SIGNER_STATE_ANCHOR_ACTIVATION_MANIFEST_SEQUENCE_ENV, + request.state_anchor_activation_manifest_sequence, + ); + insert_string( + &mut values, + TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_ENV, + &request.state_anchor_offline_authority_public_key, + )?; + insert_string( + &mut values, + TBTC_SIGNER_STATE_ANCHOR_OFFLINE_AUTHORITY_PUBLIC_KEY_SPKI_SHA256_ENV, + &request.state_anchor_offline_authority_public_key_spki_sha256, + )?; + insert_u64( + &mut values, + TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_SEQUENCE_ENV, + request.state_anchor_trust_certificate_sequence, + ); + insert_string( + &mut values, + TBTC_SIGNER_STATE_ANCHOR_TRUST_CERTIFICATE_DIGEST_ENV, + &request.state_anchor_trust_certificate_digest, + )?; insert_string( &mut values, TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV, @@ -538,3 +770,80 @@ pub(crate) fn clear_installed_signer_config_for_tests() { .unwrap_or_else(|poisoned| poisoned.into_inner()); *guard = None; } + +#[cfg(test)] +mod provisioning_tests { + use super::*; + + fn valid_request() -> InitSignerConfigRequest { + InitSignerConfigRequest { + purpose: Some("state_anchor_bootstrap_provisioning".to_string()), + profile: Some("production".to_string()), + state_path: Some("/tmp/tbtc-signer-bootstrap-provisioning-test".to_string()), + state_witness_max_records: Some( + STATE_ANCHOR_BOOTSTRAP_PROVISIONING_WITNESS_MAX_RECORDS, + ), + ..InitSignerConfigRequest::default() + } + } + + #[test] + fn provisioning_request_shape_is_an_exhaustive_minimal_allowlist() { + validate_provisioning_request_shape( + &valid_request(), + SignerConfigPurpose::StateAnchorBootstrapProvisioning, + ) + .expect("minimal production provisioning request"); + + let mut cases = Vec::new(); + + let mut development = valid_request(); + development.profile = Some("development".to_string()); + cases.push(("development profile", development)); + + let mut missing_path = valid_request(); + missing_path.state_path = None; + cases.push(("missing state path", missing_path)); + + let mut empty_path = valid_request(); + empty_path.state_path = Some(" ".to_string()); + cases.push(("empty state path", empty_path)); + + let mut missing_geometry = valid_request(); + missing_geometry.state_witness_max_records = None; + cases.push(("missing witness geometry", missing_geometry)); + + let mut different_geometry = valid_request(); + different_geometry.state_witness_max_records = + Some(STATE_ANCHOR_BOOTSTRAP_PROVISIONING_WITNESS_MAX_RECORDS + 1); + cases.push(("different witness geometry", different_geometry)); + + let mut bootstrap_knob = valid_request(); + bootstrap_knob.allow_bootstrap = Some(false); + cases.push(("unrelated false boolean", bootstrap_knob)); + + let mut key_provider = valid_request(); + key_provider.state_key_provider = Some("env".to_string()); + cases.push(("key-provider knob", key_provider)); + + let mut session_limit = valid_request(); + session_limit.max_sessions = Some(1); + cases.push(("session knob", session_limit)); + + let mut anchor_pin = valid_request(); + anchor_pin.state_anchor_binding_hash = Some("41".repeat(32)); + cases.push(("anchor pin", anchor_pin)); + + for (label, request) in cases { + let error = validate_provisioning_request_shape( + &request, + SignerConfigPurpose::StateAnchorBootstrapProvisioning, + ) + .expect_err(label); + assert!( + error.to_string().contains("provisioning"), + "{label}: {error}" + ); + } + } +} diff --git a/pkg/tbtc/signer/src/engine/interactive.rs b/pkg/tbtc/signer/src/engine/interactive.rs index 7513b8894e..6c546cb538 100644 --- a/pkg/tbtc/signer/src/engine/interactive.rs +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -2008,10 +2008,12 @@ pub(crate) fn zeroize_interactive_round1(interactive: &mut InteractiveSigningSta } } -// Lazy TTL enforcement: every interactive entry point sweeps before -// acting, so an abandoned session's nonces are destroyed the first -// time anything touches the engine after expiry. Expiry has abort -// semantics - the durable consumption markers are untouched. +// Lazy TTL enforcement: every nonce-bearing or mutating interactive endpoint +// sweeps before acting, so an abandoned session's nonces are destroyed before +// another secret can be released or state can change. VerifySignatureShare is +// deliberately read-only for delayed blame checks and never performs this +// sweep. Expiry has abort semantics - durable consumption markers are +// untouched. /// Resolve the session that holds the DKG key material for `key_group`. /// /// Interactive signing runs under a fresh RoastSessionID per message, but a wallet's diff --git a/pkg/tbtc/signer/src/engine/inventory.rs b/pkg/tbtc/signer/src/engine/inventory.rs new file mode 100644 index 0000000000..ffe8c77685 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/inventory.rs @@ -0,0 +1,487 @@ +//! Retained FROST key-package readiness and dynamic state-witness readback. + +use super::*; + +use crate::api::{ + RetainedKeyPackageInventoryEntry, RetainedKeyPackageInventoryPackage, + RetainedKeyPackageInventoryResult, StateWitnessProofEntry, StateWitnessProofRequest, + StateWitnessProofResult, +}; + +pub(crate) const TBTC_SIGNER_RETAINED_KEY_PACKAGE_INVENTORY_SCHEMA: &str = + "tbtc-signer-retained-key-package-inventory/v1"; +pub(crate) const TBTC_SIGNER_STATE_WITNESS_PROOF_REQUEST_SCHEMA: &str = + "tbtc-signer-state-witness-proof-request/v1"; +pub(crate) const TBTC_SIGNER_STATE_WITNESS_PROOF_SCHEMA: &str = + "tbtc-signer-state-witness-proof/v1"; + +const INVENTORY_COMMITMENT_DOMAIN: &[u8] = + b"tbtc-signer-retained-key-package-inventory-commitment-v1\0"; +const PUBLIC_KEY_PACKAGE_COMMITMENT_DOMAIN: &[u8] = + b"tbtc-signer-retained-public-key-package-commitment-v1\0"; +const KEY_PACKAGE_COMMITMENT_DOMAIN: &[u8] = b"tbtc-signer-retained-key-package-commitment-v1\0"; + +#[derive(Clone)] +struct ValidatedInventoryPackage { + participant_seat: u16, + commitment: [u8; 32], +} + +#[derive(Clone)] +struct ValidatedInventoryEntry { + wallet_id: [u8; 32], + key_group: String, + threshold: u16, + participant_count: u16, + share_epoch: u64, + public_key_package_commitment: [u8; 32], + key_packages: Vec, +} + +pub(crate) fn retained_key_package_inventory( +) -> Result { + // Keep the engine guard through store-tip capture. Every state mutation + // takes ENGINE_STATE before the store mutex, so this order is deadlock-free + // and prevents inventory from being paired with another mutation's tip. + let engine = state()?; + let guard = engine + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + + let mut validated_entries = Vec::new(); + for (session_id, session) in &guard.sessions { + let Some(dkg_result) = session.dkg_result.as_ref() else { + if session.dkg_key_packages.is_some() || session.dkg_public_key_package.is_some() { + return Err(EngineError::Internal(format!( + "session [{session_id}] has retained DKG material without a DKG result" + ))); + } + continue; + }; + validated_entries.push(validate_inventory_entry(session_id, session, dkg_result)?); + } + validated_entries.sort_by_key(|entry| entry.wallet_id); + for pair in validated_entries.windows(2) { + if pair[0].wallet_id == pair[1].wallet_id { + return Err(EngineError::Internal(format!( + "multiple retained sessions claim wallet [{}]", + bytes32_hex(pair[0].wallet_id) + ))); + } + } + let inventory_commitment = compute_inventory_commitment(&validated_entries)?; + + let (store_identity, state_tip) = with_state_file_lock(|store| { + let identity = store.identity()?; + let tip = store.state_witness_tip()?; + Ok((identity, tip)) + })?; + + let entries = validated_entries + .into_iter() + .map(|entry| RetainedKeyPackageInventoryEntry { + wallet_id: bytes32_hex(entry.wallet_id), + key_group: entry.key_group, + threshold: entry.threshold, + participant_count: entry.participant_count, + share_epoch: entry.share_epoch, + public_key_package_commitment: bytes32_hex(entry.public_key_package_commitment), + key_packages: entry + .key_packages + .into_iter() + .map(|package| RetainedKeyPackageInventoryPackage { + participant_seat: package.participant_seat, + key_package_commitment: bytes32_hex(package.commitment), + }) + .collect(), + }) + .collect(); + + Ok(RetainedKeyPackageInventoryResult { + schema: TBTC_SIGNER_RETAINED_KEY_PACKAGE_INVENTORY_SCHEMA.to_string(), + store_fingerprint: bytes32_hex(store_identity.fingerprint), + state_generation: state_tip.generation, + state_commitment: bytes32_hex(state_tip.commitment), + previous_state_commitment: bytes32_hex(state_tip.previous_commitment), + state_image_digest: bytes32_hex(state_tip.state_image_digest), + inventory_commitment: bytes32_hex(inventory_commitment), + entries, + }) +} + +pub(crate) fn state_witness_proof( + request: StateWitnessProofRequest, +) -> Result { + if request.schema != TBTC_SIGNER_STATE_WITNESS_PROOF_REQUEST_SCHEMA { + return Err(EngineError::Validation( + "unsupported state witness proof request schema".to_string(), + )); + } + if request.ancestor_generation == 0 || request.target_generation == 0 { + return Err(EngineError::Validation( + "state witness proof generations must be positive".to_string(), + )); + } + if request.maximum_entries == 0 || request.maximum_entries > 256 { + return Err(EngineError::Validation( + "state witness proof maximumEntries must be between 1 and 256".to_string(), + )); + } + let requested_store_fingerprint = + parse_bytes32(&request.store_fingerprint, "storeFingerprint")?; + let ancestor_commitment = parse_bytes32(&request.ancestor_commitment, "ancestorCommitment")?; + let target_commitment = parse_bytes32(&request.target_commitment, "targetCommitment")?; + + let (actual_store_fingerprint, entries, complete) = with_state_file_lock(|store| { + let identity = store.identity()?; + if identity.fingerprint != requested_store_fingerprint { + return Err(EngineError::Validation( + "state witness proof storeFingerprint does not match the active store".to_string(), + )); + } + let (entries, complete) = store.state_witness_proof( + request.ancestor_generation, + ancestor_commitment, + request.target_generation, + target_commitment, + request.maximum_entries as usize, + )?; + Ok((identity.fingerprint, entries, complete)) + })?; + + Ok(StateWitnessProofResult { + schema: TBTC_SIGNER_STATE_WITNESS_PROOF_SCHEMA.to_string(), + store_fingerprint: bytes32_hex(actual_store_fingerprint), + ancestor_generation: request.ancestor_generation, + ancestor_commitment: bytes32_hex(ancestor_commitment), + target_generation: request.target_generation, + target_commitment: bytes32_hex(target_commitment), + complete, + entries: entries + .into_iter() + .map(|entry| StateWitnessProofEntry { + generation: entry.generation, + previous_state_commitment: bytes32_hex(entry.previous_commitment), + state_commitment: bytes32_hex(entry.commitment), + state_image_digest: bytes32_hex(entry.state_image_digest), + }) + .collect(), + }) +} + +fn validate_inventory_entry( + session_id: &str, + session: &SessionState, + dkg_result: &DkgResult, +) -> Result { + if dkg_result.session_id != session_id { + return Err(EngineError::Internal(format!( + "retained DKG result session [{}] does not match owner session [{session_id}]", + dkg_result.session_id + ))); + } + if session.dkg_share_epoch != 0 { + return Err(EngineError::Internal(format!( + "retained wallet [{}] has unsupported key-package share epoch [{}]", + dkg_result.key_group, session.dkg_share_epoch + ))); + } + let (wallet_id, compressed_key_group) = parse_key_group(&dkg_result.key_group)?; + if dkg_result.threshold < 2 || dkg_result.participant_count < dkg_result.threshold { + return Err(EngineError::Internal(format!( + "retained wallet [{}] has invalid threshold/participant count", + dkg_result.key_group + ))); + } + let public_package = session.dkg_public_key_package.as_ref().ok_or_else(|| { + EngineError::Internal(format!( + "retained wallet [{}] is missing its public key package", + dkg_result.key_group + )) + })?; + let public_verifying_key = public_package + .verifying_key() + .serialize() + .map_err(|error| { + EngineError::Internal(format!( + "failed to serialize retained wallet verifying key: {error}" + )) + })?; + if public_verifying_key.as_slice() != compressed_key_group { + return Err(EngineError::Internal(format!( + "retained wallet key group [{}] differs from its public key package", + dkg_result.key_group + ))); + } + if public_package.verifying_shares().len() != dkg_result.participant_count as usize { + return Err(EngineError::Internal(format!( + "retained wallet [{}] participant count differs from its public package", + dkg_result.key_group + ))); + } + + let key_packages = session.dkg_key_packages.as_ref().ok_or_else(|| { + EngineError::Internal(format!( + "retained wallet [{}] has no local key packages", + dkg_result.key_group + )) + })?; + if key_packages.is_empty() { + return Err(EngineError::Internal(format!( + "retained wallet [{}] has an empty local key-package set", + dkg_result.key_group + ))); + } + + let public_serialized = public_package.serialize().map_err(|error| { + EngineError::Internal(format!( + "failed to serialize retained public key package: {error}" + )) + })?; + let public_key_package_commitment = public_key_package_commitment( + &wallet_id, + &dkg_result.key_group, + dkg_result.threshold, + dkg_result.participant_count, + session.dkg_share_epoch, + &public_serialized, + ); + + let mut packages = Vec::with_capacity(key_packages.len()); + for (participant_seat, key_package) in key_packages { + if *participant_seat == 0 { + return Err(EngineError::Internal(format!( + "retained wallet [{}] has zero participant seat", + dkg_result.key_group + ))); + } + let expected_identifier = participant_identifier_to_frost_identifier(*participant_seat)?; + if *key_package.identifier() != expected_identifier { + return Err(EngineError::Internal(format!( + "retained wallet [{}] seat [{}] key-package identifier mismatch", + dkg_result.key_group, participant_seat + ))); + } + if *key_package.min_signers() != dkg_result.threshold + || key_package.verifying_key() != public_package.verifying_key() + { + return Err(EngineError::Internal(format!( + "retained wallet [{}] seat [{}] key package has incompatible threshold or group key", + dkg_result.key_group, participant_seat + ))); + } + let expected_share = public_package + .verifying_shares() + .get(&expected_identifier) + .ok_or_else(|| { + EngineError::Internal(format!( + "retained wallet [{}] seat [{}] is absent from its public package", + dkg_result.key_group, participant_seat + )) + })?; + if key_package.verifying_share() != expected_share { + return Err(EngineError::Internal(format!( + "retained wallet [{}] seat [{}] verifying share mismatch", + dkg_result.key_group, participant_seat + ))); + } + let mut signing_share = *key_package.signing_share(); + let derives = frost::keys::VerifyingShare::from(signing_share) == *expected_share; + signing_share.zeroize(); + if !derives { + return Err(EngineError::Internal(format!( + "retained wallet [{}] seat [{}] signing share is not ready", + dkg_result.key_group, participant_seat + ))); + } + + let identifier_bytes = key_package.identifier().serialize(); + let verifying_share_bytes = key_package.verifying_share().serialize().map_err(|error| { + EngineError::Internal(format!( + "failed to serialize retained wallet verifying share: {error}" + )) + })?; + packages.push(ValidatedInventoryPackage { + participant_seat: *participant_seat, + commitment: key_package_commitment( + &wallet_id, + &dkg_result.key_group, + *participant_seat, + session.dkg_share_epoch, + identifier_bytes.as_ref(), + verifying_share_bytes.as_ref(), + &public_verifying_key, + *key_package.min_signers(), + ), + }); + } + packages.sort_by_key(|package| package.participant_seat); + + Ok(ValidatedInventoryEntry { + wallet_id, + key_group: dkg_result.key_group.clone(), + threshold: dkg_result.threshold, + participant_count: dkg_result.participant_count, + share_epoch: session.dkg_share_epoch, + public_key_package_commitment, + key_packages: packages, + }) +} + +pub(crate) fn parse_key_group(key_group: &str) -> Result<([u8; 32], [u8; 33]), EngineError> { + if key_group.len() != 66 || key_group != key_group.to_ascii_lowercase() { + return Err(EngineError::Internal( + "retained wallet key group is not canonical lowercase compressed SEC1 hex".to_string(), + )); + } + let bytes = hex::decode(key_group).map_err(|_| { + EngineError::Internal("retained wallet key group is not valid hex".to_string()) + })?; + let public_key = bitcoin::secp256k1::PublicKey::from_slice(&bytes).map_err(|_| { + EngineError::Internal( + "retained wallet key group is not a compressed secp256k1 public key".to_string(), + ) + })?; + let mut compressed = [0u8; 33]; + compressed.copy_from_slice(&bytes); + if public_key.serialize() != compressed { + return Err(EngineError::Internal( + "retained wallet key group is not canonical compressed SEC1".to_string(), + )); + } + let (x_only, _) = public_key.x_only_public_key(); + Ok((x_only.serialize(), compressed)) +} + +fn parse_bytes32(value: &str, label: &str) -> Result<[u8; 32], EngineError> { + if value.len() != 66 || !value.starts_with("0x") || value != value.to_ascii_lowercase() { + return Err(EngineError::Validation(format!( + "{label} must be canonical lowercase 0x-prefixed bytes32" + ))); + } + let decoded = hex::decode(&value[2..]).map_err(|_| { + EngineError::Validation(format!( + "{label} must be canonical lowercase 0x-prefixed bytes32" + )) + })?; + let mut result = [0u8; 32]; + result.copy_from_slice(&decoded); + if result == [0u8; 32] { + return Err(EngineError::Validation(format!("{label} must not be zero"))); + } + Ok(result) +} + +pub(crate) fn bytes32_hex(value: [u8; 32]) -> String { + format!("0x{}", hex::encode(value)) +} + +fn public_key_package_commitment( + wallet_id: &[u8; 32], + key_group: &str, + threshold: u16, + participant_count: u16, + share_epoch: u64, + serialized_public_package: &[u8], +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(PUBLIC_KEY_PACKAGE_COMMITMENT_DOMAIN); + digest.update(wallet_id); + write_length_prefixed(&mut digest, key_group.as_bytes()); + digest.update(threshold.to_be_bytes()); + digest.update(participant_count.to_be_bytes()); + digest.update(share_epoch.to_be_bytes()); + write_length_prefixed(&mut digest, serialized_public_package); + digest.finalize().into() +} + +#[allow(clippy::too_many_arguments)] +fn key_package_commitment( + wallet_id: &[u8; 32], + key_group: &str, + participant_seat: u16, + share_epoch: u64, + identifier: &[u8], + verifying_share: &[u8], + verifying_key: &[u8], + min_signers: u16, +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(KEY_PACKAGE_COMMITMENT_DOMAIN); + digest.update(wallet_id); + write_length_prefixed(&mut digest, key_group.as_bytes()); + digest.update(participant_seat.to_be_bytes()); + digest.update(share_epoch.to_be_bytes()); + write_length_prefixed(&mut digest, identifier); + write_length_prefixed(&mut digest, verifying_share); + write_length_prefixed(&mut digest, verifying_key); + digest.update(min_signers.to_be_bytes()); + digest.finalize().into() +} + +fn compute_inventory_commitment( + entries: &[ValidatedInventoryEntry], +) -> Result<[u8; 32], EngineError> { + let entry_count = u32::try_from(entries.len()).map_err(|_| { + EngineError::Internal("retained key-package inventory has too many entries".to_string()) + })?; + let mut digest = Sha256::new(); + digest.update(INVENTORY_COMMITMENT_DOMAIN); + digest.update(entry_count.to_be_bytes()); + for entry in entries { + digest.update(entry.wallet_id); + write_length_prefixed(&mut digest, entry.key_group.as_bytes()); + digest.update(entry.threshold.to_be_bytes()); + digest.update(entry.participant_count.to_be_bytes()); + digest.update(entry.share_epoch.to_be_bytes()); + digest.update(entry.public_key_package_commitment); + let package_count = u32::try_from(entry.key_packages.len()).map_err(|_| { + EngineError::Internal( + "retained key-package inventory entry has too many packages".to_string(), + ) + })?; + digest.update(package_count.to_be_bytes()); + for package in &entry.key_packages { + digest.update(package.participant_seat.to_be_bytes()); + digest.update(package.commitment); + } + } + Ok(digest.finalize().into()) +} + +fn write_length_prefixed(destination: &mut Sha256, value: &[u8]) { + destination.update((value.len() as u32).to_be_bytes()); + destination.update(value); +} + +#[cfg(test)] +mod inventory_transcript_tests { + use super::*; + + #[test] + fn inventory_commitment_matches_frozen_go_v1_vector() { + let entries = vec![ValidatedInventoryEntry { + wallet_id: [0x11; 32], + key_group: format!("02{}", "11".repeat(32)), + threshold: 2, + participant_count: 3, + share_epoch: 0, + public_key_package_commitment: [0x33; 32], + key_packages: vec![ + ValidatedInventoryPackage { + participant_seat: 1, + commitment: [0x44; 32], + }, + ValidatedInventoryPackage { + participant_seat: 3, + commitment: [0x55; 32], + }, + ], + }]; + + assert_eq!( + hex::encode(compute_inventory_commitment(&entries).expect("inventory commitment")), + "bd6ec36fa27a57dd9926883bb2ff4dee7ececd28de940df7294f0e0f0dedd150" + ); + } +} diff --git a/pkg/tbtc/signer/src/engine/mod.rs b/pkg/tbtc/signer/src/engine/mod.rs index 00acf364f0..a2481637d1 100644 --- a/pkg/tbtc/signer/src/engine/mod.rs +++ b/pkg/tbtc/signer/src/engine/mod.rs @@ -12,12 +12,14 @@ //! - [`dkg`] — distributed-DKG key-package persistence (`persist_distributed_dkg_key_package`). //! - [`frost_ops`] — Stateless FROST primitives: dkg_part1..3 and signing-package assembly. //! - [`interactive`] — Phase 7.1 hardened interactive signing session: engine-held nonce custody, Round1/Round2, consumption markers. +//! - [`inventory`] — Public retained-key inventory and bounded witness-proof responses. //! - [`lifecycle`] — Operational lifecycle: canary rollout, refresh cadence/shares, emergency rekey, quarantine status. //! - [`persistence`] — Encrypted state-file persistence: envelope codec, key providers, corruption recovery, persisted<->live conversions. //! - [`policy`] — Admission, signing-policy firewall, rate limiting, and auto-quarantine enforcement. //! - [`provenance`] — Runtime provenance attestation gate. //! - [`roast`] — ROAST/RFC-21 attempt machinery: request fingerprints, round/attempt ids, attempt-context and transition-evidence validation. -//! - [`state`] — In-memory engine/session state, the state-file lock, and registry capacity guards. +//! - [`state`] — In-memory engine/session state and registry capacity guards. +//! - [`store`] — Descriptor-bound durable identity, atomic state replacement, and append-only witness journal. //! - [`telemetry`] — Hardening telemetry: latency trackers and metrics reporting. //! - [`transaction`] — Taproot transaction building. //! - [`testsupport`] — Cross-module test helpers (cfg(test)): state lock, reset, restart simulation. @@ -36,14 +38,10 @@ use bitcoin::{ }; use chacha20poly1305::aead::{Aead, KeyInit, OsRng, Payload}; use chacha20poly1305::{XChaCha20Poly1305, XNonce}; -#[cfg(unix)] -use libc::{flock, EAGAIN, EWOULDBLOCK, LOCK_EX, LOCK_NB}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use std::fs; use std::io::{Read, Write}; #[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; -#[cfg(unix)] use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Output, Stdio}; @@ -76,14 +74,20 @@ use crate::api::{ PersistDistributedDkgKeyPackageRequest, PromoteCanaryRequest, PromoteCanaryResult, QuarantineStatusRequest, QuarantineStatusResult, RefreshCadenceStatusRequest, RefreshCadenceStatusResult, RefreshSharesRequest, RefreshSharesResult, + RetireDistributedDkgKeyPackagesRequest, RetireDistributedDkgKeyPackagesResult, RoastLivenessPolicyResult, RollbackCanaryRequest, RollbackCanaryResult, RoundState, SecretHex, - SignatureResult, SignerHardeningMetricsResult, TransactionResult, TranscriptAuditRecord, - TranscriptAuditRequest, TranscriptAuditResult, TriggerEmergencyRekeyRequest, - TriggerEmergencyRekeyResult, VerifyBlameProofRequest, + SignatureResult, SignerHardeningMetricsResult, StateAnchorBootstrapFactsResult, + StateAnchorTrustCertificate, StateAnchorTrustCheckpoint, StateAnchorTrustEndpoint, + StateAnchorTrustHeadResult, StateAnchorTrustReference, TransactionResult, + TranscriptAuditRecord, TranscriptAuditRequest, TranscriptAuditResult, + TransitionStateWitnessAnchorRequest, TransitionStateWitnessAnchorResult, + TriggerEmergencyRekeyRequest, TriggerEmergencyRekeyResult, VerifyBlameProofRequest, }; -use crate::errors::EngineError; +use crate::errors::{EngineError, StateAnchorTrustRecoveryContext}; use crate::go_math_rand::select_coordinator_identifier; +mod anchor; +mod anchor_trust; mod audit; mod codec; mod config; @@ -91,12 +95,14 @@ mod dkg; mod frost_ops; mod init_config; mod interactive; +mod inventory; mod lifecycle; mod persistence; mod policy; mod provenance; mod roast; mod state; +mod store; mod telemetry; #[cfg(test)] mod tests; @@ -105,6 +111,8 @@ mod testsupport; mod transaction; mod verify_share; +pub(crate) use anchor::*; +pub(crate) use anchor_trust::*; pub(crate) use audit::*; pub(crate) use codec::*; pub(crate) use config::*; @@ -112,12 +120,14 @@ pub(crate) use dkg::*; pub(crate) use frost_ops::*; pub(crate) use init_config::*; pub(crate) use interactive::*; +pub(crate) use inventory::*; pub(crate) use lifecycle::*; pub(crate) use persistence::*; pub(crate) use policy::*; pub(crate) use provenance::*; pub(crate) use roast::*; pub(crate) use state::*; +pub(crate) use store::*; pub(crate) use telemetry::*; #[cfg(test)] pub(crate) use testsupport::*; diff --git a/pkg/tbtc/signer/src/engine/persistence.rs b/pkg/tbtc/signer/src/engine/persistence.rs index fbaeda71a9..d28aea37cf 100644 --- a/pkg/tbtc/signer/src/engine/persistence.rs +++ b/pkg/tbtc/signer/src/engine/persistence.rs @@ -28,6 +28,8 @@ pub(crate) struct PersistedSessionState { pub(crate) dkg_key_packages: Option>, pub(crate) dkg_public_key_package_hex: Option, pub(crate) dkg_result: Option, + #[serde(default)] + pub(crate) dkg_share_epoch: u64, pub(crate) sign_request_fingerprint: Option, pub(crate) sign_message_hex: Option, pub(crate) round_state: Option, @@ -97,6 +99,7 @@ impl std::fmt::Debug for PersistedSessionState { &self.dkg_public_key_package_hex, ) .field("dkg_result", &self.dkg_result) + .field("dkg_share_epoch", &self.dkg_share_epoch) .field("sign_request_fingerprint", &self.sign_request_fingerprint) .field( "sign_message_hex", @@ -758,13 +761,7 @@ set {}={} to quarantine the file and continue with clean state", ))), CorruptStatePolicy::QuarantineAndReset => { let backup_path = corrupted_state_backup_path(path); - fs::rename(path, &backup_path).map_err(|e| { - EngineError::Internal(format!( - "failed to quarantine corrupted signer state file [{}] to [{}]: {e}", - path.display(), - backup_path.display() - )) - })?; + with_state_file_lock_for_load(|store| store.quarantine_state(&backup_path))?; eprintln!( "warning: quarantined corrupted signer state file [{}] to [{}]: {}", @@ -1376,26 +1373,17 @@ pub(crate) fn decode_persisted_state_storage_format( } pub(crate) fn load_engine_state_from_storage() -> Result { + ensure_state_file_lock_for_load()?; let path = active_state_file_path()?; - match fs::symlink_metadata(&path) { - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(EngineState::default()) - } - Err(error) => { - return Err(EngineError::Internal(format!( - "failed to inspect signer state file [{}]: {error}", - path.display() - ))) - } - } - - let mut bytes = fs::read(&path).map_err(|e| { - EngineError::Internal(format!( - "failed to read signer state file [{}]: {e}", - path.display() - )) - })?; + let loaded_image = with_state_file_lock_for_load(|store| store.read_state_for_load())?; + let loaded_digest = loaded_image.digest; + let Some(mut bytes) = loaded_image.bytes else { + // Absence is itself an authenticated state image. A removed live state + // file must not silently become a clean signer merely because there are + // no bytes to decode. + validate_loaded_state_image(&path, loaded_digest)?; + return Ok(EngineState::default()); + }; if bytes.is_empty() { bytes.zeroize(); return recover_or_fail_from_corrupted_state_file( @@ -1444,6 +1432,14 @@ pub(crate) fn load_engine_state_from_storage() -> Result Result Result<(), EngineError> { + if let Err(error) = + with_state_file_lock_for_load(|store| store.validate_loaded_state_image(loaded_digest)) + { + return Err(EngineError::Internal(format!( + "signer state file [{}] does not match its committed state witness: {error}; \ + authenticated rollback evidence is always fail-closed and cannot be handled by \ + the generic corruption reset policy", + path.display() + ))); + } + Ok(()) +} + #[cfg(test)] pub(crate) fn persist_fault_injection_label(point: PersistFaultInjectionPoint) -> &'static str { match point { @@ -1546,72 +1556,26 @@ pub(crate) fn persist_engine_state_to_storage_with_key( engine_state: &EngineState, key_material: &StateEncryptionKeyMaterial, ) -> Result<(), PersistEngineStateError> { - let path = - active_state_file_path().map_err(PersistEngineStateError::before_state_file_replacement)?; let persisted: PersistedEngineState = engine_state .try_into() .map_err(PersistEngineStateError::before_state_file_replacement)?; let mut bytes = encode_encrypted_state_envelope(&persisted, key_material) .map_err(PersistEngineStateError::before_state_file_replacement)?; drop(persisted); - let temp_path = path.with_extension(format!("tmp-{}", std::process::id())); - let mut state_file_replaced = false; - let persist_result = (|| -> Result<(), EngineError> { - if let Some(parent) = state_file_parent_directory(&path) { - fs::create_dir_all(parent).map_err(|e| { - EngineError::Internal(format!( - "failed to create signer state directory [{}]: {e}", - parent.display() - )) - })?; - } - - { - let mut temp_file = { - let mut options = fs::OpenOptions::new(); - options.create(true).truncate(true).write(true); - #[cfg(unix)] - options.mode(0o600); - options.open(&temp_path).map_err(|e| { - EngineError::Internal(format!( - "failed to open signer state temp file [{}]: {e}", - temp_path.display() - )) - })? - }; - temp_file.write_all(bytes.as_ref()).map_err(|e| { - EngineError::Internal(format!( - "failed to write signer state temp file [{}]: {e}", - temp_path.display() - )) - })?; - temp_file.sync_all().map_err(|e| { - EngineError::Internal(format!( - "failed to sync signer state temp file [{}]: {e}", - temp_path.display() - )) - })?; - } - maybe_inject_persist_fault(PersistFaultInjectionPoint::AfterTempSyncBeforeRename)?; - - fs::rename(&temp_path, &path).map_err(|e| { - EngineError::Internal(format!( - "failed to move signer state temp file [{}] to [{}]: {e}", - temp_path.display(), - path.display() + let persist_result = { + ensure_state_file_lock().map_err(PersistEngineStateError::before_state_file_replacement)?; + let mut lock_slot = state_file_lock_slot().lock().map_err(|_| { + PersistEngineStateError::before_state_file_replacement(EngineError::Internal( + "state file lock mutex poisoned".to_string(), )) })?; - state_file_replaced = true; - maybe_inject_persist_fault(PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync)?; - - sync_state_file_parent_directory(&path)?; - - Ok(()) - })(); - - if persist_result.is_err() { - let _ = fs::remove_file(&temp_path); - } + let store = lock_slot.as_mut().ok_or_else(|| { + PersistEngineStateError::before_state_file_replacement(EngineError::Internal( + "signer durable store lock is not initialized".to_string(), + )) + })?; + store.replace_state(bytes.as_ref()) + }; bytes.zeroize(); match persist_result { @@ -1619,11 +1583,11 @@ pub(crate) fn persist_engine_state_to_storage_with_key( clear_snapshot_covered_operations(engine_state); Ok(()) } - Err(error) if state_file_replaced => { - Err(PersistEngineStateError::after_state_file_replacement(error)) - } + Err(error) if error.replaced() => Err( + PersistEngineStateError::after_state_file_replacement(error.into_engine_error()), + ), Err(error) => Err(PersistEngineStateError::before_state_file_replacement( - error, + error.into_engine_error(), )), } } @@ -1755,6 +1719,13 @@ impl TryFrom for SessionState { type Error = EngineError; fn try_from(persisted: PersistedSessionState) -> Result { + if persisted.dkg_share_epoch != 0 { + return Err(EngineError::Internal(format!( + "persisted key-package share epoch [{}] is unsupported; this signer accepts only \ + epoch 0 until cryptographic refresh is implemented", + persisted.dkg_share_epoch + ))); + } let dkg_key_packages = persisted .dkg_key_packages .map(|persisted_key_packages| { @@ -2017,6 +1988,7 @@ impl TryFrom for SessionState { dkg_key_packages, dkg_public_key_package, dkg_result: persisted.dkg_result, + dkg_share_epoch: persisted.dkg_share_epoch, sign_request_fingerprint: persisted.sign_request_fingerprint, sign_message_bytes, round_state: persisted.round_state, @@ -2072,6 +2044,13 @@ impl TryFrom<&SessionState> for PersistedSessionState { type Error = EngineError; fn try_from(session_state: &SessionState) -> Result { + if session_state.dkg_share_epoch != 0 { + return Err(EngineError::Internal(format!( + "key-package share epoch [{}] is unsupported; refusing to persist synthetic \ + refresh state", + session_state.dkg_share_epoch + ))); + } let dkg_key_packages = session_state .dkg_key_packages .as_ref() @@ -2196,6 +2175,7 @@ impl TryFrom<&SessionState> for PersistedSessionState { dkg_key_packages, dkg_public_key_package_hex, dkg_result: session_state.dkg_result.clone(), + dkg_share_epoch: session_state.dkg_share_epoch, sign_request_fingerprint: session_state.sign_request_fingerprint.clone(), sign_message_hex, round_state: session_state.round_state.clone(), diff --git a/pkg/tbtc/signer/src/engine/state.rs b/pkg/tbtc/signer/src/engine/state.rs index 4ad0ffa106..ac238cc660 100644 --- a/pkg/tbtc/signer/src/engine/state.rs +++ b/pkg/tbtc/signer/src/engine/state.rs @@ -102,6 +102,10 @@ pub(crate) struct SessionState { pub(crate) dkg_key_packages: Option>, pub(crate) dkg_public_key_package: Option, pub(crate) dkg_result: Option, + /// Epoch of the retained cryptographic key packages. The current ABI-4 + /// signer deliberately rejects synthetic share refresh, so zero is the only + /// supported value until a real atomic replacement protocol is introduced. + pub(crate) dkg_share_epoch: u64, pub(crate) sign_request_fingerprint: Option, pub(crate) sign_message_bytes: Option, pub(crate) round_state: Option, @@ -240,113 +244,121 @@ pub(crate) enum CorruptStatePolicy { QuarantineAndReset, } -pub(crate) struct StateFileLock { - pub(crate) _file: fs::File, - pub(crate) state_path: PathBuf, - pub(crate) lock_path: PathBuf, +pub(crate) fn state_file_lock_slot() -> &'static Mutex> { + STATE_FILE_LOCK.get_or_init(|| Mutex::new(None)) } -impl StateFileLock { - pub(crate) fn acquire(state_path: &Path) -> Result { - let lock_path = state_lock_file_path(state_path); - if let Some(parent) = lock_path.parent() { - fs::create_dir_all(parent).map_err(|e| { - EngineError::Internal(format!( - "failed to create signer state lock directory [{}]: {e}", - parent.display() - )) - })?; - } - - let mut lock_file = fs::OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(&lock_path) - .map_err(|e| { - EngineError::Internal(format!( - "failed to open signer state lock file [{}]: {e}", - lock_path.display() - )) - })?; - - #[cfg(unix)] - { - use std::os::fd::AsRawFd; - - let rc = unsafe { flock(lock_file.as_raw_fd(), LOCK_EX | LOCK_NB) }; - if rc != 0 { - let lock_error = std::io::Error::last_os_error(); - if lock_error - .raw_os_error() - .is_some_and(is_lock_contention_errno) - { - return Err(EngineError::Internal(format!( - "signer state lock already held by another process [{}]", - lock_path.display() - ))); - } +/// Executes the offline-certified trust transition before any ordinary store +/// or engine access can win the startup race. Lock order intentionally matches +/// `state()`: engine-initialization gate first, then the store slot. +pub(crate) fn with_startup_state_anchor_trust_transition( + transition: &VerifiedStateAnchorTrustTransition, + operation: impl FnOnce(&mut StateFileLock) -> Result, +) -> Result { + require_installed_signer_config()?; + let _initialization_guard = ENGINE_STATE_INITIALIZATION_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if ENGINE_STATE.get().is_some() { + return Err(EngineError::Validation( + "state-anchor trust transition is startup-only and the signer engine is initialized" + .to_string(), + )); + } + let slot = state_file_lock_slot() + .lock() + .map_err(|_| EngineError::Internal("state file lock mutex poisoned".to_string()))?; + if slot.is_some() { + return Err(EngineError::Validation( + "state-anchor trust transition is startup-only and the durable store was opened" + .to_string(), + )); + } + let state_path = state_file_path()?; + let mut store = StateFileLock::acquire_for_trust_transition(&state_path, transition)?; + operation(&mut store) +} + +/// Reads the durable state-anchor trust head without making a read-only +/// preflight claim initialize the process-wide signer store. Before any normal +/// engine/store access, a dedicated inspection acquisition holds the same +/// descriptor-bound OS lock and performs the trust-specific validation, then +/// drops it when the operation completes. If the store is already open, use +/// that held descriptor set so replacement checks and in-process path +/// consistency remain identical to every other stateful call. +pub(crate) fn with_startup_state_anchor_trust_head_inspection( + operation: impl FnOnce(&mut StateFileLock) -> Result, +) -> Result { + require_installed_signer_config()?; + let _initialization_guard = ENGINE_STATE_INITIALIZATION_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let state_path = state_file_path()?; + let mut slot = state_file_lock_slot() + .lock() + .map_err(|_| EngineError::Internal("state file lock mutex poisoned".to_string()))?; - return Err(EngineError::Internal(format!( - "failed to lock signer state file [{}]: {lock_error}", - lock_path.display() - ))); - } + if let Some(store) = slot.as_mut() { + if store.state_path != state_path { + return Err(EngineError::Internal(format!( + "state file lock already initialized for [{}] with lock [{}]; refusing to switch to [{}] in-process", + store.state_path.display(), + store.lock_path.display(), + state_path.display() + ))); } - - lock_file.set_len(0).map_err(|e| { - EngineError::Internal(format!( - "failed to truncate signer state lock file [{}]: {e}", - lock_path.display() - )) - })?; - writeln!( - lock_file, - "pid={}\nstate_path={}", - std::process::id(), - state_path.display() - ) - .map_err(|e| { - EngineError::Internal(format!( - "failed to write signer state lock file [{}]: {e}", - lock_path.display() - )) - })?; - lock_file.sync_all().map_err(|e| { - EngineError::Internal(format!( - "failed to sync signer state lock file [{}]: {e}", - lock_path.display() - )) - })?; - - Ok(Self { - _file: lock_file, - state_path: state_path.to_path_buf(), - lock_path, - }) + store.revalidate_store_entries()?; + return operation(store); } -} -pub(crate) fn state_file_lock_slot() -> &'static Mutex> { - STATE_FILE_LOCK.get_or_init(|| Mutex::new(None)) + let mut store = StateFileLock::acquire_for_trust_head_inspection(&state_path)?; + operation(&mut store) } -#[cfg(unix)] -pub(crate) fn is_lock_contention_errno(errno: i32) -> bool { - errno == EAGAIN || errno == EWOULDBLOCK +/// Provisioning-only, ephemeral acquisition used to export the stable store +/// fingerprint and exact pristine genesis checkpoint for offline bootstrap +/// certification. It must never populate the process-wide engine/store slots. +pub(crate) fn with_startup_state_anchor_bootstrap_facts( + operation: impl FnOnce(&mut StateFileLock) -> Result, +) -> Result { + require_state_anchor_bootstrap_provisioning_config()?; + let _initialization_guard = ENGINE_STATE_INITIALIZATION_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if ENGINE_STATE.get().is_some() { + return Err(EngineError::Validation( + "state-anchor bootstrap provisioning is unavailable after engine initialization" + .to_string(), + )); + } + let slot = state_file_lock_slot() + .lock() + .map_err(|_| EngineError::Internal("state file lock mutex poisoned".to_string()))?; + if slot.is_some() { + return Err(EngineError::Validation( + "state-anchor bootstrap provisioning requires an unopened process-wide store" + .to_string(), + )); + } + let state_path = state_file_path()?; + let mut store = StateFileLock::acquire_for_bootstrap_facts(&state_path)?; + operation(&mut store) } pub(crate) fn state() -> Result<&'static Mutex, EngineError> { - ensure_state_file_lock()?; warn_disabled_policy_gates(); - initialize_engine_state_with_loader( + let engine = initialize_engine_state_with_loader( &ENGINE_STATE, &ENGINE_STATE_INITIALIZATION_LOCK, || {}, load_engine_state_from_storage, - ) + )?; + // The loader uses the startup-only validation path so it can apply the + // explicit corruption policy. Once an EngineState exists, every caller + // must satisfy the full state-image/witness check. + ensure_state_file_lock()?; + Ok(engine) } /// Installs the first engine state while serializing the complete fallible load @@ -442,13 +454,53 @@ pub(crate) fn state_lock_file_path(state_path: &Path) -> PathBuf { } pub(crate) fn ensure_state_file_lock() -> Result<(), EngineError> { + require_normal_signer_purpose()?; let state_path = state_file_path()?; let mut lock_slot = state_file_lock_slot() .lock() .map_err(|_| EngineError::Internal("state file lock mutex poisoned".to_string()))?; - if let Some(existing_lock) = lock_slot.as_ref() { + if let Some(existing_lock) = lock_slot.as_mut() { if existing_lock.state_path == state_path { + // `state()` is the front door for every stateful signer operation. + // Revalidate the held no-follow store on every call so a lock, + // store-ID, directory, witness, or state replacement after startup + // cannot be hidden behind the initialized in-memory state. + existing_lock.identity()?; + return Ok(()); + } + + return Err(EngineError::Internal(format!( + "state file lock already initialized for [{}] with lock [{}]; refusing to switch to [{}] in-process", + existing_lock.state_path.display(), + existing_lock.lock_path.display(), + state_path.display() + ))); + } + + // Acquisition validates descriptor structure and the journal. Validate the + // state image against the committed tip before exposing the store through + // the ordinary stateful-operation front door. + let mut acquired = StateFileLock::acquire(&state_path)?; + acquired.identity()?; + *lock_slot = Some(acquired); + Ok(()) +} + +/// Startup-only variant which validates the descriptor-bound store and witness +/// journal but defers the state-image digest comparison until after decoding. +/// This lets the explicit corruption policy quarantine malformed state without +/// allowing a valid rollback image into `EngineState`. +pub(crate) fn ensure_state_file_lock_for_load() -> Result<(), EngineError> { + require_normal_signer_purpose()?; + let state_path = state_file_path()?; + let mut lock_slot = state_file_lock_slot() + .lock() + .map_err(|_| EngineError::Internal("state file lock mutex poisoned".to_string()))?; + + if let Some(existing_lock) = lock_slot.as_mut() { + if existing_lock.state_path == state_path { + existing_lock.revalidate_store_entries()?; return Ok(()); } @@ -464,6 +516,76 @@ pub(crate) fn ensure_state_file_lock() -> Result<(), EngineError> { Ok(()) } +pub(crate) fn with_state_file_lock( + operation: impl FnOnce(&mut StateFileLock) -> Result, +) -> Result { + ensure_state_file_lock()?; + let mut lock_slot = state_file_lock_slot() + .lock() + .map_err(|_| EngineError::Internal("state file lock mutex poisoned".to_string()))?; + let store = lock_slot.as_mut().ok_or_else(|| { + EngineError::Internal("signer durable store lock is not initialized".to_string()) + })?; + operation(store) +} + +pub(crate) fn with_state_file_lock_for_load( + operation: impl FnOnce(&mut StateFileLock) -> Result, +) -> Result { + ensure_state_file_lock_for_load()?; + let mut lock_slot = state_file_lock_slot() + .lock() + .map_err(|_| EngineError::Internal("state file lock mutex poisoned".to_string()))?; + let store = lock_slot.as_mut().ok_or_else(|| { + EngineError::Internal("signer durable store lock is not initialized".to_string()) + })?; + operation(store) +} + +/// Applies a checkpoint acknowledgement before a mandatory startup rewrite. +/// +/// Once the engine is initialized, preserve the ordinary ENGINE_STATE -> +/// durable-store lock order. Before initialization, the initialization mutex +/// excludes every stateful caller, so the exact-tip acknowledgement can rotate +/// a full witness segment through the load-safe structural store path. Only +/// then is the state loaded and any required migration persisted. +pub(crate) fn with_state_file_lock_before_startup_rewrite( + operation: impl FnOnce(&mut StateFileLock) -> Result, +) -> Result { + if let Some(engine) = ENGINE_STATE.get() { + let _guard = engine + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + return with_state_file_lock(operation); + } + + let initialization_guard = ENGINE_STATE_INITIALIZATION_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(engine) = ENGINE_STATE.get() { + let _guard = engine + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + return with_state_file_lock(operation); + } + + let outcome = with_state_file_lock_for_load(operation)?; + drop(initialization_guard); + + // Loading after the acknowledgement is the security boundary: a segment + // at its terminal limit is rotated before a legacy-envelope or bounded + // session-registry rewrite attempts to append its own witness records. + state()?; + Ok(outcome) +} + +pub(crate) fn durable_store_identity() -> Result { + // Store identity is deliberately available before state classification. + // Use the load-safe structural path so a malformed image can still reach + // the configured quarantine-and-reset policy during the subsequent load. + with_state_file_lock_for_load(|store| store.identity_for_load()) +} + pub(crate) fn state_corruption_policy() -> CorruptStatePolicy { let policy = signer_env_var(TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV) .map(|value| value.trim().to_ascii_lowercase()) @@ -542,6 +664,7 @@ pub(crate) fn per_message_interactive_session(session: &SessionState) -> bool { dkg_key_packages, dkg_public_key_package, dkg_result, + dkg_share_epoch, sign_request_fingerprint, sign_message_bytes, round_state, @@ -596,6 +719,7 @@ pub(crate) fn per_message_interactive_session(session: &SessionState) -> bool { consumed_interactive_attempt_markers, authorized_interactive_aggregate_markers, aggregated_interactive_attempt_markers, + dkg_share_epoch, ); bound_key_group.is_some() @@ -603,6 +727,7 @@ pub(crate) fn per_message_interactive_session(session: &SessionState) -> bool { && dkg_key_packages.is_none() && dkg_public_key_package.is_none() && dkg_result.is_none() + && *dkg_share_epoch == 0 } pub(crate) fn retire_idle_per_message_sessions( diff --git a/pkg/tbtc/signer/src/engine/store.rs b/pkg/tbtc/signer/src/engine/store.rs new file mode 100644 index 0000000000..795800ea4a --- /dev/null +++ b/pkg/tbtc/signer/src/engine/store.rs @@ -0,0 +1,9362 @@ +//! Descriptor-bound durable signer store. +//! +//! The state image is atomically replaced, so its inode cannot be the store +//! identity. The identity is instead anchored by a stable, fsynced store ID, +//! a stable exclusively-locked lock file, and the no-follow directory handle +//! through which every state operation is performed. +//! +//! This stable v2 identity proves which local store is open; it intentionally +//! does not claim pre-start state freshness or key-package inventory. A caller +//! must reconcile those through the separate inventory/witness contract. +//! +//! # Stable identity versus volatile descriptors (transcript v2) +//! +//! The store fingerprint that anchors the state-commitment chain binds ONLY the +//! stable, fsynced `.store-id` bytes. Path, device, and inode descriptors are +//! still validated on every access - they are the real defense against store +//! substitution - and are still reported for diagnostics, but they are NOT part +//! of any committed transcript. Under the retired v1 transcript they were: a +//! deleted lock file, a restore-from-backup at the same path, a renamed +//! directory, or a remount that moved `st_dev` silently invalidated every +//! committed record and left the signer unstartable, with `rm .state-witness` +//! (a generation-1 re-genesis, i.e. exactly the rollback the journal exists to +//! detect) as the only remaining operator action. + +use super::*; + +#[cfg(unix)] +use std::ffi::CString; +use std::ffi::{OsStr, OsString}; +#[cfg(unix)] +use std::io::{Seek, SeekFrom}; + +#[cfg(unix)] +use std::os::fd::{AsRawFd, FromRawFd, RawFd}; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; + +pub(crate) const TBTC_SIGNER_DURABLE_STORE_IDENTITY_SCHEMA: &str = + "tbtc-signer-durable-session-store-identity/v2"; +pub(crate) const TBTC_SIGNER_DURABLE_STORE_BACKEND: &str = "encrypted-file-v1"; +pub(crate) const TBTC_SIGNER_DURABLE_STORE_ID_SUFFIX: &str = ".store-id"; +pub(crate) const TBTC_SIGNER_STATE_WITNESS_SUFFIX: &str = ".state-witness"; +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_SUFFIX: &str = ".state-anchor"; +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_TRUST_SUFFIX: &str = ".state-anchor-trust"; +pub(crate) const TBTC_SIGNER_STATE_ANCHOR_TRUST_INTENT_SUFFIX: &str = ".state-anchor-trust.intent"; +const TBTC_SIGNER_STATE_WITNESS_NEXT_SUFFIX: &str = ".next"; +const TBTC_SIGNER_STATE_WITNESS_PREVIOUS_SUFFIX: &str = ".previous"; + +const TBTC_SIGNER_DURABLE_STORE_FINGERPRINT_DOMAIN: &[u8] = + b"tbtc-signer-durable-session-store-fingerprint-v2\0"; +const TBTC_SIGNER_DURABLE_STORE_PATH_FINGERPRINT_DOMAIN: &[u8] = + b"tbtc-signer-durable-session-store-canonical-path-v1\0"; +const TBTC_SIGNER_DURABLE_STORE_FILESYSTEM_FINGERPRINT_DOMAIN: &[u8] = + b"tbtc-signer-durable-session-store-filesystem-v1\0"; +const TBTC_SIGNER_DURABLE_STORE_LOCK_FINGERPRINT_DOMAIN: &[u8] = + b"tbtc-signer-durable-session-store-lock-v1\0"; +const TBTC_SIGNER_STATE_IMAGE_DIGEST_DOMAIN: &[u8] = b"tbtc-signer-durable-state-image-digest-v1\0"; +const TBTC_SIGNER_STATE_WITNESS_GENESIS_DOMAIN: &[u8] = b"tbtc-signer-state-witness-genesis-v2\0"; +const TBTC_SIGNER_STATE_COMMITMENT_DOMAIN: &[u8] = b"tbtc-signer-state-witness-commitment-v2\0"; +const TBTC_SIGNER_STATE_WITNESS_MAGIC: &[u8; 16] = b"TBTCWITNESSv2\0\0\0"; +const TBTC_SIGNER_STATE_WITNESS_SEGMENT_MAGIC: &[u8; 16] = b"TBTCWITNESSSEG1\0"; +/// The retired v1 journal magic. It is never written and never repaired; it is +/// recognized only so a v1 store fails closed with an actionable migration +/// error instead of a generic "invalid commitment". +const TBTC_SIGNER_STATE_WITNESS_MAGIC_V1: &[u8; 16] = b"TBTCWITNESSv1\0\0\0"; +pub(crate) const TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH: usize = 48; +pub(crate) const TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_LENGTH: usize = 472; +/// The journal is a fixed-width header followed by fixed-width records; the +/// tests build on-disk fixtures from this geometry, so it is part of the +/// crate-visible store contract. +pub(crate) const TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH: usize = 105; +/// Reconciliation can commit an interrupted write at the rotation threshold +/// before a mutating interactive retry persists two expiry-sweep repairs and +/// its requested mutation. Those three snapshots need six records to finish +/// the in-flight request before a checkpoint can be acknowledged. +pub(crate) const TBTC_SIGNER_STATE_WITNESS_ROTATION_TERMINAL_RECORD_RESERVATION: usize = 6; +/// Those three snapshots are a legitimately reachable steady state, so the +/// journal can park at the terminal reservation while it waits for the next +/// checkpoint acknowledgement. Corruption quarantine must still work there: +/// it is the operator-selected recovery for a state image that no longer +/// decodes, and its own unblock - an acknowledgement of the current tip - is +/// itself refused while the image is corrupt, because acknowledging first +/// revalidates the image against the committed witness tip. Quarantine +/// commits an ABSENCE: it renames the undecodable image away and records its +/// removal, so it never extends usable state and cannot be repeated (the +/// second call finds no state file). Reserving its PREPARE/COMMIT pair above +/// the terminal band therefore keeps a supported exit open without widening +/// the bound on ordinary state writes by a single record. +pub(crate) const TBTC_SIGNER_STATE_WITNESS_QUARANTINE_RECORD_RESERVATION: usize = 2; +const TBTC_SIGNER_STATE_WITNESS_RECORD_PREPARE: u8 = 1; +const TBTC_SIGNER_STATE_WITNESS_RECORD_COMMIT: u8 = 2; +const TBTC_SIGNER_STATE_WITNESS_RECORD_ABORT: u8 = 3; +const TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_VERSION: u32 = 1; +const TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_DOMAIN: &[u8] = + b"tbtc-signer-state-witness-segment-header/v1\0"; + +const TBTC_SIGNER_STATE_ANCHOR_MAGIC: &[u8; 16] = b"TBTCSTATEANCH1\0\0"; +const TBTC_SIGNER_STATE_ANCHOR_VERSION: u32 = 1; +// Fixed-width canonical encoding of every field in +// `StateAnchorAcknowledgement`: fourteen bytes32 values, one 64-byte +// signature, five u64 values, and one status byte followed by seven reserved +// zero bytes. +const TBTC_SIGNER_STATE_ANCHOR_ACK_LENGTH: usize = 560; +const TBTC_SIGNER_STATE_ANCHOR_METADATA_LENGTH: usize = + 16 + 4 + 32 + 4 + 3 * TBTC_SIGNER_STATE_ANCHOR_ACK_LENGTH + 32; +const TBTC_SIGNER_STATE_ANCHOR_METADATA_DOMAIN: &[u8] = b"tbtc-signer-state-anchor-metadata/v1\0"; + +#[cfg(test)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +// The `After` prefix is load-bearing: every fault injects after the named +// durable step, and the shared prefix keeps that invariant explicit. +#[allow(clippy::enum_variant_names)] +enum StateAnchorTrustTransitionFaultInjectionPoint { + AfterIntentPublication, + AfterPrepareBatch, + AfterNextWitnessPublication, + AfterPreviousWitnessPublication, + AfterCurrentWitnessPublication, + AfterTargetAnchorPublication, + AfterCommitPublication, + AfterPreviousWitnessRetirement, +} + +#[cfg(test)] +static STATE_ANCHOR_TRUST_TRANSITION_FAULT_INJECTION_POINT: OnceLock< + Mutex>, +> = OnceLock::new(); + +/// The lock-replacement fault injectors below displace and recreate real +/// directory entries through the same `openat`/`renameat` primitives the store +/// uses, so they exist only where those primitives do. Their switches are +/// gated with them: on a non-Unix target the durable store is a stub, the +/// tests that arm these switches are `cfg(unix)` too, and an ungated switch +/// would only be unreachable state that keeps `cargo test` from compiling. +#[cfg(all(test, unix))] +static REPLACE_TRUST_LOCK_AFTER_GUARDED_PUBLICATION: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +#[cfg(all(test, unix))] +static REPLACE_TRUST_LOCK_AFTER_FLOCK: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +#[cfg(test)] +fn maybe_inject_state_anchor_trust_transition_fault( + point: StateAnchorTrustTransitionFaultInjectionPoint, +) -> Result<(), EngineError> { + let configured = *STATE_ANCHOR_TRUST_TRANSITION_FAULT_INJECTION_POINT + .get_or_init(|| Mutex::new(None)) + .lock() + .map_err(|_| { + EngineError::Internal( + "state-anchor trust transition fault-injection mutex poisoned".to_string(), + ) + })?; + if configured == Some(point) { + return Err(EngineError::Internal(format!( + "injected state-anchor trust transition fault at {point:?}" + ))); + } + Ok(()) +} + +#[cfg(test)] +fn set_state_anchor_trust_transition_fault_for_tests( + point: StateAnchorTrustTransitionFaultInjectionPoint, +) { + if let Ok(mut configured) = STATE_ANCHOR_TRUST_TRANSITION_FAULT_INJECTION_POINT + .get_or_init(|| Mutex::new(None)) + .lock() + { + *configured = Some(point); + } +} + +#[cfg(test)] +fn clear_state_anchor_trust_transition_fault_for_tests() { + if let Ok(mut configured) = STATE_ANCHOR_TRUST_TRANSITION_FAULT_INJECTION_POINT + .get_or_init(|| Mutex::new(None)) + .lock() + { + *configured = None; + } +} + +#[cfg(all(test, unix))] +fn maybe_replace_trust_lock_after_guarded_publication( + recovery_guard: Option<&StateAnchorTrustRecoveryGuard<'_>>, +) -> Result<(), EngineError> { + if !REPLACE_TRUST_LOCK_AFTER_GUARDED_PUBLICATION + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + return Ok(()); + } + let guard = recovery_guard.ok_or_else(|| { + EngineError::Internal( + "post-publication lock replacement requires a recovery guard".to_string(), + ) + })?; + let mut displaced_name = guard.lock_name.to_os_string(); + displaced_name.push(".test-post-publication-displaced"); + validate_entry_name(&displaced_name, "displaced test lock")?; + ensure_entry_absent( + guard.directory.as_raw_fd(), + &displaced_name, + "displaced test lock", + )?; + renameat_same_directory( + guard.directory.as_raw_fd(), + guard.lock_name, + &displaced_name, + "displace trust-recovery lock in publication-window test", + )?; + let replacement = openat_regular( + guard.directory.as_raw_fd(), + guard.lock_name, + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL, + 0o600, + "replacement test lock", + )?; + set_owner_only_permissions(&replacement, "replacement test lock")?; + replacement.sync_all().map_err(|error| { + EngineError::Internal(format!("failed to sync replacement test lock: {error}")) + })?; + guard.directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync publication-window lock replacement: {error}" + )) + }) +} + +#[cfg(all(test, unix))] +fn maybe_replace_trust_lock_after_flock( + directory: &fs::File, + lock_name: &OsStr, +) -> Result<(), EngineError> { + if !REPLACE_TRUST_LOCK_AFTER_FLOCK.swap(false, std::sync::atomic::Ordering::SeqCst) { + return Ok(()); + } + let mut displaced_name = lock_name.to_os_string(); + displaced_name.push(".test-post-flock-displaced"); + validate_entry_name(&displaced_name, "post-flock displaced test lock")?; + ensure_entry_absent( + directory.as_raw_fd(), + &displaced_name, + "post-flock displaced test lock", + )?; + renameat_same_directory( + directory.as_raw_fd(), + lock_name, + &displaced_name, + "displace lock after flock", + )?; + let replacement = openat_regular( + directory.as_raw_fd(), + lock_name, + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL, + 0o600, + "post-flock replacement test lock", + )?; + set_owner_only_permissions(&replacement, "post-flock replacement test lock")?; + replacement.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync post-flock replacement lock: {error}" + )) + })?; + directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync post-flock lock replacement: {error}" + )) + }) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct OpenedObjectIdentity { + device: u64, + inode: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DurableStoreIdentity { + pub(crate) store_id: [u8; 32], + /// Diagnostic-only descriptors. They are recomputed and CHECKED on every + /// store access, but deliberately do not enter the state-commitment + /// transcript: each of them changes under benign operations (lock-file + /// cleanup, restore-from-backup, directory rename, remount). + pub(crate) canonical_path_fingerprint: [u8; 32], + pub(crate) filesystem_fingerprint: [u8; 32], + pub(crate) lock_fingerprint: [u8; 32], + /// The stable transcript anchor: a function of `store_id` alone. This is + /// the value bound into every state commitment and into the witness + /// genesis root. + pub(crate) fingerprint: [u8; 32], +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StateWitness { + pub(crate) generation: u64, + pub(crate) previous_commitment: [u8; 32], + pub(crate) commitment: [u8; 32], + pub(crate) state_image_digest: [u8; 32], +} + +pub(crate) struct LoadedStateImage { + pub(crate) bytes: Option>, + pub(crate) digest: [u8; 32], +} + +/// Why a witness pair is being prepared. Only the corruption quarantine may +/// draw on `TBTC_SIGNER_STATE_WITNESS_QUARANTINE_RECORD_RESERVATION`; every +/// ordinary state write stays inside the rotation bound, so a caller cannot +/// reach the reserve by persisting state. +#[cfg(unix)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WitnessAppendPurpose { + StateWrite, + CorruptionQuarantine, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct StateWitnessSegmentHeader { + store_fingerprint: [u8; 32], + base: StateWitness, + binding_hash: [u8; 32], + service_epoch: u64, + revision: u64, + previous_event_root: [u8; 32], + event_root: [u8; 32], + operation_id: [u8; 32], + transition_digest: [u8; 32], + committed_at_unix_ms: u64, + acknowledgement_digest: [u8; 32], + signature: [u8; 64], + header_commitment: [u8; 32], +} + +#[cfg(unix)] +struct ParsedStateWitnessJournal { + history: Vec, + pending: Option, + length: usize, + header_length: usize, + header_bytes: Vec, + segment_header: Option, + tail_record: [u8; TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH], +} + +#[cfg(unix)] +struct OpenedStateWitnessJournal { + file: fs::File, + identity: OpenedObjectIdentity, + parsed: ParsedStateWitnessJournal, +} + +#[cfg(unix)] +type OpenedStateAnchor = ( + Option, + Option, + Option, + Option>, +); + +#[cfg(unix)] +type OpenedStateAnchorTrustJournal = ( + Option, + Option, + Option, + Option, + Option>, +); + +#[cfg(unix)] +#[derive(Clone, Copy)] +struct StateWitnessRotationNames<'a> { + current: &'a OsStr, + next: &'a OsStr, + previous: &'a OsStr, +} + +#[cfg(unix)] +struct StateAnchorTrustRecoveryGuard<'a> { + directory: &'a fs::File, + canonical_parent: &'a Path, + directory_identity: OpenedObjectIdentity, + lock_name: &'a OsStr, + lock_file: &'a fs::File, + lock_identity: OpenedObjectIdentity, + store_id_name: &'a OsStr, + store_id_file: &'a fs::File, + store_id_identity: OpenedObjectIdentity, + store_id: [u8; 32], + state_name: &'a OsStr, + state_file: Option<&'a fs::File>, + state_identity: Option, +} + +#[cfg(unix)] +impl StateAnchorTrustRecoveryGuard<'_> { + fn revalidate(&self) -> Result<(), EngineError> { + let live_directory = open_absolute_directory_nofollow(self.canonical_parent)?; + if descriptor_identity(&live_directory, "live signer state directory")? + != self.directory_identity + { + return Err(replacement_error("signer state directory")); + } + validate_live_entry( + self.directory, + self.lock_name, + self.lock_identity, + "signer state lock file", + )?; + validate_live_entry( + self.directory, + self.store_id_name, + self.store_id_identity, + "signer durable store ID file", + )?; + validate_secure_regular_file(self.lock_file, "signer state lock file")?; + validate_secure_regular_file(self.store_id_file, "signer durable store ID file")?; + if read_store_id(self.store_id_file)? != self.store_id { + return Err(EngineError::Internal( + "signer durable store ID changed during trust recovery".to_string(), + )); + } + match (self.state_file, self.state_identity) { + (Some(file), Some(identity)) => { + validate_live_entry( + self.directory, + self.state_name, + identity, + "signer state file", + )?; + validate_secure_regular_file(file, "signer state file")?; + } + (None, None) => ensure_entry_absent( + self.directory.as_raw_fd(), + self.state_name, + "signer state file", + )?, + _ => { + return Err(EngineError::Internal( + "signer state descriptor invariant is inconsistent during trust recovery" + .to_string(), + )) + } + } + Ok(()) + } +} + +/// Cheap, exact evidence that a file has not been written since it was last +/// inspected: size plus the mtime and ctime pairs. Any write moves at least one +/// of them, and ctime cannot be back-dated by an unprivileged writer. A stamp +/// mismatch never admits anything - it only forces a full re-verification. +#[cfg(unix)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct FileChangeStamp { + size: u64, + modified_seconds: u64, + modified_nanoseconds: u64, + changed_seconds: u64, + changed_nanoseconds: u64, +} + +/// The verified prefix of the append-only witness journal. +/// +/// The journal is append-only, so verification is incremental: the bytes below +/// `verified_length` have already been parsed and matched against the in-memory +/// history, and only newly appended bytes need to be read back. The anchor - +/// last verified commitment and generation - plus the exact trailing record +/// bytes and the file change stamp are what a later access re-checks in O(1) +/// before trusting the prefix. +/// +/// This cache lives only in the `StateFileLock` instance, so it is never a +/// trust anchor across process restarts: a fresh open always re-parses and +/// re-hashes the entire journal. +#[cfg(unix)] +#[derive(Clone, Debug)] +struct WitnessJournalPrefix { + identity: OpenedObjectIdentity, + stamp: FileChangeStamp, + verified_length: usize, + history_length: usize, + tip_generation: u64, + tip_commitment: [u8; 32], + tail_record: [u8; TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH], +} + +/// Counts full journal re-parses. The incremental path must keep this flat as +/// the journal grows; the test suite asserts exactly that. +#[cfg(all(test, unix))] +pub(crate) static WITNESS_FULL_VERIFICATIONS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +/// Counts verifications served from the verified prefix. +#[cfg(all(test, unix))] +pub(crate) static WITNESS_INCREMENTAL_VERIFICATIONS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +/// Counts journal bytes read for verification. This is the direct measure of +/// the fix: it must grow with the bytes appended, not with accesses times +/// journal length. +#[cfg(all(test, unix))] +pub(crate) static WITNESS_VERIFIED_BYTES_READ: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +#[cfg(all(test, unix))] +pub(crate) fn reset_witness_verification_counters() { + use std::sync::atomic::Ordering; + WITNESS_FULL_VERIFICATIONS.store(0, Ordering::SeqCst); + WITNESS_INCREMENTAL_VERIFICATIONS.store(0, Ordering::SeqCst); + WITNESS_VERIFIED_BYTES_READ.store(0, Ordering::SeqCst); +} + +/// `(full re-parses, incremental verifications, journal bytes read)`. +#[cfg(all(test, unix))] +pub(crate) fn witness_verification_counters() -> (u64, u64, u64) { + use std::sync::atomic::Ordering; + ( + WITNESS_FULL_VERIFICATIONS.load(Ordering::SeqCst), + WITNESS_INCREMENTAL_VERIFICATIONS.load(Ordering::SeqCst), + WITNESS_VERIFIED_BYTES_READ.load(Ordering::SeqCst), + ) +} + +/// A process-lifetime handle to the exact durable store opened by the signer. +/// +/// The public path fields are retained for diagnostics and existing tests. All +/// security-sensitive operations use `directory` plus `openat`/`renameat` and +/// compare live directory entries with the held descriptors before proceeding. +pub(crate) struct StateFileLock { + pub(crate) _file: fs::File, + pub(crate) state_path: PathBuf, + pub(crate) lock_path: PathBuf, + directory: fs::File, + canonical_parent: PathBuf, + state_name: OsString, + lock_name: OsString, + store_id_name: OsString, + directory_identity: OpenedObjectIdentity, + lock_identity: OpenedObjectIdentity, + store_id_file: fs::File, + store_id_identity: OpenedObjectIdentity, + trust_name: OsString, + trust_file: Option, + trust_identity: Option, + trust_journal: Option, + #[cfg(unix)] + trust_stamp: Option, + trust_bytes: Option>, + trust_intent_name: OsString, + trust_head_inspection: bool, + anchor_configuration: Option, + anchor_name: OsString, + anchor_file: Option, + anchor_identity: Option, + anchor_metadata: Option, + anchor_bytes: Option>, + witness_name: OsString, + witness_next_name: OsString, + witness_previous_name: OsString, + witness_file: fs::File, + witness_identity: OpenedObjectIdentity, + witness_history: Vec, + pending_witness: Option, + witness_length: usize, + witness_max_records: usize, + witness_rotation_threshold: Option, + witness_header_length: usize, + witness_header_bytes: Vec, + witness_segment_header: Option, + /// The verified prefix of the journal. `None` means "nothing is cached", + /// which forces the next verification to parse the whole journal. It is + /// deliberately `None` on every fresh open. + #[cfg(unix)] + witness_prefix: Option, + /// Bytes of the most recently appended record, used to verify the append + /// read-back and to anchor the cached prefix. + #[cfg(unix)] + last_appended_record: [u8; TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH], + /// Exact file stamp captured immediately after the appended record was + /// fsynced. The append read-back must observe this stamp before adopting a + /// new verified-prefix baseline. + #[cfg(unix)] + last_appended_stamp: Option, + current_state_file: Option, + current_state_identity: Option, + identity: DurableStoreIdentity, + lock_held: bool, +} + +#[derive(Clone, Debug)] +pub(crate) struct StateAnchorTrustTransitionStoreOutcome { + pub(crate) idempotent: bool, + pub(crate) applied_certificate_count: usize, + pub(crate) trust_head: VerifiedStateAnchorTrustCertificate, + pub(crate) tip: StateWitness, + pub(crate) base: StateWitness, + pub(crate) anchor: StateAnchorMetadata, +} + +#[cfg(unix)] +enum StateFileLockAcquireMode<'a> { + Ordinary, + BootstrapFactsProvisioning, + TrustHeadInspection, + TrustTransition(&'a VerifiedStateAnchorTrustTransition), +} + +impl StateFileLock { + #[cfg(unix)] + pub(crate) fn acquire(state_path: &Path) -> Result { + Self::acquire_with_mode(state_path, StateFileLockAcquireMode::Ordinary) + } + + #[cfg(unix)] + pub(crate) fn acquire_for_trust_transition( + state_path: &Path, + transition: &VerifiedStateAnchorTrustTransition, + ) -> Result { + Self::acquire_with_mode( + state_path, + StateFileLockAcquireMode::TrustTransition(transition), + ) + } + + #[cfg(unix)] + pub(crate) fn acquire_for_trust_head_inspection( + state_path: &Path, + ) -> Result { + Self::acquire_with_mode(state_path, StateFileLockAcquireMode::TrustHeadInspection) + } + + #[cfg(unix)] + pub(crate) fn acquire_for_bootstrap_facts(state_path: &Path) -> Result { + Self::acquire_with_mode( + state_path, + StateFileLockAcquireMode::BootstrapFactsProvisioning, + ) + } + + #[cfg(unix)] + fn acquire_with_mode( + state_path: &Path, + mode: StateFileLockAcquireMode<'_>, + ) -> Result { + let state_name = state_path + .file_name() + .filter(|name| !name.is_empty()) + .ok_or_else(|| { + EngineError::Internal(format!( + "signer state path [{}] has no file name", + state_path.display() + )) + })? + .to_os_string(); + validate_entry_name(&state_name, "state")?; + let trust_intent_name = state_anchor_trust_intent_file_name(&state_name); + validate_entry_name(&trust_intent_name, "state anchor trust transition intent")?; + + let configured_parent = state_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let canonical_parent = match fs::canonicalize(configured_parent) { + Ok(parent) => parent, + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && matches!(&mode, StateFileLockAcquireMode::TrustHeadInspection) => + { + // Trust-head inspection is read-only. An absent parent proves + // there cannot be a durable intent or committed head and must + // not create the configured directory hierarchy. + return Err(EngineError::StateAnchorTrustHeadAbsent); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir_all(configured_parent).map_err(|create_error| { + EngineError::Internal(format!( + "failed to create signer state directory [{}]: {create_error}", + configured_parent.display() + )) + })?; + fs::canonicalize(configured_parent).map_err(|canonicalize_error| { + EngineError::Internal(format!( + "failed to canonicalize newly created signer state directory [{}]: \ + {canonicalize_error}", + configured_parent.display() + )) + })? + } + Err(error) => { + return Err(EngineError::Internal(format!( + "failed to canonicalize signer state directory [{}]: {error}", + configured_parent.display() + ))); + } + }; + if !canonical_parent.is_absolute() { + return Err(EngineError::Internal(format!( + "canonical signer state directory [{}] is not absolute", + canonical_parent.display() + ))); + } + + // Traverse the canonical absolute path one component at a time. A + // concurrent symlink substitution therefore fails with ELOOP instead + // of redirecting the store open. + let directory = open_absolute_directory_nofollow(&canonical_parent)?; + let directory_identity = descriptor_identity(&directory, "signer state directory")?; + + let lock_path = state_lock_file_path(state_path); + let lock_name = lock_path + .file_name() + .ok_or_else(|| { + EngineError::Internal(format!( + "signer state lock path [{}] has no file name", + lock_path.display() + )) + })? + .to_os_string(); + validate_entry_name(&lock_name, "lock")?; + + let intent_existed_before_lock = live_entry_stat( + directory.as_raw_fd(), + &trust_intent_name, + "state anchor trust transition intent", + )? + .is_some(); + let mut lock_file = match openat_optional( + directory.as_raw_fd(), + &lock_name, + libc::O_RDWR, + "signer state lock file", + )? { + Some(file) => file, + None if intent_existed_before_lock => { + return Err(EngineError::Internal( + "durable state-anchor trust intent exists without its signer state lock; \ + refusing to recreate recovery prerequisites" + .to_string(), + )); + } + None => openat_regular( + directory.as_raw_fd(), + &lock_name, + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL, + 0o600, + "signer state lock file", + )?, + }; + validate_owned_unlinked_regular(&lock_file, "signer state lock file")?; + acquire_exclusive_lock(&lock_file, &lock_path)?; + let lock_identity = descriptor_identity(&lock_file, "signer state lock file")?; + #[cfg(test)] + maybe_replace_trust_lock_after_flock(&directory, &lock_name)?; + validate_live_entry( + &directory, + &lock_name, + lock_identity, + "signer state lock file", + )?; + let recovery_intent_present = live_entry_stat( + directory.as_raw_fd(), + &trust_intent_name, + "state anchor trust transition intent", + )? + .is_some(); + if recovery_intent_present { + // A local intent is evidence only. Until a fresh transition request + // is supplied, inspection must not chmod, truncate, rewrite, create, + // rename, unlink, or fsync any recovery prerequisite. + validate_secure_regular_file(&lock_file, "signer state lock file")?; + } else { + set_owner_only_permissions(&lock_file, "signer state lock file")?; + validate_secure_regular_file(&lock_file, "signer state lock file")?; + lock_file.set_len(0).map_err(|error| { + EngineError::Internal(format!( + "failed to truncate signer state lock file [{}]: {error}", + lock_path.display() + )) + })?; + lock_file.seek(SeekFrom::Start(0)).map_err(|error| { + EngineError::Internal(format!( + "failed to seek signer state lock file [{}]: {error}", + lock_path.display() + )) + })?; + writeln!( + lock_file, + "pid={}\ncanonical_state_path={}", + std::process::id(), + canonical_parent.join(&state_name).display() + ) + .map_err(|error| { + EngineError::Internal(format!( + "failed to write signer state lock file [{}]: {error}", + lock_path.display() + )) + })?; + lock_file.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync signer state lock file [{}]: {error}", + lock_path.display() + )) + })?; + } + + let store_id_name = durable_store_id_file_name(&state_name); + validate_entry_name(&store_id_name, "store ID")?; + let (store_id_file, store_id, store_id_identity) = if recovery_intent_present { + let file = openat_optional( + directory.as_raw_fd(), + &store_id_name, + libc::O_RDONLY, + "signer durable store ID file", + )? + .ok_or_else(|| { + EngineError::Internal( + "durable state-anchor trust intent exists without its signer durable store \ + ID; refusing to recreate recovery prerequisites" + .to_string(), + ) + })?; + validate_owned_unlinked_regular(&file, "signer durable store ID file")?; + validate_secure_regular_file(&file, "signer durable store ID file")?; + let store_id = read_store_id(&file)?; + let identity = descriptor_identity(&file, "signer durable store ID file")?; + (file, store_id, identity) + } else { + open_or_create_store_id(&directory, &store_id_name)? + }; + + // Persist the creation of both stable anchor entries before claiming + // durability to the host. + if !recovery_intent_present { + directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync signer state directory [{}]: {error}", + canonical_parent.display() + )) + })?; + } + + let (current_state_file, current_state_identity) = + open_optional_state(&directory, &state_name)?; + + let canonical_path_fingerprint = hash_fields( + TBTC_SIGNER_DURABLE_STORE_PATH_FINGERPRINT_DOMAIN, + &[ + canonical_parent.as_os_str().as_bytes(), + state_name.as_bytes(), + &directory_identity.device.to_be_bytes(), + &directory_identity.inode.to_be_bytes(), + ], + ); + let filesystem_fingerprint = hash_fields( + TBTC_SIGNER_DURABLE_STORE_FILESYSTEM_FINGERPRINT_DOMAIN, + &[&directory_identity.device.to_be_bytes()], + ); + let lock_fingerprint = hash_fields( + TBTC_SIGNER_DURABLE_STORE_LOCK_FINGERPRINT_DOMAIN, + &[ + lock_name.as_bytes(), + &lock_identity.device.to_be_bytes(), + &lock_identity.inode.to_be_bytes(), + ], + ); + // Only the stable store ID anchors the committed transcript. The three + // descriptor fingerprints above stay in the identity for diagnostics + // and are enforced by `revalidate_store_entries`, but binding them into + // the commitment would make every committed record unverifiable after a + // benign lock-file, path, inode, or device change. + let fingerprint = durable_store_fingerprint(&store_id); + let identity = DurableStoreIdentity { + store_id, + canonical_path_fingerprint, + filesystem_fingerprint, + lock_fingerprint, + fingerprint, + }; + + let configured_anchor = configured_state_anchor()?; + let trust_name = state_anchor_trust_file_name(&state_name); + validate_entry_name(&trust_name, "state anchor trust journal")?; + let anchor_name = state_anchor_file_name(&state_name); + validate_entry_name(&anchor_name, "state anchor")?; + let witness_name = state_witness_file_name(&state_name); + validate_entry_name(&witness_name, "state witness")?; + let mut witness_next_name = witness_name.clone(); + witness_next_name.push(TBTC_SIGNER_STATE_WITNESS_NEXT_SUFFIX); + validate_entry_name(&witness_next_name, "next state witness")?; + let mut witness_previous_name = witness_name.clone(); + witness_previous_name.push(TBTC_SIGNER_STATE_WITNESS_PREVIOUS_SUFFIX); + validate_entry_name(&witness_previous_name, "previous state witness")?; + let witness_max_records = state_witness_max_records()?; + + let opened_recovery_intent = open_state_anchor_trust_transition_intent( + &directory, + &trust_intent_name, + &identity.fingerprint, + )?; + if recovery_intent_present && opened_recovery_intent.is_none() { + return Err(EngineError::Internal( + "state-anchor trust transition intent disappeared while its store lock was held" + .to_string(), + )); + } + if let Some(intent_bytes) = opened_recovery_intent { + if matches!(&mode, StateFileLockAcquireMode::BootstrapFactsProvisioning) { + return Err(EngineError::Validation( + "bootstrap facts require a pristine store without a trust-transition intent" + .to_string(), + )); + } + let request = + parse_state_anchor_trust_transition_intent(&intent_bytes, &identity.fingerprint)?; + let persisted_transition = verify_state_anchor_trust_transition_request(request, false) + .map_err(|error| { + EngineError::Internal(format!( + "durable state-anchor trust transition intent failed verification: \ + {error}" + )) + })?; + let requested_transition = match &mode { + StateFileLockAcquireMode::TrustTransition(requested) => { + let persisted_final = persisted_transition + .certificates + .last() + .expect("verified durable transition is nonempty"); + if requested.request.schema != persisted_transition.request.schema + || requested.request.certificate_chain + != persisted_transition.request.certificate_chain + || requested.certificates.len() != persisted_transition.certificates.len() + || requested + .certificates + .iter() + .zip(&persisted_transition.certificates) + .any(|(requested, persisted)| { + requested.certificate_sequence != persisted.certificate_sequence + || requested.certificate_digest != persisted.certificate_digest + || requested.target_acknowledgement_bytes + != persisted.target_acknowledgement_bytes + }) + || requested.target_read_acknowledgement_bytes + != persisted_final.target_acknowledgement_bytes + || requested.target_read_acknowledgement + != persisted_final.target_acknowledgement + { + return Err(EngineError::Validation( + "state-anchor trust transition certificate chain or freshly read \ + target acknowledgement differs from the durable recovery intent" + .to_string(), + )); + } + *requested + } + _ => { + let final_certificate = persisted_transition + .certificates + .last() + .expect("verified durable transition is nonempty"); + return Err(EngineError::StateAnchorTrustRecoveryRequired { + context: Box::new(StateAnchorTrustRecoveryContext { + store_fingerprint: identity.fingerprint, + certificate_sequences: persisted_transition + .certificates + .iter() + .map(|certificate| certificate.certificate_sequence) + .collect(), + certificate_digests: persisted_transition + .certificates + .iter() + .map(|certificate| certificate.certificate_digest) + .collect(), + target_binding_hash: final_certificate.to.binding_hash, + target_service_epoch: final_certificate.to.reference.service_epoch, + target_revision: final_certificate.to.reference.revision, + target_checkpoint_store_fingerprint: final_certificate + .to + .reference + .checkpoint + .store_fingerprint, + target_checkpoint_generation: final_certificate + .to + .reference + .checkpoint + .generation, + target_checkpoint_previous_state_commitment: final_certificate + .to + .reference + .checkpoint + .previous_state_commitment, + target_checkpoint_state_image_digest: final_certificate + .to + .reference + .checkpoint + .state_image_digest, + target_checkpoint_state_commitment: final_certificate + .to + .reference + .checkpoint + .state_commitment, + }), + }); + } + }; + recheck_state_anchor_admission_expiry( + requested_transition.target_read_expires_at_unix_ms, + )?; + let recovery_guard = StateAnchorTrustRecoveryGuard { + directory: &directory, + canonical_parent: &canonical_parent, + directory_identity, + lock_name: &lock_name, + lock_file: &lock_file, + lock_identity, + store_id_name: &store_id_name, + store_id_file: &store_id_file, + store_id_identity, + store_id, + state_name: &state_name, + state_file: current_state_file.as_ref(), + state_identity: current_state_identity, + }; + recovery_guard.revalidate()?; + recover_state_anchor_trust_transition( + &recovery_guard, + &trust_name, + &trust_intent_name, + &anchor_name, + StateWitnessRotationNames { + current: &witness_name, + next: &witness_next_name, + previous: &witness_previous_name, + }, + &identity, + current_state_file.as_ref(), + witness_max_records, + configured_anchor.as_ref(), + &intent_bytes, + requested_transition, + )?; + } + ensure_entry_absent( + directory.as_raw_fd(), + &trust_intent_name, + "state anchor trust transition intent", + )?; + let trust_required = matches!( + &mode, + StateFileLockAcquireMode::Ordinary | StateFileLockAcquireMode::TrustHeadInspection + ) && configured_anchor + .as_ref() + .and_then(|configuration| configuration.trust.as_ref()) + .is_some(); + let (trust_file, trust_identity, trust_journal, trust_stamp, trust_bytes) = + open_state_anchor_trust_journal( + &directory, + &trust_name, + &identity.fingerprint, + trust_required, + )?; + let anchor_configuration = match &mode { + StateFileLockAcquireMode::Ordinary => { + if let (Some(journal), Some(configuration)) = + (trust_journal.as_ref(), configured_anchor.as_ref()) + { + validate_state_anchor_trust_journal_head( + journal, + configuration, + &identity.fingerprint, + )?; + } + configured_anchor.clone() + } + StateFileLockAcquireMode::BootstrapFactsProvisioning => { + if configured_anchor.is_some() || trust_journal.is_some() { + return Err(EngineError::Validation( + "bootstrap facts require a pristine store without anchor or trust state" + .to_string(), + )); + } + None + } + StateFileLockAcquireMode::TrustHeadInspection => { + let journal = trust_journal + .as_ref() + .ok_or(EngineError::StateAnchorTrustHeadAbsent)?; + let target_configuration = configured_anchor.as_ref().ok_or_else(|| { + EngineError::Internal( + "state-anchor trust-head inspection requires installed target pins" + .to_string(), + ) + })?; + validate_state_anchor_trust_journal_stable_pins( + journal, + target_configuration, + &identity.fingerprint, + )?; + Some( + journal + .head() + .expect("stable-pin validation requires a head") + .to + .anchor_configuration()?, + ) + } + StateFileLockAcquireMode::TrustTransition(transition) => { + if trust_journal + .as_ref() + .is_some_and(|journal| !journal.pending.is_empty()) + { + return Err(EngineError::Internal( + "state-anchor trust PREPARE records exist without a durable intent" + .to_string(), + )); + } + if let Some(head) = trust_journal + .as_ref() + .and_then(StateAnchorTrustJournalModel::head) + { + Some(head.to.anchor_configuration()?) + } else if let Some(from) = transition + .certificates + .first() + .and_then(|certificate| certificate.from.as_ref()) + { + Some(from.anchor_configuration()?) + } else { + configured_anchor.clone() + } + } + }; + let certified_floors = trust_journal + .as_ref() + .map(StateAnchorTrustJournalModel::certified_floors) + .unwrap_or_default(); + let (mut anchor_file, mut anchor_identity, mut anchor_metadata, mut anchor_bytes) = + open_state_anchor( + &directory, + &anchor_name, + &identity.fingerprint, + anchor_configuration.as_ref(), + &certified_floors, + )?; + + let promote_pending_anchor = recover_state_witness_rotation( + &directory, + StateWitnessRotationNames { + current: &witness_name, + next: &witness_next_name, + previous: &witness_previous_name, + }, + &identity, + current_state_file.as_ref(), + anchor_metadata.as_ref(), + witness_max_records, + true, + None, + )?; + if promote_pending_anchor { + let current = anchor_metadata.as_ref().ok_or_else(|| { + EngineError::Internal( + "rotation recovery completed without state anchor metadata".to_string(), + ) + })?; + let pending = current.pending_witness_base.clone().ok_or_else(|| { + EngineError::Internal( + "rotation recovery completed without a pending signed base".to_string(), + ) + })?; + let normalized = StateAnchorMetadata { + latest: current.latest.clone(), + witness_base: Some(pending), + pending_witness_base: None, + }; + let bytes = encode_state_anchor_metadata(&identity.fingerprint, &normalized); + let configuration = anchor_configuration.as_ref().ok_or_else(|| { + EngineError::Internal( + "rotation recovery cannot normalize anchor metadata without manifest pins" + .to_string(), + ) + })?; + parse_state_anchor_metadata( + &bytes, + &identity.fingerprint, + configuration, + &certified_floors, + )?; + let (file, entry_identity) = + replace_state_anchor_entry(&directory, &anchor_name, &bytes)?; + anchor_file = Some(file); + anchor_identity = Some(entry_identity); + anchor_metadata = Some(normalized); + anchor_bytes = Some(bytes); + } + let opened_witness = open_or_create_state_witness( + &directory, + &witness_name, + &identity, + current_state_file.as_ref(), + witness_max_records, + anchor_metadata.as_ref(), + )?; + if let Some(head) = trust_journal + .as_ref() + .and_then(StateAnchorTrustJournalModel::head) + { + let anchor = anchor_metadata.as_ref().ok_or_else(|| { + EngineError::Internal( + "committed state-anchor trust head requires persisted anchor metadata" + .to_string(), + ) + })?; + if opened_witness.parsed.segment_header.is_none() { + return Err(EngineError::Internal( + "committed state-anchor trust head requires an authenticated witness segment" + .to_string(), + )); + } + validate_state_anchor_trust_reference_descendant( + &head.to.reference, + &StateAnchorTrustReferenceModel::from_acknowledgement(&anchor.latest), + "persisted state-anchor reference", + ) + .map_err(|error| { + EngineError::Internal(format!( + "persisted state anchor exceeds its certified restart window: {error}" + )) + })?; + } + // Persist a newly-created witness entry before exposing either the + // static identity or dynamic state tip. + directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync signer state directory after witness initialization: {error}" + )) + })?; + + let mut store = Self { + _file: lock_file, + state_path: state_path.to_path_buf(), + lock_path, + directory, + canonical_parent, + state_name, + lock_name, + store_id_name, + directory_identity, + lock_identity, + store_id_file, + store_id_identity, + trust_name, + trust_file, + trust_identity, + trust_journal, + trust_stamp, + trust_bytes, + trust_intent_name, + trust_head_inspection: matches!( + &mode, + StateFileLockAcquireMode::TrustHeadInspection + | StateFileLockAcquireMode::TrustTransition(_) + ), + anchor_configuration: anchor_configuration.clone(), + anchor_name, + anchor_file, + anchor_identity, + anchor_metadata, + anchor_bytes, + witness_name, + witness_next_name, + witness_previous_name, + witness_file: opened_witness.file, + witness_identity: opened_witness.identity, + witness_history: opened_witness.parsed.history, + pending_witness: opened_witness.parsed.pending, + witness_length: opened_witness.parsed.length, + witness_max_records, + witness_rotation_threshold: anchor_configuration + .map(|configuration| configuration.rotation_threshold_records), + witness_header_length: opened_witness.parsed.header_length, + witness_header_bytes: opened_witness.parsed.header_bytes, + witness_segment_header: opened_witness.parsed.segment_header, + witness_prefix: None, + last_appended_record: [0u8; TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH], + last_appended_stamp: None, + current_state_file, + current_state_identity, + identity, + lock_held: true, + }; + store.reconcile_pending_witness()?; + // Startup loading must be able to inspect a malformed state image and + // apply the explicit corruption policy. Validate every held descriptor + // and the journal itself here; the state-image commitment is checked + // separately and authenticated rollback evidence always fails closed. + match mode { + StateFileLockAcquireMode::Ordinary | StateFileLockAcquireMode::TrustHeadInspection => { + store.revalidate_store_entries()? + } + StateFileLockAcquireMode::BootstrapFactsProvisioning => { + store.revalidate_store_entries()?; + store.validate_bootstrap_facts_pristine()?; + } + StateFileLockAcquireMode::TrustTransition(_) => { + store.settle_pending_state_witness_rotation_inner(false)?; + if store.pending_witness.is_some() { + return Err(EngineError::Internal( + "cannot transition state-anchor trust while a state transaction is pending" + .to_string(), + )); + } + let current_digest = current_state_image_digest(store.current_state_file.as_ref())?; + if store + .witness_history + .last() + .is_none_or(|tip| tip.state_image_digest != current_digest) + { + return Err(EngineError::Internal( + "state image does not match its witness before trust transition" + .to_string(), + )); + } + } + } + Ok(store) + } + + #[cfg(not(unix))] + pub(crate) fn acquire(state_path: &Path) -> Result { + Err(EngineError::Internal(format!( + "descriptor-bound durable signer storage is unavailable on this platform for [{}]", + state_path.display() + ))) + } + + #[cfg(not(unix))] + pub(crate) fn acquire_for_trust_transition( + state_path: &Path, + _transition: &VerifiedStateAnchorTrustTransition, + ) -> Result { + Self::acquire(state_path) + } + + #[cfg(not(unix))] + pub(crate) fn acquire_for_trust_head_inspection( + state_path: &Path, + ) -> Result { + Self::acquire(state_path) + } + + #[cfg(not(unix))] + pub(crate) fn acquire_for_bootstrap_facts(state_path: &Path) -> Result { + Self::acquire(state_path) + } + + pub(crate) fn identity(&mut self) -> Result { + self.reconcile_pending_witness()?; + self.revalidate()?; + Ok(self.identity.clone()) + } + + /// Returns the stable store identity without classifying the state image. + /// + /// Identity is a startup preflight and state freshness is a separate + /// contract. Keeping this path structural lets the subsequent loader apply + /// the configured corruption policy to malformed state while still + /// validating every held descriptor and the witness journal. + #[cfg(unix)] + pub(crate) fn identity_for_load(&mut self) -> Result { + self.reconcile_pending_witness()?; + self.settle_pending_state_witness_rotation()?; + self.revalidate_store_entries()?; + Ok(self.identity.clone()) + } + + #[cfg(not(unix))] + pub(crate) fn identity_for_load(&mut self) -> Result { + Err(EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform".to_string(), + )) + } + + #[cfg(all(test, unix))] + pub(crate) fn read_state(&mut self) -> Result>, EngineError> { + self.reconcile_pending_witness()?; + self.revalidate()?; + let Some(state_file) = self.current_state_file.as_ref() else { + return Ok(None); + }; + read_file_at(state_file, "signer state file").map(Some) + } + + /// Reads the exact held state descriptor during startup after structural + /// store validation, but before validating the image against the witness. + /// This narrow path lets the loader distinguish malformed state, which may + /// use the explicit corruption policy, from a valid-but-rolled-back image, + /// which always fails closed. + #[cfg(unix)] + pub(crate) fn read_state_for_load(&mut self) -> Result { + self.reconcile_pending_witness()?; + self.settle_pending_state_witness_rotation()?; + self.revalidate_store_entries()?; + let Some(state_file) = self.current_state_file.as_ref() else { + // Revalidate the absent live entry across the observation. The + // digest is carried through decoding and compared directly with + // the witness tip, so a later restoration cannot authenticate + // this observed absence as some other image. + self.revalidate_store_entries()?; + return Ok(LoadedStateImage { + bytes: None, + digest: state_image_digest(None), + }); + }; + let before = file_change_stamp(state_file, "signer state file")?; + let bytes = read_file_at(state_file, "signer state file")?; + let after = file_change_stamp(state_file, "signer state file")?; + if before != after || after.size != bytes.len() as u64 { + return Err(EngineError::Internal( + "signer state file changed while its startup image was being read".to_string(), + )); + } + let digest = state_image_digest(Some(&bytes)); + self.revalidate_store_entries()?; + Ok(LoadedStateImage { + bytes: Some(bytes), + digest, + }) + } + + #[cfg(not(unix))] + pub(crate) fn read_state_for_load(&mut self) -> Result { + Err(EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform".to_string(), + )) + } + + #[cfg(all(test, not(unix)))] + pub(crate) fn read_state(&mut self) -> Result>, EngineError> { + Err(EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform".to_string(), + )) + } + + /// Atomically replaces the encrypted state image through the held directory + /// descriptor. `Ok(true)` means the destination was replaced even if a later + /// durability check fails; callers preserve the existing retry semantics. + #[cfg(unix)] + pub(crate) fn replace_state(&mut self, bytes: &[u8]) -> Result<(), StoreReplaceError> { + if let Err(error) = self.reconcile_pending_witness() { + return Err(StoreReplaceError::before_replacement(error)); + } + if let Err(error) = self.revalidate() { + return Err(StoreReplaceError::before_replacement(error)); + } + + let temp_name = match unique_temp_name(&self.state_name) { + Ok(name) => name, + Err(error) => return Err(StoreReplaceError::before_replacement(error)), + }; + let temp_file = match openat_regular( + self.directory.as_raw_fd(), + &temp_name, + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL, + 0o600, + "signer state temp file", + ) { + Ok(file) => file, + Err(error) => return Err(StoreReplaceError::before_replacement(error)), + }; + + let before_prepare_result = + (|| -> Result<(OpenedObjectIdentity, fs::File), EngineError> { + set_owner_only_permissions(&temp_file, "signer state temp file")?; + validate_secure_regular_file(&temp_file, "signer state temp file")?; + write_file_at(&temp_file, bytes, "signer state temp file")?; + temp_file.sync_all().map_err(|error| { + EngineError::Internal(format!("failed to sync signer state temp file: {error}")) + })?; + maybe_inject_persist_fault(PersistFaultInjectionPoint::AfterTempSyncBeforeRename)?; + self.revalidate()?; + let identity = descriptor_identity(&temp_file, "new signer state file")?; + let retained = temp_file.try_clone().map_err(|error| { + EngineError::Internal(format!( + "failed to retain new signer state descriptor: {error}" + )) + })?; + Ok((identity, retained)) + })(); + let (new_state_identity, new_state_file) = match before_prepare_result { + Ok(result) => result, + Err(error) => { + let _ = unlinkat_entry(self.directory.as_raw_fd(), &temp_name); + return Err(StoreReplaceError::before_replacement(error)); + } + }; + + let next_witness = match self.next_state_witness(state_image_digest(Some(bytes))) { + Ok(witness) => witness, + Err(error) => { + let _ = unlinkat_entry(self.directory.as_raw_fd(), &temp_name); + return Err(StoreReplaceError::before_replacement(error)); + } + }; + if let Err(error) = self.prepare_witness(next_witness, WitnessAppendPurpose::StateWrite) { + let _ = unlinkat_entry(self.directory.as_raw_fd(), &temp_name); + return Err(StoreReplaceError::before_replacement(error)); + } + + if let Err(rename_error) = renameat_same_directory( + self.directory.as_raw_fd(), + &temp_name, + &self.state_name, + "replace signer state file", + ) { + let _ = unlinkat_entry(self.directory.as_raw_fd(), &temp_name); + let error = match self.abort_pending_witness() { + Ok(()) => rename_error, + Err(abort_error) => EngineError::Internal(format!( + "{rename_error}; additionally failed to abort prepared state witness: {abort_error}" + )), + }; + return Err(StoreReplaceError::before_replacement(error)); + } + + // renameat preserves the prepared temp descriptor's identity. Publish + // it immediately so every recovery path hashes the replacement image. + self.current_state_identity = Some(new_state_identity); + self.current_state_file = Some(new_state_file); + + let after_replacement_result = (|| -> Result<(), EngineError> { + maybe_inject_persist_fault(PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync)?; + self.directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync signer state directory [{}]: {error}", + self.canonical_parent.display() + )) + })?; + self.commit_pending_witness()?; + self.revalidate()?; + Ok(()) + })(); + + after_replacement_result.map_err(|error| StoreReplaceError { + error, + replaced: true, + }) + } + + #[cfg(not(unix))] + pub(crate) fn replace_state(&mut self, _bytes: &[u8]) -> Result<(), StoreReplaceError> { + Err(StoreReplaceError::before_replacement( + EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform" + .to_string(), + ), + )) + } + + #[cfg(unix)] + pub(crate) fn quarantine_state(&mut self, backup_path: &Path) -> Result<(), EngineError> { + self.reconcile_pending_witness()?; + self.settle_pending_state_witness_rotation()?; + // Quarantine is the sole operation allowed to proceed when the live + // state bytes differ from the committed image, and only after the + // caller has selected the explicit quarantine-and-reset policy. All + // directory, anchor, inode, permission, and journal checks still hold. + // It is also the sole operation allowed to draw on the quarantine + // record reservation, because the acknowledgement that would otherwise + // rotate the segment revalidates the state image first and therefore + // cannot run while that image is the thing that is broken. + self.revalidate_store_entries()?; + if self.current_state_identity.is_none() { + return Err(EngineError::Internal(format!( + "cannot quarantine absent signer state file [{}]", + self.state_path.display() + ))); + } + let backup_parent = backup_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let canonical_backup_parent = fs::canonicalize(backup_parent).map_err(|error| { + EngineError::Internal(format!( + "failed to canonicalize signer state backup directory [{}]: {error}", + backup_parent.display() + )) + })?; + if canonical_backup_parent != self.canonical_parent { + return Err(EngineError::Internal(format!( + "refusing to quarantine signer state outside the opened store directory [{}]", + self.canonical_parent.display() + ))); + } + let backup_name = backup_path.file_name().ok_or_else(|| { + EngineError::Internal(format!( + "signer state backup path [{}] has no file name", + backup_path.display() + )) + })?; + validate_entry_name(backup_name, "state backup")?; + ensure_entry_absent(self.directory.as_raw_fd(), backup_name, "state backup")?; + let next_witness = self.next_state_witness(state_image_digest(None))?; + self.prepare_witness(next_witness, WitnessAppendPurpose::CorruptionQuarantine)?; + if let Err(rename_error) = renameat_same_directory( + self.directory.as_raw_fd(), + &self.state_name, + backup_name, + "quarantine signer state file", + ) { + let error = match self.abort_pending_witness() { + Ok(()) => rename_error, + Err(abort_error) => EngineError::Internal(format!( + "{rename_error}; additionally failed to abort prepared state witness: {abort_error}" + )), + }; + return Err(error); + } + self.current_state_file = None; + self.current_state_identity = None; + self.directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync signer state directory after quarantine: {error}" + )) + })?; + self.commit_pending_witness()?; + self.revalidate()?; + Ok(()) + } + + #[cfg(not(unix))] + pub(crate) fn quarantine_state(&mut self, _backup_path: &Path) -> Result<(), EngineError> { + Err(EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform".to_string(), + )) + } + + #[cfg(unix)] + pub(crate) fn revalidate_store_entries(&mut self) -> Result<(), EngineError> { + if !self.lock_held { + return Err(EngineError::Internal( + "signer durable store exclusive lock is no longer held".to_string(), + )); + } + + let live_directory = open_absolute_directory_nofollow(&self.canonical_parent)?; + let live_directory_identity = + descriptor_identity(&live_directory, "live signer state directory")?; + if live_directory_identity != self.directory_identity { + return Err(replacement_error("signer state directory")); + } + + validate_live_entry( + &self.directory, + &self.lock_name, + self.lock_identity, + "signer state lock file", + )?; + validate_live_entry( + &self.directory, + &self.store_id_name, + self.store_id_identity, + "signer durable store ID file", + )?; + validate_secure_regular_file(&self._file, "signer state lock file")?; + validate_secure_regular_file(&self.store_id_file, "signer durable store ID file")?; + let live_store_id = read_store_id(&self.store_id_file)?; + if live_store_id != self.identity.store_id { + return Err(EngineError::Internal( + "signer durable store ID changed after the store was opened".to_string(), + )); + } + + match ( + self.trust_identity, + self.trust_file.as_ref(), + self.trust_journal.as_ref(), + self.trust_stamp, + self.trust_bytes.as_ref(), + ) { + ( + Some(identity), + Some(file), + Some(journal), + Some(expected_stamp), + Some(expected_bytes), + ) => { + validate_live_entry( + &self.directory, + &self.trust_name, + identity, + "state-anchor trust journal", + )?; + validate_secure_regular_file(file, "state-anchor trust journal")?; + if file_change_stamp(file, "state-anchor trust journal")? != expected_stamp { + return Err(EngineError::Internal( + "state-anchor trust journal changed after startup verification".to_string(), + )); + } + if usize::try_from( + file.metadata() + .map_err(|error| { + EngineError::Internal(format!( + "failed to stat state-anchor trust journal: {error}" + )) + })? + .len(), + ) + .ok() + != Some(expected_bytes.len()) + { + return Err(EngineError::Internal( + "state-anchor trust journal length changed after startup verification" + .to_string(), + )); + } + let configuration = configured_state_anchor()?.ok_or_else(|| { + EngineError::Internal( + "state-anchor trust journal requires configured anchor pins".to_string(), + ) + })?; + if self.trust_head_inspection { + validate_state_anchor_trust_journal_stable_pins( + journal, + &configuration, + &self.identity.fingerprint, + )?; + } else { + validate_state_anchor_trust_journal_head( + journal, + &configuration, + &self.identity.fingerprint, + )?; + } + } + (None, None, None, None, None) => ensure_entry_absent( + self.directory.as_raw_fd(), + &self.trust_name, + "state-anchor trust journal", + )?, + _ => { + return Err(EngineError::Internal( + "state-anchor trust journal descriptor invariant is inconsistent".to_string(), + )) + } + } + ensure_entry_absent( + self.directory.as_raw_fd(), + &self.trust_intent_name, + "state-anchor trust transition intent", + )?; + + match ( + self.anchor_identity, + self.anchor_file.as_ref(), + self.anchor_metadata.as_ref(), + self.anchor_bytes.as_ref(), + ) { + (Some(identity), Some(file), Some(metadata), Some(expected_bytes)) => { + validate_live_entry( + &self.directory, + &self.anchor_name, + identity, + "signer state anchor metadata", + )?; + validate_secure_regular_file(file, "signer state anchor metadata")?; + let live_bytes = read_file_at(file, "signer state anchor metadata")?; + if &live_bytes != expected_bytes { + return Err(EngineError::Internal( + "signer state anchor metadata changed outside the locked store".to_string(), + )); + } + let configuration = self.anchor_configuration.as_ref().ok_or_else(|| { + EngineError::Internal( + "persisted signer state anchor metadata requires manifest pins".to_string(), + ) + })?; + let parsed = parse_state_anchor_metadata( + &live_bytes, + &self.identity.fingerprint, + configuration, + &self + .trust_journal + .as_ref() + .map(StateAnchorTrustJournalModel::certified_floors) + .unwrap_or_default(), + )?; + if &parsed != metadata { + return Err(EngineError::Internal( + "signer state anchor metadata no longer matches its verified model" + .to_string(), + )); + } + if let Some(head) = self + .trust_journal + .as_ref() + .and_then(StateAnchorTrustJournalModel::head) + { + validate_state_anchor_trust_reference_descendant( + &head.to.reference, + &StateAnchorTrustReferenceModel::from_acknowledgement(&parsed.latest), + "persisted state-anchor reference", + ) + .map_err(|error| { + EngineError::Internal(format!( + "persisted state anchor exceeds its certified restart window: {error}" + )) + })?; + } + } + (None, None, None, None) => { + if self + .trust_journal + .as_ref() + .and_then(StateAnchorTrustJournalModel::head) + .is_some() + { + return Err(EngineError::Internal( + "committed state-anchor trust head requires persisted anchor metadata" + .to_string(), + )); + } + ensure_entry_absent( + self.directory.as_raw_fd(), + &self.anchor_name, + "signer state anchor metadata", + )? + } + _ => { + return Err(EngineError::Internal( + "signer state anchor descriptor invariant is inconsistent".to_string(), + )) + } + } + ensure_entry_absent( + self.directory.as_raw_fd(), + &self.witness_next_name, + "next signer state witness journal", + )?; + ensure_entry_absent( + self.directory.as_raw_fd(), + &self.witness_previous_name, + "previous signer state witness journal", + )?; + + match ( + self.current_state_identity, + self.current_state_file.as_ref(), + ) { + (Some(identity), Some(file)) => { + validate_live_entry( + &self.directory, + &self.state_name, + identity, + "signer state file", + )?; + validate_secure_regular_file(file, "signer state file")?; + } + (None, None) => ensure_entry_absent( + self.directory.as_raw_fd(), + &self.state_name, + "signer state file", + )?, + _ => { + return Err(EngineError::Internal( + "signer durable store state descriptor invariant is inconsistent".to_string(), + )) + } + } + + validate_live_entry( + &self.directory, + &self.witness_name, + self.witness_identity, + "signer state witness journal", + )?; + validate_secure_regular_file(&self.witness_file, "signer state witness journal")?; + self.verify_state_witness_journal()?; + if self + .trust_journal + .as_ref() + .and_then(StateAnchorTrustJournalModel::head) + .is_some() + && self.witness_segment_header.is_none() + { + return Err(EngineError::Internal( + "committed state-anchor trust head requires an authenticated witness segment" + .to_string(), + )); + } + Ok(()) + } + + /// Verifies the journal against the in-memory history. + /// + /// The journal is append-only and is written only by this process while the + /// exclusive lock is held, so re-reading and re-hashing every record ever + /// written on every access is pure waste that grows without bound in + /// lifetime persist count. Instead the verified prefix is cached and the + /// O(1) anchor - file identity, change stamp, header, trailing record, and + /// the last verified generation/commitment - is re-checked. ANY mismatch, + /// including a file whose identity moved underneath, falls through to a + /// full re-parse, which is what produces the precise failure. Bytes + /// appended since the last verification are read back and checked at append + /// time, so no byte is ever trusted without having been read from disk. + /// + /// The cache is per-`StateFileLock`, so a tampered prefix is still caught + /// in full on any fresh open. + #[cfg(unix)] + fn verify_state_witness_journal(&mut self) -> Result<(), EngineError> { + let stamp = witness_change_stamp(&self.witness_file)?; + if let Some(prefix) = self.witness_prefix.as_ref() { + let tip = self + .witness_history + .last() + .map(|tip| (tip.generation, tip.commitment)); + if prefix.identity == self.witness_identity + && prefix.stamp == stamp + && prefix.verified_length == self.witness_length + && prefix.history_length == self.witness_history.len() + && tip == Some((prefix.tip_generation, prefix.tip_commitment)) + && self.witness_anchor_matches(prefix)? + { + #[cfg(test)] + WITNESS_INCREMENTAL_VERIFICATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + return Ok(()); + } + } + self.verify_state_witness_journal_fully() + } + + /// Re-reads the two fixed anchors of the cached prefix: the header, which + /// binds this store's ID, and the trailing record. Returns `false` - never + /// an error - when either differs, so the caller falls back to the full + /// parse that reports the real problem. + #[cfg(unix)] + fn witness_anchor_matches(&self, prefix: &WitnessJournalPrefix) -> Result { + const LABEL: &str = "signer state witness journal"; + if prefix.verified_length + < self.witness_header_length + TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH + { + return Ok(false); + } + // Take stamps around the anchor reads. A writer that changes the file + // between the caller's initial stat and these reads must not be + // admitted merely because it restores the same length. + let before = witness_change_stamp(&self.witness_file)?; + if before != prefix.stamp { + return Ok(false); + } + let header = read_file_range_at(&self.witness_file, 0, self.witness_header_length, LABEL)?; + if header != self.witness_header_bytes { + return Ok(false); + } + let tail = read_file_range_at( + &self.witness_file, + prefix.verified_length - TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH, + TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH, + LABEL, + )?; + let after = witness_change_stamp(&self.witness_file)?; + #[cfg(test)] + WITNESS_VERIFIED_BYTES_READ.fetch_add( + (header.len() + tail.len()) as u64, + std::sync::atomic::Ordering::SeqCst, + ); + Ok(before == after && after == prefix.stamp && tail == prefix.tail_record) + } + + #[cfg(unix)] + fn verify_state_witness_journal_fully(&mut self) -> Result<(), EngineError> { + let anchor = self.anchor_metadata.clone(); + self.verify_state_witness_journal_fully_with_anchor(anchor.as_ref()) + } + + #[cfg(unix)] + fn verify_state_witness_journal_fully_with_anchor( + &mut self, + validation_anchor: Option<&StateAnchorMetadata>, + ) -> Result<(), EngineError> { + #[cfg(test)] + WITNESS_FULL_VERIFICATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + let before = witness_change_stamp(&self.witness_file)?; + let parsed = read_state_witness_journal_streaming( + &self.witness_file, + &self.identity.store_id, + &self.identity.fingerprint, + self.witness_max_records, + validation_anchor, + )?; + if parsed.length != self.witness_length + || parsed.header_length != self.witness_header_length + || parsed.header_bytes != self.witness_header_bytes + || parsed.segment_header != self.witness_segment_header + { + return Err(EngineError::Internal( + "signer state witness journal length changed outside the locked store".to_string(), + )); + } + if parsed.pending != self.pending_witness || parsed.history != self.witness_history { + return Err(EngineError::Internal( + "signer state witness journal changed outside the locked store".to_string(), + )); + } + + // Only cache a prefix whose bytes provably did not move while they were + // being read. + let after = witness_change_stamp(&self.witness_file)?; + if before != after { + self.witness_prefix = None; + return Err(EngineError::Internal( + "signer state witness journal changed during full verification".to_string(), + )); + } + self.witness_prefix = self.build_witness_prefix(after, &parsed.tail_record); + Ok(()) + } + + /// Builds the cached prefix from the current in-memory model. Returns + /// `None` when there is nothing to anchor to, which simply disables the + /// incremental path. + #[cfg(unix)] + fn build_witness_prefix( + &self, + stamp: FileChangeStamp, + tail_record: &[u8], + ) -> Option { + let tip = self.witness_history.last()?; + if tail_record.len() != TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH + || self.witness_length + < self.witness_header_length + TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH + { + return None; + } + let mut tail = [0u8; TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH]; + tail.copy_from_slice(tail_record); + Some(WitnessJournalPrefix { + identity: self.witness_identity, + stamp, + verified_length: self.witness_length, + history_length: self.witness_history.len(), + tip_generation: tip.generation, + tip_commitment: tip.commitment, + tail_record: tail, + }) + } + + /// Reads back the record that was just appended and extends the verified + /// prefix over it. This is the "verify only the bytes appended since" + /// half of the incremental scheme: every journal byte is still read from + /// disk and checked exactly once. + #[cfg(unix)] + fn extend_witness_prefix(&mut self) -> Result<(), EngineError> { + const LABEL: &str = "signer state witness journal"; + let appended_stamp = self.last_appended_stamp.take(); + let Some(previous) = self.witness_prefix.clone() else { + // Nothing verified yet; the next access parses the whole journal. + return Ok(()); + }; + let Some(appended_stamp) = appended_stamp else { + self.witness_prefix = None; + return Err(EngineError::Internal( + "signer state witness append has no post-sync change stamp".to_string(), + )); + }; + let appended_offset = previous.verified_length; + if appended_offset.checked_add(TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH) + != Some(self.witness_length) + { + self.witness_prefix = None; + return Err(EngineError::Internal( + "signer state witness journal length did not advance by exactly one record" + .to_string(), + )); + } + let before = witness_change_stamp(&self.witness_file)?; + if before != appended_stamp || before.size != self.witness_length as u64 { + self.witness_prefix = None; + return Err(EngineError::Internal( + "signer state witness journal changed after the append was synced".to_string(), + )); + } + let header = read_file_range_at(&self.witness_file, 0, self.witness_header_length, LABEL)?; + let old_tail = read_file_range_at( + &self.witness_file, + previous.verified_length - TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH, + TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH, + LABEL, + )?; + let appended = read_file_range_at( + &self.witness_file, + appended_offset, + TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH, + LABEL, + )?; + #[cfg(test)] + WITNESS_VERIFIED_BYTES_READ.fetch_add( + (header.len() + old_tail.len() + appended.len()) as u64, + std::sync::atomic::Ordering::SeqCst, + ); + let header_matches = header == self.witness_header_bytes; + if !header_matches + || old_tail != previous.tail_record + || appended != self.last_appended_record + { + self.witness_prefix = None; + return Err(EngineError::Internal( + "signer state witness journal prefix or append read-back changed during append" + .to_string(), + )); + } + let after = witness_change_stamp(&self.witness_file)?; + if before != after { + self.witness_prefix = None; + return Err(EngineError::Internal( + "signer state witness journal changed during append read-back".to_string(), + )); + } + self.witness_prefix = self.build_witness_prefix(after, &self.last_appended_record); + Ok(()) + } + + #[cfg(unix)] + pub(crate) fn validate_state_image(&mut self) -> Result<(), EngineError> { + self.settle_pending_state_witness_rotation()?; + self.revalidate_store_entries()?; + let current_digest = current_state_image_digest(self.current_state_file.as_ref())?; + self.validate_state_image_digest(current_digest) + } + + /// Validates the digest captured from the exact stable-read bytes supplied + /// to the state decoder. This must not reread the state descriptor: doing + /// so would let an older valid snapshot be decoded and then swapped back to + /// the current bytes before witness validation. + #[cfg(unix)] + pub(crate) fn validate_loaded_state_image( + &mut self, + loaded_digest: [u8; 32], + ) -> Result<(), EngineError> { + self.revalidate_store_entries()?; + self.validate_state_image_digest(loaded_digest) + } + + fn validate_state_image_digest(&self, image_digest: [u8; 32]) -> Result<(), EngineError> { + let history = &self.witness_history; + if history + .last() + .map(|tip| tip.state_image_digest != image_digest) + .unwrap_or(true) + { + return Err(EngineError::Internal( + "signer state image does not match the committed witness tip".to_string(), + )); + } + + Ok(()) + } + + #[cfg(unix)] + fn revalidate(&mut self) -> Result<(), EngineError> { + self.validate_state_image() + } + + #[cfg(not(unix))] + pub(crate) fn revalidate_store_entries(&mut self) -> Result<(), EngineError> { + Err(EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform".to_string(), + )) + } + + #[cfg(not(unix))] + pub(crate) fn validate_state_image(&mut self) -> Result<(), EngineError> { + Err(EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform".to_string(), + )) + } + + #[cfg(not(unix))] + pub(crate) fn validate_loaded_state_image( + &mut self, + _loaded_digest: [u8; 32], + ) -> Result<(), EngineError> { + Err(EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform".to_string(), + )) + } + + #[cfg(not(unix))] + fn revalidate(&mut self) -> Result<(), EngineError> { + Err(EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform".to_string(), + )) + } + + pub(crate) fn state_witness_tip(&mut self) -> Result { + self.reconcile_pending_witness()?; + self.revalidate()?; + self.witness_history.last().cloned().ok_or_else(|| { + EngineError::Internal("signer state witness journal has no committed tip".to_string()) + }) + } + + #[cfg(unix)] + pub(crate) fn state_witness_tip_snapshot( + &mut self, + ) -> Result { + self.reconcile_pending_witness()?; + self.revalidate()?; + self.normalize_published_pending_anchor()?; + let tip = self.witness_history.last().cloned().ok_or_else(|| { + EngineError::Internal("signer state witness journal has no committed tip".to_string()) + })?; + let base = self.witness_history.first().cloned().ok_or_else(|| { + EngineError::Internal("signer state witness journal has no retained base".to_string()) + })?; + Ok(StateWitnessTipSnapshot { + store_fingerprint: self.identity.fingerprint, + tip, + base, + anchor: self.anchor_metadata.clone(), + }) + } + + #[cfg(not(unix))] + pub(crate) fn state_witness_tip_snapshot( + &mut self, + ) -> Result { + Err(EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform".to_string(), + )) + } + + #[cfg(unix)] + pub(crate) fn state_anchor_trust_head_snapshot( + &mut self, + ) -> Result { + self.reconcile_pending_witness()?; + self.revalidate()?; + self.normalize_published_pending_anchor()?; + self.state_anchor_trust_transition_outcome(true, 0) + } + + #[cfg(not(unix))] + pub(crate) fn state_anchor_trust_head_snapshot( + &mut self, + ) -> Result { + Err(EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform".to_string(), + )) + } + + pub(crate) fn state_anchor_bootstrap_facts_snapshot( + &mut self, + ) -> Result<([u8; 32], StateWitness), EngineError> { + self.revalidate()?; + self.validate_bootstrap_facts_pristine()?; + let tip = self.witness_history.last().cloned().ok_or_else(|| { + EngineError::Internal( + "bootstrap provisioning witness has no committed genesis".to_string(), + ) + })?; + Ok((self.identity.fingerprint, tip)) + } + + fn validate_bootstrap_facts_pristine(&self) -> Result<(), EngineError> { + let exact_genesis_length = + TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH + 2 * TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH; + let pristine = self.trust_journal.is_none() + && self.trust_bytes.is_none() + && self.anchor_metadata.is_none() + && self.anchor_bytes.is_none() + && self.witness_segment_header.is_none() + && self.pending_witness.is_none() + && self.current_state_file.is_none() + && self.current_state_identity.is_none() + && self.witness_history.len() == 1 + && self.witness_history[0].generation == 1 + && self.witness_length == exact_genesis_length; + if !pristine { + return Err(EngineError::Validation( + "state-anchor bootstrap facts require a pristine genesis-only store".to_string(), + )); + } + Ok(()) + } + + #[cfg(unix)] + pub(crate) fn transition_state_witness_anchor( + &mut self, + transition: &VerifiedStateAnchorTrustTransition, + ) -> Result { + // In transition mode the rotating endpoint may still be the prior + // head, but immutable trust pins and every live descriptor must be + // revalidated before the first durable mutation. + self.revalidate()?; + let idempotent = self.validate_state_anchor_trust_transition_local(transition)?; + recheck_state_anchor_admission_expiry(transition.target_read_expires_at_unix_ms)?; + if idempotent { + // Trust-transition acquisition deliberately permits the prior head + // while selecting a missing suffix. An exact replay is already at + // the installed target, so it must perform the full steady-state + // descriptor and state-image validation before returning claims + // from held descriptors. + self.trust_head_inspection = false; + self.revalidate()?; + self.normalize_published_pending_anchor()?; + return self.state_anchor_trust_transition_outcome(true, 0); + } + + let journal_growth = self.state_anchor_trust_transition_journal_growth(transition)?; + self.ensure_state_anchor_trust_transition_journal_capacity(journal_growth)?; + let intent_bytes = encode_state_anchor_trust_transition_intent( + &self.identity.fingerprint, + &transition.request, + )?; + self.revalidate_trust_transition_mutation_guard()?; + self.create_state_anchor_trust_transition_intent( + &intent_bytes, + transition.target_read_expires_at_unix_ms, + journal_growth, + )?; + #[cfg(test)] + maybe_inject_state_anchor_trust_transition_fault( + StateAnchorTrustTransitionFaultInjectionPoint::AfterIntentPublication, + )?; + self.revalidate_state_anchor_trust_intent(&intent_bytes)?; + + self.ensure_state_anchor_trust_journal()?; + for certificate in &transition.certificates { + self.revalidate_state_anchor_trust_intent(&intent_bytes)?; + self.revalidate_trust_transition_mutation_guard()?; + let previous = self + .trust_journal + .as_ref() + .ok_or_else(|| { + EngineError::Internal( + "state-anchor trust journal disappeared during PREPARE".to_string(), + ) + })? + .last_record_commitment; + let record = encode_state_anchor_trust_prepare_record( + &self.identity.fingerprint, + &previous, + certificate, + )?; + self.publish_state_anchor_trust_journal_record(&record)?; + } + #[cfg(test)] + maybe_inject_state_anchor_trust_transition_fault( + StateAnchorTrustTransitionFaultInjectionPoint::AfterPrepareBatch, + )?; + + let final_certificate = transition + .certificates + .last() + .expect("verified transition chain is nonempty"); + let target_acknowledgement = final_certificate.target_acknowledgement.clone(); + let target_anchor = StateAnchorMetadata { + latest: target_acknowledgement.clone(), + witness_base: Some(target_acknowledgement.clone()), + pending_witness_base: None, + }; + self.revalidate_state_anchor_trust_intent(&intent_bytes)?; + self.revalidate_trust_transition_mutation_guard()?; + self.rotate_state_witness_segment_for_trust_transition( + &target_acknowledgement, + &target_anchor, + )?; + + self.anchor_configuration = Some(final_certificate.to.anchor_configuration()?); + self.revalidate_state_anchor_trust_intent(&intent_bytes)?; + self.revalidate_trust_transition_mutation_guard()?; + self.persist_state_anchor_metadata(target_anchor)?; + #[cfg(test)] + maybe_inject_state_anchor_trust_transition_fault( + StateAnchorTrustTransitionFaultInjectionPoint::AfterTargetAnchorPublication, + )?; + + for certificate in &transition.certificates { + self.revalidate_state_anchor_trust_intent(&intent_bytes)?; + self.revalidate_trust_transition_mutation_guard()?; + let previous = self + .trust_journal + .as_ref() + .ok_or_else(|| { + EngineError::Internal( + "state-anchor trust journal disappeared before COMMIT".to_string(), + ) + })? + .last_record_commitment; + let record = encode_state_anchor_trust_commit_record( + &self.identity.fingerprint, + &previous, + certificate, + )?; + self.publish_state_anchor_trust_journal_record(&record)?; + #[cfg(test)] + maybe_inject_state_anchor_trust_transition_fault( + StateAnchorTrustTransitionFaultInjectionPoint::AfterCommitPublication, + )?; + } + + let configured = configured_state_anchor()?.ok_or_else(|| { + EngineError::Internal( + "state-anchor config disappeared during trust transition".to_string(), + ) + })?; + validate_state_anchor_trust_journal_head( + self.trust_journal.as_ref().ok_or_else(|| { + EngineError::Internal( + "state-anchor trust journal is absent after COMMIT".to_string(), + ) + })?, + &configured, + &self.identity.fingerprint, + )?; + + self.revalidate_state_anchor_trust_intent(&intent_bytes)?; + self.revalidate_trust_transition_mutation_guard()?; + unlinkat_entry(self.directory.as_raw_fd(), &self.witness_previous_name)?; + self.directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync trust transition after retiring previous witness: {error}" + )) + })?; + #[cfg(test)] + maybe_inject_state_anchor_trust_transition_fault( + StateAnchorTrustTransitionFaultInjectionPoint::AfterPreviousWitnessRetirement, + )?; + self.revalidate_trust_transition_mutation_guard()?; + unlinkat_entry(self.directory.as_raw_fd(), &self.trust_intent_name)?; + self.directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync trust transition intent removal: {error}" + )) + })?; + self.state_anchor_trust_mutation_guard().revalidate()?; + + self.trust_head_inspection = false; + self.revalidate_store_entries()?; + self.state_anchor_trust_transition_outcome(false, transition.certificates.len()) + } + + #[cfg(not(unix))] + pub(crate) fn transition_state_witness_anchor( + &mut self, + _transition: &VerifiedStateAnchorTrustTransition, + ) -> Result { + Err(EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform".to_string(), + )) + } + + #[cfg(unix)] + fn validate_state_anchor_trust_transition_local( + &self, + transition: &VerifiedStateAnchorTrustTransition, + ) -> Result { + let first = transition + .certificates + .first() + .expect("verified transition chain is nonempty"); + let final_certificate = transition + .certificates + .last() + .expect("verified transition chain is nonempty"); + if transition.certificates.iter().any(|certificate| { + certificate.signer_store_fingerprint != self.identity.fingerprint + || certificate.to.reference.checkpoint.store_fingerprint + != self.identity.fingerprint + }) { + return Err(EngineError::Validation( + "state-anchor trust certificate targets a different durable signer store" + .to_string(), + )); + } + let tip = self.witness_history.last().ok_or_else(|| { + EngineError::Internal("state witness has no committed tip".to_string()) + })?; + let local_checkpoint = + StateAnchorTrustCheckpointModel::from_witness(self.identity.fingerprint, tip); + + if let Some(head) = self + .trust_journal + .as_ref() + .and_then(StateAnchorTrustJournalModel::head) + { + if first.certificate_sequence == head.certificate_sequence { + let local_anchor = self.anchor_metadata.as_ref().ok_or_else(|| { + EngineError::Internal( + "state-anchor trust head exists without local anchor metadata".to_string(), + ) + })?; + if transition.certificates.len() != 1 + || first.certificate_digest != head.certificate_digest + || first.wire != head.wire + || StateAnchorTrustReferenceModel::from_acknowledgement(&local_anchor.latest) + != StateAnchorTrustReferenceModel::from_acknowledgement( + &transition.target_read_acknowledgement, + ) + { + return Err(EngineError::Validation( + "completed trust transition accepts only a one-certificate exact replay \ + plus a fresh Read of the current local descendant" + .to_string(), + )); + } + let local_reference = + StateAnchorTrustReferenceModel::from_acknowledgement(&local_anchor.latest); + validate_state_anchor_trust_reference_descendant( + &head.to.reference, + &local_reference, + "completed trust transition local anchor", + )?; + if local_reference.checkpoint != local_checkpoint { + return Err(EngineError::Validation( + "completed trust transition replay requires the current signed anchor \ + checkpoint to exactly match the local witness tip" + .to_string(), + )); + } + return Ok(true); + } + let expected_sequence = head.certificate_sequence.checked_add(1).ok_or_else(|| { + EngineError::Validation( + "state-anchor trust certificate sequence overflows u64".to_string(), + ) + })?; + let local_anchor = self.anchor_metadata.as_ref().ok_or_else(|| { + EngineError::Internal( + "state-anchor trust head exists without local anchor metadata".to_string(), + ) + })?; + let local_reference = + StateAnchorTrustReferenceModel::from_acknowledgement(&local_anchor.latest); + validate_state_anchor_trust_reference_descendant( + &head.to.reference, + &local_reference, + "persisted local anchor", + )?; + let mut expected_from = head.to.clone(); + expected_from.reference = local_reference; + if first.certificate_sequence != expected_sequence + || first.previous_certificate_digest != head.certificate_digest + || first.from.as_ref() != Some(&expected_from) + { + return Err(EngineError::Validation( + "first missing trust certificate does not extend the exact local trust and \ + anchor head" + .to_string(), + )); + } + } else if let Some(local_anchor) = self.anchor_metadata.as_ref() { + let from = first.from.as_ref().ok_or_else(|| { + EngineError::Validation( + "legacy anchored adoption requires a rotation certificate".to_string(), + ) + })?; + if first.kind != StateAnchorTrustCertificateKind::Rotation + || first.certificate_sequence != 1 + || first.previous_certificate_digest != [0u8; 32] + || from.binding_hash != local_anchor.latest.binding_hash + || from.response_public_key + != self + .anchor_configuration + .as_ref() + .map(|configuration| configuration.response_public_key) + .unwrap_or([0u8; 32]) + || !from.reference.matches_acknowledgement(&local_anchor.latest) + { + return Err(EngineError::Validation( + "legacy adoption certificate does not exactly authenticate the persisted \ + anchor" + .to_string(), + )); + } + } else if first.kind != StateAnchorTrustCertificateKind::Bootstrap + || first.certificate_sequence != 1 + || self.witness_segment_header.is_some() + { + return Err(EngineError::Validation( + "unanchored store requires a sequence-1 bootstrap certificate and clean \ + unsegmented witness" + .to_string(), + )); + } + + if final_certificate.to.reference.checkpoint != local_checkpoint { + return Err(EngineError::Validation( + "final trust certificate checkpoint does not exactly match the local witness tip" + .to_string(), + )); + } + for certificate in &transition.certificates { + if let Some(from) = certificate.from.as_ref() { + self.validate_trust_checkpoint_against_retained_witness( + &from.reference.checkpoint, + "certificate from.reference checkpoint", + )?; + } + self.validate_trust_checkpoint_against_retained_witness( + &certificate.to.reference.checkpoint, + "certificate to.reference checkpoint", + )?; + } + if transition.target_read_acknowledgement_bytes + != final_certificate.target_acknowledgement_bytes + || transition.target_read_acknowledgement != final_certificate.target_acknowledgement + { + return Err(EngineError::Validation( + "fresh final Read does not contain the exact final certificate acknowledgement" + .to_string(), + )); + } + Ok(false) + } + + fn validate_trust_checkpoint_against_retained_witness( + &self, + checkpoint: &StateAnchorTrustCheckpointModel, + label: &str, + ) -> Result<(), EngineError> { + if checkpoint.store_fingerprint != self.identity.fingerprint { + return Err(EngineError::Validation(format!( + "{label} targets a different durable signer store" + ))); + } + let base = self.witness_history.first().ok_or_else(|| { + EngineError::Internal( + "state witness has no retained base during trust transition".to_string(), + ) + })?; + let tip = self.witness_history.last().ok_or_else(|| { + EngineError::Internal( + "state witness has no committed tip during trust transition".to_string(), + ) + })?; + if checkpoint.generation > tip.generation { + return Err(EngineError::Validation(format!( + "{label} is ahead of the local witness tip" + ))); + } + if checkpoint.generation < base.generation { + // The offline authority authenticates this historical checkpoint; + // a compacted local segment cannot prove data older than its signed + // retained base. + return Ok(()); + } + let index = usize::try_from(checkpoint.generation - base.generation).map_err(|_| { + EngineError::Validation(format!("{label} index does not fit this platform")) + })?; + let witness = self.witness_history.get(index).ok_or_else(|| { + EngineError::Validation(format!("{label} is missing from retained witness history")) + })?; + if StateAnchorTrustCheckpointModel::from_witness(self.identity.fingerprint, witness) + != *checkpoint + { + return Err(EngineError::Validation(format!( + "{label} disagrees with retained witness history" + ))); + } + Ok(()) + } + + #[cfg(unix)] + fn state_anchor_trust_transition_outcome( + &self, + idempotent: bool, + applied_certificate_count: usize, + ) -> Result { + let trust_head = self + .trust_journal + .as_ref() + .and_then(StateAnchorTrustJournalModel::head) + .cloned() + .ok_or_else(|| { + EngineError::Internal( + "state-anchor trust transition completed without a trust head".to_string(), + ) + })?; + let tip = self.witness_history.last().cloned().ok_or_else(|| { + EngineError::Internal("state witness has no committed tip".to_string()) + })?; + let base = self.witness_history.first().cloned().ok_or_else(|| { + EngineError::Internal("state witness has no retained base".to_string()) + })?; + let anchor = self.anchor_metadata.clone().ok_or_else(|| { + EngineError::Internal( + "state-anchor trust transition completed without anchor metadata".to_string(), + ) + })?; + Ok(StateAnchorTrustTransitionStoreOutcome { + idempotent, + applied_certificate_count, + trust_head, + tip, + base, + anchor, + }) + } + + #[cfg(unix)] + fn ensure_state_anchor_trust_journal(&mut self) -> Result<(), EngineError> { + if self.trust_journal.is_some() { + return Ok(()); + } + let bytes = encode_state_anchor_trust_journal_header(&self.identity.fingerprint); + self.publish_state_anchor_trust_journal(&bytes) + } + + #[cfg(unix)] + fn publish_state_anchor_trust_journal_record( + &mut self, + record: &[u8], + ) -> Result<(), EngineError> { + let mut bytes = self.trust_bytes.clone().ok_or_else(|| { + EngineError::Internal( + "state-anchor trust journal bytes are unavailable during transition".to_string(), + ) + })?; + if bytes + .len() + .checked_add(record.len()) + .is_none_or(|length| length > STATE_ANCHOR_TRUST_MAX_JOURNAL_LENGTH) + { + return Err(EngineError::Validation( + "state-anchor trust journal reached its durable size bound".to_string(), + )); + } + bytes.extend_from_slice(record); + self.publish_state_anchor_trust_journal(&bytes) + } + + #[cfg(unix)] + fn publish_state_anchor_trust_journal(&mut self, bytes: &[u8]) -> Result<(), EngineError> { + let parsed = parse_state_anchor_trust_journal(bytes, &self.identity.fingerprint)?; + let (file, identity) = if self.trust_head_inspection { + let guard = self.state_anchor_trust_mutation_guard(); + replace_durable_entry_with_guard( + &self.directory, + &self.trust_name, + bytes, + "state-anchor trust journal", + Some(&guard), + )? + } else { + replace_durable_entry( + &self.directory, + &self.trust_name, + bytes, + "state-anchor trust journal", + )? + }; + let before = file_change_stamp(&file, "state-anchor trust journal")?; + let published_bytes = read_file_at(&file, "state-anchor trust journal")?; + let after = file_change_stamp(&file, "state-anchor trust journal")?; + if before != after || published_bytes != bytes { + return Err(EngineError::Internal( + "state-anchor trust journal changed during publication verification".to_string(), + )); + } + self.trust_file = Some(file); + self.trust_identity = Some(identity); + self.trust_journal = Some(parsed); + self.trust_stamp = Some(after); + self.trust_bytes = Some(bytes.to_vec()); + Ok(()) + } + + #[cfg(unix)] + fn revalidate_state_anchor_trust_intent( + &self, + expected_bytes: &[u8], + ) -> Result<(), EngineError> { + let file = openat_optional( + self.directory.as_raw_fd(), + &self.trust_intent_name, + libc::O_RDONLY, + "state-anchor trust transition intent", + )? + .ok_or_else(|| { + EngineError::Internal( + "state-anchor trust transition intent disappeared during mutation".to_string(), + ) + })?; + validate_secure_regular_file(&file, "state-anchor trust transition intent")?; + let length = usize::try_from( + file.metadata() + .map_err(|error| { + EngineError::Internal(format!( + "failed to stat state-anchor trust transition intent: {error}" + )) + })? + .len(), + ) + .map_err(|_| { + EngineError::Internal( + "state-anchor trust transition intent length does not fit this platform" + .to_string(), + ) + })?; + if length > STATE_ANCHOR_TRUST_MAX_INTENT_LENGTH { + return Err(EngineError::Internal( + "state-anchor trust transition intent exceeds its durable bound".to_string(), + )); + } + let live_bytes = read_file_at(&file, "state-anchor trust transition intent")?; + if live_bytes != expected_bytes { + return Err(EngineError::Internal( + "state-anchor trust transition intent changed during mutation".to_string(), + )); + } + parse_state_anchor_trust_transition_intent(&live_bytes, &self.identity.fingerprint)?; + Ok(()) + } + + #[cfg(unix)] + fn state_anchor_trust_transition_journal_growth( + &self, + transition: &VerifiedStateAnchorTrustTransition, + ) -> Result { + let mut growth = if self.trust_bytes.is_none() { + STATE_ANCHOR_TRUST_JOURNAL_HEADER_LENGTH + } else { + 0 + }; + for certificate in &transition.certificates { + let prepare = encode_state_anchor_trust_prepare_record( + &self.identity.fingerprint, + &[0u8; 32], + certificate, + )?; + let commit = encode_state_anchor_trust_commit_record( + &self.identity.fingerprint, + &[0u8; 32], + certificate, + )?; + growth = growth + .checked_add(prepare.len()) + .and_then(|length| length.checked_add(commit.len())) + .ok_or_else(|| { + EngineError::Validation( + "state-anchor trust journal batch length overflows this platform" + .to_string(), + ) + })?; + } + Ok(growth) + } + + #[cfg(unix)] + fn ensure_state_anchor_trust_transition_journal_capacity( + &self, + journal_growth: usize, + ) -> Result<(), EngineError> { + let current_length = match (&self.trust_journal, &self.trust_bytes) { + (Some(_), Some(bytes)) => bytes.len(), + (None, None) => 0, + _ => { + return Err(EngineError::Internal( + "state-anchor trust journal byte/model invariant is inconsistent".to_string(), + )) + } + }; + ensure_state_anchor_trust_transition_journal_capacity_for_length( + current_length, + journal_growth, + ) + } + + #[cfg(unix)] + fn create_state_anchor_trust_transition_intent( + &mut self, + bytes: &[u8], + admission_expires_at_unix_ms: u64, + journal_growth: usize, + ) -> Result<(), EngineError> { + const LABEL: &str = "state-anchor trust transition intent"; + let temp_name = unique_temp_name(&self.trust_intent_name)?; + let temp_file = openat_regular( + self.directory.as_raw_fd(), + &temp_name, + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL, + 0o600, + LABEL, + )?; + let outcome = (|| { + validate_owned_unlinked_regular(&temp_file, LABEL)?; + set_owner_only_permissions(&temp_file, LABEL)?; + validate_secure_regular_file(&temp_file, LABEL)?; + write_file_at(&temp_file, bytes, LABEL)?; + temp_file.sync_all().map_err(|error| { + EngineError::Internal(format!("failed to sync new {LABEL}: {error}")) + })?; + + // Resolve absence before the final admission check because even + // fstatat can stall. The descriptor guard, capacity, and expiry + // checks must be the last operations before publication. The + // resulting intent is durable evidence only; resumed mutation + // still requires a newly verified fresh Read. + ensure_entry_absent(self.directory.as_raw_fd(), &self.trust_intent_name, LABEL)?; + self.revalidate()?; + self.ensure_state_anchor_trust_transition_journal_capacity(journal_growth)?; + recheck_state_anchor_admission_expiry(admission_expires_at_unix_ms)?; + renameat_same_directory( + self.directory.as_raw_fd(), + &temp_name, + &self.trust_intent_name, + LABEL, + )?; + self.directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync signer state directory after publishing {LABEL}: {error}" + )) + })?; + let identity = descriptor_identity(&temp_file, LABEL)?; + validate_live_entry(&self.directory, &self.trust_intent_name, identity, LABEL)?; + self.state_anchor_trust_mutation_guard().revalidate() + })(); + if outcome.is_err() { + let _ = unlinkat_entry(self.directory.as_raw_fd(), &temp_name); + } + outcome + } + + #[cfg(unix)] + fn state_anchor_trust_mutation_guard(&self) -> StateAnchorTrustRecoveryGuard<'_> { + StateAnchorTrustRecoveryGuard { + directory: &self.directory, + canonical_parent: &self.canonical_parent, + directory_identity: self.directory_identity, + lock_name: &self.lock_name, + lock_file: &self._file, + lock_identity: self.lock_identity, + store_id_name: &self.store_id_name, + store_id_file: &self.store_id_file, + store_id_identity: self.store_id_identity, + store_id: self.identity.store_id, + state_name: &self.state_name, + state_file: self.current_state_file.as_ref(), + state_identity: self.current_state_identity, + } + } + + #[cfg(unix)] + fn revalidate_trust_transition_mutation_guard(&self) -> Result<(), EngineError> { + self.state_anchor_trust_mutation_guard().revalidate()?; + validate_live_entry( + &self.directory, + &self.witness_name, + self.witness_identity, + "signer state witness journal", + )?; + if let Some(identity) = self.current_state_identity { + validate_live_entry( + &self.directory, + &self.state_name, + identity, + "signer state file", + )?; + } else { + ensure_entry_absent( + self.directory.as_raw_fd(), + &self.state_name, + "signer state file", + )?; + } + match self.trust_identity { + Some(identity) => validate_live_entry( + &self.directory, + &self.trust_name, + identity, + "state-anchor trust journal", + )?, + None => ensure_entry_absent( + self.directory.as_raw_fd(), + &self.trust_name, + "state-anchor trust journal", + )?, + } + match self.anchor_identity { + Some(identity) => validate_live_entry( + &self.directory, + &self.anchor_name, + identity, + "signer state anchor metadata", + )?, + None => ensure_entry_absent( + self.directory.as_raw_fd(), + &self.anchor_name, + "signer state anchor metadata", + )?, + } + Ok(()) + } + + #[cfg(unix)] + pub(crate) fn acknowledge_state_witness_checkpoint( + &mut self, + acknowledgement: StateAnchorAcknowledgement, + rotation_threshold: usize, + allow_unanchored_recovery: bool, + admission_expires_at_unix_ms: u64, + ) -> Result { + self.reconcile_pending_witness()?; + self.revalidate()?; + self.normalize_published_pending_anchor()?; + recheck_state_anchor_admission_expiry(admission_expires_at_unix_ms)?; + if self.witness_rotation_threshold != Some(rotation_threshold) { + return Err(EngineError::Internal( + "state witness rotation threshold changed after the durable store was opened" + .to_string(), + )); + } + validate_anchor_acknowledgement_shape(&acknowledgement, &self.identity.fingerprint)?; + let tip = self.witness_history.last().ok_or_else(|| { + EngineError::Internal("signer state witness journal has no committed tip".to_string()) + })?; + if acknowledgement.checkpoint_store_fingerprint != self.identity.fingerprint + || acknowledgement.checkpoint_generation != tip.generation + || acknowledgement.checkpoint_previous_commitment != tip.previous_commitment + || acknowledgement.checkpoint_state_image_digest != tip.state_image_digest + || acknowledgement.checkpoint_state_commitment != tip.commitment + { + return Err(EngineError::Validation( + "state-anchor acknowledgement checkpoint does not exactly match the current \ + durable witness tip" + .to_string(), + )); + } + + let idempotent = validate_anchor_monotonic_update( + self.anchor_metadata.as_ref(), + &acknowledgement, + allow_unanchored_recovery, + )?; + if let Some(head) = self + .trust_journal + .as_ref() + .and_then(StateAnchorTrustJournalModel::head) + { + validate_state_anchor_trust_reference_descendant( + &head.to.reference, + &StateAnchorTrustReferenceModel::from_acknowledgement(&acknowledgement), + "state-anchor acknowledgement", + )?; + } + + // A failed state transaction appends PREPARE+ABORT without advancing + // the tip. Once that reaches the threshold, an exact replay of the + // already-anchored tip must still be able to compact the segment. + // A newly rotated segment has zero records, so the count gate alone + // prevents duplicate rotations. + let should_rotate = self.witness_record_count()? >= rotation_threshold; + let witness_base = self + .anchor_metadata + .as_ref() + .and_then(|metadata| metadata.witness_base.clone()); + let pending_witness_base = if should_rotate { + Some(acknowledgement.clone()) + } else { + self.anchor_metadata + .as_ref() + .and_then(|metadata| metadata.pending_witness_base.clone()) + }; + let next_metadata = StateAnchorMetadata { + latest: acknowledgement.clone(), + witness_base, + pending_witness_base, + }; + if self.anchor_metadata.as_ref() != Some(&next_metadata) { + self.persist_state_anchor_metadata(next_metadata)?; + } + let rotated = if should_rotate { + self.rotate_state_witness_segment(&acknowledgement)?; + self.persist_state_anchor_metadata(StateAnchorMetadata { + latest: acknowledgement.clone(), + witness_base: Some(acknowledgement), + pending_witness_base: None, + })?; + true + } else { + false + }; + let snapshot = self.state_witness_tip_snapshot()?; + Ok(AnchorAcknowledgeOutcome { + idempotent, + rotated, + snapshot, + }) + } + + #[cfg(not(unix))] + pub(crate) fn acknowledge_state_witness_checkpoint( + &mut self, + _acknowledgement: StateAnchorAcknowledgement, + _rotation_threshold: usize, + _allow_unanchored_recovery: bool, + _admission_expires_at_unix_ms: u64, + ) -> Result { + Err(EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform".to_string(), + )) + } + + #[cfg(unix)] + fn persist_state_anchor_metadata( + &mut self, + metadata: StateAnchorMetadata, + ) -> Result<(), EngineError> { + let configuration = self.anchor_configuration.as_ref().ok_or_else(|| { + EngineError::Internal( + "cannot persist signer state anchor metadata without manifest pins".to_string(), + ) + })?; + let bytes = encode_state_anchor_metadata(&self.identity.fingerprint, &metadata); + let parsed = parse_state_anchor_metadata( + &bytes, + &self.identity.fingerprint, + configuration, + &self + .trust_journal + .as_ref() + .map(StateAnchorTrustJournalModel::certified_floors) + .unwrap_or_default(), + )?; + if parsed != metadata { + return Err(EngineError::Internal( + "new signer state anchor metadata failed canonical round-trip".to_string(), + )); + } + let (file, identity) = if self.trust_head_inspection { + let guard = self.state_anchor_trust_mutation_guard(); + replace_durable_entry_with_guard( + &self.directory, + &self.anchor_name, + &bytes, + "signer state anchor metadata", + Some(&guard), + )? + } else { + replace_state_anchor_entry(&self.directory, &self.anchor_name, &bytes)? + }; + self.anchor_file = Some(file); + self.anchor_identity = Some(identity); + self.anchor_metadata = Some(metadata); + self.anchor_bytes = Some(bytes); + Ok(()) + } + + #[cfg(unix)] + fn normalize_published_pending_anchor(&mut self) -> Result<(), EngineError> { + let Some(metadata) = self.anchor_metadata.as_ref() else { + return Ok(()); + }; + let Some(pending) = metadata.pending_witness_base.as_ref() else { + return Ok(()); + }; + let published = self + .witness_segment_header + .as_ref() + .is_some_and(|header| acknowledgement_matches_witness(pending, Some(&header.base))); + if !published { + return Ok(()); + } + let normalized = StateAnchorMetadata { + latest: metadata.latest.clone(), + witness_base: Some(pending.clone()), + pending_witness_base: None, + }; + self.persist_state_anchor_metadata(normalized) + } + + /// Completes a durably authorized witness rotation before any caller can + /// observe a tip or perform another state mutation. This is the + /// same-process counterpart of acquisition-time crash recovery: an I/O + /// failure after the pending acknowledgement or any rename boundary must + /// never leave maintenance to occur lazily after the host installs its + /// rollback/output barrier. + #[cfg(unix)] + fn settle_pending_state_witness_rotation(&mut self) -> Result<(), EngineError> { + self.settle_pending_state_witness_rotation_inner(true) + } + + #[cfg(unix)] + fn settle_pending_state_witness_rotation_inner( + &mut self, + revalidate_steady_store: bool, + ) -> Result<(), EngineError> { + if self + .anchor_metadata + .as_ref() + .and_then(|metadata| metadata.pending_witness_base.as_ref()) + .is_none() + { + return Ok(()); + } + if self.pending_witness.is_some() { + return Err(EngineError::Internal( + "cannot settle state witness rotation while a state update is pending".to_string(), + )); + } + if !recover_state_witness_rotation( + &self.directory, + StateWitnessRotationNames { + current: &self.witness_name, + next: &self.witness_next_name, + previous: &self.witness_previous_name, + }, + &self.identity, + self.current_state_file.as_ref(), + self.anchor_metadata.as_ref(), + self.witness_max_records, + true, + None, + )? { + return Err(EngineError::Internal( + "pending state witness rotation did not produce a promoted base".to_string(), + )); + } + + let opened = open_or_create_state_witness( + &self.directory, + &self.witness_name, + &self.identity, + self.current_state_file.as_ref(), + self.witness_max_records, + self.anchor_metadata.as_ref(), + )?; + self.witness_file = opened.file; + self.witness_identity = opened.identity; + self.witness_history = opened.parsed.history; + self.pending_witness = opened.parsed.pending; + self.witness_length = opened.parsed.length; + self.witness_header_length = opened.parsed.header_length; + self.witness_header_bytes = opened.parsed.header_bytes; + self.witness_segment_header = opened.parsed.segment_header; + self.witness_prefix = None; + self.last_appended_record = [0u8; TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH]; + self.last_appended_stamp = None; + self.verify_state_witness_journal_fully()?; + self.normalize_published_pending_anchor()?; + if revalidate_steady_store { + self.revalidate_store_entries()?; + } + Ok(()) + } + + #[cfg(unix)] + fn rotate_state_witness_segment( + &mut self, + acknowledgement: &StateAnchorAcknowledgement, + ) -> Result<(), EngineError> { + let validation_anchor = self.anchor_metadata.clone(); + self.rotate_state_witness_segment_inner(acknowledgement, validation_anchor.as_ref(), true) + } + + #[cfg(unix)] + fn rotate_state_witness_segment_for_trust_transition( + &mut self, + acknowledgement: &StateAnchorAcknowledgement, + target_anchor: &StateAnchorMetadata, + ) -> Result<(), EngineError> { + self.rotate_state_witness_segment_inner(acknowledgement, Some(target_anchor), false) + } + + #[cfg(unix)] + fn rotate_state_witness_segment_inner( + &mut self, + acknowledgement: &StateAnchorAcknowledgement, + validation_anchor: Option<&StateAnchorMetadata>, + retire_previous: bool, + ) -> Result<(), EngineError> { + if self.pending_witness.is_some() { + return Err(EngineError::Internal( + "cannot rotate a state witness segment while an update is pending".to_string(), + )); + } + let tip = self.witness_history.last().cloned().ok_or_else(|| { + EngineError::Internal("signer state witness journal has no committed tip".to_string()) + })?; + if acknowledgement.checkpoint_generation != tip.generation + || acknowledgement.checkpoint_previous_commitment != tip.previous_commitment + || acknowledgement.checkpoint_state_image_digest != tip.state_image_digest + || acknowledgement.checkpoint_state_commitment != tip.commitment + { + return Err(EngineError::Internal( + "state witness rotation acknowledgement no longer matches the current tip" + .to_string(), + )); + } + ensure_entry_absent( + self.directory.as_raw_fd(), + &self.witness_next_name, + "next signer state witness journal", + )?; + ensure_entry_absent( + self.directory.as_raw_fd(), + &self.witness_previous_name, + "previous signer state witness journal", + )?; + let bytes = + encode_state_witness_segment_header(&self.identity.fingerprint, acknowledgement)?; + let (next_file, next_identity) = if retire_previous { + create_entry_atomically( + &self.directory, + &self.witness_next_name, + &bytes, + "next signer state witness journal", + )? + } else { + let guard = self.state_anchor_trust_mutation_guard(); + create_entry_atomically_with_guard( + &self.directory, + &self.witness_next_name, + &bytes, + "next signer state witness journal", + Some(&guard), + )? + }; + let parsed = read_state_witness_journal_streaming( + &next_file, + &self.identity.store_id, + &self.identity.fingerprint, + self.witness_max_records, + validation_anchor, + )?; + if parsed.segment_header.is_none() + || parsed.pending.is_some() + || parsed.history.as_slice() != [tip.clone()] + { + let _ = unlinkat_entry(self.directory.as_raw_fd(), &self.witness_next_name); + return Err(EngineError::Internal( + "new state witness segment failed pre-publication verification".to_string(), + )); + } + #[cfg(test)] + if !retire_previous { + maybe_inject_state_anchor_trust_transition_fault( + StateAnchorTrustTransitionFaultInjectionPoint::AfterNextWitnessPublication, + )?; + } + let current_digest = current_state_image_digest(self.current_state_file.as_ref())?; + if tip.state_image_digest != current_digest { + let _ = unlinkat_entry(self.directory.as_raw_fd(), &self.witness_next_name); + return Err(EngineError::Internal( + "new state witness segment base does not commit the current state image" + .to_string(), + )); + } + if !retire_previous { + self.state_anchor_trust_mutation_guard().revalidate()?; + } + if let Err(error) = renameat_same_directory( + self.directory.as_raw_fd(), + &self.witness_name, + &self.witness_previous_name, + "retain previous state witness segment", + ) { + let _ = unlinkat_entry(self.directory.as_raw_fd(), &self.witness_next_name); + return Err(error); + } + self.directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync signer state directory after retaining previous witness \ + segment: {error}" + )) + })?; + if !retire_previous { + self.state_anchor_trust_mutation_guard().revalidate()?; + #[cfg(test)] + maybe_inject_state_anchor_trust_transition_fault( + StateAnchorTrustTransitionFaultInjectionPoint::AfterPreviousWitnessPublication, + )?; + } + renameat_same_directory( + self.directory.as_raw_fd(), + &self.witness_next_name, + &self.witness_name, + "publish next state witness segment", + )?; + self.directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync signer state directory after publishing witness segment: {error}" + )) + })?; + validate_live_entry( + &self.directory, + &self.witness_name, + next_identity, + "signer state witness journal", + )?; + if !retire_previous { + self.state_anchor_trust_mutation_guard().revalidate()?; + #[cfg(test)] + maybe_inject_state_anchor_trust_transition_fault( + StateAnchorTrustTransitionFaultInjectionPoint::AfterCurrentWitnessPublication, + )?; + } + + self.witness_file = next_file; + self.witness_identity = next_identity; + self.witness_history = parsed.history; + self.pending_witness = parsed.pending; + self.witness_length = parsed.length; + self.witness_header_length = parsed.header_length; + self.witness_header_bytes = parsed.header_bytes; + self.witness_segment_header = parsed.segment_header; + self.witness_prefix = None; + self.last_appended_record = [0u8; TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH]; + self.last_appended_stamp = None; + + // The new name and complete signed header are durable and verified. + // Only now may the previous segment be retired. + self.verify_state_witness_journal_fully_with_anchor(validation_anchor)?; + if self + .witness_history + .last() + .map(|entry| entry.state_image_digest != current_digest) + .unwrap_or(true) + { + return Err(EngineError::Internal( + "published state witness segment does not commit the current state image; \ + retaining the previous segment" + .to_string(), + )); + } + if retire_previous { + unlinkat_entry(self.directory.as_raw_fd(), &self.witness_previous_name)?; + self.directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync signer state directory after retiring previous witness \ + segment: {error}" + )) + })?; + } + Ok(()) + } + + pub(crate) fn state_witness_proof( + &mut self, + ancestor_generation: u64, + ancestor_commitment: [u8; 32], + target_generation: u64, + target_commitment: [u8; 32], + maximum_entries: usize, + ) -> Result<(Vec, bool), EngineError> { + self.reconcile_pending_witness()?; + self.revalidate()?; + if maximum_entries == 0 || maximum_entries > 256 { + return Err(EngineError::Validation( + "state witness proof maximumEntries must be between 1 and 256".to_string(), + )); + } + let ancestor_index = resolve_witness_history_index( + &self.witness_history, + ancestor_generation, + ancestor_commitment, + "ancestor", + )?; + let target_index = resolve_witness_history_index( + &self.witness_history, + target_generation, + target_commitment, + "target", + )?; + if target_index < ancestor_index { + return Err(EngineError::Validation( + "state witness proof target precedes the requested ancestor".to_string(), + )); + } + if target_index == ancestor_index { + return Ok((Vec::new(), true)); + } + + let end = (ancestor_index + 1 + maximum_entries).min(target_index + 1); + let entries = self.witness_history[(ancestor_index + 1)..end].to_vec(); + Ok((entries, end == target_index + 1)) + } + + #[cfg(unix)] + fn next_state_witness( + &self, + state_image_digest: [u8; 32], + ) -> Result { + let tip = self + .witness_history + .last() + .expect("a durable store always has a genesis state witness"); + let generation = tip.generation.checked_add(1).ok_or_else(|| { + EngineError::Internal("signer state witness generation exhausted u64".to_string()) + })?; + let previous_commitment = tip.commitment; + let commitment = state_commitment( + &self.identity.fingerprint, + generation, + &previous_commitment, + &state_image_digest, + ); + Ok(StateWitness { + generation, + previous_commitment, + commitment, + state_image_digest, + }) + } + + #[cfg(unix)] + fn prepare_witness( + &mut self, + witness: StateWitness, + purpose: WitnessAppendPurpose, + ) -> Result<(), EngineError> { + if self.pending_witness.is_some() { + return Err(EngineError::Internal( + "cannot prepare a state witness while another update is pending".to_string(), + )); + } + let expected = self.next_state_witness(witness.state_image_digest)?; + if witness != expected || witness.generation == 0 { + return Err(EngineError::Internal( + "prepared state witness does not extend the active witness tip".to_string(), + )); + } + if let Some(threshold) = self.witness_rotation_threshold { + let record_count = self.witness_record_count()?; + // The quarantine reserve sits strictly above the terminal band, so + // it is reachable only by the absence commit; ordinary writes stop + // at the terminal band exactly as before. The configured geometry + // keeps the reserve below the hard record ceiling, so the quarantine + // pair never has to choose between the rotation bound and a journal + // that would no longer parse on reopen. + let completion_limit = threshold + .checked_add(TBTC_SIGNER_STATE_WITNESS_ROTATION_TERMINAL_RECORD_RESERVATION) + .and_then(|limit| match purpose { + WitnessAppendPurpose::StateWrite => Some(limit), + WitnessAppendPurpose::CorruptionQuarantine => { + limit.checked_add(TBTC_SIGNER_STATE_WITNESS_QUARANTINE_RECORD_RESERVATION) + } + }) + .ok_or_else(|| { + EngineError::Internal( + "signer state witness rotation completion limit overflowed".to_string(), + ) + })?; + if record_count + .checked_add(2) + .is_none_or(|required| required > completion_limit) + { + return Err(EngineError::Internal( + "signer state witness rotation threshold reached; a fresh manifest-pinned, \ + authority-signed acknowledgement of the current tip is required before \ + additional state writes" + .to_string(), + )); + } + } + self.ensure_witness_record_capacity(2)?; + self.append_witness_record(TBTC_SIGNER_STATE_WITNESS_RECORD_PREPARE, &witness)?; + self.pending_witness = Some(witness); + self.extend_witness_prefix() + } + + #[cfg(unix)] + fn commit_pending_witness(&mut self) -> Result<(), EngineError> { + let pending = self.pending_witness.clone().ok_or_else(|| { + EngineError::Internal("no prepared state witness to commit".to_string()) + })?; + self.append_witness_record(TBTC_SIGNER_STATE_WITNESS_RECORD_COMMIT, &pending)?; + self.witness_history.push(pending); + self.pending_witness = None; + self.extend_witness_prefix() + } + + #[cfg(unix)] + fn abort_pending_witness(&mut self) -> Result<(), EngineError> { + let pending = self.pending_witness.clone().ok_or_else(|| { + EngineError::Internal("no prepared state witness to abort".to_string()) + })?; + self.append_witness_record(TBTC_SIGNER_STATE_WITNESS_RECORD_ABORT, &pending)?; + self.pending_witness = None; + self.extend_witness_prefix() + } + + #[cfg(unix)] + fn reconcile_pending_witness(&mut self) -> Result<(), EngineError> { + let Some(pending) = self.pending_witness.clone() else { + return Ok(()); + }; + let current_digest = current_state_image_digest(self.current_state_file.as_ref())?; + let committed = self.witness_history.last().ok_or_else(|| { + EngineError::Internal("signer state witness journal has no committed tip".to_string()) + })?; + if current_digest == pending.state_image_digest { + // The state rename won. Make that directory entry durable before + // appending COMMIT, including recovery from a crash/fault directly + // after rename. + self.directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync signer state directory while recovering witness: {error}" + )) + })?; + return self.commit_pending_witness(); + } + if current_digest == committed.state_image_digest { + return self.abort_pending_witness(); + } + Err(EngineError::Internal( + "ambiguous signer state witness update: current state matches neither the committed nor prepared image" + .to_string(), + )) + } + + #[cfg(not(unix))] + fn reconcile_pending_witness(&mut self) -> Result<(), EngineError> { + Err(EngineError::Internal( + "descriptor-bound durable signer storage is unavailable on this platform".to_string(), + )) + } + + #[cfg(unix)] + fn append_witness_record( + &mut self, + record_type: u8, + witness: &StateWitness, + ) -> Result<(), EngineError> { + // A cooperating append must never turn an unverified or externally + // changed prefix into a new trusted cache entry. Validate the exact + // pre-append journal first; this also checks the fixed anchors and + // forces a streaming full parse on any stamp mismatch. + self.verify_state_witness_journal()?; + self.ensure_witness_record_capacity(1)?; + let stat = descriptor_stat(&self.witness_file, "signer state witness journal")?; + if stat.st_size < 0 || stat.st_size as usize != self.witness_length { + return Err(EngineError::Internal( + "signer state witness journal length changed before append".to_string(), + )); + } + let record = encode_state_witness_record(record_type, witness); + append_file_at( + &self.witness_file, + self.witness_length, + &record, + "signer state witness journal", + )?; + self.witness_file.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync signer state witness journal: {error}" + )) + })?; + let appended_stamp = witness_change_stamp(&self.witness_file)?; + self.witness_length += record.len(); + self.last_appended_record.copy_from_slice(&record); + self.last_appended_stamp = Some(appended_stamp); + Ok(()) + } + + #[cfg(unix)] + fn witness_record_count(&self) -> Result { + self.witness_length + .checked_sub(self.witness_header_length) + .filter(|bytes| bytes % TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH == 0) + .map(|bytes| bytes / TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH) + .ok_or_else(|| { + EngineError::Internal( + "signer state witness journal length is not record-aligned".to_string(), + ) + }) + } + + #[cfg(unix)] + fn ensure_witness_record_capacity(&self, additional: usize) -> Result<(), EngineError> { + let required = self + .witness_record_count()? + .checked_add(additional) + .ok_or_else(|| { + EngineError::Internal( + "signer state witness journal record count overflowed".to_string(), + ) + })?; + if required > self.witness_max_records { + return Err(EngineError::Internal(format!( + "signer state witness journal record ceiling [{}] reached; refusing unsigned \ + local compaction or re-genesis. Install a future manifest-pinned, \ + authority-signed checkpoint through the checkpoint ABI before resuming writes", + self.witness_max_records + ))); + } + Ok(()) + } +} + +fn resolve_witness_history_index( + history: &[StateWitness], + generation: u64, + commitment: [u8; 32], + label: &str, +) -> Result { + let base_generation = history + .first() + .map(|entry| entry.generation) + .ok_or_else(|| { + EngineError::Internal("signer state witness journal has no retained base".to_string()) + })?; + if generation < base_generation { + return Err(EngineError::HistoryPruned { + requested_generation: generation, + witness_base_generation: base_generation, + }); + } + let index = generation + .checked_sub(base_generation) + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| { + EngineError::Validation(format!( + "state witness proof {label} is not in the active store history" + )) + })?; + match history.get(index) { + Some(entry) if entry.generation == generation && entry.commitment == commitment => { + Ok(index) + } + _ => Err(EngineError::Validation(format!( + "state witness proof {label} is not in the active store history" + ))), + } +} + +#[cfg(unix)] +fn ensure_state_anchor_trust_transition_journal_capacity_for_length( + current_length: usize, + journal_growth: usize, +) -> Result<(), EngineError> { + if current_length + .checked_add(journal_growth) + .is_none_or(|length| length > STATE_ANCHOR_TRUST_MAX_JOURNAL_LENGTH) + { + return Err(EngineError::Validation( + "complete state-anchor trust transition batch would exceed the durable journal size \ + bound" + .to_string(), + )); + } + Ok(()) +} + +#[derive(Debug)] +pub(crate) struct StoreReplaceError { + error: EngineError, + replaced: bool, +} + +impl StoreReplaceError { + fn before_replacement(error: EngineError) -> Self { + Self { + error, + replaced: false, + } + } + + pub(crate) fn replaced(&self) -> bool { + self.replaced + } + + pub(crate) fn into_engine_error(self) -> EngineError { + self.error + } +} + +#[cfg(test)] +pub(crate) fn durable_store_id_file_path(state_path: &Path) -> PathBuf { + let name = state_path + .file_name() + .map(durable_store_id_file_name) + .unwrap_or_else(|| OsString::from("signer-state.store-id")); + state_path + .parent() + .map(|parent| parent.join(&name)) + .unwrap_or_else(|| PathBuf::from(name)) +} + +#[cfg(test)] +pub(crate) fn state_witness_file_path(state_path: &Path) -> PathBuf { + let name = state_path + .file_name() + .map(state_witness_file_name) + .unwrap_or_else(|| OsString::from("signer-state.state-witness")); + state_path + .parent() + .map(|parent| parent.join(&name)) + .unwrap_or_else(|| PathBuf::from(name)) +} + +#[cfg(test)] +pub(crate) fn state_anchor_file_path(state_path: &Path) -> PathBuf { + let name = state_path + .file_name() + .map(state_anchor_file_name) + .unwrap_or_else(|| OsString::from("signer-state.state-anchor")); + state_path + .parent() + .map(|parent| parent.join(&name)) + .unwrap_or_else(|| PathBuf::from(name)) +} + +#[cfg(test)] +#[allow(dead_code)] +pub(crate) fn state_anchor_trust_file_path(state_path: &Path) -> PathBuf { + let name = state_path + .file_name() + .map(state_anchor_trust_file_name) + .unwrap_or_else(|| OsString::from("signer-state.state-anchor-trust")); + state_path + .parent() + .map(|parent| parent.join(&name)) + .unwrap_or_else(|| PathBuf::from(name)) +} + +#[cfg(test)] +#[allow(dead_code)] +pub(crate) fn state_anchor_trust_intent_file_path(state_path: &Path) -> PathBuf { + let name = state_path + .file_name() + .map(state_anchor_trust_intent_file_name) + .unwrap_or_else(|| OsString::from("signer-state.state-anchor-trust.intent")); + state_path + .parent() + .map(|parent| parent.join(&name)) + .unwrap_or_else(|| PathBuf::from(name)) +} + +fn durable_store_id_file_name(state_name: &OsStr) -> OsString { + let mut name = state_name.to_os_string(); + name.push(TBTC_SIGNER_DURABLE_STORE_ID_SUFFIX); + name +} + +fn state_witness_file_name(state_name: &OsStr) -> OsString { + let mut name = state_name.to_os_string(); + name.push(TBTC_SIGNER_STATE_WITNESS_SUFFIX); + name +} + +fn state_anchor_file_name(state_name: &OsStr) -> OsString { + let mut name = state_name.to_os_string(); + name.push(TBTC_SIGNER_STATE_ANCHOR_SUFFIX); + name +} + +fn state_anchor_trust_file_name(state_name: &OsStr) -> OsString { + let mut name = state_name.to_os_string(); + name.push(TBTC_SIGNER_STATE_ANCHOR_TRUST_SUFFIX); + name +} + +fn state_anchor_trust_intent_file_name(state_name: &OsStr) -> OsString { + let mut name = state_name.to_os_string(); + name.push(TBTC_SIGNER_STATE_ANCHOR_TRUST_INTENT_SUFFIX); + name +} + +/// The Go/Rust v2 store fingerprint: the stable anchor of the state-commitment +/// transcript. +/// +/// Inputs are the two compile-time contract constants and the 32 fsynced +/// `.store-id` bytes, length-prefixed exactly as `hash_fields` prescribes. +/// Nothing volatile (path, device, inode, lock file) may ever be added here: +/// this value is recomputed on every start and every committed record must +/// still verify under it. +pub(crate) fn durable_store_fingerprint(store_id: &[u8; 32]) -> [u8; 32] { + hash_fields( + TBTC_SIGNER_DURABLE_STORE_FINGERPRINT_DOMAIN, + &[ + TBTC_SIGNER_DURABLE_STORE_IDENTITY_SCHEMA.as_bytes(), + TBTC_SIGNER_DURABLE_STORE_BACKEND.as_bytes(), + store_id, + ], + ) +} + +/// The retired v1 fingerprint transcript, kept only so the frozen cross-language +/// v1 vector stays pinned and so v1 journal fixtures can be built for the +/// rejection-path regression tests. +#[cfg(test)] +pub(crate) fn durable_store_fingerprint_v1( + store_id: &[u8; 32], + canonical_path_fingerprint: &[u8; 32], + filesystem_fingerprint: &[u8; 32], + lock_fingerprint: &[u8; 32], +) -> [u8; 32] { + hash_fields( + b"tbtc-signer-durable-session-store-fingerprint-v1\0", + &[ + b"tbtc-signer-durable-session-store-identity/v1", + TBTC_SIGNER_DURABLE_STORE_BACKEND.as_bytes(), + store_id, + canonical_path_fingerprint, + filesystem_fingerprint, + lock_fingerprint, + ], + ) +} + +fn state_image_digest(state_bytes: Option<&[u8]>) -> [u8; 32] { + match state_bytes { + Some(bytes) => hash_fields(TBTC_SIGNER_STATE_IMAGE_DIGEST_DOMAIN, &[&[1], bytes]), + None => hash_fields(TBTC_SIGNER_STATE_IMAGE_DIGEST_DOMAIN, &[&[0], &[]]), + } +} + +pub(crate) fn state_commitment( + store_fingerprint: &[u8; 32], + generation: u64, + previous_commitment: &[u8; 32], + state_image_digest: &[u8; 32], +) -> [u8; 32] { + // Frozen Go/Rust v2 transcript: fields are fixed-width and therefore are + // concatenated directly, without the length prefixes used by hash_fields. + // `store_fingerprint` MUST be the stable `durable_store_fingerprint`. + let mut digest = Sha256::new(); + digest.update(TBTC_SIGNER_STATE_COMMITMENT_DOMAIN); + digest.update(store_fingerprint); + digest.update(generation.to_be_bytes()); + digest.update(previous_commitment); + digest.update(state_image_digest); + digest.finalize().into() +} + +fn state_witness_genesis(store_fingerprint: &[u8; 32]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(TBTC_SIGNER_STATE_WITNESS_GENESIS_DOMAIN); + digest.update(store_fingerprint); + digest.finalize().into() +} + +/// The retired v1 commitment transcript, retained for the frozen v1 vectors and +/// for building v1 journal fixtures in the rejection-path regression tests. +#[cfg(test)] +pub(crate) fn state_commitment_v1( + store_fingerprint: &[u8; 32], + generation: u64, + previous_commitment: &[u8; 32], + state_image_digest: &[u8; 32], +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(b"tbtc-signer-state-witness-commitment-v1\0"); + digest.update(store_fingerprint); + digest.update(generation.to_be_bytes()); + digest.update(previous_commitment); + digest.update(state_image_digest); + digest.finalize().into() +} + +/// The retired v1 genesis transcript. See [`state_commitment_v1`]. +#[cfg(test)] +pub(crate) fn state_witness_genesis_v1(store_fingerprint: &[u8; 32]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(b"tbtc-signer-state-witness-genesis-v1\0"); + digest.update(store_fingerprint); + digest.finalize().into() +} + +/// Builds a complete, well-formed v1 journal image (header + PREPARE + COMMIT) +/// for the v1 rejection-path regression test. Only the v1 magic is needed to +/// recognize such a journal, but a fixture that is otherwise valid proves the +/// rejection is driven by the transcript version and not by incidental damage. +#[cfg(test)] +pub(crate) fn encode_v1_state_witness_genesis_journal( + store_id: &[u8; 32], + v1_store_fingerprint: &[u8; 32], + state_image_digest: &[u8; 32], +) -> Vec { + let previous_commitment = state_witness_genesis_v1(v1_store_fingerprint); + let genesis = StateWitness { + generation: 1, + previous_commitment, + commitment: state_commitment_v1( + v1_store_fingerprint, + 1, + &previous_commitment, + state_image_digest, + ), + state_image_digest: *state_image_digest, + }; + let mut bytes = Vec::new(); + bytes.extend_from_slice(TBTC_SIGNER_STATE_WITNESS_MAGIC_V1); + bytes.extend_from_slice(store_id); + bytes.extend_from_slice(&encode_state_witness_record( + TBTC_SIGNER_STATE_WITNESS_RECORD_PREPARE, + &genesis, + )); + bytes.extend_from_slice(&encode_state_witness_record( + TBTC_SIGNER_STATE_WITNESS_RECORD_COMMIT, + &genesis, + )); + bytes +} + +fn hash_fields(domain: &[u8], fields: &[&[u8]]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(domain); + for field in fields { + digest.update((field.len() as u32).to_be_bytes()); + digest.update(field); + } + digest.finalize().into() +} + +#[cfg(unix)] +fn validate_entry_name(name: &OsStr, label: &str) -> Result<(), EngineError> { + if name.is_empty() || name.as_bytes().contains(&b'/') || name.as_bytes().contains(&0) { + return Err(EngineError::Internal(format!( + "invalid signer {label} file name" + ))); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_entry_name(_name: &OsStr, _label: &str) -> Result<(), EngineError> { + Ok(()) +} + +#[cfg(unix)] +fn os_str_cstring(value: &OsStr, label: &str) -> Result { + CString::new(value.as_bytes()) + .map_err(|_| EngineError::Internal(format!("signer {label} path contains a NUL byte"))) +} + +#[cfg(unix)] +fn open_absolute_directory_nofollow(path: &Path) -> Result { + use std::path::Component; + + if !path.is_absolute() { + return Err(EngineError::Internal(format!( + "signer store directory [{}] is not absolute", + path.display() + ))); + } + + let root = CString::new("/").expect("root contains no NUL"); + let root_fd = unsafe { + libc::open( + root.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if root_fd < 0 { + return Err(EngineError::Internal(format!( + "failed to open filesystem root without following symlinks: {}", + std::io::Error::last_os_error() + ))); + } + let mut directory = unsafe { fs::File::from_raw_fd(root_fd) }; + + for component in path.components() { + let Component::Normal(component) = component else { + if matches!(component, Component::RootDir) { + continue; + } + return Err(EngineError::Internal(format!( + "canonical signer store directory [{}] contains a non-normal component", + path.display() + ))); + }; + let component = os_str_cstring(component, "directory component")?; + let next_fd = unsafe { + libc::openat( + directory.as_raw_fd(), + component.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if next_fd < 0 { + return Err(EngineError::Internal(format!( + "failed to traverse canonical signer store directory [{}] without following symlinks: {}", + path.display(), + std::io::Error::last_os_error() + ))); + } + directory = unsafe { fs::File::from_raw_fd(next_fd) }; + } + Ok(directory) +} + +#[cfg(unix)] +fn openat_regular( + directory_fd: RawFd, + name: &OsStr, + flags: i32, + mode: libc::mode_t, + label: &str, +) -> Result { + let name_c = os_str_cstring(name, label)?; + let fd = unsafe { + libc::openat( + directory_fd, + name_c.as_ptr(), + flags | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK, + mode as libc::c_uint, + ) + }; + if fd < 0 { + return Err(EngineError::Internal(format!( + "failed to open {label} without following symlinks: {}", + std::io::Error::last_os_error() + ))); + } + Ok(unsafe { fs::File::from_raw_fd(fd) }) +} + +/// Creates a store entry whose ENTIRE content is written in one all-or-nothing +/// step. +/// +/// `O_CREAT|O_EXCL` followed by a plain write is not atomic: a hard kill in that +/// window leaves a short file at the final name, and every short length of the +/// store ID and of the genesis journal is fatal on the next start - inside +/// `acquire`, where the corruption policy cannot reach. The complete image is +/// therefore written to a temp entry in the same directory, fsynced, renamed +/// over the target, and the DIRECTORY is fsynced so the rename itself is +/// durable. A crash anywhere before the rename leaves the target absent, which +/// is a state the opener already handles by creating it. +/// +/// The temp entry is created with `openat` + `O_EXCL` + `O_NOFOLLOW` under the +/// held no-follow directory descriptor and mode 0600, so it is never a +/// symlink-following hazard, and the returned descriptor is the one that +/// survives the rename - no reopen by name is involved. +#[cfg(unix)] +fn create_entry_atomically( + directory: &fs::File, + name: &OsStr, + bytes: &[u8], + label: &str, +) -> Result<(fs::File, OpenedObjectIdentity), EngineError> { + create_entry_atomically_with_guard(directory, name, bytes, label, None) +} + +#[cfg(unix)] +fn create_entry_atomically_with_guard( + directory: &fs::File, + name: &OsStr, + bytes: &[u8], + label: &str, + recovery_guard: Option<&StateAnchorTrustRecoveryGuard<'_>>, +) -> Result<(fs::File, OpenedObjectIdentity), EngineError> { + let temp_name = unique_temp_name(name)?; + let temp_file = openat_regular( + directory.as_raw_fd(), + &temp_name, + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL, + 0o600, + label, + )?; + + let outcome = (|| -> Result { + validate_owned_unlinked_regular(&temp_file, label)?; + set_owner_only_permissions(&temp_file, label)?; + validate_secure_regular_file(&temp_file, label)?; + write_file_at(&temp_file, bytes, label)?; + temp_file.sync_all().map_err(|error| { + EngineError::Internal(format!("failed to sync new {label}: {error}")) + })?; + // Publish only over an absent name. The exclusive store lock is held, + // so nothing that participates in this protocol can be racing us here; + // an entry that appeared anyway is not ours to overwrite. + ensure_entry_absent(directory.as_raw_fd(), name, label)?; + if let Some(guard) = recovery_guard { + guard.revalidate()?; + } + renameat_same_directory(directory.as_raw_fd(), &temp_name, name, label)?; + directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync signer state directory after publishing {label}: {error}" + )) + })?; + let identity = descriptor_identity(&temp_file, label)?; + validate_live_entry(directory, name, identity, label)?; + #[cfg(test)] + maybe_replace_trust_lock_after_guarded_publication(recovery_guard)?; + if let Some(guard) = recovery_guard { + guard.revalidate()?; + } + Ok(identity) + })(); + + match outcome { + Ok(identity) => Ok((temp_file, identity)), + Err(error) => { + let _ = unlinkat_entry(directory.as_raw_fd(), &temp_name); + Err(error) + } + } +} + +#[cfg(unix)] +fn open_or_create_store_id( + directory: &fs::File, + name: &OsStr, +) -> Result<(fs::File, [u8; 32], OpenedObjectIdentity), EngineError> { + const LABEL: &str = "signer durable store ID file"; + + if let Some(file) = openat_optional(directory.as_raw_fd(), name, libc::O_RDWR, LABEL)? { + validate_owned_unlinked_regular(&file, LABEL)?; + set_owner_only_permissions(&file, LABEL)?; + validate_secure_regular_file(&file, LABEL)?; + let store_id = read_store_id(&file)?; + let identity = descriptor_identity(&file, LABEL)?; + return Ok((file, store_id, identity)); + } + + let mut store_id = [0u8; 32]; + loop { + OsRng.fill_bytes(&mut store_id); + if store_id != [0u8; 32] { + break; + } + } + let (file, identity) = create_entry_atomically(directory, name, &store_id, LABEL)?; + Ok((file, store_id, identity)) +} + +#[cfg(unix)] +fn open_state_anchor( + directory: &fs::File, + name: &OsStr, + store_fingerprint: &[u8; 32], + configuration: Option<&StateAnchorConfiguration>, + certified_floors: &[StateAnchorTrustReferenceModel], +) -> Result { + const LABEL: &str = "signer state anchor metadata"; + let Some(file) = openat_optional(directory.as_raw_fd(), name, libc::O_RDWR, LABEL)? else { + return Ok((None, None, None, None)); + }; + let configuration = configuration.ok_or_else(|| { + EngineError::Internal( + "persisted signer state anchor metadata requires all manifest-pinned anchor \ + configuration values" + .to_string(), + ) + })?; + validate_owned_unlinked_regular(&file, LABEL)?; + set_owner_only_permissions(&file, LABEL)?; + validate_secure_regular_file(&file, LABEL)?; + let bytes = read_file_at(&file, LABEL)?; + let metadata = + parse_state_anchor_metadata(&bytes, store_fingerprint, configuration, certified_floors)?; + let identity = descriptor_identity(&file, LABEL)?; + Ok((Some(file), Some(identity), Some(metadata), Some(bytes))) +} + +#[cfg(unix)] +fn open_state_anchor_trust_journal( + directory: &fs::File, + name: &OsStr, + store_fingerprint: &[u8; 32], + required: bool, +) -> Result { + const LABEL: &str = "state-anchor trust journal"; + let Some(file) = openat_optional(directory.as_raw_fd(), name, libc::O_RDWR, LABEL)? else { + if required { + return Err(EngineError::StateAnchorTrustHeadAbsent); + } + return Ok((None, None, None, None, None)); + }; + validate_secure_regular_file(&file, LABEL)?; + let metadata = file + .metadata() + .map_err(|error| EngineError::Internal(format!("failed to stat {LABEL}: {error}")))?; + let length = usize::try_from(metadata.len()) + .map_err(|_| EngineError::Internal(format!("{LABEL} length does not fit this platform")))?; + if length > STATE_ANCHOR_TRUST_MAX_JOURNAL_LENGTH { + return Err(EngineError::Internal(format!( + "{LABEL} exceeds its maximum durable length" + ))); + } + let identity = descriptor_identity(&file, LABEL)?; + let before = file_change_stamp(&file, LABEL)?; + let bytes = read_file_at(&file, LABEL)?; + let parsed = parse_state_anchor_trust_journal(&bytes, store_fingerprint)?; + let after = file_change_stamp(&file, LABEL)?; + if before != after { + return Err(EngineError::Internal( + "state-anchor trust journal changed while it was being opened".to_string(), + )); + } + Ok(( + Some(file), + Some(identity), + Some(parsed), + Some(after), + Some(bytes), + )) +} + +#[cfg(unix)] +fn open_state_anchor_trust_transition_intent( + directory: &fs::File, + name: &OsStr, + store_fingerprint: &[u8; 32], +) -> Result>, EngineError> { + const LABEL: &str = "state-anchor trust transition intent"; + let Some(file) = openat_optional(directory.as_raw_fd(), name, libc::O_RDONLY, LABEL)? else { + return Ok(None); + }; + validate_owned_unlinked_regular(&file, LABEL)?; + validate_secure_regular_file(&file, LABEL)?; + let length = usize::try_from( + file.metadata() + .map_err(|error| EngineError::Internal(format!("failed to stat {LABEL}: {error}")))? + .len(), + ) + .map_err(|_| EngineError::Internal(format!("{LABEL} length does not fit this platform")))?; + if length > STATE_ANCHOR_TRUST_MAX_INTENT_LENGTH { + return Err(EngineError::Internal(format!( + "{LABEL} exceeds its maximum durable length" + ))); + } + let bytes = read_file_at(&file, LABEL)?; + parse_state_anchor_trust_transition_intent(&bytes, store_fingerprint)?; + Ok(Some(bytes)) +} + +fn encode_state_anchor_acknowledgement(acknowledgement: &StateAnchorAcknowledgement) -> Vec { + let mut bytes = Vec::with_capacity(TBTC_SIGNER_STATE_ANCHOR_ACK_LENGTH); + bytes.extend_from_slice(&acknowledgement.binding_hash); + bytes.extend_from_slice(&acknowledgement.request_digest); + bytes.extend_from_slice(&acknowledgement.nonce); + bytes.push(acknowledgement.status); + bytes.extend_from_slice(&[0u8; 7]); + bytes.extend_from_slice(&acknowledgement.service_epoch.to_be_bytes()); + bytes.extend_from_slice(&acknowledgement.revision.to_be_bytes()); + bytes.extend_from_slice(&acknowledgement.previous_event_root); + bytes.extend_from_slice(&acknowledgement.event_root); + bytes.extend_from_slice(&acknowledgement.checkpoint_store_fingerprint); + bytes.extend_from_slice(&acknowledgement.checkpoint_generation.to_be_bytes()); + bytes.extend_from_slice(&acknowledgement.checkpoint_previous_commitment); + bytes.extend_from_slice(&acknowledgement.checkpoint_state_image_digest); + bytes.extend_from_slice(&acknowledgement.checkpoint_state_commitment); + bytes.extend_from_slice(&acknowledgement.operation_id); + bytes.extend_from_slice(&acknowledgement.transition_digest); + bytes.extend_from_slice(&acknowledgement.committed_at_unix_ms.to_be_bytes()); + bytes.extend_from_slice(&acknowledgement.expires_at_unix_ms.to_be_bytes()); + bytes.extend_from_slice(&acknowledgement.signing_digest); + bytes.extend_from_slice(&acknowledgement.signature); + bytes.extend_from_slice(&acknowledgement.configured_spki_hash); + bytes.extend_from_slice(&acknowledgement.acknowledgement_digest); + debug_assert_eq!(bytes.len(), TBTC_SIGNER_STATE_ANCHOR_ACK_LENGTH); + bytes +} + +fn decode_state_anchor_acknowledgement( + bytes: &[u8], +) -> Result { + if bytes.len() != TBTC_SIGNER_STATE_ANCHOR_ACK_LENGTH { + return Err(EngineError::Internal(format!( + "persisted state-anchor acknowledgement has invalid length [{}]", + bytes.len() + ))); + } + let mut offset = 0usize; + let binding_hash = take_fixed::<32>(bytes, &mut offset, "anchor binding hash")?; + let request_digest = take_fixed::<32>(bytes, &mut offset, "anchor request digest")?; + let nonce = take_fixed::<32>(bytes, &mut offset, "anchor nonce")?; + let status = take_fixed::<1>(bytes, &mut offset, "anchor status")?[0]; + if take_fixed::<7>(bytes, &mut offset, "anchor reserved bytes")? != [0u8; 7] { + return Err(EngineError::Internal( + "persisted state-anchor acknowledgement reserved bytes are nonzero".to_string(), + )); + } + let service_epoch = + u64::from_be_bytes(take_fixed::<8>(bytes, &mut offset, "anchor service epoch")?); + let revision = u64::from_be_bytes(take_fixed::<8>(bytes, &mut offset, "anchor revision")?); + let previous_event_root = take_fixed::<32>(bytes, &mut offset, "anchor previous event root")?; + let event_root = take_fixed::<32>(bytes, &mut offset, "anchor event root")?; + let checkpoint_store_fingerprint = + take_fixed::<32>(bytes, &mut offset, "anchor checkpoint store fingerprint")?; + let checkpoint_generation = u64::from_be_bytes(take_fixed::<8>( + bytes, + &mut offset, + "anchor checkpoint generation", + )?); + let checkpoint_previous_commitment = + take_fixed::<32>(bytes, &mut offset, "anchor checkpoint previous commitment")?; + let checkpoint_state_image_digest = + take_fixed::<32>(bytes, &mut offset, "anchor checkpoint state image digest")?; + let checkpoint_state_commitment = + take_fixed::<32>(bytes, &mut offset, "anchor checkpoint state commitment")?; + let operation_id = take_fixed::<32>(bytes, &mut offset, "anchor operation ID")?; + let transition_digest = take_fixed::<32>(bytes, &mut offset, "anchor transition digest")?; + let committed_at_unix_ms = u64::from_be_bytes(take_fixed::<8>( + bytes, + &mut offset, + "anchor committed timestamp", + )?); + let expires_at_unix_ms = u64::from_be_bytes(take_fixed::<8>( + bytes, + &mut offset, + "anchor expiration timestamp", + )?); + let signing_digest = take_fixed::<32>(bytes, &mut offset, "anchor signing digest")?; + let signature = take_fixed::<64>(bytes, &mut offset, "anchor signature")?; + let configured_spki_hash = take_fixed::<32>(bytes, &mut offset, "anchor configured SPKI hash")?; + let acknowledgement_digest = + take_fixed::<32>(bytes, &mut offset, "anchor acknowledgement digest")?; + debug_assert_eq!(offset, bytes.len()); + Ok(StateAnchorAcknowledgement { + binding_hash, + request_digest, + nonce, + status, + service_epoch, + revision, + previous_event_root, + event_root, + checkpoint_store_fingerprint, + checkpoint_generation, + checkpoint_previous_commitment, + checkpoint_state_image_digest, + checkpoint_state_commitment, + operation_id, + transition_digest, + committed_at_unix_ms, + expires_at_unix_ms, + signing_digest, + signature, + configured_spki_hash, + acknowledgement_digest, + }) +} + +fn encode_state_anchor_metadata( + store_fingerprint: &[u8; 32], + metadata: &StateAnchorMetadata, +) -> Vec { + let mut bytes = Vec::with_capacity(TBTC_SIGNER_STATE_ANCHOR_METADATA_LENGTH); + bytes.extend_from_slice(TBTC_SIGNER_STATE_ANCHOR_MAGIC); + bytes.extend_from_slice(&TBTC_SIGNER_STATE_ANCHOR_VERSION.to_be_bytes()); + bytes.extend_from_slice(store_fingerprint); + let flags = u8::from(metadata.witness_base.is_some()) + | (u8::from(metadata.pending_witness_base.is_some()) << 1); + bytes.push(flags); + bytes.extend_from_slice(&[0u8; 3]); + bytes.extend_from_slice(&encode_state_anchor_acknowledgement(&metadata.latest)); + match metadata.witness_base.as_ref() { + Some(base) => bytes.extend_from_slice(&encode_state_anchor_acknowledgement(base)), + None => bytes.extend_from_slice(&vec![0u8; TBTC_SIGNER_STATE_ANCHOR_ACK_LENGTH]), + } + match metadata.pending_witness_base.as_ref() { + Some(base) => bytes.extend_from_slice(&encode_state_anchor_acknowledgement(base)), + None => bytes.extend_from_slice(&vec![0u8; TBTC_SIGNER_STATE_ANCHOR_ACK_LENGTH]), + } + let commitment = state_anchor_metadata_commitment(&bytes); + bytes.extend_from_slice(&commitment); + debug_assert_eq!(bytes.len(), TBTC_SIGNER_STATE_ANCHOR_METADATA_LENGTH); + bytes +} + +fn parse_state_anchor_metadata( + bytes: &[u8], + expected_store_fingerprint: &[u8; 32], + configuration: &StateAnchorConfiguration, + certified_floors: &[StateAnchorTrustReferenceModel], +) -> Result { + if bytes.len() != TBTC_SIGNER_STATE_ANCHOR_METADATA_LENGTH { + return Err(EngineError::Internal(format!( + "signer state anchor metadata has invalid length [{}], expected [{}]", + bytes.len(), + TBTC_SIGNER_STATE_ANCHOR_METADATA_LENGTH + ))); + } + let committed_prefix = &bytes[..bytes.len() - 32]; + let expected_commitment = state_anchor_metadata_commitment(committed_prefix); + if bytes[bytes.len() - 32..] != expected_commitment { + return Err(EngineError::Internal( + "signer state anchor metadata commitment is invalid".to_string(), + )); + } + if &bytes[..16] != TBTC_SIGNER_STATE_ANCHOR_MAGIC + || u32::from_be_bytes(bytes[16..20].try_into().expect("fixed anchor version")) + != TBTC_SIGNER_STATE_ANCHOR_VERSION + || &bytes[20..52] != expected_store_fingerprint + { + return Err(EngineError::Internal( + "signer state anchor metadata header or store fingerprint is invalid".to_string(), + )); + } + let flags = bytes[52]; + if flags & !0b11 != 0 { + return Err(EngineError::Internal( + "signer state anchor metadata flags are invalid".to_string(), + )); + } + let base_present = flags & 1 != 0; + let pending_base_present = flags & 2 != 0; + if bytes[53..56] != [0u8; 3] { + return Err(EngineError::Internal( + "signer state anchor metadata reserved bytes are nonzero".to_string(), + )); + } + let latest_end = 56 + TBTC_SIGNER_STATE_ANCHOR_ACK_LENGTH; + let base_end = latest_end + TBTC_SIGNER_STATE_ANCHOR_ACK_LENGTH; + let pending_base_end = base_end + TBTC_SIGNER_STATE_ANCHOR_ACK_LENGTH; + let latest = decode_state_anchor_acknowledgement(&bytes[56..latest_end])?; + let base_bytes = &bytes[latest_end..base_end]; + let witness_base = if base_present { + Some(decode_state_anchor_acknowledgement(base_bytes)?) + } else { + if base_bytes.iter().any(|byte| *byte != 0) { + return Err(EngineError::Internal( + "signer state anchor metadata has an unflagged witness-base value".to_string(), + )); + } + None + }; + let pending_base_bytes = &bytes[base_end..pending_base_end]; + let pending_witness_base = if pending_base_present { + Some(decode_state_anchor_acknowledgement(pending_base_bytes)?) + } else { + if pending_base_bytes.iter().any(|byte| *byte != 0) { + return Err(EngineError::Internal( + "signer state anchor metadata has an unflagged pending witness-base value" + .to_string(), + )); + } + None + }; + validate_persisted_state_anchor_acknowledgement(&latest, configuration)?; + validate_anchor_acknowledgement_shape(&latest, expected_store_fingerprint)?; + validate_persisted_anchor_parent(&latest, certified_floors)?; + if let Some(base) = witness_base.as_ref() { + validate_persisted_state_anchor_acknowledgement(base, configuration)?; + validate_anchor_acknowledgement_shape(base, expected_store_fingerprint)?; + validate_persisted_anchor_parent(base, certified_floors)?; + if base.service_epoch != latest.service_epoch + || base.revision > latest.revision + || (base.revision == latest.revision && base != &latest) + || (base.revision.checked_add(1) == Some(latest.revision) + && latest.previous_event_root != base.event_root) + { + return Err(EngineError::Internal( + "signer state anchor witness-base acknowledgement is inconsistent with the \ + latest acknowledgement" + .to_string(), + )); + } + } + if let Some(pending) = pending_witness_base.as_ref() { + validate_persisted_state_anchor_acknowledgement(pending, configuration)?; + validate_anchor_acknowledgement_shape(pending, expected_store_fingerprint)?; + validate_persisted_anchor_parent(pending, certified_floors)?; + if pending != &latest { + return Err(EngineError::Internal( + "pending signer state witness base is not the latest accepted acknowledgement" + .to_string(), + )); + } + } + Ok(StateAnchorMetadata { + latest, + witness_base, + pending_witness_base, + }) +} + +fn validate_persisted_anchor_parent( + acknowledgement: &StateAnchorAcknowledgement, + certified_floors: &[StateAnchorTrustReferenceModel], +) -> Result<(), EngineError> { + let ordinary = (acknowledgement.revision == 1 + && acknowledgement.previous_event_root == [0u8; 32]) + || (acknowledgement.revision > 1 && acknowledgement.previous_event_root != [0u8; 32]); + let certified = acknowledgement.revision == 1 + && acknowledgement.previous_event_root != [0u8; 32] + && certified_floors + .iter() + .any(|floor| floor.matches_acknowledgement(acknowledgement)); + if ordinary || certified { + return Ok(()); + } + Err(EngineError::Internal( + "persisted revision-1 state-anchor acknowledgement lacks its exact offline-certified \ + epoch-genesis trust record" + .to_string(), + )) +} + +fn validate_anchor_acknowledgement_shape( + acknowledgement: &StateAnchorAcknowledgement, + expected_store_fingerprint: &[u8; 32], +) -> Result<(), EngineError> { + if !matches!(acknowledgement.status, 1 | 2) + || acknowledgement.service_epoch == 0 + || acknowledgement.revision == 0 + || acknowledgement.event_root == [0u8; 32] + || acknowledgement.request_digest == [0u8; 32] + || acknowledgement.nonce == [0u8; 32] + || acknowledgement.checkpoint_store_fingerprint != *expected_store_fingerprint + || acknowledgement.checkpoint_generation == 0 + || acknowledgement.checkpoint_state_image_digest == [0u8; 32] + || acknowledgement.checkpoint_state_commitment == [0u8; 32] + || acknowledgement.checkpoint_state_commitment + != state_commitment( + expected_store_fingerprint, + acknowledgement.checkpoint_generation, + &acknowledgement.checkpoint_previous_commitment, + &acknowledgement.checkpoint_state_image_digest, + ) + || acknowledgement.operation_id == [0u8; 32] + || acknowledgement.transition_digest == [0u8; 32] + { + return Err(EngineError::Internal( + "persisted state-anchor acknowledgement has invalid structural fields".to_string(), + )); + } + Ok(()) +} + +fn validate_anchor_monotonic_update( + existing: Option<&StateAnchorMetadata>, + acknowledgement: &StateAnchorAcknowledgement, + allow_unanchored_recovery: bool, +) -> Result { + let Some(existing) = existing else { + let is_first_revision = + acknowledgement.revision == 1 && acknowledgement.previous_event_root == [0u8; 32]; + let is_recovery_revision = allow_unanchored_recovery + && acknowledgement.revision > 1 + && acknowledgement.previous_event_root != [0u8; 32]; + if !is_first_revision && !is_recovery_revision { + return Err(EngineError::Validation( + "first state-anchor acknowledgement must have revision 1 and a zero \ + previousEventRoot unless admitted by a fresh recovery response" + .to_string(), + )); + } + return Ok(false); + }; + let latest = &existing.latest; + if acknowledgement.service_epoch != latest.service_epoch { + return Err(EngineError::Validation( + "state-anchor service epoch changed; an offline recovery certificate is required" + .to_string(), + )); + } + if acknowledgement.revision == latest.revision { + if acknowledgement != latest { + return Err(EngineError::Validation( + "state-anchor acknowledgement equivocates at an already accepted revision" + .to_string(), + )); + } + return Ok(true); + } + let expected_revision = latest.revision.checked_add(1).ok_or_else(|| { + EngineError::Internal("state-anchor service revision exhausted u64".to_string()) + })?; + if acknowledgement.revision != expected_revision + || acknowledgement.previous_event_root != latest.event_root + { + return Err(EngineError::Validation(format!( + "state-anchor acknowledgement must extend revision [{}] and its event root exactly", + latest.revision + ))); + } + Ok(false) +} + +fn validate_anchor_history( + anchor: Option<&StateAnchorMetadata>, + history: &[StateWitness], +) -> Result<(), EngineError> { + let Some(anchor) = anchor else { + return Ok(()); + }; + let base_generation = history + .first() + .map(|entry| entry.generation) + .ok_or_else(|| { + EngineError::Internal( + "state witness history is empty while validating signed anchor metadata" + .to_string(), + ) + })?; + let acknowledgement_is_retained = |acknowledgement: &StateAnchorAcknowledgement| -> bool { + acknowledgement + .checkpoint_generation + .checked_sub(base_generation) + .and_then(|offset| usize::try_from(offset).ok()) + .and_then(|index| history.get(index)) + .is_some_and(|entry| { + entry.generation == acknowledgement.checkpoint_generation + && entry.previous_commitment == acknowledgement.checkpoint_previous_commitment + && entry.state_image_digest == acknowledgement.checkpoint_state_image_digest + && entry.commitment == acknowledgement.checkpoint_state_commitment + }) + }; + if !acknowledgement_is_retained(&anchor.latest) { + return Err(EngineError::Internal( + "latest signed state-anchor checkpoint is not present in the active witness \ + segment" + .to_string(), + )); + } + if let Some(pending) = anchor.pending_witness_base.as_ref() { + if !acknowledgement_is_retained(pending) { + return Err(EngineError::Internal( + "pending signed state-anchor witness base is not present in the active witness \ + segment" + .to_string(), + )); + } + } + let pending_is_active_base = anchor.pending_witness_base.as_ref().is_some_and(|pending| { + history.first().is_some_and(|entry| { + entry.generation == pending.checkpoint_generation + && entry.previous_commitment == pending.checkpoint_previous_commitment + && entry.state_image_digest == pending.checkpoint_state_image_digest + && entry.commitment == pending.checkpoint_state_commitment + }) + }); + if let Some(base) = anchor.witness_base.as_ref() { + if !pending_is_active_base && !acknowledgement_is_retained(base) { + return Err(EngineError::Internal( + "signed state-anchor witness base is not present in the active witness segment" + .to_string(), + )); + } + } + Ok(()) +} + +fn state_anchor_metadata_commitment(bytes: &[u8]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(TBTC_SIGNER_STATE_ANCHOR_METADATA_DOMAIN); + digest.update(bytes); + digest.finalize().into() +} + +fn take_fixed( + bytes: &[u8], + offset: &mut usize, + label: &str, +) -> Result<[u8; N], EngineError> { + let end = offset + .checked_add(N) + .ok_or_else(|| EngineError::Internal(format!("persisted {label} offset overflowed")))?; + let value: [u8; N] = bytes + .get(*offset..end) + .ok_or_else(|| EngineError::Internal(format!("persisted {label} is truncated")))? + .try_into() + .expect("range length is fixed"); + *offset = end; + Ok(value) +} + +#[cfg(unix)] +fn replace_state_anchor_entry( + directory: &fs::File, + name: &OsStr, + bytes: &[u8], +) -> Result<(fs::File, OpenedObjectIdentity), EngineError> { + replace_durable_entry(directory, name, bytes, "signer state anchor metadata") +} + +#[cfg(unix)] +fn replace_durable_entry( + directory: &fs::File, + name: &OsStr, + bytes: &[u8], + label: &str, +) -> Result<(fs::File, OpenedObjectIdentity), EngineError> { + replace_durable_entry_with_guard(directory, name, bytes, label, None) +} + +#[cfg(unix)] +fn replace_durable_entry_with_guard( + directory: &fs::File, + name: &OsStr, + bytes: &[u8], + label: &str, + recovery_guard: Option<&StateAnchorTrustRecoveryGuard<'_>>, +) -> Result<(fs::File, OpenedObjectIdentity), EngineError> { + let temp_name = unique_temp_name(name)?; + let temp_file = openat_regular( + directory.as_raw_fd(), + &temp_name, + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL, + 0o600, + label, + )?; + let outcome = (|| { + validate_owned_unlinked_regular(&temp_file, label)?; + set_owner_only_permissions(&temp_file, label)?; + validate_secure_regular_file(&temp_file, label)?; + write_file_at(&temp_file, bytes, label)?; + temp_file.sync_all().map_err(|error| { + EngineError::Internal(format!("failed to sync new {label}: {error}")) + })?; + if let Some(guard) = recovery_guard { + guard.revalidate()?; + } + renameat_same_directory(directory.as_raw_fd(), &temp_name, name, label)?; + directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync signer state directory after publishing {label}: {error}" + )) + })?; + let identity = descriptor_identity(&temp_file, label)?; + validate_live_entry(directory, name, identity, label)?; + #[cfg(test)] + maybe_replace_trust_lock_after_guarded_publication(recovery_guard)?; + if let Some(guard) = recovery_guard { + guard.revalidate()?; + } + Ok(identity) + })(); + match outcome { + Ok(identity) => Ok((temp_file, identity)), + Err(error) => { + let _ = unlinkat_entry(directory.as_raw_fd(), &temp_name); + Err(error) + } + } +} + +fn encode_state_witness_segment_header( + store_fingerprint: &[u8; 32], + acknowledgement: &StateAnchorAcknowledgement, +) -> Result, EngineError> { + validate_anchor_acknowledgement_shape(acknowledgement, store_fingerprint)?; + let mut bytes = Vec::with_capacity(TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_LENGTH); + bytes.extend_from_slice(TBTC_SIGNER_STATE_WITNESS_SEGMENT_MAGIC); + bytes.extend_from_slice(&TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_VERSION.to_be_bytes()); + bytes + .extend_from_slice(&(TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_LENGTH as u32).to_be_bytes()); + bytes.extend_from_slice(store_fingerprint); + bytes.extend_from_slice(&acknowledgement.checkpoint_generation.to_be_bytes()); + bytes.extend_from_slice(&acknowledgement.checkpoint_previous_commitment); + bytes.extend_from_slice(&acknowledgement.checkpoint_state_image_digest); + bytes.extend_from_slice(&acknowledgement.checkpoint_state_commitment); + bytes.extend_from_slice(&acknowledgement.binding_hash); + bytes.extend_from_slice(&acknowledgement.service_epoch.to_be_bytes()); + bytes.extend_from_slice(&acknowledgement.revision.to_be_bytes()); + bytes.extend_from_slice(&acknowledgement.previous_event_root); + bytes.extend_from_slice(&acknowledgement.event_root); + bytes.extend_from_slice(&acknowledgement.operation_id); + bytes.extend_from_slice(&acknowledgement.transition_digest); + bytes.extend_from_slice(&acknowledgement.committed_at_unix_ms.to_be_bytes()); + bytes.extend_from_slice(&acknowledgement.acknowledgement_digest); + bytes.extend_from_slice(&acknowledgement.signature); + debug_assert_eq!(bytes.len(), 440); + let mut digest = Sha256::new(); + digest.update(TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_DOMAIN); + digest.update(&bytes); + bytes.extend_from_slice(&<[u8; 32]>::from(digest.finalize())); + debug_assert_eq!(bytes.len(), TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_LENGTH); + Ok(bytes) +} + +fn parse_state_witness_segment_header( + bytes: &[u8], + expected_store_fingerprint: &[u8; 32], + anchor: Option<&StateAnchorMetadata>, +) -> Result { + if bytes.len() != TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_LENGTH { + return Err(EngineError::Internal(format!( + "state witness segment header has invalid length [{}]", + bytes.len() + ))); + } + if &bytes[..16] != TBTC_SIGNER_STATE_WITNESS_SEGMENT_MAGIC + || u32::from_be_bytes(bytes[16..20].try_into().expect("fixed segment version")) + != TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_VERSION + || u32::from_be_bytes(bytes[20..24].try_into().expect("fixed segment length")) + != TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_LENGTH as u32 + { + return Err(EngineError::Internal( + "state witness segment header magic, version, or length is invalid".to_string(), + )); + } + let mut offset = 24usize; + let store_fingerprint = + take_fixed::<32>(bytes, &mut offset, "witness segment store fingerprint")?; + let generation = u64::from_be_bytes(take_fixed::<8>( + bytes, + &mut offset, + "witness segment base generation", + )?); + let previous_commitment = take_fixed::<32>( + bytes, + &mut offset, + "witness segment base previous commitment", + )?; + let state_image_digest = + take_fixed::<32>(bytes, &mut offset, "witness segment base image digest")?; + let commitment = take_fixed::<32>(bytes, &mut offset, "witness segment base commitment")?; + let base = StateWitness { + generation, + previous_commitment, + commitment, + state_image_digest, + }; + let binding_hash = take_fixed::<32>(bytes, &mut offset, "witness segment binding hash")?; + let service_epoch = u64::from_be_bytes(take_fixed::<8>( + bytes, + &mut offset, + "witness segment service epoch", + )?); + let revision = u64::from_be_bytes(take_fixed::<8>( + bytes, + &mut offset, + "witness segment revision", + )?); + let previous_event_root = + take_fixed::<32>(bytes, &mut offset, "witness segment previous event root")?; + let event_root = take_fixed::<32>(bytes, &mut offset, "witness segment event root")?; + let operation_id = take_fixed::<32>(bytes, &mut offset, "witness segment operation ID")?; + let transition_digest = + take_fixed::<32>(bytes, &mut offset, "witness segment transition digest")?; + let committed_at_unix_ms = u64::from_be_bytes(take_fixed::<8>( + bytes, + &mut offset, + "witness segment committed timestamp", + )?); + let acknowledgement_digest = + take_fixed::<32>(bytes, &mut offset, "witness segment acknowledgement digest")?; + let signature = take_fixed::<64>(bytes, &mut offset, "witness segment signature")?; + debug_assert_eq!(offset, 440); + let header_commitment = + take_fixed::<32>(bytes, &mut offset, "witness segment header commitment")?; + let mut digest = Sha256::new(); + digest.update(TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_DOMAIN); + digest.update(&bytes[..440]); + let expected_header_commitment: [u8; 32] = digest.finalize().into(); + if header_commitment != expected_header_commitment + || store_fingerprint != *expected_store_fingerprint + || generation == 0 + || state_image_digest == [0u8; 32] + || commitment == [0u8; 32] + || commitment + != state_commitment( + expected_store_fingerprint, + generation, + &previous_commitment, + &state_image_digest, + ) + { + return Err(EngineError::Internal( + "state witness segment header commitment or base is invalid".to_string(), + )); + } + let metadata = anchor.ok_or_else(|| { + EngineError::Internal( + "rotated state witness segment has no retained signed base acknowledgement; \ + offline recovery certification is required" + .to_string(), + ) + })?; + let header_matches_acknowledgement = |acknowledgement: &StateAnchorAcknowledgement| { + acknowledgement.checkpoint_store_fingerprint == store_fingerprint + && acknowledgement.checkpoint_generation == base.generation + && acknowledgement.checkpoint_previous_commitment == base.previous_commitment + && acknowledgement.checkpoint_state_image_digest == base.state_image_digest + && acknowledgement.checkpoint_state_commitment == base.commitment + && acknowledgement.binding_hash == binding_hash + && acknowledgement.service_epoch == service_epoch + && acknowledgement.revision == revision + && acknowledgement.previous_event_root == previous_event_root + && acknowledgement.event_root == event_root + && acknowledgement.operation_id == operation_id + && acknowledgement.transition_digest == transition_digest + && acknowledgement.committed_at_unix_ms == committed_at_unix_ms + && acknowledgement.acknowledgement_digest == acknowledgement_digest + && acknowledgement.signature == signature + }; + if ![ + metadata.witness_base.as_ref(), + metadata.pending_witness_base.as_ref(), + ] + .into_iter() + .flatten() + .any(header_matches_acknowledgement) + { + return Err(EngineError::Internal( + "state witness segment header disagrees with every retained signed base \ + acknowledgement" + .to_string(), + )); + } + Ok(StateWitnessSegmentHeader { + store_fingerprint, + base, + binding_hash, + service_epoch, + revision, + previous_event_root, + event_root, + operation_id, + transition_digest, + committed_at_unix_ms, + acknowledgement_digest, + signature, + header_commitment, + }) +} + +#[cfg(unix)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TrustRecoveryAnchorStage { + Prior, + Target, +} + +/// Completes every publication boundary authorized by a durable trust +/// transition intent. This runs while the descriptor-bound store lock is held +/// and before ordinary anchor/witness opening, because a crash can leave the +/// rotating endpoint, witness segment, and trust journal at different +/// individually durable stages. +#[cfg(unix)] +#[allow(clippy::too_many_arguments)] +fn recover_state_anchor_trust_transition( + recovery_guard: &StateAnchorTrustRecoveryGuard<'_>, + trust_name: &OsStr, + intent_name: &OsStr, + anchor_name: &OsStr, + witness_names: StateWitnessRotationNames<'_>, + store_identity: &DurableStoreIdentity, + current_state_file: Option<&fs::File>, + maximum_records: usize, + target_configuration: Option<&StateAnchorConfiguration>, + expected_intent_bytes: &[u8], + transition: &VerifiedStateAnchorTrustTransition, +) -> Result<(), EngineError> { + let directory = recovery_guard.directory; + let target_configuration = target_configuration.ok_or_else(|| { + EngineError::Internal( + "durable state-anchor trust recovery requires installed target pins".to_string(), + ) + })?; + let final_certificate = transition + .certificates + .last() + .expect("verified trust transition is nonempty"); + + revalidate_state_anchor_trust_transition_intent_entry( + directory, + intent_name, + &store_identity.fingerprint, + expected_intent_bytes, + )?; + let (_, _, mut journal, _, mut journal_bytes) = + open_state_anchor_trust_journal(directory, trust_name, &store_identity.fingerprint, false)?; + if journal.is_none() { + let header = encode_state_anchor_trust_journal_header(&store_identity.fingerprint); + recovery_guard.revalidate()?; + replace_durable_entry_with_guard( + directory, + trust_name, + &header, + "state-anchor trust journal during recovery", + Some(recovery_guard), + )?; + journal = Some(parse_state_anchor_trust_journal( + &header, + &store_identity.fingerprint, + )?); + journal_bytes = Some(header); + } + let mut journal = journal.expect("journal initialized above"); + let mut journal_bytes = journal_bytes.expect("journal bytes initialized above"); + validate_trust_recovery_journal(&journal, transition)?; + + // PREPARE every missing certificate before changing the online endpoint. + // A partial COMMIT implies that the complete PREPARE batch was already + // durable, which the validation helper enforces. + loop { + let (committed_count, prepared_count) = + trust_recovery_transition_progress(&journal, transition)?; + if prepared_count == transition.certificates.len() { + break; + } + if committed_count != 0 { + return Err(EngineError::Internal( + "state-anchor trust journal committed a partial transition before all PREPARE \ + records were durable" + .to_string(), + )); + } + revalidate_state_anchor_trust_transition_intent_entry( + directory, + intent_name, + &store_identity.fingerprint, + expected_intent_bytes, + )?; + recovery_guard.revalidate()?; + let certificate = &transition.certificates[prepared_count]; + let record = encode_state_anchor_trust_prepare_record( + &store_identity.fingerprint, + &journal.last_record_commitment, + certificate, + )?; + journal_bytes.extend_from_slice(&record); + let (next_journal, next_bytes) = publish_state_anchor_trust_journal_for_recovery( + directory, + trust_name, + &store_identity.fingerprint, + &journal_bytes, + recovery_guard, + )?; + journal = next_journal; + journal_bytes = next_bytes; + } + + let certified_floors = journal.certified_floors(); + let (prior_anchor, anchor_stage) = open_state_anchor_for_trust_recovery( + directory, + anchor_name, + &store_identity.fingerprint, + transition, + &certified_floors, + )?; + let target_acknowledgement = final_certificate.target_acknowledgement.clone(); + let rotation_anchor = match (anchor_stage, prior_anchor.as_ref()) { + (Some(TrustRecoveryAnchorStage::Prior), Some(prior)) => StateAnchorMetadata { + latest: prior.latest.clone(), + witness_base: prior.witness_base.clone(), + pending_witness_base: Some(target_acknowledgement.clone()), + }, + (Some(TrustRecoveryAnchorStage::Target), Some(_)) | (None, None) => StateAnchorMetadata { + latest: target_acknowledgement.clone(), + witness_base: Some(target_acknowledgement.clone()), + pending_witness_base: Some(target_acknowledgement.clone()), + }, + _ => { + return Err(EngineError::Internal( + "state-anchor trust recovery anchor stage is inconsistent".to_string(), + )) + } + }; + + revalidate_state_anchor_trust_transition_intent_entry( + directory, + intent_name, + &store_identity.fingerprint, + expected_intent_bytes, + )?; + if !recover_state_witness_rotation( + directory, + witness_names, + store_identity, + current_state_file, + Some(&rotation_anchor), + maximum_records, + false, + Some(recovery_guard), + )? { + return Err(EngineError::Internal( + "state-anchor trust recovery did not publish the certified witness base".to_string(), + )); + } + + let target_anchor = StateAnchorMetadata { + latest: target_acknowledgement.clone(), + witness_base: Some(target_acknowledgement), + pending_witness_base: None, + }; + let target_anchor_bytes = + encode_state_anchor_metadata(&store_identity.fingerprint, &target_anchor); + parse_state_anchor_metadata( + &target_anchor_bytes, + &store_identity.fingerprint, + &final_certificate.to.anchor_configuration()?, + &certified_floors, + )?; + revalidate_state_anchor_trust_transition_intent_entry( + directory, + intent_name, + &store_identity.fingerprint, + expected_intent_bytes, + )?; + recovery_guard.revalidate()?; + replace_durable_entry_with_guard( + directory, + anchor_name, + &target_anchor_bytes, + "signer state anchor metadata during trust recovery", + Some(recovery_guard), + )?; + + // COMMIT each already-prepared certificate with a COW publication. The + // parser represents a crash after a COMMIT prefix as a committed prefix + // plus the still-pending tail, so this loop naturally resumes at the exact + // next certificate. + loop { + let (committed_count, prepared_count) = + trust_recovery_transition_progress(&journal, transition)?; + if committed_count == transition.certificates.len() { + break; + } + if prepared_count != transition.certificates.len() { + return Err(EngineError::Internal( + "state-anchor trust recovery reached COMMIT without a complete PREPARE batch" + .to_string(), + )); + } + revalidate_state_anchor_trust_transition_intent_entry( + directory, + intent_name, + &store_identity.fingerprint, + expected_intent_bytes, + )?; + recovery_guard.revalidate()?; + let certificate = &transition.certificates[committed_count]; + let record = encode_state_anchor_trust_commit_record( + &store_identity.fingerprint, + &journal.last_record_commitment, + certificate, + )?; + journal_bytes.extend_from_slice(&record); + let (next_journal, next_bytes) = publish_state_anchor_trust_journal_for_recovery( + directory, + trust_name, + &store_identity.fingerprint, + &journal_bytes, + recovery_guard, + )?; + journal = next_journal; + journal_bytes = next_bytes; + } + + validate_state_anchor_trust_journal_head( + &journal, + target_configuration, + &store_identity.fingerprint, + )?; + revalidate_state_anchor_trust_transition_intent_entry( + directory, + intent_name, + &store_identity.fingerprint, + expected_intent_bytes, + )?; + recovery_guard.revalidate()?; + unlinkat_entry(directory.as_raw_fd(), witness_names.previous)?; + directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync trust recovery after retiring previous witness: {error}" + )) + })?; + recovery_guard.revalidate()?; + unlinkat_entry(directory.as_raw_fd(), intent_name)?; + directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync completed state-anchor trust intent removal: {error}" + )) + })?; + recovery_guard.revalidate()?; + Ok(()) +} + +#[cfg(unix)] +fn validate_trust_recovery_journal( + journal: &StateAnchorTrustJournalModel, + transition: &VerifiedStateAnchorTrustTransition, +) -> Result<(), EngineError> { + trust_recovery_transition_progress(journal, transition).map(|_| ()) +} + +/// Returns `(committed_count, prepared_or_committed_count)` within the intent +/// suffix after proving the durable journal contains no conflicting or extra +/// certificate at any suffix position. +#[cfg(unix)] +fn trust_recovery_transition_progress( + journal: &StateAnchorTrustJournalModel, + transition: &VerifiedStateAnchorTrustTransition, +) -> Result<(usize, usize), EngineError> { + let first = transition + .certificates + .first() + .expect("verified trust transition is nonempty"); + let prior_count = usize::try_from(first.certificate_sequence - 1).map_err(|_| { + EngineError::Internal( + "state-anchor trust recovery sequence does not fit this platform".to_string(), + ) + })?; + if journal.committed.len() < prior_count { + return Err(EngineError::Internal( + "state-anchor trust recovery journal is missing the intent predecessor".to_string(), + )); + } + if prior_count > 0 + && journal.committed[prior_count - 1].certificate_digest + != first.previous_certificate_digest + { + return Err(EngineError::Internal( + "state-anchor trust recovery intent does not extend the durable predecessor" + .to_string(), + )); + } + if journal.committed.len() + > prior_count + .checked_add(transition.certificates.len()) + .ok_or_else(|| { + EngineError::Internal( + "state-anchor trust recovery certificate count overflowed".to_string(), + ) + })? + { + return Err(EngineError::Internal( + "state-anchor trust recovery journal is ahead of its durable intent".to_string(), + )); + } + let committed_count = journal.committed.len() - prior_count; + for (index, certificate) in journal.committed[prior_count..].iter().enumerate() { + if certificate.wire != transition.certificates[index].wire { + return Err(EngineError::Internal( + "state-anchor trust recovery committed certificate conflicts with its intent" + .to_string(), + )); + } + } + if committed_count + .checked_add(journal.pending.len()) + .is_none_or(|count| count > transition.certificates.len()) + { + return Err(EngineError::Internal( + "state-anchor trust recovery journal has an extra PREPARE certificate".to_string(), + )); + } + for (offset, certificate) in journal.pending.iter().enumerate() { + if certificate.wire != transition.certificates[committed_count + offset].wire { + return Err(EngineError::Internal( + "state-anchor trust recovery PREPARE certificate conflicts with its intent" + .to_string(), + )); + } + } + if committed_count != 0 + && committed_count + journal.pending.len() != transition.certificates.len() + { + return Err(EngineError::Internal( + "state-anchor trust recovery has a partial COMMIT without the complete PREPARE batch" + .to_string(), + )); + } + Ok((committed_count, committed_count + journal.pending.len())) +} + +#[cfg(unix)] +fn publish_state_anchor_trust_journal_for_recovery( + directory: &fs::File, + name: &OsStr, + store_fingerprint: &[u8; 32], + bytes: &[u8], + recovery_guard: &StateAnchorTrustRecoveryGuard<'_>, +) -> Result<(StateAnchorTrustJournalModel, Vec), EngineError> { + if bytes.len() > STATE_ANCHOR_TRUST_MAX_JOURNAL_LENGTH { + return Err(EngineError::Internal( + "state-anchor trust recovery journal exceeds its durable bound".to_string(), + )); + } + let parsed = parse_state_anchor_trust_journal(bytes, store_fingerprint)?; + replace_durable_entry_with_guard( + directory, + name, + bytes, + "state-anchor trust journal during recovery", + Some(recovery_guard), + )?; + Ok((parsed, bytes.to_vec())) +} + +#[cfg(unix)] +fn open_state_anchor_for_trust_recovery( + directory: &fs::File, + name: &OsStr, + store_fingerprint: &[u8; 32], + transition: &VerifiedStateAnchorTrustTransition, + certified_floors: &[StateAnchorTrustReferenceModel], +) -> Result< + ( + Option, + Option, + ), + EngineError, +> { + const LABEL: &str = "signer state anchor metadata during trust recovery"; + let Some(file) = openat_optional(directory.as_raw_fd(), name, libc::O_RDWR, LABEL)? else { + let first = transition + .certificates + .first() + .expect("verified trust transition is nonempty"); + if first.from.is_some() { + return Err(EngineError::Internal( + "rotation/adoption trust recovery is missing its persisted prior anchor" + .to_string(), + )); + } + return Ok((None, None)); + }; + validate_owned_unlinked_regular(&file, LABEL)?; + validate_secure_regular_file(&file, LABEL)?; + let bytes = read_file_at(&file, LABEL)?; + let first = transition + .certificates + .first() + .expect("verified trust transition is nonempty"); + let final_certificate = transition + .certificates + .last() + .expect("verified trust transition is nonempty"); + + if let Some(from) = first.from.as_ref() { + if let Ok(metadata) = parse_state_anchor_metadata( + &bytes, + store_fingerprint, + &from.anchor_configuration()?, + certified_floors, + ) { + if from.reference.matches_acknowledgement(&metadata.latest) { + return Ok((Some(metadata), Some(TrustRecoveryAnchorStage::Prior))); + } + } + } + if let Ok(metadata) = parse_state_anchor_metadata( + &bytes, + store_fingerprint, + &final_certificate.to.anchor_configuration()?, + certified_floors, + ) { + if metadata.latest == final_certificate.target_acknowledgement + && metadata.witness_base.as_ref() == Some(&final_certificate.target_acknowledgement) + && metadata.pending_witness_base.is_none() + { + return Ok((Some(metadata), Some(TrustRecoveryAnchorStage::Target))); + } + } + Err(EngineError::Internal( + "persisted state anchor matches neither the certified prior nor target recovery stage" + .to_string(), + )) +} + +#[cfg(unix)] +fn revalidate_state_anchor_trust_transition_intent_entry( + directory: &fs::File, + name: &OsStr, + store_fingerprint: &[u8; 32], + expected_bytes: &[u8], +) -> Result<(), EngineError> { + let live = open_state_anchor_trust_transition_intent(directory, name, store_fingerprint)? + .ok_or_else(|| { + EngineError::Internal( + "state-anchor trust transition intent disappeared during recovery".to_string(), + ) + })?; + if live != expected_bytes { + return Err(EngineError::Internal( + "state-anchor trust transition intent changed during recovery".to_string(), + )); + } + Ok(()) +} + +#[cfg(unix)] +#[allow(clippy::too_many_arguments)] +fn recover_state_witness_rotation( + directory: &fs::File, + names: StateWitnessRotationNames<'_>, + store_identity: &DurableStoreIdentity, + current_state_file: Option<&fs::File>, + anchor: Option<&StateAnchorMetadata>, + maximum_records: usize, + retire_previous: bool, + recovery_guard: Option<&StateAnchorTrustRecoveryGuard<'_>>, +) -> Result { + let StateWitnessRotationNames { + current: current_name, + next: next_name, + previous: previous_name, + } = names; + let current_exists = + live_entry_stat(directory.as_raw_fd(), current_name, "state witness journal")?.is_some(); + let mut next_exists = live_entry_stat( + directory.as_raw_fd(), + next_name, + "next state witness journal", + )? + .is_some(); + let mut previous_exists = live_entry_stat( + directory.as_raw_fd(), + previous_name, + "previous state witness journal", + )? + .is_some(); + let pending = anchor.and_then(|metadata| metadata.pending_witness_base.as_ref()); + if !next_exists && !previous_exists { + let Some(pending) = pending else { + return Ok(false); + }; + if !current_exists { + return Err(EngineError::Internal( + "pending signed state witness rotation has no current journal".to_string(), + )); + } + let current_file = openat_optional( + directory.as_raw_fd(), + current_name, + libc::O_RDWR, + "current state witness journal", + )? + .ok_or_else(|| { + EngineError::Internal( + "current state witness journal disappeared during rotation recovery".to_string(), + ) + })?; + validate_secure_regular_file(¤t_file, "current state witness journal")?; + let parsed = read_state_witness_journal_streaming( + ¤t_file, + &store_identity.store_id, + &store_identity.fingerprint, + maximum_records, + anchor, + )?; + validate_anchor_history(anchor, &parsed.history)?; + if parsed.pending.is_some() { + return Err(EngineError::Internal( + "cannot resume state witness rotation while a state update is pending".to_string(), + )); + } + let current_digest = current_state_image_digest(current_state_file)?; + let tip = parsed.history.last().ok_or_else(|| { + EngineError::Internal( + "current state witness journal has no committed tip during rotation recovery" + .to_string(), + ) + })?; + if tip.state_image_digest != current_digest { + return Err(EngineError::Internal( + "pending state witness rotation does not commit the current state image" + .to_string(), + )); + } + if parsed.length == parsed.header_length + && parsed.segment_header.as_ref().is_some_and(|header| { + state_witness_segment_header_matches_acknowledgement(header, pending) + }) + { + // Publication and old-segment retirement completed; only promotion + // of the already-verified pending base remains. + return Ok(true); + } + if !acknowledgement_matches_witness(pending, Some(tip)) { + return Err(EngineError::Internal( + "pending signed state witness rotation no longer matches the current tip" + .to_string(), + )); + } + let bytes = encode_state_witness_segment_header(&store_identity.fingerprint, pending)?; + create_entry_atomically_with_guard( + directory, + next_name, + &bytes, + "next signer state witness journal during recovery", + recovery_guard, + )?; + next_exists = true; + } + let pending = pending.ok_or_else(|| { + EngineError::Internal( + "state witness rotation artifacts exist without a pending signed base".to_string(), + ) + })?; + + if next_exists && current_exists && !previous_exists { + validate_rotation_candidate( + directory, + next_name, + store_identity, + anchor, + maximum_records, + )?; + if let Some(guard) = recovery_guard { + guard.revalidate()?; + } + renameat_same_directory( + directory.as_raw_fd(), + current_name, + previous_name, + "retain previous state witness journal during recovery", + )?; + directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync state witness recovery after retaining previous segment: {error}" + )) + })?; + if let Some(guard) = recovery_guard { + guard.revalidate()?; + } + renameat_same_directory( + directory.as_raw_fd(), + next_name, + current_name, + "publish recovered state witness segment", + )?; + directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync state witness recovery after publishing segment: {error}" + )) + })?; + validate_rotation_candidate( + directory, + current_name, + store_identity, + anchor, + maximum_records, + )?; + if let Some(guard) = recovery_guard { + guard.revalidate()?; + } + previous_exists = true; + } else if next_exists && !current_exists && previous_exists { + validate_rotation_candidate( + directory, + next_name, + store_identity, + anchor, + maximum_records, + )?; + if let Some(guard) = recovery_guard { + guard.revalidate()?; + } + renameat_same_directory( + directory.as_raw_fd(), + next_name, + current_name, + "publish recovered state witness segment", + )?; + directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync recovered state witness segment publication: {error}" + )) + })?; + validate_rotation_candidate( + directory, + current_name, + store_identity, + anchor, + maximum_records, + )?; + if let Some(guard) = recovery_guard { + guard.revalidate()?; + } + } else if !next_exists && current_exists && previous_exists { + validate_rotation_candidate( + directory, + current_name, + store_identity, + anchor, + maximum_records, + )?; + } else { + return Err(EngineError::Internal( + "ambiguous state witness rotation entries; refusing to discard either segment" + .to_string(), + )); + } + + let parsed = validate_rotation_candidate( + directory, + current_name, + store_identity, + anchor, + maximum_records, + )?; + if !acknowledgement_matches_witness(pending, parsed.history.first()) { + return Err(EngineError::Internal( + "recovered state witness segment does not use the pending signed base; retaining \ + the previous segment" + .to_string(), + )); + } + let current_digest = current_state_image_digest(current_state_file)?; + if parsed + .history + .last() + .map(|tip| tip.state_image_digest != current_digest) + .unwrap_or(true) + { + return Err(EngineError::Internal( + "recovered state witness segment does not commit the current state image; \ + retaining the previous segment" + .to_string(), + )); + } + if previous_exists && retire_previous { + if let Some(guard) = recovery_guard { + guard.revalidate()?; + } + unlinkat_entry(directory.as_raw_fd(), previous_name)?; + } + directory.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync state witness recovery after finalizing segment publication: {error}" + )) + })?; + if let Some(guard) = recovery_guard { + guard.revalidate()?; + } + Ok(true) +} + +fn acknowledgement_matches_witness( + acknowledgement: &StateAnchorAcknowledgement, + witness: Option<&StateWitness>, +) -> bool { + witness.is_some_and(|witness| { + acknowledgement.checkpoint_generation == witness.generation + && acknowledgement.checkpoint_previous_commitment == witness.previous_commitment + && acknowledgement.checkpoint_state_image_digest == witness.state_image_digest + && acknowledgement.checkpoint_state_commitment == witness.commitment + }) +} + +fn state_witness_segment_header_matches_acknowledgement( + header: &StateWitnessSegmentHeader, + acknowledgement: &StateAnchorAcknowledgement, +) -> bool { + acknowledgement.checkpoint_store_fingerprint == header.store_fingerprint + && acknowledgement.checkpoint_generation == header.base.generation + && acknowledgement.checkpoint_previous_commitment == header.base.previous_commitment + && acknowledgement.checkpoint_state_image_digest == header.base.state_image_digest + && acknowledgement.checkpoint_state_commitment == header.base.commitment + && acknowledgement.binding_hash == header.binding_hash + && acknowledgement.service_epoch == header.service_epoch + && acknowledgement.revision == header.revision + && acknowledgement.previous_event_root == header.previous_event_root + && acknowledgement.event_root == header.event_root + && acknowledgement.operation_id == header.operation_id + && acknowledgement.transition_digest == header.transition_digest + && acknowledgement.committed_at_unix_ms == header.committed_at_unix_ms + && acknowledgement.acknowledgement_digest == header.acknowledgement_digest + && acknowledgement.signature == header.signature +} + +#[cfg(unix)] +fn validate_rotation_candidate( + directory: &fs::File, + name: &OsStr, + store_identity: &DurableStoreIdentity, + anchor: Option<&StateAnchorMetadata>, + maximum_records: usize, +) -> Result { + let file = openat_optional( + directory.as_raw_fd(), + name, + libc::O_RDWR, + "state witness rotation candidate", + )? + .ok_or_else(|| { + EngineError::Internal("state witness rotation candidate disappeared".to_string()) + })?; + validate_secure_regular_file(&file, "state witness rotation candidate")?; + let parsed = read_state_witness_journal_streaming( + &file, + &store_identity.store_id, + &store_identity.fingerprint, + maximum_records, + anchor, + )?; + if parsed.segment_header.is_none() || parsed.pending.is_some() { + return Err(EngineError::Internal( + "state witness rotation candidate is not a complete signed segment".to_string(), + )); + } + Ok(parsed) +} + +#[cfg(unix)] +fn open_or_create_state_witness( + directory: &fs::File, + name: &OsStr, + store_identity: &DurableStoreIdentity, + current_state_file: Option<&fs::File>, + maximum_records: usize, + anchor: Option<&StateAnchorMetadata>, +) -> Result { + const LABEL: &str = "signer state witness journal"; + + if let Some(file) = openat_optional(directory.as_raw_fd(), name, libc::O_RDWR, LABEL)? { + validate_owned_unlinked_regular(&file, LABEL)?; + set_owner_only_permissions(&file, LABEL)?; + validate_secure_regular_file(&file, LABEL)?; + + // The journal is a fixed header followed by fixed-width records, each + // appended and fsynced individually, and the genesis header+PREPARE+ + // COMMIT image is published by an atomic rename. A crash can therefore + // leave at most one torn trailing record - bytes past the last complete + // record boundary - and those bytes are the only thing that may be + // discarded, after the truncation itself is made durable. Every record + // that survives is then parsed and validated in full, so a COMPLETE + // record with invalid content still fails closed instead of being + // truncated away. Any PREPARE that survives the repair is returned to + // the caller, which reconciles it against the held state image. + let length = truncate_incomplete_witness_record( + &file, + &store_identity.store_id, + &store_identity.fingerprint, + anchor, + )?; + let parsed = read_state_witness_journal_streaming( + &file, + &store_identity.store_id, + &store_identity.fingerprint, + maximum_records, + anchor, + )?; + validate_anchor_history(anchor, &parsed.history)?; + debug_assert_eq!(length, parsed.length); + let identity = descriptor_identity(&file, LABEL)?; + return Ok(OpenedStateWitnessJournal { + file, + identity, + parsed, + }); + } + + if anchor.is_some() { + return Err(EngineError::Internal( + "signed state-anchor metadata exists but the state witness journal is missing; \ + refusing to re-genesis without an offline recovery certificate" + .to_string(), + )); + } + + // Genesis is the one write that is not a single fixed-width record, so it + // is the one write `truncate_incomplete_witness_record` cannot repair: a + // short genesis is fatal at every length (0-47 fails the header, 48-152 + // has no complete record, 153-257 has no committed genesis). Publish it + // atomically so the window does not exist. + if maximum_records < 2 { + return Err(EngineError::Validation(format!( + "signer state witness record ceiling must reserve two genesis records; got [{}]", + maximum_records + ))); + } + let digest = current_state_image_digest(current_state_file)?; + let genesis_root = state_witness_genesis(&store_identity.fingerprint); + let genesis = StateWitness { + generation: 1, + previous_commitment: genesis_root, + commitment: state_commitment(&store_identity.fingerprint, 1, &genesis_root, &digest), + state_image_digest: digest, + }; + let mut bytes = Vec::with_capacity( + TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH + 2 * TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH, + ); + bytes.extend_from_slice(TBTC_SIGNER_STATE_WITNESS_MAGIC); + bytes.extend_from_slice(&store_identity.store_id); + bytes.extend_from_slice(&encode_state_witness_record( + TBTC_SIGNER_STATE_WITNESS_RECORD_PREPARE, + &genesis, + )); + bytes.extend_from_slice(&encode_state_witness_record( + TBTC_SIGNER_STATE_WITNESS_RECORD_COMMIT, + &genesis, + )); + let (file, identity) = create_entry_atomically(directory, name, &bytes, LABEL)?; + Ok(OpenedStateWitnessJournal { + file, + identity, + parsed: ParsedStateWitnessJournal { + history: vec![genesis], + pending: None, + length: bytes.len(), + header_length: TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH, + header_bytes: bytes[..TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH].to_vec(), + segment_header: None, + tail_record: bytes[bytes.len() - TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH..] + .try_into() + .expect("genesis journal has one trailing fixed-width record"), + }, + }) +} + +/// Removes an incomplete trailing journal record, and nothing else. +/// +/// Only bytes after the last complete record boundary are dropped: they can +/// only be a torn append from a crash, because a record is written and fsynced +/// as one unit. A well-formed-length record is never removed here even when its +/// content is invalid - that case is a corruption signal and must fail closed +/// in `parse_state_witness_journal`. A journal whose header is short or does not +/// bind this exact store is likewise left untouched: nothing that may not be +/// this store's journal is ever rewritten, only rejected. +#[cfg(unix)] +fn truncate_incomplete_witness_record( + file: &fs::File, + expected_store_id: &[u8; 32], + expected_store_fingerprint: &[u8; 32], + anchor: Option<&StateAnchorMetadata>, +) -> Result { + const LABEL: &str = "signer state witness journal"; + let stat = descriptor_stat(file, LABEL)?; + if stat.st_size < 0 { + return Err(EngineError::Internal( + "signer state witness journal has a negative length".to_string(), + )); + } + let length = usize::try_from(stat.st_size).map_err(|_| { + EngineError::Internal( + "signer state witness journal length does not fit this platform".to_string(), + ) + })?; + if length < TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH { + return Ok(length); + } + let prefix_length = length.min(TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_LENGTH); + let prefix = read_file_range_at(file, 0, prefix_length, LABEL)?; + let header_length = if prefix.starts_with(TBTC_SIGNER_STATE_WITNESS_MAGIC) + && prefix.len() >= TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH + && &prefix[TBTC_SIGNER_STATE_WITNESS_MAGIC.len()..TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH] + == expected_store_id + { + TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH + } else if prefix.starts_with(TBTC_SIGNER_STATE_WITNESS_SEGMENT_MAGIC) + && prefix.len() >= TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_LENGTH + && parse_state_witness_segment_header( + &prefix[..TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_LENGTH], + expected_store_fingerprint, + anchor, + ) + .is_ok() + { + TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_LENGTH + } else { + return Ok(length); + }; + let incomplete_len = (length - header_length) % TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH; + if incomplete_len == 0 { + return Ok(length); + } + + let complete_len = length - incomplete_len; + file.set_len(complete_len as u64).map_err(|error| { + EngineError::Internal(format!( + "failed to discard the incomplete trailing signer state witness record: {error}" + )) + })?; + file.sync_all().map_err(|error| { + EngineError::Internal(format!( + "failed to sync the repaired signer state witness journal: {error}" + )) + })?; + Ok(complete_len) +} + +#[cfg(unix)] +fn current_state_image_digest(state_file: Option<&fs::File>) -> Result<[u8; 32], EngineError> { + match state_file { + Some(file) => { + let bytes = read_file_at(file, "signer state file")?; + Ok(state_image_digest(Some(&bytes))) + } + None => Ok(state_image_digest(None)), + } +} + +fn encode_state_witness_record(record_type: u8, witness: &StateWitness) -> Vec { + let mut record = Vec::with_capacity(TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH); + record.push(record_type); + record.extend_from_slice(&witness.generation.to_be_bytes()); + record.extend_from_slice(&witness.previous_commitment); + record.extend_from_slice(&witness.state_image_digest); + record.extend_from_slice(&witness.commitment); + debug_assert_eq!(record.len(), TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH); + record +} + +/// Parses and validates the journal one fixed-width record at a time. +/// +/// Startup must verify the entire anti-rollback chain, but it must not first +/// materialize an attacker-controlled file-sized `Vec`. The configured hard +/// record ceiling is checked from descriptor metadata before allocating the +/// bounded history, and stamps around the streaming read reject concurrent +/// modification. +#[cfg(unix)] +fn read_state_witness_journal_streaming( + file: &fs::File, + expected_store_id: &[u8; 32], + store_fingerprint: &[u8; 32], + maximum_records: usize, + anchor: Option<&StateAnchorMetadata>, +) -> Result { + const LABEL: &str = "signer state witness journal"; + let stat = descriptor_stat(file, LABEL)?; + if stat.st_size < 0 { + return Err(EngineError::Internal( + "signer state witness journal has a negative length".to_string(), + )); + } + let before = witness_change_stamp(file)?; + let length = usize::try_from(stat.st_size).map_err(|_| { + EngineError::Internal( + "signer state witness journal length does not fit this platform".to_string(), + ) + })?; + if before.size != length as u64 { + return Err(EngineError::Internal( + "signer state witness journal changed before streaming verification".to_string(), + )); + } + let prefix_length = length.min(TBTC_SIGNER_STATE_WITNESS_MAGIC.len()); + let prefix = read_file_range_at(file, 0, prefix_length, LABEL)?; + #[cfg(test)] + WITNESS_VERIFIED_BYTES_READ.fetch_add(prefix.len() as u64, std::sync::atomic::Ordering::SeqCst); + if is_retired_v1_state_witness_journal(&prefix) { + return Err(retired_v1_state_witness_journal_error()); + } + if length < TBTC_SIGNER_STATE_WITNESS_MAGIC.len() { + return Err(truncated_state_witness_journal_error(format!( + "signer state witness journal is [{length}] bytes, shorter than its magic" + ))); + } + let header_length = if prefix.as_slice() == TBTC_SIGNER_STATE_WITNESS_MAGIC { + TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH + } else if prefix.as_slice() == TBTC_SIGNER_STATE_WITNESS_SEGMENT_MAGIC { + TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_LENGTH + } else { + return Err(EngineError::Internal( + "signer state witness journal magic is invalid".to_string(), + )); + }; + if length < header_length { + return Err(truncated_state_witness_journal_error(format!( + "signer state witness journal is [{length}] bytes, shorter than its \ + {header_length}-byte header" + ))); + } + let mut header_bytes = prefix; + let header_tail = read_file_range_at( + file, + TBTC_SIGNER_STATE_WITNESS_MAGIC.len(), + header_length - TBTC_SIGNER_STATE_WITNESS_MAGIC.len(), + LABEL, + )?; + #[cfg(test)] + WITNESS_VERIFIED_BYTES_READ.fetch_add( + header_tail.len() as u64, + std::sync::atomic::Ordering::SeqCst, + ); + header_bytes.extend_from_slice(&header_tail); + + let (segment_header, mut history, requires_record) = if header_length + == TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH + { + if &header_bytes[TBTC_SIGNER_STATE_WITNESS_MAGIC.len()..] != expected_store_id { + return Err(EngineError::Internal( + "signer state witness journal store ID is invalid".to_string(), + )); + } + (None, Vec::new(), true) + } else { + let header = parse_state_witness_segment_header(&header_bytes, store_fingerprint, anchor)?; + let base = header.base.clone(); + (Some(header), vec![base], false) + }; + + let record_bytes = length - header_length; + if (requires_record && record_bytes == 0) + || !record_bytes.is_multiple_of(TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH) + { + return Err(truncated_state_witness_journal_error( + "signer state witness journal contains a missing or partial record".to_string(), + )); + } + let record_count = record_bytes / TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH; + if record_count > maximum_records { + return Err(EngineError::Internal(format!( + "signer state witness journal contains [{record_count}] records, exceeding the \ + configured fail-closed ceiling [{maximum_records}]" + ))); + } + + history.reserve(record_count.div_ceil(2)); + let mut pending = None::; + let mut tail = [0u8; TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH]; + for index in 0..record_count { + let offset = header_length + index * TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH; + let record = + read_file_range_at(file, offset, TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH, LABEL)?; + #[cfg(test)] + WITNESS_VERIFIED_BYTES_READ + .fetch_add(record.len() as u64, std::sync::atomic::Ordering::SeqCst); + apply_state_witness_record(&record, store_fingerprint, &mut history, &mut pending)?; + if index + 1 == record_count { + tail.copy_from_slice(&record); + } + } + if history.is_empty() { + return Err(truncated_state_witness_journal_error( + "signer state witness journal has no committed genesis record".to_string(), + )); + } + let after = witness_change_stamp(file)?; + if before != after { + return Err(EngineError::Internal( + "signer state witness journal changed while it was being verified".to_string(), + )); + } + Ok(ParsedStateWitnessJournal { + history, + pending, + length, + header_length, + header_bytes, + segment_header, + tail_record: tail, + }) +} + +#[cfg(test)] +fn parse_state_witness_journal( + bytes: &[u8], + expected_store_id: &[u8; 32], + store_fingerprint: &[u8; 32], +) -> Result<(Vec, Option), EngineError> { + if is_retired_v1_state_witness_journal(bytes) { + return Err(retired_v1_state_witness_journal_error()); + } + if bytes.len() < TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH { + return Err(truncated_state_witness_journal_error(format!( + "signer state witness journal is [{}] bytes, shorter than its \ + {TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH}-byte header", + bytes.len() + ))); + } + if &bytes[..TBTC_SIGNER_STATE_WITNESS_MAGIC.len()] != TBTC_SIGNER_STATE_WITNESS_MAGIC + || &bytes[TBTC_SIGNER_STATE_WITNESS_MAGIC.len()..TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH] + != expected_store_id + { + return Err(EngineError::Internal( + "signer state witness journal header or store ID is invalid".to_string(), + )); + } + let records = &bytes[TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH..]; + let complete_records = records.chunks_exact(TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH); + if records.is_empty() || !complete_records.remainder().is_empty() { + return Err(truncated_state_witness_journal_error( + "signer state witness journal contains a missing or partial record".to_string(), + )); + } + + let mut history = Vec::::new(); + let mut pending = None::; + for record in complete_records { + apply_state_witness_record(record, store_fingerprint, &mut history, &mut pending)?; + } + if history.is_empty() { + return Err(truncated_state_witness_journal_error( + "signer state witness journal has no committed genesis record".to_string(), + )); + } + Ok((history, pending)) +} + +fn apply_state_witness_record( + record: &[u8], + store_fingerprint: &[u8; 32], + history: &mut Vec, + pending: &mut Option, +) -> Result<(), EngineError> { + if record.len() != TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH { + return Err(truncated_state_witness_journal_error( + "signer state witness journal contains a missing or partial record".to_string(), + )); + } + let record_type = record[0]; + let generation = u64::from_be_bytes( + record[1..9] + .try_into() + .expect("fixed state witness generation slice"), + ); + let mut previous_commitment = [0u8; 32]; + previous_commitment.copy_from_slice(&record[9..41]); + let mut state_image_digest = [0u8; 32]; + state_image_digest.copy_from_slice(&record[41..73]); + let mut commitment = [0u8; 32]; + commitment.copy_from_slice(&record[73..105]); + let witness = StateWitness { + generation, + previous_commitment, + commitment, + state_image_digest, + }; + if generation == 0 + || state_image_digest == [0u8; 32] + || commitment == [0u8; 32] + || commitment + != state_commitment( + store_fingerprint, + generation, + &previous_commitment, + &state_image_digest, + ) + { + return Err(EngineError::Internal( + "signer state witness journal contains an invalid commitment".to_string(), + )); + } + + match record_type { + TBTC_SIGNER_STATE_WITNESS_RECORD_PREPARE => { + if pending.is_some() { + return Err(EngineError::Internal( + "signer state witness journal contains nested PREPARE records".to_string(), + )); + } + let (expected_generation, expected_previous) = match history.last() { + Some(tip) => ( + tip.generation.checked_add(1).ok_or_else(|| { + EngineError::Internal( + "signer state witness generation exhausted u64".to_string(), + ) + })?, + tip.commitment, + ), + None => (1, state_witness_genesis(store_fingerprint)), + }; + if witness.generation != expected_generation + || witness.previous_commitment != expected_previous + { + return Err(EngineError::Internal( + "signer state witness PREPARE does not extend the committed tip".to_string(), + )); + } + *pending = Some(witness); + } + TBTC_SIGNER_STATE_WITNESS_RECORD_COMMIT => { + if pending.as_ref() != Some(&witness) { + return Err(EngineError::Internal( + "signer state witness COMMIT does not match its PREPARE".to_string(), + )); + } + history.push(witness); + *pending = None; + } + TBTC_SIGNER_STATE_WITNESS_RECORD_ABORT => { + if pending.as_ref() != Some(&witness) { + return Err(EngineError::Internal( + "signer state witness ABORT does not match its PREPARE".to_string(), + )); + } + *pending = None; + } + _ => { + return Err(EngineError::Internal(format!( + "signer state witness journal contains unknown record type [{record_type}]" + ))) + } + } + Ok(()) +} + +/// True when the journal carries the retired v1 magic. The store ID is not +/// consulted: a v1 journal must be recognized even when the caller cannot +/// recompute the v1 fingerprint any more, which is precisely the situation the +/// v2 transcript exists to fix. +fn is_retired_v1_state_witness_journal(bytes: &[u8]) -> bool { + bytes.len() >= TBTC_SIGNER_STATE_WITNESS_MAGIC_V1.len() + && &bytes[..TBTC_SIGNER_STATE_WITNESS_MAGIC_V1.len()] == TBTC_SIGNER_STATE_WITNESS_MAGIC_V1 +} + +fn retired_v1_state_witness_journal_error() -> EngineError { + EngineError::Internal(format!( + "signer state witness journal uses the retired v1 state-commitment transcript \ + (magic [{}]); this build commits under v2, whose store fingerprint binds only the \ + stable {TBTC_SIGNER_DURABLE_STORE_ID_SUFFIX} bytes. The journal was left byte-for-byte \ + intact. Run the documented v1->v2 witness re-anchor before starting this build; do NOT \ + delete the journal, which would silently re-genesis the anti-rollback chain at \ + generation 1", + String::from_utf8_lossy(TBTC_SIGNER_STATE_WITNESS_MAGIC_V1).trim_end_matches('\0') + )) +} + +/// A short journal is never a torn create: the header, PREPARE, and COMMIT of a +/// genesis journal are written to a temp file, fsynced, and renamed into place +/// as one unit, and every later record is appended and fsynced as one +/// fixed-width unit whose torn remainder is repaired in place. Fail closed and +/// say what an operator can actually do. +fn truncated_state_witness_journal_error(detail: String) -> EngineError { + EngineError::Internal(format!( + "{detail}. The journal is created atomically and appended one fsynced fixed-width \ + record at a time, so this is damage from outside the signer, not a torn write. \ + Restore {TBTC_SIGNER_STATE_WITNESS_SUFFIX} together with the state image it commits \ + to, or run the documented re-anchor procedure; deleting the journal would silently \ + re-genesis the anti-rollback chain at generation 1" + )) +} + +#[cfg(unix)] +fn read_store_id(file: &fs::File) -> Result<[u8; 32], EngineError> { + let bytes = read_file_at(file, "signer durable store ID file")?; + if bytes.len() != 32 { + return Err(EngineError::Internal(format!( + "signer durable store ID file has invalid length [{}], expected 32. The store ID is \ + written to a temp entry, fsynced, and renamed into place, so a short file is not a \ + torn create: restore {TBTC_SIGNER_DURABLE_STORE_ID_SUFFIX} from the store's backup. \ + Replacing it with fresh bytes would orphan the state-witness journal, which binds \ + this exact store ID", + bytes.len() + ))); + } + let mut id = [0u8; 32]; + id.copy_from_slice(&bytes); + if id == [0u8; 32] { + return Err(EngineError::Internal( + "signer durable store ID must not be zero".to_string(), + )); + } + Ok(id) +} + +#[cfg(unix)] +fn open_optional_state( + directory: &fs::File, + state_name: &OsStr, +) -> Result<(Option, Option), EngineError> { + let Some(file) = openat_optional( + directory.as_raw_fd(), + state_name, + libc::O_RDONLY, + "signer state file", + )? + else { + return Ok((None, None)); + }; + validate_secure_regular_file(&file, "signer state file")?; + let identity = descriptor_identity(&file, "signer state file")?; + Ok((Some(file), Some(identity))) +} + +#[cfg(unix)] +fn openat_optional( + directory_fd: RawFd, + name: &OsStr, + flags: i32, + label: &str, +) -> Result, EngineError> { + let name_c = os_str_cstring(name, label)?; + let fd = unsafe { + libc::openat( + directory_fd, + name_c.as_ptr(), + flags | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK, + ) + }; + if fd >= 0 { + return Ok(Some(unsafe { fs::File::from_raw_fd(fd) })); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENOENT) { + return Ok(None); + } + Err(EngineError::Internal(format!( + "failed to open {label} without following symlinks: {error}" + ))) +} + +#[cfg(unix)] +fn acquire_exclusive_lock(file: &fs::File, lock_path: &Path) -> Result<(), EngineError> { + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error().is_some_and(is_lock_contention_errno) { + return Err(EngineError::Internal(format!( + "signer state lock already held by another process [{}]", + lock_path.display() + ))); + } + Err(EngineError::Internal(format!( + "failed to lock signer state file [{}]: {error}", + lock_path.display() + ))) +} + +#[cfg(unix)] +fn is_lock_contention_errno(errno: i32) -> bool { + errno == libc::EAGAIN || errno == libc::EWOULDBLOCK +} + +#[cfg(unix)] +fn descriptor_stat(file: &fs::File, label: &str) -> Result { + let mut stat = unsafe { std::mem::zeroed::() }; + if unsafe { libc::fstat(file.as_raw_fd(), &mut stat) } != 0 { + return Err(EngineError::Internal(format!( + "failed to inspect opened {label}: {}", + std::io::Error::last_os_error() + ))); + } + Ok(stat) +} + +#[cfg(unix)] +fn descriptor_identity(file: &fs::File, label: &str) -> Result { + let stat = descriptor_stat(file, label)?; + Ok(stat_identity(stat)) +} + +/// `st_dev`/`st_ino` widths and signedness are platform-dependent, so the +/// identity is built once here - through one widening conversion that keeps the +/// frozen fingerprint bytes identical on every target - and then compared as a +/// whole value rather than field by field. +#[cfg(unix)] +fn stat_identity(stat: libc::stat) -> OpenedObjectIdentity { + OpenedObjectIdentity { + device: widen_stat_field(stat.st_dev), + inode: widen_stat_field(stat.st_ino), + } +} + +#[cfg(unix)] +fn widen_stat_field>(value: T) -> u64 { + value.into() as u64 +} + +#[cfg(unix)] +fn validate_secure_regular_file(file: &fs::File, label: &str) -> Result<(), EngineError> { + validate_owned_unlinked_regular(file, label)?; + let stat = descriptor_stat(file, label)?; + if stat.st_mode & 0o077 != 0 { + return Err(EngineError::Internal(format!( + "opened {label} is accessible by group or other users" + ))); + } + Ok(()) +} + +#[cfg(unix)] +fn validate_owned_unlinked_regular(file: &fs::File, label: &str) -> Result<(), EngineError> { + let stat = descriptor_stat(file, label)?; + if stat.st_mode & libc::S_IFMT != libc::S_IFREG { + return Err(EngineError::Internal(format!( + "opened {label} is not a regular file" + ))); + } + if stat.st_nlink != 1 { + return Err(EngineError::Internal(format!( + "opened {label} has [{}] hard links; exactly one is required", + stat.st_nlink + ))); + } + if stat.st_uid != unsafe { libc::geteuid() } { + return Err(EngineError::Internal(format!( + "opened {label} is not owned by the signer user" + ))); + } + Ok(()) +} + +#[cfg(unix)] +fn set_owner_only_permissions(file: &fs::File, label: &str) -> Result<(), EngineError> { + if unsafe { libc::fchmod(file.as_raw_fd(), 0o600) } != 0 { + return Err(EngineError::Internal(format!( + "failed to set owner-only permissions on {label}: {}", + std::io::Error::last_os_error() + ))); + } + Ok(()) +} + +#[cfg(unix)] +fn live_entry_stat( + directory_fd: RawFd, + name: &OsStr, + label: &str, +) -> Result, EngineError> { + let name_c = os_str_cstring(name, label)?; + let mut stat = unsafe { std::mem::zeroed::() }; + let result = unsafe { + libc::fstatat( + directory_fd, + name_c.as_ptr(), + &mut stat, + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if result == 0 { + return Ok(Some(stat)); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENOENT) { + return Ok(None); + } + Err(EngineError::Internal(format!( + "failed to inspect live {label}: {error}" + ))) +} + +#[cfg(unix)] +fn validate_live_entry( + directory: &fs::File, + name: &OsStr, + expected: OpenedObjectIdentity, + label: &str, +) -> Result<(), EngineError> { + let Some(stat) = live_entry_stat(directory.as_raw_fd(), name, label)? else { + return Err(replacement_error(label)); + }; + if stat.st_mode & libc::S_IFMT != libc::S_IFREG + || stat.st_nlink != 1 + || stat_identity(stat) != expected + { + return Err(replacement_error(label)); + } + Ok(()) +} + +#[cfg(unix)] +fn ensure_entry_absent(directory_fd: RawFd, name: &OsStr, label: &str) -> Result<(), EngineError> { + if live_entry_stat(directory_fd, name, label)?.is_some() { + return Err(EngineError::Internal(format!( + "unexpected {label} appeared after the signer store was opened" + ))); + } + Ok(()) +} + +fn replacement_error(label: &str) -> EngineError { + EngineError::Internal(format!( + "{label} was removed, replaced, linked, or redirected after the signer store was opened" + )) +} + +#[cfg(unix)] +fn read_file_at(file: &fs::File, label: &str) -> Result, EngineError> { + use std::os::unix::fs::FileExt; + + let stat = descriptor_stat(file, label)?; + if stat.st_size < 0 { + return Err(EngineError::Internal(format!( + "opened {label} has a negative size" + ))); + } + let length = usize::try_from(stat.st_size) + .map_err(|_| EngineError::Internal(format!("opened {label} is too large to read")))?; + let mut bytes = vec![0u8; length]; + let mut offset = 0usize; + while offset < length { + let read = file + .read_at(&mut bytes[offset..], offset as u64) + .map_err(|error| { + EngineError::Internal(format!("failed to read opened {label}: {error}")) + })?; + if read == 0 { + return Err(EngineError::Internal(format!( + "opened {label} was truncated while being read" + ))); + } + offset += read; + } + Ok(bytes) +} + +/// Reads an exact byte range. Unlike `read_file_at` this never depends on - and +/// never pays for - the total file length, which is what makes verifying only +/// the newly appended journal bytes cheap. +#[cfg(unix)] +fn read_file_range_at( + file: &fs::File, + offset: usize, + length: usize, + label: &str, +) -> Result, EngineError> { + use std::os::unix::fs::FileExt; + + let mut bytes = vec![0u8; length]; + let mut read_total = 0usize; + while read_total < length { + let position = offset + .checked_add(read_total) + .ok_or_else(|| EngineError::Internal(format!("opened {label} read offset overflow")))?; + let read = file + .read_at(&mut bytes[read_total..], position as u64) + .map_err(|error| { + EngineError::Internal(format!("failed to read opened {label}: {error}")) + })?; + if read == 0 { + return Err(EngineError::Internal(format!( + "opened {label} was truncated while being read" + ))); + } + read_total += read; + } + Ok(bytes) +} + +#[cfg(unix)] +fn witness_change_stamp(file: &fs::File) -> Result { + file_change_stamp(file, "signer state witness journal") +} + +#[cfg(unix)] +fn file_change_stamp(file: &fs::File, label: &str) -> Result { + let stat = descriptor_stat(file, label)?; + Ok(FileChangeStamp { + size: widen_stat_field(stat.st_size), + modified_seconds: widen_stat_field(stat.st_mtime), + modified_nanoseconds: widen_stat_field(stat.st_mtime_nsec), + changed_seconds: widen_stat_field(stat.st_ctime), + changed_nanoseconds: widen_stat_field(stat.st_ctime_nsec), + }) +} + +#[cfg(unix)] +fn write_file_at(file: &fs::File, bytes: &[u8], label: &str) -> Result<(), EngineError> { + use std::os::unix::fs::FileExt; + + file.set_len(0).map_err(|error| { + EngineError::Internal(format!("failed to truncate opened {label}: {error}")) + })?; + let mut offset = 0usize; + while offset < bytes.len() { + let written = file + .write_at(&bytes[offset..], offset as u64) + .map_err(|error| { + EngineError::Internal(format!("failed to write opened {label}: {error}")) + })?; + if written == 0 { + return Err(EngineError::Internal(format!( + "short write while writing opened {label}" + ))); + } + offset += written; + } + Ok(()) +} + +#[cfg(unix)] +fn append_file_at( + file: &fs::File, + initial_offset: usize, + bytes: &[u8], + label: &str, +) -> Result<(), EngineError> { + use std::os::unix::fs::FileExt; + + let mut written_total = 0usize; + while written_total < bytes.len() { + let offset = initial_offset.checked_add(written_total).ok_or_else(|| { + EngineError::Internal(format!("opened {label} append offset overflow")) + })?; + let written = file + .write_at(&bytes[written_total..], offset as u64) + .map_err(|error| { + EngineError::Internal(format!("failed to append opened {label}: {error}")) + })?; + if written == 0 { + return Err(EngineError::Internal(format!( + "short write while appending opened {label}" + ))); + } + written_total += written; + } + Ok(()) +} + +#[cfg(unix)] +fn unique_temp_name(state_name: &OsStr) -> Result { + let mut random = [0u8; 16]; + OsRng.fill_bytes(&mut random); + let mut name = state_name.to_os_string(); + name.push(format!( + ".tmp-{}-{}", + std::process::id(), + hex::encode(random) + )); + validate_entry_name(&name, "state temp")?; + Ok(name) +} + +#[cfg(unix)] +fn renameat_same_directory( + directory_fd: RawFd, + source: &OsStr, + destination: &OsStr, + label: &str, +) -> Result<(), EngineError> { + let source = os_str_cstring(source, label)?; + let destination = os_str_cstring(destination, label)?; + if unsafe { + libc::renameat( + directory_fd, + source.as_ptr(), + directory_fd, + destination.as_ptr(), + ) + } != 0 + { + return Err(EngineError::Internal(format!( + "failed to {label}: {}", + std::io::Error::last_os_error() + ))); + } + Ok(()) +} + +#[cfg(unix)] +fn unlinkat_entry(directory_fd: RawFd, name: &OsStr) -> Result<(), EngineError> { + let name = os_str_cstring(name, "state temp")?; + if unsafe { libc::unlinkat(directory_fd, name.as_ptr(), 0) } != 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::ENOENT) { + return Err(EngineError::Internal(format!( + "failed to remove signer state temp file: {error}" + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod witness_transcript_tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + /// Entry name, inode, mode, length, mtime, mtime nanos, ctime, ctime + /// nanos, and contents. + #[cfg(unix)] + type DirectorySnapshotEntry = (OsString, u64, u32, u64, i64, i64, i64, i64, Vec); + + #[cfg(unix)] + #[derive(Debug, Eq, PartialEq)] + struct DirectorySnapshot { + directory: (u64, u32, u64, i64, i64, i64, i64), + entries: Vec, + } + + #[cfg(unix)] + fn snapshot_directory_without_atime(path: &Path) -> DirectorySnapshot { + use std::os::unix::fs::MetadataExt; + + let metadata_tuple = |metadata: &fs::Metadata| { + ( + metadata.ino(), + metadata.mode(), + metadata.len(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + ) + }; + let directory_metadata = fs::symlink_metadata(path).expect("snapshot directory metadata"); + let mut entries = fs::read_dir(path) + .expect("snapshot directory entries") + .map(|entry| { + let entry = entry.expect("snapshot directory entry"); + let metadata = fs::symlink_metadata(entry.path()).expect("snapshot entry metadata"); + let (inode, mode, length, mtime, mtime_nsec, ctime, ctime_nsec) = + metadata_tuple(&metadata); + let bytes = if metadata.file_type().is_file() { + fs::read(entry.path()).expect("snapshot regular entry bytes") + } else { + Vec::new() + }; + ( + entry.file_name(), + inode, + mode, + length, + mtime, + mtime_nsec, + ctime, + ctime_nsec, + bytes, + ) + }) + .collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + DirectorySnapshot { + directory: metadata_tuple(&directory_metadata), + entries, + } + } + + /// The live cross-language transcript. The Go bridge must reproduce these + /// bytes exactly; `store_fingerprint` here is a `durable_store_fingerprint` + /// output, which under v2 is a function of the `.store-id` bytes alone. + #[test] + fn state_witness_transcripts_match_frozen_go_v2_vectors() { + let store_fingerprint = [0x11; 32]; + assert_eq!( + hex::encode(state_witness_genesis(&store_fingerprint)), + "44085b42d29bf25f06207142f9e2db58eaf86f88d92b6e18104161ce59e98a89" + ); + assert_eq!( + hex::encode(state_commitment( + &store_fingerprint, + 42, + &[0x22; 32], + &[0x33; 32], + )), + "ea5eb04a4776357e59875f683390a2ff4b7dd511ad394e588dfab147f94fa867" + ); + } + + /// End-to-end v2 chain vector: the `.store-id` bytes derive the store + /// fingerprint, the fingerprint derives the genesis root, and the genesis + /// record commits over it. The Go bridge must reproduce all three. + #[test] + fn state_witness_chain_matches_frozen_go_v2_vector() { + let fingerprint = durable_store_fingerprint(&[0x11; 32]); + assert_eq!( + hex::encode(fingerprint), + "8bb8d21c69e78916e8f165b0c861c0d84c5d7af5393f75b0321fe048f772abba" + ); + let genesis_root = state_witness_genesis(&fingerprint); + assert_eq!( + hex::encode(genesis_root), + "3179b8bc6614b0951b703f9c418b17cf7cd8b7f1bef1f86587385d4c150efab2" + ); + assert_eq!( + hex::encode(state_commitment( + &fingerprint, + 1, + &genesis_root, + &[0x33; 32] + )), + "5387626d5314b17b324f9a7df1ab16fcbf10917a137527bf33c71847e1b77da0" + ); + } + + /// Regression guard for the rejection path: the retired v1 transcript must + /// keep producing its frozen bytes so v1 journal fixtures stay realistic, + /// and it must be distinct from v2 in both the genesis root and the + /// commitment. + #[test] + fn retired_v1_transcript_vectors_stay_frozen_and_distinct() { + let store_fingerprint = [0x11; 32]; + assert_eq!( + hex::encode(state_witness_genesis_v1(&store_fingerprint)), + "639ab6bce7b111044aa40cbe05d2a79a789c47d83e0dbf5ac83af3e2c8717775" + ); + assert_eq!( + hex::encode(state_commitment_v1( + &store_fingerprint, + 42, + &[0x22; 32], + &[0x33; 32], + )), + "903d154bca4b0e46f2cadda81db9559bdf2d719956065266f55bd845e64b7ced" + ); + assert_ne!( + state_witness_genesis(&store_fingerprint), + state_witness_genesis_v1(&store_fingerprint) + ); + assert_ne!( + state_commitment(&store_fingerprint, 42, &[0x22; 32], &[0x33; 32]), + state_commitment_v1(&store_fingerprint, 42, &[0x22; 32], &[0x33; 32]) + ); + } + + #[test] + fn retired_v1_journals_are_recognized_by_magic_alone() { + let journal = + encode_v1_state_witness_genesis_journal(&[0x24; 32], &[0x11; 32], &[0x33; 32]); + assert!(is_retired_v1_state_witness_journal(&journal)); + assert!(!is_retired_v1_state_witness_journal( + TBTC_SIGNER_STATE_WITNESS_MAGIC + )); + + let error = parse_state_witness_journal(&journal, &[0x24; 32], &[0x11; 32]) + .expect_err("a v1 journal must fail closed"); + let EngineError::Internal(message) = error else { + panic!("unexpected error variant"); + }; + assert!( + message.contains("retired v1 state-commitment transcript"), + "unexpected v1 rejection message: {message}" + ); + assert!( + message.contains("re-anchor"), + "the v1 rejection must be actionable: {message}" + ); + } + + fn fixture_acknowledgement() -> StateAnchorAcknowledgement { + let store_fingerprint = [0x11; 32]; + let previous_commitment = [0x22; 32]; + let state_image_digest = [0x33; 32]; + StateAnchorAcknowledgement { + binding_hash: [0x44; 32], + request_digest: [0x45; 32], + nonce: [0x46; 32], + status: 1, + service_epoch: 7, + revision: 1, + previous_event_root: [0u8; 32], + event_root: [0x55; 32], + checkpoint_store_fingerprint: store_fingerprint, + checkpoint_generation: 42, + checkpoint_previous_commitment: previous_commitment, + checkpoint_state_image_digest: state_image_digest, + checkpoint_state_commitment: state_commitment( + &store_fingerprint, + 42, + &previous_commitment, + &state_image_digest, + ), + operation_id: [0x66; 32], + transition_digest: [0x77; 32], + committed_at_unix_ms: 123_456_789, + expires_at_unix_ms: 123_456_790, + signing_digest: [0x88; 32], + signature: [0x99; 64], + configured_spki_hash: [0xaa; 32], + acknowledgement_digest: [0xbb; 32], + } + } + + #[test] + #[cfg(unix)] + fn restored_expired_intent_never_authorizes_local_recovery() { + let _guard = lock_test_state(); + let (state_path, fresh, tip) = bootstrap_trust_store_fixture("expired-intent-rollback"); + let now = u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_millis(), + ) + .expect("clock fits u64"); + let nested = &fresh.certificates[0].target_acknowledgement; + let expired = bootstrap_state_anchor_trust_transition_for_tests( + nested.checkpoint_store_fingerprint, + &tip, + nested.committed_at_unix_ms, + nested.expires_at_unix_ms, + now - 60_000, + now - 30_000, + false, + ) + .expect("intrinsically valid expired transition"); + assert_eq!( + expired.request.certificate_chain, + fresh.request.certificate_chain + ); + + let store = StateFileLock::acquire_for_trust_transition(&state_path, &fresh) + .expect("open pristine transition store"); + let intent_bytes = encode_state_anchor_trust_transition_intent( + &store.identity.fingerprint, + &expired.request, + ) + .expect("encode restored expired intent"); + let _ = create_entry_atomically( + &store.directory, + &store.trust_intent_name, + &intent_bytes, + "restored expired trust intent", + ) + .expect("restore old intent snapshot"); + let witness_before = + fs::read(state_witness_file_path(&state_path)).expect("read pristine witness"); + drop(store); + + let recovery_error = match StateFileLock::acquire_for_trust_head_inspection(&state_path) { + Ok(_) => panic!("expired local intent must not auto-recover"), + Err(error) => error, + }; + let EngineError::StateAnchorTrustRecoveryRequired { context } = recovery_error else { + panic!("unexpected restored-intent preflight error: {recovery_error}"); + }; + assert_eq!( + context.certificate_digests, + vec![fresh.certificates[0].certificate_digest] + ); + assert_eq!( + fs::read(state_anchor_trust_intent_file_path(&state_path)) + .expect("intent remains after preflight"), + intent_bytes + ); + assert_eq!( + fs::read(state_witness_file_path(&state_path)) + .expect("witness remains after preflight"), + witness_before + ); + assert!(!state_anchor_trust_file_path(&state_path).exists()); + assert!(!state_anchor_file_path(&state_path).exists()); + + let expired_error = match StateFileLock::acquire_for_trust_transition(&state_path, &expired) + { + Ok(_) => panic!("expired wrapper must not resume restored intent"), + Err(error) => error, + }; + assert!( + expired_error.to_string().contains("expired"), + "unexpected expired recovery error: {expired_error}" + ); + assert!(state_anchor_trust_intent_file_path(&state_path).exists()); + + let mut resumed = StateFileLock::acquire_for_trust_transition(&state_path, &fresh) + .expect("fresh wrapper resumes exact restored intent"); + let outcome = resumed + .transition_state_witness_anchor(&fresh) + .expect("post-recovery exact replay"); + assert!(outcome.idempotent); + drop(resumed); + assert!(!state_anchor_trust_intent_file_path(&state_path).exists()); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + #[cfg(unix)] + fn recovery_required_preflight_is_byte_and_metadata_read_only() { + let _guard = lock_test_state(); + + let leave_intent = |state_path: &Path| { + let (transition, _) = bootstrap_trust_store_fixture_at(state_path); + let mut store = StateFileLock::acquire_for_trust_transition(state_path, &transition) + .expect("open transition store"); + set_state_anchor_trust_transition_fault_for_tests( + StateAnchorTrustTransitionFaultInjectionPoint::AfterIntentPublication, + ); + store + .transition_state_witness_anchor(&transition) + .expect_err("leave intent before first transition mutation"); + clear_state_anchor_trust_transition_fault_for_tests(); + drop(store); + transition + }; + let unique_directory = |label: &str| { + let mut random = [0u8; 12]; + OsRng.fill_bytes(&mut random); + std::env::temp_dir().join(format!( + "tbtc-signer-zero-write-{label}-{}-{}", + std::process::id(), + hex::encode(random) + )) + }; + + let directory = unique_directory("intact"); + fs::create_dir(&directory).expect("create isolated signer directory"); + let state_path = directory.join("signer-state.json"); + let transition = leave_intent(&state_path); + let before = snapshot_directory_without_atime(&directory); + let error = match StateFileLock::acquire_for_trust_head_inspection(&state_path) { + Ok(_) => panic!("intent inspection must require externally fresh recovery"), + Err(error) => error, + }; + let EngineError::StateAnchorTrustRecoveryRequired { context } = error else { + panic!("unexpected intact recovery preflight error: {error}"); + }; + assert_eq!( + context.certificate_digests, + vec![transition.certificates[0].certificate_digest] + ); + assert_eq!( + snapshot_directory_without_atime(&directory), + before, + "recovery-required inspection changed directory entries, bytes, inode, mode, \ + mtime, or ctime" + ); + cleanup_anchor_store_fixture(&state_path); + fs::remove_dir(&directory).expect("remove isolated signer directory"); + + let outer_directory = unique_directory("missing-parent"); + fs::create_dir(&outer_directory).expect("create missing-parent snapshot root"); + let missing_parent = outer_directory.join("absent-store"); + let missing_state_path = missing_parent.join("signer-state.json"); + let before = snapshot_directory_without_atime(&outer_directory); + let error = match StateFileLock::acquire_for_trust_head_inspection(&missing_state_path) { + Ok(_) => panic!("missing trust store cannot have a committed head"), + Err(error) => error, + }; + assert!(matches!(error, EngineError::StateAnchorTrustHeadAbsent)); + assert_eq!( + snapshot_directory_without_atime(&outer_directory), + before, + "trust-head inspection created or modified a missing parent" + ); + assert!(!missing_parent.exists()); + fs::remove_dir(&outer_directory).expect("remove missing-parent snapshot root"); + + for missing in ["lock", "store-id"] { + establish_clean_signer_test_env(); + let directory = unique_directory(missing); + fs::create_dir(&directory).expect("create isolated missing-prerequisite directory"); + let state_path = directory.join("signer-state.json"); + let _transition = leave_intent(&state_path); + let prerequisite = if missing == "lock" { + state_lock_file_path(&state_path) + } else { + durable_store_id_file_path(&state_path) + }; + fs::remove_file(&prerequisite).expect("remove recovery prerequisite"); + let before = snapshot_directory_without_atime(&directory); + let error = match StateFileLock::acquire_for_trust_head_inspection(&state_path) { + Ok(_) => panic!("missing {missing} must fail closed"), + Err(error) => error, + }; + assert!( + error.to_string().contains("refusing to recreate"), + "unexpected missing-{missing} error: {error}" + ); + assert_eq!( + snapshot_directory_without_atime(&directory), + before, + "missing-{missing} preflight recreated or modified recovery state" + ); + cleanup_anchor_store_fixture(&state_path); + fs::remove_dir(&directory).expect("remove missing-prerequisite directory"); + } + } + + #[test] + #[cfg(unix)] + fn guarded_cow_publication_detects_lock_replacement_after_rename_and_fsync() { + let _guard = lock_test_state(); + let (state_path, transition, _) = bootstrap_trust_store_fixture("guarded-cow-lock-swap"); + let store = StateFileLock::acquire_for_trust_transition(&state_path, &transition) + .expect("open guarded publication store"); + let mut target_name = store.state_name.clone(); + target_name.push(".guarded-cow-test"); + validate_entry_name(&target_name, "guarded COW test target").expect("valid target name"); + ensure_entry_absent( + store.directory.as_raw_fd(), + &target_name, + "guarded COW test target", + ) + .expect("test target absent"); + + let restore_lock = |store: &StateFileLock| { + let mut displaced_name = store.lock_name.clone(); + displaced_name.push(".test-post-publication-displaced"); + unlinkat_entry(store.directory.as_raw_fd(), &store.lock_name) + .expect("remove replacement lock"); + renameat_same_directory( + store.directory.as_raw_fd(), + &displaced_name, + &store.lock_name, + "restore publication-window test lock", + ) + .expect("restore held lock"); + store.directory.sync_all().expect("sync restored lock"); + store + .state_anchor_trust_mutation_guard() + .revalidate() + .expect("restored mutation guard"); + }; + + let guard = store.state_anchor_trust_mutation_guard(); + REPLACE_TRUST_LOCK_AFTER_GUARDED_PUBLICATION + .store(true, std::sync::atomic::Ordering::SeqCst); + let create_error = create_entry_atomically_with_guard( + &store.directory, + &target_name, + b"created-before-post-publication-revalidation", + "guarded COW create test", + Some(&guard), + ) + .expect_err("post-publication lock replacement must fail guarded create"); + assert!( + create_error.to_string().contains("replaced"), + "unexpected guarded-create error: {create_error}" + ); + assert_eq!( + fs::read(state_path.with_file_name(&target_name)).expect("published create target"), + b"created-before-post-publication-revalidation" + ); + restore_lock(&store); + + let guard = store.state_anchor_trust_mutation_guard(); + REPLACE_TRUST_LOCK_AFTER_GUARDED_PUBLICATION + .store(true, std::sync::atomic::Ordering::SeqCst); + let replace_error = replace_durable_entry_with_guard( + &store.directory, + &target_name, + b"replaced-before-post-publication-revalidation", + "guarded COW replace test", + Some(&guard), + ) + .expect_err("post-publication lock replacement must fail guarded replace"); + assert!( + replace_error.to_string().contains("replaced"), + "unexpected guarded-replace error: {replace_error}" + ); + assert_eq!( + fs::read(state_path.with_file_name(&target_name)) + .expect("published replacement target"), + b"replaced-before-post-publication-revalidation" + ); + restore_lock(&store); + REPLACE_TRUST_LOCK_AFTER_GUARDED_PUBLICATION + .store(false, std::sync::atomic::Ordering::SeqCst); + + unlinkat_entry(store.directory.as_raw_fd(), &target_name).expect("remove COW test target"); + store.directory.sync_all().expect("sync COW target removal"); + drop(store); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + #[cfg(unix)] + fn recovery_preflight_detects_lock_name_replacement_after_flock() { + let _guard = lock_test_state(); + let (state_path, transition, _) = bootstrap_trust_store_fixture("post-flock-lock-swap"); + let mut store = StateFileLock::acquire_for_trust_transition(&state_path, &transition) + .expect("open transition store"); + set_state_anchor_trust_transition_fault_for_tests( + StateAnchorTrustTransitionFaultInjectionPoint::AfterIntentPublication, + ); + store + .transition_state_witness_anchor(&transition) + .expect_err("leave durable recovery intent"); + clear_state_anchor_trust_transition_fault_for_tests(); + let directory = store + .directory + .try_clone() + .expect("clone directory descriptor"); + let lock_name = store.lock_name.clone(); + drop(store); + + REPLACE_TRUST_LOCK_AFTER_FLOCK.store(true, std::sync::atomic::Ordering::SeqCst); + let error = match StateFileLock::acquire_for_trust_head_inspection(&state_path) { + Ok(_) => panic!("post-flock lock replacement must fail preflight"), + Err(error) => error, + }; + assert!( + error.to_string().contains("replaced"), + "unexpected post-flock replacement error: {error}" + ); + assert!(state_anchor_trust_intent_file_path(&state_path).exists()); + + let mut displaced_name = lock_name.clone(); + displaced_name.push(".test-post-flock-displaced"); + unlinkat_entry(directory.as_raw_fd(), &lock_name).expect("remove replacement lock"); + renameat_same_directory( + directory.as_raw_fd(), + &displaced_name, + &lock_name, + "restore post-flock test lock", + ) + .expect("restore original lock"); + directory.sync_all().expect("sync restored original lock"); + let recovery = match StateFileLock::acquire_for_trust_head_inspection(&state_path) { + Ok(_) => panic!("restored intent still requires fresh recovery"), + Err(error) => error, + }; + assert!(matches!( + recovery, + EngineError::StateAnchorTrustRecoveryRequired { .. } + )); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + #[cfg(unix)] + fn fresh_remote_read_for_a_later_checkpoint_cannot_resume_an_older_intent() { + let _guard = lock_test_state(); + let (state_path, original, tip) = bootstrap_trust_store_fixture("advanced-read-recovery"); + let mut store = StateFileLock::acquire_for_trust_transition(&state_path, &original) + .expect("open transition store"); + set_state_anchor_trust_transition_fault_for_tests( + StateAnchorTrustTransitionFaultInjectionPoint::AfterIntentPublication, + ); + store + .transition_state_witness_anchor(&original) + .expect_err("leave durable intent before mutation"); + clear_state_anchor_trust_transition_fault_for_tests(); + drop(store); + + let store_fingerprint = original.certificates[0].signer_store_fingerprint; + let advanced_state_image_digest = [0x91; 32]; + let advanced_tip = StateWitness { + generation: tip.generation + 1, + previous_commitment: tip.commitment, + commitment: state_commitment( + &store_fingerprint, + tip.generation + 1, + &tip.commitment, + &advanced_state_image_digest, + ), + state_image_digest: advanced_state_image_digest, + }; + let now = u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_millis(), + ) + .expect("clock fits u64"); + let advanced = bootstrap_state_anchor_trust_transition_for_tests( + store_fingerprint, + &advanced_tip, + now, + now + 30_000, + now, + now + 30_000, + true, + ) + .expect("build fresh remote-advanced Read"); + + let embedded = &original.certificates[0].target_acknowledgement; + let refreshed_exact = bootstrap_state_anchor_trust_transition_for_tests( + store_fingerprint, + &tip, + embedded.committed_at_unix_ms, + embedded.expires_at_unix_ms, + now, + now + 30_000, + true, + ) + .expect("restore original pins with refreshed exact Read"); + assert_eq!( + refreshed_exact.request.certificate_chain, + original.request.certificate_chain + ); + + let mut mixed_request = refreshed_exact.request.clone(); + mixed_request.target_read_response_base64 = + advanced.request.target_read_response_base64.clone(); + let mixed = verify_state_anchor_trust_transition_request(mixed_request, true) + .expect("remote-advanced Read is otherwise fresh and correctly signed"); + assert_ne!( + mixed + .target_read_acknowledgement + .checkpoint_state_commitment, + original.certificates[0] + .target_acknowledgement + .checkpoint_state_commitment + ); + + let error = match StateFileLock::acquire_for_trust_transition(&state_path, &mixed) { + Ok(_) => panic!("later remote checkpoint must not resume older local intent"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("freshly read target acknowledgement differs"), + "unexpected advanced-Read recovery error: {error}" + ); + assert!(state_anchor_trust_intent_file_path(&state_path).exists()); + assert!(!state_anchor_trust_file_path(&state_path).exists()); + assert!(!state_anchor_file_path(&state_path).exists()); + + let mut resumed = + StateFileLock::acquire_for_trust_transition(&state_path, &refreshed_exact) + .expect("exact fresh Read resumes pending intent"); + let replay = resumed + .transition_state_witness_anchor(&refreshed_exact) + .expect("post-recovery exact replay"); + assert!(replay.idempotent); + drop(resumed); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + fn signed_segment_header_matches_frozen_472_byte_vector() { + let acknowledgement = fixture_acknowledgement(); + let header = encode_state_witness_segment_header( + &acknowledgement.checkpoint_store_fingerprint, + &acknowledgement, + ) + .expect("encode fixed segment header"); + assert_eq!(header.len(), 472); + assert_eq!( + hex::encode(&header), + concat!( + "544254435749544e455353534547310000000001000001d81111111111111111111111111111111111111111111111111111111111111111", + "000000000000002a222222222222222222222222222222222222222222222222222222222222222233333333333333333333333333333333", + "33333333333333333333333333333333ea5eb04a4776357e59875f683390a2ff4b7dd511ad394e588dfab147f94fa8674444444444444444", + "4444444444444444444444444444444444444444444444440000000000000007000000000000000100000000000000000000000000000000", + "0000000000000000000000000000000055555555555555555555555555555555555555555555555555555555555555556666666666666666", + "6666666666666666666666666666666666666666666666667777777777777777777777777777777777777777777777777777777777777777", + "00000000075bcd15bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb99999999999999999999999999999999", + "999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999004c908654bcdcd2", + "92d7711cecda4233a4adb5755107d0bfae36a7b8c4e454c0" + ) + ); + let metadata = StateAnchorMetadata { + latest: acknowledgement.clone(), + witness_base: Some(acknowledgement), + pending_witness_base: None, + }; + let parsed = parse_state_witness_segment_header(&header, &[0x11; 32], Some(&metadata)) + .expect("parse frozen segment header"); + assert_eq!(parsed.base.generation, 42); + } + + #[test] + #[cfg(unix)] + fn incomplete_witness_repair_covers_both_headers_and_fails_closed_elsewhere() { + let store_id = [0x24; 32]; + let store_fingerprint = durable_store_fingerprint(&store_id); + let state_digest = [0x33; 32]; + let previous_commitment = state_witness_genesis(&store_fingerprint); + let genesis = StateWitness { + generation: 1, + previous_commitment, + state_image_digest: state_digest, + commitment: state_commitment( + &store_fingerprint, + 1, + &previous_commitment, + &state_digest, + ), + }; + let mut genesis_journal = Vec::new(); + genesis_journal.extend_from_slice(TBTC_SIGNER_STATE_WITNESS_MAGIC); + genesis_journal.extend_from_slice(&store_id); + genesis_journal.extend_from_slice(&encode_state_witness_record( + TBTC_SIGNER_STATE_WITNESS_RECORD_PREPARE, + &genesis, + )); + genesis_journal.extend_from_slice(&encode_state_witness_record( + TBTC_SIGNER_STATE_WITNESS_RECORD_COMMIT, + &genesis, + )); + + let acknowledgement = fixture_acknowledgement(); + let segment_header = encode_state_witness_segment_header( + &acknowledgement.checkpoint_store_fingerprint, + &acknowledgement, + ) + .expect("encode segment header fixture"); + let anchor = StateAnchorMetadata { + latest: acknowledgement.clone(), + witness_base: Some(acknowledgement.clone()), + pending_witness_base: None, + }; + let next_digest = [0x34; 32]; + let segment_next = StateWitness { + generation: acknowledgement.checkpoint_generation + 1, + previous_commitment: acknowledgement.checkpoint_state_commitment, + state_image_digest: next_digest, + commitment: state_commitment( + &acknowledgement.checkpoint_store_fingerprint, + acknowledgement.checkpoint_generation + 1, + &acknowledgement.checkpoint_state_commitment, + &next_digest, + ), + }; + let segment_record = + encode_state_witness_record(TBTC_SIGNER_STATE_WITNESS_RECORD_PREPARE, &segment_next); + + let mut random = [0u8; 12]; + OsRng.fill_bytes(&mut random); + let fixture_path = std::env::temp_dir().join(format!( + "tbtc-signer-witness-repair-{}-{}", + std::process::id(), + hex::encode(random) + )); + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&fixture_path) + .expect("create witness repair fixture"); + let install = |bytes: &[u8]| { + write_file_at(&file, bytes, "witness repair fixture") + .expect("write witness repair fixture"); + file.sync_all().expect("sync witness repair fixture"); + }; + + for partial_length in [1, TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH - 1] { + let mut torn = genesis_journal.clone(); + torn.extend_from_slice(&segment_record[..partial_length]); + install(&torn); + let repaired = + truncate_incomplete_witness_record(&file, &store_id, &store_fingerprint, None) + .expect("repair torn genesis-journal append"); + assert_eq!(repaired, genesis_journal.len()); + assert_eq!( + usize::try_from(file.metadata().expect("stat repaired journal").len()) + .expect("journal length fits usize"), + genesis_journal.len() + ); + read_state_witness_journal_streaming(&file, &store_id, &store_fingerprint, 8, None) + .expect("repaired genesis journal verifies"); + } + + for partial_length in [1, TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH - 1] { + let mut torn = segment_header.clone(); + torn.extend_from_slice(&segment_record[..partial_length]); + install(&torn); + let repaired = truncate_incomplete_witness_record( + &file, + &store_id, + &acknowledgement.checkpoint_store_fingerprint, + Some(&anchor), + ) + .expect("repair torn signed-segment append"); + assert_eq!(repaired, TBTC_SIGNER_STATE_WITNESS_SEGMENT_HEADER_LENGTH); + read_state_witness_journal_streaming( + &file, + &store_id, + &acknowledgement.checkpoint_store_fingerprint, + 8, + Some(&anchor), + ) + .expect("repaired signed segment verifies"); + } + + for short_length in [ + 0, + TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH - 1, + TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH, + TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH + TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH, + ] { + let mut short = genesis_journal.clone(); + short.truncate(short_length); + install(&short); + let retained = + truncate_incomplete_witness_record(&file, &store_id, &store_fingerprint, None) + .expect("short journal is inspected without repair"); + assert_eq!(retained, short_length); + assert_eq!( + usize::try_from(file.metadata().expect("stat short journal").len()) + .expect("journal length fits usize"), + short_length + ); + let error = match read_state_witness_journal_streaming( + &file, + &store_id, + &store_fingerprint, + 8, + None, + ) { + Ok(_) => panic!("short or uncommitted genesis journal must fail closed"), + Err(error) => error, + }; + assert!( + error.to_string().contains("shorter") + || error.to_string().contains("missing") + || error.to_string().contains("no committed genesis"), + "unexpected short-journal error: {error}" + ); + } + + let retired = + encode_v1_state_witness_genesis_journal(&store_id, &[0x11; 32], &state_digest); + install(&retired); + let retained = + truncate_incomplete_witness_record(&file, &store_id, &store_fingerprint, None) + .expect("retired journal is left untouched"); + assert_eq!(retained, retired.len()); + let error = match read_state_witness_journal_streaming( + &file, + &store_id, + &store_fingerprint, + 8, + None, + ) { + Ok(_) => panic!("retired v1 journal must fail closed in production reader"), + Err(error) => error, + }; + assert!(error + .to_string() + .contains("retired v1 state-commitment transcript")); + drop(file); + fs::remove_file(fixture_path).expect("remove witness repair fixture"); + } + + #[test] + fn ordinary_anchor_history_allows_a_retained_anchor_behind_the_local_tip() { + let mut acknowledgement = fixture_acknowledgement(); + let anchored = StateWitness { + generation: acknowledgement.checkpoint_generation, + previous_commitment: acknowledgement.checkpoint_previous_commitment, + state_image_digest: acknowledgement.checkpoint_state_image_digest, + commitment: acknowledgement.checkpoint_state_commitment, + }; + let next_image = [0x5c; 32]; + let tip = StateWitness { + generation: anchored.generation + 1, + previous_commitment: anchored.commitment, + state_image_digest: next_image, + commitment: state_commitment( + &acknowledgement.checkpoint_store_fingerprint, + anchored.generation + 1, + &anchored.commitment, + &next_image, + ), + }; + acknowledgement.checkpoint_generation = anchored.generation; + let metadata = StateAnchorMetadata { + latest: acknowledgement.clone(), + witness_base: Some(acknowledgement), + pending_witness_base: None, + }; + + validate_anchor_history(Some(&metadata), &[anchored, tip]) + .expect("ordinary reconciliation may advance a retained anchor to the local tip"); + } + + fn signed_fixture() -> (StateAnchorConfiguration, StateAnchorAcknowledgement) { + let signing_key = SigningKey::from_bytes(&[0x07; 32]); + let response_public_key = signing_key.verifying_key().to_bytes(); + let mut spki_digest = Sha256::new(); + spki_digest.update([ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ]); + spki_digest.update(response_public_key); + let configured_spki_hash: [u8; 32] = spki_digest.finalize().into(); + let mut acknowledgement = fixture_acknowledgement(); + acknowledgement.configured_spki_hash = configured_spki_hash; + acknowledgement.event_root = state_anchor_event_root_for_tests(&acknowledgement); + acknowledgement.signing_digest = state_anchor_signing_digest_for_tests(&acknowledgement); + acknowledgement.signature = signing_key.sign(&acknowledgement.signing_digest).to_bytes(); + let mut acknowledgement_digest = Sha256::new(); + acknowledgement_digest.update(b"tbtc-signer-state-anchor-acknowledgement/v1\0"); + acknowledgement_digest.update(acknowledgement.signing_digest); + acknowledgement_digest.update(acknowledgement.signature); + acknowledgement_digest.update(configured_spki_hash); + acknowledgement.acknowledgement_digest = acknowledgement_digest.finalize().into(); + ( + StateAnchorConfiguration { + binding_hash: acknowledgement.binding_hash, + response_public_key, + response_public_key_spki_sha256: configured_spki_hash, + rotation_threshold_records: 8, + trust: None, + }, + acknowledgement, + ) + } + + #[cfg(unix)] + fn configure_anchor_store_fixture( + state_path: &Path, + signing_key: &SigningKey, + binding_hash: [u8; 32], + ) -> [u8; 32] { + let response_public_key = signing_key.verifying_key().to_bytes(); + let mut spki_digest = Sha256::new(); + spki_digest.update([ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ]); + spki_digest.update(response_public_key); + let configured_spki_hash: [u8; 32] = spki_digest.finalize().into(); + std::env::set_var(TBTC_SIGNER_STATE_PATH_ENV, state_path); + std::env::set_var(TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, "10"); + std::env::set_var( + TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS_ENV, + "2", + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH_ENV, + bytes32_hex(binding_hash), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_ENV, + bytes32_hex(response_public_key), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256_ENV, + bytes32_hex(configured_spki_hash), + ); + configured_spki_hash + } + + #[cfg(unix)] + fn signed_acknowledgement_for_tip( + signing_key: &SigningKey, + configured_spki_hash: [u8; 32], + store_fingerprint: [u8; 32], + tip: &StateWitness, + ) -> StateAnchorAcknowledgement { + signed_acknowledgement_for_tip_at_revision( + signing_key, + configured_spki_hash, + store_fingerprint, + tip, + 1, + [0u8; 32], + ) + } + + /// Every acknowledgement after the first must advance the service revision + /// by exactly one and chain the previous event root, so a test that + /// acknowledges the same store twice has to sign the successor rather than + /// replay revision 1. + #[cfg(unix)] + fn signed_acknowledgement_for_tip_at_revision( + signing_key: &SigningKey, + configured_spki_hash: [u8; 32], + store_fingerprint: [u8; 32], + tip: &StateWitness, + revision: u64, + previous_event_root: [u8; 32], + ) -> StateAnchorAcknowledgement { + let now = u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_millis(), + ) + .expect("clock fits u64"); + let mut acknowledgement = fixture_acknowledgement(); + acknowledgement.revision = revision; + acknowledgement.previous_event_root = previous_event_root; + acknowledgement.checkpoint_store_fingerprint = store_fingerprint; + acknowledgement.checkpoint_generation = tip.generation; + acknowledgement.checkpoint_previous_commitment = tip.previous_commitment; + acknowledgement.checkpoint_state_image_digest = tip.state_image_digest; + acknowledgement.checkpoint_state_commitment = tip.commitment; + acknowledgement.committed_at_unix_ms = now; + acknowledgement.expires_at_unix_ms = now + 30_000; + acknowledgement.configured_spki_hash = configured_spki_hash; + acknowledgement.event_root = state_anchor_event_root_for_tests(&acknowledgement); + acknowledgement.signing_digest = state_anchor_signing_digest_for_tests(&acknowledgement); + acknowledgement.signature = signing_key.sign(&acknowledgement.signing_digest).to_bytes(); + let mut acknowledgement_digest = Sha256::new(); + acknowledgement_digest.update(b"tbtc-signer-state-anchor-acknowledgement/v1\0"); + acknowledgement_digest.update(acknowledgement.signing_digest); + acknowledgement_digest.update(acknowledgement.signature); + acknowledgement_digest.update(configured_spki_hash); + acknowledgement.acknowledgement_digest = acknowledgement_digest.finalize().into(); + acknowledgement + } + + #[cfg(unix)] + fn acknowledgement_wire_for_store_tests( + acknowledgement: &StateAnchorAcknowledgement, + ) -> crate::api::AcknowledgeStateWitnessCheckpointRequest { + crate::api::AcknowledgeStateWitnessCheckpointRequest { + schema: TBTC_SIGNER_STATE_WITNESS_CHECKPOINT_ACK_SCHEMA.to_string(), + binding_hash: bytes32_hex(acknowledgement.binding_hash), + request_digest: bytes32_hex(acknowledgement.request_digest), + nonce: bytes32_hex(acknowledgement.nonce), + status: match acknowledgement.status { + 1 => "applied", + 2 => "already-applied", + _ => panic!("invalid fixture status"), + } + .to_string(), + service_epoch: acknowledgement.service_epoch.to_string(), + revision: acknowledgement.revision.to_string(), + previous_event_root: bytes32_hex(acknowledgement.previous_event_root), + event_root: bytes32_hex(acknowledgement.event_root), + checkpoint: crate::api::StateWitnessCheckpointRequest { + store_fingerprint: bytes32_hex(acknowledgement.checkpoint_store_fingerprint), + generation: acknowledgement.checkpoint_generation.to_string(), + previous_state_commitment: bytes32_hex( + acknowledgement.checkpoint_previous_commitment, + ), + state_image_digest: bytes32_hex(acknowledgement.checkpoint_state_image_digest), + state_commitment: bytes32_hex(acknowledgement.checkpoint_state_commitment), + }, + operation_id: bytes32_hex(acknowledgement.operation_id), + transition_digest: bytes32_hex(acknowledgement.transition_digest), + committed_at_unix_ms: acknowledgement.committed_at_unix_ms.to_string(), + expires_at_unix_ms: acknowledgement.expires_at_unix_ms.to_string(), + signature: format!("0x{}", hex::encode(acknowledgement.signature)), + } + } + + #[cfg(unix)] + fn cleanup_anchor_store_fixture(state_path: &Path) { + let witness_path = state_witness_file_path(state_path); + let mut witness_next = witness_path.as_os_str().to_os_string(); + witness_next.push(TBTC_SIGNER_STATE_WITNESS_NEXT_SUFFIX); + let mut witness_previous = witness_path.as_os_str().to_os_string(); + witness_previous.push(TBTC_SIGNER_STATE_WITNESS_PREVIOUS_SUFFIX); + for path in [ + state_path.to_path_buf(), + state_lock_file_path(state_path), + durable_store_id_file_path(state_path), + witness_path, + PathBuf::from(witness_next), + PathBuf::from(witness_previous), + state_anchor_file_path(state_path), + state_anchor_trust_file_path(state_path), + state_anchor_trust_intent_file_path(state_path), + ] { + let _ = fs::remove_file(path); + } + } + + #[cfg(unix)] + fn bootstrap_trust_store_fixture( + label: &str, + ) -> (PathBuf, VerifiedStateAnchorTrustTransition, StateWitness) { + let mut random = [0u8; 12]; + OsRng.fill_bytes(&mut random); + let state_path = std::env::temp_dir().join(format!( + "tbtc-signer-trust-{label}-{}-{}", + std::process::id(), + hex::encode(random) + )); + let (transition, tip) = bootstrap_trust_store_fixture_at(&state_path); + (state_path, transition, tip) + } + + #[cfg(unix)] + fn bootstrap_trust_store_fixture_at( + state_path: &Path, + ) -> (VerifiedStateAnchorTrustTransition, StateWitness) { + std::env::set_var(TBTC_SIGNER_STATE_PATH_ENV, state_path); + std::env::set_var(TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, "10"); + let mut initial = StateFileLock::acquire(state_path).expect("open unanchored store"); + let tip = initial.state_witness_tip().expect("unanchored genesis tip"); + let store_fingerprint = initial.identity.fingerprint; + drop(initial); + + let now = u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_millis(), + ) + .expect("clock fits u64"); + let transition = bootstrap_state_anchor_trust_transition_for_tests( + store_fingerprint, + &tip, + now, + now + 30_000, + now, + now + 30_000, + true, + ) + .expect("build verified bootstrap transition"); + (transition, tip) + } + + #[test] + #[cfg(unix)] + fn bootstrap_trust_transition_succeeds_on_first_call_and_ordinary_reopen() { + let _guard = lock_test_state(); + let (state_path, transition, tip) = bootstrap_trust_store_fixture("bootstrap"); + let mut store = StateFileLock::acquire_for_trust_transition(&state_path, &transition) + .expect("acquire transition store"); + let outcome = store + .transition_state_witness_anchor(&transition) + .expect("bootstrap succeeds on its first call"); + assert!(!outcome.idempotent); + assert_eq!(outcome.applied_certificate_count, 1); + assert_eq!(outcome.tip, tip); + assert_eq!(outcome.base, tip); + assert_eq!( + outcome.trust_head.certificate_digest, + transition.certificates[0].certificate_digest + ); + ensure_entry_absent( + store.directory.as_raw_fd(), + &store.trust_intent_name, + "completed trust transition intent", + ) + .expect("completed intent removed"); + drop(store); + + let mut reopened = StateFileLock::acquire(&state_path).expect("ordinary target reopen"); + let snapshot = reopened + .state_anchor_trust_head_snapshot() + .expect("ordinary reopened trust head"); + assert_eq!(snapshot.tip, tip); + assert_eq!(snapshot.base, tip); + assert_eq!( + snapshot.trust_head.certificate_digest, + transition.certificates[0].certificate_digest + ); + assert_eq!( + snapshot.anchor.latest, + transition.certificates[0].target_acknowledgement + ); + drop(reopened); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + #[cfg(unix)] + fn bootstrap_facts_store_acquisition_is_repeatable_ephemeral_and_pristine_only() { + let _guard = lock_test_state(); + let mut random = [0u8; 12]; + OsRng.fill_bytes(&mut random); + let state_path = std::env::temp_dir().join(format!( + "tbtc-signer-bootstrap-facts-store-{}-{}", + std::process::id(), + hex::encode(random) + )); + if let Ok(mut slot) = state_file_lock_slot().lock() { + *slot = None; + } + + let first = { + let mut store = + StateFileLock::acquire_for_bootstrap_facts(&state_path).expect("first acquisition"); + store + .state_anchor_bootstrap_facts_snapshot() + .expect("first bootstrap facts") + }; + let second = { + let mut store = StateFileLock::acquire_for_bootstrap_facts(&state_path) + .expect("repeat acquisition"); + store + .state_anchor_bootstrap_facts_snapshot() + .expect("repeat bootstrap facts") + }; + assert_eq!(first, second); + assert!(state_file_lock_slot().lock().expect("store slot").is_none()); + + fs::write(&state_path, b"non-pristine-state-image").expect("publish state entry"); + let mut permissions = fs::metadata(&state_path) + .expect("state entry metadata") + .permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o600); + fs::set_permissions(&state_path, permissions).expect("restrict state entry permissions"); + let error = match StateFileLock::acquire_for_bootstrap_facts(&state_path) { + Ok(_) => panic!("non-pristine store must reject bootstrap facts"), + Err(error) => error, + }; + assert!( + error.to_string().contains("pristine genesis-only store") + || error.to_string().contains("state image"), + "unexpected non-pristine rejection: {error}" + ); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + #[cfg(unix)] + fn provisioning_config_ffi_is_startup_only_and_capability_minimal() { + let mut random = [0u8; 12]; + OsRng.fill_bytes(&mut random); + let state_path = std::env::temp_dir().join(format!( + "tbtc-signer-bootstrap-provisioning-{}-{}", + std::process::id(), + hex::encode(random) + )); + let output = + std::process::Command::new(std::env::current_exe().expect("current test binary path")) + .arg("--exact") + .arg("engine::store::witness_transcript_tests::provisioning_config_ffi_helper") + .arg("--ignored") + .arg("--nocapture") + .env("TBTC_SIGNER_BOOTSTRAP_PROVISIONING_HELPER", "1") + .env("TBTC_SIGNER_BOOTSTRAP_PROVISIONING_STATE_PATH", &state_path) + .output() + .expect("spawn isolated provisioning helper"); + assert!( + output.status.success(), + "provisioning helper failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "provisioning helper did not execute exactly one test\nstdout:\n{}", + String::from_utf8_lossy(&output.stdout) + ); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + #[ignore = "isolated subprocess helper"] + #[cfg(unix)] + fn provisioning_config_ffi_helper() { + if std::env::var("TBTC_SIGNER_BOOTSTRAP_PROVISIONING_HELPER").as_deref() != Ok("1") { + return; + } + assert!(ENGINE_STATE.get().is_none()); + assert!(state_file_lock_slot().lock().expect("store slot").is_none()); + let state_path = PathBuf::from( + std::env::var("TBTC_SIGNER_BOOTSTRAP_PROVISIONING_STATE_PATH") + .expect("helper state path"), + ); + + let call_with_json = |payload: Vec, + function: extern "C" fn( + *const u8, + usize, + ) -> crate::ffi::TbtcSignerResult| { + let result = function(payload.as_ptr(), payload.len()); + let bytes = if result.buffer.ptr.is_null() || result.buffer.len == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(result.buffer.ptr, result.buffer.len).to_vec() } + }; + crate::frost_tbtc_free_buffer(result.buffer.ptr, result.buffer.len); + (result.status_code, bytes) + }; + let call_without_json = |function: extern "C" fn() -> crate::ffi::TbtcSignerResult| { + let result = function(); + let bytes = if result.buffer.ptr.is_null() || result.buffer.len == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(result.buffer.ptr, result.buffer.len).to_vec() } + }; + crate::frost_tbtc_free_buffer(result.buffer.ptr, result.buffer.len); + (result.status_code, bytes) + }; + + let mut provisioning = InitSignerConfigRequest { + purpose: Some("state_anchor_bootstrap_provisioning".to_string()), + profile: Some("production".to_string()), + state_path: Some(state_path.to_string_lossy().into_owned()), + state_witness_max_records: Some(4), + ..InitSignerConfigRequest::default() + }; + provisioning.state_anchor_binding_hash = Some(bytes32_hex([0x41; 32])); + let (invalid_status, invalid_payload) = call_with_json( + serde_json::to_vec(&provisioning).expect("invalid config JSON"), + crate::frost_tbtc_init_signer_config, + ); + assert_eq!(invalid_status, 1); + let invalid_error: crate::api::ErrorResponse = + serde_json::from_slice(&invalid_payload).expect("invalid config error"); + assert!( + invalid_error + .message + .contains("forbids populated field [state_anchor_binding_hash]"), + "unexpected provisioning pin rejection: {}", + invalid_error.message + ); + + provisioning.state_anchor_binding_hash = None; + let (init_status, _) = call_with_json( + serde_json::to_vec(&provisioning).expect("provisioning config JSON"), + crate::frost_tbtc_init_signer_config, + ); + assert_eq!(init_status, 0); + + let (first_status, first_payload) = + call_without_json(crate::frost_tbtc_state_anchor_bootstrap_facts); + let (second_status, second_payload) = + call_without_json(crate::frost_tbtc_state_anchor_bootstrap_facts); + assert_eq!(first_status, 0); + assert_eq!(second_status, 0); + assert_eq!(first_payload, second_payload); + let facts: StateAnchorBootstrapFactsResult = + serde_json::from_slice(&first_payload).expect("bootstrap facts result"); + assert_eq!(facts.schema, STATE_ANCHOR_BOOTSTRAP_FACTS_SCHEMA); + assert_eq!( + facts.store_fingerprint, + facts.current_checkpoint.store_fingerprint + ); + assert_eq!(facts.current_checkpoint.generation, "1"); + + let (ordinary_status, ordinary_payload) = + call_without_json(crate::frost_tbtc_durable_store_identity); + assert_eq!(ordinary_status, 1); + let ordinary_error: crate::api::ErrorResponse = + serde_json::from_slice(&ordinary_payload).expect("ordinary operation error"); + assert!(ordinary_error.message.contains("normal_signer")); + + let dkg_request = DkgPart1Request { + participant_identifier: "01".to_string(), + max_signers: 3, + min_signers: 2, + }; + let (dkg_status, dkg_payload) = call_with_json( + serde_json::to_vec(&dkg_request).expect("DKG request JSON"), + crate::frost_tbtc_dkg_part1, + ); + assert_eq!(dkg_status, 1); + let dkg_error: crate::api::ErrorResponse = + serde_json::from_slice(&dkg_payload).expect("DKG purpose error"); + assert!(dkg_error.message.contains("normal_signer")); + + assert!(ENGINE_STATE.get().is_none()); + assert!(state_file_lock_slot().lock().expect("store slot").is_none()); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + #[cfg(unix)] + fn committed_trust_head_rejects_missing_anchor_and_legacy_witness_rollback_mix() { + let _guard = lock_test_state(); + for remove_anchor in [true, false] { + establish_clean_signer_test_env(); + let label = if remove_anchor { + "rollback-missing-anchor" + } else { + "rollback-legacy-witness" + }; + let (state_path, transition, _) = bootstrap_trust_store_fixture(label); + let witness_path = state_witness_file_path(&state_path); + let legacy_witness = fs::read(&witness_path).expect("read pre-bootstrap witness"); + let mut store = StateFileLock::acquire_for_trust_transition(&state_path, &transition) + .expect("acquire bootstrap store"); + store + .transition_state_witness_anchor(&transition) + .expect("install committed trust head"); + drop(store); + + if remove_anchor { + fs::remove_file(state_anchor_file_path(&state_path)) + .expect("remove anchor rollback component"); + fs::write(&witness_path, &legacy_witness) + .expect("restore pre-bootstrap witness rollback component"); + } else { + fs::write(&witness_path, &legacy_witness) + .expect("restore pre-bootstrap witness component"); + } + let error = StateFileLock::acquire(&state_path) + .err() + .expect("mixed pre/post-bootstrap rollback state must fail closed"); + if remove_anchor { + assert!( + error + .to_string() + .contains("requires persisted anchor metadata"), + "unexpected missing-anchor error: {error}" + ); + } else { + assert!( + error + .to_string() + .contains("requires an authenticated witness segment"), + "unexpected legacy-witness error: {error}" + ); + } + cleanup_anchor_store_fixture(&state_path); + } + } + + #[test] + #[cfg(unix)] + fn durable_bootstrap_intent_requires_fresh_resubmission_at_every_publication_boundary() { + let _guard = lock_test_state(); + let fault_points = [ + StateAnchorTrustTransitionFaultInjectionPoint::AfterIntentPublication, + StateAnchorTrustTransitionFaultInjectionPoint::AfterPrepareBatch, + StateAnchorTrustTransitionFaultInjectionPoint::AfterNextWitnessPublication, + StateAnchorTrustTransitionFaultInjectionPoint::AfterPreviousWitnessPublication, + StateAnchorTrustTransitionFaultInjectionPoint::AfterCurrentWitnessPublication, + StateAnchorTrustTransitionFaultInjectionPoint::AfterTargetAnchorPublication, + StateAnchorTrustTransitionFaultInjectionPoint::AfterCommitPublication, + StateAnchorTrustTransitionFaultInjectionPoint::AfterPreviousWitnessRetirement, + ]; + for point in fault_points { + establish_clean_signer_test_env(); + let (state_path, transition, tip) = + bootstrap_trust_store_fixture(&format!("{point:?}")); + let mut store = StateFileLock::acquire_for_trust_transition(&state_path, &transition) + .expect("acquire transition store"); + set_state_anchor_trust_transition_fault_for_tests(point); + let error = store + .transition_state_witness_anchor(&transition) + .expect_err("fault interrupts fresh transition"); + assert!( + error + .to_string() + .contains("injected state-anchor trust transition fault"), + "unexpected fault result at {point:?}: {error}" + ); + clear_state_anchor_trust_transition_fault_for_tests(); + let intent_path = state_anchor_trust_intent_file_path(&state_path); + assert!( + intent_path.exists(), + "durable intent must survive fault at {point:?}" + ); + drop(store); + + let recovery_error = match StateFileLock::acquire_for_trust_head_inspection(&state_path) + { + Ok(_) => panic!("preflight must not recover locally at {point:?}"), + Err(error) => error, + }; + let EngineError::StateAnchorTrustRecoveryRequired { context } = recovery_error else { + panic!("unexpected preflight result at {point:?}: {recovery_error}"); + }; + assert_eq!( + context.store_fingerprint, + transition.certificates[0].signer_store_fingerprint + ); + assert_eq!( + context.certificate_digests, + vec![transition.certificates[0].certificate_digest] + ); + assert!( + intent_path.exists(), + "preflight must leave the intent untouched at {point:?}" + ); + + let mut resumed = StateFileLock::acquire_for_trust_transition(&state_path, &transition) + .unwrap_or_else(|error| { + panic!("fresh transition resumption failed at {point:?}: {error}") + }); + let replay = resumed + .transition_state_witness_anchor(&transition) + .unwrap_or_else(|error| { + panic!("post-recovery replay failed at {point:?}: {error}") + }); + assert!(replay.idempotent); + drop(resumed); + + let mut inspection = StateFileLock::acquire_for_trust_head_inspection(&state_path) + .unwrap_or_else(|error| { + panic!("post-recovery preflight failed at {point:?}: {error}") + }); + let recovered = inspection + .state_anchor_trust_head_snapshot() + .unwrap_or_else(|error| { + panic!("recovered trust head failed at {point:?}: {error}") + }); + assert_eq!(recovered.tip, tip, "tip changed at {point:?}"); + assert_eq!(recovered.base, tip, "base changed at {point:?}"); + assert_eq!( + recovered.trust_head.certificate_digest, + transition.certificates[0].certificate_digest, + "wrong recovered head at {point:?}" + ); + assert!( + !intent_path.exists(), + "preflight must retire recovered intent at {point:?}" + ); + drop(inspection); + + let mut ordinary = StateFileLock::acquire(&state_path) + .unwrap_or_else(|error| panic!("ordinary reopen failed at {point:?}: {error}")); + assert_eq!( + ordinary + .state_witness_tip() + .expect("ordinary recovered tip"), + tip, + "ordinary tip changed at {point:?}" + ); + drop(ordinary); + cleanup_anchor_store_fixture(&state_path); + } + } + + #[test] + #[cfg(unix)] + fn exact_bootstrap_replay_requires_a_fresh_read_and_never_recreates_intent() { + let _guard = lock_test_state(); + let (state_path, transition, tip) = bootstrap_trust_store_fixture("replay"); + let mut first = StateFileLock::acquire_for_trust_transition(&state_path, &transition) + .expect("acquire initial bootstrap"); + first + .transition_state_witness_anchor(&transition) + .expect("initial bootstrap"); + drop(first); + + let mut replay = StateFileLock::acquire_for_trust_transition(&state_path, &transition) + .expect("acquire exact replay"); + let replayed = replay + .transition_state_witness_anchor(&transition) + .expect("fresh exact replay"); + assert!(replayed.idempotent); + assert_eq!(replayed.applied_certificate_count, 0); + drop(replay); + + let now = u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_millis(), + ) + .expect("clock fits u64"); + let nested = &transition.certificates[0].target_acknowledgement; + let expired = bootstrap_state_anchor_trust_transition_for_tests( + nested.checkpoint_store_fingerprint, + &tip, + nested.committed_at_unix_ms, + nested.expires_at_unix_ms, + now - 60_000, + now - 30_000, + false, + ) + .expect("intrinsically valid expired Read"); + assert_eq!( + expired.certificates[0].certificate_digest, + transition.certificates[0].certificate_digest + ); + let mut expired_store = StateFileLock::acquire_for_trust_transition(&state_path, &expired) + .expect("acquire expired exact replay for store-level admission check"); + let error = expired_store + .transition_state_witness_anchor(&expired) + .expect_err("expired exact replay rejected"); + assert!( + error.to_string().contains("expired"), + "unexpected expired replay error: {error}" + ); + ensure_entry_absent( + expired_store.directory.as_raw_fd(), + &expired_store.trust_intent_name, + "expired exact replay intent", + ) + .expect("expired replay cannot publish intent"); + drop(expired_store); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + #[cfg(unix)] + fn trust_journal_batch_capacity_preflight_rejects_before_intent_publication() { + let _guard = lock_test_state(); + let (state_path, transition, _) = bootstrap_trust_store_fixture("capacity"); + let mut store = StateFileLock::acquire_for_trust_transition(&state_path, &transition) + .expect("acquire capacity fixture"); + let certificate = &transition.certificates[0]; + let prepare_length = encode_state_anchor_trust_prepare_record( + &store.identity.fingerprint, + &[0u8; 32], + certificate, + ) + .expect("encode PREPARE") + .len(); + let commit_length = encode_state_anchor_trust_commit_record( + &store.identity.fingerprint, + &[0u8; 32], + certificate, + ) + .expect("encode COMMIT") + .len(); + for current_length in [ + STATE_ANCHOR_TRUST_MAX_JOURNAL_LENGTH - prepare_length + 1, + STATE_ANCHOR_TRUST_MAX_JOURNAL_LENGTH - prepare_length - commit_length + 1, + ] { + let growth = store + .state_anchor_trust_transition_journal_growth(&transition) + .expect("compute exact journal growth"); + let error = ensure_state_anchor_trust_transition_journal_capacity_for_length( + current_length, + growth - STATE_ANCHOR_TRUST_JOURNAL_HEADER_LENGTH, + ) + .expect_err("near-capacity batch rejected"); + assert!(error.to_string().contains("size bound")); + ensure_entry_absent( + store.directory.as_raw_fd(), + &store.trust_intent_name, + "capacity-rejected trust intent", + ) + .expect("capacity preflight cannot publish intent"); + } + let intent_bytes = encode_state_anchor_trust_transition_intent( + &store.identity.fingerprint, + &transition.request, + ) + .expect("encode boundary-test intent"); + let error = store + .create_state_anchor_trust_transition_intent( + &intent_bytes, + transition.target_read_expires_at_unix_ms, + STATE_ANCHOR_TRUST_MAX_JOURNAL_LENGTH + 1, + ) + .expect_err("final pre-rename capacity check rejects publication"); + assert!(error.to_string().contains("size bound")); + ensure_entry_absent( + store.directory.as_raw_fd(), + &store.trust_intent_name, + "capacity-rejected live trust intent", + ) + .expect("oversized batch cannot publish its intent"); + let temp_prefix = format!("{}.tmp-", store.trust_intent_name.to_string_lossy()); + let parent = state_path.parent().expect("fixture parent"); + assert!( + fs::read_dir(parent) + .expect("read fixture directory") + .filter_map(Result::ok) + .all(|entry| !entry + .file_name() + .to_string_lossy() + .starts_with(&temp_prefix)), + "capacity-rejected intent temp must be cleaned up" + ); + drop(store); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + fn anchor_metadata_round_trips_and_rejects_recommitted_signature_tampering() { + let (configuration, acknowledgement) = signed_fixture(); + let metadata = StateAnchorMetadata { + latest: acknowledgement.clone(), + witness_base: None, + pending_witness_base: Some(acknowledgement.clone()), + }; + let bytes = + encode_state_anchor_metadata(&acknowledgement.checkpoint_store_fingerprint, &metadata); + assert_eq!( + parse_state_anchor_metadata( + &bytes, + &acknowledgement.checkpoint_store_fingerprint, + &configuration, + &[], + ) + .expect("parse signed metadata"), + metadata + ); + + const ACK_SIGNATURE_OFFSET: usize = 432; + let mut tampered = bytes; + tampered[56 + ACK_SIGNATURE_OFFSET] ^= 1; + let commitment_offset = tampered.len() - 32; + let commitment = state_anchor_metadata_commitment(&tampered[..commitment_offset]); + tampered[commitment_offset..].copy_from_slice(&commitment); + let error = parse_state_anchor_metadata( + &tampered, + &acknowledgement.checkpoint_store_fingerprint, + &configuration, + &[], + ) + .expect_err("a recommitted but signature-tampered anchor must fail"); + assert!(error.to_string().contains("signature")); + + // The test-only path helper remains pinned to the externally documented + // suffix used by offline recovery tooling. + assert!(state_anchor_file_path(Path::new("/tmp/state")).ends_with("state.state-anchor")); + } + + #[test] + fn anchor_metadata_rejects_adjacent_base_latest_fork_splice() { + let (configuration, base) = signed_fixture(); + let signing_key = SigningKey::from_bytes(&[0x07; 32]); + let mut latest = base.clone(); + latest.revision = base.revision + 1; + latest.previous_event_root = [0x99; 32]; + latest.event_root = state_anchor_event_root_for_tests(&latest); + latest.signing_digest = state_anchor_signing_digest_for_tests(&latest); + latest.signature = signing_key.sign(&latest.signing_digest).to_bytes(); + let mut acknowledgement_digest = Sha256::new(); + acknowledgement_digest.update(b"tbtc-signer-state-anchor-acknowledgement/v1\0"); + acknowledgement_digest.update(latest.signing_digest); + acknowledgement_digest.update(latest.signature); + acknowledgement_digest.update(latest.configured_spki_hash); + latest.acknowledgement_digest = acknowledgement_digest.finalize().into(); + + let metadata = StateAnchorMetadata { + latest: latest.clone(), + witness_base: Some(base.clone()), + pending_witness_base: None, + }; + let bytes = encode_state_anchor_metadata(&latest.checkpoint_store_fingerprint, &metadata); + let error = parse_state_anchor_metadata( + &bytes, + &latest.checkpoint_store_fingerprint, + &configuration, + &[], + ) + .expect_err("adjacent signed fork splice must fail"); + assert!(error.to_string().contains("inconsistent")); + + latest.previous_event_root = base.event_root; + latest.event_root = state_anchor_event_root_for_tests(&latest); + latest.signing_digest = state_anchor_signing_digest_for_tests(&latest); + latest.signature = signing_key.sign(&latest.signing_digest).to_bytes(); + let mut acknowledgement_digest = Sha256::new(); + acknowledgement_digest.update(b"tbtc-signer-state-anchor-acknowledgement/v1\0"); + acknowledgement_digest.update(latest.signing_digest); + acknowledgement_digest.update(latest.signature); + acknowledgement_digest.update(latest.configured_spki_hash); + latest.acknowledgement_digest = acknowledgement_digest.finalize().into(); + let linked = StateAnchorMetadata { + latest: latest.clone(), + witness_base: Some(base), + pending_witness_base: None, + }; + let bytes = encode_state_anchor_metadata(&latest.checkpoint_store_fingerprint, &linked); + assert_eq!( + parse_state_anchor_metadata( + &bytes, + &latest.checkpoint_store_fingerprint, + &configuration, + &[], + ) + .expect("adjacent linked metadata"), + linked + ); + } + + #[test] + fn monotonic_anchor_rules_reject_replays_forks_gaps_and_epoch_changes() { + let (_, first) = signed_fixture(); + assert!(!validate_anchor_monotonic_update(None, &first, false).expect("first ack")); + let existing = StateAnchorMetadata { + latest: first.clone(), + witness_base: None, + pending_witness_base: None, + }; + assert!( + validate_anchor_monotonic_update(Some(&existing), &first, false).expect("exact replay") + ); + + let mut next = first.clone(); + next.revision = 2; + next.previous_event_root = first.event_root; + next.event_root = [0x56; 32]; + assert!( + !validate_anchor_monotonic_update(Some(&existing), &next, false).expect("next ack") + ); + + let mut same_revision_fork = first.clone(); + same_revision_fork.event_root = [0x57; 32]; + assert!( + validate_anchor_monotonic_update(Some(&existing), &same_revision_fork, false).is_err() + ); + let mut wrong_parent = next.clone(); + wrong_parent.previous_event_root = [0x58; 32]; + assert!(validate_anchor_monotonic_update(Some(&existing), &wrong_parent, false).is_err()); + let mut gap = next.clone(); + gap.revision = 3; + assert!(validate_anchor_monotonic_update(Some(&existing), &gap, false).is_err()); + let mut stale = first.clone(); + stale.revision = 0; + assert!(validate_anchor_monotonic_update(Some(&existing), &stale, false).is_err()); + let mut epoch_change = next; + epoch_change.service_epoch += 1; + assert!(validate_anchor_monotonic_update(Some(&existing), &epoch_change, false).is_err()); + + let mut recovered = first; + recovered.revision = 7; + recovered.previous_event_root = [0x59; 32]; + assert!(!validate_anchor_monotonic_update(None, &recovered, true) + .expect("fresh recovery may restore a later revision without local metadata")); + assert!(validate_anchor_monotonic_update(None, &recovered, false).is_err()); + } + + #[test] + #[cfg(unix)] + fn tip_settles_replayed_anchor_after_prepare_abort_reaches_rotation_threshold() { + let _guard = lock_test_state(); + let mut random = [0u8; 12]; + OsRng.fill_bytes(&mut random); + let state_path = std::env::temp_dir().join(format!( + "tbtc-signer-anchor-abort-{}-{}", + std::process::id(), + hex::encode(random) + )); + std::env::set_var(TBTC_SIGNER_STATE_PATH_ENV, &state_path); + std::env::set_var(TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, "10"); + std::env::set_var( + TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS_ENV, + "2", + ); + + let signing_key = SigningKey::from_bytes(&[0x07; 32]); + let response_public_key = signing_key.verifying_key().to_bytes(); + let mut spki_digest = Sha256::new(); + spki_digest.update([ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ]); + spki_digest.update(response_public_key); + let configured_spki_hash: [u8; 32] = spki_digest.finalize().into(); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH_ENV, + bytes32_hex([0x44; 32]), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_ENV, + bytes32_hex(response_public_key), + ); + std::env::set_var( + TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256_ENV, + bytes32_hex(configured_spki_hash), + ); + + let mut store = StateFileLock::acquire(&state_path).expect("open anchored store"); + let tip = store.state_witness_tip().expect("genesis tip"); + let now = u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_millis(), + ) + .expect("clock fits u64"); + let mut acknowledgement = fixture_acknowledgement(); + acknowledgement.checkpoint_store_fingerprint = store.identity.fingerprint; + acknowledgement.checkpoint_generation = tip.generation; + acknowledgement.checkpoint_previous_commitment = tip.previous_commitment; + acknowledgement.checkpoint_state_image_digest = tip.state_image_digest; + acknowledgement.checkpoint_state_commitment = tip.commitment; + acknowledgement.committed_at_unix_ms = now; + acknowledgement.expires_at_unix_ms = now + 30_000; + acknowledgement.configured_spki_hash = configured_spki_hash; + acknowledgement.event_root = state_anchor_event_root_for_tests(&acknowledgement); + acknowledgement.signing_digest = state_anchor_signing_digest_for_tests(&acknowledgement); + acknowledgement.signature = signing_key.sign(&acknowledgement.signing_digest).to_bytes(); + let mut acknowledgement_digest = Sha256::new(); + acknowledgement_digest.update(b"tbtc-signer-state-anchor-acknowledgement/v1\0"); + acknowledgement_digest.update(acknowledgement.signing_digest); + acknowledgement_digest.update(acknowledgement.signature); + acknowledgement_digest.update(configured_spki_hash); + acknowledgement.acknowledgement_digest = acknowledgement_digest.finalize().into(); + + let first = store + .acknowledge_state_witness_checkpoint( + acknowledgement.clone(), + 2, + false, + acknowledgement.expires_at_unix_ms, + ) + .expect("anchor and rotate genesis"); + assert!(first.rotated); + assert_eq!(store.witness_record_count().expect("empty segment"), 0); + + let aborted = store + .next_state_witness(state_image_digest(Some(b"aborted state"))) + .expect("next witness"); + store + .prepare_witness(aborted, WitnessAppendPurpose::StateWrite) + .expect("prepare witness"); + store.abort_pending_witness().expect("abort witness"); + assert_eq!(store.state_witness_tip().expect("unchanged tip"), tip); + assert_eq!(store.witness_record_count().expect("prepare plus abort"), 2); + + // Reproduce a crash after the exact replay's pending-anchor fsync but + // before `.next` creation. The current signed segment has the same base + // and tip as the replay, but is not a completed publication because it + // still contains PREPARE+ABORT. A tip read must finish compaction rather + // than falsely promoting the pending metadata and leaving writes + // blocked forever. + let witness_base = store + .anchor_metadata + .as_ref() + .and_then(|metadata| metadata.witness_base.clone()); + store + .persist_state_anchor_metadata(StateAnchorMetadata { + latest: acknowledgement.clone(), + witness_base, + pending_witness_base: Some(acknowledgement.clone()), + }) + .expect("persist replay rotation intent"); + let snapshot = store + .state_witness_tip_snapshot() + .expect("tip settles pending compaction"); + assert_eq!(snapshot.tip, tip); + assert_eq!(snapshot.base, tip); + let settled_anchor = snapshot.anchor.expect("settled anchor"); + assert_eq!(settled_anchor.witness_base, Some(acknowledgement)); + assert!(settled_anchor.pending_witness_base.is_none()); + assert_eq!(store.witness_record_count().expect("compacted segment"), 0); + store + .replace_state(b"write after compaction") + .expect("writes resume after compaction"); + drop(store); + + let witness_path = state_witness_file_path(&state_path); + let mut witness_next = witness_path.as_os_str().to_os_string(); + witness_next.push(TBTC_SIGNER_STATE_WITNESS_NEXT_SUFFIX); + let mut witness_previous = witness_path.as_os_str().to_os_string(); + witness_previous.push(TBTC_SIGNER_STATE_WITNESS_PREVIOUS_SUFFIX); + for path in [ + state_path.clone(), + state_lock_file_path(&state_path), + durable_store_id_file_path(&state_path), + witness_path, + PathBuf::from(witness_next), + PathBuf::from(witness_previous), + state_anchor_file_path(&state_path), + ] { + let _ = fs::remove_file(path); + } + } + + #[test] + #[cfg(unix)] + fn exact_anchor_replay_rotates_unchanged_tip_at_record_threshold() { + let _guard = lock_test_state(); + let mut random = [0u8; 12]; + OsRng.fill_bytes(&mut random); + let state_path = std::env::temp_dir().join(format!( + "tbtc-signer-anchor-exact-replay-{}-{}", + std::process::id(), + hex::encode(random) + )); + let signing_key = SigningKey::from_bytes(&[0x07; 32]); + let configured_spki_hash = + configure_anchor_store_fixture(&state_path, &signing_key, [0x44; 32]); + let mut store = StateFileLock::acquire(&state_path).expect("open anchored store"); + let tip = store.state_witness_tip().expect("genesis tip"); + let acknowledgement = signed_acknowledgement_for_tip( + &signing_key, + configured_spki_hash, + store.identity.fingerprint, + &tip, + ); + assert!( + store + .acknowledge_state_witness_checkpoint( + acknowledgement.clone(), + 2, + false, + acknowledgement.expires_at_unix_ms, + ) + .expect("initial anchor rotation") + .rotated + ); + + let aborted = store + .next_state_witness(state_image_digest(Some(b"aborted state"))) + .expect("next witness"); + store + .prepare_witness(aborted, WitnessAppendPurpose::StateWrite) + .expect("prepare witness"); + store.abort_pending_witness().expect("abort witness"); + assert_eq!(store.state_witness_tip().expect("unchanged tip"), tip); + assert_eq!(store.witness_record_count().expect("threshold count"), 2); + + let replay = store + .acknowledge_state_witness_checkpoint( + acknowledgement.clone(), + 2, + false, + acknowledgement.expires_at_unix_ms, + ) + .expect("exact replay compacts unchanged tip"); + assert!(replay.idempotent); + assert!(replay.rotated); + assert_eq!(store.witness_record_count().expect("compacted count"), 0); + store + .replace_state(b"write after exact-replay compaction") + .expect("writes resume"); + drop(store); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + #[cfg(unix)] + fn terminal_record_reservation_allows_an_interrupted_multi_snapshot_retry_to_finish() { + let _guard = lock_test_state(); + let mut random = [0u8; 12]; + OsRng.fill_bytes(&mut random); + let state_path = std::env::temp_dir().join(format!( + "tbtc-signer-anchor-multi-snapshot-{}-{}", + std::process::id(), + hex::encode(random) + )); + let signing_key = SigningKey::from_bytes(&[0x07; 32]); + let configured_spki_hash = + configure_anchor_store_fixture(&state_path, &signing_key, [0x44; 32]); + let mut store = StateFileLock::acquire(&state_path).expect("open anchored store"); + let genesis = store.state_witness_tip().expect("genesis tip"); + let acknowledgement = signed_acknowledgement_for_tip( + &signing_key, + configured_spki_hash, + store.identity.fingerprint, + &genesis, + ); + assert!( + store + .acknowledge_state_witness_checkpoint( + acknowledgement.clone(), + 2, + false, + acknowledgement.expires_at_unix_ms, + ) + .expect("anchor and rotate genesis") + .rotated + ); + assert_eq!(store.witness_record_count().expect("empty segment"), 0); + + // Model a write that begins immediately below the threshold, renames + // the new image, and is interrupted before COMMIT. The next state() + // reconciliation appends COMMIT and reaches the threshold before the + // retry starts its ordinary work. + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let interrupted = store + .replace_state(b"interrupted pre-retry snapshot") + .expect_err("fault after rename leaves PREPARE pending"); + clear_persist_fault_injection_for_tests(); + assert!(interrupted.replaced()); + assert_eq!( + store + .witness_record_count() + .expect("interrupted PREPARE count"), + 1 + ); + store + .reconcile_pending_witness() + .expect("startup reconciliation commits renamed state"); + assert_eq!(store.witness_record_count().expect("threshold count"), 2); + + // The retry repairs an InteractiveState persistence-pending marker, + // retires the newly unprotected expired session, and then persists its + // requested mutation. All three snapshots must fit after the + // reconciliation has already reached the threshold. + store + .replace_state(b"persistence-pending repair snapshot") + .expect("first repair snapshot uses terminal reservation"); + assert_eq!(store.witness_record_count().expect("terminal count"), 4); + store + .replace_state(b"expired session retirement snapshot") + .expect("second repair snapshot uses terminal reservation"); + assert_eq!(store.witness_record_count().expect("terminal count"), 6); + store + .replace_state(b"requested mutation snapshot") + .expect("requested mutation uses the final terminal record pair"); + assert_eq!(store.witness_record_count().expect("terminal count"), 8); + assert_eq!( + store.read_state().expect("completed multi-snapshot state"), + Some(b"requested mutation snapshot".to_vec()) + ); + + let rejected = store + .replace_state(b"unacknowledged fourth snapshot") + .expect_err("a later snapshot still requires checkpoint rotation"); + assert!(!rejected.replaced()); + assert!(rejected + .into_engine_error() + .to_string() + .contains("rotation threshold reached")); + drop(store); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + #[cfg(unix)] + fn quarantine_recovers_a_corrupt_image_parked_at_the_terminal_reservation() { + let _guard = lock_test_state(); + let mut random = [0u8; 12]; + OsRng.fill_bytes(&mut random); + let state_path = std::env::temp_dir().join(format!( + "tbtc-signer-anchor-terminal-quarantine-{}-{}", + std::process::id(), + hex::encode(random) + )); + let signing_key = SigningKey::from_bytes(&[0x07; 32]); + let configured_spki_hash = + configure_anchor_store_fixture(&state_path, &signing_key, [0x44; 32]); + let mut store = StateFileLock::acquire(&state_path).expect("open anchored store"); + let genesis = store.state_witness_tip().expect("genesis tip"); + let genesis_acknowledgement = signed_acknowledgement_for_tip( + &signing_key, + configured_spki_hash, + store.identity.fingerprint, + &genesis, + ); + assert!( + store + .acknowledge_state_witness_checkpoint( + genesis_acknowledgement.clone(), + 2, + false, + genesis_acknowledgement.expires_at_unix_ms, + ) + .expect("anchor and rotate genesis") + .rotated + ); + + // Park the journal exactly where the terminal reservation leaves it: + // the threshold is behind us and the last reserved snapshot has been + // spent, so the next ordinary write waits for a checkpoint. + for snapshot in 0..4 { + store + .replace_state(format!("terminal snapshot {snapshot}").as_bytes()) + .expect("terminal reservation admits the parked snapshots"); + } + assert_eq!(store.witness_record_count().expect("parked count"), 8); + let parked_tip = store.state_witness_tip().expect("parked tip"); + drop(store); + + // Corrupt the committed image in place. The inode, mode, and link count + // are unchanged, so only the image digest stops matching the tip. + fs::write(&state_path, b"{corrupt-while-parked").expect("corrupt the parked state image"); + let mut store = StateFileLock::acquire(&state_path).expect("reopen with a corrupt image"); + + // Both maintenance exits are closed: the acknowledgement that would + // rotate the segment revalidates the image first, and it is the image + // that is broken. + let image_error = store + .validate_state_image() + .expect_err("the corrupt image no longer matches the committed tip"); + assert!(image_error + .to_string() + .contains("signer state image does not match the committed witness tip")); + let parked_acknowledgement = signed_acknowledgement_for_tip( + &signing_key, + configured_spki_hash, + store.identity.fingerprint, + &parked_tip, + ); + let acknowledge_error = store + .acknowledge_state_witness_checkpoint( + parked_acknowledgement.clone(), + 2, + false, + parked_acknowledgement.expires_at_unix_ms, + ) + .expect_err("a corrupt image cannot be checkpointed into a rotation"); + assert!(acknowledge_error + .to_string() + .contains("signer state image does not match the committed witness tip")); + + // The operator-selected quarantine policy is the remaining exit, and it + // draws on the reserve rather than the rotation bound. + let backup_path = state_path.with_file_name(format!( + "{}.corrupt-backup", + state_path + .file_name() + .expect("fixture state file name") + .to_string_lossy() + )); + store + .quarantine_state(&backup_path) + .expect("quarantine uses the reserved record pair"); + assert_eq!(store.witness_record_count().expect("quarantined count"), 10); + assert_eq!(store.read_state().expect("quarantined state"), None); + assert_eq!( + fs::read(&backup_path).expect("read quarantined image"), + b"{corrupt-while-parked" + ); + + // The reserve is not an escape hatch for ordinary writes: they still + // wait for the acknowledgement, which is now unblocked because the + // committed tip is the absence the quarantine recorded. + let rejected = store + .replace_state(b"write after quarantine") + .expect_err("the quarantine reserve does not admit state writes"); + assert!(!rejected.replaced()); + assert!(rejected + .into_engine_error() + .to_string() + .contains("rotation threshold reached")); + let quarantined_tip = store.state_witness_tip().expect("quarantined tip"); + assert_eq!(quarantined_tip.state_image_digest, state_image_digest(None)); + let quarantined_acknowledgement = signed_acknowledgement_for_tip_at_revision( + &signing_key, + configured_spki_hash, + store.identity.fingerprint, + &quarantined_tip, + genesis_acknowledgement.revision + 1, + genesis_acknowledgement.event_root, + ); + assert!( + store + .acknowledge_state_witness_checkpoint( + quarantined_acknowledgement.clone(), + 2, + false, + quarantined_acknowledgement.expires_at_unix_ms, + ) + .expect("the quarantined tip is checkpointable") + .rotated + ); + store + .replace_state(b"clean state after quarantine") + .expect("writes resume on the rotated segment"); + drop(store); + let _ = fs::remove_file(&backup_path); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + #[cfg(unix)] + fn checkpoint_rotation_precedes_mandatory_startup_state_rewrite() { + let _guard = lock_test_state(); + let mut random = [0u8; 12]; + OsRng.fill_bytes(&mut random); + let state_path = std::env::temp_dir().join(format!( + "tbtc-signer-anchor-startup-rewrite-{}-{}", + std::process::id(), + hex::encode(random) + )); + let signing_key = SigningKey::from_bytes(&[0x07; 32]); + let configured_spki_hash = + configure_anchor_store_fixture(&state_path, &signing_key, [0x44; 32]); + let mut store = StateFileLock::acquire(&state_path).expect("open anchored store"); + + let legacy_state = PersistedEngineState::try_from(&EngineState::default()) + .expect("encode valid legacy state"); + let mut plaintext = serde_json::to_vec(&legacy_state).expect("serialize legacy state"); + let key_material = state_encryption_key_material().expect("load test state key"); + let cipher = XChaCha20Poly1305::new_from_slice(&key_material.key[..]) + .expect("initialize test cipher"); + let nonce_bytes = [7u8; TBTC_SIGNER_STATE_ENVELOPE_NONCE_BYTES]; + let nonce = XNonce::from_slice(&nonce_bytes); + let mut ciphertext_and_tag = cipher + .encrypt(nonce, plaintext.as_ref()) + .expect("encrypt legacy v2 envelope fixture"); + plaintext.zeroize(); + let mut authentication_tag = ciphertext_and_tag + .split_off(ciphertext_and_tag.len() - TBTC_SIGNER_STATE_ENVELOPE_AUTH_TAG_BYTES); + let legacy_envelope = PersistedEncryptedEngineStateEnvelope { + schema_version: PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION_V2, + encryption_algorithm: TBTC_SIGNER_STATE_ENCRYPTION_ALGORITHM_XCHACHA20POLY1305 + .to_string(), + key_provider: TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT.to_string(), + key_id: TBTC_SIGNER_STATE_KEY_ID_LEGACY_ENV_HEX.to_string(), + nonce: hex::encode(nonce_bytes), + ciphertext: hex::encode(&ciphertext_and_tag), + authentication_tag: hex::encode(&authentication_tag), + }; + ciphertext_and_tag.zeroize(); + authentication_tag.zeroize(); + let legacy_bytes = + serde_json::to_vec(&legacy_envelope).expect("serialize legacy v2 envelope"); + store + .replace_state(b"first pre-rewrite snapshot") + .expect("consume first terminal record pair"); + store + .replace_state(b"second pre-rewrite snapshot") + .expect("consume second terminal record pair"); + store + .replace_state(&legacy_bytes) + .expect("install legacy image at the terminal record limit"); + assert_eq!( + store.witness_record_count().expect("terminal record count"), + 8 + ); + + let tip = store.state_witness_tip().expect("legacy image tip"); + let acknowledgement = signed_acknowledgement_for_tip( + &signing_key, + configured_spki_hash, + store.identity.fingerprint, + &tip, + ); + let request_json = + serde_json::to_string(&acknowledgement_wire_for_store_tests(&acknowledgement)) + .expect("serialize acknowledgement request"); + drop(store); + + let output = + std::process::Command::new(std::env::current_exe().expect("current test binary path")) + .arg("--exact") + .arg( + "engine::store::witness_transcript_tests::checkpoint_rotation_before_startup_rewrite_helper", + ) + .arg("--ignored") + .arg("--nocapture") + .env("TBTC_SIGNER_STARTUP_REWRITE_ACK_HELPER", "1") + .env("TBTC_SIGNER_STARTUP_REWRITE_ACK_REQUEST", request_json) + .output() + .expect("spawn isolated startup acknowledgement helper"); + assert!( + output.status.success(), + "startup acknowledgement helper failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "startup acknowledgement helper did not execute exactly one test\nstdout:\n{}", + String::from_utf8_lossy(&output.stdout) + ); + cleanup_anchor_store_fixture(&state_path); + } + + #[test] + #[ignore = "isolated subprocess helper"] + #[cfg(unix)] + fn checkpoint_rotation_before_startup_rewrite_helper() { + if std::env::var("TBTC_SIGNER_STARTUP_REWRITE_ACK_HELPER").as_deref() != Ok("1") { + return; + } + assert!(ENGINE_STATE.get().is_none()); + assert!(state_file_lock_slot().lock().expect("store slot").is_none()); + let request: crate::api::AcknowledgeStateWitnessCheckpointRequest = serde_json::from_str( + &std::env::var("TBTC_SIGNER_STARTUP_REWRITE_ACK_REQUEST") + .expect("startup acknowledgement request"), + ) + .expect("decode startup acknowledgement request"); + + let result = crate::engine::acknowledge_state_witness_checkpoint(request) + .expect("rotate checkpoint before rewriting legacy state"); + assert!(result.rotated); + assert!(ENGINE_STATE.get().is_some()); + + let state_path = state_file_path().expect("configured state path"); + let rewritten: PersistedEncryptedEngineStateEnvelope = + serde_json::from_slice(&fs::read(&state_path).expect("read rewritten encrypted state")) + .expect("startup rewrite must replace the legacy encrypted envelope"); + assert_eq!( + rewritten.schema_version, + PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION + ); + assert_eq!( + with_state_file_lock(|store| store.witness_record_count()) + .expect("post-rewrite witness count"), + 2, + "the migrated snapshot must be appended to the newly rotated segment" + ); + } + + #[test] + #[cfg(unix)] + fn startup_settles_pending_rotation_at_every_rename_boundary_before_tip_read() { + let _guard = lock_test_state(); + let signing_key = SigningKey::from_bytes(&[0x07; 32]); + for case in 0..=4 { + let mut random = [0u8; 12]; + OsRng.fill_bytes(&mut random); + let state_path = std::env::temp_dir().join(format!( + "tbtc-signer-anchor-startup-{case}-{}-{}", + std::process::id(), + hex::encode(random) + )); + let configured_spki_hash = + configure_anchor_store_fixture(&state_path, &signing_key, [0x44; 32]); + let mut store = StateFileLock::acquire(&state_path).expect("open fixture store"); + let tip = store.state_witness_tip().expect("fixture genesis tip"); + let acknowledgement = signed_acknowledgement_for_tip( + &signing_key, + configured_spki_hash, + store.identity.fingerprint, + &tip, + ); + store + .persist_state_anchor_metadata(StateAnchorMetadata { + latest: acknowledgement.clone(), + witness_base: None, + pending_witness_base: Some(acknowledgement.clone()), + }) + .expect("persist pending rotation"); + + if case >= 1 { + let header = encode_state_witness_segment_header( + &store.identity.fingerprint, + &acknowledgement, + ) + .expect("encode next segment"); + create_entry_atomically( + &store.directory, + &store.witness_next_name, + &header, + "startup fixture next witness", + ) + .expect("publish next fixture"); + } + if case >= 2 { + renameat_same_directory( + store.directory.as_raw_fd(), + &store.witness_name, + &store.witness_previous_name, + "startup fixture retain previous", + ) + .expect("retain previous fixture"); + } + if case >= 3 { + renameat_same_directory( + store.directory.as_raw_fd(), + &store.witness_next_name, + &store.witness_name, + "startup fixture publish current", + ) + .expect("publish current fixture"); + } + if case >= 4 { + unlinkat_entry(store.directory.as_raw_fd(), &store.witness_previous_name) + .expect("retire previous fixture"); + } + store.directory.sync_all().expect("sync crash fixture"); + drop(store); + + let mut reopened = + StateFileLock::acquire(&state_path).expect("startup completes rotation"); + let metadata = reopened + .anchor_metadata + .as_ref() + .expect("normalized anchor metadata"); + assert_eq!(metadata.latest, acknowledgement); + assert_eq!(metadata.witness_base, Some(acknowledgement.clone())); + assert!(metadata.pending_witness_base.is_none()); + ensure_entry_absent( + reopened.directory.as_raw_fd(), + &reopened.witness_next_name, + "settled next witness", + ) + .expect("next absent after startup"); + ensure_entry_absent( + reopened.directory.as_raw_fd(), + &reopened.witness_previous_name, + "settled previous witness", + ) + .expect("previous absent after startup"); + + let anchor_before = + fs::read(state_anchor_file_path(&state_path)).expect("anchor before tip"); + let witness_before = + fs::read(state_witness_file_path(&state_path)).expect("witness before tip"); + let snapshot = reopened + .state_witness_tip_snapshot() + .expect("tip after startup settlement"); + assert_eq!(snapshot.tip, tip); + assert_eq!(snapshot.base, tip); + assert_eq!( + fs::read(state_anchor_file_path(&state_path)).expect("anchor after tip"), + anchor_before, + "case {case}: tip read must not lazily normalize anchor metadata" + ); + assert_eq!( + fs::read(state_witness_file_path(&state_path)).expect("witness after tip"), + witness_before, + "case {case}: tip read must not lazily rotate the witness" + ); + drop(reopened); + cleanup_anchor_store_fixture(&state_path); + } + } + + #[test] + fn proof_lookup_reports_structured_history_pruned_below_rotated_base() { + let base = StateWitness { + generation: 42, + previous_commitment: [0x21; 32], + state_image_digest: [0x22; 32], + commitment: [0x23; 32], + }; + let error = resolve_witness_history_index(&[base], 41, [0x24; 32], "ancestor") + .expect_err("generation before base must be pruned"); + assert_eq!(error.code(), "history_pruned"); + assert!(matches!( + error, + EngineError::HistoryPruned { + requested_generation: 41, + witness_base_generation: 42, + } + )); + } + + #[cfg(unix)] + fn crash_recovery_fixture( + case: u8, + ) -> ( + PathBuf, + fs::File, + OsString, + OsString, + OsString, + DurableStoreIdentity, + StateAnchorMetadata, + ) { + let mut random = [0u8; 12]; + OsRng.fill_bytes(&mut random); + let path = std::env::temp_dir().join(format!( + "tbtc-signer-witness-rotation-test-{}-{}", + std::process::id(), + hex::encode(random) + )); + fs::create_dir(&path).expect("create rotation fixture directory"); + let canonical = fs::canonicalize(&path).expect("canonical fixture directory"); + let directory = + open_absolute_directory_nofollow(&canonical).expect("open fixture directory"); + let store_id = [0x19; 32]; + let fingerprint = durable_store_fingerprint(&store_id); + let identity = DurableStoreIdentity { + store_id, + canonical_path_fingerprint: [0u8; 32], + filesystem_fingerprint: [0u8; 32], + lock_fingerprint: [0u8; 32], + fingerprint, + }; + let digest = state_image_digest(None); + let previous = state_witness_genesis(&fingerprint); + let base = StateWitness { + generation: 1, + previous_commitment: previous, + state_image_digest: digest, + commitment: state_commitment(&fingerprint, 1, &previous, &digest), + }; + let mut acknowledgement = fixture_acknowledgement(); + acknowledgement.checkpoint_store_fingerprint = fingerprint; + acknowledgement.checkpoint_generation = base.generation; + acknowledgement.checkpoint_previous_commitment = base.previous_commitment; + acknowledgement.checkpoint_state_image_digest = base.state_image_digest; + acknowledgement.checkpoint_state_commitment = base.commitment; + let metadata = StateAnchorMetadata { + latest: acknowledgement.clone(), + witness_base: None, + pending_witness_base: Some(acknowledgement.clone()), + }; + let current = OsString::from("state.state-witness"); + let next = OsString::from("state.state-witness.next"); + let previous_name = OsString::from("state.state-witness.previous"); + let mut legacy = Vec::new(); + legacy.extend_from_slice(TBTC_SIGNER_STATE_WITNESS_MAGIC); + legacy.extend_from_slice(&store_id); + legacy.extend_from_slice(&encode_state_witness_record( + TBTC_SIGNER_STATE_WITNESS_RECORD_PREPARE, + &base, + )); + legacy.extend_from_slice(&encode_state_witness_record( + TBTC_SIGNER_STATE_WITNESS_RECORD_COMMIT, + &base, + )); + create_entry_atomically(&directory, ¤t, &legacy, "fixture current witness") + .expect("publish legacy current"); + if case >= 1 { + let header = encode_state_witness_segment_header(&fingerprint, &acknowledgement) + .expect("encode next fixture"); + create_entry_atomically(&directory, &next, &header, "fixture next witness") + .expect("publish next fixture"); + } + if case >= 2 { + renameat_same_directory( + directory.as_raw_fd(), + ¤t, + &previous_name, + "fixture current to previous", + ) + .expect("retain fixture previous"); + } + if case >= 3 { + renameat_same_directory( + directory.as_raw_fd(), + &next, + ¤t, + "fixture next to current", + ) + .expect("publish fixture current"); + } + if case >= 4 { + unlinkat_entry(directory.as_raw_fd(), &previous_name).expect("retire fixture previous"); + } + directory.sync_all().expect("sync fixture state"); + ( + path, + directory, + current, + next, + previous_name, + identity, + metadata, + ) + } + + #[cfg(unix)] + #[test] + fn rotation_recovery_completes_every_durable_rename_boundary() { + // 0: signed base durable, before .next; 1: .next durable; 2: current + // renamed to .previous; 3: .next renamed to current; 4: .previous + // retired before anchor normalization. Each state also represents the + // corresponding pre/post directory-fsync crash image. + for case in 0..=4 { + let (path, directory, current, next, previous, identity, metadata) = + crash_recovery_fixture(case); + assert!( + recover_state_witness_rotation( + &directory, + StateWitnessRotationNames { + current: ¤t, + next: &next, + previous: &previous, + }, + &identity, + None, + Some(&metadata), + 16, + true, + None, + ) + .expect("recover signed rotation"), + "case {case} must promote its pending base" + ); + ensure_entry_absent(directory.as_raw_fd(), &next, "fixture next") + .expect("next retired"); + ensure_entry_absent(directory.as_raw_fd(), &previous, "fixture previous") + .expect("previous retired"); + let parsed = + validate_rotation_candidate(&directory, ¤t, &identity, Some(&metadata), 16) + .expect("final current segment"); + assert!(acknowledgement_matches_witness( + metadata + .pending_witness_base + .as_ref() + .expect("pending fixture base"), + parsed.history.first() + )); + drop(directory); + fs::remove_dir_all(path).expect("remove rotation fixture"); + } + } +} diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index dac089d1ee..f34d6a25d4 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -590,6 +590,7 @@ fn configure_test_state_path(suffix: &str) -> PathBuf { fn clear_state_storage_policy_overrides() { std::env::remove_var(TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV); std::env::remove_var(TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV); + std::env::remove_var(TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV); std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); std::env::remove_var(TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV); std::env::remove_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV); @@ -707,6 +708,8 @@ fn configure_valid_provenance_attestation_for_tests() { fn cleanup_test_state_artifacts(path: &Path) { let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(state_lock_file_path(path)); + let _ = std::fs::remove_file(durable_store_id_file_path(path)); + let _ = std::fs::remove_file(state_witness_file_path(path)); let _ = std::fs::remove_file(path.with_extension(format!("tmp-{}", std::process::id()))); if let Ok(backups) = sorted_corrupted_state_backups(path) { @@ -716,12 +719,30 @@ fn cleanup_test_state_artifacts(path: &Path) { } } +/// Recreates the on-disk shape of a store written before the witness journal +/// existed. Overwriting only the state image after `reset_for_tests` would be a +/// real rollback against an already-committed witness, not a migration fixture. +fn make_store_pre_witness_for_legacy_fixture(state_path: &Path) { + if let Ok(mut lock_slot) = state_file_lock_slot().lock() { + *lock_slot = None; + } + let _ = std::fs::remove_file(state_witness_file_path(state_path)); +} + +fn write_legacy_state_fixture(state_path: &Path, bytes: &[u8], label: &str) { + std::fs::write(state_path, bytes).unwrap_or_else(|error| panic!("{label}: {error}")); + #[cfg(unix)] + std::fs::set_permissions(state_path, std::fs::Permissions::from_mode(0o600)) + .unwrap_or_else(|error| panic!("secure {label} permissions: {error}")); +} + fn persisted_session_state_fixture() -> PersistedSessionState { PersistedSessionState { dkg_request_fingerprint: None, dkg_key_packages: None, dkg_public_key_package_hex: None, dkg_result: None, + dkg_share_epoch: 0, sign_request_fingerprint: None, sign_message_hex: None, round_state: None, @@ -758,6 +779,23 @@ fn expect_internal_error_contains(err: EngineError, expected_substring: &str) { ); } +fn expect_validation_error_contains(err: EngineError, expected_substring: &str) { + let EngineError::Validation(message) = err else { + panic!("unexpected error variant"); + }; + assert!( + message.contains(expected_substring), + "unexpected validation error message: {message}" + ); +} + +#[cfg(unix)] +fn write_witness_journal_fixture(witness_path: &Path, bytes: &[u8]) { + std::fs::write(witness_path, bytes).expect("write witness journal fixture"); + std::fs::set_permissions(witness_path, std::fs::Permissions::from_mode(0o600)) + .expect("secure witness journal fixture permissions"); +} + fn persist_state_for_key_provider_test(session_id: &str) -> Result<(), EngineError> { let mut guard = state()? .lock() @@ -1188,6 +1226,142 @@ fn persist_distributed_dkg_key_package_accumulates_seats_under_one_session() { assert!(session.dkg_public_key_package.is_some()); } +#[test] +fn retire_distributed_dkg_key_packages_removes_every_seat_and_is_idempotent() { + let _guard = lock_test_state(); + reset_for_tests(); + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(11); + let session_id = "session-distributed-retirement".to_string(); + let mut key_group = String::new(); + for participant_identifier in [1_u16, 2] { + let result = persist_distributed_dkg_key_package( + crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier, + threshold: 2, + participant_count: 3, + key_package: native_key_packages + .get(&participant_identifier) + .expect("local seat") + .clone(), + public_key_package: native_public.clone(), + }, + ) + .expect("persist distributed DKG seat"); + key_group = result.key_group; + } + + let retired = + retire_distributed_dkg_key_packages(crate::api::RetireDistributedDkgKeyPackagesRequest { + key_group: key_group.clone(), + }) + .expect("retire distributed DKG packages"); + assert_eq!(retired.key_group, key_group); + assert!(retired.retired); + assert_eq!(retired.retired_key_package_count, 2); + assert!(!state() + .expect("state") + .lock() + .expect("engine lock") + .sessions + .contains_key(&session_id)); + + let repeated = + retire_distributed_dkg_key_packages(crate::api::RetireDistributedDkgKeyPackagesRequest { + key_group: key_group.clone(), + }) + .expect("repeat distributed DKG retirement"); + assert_eq!(repeated.key_group, key_group); + assert!(!repeated.retired); + assert_eq!(repeated.retired_key_package_count, 0); +} + +#[test] +fn retire_distributed_dkg_key_packages_does_not_remove_another_group() { + let _guard = lock_test_state(); + reset_for_tests(); + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(13); + let session_id = "session-distributed-retirement-exact-match".to_string(); + let persisted = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("local seat").clone(), + public_key_package: native_public, + }) + .expect("persist distributed DKG seat"); + + let result = + retire_distributed_dkg_key_packages(crate::api::RetireDistributedDkgKeyPackagesRequest { + key_group: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .to_string(), + }) + .expect("unrelated retirement is an idempotent no-op"); + assert!(!result.retired); + assert_eq!(result.retired_key_package_count, 0); + let guard = state().expect("state").lock().expect("engine lock"); + assert_eq!( + guard.sessions[&session_id] + .dkg_result + .as_ref() + .expect("retained DKG result") + .key_group, + persisted.key_group + ); +} + +#[test] +fn retire_distributed_dkg_key_packages_pre_replace_failure_restores_owner() { + let _guard = lock_test_state(); + let _state_path = configure_test_state_path("distributed_dkg_retirement_rollback"); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(15); + let session_id = "session-distributed-retirement-rollback".to_string(); + let persisted = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("local seat").clone(), + public_key_package: native_public, + }) + .expect("persist distributed DKG seat"); + + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let error = + retire_distributed_dkg_key_packages(crate::api::RetireDistributedDkgKeyPackagesRequest { + key_group: persisted.key_group.clone(), + }) + .expect_err("pre-replacement retirement failure must roll back"); + clear_persist_fault_injection_for_tests(); + assert!(matches!( + error, + EngineError::Internal(ref message) if message.contains("injected persist fault") + )); + assert!(state() + .expect("state") + .lock() + .expect("engine lock") + .sessions + .contains_key(&session_id)); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + assert!(state() + .expect("state") + .lock() + .expect("engine lock") + .sessions + .contains_key(&session_id)); +} + #[test] fn persist_distributed_dkg_key_package_rejects_second_key_group_owner() { let _guard = lock_test_state(); @@ -2685,7 +2859,7 @@ fn completed_canary_rollback_retries_are_idempotent() { } #[test] -fn fresh_canary_rollback_noop_does_not_require_a_state_parent_directory() { +fn fresh_canary_rollback_noop_initializes_anchors_without_replacing_state() { let _guard = lock_test_state(); clear_state_storage_policy_overrides(); @@ -2698,11 +2872,24 @@ fn fresh_canary_rollback_noop_does_not_require_a_state_parent_directory() { let _ = std::fs::remove_dir(&missing_parent); std::env::set_var(TBTC_SIGNER_STATE_PATH_ENV, &state_path); reset_for_tests(); + if let Ok(mut lock_slot) = state_file_lock_slot().lock() { + *lock_slot = None; + } cleanup_test_state_artifacts(&state_path); let _ = std::fs::remove_dir(&missing_parent); assert!(!missing_parent.exists()); let status_before = canary_rollout_status().expect("fresh canary rollout status"); + assert!( + missing_parent.exists(), + "the first stateful access must initialize the descriptor-bound store" + ); + assert!(durable_store_id_file_path(&state_path).exists()); + assert!(state_witness_file_path(&state_path).exists()); + assert!( + !state_path.exists(), + "reading fresh state must not synthesize a state image" + ); let directory_syncs_before = state_file_parent_directory_syncs_for_tests(); let rollback = rollback_canary(RollbackCanaryRequest { reason: "fresh state no-op".to_string(), @@ -2719,11 +2906,11 @@ fn fresh_canary_rollback_noop_does_not_require_a_state_parent_directory() { assert_eq!( state_file_parent_directory_syncs_for_tests(), directory_syncs_before, - "an absent state file must not trigger a parent-directory sync" + "the no-op rollback must not perform a state-image replacement" ); assert!( - !missing_parent.exists(), - "a fresh-state no-op must not create persistence directories" + !state_path.exists(), + "a fresh-state no-op must not create a state image" ); std::env::remove_var(TBTC_SIGNER_STATE_PATH_ENV); @@ -4645,8 +4832,12 @@ fn persisted_engine_state_compacts_migrated_idle_entries_to_legacy_total_bound() let key_material = state_encryption_key_material().expect("test state key"); let oversized_envelope = encode_encrypted_state_envelope(&persisted, &key_material).expect("oversized envelope"); - std::fs::write(&state_path, oversized_envelope.as_slice()) - .expect("write intermediate oversized state"); + make_store_pre_witness_for_legacy_fixture(&state_path); + write_legacy_state_fixture( + &state_path, + oversized_envelope.as_slice(), + "write intermediate oversized state", + ); let reloaded = load_engine_state_from_storage().expect("load compacts and rewrites state"); assert_eq!(reloaded.sessions.len(), 2); @@ -5939,7 +6130,7 @@ fn dangling_state_file_symlink_fails_closed() { Err(err) => err, }; assert!( - matches!(err, EngineError::Internal(ref message) if message.contains("failed to read signer state file")), + matches!(err, EngineError::Internal(ref message) if message.contains("removed, replaced, linked, or redirected")), "unexpected error: {err:?}" ); assert!( @@ -5984,6 +6175,146 @@ fn corrupt_state_file_quarantines_and_resets_when_enabled() { clear_state_storage_policy_overrides(); } +#[test] +fn identity_preflight_preserves_configured_corruption_recovery() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("identity_preflight_corruption_recovery"); + reset_for_tests(); + + std::env::set_var( + TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV, + TBTC_SIGNER_STATE_CORRUPTION_POLICY_QUARANTINE_AND_RESET, + ); + std::fs::write(&state_path, b"{invalid-state-after-identity") + .expect("write corrupt state file"); + + let identity = + durable_store_identity().expect("identity preflight must remain structural and available"); + assert_ne!(identity.store_id, [0u8; 32]); + + let loaded = load_engine_state_from_storage() + .expect("state load after identity preflight must apply corruption policy"); + assert!(loaded.sessions.is_empty()); + assert!(!state_path.exists()); + let backups = + sorted_corrupted_state_backups(&state_path).expect("list corrupted state backups"); + assert_eq!(backups.len(), 1); + assert_eq!( + std::fs::read(&backups[0]).expect("read quarantined state"), + b"{invalid-state-after-identity" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn authenticated_state_rollback_fails_closed_even_when_corruption_reset_is_enabled() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("authenticated_rollback_fail_closed"); + reset_for_tests(); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + guard.refresh_epoch_counter = 1; + persist_engine_state_to_storage(&guard).expect("persist rollback candidate"); + } + let rolled_back_bytes = std::fs::read(&state_path).expect("read rollback candidate"); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + guard.refresh_epoch_counter = 2; + persist_engine_state_to_storage(&guard).expect("advance committed witness"); + } + simulate_process_restart_for_tests(); + std::fs::write(&state_path, &rolled_back_bytes).expect("restore valid old state image"); + std::env::set_var( + TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV, + TBTC_SIGNER_STATE_CORRUPTION_POLICY_QUARANTINE_AND_RESET, + ); + + let error = match load_engine_state_from_storage() { + Ok(_) => panic!("authenticated rollback evidence must not reset signer state"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("authenticated rollback evidence is always fail-closed"), + "unexpected rollback error: {error}" + ); + assert!( + state_path.exists(), + "rolled-back evidence was quarantined away" + ); + assert!( + sorted_corrupted_state_backups(&state_path) + .expect("enumerate corrupt-state backups") + .is_empty(), + "authenticated rollback was routed through generic corruption backup handling" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +#[cfg(unix)] +fn startup_validation_binds_the_exact_decoded_state_image() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("exact_loaded_image_validation"); + reset_for_tests(); + + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + guard.refresh_epoch_counter = 1; + persist_engine_state_to_storage(&guard).expect("persist rollback candidate"); + } + let rolled_back_bytes = std::fs::read(&state_path).expect("read rollback candidate"); + { + let mut guard = state().expect("engine state").lock().expect("engine lock"); + guard.refresh_epoch_counter = 2; + persist_engine_state_to_storage(&guard).expect("persist current state"); + } + let current_bytes = std::fs::read(&state_path).expect("read current state"); + + simulate_process_restart_for_tests(); + std::fs::write(&state_path, &rolled_back_bytes).expect("serve rollback candidate to loader"); + let loaded_image = + with_state_file_lock_for_load(|store| store.read_state_for_load()).expect("stable load"); + let loaded_bytes = loaded_image.bytes.as_ref().expect("loaded state bytes"); + let loaded_state: EngineState = match decode_persisted_state_storage_format(loaded_bytes) + .expect("old image remains a valid authenticated envelope") + { + PersistedStateStorageFormat::EncryptedEnvelope { persisted, .. } + | PersistedStateStorageFormat::LegacyPlaintext(persisted) => persisted + .try_into() + .expect("old image remains semantically valid"), + }; + assert_eq!(loaded_state.refresh_epoch_counter, 1); + + // Restore the current image before validation. A fresh descriptor reread + // now passes, reproducing the former time-of-check/time-of-use bypass. + std::fs::write(&state_path, ¤t_bytes).expect("restore current bytes before validation"); + with_state_file_lock_for_load(|store| store.validate_state_image()) + .expect("fresh reread sees the current committed image"); + + let error = with_state_file_lock_for_load(|store| { + store.validate_loaded_state_image(loaded_image.digest) + }) + .expect_err("the exact bytes supplied to the decoder must fail witness validation"); + expect_internal_error_contains( + error, + "signer state image does not match the committed witness tip", + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + #[test] fn empty_state_file_quarantines_and_resets_when_enabled() { let _guard = lock_test_state(); @@ -6125,8 +6456,17 @@ fn corrupt_state_backup_retention_evicts_old_backups() { std::env::set_var(TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV, "2"); for seed in 0..4 { + // Each fixture models a separate startup. Once a held store observes + // the state entry absent after quarantine, a file appearing there + // out-of-band is correctly treated as replacement. + if let Ok(mut lock_slot) = state_file_lock_slot().lock() { + *lock_slot = None; + } std::fs::write(&state_path, format!("{{invalid-state-{seed}")) .expect("write corrupt state"); + #[cfg(unix)] + std::fs::set_permissions(&state_path, std::fs::Permissions::from_mode(0o600)) + .expect("secure corrupt-state fixture permissions"); let loaded = load_engine_state_from_storage().expect("recover from corrupt state iteration"); assert!(loaded.sessions.is_empty()); @@ -6162,7 +6502,8 @@ fn legacy_plaintext_state_migrates_to_encrypted_envelope_on_load() { canary_rollout: CanaryRolloutState::default(), }; let plaintext_bytes = serde_json::to_vec(&plaintext_state).expect("encode plaintext state"); - std::fs::write(&state_path, &plaintext_bytes).expect("write plaintext state file"); + make_store_pre_witness_for_legacy_fixture(&state_path); + write_legacy_state_fixture(&state_path, &plaintext_bytes, "write plaintext state file"); // Without the opt-in rollback flag the unauthenticated plaintext is refused // (fail-closed), even in a non-production profile. @@ -6231,11 +6572,12 @@ fn legacy_v2_encrypted_state_rewrites_with_current_key_id() { }; ciphertext_and_tag.zeroize(); authentication_tag.zeroize(); - std::fs::write( + make_store_pre_witness_for_legacy_fixture(&state_path); + write_legacy_state_fixture( &state_path, - serde_json::to_vec(&envelope).expect("encode legacy v2 envelope"), - ) - .expect("write legacy v2 envelope"); + &serde_json::to_vec(&envelope).expect("encode legacy v2 envelope"), + "write legacy v2 envelope", + ); let loaded = load_engine_state_from_storage().expect("load legacy v2 envelope"); assert_eq!(loaded.refresh_epoch_counter, 11); @@ -13730,47 +14072,27 @@ fn verify_signature_share_verdicts_match_aggregate_and_handle_edges() { ); } -// Script-path (tweaked-root) companion to the equivalence test above: the -// None-root case pins parity on the untweaked path; this pins it on the taproot -// tweak path, where the even-Y/tweak machinery is exercised. Shares are produced -// with sign_with_tweak (== sign under a taproot-tweaked key package, the -// production taproot signing path), and verify_signature_share / aggregate are -// driven with Some(root). The clinching assertion is that the SAME tweaked share -// is Invalid under None: the root must be materially applied, not ignored. #[test] -fn verify_signature_share_tweaked_root_matches_aggregate() { +fn verify_signature_share_is_read_only_across_ttl_and_pending_repair() { use crate::api::ShareVerificationVerdict; let _guard = lock_test_state(); + let state_path = configure_test_state_path("verify_share_read_only"); reset_for_tests(); - let session_id = "verify-share-tweaked-session"; + let session_id = "verify-share-read-only-session"; let key_group = "interactive-test-key-group"; - let message = [0x55u8; 32]; + let message = [0x79u8; 32]; let included = [1u16, 2]; let key_packages = ensure_interactive_dkg_session(session_id, key_group); - - let taproot_merkle_root = [0x11u8; 32]; - let taproot_merkle_root_hex = hex::encode(taproot_merkle_root); - let opened = interactive_session_open(InteractiveSessionOpenRequest { - session_id: session_id.to_string(), - member_identifier: 1, - message_hex: hex::encode(message), - key_group: key_group.to_string(), - threshold: 2, - taproot_merkle_root_hex: Some(taproot_merkle_root_hex.clone()), - signing_intent: None, - attempt_context: interactive_test_attempt_context( - session_id, key_group, &message, &included, 1, - ), - }) - .expect("opens with the tweaked root"); - let member1 = interactive_round1(InteractiveRound1Request { + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("open interactive attempt"); + let round1 = interactive_round1(InteractiveRound1Request { session_id: session_id.to_string(), - attempt_id: opened.attempt_id.clone(), + attempt_id: opened.attempt_id, member_identifier: 1, }) - .expect("member 1 interactive nonces"); + .expect("round 1"); let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { key_package_identifier: key_packages[&2].identifier.clone(), key_package_hex: key_packages[&2].data_hex.clone(), @@ -13781,37 +14103,441 @@ fn verify_signature_share_tweaked_root_matches_aggregate() { vec![ NativeFrostCommitment { identifier: key_packages[&1].identifier.clone(), - data_hex: member1.commitments_hex.clone(), + data_hex: round1.commitments_hex, }, - member2.commitment.clone(), + member2.commitment, ], ); + let member2_share = sign_share(SignShareRequest { + signing_package_hex: signing_package_hex.clone(), + nonces_hex: member2.nonces_hex, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 share"); - // Produce a TWEAKED round-2 share: sign_with_tweak signs under a - // taproot-tweaked key package, exactly the production taproot signing path. - let sign_tweaked = |key_package_hex: &str, nonces_hex: &str, package_hex: &str| -> String { - let key_package = frost::keys::KeyPackage::deserialize( - &hex::decode(key_package_hex).expect("key package hex"), - ) - .expect("key package"); - let nonces = frost::round1::SigningNonces::deserialize( - &hex::decode(nonces_hex).expect("nonces hex"), - ) - .expect("nonces"); - let package = - frost::SigningPackage::deserialize(&hex::decode(package_hex).expect("package hex")) - .expect("signing package"); - let share = frost::round2::sign_with_tweak( - &package, - &nonces, - &key_package, - Some(taproot_merkle_root.as_slice()), - ) - .expect("sign_with_tweak"); - hex::encode(share.serialize()) - }; + advance_interactive_clock_for_tests(interactive_session_ttl_seconds().saturating_add(1)); + mark_persistence_pending(PersistencePendingOperation::InteractiveState { + session_id: session_id.to_string(), + }); + let tip_before = state_witness_tip().expect("tip before verify"); + let state_before = std::fs::read(&state_path).expect("state image before verify"); + let witness_before = + std::fs::read(state_witness_file_path(&state_path)).expect("witness before verify"); + assert!(interactive_state_persistence_pending()); - let share1 = interactive_round2(InteractiveRound2Request { + let result = verify_signature_share(crate::api::VerifySignatureShareRequest { + session_id: session_id.to_string(), + signing_package_hex, + signature_share_hex: member2_share.signature_share.data_hex, + member_identifier: 2, + taproot_merkle_root_hex: None, + }) + .expect("delayed share verification"); + assert_eq!(result.verdict, ShareVerificationVerdict::Valid); + assert_eq!( + state_witness_tip().expect("tip after verify"), + tip_before, + "read-only verification must not advance the durable witness" + ); + assert_eq!( + std::fs::read(&state_path).expect("state image after verify"), + state_before, + "read-only verification must not rewrite signer state" + ); + assert_eq!( + std::fs::read(state_witness_file_path(&state_path)).expect("witness after verify"), + witness_before, + "read-only verification must not rewrite the witness journal" + ); + assert!( + interactive_state_persistence_pending(), + "verification must not consume a pending mutation repair" + ); + { + let guard = state().expect("state").lock().expect("lock"); + assert!( + !guard.sessions[session_id].interactive_signing.is_empty(), + "verification must not sweep expired nonce state" + ); + } + + interactive_session_abort(InteractiveSessionAbortRequest { + session_id: "verify-share-read-only-unrelated".to_string(), + attempt_id: None, + }) + .expect("next mutating endpoint repairs and sweeps"); + assert!( + !interactive_state_persistence_pending(), + "mutating endpoint must durably clear the pending repair" + ); + assert_ne!( + state_witness_tip().expect("tip after mutating sweep"), + tip_before, + "durable sweep must advance the witness before returning" + ); + let guard = state().expect("state").lock().expect("lock"); + assert!( + guard.sessions[session_id].interactive_signing.is_empty(), + "next mutating endpoint must sweep expired nonce state" + ); + drop(guard); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn interactive_anchored_calls_advance_at_most_three_generations_with_repairs() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("interactive_generation_bound"); + reset_for_tests(); + + let key_group = "generation-bound-key-group"; + let wallet_session = "generation-bound-wallet"; + let included = [1u16, 2]; + let current_generation = || { + state_witness_tip() + .expect("current witness tip") + .generation + .parse::() + .expect("canonical generation") + }; + let assert_bound = |label: &str, before: u64| { + let after = current_generation(); + assert!( + after >= before && after - before <= 3, + "{label} advanced {} generations; anchored-call bound is 3", + after.saturating_sub(before) + ); + }; + let open_bound = |session_id: &str, message: [u8; 32]| { + interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + session_id, key_group, &message, &included, 1, + ), + }) + .expect("bound session opens") + }; + let seed_expired_pending = |session_id: &str, message: [u8; 32]| { + let opened = open_bound(session_id, message); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id, + member_identifier: 1, + }) + .expect("expired fixture reaches round 1"); + { + let mut guard = state().expect("state").lock().expect("lock"); + let stale = interactive_now() + .checked_sub(Duration::from_secs( + interactive_session_ttl_seconds().saturating_add(1), + )) + .expect("interactive clock supports stale instant"); + for interactive in guard + .sessions + .get_mut(session_id) + .expect("expired fixture session") + .interactive_signing + .values_mut() + { + interactive.last_activity_at = stale; + } + } + mark_persistence_pending(PersistencePendingOperation::InteractiveState { + session_id: session_id.to_string(), + }); + }; + let mark_nonmatching_repairs = |suffix: &str| { + mark_persistence_pending(PersistencePendingOperation::InteractiveRound2 { + session_id: format!("missing-round2-{suffix}"), + consumed_marker: format!("missing-marker-{suffix}"), + }); + mark_persistence_pending(PersistencePendingOperation::InteractiveAggregate { + session_id: format!("missing-aggregate-{suffix}"), + aggregated_marker: format!("missing-aggregate-marker-{suffix}"), + }); + }; + + // Open: expiry repair (the sweep snapshot also covers protected + // retirement) + Open's binding snapshot stays within the three-generation + // hard bound even if a prior prepared witness must first be reconciled. + ensure_interactive_dkg_session(wallet_session, key_group); + seed_expired_pending("generation-open-expired", [0x31; 32]); + mark_nonmatching_repairs("open"); + let open_request = InteractiveSessionOpenRequest { + session_id: "generation-open-target".to_string(), + member_identifier: 1, + message_hex: hex::encode([0x32; 32]), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + signing_intent: None, + attempt_context: interactive_test_attempt_context( + "generation-open-target", + key_group, + &[0x32; 32], + &included, + 1, + ), + }; + let before = current_generation(); + interactive_session_open(open_request).expect("bounded Open"); + assert_bound("Open", before); + + reset_for_tests(); + ensure_interactive_dkg_session(wallet_session, key_group); + let round1_target = open_bound("generation-round1-target", [0x41; 32]); + seed_expired_pending("generation-round1-expired", [0x42; 32]); + mark_nonmatching_repairs("round1"); + let before = current_generation(); + interactive_round1(InteractiveRound1Request { + session_id: "generation-round1-target".to_string(), + attempt_id: round1_target.attempt_id, + member_identifier: 1, + }) + .expect("bounded Round1"); + assert_bound("Round1", before); + + reset_for_tests(); + let key_packages = ensure_interactive_dkg_session(wallet_session, key_group); + let round2_target = open_bound("generation-round2-target", [0x51; 32]); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: "generation-round2-target".to_string(), + attempt_id: round2_target.attempt_id.clone(), + member_identifier: 1, + }) + .expect("Round2 fixture round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("Round2 fixture member 2"); + let signing_package_hex = interactive_package_for_test( + &[0x51; 32], + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + let round2_request = InteractiveRound2Request { + session_id: "generation-round2-target".to_string(), + attempt_id: round2_target.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }; + seed_expired_pending("generation-round2-expired", [0x52; 32]); + mark_nonmatching_repairs("round2"); + let before = current_generation(); + interactive_round2(round2_request.clone()).expect("bounded Round2"); + assert_bound("Round2", before); + + // Matching Round2 repair is covered by the sweep snapshot and cannot add + // a write beyond the three-generation bound; the replay then fails closed. + let consumed_marker = interactive_consumed_marker(&round2_target.attempt_id, 1); + mark_persistence_pending(PersistencePendingOperation::InteractiveRound2 { + session_id: round2_request.session_id.clone(), + consumed_marker, + }); + seed_expired_pending("generation-round2-match-expired", [0x53; 32]); + let before = current_generation(); + let replay = interactive_round2(round2_request) + .expect_err("matching Round2 repair reaches consumed replay gate"); + assert!(matches!(replay, EngineError::ConsumedNonceReplay { .. })); + assert_bound("Round2 matching repair", before); + + reset_for_tests(); + let key_packages = ensure_interactive_dkg_session(wallet_session, key_group); + let aggregate_target = open_bound("generation-aggregate-target", [0x61; 32]); + let aggregate_round1 = interactive_round1(InteractiveRound1Request { + session_id: "generation-aggregate-target".to_string(), + attempt_id: aggregate_target.attempt_id.clone(), + member_identifier: 1, + }) + .expect("Aggregate fixture round 1"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("Aggregate fixture member 2"); + let member2_nonces = member2.nonces_hex.clone(); + let aggregate_package = interactive_package_for_test( + &[0x61; 32], + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: aggregate_round1.commitments_hex, + }, + member2.commitment, + ], + ); + let member1_share = interactive_round2(InteractiveRound2Request { + session_id: "generation-aggregate-target".to_string(), + attempt_id: aggregate_target.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: aggregate_package.clone(), + }) + .expect("Aggregate fixture member 1 share"); + let member2_share = sign_share(SignShareRequest { + signing_package_hex: aggregate_package.clone(), + nonces_hex: member2_nonces, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("Aggregate fixture member 2 share"); + let aggregate_request = InteractiveAggregateRequest { + session_id: "generation-aggregate-target".to_string(), + attempt_id: aggregate_target.attempt_id.clone(), + signing_package_hex: aggregate_package, + signature_shares: vec![ + NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: member1_share.signature_share_hex, + }, + member2_share.signature_share, + ], + taproot_merkle_root_hex: None, + }; + seed_expired_pending("generation-aggregate-expired", [0x62; 32]); + mark_nonmatching_repairs("aggregate"); + let before = current_generation(); + interactive_aggregate(aggregate_request.clone()).expect("bounded Aggregate"); + assert_bound("Aggregate", before); + + let aggregated_marker = + interactive_aggregated_marker(&aggregate_target.attempt_id, &hash_hex(&[0x61; 32]), None); + mark_persistence_pending(PersistencePendingOperation::InteractiveAggregate { + session_id: aggregate_request.session_id.clone(), + aggregated_marker, + }); + seed_expired_pending("generation-aggregate-match-expired", [0x63; 32]); + let before = current_generation(); + let replay = interactive_aggregate(aggregate_request) + .expect_err("matching Aggregate repair reaches completed replay gate"); + assert!(matches!( + replay, + EngineError::InteractiveAttemptAlreadyAggregated { .. } + )); + assert_bound("Aggregate matching repair", before); + + reset_for_tests(); + ensure_interactive_dkg_session(wallet_session, key_group); + let abort_target = open_bound("generation-abort-target", [0x71; 32]); + interactive_round1(InteractiveRound1Request { + session_id: "generation-abort-target".to_string(), + attempt_id: abort_target.attempt_id.clone(), + member_identifier: 1, + }) + .expect("Abort fixture round 1"); + seed_expired_pending("generation-abort-expired", [0x72; 32]); + mark_nonmatching_repairs("abort"); + let before = current_generation(); + let aborted = interactive_session_abort(InteractiveSessionAbortRequest { + session_id: "generation-abort-target".to_string(), + attempt_id: Some(abort_target.attempt_id), + }) + .expect("bounded Abort"); + assert!(aborted.aborted); + assert_bound("Abort", before); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +// Script-path (tweaked-root) companion to the equivalence test above: the +// None-root case pins parity on the untweaked path; this pins it on the taproot +// tweak path, where the even-Y/tweak machinery is exercised. Shares are produced +// with sign_with_tweak (== sign under a taproot-tweaked key package, the +// production taproot signing path), and verify_signature_share / aggregate are +// driven with Some(root). The clinching assertion is that the SAME tweaked share +// is Invalid under None: the root must be materially applied, not ignored. +#[test] +fn verify_signature_share_tweaked_root_matches_aggregate() { + use crate::api::ShareVerificationVerdict; + + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "verify-share-tweaked-session"; + let key_group = "interactive-test-key-group"; + let message = [0x55u8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + + let taproot_merkle_root = [0x11u8; 32]; + let taproot_merkle_root_hex = hex::encode(taproot_merkle_root); + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: Some(taproot_merkle_root_hex.clone()), + signing_intent: None, + attempt_context: interactive_test_attempt_context( + session_id, key_group, &message, &included, 1, + ), + }) + .expect("opens with the tweaked root"); + let member1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 interactive nonces"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: member1.commitments_hex.clone(), + }, + member2.commitment.clone(), + ], + ); + + // Produce a TWEAKED round-2 share: sign_with_tweak signs under a + // taproot-tweaked key package, exactly the production taproot signing path. + let sign_tweaked = |key_package_hex: &str, nonces_hex: &str, package_hex: &str| -> String { + let key_package = frost::keys::KeyPackage::deserialize( + &hex::decode(key_package_hex).expect("key package hex"), + ) + .expect("key package"); + let nonces = frost::round1::SigningNonces::deserialize( + &hex::decode(nonces_hex).expect("nonces hex"), + ) + .expect("nonces"); + let package = + frost::SigningPackage::deserialize(&hex::decode(package_hex).expect("package hex")) + .expect("signing package"); + let share = frost::round2::sign_with_tweak( + &package, + &nonces, + &key_package, + Some(taproot_merkle_root.as_slice()), + ) + .expect("sign_with_tweak"); + hex::encode(share.serialize()) + }; + + let share1 = interactive_round2(InteractiveRound2Request { session_id: session_id.to_string(), attempt_id: opened.attempt_id.clone(), member_identifier: 1, @@ -14003,3 +14729,305 @@ fn enforce_provenance_gate_rejects_signed_attestation_runtime_version_mismatch() clear_state_storage_policy_overrides(); } + +#[test] +#[cfg(unix)] +fn durable_store_identity_survives_restart_and_atomic_state_replacement() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("durable_store_identity_restart"); + + let mut first = StateFileLock::acquire(&state_path).expect("open first durable store"); + let first_identity = first.identity().expect("read first identity"); + assert_ne!(first_identity.store_id, [0u8; 32]); + first + .replace_state(b"first atomic state image") + .expect("first replacement"); + assert_eq!( + first.identity().expect("identity after first write"), + first_identity + ); + first + .replace_state(b"second atomic state image") + .expect("second replacement"); + assert_eq!( + first.identity().expect("identity after second write"), + first_identity + ); + drop(first); + + let mut restarted = StateFileLock::acquire(&state_path).expect("reopen durable store"); + assert_eq!( + restarted.identity().expect("restarted identity"), + first_identity + ); + assert_eq!( + restarted.read_state().expect("restarted state"), + Some(b"second atomic state image".to_vec()) + ); + drop(restarted); + + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn durable_store_fingerprint_vectors_pin_v2_and_retired_v1_transcripts() { + assert_eq!( + hex::encode(durable_store_fingerprint(&[0x11; 32])), + "8bb8d21c69e78916e8f165b0c861c0d84c5d7af5393f75b0321fe048f772abba" + ); + assert_eq!( + hex::encode(durable_store_fingerprint_v1( + &[0x11; 32], + &[0x22; 32], + &[0x33; 32], + &[0x44; 32], + )), + "4d6aae1b6ac64f36ddc58d43ea89bc8eaa5bcece511f53d631d662392313f6c9" + ); +} + +#[test] +#[cfg(unix)] +fn pending_commit_refuses_to_absorb_same_length_prefix_corruption() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("witness_pending_prefix_corruption"); + clear_persist_fault_injection_for_tests(); + + let mut store = StateFileLock::acquire(&state_path).expect("open durable store"); + store + .replace_state(b"baseline state") + .expect("persist baseline"); + let baseline = store.state_witness_tip().expect("baseline tip"); + + set_persist_fault_injection_for_tests( + PersistFaultInjectionPoint::AfterRenameBeforeDirectorySync, + ); + let interrupted = store + .replace_state(b"prepared replacement") + .expect_err("stop after PREPARE and rename"); + assert!(interrupted.replaced()); + clear_persist_fault_injection_for_tests(); + + let witness_path = state_witness_file_path(&state_path); + let prepared_journal = std::fs::read(&witness_path).expect("prepared journal"); + let prepared_length = prepared_journal.len(); + let mut corrupted = prepared_journal.clone(); + let old_commitment_offset = + TBTC_SIGNER_STATE_WITNESS_HEADER_LENGTH + TBTC_SIGNER_STATE_WITNESS_RECORD_LENGTH + 80; + corrupted[old_commitment_offset] ^= 0x80; + write_witness_journal_fixture(&witness_path, &corrupted); + + expect_internal_error_contains( + store + .state_witness_tip() + .expect_err("COMMIT must verify the complete pre-append prefix"), + "invalid commitment", + ); + assert_eq!( + std::fs::metadata(&witness_path) + .expect("corrupted journal metadata") + .len() as usize, + prepared_length, + "a failed verification must not append COMMIT" + ); + assert_eq!( + std::fs::read(&witness_path).expect("corrupted journal after rejection"), + corrupted, + "the rejection must not rewrite attacker-visible evidence" + ); + + write_witness_journal_fixture(&witness_path, &prepared_journal); + let recovered = store + .state_witness_tip() + .expect("restored PREPARE reconciles"); + assert_eq!(recovered.generation, baseline.generation + 1); + assert_eq!( + store.read_state().expect("prepared replacement state"), + Some(b"prepared replacement".to_vec()) + ); + drop(store); + + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +#[cfg(unix)] +fn state_witness_record_ceiling_fails_closed_before_prepare_and_on_restart() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("witness_record_ceiling"); + std::env::set_var(TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, "4"); + + let mut store = StateFileLock::acquire(&state_path).expect("open capped durable store"); + store + .replace_state(b"only permitted replacement") + .expect("fill the record budget"); + let full_tip = store.state_witness_tip().expect("tip at record ceiling"); + assert_eq!(full_tip.generation, 2); + + let rejected = store + .replace_state(b"must not be installed") + .expect_err("a new PREPARE must reserve its terminal record"); + assert!(!rejected.replaced()); + expect_internal_error_contains(rejected.into_engine_error(), "record ceiling [4] reached"); + assert_eq!( + store.read_state().expect("state after ceiling rejection"), + Some(b"only permitted replacement".to_vec()) + ); + drop(store); + + std::env::set_var(TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, "2"); + expect_internal_error_contains( + match StateFileLock::acquire(&state_path) { + Ok(_) => panic!("startup must reject a journal over the configured ceiling"), + Err(error) => error, + }, + "exceeding the configured fail-closed ceiling [2]", + ); + + std::env::set_var(TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, "4"); + let mut reopened = StateFileLock::acquire(&state_path).expect("reopen with adequate ceiling"); + assert_eq!( + reopened.state_witness_tip().expect("reopened tip"), + full_tip + ); + drop(reopened); + + std::env::set_var(TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS_ENV, "1"); + expect_validation_error_contains( + state_witness_max_records().expect_err("genesis requires two records"), + "must be between 2 and 1000000", + ); + + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +#[cfg(unix)] +fn state_witness_verification_cost_stays_constant_as_history_grows() { + const PERSISTS: usize = 24; + + let _guard = lock_test_state(); + let state_path = configure_test_state_path("witness_incremental_cost"); + reset_witness_verification_counters(); + + let mut store = StateFileLock::acquire(&state_path).expect("open durable store"); + let (full_after_open, _, bytes_after_open) = witness_verification_counters(); + assert_eq!(full_after_open, 1); + for index in 0..PERSISTS { + store + .replace_state(format!("incremental image {index}").as_bytes()) + .expect("persist through durable store"); + } + + let (full, incremental, bytes_read) = witness_verification_counters(); + assert_eq!( + full, 1, + "steady-state writes must not reparse historical records" + ); + assert!(incremental >= (PERSISTS * 4) as u64); + let bytes_per_persist = (bytes_read - bytes_after_open) / PERSISTS as u64; + assert!( + bytes_per_persist < 2_048, + "verification must remain O(1), got {bytes_per_persist} bytes per persist" + ); + let journal_length = std::fs::metadata(state_witness_file_path(&state_path)) + .expect("journal metadata") + .len(); + assert!( + bytes_per_persist < journal_length, + "constant verification reads must stay below the growing journal" + ); + drop(store); + + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +#[cfg(unix)] +fn retained_inventory_is_public_sorted_and_bound_to_the_witness_tip() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("retained_inventory"); + reset_for_tests(); + + let baseline = retained_key_package_inventory().expect("baseline inventory"); + assert!(baseline.entries.is_empty()); + assert_eq!( + baseline.schema, + TBTC_SIGNER_RETAINED_KEY_PACKAGE_INVENTORY_SCHEMA + ); + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(61); + for seat in [3u16, 1u16] { + persist_distributed_dkg_key_package(PersistDistributedDkgKeyPackageRequest { + session_id: "inventory-wallet".to_string(), + participant_identifier: seat, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&seat).expect("local seat").clone(), + public_key_package: native_public.clone(), + }) + .expect("persist local DKG seat"); + } + + let inventory = retained_key_package_inventory().expect("retained inventory"); + assert!(inventory.state_generation > baseline.state_generation); + assert_ne!(inventory.state_commitment, baseline.state_commitment); + assert_eq!(inventory.entries.len(), 1); + let entry = &inventory.entries[0]; + assert_eq!(entry.wallet_id, format!("0x{}", &entry.key_group[2..])); + assert_eq!(entry.threshold, 2); + assert_eq!(entry.participant_count, 3); + assert_eq!(entry.share_epoch, 0); + assert_eq!( + entry + .key_packages + .iter() + .map(|package| package.participant_seat) + .collect::>(), + vec![1, 3] + ); + + let inventory_json = serde_json::to_string(&inventory).expect("inventory JSON"); + for package in native_key_packages.values() { + assert!( + !inventory_json.contains(package.data_hex.expose_secret()), + "the public inventory must not expose a serialized secret key package" + ); + } + + { + let engine = state().expect("engine state"); + let mut guard = engine.lock().expect("engine lock"); + guard.operator_fault_scores.insert(65000, 1); + persist_engine_state_to_storage(&guard).expect("persist unrelated durable state"); + } + let advanced = retained_key_package_inventory().expect("advanced inventory"); + assert!(advanced.state_generation > inventory.state_generation); + assert_eq!( + advanced.inventory_commitment, + inventory.inventory_commitment + ); + assert_eq!(advanced.entries, inventory.entries); + + { + let engine = state().expect("engine state"); + let mut guard = engine.lock().expect("engine lock"); + guard + .sessions + .get_mut("inventory-wallet") + .expect("wallet session") + .dkg_share_epoch = 1; + } + expect_internal_error_contains( + retained_key_package_inventory().expect_err("unsupported nonzero epoch must fail closed"), + "unsupported key-package share epoch", + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} diff --git a/pkg/tbtc/signer/src/engine/verify_share.rs b/pkg/tbtc/signer/src/engine/verify_share.rs index a62bcd97ec..a0eb427757 100644 --- a/pkg/tbtc/signer/src/engine/verify_share.rs +++ b/pkg/tbtc/signer/src/engine/verify_share.rs @@ -81,19 +81,18 @@ pub fn verify_signature_share( // never the request - mirroring InteractiveAggregate. A missing session or // incomplete DKG is not the member's fault -> indeterminate. let public_key_package = { - let mut guard = state()? + let guard = state()? .lock() .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; - // Verify-share takes the engine lock like every other interactive entry - // point, so it sweeps expired interactive state too: the nonce-TTL - // guarantee (an abandoned interactive nonce handle gone within the TTL of - // inactivity) must hold even when the only post-expiry traffic is - // verify-share blame rechecks. Mirrors InteractiveAggregate. - sweep_expired_interactive_state_durably(&mut guard)?; - // The public key package is a WALLET-level asset resolved by key_group, so a - // per-signing session (a distinct RoastSessionID) can be blame-checked. The - // key_group is this signing session's own DKG (co-located) or the one bound at - // Open; a missing session/binding/DKG is not the member's fault -> indeterminate. + // Delayed blame verification is intentionally read-only and remains + // available after the interactive nonce TTL. It resolves only public, + // wallet-scoped DKG material and must not sweep, retire, repair, or + // persist any interactive state. The next nonce-bearing/mutating + // endpoint performs the durable TTL sweep before secret release. + // + // The key_group is this signing session's own DKG (co-located) or the + // one bound at Open; a missing session/binding/DKG is not the member's + // fault -> indeterminate. let key_group = match guard.sessions.get(&request.session_id) { Some(session) => session .dkg_result diff --git a/pkg/tbtc/signer/src/errors.rs b/pkg/tbtc/signer/src/errors.rs index e82e333162..7c990334c9 100644 --- a/pkg/tbtc/signer/src/errors.rs +++ b/pkg/tbtc/signer/src/errors.rs @@ -1,5 +1,20 @@ use thiserror::Error; +#[derive(Debug)] +pub struct StateAnchorTrustRecoveryContext { + pub store_fingerprint: [u8; 32], + pub certificate_sequences: Vec, + pub certificate_digests: Vec<[u8; 32]>, + pub target_binding_hash: [u8; 32], + pub target_service_epoch: u64, + pub target_revision: u64, + pub target_checkpoint_store_fingerprint: [u8; 32], + pub target_checkpoint_generation: u64, + pub target_checkpoint_previous_state_commitment: [u8; 32], + pub target_checkpoint_state_image_digest: [u8; 32], + pub target_checkpoint_state_commitment: [u8; 32], +} + #[derive(Debug, Error)] pub enum EngineError { #[error("validation failed: {0}")] @@ -91,6 +106,29 @@ pub enum EngineError { attempt_id: String, candidate_culprits: Vec, }, + /// The requested witness ancestor predates the independently acknowledged + /// retained base. It cannot be recovered by retrying this signer; the host + /// must use its independent checkpoint/anchor evidence. + #[error( + "state witness history pruned: requested generation [{requested_generation}] precedes retained base [{witness_base_generation}]" + )] + HistoryPruned { + requested_generation: u64, + witness_base_generation: u64, + }, + /// No offline-certified state-anchor trust head has been committed for the + /// durable signer store. This is distinct from malformed or corrupt trust + /// state: the operator must complete bootstrap/adoption before retrying a + /// trust-head-dependent operation. + #[error("state-anchor trust head is absent")] + StateAnchorTrustHeadAbsent, + /// A durable transition intent exists, but local replayable bytes are not + /// freshness authority. The host must select the exact configured + /// certificate chain and resubmit it with a newly signed Read wrapper. + #[error("state-anchor trust recovery requires a fresh signed Read")] + StateAnchorTrustRecoveryRequired { + context: Box, + }, #[error("internal error: {0}")] Internal(String), } @@ -115,6 +153,9 @@ impl EngineError { "interactive_attempt_already_aggregated" } Self::AggregateShareVerificationFailed { .. } => "aggregate_share_verification_failed", + Self::HistoryPruned { .. } => "history_pruned", + Self::StateAnchorTrustHeadAbsent => "state_anchor_trust_head_absent", + Self::StateAnchorTrustRecoveryRequired { .. } => "state_anchor_trust_recovery_required", Self::Internal(_) => "internal_error", } } @@ -145,6 +186,9 @@ impl EngineError { // produce a signature, so this is recoverable: the caller mints a // new attempt after the Go host adjudicates blame. Self::AggregateShareVerificationFailed { .. } => "recoverable", + Self::HistoryPruned { .. } => "terminal", + Self::StateAnchorTrustHeadAbsent => "terminal", + Self::StateAnchorTrustRecoveryRequired { .. } => "recoverable", Self::SessionFinalized { .. } => "terminal", Self::SessionNotFound { .. } => "terminal", Self::Internal(_) => "terminal", @@ -163,6 +207,13 @@ impl EngineError { _ => &[], } } + + pub fn state_anchor_trust_recovery(&self) -> Option<&StateAnchorTrustRecoveryContext> { + match self { + Self::StateAnchorTrustRecoveryRequired { context } => Some(context), + _ => None, + } + } } #[cfg(test)] @@ -224,6 +275,13 @@ mod tests { EngineError::Internal("panic".to_string()).recovery_class(), "terminal" ); + let absent_trust_head = EngineError::StateAnchorTrustHeadAbsent; + assert_eq!(absent_trust_head.code(), "state_anchor_trust_head_absent"); + assert_eq!(absent_trust_head.recovery_class(), "terminal"); + assert_eq!( + absent_trust_head.to_string(), + "state-anchor trust head is absent" + ); let unsupported_refresh = EngineError::CryptographicRefreshNotSupported { session_id: "session-refresh".to_string(), }; diff --git a/pkg/tbtc/signer/src/ffi.rs b/pkg/tbtc/signer/src/ffi.rs index ac9567d078..24573def48 100644 --- a/pkg/tbtc/signer/src/ffi.rs +++ b/pkg/tbtc/signer/src/ffi.rs @@ -4,7 +4,7 @@ use serde::de::DeserializeOwned; use zeroize::Zeroize; -use crate::api::ErrorResponse; +use crate::api::{ErrorResponse, StateAnchorTrustCheckpoint, StateAnchorTrustRecoveryRequired}; use crate::errors::EngineError; #[repr(C)] @@ -133,11 +133,71 @@ pub fn free_buffer(ptr: *mut u8, len: usize) { } fn error_result(error: EngineError) -> TbtcSignerResult { + let (requested_generation, witness_base_generation) = match &error { + EngineError::HistoryPruned { + requested_generation, + witness_base_generation, + } => (Some(*requested_generation), Some(*witness_base_generation)), + _ => (None, None), + }; + let state_anchor_trust_recovery = error.state_anchor_trust_recovery().map(|context| { + let bytes32 = |value: [u8; 32]| format!("0x{}", hex::encode(value)); + let certificate_count = context.certificate_digests.len(); + debug_assert_eq!( + certificate_count, + context.certificate_sequences.len(), + "verified recovery selector vectors remain aligned" + ); + StateAnchorTrustRecoveryRequired { + schema: "tbtc-signer-state-anchor-trust-recovery-required/v1".to_string(), + store_fingerprint: bytes32(context.store_fingerprint), + certificate_count: certificate_count.to_string(), + first_certificate_sequence: context + .certificate_sequences + .first() + .copied() + .unwrap_or_default() + .to_string(), + ordered_certificate_digests: context + .certificate_digests + .iter() + .copied() + .map(bytes32) + .collect(), + final_certificate_sequence: context + .certificate_sequences + .last() + .copied() + .unwrap_or_default() + .to_string(), + final_certificate_digest: context + .certificate_digests + .last() + .copied() + .map(bytes32) + .unwrap_or_else(|| bytes32([0u8; 32])), + target_binding_hash: bytes32(context.target_binding_hash), + target_service_epoch: context.target_service_epoch.to_string(), + target_revision: context.target_revision.to_string(), + target_checkpoint: StateAnchorTrustCheckpoint { + store_fingerprint: bytes32(context.target_checkpoint_store_fingerprint), + generation: context.target_checkpoint_generation.to_string(), + previous_state_commitment: bytes32( + context.target_checkpoint_previous_state_commitment, + ), + state_image_digest: bytes32(context.target_checkpoint_state_image_digest), + state_commitment: bytes32(context.target_checkpoint_state_commitment), + }, + } + }); let payload = ErrorResponse { code: error.code().to_string(), message: error.to_string(), recovery_class: error.recovery_class().to_string(), + requested_generation, + witness_base_generation, candidate_culprits: error.candidate_culprits().to_vec(), + state_anchor_trust_recovery, }; let bytes = serde_json::to_vec(&payload).unwrap_or_else(|_| { @@ -236,6 +296,22 @@ mod tests { ); } + #[test] + fn history_pruned_error_exposes_machine_readable_generations() { + let result = error_result(EngineError::HistoryPruned { + requested_generation: 41, + witness_base_generation: 42, + }); + assert_eq!(result.status_code, STATUS_ERROR); + let bytes = unsafe { std::slice::from_raw_parts(result.buffer.ptr, result.buffer.len) }; + let response: ErrorResponse = + serde_json::from_slice(bytes).expect("decode history-pruned error"); + assert_eq!(response.code, "history_pruned"); + assert_eq!(response.requested_generation, Some(41)); + assert_eq!(response.witness_base_generation, Some(42)); + free_buffer(result.buffer.ptr, result.buffer.len); + } + // A panic payload may carry internal detail (paths, config). It must be // withheld from the host in production and only surfaced under the // development profile. Serialized under the shared test-state lock because diff --git a/pkg/tbtc/signer/src/lib.rs b/pkg/tbtc/signer/src/lib.rs index cc90f231c3..ca4ac3aed0 100644 --- a/pkg/tbtc/signer/src/lib.rs +++ b/pkg/tbtc/signer/src/lib.rs @@ -5,14 +5,16 @@ mod ffi; mod go_math_rand; use api::{ - BuildTaprootTxRequest, DeriveInteractiveAttemptContextRequest, DifferentialFuzzRequest, - DkgPart1Request, DkgPart2Request, DkgPart3Request, FrostTbtcAbiVersionResult, + AcknowledgeStateWitnessCheckpointRequest, BuildTaprootTxRequest, + DeriveInteractiveAttemptContextRequest, DifferentialFuzzRequest, DkgPart1Request, + DkgPart2Request, DkgPart3Request, DurableStoreIdentityResult, FrostTbtcAbiVersionResult, InitSignerConfigRequest, InteractiveAggregateRequest, InteractiveRound1Request, InteractiveRound2Request, InteractiveSessionAbortRequest, InteractiveSessionOpenRequest, NewSigningPackageRequest, PersistDistributedDkgKeyPackageRequest, PromoteCanaryRequest, - QuarantineStatusRequest, RefreshCadenceStatusRequest, RefreshSharesRequest, - RollbackCanaryRequest, TranscriptAuditRequest, TriggerEmergencyRekeyRequest, - VerifyBlameProofRequest, + QuarantineStatusRequest, RecoverStateWitnessCheckpointRequest, RefreshCadenceStatusRequest, + RefreshSharesRequest, RetireDistributedDkgKeyPackagesRequest, RollbackCanaryRequest, + StateWitnessProofRequest, TranscriptAuditRequest, TransitionStateWitnessAnchorRequest, + TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, }; use ffi::{ ffi_entry, free_buffer, parse_request, serialize_response, success_from_string, @@ -44,13 +46,38 @@ const TBTC_SIGNER_VERSION: &str = "tbtc-signer/0.1.0-bootstrap"; // JSON meaning is incompatible, so ABI-3 bridges must reject the library during // negotiation rather than discovering the change at refresh time. const TBTC_SIGNER_ABI_MAJOR: u32 = 4; -// Major bumps reset the additive minor version. ABI 3.1-3.3 introduced the typed -// heartbeat intent, its rate-limit configuration/metric, and optional canary -// evidence configuration; all remain present in ABI 4.0. -const TBTC_SIGNER_ABI_MINOR: u32 = 0; +// Minor 1 adds the descriptor-bound durable-store identity, retained-key-package +// inventory, and paginated state-witness proof symbols. Minor 2 additionally +// adds the constant-size witness-tip readback plus signed external-checkpoint +// acknowledgement and recovery symbols. These are additive symbols and response types; +// older ABI-4 callers remain valid and safely ignore them. Consumers enforcing +// the external rollback/output barrier require ABI 4.2 so a 4.1 library cannot +// pass preflight and then fail late on a missing symbol. Minor 3 adds the +// offline-certified anchor trust-transition, trust-head inspection, and +// bootstrap-facts provisioning symbols; consumers of those surfaces require +// ABI 4.3 so a published 4.2 library cannot pass negotiation then fail dlsym. +// Minor 4 adds idempotent durable retirement of distributed-DKG key packages, +// allowing the host to reconcile packages whose DKG result was never accepted. +const TBTC_SIGNER_ABI_MINOR: u32 = 4; #[cfg(test)] use engine::TBTC_SIGNER_PROFILE_ENV; +/// Default boundary for every operational FFI export. +/// +/// A bootstrap-provisioning config is deliberately capability-minimal: after +/// it is installed, only init-time compatibility discovery and the bootstrap +/// facts export remain available. Keeping this check at the FFI boundary also +/// covers stateless cryptographic helpers that never initialize engine state. +fn normal_ffi_entry(operation: F) -> TbtcSignerResult +where + F: FnOnce() -> Result, errors::EngineError>, +{ + ffi_entry(|| { + engine::require_normal_signer_purpose()?; + operation() + }) +} + /// FFI ownership contract: /// - On return, `TbtcSignerResult.buffer` (if non-null) is owned by the caller. /// - The caller must release that buffer exactly once via `frost_tbtc_free_buffer`. @@ -74,6 +101,136 @@ pub extern "C" fn frost_tbtc_abi_version() -> TbtcSignerResult { }) } +/// Returns the identity of the durable store actually opened and locked by the +/// signer. This call initializes the descriptor-bound store before any state +/// access if it has not already been opened, then revalidates every stable +/// anchor and the current atomic state entry before making safety claims. +/// State freshness and retained key inventory are separate ABI contracts. +#[no_mangle] +pub extern "C" fn frost_tbtc_durable_store_identity() -> TbtcSignerResult { + normal_ffi_entry(|| { + let identity = engine::durable_store_identity()?; + let encode = |value: [u8; 32]| format!("0x{}", hex::encode(value)); + serialize_response(&DurableStoreIdentityResult { + schema: engine::TBTC_SIGNER_DURABLE_STORE_IDENTITY_SCHEMA.to_string(), + backend: engine::TBTC_SIGNER_DURABLE_STORE_BACKEND.to_string(), + store_id: encode(identity.store_id), + canonical_path_fingerprint: encode(identity.canonical_path_fingerprint), + filesystem_fingerprint: encode(identity.filesystem_fingerprint), + lock_fingerprint: encode(identity.lock_fingerprint), + fingerprint: encode(identity.fingerprint), + durable: true, + exclusive_lock_held: true, + symlink_free: true, + replacement_protected: true, + }) + }) +} + +/// Returns a validated, public-only inventory of every locally retained FROST +/// key package together with the exact committed durable-state witness tip. +#[no_mangle] +pub extern "C" fn frost_tbtc_retained_key_package_inventory() -> TbtcSignerResult { + normal_ffi_entry(|| serialize_response(&engine::retained_key_package_inventory()?)) +} + +/// Proves a bounded, contiguous segment of the append-only durable-state +/// witness chain. Callers paginate against a target captured from the inventory +/// response and persist accepted tips outside this store. +#[no_mangle] +pub extern "C" fn frost_tbtc_state_witness_proof( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + normal_ffi_entry(|| { + let request: StateWitnessProofRequest = parse_request(request_ptr, request_len)?; + serialize_response(&engine::state_witness_proof(request)?) + }) +} + +/// Returns the exact durable state-witness tip and latest independently signed +/// anchor acknowledgement using tbtc-signer-state-witness-tip/v1. All anchor +/// fields are zero before an acknowledgement has been durably accepted. +#[no_mangle] +pub extern "C" fn frost_tbtc_state_witness_tip() -> TbtcSignerResult { + normal_ffi_entry(|| serialize_response(&engine::state_witness_tip()?)) +} + +/// Verifies and durably applies (or idempotently replays) a signed external +/// state-witness checkpoint acknowledgement. Unknown JSON fields fail parsing +/// before engine validation. +#[no_mangle] +pub extern "C" fn frost_tbtc_acknowledge_state_witness_checkpoint( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + normal_ffi_entry(|| { + let request: AcknowledgeStateWitnessCheckpointRequest = + parse_request(request_ptr, request_len)?; + serialize_response(&engine::acknowledge_state_witness_checkpoint(request)?) + }) +} + +/// Recovers a remotely committed checkpoint from a fresh signed history-service +/// read wrapper. The nested original acknowledgement is retained byte-for-byte; +/// only its historical wall-clock expiry is waived after the fresh wrapper +/// authenticates its raw SHA-256 digest and exact summary. +#[no_mangle] +pub extern "C" fn frost_tbtc_recover_state_witness_checkpoint( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + normal_ffi_entry(|| { + let request: RecoverStateWitnessCheckpointRequest = + parse_request(request_ptr, request_len)?; + serialize_response(&engine::recover_state_witness_checkpoint(request)?) + }) +} + +/// Verifies and applies a strict +/// tbtc-signer-state-anchor-trust-transition/v1 request while the signer is +/// still behind its startup gate. The supplied certificate suffix and fresh +/// target Read are retained in the durable intent until the transition +/// completes, while the full verified certificate chain and each certificate's +/// raw embedded target acknowledgement remain in the durable audit journal. +/// Callers MUST invoke `frost_tbtc_state_anchor_trust_head` first on every +/// startup. If it reports `state_anchor_trust_recovery_required`, select the +/// exact configured certificate chain using the bounded recovery metadata, +/// obtain a newly signed target Read wrapper, and resubmit this request. Local +/// intent bytes never waive external freshness. +#[no_mangle] +pub extern "C" fn frost_tbtc_transition_state_witness_anchor( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + normal_ffi_entry(|| { + let request: TransitionStateWitnessAnchorRequest = parse_request(request_ptr, request_len)?; + serialize_response(&engine::transition_state_witness_anchor(request)?) + }) +} + +/// Required startup preflight that returns the committed +/// tbtc-signer-state-anchor-trust-head/v1 record without turning inspection +/// into ordinary engine/store initialization. A durable in-progress intent is +/// reported without mutation as `state_anchor_trust_recovery_required`; the +/// caller must resume it through the transition symbol with a fresh signed +/// target Read. +#[no_mangle] +pub extern "C" fn frost_tbtc_state_anchor_trust_head() -> TbtcSignerResult { + normal_ffi_entry(|| serialize_response(&engine::state_anchor_trust_head()?)) +} + +/// Provisioning-only startup preflight returning the stable store fingerprint +/// and exact pristine genesis checkpoint needed to obtain the first offline +/// trust certificate. Requires an installed +/// `state_anchor_bootstrap_provisioning` config with no anchor/trust pins, +/// leaves both process-wide state slots untouched, and rejects any non-pristine +/// store. +#[no_mangle] +pub extern "C" fn frost_tbtc_state_anchor_bootstrap_facts() -> TbtcSignerResult { + ffi_entry(|| serialize_response(&engine::state_anchor_bootstrap_facts()?)) +} + #[no_mangle] pub extern "C" fn frost_tbtc_init_signer_config( request_ptr: *const u8, @@ -88,12 +245,12 @@ pub extern "C" fn frost_tbtc_init_signer_config( #[no_mangle] pub extern "C" fn frost_tbtc_roast_liveness_policy() -> TbtcSignerResult { - ffi_entry(|| serialize_response(&engine::roast_liveness_policy())) + normal_ffi_entry(|| serialize_response(&engine::roast_liveness_policy())) } #[no_mangle] pub extern "C" fn frost_tbtc_hardening_metrics() -> TbtcSignerResult { - ffi_entry(|| serialize_response(&engine::hardening_metrics())) + normal_ffi_entry(|| serialize_response(&engine::hardening_metrics())) } #[no_mangle] @@ -101,7 +258,7 @@ pub extern "C" fn frost_tbtc_roast_transcript_audit( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: TranscriptAuditRequest = parse_request(request_ptr, request_len)?; let response = engine::roast_transcript_audit(request)?; serialize_response(&response) @@ -113,7 +270,7 @@ pub extern "C" fn frost_tbtc_verify_blame_proof( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: VerifyBlameProofRequest = parse_request(request_ptr, request_len)?; let response = engine::verify_blame_proof(request)?; serialize_response(&response) @@ -125,7 +282,7 @@ pub extern "C" fn frost_tbtc_quarantine_status( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: QuarantineStatusRequest = parse_request(request_ptr, request_len)?; let response = engine::quarantine_status(request)?; serialize_response(&response) @@ -137,7 +294,7 @@ pub extern "C" fn frost_tbtc_refresh_cadence_status( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: RefreshCadenceStatusRequest = parse_request(request_ptr, request_len)?; let response = engine::refresh_cadence_status(request)?; serialize_response(&response) @@ -149,7 +306,7 @@ pub extern "C" fn frost_tbtc_trigger_emergency_rekey( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: TriggerEmergencyRekeyRequest = parse_request(request_ptr, request_len)?; let response = engine::trigger_emergency_rekey(request)?; serialize_response(&response) @@ -161,7 +318,7 @@ pub extern "C" fn frost_tbtc_run_differential_fuzzing( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: DifferentialFuzzRequest = parse_request(request_ptr, request_len)?; let response = engine::run_differential_fuzzing(request)?; serialize_response(&response) @@ -170,7 +327,7 @@ pub extern "C" fn frost_tbtc_run_differential_fuzzing( #[no_mangle] pub extern "C" fn frost_tbtc_canary_rollout_status() -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let response = engine::canary_rollout_status()?; serialize_response(&response) }) @@ -181,7 +338,7 @@ pub extern "C" fn frost_tbtc_promote_canary( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: PromoteCanaryRequest = parse_request(request_ptr, request_len)?; let response = engine::promote_canary(request)?; serialize_response(&response) @@ -193,7 +350,7 @@ pub extern "C" fn frost_tbtc_rollback_canary( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: RollbackCanaryRequest = parse_request(request_ptr, request_len)?; let response = engine::rollback_canary(request)?; serialize_response(&response) @@ -216,7 +373,7 @@ pub extern "C" fn frost_tbtc_dkg_part1( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: DkgPart1Request = parse_request(request_ptr, request_len)?; let response = engine::dkg_part1(request)?; serialize_response(&response) @@ -228,7 +385,7 @@ pub extern "C" fn frost_tbtc_dkg_part2( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: DkgPart2Request = parse_request(request_ptr, request_len)?; let response = engine::dkg_part2(request)?; serialize_response(&response) @@ -240,7 +397,7 @@ pub extern "C" fn frost_tbtc_dkg_part3( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: DkgPart3Request = parse_request(request_ptr, request_len)?; let response = engine::dkg_part3(request)?; serialize_response(&response) @@ -252,7 +409,7 @@ pub extern "C" fn frost_tbtc_persist_distributed_dkg_key_package( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: PersistDistributedDkgKeyPackageRequest = parse_request(request_ptr, request_len)?; let response = engine::persist_distributed_dkg_key_package(request)?; @@ -260,12 +417,25 @@ pub extern "C" fn frost_tbtc_persist_distributed_dkg_key_package( }) } +#[no_mangle] +pub extern "C" fn frost_tbtc_retire_distributed_dkg_key_packages( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + normal_ffi_entry(|| { + let request: RetireDistributedDkgKeyPackagesRequest = + parse_request(request_ptr, request_len)?; + let response = engine::retire_distributed_dkg_key_packages(request)?; + serialize_response(&response) + }) +} + #[no_mangle] pub extern "C" fn frost_tbtc_new_signing_package( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: NewSigningPackageRequest = parse_request(request_ptr, request_len)?; let response = engine::new_signing_package(request)?; serialize_response(&response) @@ -277,7 +447,7 @@ pub extern "C" fn frost_tbtc_verify_signature_share( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: crate::api::VerifySignatureShareRequest = parse_request(request_ptr, request_len)?; let response = engine::verify_signature_share(request)?; @@ -295,7 +465,7 @@ pub extern "C" fn frost_tbtc_interactive_session_open( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: InteractiveSessionOpenRequest = parse_request(request_ptr, request_len)?; let response = engine::interactive_session_open(request)?; serialize_response(&response) @@ -307,7 +477,7 @@ pub extern "C" fn frost_tbtc_interactive_round1( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: InteractiveRound1Request = parse_request(request_ptr, request_len)?; let response = engine::interactive_round1(request)?; serialize_response(&response) @@ -319,7 +489,7 @@ pub extern "C" fn frost_tbtc_interactive_round2( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: InteractiveRound2Request = parse_request(request_ptr, request_len)?; let response = engine::interactive_round2(request)?; serialize_response(&response) @@ -331,7 +501,7 @@ pub extern "C" fn frost_tbtc_interactive_session_abort( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: InteractiveSessionAbortRequest = parse_request(request_ptr, request_len)?; let response = engine::interactive_session_abort(request)?; serialize_response(&response) @@ -343,7 +513,7 @@ pub extern "C" fn frost_tbtc_interactive_aggregate( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: InteractiveAggregateRequest = parse_request(request_ptr, request_len)?; let response = engine::interactive_aggregate(request)?; serialize_response(&response) @@ -355,7 +525,7 @@ pub extern "C" fn frost_tbtc_derive_interactive_attempt_context( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: DeriveInteractiveAttemptContextRequest = parse_request(request_ptr, request_len)?; let response = engine::derive_interactive_attempt_context(request)?; @@ -368,7 +538,7 @@ pub extern "C" fn frost_tbtc_build_taproot_tx( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: BuildTaprootTxRequest = parse_request(request_ptr, request_len)?; let response = engine::build_taproot_tx(request)?; serialize_response(&response) @@ -380,7 +550,7 @@ pub extern "C" fn frost_tbtc_refresh_shares( request_ptr: *const u8, request_len: usize, ) -> TbtcSignerResult { - ffi_entry(|| { + normal_ffi_entry(|| { let request: RefreshSharesRequest = parse_request(request_ptr, request_len)?; let response = engine::refresh_shares(request)?; serialize_response(&response) @@ -396,21 +566,26 @@ mod tests { use crate::api::{ BuildTaprootTxRequest, CanaryRolloutStatusResult, DifferentialFuzzRequest, DifferentialFuzzResult, DkgPart1Request, DkgPart1Result, DkgPart2Request, DkgPart2Result, - DkgPart3Request, DkgPart3Result, DkgRound1Package, DkgRound2Package, ErrorResponse, - FrostTbtcAbiVersionResult, PromoteCanaryRequest, QuarantineStatusRequest, - QuarantineStatusResult, RefreshCadenceStatusRequest, RefreshCadenceStatusResult, - RefreshSharesRequest, RoastLivenessPolicyResult, RollbackCanaryRequest, - SignerHardeningMetricsResult, TransactionResult, TranscriptAuditRequest, - TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, + DkgPart3Request, DkgPart3Result, DkgRound1Package, DkgRound2Package, + DurableStoreIdentityResult, ErrorResponse, FrostTbtcAbiVersionResult, PromoteCanaryRequest, + QuarantineStatusRequest, QuarantineStatusResult, RefreshCadenceStatusRequest, + RefreshCadenceStatusResult, RefreshSharesRequest, RetainedKeyPackageInventoryResult, + RoastLivenessPolicyResult, RollbackCanaryRequest, SignerHardeningMetricsResult, + StateWitnessProofRequest, StateWitnessProofResult, TransactionResult, + TranscriptAuditRequest, TransitionStateWitnessAnchorRequest, TriggerEmergencyRekeyRequest, + VerifyBlameProofRequest, }; use crate::{ frost_tbtc_abi_version, frost_tbtc_build_taproot_tx, frost_tbtc_canary_rollout_status, - frost_tbtc_dkg_part1, frost_tbtc_dkg_part2, frost_tbtc_dkg_part3, frost_tbtc_free_buffer, - frost_tbtc_hardening_metrics, frost_tbtc_promote_canary, frost_tbtc_quarantine_status, - frost_tbtc_refresh_cadence_status, frost_tbtc_refresh_shares, + frost_tbtc_dkg_part1, frost_tbtc_dkg_part2, frost_tbtc_dkg_part3, + frost_tbtc_durable_store_identity, frost_tbtc_free_buffer, frost_tbtc_hardening_metrics, + frost_tbtc_promote_canary, frost_tbtc_quarantine_status, frost_tbtc_refresh_cadence_status, + frost_tbtc_refresh_shares, frost_tbtc_retained_key_package_inventory, frost_tbtc_roast_liveness_policy, frost_tbtc_roast_transcript_audit, frost_tbtc_rollback_canary, frost_tbtc_run_differential_fuzzing, - frost_tbtc_trigger_emergency_rekey, frost_tbtc_verify_blame_proof, + frost_tbtc_state_anchor_trust_head, frost_tbtc_state_witness_proof, + frost_tbtc_transition_state_witness_anchor, frost_tbtc_trigger_emergency_rekey, + frost_tbtc_verify_blame_proof, }; fn call_ffi( @@ -761,10 +936,183 @@ mod tests { // The enforced FFI contract starts at 1.0; bump deliberately per the // TBTC_SIGNER_ABI_MAJOR / TBTC_SIGNER_ABI_MINOR rules. This test pins the // current value so an accidental bump is caught. ABI 4 changes a valid - // RefreshShares call from a synthetic success response to a terminal error, - // forcing ABI-3 consumers to fail closed during compatibility negotiation. + // RefreshShares call from a synthetic success response to a terminal error; + // minor 2 adds the signed external-anchor tip/acknowledgement/recovery symbols; + // minor 3 adds offline trust transition/head and provisioning bootstrap facts; + // minor 4 adds durable distributed-DKG key-package retirement. assert_eq!(abi.abi_major, 4); - assert_eq!(abi.abi_minor, 0); + assert_eq!(abi.abi_minor, 4); + } + + #[test] + fn state_anchor_trust_ffi_symbols_have_frozen_signatures_and_dispatch() { + let transition_symbol: extern "C" fn(*const u8, usize) -> crate::ffi::TbtcSignerResult = + frost_tbtc_transition_state_witness_anchor; + let _head_symbol: extern "C" fn() -> crate::ffi::TbtcSignerResult = + frost_tbtc_state_anchor_trust_head; + + // Empty chains are rejected before config/store access. This proves + // symbol -> strict request parse -> trust-transition verifier -> + // structured FFI error dispatch without mutating a durable fixture. + let request = TransitionStateWitnessAnchorRequest { + schema: crate::engine::STATE_ANCHOR_TRUST_TRANSITION_SCHEMA.to_string(), + certificate_chain: Vec::new(), + target_read_response_base64: String::new(), + }; + let (status, payload) = call_ffi(&request, transition_symbol); + assert_ne!(status, 0); + let error: ErrorResponse = + serde_json::from_slice(&payload).expect("trust-transition error payload"); + assert_eq!(error.code, "validation_error"); + assert_eq!(error.recovery_class, "recoverable"); + assert!(error.message.contains("certificateChain")); + } + + #[test] + #[cfg(unix)] + fn durable_store_identity_ffi_has_exact_v2_wire_shape_and_transcript() { + let _guard = crate::engine::lock_test_state(); + let path = std::env::temp_dir().join(format!( + "frost_tbtc_ffi_store_identity_{}.json", + std::process::id() + )); + let path_string = path.to_string_lossy().into_owned(); + let _state_path = EnvVarGuard::set("TBTC_SIGNER_STATE_PATH", &path_string); + crate::engine::reset_for_tests(); + + let (status, payload) = call_ffi_no_input(frost_tbtc_durable_store_identity); + assert_eq!(status, 0); + let wire: DurableStoreIdentityResult = + serde_json::from_slice(&payload).expect("durable store identity payload"); + assert_eq!( + wire.schema, + crate::engine::TBTC_SIGNER_DURABLE_STORE_IDENTITY_SCHEMA + ); + assert_eq!( + wire.backend, + crate::engine::TBTC_SIGNER_DURABLE_STORE_BACKEND + ); + assert!( + wire.durable + && wire.exclusive_lock_held + && wire.symlink_free + && wire.replacement_protected + ); + + let decode = |value: &str| -> [u8; 32] { + assert_eq!(value, value.to_ascii_lowercase()); + assert!(value.starts_with("0x")); + assert_eq!(value.len(), 66); + let bytes = hex::decode(&value[2..]).expect("bytes32 hex"); + let mut result = [0u8; 32]; + result.copy_from_slice(&bytes); + assert_ne!(result, [0u8; 32]); + result + }; + let store_id = decode(&wire.store_id); + decode(&wire.canonical_path_fingerprint); + decode(&wire.filesystem_fingerprint); + decode(&wire.lock_fingerprint); + assert_eq!( + decode(&wire.fingerprint), + crate::engine::durable_store_fingerprint(&store_id) + ); + + let (second_status, second_payload) = call_ffi_no_input(frost_tbtc_durable_store_identity); + assert_eq!(second_status, 0); + assert_eq!(second_payload, payload); + + if let Ok(mut slot) = crate::engine::state_file_lock_slot().lock() { + *slot = None; + } + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(crate::engine::state_lock_file_path(&path)); + let _ = std::fs::remove_file(crate::engine::durable_store_id_file_path(&path)); + let _ = std::fs::remove_file(crate::engine::state_witness_file_path(&path)); + } + + #[test] + #[cfg(unix)] + fn inventory_and_state_witness_ffi_have_exact_wire_contracts() { + use std::collections::BTreeSet; + + let _guard = crate::engine::lock_test_state(); + let path = std::env::temp_dir().join(format!( + "frost_tbtc_ffi_state_witness_{}.json", + std::process::id() + )); + let path_string = path.to_string_lossy().into_owned(); + let _state_path = EnvVarGuard::set("TBTC_SIGNER_STATE_PATH", &path_string); + crate::engine::reset_for_tests(); + + let (status, payload) = call_ffi_no_input(frost_tbtc_retained_key_package_inventory); + assert_eq!(status, 0); + let value: serde_json::Value = + serde_json::from_slice(&payload).expect("inventory JSON object"); + let keys = value + .as_object() + .expect("inventory object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + keys, + BTreeSet::from([ + "entries", + "inventoryCommitment", + "previousStateCommitment", + "schema", + "stateCommitment", + "stateGeneration", + "stateImageDigest", + "storeFingerprint", + ]) + ); + let inventory: RetainedKeyPackageInventoryResult = + serde_json::from_slice(&payload).expect("inventory response"); + assert_eq!( + inventory.schema, + crate::engine::TBTC_SIGNER_RETAINED_KEY_PACKAGE_INVENTORY_SCHEMA + ); + assert!(inventory.state_generation > 0); + assert!(inventory.entries.is_empty()); + + let request = StateWitnessProofRequest { + schema: crate::engine::TBTC_SIGNER_STATE_WITNESS_PROOF_REQUEST_SCHEMA.to_string(), + store_fingerprint: inventory.store_fingerprint.clone(), + ancestor_generation: inventory.state_generation, + ancestor_commitment: inventory.state_commitment.clone(), + target_generation: inventory.state_generation, + target_commitment: inventory.state_commitment.clone(), + maximum_entries: 16, + }; + let (proof_status, proof_payload) = call_ffi(&request, frost_tbtc_state_witness_proof); + assert_eq!(proof_status, 0); + let proof: StateWitnessProofResult = + serde_json::from_slice(&proof_payload).expect("state witness proof"); + assert_eq!( + proof.schema, + crate::engine::TBTC_SIGNER_STATE_WITNESS_PROOF_SCHEMA + ); + assert_eq!(proof.store_fingerprint, inventory.store_fingerprint); + assert_eq!(proof.ancestor_generation, inventory.state_generation); + assert_eq!(proof.target_generation, inventory.state_generation); + assert!(proof.complete); + assert!(proof.entries.is_empty()); + + let mut unknown_field_request = + serde_json::to_value(&request).expect("proof request value"); + unknown_field_request["unexpectedField"] = serde_json::json!(true); + let (invalid_status, _) = call_ffi(&unknown_field_request, frost_tbtc_state_witness_proof); + assert_ne!(invalid_status, 0); + + if let Ok(mut slot) = crate::engine::state_file_lock_slot().lock() { + *slot = None; + } + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(crate::engine::state_lock_file_path(&path)); + let _ = std::fs::remove_file(crate::engine::durable_store_id_file_path(&path)); + let _ = std::fs::remove_file(crate::engine::state_witness_file_path(&path)); } #[test]