From f593d4b4c9c4e4d5103780de4ed2d2a5e2e38e69 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 13:38:09 -0400 Subject: [PATCH 1/5] feat(tbtc): add offline-authorized FROST share repair --- pkg/tbtc/signer/include/frost_tbtc.h | 10 + pkg/tbtc/signer/src/api.rs | 115 +++ pkg/tbtc/signer/src/engine/dkg.rs | 115 ++- pkg/tbtc/signer/src/engine/inventory.rs | 133 +++- pkg/tbtc/signer/src/engine/mod.rs | 34 +- pkg/tbtc/signer/src/engine/persistence.rs | 81 +- pkg/tbtc/signer/src/engine/repair.rs | 878 ++++++++++++++++++++++ pkg/tbtc/signer/src/engine/state.rs | 20 + pkg/tbtc/signer/src/engine/tests.rs | 539 ++++++++++++- pkg/tbtc/signer/src/engine/testsupport.rs | 1 + pkg/tbtc/signer/src/lib.rs | 65 +- 11 files changed, 1948 insertions(+), 43 deletions(-) create mode 100644 pkg/tbtc/signer/src/engine/repair.rs diff --git a/pkg/tbtc/signer/include/frost_tbtc.h b/pkg/tbtc/signer/include/frost_tbtc.h index 92bc64d083..4e577e55d1 100644 --- a/pkg/tbtc/signer/include/frost_tbtc.h +++ b/pkg/tbtc/signer/include/frost_tbtc.h @@ -129,6 +129,16 @@ TbtcSignerResult frost_tbtc_dkg_part2(const uint8_t* request_ptr, size_t request 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); +/* + * Offline-authorized disaster-recovery protocol. Part1 deltas and Part2 + * sigmas are secret and must use authenticated confidential transport. Install + * validates the exact context/helper set and writes the reconstructed share + * directly to the authorization-bound fresh durable store; no KeyPackage is + * returned across this ABI. + */ +TbtcSignerResult frost_tbtc_share_repair_part1(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_share_repair_part2(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_install_repaired_share(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 f18f56262c..360e696f1a 100644 --- a/pkg/tbtc/signer/src/api.rs +++ b/pkg/tbtc/signer/src/api.rs @@ -145,6 +145,106 @@ pub struct RetireDistributedDkgKeyPackagesResult { pub retired_key_package_count: u16, } +/// Offline-authority authorization for one bounded repair of one participant +/// share. The signature covers every field except `signature_hex` using the +/// frozen `tbtc-frost-share-repair-authorization/v1` transcript. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ShareRepairAuthorization { + pub schema: String, + pub session_id: String, + pub wallet_id: String, + pub key_group: String, + pub public_key_package_commitment: String, + pub target_identifier: u16, + pub helper_identifiers: Vec, + pub threshold: u16, + pub participant_count: u16, + pub old_store_fingerprint: String, + pub new_store_fingerprint: String, + pub recovery_epoch: u64, + pub issued_at_unix: u64, + pub not_before_unix: u64, + pub expires_at_unix: u64, + pub nonce: String, + pub signature_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ShareRepairPart1Request { + pub authorization: ShareRepairAuthorization, + pub helper_identifier: u16, +} + +/// One secret repair delta. The context digest and explicit endpoints prevent +/// the application from accidentally accepting a scalar from another repair +/// session even though the upstream arithmetic type itself has no context. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ShareRepairDelta { + pub context_digest: String, + pub sender_identifier: u16, + pub recipient_identifier: u16, + pub data_hex: SecretHex, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ShareRepairPart1Result { + pub context_digest: String, + pub helper_identifier: u16, + /// Public package independently read and commitment-checked from this + /// helper's retained session. The target obtains it from the exact helper + /// set; no pre-existing target-store material is required. + pub public_key_package: NativeFrostPublicKeyPackage, + pub deltas: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ShareRepairPart2Request { + pub authorization: ShareRepairAuthorization, + pub helper_identifier: u16, + pub deltas: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ShareRepairSigma { + pub context_digest: String, + pub helper_identifier: u16, + pub data_hex: SecretHex, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ShareRepairPart2Result { + pub context_digest: String, + pub sigma: ShareRepairSigma, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InstallRepairedShareRequest { + pub authorization: ShareRepairAuthorization, + pub public_key_package: NativeFrostPublicKeyPackage, + pub sigmas: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InstallRepairedShareResult { + pub schema: String, + pub session_id: String, + pub key_group: String, + pub target_identifier: u16, + pub recovery_epoch: u64, + pub authorization_digest: String, + pub active_store_fingerprint: String, + pub idempotent: bool, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct NativeFrostCommitment { pub identifier: String, @@ -726,6 +826,17 @@ pub struct RetainedKeyPackageInventoryEntry { pub key_packages: Vec, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RetainedKeyPackageInventoryRecoveredSeat { + pub wallet_id: String, + pub key_group: String, + pub participant_seat: u16, + pub recovery_epoch: u64, + pub authorization_digest: String, + pub active_store_fingerprint: String, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct RetainedKeyPackageInventoryResult { @@ -737,6 +848,10 @@ pub struct RetainedKeyPackageInventoryResult { pub state_image_digest: String, pub inventory_commitment: String, pub entries: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub recovered_seats: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recovery_activation_commitment: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] diff --git a/pkg/tbtc/signer/src/engine/dkg.rs b/pkg/tbtc/signer/src/engine/dkg.rs index 85ad935b0e..63f7c95f34 100644 --- a/pkg/tbtc/signer/src/engine/dkg.rs +++ b/pkg/tbtc/signer/src/engine/dkg.rs @@ -13,8 +13,27 @@ use super::*; /// one session (same key group). There is NO production gate: this is the real /// distributed path, not the transitional dealer one. pub fn persist_distributed_dkg_key_package( - mut request: PersistDistributedDkgKeyPackageRequest, + request: PersistDistributedDkgKeyPackageRequest, ) -> Result { + persist_distributed_dkg_key_package_with_recovery(request, None).map(|outcome| outcome.result) +} + +pub(crate) struct PersistDistributedDkgKeyPackageOutcome { + pub(crate) result: DkgResult, + pub(crate) idempotent: bool, +} + +pub(crate) fn persist_repaired_dkg_key_package( + request: PersistDistributedDkgKeyPackageRequest, + recovered_seat: RecoveredSeatState, +) -> Result { + persist_distributed_dkg_key_package_with_recovery(request, Some(recovered_seat)) +} + +fn persist_distributed_dkg_key_package_with_recovery( + mut request: PersistDistributedDkgKeyPackageRequest, + recovered_seat: Option, +) -> Result { const OP: &str = "persist_distributed_dkg_key_package"; // data_hex is the serialized SECRET signing share. Move its redacting, // zeroizing holder out BEFORE any fallible check so it is wiped on every @@ -38,6 +57,17 @@ pub fn persist_distributed_dkg_key_package( request.threshold, request.participant_count ))); } + if let Some(recovered) = recovered_seat.as_ref() { + if recovered.participant_identifier != request.participant_identifier + || recovered.recovery_epoch == 0 + || recovered.authorization_digest == [0u8; 32] + || recovered.active_store_fingerprint == [0u8; 32] + { + return Err(EngineError::Validation(format!( + "{OP}: recovered-seat metadata is incomplete or belongs to another participant" + ))); + } + } let public_key_package = native_public_key_package_to_frost(OP, &request.public_key_package)?; @@ -211,6 +241,67 @@ pub fn persist_distributed_dkg_key_package( }); } + // A target may retry after the Rust state replacement succeeded but the Go + // anchor acknowledgement was interrupted. Detect the exact replay before + // capacity compaction or any mutation so it consumes no new witness record. + if let Some(recovered) = recovered_seat.as_ref() { + if let Some(existing_session) = guard.sessions.get(&request.session_id) { + let exact_result = existing_session.dkg_result.as_ref().is_some_and(|result| { + result.key_group == key_group + && result.threshold == request.threshold + && result.participant_count == request.participant_count + }); + if exact_result + && existing_session.dkg_public_key_package.as_ref() == Some(&public_key_package) + && existing_session + .dkg_key_packages + .as_ref() + .and_then(|packages| packages.get(&request.participant_identifier)) + == Some(&key_package) + && existing_session + .recovered_seats + .get(&request.participant_identifier) + == Some(recovered) + { + return Ok(PersistDistributedDkgKeyPackageOutcome { + result: existing_session + .dkg_result + .clone() + .expect("exact_result requires a DKG result"), + idempotent: true, + }); + } + } + } + + // Reject recovery-generation conflicts before the capacity helper has any + // opportunity to compact a retired tombstone. These failures are expected + // protocol outcomes, not persistence failures, so they must leave memory + // and disk exactly unchanged. + if let Some(existing_session) = guard.sessions.get(&request.session_id) { + match ( + recovered_seat.as_ref(), + existing_session + .recovered_seats + .get(&request.participant_identifier), + ) { + (None, Some(_)) => { + return Err(EngineError::Validation(format!( + "{OP}: ordinary DKG persistence cannot replace a recovered seat" + ))) + } + (Some(candidate), Some(existing_recovery)) + if candidate.recovery_epoch <= existing_recovery.recovery_epoch => + { + return Err(EngineError::Validation(format!( + "{OP}: recovery epoch [{}] does not advance stored epoch [{}]", + candidate.recovery_epoch, existing_recovery.recovery_epoch + ))) + } + _ => {} + } + } + // Reserve a total-registry slot only after every rejection that can apply // to a fresh DKG session. If the ensuing durable write fails before file // replacement, restore any retired tombstone evicted for this slot. @@ -224,6 +315,10 @@ pub fn persist_distributed_dkg_key_package( .or_insert_with(SessionState::default); let previous_dkg_result = session.dkg_result.clone(); let previous_dkg_public_key_package = session.dkg_public_key_package.clone(); + let previous_recovered_seat = session + .recovered_seats + .get(&request.participant_identifier) + .cloned(); let key_package_map_was_absent = session.dkg_key_packages.is_none(); // A session may already hold a DKG result: this seat re-persisting (idempotent) @@ -268,6 +363,11 @@ pub fn persist_distributed_dkg_key_package( .dkg_key_packages .get_or_insert_with(BTreeMap::new) .insert(request.participant_identifier, key_package); + if let Some(recovered_seat) = recovered_seat { + session + .recovered_seats + .insert(request.participant_identifier, recovered_seat); + } // Clone the result before the `&guard` persist call so the mutable `session` // borrow ends here (mirrors run_dkg's ordering). @@ -287,6 +387,14 @@ pub fn persist_distributed_dkg_key_package( })?; rollback_session.dkg_result = previous_dkg_result; rollback_session.dkg_public_key_package = previous_dkg_public_key_package; + rollback_session + .recovered_seats + .remove(&request.participant_identifier); + if let Some(previous_recovered_seat) = previous_recovered_seat { + rollback_session + .recovered_seats + .insert(request.participant_identifier, previous_recovered_seat); + } if let Some(key_packages) = rollback_session.dkg_key_packages.as_mut() { key_packages.remove(&request.participant_identifier); if let Some(previous_key_package) = replaced_key_package { @@ -309,7 +417,10 @@ pub fn persist_distributed_dkg_key_package( return Err(persist_error); } - Ok(result) + Ok(PersistDistributedDkgKeyPackageOutcome { + result, + idempotent: false, + }) } /// Durably retires the wallet-owner session holding the exact distributed-DKG diff --git a/pkg/tbtc/signer/src/engine/inventory.rs b/pkg/tbtc/signer/src/engine/inventory.rs index ffe8c77685..57ebfab7ea 100644 --- a/pkg/tbtc/signer/src/engine/inventory.rs +++ b/pkg/tbtc/signer/src/engine/inventory.rs @@ -4,12 +4,14 @@ use super::*; use crate::api::{ RetainedKeyPackageInventoryEntry, RetainedKeyPackageInventoryPackage, - RetainedKeyPackageInventoryResult, StateWitnessProofEntry, StateWitnessProofRequest, - StateWitnessProofResult, + RetainedKeyPackageInventoryRecoveredSeat, 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_RETAINED_KEY_PACKAGE_INVENTORY_RECOVERY_SCHEMA: &str = + "tbtc-signer-retained-key-package-inventory/v2"; 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 = @@ -20,6 +22,8 @@ const INVENTORY_COMMITMENT_DOMAIN: &[u8] = 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"; +const RECOVERY_ACTIVATION_COMMITMENT_DOMAIN: &[u8] = + b"tbtc-signer-recovered-seat-activation-commitment-v1\0"; #[derive(Clone)] struct ValidatedInventoryPackage { @@ -38,6 +42,16 @@ struct ValidatedInventoryEntry { key_packages: Vec, } +#[derive(Clone)] +struct ValidatedRecoveredSeat { + wallet_id: [u8; 32], + key_group: String, + participant_seat: u16, + recovery_epoch: u64, + authorization_digest: [u8; 32], + active_store_fingerprint: [u8; 32], +} + pub(crate) fn retained_key_package_inventory( ) -> Result { // Keep the engine guard through store-tip capture. Every state mutation @@ -49,6 +63,7 @@ pub(crate) fn retained_key_package_inventory( .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; let mut validated_entries = Vec::new(); + let mut recovered_seats = 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() { @@ -58,7 +73,31 @@ pub(crate) fn retained_key_package_inventory( } continue; }; - validated_entries.push(validate_inventory_entry(session_id, session, dkg_result)?); + let validated = validate_inventory_entry(session_id, session, dkg_result)?; + for (participant_seat, recovery) in &session.recovered_seats { + if recovery.participant_identifier != *participant_seat + || recovery.recovery_epoch == 0 + || recovery.authorization_digest == [0u8; 32] + || recovery.active_store_fingerprint == [0u8; 32] + || !validated + .key_packages + .iter() + .any(|package| package.participant_seat == *participant_seat) + { + return Err(EngineError::Internal(format!( + "session [{session_id}] has inconsistent recovered-seat metadata" + ))); + } + recovered_seats.push(ValidatedRecoveredSeat { + wallet_id: validated.wallet_id, + key_group: validated.key_group.clone(), + participant_seat: *participant_seat, + recovery_epoch: recovery.recovery_epoch, + authorization_digest: recovery.authorization_digest, + active_store_fingerprint: recovery.active_store_fingerprint, + }); + } + validated_entries.push(validated); } validated_entries.sort_by_key(|entry| entry.wallet_id); for pair in validated_entries.windows(2) { @@ -70,12 +109,39 @@ pub(crate) fn retained_key_package_inventory( } } let inventory_commitment = compute_inventory_commitment(&validated_entries)?; + recovered_seats.sort_by(|left, right| { + left.wallet_id + .cmp(&right.wallet_id) + .then(left.participant_seat.cmp(&right.participant_seat)) + }); + for pair in recovered_seats.windows(2) { + if pair[0].wallet_id == pair[1].wallet_id + && pair[0].participant_seat == pair[1].participant_seat + { + return Err(EngineError::Internal( + "duplicate recovered-seat activation metadata".to_string(), + )); + } + } let (store_identity, state_tip) = with_state_file_lock(|store| { let identity = store.identity()?; let tip = store.state_witness_tip()?; Ok((identity, tip)) })?; + if recovered_seats + .iter() + .any(|seat| seat.active_store_fingerprint != store_identity.fingerprint) + { + return Err(EngineError::Internal( + "recovered-seat activation metadata belongs to another durable store".to_string(), + )); + } + let recovery_activation_commitment = if recovered_seats.is_empty() { + None + } else { + Some(compute_recovery_activation_commitment(&recovered_seats)?) + }; let entries = validated_entries .into_iter() @@ -98,7 +164,12 @@ pub(crate) fn retained_key_package_inventory( .collect(); Ok(RetainedKeyPackageInventoryResult { - schema: TBTC_SIGNER_RETAINED_KEY_PACKAGE_INVENTORY_SCHEMA.to_string(), + schema: if recovered_seats.is_empty() { + TBTC_SIGNER_RETAINED_KEY_PACKAGE_INVENTORY_SCHEMA + } else { + TBTC_SIGNER_RETAINED_KEY_PACKAGE_INVENTORY_RECOVERY_SCHEMA + } + .to_string(), store_fingerprint: bytes32_hex(store_identity.fingerprint), state_generation: state_tip.generation, state_commitment: bytes32_hex(state_tip.commitment), @@ -106,6 +177,18 @@ pub(crate) fn retained_key_package_inventory( state_image_digest: bytes32_hex(state_tip.state_image_digest), inventory_commitment: bytes32_hex(inventory_commitment), entries, + recovered_seats: recovered_seats + .into_iter() + .map(|seat| RetainedKeyPackageInventoryRecoveredSeat { + wallet_id: bytes32_hex(seat.wallet_id), + key_group: seat.key_group, + participant_seat: seat.participant_seat, + recovery_epoch: seat.recovery_epoch, + authorization_digest: bytes32_hex(seat.authorization_digest), + active_store_fingerprint: bytes32_hex(seat.active_store_fingerprint), + }) + .collect(), + recovery_activation_commitment: recovery_activation_commitment.map(bytes32_hex), }) } @@ -376,7 +459,7 @@ pub(crate) fn bytes32_hex(value: [u8; 32]) -> String { format!("0x{}", hex::encode(value)) } -fn public_key_package_commitment( +pub(crate) fn public_key_package_commitment( wallet_id: &[u8; 32], key_group: &str, threshold: u16, @@ -449,6 +532,26 @@ fn compute_inventory_commitment( Ok(digest.finalize().into()) } +fn compute_recovery_activation_commitment( + seats: &[ValidatedRecoveredSeat], +) -> Result<[u8; 32], EngineError> { + let count = u32::try_from(seats.len()).map_err(|_| { + EngineError::Internal("recovered-seat inventory has too many entries".to_string()) + })?; + let mut digest = Sha256::new(); + digest.update(RECOVERY_ACTIVATION_COMMITMENT_DOMAIN); + digest.update(count.to_be_bytes()); + for seat in seats { + digest.update(seat.wallet_id); + write_length_prefixed(&mut digest, seat.key_group.as_bytes()); + digest.update(seat.participant_seat.to_be_bytes()); + digest.update(seat.recovery_epoch.to_be_bytes()); + digest.update(seat.authorization_digest); + digest.update(seat.active_store_fingerprint); + } + 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); @@ -484,4 +587,24 @@ mod inventory_transcript_tests { "bd6ec36fa27a57dd9926883bb2ff4dee7ececd28de940df7294f0e0f0dedd150" ); } + + #[test] + fn recovery_activation_commitment_matches_frozen_go_v1_vector() { + let seats = vec![ValidatedRecoveredSeat { + wallet_id: [0x11; 32], + key_group: format!("02{}", "11".repeat(32)), + participant_seat: 3, + recovery_epoch: 7, + authorization_digest: [0x22; 32], + active_store_fingerprint: [0x33; 32], + }]; + + assert_eq!( + hex::encode( + compute_recovery_activation_commitment(&seats) + .expect("recovery activation commitment") + ), + "48484643db480de91c011eece129e51fb32864f33887975009f993e54f7a2f20" + ); + } } diff --git a/pkg/tbtc/signer/src/engine/mod.rs b/pkg/tbtc/signer/src/engine/mod.rs index a2481637d1..5728c0cd56 100644 --- a/pkg/tbtc/signer/src/engine/mod.rs +++ b/pkg/tbtc/signer/src/engine/mod.rs @@ -65,21 +65,23 @@ use crate::api::{ DifferentialDivergence, DifferentialFuzzRequest, DifferentialFuzzResult, DkgPart1Request, DkgPart1Result, DkgPart2Request, DkgPart2Result, DkgPart3Request, DkgPart3Result, DkgResult, DkgRound1Package, DkgRound2Package, InitSignerConfigRequest, InitSignerConfigResult, - InteractiveAggregateRequest, InteractiveAggregateResult, InteractiveRound1Request, - InteractiveRound1Result, InteractiveRound2Request, InteractiveRound2Result, - InteractiveSessionAbortRequest, InteractiveSessionAbortResult, InteractiveSessionOpenRequest, - InteractiveSessionOpenResult, InteractiveSigningIntent, NativeFrostCommitment, - NativeFrostKeyPackage, NativeFrostPublicKeyPackage, NativeFrostSignatureShare, - NewSigningPackageRequest, NewSigningPackageResult, ParticipantFrostIdentifier, - PersistDistributedDkgKeyPackageRequest, PromoteCanaryRequest, PromoteCanaryResult, - QuarantineStatusRequest, QuarantineStatusResult, RefreshCadenceStatusRequest, - RefreshCadenceStatusResult, RefreshSharesRequest, RefreshSharesResult, - RetireDistributedDkgKeyPackagesRequest, RetireDistributedDkgKeyPackagesResult, - RoastLivenessPolicyResult, RollbackCanaryRequest, RollbackCanaryResult, RoundState, SecretHex, - SignatureResult, SignerHardeningMetricsResult, StateAnchorBootstrapFactsResult, - StateAnchorTrustCertificate, StateAnchorTrustCheckpoint, StateAnchorTrustEndpoint, - StateAnchorTrustHeadResult, StateAnchorTrustReference, TransactionResult, - TranscriptAuditRecord, TranscriptAuditRequest, TranscriptAuditResult, + InstallRepairedShareRequest, InstallRepairedShareResult, InteractiveAggregateRequest, + InteractiveAggregateResult, InteractiveRound1Request, InteractiveRound1Result, + InteractiveRound2Request, InteractiveRound2Result, InteractiveSessionAbortRequest, + InteractiveSessionAbortResult, InteractiveSessionOpenRequest, InteractiveSessionOpenResult, + InteractiveSigningIntent, NativeFrostCommitment, NativeFrostKeyPackage, + NativeFrostPublicKeyPackage, NativeFrostSignatureShare, NewSigningPackageRequest, + NewSigningPackageResult, ParticipantFrostIdentifier, PersistDistributedDkgKeyPackageRequest, + PromoteCanaryRequest, PromoteCanaryResult, QuarantineStatusRequest, QuarantineStatusResult, + RefreshCadenceStatusRequest, RefreshCadenceStatusResult, RefreshSharesRequest, + RefreshSharesResult, RetireDistributedDkgKeyPackagesRequest, + RetireDistributedDkgKeyPackagesResult, RoastLivenessPolicyResult, RollbackCanaryRequest, + RollbackCanaryResult, RoundState, SecretHex, ShareRepairAuthorization, ShareRepairDelta, + ShareRepairPart1Request, ShareRepairPart1Result, ShareRepairPart2Request, + ShareRepairPart2Result, ShareRepairSigma, SignatureResult, SignerHardeningMetricsResult, + StateAnchorBootstrapFactsResult, StateAnchorTrustCertificate, StateAnchorTrustCheckpoint, + StateAnchorTrustEndpoint, StateAnchorTrustHeadResult, StateAnchorTrustReference, + TransactionResult, TranscriptAuditRecord, TranscriptAuditRequest, TranscriptAuditResult, TransitionStateWitnessAnchorRequest, TransitionStateWitnessAnchorResult, TriggerEmergencyRekeyRequest, TriggerEmergencyRekeyResult, VerifyBlameProofRequest, }; @@ -100,6 +102,7 @@ mod lifecycle; mod persistence; mod policy; mod provenance; +mod repair; mod roast; mod state; mod store; @@ -125,6 +128,7 @@ pub(crate) use lifecycle::*; pub(crate) use persistence::*; pub(crate) use policy::*; pub(crate) use provenance::*; +pub(crate) use repair::*; pub(crate) use roast::*; pub(crate) use state::*; pub(crate) use store::*; diff --git a/pkg/tbtc/signer/src/engine/persistence.rs b/pkg/tbtc/signer/src/engine/persistence.rs index d28aea37cf..c0ee3612e5 100644 --- a/pkg/tbtc/signer/src/engine/persistence.rs +++ b/pkg/tbtc/signer/src/engine/persistence.rs @@ -30,6 +30,8 @@ pub(crate) struct PersistedSessionState { pub(crate) dkg_result: Option, #[serde(default)] pub(crate) dkg_share_epoch: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) recovered_seats: Vec, pub(crate) sign_request_fingerprint: Option, pub(crate) sign_message_hex: Option, pub(crate) round_state: Option, @@ -100,6 +102,7 @@ impl std::fmt::Debug for PersistedSessionState { ) .field("dkg_result", &self.dkg_result) .field("dkg_share_epoch", &self.dkg_share_epoch) + .field("recovered_seats", &self.recovered_seats) .field("sign_request_fingerprint", &self.sign_request_fingerprint) .field( "sign_message_hex", @@ -197,7 +200,12 @@ pub(crate) struct StateEncryptionKeyMaterial { pub(crate) key_id: String, } -pub(crate) const PERSISTED_STATE_SCHEMA_VERSION: u16 = 1; +// Schema 2 makes repaired-seat activation metadata downgrade-resistant. New +// binaries continue writing schema 1 until the first repair is installed; from +// that point the store is schema 2, so an older binary fails closed instead of +// silently dropping the recovered-seat binding. +pub(crate) const PERSISTED_STATE_SCHEMA_VERSION: u16 = 2; +pub(crate) const PERSISTED_STATE_SCHEMA_VERSION_LEGACY: u16 = 1; pub(crate) const PERSISTED_STATE_ENVELOPE_SCHEMA_VERSION_V2: u16 = 2; @@ -1596,16 +1604,28 @@ impl TryFrom for EngineState { type Error = EngineError; fn try_from(persisted: PersistedEngineState) -> Result { - if persisted.schema_version != PERSISTED_STATE_SCHEMA_VERSION { + if persisted.schema_version != PERSISTED_STATE_SCHEMA_VERSION + && persisted.schema_version != PERSISTED_STATE_SCHEMA_VERSION_LEGACY + { return Err(EngineError::Internal(format!( - "unsupported signer state schema version: expected [{}], got [{}]", - PERSISTED_STATE_SCHEMA_VERSION, persisted.schema_version + "unsupported signer state schema version: expected [{}] or legacy [{}], got [{}]", + PERSISTED_STATE_SCHEMA_VERSION, + PERSISTED_STATE_SCHEMA_VERSION_LEGACY, + persisted.schema_version ))); } + let schema_version = persisted.schema_version; let mut sessions = HashMap::new(); let mut key_group_owners = HashMap::::new(); for (session_id, session_state) in persisted.sessions { + if schema_version == PERSISTED_STATE_SCHEMA_VERSION_LEGACY + && !session_state.recovered_seats.is_empty() + { + return Err(EngineError::Internal( + "legacy signer state contains schema-2 recovered-seat metadata".to_string(), + )); + } let session_state: SessionState = session_state.try_into()?; if let Some(dkg_result) = session_state.dkg_result.as_ref() { if let Some(existing_owner) = @@ -1704,8 +1724,17 @@ impl TryFrom<&EngineState> for PersistedEngineState { .collect::>(); quarantined_operator_identifiers.sort_unstable(); + let schema_version = if engine_state + .sessions + .values() + .any(|session| !session.recovered_seats.is_empty()) + { + PERSISTED_STATE_SCHEMA_VERSION + } else { + PERSISTED_STATE_SCHEMA_VERSION_LEGACY + }; Ok(PersistedEngineState { - schema_version: PERSISTED_STATE_SCHEMA_VERSION, + schema_version, sessions, refresh_epoch_counter: engine_state.refresh_epoch_counter, operator_fault_scores: engine_state.operator_fault_scores.clone(), @@ -1726,6 +1755,27 @@ impl TryFrom for SessionState { persisted.dkg_share_epoch ))); } + let mut recovered_seats = BTreeMap::new(); + for recovered in persisted.recovered_seats { + if recovered.participant_identifier == 0 + || recovered.recovery_epoch == 0 + || recovered.authorization_digest == [0u8; 32] + || recovered.active_store_fingerprint == [0u8; 32] + { + return Err(EngineError::Internal( + "persisted recovered-seat metadata is incomplete".to_string(), + )); + } + let participant_identifier = recovered.participant_identifier; + if recovered_seats + .insert(participant_identifier, recovered) + .is_some() + { + return Err(EngineError::Internal(format!( + "duplicate persisted recovered-seat identifier [{participant_identifier}]" + ))); + } + } let dkg_key_packages = persisted .dkg_key_packages .map(|persisted_key_packages| { @@ -1989,6 +2039,7 @@ impl TryFrom for SessionState { dkg_public_key_package, dkg_result: persisted.dkg_result, dkg_share_epoch: persisted.dkg_share_epoch, + recovered_seats, sign_request_fingerprint: persisted.sign_request_fingerprint, sign_message_bytes, round_state: persisted.round_state, @@ -2029,6 +2080,25 @@ impl TryFrom for SessionState { authorized_interactive_aggregate_markers, aggregated_interactive_attempt_markers, }; + if !session.recovered_seats.is_empty() { + if session.dkg_result.is_none() || session.dkg_public_key_package.is_none() { + return Err(EngineError::Internal( + "persisted recovered-seat metadata has no wallet DKG owner".to_string(), + )); + } + let key_packages = session.dkg_key_packages.as_ref().ok_or_else(|| { + EngineError::Internal( + "persisted recovered-seat metadata has no key packages".to_string(), + ) + })?; + for participant_identifier in session.recovered_seats.keys() { + if !key_packages.contains_key(participant_identifier) { + return Err(EngineError::Internal(format!( + "persisted recovered seat [{participant_identifier}] has no key package" + ))); + } + } + } if session.retired_interactive_at_unix.is_some() && !per_message_interactive_session(&session) { @@ -2176,6 +2246,7 @@ impl TryFrom<&SessionState> for PersistedSessionState { dkg_public_key_package_hex, dkg_result: session_state.dkg_result.clone(), dkg_share_epoch: session_state.dkg_share_epoch, + recovered_seats: session_state.recovered_seats.values().cloned().collect(), 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/repair.rs b/pkg/tbtc/signer/src/engine/repair.rs new file mode 100644 index 0000000000..f4b63f006d --- /dev/null +++ b/pkg/tbtc/signer/src/engine/repair.rs @@ -0,0 +1,878 @@ +//! Offline-authorized repair of one lost FROST signing share. +//! +//! The upstream repairable-threshold primitive intentionally implements only +//! the scalar arithmetic. This module supplies the protocol boundary it does +//! not have: a signed, expiring context; exact helper-set validation; endpoint +//! binding for every delta and sigma; public-package commitment checks; and an +//! atomic install into the descriptor-bound signer store. + +use super::*; + +use ed25519_dalek::{Signature, VerifyingKey}; +use frost::keys::repairable::{ + repair_share_part1 as frost_repair_share_part1, repair_share_part2 as frost_repair_share_part2, + repair_share_part3 as frost_repair_share_part3, Delta, Sigma, +}; + +pub(crate) const TBTC_SIGNER_SHARE_REPAIR_AUTHORIZATION_SCHEMA: &str = + "tbtc-frost-share-repair-authorization/v1"; +pub(crate) const TBTC_SIGNER_SHARE_REPAIR_INSTALL_RESULT_SCHEMA: &str = + "tbtc-frost-share-repair-install-result/v1"; + +const SHARE_REPAIR_AUTHORIZATION_DOMAIN: &[u8] = b"tbtc-frost-share-repair-authorization/v1\0"; +const SHARE_REPAIR_MAX_AUTHORIZATION_LIFETIME_SECONDS: u64 = 24 * 60 * 60; + +#[cfg(test)] +static TEST_SHARE_REPAIR_AUTHORITY: OnceLock>> = OnceLock::new(); + +#[cfg(test)] +pub(crate) fn set_share_repair_authority_for_tests(public_key: Option<[u8; 32]>) { + *TEST_SHARE_REPAIR_AUTHORITY + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("share-repair test authority lock") = public_key; +} + +#[derive(Clone)] +struct ValidatedShareRepairAuthorization { + digest: [u8; 32], + wallet_id: [u8; 32], + compressed_key_group: [u8; 33], + public_key_package_commitment: [u8; 32], + target_identifier: frost::Identifier, + helper_identifiers: Vec, + new_store_fingerprint: [u8; 32], +} + +fn validation_error(operation: &str, detail: impl std::fmt::Display) -> EngineError { + EngineError::Validation(format!("{operation}: {detail}")) +} + +fn write_length_prefixed(digest: &mut Sha256, value: &[u8]) -> Result<(), EngineError> { + let length = u32::try_from(value.len()).map_err(|_| { + EngineError::Validation( + "share-repair authorization contains a field longer than u32::MAX".to_string(), + ) + })?; + digest.update(length.to_be_bytes()); + digest.update(value); + Ok(()) +} + +fn require_nonzero_bytes32(value: [u8; 32], label: &str) -> Result<[u8; 32], EngineError> { + if value == [0u8; 32] { + return Err(EngineError::Validation(format!("{label} must not be zero"))); + } + Ok(value) +} + +#[allow(clippy::too_many_arguments)] +fn share_repair_authorization_signing_digest( + authorization: &ShareRepairAuthorization, + wallet_id: [u8; 32], + compressed_key_group: [u8; 33], + public_key_package_commitment: [u8; 32], + old_store_fingerprint: [u8; 32], + new_store_fingerprint: [u8; 32], + nonce: [u8; 32], +) -> Result<[u8; 32], EngineError> { + let mut transcript = Sha256::new(); + transcript.update(SHARE_REPAIR_AUTHORIZATION_DOMAIN); + write_length_prefixed(&mut transcript, authorization.session_id.as_bytes())?; + transcript.update(wallet_id); + transcript.update(compressed_key_group); + transcript.update(public_key_package_commitment); + transcript.update(authorization.target_identifier.to_be_bytes()); + transcript.update( + u16::try_from(authorization.helper_identifiers.len()) + .map_err(|_| { + EngineError::Validation( + "share-repair helper count does not fit the signing transcript".to_string(), + ) + })? + .to_be_bytes(), + ); + for helper in &authorization.helper_identifiers { + transcript.update(helper.to_be_bytes()); + } + transcript.update(authorization.threshold.to_be_bytes()); + transcript.update(authorization.participant_count.to_be_bytes()); + transcript.update(old_store_fingerprint); + transcript.update(new_store_fingerprint); + transcript.update(authorization.recovery_epoch.to_be_bytes()); + transcript.update(authorization.issued_at_unix.to_be_bytes()); + transcript.update(authorization.not_before_unix.to_be_bytes()); + transcript.update(authorization.expires_at_unix.to_be_bytes()); + transcript.update(nonce); + Ok(transcript.finalize().into()) +} + +fn enforce_share_repair_authorization_time( + authorization: &ShareRepairAuthorization, +) -> Result<(), EngineError> { + let now = now_unix(); + if now == 0 { + return Err(EngineError::Internal( + "share-repair authorization: system clock is before UNIX epoch".to_string(), + )); + } + if now < authorization.not_before_unix { + return Err(EngineError::Validation(format!( + "share-repair authorization is not valid before [{}]", + authorization.not_before_unix + ))); + } + if now >= authorization.expires_at_unix { + return Err(EngineError::Validation(format!( + "share-repair authorization expired at [{}]", + authorization.expires_at_unix + ))); + } + Ok(()) +} + +fn validate_share_repair_authorization( + operation: &str, + authorization: &ShareRepairAuthorization, + enforce_time: bool, +) -> Result { + if authorization.schema != TBTC_SIGNER_SHARE_REPAIR_AUTHORIZATION_SCHEMA { + return Err(validation_error( + operation, + "unsupported share-repair authorization schema", + )); + } + validate_session_id(&authorization.session_id)?; + if authorization.threshold < 2 + || authorization.participant_count < authorization.threshold + || authorization.participant_count > 100 + { + return Err(validation_error( + operation, + format!( + "threshold [{}] must be between 2 and participant_count [{}], with at most 100 participants", + authorization.threshold, authorization.participant_count + ), + )); + } + if authorization.helper_identifiers.len() != authorization.threshold as usize { + return Err(validation_error( + operation, + format!( + "helper_identifiers must contain exactly threshold [{}] members", + authorization.threshold + ), + )); + } + if authorization.target_identifier == 0 + || authorization.target_identifier > authorization.participant_count + { + return Err(validation_error( + operation, + "target_identifier is outside the participant set", + )); + } + let mut previous_helper = 0u16; + for helper in &authorization.helper_identifiers { + if *helper == 0 || *helper > authorization.participant_count { + return Err(validation_error( + operation, + "helper identifier is outside the participant set", + )); + } + if *helper <= previous_helper { + return Err(validation_error( + operation, + "helper_identifiers must be distinct and strictly ascending", + )); + } + if *helper == authorization.target_identifier { + return Err(validation_error( + operation, + "target_identifier must not be a helper", + )); + } + previous_helper = *helper; + } + if authorization.recovery_epoch == 0 { + return Err(validation_error( + operation, + "recovery_epoch must be non-zero", + )); + } + if authorization.issued_at_unix > authorization.not_before_unix + || authorization.not_before_unix >= authorization.expires_at_unix + { + return Err(validation_error( + operation, + "authorization timestamps are not ordered", + )); + } + let lifetime = authorization + .expires_at_unix + .checked_sub(authorization.issued_at_unix) + .ok_or_else(|| validation_error(operation, "authorization lifetime underflow"))?; + if lifetime > SHARE_REPAIR_MAX_AUTHORIZATION_LIFETIME_SECONDS { + return Err(validation_error( + operation, + format!( + "authorization lifetime exceeds [{}] seconds", + SHARE_REPAIR_MAX_AUTHORIZATION_LIFETIME_SECONDS + ), + )); + } + + let wallet_id = require_nonzero_bytes32( + parse_canonical_bytes32(&authorization.wallet_id, "wallet_id")?, + "wallet_id", + )?; + let public_key_package_commitment = require_nonzero_bytes32( + parse_canonical_bytes32( + &authorization.public_key_package_commitment, + "public_key_package_commitment", + )?, + "public_key_package_commitment", + )?; + let old_store_fingerprint = require_nonzero_bytes32( + parse_canonical_bytes32( + &authorization.old_store_fingerprint, + "old_store_fingerprint", + )?, + "old_store_fingerprint", + )?; + let new_store_fingerprint = require_nonzero_bytes32( + parse_canonical_bytes32( + &authorization.new_store_fingerprint, + "new_store_fingerprint", + )?, + "new_store_fingerprint", + )?; + if old_store_fingerprint == new_store_fingerprint { + return Err(validation_error( + operation, + "old_store_fingerprint and new_store_fingerprint must differ", + )); + } + let nonce = require_nonzero_bytes32( + parse_canonical_bytes32(&authorization.nonce, "nonce")?, + "nonce", + )?; + + let (derived_wallet_id, compressed_key_group) = + super::inventory::parse_key_group(&authorization.key_group).map_err(|error| { + validation_error( + operation, + format!("key_group is not canonical compressed SEC1: {error}"), + ) + })?; + if derived_wallet_id != wallet_id { + return Err(validation_error( + operation, + "wallet_id does not match key_group", + )); + } + + let target_identifier = + participant_identifier_to_frost_identifier(authorization.target_identifier)?; + let helper_identifiers = authorization + .helper_identifiers + .iter() + .copied() + .map(participant_identifier_to_frost_identifier) + .collect::, _>>()?; + + let digest = share_repair_authorization_signing_digest( + authorization, + wallet_id, + compressed_key_group, + public_key_package_commitment, + old_store_fingerprint, + new_store_fingerprint, + nonce, + )?; + + #[cfg(test)] + let test_authority = TEST_SHARE_REPAIR_AUTHORITY + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("share-repair test authority lock") + .as_ref() + .copied(); + #[cfg(not(test))] + let test_authority: Option<[u8; 32]> = None; + let authority_public_key = if let Some(test_authority) = test_authority { + test_authority + } else { + let configuration = configured_state_anchor()?.ok_or_else(|| { + validation_error( + operation, + "state-anchor trust configuration is required for share repair", + ) + })?; + configuration + .trust + .ok_or_else(|| { + validation_error( + operation, + "offline-authority trust configuration is required for share repair", + ) + })? + .offline_authority_public_key + }; + let signature = parse_canonical_signature(&authorization.signature_hex)?; + let verifying_key = VerifyingKey::from_bytes(&authority_public_key).map_err(|error| { + EngineError::Internal(format!( + "configured share-repair authority key is invalid: {error}" + )) + })?; + verifying_key + .verify_strict(&digest, &Signature::from_bytes(&signature)) + .map_err(|_| validation_error(operation, "authorization signature is invalid"))?; + + if enforce_time { + enforce_share_repair_authorization_time(authorization)?; + } + + Ok(ValidatedShareRepairAuthorization { + digest, + wallet_id, + compressed_key_group, + public_key_package_commitment, + target_identifier, + helper_identifiers, + new_store_fingerprint, + }) +} + +#[cfg(test)] +pub(crate) fn share_repair_authorization_digest_for_tests( + authorization: &ShareRepairAuthorization, +) -> Result<[u8; 32], EngineError> { + let wallet_id = parse_canonical_bytes32(&authorization.wallet_id, "wallet_id")?; + let (_, compressed_key_group) = super::inventory::parse_key_group(&authorization.key_group)?; + let public_key_package_commitment = parse_canonical_bytes32( + &authorization.public_key_package_commitment, + "public_key_package_commitment", + )?; + let old_store_fingerprint = parse_canonical_bytes32( + &authorization.old_store_fingerprint, + "old_store_fingerprint", + )?; + let new_store_fingerprint = parse_canonical_bytes32( + &authorization.new_store_fingerprint, + "new_store_fingerprint", + )?; + let nonce = parse_canonical_bytes32(&authorization.nonce, "nonce")?; + share_repair_authorization_signing_digest( + authorization, + wallet_id, + compressed_key_group, + public_key_package_commitment, + old_store_fingerprint, + new_store_fingerprint, + nonce, + ) +} + +fn validate_public_key_package( + operation: &str, + authorization: &ShareRepairAuthorization, + validated: &ValidatedShareRepairAuthorization, + public_key_package: &NativeFrostPublicKeyPackage, +) -> Result<(frost::keys::PublicKeyPackage, frost::keys::PublicKeyPackage), EngineError> { + let stored_shape = native_public_key_package_to_frost(operation, public_key_package)?; + if stored_shape.max_signers() != authorization.participant_count { + return Err(validation_error( + operation, + format!( + "public key package has [{}] participants; expected [{}]", + stored_shape.max_signers(), + authorization.participant_count + ), + )); + } + for identifier in stored_shape.verifying_shares().keys() { + let participant = frost_identifier_to_u16(*identifier).ok_or_else(|| { + validation_error( + operation, + "public key package contains a non-canonical participant identifier", + ) + })?; + if participant == 0 || participant > authorization.participant_count { + return Err(validation_error( + operation, + "public key package identifier is outside the participant set", + )); + } + } + if !stored_shape + .verifying_shares() + .contains_key(&validated.target_identifier) + || validated + .helper_identifiers + .iter() + .any(|helper| !stored_shape.verifying_shares().contains_key(helper)) + { + return Err(validation_error( + operation, + "public key package does not contain the authorized target and helper set", + )); + } + + let serialized_group_key = stored_shape.verifying_key().serialize().map_err(|error| { + EngineError::Internal(format!( + "{operation}: failed to serialize public verifying key: {error}" + )) + })?; + if serialized_group_key.as_slice() != validated.compressed_key_group { + return Err(validation_error( + operation, + "public key package verifying key does not match key_group", + )); + } + let serialized_public_package = stored_shape.serialize().map_err(|error| { + EngineError::Internal(format!( + "{operation}: failed to serialize public key package: {error}" + )) + })?; + let commitment = public_key_package_commitment( + &validated.wallet_id, + &authorization.key_group, + authorization.threshold, + authorization.participant_count, + 0, + &serialized_public_package, + ); + if commitment != validated.public_key_package_commitment { + return Err(validation_error( + operation, + "public key package does not match its authorized commitment", + )); + } + + let repair_shape = frost::keys::PublicKeyPackage::new( + stored_shape.verifying_shares().clone(), + *stored_shape.verifying_key(), + Some(authorization.threshold), + ); + Ok((stored_shape, repair_shape)) +} + +fn load_helper_material( + operation: &str, + authorization: &ShareRepairAuthorization, + validated: &ValidatedShareRepairAuthorization, + helper_identifier: u16, +) -> Result<(frost::keys::KeyPackage, frost::keys::PublicKeyPackage), EngineError> { + if authorization + .helper_identifiers + .binary_search(&helper_identifier) + .is_err() + { + return Err(validation_error( + operation, + "helper_identifier is not in the authorized helper set", + )); + } + let guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + let session = guard + .sessions + .get(&authorization.session_id) + .ok_or_else(|| EngineError::SessionNotFound { + session_id: authorization.session_id.clone(), + })?; + let dkg_result = session + .dkg_result + .as_ref() + .ok_or_else(|| EngineError::DkgNotReady { + session_id: authorization.session_id.clone(), + })?; + if dkg_result.key_group != authorization.key_group + || dkg_result.threshold != authorization.threshold + || dkg_result.participant_count != authorization.participant_count + || session.dkg_share_epoch != 0 + { + return Err(validation_error( + operation, + "authorization does not match the retained DKG session", + )); + } + let stored_public = session.dkg_public_key_package.clone().ok_or_else(|| { + EngineError::Internal(format!( + "{operation}: retained DKG session has no public key package" + )) + })?; + let native_public = native_public_key_package_from_frost(&stored_public)?; + let (validated_stored_public, _) = + validate_public_key_package(operation, authorization, validated, &native_public)?; + if validated_stored_public != stored_public { + return Err(EngineError::Internal(format!( + "{operation}: retained public key package failed canonical round trip" + ))); + } + let key_package = session + .dkg_key_packages + .as_ref() + .and_then(|packages| packages.get(&helper_identifier)) + .cloned() + .ok_or_else(|| { + validation_error( + operation, + format!("local store has no key package for helper [{helper_identifier}]"), + ) + })?; + let frost_helper = participant_identifier_to_frost_identifier(helper_identifier)?; + if *key_package.identifier() != frost_helper + || *key_package.min_signers() != authorization.threshold + || key_package.verifying_key() != stored_public.verifying_key() + || stored_public.verifying_shares().get(&frost_helper) + != Some(key_package.verifying_share()) + { + return Err(EngineError::Internal(format!( + "{operation}: retained helper key package is inconsistent with its DKG session" + ))); + } + Ok((key_package, stored_public)) +} + +fn decode_repair_delta( + operation: &str, + index: usize, + value: &SecretHex, +) -> Result { + let wire = value.expose_secret(); + if wire.len() != 64 || wire.bytes().any(|byte| byte.is_ascii_uppercase()) { + return Err(validation_error( + operation, + format!("deltas[{index}].data_hex must be canonical lowercase 32-byte hex"), + )); + } + let mut bytes = decode_hex_field(operation, &format!("deltas[{index}].data_hex"), wire)?; + let result = Delta::deserialize(&bytes).map_err(|error| { + validation_error( + operation, + format!("invalid repair delta [{index}]: {error}"), + ) + }); + bytes.zeroize(); + result +} + +fn decode_repair_sigma( + operation: &str, + index: usize, + value: &SecretHex, +) -> Result { + let wire = value.expose_secret(); + if wire.len() != 64 || wire.bytes().any(|byte| byte.is_ascii_uppercase()) { + return Err(validation_error( + operation, + format!("sigmas[{index}].data_hex must be canonical lowercase 32-byte hex"), + )); + } + let mut bytes = decode_hex_field(operation, &format!("sigmas[{index}].data_hex"), wire)?; + let result = Sigma::deserialize(&bytes).map_err(|error| { + validation_error( + operation, + format!("invalid repair sigma [{index}]: {error}"), + ) + }); + bytes.zeroize(); + result +} + +pub(crate) fn share_repair_part1( + request: ShareRepairPart1Request, +) -> Result { + const OP: &str = "share_repair_part1"; + enforce_provenance_gate()?; + let validated = validate_share_repair_authorization(OP, &request.authorization, true)?; + let (key_package, stored_public_key_package) = load_helper_material( + OP, + &request.authorization, + &validated, + request.helper_identifier, + )?; + let mut rng = zeroizing_rng_from_os(); + let deltas = frost_repair_share_part1::( + &validated.helper_identifiers, + &key_package, + &mut rng, + validated.target_identifier, + ) + .map_err(|error| validation_error(OP, format!("share repair part1 failed: {error}")))?; + if deltas.len() != validated.helper_identifiers.len() { + return Err(EngineError::Internal(format!( + "{OP}: repair primitive returned an incomplete delta set" + ))); + } + + let context_digest = bytes32_hex(validated.digest); + let mut result_deltas = Vec::with_capacity(deltas.len()); + for (recipient, delta) in deltas { + let recipient_identifier = frost_identifier_to_u16(recipient).ok_or_else(|| { + EngineError::Internal(format!( + "{OP}: repair primitive returned a foreign identifier" + )) + })?; + let mut bytes = delta.serialize(); + let data_hex = SecretHex::new(hex::encode(&bytes)); + bytes.zeroize(); + result_deltas.push(ShareRepairDelta { + context_digest: context_digest.clone(), + sender_identifier: request.helper_identifier, + recipient_identifier, + data_hex, + }); + } + Ok(ShareRepairPart1Result { + context_digest, + helper_identifier: request.helper_identifier, + public_key_package: native_public_key_package_from_frost(&stored_public_key_package)?, + deltas: result_deltas, + }) +} + +pub(crate) fn share_repair_part2( + request: ShareRepairPart2Request, +) -> Result { + const OP: &str = "share_repair_part2"; + enforce_provenance_gate()?; + let validated = validate_share_repair_authorization(OP, &request.authorization, true)?; + // Loading the selected helper's key is a possession/admission check even + // though Part2 itself only sums incoming scalars. + let _ = load_helper_material( + OP, + &request.authorization, + &validated, + request.helper_identifier, + )?; + if request.deltas.len() != request.authorization.helper_identifiers.len() { + return Err(validation_error( + OP, + "deltas must contain exactly one value from every authorized helper", + )); + } + let context_digest = bytes32_hex(validated.digest); + let mut decoded = Vec::with_capacity(request.deltas.len()); + for (index, (delta, expected_sender)) in request + .deltas + .iter() + .zip(request.authorization.helper_identifiers.iter()) + .enumerate() + { + if delta.context_digest != context_digest + || delta.sender_identifier != *expected_sender + || delta.recipient_identifier != request.helper_identifier + { + return Err(validation_error( + OP, + format!("delta [{index}] has the wrong context, sender, or recipient"), + )); + } + decoded.push(decode_repair_delta(OP, index, &delta.data_hex)?); + } + let sigma = frost_repair_share_part2(&decoded); + let mut bytes = sigma.serialize(); + let data_hex = SecretHex::new(hex::encode(&bytes)); + bytes.zeroize(); + Ok(ShareRepairPart2Result { + context_digest: context_digest.clone(), + sigma: ShareRepairSigma { + context_digest, + helper_identifier: request.helper_identifier, + data_hex, + }, + }) +} + +fn exact_installed_repair( + authorization: &ShareRepairAuthorization, + validated: &ValidatedShareRepairAuthorization, + public_key_package: &frost::keys::PublicKeyPackage, +) -> Result, EngineError> { + let guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + let Some(session) = guard.sessions.get(&authorization.session_id) else { + return Ok(None); + }; + let Some(recovered) = session + .recovered_seats + .get(&authorization.target_identifier) + else { + return Ok(None); + }; + if recovered.recovery_epoch != authorization.recovery_epoch + || recovered.authorization_digest != validated.digest + || recovered.active_store_fingerprint != validated.new_store_fingerprint + { + return Ok(None); + } + let result = session.dkg_result.as_ref().ok_or_else(|| { + EngineError::Internal("recovered seat has no retained DKG result".to_string()) + })?; + let key_package = session + .dkg_key_packages + .as_ref() + .and_then(|packages| packages.get(&authorization.target_identifier)) + .ok_or_else(|| { + EngineError::Internal("recovered seat has no retained key package".to_string()) + })?; + if result.key_group != authorization.key_group + || result.threshold != authorization.threshold + || result.participant_count != authorization.participant_count + || session.dkg_public_key_package.as_ref() != Some(public_key_package) + || *key_package.identifier() != validated.target_identifier + || public_key_package + .verifying_shares() + .get(&validated.target_identifier) + != Some(key_package.verifying_share()) + { + return Err(EngineError::Internal( + "recovered-seat metadata is inconsistent with retained key material".to_string(), + )); + } + Ok(Some(result.clone())) +} + +fn install_result( + authorization: &ShareRepairAuthorization, + validated: &ValidatedShareRepairAuthorization, + result: DkgResult, + idempotent: bool, +) -> InstallRepairedShareResult { + InstallRepairedShareResult { + schema: TBTC_SIGNER_SHARE_REPAIR_INSTALL_RESULT_SCHEMA.to_string(), + session_id: result.session_id, + key_group: result.key_group, + target_identifier: authorization.target_identifier, + recovery_epoch: authorization.recovery_epoch, + authorization_digest: bytes32_hex(validated.digest), + active_store_fingerprint: bytes32_hex(validated.new_store_fingerprint), + idempotent, + } +} + +pub(crate) fn install_repaired_share( + request: InstallRepairedShareRequest, +) -> Result { + const OP: &str = "install_repaired_share"; + enforce_provenance_gate()?; + // Verify the static certificate first. The endpoint recognizes an exact + // already-committed replay after expiry as defense in depth; normal + // recovery from an uncertain external-anchor outcome is process restart + // plus authenticated startup reconciliation. Expiry still gates every + // initial installation and all helper-side secret generation. + let validated = validate_share_repair_authorization(OP, &request.authorization, false)?; + let (stored_public, repair_public) = validate_public_key_package( + OP, + &request.authorization, + &validated, + &request.public_key_package, + )?; + let current_store_fingerprint = durable_store_identity()?.fingerprint; + if current_store_fingerprint != validated.new_store_fingerprint { + return Err(validation_error( + OP, + "authorization does not name the active durable store", + )); + } + if let Some(result) = + exact_installed_repair(&request.authorization, &validated, &stored_public)? + { + return Ok(install_result( + &request.authorization, + &validated, + result, + true, + )); + } + enforce_share_repair_authorization_time(&request.authorization)?; + + if request.sigmas.len() != request.authorization.helper_identifiers.len() { + return Err(validation_error( + OP, + "sigmas must contain exactly one value from every authorized helper", + )); + } + let context_digest = bytes32_hex(validated.digest); + let mut decoded = Vec::with_capacity(request.sigmas.len()); + for (index, (sigma, expected_helper)) in request + .sigmas + .iter() + .zip(request.authorization.helper_identifiers.iter()) + .enumerate() + { + if sigma.context_digest != context_digest || sigma.helper_identifier != *expected_helper { + return Err(validation_error( + OP, + format!("sigma [{index}] has the wrong context or helper"), + )); + } + decoded.push(decode_repair_sigma(OP, index, &sigma.data_hex)?); + } + let key_package = + frost_repair_share_part3(&decoded, validated.target_identifier, &repair_public) + .map_err(|error| validation_error(OP, format!("share repair part3 failed: {error}")))?; + let expected_verifying_share = stored_public + .verifying_shares() + .get(&validated.target_identifier) + .ok_or_else(|| { + EngineError::Internal(format!("{OP}: target verifying share disappeared")) + })?; + if key_package.verifying_share() != expected_verifying_share + || key_package.verifying_key() != stored_public.verifying_key() + || *key_package.min_signers() != request.authorization.threshold + { + return Err(validation_error( + OP, + "reconstructed share does not match the authorized public key package", + )); + } + let mut signing_share = *key_package.signing_share(); + let derives = frost::keys::VerifyingShare::from(signing_share) == *expected_verifying_share; + signing_share.zeroize(); + if !derives { + return Err(validation_error( + OP, + "reconstructed signing share does not derive to the target verifying share", + )); + } + + let mut key_package_bytes = key_package.serialize().map_err(|error| { + EngineError::Internal(format!( + "{OP}: failed to serialize repaired key package: {error}" + )) + })?; + let key_package_hex = SecretHex::new(hex::encode(&key_package_bytes)); + key_package_bytes.zeroize(); + let persistence_request = PersistDistributedDkgKeyPackageRequest { + session_id: request.authorization.session_id.clone(), + participant_identifier: request.authorization.target_identifier, + threshold: request.authorization.threshold, + participant_count: request.authorization.participant_count, + key_package: NativeFrostKeyPackage { + identifier: frost_identifier_to_go_string(validated.target_identifier), + data_hex: key_package_hex, + }, + public_key_package: request.public_key_package, + }; + let outcome = persist_repaired_dkg_key_package( + persistence_request, + RecoveredSeatState { + participant_identifier: request.authorization.target_identifier, + recovery_epoch: request.authorization.recovery_epoch, + authorization_digest: validated.digest, + active_store_fingerprint: validated.new_store_fingerprint, + }, + )?; + Ok(install_result( + &request.authorization, + &validated, + outcome.result, + outcome.idempotent, + )) +} diff --git a/pkg/tbtc/signer/src/engine/state.rs b/pkg/tbtc/signer/src/engine/state.rs index ac238cc660..ee5bfdce4f 100644 --- a/pkg/tbtc/signer/src/engine/state.rs +++ b/pkg/tbtc/signer/src/engine/state.rs @@ -96,6 +96,19 @@ impl Drop for InteractiveRound1State { } } +/// Durable activation metadata for a share reconstructed into this store. +/// `recovery_epoch` is operational, not a cryptographic share epoch: repair +/// recreates the same Shamir share and public package. The signed authorization +/// digest and store fingerprint let the Go activation layer bind this seat to +/// exactly one repaired store instance. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct RecoveredSeatState { + pub(crate) participant_identifier: u16, + pub(crate) recovery_epoch: u64, + pub(crate) authorization_digest: [u8; 32], + pub(crate) active_store_fingerprint: [u8; 32], +} + #[derive(Default)] pub(crate) struct SessionState { pub(crate) dkg_request_fingerprint: Option, @@ -106,6 +119,10 @@ pub(crate) struct SessionState { /// 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, + /// Per-seat operational recovery/cutover metadata. This is intentionally + /// separate from `dkg_share_epoch`: repairing a lost share does not rotate + /// the wallet polynomial or invalidate the original share. + pub(crate) recovered_seats: BTreeMap, pub(crate) sign_request_fingerprint: Option, pub(crate) sign_message_bytes: Option, pub(crate) round_state: Option, @@ -665,6 +682,7 @@ pub(crate) fn per_message_interactive_session(session: &SessionState) -> bool { dkg_public_key_package, dkg_result, dkg_share_epoch, + recovered_seats, sign_request_fingerprint, sign_message_bytes, round_state, @@ -720,6 +738,7 @@ pub(crate) fn per_message_interactive_session(session: &SessionState) -> bool { authorized_interactive_aggregate_markers, aggregated_interactive_attempt_markers, dkg_share_epoch, + recovered_seats, ); bound_key_group.is_some() @@ -728,6 +747,7 @@ pub(crate) fn per_message_interactive_session(session: &SessionState) -> bool { && dkg_public_key_package.is_none() && dkg_result.is_none() && *dkg_share_epoch == 0 + && recovered_seats.is_empty() } pub(crate) fn retire_idle_per_message_sessions( diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index f34d6a25d4..1a0a4dba49 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -743,6 +743,7 @@ fn persisted_session_state_fixture() -> PersistedSessionState { dkg_public_key_package_hex: None, dkg_result: None, dkg_share_epoch: 0, + recovered_seats: Vec::new(), sign_request_fingerprint: None, sign_message_hex: None, round_state: None, @@ -1130,14 +1131,24 @@ fn sample_distributed_dkg_native_material( crate::api::NativeFrostPublicKeyPackage, BTreeMap, ) { - let identifiers = [1_u16, 2, 3] - .iter() - .map(|m| participant_identifier_to_frost_identifier(*m).expect("frost identifier")) + sample_distributed_dkg_native_material_with_parameters(seed, 3, 2) +} + +fn sample_distributed_dkg_native_material_with_parameters( + seed: u8, + participant_count: u16, + threshold: u16, +) -> ( + crate::api::NativeFrostPublicKeyPackage, + BTreeMap, +) { + let identifiers = (1..=participant_count) + .map(|member| participant_identifier_to_frost_identifier(member).expect("frost identifier")) .collect::>(); let rng = ZeroizingChaCha20Rng::from_seed([seed; 32]); let (shares, public_key_package) = frost::keys::generate_with_dealer( - 3, - 2, + participant_count, + threshold, frost::keys::IdentifierList::Custom(&identifiers), rng, ) @@ -1152,7 +1163,7 @@ fn sample_distributed_dkg_native_material( native_public_key_package_from_frost(&public_key_package).expect("native public package"); let mut native_key_packages = BTreeMap::new(); - for member in [1_u16, 2, 3] { + for member in 1..=participant_count { let frost_id = participant_identifier_to_frost_identifier(member).expect("frost identifier"); let share = shares.get(&frost_id).expect("share for member").clone(); @@ -1172,6 +1183,522 @@ fn sample_distributed_dkg_native_material( (native_public, native_key_packages) } +fn signed_share_repair_authorization( + signing_key: &ed25519_dalek::SigningKey, + session_id: &str, + key_group: &str, + public_key_package: &NativeFrostPublicKeyPackage, + new_store_fingerprint: [u8; 32], +) -> ShareRepairAuthorization { + signed_share_repair_authorization_with_shape( + signing_key, + session_id, + key_group, + public_key_package, + new_store_fingerprint, + 2, + 3, + 3, + vec![1, 2], + ) +} + +#[allow(clippy::too_many_arguments)] +fn signed_share_repair_authorization_with_shape( + signing_key: &ed25519_dalek::SigningKey, + session_id: &str, + key_group: &str, + public_key_package: &NativeFrostPublicKeyPackage, + new_store_fingerprint: [u8; 32], + threshold: u16, + participant_count: u16, + target_identifier: u16, + helper_identifiers: Vec, +) -> ShareRepairAuthorization { + use ed25519_dalek::Signer as _; + + let stored_public = + native_public_key_package_to_frost("repair-test", public_key_package).expect("public"); + let serialized_public = stored_public.serialize().expect("serialize public"); + let (wallet_id, _) = super::inventory::parse_key_group(key_group).expect("key group"); + let public_commitment = public_key_package_commitment( + &wallet_id, + key_group, + threshold, + participant_count, + 0, + &serialized_public, + ); + let now = now_unix(); + let mut authorization = ShareRepairAuthorization { + schema: TBTC_SIGNER_SHARE_REPAIR_AUTHORIZATION_SCHEMA.to_string(), + session_id: session_id.to_string(), + wallet_id: bytes32_hex(wallet_id), + key_group: key_group.to_string(), + public_key_package_commitment: bytes32_hex(public_commitment), + target_identifier, + helper_identifiers, + threshold, + participant_count, + old_store_fingerprint: bytes32_hex([0x91; 32]), + new_store_fingerprint: bytes32_hex(new_store_fingerprint), + recovery_epoch: 1, + issued_at_unix: now.saturating_sub(1), + not_before_unix: now.saturating_sub(1), + expires_at_unix: now.saturating_add(3600), + nonce: bytes32_hex([0x92; 32]), + signature_hex: format!("0x{}", "00".repeat(64)), + }; + let digest = + share_repair_authorization_digest_for_tests(&authorization).expect("authorization digest"); + authorization.signature_hex = + format!("0x{}", hex::encode(signing_key.sign(&digest).to_bytes())); + authorization +} + +#[test] +fn share_repair_authorization_digest_matches_go_frozen_vector() { + let secp = bitcoin::secp256k1::Secp256k1::new(); + let secret_key = bitcoin::secp256k1::SecretKey::from_slice(&[0x09; 32]).expect("secret key"); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + let compressed = public_key.serialize(); + let authorization = ShareRepairAuthorization { + schema: TBTC_SIGNER_SHARE_REPAIR_AUTHORIZATION_SCHEMA.to_string(), + session_id: "repair-wallet-a-seat-3-epoch-1".to_string(), + wallet_id: format!("0x{}", hex::encode(&compressed[1..])), + key_group: hex::encode(compressed), + public_key_package_commitment: bytes32_hex([0x31; 32]), + target_identifier: 3, + helper_identifiers: vec![1, 2], + threshold: 2, + participant_count: 3, + old_store_fingerprint: bytes32_hex([0x51; 32]), + new_store_fingerprint: bytes32_hex([0x52; 32]), + recovery_epoch: 1, + issued_at_unix: 1_700_000_000, + not_before_unix: 1_700_000_000, + expires_at_unix: 1_700_003_600, + nonce: bytes32_hex([0x61; 32]), + signature_hex: format!("0x{}", "00".repeat(64)), + }; + let digest = + share_repair_authorization_digest_for_tests(&authorization).expect("authorization digest"); + assert_eq!( + "aa8e36cbf287d988c6ed34bf0c38fd64c177500c768fbd3ea7c184b031d7511b", + hex::encode(digest) + ); +} + +fn resign_share_repair_authorization( + authorization: &mut ShareRepairAuthorization, + signing_key: &ed25519_dalek::SigningKey, +) { + use ed25519_dalek::Signer as _; + + let digest = + share_repair_authorization_digest_for_tests(authorization).expect("authorization digest"); + authorization.signature_hex = + format!("0x{}", hex::encode(signing_key.sign(&digest).to_bytes())); +} + +fn share_repair_sigmas( + authorization: &ShareRepairAuthorization, +) -> (Vec, Vec) { + let part1 = authorization + .helper_identifiers + .iter() + .map(|helper| { + share_repair_part1(ShareRepairPart1Request { + authorization: authorization.clone(), + helper_identifier: *helper, + }) + .expect("repair part1") + }) + .collect::>(); + + let sigmas = authorization + .helper_identifiers + .iter() + .map(|recipient| { + let deltas = authorization + .helper_identifiers + .iter() + .map(|sender| { + part1 + .iter() + .find(|result| result.helper_identifier == *sender) + .and_then(|result| { + result + .deltas + .iter() + .find(|delta| delta.recipient_identifier == *recipient) + }) + .expect("sender/recipient delta") + .clone() + }) + .collect(); + share_repair_part2(ShareRepairPart2Request { + authorization: authorization.clone(), + helper_identifier: *recipient, + deltas, + }) + .expect("repair part2") + .sigma + }) + .collect(); + (part1, sigmas) +} + +#[test] +fn share_repair_installs_exact_share_durably_and_replays_idempotently() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("share_repair_happy_path"); + reset_for_tests(); + + let signing_key = ed25519_dalek::SigningKey::from_bytes(&[0xa5; 32]); + set_share_repair_authority_for_tests(Some(signing_key.verifying_key().to_bytes())); + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(37); + let session_id = "share-repair-wallet-owner"; + let mut persisted = None; + for helper in [1_u16, 2] { + persisted = Some( + persist_distributed_dkg_key_package(PersistDistributedDkgKeyPackageRequest { + session_id: session_id.to_string(), + participant_identifier: helper, + threshold: 2, + participant_count: 3, + key_package: native_key_packages[&helper].clone(), + public_key_package: native_public.clone(), + }) + .expect("persist helper"), + ); + } + let persisted = persisted.expect("persisted DKG"); + let store_fingerprint = durable_store_identity() + .expect("store identity") + .fingerprint; + let authorization = signed_share_repair_authorization( + &signing_key, + session_id, + &persisted.key_group, + &native_public, + store_fingerprint, + ); + let (_, sigmas) = share_repair_sigmas(&authorization); + let install_request = InstallRepairedShareRequest { + authorization: authorization.clone(), + public_key_package: native_public.clone(), + sigmas, + }; + let installed = + install_repaired_share(install_request.clone()).expect("install repaired share"); + assert!(!installed.idempotent); + assert_eq!(installed.target_identifier, 3); + assert_eq!( + installed.active_store_fingerprint, + bytes32_hex(store_fingerprint) + ); + let recovered_inventory = + retained_key_package_inventory().expect("recovered retained inventory"); + assert_eq!( + recovered_inventory.schema, + TBTC_SIGNER_RETAINED_KEY_PACKAGE_INVENTORY_RECOVERY_SCHEMA + ); + assert_eq!(recovered_inventory.recovered_seats.len(), 1); + assert_eq!( + recovered_inventory.recovered_seats[0].authorization_digest, + installed.authorization_digest + ); + assert_eq!( + recovered_inventory.recovered_seats[0].active_store_fingerprint, + installed.active_store_fingerprint + ); + assert!(recovered_inventory.recovery_activation_commitment.is_some()); + + let expected = decode_key_package( + "repair-test", + &native_key_packages[&3].identifier, + native_key_packages[&3].data_hex.expose_secret(), + ) + .expect("decode expected target share"); + { + let guard = state().expect("state").lock().expect("engine lock"); + let session = &guard.sessions[session_id]; + assert_eq!( + session.dkg_key_packages.as_ref().expect("packages")[&3], + expected + ); + let recovered = &session.recovered_seats[&3]; + assert_eq!(recovered.recovery_epoch, 1); + assert_eq!( + bytes32_hex(recovered.authorization_digest), + installed.authorization_digest + ); + assert_eq!(recovered.active_store_fingerprint, store_fingerprint); + assert_eq!(session.dkg_share_epoch, 0); + let persisted_state = PersistedEngineState::try_from(&*guard).expect("persisted state"); + assert_eq!( + persisted_state.schema_version, + PERSISTED_STATE_SCHEMA_VERSION + ); + assert_eq!( + persisted_state.sessions[session_id].recovered_seats, + vec![recovered.clone()] + ); + let mut forged_legacy = persisted_state; + forged_legacy.schema_version = PERSISTED_STATE_SCHEMA_VERSION_LEGACY; + assert!(matches!( + EngineState::try_from(forged_legacy), + Err(EngineError::Internal(message)) + if message.contains("legacy signer state contains schema-2") + )); + } + + let mut replay = install_request; + replay.sigmas.clear(); + let replayed = install_repaired_share(replay).expect("exact installed replay"); + assert!(replayed.idempotent); + assert_eq!( + replayed.authorization_digest, + installed.authorization_digest + ); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let guard = state().expect("state").lock().expect("engine lock"); + assert_eq!( + guard.sessions[session_id] + .dkg_key_packages + .as_ref() + .unwrap()[&3], + expected + ); + assert_eq!( + guard.sessions[session_id].recovered_seats[&3].recovery_epoch, + 1 + ); + drop(guard); + let restarted_inventory = + retained_key_package_inventory().expect("restarted recovered inventory"); + assert_eq!( + restarted_inventory.recovery_activation_commitment, + recovered_inventory.recovery_activation_commitment + ); + + set_share_repair_authority_for_tests(None); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +#[ignore = "production-scale 51-of-100 share-repair launch gate"] +fn share_repair_production_scale_51_of_100_launch_gate() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("share_repair_51_of_100"); + reset_for_tests(); + + let signing_key = ed25519_dalek::SigningKey::from_bytes(&[0xb5; 32]); + set_share_repair_authority_for_tests(Some(signing_key.verifying_key().to_bytes())); + let (native_public, native_key_packages) = + sample_distributed_dkg_native_material_with_parameters(73, 100, 51); + let session_id = "share-repair-production-scale-wallet"; + let mut persisted = None; + for helper in 1_u16..=51 { + persisted = Some( + persist_distributed_dkg_key_package(PersistDistributedDkgKeyPackageRequest { + session_id: session_id.to_string(), + participant_identifier: helper, + threshold: 51, + participant_count: 100, + key_package: native_key_packages[&helper].clone(), + public_key_package: native_public.clone(), + }) + .expect("persist production-scale helper"), + ); + } + let persisted = persisted.expect("persisted production-scale DKG"); + let store_fingerprint = durable_store_identity() + .expect("store identity") + .fingerprint; + let authorization = signed_share_repair_authorization_with_shape( + &signing_key, + session_id, + &persisted.key_group, + &native_public, + store_fingerprint, + 51, + 100, + 100, + (1_u16..=51).collect(), + ); + let (_, sigmas) = share_repair_sigmas(&authorization); + let installed = install_repaired_share(InstallRepairedShareRequest { + authorization, + public_key_package: native_public, + sigmas, + }) + .expect("install production-scale repaired share"); + assert_eq!(installed.target_identifier, 100); + + let expected = decode_key_package( + "repair-production-scale", + &native_key_packages[&100].identifier, + native_key_packages[&100].data_hex.expose_secret(), + ) + .expect("decode expected production-scale target share"); + let guard = state().expect("state").lock().expect("engine lock"); + assert_eq!( + guard.sessions[session_id] + .dkg_key_packages + .as_ref() + .expect("production-scale packages")[&100], + expected + ); + drop(guard); + let inventory = retained_key_package_inventory().expect("production-scale inventory"); + assert_eq!( + inventory.schema, + TBTC_SIGNER_RETAINED_KEY_PACKAGE_INVENTORY_RECOVERY_SCHEMA + ); + assert_eq!(inventory.recovered_seats.len(), 1); + + set_share_repair_authority_for_tests(None); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + +#[test] +fn share_repair_rejects_incomplete_cross_context_corrupt_and_wrong_store_inputs() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("share_repair_rejections"); + reset_for_tests(); + + let signing_key = ed25519_dalek::SigningKey::from_bytes(&[0xa6; 32]); + set_share_repair_authority_for_tests(Some(signing_key.verifying_key().to_bytes())); + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(41); + let session_id = "share-repair-rejection-wallet"; + let mut persisted = None; + for helper in [1_u16, 2] { + persisted = Some( + persist_distributed_dkg_key_package(PersistDistributedDkgKeyPackageRequest { + session_id: session_id.to_string(), + participant_identifier: helper, + threshold: 2, + participant_count: 3, + key_package: native_key_packages[&helper].clone(), + public_key_package: native_public.clone(), + }) + .expect("persist helper"), + ); + } + let persisted = persisted.unwrap(); + let store_fingerprint = durable_store_identity() + .expect("store identity") + .fingerprint; + let authorization = signed_share_repair_authorization( + &signing_key, + session_id, + &persisted.key_group, + &native_public, + store_fingerprint, + ); + let (part1, sigmas) = share_repair_sigmas(&authorization); + + let incomplete = share_repair_part2(ShareRepairPart2Request { + authorization: authorization.clone(), + helper_identifier: 1, + deltas: vec![part1[0].deltas[0].clone()], + }) + .expect_err("incomplete delta set"); + assert!(matches!(incomplete, EngineError::Validation(_))); + + let mut cross_context = part1[0].deltas[0].clone(); + cross_context.context_digest = bytes32_hex([0x55; 32]); + let cross_context_error = share_repair_part2(ShareRepairPart2Request { + authorization: authorization.clone(), + helper_identifier: 1, + deltas: vec![cross_context, part1[1].deltas[0].clone()], + }) + .expect_err("cross-context delta"); + assert!(matches!(cross_context_error, EngineError::Validation(_))); + + let missing_sigma = install_repaired_share(InstallRepairedShareRequest { + authorization: authorization.clone(), + public_key_package: native_public.clone(), + sigmas: vec![sigmas[0].clone()], + }) + .expect_err("incomplete sigma set"); + assert!(matches!(missing_sigma, EngineError::Validation(_))); + + // A second valid Part1 transcript has the same authorization context but + // independent randomness. Substituting just one of its deltas passes the + // wire/context checks and must be caught by the target public-share check. + let (alternate_part1, _) = share_repair_sigmas(&authorization); + let substituted = share_repair_part2(ShareRepairPart2Request { + authorization: authorization.clone(), + helper_identifier: 1, + deltas: vec![ + alternate_part1[0].deltas[0].clone(), + part1[1].deltas[0].clone(), + ], + }) + .expect("well-shaped but inconsistent deltas") + .sigma; + let corrupted = install_repaired_share(InstallRepairedShareRequest { + authorization: authorization.clone(), + public_key_package: native_public.clone(), + sigmas: vec![substituted, sigmas[1].clone()], + }) + .expect_err("reconstructed share must match public commitment"); + assert!(matches!(corrupted, EngineError::Validation(_))); + assert!(!state() + .expect("state") + .lock() + .expect("engine lock") + .sessions[session_id] + .dkg_key_packages + .as_ref() + .unwrap() + .contains_key(&3)); + + let mut wrong_store = authorization.clone(); + wrong_store.new_store_fingerprint = bytes32_hex([0x77; 32]); + resign_share_repair_authorization(&mut wrong_store, &signing_key); + let wrong_store_error = install_repaired_share(InstallRepairedShareRequest { + authorization: wrong_store, + public_key_package: native_public.clone(), + sigmas: Vec::new(), + }) + .expect_err("wrong durable store"); + assert!(matches!(wrong_store_error, EngineError::Validation(_))); + + let mut oversized_group = authorization.clone(); + oversized_group.participant_count = 101; + resign_share_repair_authorization(&mut oversized_group, &signing_key); + let oversized_group_error = share_repair_part1(ShareRepairPart1Request { + authorization: oversized_group, + helper_identifier: 1, + }) + .expect_err("participant count above the production group bound"); + assert!(matches!(oversized_group_error, EngineError::Validation(_))); + + let mut bad_signature = authorization; + bad_signature.signature_hex = format!("0x{}", "11".repeat(64)); + let signature_error = share_repair_part1(ShareRepairPart1Request { + authorization: bad_signature, + helper_identifier: 1, + }) + .expect_err("bad authority signature"); + assert!(matches!(signature_error, EngineError::Validation(_))); + + set_share_repair_authority_for_tests(None); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + // A multi-seat operator persists several local seats of the SAME distributed DKG // into one session; the key packages must accumulate (not overwrite), so every // local seat can later open an interactive signing session. diff --git a/pkg/tbtc/signer/src/engine/testsupport.rs b/pkg/tbtc/signer/src/engine/testsupport.rs index b0fc7b2b52..48e37fce7d 100644 --- a/pkg/tbtc/signer/src/engine/testsupport.rs +++ b/pkg/tbtc/signer/src/engine/testsupport.rs @@ -35,6 +35,7 @@ pub fn lock_test_state() -> std::sync::MutexGuard<'static, ()> { // raw set_var without per-site teardown. #[cfg(test)] pub(crate) fn establish_clean_signer_test_env() { + set_share_repair_authority_for_tests(None); // Iterate with vars_os, not vars: std::env::vars panics if ANY env // var in the process (name or value) is not valid UTF-8 - even one // unrelated to the signer - which would abort every locked test in diff --git a/pkg/tbtc/signer/src/lib.rs b/pkg/tbtc/signer/src/lib.rs index ca4ac3aed0..4256b7f368 100644 --- a/pkg/tbtc/signer/src/lib.rs +++ b/pkg/tbtc/signer/src/lib.rs @@ -8,13 +8,14 @@ use api::{ AcknowledgeStateWitnessCheckpointRequest, BuildTaprootTxRequest, DeriveInteractiveAttemptContextRequest, DifferentialFuzzRequest, DkgPart1Request, DkgPart2Request, DkgPart3Request, DurableStoreIdentityResult, FrostTbtcAbiVersionResult, - InitSignerConfigRequest, InteractiveAggregateRequest, InteractiveRound1Request, - InteractiveRound2Request, InteractiveSessionAbortRequest, InteractiveSessionOpenRequest, - NewSigningPackageRequest, PersistDistributedDkgKeyPackageRequest, PromoteCanaryRequest, - QuarantineStatusRequest, RecoverStateWitnessCheckpointRequest, RefreshCadenceStatusRequest, - RefreshSharesRequest, RetireDistributedDkgKeyPackagesRequest, RollbackCanaryRequest, - StateWitnessProofRequest, TranscriptAuditRequest, TransitionStateWitnessAnchorRequest, - TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, + InitSignerConfigRequest, InstallRepairedShareRequest, InteractiveAggregateRequest, + InteractiveRound1Request, InteractiveRound2Request, InteractiveSessionAbortRequest, + InteractiveSessionOpenRequest, NewSigningPackageRequest, + PersistDistributedDkgKeyPackageRequest, PromoteCanaryRequest, QuarantineStatusRequest, + RecoverStateWitnessCheckpointRequest, RefreshCadenceStatusRequest, RefreshSharesRequest, + RetireDistributedDkgKeyPackagesRequest, RollbackCanaryRequest, ShareRepairPart1Request, + ShareRepairPart2Request, StateWitnessProofRequest, TranscriptAuditRequest, + TransitionStateWitnessAnchorRequest, TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, }; use ffi::{ ffi_entry, free_buffer, parse_request, serialize_response, success_from_string, @@ -58,7 +59,9 @@ const TBTC_SIGNER_ABI_MAJOR: u32 = 4; // 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; +// Minor 5 adds offline-authorized, context-bound share-repair Part1/Part2 and +// atomic repaired-share installation. Existing response shapes are unchanged. +const TBTC_SIGNER_ABI_MINOR: u32 = 5; #[cfg(test)] use engine::TBTC_SIGNER_PROFILE_ENV; @@ -430,6 +433,47 @@ pub extern "C" fn frost_tbtc_retire_distributed_dkg_key_packages( }) } +/// Generates one authorized helper's context-bound repair deltas. Every delta +/// is secret and must be delivered only to its named helper recipient over an +/// authenticated confidential channel. +#[no_mangle] +pub extern "C" fn frost_tbtc_share_repair_part1( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + normal_ffi_entry(|| { + let request: ShareRepairPart1Request = parse_request(request_ptr, request_len)?; + serialize_response(&engine::share_repair_part1(request)?) + }) +} + +/// Combines the exact authorized delta sender set at one helper and returns a +/// context-bound secret sigma for the recovering target. +#[no_mangle] +pub extern "C" fn frost_tbtc_share_repair_part2( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + normal_ffi_entry(|| { + let request: ShareRepairPart2Request = parse_request(request_ptr, request_len)?; + serialize_response(&engine::share_repair_part2(request)?) + }) +} + +/// Reconstructs, publicly verifies, and atomically persists the repaired share +/// in the authorization's exact fresh durable store. No raw KeyPackage crosses +/// from Rust into the host process. +#[no_mangle] +pub extern "C" fn frost_tbtc_install_repaired_share( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + normal_ffi_entry(|| { + let request: InstallRepairedShareRequest = parse_request(request_ptr, request_len)?; + serialize_response(&engine::install_repaired_share(request)?) + }) +} + #[no_mangle] pub extern "C" fn frost_tbtc_new_signing_package( request_ptr: *const u8, @@ -939,9 +983,10 @@ mod tests { // 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. + // minor 4 adds durable distributed-DKG key-package retirement; + // minor 5 adds context-bound share repair and atomic installation. assert_eq!(abi.abi_major, 4); - assert_eq!(abi.abi_minor, 4); + assert_eq!(abi.abi_minor, 5); } #[test] From 98357a9926e44e8f6477b852a7a55e43dfb96fd4 Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 14:15:21 -0400 Subject: [PATCH 2/5] ci(tbtc/signer): refresh verified TLA tools checksum --- pkg/tbtc/signer/scripts/formal/run_tla_models.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tbtc/signer/scripts/formal/run_tla_models.sh b/pkg/tbtc/signer/scripts/formal/run_tla_models.sh index 21a97ae395..6771581087 100755 --- a/pkg/tbtc/signer/scripts/formal/run_tla_models.sh +++ b/pkg/tbtc/signer/scripts/formal/run_tla_models.sh @@ -16,7 +16,7 @@ TLA_TOOLS_URL="${TLA_TOOLS_URL:-https://github.com/tlaplus/tlaplus/releases/down # release v1.8.0). Re-pin this when the upstream release asset is rebuilt and the # download-verification gate below reports a mismatch, after confirming the new # jar comes from the official release URL. -TLA_TOOLS_SHA256="${TLA_TOOLS_SHA256:-cc4803dce2a8ffaf0f5920a9dc39df4b5ee34ab4cb53fb58ac557277a7e516b3}" +TLA_TOOLS_SHA256="${TLA_TOOLS_SHA256:-e22f8ffb4bacdea0a871f444dd94fe5fb0d8013b3388ae39e82e26f852c735d5}" if ! command -v java >/dev/null 2>&1; then echo "java is required to run TLC model checks" >&2 From 35259de9aeb2daa25429a2630f1e8290240e500f Mon Sep 17 00:00:00 2001 From: maclane Date: Mon, 10 Aug 2026 22:59:55 -0400 Subject: [PATCH 3/5] fix(tbtc/signer): keep repair scalars behind native custody --- pkg/tbtc/signer/Cargo.lock | 22 + pkg/tbtc/signer/Cargo.toml | 2 + pkg/tbtc/signer/include/frost_tbtc.h | 19 +- pkg/tbtc/signer/src/api.rs | 65 +- pkg/tbtc/signer/src/engine/mod.rs | 16 +- pkg/tbtc/signer/src/engine/persistence.rs | 1 + pkg/tbtc/signer/src/engine/repair.rs | 1804 +++++++++++++++++++-- pkg/tbtc/signer/src/engine/state.rs | 9 +- pkg/tbtc/signer/src/engine/tests.rs | 529 +++++- pkg/tbtc/signer/src/engine/testsupport.rs | 2 + pkg/tbtc/signer/src/lib.rs | 82 +- 11 files changed, 2338 insertions(+), 213 deletions(-) diff --git a/pkg/tbtc/signer/Cargo.lock b/pkg/tbtc/signer/Cargo.lock index 21c6f61447..13d814dbac 100644 --- a/pkg/tbtc/signer/Cargo.lock +++ b/pkg/tbtc/signer/Cargo.lock @@ -475,6 +475,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -528,6 +529,7 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", "rand_core 0.6.4", "sec1", "subtle", @@ -791,6 +793,24 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1424,6 +1444,8 @@ dependencies = [ "frost-core", "frost-secp256k1-tr", "hex", + "hkdf", + "k256", "libc", "pretty_assertions", "proptest", diff --git a/pkg/tbtc/signer/Cargo.toml b/pkg/tbtc/signer/Cargo.toml index 1aa5f892d2..c01d46bbb1 100644 --- a/pkg/tbtc/signer/Cargo.toml +++ b/pkg/tbtc/signer/Cargo.toml @@ -19,7 +19,9 @@ serde_json = { version = "1.0", features = ["raw_value"] } sha2 = "0.10" hex = "0.4" thiserror = "2.0" +hkdf = { version = "=0.12.4", default-features = false } frost-secp256k1-tr = "=3.0.0" +k256 = { version = "=0.13.4", default-features = false, features = ["arithmetic", "ecdh"] } # Direct, version-matched access to aggregate_custom + CheaterDetection (the # frost-secp256k1-tr aggregate wrappers hardcode FirstCheater). Already a # transitive dependency via frost-secp256k1-tr, pinned to the same 3.0.0. diff --git a/pkg/tbtc/signer/include/frost_tbtc.h b/pkg/tbtc/signer/include/frost_tbtc.h index 4e577e55d1..aba7daa2e5 100644 --- a/pkg/tbtc/signer/include/frost_tbtc.h +++ b/pkg/tbtc/signer/include/frost_tbtc.h @@ -131,11 +131,22 @@ TbtcSignerResult frost_tbtc_persist_distributed_dkg_key_package(const uint8_t* r TbtcSignerResult frost_tbtc_retire_distributed_dkg_key_packages(const uint8_t* request_ptr, size_t request_len); /* * Offline-authorized disaster-recovery protocol. Part1 deltas and Part2 - * sigmas are secret and must use authenticated confidential transport. Install - * validates the exact context/helper set and writes the reconstructed share - * directly to the authorization-bound fresh durable store; no KeyPackage is - * returned across this ABI. + * sigmas cross this ABI only as native AEAD ciphertexts under an + * authority-signed endpoint roster; the host transport must still preserve + * authenticated sender identity and delivery. Install validates the exact + * context/helper set and writes the reconstructed share directly to the + * authorization-bound fresh durable store; no KeyPackage is returned across + * this ABI. Finish wipes only the live key cache; Begin can rederive the same + * store-bound key until authorization expiry. */ +TbtcSignerResult frost_tbtc_begin_share_repair_session( + const uint8_t* request_ptr, + size_t request_len +); +TbtcSignerResult frost_tbtc_finish_share_repair_session( + const uint8_t* request_ptr, + size_t request_len +); TbtcSignerResult frost_tbtc_share_repair_part1(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_share_repair_part2(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_install_repaired_share(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 360e696f1a..7139384183 100644 --- a/pkg/tbtc/signer/src/api.rs +++ b/pkg/tbtc/signer/src/api.rs @@ -170,23 +170,76 @@ pub struct ShareRepairAuthorization { pub signature_hex: String, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BeginShareRepairSessionRequest { + pub authorization: ShareRepairAuthorization, + pub participant_identifier: u16, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BeginShareRepairSessionResult { + pub context_digest: String, + pub participant_identifier: u16, + /// Fingerprint of the exact durable store admitted for transport-key + /// derivation. The offline authority signs this value into the transport + /// roster together with the native public key. + pub store_fingerprint: String, + /// Authorization-scoped native transport key. It is deterministic through + /// authorization expiry; only per-envelope ECDH keys are ephemeral. + pub transport_public_key_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FinishShareRepairSessionRequest { + pub authorization: ShareRepairAuthorization, + pub participant_identifier: u16, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FinishShareRepairSessionResult { + pub context_digest: String, + pub participant_identifier: u16, + pub finished: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ShareRepairEndpointPublicKey { + pub participant_identifier: u16, + pub store_fingerprint: String, + pub public_key_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ShareRepairTransportRoster { + pub schema: String, + pub authorization_digest: String, + pub participant_public_keys: Vec, + pub signature_hex: String, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(deny_unknown_fields)] pub struct ShareRepairPart1Request { pub authorization: ShareRepairAuthorization, pub helper_identifier: u16, + pub transport_roster: ShareRepairTransportRoster, } -/// One secret repair delta. The context digest and explicit endpoints prevent -/// the application from accidentally accepting a scalar from another repair -/// session even though the upstream arithmetic type itself has no context. +/// One opaque, native-encrypted repair delta. The scalar plaintext and native +/// transport private key never cross the FFI boundary. #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(deny_unknown_fields)] pub struct ShareRepairDelta { pub context_digest: String, pub sender_identifier: u16, pub recipient_identifier: u16, - pub data_hex: SecretHex, + pub payload_hex: String, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] @@ -207,6 +260,7 @@ pub struct ShareRepairPart2Request { pub authorization: ShareRepairAuthorization, pub helper_identifier: u16, pub deltas: Vec, + pub transport_roster: ShareRepairTransportRoster, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] @@ -214,7 +268,7 @@ pub struct ShareRepairPart2Request { pub struct ShareRepairSigma { pub context_digest: String, pub helper_identifier: u16, - pub data_hex: SecretHex, + pub payload_hex: String, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] @@ -230,6 +284,7 @@ pub struct InstallRepairedShareRequest { pub authorization: ShareRepairAuthorization, pub public_key_package: NativeFrostPublicKeyPackage, pub sigmas: Vec, + pub transport_roster: ShareRepairTransportRoster, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] diff --git a/pkg/tbtc/signer/src/engine/mod.rs b/pkg/tbtc/signer/src/engine/mod.rs index 5728c0cd56..b9b2698196 100644 --- a/pkg/tbtc/signer/src/engine/mod.rs +++ b/pkg/tbtc/signer/src/engine/mod.rs @@ -60,11 +60,13 @@ use sha2::{Digest, Sha256}; use zeroize::{Zeroize, Zeroizing}; use crate::api::{ - AttemptContext, BlameProofVerificationResult, BuildTaprootTxRequest, CanaryRolloutStatusResult, + AttemptContext, BeginShareRepairSessionRequest, BeginShareRepairSessionResult, + BlameProofVerificationResult, BuildTaprootTxRequest, CanaryRolloutStatusResult, DeriveInteractiveAttemptContextRequest, DeriveInteractiveAttemptContextResult, DifferentialDivergence, DifferentialFuzzRequest, DifferentialFuzzResult, DkgPart1Request, DkgPart1Result, DkgPart2Request, DkgPart2Result, DkgPart3Request, DkgPart3Result, DkgResult, - DkgRound1Package, DkgRound2Package, InitSignerConfigRequest, InitSignerConfigResult, + DkgRound1Package, DkgRound2Package, FinishShareRepairSessionRequest, + FinishShareRepairSessionResult, InitSignerConfigRequest, InitSignerConfigResult, InstallRepairedShareRequest, InstallRepairedShareResult, InteractiveAggregateRequest, InteractiveAggregateResult, InteractiveRound1Request, InteractiveRound1Result, InteractiveRound2Request, InteractiveRound2Result, InteractiveSessionAbortRequest, @@ -78,11 +80,11 @@ use crate::api::{ RetireDistributedDkgKeyPackagesResult, RoastLivenessPolicyResult, RollbackCanaryRequest, RollbackCanaryResult, RoundState, SecretHex, ShareRepairAuthorization, ShareRepairDelta, ShareRepairPart1Request, ShareRepairPart1Result, ShareRepairPart2Request, - ShareRepairPart2Result, ShareRepairSigma, SignatureResult, SignerHardeningMetricsResult, - StateAnchorBootstrapFactsResult, StateAnchorTrustCertificate, StateAnchorTrustCheckpoint, - StateAnchorTrustEndpoint, StateAnchorTrustHeadResult, StateAnchorTrustReference, - TransactionResult, TranscriptAuditRecord, TranscriptAuditRequest, TranscriptAuditResult, - TransitionStateWitnessAnchorRequest, TransitionStateWitnessAnchorResult, + ShareRepairPart2Result, ShareRepairSigma, ShareRepairTransportRoster, SignatureResult, + SignerHardeningMetricsResult, StateAnchorBootstrapFactsResult, StateAnchorTrustCertificate, + StateAnchorTrustCheckpoint, StateAnchorTrustEndpoint, StateAnchorTrustHeadResult, + StateAnchorTrustReference, TransactionResult, TranscriptAuditRecord, TranscriptAuditRequest, + TranscriptAuditResult, TransitionStateWitnessAnchorRequest, TransitionStateWitnessAnchorResult, TriggerEmergencyRekeyRequest, TriggerEmergencyRekeyResult, VerifyBlameProofRequest, }; use crate::errors::{EngineError, StateAnchorTrustRecoveryContext}; diff --git a/pkg/tbtc/signer/src/engine/persistence.rs b/pkg/tbtc/signer/src/engine/persistence.rs index c0ee3612e5..93eb940dc0 100644 --- a/pkg/tbtc/signer/src/engine/persistence.rs +++ b/pkg/tbtc/signer/src/engine/persistence.rs @@ -1694,6 +1694,7 @@ impl TryFrom for EngineState { let mut engine_state = EngineState { sessions, + share_repair_sessions: HashMap::new(), refresh_epoch_counter: persisted.refresh_epoch_counter, operator_fault_scores: persisted.operator_fault_scores, quarantined_operator_identifiers, diff --git a/pkg/tbtc/signer/src/engine/repair.rs b/pkg/tbtc/signer/src/engine/repair.rs index f4b63f006d..86445f1da8 100644 --- a/pkg/tbtc/signer/src/engine/repair.rs +++ b/pkg/tbtc/signer/src/engine/repair.rs @@ -1,26 +1,249 @@ //! Offline-authorized repair of one lost FROST signing share. //! -//! The upstream repairable-threshold primitive intentionally implements only -//! the scalar arithmetic. This module supplies the protocol boundary it does -//! not have: a signed, expiring context; exact helper-set validation; endpoint -//! binding for every delta and sigma; public-package commitment checks; and an -//! atomic install into the descriptor-bound signer store. +//! This module implements the small secp256k1 repair arithmetic locally so +//! complete Delta/Sigma scalar sets never enter Copy-typed upstream repair +//! wrappers or cross the FFI boundary. It also supplies the protocol boundary: +//! a signed, expiring context and transport roster; exact helper-set and +//! endpoint binding; public-package commitment checks; and an atomic install +//! into the descriptor-bound signer store. use super::*; use ed25519_dalek::{Signature, VerifyingKey}; -use frost::keys::repairable::{ - repair_share_part1 as frost_repair_share_part1, repair_share_part2 as frost_repair_share_part2, - repair_share_part3 as frost_repair_share_part3, Delta, Sigma, +use hkdf::Hkdf; +use k256::{ + ecdh::{diffie_hellman, EphemeralSecret}, + elliptic_curve::{sec1::ToEncodedPoint, Field as K256Field, PrimeField}, + PublicKey as RepairPublicKey, Scalar as RepairScalar, SecretKey as RepairSecretKey, }; +use zeroize::ZeroizeOnDrop; pub(crate) const TBTC_SIGNER_SHARE_REPAIR_AUTHORIZATION_SCHEMA: &str = "tbtc-frost-share-repair-authorization/v1"; +pub(crate) const TBTC_SIGNER_SHARE_REPAIR_TRANSPORT_ROSTER_SCHEMA: &str = + "tbtc-frost-share-repair-transport-roster/v1"; pub(crate) const TBTC_SIGNER_SHARE_REPAIR_INSTALL_RESULT_SCHEMA: &str = "tbtc-frost-share-repair-install-result/v1"; const SHARE_REPAIR_AUTHORIZATION_DOMAIN: &[u8] = b"tbtc-frost-share-repair-authorization/v1\0"; +const SHARE_REPAIR_TRANSPORT_ROSTER_DOMAIN: &[u8] = + b"tbtc-frost-share-repair-transport-roster/v1\0"; +const SHARE_REPAIR_TRANSPORT_SECRET_DERIVATION_DOMAIN: &[u8] = + b"tbtc-frost-share-repair-transport-secret/v1\0"; const SHARE_REPAIR_MAX_AUTHORIZATION_LIFETIME_SECONDS: u64 = 24 * 60 * 60; +const SHARE_REPAIR_TRANSPORT_VERSION: u8 = 1; +const SHARE_REPAIR_TRANSPORT_KDF_DOMAIN: &[u8] = b"tbtc-frost-share-repair-kdf/v1\0"; +const SHARE_REPAIR_TRANSPORT_AAD_DOMAIN: &[u8] = b"tbtc-frost-share-repair-aad/v1\0"; +const SHARE_REPAIR_TRANSPORT_PUBLIC_KEY_BYTES: usize = 33; +const SHARE_REPAIR_TRANSPORT_NONCE_BYTES: usize = 24; +const SHARE_REPAIR_TRANSPORT_SCALAR_BYTES: usize = 32; +const SHARE_REPAIR_TRANSPORT_TAG_BYTES: usize = 16; +pub(crate) const SHARE_REPAIR_TRANSPORT_PAYLOAD_BYTES: usize = + SHARE_REPAIR_TRANSPORT_PUBLIC_KEY_BYTES + + SHARE_REPAIR_TRANSPORT_NONCE_BYTES + + SHARE_REPAIR_TRANSPORT_SCALAR_BYTES + + SHARE_REPAIR_TRANSPORT_TAG_BYTES; +const SHARE_REPAIR_MAX_LIVE_TRANSPORT_SESSIONS: usize = 256; + +/// The pinned hkdf crate does not zeroize its PRK/HMAC state. Wrap the +/// short-lived context and wipe its complete in-place representation after +/// expansion. It owns no external allocation and has no Drop implementation +/// in the pinned 0.12.4 release. +struct ZeroizingHkdfSha256(Hkdf); + +impl ZeroizingHkdfSha256 { + fn new(salt: &[u8], input_key_material: &[u8]) -> Self { + Self(Hkdf::::new(Some(salt), input_key_material)) + } + + fn expand(&self, info: &[u8], output: &mut [u8]) -> Result<(), EngineError> { + self.0 + .expand(info, output) + .map_err(|_| EngineError::Internal("share-repair HKDF expansion failed".to_string())) + } +} + +impl Drop for ZeroizingHkdfSha256 { + fn drop(&mut self) { + // SAFETY: hkdf 0.12.4's Hkdf is an owned, fixed-size value with + // no Drop implementation or external allocation. It is never used + // after this wrapper's Drop starts. Volatile zeroization prevents the + // compiler from eliding the wipe of its secret PRK/HMAC state. + unsafe { + std::slice::from_raw_parts_mut( + (&mut self.0 as *mut Hkdf).cast::(), + std::mem::size_of::>(), + ) + .zeroize(); + } + } +} + +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub(crate) struct ShareRepairTransportSessionKey { + authorization_digest: [u8; 32], + participant_identifier: u16, +} + +pub(crate) struct ShareRepairTransportSession { + secret_key: RepairSecretKey, + expires_at_unix: u64, + /// Set on first use after the offline authority signs a roster. A live + /// native key cannot be reused under a second roster for the same + /// authorization and seat. + transport_roster_digest: Option<[u8; 32]>, +} + +/// A repair scalar with a single, non-Copy, byte-backed owner whose storage is +/// wiped on every success and error path. k256's arithmetic scalar must itself +/// be Copy, so it exists only in narrowly scoped `Zeroizing` temporaries; +/// persistent repair values are never represented by a Copy scalar type. +struct SecretRepairScalar(Zeroizing<[u8; SHARE_REPAIR_TRANSPORT_SCALAR_BYTES]>); + +impl std::fmt::Debug for SecretRepairScalar { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("") + } +} + +impl Zeroize for SecretRepairScalar { + fn zeroize(&mut self) { + self.0.zeroize(); + } +} + +impl ZeroizeOnDrop for SecretRepairScalar {} + +impl Drop for SecretRepairScalar { + fn drop(&mut self) { + self.zeroize(); + } +} + +impl SecretRepairScalar { + fn zero() -> Self { + Self(Zeroizing::new([0u8; SHARE_REPAIR_TRANSPORT_SCALAR_BYTES])) + } + + fn random(rng: &mut (impl RngCore + CryptoRng)) -> Self { + let scalar = Zeroizing::new(RepairScalar::random(rng)); + Self::from_scalar(&scalar) + } + + fn deserialize(bytes: &[u8]) -> Result { + if bytes.len() != SHARE_REPAIR_TRANSPORT_SCALAR_BYTES { + return Err(EngineError::Validation( + "repair scalar must contain exactly 32 bytes".to_string(), + )); + } + let mut representation = Zeroizing::new(k256::FieldBytes::default()); + representation.copy_from_slice(bytes); + let _scalar = Zeroizing::new( + Option::::from(RepairScalar::from_repr(*representation)).ok_or_else( + || EngineError::Validation("repair scalar is not canonical".to_string()), + )?, + ); + let mut owned = Zeroizing::new([0u8; SHARE_REPAIR_TRANSPORT_SCALAR_BYTES]); + owned.copy_from_slice(bytes); + Ok(Self(owned)) + } + + fn serialize(&self) -> Zeroizing<[u8; SHARE_REPAIR_TRANSPORT_SCALAR_BYTES]> { + let mut bytes = Zeroizing::new([0u8; SHARE_REPAIR_TRANSPORT_SCALAR_BYTES]); + bytes.copy_from_slice(self.0.as_ref()); + bytes + } + + fn to_scalar(&self) -> Zeroizing { + let mut representation = Zeroizing::new(k256::FieldBytes::default()); + representation.copy_from_slice(self.0.as_ref()); + Zeroizing::new( + Option::::from(RepairScalar::from_repr(*representation)) + .expect("SecretRepairScalar invariant: stored bytes are canonical"), + ) + } + + fn from_scalar(scalar: &RepairScalar) -> Self { + let representation = Zeroizing::new(scalar.to_repr()); + let mut bytes = Zeroizing::new([0u8; SHARE_REPAIR_TRANSPORT_SCALAR_BYTES]); + bytes.copy_from_slice(representation.as_slice()); + Self(bytes) + } + + fn add_assign(&mut self, other: &Self) { + let left = self.to_scalar(); + let right = other.to_scalar(); + let result = Zeroizing::new(*left + *right); + let replacement = Self::from_scalar(&result); + self.zeroize(); + self.0.copy_from_slice(replacement.0.as_ref()); + } + + fn subtract_assign(&mut self, other: &Self) { + let left = self.to_scalar(); + let right = other.to_scalar(); + let result = Zeroizing::new(*left - *right); + let replacement = Self::from_scalar(&result); + self.zeroize(); + self.0.copy_from_slice(replacement.0.as_ref()); + } + + fn multiply_public(&self, public: RepairScalar) -> Self { + let secret = self.to_scalar(); + let product = Zeroizing::new(*secret * public); + Self::from_scalar(&product) + } + + fn verifying_share_encoding( + &self, + operation: &str, + ) -> Result<[u8; SHARE_REPAIR_TRANSPORT_PUBLIC_KEY_BYTES], EngineError> { + let secret = self.to_scalar(); + let public = (k256::ProjectivePoint::GENERATOR * *secret).to_affine(); + let encoded = public.to_encoded_point(true); + let mut bytes = [0u8; SHARE_REPAIR_TRANSPORT_PUBLIC_KEY_BYTES]; + if encoded.as_bytes().len() != bytes.len() { + return Err(validation_error( + operation, + "reconstructed signing share derives the identity element", + )); + } + bytes.copy_from_slice(encoded.as_bytes()); + Ok(bytes) + } +} + +#[derive(Clone, Copy)] +enum ShareRepairEnvelopeKind { + Delta = 1, + Sigma = 2, +} + +#[derive(Clone, Copy)] +enum ShareRepairTransportRole { + Helper = 1, + Target = 2, +} + +struct ShareRepairEnvelopeContext<'a> { + kind: ShareRepairEnvelopeKind, + authorization_digest: [u8; 32], + transport_roster_digest: [u8; 32], + sender_identifier: u16, + recipient_identifier: u16, + ephemeral_public_key: &'a [u8], + sender_public_key: &'a [u8], + recipient_public_key: &'a [u8], +} + +impl ShareRepairEnvelopeKind { + fn label(self) -> &'static str { + match self { + Self::Delta => "delta", + Self::Sigma => "sigma", + } + } +} #[cfg(test)] static TEST_SHARE_REPAIR_AUTHORITY: OnceLock>> = OnceLock::new(); @@ -44,10 +267,367 @@ struct ValidatedShareRepairAuthorization { new_store_fingerprint: [u8; 32], } +struct ValidatedShareRepairTransportEndpoint { + store_fingerprint: [u8; 32], + public_key: RepairPublicKey, +} + +struct ValidatedShareRepairTransportRoster { + digest: [u8; 32], + endpoints: BTreeMap, +} + fn validation_error(operation: &str, detail: impl std::fmt::Display) -> EngineError { EngineError::Validation(format!("{operation}: {detail}")) } +fn share_repair_transport_session_key( + authorization_digest: [u8; 32], + participant_identifier: u16, +) -> ShareRepairTransportSessionKey { + ShareRepairTransportSessionKey { + authorization_digest, + participant_identifier, + } +} + +fn derive_share_repair_transport_secret( + authorization_digest: [u8; 32], + participant_identifier: u16, + role: ShareRepairTransportRole, + store_fingerprint: [u8; 32], +) -> Result { + let key_material = state_encryption_key_material()?; + let key_provider = key_material.key_provider.as_bytes(); + let key_id = key_material.key_id.as_bytes(); + let key_provider_length = u16::try_from(key_provider.len()).map_err(|_| { + EngineError::Internal("state-key provider name exceeds u16::MAX".to_string()) + })?; + let key_id_length = u16::try_from(key_id.len()) + .map_err(|_| EngineError::Internal("state-key identifier exceeds u16::MAX".to_string()))?; + let kdf = ZeroizingHkdfSha256::new( + SHARE_REPAIR_TRANSPORT_SECRET_DERIVATION_DOMAIN, + key_material.key.as_ref(), + ); + for counter in 0..=u32::MAX { + let mut info = Vec::with_capacity( + SHARE_REPAIR_TRANSPORT_SECRET_DERIVATION_DOMAIN.len() + + authorization_digest.len() + + 2 + + 1 + + store_fingerprint.len() + + 2 + + key_provider.len() + + 2 + + key_id.len() + + 4, + ); + info.extend_from_slice(SHARE_REPAIR_TRANSPORT_SECRET_DERIVATION_DOMAIN); + info.extend_from_slice(&authorization_digest); + info.extend_from_slice(&participant_identifier.to_be_bytes()); + info.push(role as u8); + info.extend_from_slice(&store_fingerprint); + info.extend_from_slice(&key_provider_length.to_be_bytes()); + info.extend_from_slice(key_provider); + info.extend_from_slice(&key_id_length.to_be_bytes()); + info.extend_from_slice(key_id); + info.extend_from_slice(&counter.to_be_bytes()); + let mut candidate = Zeroizing::new([0u8; 32]); + kdf.expand(&info, candidate.as_mut())?; + if let Ok(secret_key) = RepairSecretKey::from_slice(candidate.as_ref()) { + return Ok(secret_key); + } + } + Err(EngineError::Internal( + "share-repair transport key derivation exhausted its counter".to_string(), + )) +} + +fn ensure_current_share_repair_transport_key( + operation: &str, + expected_public_key: &RepairPublicKey, + authorization_digest: [u8; 32], + participant_identifier: u16, + role: ShareRepairTransportRole, + store_fingerprint: [u8; 32], +) -> Result<(), EngineError> { + // Provider commands must never run while ENGINE_STATE is locked. Re-resolve + // after the cryptographic work and before releasing any ciphertext or + // performing Install's irreversible persistence. This is the operation's + // fail-closed key-generation linearization point: if the provider/root + // changed while work was in flight, its result is discarded. Production + // provider rotation is coupled to signer restart. + let current = derive_share_repair_transport_secret( + authorization_digest, + participant_identifier, + role, + store_fingerprint, + )?; + if current.public_key() != *expected_public_key { + return Err(validation_error( + operation, + "native repair transport key changed while the operation was in flight", + )); + } + Ok(()) +} + +fn require_authorized_repair_participant( + operation: &str, + authorization: &ShareRepairAuthorization, + participant_identifier: u16, +) -> Result<(), EngineError> { + if participant_identifier != authorization.target_identifier + && authorization + .helper_identifiers + .binary_search(&participant_identifier) + .is_err() + { + return Err(validation_error( + operation, + "participant_identifier is not in the authorized repair set", + )); + } + Ok(()) +} + +fn canonical_repair_public_key( + operation: &str, + field: &str, + value: &str, +) -> Result { + if value.len() != SHARE_REPAIR_TRANSPORT_PUBLIC_KEY_BYTES * 2 + || value.bytes().any(|byte| byte.is_ascii_uppercase()) + { + return Err(validation_error( + operation, + format!("{field} must be canonical lowercase 33-byte compressed SEC1 hex"), + )); + } + let bytes = hex::decode(value) + .map_err(|error| validation_error(operation, format!("invalid {field}: {error}")))?; + let public_key = RepairPublicKey::from_sec1_bytes(&bytes) + .map_err(|_| validation_error(operation, format!("invalid {field}")))?; + if public_key.to_encoded_point(true).as_bytes() != bytes { + return Err(validation_error( + operation, + format!("{field} is not canonical compressed SEC1"), + )); + } + Ok(public_key) +} + +fn share_repair_envelope_binding( + domain: &[u8], + context: &ShareRepairEnvelopeContext<'_>, +) -> Vec { + let mut binding = Vec::with_capacity( + domain.len() + + 1 + + 1 + + context.authorization_digest.len() + + context.transport_roster_digest.len() + + 2 + + 2 + + context.ephemeral_public_key.len() + + context.sender_public_key.len() + + context.recipient_public_key.len(), + ); + binding.extend_from_slice(domain); + binding.push(SHARE_REPAIR_TRANSPORT_VERSION); + binding.push(context.kind as u8); + binding.extend_from_slice(&context.authorization_digest); + binding.extend_from_slice(&context.transport_roster_digest); + binding.extend_from_slice(&context.sender_identifier.to_be_bytes()); + binding.extend_from_slice(&context.recipient_identifier.to_be_bytes()); + binding.extend_from_slice(context.ephemeral_public_key); + binding.extend_from_slice(context.sender_public_key); + binding.extend_from_slice(context.recipient_public_key); + binding +} + +fn share_repair_envelope_key( + ephemeral_shared_secret: &k256::ecdh::SharedSecret, + authenticated_sender_shared_secret: &k256::ecdh::SharedSecret, + context: &ShareRepairEnvelopeContext<'_>, +) -> Result, EngineError> { + let info = share_repair_envelope_binding(SHARE_REPAIR_TRANSPORT_KDF_DOMAIN, context); + let mut input_key_material = Zeroizing::new([0u8; 64]); + input_key_material[..32].copy_from_slice(ephemeral_shared_secret.raw_secret_bytes().as_ref()); + input_key_material[32..].copy_from_slice( + authenticated_sender_shared_secret + .raw_secret_bytes() + .as_ref(), + ); + let kdf = ZeroizingHkdfSha256::new( + SHARE_REPAIR_TRANSPORT_KDF_DOMAIN, + input_key_material.as_ref(), + ); + let mut key = Zeroizing::new([0u8; 32]); + kdf.expand(&info, key.as_mut())?; + Ok(key) +} + +#[allow(clippy::too_many_arguments)] +fn encrypt_repair_scalar( + scalar: &SecretRepairScalar, + sender_secret_key: &RepairSecretKey, + recipient_public_key: &RepairPublicKey, + kind: ShareRepairEnvelopeKind, + context_digest: [u8; 32], + transport_roster_digest: [u8; 32], + sender_identifier: u16, + recipient_identifier: u16, + rng: &mut (impl RngCore + CryptoRng), +) -> Result { + let ephemeral_secret = EphemeralSecret::random(rng); + let ephemeral_public_key = ephemeral_secret.public_key(); + let ephemeral_public_key_bytes = ephemeral_public_key.to_encoded_point(true); + let sender_public_key = sender_secret_key.public_key(); + let sender_public_key_bytes = sender_public_key.to_encoded_point(true); + let recipient_public_key_bytes = recipient_public_key.to_encoded_point(true); + let ephemeral_shared_secret = ephemeral_secret.diffie_hellman(recipient_public_key); + let sender_secret_scalar = Zeroizing::new(sender_secret_key.to_nonzero_scalar()); + let authenticated_sender_shared_secret = + diffie_hellman(&*sender_secret_scalar, recipient_public_key.as_affine()); + let envelope_context = ShareRepairEnvelopeContext { + kind, + authorization_digest: context_digest, + transport_roster_digest, + sender_identifier, + recipient_identifier, + ephemeral_public_key: ephemeral_public_key_bytes.as_bytes(), + sender_public_key: sender_public_key_bytes.as_bytes(), + recipient_public_key: recipient_public_key_bytes.as_bytes(), + }; + let key = share_repair_envelope_key( + &ephemeral_shared_secret, + &authenticated_sender_shared_secret, + &envelope_context, + )?; + let cipher = XChaCha20Poly1305::new_from_slice(key.as_ref()).map_err(|_| { + EngineError::Internal("share-repair transport cipher initialization failed".to_string()) + })?; + let mut nonce = [0u8; SHARE_REPAIR_TRANSPORT_NONCE_BYTES]; + rng.fill_bytes(&mut nonce); + let aad = share_repair_envelope_binding(SHARE_REPAIR_TRANSPORT_AAD_DOMAIN, &envelope_context); + let plaintext = scalar.serialize(); + let ciphertext = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: plaintext.as_ref(), + aad: &aad, + }, + ) + .map_err(|_| EngineError::Internal("share-repair encryption failed".to_string()))?; + let mut payload = Vec::with_capacity(SHARE_REPAIR_TRANSPORT_PAYLOAD_BYTES); + payload.extend_from_slice(ephemeral_public_key_bytes.as_bytes()); + payload.extend_from_slice(&nonce); + payload.extend_from_slice(&ciphertext); + if payload.len() != SHARE_REPAIR_TRANSPORT_PAYLOAD_BYTES { + return Err(EngineError::Internal( + "share-repair cipher returned an unexpected payload length".to_string(), + )); + } + Ok(hex::encode(payload)) +} + +#[allow(clippy::too_many_arguments)] +fn decrypt_repair_scalar( + recipient_secret_key: &RepairSecretKey, + sender_public_key: &RepairPublicKey, + payload_hex: &str, + kind: ShareRepairEnvelopeKind, + context_digest: [u8; 32], + transport_roster_digest: [u8; 32], + sender_identifier: u16, + recipient_identifier: u16, +) -> Result { + let operation = match kind { + ShareRepairEnvelopeKind::Delta => "share_repair_part2", + ShareRepairEnvelopeKind::Sigma => "install_repaired_share", + }; + if payload_hex.len() != SHARE_REPAIR_TRANSPORT_PAYLOAD_BYTES * 2 + || payload_hex.bytes().any(|byte| byte.is_ascii_uppercase()) + { + return Err(validation_error( + operation, + format!( + "{} payload must be canonical lowercase {}-byte hex", + kind.label(), + SHARE_REPAIR_TRANSPORT_PAYLOAD_BYTES + ), + )); + } + let payload = hex::decode(payload_hex) + .map_err(|_| validation_error(operation, format!("invalid {} payload", kind.label())))?; + let (ephemeral_public_key_bytes, remaining) = + payload.split_at(SHARE_REPAIR_TRANSPORT_PUBLIC_KEY_BYTES); + let (nonce, ciphertext) = remaining.split_at(SHARE_REPAIR_TRANSPORT_NONCE_BYTES); + let ephemeral_public_key = RepairPublicKey::from_sec1_bytes(ephemeral_public_key_bytes) + .map_err(|_| { + validation_error( + operation, + format!("invalid {} ephemeral public key", kind.label()), + ) + })?; + if ephemeral_public_key.to_encoded_point(true).as_bytes() != ephemeral_public_key_bytes { + return Err(validation_error( + operation, + format!("non-canonical {} ephemeral public key", kind.label()), + )); + } + let recipient_public_key = recipient_secret_key.public_key(); + let sender_public_key_bytes = sender_public_key.to_encoded_point(true); + let recipient_public_key_bytes = recipient_public_key.to_encoded_point(true); + let secret_scalar = Zeroizing::new(recipient_secret_key.to_nonzero_scalar()); + let ephemeral_shared_secret = diffie_hellman(&*secret_scalar, ephemeral_public_key.as_affine()); + let authenticated_sender_shared_secret = + diffie_hellman(&*secret_scalar, sender_public_key.as_affine()); + let envelope_context = ShareRepairEnvelopeContext { + kind, + authorization_digest: context_digest, + transport_roster_digest, + sender_identifier, + recipient_identifier, + ephemeral_public_key: ephemeral_public_key_bytes, + sender_public_key: sender_public_key_bytes.as_bytes(), + recipient_public_key: recipient_public_key_bytes.as_bytes(), + }; + let key = share_repair_envelope_key( + &ephemeral_shared_secret, + &authenticated_sender_shared_secret, + &envelope_context, + )?; + let cipher = XChaCha20Poly1305::new_from_slice(key.as_ref()).map_err(|_| { + EngineError::Internal("share-repair transport cipher initialization failed".to_string()) + })?; + let aad = share_repair_envelope_binding(SHARE_REPAIR_TRANSPORT_AAD_DOMAIN, &envelope_context); + let plaintext = Zeroizing::new( + cipher + .decrypt( + XNonce::from_slice(nonce), + Payload { + msg: ciphertext, + aad: &aad, + }, + ) + .map_err(|_| { + validation_error( + operation, + format!("{} payload authentication failed", kind.label()), + ) + })?, + ); + SecretRepairScalar::deserialize(plaintext.as_ref()).map_err(|_| { + validation_error( + operation, + format!("{} payload contains an invalid scalar", kind.label()), + ) + }) +} + fn write_length_prefixed(digest: &mut Sha256, value: &[u8]) -> Result<(), EngineError> { let length = u32::try_from(value.len()).map_err(|_| { EngineError::Validation( @@ -131,6 +711,66 @@ fn enforce_share_repair_authorization_time( Ok(()) } +fn enforce_share_repair_preparation_time( + authorization: &ShareRepairAuthorization, +) -> Result<(), EngineError> { + let now = now_unix(); + if now == 0 { + return Err(EngineError::Internal( + "share-repair preparation: system clock is before UNIX epoch".to_string(), + )); + } + if now < authorization.issued_at_unix { + return Err(validation_error( + "begin_share_repair_session", + format!( + "share-repair authorization is not issued until [{}]", + authorization.issued_at_unix + ), + )); + } + if now >= authorization.expires_at_unix { + return Err(validation_error( + "begin_share_repair_session", + format!( + "share-repair authorization expired at [{}]", + authorization.expires_at_unix + ), + )); + } + Ok(()) +} + +fn configured_share_repair_authority_key(operation: &str) -> Result<[u8; 32], EngineError> { + #[cfg(test)] + let test_authority = TEST_SHARE_REPAIR_AUTHORITY + .get_or_init(|| Mutex::new(None)) + .lock() + .expect("share-repair test authority lock") + .as_ref() + .copied(); + #[cfg(not(test))] + let test_authority: Option<[u8; 32]> = None; + if let Some(test_authority) = test_authority { + return Ok(test_authority); + } + let configuration = configured_state_anchor()?.ok_or_else(|| { + validation_error( + operation, + "state-anchor trust configuration is required for share repair", + ) + })?; + Ok(configuration + .trust + .ok_or_else(|| { + validation_error( + operation, + "offline-authority trust configuration is required for share repair", + ) + })? + .offline_authority_public_key) +} + fn validate_share_repair_authorization( operation: &str, authorization: &ShareRepairAuthorization, @@ -291,34 +931,7 @@ fn validate_share_repair_authorization( nonce, )?; - #[cfg(test)] - let test_authority = TEST_SHARE_REPAIR_AUTHORITY - .get_or_init(|| Mutex::new(None)) - .lock() - .expect("share-repair test authority lock") - .as_ref() - .copied(); - #[cfg(not(test))] - let test_authority: Option<[u8; 32]> = None; - let authority_public_key = if let Some(test_authority) = test_authority { - test_authority - } else { - let configuration = configured_state_anchor()?.ok_or_else(|| { - validation_error( - operation, - "state-anchor trust configuration is required for share repair", - ) - })?; - configuration - .trust - .ok_or_else(|| { - validation_error( - operation, - "offline-authority trust configuration is required for share repair", - ) - })? - .offline_authority_public_key - }; + let authority_public_key = configured_share_repair_authority_key(operation)?; let signature = parse_canonical_signature(&authorization.signature_hex)?; let verifying_key = VerifyingKey::from_bytes(&authority_public_key).map_err(|error| { EngineError::Internal(format!( @@ -345,33 +958,386 @@ fn validate_share_repair_authorization( } #[cfg(test)] -pub(crate) fn share_repair_authorization_digest_for_tests( +pub(crate) fn share_repair_authorization_digest_for_tests( + authorization: &ShareRepairAuthorization, +) -> Result<[u8; 32], EngineError> { + let wallet_id = parse_canonical_bytes32(&authorization.wallet_id, "wallet_id")?; + let (_, compressed_key_group) = super::inventory::parse_key_group(&authorization.key_group)?; + let public_key_package_commitment = parse_canonical_bytes32( + &authorization.public_key_package_commitment, + "public_key_package_commitment", + )?; + let old_store_fingerprint = parse_canonical_bytes32( + &authorization.old_store_fingerprint, + "old_store_fingerprint", + )?; + let new_store_fingerprint = parse_canonical_bytes32( + &authorization.new_store_fingerprint, + "new_store_fingerprint", + )?; + let nonce = parse_canonical_bytes32(&authorization.nonce, "nonce")?; + share_repair_authorization_signing_digest( + authorization, + wallet_id, + compressed_key_group, + public_key_package_commitment, + old_store_fingerprint, + new_store_fingerprint, + nonce, + ) +} + +fn share_repair_transport_roster_signing_digest( + authorization_digest: [u8; 32], + participant_public_keys: &[(u16, [u8; 32], [u8; SHARE_REPAIR_TRANSPORT_PUBLIC_KEY_BYTES])], +) -> Result<[u8; 32], EngineError> { + let mut digest = Sha256::new(); + digest.update(SHARE_REPAIR_TRANSPORT_ROSTER_DOMAIN); + digest.update(authorization_digest); + digest.update( + u16::try_from(participant_public_keys.len()) + .map_err(|_| { + EngineError::Validation( + "share-repair transport roster participant count exceeds u16::MAX".to_string(), + ) + })? + .to_be_bytes(), + ); + for (participant_identifier, store_fingerprint, public_key) in participant_public_keys { + digest.update(participant_identifier.to_be_bytes()); + digest.update(store_fingerprint); + digest.update(public_key); + } + Ok(digest.finalize().into()) +} + +fn validate_share_repair_transport_roster_shape( + operation: &str, + authorization: &ShareRepairAuthorization, + authorization_digest: [u8; 32], + roster: &ShareRepairTransportRoster, +) -> Result< + ( + [u8; 32], + BTreeMap, + ), + EngineError, +> { + if roster.schema != TBTC_SIGNER_SHARE_REPAIR_TRANSPORT_ROSTER_SCHEMA { + return Err(validation_error( + operation, + "unsupported share-repair transport-roster schema", + )); + } + let claimed_authorization_digest = parse_canonical_bytes32( + &roster.authorization_digest, + "transport_roster.authorization_digest", + )?; + if claimed_authorization_digest != authorization_digest { + return Err(validation_error( + operation, + "transport roster names a different authorization digest", + )); + } + let expected_count = authorization.helper_identifiers.len() + 1; + if roster.participant_public_keys.len() != expected_count { + return Err(validation_error( + operation, + "transport roster must contain the exact helper set followed by the target", + )); + } + let expected_identifiers = authorization + .helper_identifiers + .iter() + .copied() + .chain(std::iter::once(authorization.target_identifier)); + let target_store_fingerprint = parse_canonical_bytes32( + &authorization.new_store_fingerprint, + "new_store_fingerprint", + )?; + let mut endpoints = BTreeMap::new(); + let mut unique_public_keys = HashSet::new(); + let mut transcript_public_keys = Vec::with_capacity(expected_count); + for (index, (endpoint, expected_identifier)) in roster + .participant_public_keys + .iter() + .zip(expected_identifiers) + .enumerate() + { + if endpoint.participant_identifier != expected_identifier { + return Err(validation_error( + operation, + format!( + "transport_roster.participant_public_keys[{index}] has the wrong participant" + ), + )); + } + let store_fingerprint = parse_canonical_bytes32( + &endpoint.store_fingerprint, + &format!("transport_roster.participant_public_keys[{index}].store_fingerprint"), + )?; + if expected_identifier == authorization.target_identifier + && store_fingerprint != target_store_fingerprint + { + return Err(validation_error( + operation, + "target transport-roster store fingerprint does not match new_store_fingerprint", + )); + } + let public_key = canonical_repair_public_key( + operation, + &format!("transport_roster.participant_public_keys[{index}].public_key_hex"), + &endpoint.public_key_hex, + )?; + let encoded = public_key.to_encoded_point(true); + let mut public_key_bytes = [0u8; SHARE_REPAIR_TRANSPORT_PUBLIC_KEY_BYTES]; + public_key_bytes.copy_from_slice(encoded.as_bytes()); + if !unique_public_keys.insert(public_key_bytes) { + return Err(validation_error( + operation, + "transport roster contains duplicate public keys", + )); + } + endpoints.insert( + expected_identifier, + ValidatedShareRepairTransportEndpoint { + store_fingerprint, + public_key, + }, + ); + transcript_public_keys.push((expected_identifier, store_fingerprint, public_key_bytes)); + } + let signing_digest = share_repair_transport_roster_signing_digest( + authorization_digest, + &transcript_public_keys, + )?; + Ok((signing_digest, endpoints)) +} + +fn validate_share_repair_transport_roster( + operation: &str, + authorization: &ShareRepairAuthorization, + validated: &ValidatedShareRepairAuthorization, + roster: &ShareRepairTransportRoster, +) -> Result { + // The caller has already revalidated the original authorization's exact + // wall-clock window. Binding this roster to its digest inherits the same + // issued/not-before/expiry limits without a second, divergent clock. + let (signing_digest, endpoints) = validate_share_repair_transport_roster_shape( + operation, + authorization, + validated.digest, + roster, + )?; + let signature = parse_canonical_signature(&roster.signature_hex)?; + let authority_public_key = configured_share_repair_authority_key(operation)?; + let verifying_key = VerifyingKey::from_bytes(&authority_public_key).map_err(|error| { + EngineError::Internal(format!( + "configured share-repair authority key is invalid: {error}" + )) + })?; + verifying_key + .verify_strict(&signing_digest, &Signature::from_bytes(&signature)) + .map_err(|_| validation_error(operation, "transport-roster signature is invalid"))?; + Ok(ValidatedShareRepairTransportRoster { + digest: signing_digest, + endpoints, + }) +} + +#[cfg(test)] +pub(crate) fn share_repair_transport_roster_digest_for_tests( authorization: &ShareRepairAuthorization, + roster: &ShareRepairTransportRoster, ) -> Result<[u8; 32], EngineError> { - let wallet_id = parse_canonical_bytes32(&authorization.wallet_id, "wallet_id")?; - let (_, compressed_key_group) = super::inventory::parse_key_group(&authorization.key_group)?; - let public_key_package_commitment = parse_canonical_bytes32( - &authorization.public_key_package_commitment, - "public_key_package_commitment", + let authorization_digest = share_repair_authorization_digest_for_tests(authorization)?; + let (signing_digest, _) = validate_share_repair_transport_roster_shape( + "share_repair_transport_roster_digest_for_tests", + authorization, + authorization_digest, + roster, )?; - let old_store_fingerprint = parse_canonical_bytes32( - &authorization.old_store_fingerprint, - "old_store_fingerprint", + Ok(signing_digest) +} + +pub(crate) fn begin_share_repair_session( + request: BeginShareRepairSessionRequest, +) -> Result { + const OP: &str = "begin_share_repair_session"; + enforce_provenance_gate()?; + let validated = validate_share_repair_authorization(OP, &request.authorization, false)?; + enforce_share_repair_preparation_time(&request.authorization)?; + require_authorized_repair_participant( + OP, + &request.authorization, + request.participant_identifier, )?; - let new_store_fingerprint = parse_canonical_bytes32( - &authorization.new_store_fingerprint, - "new_store_fingerprint", + let store_fingerprint = durable_store_identity()?.fingerprint; + let role = if request.participant_identifier == request.authorization.target_identifier { + if store_fingerprint != validated.new_store_fingerprint { + return Err(validation_error( + OP, + "target repair session must be opened in the authorized new durable store", + )); + } + ShareRepairTransportRole::Target + } else { + // A helper transport key is available only in a native signer that + // demonstrably retains that helper's authorized long-lived share. + let _ = load_helper_material( + OP, + &request.authorization, + &validated, + request.participant_identifier, + )?; + ShareRepairTransportRole::Helper + }; + let session_key = + share_repair_transport_session_key(validated.digest, request.participant_identifier); + // Cache only a live copy. Deterministic derivation keeps the public key + // stable across Finish/process restart so an offline authority can sign + // the roster; rotating the state root or durable store intentionally + // invalidates that roster. + let derived_secret = derive_share_repair_transport_secret( + validated.digest, + request.participant_identifier, + role, + store_fingerprint, )?; - let nonce = parse_canonical_bytes32(&authorization.nonce, "nonce")?; - share_repair_authorization_signing_digest( - authorization, - wallet_id, - compressed_key_group, - public_key_package_commitment, - old_store_fingerprint, - new_store_fingerprint, - nonce, - ) + let derived_public = derived_secret.public_key(); + let now = now_unix(); + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + guard + .share_repair_sessions + .retain(|_, session| session.expires_at_unix > now); + if let Some(session) = guard.share_repair_sessions.get(&session_key) { + if session.secret_key.public_key() != derived_public { + return Err(validation_error( + OP, + "cached native repair session conflicts with the derived store-bound key", + )); + } + } else { + if guard.share_repair_sessions.len() >= SHARE_REPAIR_MAX_LIVE_TRANSPORT_SESSIONS { + return Err(validation_error( + OP, + "live native repair-session limit reached", + )); + } + guard.share_repair_sessions.insert( + session_key, + ShareRepairTransportSession { + secret_key: derived_secret, + expires_at_unix: request.authorization.expires_at_unix, + transport_roster_digest: None, + }, + ); + } + let session = guard + .share_repair_sessions + .get(&session_key) + .ok_or_else(|| EngineError::Internal(format!("{OP}: native repair session disappeared")))?; + let public_key = session.secret_key.public_key().to_encoded_point(true); + let transport_public_key_hex = hex::encode(public_key.as_bytes()); + drop(guard); + ensure_current_share_repair_transport_key( + OP, + &derived_public, + validated.digest, + request.participant_identifier, + role, + store_fingerprint, + )?; + Ok(BeginShareRepairSessionResult { + context_digest: bytes32_hex(validated.digest), + participant_identifier: request.participant_identifier, + store_fingerprint: bytes32_hex(store_fingerprint), + transport_public_key_hex, + }) +} + +pub(crate) fn finish_share_repair_session( + request: FinishShareRepairSessionRequest, +) -> Result { + const OP: &str = "finish_share_repair_session"; + enforce_provenance_gate()?; + // Cleanup deliberately verifies the signed context but ignores its wall + // clock window so a deadline race cannot strand a native private key. + let validated = validate_share_repair_authorization(OP, &request.authorization, false)?; + require_authorized_repair_participant( + OP, + &request.authorization, + request.participant_identifier, + )?; + let session_key = + share_repair_transport_session_key(validated.digest, request.participant_identifier); + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + // Report the postcondition rather than whether this particular call found + // the entry, making cleanup retries genuine successful no-ops. Finish + // wipes only the live cache; a valid signed authorization can rederive the + // deterministic key until its expiry boundary. + guard.share_repair_sessions.remove(&session_key); + Ok(FinishShareRepairSessionResult { + context_digest: bytes32_hex(validated.digest), + participant_identifier: request.participant_identifier, + finished: true, + }) +} + +fn with_share_repair_transport_secret( + operation: &str, + authorization_digest: [u8; 32], + transport_roster_digest: [u8; 32], + participant_identifier: u16, + current_root_bound_secret: &RepairSecretKey, + expected_public_key: &RepairPublicKey, + use_secret: impl FnOnce(&RepairSecretKey) -> Result, +) -> Result { + let now = now_unix(); + let session_key = + share_repair_transport_session_key(authorization_digest, participant_identifier); + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + guard + .share_repair_sessions + .retain(|_, session| session.expires_at_unix > now); + let session = guard + .share_repair_sessions + .get_mut(&session_key) + .ok_or_else(|| { + validation_error( + operation, + format!("no live native repair session for participant [{participant_identifier}]"), + ) + })?; + if session.secret_key.public_key() != current_root_bound_secret.public_key() { + return Err(validation_error( + operation, + "cached native repair session does not match the current state-root-bound key", + )); + } + if current_root_bound_secret.public_key() != *expected_public_key { + return Err(validation_error( + operation, + "local native repair session does not match the signed transport roster", + )); + } + match session.transport_roster_digest { + Some(bound_digest) if bound_digest != transport_roster_digest => { + return Err(validation_error( + operation, + "local native repair session is already bound to a different signed transport roster", + )); + } + Some(_) => {} + None => session.transport_roster_digest = Some(transport_roster_digest), + } + use_secret(&session.secret_key) } fn validate_public_key_package( @@ -537,50 +1503,36 @@ fn load_helper_material( Ok((key_package, stored_public)) } -fn decode_repair_delta( +fn share_repair_lagrange_coefficient( operation: &str, - index: usize, - value: &SecretHex, -) -> Result { - let wire = value.expose_secret(); - if wire.len() != 64 || wire.bytes().any(|byte| byte.is_ascii_uppercase()) { - return Err(validation_error( - operation, - format!("deltas[{index}].data_hex must be canonical lowercase 32-byte hex"), - )); + helper_identifiers: &[u16], + helper_identifier: u16, + target_identifier: u16, +) -> Result { + let helper = RepairScalar::from(u64::from(helper_identifier)); + let target = RepairScalar::from(u64::from(target_identifier)); + let mut numerator = RepairScalar::ONE; + let mut denominator = RepairScalar::ONE; + let mut found = false; + for candidate_identifier in helper_identifiers { + if *candidate_identifier == helper_identifier { + found = true; + continue; + } + let candidate = RepairScalar::from(u64::from(*candidate_identifier)); + numerator *= target - candidate; + denominator *= helper - candidate; } - let mut bytes = decode_hex_field(operation, &format!("deltas[{index}].data_hex"), wire)?; - let result = Delta::deserialize(&bytes).map_err(|error| { - validation_error( - operation, - format!("invalid repair delta [{index}]: {error}"), - ) - }); - bytes.zeroize(); - result -} - -fn decode_repair_sigma( - operation: &str, - index: usize, - value: &SecretHex, -) -> Result { - let wire = value.expose_secret(); - if wire.len() != 64 || wire.bytes().any(|byte| byte.is_ascii_uppercase()) { + if !found { return Err(validation_error( operation, - format!("sigmas[{index}].data_hex must be canonical lowercase 32-byte hex"), + "helper_identifier is not in the authorized helper set", )); } - let mut bytes = decode_hex_field(operation, &format!("sigmas[{index}].data_hex"), wire)?; - let result = Sigma::deserialize(&bytes).map_err(|error| { - validation_error( - operation, - format!("invalid repair sigma [{index}]: {error}"), - ) - }); - bytes.zeroize(); - result + let inverse = Option::::from(denominator.invert()).ok_or_else(|| { + validation_error(operation, "authorized helper identifiers are not distinct") + })?; + Ok(numerator * inverse) } pub(crate) fn share_repair_part1( @@ -595,38 +1547,123 @@ pub(crate) fn share_repair_part1( &validated, request.helper_identifier, )?; - let mut rng = zeroizing_rng_from_os(); - let deltas = frost_repair_share_part1::( - &validated.helper_identifiers, - &key_package, - &mut rng, - validated.target_identifier, - ) - .map_err(|error| validation_error(OP, format!("share repair part1 failed: {error}")))?; - if deltas.len() != validated.helper_identifiers.len() { - return Err(EngineError::Internal(format!( - "{OP}: repair primitive returned an incomplete delta set" - ))); + let transport_roster = validate_share_repair_transport_roster( + OP, + &request.authorization, + &validated, + &request.transport_roster, + )?; + let local_store_fingerprint = durable_store_identity()?.fingerprint; + let local_transport_endpoint = transport_roster + .endpoints + .get(&request.helper_identifier) + .ok_or_else(|| EngineError::Internal(format!("{OP}: helper transport key disappeared")))?; + if local_transport_endpoint.store_fingerprint != local_store_fingerprint { + return Err(validation_error( + OP, + "helper transport-roster store fingerprint does not match the active durable store", + )); } + // Re-resolve the active state-key provider outside the engine-state lock. + // A provider/root rotation invalidates a cached session instead of letting + // old native key material bridge the rotation boundary. + let current_transport_secret = derive_share_repair_transport_secret( + validated.digest, + request.helper_identifier, + ShareRepairTransportRole::Helper, + local_store_fingerprint, + )?; + with_share_repair_transport_secret( + OP, + validated.digest, + transport_roster.digest, + request.helper_identifier, + ¤t_transport_secret, + &local_transport_endpoint.public_key, + |_| Ok(()), + )?; + let signing_share_bytes = Zeroizing::new(key_package.signing_share().serialize()); + let signing_share = + SecretRepairScalar::deserialize(signing_share_bytes.as_ref()).map_err(|error| { + validation_error(OP, format!("invalid retained signing share: {error}")) + })?; + let lagrange = share_repair_lagrange_coefficient( + OP, + &request.authorization.helper_identifiers, + request.helper_identifier, + request.authorization.target_identifier, + )?; + let mut weighted_share = signing_share.multiply_public(lagrange); + let mut rng = zeroizing_rng_from_os(); let context_digest = bytes32_hex(validated.digest); - let mut result_deltas = Vec::with_capacity(deltas.len()); - for (recipient, delta) in deltas { - let recipient_identifier = frost_identifier_to_u16(recipient).ok_or_else(|| { - EngineError::Internal(format!( - "{OP}: repair primitive returned a foreign identifier" - )) + let mut result_deltas = Vec::with_capacity(request.authorization.helper_identifiers.len()); + let mut running_sum = SecretRepairScalar::zero(); + let last_index = request + .authorization + .helper_identifiers + .len() + .checked_sub(1) + .ok_or_else(|| { + EngineError::Internal(format!("{OP}: authorized helper set is unexpectedly empty")) })?; - let mut bytes = delta.serialize(); - let data_hex = SecretHex::new(hex::encode(&bytes)); - bytes.zeroize(); + for (index, recipient_identifier) in request.authorization.helper_identifiers.iter().enumerate() + { + let endpoint = transport_roster + .endpoints + .get(recipient_identifier) + .ok_or_else(|| { + EngineError::Internal(format!("{OP}: recipient transport key disappeared")) + })?; + let delta = if index == last_index { + weighted_share.subtract_assign(&running_sum); + &weighted_share + } else { + let random_delta = SecretRepairScalar::random(&mut rng); + running_sum.add_assign(&random_delta); + result_deltas.push(ShareRepairDelta { + context_digest: context_digest.clone(), + sender_identifier: request.helper_identifier, + recipient_identifier: *recipient_identifier, + payload_hex: encrypt_repair_scalar( + &random_delta, + ¤t_transport_secret, + &endpoint.public_key, + ShareRepairEnvelopeKind::Delta, + validated.digest, + transport_roster.digest, + request.helper_identifier, + *recipient_identifier, + &mut rng, + )?, + }); + continue; + }; result_deltas.push(ShareRepairDelta { context_digest: context_digest.clone(), sender_identifier: request.helper_identifier, - recipient_identifier, - data_hex, + recipient_identifier: *recipient_identifier, + payload_hex: encrypt_repair_scalar( + delta, + ¤t_transport_secret, + &endpoint.public_key, + ShareRepairEnvelopeKind::Delta, + validated.digest, + transport_roster.digest, + request.helper_identifier, + *recipient_identifier, + &mut rng, + )?, }); } + ensure_current_share_repair_transport_key( + OP, + ¤t_transport_secret.public_key(), + validated.digest, + request.helper_identifier, + ShareRepairTransportRole::Helper, + local_store_fingerprint, + )?; Ok(ShareRepairPart1Result { context_digest, helper_identifier: request.helper_identifier, @@ -656,34 +1693,104 @@ pub(crate) fn share_repair_part2( )); } let context_digest = bytes32_hex(validated.digest); - let mut decoded = Vec::with_capacity(request.deltas.len()); - for (index, (delta, expected_sender)) in request - .deltas - .iter() - .zip(request.authorization.helper_identifiers.iter()) - .enumerate() - { - if delta.context_digest != context_digest - || delta.sender_identifier != *expected_sender - || delta.recipient_identifier != request.helper_identifier - { - return Err(validation_error( - OP, - format!("delta [{index}] has the wrong context, sender, or recipient"), - )); - } - decoded.push(decode_repair_delta(OP, index, &delta.data_hex)?); + let transport_roster = validate_share_repair_transport_roster( + OP, + &request.authorization, + &validated, + &request.transport_roster, + )?; + let target_transport_endpoint = transport_roster + .endpoints + .get(&request.authorization.target_identifier) + .ok_or_else(|| EngineError::Internal(format!("{OP}: target transport key disappeared")))?; + let local_transport_endpoint = transport_roster + .endpoints + .get(&request.helper_identifier) + .ok_or_else(|| EngineError::Internal(format!("{OP}: helper transport key disappeared")))?; + let local_store_fingerprint = durable_store_identity()?.fingerprint; + if local_transport_endpoint.store_fingerprint != local_store_fingerprint { + return Err(validation_error( + OP, + "helper transport-roster store fingerprint does not match the active durable store", + )); } - let sigma = frost_repair_share_part2(&decoded); - let mut bytes = sigma.serialize(); - let data_hex = SecretHex::new(hex::encode(&bytes)); - bytes.zeroize(); + let current_transport_secret = derive_share_repair_transport_secret( + validated.digest, + request.helper_identifier, + ShareRepairTransportRole::Helper, + local_store_fingerprint, + )?; + let sigma = with_share_repair_transport_secret( + OP, + validated.digest, + transport_roster.digest, + request.helper_identifier, + ¤t_transport_secret, + &local_transport_endpoint.public_key, + |recipient_secret_key| { + let mut accumulator = SecretRepairScalar::zero(); + for (index, (delta, expected_sender)) in request + .deltas + .iter() + .zip(request.authorization.helper_identifiers.iter()) + .enumerate() + { + if delta.context_digest != context_digest + || delta.sender_identifier != *expected_sender + || delta.recipient_identifier != request.helper_identifier + { + return Err(validation_error( + OP, + format!("delta [{index}] has the wrong context, sender, or recipient"), + )); + } + let decoded = decrypt_repair_scalar( + recipient_secret_key, + &transport_roster + .endpoints + .get(expected_sender) + .ok_or_else(|| { + EngineError::Internal(format!("{OP}: sender transport key disappeared")) + })? + .public_key, + &delta.payload_hex, + ShareRepairEnvelopeKind::Delta, + validated.digest, + transport_roster.digest, + *expected_sender, + request.helper_identifier, + )?; + accumulator.add_assign(&decoded); + } + Ok(accumulator) + }, + )?; + let mut rng = zeroizing_rng_from_os(); + let sigma_payload_hex = encrypt_repair_scalar( + &sigma, + ¤t_transport_secret, + &target_transport_endpoint.public_key, + ShareRepairEnvelopeKind::Sigma, + validated.digest, + transport_roster.digest, + request.helper_identifier, + request.authorization.target_identifier, + &mut rng, + )?; + ensure_current_share_repair_transport_key( + OP, + ¤t_transport_secret.public_key(), + validated.digest, + request.helper_identifier, + ShareRepairTransportRole::Helper, + local_store_fingerprint, + )?; Ok(ShareRepairPart2Result { context_digest: context_digest.clone(), sigma: ShareRepairSigma { context_digest, helper_identifier: request.helper_identifier, - data_hex, + payload_hex: sigma_payload_hex, }, }) } @@ -791,6 +1898,28 @@ pub(crate) fn install_repaired_share( )); } enforce_share_repair_authorization_time(&request.authorization)?; + let transport_roster = validate_share_repair_transport_roster( + OP, + &request.authorization, + &validated, + &request.transport_roster, + )?; + let local_transport_endpoint = transport_roster + .endpoints + .get(&request.authorization.target_identifier) + .ok_or_else(|| EngineError::Internal(format!("{OP}: target transport key disappeared")))?; + if local_transport_endpoint.store_fingerprint != current_store_fingerprint { + return Err(validation_error( + OP, + "target transport-roster store fingerprint does not match the active durable store", + )); + } + let current_transport_secret = derive_share_repair_transport_secret( + validated.digest, + request.authorization.target_identifier, + ShareRepairTransportRole::Target, + current_store_fingerprint, + )?; if request.sigmas.len() != request.authorization.helper_identifiers.len() { return Err(validation_error( @@ -799,49 +1928,101 @@ pub(crate) fn install_repaired_share( )); } let context_digest = bytes32_hex(validated.digest); - let mut decoded = Vec::with_capacity(request.sigmas.len()); - for (index, (sigma, expected_helper)) in request - .sigmas - .iter() - .zip(request.authorization.helper_identifiers.iter()) - .enumerate() - { - if sigma.context_digest != context_digest || sigma.helper_identifier != *expected_helper { - return Err(validation_error( - OP, - format!("sigma [{index}] has the wrong context or helper"), - )); - } - decoded.push(decode_repair_sigma(OP, index, &sigma.data_hex)?); - } - let key_package = - frost_repair_share_part3(&decoded, validated.target_identifier, &repair_public) - .map_err(|error| validation_error(OP, format!("share repair part3 failed: {error}")))?; + let repaired_share = with_share_repair_transport_secret( + OP, + validated.digest, + transport_roster.digest, + request.authorization.target_identifier, + ¤t_transport_secret, + &local_transport_endpoint.public_key, + |recipient_secret_key| { + let mut accumulator = SecretRepairScalar::zero(); + for (index, (sigma, expected_helper)) in request + .sigmas + .iter() + .zip(request.authorization.helper_identifiers.iter()) + .enumerate() + { + if sigma.context_digest != context_digest + || sigma.helper_identifier != *expected_helper + { + return Err(validation_error( + OP, + format!("sigma [{index}] has the wrong context or helper"), + )); + } + let decoded = decrypt_repair_scalar( + recipient_secret_key, + &transport_roster + .endpoints + .get(expected_helper) + .ok_or_else(|| { + EngineError::Internal(format!("{OP}: helper transport key disappeared")) + })? + .public_key, + &sigma.payload_hex, + ShareRepairEnvelopeKind::Sigma, + validated.digest, + transport_roster.digest, + *expected_helper, + request.authorization.target_identifier, + )?; + accumulator.add_assign(&decoded); + } + Ok(accumulator) + }, + )?; + ensure_current_share_repair_transport_key( + OP, + ¤t_transport_secret.public_key(), + validated.digest, + request.authorization.target_identifier, + ShareRepairTransportRole::Target, + current_store_fingerprint, + )?; let expected_verifying_share = stored_public .verifying_shares() .get(&validated.target_identifier) .ok_or_else(|| { EngineError::Internal(format!("{OP}: target verifying share disappeared")) })?; - if key_package.verifying_share() != expected_verifying_share - || key_package.verifying_key() != stored_public.verifying_key() - || *key_package.min_signers() != request.authorization.threshold + let expected_verifying_share_bytes = expected_verifying_share.serialize().map_err(|error| { + EngineError::Internal(format!( + "{OP}: failed to serialize target verifying share: {error}" + )) + })?; + if repaired_share.verifying_share_encoding(OP)?.as_slice() + != expected_verifying_share_bytes.as_slice() { return Err(validation_error( OP, "reconstructed share does not match the authorized public key package", )); } - let mut signing_share = *key_package.signing_share(); - let derives = frost::keys::VerifyingShare::from(signing_share) == *expected_verifying_share; + + let repaired_share_bytes = repaired_share.serialize(); + let mut signing_share = + frost::keys::SigningShare::deserialize(repaired_share_bytes.as_ref()) + .map_err(|error| validation_error(OP, format!("invalid repaired share: {error}")))?; + let key_package = frost::keys::KeyPackage::new( + validated.target_identifier, + signing_share, + *expected_verifying_share, + *repair_public.verifying_key(), + request.authorization.threshold, + ); + // SigningShare is an upstream Copy type. Wipe the named source copy after + // KeyPackage construction; the package owns its own ZeroizeOnDrop copy. signing_share.zeroize(); - if !derives { + if key_package.verifying_share() != expected_verifying_share + || key_package.verifying_key() != stored_public.verifying_key() + || *key_package.min_signers() != request.authorization.threshold + { return Err(validation_error( OP, - "reconstructed signing share does not derive to the target verifying share", + "reconstructed share does not match the authorized public key package", )); } - let mut key_package_bytes = key_package.serialize().map_err(|error| { EngineError::Internal(format!( "{OP}: failed to serialize repaired key package: {error}" @@ -876,3 +2057,292 @@ pub(crate) fn install_repaired_share( outcome.idempotent, )) } + +#[cfg(test)] +mod repair_secret_tests { + use super::*; + + fn assert_zeroizing_drop() {} + fn assert_zeroize_on_drop() {} + + #[test] + fn repair_scalar_has_owned_zeroizing_lifetime() { + assert_zeroizing_drop::(); + assert_zeroize_on_drop::(); + assert!(std::mem::needs_drop::()); + assert!(std::mem::needs_drop::()); + // This is the safety premise for ZeroizingHkdfSha256's complete + // in-place wipe. A dependency upgrade that adds an owned allocation or + // Drop implementation must fail this focused audit guard. + assert!(!std::mem::needs_drop::>()); + assert!(std::mem::needs_drop::()); + + let mut bytes = [0u8; SHARE_REPAIR_TRANSPORT_SCALAR_BYTES]; + bytes[SHARE_REPAIR_TRANSPORT_SCALAR_BYTES - 1] = 7; + let mut scalar = SecretRepairScalar::deserialize(&bytes).expect("canonical scalar"); + assert_eq!(scalar.serialize().as_ref(), bytes); + scalar.zeroize(); + assert_eq!( + scalar.serialize().as_ref(), + [0u8; SHARE_REPAIR_TRANSPORT_SCALAR_BYTES] + ); + } + + #[test] + fn repair_transport_derivation_is_stable_and_domain_separated() { + let _guard = lock_test_state(); + reset_for_tests(); + + let authorization_digest = [0x71; 32]; + let store_fingerprint = [0x72; 32]; + let helper = derive_share_repair_transport_secret( + authorization_digest, + 7, + ShareRepairTransportRole::Helper, + store_fingerprint, + ) + .expect("derive helper transport secret"); + let repeated = derive_share_repair_transport_secret( + authorization_digest, + 7, + ShareRepairTransportRole::Helper, + store_fingerprint, + ) + .expect("repeat helper transport derivation"); + assert_eq!(helper.public_key(), repeated.public_key()); + + let target_role = derive_share_repair_transport_secret( + authorization_digest, + 7, + ShareRepairTransportRole::Target, + store_fingerprint, + ) + .expect("derive target-role transport secret"); + let other_participant = derive_share_repair_transport_secret( + authorization_digest, + 8, + ShareRepairTransportRole::Helper, + store_fingerprint, + ) + .expect("derive other-participant transport secret"); + let other_authorization = derive_share_repair_transport_secret( + [0x73; 32], + 7, + ShareRepairTransportRole::Helper, + store_fingerprint, + ) + .expect("derive other-authorization transport secret"); + let other_store = derive_share_repair_transport_secret( + authorization_digest, + 7, + ShareRepairTransportRole::Helper, + [0x74; 32], + ) + .expect("derive other-store transport secret"); + for separated in [ + target_role.public_key(), + other_participant.public_key(), + other_authorization.public_key(), + other_store.public_key(), + ] { + assert_ne!(helper.public_key(), separated); + } + + std::env::set_var( + TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV, + hex::encode([0x75; 32]), + ); + let rotated_state_root = derive_share_repair_transport_secret( + authorization_digest, + 7, + ShareRepairTransportRole::Helper, + store_fingerprint, + ) + .expect("derive under rotated state root"); + assert_ne!(helper.public_key(), rotated_state_root.public_key()); + assert!(ensure_current_share_repair_transport_key( + "repair-transport-rotation-test", + &helper.public_key(), + authorization_digest, + 7, + ShareRepairTransportRole::Helper, + store_fingerprint, + ) + .is_err()); + ensure_current_share_repair_transport_key( + "repair-transport-rotation-test", + &rotated_state_root.public_key(), + authorization_digest, + 7, + ShareRepairTransportRole::Helper, + store_fingerprint, + ) + .expect("current root-bound key passes the post-operation gate"); + + reset_for_tests(); + } + + #[test] + fn repair_envelope_authenticates_every_routing_binding() { + let mut rng = zeroizing_rng_from_os(); + let sender_secret = RepairSecretKey::random(&mut rng); + let sender_public = sender_secret.public_key(); + let recipient_secret = RepairSecretKey::random(&mut rng); + let recipient_public = recipient_secret.public_key(); + let mut scalar_bytes = [0u8; SHARE_REPAIR_TRANSPORT_SCALAR_BYTES]; + scalar_bytes[SHARE_REPAIR_TRANSPORT_SCALAR_BYTES - 1] = 9; + let scalar = SecretRepairScalar::deserialize(&scalar_bytes).expect("canonical scalar"); + let context_digest = [0x31; 32]; + let transport_roster_digest = [0x41; 32]; + let payload = encrypt_repair_scalar( + &scalar, + &sender_secret, + &recipient_public, + ShareRepairEnvelopeKind::Delta, + context_digest, + transport_roster_digest, + 1, + 2, + &mut rng, + ) + .expect("encrypt scalar"); + assert_eq!(payload.len(), SHARE_REPAIR_TRANSPORT_PAYLOAD_BYTES * 2); + assert!(!payload.contains(&hex::encode(scalar_bytes))); + + let decrypted = decrypt_repair_scalar( + &recipient_secret, + &sender_public, + &payload, + ShareRepairEnvelopeKind::Delta, + context_digest, + transport_roster_digest, + 1, + 2, + ) + .expect("decrypt scalar"); + assert_eq!(decrypted.serialize().as_ref(), scalar_bytes); + + for invalid in [ + decrypt_repair_scalar( + &recipient_secret, + &sender_public, + &payload, + ShareRepairEnvelopeKind::Sigma, + context_digest, + transport_roster_digest, + 1, + 2, + ), + decrypt_repair_scalar( + &recipient_secret, + &sender_public, + &payload, + ShareRepairEnvelopeKind::Delta, + [0x32; 32], + transport_roster_digest, + 1, + 2, + ), + decrypt_repair_scalar( + &recipient_secret, + &sender_public, + &payload, + ShareRepairEnvelopeKind::Delta, + context_digest, + [0x42; 32], + 1, + 2, + ), + decrypt_repair_scalar( + &recipient_secret, + &sender_public, + &payload, + ShareRepairEnvelopeKind::Delta, + context_digest, + transport_roster_digest, + 3, + 2, + ), + decrypt_repair_scalar( + &recipient_secret, + &sender_public, + &payload, + ShareRepairEnvelopeKind::Delta, + context_digest, + transport_roster_digest, + 1, + 3, + ), + ] { + assert!(invalid.is_err()); + } + + let wrong_recipient = RepairSecretKey::random(&mut rng); + assert!(decrypt_repair_scalar( + &wrong_recipient, + &sender_public, + &payload, + ShareRepairEnvelopeKind::Delta, + context_digest, + transport_roster_digest, + 1, + 2, + ) + .is_err()); + + let wrong_sender = RepairSecretKey::random(&mut rng).public_key(); + assert!(decrypt_repair_scalar( + &recipient_secret, + &wrong_sender, + &payload, + ShareRepairEnvelopeKind::Delta, + context_digest, + transport_roster_digest, + 1, + 2, + ) + .is_err()); + + let forger_secret = RepairSecretKey::random(&mut rng); + let forged_payload = encrypt_repair_scalar( + &scalar, + &forger_secret, + &recipient_public, + ShareRepairEnvelopeKind::Delta, + context_digest, + transport_roster_digest, + 1, + 2, + &mut rng, + ) + .expect("construct payload under non-roster sender key"); + assert!(decrypt_repair_scalar( + &recipient_secret, + &sender_public, + &forged_payload, + ShareRepairEnvelopeKind::Delta, + context_digest, + transport_roster_digest, + 1, + 2, + ) + .is_err()); + + let mut corrupted_payload = hex::decode(&payload).expect("payload hex"); + let last = corrupted_payload + .last_mut() + .expect("non-empty encrypted payload"); + *last ^= 1; + assert!(decrypt_repair_scalar( + &recipient_secret, + &sender_public, + &hex::encode(corrupted_payload), + ShareRepairEnvelopeKind::Delta, + context_digest, + transport_roster_digest, + 1, + 2, + ) + .is_err()); + } +} diff --git a/pkg/tbtc/signer/src/engine/state.rs b/pkg/tbtc/signer/src/engine/state.rs index ee5bfdce4f..e932df02f6 100644 --- a/pkg/tbtc/signer/src/engine/state.rs +++ b/pkg/tbtc/signer/src/engine/state.rs @@ -115,7 +115,7 @@ 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 + /// Epoch of the retained cryptographic key packages. The current ABI-5 /// 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, @@ -192,6 +192,13 @@ pub(crate) struct SessionState { #[derive(Default)] pub(crate) struct EngineState { pub(crate) sessions: HashMap, + /// Live authorization-, seat-, role-, and store-bound repair transport + /// keys. The cache is deliberately transient and its native private keys + /// are zeroized on Finish/restart. A still-valid signed authorization can + /// deterministically rederive the same key from the state root after a + /// restart so offline roster authoring and retry remain stable. + pub(crate) share_repair_sessions: + HashMap, pub(crate) refresh_epoch_counter: u64, pub(crate) operator_fault_scores: BTreeMap, pub(crate) quarantined_operator_identifiers: HashSet, diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index 1a0a4dba49..e83c93558f 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -3,6 +3,7 @@ // docs reference them; splitting this file would break those contracts. use super::*; +use crate::api::ShareRepairEndpointPublicKey; use proptest::prelude::*; use serde::Deserialize; #[cfg(unix)] @@ -1289,6 +1290,65 @@ fn share_repair_authorization_digest_matches_go_frozen_vector() { ); } +#[test] +fn share_repair_transport_roster_digest_matches_go_frozen_vector() { + let secp = bitcoin::secp256k1::Secp256k1::new(); + let secret_key = bitcoin::secp256k1::SecretKey::from_slice(&[0x09; 32]).expect("secret key"); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + let compressed = public_key.serialize(); + let authorization = ShareRepairAuthorization { + schema: TBTC_SIGNER_SHARE_REPAIR_AUTHORIZATION_SCHEMA.to_string(), + session_id: "repair-wallet-a-seat-3-epoch-1".to_string(), + wallet_id: format!("0x{}", hex::encode(&compressed[1..])), + key_group: hex::encode(compressed), + public_key_package_commitment: bytes32_hex([0x31; 32]), + target_identifier: 3, + helper_identifiers: vec![1, 2], + threshold: 2, + participant_count: 3, + old_store_fingerprint: bytes32_hex([0x51; 32]), + new_store_fingerprint: bytes32_hex([0x52; 32]), + recovery_epoch: 1, + issued_at_unix: 1_700_000_000, + not_before_unix: 1_700_000_000, + expires_at_unix: 1_700_003_600, + nonce: bytes32_hex([0x61; 32]), + signature_hex: format!("0x{}", "00".repeat(64)), + }; + let roster = ShareRepairTransportRoster { + schema: TBTC_SIGNER_SHARE_REPAIR_TRANSPORT_ROSTER_SCHEMA.to_string(), + authorization_digest: "0xaa8e36cbf287d988c6ed34bf0c38fd64c177500c768fbd3ea7c184b031d7511b" + .to_string(), + participant_public_keys: vec![ + ShareRepairEndpointPublicKey { + participant_identifier: 1, + store_fingerprint: bytes32_hex([0x52; 32]), + public_key_hex: + "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa".to_string(), + }, + ShareRepairEndpointPublicKey { + participant_identifier: 2, + store_fingerprint: bytes32_hex([0x52; 32]), + public_key_hex: + "02466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27".to_string(), + }, + ShareRepairEndpointPublicKey { + participant_identifier: 3, + store_fingerprint: bytes32_hex([0x52; 32]), + public_key_hex: + "023c72addb4fdf09af94f0c94d7fe92a386a7e70cf8a1d85916386bb2535c7b1b1".to_string(), + }, + ], + signature_hex: format!("0x{}", "00".repeat(64)), + }; + let digest = share_repair_transport_roster_digest_for_tests(&authorization, &roster) + .expect("roster digest"); + assert_eq!( + "1a46b993431f075de1adef58a668e8133cca8ca7070eb5d6ffbedee92d224364", + hex::encode(digest) + ); +} + fn resign_share_repair_authorization( authorization: &mut ShareRepairAuthorization, signing_key: &ed25519_dalek::SigningKey, @@ -1301,9 +1361,64 @@ fn resign_share_repair_authorization( format!("0x{}", hex::encode(signing_key.sign(&digest).to_bytes())); } +fn resign_share_repair_transport_roster( + authorization: &ShareRepairAuthorization, + roster: &mut ShareRepairTransportRoster, + signing_key: &ed25519_dalek::SigningKey, +) { + use ed25519_dalek::Signer as _; + + let digest = share_repair_transport_roster_digest_for_tests(authorization, roster) + .expect("transport roster digest"); + roster.signature_hex = format!("0x{}", hex::encode(signing_key.sign(&digest).to_bytes())); +} + fn share_repair_sigmas( authorization: &ShareRepairAuthorization, -) -> (Vec, Vec) { + signing_key: &ed25519_dalek::SigningKey, +) -> ( + Vec, + Vec, + ShareRepairTransportRoster, +) { + let transport_public_keys = authorization + .helper_identifiers + .iter() + .copied() + .chain(std::iter::once(authorization.target_identifier)) + .map(|participant_identifier| { + let session = begin_share_repair_session(BeginShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier, + }) + .expect("begin native repair transport session"); + ( + participant_identifier, + (session.store_fingerprint, session.transport_public_key_hex), + ) + }) + .collect::>(); + let participant_public_keys = authorization + .helper_identifiers + .iter() + .copied() + .chain(std::iter::once(authorization.target_identifier)) + .map(|participant_identifier| ShareRepairEndpointPublicKey { + participant_identifier, + store_fingerprint: transport_public_keys[&participant_identifier].0.clone(), + public_key_hex: transport_public_keys[&participant_identifier].1.clone(), + }) + .collect::>(); + let mut transport_roster = ShareRepairTransportRoster { + schema: TBTC_SIGNER_SHARE_REPAIR_TRANSPORT_ROSTER_SCHEMA.to_string(), + authorization_digest: bytes32_hex( + share_repair_authorization_digest_for_tests(authorization) + .expect("authorization digest"), + ), + participant_public_keys, + signature_hex: format!("0x{}", "00".repeat(64)), + }; + resign_share_repair_transport_roster(authorization, &mut transport_roster, signing_key); let part1 = authorization .helper_identifiers .iter() @@ -1311,6 +1426,7 @@ fn share_repair_sigmas( share_repair_part1(ShareRepairPart1Request { authorization: authorization.clone(), helper_identifier: *helper, + transport_roster: transport_roster.clone(), }) .expect("repair part1") }) @@ -1341,12 +1457,179 @@ fn share_repair_sigmas( authorization: authorization.clone(), helper_identifier: *recipient, deltas, + transport_roster: transport_roster.clone(), }) .expect("repair part2") .sigma }) .collect(); - (part1, sigmas) + (part1, sigmas, transport_roster) +} + +#[test] +fn share_repair_transport_preflight_is_restart_stable_and_time_bounded() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("share_repair_transport_preflight"); + reset_for_tests(); + + let signing_key = ed25519_dalek::SigningKey::from_bytes(&[0xa4; 32]); + set_share_repair_authority_for_tests(Some(signing_key.verifying_key().to_bytes())); + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(35); + let session_id = "share-repair-transport-preflight"; + let mut persisted = None; + for helper in [1_u16, 2] { + persisted = Some( + persist_distributed_dkg_key_package(PersistDistributedDkgKeyPackageRequest { + session_id: session_id.to_string(), + participant_identifier: helper, + threshold: 2, + participant_count: 3, + key_package: native_key_packages[&helper].clone(), + public_key_package: native_public.clone(), + }) + .expect("persist helper for transport preflight"), + ); + } + let persisted = persisted.expect("persisted DKG"); + let store_fingerprint = durable_store_identity() + .expect("store identity") + .fingerprint; + let authorization = signed_share_repair_authorization( + &signing_key, + session_id, + &persisted.key_group, + &native_public, + store_fingerprint, + ); + + let first_helper = begin_share_repair_session(BeginShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier: 1, + }) + .expect("begin helper transport preflight"); + let first_target = begin_share_repair_session(BeginShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier: 3, + }) + .expect("begin target transport preflight"); + assert_eq!( + first_helper.store_fingerprint, + bytes32_hex(store_fingerprint) + ); + assert_eq!( + first_target.store_fingerprint, + bytes32_hex(store_fingerprint) + ); + assert_ne!( + first_helper.transport_public_key_hex, + first_target.transport_public_key_hex + ); + + assert!( + finish_share_repair_session(FinishShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier: 1, + }) + .expect("finish helper transport cache") + .finished + ); + assert!( + finish_share_repair_session(FinishShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier: 1, + }) + .expect("repeat finished helper transport cache") + .finished + ); + let after_finish = begin_share_repair_session(BeginShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier: 1, + }) + .expect("rederive helper transport after Finish"); + assert_eq!( + first_helper.transport_public_key_hex, + after_finish.transport_public_key_hex + ); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + let restarted_helper = begin_share_repair_session(BeginShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier: 1, + }) + .expect("rederive helper transport after restart"); + let restarted_target = begin_share_repair_session(BeginShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier: 3, + }) + .expect("rederive target transport after restart"); + assert_eq!( + first_helper.transport_public_key_hex, + restarted_helper.transport_public_key_hex + ); + assert_eq!( + first_target.transport_public_key_hex, + restarted_target.transport_public_key_hex + ); + + let now = now_unix(); + let mut pre_not_before = authorization.clone(); + pre_not_before.issued_at_unix = now.saturating_sub(1); + pre_not_before.not_before_unix = now.saturating_add(60); + pre_not_before.expires_at_unix = now.saturating_add(120); + pre_not_before.nonce = bytes32_hex([0x93; 32]); + resign_share_repair_authorization(&mut pre_not_before, &signing_key); + let prepared = begin_share_repair_session(BeginShareRepairSessionRequest { + authorization: pre_not_before.clone(), + participant_identifier: 3, + }) + .expect("native roster preflight is allowed before not_before"); + assert_eq!(prepared.store_fingerprint, bytes32_hex(store_fingerprint)); + assert!( + finish_share_repair_session(FinishShareRepairSessionRequest { + authorization: pre_not_before, + participant_identifier: 3, + }) + .expect("pre-not-before transport cleanup") + .finished + ); + + let mut pre_issued = authorization.clone(); + pre_issued.issued_at_unix = now.saturating_add(60); + pre_issued.not_before_unix = now.saturating_add(60); + pre_issued.expires_at_unix = now.saturating_add(120); + pre_issued.nonce = bytes32_hex([0x94; 32]); + resign_share_repair_authorization(&mut pre_issued, &signing_key); + let pre_issued_error = begin_share_repair_session(BeginShareRepairSessionRequest { + authorization: pre_issued, + participant_identifier: 3, + }) + .expect_err("transport preflight before issued_at must fail"); + assert!(matches!( + pre_issued_error, + EngineError::Validation(ref message) if message.contains("not issued until") + )); + + let mut expired = authorization; + expired.issued_at_unix = now.saturating_sub(10); + expired.not_before_unix = now.saturating_sub(5); + expired.expires_at_unix = now; + expired.nonce = bytes32_hex([0x95; 32]); + resign_share_repair_authorization(&mut expired, &signing_key); + let expired_error = begin_share_repair_session(BeginShareRepairSessionRequest { + authorization: expired, + participant_identifier: 3, + }) + .expect_err("expired transport preflight must fail"); + assert!(matches!( + expired_error, + EngineError::Validation(ref message) if message.contains("expired at") + )); + + set_share_repair_authority_for_tests(None); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); } #[test] @@ -1384,11 +1667,52 @@ fn share_repair_installs_exact_share_durably_and_replays_idempotently() { &native_public, store_fingerprint, ); - let (_, sigmas) = share_repair_sigmas(&authorization); + let (part1, sigmas, transport_roster) = share_repair_sigmas(&authorization, &signing_key); + for result in &part1 { + for delta in &result.deltas { + assert_eq!( + delta.payload_hex.len(), + SHARE_REPAIR_TRANSPORT_PAYLOAD_BYTES * 2 + ); + } + let json = serde_json::to_string(result).expect("serialize opaque Part1 result"); + assert!(json.contains("payload_hex")); + assert!(!json.contains("data_hex")); + } + for sigma in &sigmas { + assert_eq!( + sigma.payload_hex.len(), + SHARE_REPAIR_TRANSPORT_PAYLOAD_BYTES * 2 + ); + } + let target_session = begin_share_repair_session(BeginShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier: authorization.target_identifier, + }) + .expect("idempotent target transport begin"); + let repeated_target_session = begin_share_repair_session(BeginShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier: authorization.target_identifier, + }) + .expect("repeated target transport begin"); + assert_eq!( + target_session.transport_public_key_hex, + repeated_target_session.transport_public_key_hex + ); + assert_eq!( + state() + .expect("state") + .lock() + .expect("engine lock") + .share_repair_sessions + .len(), + 3 + ); let install_request = InstallRepairedShareRequest { authorization: authorization.clone(), public_key_package: native_public.clone(), sigmas, + transport_roster, }; let installed = install_repaired_share(install_request.clone()).expect("install repaired share"); @@ -1415,6 +1739,36 @@ fn share_repair_installs_exact_share_durably_and_replays_idempotently() { ); assert!(recovered_inventory.recovery_activation_commitment.is_some()); + for participant_identifier in authorization + .helper_identifiers + .iter() + .copied() + .chain(std::iter::once(authorization.target_identifier)) + { + assert!( + finish_share_repair_session(FinishShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier, + }) + .expect("finish native repair transport session") + .finished + ); + } + assert!( + finish_share_repair_session(FinishShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier: authorization.target_identifier, + }) + .expect("idempotent transport finish") + .finished + ); + assert!(state() + .expect("state") + .lock() + .expect("engine lock") + .share_repair_sessions + .is_empty()); + let expected = decode_key_package( "repair-test", &native_key_packages[&3].identifier, @@ -1532,11 +1886,12 @@ fn share_repair_production_scale_51_of_100_launch_gate() { 100, (1_u16..=51).collect(), ); - let (_, sigmas) = share_repair_sigmas(&authorization); + let (_, sigmas, transport_roster) = share_repair_sigmas(&authorization, &signing_key); let installed = install_repaired_share(InstallRepairedShareRequest { authorization, public_key_package: native_public, sigmas, + transport_roster, }) .expect("install production-scale repaired share"); assert_eq!(installed.target_identifier, 100); @@ -1604,12 +1959,13 @@ fn share_repair_rejects_incomplete_cross_context_corrupt_and_wrong_store_inputs( &native_public, store_fingerprint, ); - let (part1, sigmas) = share_repair_sigmas(&authorization); + let (part1, sigmas, transport_roster) = share_repair_sigmas(&authorization, &signing_key); let incomplete = share_repair_part2(ShareRepairPart2Request { authorization: authorization.clone(), helper_identifier: 1, deltas: vec![part1[0].deltas[0].clone()], + transport_roster: transport_roster.clone(), }) .expect_err("incomplete delta set"); assert!(matches!(incomplete, EngineError::Validation(_))); @@ -1620,6 +1976,7 @@ fn share_repair_rejects_incomplete_cross_context_corrupt_and_wrong_store_inputs( authorization: authorization.clone(), helper_identifier: 1, deltas: vec![cross_context, part1[1].deltas[0].clone()], + transport_roster: transport_roster.clone(), }) .expect_err("cross-context delta"); assert!(matches!(cross_context_error, EngineError::Validation(_))); @@ -1628,6 +1985,7 @@ fn share_repair_rejects_incomplete_cross_context_corrupt_and_wrong_store_inputs( authorization: authorization.clone(), public_key_package: native_public.clone(), sigmas: vec![sigmas[0].clone()], + transport_roster: transport_roster.clone(), }) .expect_err("incomplete sigma set"); assert!(matches!(missing_sigma, EngineError::Validation(_))); @@ -1635,7 +1993,7 @@ fn share_repair_rejects_incomplete_cross_context_corrupt_and_wrong_store_inputs( // A second valid Part1 transcript has the same authorization context but // independent randomness. Substituting just one of its deltas passes the // wire/context checks and must be caught by the target public-share check. - let (alternate_part1, _) = share_repair_sigmas(&authorization); + let (alternate_part1, _, _) = share_repair_sigmas(&authorization, &signing_key); let substituted = share_repair_part2(ShareRepairPart2Request { authorization: authorization.clone(), helper_identifier: 1, @@ -1643,6 +2001,7 @@ fn share_repair_rejects_incomplete_cross_context_corrupt_and_wrong_store_inputs( alternate_part1[0].deltas[0].clone(), part1[1].deltas[0].clone(), ], + transport_roster: transport_roster.clone(), }) .expect("well-shaped but inconsistent deltas") .sigma; @@ -1650,6 +2009,7 @@ fn share_repair_rejects_incomplete_cross_context_corrupt_and_wrong_store_inputs( authorization: authorization.clone(), public_key_package: native_public.clone(), sigmas: vec![substituted, sigmas[1].clone()], + transport_roster: transport_roster.clone(), }) .expect_err("reconstructed share must match public commitment"); assert!(matches!(corrupted, EngineError::Validation(_))); @@ -1670,16 +2030,172 @@ fn share_repair_rejects_incomplete_cross_context_corrupt_and_wrong_store_inputs( authorization: wrong_store, public_key_package: native_public.clone(), sigmas: Vec::new(), + transport_roster: transport_roster.clone(), }) .expect_err("wrong durable store"); assert!(matches!(wrong_store_error, EngineError::Validation(_))); + let secp = bitcoin::secp256k1::Secp256k1::new(); + let chosen_secret = + bitcoin::secp256k1::SecretKey::from_slice(&[0x42; 32]).expect("chosen transport secret"); + let chosen_public = hex::encode( + bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &chosen_secret).serialize(), + ); + + // A caller cannot substitute a chosen recipient key after the offline + // authority signs the native preflight roster. + let mut unsigned_key_substitution = transport_roster.clone(); + unsigned_key_substitution.participant_public_keys[0].public_key_hex = chosen_public.clone(); + let unsigned_key_error = share_repair_part1(ShareRepairPart1Request { + authorization: authorization.clone(), + helper_identifier: 1, + transport_roster: unsigned_key_substitution, + }) + .expect_err("caller key substitution must invalidate the roster signature"); + assert!(matches!( + unsigned_key_error, + EngineError::Validation(ref message) if message.contains("transport-roster signature") + )); + + // Even an authority-signed roster cannot make a local native endpoint use + // a key that it did not derive for this authorization and seat. + let mut signed_local_key_mismatch = transport_roster.clone(); + signed_local_key_mismatch.participant_public_keys[0].public_key_hex = chosen_public.clone(); + resign_share_repair_transport_roster( + &authorization, + &mut signed_local_key_mismatch, + &signing_key, + ); + let signed_local_key_error = share_repair_part1(ShareRepairPart1Request { + authorization: authorization.clone(), + helper_identifier: 1, + transport_roster: signed_local_key_mismatch, + }) + .expect_err("signed chosen local key must not bypass native key ownership"); + assert!(matches!( + signed_local_key_error, + EngineError::Validation(ref message) if message.contains("local native repair session") + )); + + let mut signed_local_store_mismatch = transport_roster.clone(); + signed_local_store_mismatch.participant_public_keys[0].store_fingerprint = + bytes32_hex([0x55; 32]); + resign_share_repair_transport_roster( + &authorization, + &mut signed_local_store_mismatch, + &signing_key, + ); + let signed_local_store_error = share_repair_part1(ShareRepairPart1Request { + authorization: authorization.clone(), + helper_identifier: 1, + transport_roster: signed_local_store_mismatch, + }) + .expect_err("signed wrong local store must not bypass store ownership"); + assert!(matches!( + signed_local_store_error, + EngineError::Validation(ref message) if message.contains("active durable store") + )); + + let mut signed_target_key_mismatch = transport_roster.clone(); + signed_target_key_mismatch + .participant_public_keys + .last_mut() + .expect("target roster entry") + .public_key_hex = chosen_public; + resign_share_repair_transport_roster( + &authorization, + &mut signed_target_key_mismatch, + &signing_key, + ); + let signed_target_key_error = install_repaired_share(InstallRepairedShareRequest { + authorization: authorization.clone(), + public_key_package: native_public.clone(), + sigmas: sigmas.clone(), + transport_roster: signed_target_key_mismatch, + }) + .expect_err("signed chosen target key must not bypass native key ownership"); + assert!(matches!( + signed_target_key_error, + EngineError::Validation(ref message) if message.contains("local native repair session") + )); + + let mut alternate_signed_roster = transport_roster.clone(); + alternate_signed_roster.participant_public_keys[1].store_fingerprint = bytes32_hex([0x56; 32]); + resign_share_repair_transport_roster( + &authorization, + &mut alternate_signed_roster, + &signing_key, + ); + let alternate_roster_error = share_repair_part1(ShareRepairPart1Request { + authorization: authorization.clone(), + helper_identifier: 1, + transport_roster: alternate_signed_roster, + }) + .expect_err("one live native session must not serve two signed rosters"); + assert!(matches!( + alternate_roster_error, + EngineError::Validation(ref message) if message.contains("different signed transport roster") + )); + + let mut duplicate_key_roster = transport_roster.clone(); + duplicate_key_roster.participant_public_keys[1].public_key_hex = duplicate_key_roster + .participant_public_keys[0] + .public_key_hex + .clone(); + let duplicate_key_error = share_repair_part1(ShareRepairPart1Request { + authorization: authorization.clone(), + helper_identifier: 1, + transport_roster: duplicate_key_roster, + }) + .expect_err("duplicate native transport keys must be rejected before signature use"); + assert!(matches!( + duplicate_key_error, + EngineError::Validation(ref message) if message.contains("duplicate public keys") + )); + + let mut target_store_mismatch_roster = transport_roster.clone(); + target_store_mismatch_roster + .participant_public_keys + .last_mut() + .expect("target roster entry") + .store_fingerprint = bytes32_hex([0x57; 32]); + let target_store_roster_error = share_repair_part1(ShareRepairPart1Request { + authorization: authorization.clone(), + helper_identifier: 1, + transport_roster: target_store_mismatch_roster, + }) + .expect_err("target roster store must be the authorization's new store"); + assert!(matches!( + target_store_roster_error, + EngineError::Validation(ref message) if message.contains("new_store_fingerprint") + )); + + std::env::set_var( + TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV, + hex::encode([0x58; 32]), + ); + let rotated_state_root_error = share_repair_part1(ShareRepairPart1Request { + authorization: authorization.clone(), + helper_identifier: 1, + transport_roster: transport_roster.clone(), + }) + .expect_err("cached transport key must not bridge a state-root rotation"); + std::env::set_var( + TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV, + TEST_STATE_ENCRYPTION_KEY_HEX, + ); + assert!(matches!( + rotated_state_root_error, + EngineError::Validation(ref message) if message.contains("current state-root-bound key") + )); + let mut oversized_group = authorization.clone(); oversized_group.participant_count = 101; resign_share_repair_authorization(&mut oversized_group, &signing_key); let oversized_group_error = share_repair_part1(ShareRepairPart1Request { authorization: oversized_group, helper_identifier: 1, + transport_roster: transport_roster.clone(), }) .expect_err("participant count above the production group bound"); assert!(matches!(oversized_group_error, EngineError::Validation(_))); @@ -1689,6 +2205,7 @@ fn share_repair_rejects_incomplete_cross_context_corrupt_and_wrong_store_inputs( let signature_error = share_repair_part1(ShareRepairPart1Request { authorization: bad_signature, helper_identifier: 1, + transport_roster, }) .expect_err("bad authority signature"); assert!(matches!(signature_error, EngineError::Validation(_))); diff --git a/pkg/tbtc/signer/src/engine/testsupport.rs b/pkg/tbtc/signer/src/engine/testsupport.rs index 48e37fce7d..be6d2ee288 100644 --- a/pkg/tbtc/signer/src/engine/testsupport.rs +++ b/pkg/tbtc/signer/src/engine/testsupport.rs @@ -93,6 +93,7 @@ pub fn reset_for_tests() { if let Ok(state) = state() { if let Ok(mut guard) = state.lock() { guard.sessions.clear(); + guard.share_repair_sessions.clear(); guard.refresh_epoch_counter = 0; guard.operator_fault_scores.clear(); guard.quarantined_operator_identifiers.clear(); @@ -124,6 +125,7 @@ pub fn simulate_process_restart_for_tests() { if let Some(state) = ENGINE_STATE.get() { if let Ok(mut guard) = state.lock() { guard.sessions.clear(); + guard.share_repair_sessions.clear(); guard.refresh_epoch_counter = 0; guard.operator_fault_scores.clear(); guard.quarantined_operator_identifiers.clear(); diff --git a/pkg/tbtc/signer/src/lib.rs b/pkg/tbtc/signer/src/lib.rs index 4256b7f368..e3d741c2ec 100644 --- a/pkg/tbtc/signer/src/lib.rs +++ b/pkg/tbtc/signer/src/lib.rs @@ -5,17 +5,18 @@ mod ffi; mod go_math_rand; use api::{ - AcknowledgeStateWitnessCheckpointRequest, BuildTaprootTxRequest, - DeriveInteractiveAttemptContextRequest, DifferentialFuzzRequest, DkgPart1Request, - DkgPart2Request, DkgPart3Request, DurableStoreIdentityResult, FrostTbtcAbiVersionResult, - InitSignerConfigRequest, InstallRepairedShareRequest, InteractiveAggregateRequest, - InteractiveRound1Request, InteractiveRound2Request, InteractiveSessionAbortRequest, - InteractiveSessionOpenRequest, NewSigningPackageRequest, - PersistDistributedDkgKeyPackageRequest, PromoteCanaryRequest, QuarantineStatusRequest, - RecoverStateWitnessCheckpointRequest, RefreshCadenceStatusRequest, RefreshSharesRequest, - RetireDistributedDkgKeyPackagesRequest, RollbackCanaryRequest, ShareRepairPart1Request, - ShareRepairPart2Request, StateWitnessProofRequest, TranscriptAuditRequest, - TransitionStateWitnessAnchorRequest, TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, + AcknowledgeStateWitnessCheckpointRequest, BeginShareRepairSessionRequest, + BuildTaprootTxRequest, DeriveInteractiveAttemptContextRequest, DifferentialFuzzRequest, + DkgPart1Request, DkgPart2Request, DkgPart3Request, DurableStoreIdentityResult, + FinishShareRepairSessionRequest, FrostTbtcAbiVersionResult, InitSignerConfigRequest, + InstallRepairedShareRequest, InteractiveAggregateRequest, InteractiveRound1Request, + InteractiveRound2Request, InteractiveSessionAbortRequest, InteractiveSessionOpenRequest, + NewSigningPackageRequest, PersistDistributedDkgKeyPackageRequest, PromoteCanaryRequest, + QuarantineStatusRequest, RecoverStateWitnessCheckpointRequest, RefreshCadenceStatusRequest, + RefreshSharesRequest, RetireDistributedDkgKeyPackagesRequest, RollbackCanaryRequest, + ShareRepairPart1Request, ShareRepairPart2Request, StateWitnessProofRequest, + TranscriptAuditRequest, TransitionStateWitnessAnchorRequest, TriggerEmergencyRekeyRequest, + VerifyBlameProofRequest, }; use ffi::{ ffi_entry, free_buffer, parse_request, serialize_response, success_from_string, @@ -46,7 +47,12 @@ const TBTC_SIGNER_VERSION: &str = "tbtc-signer/0.1.0-bootstrap"; // exists. Changing status_code from success to error and replacing the response // 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 5: share-repair transport private keys, plaintext delta aggregation, +// sigma aggregation, and repaired-share reconstruction now remain inside Rust. +// The former plaintext scalar request/response fields are replaced by native +// session public keys and opaque AEAD envelopes, so ABI-4.5 bridges must fail +// negotiation rather than decode the incompatible contract. +const TBTC_SIGNER_ABI_MAJOR: u32 = 5; // 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 @@ -59,9 +65,8 @@ const TBTC_SIGNER_ABI_MAJOR: u32 = 4; // 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. -// Minor 5 adds offline-authorized, context-bound share-repair Part1/Part2 and -// atomic repaired-share installation. Existing response shapes are unchanged. -const TBTC_SIGNER_ABI_MINOR: u32 = 5; +// ABI 5.0 starts with the native-custody share-repair transport contract. +const TBTC_SIGNER_ABI_MINOR: u32 = 0; #[cfg(test)] use engine::TBTC_SIGNER_PROFILE_ENV; @@ -433,9 +438,38 @@ pub extern "C" fn frost_tbtc_retire_distributed_dkg_key_packages( }) } -/// Generates one authorized helper's context-bound repair deltas. Every delta -/// is secret and must be delivered only to its named helper recipient over an -/// authenticated confidential channel. +/// Opens an authorization-, participant-, role-, and durable-store-bound +/// native repair transport session. Only the store fingerprint and compressed +/// public key cross the FFI boundary. The zeroizing private key remains in the +/// transient Rust cache and can be deterministically rederived from the native +/// state root until the signed authorization expires. +#[no_mangle] +pub extern "C" fn frost_tbtc_begin_share_repair_session( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + normal_ffi_entry(|| { + let request: BeginShareRepairSessionRequest = parse_request(request_ptr, request_len)?; + serialize_response(&engine::begin_share_repair_session(request)?) + }) +} + +/// Wipes the live native repair transport key cache entry. Cleanup is +/// idempotent and remains available after authorization expiry; before expiry, +/// a later Begin can deterministically rederive the same store-bound key. +#[no_mangle] +pub extern "C" fn frost_tbtc_finish_share_repair_session( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + normal_ffi_entry(|| { + let request: FinishShareRepairSessionRequest = parse_request(request_ptr, request_len)?; + serialize_response(&engine::finish_share_repair_session(request)?) + }) +} + +/// Generates one authorized helper's context-bound repair deltas and encrypts +/// each scalar inside Rust before returning opaque envelopes to the host. #[no_mangle] pub extern "C" fn frost_tbtc_share_repair_part1( request_ptr: *const u8, @@ -447,8 +481,8 @@ pub extern "C" fn frost_tbtc_share_repair_part1( }) } -/// Combines the exact authorized delta sender set at one helper and returns a -/// context-bound secret sigma for the recovering target. +/// Decrypts and stream-combines the exact authorized delta sender set inside +/// Rust, then returns only an opaque sigma envelope for the recovering target. #[no_mangle] pub extern "C" fn frost_tbtc_share_repair_part2( request_ptr: *const u8, @@ -984,9 +1018,11 @@ mod tests { // 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; - // minor 5 adds context-bound share repair and atomic installation. - assert_eq!(abi.abi_major, 4); - assert_eq!(abi.abi_minor, 5); + // minor 5 adds context-bound share repair and atomic installation; + // ABI 5 replaces its plaintext scalar wire shape with native repair + // sessions and opaque authenticated ciphertexts. + assert_eq!(abi.abi_major, 5); + assert_eq!(abi.abi_minor, 0); } #[test] From 87492db35f12566f83429fb2a2f751811b5afd95 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 11 Aug 2026 08:56:58 -0400 Subject: [PATCH 4/5] fix(tbtc/signer): stabilize share-repair retries --- pkg/tbtc/signer/src/engine/repair.rs | 314 ++++++++++++++++++++++++++- pkg/tbtc/signer/src/engine/tests.rs | 232 ++++++++++++++++++-- 2 files changed, 520 insertions(+), 26 deletions(-) diff --git a/pkg/tbtc/signer/src/engine/repair.rs b/pkg/tbtc/signer/src/engine/repair.rs index 86445f1da8..35d6b476f5 100644 --- a/pkg/tbtc/signer/src/engine/repair.rs +++ b/pkg/tbtc/signer/src/engine/repair.rs @@ -13,7 +13,7 @@ use ed25519_dalek::{Signature, VerifyingKey}; use hkdf::Hkdf; use k256::{ ecdh::{diffie_hellman, EphemeralSecret}, - elliptic_curve::{sec1::ToEncodedPoint, Field as K256Field, PrimeField}, + elliptic_curve::{sec1::ToEncodedPoint, PrimeField}, PublicKey as RepairPublicKey, Scalar as RepairScalar, SecretKey as RepairSecretKey, }; use zeroize::ZeroizeOnDrop; @@ -30,6 +30,14 @@ const SHARE_REPAIR_TRANSPORT_ROSTER_DOMAIN: &[u8] = b"tbtc-frost-share-repair-transport-roster/v1\0"; const SHARE_REPAIR_TRANSPORT_SECRET_DERIVATION_DOMAIN: &[u8] = b"tbtc-frost-share-repair-transport-secret/v1\0"; +// A signed authorization and transport roster define one replayable Part1 +// plaintext transcript. This derivation is frozen for transport v1. Any +// algorithm or domain change must also bump the transport/AAD/ABI version and +// require a uniform launch; otherwise two signer versions could disagree about +// a sender/recipient slot while accepting the same recovery bundle. The frozen +// known-answer test below catches accidental drift within v1. +const SHARE_REPAIR_PART1_DELTA_DERIVATION_DOMAIN: &[u8] = + b"tbtc-frost-share-repair-part1-delta/v1\0"; const SHARE_REPAIR_MAX_AUTHORIZATION_LIFETIME_SECONDS: u64 = 24 * 60 * 60; const SHARE_REPAIR_TRANSPORT_VERSION: u8 = 1; const SHARE_REPAIR_TRANSPORT_KDF_DOMAIN: &[u8] = b"tbtc-frost-share-repair-kdf/v1\0"; @@ -125,11 +133,6 @@ impl SecretRepairScalar { Self(Zeroizing::new([0u8; SHARE_REPAIR_TRANSPORT_SCALAR_BYTES])) } - fn random(rng: &mut (impl RngCore + CryptoRng)) -> Self { - let scalar = Zeroizing::new(RepairScalar::random(rng)); - Self::from_scalar(&scalar) - } - fn deserialize(bytes: &[u8]) -> Result { if bytes.len() != SHARE_REPAIR_TRANSPORT_SCALAR_BYTES { return Err(EngineError::Validation( @@ -343,6 +346,52 @@ fn derive_share_repair_transport_secret( )) } +fn derive_share_repair_part1_delta( + transport_secret: &RepairSecretKey, + authorization_digest: [u8; 32], + transport_roster_digest: [u8; 32], + sender_identifier: u16, + recipient_identifier: u16, +) -> Result { + // The transport secret is itself authorization-, role-, store-, provider-, + // and state-root-bound. Keying the per-slot PRF with it makes an exact + // signed bundle replay the same plaintext row after Finish/restart while a + // root or roster change produces an independent transcript. Each slot is + // sampled independently so code changes cannot shift a sequential DRBG and + // silently change every later recipient. + let transport_secret_bytes = Zeroizing::new(transport_secret.to_bytes()); + let kdf = ZeroizingHkdfSha256::new( + SHARE_REPAIR_PART1_DELTA_DERIVATION_DOMAIN, + transport_secret_bytes.as_slice(), + ); + for counter in 0..=u32::MAX { + let mut info = Zeroizing::new(Vec::with_capacity( + SHARE_REPAIR_PART1_DELTA_DERIVATION_DOMAIN.len() + + 1 + + authorization_digest.len() + + transport_roster_digest.len() + + 2 + + 2 + + 4, + )); + info.extend_from_slice(SHARE_REPAIR_PART1_DELTA_DERIVATION_DOMAIN); + info.push(SHARE_REPAIR_TRANSPORT_VERSION); + info.extend_from_slice(&authorization_digest); + info.extend_from_slice(&transport_roster_digest); + info.extend_from_slice(&sender_identifier.to_be_bytes()); + info.extend_from_slice(&recipient_identifier.to_be_bytes()); + info.extend_from_slice(&counter.to_be_bytes()); + let mut candidate = Zeroizing::new([0u8; SHARE_REPAIR_TRANSPORT_SCALAR_BYTES]); + kdf.expand(info.as_slice(), candidate.as_mut())?; + if let Ok(delta) = SecretRepairScalar::deserialize(candidate.as_ref()) { + return Ok(delta); + } + } + Err(EngineError::Internal( + "share-repair Part1 delta derivation exhausted its counter".to_string(), + )) +} + fn ensure_current_share_repair_transport_key( operation: &str, expected_public_key: &RepairPublicKey, @@ -628,6 +677,131 @@ fn decrypt_repair_scalar( }) } +#[cfg(test)] +pub(crate) fn share_repair_delta_plaintext_for_tests( + authorization: &ShareRepairAuthorization, + transport_roster: &ShareRepairTransportRoster, + delta: &ShareRepairDelta, +) -> Result, EngineError> { + const OP: &str = "share_repair_delta_plaintext_for_tests"; + let validated = validate_share_repair_authorization(OP, authorization, true)?; + let transport_roster = + validate_share_repair_transport_roster(OP, authorization, &validated, transport_roster)?; + if delta.context_digest != bytes32_hex(validated.digest) + || authorization + .helper_identifiers + .binary_search(&delta.sender_identifier) + .is_err() + || authorization + .helper_identifiers + .binary_search(&delta.recipient_identifier) + .is_err() + { + return Err(validation_error(OP, "delta has invalid routing bindings")); + } + let local_store_fingerprint = durable_store_identity()?.fingerprint; + let recipient_endpoint = transport_roster + .endpoints + .get(&delta.recipient_identifier) + .ok_or_else(|| validation_error(OP, "recipient endpoint is missing"))?; + if recipient_endpoint.store_fingerprint != local_store_fingerprint { + return Err(validation_error( + OP, + "recipient endpoint does not use the active test store", + )); + } + let recipient_secret = derive_share_repair_transport_secret( + validated.digest, + delta.recipient_identifier, + ShareRepairTransportRole::Helper, + local_store_fingerprint, + )?; + if recipient_secret.public_key() != recipient_endpoint.public_key { + return Err(validation_error( + OP, + "recipient endpoint does not match its derived test key", + )); + } + let sender_endpoint = transport_roster + .endpoints + .get(&delta.sender_identifier) + .ok_or_else(|| validation_error(OP, "sender endpoint is missing"))?; + Ok(decrypt_repair_scalar( + &recipient_secret, + &sender_endpoint.public_key, + &delta.payload_hex, + ShareRepairEnvelopeKind::Delta, + validated.digest, + transport_roster.digest, + delta.sender_identifier, + delta.recipient_identifier, + )? + .serialize()) +} + +#[cfg(test)] +pub(crate) fn corrupt_share_repair_delta_plaintext_for_tests( + authorization: &ShareRepairAuthorization, + transport_roster: &ShareRepairTransportRoster, + delta: &ShareRepairDelta, +) -> Result { + const OP: &str = "corrupt_share_repair_delta_plaintext_for_tests"; + let plaintext = share_repair_delta_plaintext_for_tests(authorization, transport_roster, delta)?; + let mut altered = SecretRepairScalar::deserialize(plaintext.as_ref())?; + let mut one_bytes = Zeroizing::new([0u8; SHARE_REPAIR_TRANSPORT_SCALAR_BYTES]); + one_bytes[SHARE_REPAIR_TRANSPORT_SCALAR_BYTES - 1] = 1; + let one = SecretRepairScalar::deserialize(one_bytes.as_ref())?; + altered.add_assign(&one); + + let validated = validate_share_repair_authorization(OP, authorization, true)?; + let transport_roster = + validate_share_repair_transport_roster(OP, authorization, &validated, transport_roster)?; + let local_store_fingerprint = durable_store_identity()?.fingerprint; + let sender_endpoint = transport_roster + .endpoints + .get(&delta.sender_identifier) + .ok_or_else(|| validation_error(OP, "sender endpoint is missing"))?; + if sender_endpoint.store_fingerprint != local_store_fingerprint { + return Err(validation_error( + OP, + "sender endpoint does not use the active test store", + )); + } + let sender_secret = derive_share_repair_transport_secret( + validated.digest, + delta.sender_identifier, + ShareRepairTransportRole::Helper, + local_store_fingerprint, + )?; + if sender_secret.public_key() != sender_endpoint.public_key { + return Err(validation_error( + OP, + "sender endpoint does not match its derived test key", + )); + } + let recipient_endpoint = transport_roster + .endpoints + .get(&delta.recipient_identifier) + .ok_or_else(|| validation_error(OP, "recipient endpoint is missing"))?; + let mut rng = zeroizing_rng_from_os(); + Ok(ShareRepairDelta { + context_digest: delta.context_digest.clone(), + sender_identifier: delta.sender_identifier, + recipient_identifier: delta.recipient_identifier, + payload_hex: encrypt_repair_scalar( + &altered, + &sender_secret, + &recipient_endpoint.public_key, + ShareRepairEnvelopeKind::Delta, + validated.digest, + transport_roster.digest, + delta.sender_identifier, + delta.recipient_identifier, + &mut rng, + )?, + }) +} + fn write_length_prefixed(digest: &mut Sha256, value: &[u8]) -> Result<(), EngineError> { let length = u32::try_from(value.len()).map_err(|_| { EngineError::Validation( @@ -1595,7 +1769,11 @@ pub(crate) fn share_repair_part1( request.authorization.target_identifier, )?; let mut weighted_share = signing_share.multiply_public(lagrange); - let mut rng = zeroizing_rng_from_os(); + // Plaintext delta slots are deterministic for this exact signed bundle so + // a helper restart cannot mix a new row with peers' still-retransmitted old + // rows. The ECIES envelopes below deliberately retain fresh OS randomness; + // authenticated alternate encodings of a slot decrypt to this same value. + let mut envelope_rng = zeroizing_rng_from_os(); let context_digest = bytes32_hex(validated.digest); let mut result_deltas = Vec::with_capacity(request.authorization.helper_identifiers.len()); let mut running_sum = SecretRepairScalar::zero(); @@ -1619,7 +1797,13 @@ pub(crate) fn share_repair_part1( weighted_share.subtract_assign(&running_sum); &weighted_share } else { - let random_delta = SecretRepairScalar::random(&mut rng); + let random_delta = derive_share_repair_part1_delta( + ¤t_transport_secret, + validated.digest, + transport_roster.digest, + request.helper_identifier, + *recipient_identifier, + )?; running_sum.add_assign(&random_delta); result_deltas.push(ShareRepairDelta { context_digest: context_digest.clone(), @@ -1634,7 +1818,7 @@ pub(crate) fn share_repair_part1( transport_roster.digest, request.helper_identifier, *recipient_identifier, - &mut rng, + &mut envelope_rng, )?, }); continue; @@ -1652,7 +1836,7 @@ pub(crate) fn share_repair_part1( transport_roster.digest, request.helper_identifier, *recipient_identifier, - &mut rng, + &mut envelope_rng, )?, }); } @@ -2182,6 +2366,91 @@ mod repair_secret_tests { reset_for_tests(); } + #[test] + fn part1_delta_derivation_is_replay_stable_and_context_separated() { + let transport_secret = + RepairSecretKey::from_slice(&[0x61; 32]).expect("fixed transport secret"); + let authorization_digest = [0x31; 32]; + let transport_roster_digest = [0x41; 32]; + let first = derive_share_repair_part1_delta( + &transport_secret, + authorization_digest, + transport_roster_digest, + 1, + 2, + ) + .expect("derive first replay delta"); + // Deriving an unrelated slot in between must not advance shared state + // or change this slot's result. + let _other_slot = derive_share_repair_part1_delta( + &transport_secret, + authorization_digest, + transport_roster_digest, + 1, + 3, + ) + .expect("derive other replay slot"); + let replay = derive_share_repair_part1_delta( + &transport_secret, + authorization_digest, + transport_roster_digest, + 1, + 2, + ) + .expect("rederive replay delta"); + let first_bytes = first.serialize(); + assert_eq!( + "6bdeabf1aabfd58ae11dc11d7b3c685f6d13397cbabd1670507a3067f80ff0a6", + hex::encode(&first_bytes[..]) + ); + assert_eq!(&first_bytes[..], &replay.serialize()[..]); + + for separated in [ + derive_share_repair_part1_delta( + &transport_secret, + [0x32; 32], + transport_roster_digest, + 1, + 2, + ), + derive_share_repair_part1_delta( + &transport_secret, + authorization_digest, + [0x42; 32], + 1, + 2, + ), + derive_share_repair_part1_delta( + &transport_secret, + authorization_digest, + transport_roster_digest, + 2, + 2, + ), + derive_share_repair_part1_delta( + &transport_secret, + authorization_digest, + transport_roster_digest, + 1, + 3, + ), + ] { + let separated = separated.expect("derive separated replay delta"); + assert_ne!(&first_bytes[..], &separated.serialize()[..]); + } + let other_transport_secret = + RepairSecretKey::from_slice(&[0x62; 32]).expect("other transport secret"); + let other_key = derive_share_repair_part1_delta( + &other_transport_secret, + authorization_digest, + transport_roster_digest, + 1, + 2, + ) + .expect("derive other-key replay delta"); + assert_ne!(&first_bytes[..], &other_key.serialize()[..]); + } + #[test] fn repair_envelope_authenticates_every_routing_binding() { let mut rng = zeroizing_rng_from_os(); @@ -2208,6 +2477,19 @@ mod repair_secret_tests { .expect("encrypt scalar"); assert_eq!(payload.len(), SHARE_REPAIR_TRANSPORT_PAYLOAD_BYTES * 2); assert!(!payload.contains(&hex::encode(scalar_bytes))); + let alternate_encoding = encrypt_repair_scalar( + &scalar, + &sender_secret, + &recipient_public, + ShareRepairEnvelopeKind::Delta, + context_digest, + transport_roster_digest, + 1, + 2, + &mut rng, + ) + .expect("encrypt alternate scalar encoding"); + assert_ne!(payload, alternate_encoding); let decrypted = decrypt_repair_scalar( &recipient_secret, @@ -2221,6 +2503,18 @@ mod repair_secret_tests { ) .expect("decrypt scalar"); assert_eq!(decrypted.serialize().as_ref(), scalar_bytes); + let alternate_decrypted = decrypt_repair_scalar( + &recipient_secret, + &sender_public, + &alternate_encoding, + ShareRepairEnvelopeKind::Delta, + context_digest, + transport_roster_digest, + 1, + 2, + ) + .expect("decrypt alternate scalar encoding"); + assert_eq!(alternate_decrypted.serialize().as_ref(), scalar_bytes); for invalid in [ decrypt_repair_scalar( diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index e83c93558f..88dbff4dbe 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -1924,6 +1924,190 @@ fn share_repair_production_scale_51_of_100_launch_gate() { clear_state_storage_policy_overrides(); } +#[test] +fn share_repair_same_bundle_partial_restart_replays_plaintext_transcript() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("share_repair_partial_restart_replay"); + reset_for_tests(); + + let signing_key = ed25519_dalek::SigningKey::from_bytes(&[0xb6; 32]); + set_share_repair_authority_for_tests(Some(signing_key.verifying_key().to_bytes())); + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(79); + let session_id = "share-repair-partial-restart-replay"; + let mut persisted = None; + for helper in [1_u16, 2] { + persisted = Some( + persist_distributed_dkg_key_package(PersistDistributedDkgKeyPackageRequest { + session_id: session_id.to_string(), + participant_identifier: helper, + threshold: 2, + participant_count: 3, + key_package: native_key_packages[&helper].clone(), + public_key_package: native_public.clone(), + }) + .expect("persist replay helper"), + ); + } + let persisted = persisted.expect("persisted replay DKG"); + let store_fingerprint = durable_store_identity() + .expect("replay store identity") + .fingerprint; + let authorization = signed_share_repair_authorization( + &signing_key, + session_id, + &persisted.key_group, + &native_public, + store_fingerprint, + ); + let (part1, _, transport_roster) = share_repair_sigmas(&authorization, &signing_key); + let original_delta_plaintext = share_repair_delta_plaintext_for_tests( + &authorization, + &transport_roster, + &part1[0].deltas[0], + ) + .expect("decrypt original Part1 slot"); + + assert!( + finish_share_repair_session(FinishShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier: 1, + }) + .expect("finish helper before same-bundle replay") + .finished + ); + begin_share_repair_session(BeginShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier: 1, + }) + .expect("reopen helper after Finish"); + let after_finish_part1 = share_repair_part1(ShareRepairPart1Request { + authorization: authorization.clone(), + helper_identifier: 1, + transport_roster: transport_roster.clone(), + }) + .expect("replay Part1 after Finish"); + assert_ne!( + part1[0].deltas[0].payload_hex, + after_finish_part1.deltas[0].payload_hex + ); + let after_finish_delta_plaintext = share_repair_delta_plaintext_for_tests( + &authorization, + &transport_roster, + &after_finish_part1.deltas[0], + ) + .expect("decrypt after-Finish Part1 slot"); + assert_eq!( + &original_delta_plaintext[..], + &after_finish_delta_plaintext[..] + ); + + // This saved Part1/sigma material represents surviving peers' active + // retransmitters. Only helper one loses its local operation state; the + // restarted process rederives the same transport key and plaintext row. + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + for participant_identifier in [1_u16, 2, authorization.target_identifier] { + begin_share_repair_session(BeginShareRepairSessionRequest { + authorization: authorization.clone(), + participant_identifier, + }) + .expect("reopen native repair session after process restart"); + } + let restarted_part1 = share_repair_part1(ShareRepairPart1Request { + authorization: authorization.clone(), + helper_identifier: 1, + transport_roster: transport_roster.clone(), + }) + .expect("replay Part1 after process restart"); + assert_ne!( + after_finish_part1.deltas[1].payload_hex, + restarted_part1.deltas[1].payload_hex + ); + let restarted_delta_plaintext = share_repair_delta_plaintext_for_tests( + &authorization, + &transport_roster, + &restarted_part1.deltas[0], + ) + .expect("decrypt restarted Part1 slot"); + assert_eq!( + &original_delta_plaintext[..], + &restarted_delta_plaintext[..] + ); + + let mut fresh_authorization = authorization.clone(); + fresh_authorization.recovery_epoch = fresh_authorization + .recovery_epoch + .checked_add(1) + .expect("fresh recovery epoch"); + fresh_authorization.nonce = bytes32_hex([0xb7; 32]); + resign_share_repair_authorization(&mut fresh_authorization, &signing_key); + let (fresh_part1, _, fresh_transport_roster) = + share_repair_sigmas(&fresh_authorization, &signing_key); + let fresh_delta_plaintext = share_repair_delta_plaintext_for_tests( + &fresh_authorization, + &fresh_transport_roster, + &fresh_part1[0].deltas[0], + ) + .expect("decrypt fresh-authorization Part1 slot"); + assert_ne!(&original_delta_plaintext[..], &fresh_delta_plaintext[..]); + + let restarted_sigma_one = share_repair_part2(ShareRepairPart2Request { + authorization: authorization.clone(), + helper_identifier: 1, + deltas: vec![ + restarted_part1.deltas[0].clone(), + part1[1].deltas[0].clone(), + ], + transport_roster: transport_roster.clone(), + }) + .expect("aggregate restarted self delta with surviving old peer delta") + .sigma; + let replayed_sigma_two = share_repair_part2(ShareRepairPart2Request { + authorization: authorization.clone(), + helper_identifier: 2, + deltas: vec![ + after_finish_part1.deltas[1].clone(), + part1[1].deltas[1].clone(), + ], + transport_roster: transport_roster.clone(), + }) + .expect("aggregate after-Finish delta with surviving old self delta after restart") + .sigma; + let installed = install_repaired_share(InstallRepairedShareRequest { + authorization: authorization.clone(), + public_key_package: native_public.clone(), + sigmas: vec![restarted_sigma_one, replayed_sigma_two], + transport_roster, + }) + .expect("same-bundle mixed old/new ciphertext generations install"); + assert!(!installed.idempotent); + + let expected = decode_key_package( + "repair-partial-restart-replay", + &native_key_packages[&authorization.target_identifier].identifier, + native_key_packages[&authorization.target_identifier] + .data_hex + .expose_secret(), + ) + .expect("decode expected replayed target share"); + assert_eq!( + state() + .expect("state") + .lock() + .expect("engine lock") + .sessions[session_id] + .dkg_key_packages + .as_ref() + .expect("replayed packages")[&authorization.target_identifier], + expected + ); + + set_share_repair_authority_for_tests(None); + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); +} + #[test] fn share_repair_rejects_incomplete_cross_context_corrupt_and_wrong_store_inputs() { let _guard = lock_test_state(); @@ -1990,29 +2174,45 @@ fn share_repair_rejects_incomplete_cross_context_corrupt_and_wrong_store_inputs( .expect_err("incomplete sigma set"); assert!(matches!(missing_sigma, EngineError::Validation(_))); - // A second valid Part1 transcript has the same authorization context but - // independent randomness. Substituting just one of its deltas passes the - // wire/context checks and must be caught by the target public-share check. - let (alternate_part1, _, _) = share_repair_sigmas(&authorization, &signing_key); - let substituted = share_repair_part2(ShareRepairPart2Request { + // A valid sender-static AEAD can still carry an algebraically inconsistent + // scalar. Part2 accepts and aggregates the authentic shape; the target's + // public-share check must reject it before any repaired package persists. + let corrupted_delta = corrupt_share_repair_delta_plaintext_for_tests( + &authorization, + &transport_roster, + &part1[0].deltas[0], + ) + .expect("construct legitimately authenticated but altered delta"); + let original_plaintext = share_repair_delta_plaintext_for_tests( + &authorization, + &transport_roster, + &part1[0].deltas[0], + ) + .expect("decrypt original negative-test delta"); + let corrupted_plaintext = + share_repair_delta_plaintext_for_tests(&authorization, &transport_roster, &corrupted_delta) + .expect("decrypt altered negative-test delta"); + assert_ne!(&original_plaintext[..], &corrupted_plaintext[..]); + let corrupted_sigma = share_repair_part2(ShareRepairPart2Request { authorization: authorization.clone(), helper_identifier: 1, - deltas: vec![ - alternate_part1[0].deltas[0].clone(), - part1[1].deltas[0].clone(), - ], + deltas: vec![corrupted_delta, part1[1].deltas[0].clone()], transport_roster: transport_roster.clone(), }) - .expect("well-shaped but inconsistent deltas") + .expect("Part2 accepts an authentic altered delta") .sigma; - let corrupted = install_repaired_share(InstallRepairedShareRequest { + let algebraic_corruption = install_repaired_share(InstallRepairedShareRequest { authorization: authorization.clone(), public_key_package: native_public.clone(), - sigmas: vec![substituted, sigmas[1].clone()], + sigmas: vec![corrupted_sigma, sigmas[1].clone()], transport_roster: transport_roster.clone(), }) - .expect_err("reconstructed share must match public commitment"); - assert!(matches!(corrupted, EngineError::Validation(_))); + .expect_err("target public-share check rejects algebraic corruption"); + assert!(matches!( + algebraic_corruption, + EngineError::Validation(ref message) + if message.contains("reconstructed share does not match") + )); assert!(!state() .expect("state") .lock() @@ -2020,8 +2220,8 @@ fn share_repair_rejects_incomplete_cross_context_corrupt_and_wrong_store_inputs( .sessions[session_id] .dkg_key_packages .as_ref() - .unwrap() - .contains_key(&3)); + .expect("helper packages") + .contains_key(&authorization.target_identifier)); let mut wrong_store = authorization.clone(); wrong_store.new_store_fingerprint = bytes32_hex([0x77; 32]); From c0d08c0c0c831c54ff765c9520be674789b12efa Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 11 Aug 2026 09:39:52 -0400 Subject: [PATCH 5/5] chore(ci): repin rolling TLA tools artifact --- pkg/tbtc/signer/scripts/formal/run_tla_models.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/tbtc/signer/scripts/formal/run_tla_models.sh b/pkg/tbtc/signer/scripts/formal/run_tla_models.sh index 6771581087..9b3ace5ea7 100755 --- a/pkg/tbtc/signer/scripts/formal/run_tla_models.sh +++ b/pkg/tbtc/signer/scripts/formal/run_tla_models.sh @@ -13,10 +13,10 @@ TLA_TOOLS_VERSION="${TLA_TOOLS_VERSION:-v1.8.0}" TLA_TOOLS_JAR="${TLA_TOOLS_JAR:-/tmp/tla2tools-${TLA_TOOLS_VERSION}.jar}" TLA_TOOLS_URL="${TLA_TOOLS_URL:-https://github.com/tlaplus/tlaplus/releases/download/${TLA_TOOLS_VERSION}/tla2tools.jar}" # Pin the SHA-256 of the upstream tla2tools.jar (github.com/tlaplus/tlaplus -# release v1.8.0). Re-pin this when the upstream release asset is rebuilt and the -# download-verification gate below reports a mismatch, after confirming the new -# jar comes from the official release URL. -TLA_TOOLS_SHA256="${TLA_TOOLS_SHA256:-e22f8ffb4bacdea0a871f444dd94fe5fb0d8013b3388ae39e82e26f852c735d5}" +# rolling release v1.8.0 asset). Upstream may delete and rebuild this asset from +# master. Re-pin only after confirming the replacement digest in GitHub's +# official release metadata; the gate below must continue to fail closed. +TLA_TOOLS_SHA256="${TLA_TOOLS_SHA256:-ab323b79802aedc3203b3f9af37c6aca3ed43f4e0225b36f2aa77b26de46c05f}" if ! command -v java >/dev/null 2>&1; then echo "java is required to run TLC model checks" >&2