From ea3d12e759376cf5867d1f2abba585080eb93e56 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 21 Jul 2026 14:54:53 -0400 Subject: [PATCH 01/93] Harden FROST authorization and Bitcoin broadcast recovery --- pkg/bitcoin/chain.go | 21 + pkg/chain/ethereum/tbtc.go | 23 +- .../tbtc_frost_pre_sign_authorization.go | 2487 +++++++++++++++++ .../signing/native_ffi_executor_adapter.go | 11 + .../native_ffi_executor_adapter_test.go | 38 + pkg/frost/signing/request.go | 25 + ..._interactive_signing_drive_frost_native.go | 4 + .../signing/roast_runner_frost_native.go | 36 + .../signing/roast_runner_frost_native_test.go | 61 + pkg/frost/signing/signing.go | 3 + pkg/frost/signing/signing_test.go | 21 + pkg/tbtc/bitcoin_broadcast_outbox.go | 1716 ++++++++++++ pkg/tbtc/bitcoin_broadcast_outbox_test.go | 1408 ++++++++++ pkg/tbtc/coordination.go | 7 +- pkg/tbtc/coordination_test.go | 27 + pkg/tbtc/deposit.go | 5 + pkg/tbtc/deposit_sweep.go | 9 + pkg/tbtc/frost_activation_handshake.go | 569 ++++ pkg/tbtc/frost_activation_handshake_test.go | 301 ++ pkg/tbtc/frost_pre_sign_authorization.go | 1933 +++++++++++++ pkg/tbtc/frost_pre_sign_authorization_test.go | 847 ++++++ pkg/tbtc/moved_funds_sweep.go | 7 + pkg/tbtc/moving_funds.go | 7 + pkg/tbtc/node.go | 157 +- pkg/tbtc/node_test.go | 53 + pkg/tbtc/redemption.go | 101 +- pkg/tbtc/redemption_test.go | 325 --- pkg/tbtc/signing.go | 289 +- pkg/tbtc/signing_test.go | 106 + pkg/tbtc/tbtc.go | 63 + pkg/tbtc/wallet.go | 364 ++- ..._sign_transaction_build_taproot_tx_test.go | 378 ++- 32 files changed, 10900 insertions(+), 502 deletions(-) create mode 100644 pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go create mode 100644 pkg/tbtc/bitcoin_broadcast_outbox.go create mode 100644 pkg/tbtc/bitcoin_broadcast_outbox_test.go create mode 100644 pkg/tbtc/frost_activation_handshake.go create mode 100644 pkg/tbtc/frost_activation_handshake_test.go create mode 100644 pkg/tbtc/frost_pre_sign_authorization.go create mode 100644 pkg/tbtc/frost_pre_sign_authorization_test.go diff --git a/pkg/bitcoin/chain.go b/pkg/bitcoin/chain.go index bd27c1fe96..c77c040362 100644 --- a/pkg/bitcoin/chain.go +++ b/pkg/bitcoin/chain.go @@ -96,3 +96,24 @@ type Chain interface { // block height. GetCoinbaseTxHash(blockHeight uint) (Hash, error) } + +// CanonicalTransactionStatus is an authenticated transaction observation from +// a canonical Bitcoin index. Found=false is meaningful (and may be used to +// recover after a reorganization); it must not be returned for an RPC timeout +// or an incomplete index. A confirmed transaction includes the canonical block +// identity so durable broadcasters can detect confirmation reorgs. +type CanonicalTransactionStatus struct { + Found bool + Confirmations uint + BlockHeight uint + BlockHash Hash +} + +// CanonicalTransactionStatusSource is an optional extension implemented by +// Bitcoin backends that can distinguish authenticated canonical absence from +// an unavailable or incomplete RPC response. +type CanonicalTransactionStatusSource interface { + GetCanonicalTransactionStatus( + transactionHash Hash, + ) (*CanonicalTransactionStatus, error) +} diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 2a5dac344a..73df3de69b 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -86,17 +86,18 @@ var frostWalletRegistryAuthorizationABI = mustParseABI( type TbtcChain struct { *baseChain - bridge *tbtccontract.Bridge - bridgeAddress common.Address - maintainerProxy *tbtccontract.MaintainerProxy - walletRegistry *ecdsacontract.WalletRegistry - sortitionPool *ecdsacontract.EcdsaSortitionPool - frostWalletRegistry *frostabi.FrostWalletRegistry - frostWalletRegistryAddr common.Address - frostDkgValidator *frostvalidatorabi.FrostDkgValidator - frostSortitionPool *ecdsacontract.EcdsaSortitionPool - walletProposalValidator *tbtccontract.WalletProposalValidator - redemptionWatchtower *tbtccontract.RedemptionWatchtower + bridge *tbtccontract.Bridge + bridgeAddress common.Address + maintainerProxy *tbtccontract.MaintainerProxy + walletRegistry *ecdsacontract.WalletRegistry + sortitionPool *ecdsacontract.EcdsaSortitionPool + frostWalletRegistry *frostabi.FrostWalletRegistry + frostWalletRegistryAddr common.Address + frostDkgValidator *frostvalidatorabi.FrostDkgValidator + frostSortitionPool *ecdsacontract.EcdsaSortitionPool + walletProposalValidator *tbtccontract.WalletProposalValidator + redemptionWatchtower *tbtccontract.RedemptionWatchtower + frostPreSignAuthorizationAdapter *frostPreSignEthereumAdapter // ecdsaDkgValidatorAddress optional; when zero, TBTC uses defaultGroupParameters(network). ecdsaDkgValidatorAddress common.Address diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go new file mode 100644 index 0000000000..9eb26238e1 --- /dev/null +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -0,0 +1,2487 @@ +package ethereum + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "os" + "sort" + "strings" + "sync" + "time" + + geth "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + tbtcabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +const frostPreSignManifestVersion = "tbtc-p2tr-fraud-production-activation/v2" + +const frostPreSignBridgeABIJSON = `[ + {"type":"function","name":"previewP2TRTransactionAuthorization","stateMutability":"view","inputs":[{"name":"payload","type":"bytes"}],"outputs":[{"name":"","type":"bytes"}]}, + {"type":"function","name":"authorizeP2TRTransaction","stateMutability":"nonpayable","inputs":[{"name":"payload","type":"bytes"}],"outputs":[{"name":"","type":"bytes"}]}, + {"type":"function","name":"p2trFraudRouter","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"address"}]}, + {"type":"function","name":"frostLifecycleContext","stateMutability":"view","inputs":[{"name":"walletPubKeyHash","type":"bytes20"}],"outputs":[{"name":"frostRegistry","type":"address"},{"name":"walletID","type":"bytes32"}]}, + {"type":"function","name":"walletID","stateMutability":"view","inputs":[{"name":"walletPubKeyHash","type":"bytes20"}],"outputs":[{"name":"","type":"bytes32"}]}, + {"type":"function","name":"wallets","stateMutability":"view","inputs":[{"name":"walletPubKeyHash","type":"bytes20"}],"outputs":[{"name":"","type":"tuple","components":[{"name":"ecdsaWalletID","type":"bytes32"},{"name":"mainUtxoHash","type":"bytes32"},{"name":"pendingRedemptionsValue","type":"uint64"},{"name":"createdAt","type":"uint32"},{"name":"movingFundsRequestedAt","type":"uint32"},{"name":"closingStartedAt","type":"uint32"},{"name":"pendingMovedFundsSweepRequestsCount","type":"uint32"},{"name":"state","type":"uint8"},{"name":"movingFundsTargetWalletsCommitmentHash","type":"bytes32"}]}] + }] +` + +const frostPreSignRegistryABIJSON = `[ + {"type":"function","name":"protocolConfig","stateMutability":"view","inputs":[],"outputs":[{"name":"bridgeAddress","type":"address"},{"name":"frostRegistryAddress","type":"address"},{"name":"proposalValidatorAddress","type":"address"},{"name":"chainID","type":"uint256"},{"name":"protocolID","type":"bytes32"},{"name":"policyHash","type":"bytes32"}]}, + {"type":"function","name":"getReservation","stateMutability":"view","inputs":[{"name":"id","type":"bytes32"}],"outputs":[{"name":"walletID","type":"bytes32"},{"name":"walletPubKeyHash","type":"bytes20"},{"name":"membersIDsHash","type":"bytes32"},{"name":"snapshotHash","type":"bytes32"},{"name":"resourceHash","type":"bytes32"},{"name":"orderedInputRoot","type":"bytes32"},{"name":"applyPlanData1","type":"bytes32"},{"name":"applyPlanData2","type":"bytes32"},{"name":"feeLimitSnapshot","type":"uint64"},{"name":"action","type":"uint8"},{"name":"status","type":"uint8"}]}, + {"type":"function","name":"getAuthorizedVariantStatus","stateMutability":"view","inputs":[{"name":"transactionHash","type":"bytes32"}],"outputs":[{"name":"reservationID","type":"bytes32"},{"name":"authorizationRoot","type":"bytes32"},{"name":"applyPlanHash","type":"bytes32"},{"name":"authorizationSequence","type":"uint256"},{"name":"fraudDefenseAuthorized","type":"bool"},{"name":"signingAllowed","type":"bool"}]}, + {"type":"function","name":"latestAuthorizedVariant","stateMutability":"view","inputs":[{"name":"reservationID","type":"bytes32"}],"outputs":[{"name":"transactionHash","type":"bytes32"},{"name":"authorizationSequence","type":"uint256"},{"name":"signingAllowed","type":"bool"}]}, + {"type":"function","name":"activeReservation","stateMutability":"view","inputs":[{"name":"walletPubKeyHash","type":"bytes20"}],"outputs":[{"name":"","type":"bytes32"}]}, + {"type":"function","name":"preAuthorizationDigest","stateMutability":"view","inputs":[{"name":"authorization","type":"tuple","components":[{"name":"action","type":"uint8"},{"name":"walletPubKeyHash","type":"bytes20"},{"name":"walletID","type":"bytes32"},{"name":"membersIDsHash","type":"bytes32"},{"name":"snapshotHash","type":"bytes32"},{"name":"resourceHash","type":"bytes32"},{"name":"orderedInputRoot","type":"bytes32"},{"name":"applyPlanHash","type":"bytes32"},{"name":"applyPlanData1","type":"bytes32"},{"name":"applyPlanData2","type":"bytes32"},{"name":"feeLimitSnapshot","type":"uint64"}]},{"name":"transactionHash","type":"bytes32"},{"name":"authorizationRoot","type":"bytes32"}],"outputs":[{"name":"","type":"bytes32"}]}, + {"type":"event","name":"P2TRPreSigningReservationAuthorized","anonymous":false,"inputs":[{"indexed":true,"name":"reservationID","type":"bytes32"},{"indexed":true,"name":"transactionHash","type":"bytes32"},{"indexed":true,"name":"walletID","type":"bytes32"},{"indexed":false,"name":"authorizationRoot","type":"bytes32"},{"indexed":false,"name":"snapshotHash","type":"bytes32"},{"indexed":false,"name":"resourceHash","type":"bytes32"},{"indexed":false,"name":"action","type":"uint8"}]}, + {"type":"event","name":"P2TRAuthorizedVariantAdvanced","anonymous":false,"inputs":[{"indexed":true,"name":"reservationID","type":"bytes32"},{"indexed":true,"name":"transactionHash","type":"bytes32"},{"indexed":true,"name":"authorizationSequence","type":"uint256"}]} +]` + +const frostPreSignCrosslinkABIJSON = `[ + {"type":"function","name":"bridge","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"address"}]}, + {"type":"function","name":"authorizationRegistry","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"address"}]}, + {"type":"function","name":"evidenceProtocolID","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"bytes32"}]}, + {"type":"function","name":"preauthorizationProtocolID","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"bytes32"}]}, + {"type":"function","name":"signingPolicyHash","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"bytes32"}]}, + {"type":"function","name":"sortitionPool","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"address"}]}, + {"type":"function","name":"getWallet","stateMutability":"view","inputs":[{"name":"walletID","type":"bytes32"}],"outputs":[{"name":"","type":"tuple","components":[{"name":"membersIdsHash","type":"bytes32"},{"name":"xOnlyOutputKey","type":"bytes32"}]}]} +]` + +const frostPreSignCodecABIJSON = `[ + {"type":"function","name":"previewPayload","inputs":[{"name":"action","type":"uint8"},{"name":"transaction","type":"tuple","components":[{"name":"version","type":"bytes4"},{"name":"inputVector","type":"bytes"},{"name":"outputVector","type":"bytes"},{"name":"locktime","type":"bytes4"}]},{"name":"actionData","type":"bytes"},{"name":"membersIDsHash","type":"bytes32"}],"outputs":[]}, + {"type":"function","name":"authorizePayload","inputs":[{"name":"action","type":"uint8"},{"name":"transaction","type":"tuple","components":[{"name":"version","type":"bytes4"},{"name":"inputVector","type":"bytes"},{"name":"outputVector","type":"bytes"},{"name":"locktime","type":"bytes4"}]},{"name":"actionData","type":"bytes"},{"name":"attestation","type":"tuple","components":[{"name":"walletMembersIDs","type":"uint32[]"},{"name":"signingMemberIndices","type":"uint8[]"},{"name":"signatures","type":"bytes"}]}],"outputs":[]}, + {"type":"function","name":"depositData","inputs":[{"name":"data","type":"tuple","components":[{"name":"proposal","type":"tuple","components":[{"name":"walletPubKeyHash","type":"bytes20"},{"name":"depositsKeys","type":"tuple[]","components":[{"name":"fundingTxHash","type":"bytes32"},{"name":"fundingOutputIndex","type":"uint32"}]},{"name":"sweepTxFee","type":"uint256"},{"name":"depositsRevealBlocks","type":"uint256[]"}]},{"name":"depositsExtraInfo","type":"tuple[]","components":[{"name":"fundingTx","type":"tuple","components":[{"name":"version","type":"bytes4"},{"name":"inputVector","type":"bytes"},{"name":"outputVector","type":"bytes"},{"name":"locktime","type":"bytes4"}]},{"name":"blindingFactor","type":"bytes8"},{"name":"walletPubKeyHash","type":"bytes20"},{"name":"walletXOnlyPublicKey","type":"bytes32"},{"name":"refundPubKeyHash","type":"bytes20"},{"name":"refundXOnlyPublicKey","type":"bytes32"},{"name":"refundLocktime","type":"bytes4"}]},{"name":"mainUtxo","type":"tuple","components":[{"name":"txHash","type":"bytes32"},{"name":"txOutputIndex","type":"uint32"},{"name":"txOutputValue","type":"uint64"}]}]}],"outputs":[]}, + {"type":"function","name":"redemptionData","inputs":[{"name":"data","type":"tuple","components":[{"name":"proposal","type":"tuple","components":[{"name":"walletPubKeyHash","type":"bytes20"},{"name":"redeemersOutputScripts","type":"bytes[]"},{"name":"redemptionTxFee","type":"uint256"}]},{"name":"mainUtxo","type":"tuple","components":[{"name":"txHash","type":"bytes32"},{"name":"txOutputIndex","type":"uint32"},{"name":"txOutputValue","type":"uint64"}]}]}],"outputs":[]}, + {"type":"function","name":"movingData","inputs":[{"name":"data","type":"tuple","components":[{"name":"proposal","type":"tuple","components":[{"name":"walletPubKeyHash","type":"bytes20"},{"name":"targetWallets","type":"bytes20[]"},{"name":"movingFundsTxFee","type":"uint256"}]},{"name":"mainUtxo","type":"tuple","components":[{"name":"txHash","type":"bytes32"},{"name":"txOutputIndex","type":"uint32"},{"name":"txOutputValue","type":"uint64"}]}]}],"outputs":[]}, + {"type":"function","name":"movedSweepData","inputs":[{"name":"data","type":"tuple","components":[{"name":"proposal","type":"tuple","components":[{"name":"walletPubKeyHash","type":"bytes20"},{"name":"movingFundsTxHash","type":"bytes32"},{"name":"movingFundsTxOutputIndex","type":"uint32"},{"name":"movedFundsSweepTxFee","type":"uint256"}]},{"name":"mainUtxo","type":"tuple","components":[{"name":"txHash","type":"bytes32"},{"name":"txOutputIndex","type":"uint32"},{"name":"txOutputValue","type":"uint64"}]}]}],"outputs":[]}, + {"type":"function","name":"authorizationPreview","inputs":[{"name":"preview","type":"tuple","components":[{"name":"reservationID","type":"bytes32"},{"name":"transactionHash","type":"bytes32"},{"name":"authorizationRoot","type":"bytes32"},{"name":"digest","type":"bytes32"},{"name":"walletPubKeyHash","type":"bytes20"},{"name":"walletID","type":"bytes32"},{"name":"membersIDsHash","type":"bytes32"},{"name":"snapshotHash","type":"bytes32"},{"name":"resourceHash","type":"bytes32"},{"name":"orderedInputRoot","type":"bytes32"},{"name":"applyPlanHash","type":"bytes32"},{"name":"applyPlanData1","type":"bytes32"},{"name":"applyPlanData2","type":"bytes32"},{"name":"feeLimitSnapshot","type":"uint64"},{"name":"action","type":"uint8"}]}],"outputs":[]} +]` + +var ( + frostPreSignBridgeABI = mustParseABI(frostPreSignBridgeABIJSON) + frostPreSignRegistryABI = mustParseABI(frostPreSignRegistryABIJSON) + frostPreSignCrosslinkABI = mustParseABI(frostPreSignCrosslinkABIJSON) + frostPreSignCodecABI = mustParseABI(frostPreSignCodecABIJSON) +) + +type frostPreSignActivationEnvelope struct { + Payload json.RawMessage `json:"payload"` + PayloadSHA256 string `json:"payloadSha256"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + SignerPublicKeySPKI string `json:"signerPublicKeySpki"` + Signature string `json:"signature"` +} + +type frostPreSignManifestPoint struct { + BlockNumber uint64 `json:"blockNumber"` + BlockHash string `json:"blockHash"` +} + +type frostPreSignManifestUpgradeability struct { + Kind string `json:"kind"` + ImplementationAddress string `json:"implementationAddress,omitempty"` + ImplementationRuntimeCodeHash string `json:"implementationRuntimeCodeHash,omitempty"` + AdminAddress string `json:"adminAddress,omitempty"` + AdminRuntimeCodeHash string `json:"adminRuntimeCodeHash,omitempty"` + ImplementationSlotValue string `json:"implementationSlotValue,omitempty"` + AdminSlotValue string `json:"adminSlotValue,omitempty"` +} + +type frostPreSignManifestLinkReference struct { + Start uint64 `json:"start"` + Length uint64 `json:"length"` +} + +type frostPreSignManifestLinkedLibrary struct { + ProtocolRole string `json:"protocolRole"` + Address string `json:"address"` + RuntimeCodeHash string `json:"runtimeCodeHash"` + References []frostPreSignManifestLinkReference `json:"references"` + LinkedLibraryDescriptorHash string `json:"linkedLibraryDescriptorHash"` + LinkedLibraries []frostPreSignManifestLinkedLibrary `json:"linkedLibraries"` +} + +type frostPreSignManifestContract struct { + Address string `json:"address"` + RuntimeCodeHash string `json:"runtimeCodeHash"` + ProtocolID string `json:"protocolID"` + DeploymentBlock uint64 `json:"deploymentBlock"` + RelevantEventStartBlock uint64 `json:"relevantEventStartBlock"` + BridgeAddress *string `json:"bridgeAddress,omitempty"` + SigningPolicyHash *string `json:"signingPolicyHash,omitempty"` + LinkedLibraryDescriptorHash string `json:"linkedLibraryDescriptorHash"` + LinkedLibraries []frostPreSignManifestLinkedLibrary `json:"linkedLibraries"` + Upgradeability frostPreSignManifestUpgradeability `json:"upgradeability"` +} + +type frostPreSignManifestContracts struct { + Bridge frostPreSignManifestContract `json:"bridge"` + CompleteRouter frostPreSignManifestContract `json:"completeRouter"` + AuthorizationRegistry frostPreSignManifestContract `json:"authorizationRegistry"` + FrostWalletRegistry frostPreSignManifestContract `json:"frostWalletRegistry"` + FrostProposalValidator frostPreSignManifestContract `json:"frostProposalValidator"` + FrostSortitionPool frostPreSignManifestContract `json:"frostSortitionPool"` + ECDSAFraudRouter frostPreSignManifestContract `json:"ecdsaFraudRouter"` + ECDSACutoverCoordinator frostPreSignManifestContract `json:"ecdsaCutoverCoordinator"` +} + +type frostPreSignManifestEthereum struct { + ChainID uint64 `json:"chainID"` + Checkpoint frostPreSignManifestPoint `json:"checkpoint"` + ScanStartBlock uint64 `json:"scanStartBlock"` + ConfirmationDepth uint64 `json:"confirmationDepth"` + MaxJournalLagBlocks uint64 `json:"maxJournalLagBlocks"` + ConfigurationFingerprint string `json:"configurationFingerprint"` + DescriptorSetHash string `json:"descriptorSetHash"` + LinkedLibraryDescriptorSetHash string `json:"linkedLibraryDescriptorSetHash"` + StoreID string `json:"storeID"` + SourceTrustDomainID string `json:"sourceTrustDomainID"` + SourceEndpointFingerprint string `json:"sourceEndpointFingerprint"` + SourceOperatorFingerprint string `json:"sourceOperatorFingerprint"` + SourceHistoryStoreID string `json:"sourceHistoryStoreID"` + SourceHistoryStoreFingerprint string `json:"sourceHistoryStoreFingerprint"` + VerifierTrustDomainID string `json:"verifierTrustDomainID"` + VerifierEndpointFingerprint string `json:"verifierEndpointFingerprint"` + VerifierOperatorFingerprint string `json:"verifierOperatorFingerprint"` + VerifierHistoryStoreID string `json:"verifierHistoryStoreID"` + VerifierHistoryStoreFingerprint string `json:"verifierHistoryStoreFingerprint"` + Contracts frostPreSignManifestContracts `json:"contracts"` + CompleteDepositKeyInventory json.RawMessage `json:"completeDepositKeyInventory"` + FrostArchive json.RawMessage `json:"frostArchive"` +} + +type frostPreSignManifestFrostSigner struct { + TrustDomainID string `json:"trustDomainID"` + DurableSessionStoreFingerprint string `json:"durableSessionStoreFingerprint"` + ProtocolID string `json:"protocolID"` + ReservationProtocolID string `json:"reservationProtocolID"` + BitcoinOutboxProtocolID string `json:"bitcoinOutboxProtocolID"` + SigningPolicyHash string `json:"signingPolicyHash"` + CompleteRouterAddress string `json:"completeRouterAddress"` + AuthorizationRegistryAddress string `json:"authorizationRegistryAddress"` + AttestationSignerKeyHash string `json:"attestationSignerKeyHash"` + HandshakeEndpointFingerprint string `json:"handshakeEndpointFingerprint"` + HandshakeOperatorFingerprint string `json:"handshakeOperatorFingerprint"` + Threshold uint64 `json:"threshold"` + MaximumGroupSize uint64 `json:"maximumGroupSize"` + RetainedGroupInventoryProtocolID string `json:"retainedGroupInventoryProtocolID"` + ExactRetainedGroupInventoryRequired bool `json:"exactRetainedGroupInventoryRequired"` + FinalizedReservationReceiptRequired bool `json:"finalizedReservationReceiptRequired"` + ExactReservationIdentityRequired bool `json:"exactReservationIdentityRequired"` + AuthorizationRootRequired bool `json:"authorizationRootRequired"` + DurableSessionPersistenceRequired bool `json:"durableSessionPersistenceRequired"` + DurableBitcoinOutboxRequired bool `json:"durableBitcoinOutboxRequired"` + QuarantineFailClosed bool `json:"quarantineFailClosed"` +} + +type frostPreSignActivationManifest struct { + Schema string `json:"schema"` + ActivationSequence uint64 `json:"activationSequence"` + ActivationID string `json:"activationID"` + Environment string `json:"environment"` + Migrations json.RawMessage `json:"migrations"` + Bitcoin json.RawMessage `json:"bitcoin"` + Ethereum frostPreSignManifestEthereum `json:"ethereum"` + ECDSACutover json.RawMessage `json:"ecdsaCutover"` + Outbox json.RawMessage `json:"outbox"` + FrostSigner frostPreSignManifestFrostSigner `json:"frostSigner"` + manifestHash [32]byte +} + +type frostPreSignDeploymentPin struct { + role string + name string + address [20]byte + runtimeCodeHash [32]byte + upgradeability string + implementationAddress [20]byte + implementationCodeHash [32]byte + adminAddress [20]byte + adminCodeHash [32]byte + implementationSlotValue [32]byte + adminSlotValue [32]byte + linkedLibraryDescriptorHash [32]byte + linkedLibraries []frostPreSignLinkedLibraryPin +} + +type frostPreSignLinkedLibraryPin struct { + protocolRole string + address [20]byte + runtimeCodeHash [32]byte + references []frostPreSignManifestLinkReference + linkedLibraryDescriptorHash [32]byte + linkedLibraries []frostPreSignLinkedLibraryPin +} + +type frostPreSignEthereumAdapter struct { + chain *TbtcChain + profile tbtc.FrostPreSignActivationProfile + manifest frostPreSignActivationManifest + deployments []frostPreSignDeploymentPin + bridge *bind.BoundContract + registry *bind.BoundContract + router *bind.BoundContract + + mutex sync.RWMutex +} + +type frostPreSignBitcoinTxInfo struct { + Version [4]byte + InputVector []byte + OutputVector []byte + Locktime [4]byte +} + +type frostPreSignSeatAttestationABI struct { + WalletMembersIDs []uint32 + SigningMemberIndices []uint8 + Signatures []byte +} + +type frostPreSignDepositAuthorizationData struct { + Proposal tbtcabi.WalletProposalValidatorDepositSweepProposal + DepositsExtraInfo []tbtcabi.WalletProposalValidatorTaprootDepositExtraInfo + MainUtxo tbtcabi.BitcoinTxUTXO3 +} + +type frostPreSignRedemptionAuthorizationData struct { + Proposal tbtcabi.WalletProposalValidatorRedemptionProposal + MainUtxo tbtcabi.BitcoinTxUTXO3 +} + +type frostPreSignMovingAuthorizationData struct { + Proposal tbtcabi.WalletProposalValidatorMovingFundsProposal + MainUtxo tbtcabi.BitcoinTxUTXO3 +} + +type frostPreSignMovedSweepAuthorizationData struct { + Proposal tbtcabi.WalletProposalValidatorMovedFundsSweepProposal + MainUtxo tbtcabi.BitcoinTxUTXO3 +} + +type frostPreSignAuthorizationPreview struct { + ReservationID [32]byte + TransactionHash [32]byte + AuthorizationRoot [32]byte + Digest [32]byte + WalletPubKeyHash [20]byte + WalletID [32]byte + MembersIDsHash [32]byte + SnapshotHash [32]byte + ResourceHash [32]byte + OrderedInputRoot [32]byte + ApplyPlanHash [32]byte + ApplyPlanData1 [32]byte + ApplyPlanData2 [32]byte + FeeLimitSnapshot uint64 + Action uint8 +} + +type frostPreSignPreAuthorizationABI struct { + Action uint8 + WalletPubKeyHash [20]byte + WalletID [32]byte + MembersIDsHash [32]byte + SnapshotHash [32]byte + ResourceHash [32]byte + OrderedInputRoot [32]byte + ApplyPlanHash [32]byte + ApplyPlanData1 [32]byte + ApplyPlanData2 [32]byte + FeeLimitSnapshot uint64 +} + +type frostPreSignWalletABI struct { + EcdsaWalletID [32]byte + MainUtxoHash [32]byte + PendingRedemptionsValue uint64 + CreatedAt uint32 + MovingFundsRequestedAt uint32 + ClosingStartedAt uint32 + PendingMovedFundsSweepRequestsCount uint32 + State uint8 + MovingFundsTargetWalletsCommitmentHash [32]byte +} + +type frostPreSignRegistryWalletABI struct { + MembersIdsHash [32]byte + XOnlyOutputKey [32]byte +} + +func (tc *TbtcChain) ConfigureFrostPreSignAuthorization( + ctx context.Context, + manifestPath string, + trustedEnvelopeSignerKeyHash string, + expectedLinkedLibraryDescriptorSetHash string, +) (*tbtc.FrostPreSignActivationProfile, error) { + if ctx == nil { + return nil, fmt.Errorf("FROST activation context is nil") + } + manifest, err := loadFrostPreSignActivationManifest( + manifestPath, + trustedEnvelopeSignerKeyHash, + ) + if err != nil { + return nil, err + } + expectedDescriptorSetHash, err := frostPreSignParseBytes32( + expectedLinkedLibraryDescriptorSetHash, + ) + if err != nil { + return nil, fmt.Errorf("invalid expected linked-library descriptor-set hash: [%w]", err) + } + manifestDescriptorSetHash, err := frostPreSignParseBytes32( + manifest.Ethereum.LinkedLibraryDescriptorSetHash, + ) + if err != nil || expectedDescriptorSetHash != manifestDescriptorSetHash { + return nil, fmt.Errorf("signed activation linked-library descriptor set differs from this signer build") + } + adapter, err := newFrostPreSignEthereumAdapter(ctx, tc, manifest) + if err != nil { + return nil, err + } + tc.frostPreSignAuthorizationAdapter = adapter + profile := adapter.profile + return &profile, nil +} + +func loadFrostPreSignActivationManifest( + path string, + trustedEnvelopeSignerKeyHash string, +) (*frostPreSignActivationManifest, error) { + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("FROST activation manifest path is empty") + } + // #nosec G304 -- the operator explicitly configures the activation manifest. + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("cannot open FROST activation manifest: [%w]", err) + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, 1024*1024+1)) + if err != nil { + return nil, fmt.Errorf("cannot read FROST activation envelope: [%w]", err) + } + if len(data) == 0 || len(data) > 1024*1024 { + return nil, fmt.Errorf("FROST activation envelope size is invalid") + } + + envelope := &frostPreSignActivationEnvelope{} + if err := frostPreSignDecodeStrictJSON(data, envelope); err != nil { + return nil, fmt.Errorf("cannot decode FROST activation envelope: [%w]", err) + } + if envelope.SignatureAlgorithm != "ed25519" || len(envelope.Payload) == 0 { + return nil, fmt.Errorf("FROST activation envelope is malformed") + } + canonicalPayload, err := frostPreSignCanonicalJSON(envelope.Payload) + if err != nil { + return nil, fmt.Errorf("cannot canonicalize FROST activation payload: [%w]", err) + } + payloadHash := sha256.Sum256(canonicalPayload) + declaredPayloadHash, err := frostPreSignParseBytes32(envelope.PayloadSHA256) + if err != nil || declaredPayloadHash != payloadHash { + return nil, fmt.Errorf("FROST activation payload hash mismatch") + } + trustedKeyHash, err := frostPreSignParseBytes32(trustedEnvelopeSignerKeyHash) + if err != nil { + return nil, fmt.Errorf("invalid trusted FROST activation signer key hash: [%w]", err) + } + publicKeyDER, err := base64.StdEncoding.Strict().DecodeString( + envelope.SignerPublicKeySPKI, + ) + if err != nil || len(publicKeyDER) == 0 || len(publicKeyDER) > 1024 { + return nil, fmt.Errorf("FROST activation signer public key is invalid") + } + if sha256.Sum256(publicKeyDER) != trustedKeyHash { + return nil, fmt.Errorf("FROST activation signer is not trusted") + } + parsedPublicKey, err := x509.ParsePKIXPublicKey(publicKeyDER) + if err != nil { + return nil, fmt.Errorf("cannot parse FROST activation signer key: [%w]", err) + } + publicKey, ok := parsedPublicKey.(ed25519.PublicKey) + if !ok { + return nil, fmt.Errorf("FROST activation signer key is not Ed25519") + } + signature, err := base64.StdEncoding.Strict().DecodeString(envelope.Signature) + if err != nil || len(signature) != ed25519.SignatureSize || + !ed25519.Verify(publicKey, canonicalPayload, signature) { + return nil, fmt.Errorf("FROST activation envelope signature is invalid") + } + + manifest := &frostPreSignActivationManifest{} + if err := frostPreSignDecodeStrictJSON(envelope.Payload, manifest); err != nil { + return nil, fmt.Errorf("cannot decode FROST activation payload: [%w]", err) + } + manifest.manifestHash = payloadHash + if err := validateFrostPreSignActivationManifest(manifest); err != nil { + return nil, err + } + attestationKeyHash, err := frostPreSignParseBytes32( + manifest.FrostSigner.AttestationSignerKeyHash, + ) + if err != nil || attestationKeyHash == trustedKeyHash { + return nil, fmt.Errorf( + "FROST runtime attestation key must differ from the activation authority key", + ) + } + return manifest, nil +} + +func frostPreSignDecodeStrictJSON(data []byte, target interface{}) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + decoder.UseNumber() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("JSON contains trailing data") + } + return nil +} + +func frostPreSignCanonicalJSON(data []byte) ([]byte, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var value interface{} + if err := decoder.Decode(&value); err != nil { + return nil, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("JSON contains trailing data") + } + buffer := bytes.NewBuffer(nil) + if err := frostPreSignWriteCanonicalJSON(buffer, value); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func frostPreSignWriteCanonicalJSON(buffer *bytes.Buffer, value interface{}) error { + switch typed := value.(type) { + case nil: + buffer.WriteString("null") + case bool: + if typed { + buffer.WriteString("true") + } else { + buffer.WriteString("false") + } + case string: + encoded, _ := json.Marshal(typed) + buffer.Write(encoded) + case json.Number: + raw := typed.String() + if strings.ContainsAny(raw, ".eE") { + return fmt.Errorf("canonical JSON number [%s] is not an integer", raw) + } + integer, ok := new(big.Int).SetString(raw, 10) + if !ok || integer.Cmp(big.NewInt(-9007199254740991)) < 0 || + integer.Cmp(big.NewInt(9007199254740991)) > 0 { + return fmt.Errorf("canonical JSON number [%s] is unsafe", raw) + } + buffer.WriteString(integer.String()) + case []interface{}: + buffer.WriteByte('[') + for index, item := range typed { + if index > 0 { + buffer.WriteByte(',') + } + if err := frostPreSignWriteCanonicalJSON(buffer, item); err != nil { + return err + } + } + buffer.WriteByte(']') + case map[string]interface{}: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + buffer.WriteByte('{') + for index, key := range keys { + if index > 0 { + buffer.WriteByte(',') + } + encodedKey, _ := json.Marshal(key) + buffer.Write(encodedKey) + buffer.WriteByte(':') + if err := frostPreSignWriteCanonicalJSON(buffer, typed[key]); err != nil { + return err + } + } + buffer.WriteByte('}') + default: + return fmt.Errorf("unsupported canonical JSON value [%T]", value) + } + return nil +} + +func validateFrostPreSignActivationManifest( + manifest *frostPreSignActivationManifest, +) error { + if manifest == nil || manifest.Schema != frostPreSignManifestVersion || + manifest.ActivationSequence == 0 || manifest.ActivationID == "" || + len(manifest.Environment) == 0 || len(manifest.Environment) > 64 || + manifest.Ethereum.ChainID == 0 || manifest.manifestHash == [32]byte{} { + return fmt.Errorf("FROST activation payload is incomplete") + } + if _, err := frostPreSignParseBytes32(manifest.ActivationID); err != nil { + return fmt.Errorf("invalid FROST activation ID: [%w]", err) + } + frost := manifest.FrostSigner + if frost.Threshold != 51 || frost.MaximumGroupSize != 100 || + !frost.ExactRetainedGroupInventoryRequired || + !frost.FinalizedReservationReceiptRequired || + !frost.ExactReservationIdentityRequired || + !frost.AuthorizationRootRequired || + !frost.DurableSessionPersistenceRequired || + !frost.DurableBitcoinOutboxRequired || !frost.QuarantineFailClosed || + len(frost.DurableSessionStoreFingerprint) == 0 || + len(frost.DurableSessionStoreFingerprint) > 256 { + return fmt.Errorf("FROST activation signer policy is incomplete") + } + for name, value := range map[string]string{ + "signer protocol": frost.ProtocolID, + "reservation protocol": frost.ReservationProtocolID, + "Bitcoin outbox protocol": frost.BitcoinOutboxProtocolID, + "signing policy": frost.SigningPolicyHash, + "attestation signer key": frost.AttestationSignerKeyHash, + "retained group inventory protocol": frost.RetainedGroupInventoryProtocolID, + } { + if _, err := frostPreSignParseBytes32(value); err != nil { + return fmt.Errorf("invalid FROST activation %s: [%w]", name, err) + } + } + return nil +} + +func newFrostPreSignEthereumAdapter( + ctx context.Context, + tc *TbtcChain, + manifest *frostPreSignActivationManifest, +) (*frostPreSignEthereumAdapter, error) { + if tc == nil || tc.baseChain == nil || tc.client == nil || manifest == nil { + return nil, fmt.Errorf("FROST Ethereum adapter dependencies are nil") + } + if tc.frostWalletRegistry == nil || tc.frostSortitionPool == nil { + return nil, fmt.Errorf("FROST wallet registry and sortition pool are required") + } + profile, deployments, err := frostPreSignProfileFromManifest(manifest) + if err != nil { + return nil, err + } + if new(big.Int).SetBytes(profile.DomainChainID[:]).Cmp(tc.chainID) != 0 { + return nil, fmt.Errorf("activation manifest chain ID differs from connected Ethereum chain") + } + if common.Address(profile.BridgeAddress) != tc.bridgeAddress || + common.Address(profile.FrostRegistry) != tc.frostWalletRegistryAddr { + return nil, fmt.Errorf("activation manifest differs from configured Bridge/FROST registry") + } + finality, err := frostPreSignCurrentFinality(ctx, tc.client) + if err != nil { + return nil, err + } + adapter := &frostPreSignEthereumAdapter{ + chain: tc, + profile: profile, + manifest: *manifest, + deployments: deployments, + bridge: bind.NewBoundContract( + common.Address(profile.BridgeAddress), frostPreSignBridgeABI, + tc.client, tc.client, tc.client, + ), + registry: bind.NewBoundContract( + common.Address(profile.RegistryAddress), frostPreSignRegistryABI, + tc.client, tc.client, tc.client, + ), + router: bind.NewBoundContract( + common.Address(profile.CompleteRouter), frostPreSignCrosslinkABI, + tc.client, tc.client, tc.client, + ), + } + if err := adapter.verifyDeploymentAt(ctx, finality); err != nil { + return nil, fmt.Errorf("FROST activation manifest verification failed: [%w]", err) + } + return adapter, nil +} + +func frostPreSignProfileFromManifest( + manifest *frostPreSignActivationManifest, +) (tbtc.FrostPreSignActivationProfile, []frostPreSignDeploymentPin, error) { + if err := validateFrostPreSignActivationManifest(manifest); err != nil { + return tbtc.FrostPreSignActivationProfile{}, nil, err + } + profile := tbtc.FrostPreSignActivationProfile{} + new(big.Int).SetUint64(manifest.Ethereum.ChainID).FillBytes(profile.DomainChainID[:]) + profile.ActivationManifestHash = manifest.manifestHash + + contracts := manifest.Ethereum.Contracts + deploymentInputs := []struct { + role string + name string + manifest frostPreSignManifestContract + address *[20]byte + codeHash *[32]byte + }{ + {"bridge", "Bridge", contracts.Bridge, &profile.BridgeAddress, &profile.BridgeCodeHash}, + {"completeRouter", "COMPLETE router", contracts.CompleteRouter, &profile.CompleteRouter, &profile.CompleteRouterCodeHash}, + {"authorizationRegistry", "authorization registry", contracts.AuthorizationRegistry, &profile.RegistryAddress, &profile.RegistryCodeHash}, + {"frostWalletRegistry", "FROST wallet registry", contracts.FrostWalletRegistry, &profile.FrostRegistry, &profile.FrostRegistryCodeHash}, + {"frostProposalValidator", "proposal validator", contracts.FrostProposalValidator, &profile.ProposalValidator, &profile.ProposalValidatorCodeHash}, + {"frostSortitionPool", "sortition pool", contracts.FrostSortitionPool, &profile.SortitionPool, &profile.SortitionPoolCodeHash}, + {"ecdsaFraudRouter", "ECDSA fraud router", contracts.ECDSAFraudRouter, nil, nil}, + {"ecdsaCutoverCoordinator", "ECDSA cutover coordinator", contracts.ECDSACutoverCoordinator, nil, nil}, + } + deployments := make([]frostPreSignDeploymentPin, 0, len(deploymentInputs)) + for _, input := range deploymentInputs { + pin, err := frostPreSignDeploymentPinFromManifest( + input.role, + input.name, + input.manifest, + ) + if err != nil { + return profile, nil, err + } + deployments = append(deployments, pin) + if input.address != nil { + *input.address = pin.address + *input.codeHash = pin.runtimeCodeHash + } + } + globalDescriptorHash, err := frostPreSignLinkedLibraryDescriptorSetHash(deployments) + if err != nil { + return profile, nil, err + } + declaredGlobalDescriptorHash, err := frostPreSignParseBytes32( + manifest.Ethereum.LinkedLibraryDescriptorSetHash, + ) + if err != nil || globalDescriptorHash != declaredGlobalDescriptorHash { + return profile, nil, fmt.Errorf("activation linked-library descriptor-set hash mismatch") + } + profile.ImplementationSetHash = frostPreSignDeploymentSetHash(deployments) + + frost := manifest.FrostSigner + if profile.EvidenceProtocolID, err = frostPreSignParseBytes32(contracts.CompleteRouter.ProtocolID); err != nil { + return profile, nil, err + } + if profile.ReservationProtocolID, err = frostPreSignParseBytes32(frost.ReservationProtocolID); err != nil { + return profile, nil, err + } + if profile.SigningPolicyHash, err = frostPreSignParseBytes32(frost.SigningPolicyHash); err != nil { + return profile, nil, err + } + configuredRouter, err := frostPreSignParseAddress(frost.CompleteRouterAddress) + if err != nil || configuredRouter != profile.CompleteRouter { + return profile, nil, fmt.Errorf("FROST signer COMPLETE router binding mismatch") + } + configuredRegistry, err := frostPreSignParseAddress(frost.AuthorizationRegistryAddress) + if err != nil || configuredRegistry != profile.RegistryAddress { + return profile, nil, fmt.Errorf("FROST signer authorization registry binding mismatch") + } + registryProtocol, err := frostPreSignParseBytes32(contracts.AuthorizationRegistry.ProtocolID) + if err != nil || registryProtocol != profile.ReservationProtocolID { + return profile, nil, fmt.Errorf("authorization registry protocol binding mismatch") + } + for name, contract := range map[string]frostPreSignManifestContract{ + "COMPLETE router": contracts.CompleteRouter, + "authorization registry": contracts.AuthorizationRegistry, + "FROST wallet registry": contracts.FrostWalletRegistry, + "proposal validator": contracts.FrostProposalValidator, + } { + if contract.BridgeAddress == nil { + return profile, nil, fmt.Errorf("%s manifest lacks Bridge binding", name) + } + bridge, err := frostPreSignParseAddress(*contract.BridgeAddress) + if err != nil || bridge != profile.BridgeAddress { + return profile, nil, fmt.Errorf("%s manifest Bridge binding mismatch", name) + } + } + profile.ProfileHash = profile.ComputeHash() + if err := profile.ValidateForProduction(); err != nil { + return profile, nil, err + } + return profile, deployments, nil +} + +func frostPreSignDeploymentPinFromManifest( + role string, + name string, + contract frostPreSignManifestContract, +) (frostPreSignDeploymentPin, error) { + pin := frostPreSignDeploymentPin{role: role, name: name} + var err error + if pin.address, err = frostPreSignParseAddress(contract.Address); err != nil { + return pin, fmt.Errorf("invalid %s address: [%w]", name, err) + } + if pin.runtimeCodeHash, err = frostPreSignParseBytes32(contract.RuntimeCodeHash); err != nil { + return pin, fmt.Errorf("invalid %s runtime code hash: [%w]", name, err) + } + if pin.runtimeCodeHash == [32]byte{} { + return pin, fmt.Errorf("%s runtime code hash is zero", name) + } + protocolID, err := frostPreSignParseBytes32(contract.ProtocolID) + if err != nil { + return pin, fmt.Errorf("invalid %s protocol ID: [%w]", name, err) + } + if protocolID == [32]byte{} { + return pin, fmt.Errorf("%s protocol ID is zero", name) + } + if contract.DeploymentBlock == 0 || + contract.RelevantEventStartBlock < contract.DeploymentBlock { + return pin, fmt.Errorf("invalid %s deployment/event range", name) + } + if pin.linkedLibraryDescriptorHash, err = frostPreSignParseBytes32( + contract.LinkedLibraryDescriptorHash, + ); err != nil { + return pin, fmt.Errorf("invalid %s linked-library descriptor hash: [%w]", name, err) + } + count := 0 + pin.linkedLibraries, err = frostPreSignLinkedLibrariesFromManifest( + contract.LinkedLibraries, + 0, + &count, + ) + if err != nil { + return pin, fmt.Errorf("invalid %s linked libraries: [%w]", name, err) + } + computedDescriptorHash, err := frostPreSignLinkedLibraryInventoryHash(pin.linkedLibraries) + if err != nil || computedDescriptorHash != pin.linkedLibraryDescriptorHash { + return pin, fmt.Errorf("%s linked-library descriptor hash mismatch", name) + } + pin.upgradeability = contract.Upgradeability.Kind + switch pin.upgradeability { + case "immutable": + if contract.Upgradeability.ImplementationAddress != "" || + contract.Upgradeability.ImplementationRuntimeCodeHash != "" || + contract.Upgradeability.AdminAddress != "" || + contract.Upgradeability.AdminRuntimeCodeHash != "" || + contract.Upgradeability.ImplementationSlotValue != "" || + contract.Upgradeability.AdminSlotValue != "" { + return pin, fmt.Errorf("immutable %s carries proxy metadata", name) + } + case "eip1967": + upgradeability := contract.Upgradeability + if pin.implementationAddress, err = frostPreSignParseAddress(upgradeability.ImplementationAddress); err != nil { + return pin, fmt.Errorf("invalid %s implementation address: [%w]", name, err) + } + if pin.implementationCodeHash, err = frostPreSignParseBytes32(upgradeability.ImplementationRuntimeCodeHash); err != nil { + return pin, fmt.Errorf("invalid %s implementation code hash: [%w]", name, err) + } + if pin.adminAddress, err = frostPreSignParseAddress(upgradeability.AdminAddress); err != nil { + return pin, fmt.Errorf("invalid %s admin address: [%w]", name, err) + } + if pin.adminCodeHash, err = frostPreSignParseBytes32(upgradeability.AdminRuntimeCodeHash); err != nil { + return pin, fmt.Errorf("invalid %s admin code hash: [%w]", name, err) + } + if pin.implementationSlotValue, err = frostPreSignParseBytes32(upgradeability.ImplementationSlotValue); err != nil { + return pin, fmt.Errorf("invalid %s implementation slot: [%w]", name, err) + } + if pin.adminSlotValue, err = frostPreSignParseBytes32(upgradeability.AdminSlotValue); err != nil { + return pin, fmt.Errorf("invalid %s admin slot: [%w]", name, err) + } + if !frostPreSignSlotValueBindsAddress(pin.implementationSlotValue, pin.implementationAddress) || + !frostPreSignSlotValueBindsAddress(pin.adminSlotValue, pin.adminAddress) || + pin.implementationAddress == pin.adminAddress || + pin.implementationAddress == pin.address || pin.adminAddress == pin.address { + return pin, fmt.Errorf("unsafe %s EIP-1967 address/slot binding", name) + } + if pin.implementationCodeHash == [32]byte{} || pin.adminCodeHash == [32]byte{} { + return pin, fmt.Errorf("%s EIP-1967 code hash is zero", name) + } + default: + return pin, fmt.Errorf("unsupported %s upgradeability [%s]", name, pin.upgradeability) + } + return pin, nil +} + +func frostPreSignLinkedLibrariesFromManifest( + libraries []frostPreSignManifestLinkedLibrary, + depth int, + count *int, +) ([]frostPreSignLinkedLibraryPin, error) { + if depth > 16 || count == nil { + return nil, fmt.Errorf("linked-library descriptor tree is too deep") + } + result := make([]frostPreSignLinkedLibraryPin, 0, len(libraries)) + roles := make(map[string]struct{}) + addresses := make(map[[20]byte]struct{}) + for _, library := range libraries { + (*count)++ + if *count > 256 || !frostPreSignValidProtocolRole(library.ProtocolRole) { + return nil, fmt.Errorf("linked-library descriptor tree is too large or has an invalid role") + } + if _, exists := roles[library.ProtocolRole]; exists { + return nil, fmt.Errorf("duplicate linked-library role [%s]", library.ProtocolRole) + } + roles[library.ProtocolRole] = struct{}{} + pin := frostPreSignLinkedLibraryPin{protocolRole: library.ProtocolRole} + var err error + if pin.address, err = frostPreSignParseAddress(library.Address); err != nil { + return nil, err + } + if _, exists := addresses[pin.address]; exists { + return nil, fmt.Errorf("duplicate linked-library address") + } + addresses[pin.address] = struct{}{} + if pin.runtimeCodeHash, err = frostPreSignParseBytes32(library.RuntimeCodeHash); err != nil || + pin.runtimeCodeHash == [32]byte{} { + return nil, fmt.Errorf("invalid linked-library runtime code hash") + } + if pin.linkedLibraryDescriptorHash, err = frostPreSignParseBytes32( + library.LinkedLibraryDescriptorHash, + ); err != nil { + return nil, err + } + if len(library.References) == 0 { + return nil, fmt.Errorf("linked library [%s] has no references", library.ProtocolRole) + } + pin.references = append([]frostPreSignManifestLinkReference{}, library.References...) + sort.Slice(pin.references, func(i, j int) bool { + return pin.references[i].Start < pin.references[j].Start + }) + for index, reference := range pin.references { + if reference.Length != 20 || + (index > 0 && pin.references[index-1].Start+20 > reference.Start) { + return nil, fmt.Errorf("linked library [%s] has invalid references", library.ProtocolRole) + } + } + pin.linkedLibraries, err = frostPreSignLinkedLibrariesFromManifest( + library.LinkedLibraries, + depth+1, + count, + ) + if err != nil { + return nil, err + } + computedDescriptorHash, err := frostPreSignLinkedLibraryInventoryHash(pin.linkedLibraries) + if err != nil || computedDescriptorHash != pin.linkedLibraryDescriptorHash { + return nil, fmt.Errorf("linked library [%s] descriptor hash mismatch", library.ProtocolRole) + } + result = append(result, pin) + } + sort.Slice(result, func(i, j int) bool { + return result[i].protocolRole < result[j].protocolRole + }) + return result, nil +} + +func frostPreSignValidProtocolRole(value string) bool { + if len(value) == 0 || len(value) > 255 { + return false + } + for _, character := range []byte(value) { + if (character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') || + strings.ContainsRune("._:/-", rune(character)) { + continue + } + return false + } + return true +} + +type frostPreSignLinkedLibraryDescriptor struct { + ProtocolRole string `json:"protocolRole"` + References []frostPreSignManifestLinkReference `json:"references"` + LinkedLibraries []frostPreSignLinkedLibraryDescriptor `json:"linkedLibraries"` +} + +func frostPreSignLinkedLibraryDescriptors( + libraries []frostPreSignLinkedLibraryPin, +) []frostPreSignLinkedLibraryDescriptor { + result := make([]frostPreSignLinkedLibraryDescriptor, 0, len(libraries)) + for _, library := range libraries { + result = append(result, frostPreSignLinkedLibraryDescriptor{ + ProtocolRole: library.protocolRole, + References: append([]frostPreSignManifestLinkReference{}, library.references...), + LinkedLibraries: frostPreSignLinkedLibraryDescriptors(library.linkedLibraries), + }) + } + return result +} + +func frostPreSignLinkedLibraryInventoryHash( + libraries []frostPreSignLinkedLibraryPin, +) ([32]byte, error) { + canonical, err := frostPreSignCanonicalValue(map[string]interface{}{ + "schema": "tbtc-p2tr-linked-library-inventory/v1", + "linkedLibraries": frostPreSignLinkedLibraryDescriptors(libraries), + }) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(canonical), nil +} + +func frostPreSignLinkedLibraryDescriptorSetHash( + deployments []frostPreSignDeploymentPin, +) ([32]byte, error) { + type contractDescriptor struct { + ContractRole string `json:"contractRole"` + CodeKind string `json:"codeKind"` + LinkedLibraries []frostPreSignLinkedLibraryDescriptor `json:"linkedLibraries"` + } + contracts := make([]contractDescriptor, 0, len(deployments)) + for _, deployment := range deployments { + codeKind := "runtime" + if deployment.upgradeability == "eip1967" { + codeKind = "implementation-runtime" + } + contracts = append(contracts, contractDescriptor{ + ContractRole: deployment.role, + CodeKind: codeKind, + LinkedLibraries: frostPreSignLinkedLibraryDescriptors(deployment.linkedLibraries), + }) + } + sort.Slice(contracts, func(i, j int) bool { + return contracts[i].ContractRole < contracts[j].ContractRole + }) + canonical, err := frostPreSignCanonicalValue(map[string]interface{}{ + "schema": "tbtc-p2tr-linked-library-descriptor-set/v1", + "contracts": contracts, + }) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(canonical), nil +} + +func frostPreSignCanonicalValue(value interface{}) ([]byte, error) { + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + return frostPreSignCanonicalJSON(encoded) +} + +func frostPreSignSlotValueBindsAddress(value [32]byte, address [20]byte) bool { + return bytes.Equal(value[:12], make([]byte, 12)) && bytes.Equal(value[12:], address[:]) +} + +func frostPreSignDeploymentSetHash(deployments []frostPreSignDeploymentPin) [32]byte { + hasher := sha256.New() + hasher.Write([]byte("tbtc-frost-pre-sign-deployment-set-v1\x00")) + for _, deployment := range deployments { + hasher.Write([]byte(deployment.name)) + hasher.Write([]byte{0}) + hasher.Write(deployment.address[:]) + hasher.Write(deployment.runtimeCodeHash[:]) + hasher.Write([]byte(deployment.upgradeability)) + hasher.Write([]byte{0}) + hasher.Write(deployment.implementationAddress[:]) + hasher.Write(deployment.implementationCodeHash[:]) + hasher.Write(deployment.adminAddress[:]) + hasher.Write(deployment.adminCodeHash[:]) + hasher.Write(deployment.implementationSlotValue[:]) + hasher.Write(deployment.adminSlotValue[:]) + hasher.Write(deployment.linkedLibraryDescriptorHash[:]) + } + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result +} + +func frostPreSignParseAddress(value string) ([20]byte, error) { + if !common.IsHexAddress(value) || len(value) != 42 || + value != strings.ToLower(value) || !strings.HasPrefix(value, "0x") { + return [20]byte{}, fmt.Errorf("invalid activation contract address [%s]", value) + } + return [20]byte(common.HexToAddress(value)), nil +} + +func frostPreSignParseBytes32(value string) ([32]byte, error) { + if len(value) != 66 || value != strings.ToLower(value) || + !strings.HasPrefix(value, "0x") { + return [32]byte{}, fmt.Errorf("invalid activation bytes32 value [%s]", value) + } + decoded, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + if err != nil || len(decoded) != 32 { + return [32]byte{}, fmt.Errorf("invalid activation bytes32 value [%s]", value) + } + result := [32]byte{} + copy(result[:], decoded) + return result, nil +} + +func frostPreSignCurrentFinality( + ctx context.Context, + client interface { + HeaderByNumber(context.Context, *big.Int) (*types.Header, error) + }, +) (*tbtc.FrostPreSignFinality, error) { + header, err := client.HeaderByNumber( + ctx, + big.NewInt(int64(rpc.FinalizedBlockNumber)), + ) + if err != nil { + return nil, fmt.Errorf("cannot obtain finalized Ethereum header: [%w]", err) + } + if header == nil || header.Number == nil || header.Number.Sign() <= 0 { + return nil, fmt.Errorf("finalized Ethereum header is invalid") + } + return &tbtc.FrostPreSignFinality{ + BlockNumber: header.Number.Uint64(), + BlockHash: [32]byte(header.Hash()), + }, nil +} + +func (adapter *frostPreSignEthereumAdapter) verifyDeploymentAt( + ctx context.Context, + finality *tbtc.FrostPreSignFinality, +) error { + if err := adapter.requireCanonicalFinality(ctx, finality); err != nil { + return err + } + block := new(big.Int).SetUint64(finality.BlockNumber) + profile := adapter.profile + storageReader, ok := adapter.chain.client.(interface { + StorageAt( + context.Context, + common.Address, + common.Hash, + *big.Int, + ) ([]byte, error) + }) + if !ok { + return fmt.Errorf("Ethereum client does not expose finalized storage reads") + } + implementationSlot := frostPreSignEIP1967Slot("eip1967.proxy.implementation") + adminSlot := frostPreSignEIP1967Slot("eip1967.proxy.admin") + for _, expected := range adapter.deployments { + code, err := adapter.chain.client.CodeAt( + ctx, + common.Address(expected.address), + block, + ) + if err != nil { + return fmt.Errorf("cannot read %s runtime code: [%w]", expected.name, err) + } + if len(code) == 0 || + [32]byte(crypto.Keccak256Hash(code)) != expected.runtimeCodeHash { + return fmt.Errorf("%s runtime code hash mismatch", expected.name) + } + ownerCode := code + implementationValue, err := storageReader.StorageAt( + ctx, + common.Address(expected.address), + implementationSlot, + block, + ) + if err != nil || len(implementationValue) != 32 { + return fmt.Errorf("cannot read %s EIP-1967 implementation slot: [%w]", expected.name, err) + } + adminValue, err := storageReader.StorageAt( + ctx, + common.Address(expected.address), + adminSlot, + block, + ) + if err != nil || len(adminValue) != 32 { + return fmt.Errorf("cannot read %s EIP-1967 admin slot: [%w]", expected.name, err) + } + switch expected.upgradeability { + case "immutable": + if !bytes.Equal(implementationValue, make([]byte, 32)) || + !bytes.Equal(adminValue, make([]byte, 32)) { + return fmt.Errorf("immutable %s has populated EIP-1967 slots", expected.name) + } + case "eip1967": + if !bytes.Equal(implementationValue, expected.implementationSlotValue[:]) || + !bytes.Equal(adminValue, expected.adminSlotValue[:]) { + return fmt.Errorf("%s EIP-1967 slot value mismatch", expected.name) + } + implementationCode, err := adapter.chain.client.CodeAt( + ctx, + common.Address(expected.implementationAddress), + block, + ) + if err != nil || len(implementationCode) == 0 || + [32]byte(crypto.Keccak256Hash(implementationCode)) != expected.implementationCodeHash { + return fmt.Errorf("%s implementation runtime code hash mismatch: [%w]", expected.name, err) + } + ownerCode = implementationCode + adminCode, err := adapter.chain.client.CodeAt( + ctx, + common.Address(expected.adminAddress), + block, + ) + if err != nil || [32]byte(crypto.Keccak256Hash(adminCode)) != expected.adminCodeHash { + return fmt.Errorf("%s admin runtime code hash mismatch: [%w]", expected.name, err) + } + default: + return fmt.Errorf("unsupported %s upgradeability", expected.name) + } + if err := adapter.verifyLinkedLibrariesAt( + ctx, + expected.name, + ownerCode, + expected.linkedLibraries, + block, + ); err != nil { + return err + } + } + + bridgeRouter, err := adapter.callAddressAt(ctx, adapter.bridge, "p2trFraudRouter", block) + if err != nil || bridgeRouter != common.Address(profile.CompleteRouter) { + return fmt.Errorf("Bridge COMPLETE router crosslink mismatch: [%w]", err) + } + routerBridge, err := adapter.callAddressAt(ctx, adapter.router, "bridge", block) + if err != nil || routerBridge != common.Address(profile.BridgeAddress) { + return fmt.Errorf("COMPLETE router Bridge crosslink mismatch: [%w]", err) + } + routerRegistry, err := adapter.callAddressAt(ctx, adapter.router, "authorizationRegistry", block) + if err != nil || routerRegistry != common.Address(profile.RegistryAddress) { + return fmt.Errorf("COMPLETE router registry crosslink mismatch: [%w]", err) + } + for method, expected := range map[string][32]byte{ + "evidenceProtocolID": profile.EvidenceProtocolID, + "preauthorizationProtocolID": profile.ReservationProtocolID, + "signingPolicyHash": profile.SigningPolicyHash, + } { + actual, err := adapter.callBytes32At(ctx, adapter.router, method, block) + if err != nil || actual != expected { + return fmt.Errorf("COMPLETE router %s mismatch: [%w]", method, err) + } + } + + config, err := adapter.callAt(ctx, adapter.registry, "protocolConfig", block) + if err != nil || len(config) != 6 { + return fmt.Errorf("cannot read authorization registry protocol config: [%w]", err) + } + registryBridge := *abi.ConvertType(config[0], new(common.Address)).(*common.Address) + registryFrost := *abi.ConvertType(config[1], new(common.Address)).(*common.Address) + registryValidator := *abi.ConvertType(config[2], new(common.Address)).(*common.Address) + registryChainID := *abi.ConvertType(config[3], new(*big.Int)).(**big.Int) + registryProtocol := *abi.ConvertType(config[4], new([32]byte)).(*[32]byte) + registryPolicy := *abi.ConvertType(config[5], new([32]byte)).(*[32]byte) + if registryBridge != common.Address(profile.BridgeAddress) || + registryFrost != common.Address(profile.FrostRegistry) || + registryValidator != common.Address(profile.ProposalValidator) || + registryChainID.Cmp(new(big.Int).SetBytes(profile.DomainChainID[:])) != 0 || + registryProtocol != profile.ReservationProtocolID || + registryPolicy != profile.SigningPolicyHash { + return fmt.Errorf("authorization registry protocol config mismatch") + } + + frostContract := bind.NewBoundContract( + common.Address(profile.FrostRegistry), frostPreSignCrosslinkABI, + adapter.chain.client, adapter.chain.client, adapter.chain.client, + ) + sortitionPool, err := adapter.callAddressAt(ctx, frostContract, "sortitionPool", block) + if err != nil || sortitionPool != common.Address(profile.SortitionPool) { + return fmt.Errorf("FROST registry sortition pool crosslink mismatch: [%w]", err) + } + validatorContract := bind.NewBoundContract( + common.Address(profile.ProposalValidator), frostPreSignCrosslinkABI, + adapter.chain.client, adapter.chain.client, adapter.chain.client, + ) + validatorBridge, err := adapter.callAddressAt(ctx, validatorContract, "bridge", block) + if err != nil || validatorBridge != common.Address(profile.BridgeAddress) { + return fmt.Errorf("proposal validator Bridge crosslink mismatch: [%w]", err) + } + return nil +} + +func (adapter *frostPreSignEthereumAdapter) verifyLinkedLibrariesAt( + ctx context.Context, + owner string, + ownerCode []byte, + libraries []frostPreSignLinkedLibraryPin, + block *big.Int, +) error { + for _, library := range libraries { + for _, reference := range library.references { + if reference.Start > uint64(len(ownerCode)) || + reference.Start+20 < reference.Start || + reference.Start+20 > uint64(len(ownerCode)) || + !bytes.Equal( + ownerCode[int(reference.Start):int(reference.Start+20)], + library.address[:], + ) { + return fmt.Errorf( + "%s linked-library reference [%s:%d] mismatch", + owner, + library.protocolRole, + reference.Start, + ) + } + } + libraryCode, err := adapter.chain.client.CodeAt( + ctx, + common.Address(library.address), + block, + ) + if err != nil || len(libraryCode) == 0 || + [32]byte(crypto.Keccak256Hash(libraryCode)) != library.runtimeCodeHash { + return fmt.Errorf( + "%s linked-library [%s] runtime code hash mismatch: [%w]", + owner, + library.protocolRole, + err, + ) + } + if err := adapter.verifyLinkedLibrariesAt( + ctx, + owner+"/"+library.protocolRole, + libraryCode, + library.linkedLibraries, + block, + ); err != nil { + return err + } + } + return nil +} + +func frostPreSignEIP1967Slot(label string) common.Hash { + value := crypto.Keccak256Hash([]byte(label)).Big() + value.Sub(value, big.NewInt(1)) + return common.BigToHash(value) +} + +func (adapter *frostPreSignEthereumAdapter) requireCanonicalFinality( + ctx context.Context, + finality *tbtc.FrostPreSignFinality, +) error { + if finality == nil || finality.BlockNumber == 0 || finality.BlockHash == [32]byte{} { + return fmt.Errorf("Ethereum finality checkpoint is invalid") + } + header, err := adapter.chain.client.HeaderByNumber( + ctx, + new(big.Int).SetUint64(finality.BlockNumber), + ) + if err != nil { + return fmt.Errorf("cannot reread finalized Ethereum header: [%w]", err) + } + if header == nil || [32]byte(header.Hash()) != finality.BlockHash { + return fmt.Errorf("finalized Ethereum block hash mismatch") + } + return nil +} + +func (adapter *frostPreSignEthereumAdapter) callAt( + ctx context.Context, + contract *bind.BoundContract, + method string, + block *big.Int, + parameters ...interface{}, +) ([]interface{}, error) { + if contract == nil { + return nil, fmt.Errorf("contract binding is nil") + } + var result []interface{} + err := contract.Call( + &bind.CallOpts{ + Context: ctx, + From: adapter.chain.key.Address, + BlockNumber: block, + }, + &result, + method, + parameters..., + ) + return result, err +} + +func (adapter *frostPreSignEthereumAdapter) callAddressAt( + ctx context.Context, + contract *bind.BoundContract, + method string, + block *big.Int, + parameters ...interface{}, +) (common.Address, error) { + result, err := adapter.callAt(ctx, contract, method, block, parameters...) + if err != nil || len(result) != 1 { + return common.Address{}, err + } + return *abi.ConvertType(result[0], new(common.Address)).(*common.Address), nil +} + +func (adapter *frostPreSignEthereumAdapter) callBytes32At( + ctx context.Context, + contract *bind.BoundContract, + method string, + block *big.Int, + parameters ...interface{}, +) ([32]byte, error) { + result, err := adapter.callAt(ctx, contract, method, block, parameters...) + if err != nil || len(result) != 1 { + return [32]byte{}, err + } + return *abi.ConvertType(result[0], new([32]byte)).(*[32]byte), nil +} + +func (tc *TbtcChain) PrepareFrostPreSignAuthorization( + ctx context.Context, + transaction *tbtc.FrostPreSignTransaction, + walletOperators []chain.Address, +) (*tbtc.FrostPreSignAuthorizationProposal, error) { + adapter, err := tc.frostPreSignAdapter() + if err != nil { + return nil, err + } + return adapter.prepare(ctx, transaction, walletOperators) +} + +func (tc *TbtcChain) VerifyFrostPreSignActivationPoint( + ctx context.Context, + finality tbtc.FrostPreSignFinality, +) error { + adapter, err := tc.frostPreSignAdapter() + if err != nil { + return err + } + return adapter.verifyDeploymentAt(ctx, &finality) +} + +func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( + tbtc.FrostPreSignActivationRuntimeManifest, + error, +) { + adapter, err := tc.frostPreSignAdapter() + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + frost := adapter.manifest.FrostSigner + parse := func(value string) ([32]byte, error) { + return frostPreSignParseBytes32(value) + } + signerProtocolID, err := parse(frost.ProtocolID) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + bitcoinOutboxProtocolID, err := parse(frost.BitcoinOutboxProtocolID) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + attestationSignerKeyHash, err := parse(frost.AttestationSignerKeyHash) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + retainedGroupInventoryProtocolID, err := parse(frost.RetainedGroupInventoryProtocolID) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + return tbtc.FrostPreSignActivationRuntimeManifest{ + ManifestHash: adapter.profile.ActivationManifestHash, + SignerProtocolID: signerProtocolID, + ReservationProtocolID: adapter.profile.ReservationProtocolID, + BitcoinOutboxProtocolID: bitcoinOutboxProtocolID, + SigningPolicyHash: adapter.profile.SigningPolicyHash, + DurableSessionStoreFingerprint: frost.DurableSessionStoreFingerprint, + CompleteRouterAddress: adapter.profile.CompleteRouter, + AuthorizationRegistryAddress: adapter.profile.RegistryAddress, + AttestationSignerKeyHash: attestationSignerKeyHash, + Threshold: frost.Threshold, + MaximumGroupSize: frost.MaximumGroupSize, + RetainedGroupInventoryProtocolID: retainedGroupInventoryProtocolID, + }, nil +} + +func (adapter *frostPreSignEthereumAdapter) prepare( + ctx context.Context, + transaction *tbtc.FrostPreSignTransaction, + walletOperators []chain.Address, +) (*tbtc.FrostPreSignAuthorizationProposal, error) { + if ctx == nil || transaction == nil { + return nil, fmt.Errorf("FROST authorization preparation input is nil") + } + finality, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) + if err != nil { + return nil, err + } + if err := adapter.verifyDeploymentAt(ctx, finality); err != nil { + return nil, err + } + block := new(big.Int).SetUint64(finality.BlockNumber) + members, err := adapter.resolveWalletMembersAt(ctx, walletOperators, block) + if err != nil { + return nil, err + } + membersHash, err := frostPreSignHashABIArray("uint32[]", members) + if err != nil { + return nil, err + } + actionData, err := adapter.encodeActionData(transaction) + if err != nil { + return nil, err + } + transactionInfo := frostPreSignBitcoinTxInfo{ + Version: transaction.Version, + InputVector: append([]byte{}, transaction.InputVector...), + OutputVector: append([]byte{}, transaction.OutputVector...), + Locktime: transaction.Locktime, + } + payload, err := frostPreSignCodecABI.Methods["previewPayload"].Inputs.Pack( + uint8(transaction.Action), + transactionInfo, + actionData, + membersHash, + ) + if err != nil { + return nil, fmt.Errorf("cannot encode COMPLETE preview payload: [%w]", err) + } + result, err := adapter.callAt( + ctx, + adapter.bridge, + "previewP2TRTransactionAuthorization", + block, + payload, + ) + if err != nil || len(result) != 1 { + return nil, fmt.Errorf("COMPLETE preview call failed: [%w]", err) + } + encodedPreview := *abi.ConvertType(result[0], new([]byte)).(*[]byte) + decoded, err := frostPreSignCodecABI.Methods["authorizationPreview"].Inputs.Unpack( + encodedPreview, + ) + if err != nil || len(decoded) != 1 { + return nil, fmt.Errorf("cannot decode COMPLETE preview: [%w]", err) + } + preview := *abi.ConvertType( + decoded[0], + new(frostPreSignAuthorizationPreview), + ).(*frostPreSignAuthorizationPreview) + if preview.TransactionHash != [32]byte(transaction.TransactionHash) || + preview.WalletPubKeyHash != transaction.WalletPublicKeyHash || + preview.MembersIDsHash != membersHash || + preview.Action != uint8(transaction.Action) { + return nil, fmt.Errorf("COMPLETE preview identity differs from local signing batch") + } + + resourceIDs, orderedInputRoot, err := adapter.deriveResourcesAt( + ctx, + transaction, + preview.WalletID, + block, + ) + if err != nil { + return nil, err + } + resourceHash, err := frostPreSignHashABIArray("bytes32[]", resourceIDs) + if err != nil { + return nil, err + } + if preview.ResourceHash != resourceHash || preview.OrderedInputRoot != orderedInputRoot { + return nil, fmt.Errorf("COMPLETE preview resource commitments differ from local derivation") + } + + profile := adapter.profile + return &tbtc.FrostPreSignAuthorizationProposal{ + Transaction: transaction, + WalletID: preview.WalletID, + SnapshotHash: preview.SnapshotHash, + ResourceHash: preview.ResourceHash, + OrderedInputRoot: preview.OrderedInputRoot, + ApplyPlanHash: preview.ApplyPlanHash, + ApplyPlanData1: preview.ApplyPlanData1, + ApplyPlanData2: preview.ApplyPlanData2, + FeeLimitSnapshot: preview.FeeLimitSnapshot, + ResourceIDs: resourceIDs, + WalletMembersIDs: members, + WalletMembersIDsHash: preview.MembersIDsHash, + ReservationID: preview.ReservationID, + AuthorizationRoot: preview.AuthorizationRoot, + Digest: preview.Digest, + DomainChainID: profile.DomainChainID, + ActivationManifestHash: profile.ActivationManifestHash, + ImplementationSetHash: profile.ImplementationSetHash, + BridgeAddress: profile.BridgeAddress, + RegistryAddress: profile.RegistryAddress, + CompleteRouter: profile.CompleteRouter, + FrostRegistry: profile.FrostRegistry, + ProposalValidator: profile.ProposalValidator, + SortitionPool: profile.SortitionPool, + BridgeCodeHash: profile.BridgeCodeHash, + RegistryCodeHash: profile.RegistryCodeHash, + CompleteRouterCodeHash: profile.CompleteRouterCodeHash, + FrostRegistryCodeHash: profile.FrostRegistryCodeHash, + ProposalValidatorCodeHash: profile.ProposalValidatorCodeHash, + SortitionPoolCodeHash: profile.SortitionPoolCodeHash, + ReservationProtocolID: profile.ReservationProtocolID, + EvidenceProtocolID: profile.EvidenceProtocolID, + SigningPolicyHash: profile.SigningPolicyHash, + PreparationFinality: *finality, + }, nil +} + +func (tc *TbtcChain) frostPreSignAdapter() (*frostPreSignEthereumAdapter, error) { + if tc == nil || tc.frostPreSignAuthorizationAdapter == nil { + return nil, fmt.Errorf("production FROST authorization adapter is not configured") + } + return tc.frostPreSignAuthorizationAdapter, nil +} + +func (adapter *frostPreSignEthereumAdapter) resolveWalletMembersAt( + ctx context.Context, + operators []chain.Address, + block *big.Int, +) ([]uint32, error) { + if len(operators) < 51 || len(operators) > 100 { + return nil, fmt.Errorf("invalid FROST wallet seat count [%d]", len(operators)) + } + cache := make(map[chain.Address]uint32) + result := make([]uint32, len(operators)) + for i, operator := range operators { + id, found := cache[operator] + if !found { + if !common.IsHexAddress(operator.String()) { + return nil, fmt.Errorf("invalid FROST wallet operator address [%s]", operator) + } + var err error + id, err = adapter.chain.frostSortitionPool.GetOperatorIDAtBlock( + common.HexToAddress(operator.String()), + block, + ) + if err != nil { + return nil, fmt.Errorf("cannot resolve finalized FROST seat [%d]: [%w]", i+1, err) + } + if id == 0 { + return nil, fmt.Errorf("FROST wallet seat [%d] has no sortition-pool ID", i+1) + } + cache[operator] = id + } + result[i] = id + } + return result, nil +} + +func (adapter *frostPreSignEthereumAdapter) encodeActionData( + transaction *tbtc.FrostPreSignTransaction, +) ([]byte, error) { + context := transaction.ActionContext + if context == nil { + return nil, fmt.Errorf("FROST action authorization context is absent") + } + branches := 0 + for _, present := range []bool{ + context.DepositSweep != nil, + context.Redemption != nil, + context.MovingFunds != nil, + context.MovedFundsSweep != nil, + } { + if present { + branches++ + } + } + if branches != 1 { + return nil, fmt.Errorf("FROST action authorization context has [%d] branches", branches) + } + + switch transaction.Action { + case tbtc.FrostPreSignActionDepositSweep: + data := context.DepositSweep + if data == nil || data.Proposal == nil || len(data.Deposits) == 0 || + len(data.Deposits) != len(data.Proposal.DepositsKeys) { + return nil, fmt.Errorf("invalid deposit-sweep authorization context") + } + extra := make( + []tbtcabi.WalletProposalValidatorTaprootDepositExtraInfo, + len(data.Deposits), + ) + for i, deposit := range data.Deposits { + if deposit == nil || !deposit.IsTaproot() || deposit.FundingTx == nil { + return nil, fmt.Errorf("deposit [%d] lacks validated Taproot funding context", i) + } + extra[i] = tbtcabi.WalletProposalValidatorTaprootDepositExtraInfo{ + FundingTx: tbtcabi.BitcoinTxInfo2{ + Version: deposit.FundingTx.SerializeVersion(), + InputVector: deposit.FundingTx.SerializeInputs(), + OutputVector: deposit.FundingTx.SerializeOutputs(), + Locktime: deposit.FundingTx.SerializeLocktime(), + }, + BlindingFactor: deposit.BlindingFactor, + WalletPubKeyHash: deposit.WalletPublicKeyHash, + WalletXOnlyPublicKey: *deposit.WalletXOnlyPublicKey, + RefundPubKeyHash: deposit.RefundPublicKeyHash, + RefundXOnlyPublicKey: *deposit.RefundXOnlyPublicKey, + RefundLocktime: deposit.RefundLocktime, + } + } + return frostPreSignCodecABI.Methods["depositData"].Inputs.Pack( + frostPreSignDepositAuthorizationData{ + Proposal: convertDepositSweepProposalToAbiType( + transaction.WalletPublicKeyHash, + data.Proposal, + ), + DepositsExtraInfo: extra, + MainUtxo: frostPreSignABIUtxo(data.MainUtxo), + }, + ) + case tbtc.FrostPreSignActionRedemption: + data := context.Redemption + if data == nil || data.Proposal == nil || data.MainUtxo == nil { + return nil, fmt.Errorf("invalid redemption authorization context") + } + proposal, err := convertRedemptionProposalToAbiType( + transaction.WalletPublicKeyHash, + data.Proposal, + ) + if err != nil { + return nil, err + } + return frostPreSignCodecABI.Methods["redemptionData"].Inputs.Pack( + frostPreSignRedemptionAuthorizationData{ + Proposal: proposal, + MainUtxo: frostPreSignABIUtxo(data.MainUtxo), + }, + ) + case tbtc.FrostPreSignActionMovingFunds: + data := context.MovingFunds + if data == nil || data.Proposal == nil || data.MainUtxo == nil { + return nil, fmt.Errorf("invalid moving-funds authorization context") + } + return frostPreSignCodecABI.Methods["movingData"].Inputs.Pack( + frostPreSignMovingAuthorizationData{ + Proposal: tbtcabi.WalletProposalValidatorMovingFundsProposal{ + WalletPubKeyHash: transaction.WalletPublicKeyHash, + TargetWallets: data.Proposal.TargetWallets, + MovingFundsTxFee: data.Proposal.MovingFundsTxFee, + }, + MainUtxo: frostPreSignABIUtxo(data.MainUtxo), + }, + ) + case tbtc.FrostPreSignActionMovedFundsSweep: + data := context.MovedFundsSweep + if data == nil || data.Proposal == nil { + return nil, fmt.Errorf("invalid moved-funds-sweep authorization context") + } + return frostPreSignCodecABI.Methods["movedSweepData"].Inputs.Pack( + frostPreSignMovedSweepAuthorizationData{ + Proposal: tbtcabi.WalletProposalValidatorMovedFundsSweepProposal{ + WalletPubKeyHash: transaction.WalletPublicKeyHash, + MovingFundsTxHash: data.Proposal.MovingFundsTxHash, + MovingFundsTxOutputIndex: data.Proposal.MovingFundsTxOutputIndex, + MovedFundsSweepTxFee: data.Proposal.SweepTxFee, + }, + MainUtxo: frostPreSignABIUtxo(data.MainUtxo), + }, + ) + default: + return nil, fmt.Errorf("unsupported FROST action [%d]", transaction.Action) + } +} + +func frostPreSignABIUtxo( + utxo *bitcoin.UnspentTransactionOutput, +) tbtcabi.BitcoinTxUTXO3 { + if utxo == nil || utxo.Outpoint == nil { + return tbtcabi.BitcoinTxUTXO3{} + } + return tbtcabi.BitcoinTxUTXO3{ + TxHash: utxo.Outpoint.TransactionHash, + TxOutputIndex: utxo.Outpoint.OutputIndex, + TxOutputValue: uint64(utxo.Value), + } +} + +func (adapter *frostPreSignEthereumAdapter) deriveResourcesAt( + ctx context.Context, + transaction *tbtc.FrostPreSignTransaction, + walletID [32]byte, + block *big.Int, +) ([][32]byte, [32]byte, error) { + bitcoinTx := &bitcoin.Transaction{} + if err := bitcoinTx.Deserialize(transaction.RawTransaction); err != nil { + return nil, [32]byte{}, fmt.Errorf("cannot decode FROST transaction resources: [%w]", err) + } + ordered := make([][32]byte, len(bitcoinTx.Inputs)) + for i, input := range bitcoinTx.Inputs { + if input == nil || input.Outpoint == nil { + return nil, [32]byte{}, fmt.Errorf("transaction input [%d] has no outpoint", i) + } + resource, err := frostPreSignResource( + "bitcoin-outpoint", + [32]byte(input.Outpoint.TransactionHash), + input.Outpoint.OutputIndex, + ) + if err != nil { + return nil, [32]byte{}, err + } + ordered[i] = resource + } + orderedRoot, err := frostPreSignHashABIArray("bytes32[]", ordered) + if err != nil { + return nil, [32]byte{}, err + } + mainSlot, err := frostPreSignResource("wallet-main-slot", walletID) + if err != nil { + return nil, [32]byte{}, err + } + resources := append([][32]byte{}, ordered...) + resources = append(resources, mainSlot) + + switch transaction.Action { + case tbtc.FrostPreSignActionDepositSweep: + // Every physical input outpoint plus the wallet main slot is locked. + case tbtc.FrostPreSignActionRedemption: + proposal, err := convertRedemptionProposalToAbiType( + transaction.WalletPublicKeyHash, + transaction.ActionContext.Redemption.Proposal, + ) + if err != nil { + return nil, [32]byte{}, err + } + for _, script := range proposal.RedeemersOutputScripts { + scriptHash := crypto.Keccak256Hash(script) + keyHash := crypto.Keccak256Hash( + append(append([]byte{}, scriptHash[:]...), transaction.WalletPublicKeyHash[:]...), + ) + resource, err := frostPreSignResource( + "redemption-request", + new(big.Int).SetBytes(keyHash[:]), + ) + if err != nil { + return nil, [32]byte{}, err + } + resources = append(resources, resource) + } + case tbtc.FrostPreSignActionMovingFunds: + for _, target := range transaction.ActionContext.MovingFunds.Proposal.TargetWallets { + targetID, err := adapter.callBytes32At(ctx, adapter.bridge, "walletID", block, target) + if err != nil || targetID == [32]byte{} { + return nil, [32]byte{}, fmt.Errorf("cannot resolve moving-funds target wallet ID: [%w]", err) + } + resource, err := frostPreSignResource("wallet-main-slot", targetID) + if err != nil { + return nil, [32]byte{}, err + } + resources = append(resources, resource) + } + case tbtc.FrostPreSignActionMovedFundsSweep: + proposal := transaction.ActionContext.MovedFundsSweep.Proposal + var index [4]byte + binary.BigEndian.PutUint32(index[:], proposal.MovingFundsTxOutputIndex) + keyHash := crypto.Keccak256Hash( + append(append([]byte{}, proposal.MovingFundsTxHash[:]...), index[:]...), + ) + resource, err := frostPreSignResource( + "moved-funds-request", + new(big.Int).SetBytes(keyHash[:]), + ) + if err != nil { + return nil, [32]byte{}, err + } + resources = append(resources, resource) + default: + return nil, [32]byte{}, fmt.Errorf("unsupported FROST resource action [%d]", transaction.Action) + } + + sort.Slice(resources, func(i, j int) bool { + return bytes.Compare(resources[i][:], resources[j][:]) < 0 + }) + for i, resource := range resources { + if resource == [32]byte{} || + (i > 0 && resources[i-1] == resource) { + return nil, [32]byte{}, fmt.Errorf("derived FROST resource set is zero or ambiguous") + } + } + return resources, orderedRoot, nil +} + +func frostPreSignHashABIArray(kind string, value interface{}) ([32]byte, error) { + typeValue, err := abi.NewType(kind, "", nil) + if err != nil { + return [32]byte{}, err + } + encoded, err := (abi.Arguments{{Type: typeValue}}).Pack(value) + if err != nil { + return [32]byte{}, err + } + return [32]byte(crypto.Keccak256Hash(encoded)), nil +} + +func frostPreSignResource(label string, values ...interface{}) ([32]byte, error) { + arguments := abi.Arguments{} + encodedValues := []interface{}{"tbtc-p2tr-pre-signing-resource-v1", label} + for _, kind := range []string{"string", "string"} { + typeValue, _ := abi.NewType(kind, "", nil) + arguments = append(arguments, abi.Argument{Type: typeValue}) + } + for _, value := range values { + var kind string + switch value.(type) { + case [32]byte: + kind = "bytes32" + case uint32: + kind = "uint32" + case *big.Int: + kind = "uint256" + default: + return [32]byte{}, fmt.Errorf("unsupported FROST resource component [%T]", value) + } + typeValue, err := abi.NewType(kind, "", nil) + if err != nil { + return [32]byte{}, err + } + arguments = append(arguments, abi.Argument{Type: typeValue}) + encodedValues = append(encodedValues, value) + } + encoded, err := arguments.Pack(encodedValues...) + if err != nil { + return [32]byte{}, err + } + return [32]byte(crypto.Keccak256Hash(encoded)), nil +} + +func (tc *TbtcChain) RelayFrostPreSignAuthorization( + ctx context.Context, + proposal *tbtc.FrostPreSignAuthorizationProposal, + attestation *tbtc.FrostPreSignSeatAttestation, +) ([32]byte, error) { + adapter, err := tc.frostPreSignAdapter() + if err != nil { + return [32]byte{}, err + } + return adapter.relay(ctx, proposal, attestation) +} + +func (adapter *frostPreSignEthereumAdapter) relay( + ctx context.Context, + proposal *tbtc.FrostPreSignAuthorizationProposal, + attestation *tbtc.FrostPreSignSeatAttestation, +) ([32]byte, error) { + if ctx == nil || proposal == nil || proposal.Transaction == nil || attestation == nil { + return [32]byte{}, fmt.Errorf("FROST authorization relay input is nil") + } + if !bytes.Equal(frostPreSignUint32SliceBytes(proposal.WalletMembersIDs), frostPreSignUint32SliceBytes(attestation.WalletMembersIDs)) { + return [32]byte{}, fmt.Errorf("FROST relay attestation wallet members differ from preview") + } + actionData, err := adapter.encodeActionData(proposal.Transaction) + if err != nil { + return [32]byte{}, err + } + transactionInfo := frostPreSignBitcoinTxInfo{ + Version: proposal.Transaction.Version, + InputVector: proposal.Transaction.InputVector, + OutputVector: proposal.Transaction.OutputVector, + Locktime: proposal.Transaction.Locktime, + } + payload, err := frostPreSignCodecABI.Methods["authorizePayload"].Inputs.Pack( + uint8(proposal.Transaction.Action), + transactionInfo, + actionData, + frostPreSignSeatAttestationABI{ + WalletMembersIDs: attestation.WalletMembersIDs, + SigningMemberIndices: attestation.SigningMemberIndices, + Signatures: attestation.Signatures, + }, + ) + if err != nil { + return [32]byte{}, fmt.Errorf("cannot encode COMPLETE authorization payload: [%w]", err) + } + + adapter.chain.transactionMutex.Lock() + defer adapter.chain.transactionMutex.Unlock() + transactor, err := bind.NewKeyedTransactorWithChainID( + adapter.chain.key.PrivateKey, + adapter.chain.chainID, + ) + if err != nil { + return [32]byte{}, fmt.Errorf("cannot create COMPLETE relay transactor: [%w]", err) + } + transactor.Context = ctx + nonce, err := adapter.chain.nonceManager.CurrentNonce() + if err != nil { + return [32]byte{}, fmt.Errorf("cannot obtain COMPLETE relay nonce: [%w]", err) + } + transactor.Nonce = new(big.Int).SetUint64(nonce) + transaction, err := adapter.bridge.Transact( + transactor, + "authorizeP2TRTransaction", + payload, + ) + if err != nil { + return [32]byte{}, fmt.Errorf("COMPLETE authorization transaction failed: [%w]", err) + } + adapter.chain.nonceManager.IncrementNonce() + + go adapter.chain.miningWaiter.ForceMining( + transaction, + transactor, + func(options *bind.TransactOpts) (*types.Transaction, error) { + return adapter.bridge.Transact( + options, + "authorizeP2TRTransaction", + payload, + ) + }, + ) + return [32]byte(transaction.Hash()), nil +} + +func frostPreSignUint32SliceBytes(values []uint32) []byte { + result := make([]byte, len(values)*4) + for i, value := range values { + binary.BigEndian.PutUint32(result[i*4:], value) + } + return result +} + +func (tc *TbtcChain) WaitForFrostPreSignAuthorizationFinality( + ctx context.Context, + relayTransactionHash [32]byte, + proposal *tbtc.FrostPreSignAuthorizationProposal, +) (*tbtc.FrostPreSignFinality, error) { + adapter, err := tc.frostPreSignAdapter() + if err != nil { + return nil, err + } + return adapter.waitForFinality(ctx, relayTransactionHash, proposal) +} + +func (adapter *frostPreSignEthereumAdapter) waitForFinality( + ctx context.Context, + relayTransactionHash [32]byte, + proposal *tbtc.FrostPreSignAuthorizationProposal, +) (*tbtc.FrostPreSignFinality, error) { + if ctx == nil || relayTransactionHash == [32]byte{} || proposal == nil || + proposal.Transaction == nil { + return nil, fmt.Errorf("FROST finality input is invalid") + } + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + var receipt *types.Receipt + for receipt == nil { + var err error + receipt, err = adapter.chain.client.TransactionReceipt( + ctx, + common.Hash(relayTransactionHash), + ) + if err != nil && err != geth.NotFound { + return nil, fmt.Errorf("cannot obtain COMPLETE relay receipt: [%w]", err) + } + if receipt != nil { + break + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + } + } + if receipt.Status != types.ReceiptStatusSuccessful || receipt.BlockNumber == nil { + return nil, fmt.Errorf("COMPLETE relay transaction reverted or has no inclusion block") + } + sequence, logIndex, err := adapter.validateAuthorizationReceipt(receipt, proposal) + if err != nil { + return nil, err + } + + for { + finalized, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) + if err != nil { + return nil, err + } + if finalized.BlockNumber >= receipt.BlockNumber.Uint64() { + header, err := adapter.chain.client.HeaderByNumber(ctx, receipt.BlockNumber) + if err != nil { + return nil, fmt.Errorf("cannot verify COMPLETE receipt block: [%w]", err) + } + if header == nil || header.Hash() != receipt.BlockHash { + return nil, fmt.Errorf("COMPLETE relay receipt is not canonical") + } + return &tbtc.FrostPreSignFinality{ + RelayTransactionHash: relayTransactionHash, + BlockNumber: receipt.BlockNumber.Uint64(), + BlockHash: [32]byte(receipt.BlockHash), + TransactionIndex: uint32(receipt.TransactionIndex), + LogIndex: logIndex, + AuthorizationSequence: sequence, + }, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + } + } +} + +func (adapter *frostPreSignEthereumAdapter) validateAuthorizationReceipt( + receipt *types.Receipt, + proposal *tbtc.FrostPreSignAuthorizationProposal, +) ([32]byte, uint32, error) { + authorizedEvent := frostPreSignRegistryABI.Events["P2TRPreSigningReservationAuthorized"] + advancedEvent := frostPreSignRegistryABI.Events["P2TRAuthorizedVariantAdvanced"] + registryAddress := common.Address(adapter.profile.RegistryAddress) + var authorizedLog, advancedLog *types.Log + for _, logEntry := range receipt.Logs { + if logEntry == nil || logEntry.Address != registryAddress || len(logEntry.Topics) == 0 { + continue + } + switch logEntry.Topics[0] { + case authorizedEvent.ID: + if authorizedLog != nil { + return [32]byte{}, 0, fmt.Errorf("COMPLETE receipt has duplicate reservation events") + } + authorizedLog = logEntry + case advancedEvent.ID: + if advancedLog != nil { + return [32]byte{}, 0, fmt.Errorf("COMPLETE receipt has duplicate variant events") + } + advancedLog = logEntry + } + } + if authorizedLog == nil || advancedLog == nil || + len(authorizedLog.Topics) != 4 || len(advancedLog.Topics) != 4 { + return [32]byte{}, 0, fmt.Errorf("COMPLETE receipt lacks the exact reservation/variant event pair") + } + if [32]byte(authorizedLog.Topics[1]) != proposal.ReservationID || + [32]byte(authorizedLog.Topics[2]) != [32]byte(proposal.Transaction.TransactionHash) || + [32]byte(authorizedLog.Topics[3]) != proposal.WalletID || + authorizedLog.Topics[1] != advancedLog.Topics[1] || + authorizedLog.Topics[2] != advancedLog.Topics[2] { + return [32]byte{}, 0, fmt.Errorf("COMPLETE receipt indexed identity mismatch") + } + data, err := authorizedEvent.Inputs.NonIndexed().Unpack(authorizedLog.Data) + if err != nil || len(data) != 4 { + return [32]byte{}, 0, fmt.Errorf("cannot decode COMPLETE reservation event: [%w]", err) + } + root := *abi.ConvertType(data[0], new([32]byte)).(*[32]byte) + snapshot := *abi.ConvertType(data[1], new([32]byte)).(*[32]byte) + resource := *abi.ConvertType(data[2], new([32]byte)).(*[32]byte) + action := *abi.ConvertType(data[3], new(uint8)).(*uint8) + if root != proposal.AuthorizationRoot || snapshot != proposal.SnapshotHash || + resource != proposal.ResourceHash || action != uint8(proposal.Transaction.Action) { + return [32]byte{}, 0, fmt.Errorf("COMPLETE receipt unindexed commitment mismatch") + } + sequence := [32]byte(advancedLog.Topics[3]) + if sequence == [32]byte{} { + return [32]byte{}, 0, fmt.Errorf("COMPLETE authorization sequence is zero") + } + return sequence, uint32(advancedLog.Index), nil +} + +func (tc *TbtcChain) CurrentFrostPreSignFinality( + ctx context.Context, +) (*tbtc.FrostPreSignFinality, error) { + adapter, err := tc.frostPreSignAdapter() + if err != nil { + return nil, err + } + return frostPreSignCurrentFinality(ctx, adapter.chain.client) +} + +func (tc *TbtcChain) ReadFrostPreSignAuthorizationState( + ctx context.Context, + proposal *tbtc.FrostPreSignAuthorizationProposal, + finality tbtc.FrostPreSignFinality, +) (*tbtc.FrostPreSignAuthorizationState, error) { + adapter, err := tc.frostPreSignAdapter() + if err != nil { + return nil, err + } + return adapter.readAuthorizationState(ctx, proposal, finality) +} + +func (adapter *frostPreSignEthereumAdapter) readAuthorizationState( + ctx context.Context, + proposal *tbtc.FrostPreSignAuthorizationProposal, + finality tbtc.FrostPreSignFinality, +) (*tbtc.FrostPreSignAuthorizationState, error) { + if ctx == nil || proposal == nil || proposal.Transaction == nil { + return nil, fmt.Errorf("FROST authorization state input is nil") + } + if err := adapter.verifyDeploymentAt(ctx, &finality); err != nil { + return nil, err + } + block := new(big.Int).SetUint64(finality.BlockNumber) + + lifecycle, err := adapter.callAt( + ctx, + adapter.bridge, + "frostLifecycleContext", + block, + proposal.Transaction.WalletPublicKeyHash, + ) + if err != nil || len(lifecycle) != 2 { + return nil, fmt.Errorf("cannot read finalized FROST lifecycle context: [%w]", err) + } + lifecycleRegistry := *abi.ConvertType(lifecycle[0], new(common.Address)).(*common.Address) + lifecycleWalletID := *abi.ConvertType(lifecycle[1], new([32]byte)).(*[32]byte) + + walletResult, err := adapter.callAt( + ctx, + adapter.bridge, + "wallets", + block, + proposal.Transaction.WalletPublicKeyHash, + ) + if err != nil || len(walletResult) != 1 { + return nil, fmt.Errorf("cannot read finalized Bridge wallet: [%w]", err) + } + wallet := *abi.ConvertType(walletResult[0], new(frostPreSignWalletABI)).(*frostPreSignWalletABI) + + frostContract := bind.NewBoundContract( + common.Address(adapter.profile.FrostRegistry), frostPreSignCrosslinkABI, + adapter.chain.client, adapter.chain.client, adapter.chain.client, + ) + frostWalletResult, err := adapter.callAt( + ctx, + frostContract, + "getWallet", + block, + proposal.WalletID, + ) + if err != nil || len(frostWalletResult) != 1 { + return nil, fmt.Errorf("cannot read finalized FROST registry wallet: [%w]", err) + } + frostWallet := *abi.ConvertType( + frostWalletResult[0], + new(frostPreSignRegistryWalletABI), + ).(*frostPreSignRegistryWalletABI) + + activeReservation, err := adapter.callBytes32At( + ctx, + adapter.registry, + "activeReservation", + block, + proposal.Transaction.WalletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf("cannot read finalized active reservation: [%w]", err) + } + reservation, err := adapter.callAt( + ctx, + adapter.registry, + "getReservation", + block, + proposal.ReservationID, + ) + if err != nil || len(reservation) != 11 { + return nil, fmt.Errorf("cannot read finalized reservation: [%w]", err) + } + variant, err := adapter.callAt( + ctx, + adapter.registry, + "getAuthorizedVariantStatus", + block, + [32]byte(proposal.Transaction.TransactionHash), + ) + if err != nil || len(variant) != 6 { + return nil, fmt.Errorf("cannot read finalized authorized variant: [%w]", err) + } + latest, err := adapter.callAt( + ctx, + adapter.registry, + "latestAuthorizedVariant", + block, + proposal.ReservationID, + ) + if err != nil || len(latest) != 3 { + return nil, fmt.Errorf("cannot read finalized latest variant: [%w]", err) + } + + profile := adapter.profile + return &tbtc.FrostPreSignAuthorizationState{ + Finality: finality, + DomainChainID: profile.DomainChainID, + ActivationManifestHash: profile.ActivationManifestHash, + ImplementationSetHash: profile.ImplementationSetHash, + BridgeAddress: profile.BridgeAddress, + RegistryAddress: profile.RegistryAddress, + CompleteRouter: profile.CompleteRouter, + FrostRegistry: profile.FrostRegistry, + ProposalValidator: profile.ProposalValidator, + SortitionPool: profile.SortitionPool, + BridgeCodeHash: profile.BridgeCodeHash, + RegistryCodeHash: profile.RegistryCodeHash, + CompleteRouterCodeHash: profile.CompleteRouterCodeHash, + FrostRegistryCodeHash: profile.FrostRegistryCodeHash, + ProposalValidatorCodeHash: profile.ProposalValidatorCodeHash, + SortitionPoolCodeHash: profile.SortitionPoolCodeHash, + ReservationProtocolID: profile.ReservationProtocolID, + EvidenceProtocolID: profile.EvidenceProtocolID, + SigningPolicyHash: profile.SigningPolicyHash, + WalletActive: lifecycleRegistry == common.Address(profile.FrostRegistry) && lifecycleWalletID == proposal.WalletID && wallet.EcdsaWalletID == [32]byte{} && (wallet.State == 1 || wallet.State == 2), + WalletID: lifecycleWalletID, + WalletPublicKeyHash: proposal.Transaction.WalletPublicKeyHash, + WalletMembersIDsHash: frostWallet.MembersIdsHash, + WalletXOnlyOutputKey: frostWallet.XOnlyOutputKey, + ActiveReservationID: activeReservation, + ReservationWalletID: *abi.ConvertType(reservation[0], new([32]byte)).(*[32]byte), + ReservationWalletPublicKeyHash: *abi.ConvertType(reservation[1], new([20]byte)).(*[20]byte), + ReservationSnapshotHash: *abi.ConvertType(reservation[3], new([32]byte)).(*[32]byte), + ReservationResourceHash: *abi.ConvertType(reservation[4], new([32]byte)).(*[32]byte), + ReservationOrderedInputRoot: *abi.ConvertType(reservation[5], new([32]byte)).(*[32]byte), + ReservationApplyPlanData1: *abi.ConvertType(reservation[6], new([32]byte)).(*[32]byte), + ReservationApplyPlanData2: *abi.ConvertType(reservation[7], new([32]byte)).(*[32]byte), + ReservationFeeLimitSnapshot: *abi.ConvertType(reservation[8], new(uint64)).(*uint64), + ReservationAction: tbtc.FrostPreSignAction(*abi.ConvertType(reservation[9], new(uint8)).(*uint8)), + ReservationActive: *abi.ConvertType(reservation[10], new(uint8)).(*uint8) == 1, + VariantTransactionHash: proposal.Transaction.TransactionHash, + VariantReservationID: *abi.ConvertType(variant[0], new([32]byte)).(*[32]byte), + VariantAuthorizationRoot: *abi.ConvertType(variant[1], new([32]byte)).(*[32]byte), + VariantApplyPlanHash: *abi.ConvertType(variant[2], new([32]byte)).(*[32]byte), + VariantAuthorizationSequence: frostPreSignUint256Word(*abi.ConvertType(variant[3], new(*big.Int)).(**big.Int)), + VariantFraudDefenseAuthorized: *abi.ConvertType(variant[4], new(bool)).(*bool), + VariantSigningAllowed: *abi.ConvertType(variant[5], new(bool)).(*bool), + LatestVariantTransactionHash: bitcoin.Hash(*abi.ConvertType(latest[0], new([32]byte)).(*[32]byte)), + LatestVariantAuthorizationSequence: frostPreSignUint256Word(*abi.ConvertType(latest[1], new(*big.Int)).(**big.Int)), + LatestVariantSigningAllowed: *abi.ConvertType(latest[2], new(bool)).(*bool), + }, nil +} + +func frostPreSignUint256Word(value *big.Int) [32]byte { + result := [32]byte{} + if value != nil && value.Sign() >= 0 && value.BitLen() <= 256 { + value.FillBytes(result[:]) + } + return result +} + +func (tc *TbtcChain) GetCanonicalFrostBitcoinBroadcastAuthorizationStatus( + ctx context.Context, + request *tbtc.FrostBitcoinBroadcastAuthorizationStatusRequest, +) (*tbtc.FrostBitcoinBroadcastAuthorizationStatus, error) { + adapter, err := tc.frostPreSignAdapter() + if err != nil { + return nil, err + } + return adapter.canonicalBroadcastStatus(ctx, request) +} + +func (adapter *frostPreSignEthereumAdapter) canonicalBroadcastStatus( + ctx context.Context, + request *tbtc.FrostBitcoinBroadcastAuthorizationStatusRequest, +) (*tbtc.FrostBitcoinBroadcastAuthorizationStatus, error) { + if ctx == nil || request == nil || request.FinalizedBlock == 0 || + request.FinalizedBlockHash == [32]byte{} || + request.VariantSequence.AuthorizationSequence == [32]byte{} { + return nil, fmt.Errorf("Bitcoin broadcast authorization request is invalid") + } + requestHash := request.ComputeHash() + historical := tbtc.FrostPreSignFinality{ + BlockNumber: request.FinalizedBlock, + BlockHash: request.FinalizedBlockHash, + } + if err := adapter.requireCanonicalFinality(ctx, &historical); err != nil { + return nil, err + } + historicalBlock := new(big.Int).SetUint64(request.FinalizedBlock) + if err := adapter.validateHistoricalBroadcastEvent(ctx, request); err != nil { + return nil, err + } + canonical, err := adapter.validateBroadcastAuthorizationAt( + ctx, + request, + historicalBlock, + true, + ) + if err != nil { + return nil, err + } + if !canonical { + return &tbtc.FrostBitcoinBroadcastAuthorizationStatus{ + RequestHash: requestHash, + Canonical: false, + }, nil + } + + current, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) + if err != nil { + return nil, err + } + if err := adapter.verifyDeploymentAt(ctx, current); err != nil { + return nil, err + } + allowed := false + if request.ActivationProfileHash == adapter.profile.ProfileHash && + request.ActiveActivationProfileHash == adapter.profile.ProfileHash { + allowed, err = adapter.validateBroadcastAuthorizationAt( + ctx, + request, + new(big.Int).SetUint64(current.BlockNumber), + false, + ) + if err != nil { + return nil, err + } + } + return &tbtc.FrostBitcoinBroadcastAuthorizationStatus{ + RequestHash: requestHash, + Canonical: true, + BroadcastAllowed: allowed, + }, nil +} + +func (adapter *frostPreSignEthereumAdapter) validateHistoricalBroadcastEvent( + ctx context.Context, + request *tbtc.FrostBitcoinBroadcastAuthorizationStatusRequest, +) error { + blockHash := common.Hash(request.FinalizedBlockHash) + event := frostPreSignRegistryABI.Events["P2TRAuthorizedVariantAdvanced"] + logs, err := adapter.chain.client.FilterLogs(ctx, geth.FilterQuery{ + BlockHash: &blockHash, + Addresses: []common.Address{common.Address(adapter.profile.RegistryAddress)}, + Topics: [][]common.Hash{ + {event.ID}, + {common.Hash(request.ReservationID)}, + {common.Hash(request.TransactionHash)}, + {common.Hash(request.VariantSequence.AuthorizationSequence)}, + }, + }) + if err != nil { + return fmt.Errorf("cannot read historical COMPLETE authorization event: [%w]", err) + } + if len(logs) != 1 || logs[0].Index != uint(request.FinalizedLogIndex) || + logs[0].TxIndex != uint(request.FinalizedTransactionIndex) || logs[0].Removed { + return fmt.Errorf("historical COMPLETE authorization event identity mismatch") + } + return nil +} + +func (adapter *frostPreSignEthereumAdapter) validateBroadcastAuthorizationAt( + ctx context.Context, + request *tbtc.FrostBitcoinBroadcastAuthorizationStatusRequest, + block *big.Int, + requirePlan bool, +) (bool, error) { + reservation, err := adapter.callAt( + ctx, + adapter.registry, + "getReservation", + block, + request.ReservationID, + ) + if err != nil || len(reservation) != 11 { + return false, fmt.Errorf("cannot read canonical broadcast reservation: [%w]", err) + } + variant, err := adapter.callAt( + ctx, + adapter.registry, + "getAuthorizedVariantStatus", + block, + [32]byte(request.TransactionHash), + ) + if err != nil || len(variant) != 6 { + return false, fmt.Errorf("cannot read canonical broadcast variant: [%w]", err) + } + latest, err := adapter.callAt( + ctx, + adapter.registry, + "latestAuthorizedVariant", + block, + request.ReservationID, + ) + if err != nil || len(latest) != 3 { + return false, fmt.Errorf("cannot read canonical broadcast latest variant: [%w]", err) + } + reservationWalletID := *abi.ConvertType(reservation[0], new([32]byte)).(*[32]byte) + reservationWalletPKH := *abi.ConvertType(reservation[1], new([20]byte)).(*[20]byte) + membersHash := *abi.ConvertType(reservation[2], new([32]byte)).(*[32]byte) + snapshot := *abi.ConvertType(reservation[3], new([32]byte)).(*[32]byte) + resource := *abi.ConvertType(reservation[4], new([32]byte)).(*[32]byte) + ordered := *abi.ConvertType(reservation[5], new([32]byte)).(*[32]byte) + data1 := *abi.ConvertType(reservation[6], new([32]byte)).(*[32]byte) + data2 := *abi.ConvertType(reservation[7], new([32]byte)).(*[32]byte) + feeLimit := *abi.ConvertType(reservation[8], new(uint64)).(*uint64) + action := *abi.ConvertType(reservation[9], new(uint8)).(*uint8) + status := *abi.ConvertType(reservation[10], new(uint8)).(*uint8) + variantReservation := *abi.ConvertType(variant[0], new([32]byte)).(*[32]byte) + variantRoot := *abi.ConvertType(variant[1], new([32]byte)).(*[32]byte) + variantApplyPlan := *abi.ConvertType(variant[2], new([32]byte)).(*[32]byte) + variantSequence := frostPreSignUint256Word(*abi.ConvertType(variant[3], new(*big.Int)).(**big.Int)) + fraudAuthorized := *abi.ConvertType(variant[4], new(bool)).(*bool) + signingAllowed := *abi.ConvertType(variant[5], new(bool)).(*bool) + latestHash := *abi.ConvertType(latest[0], new([32]byte)).(*[32]byte) + latestSequence := frostPreSignUint256Word(*abi.ConvertType(latest[1], new(*big.Int)).(**big.Int)) + latestAllowed := *abi.ConvertType(latest[2], new(bool)).(*bool) + if reservationWalletID != request.WalletID || + reservationWalletPKH != request.WalletPublicKeyHash || + snapshot != request.SnapshotHash || resource != request.ResourceHash || + ordered != request.OrderedInputRoot || feeLimit != request.FeeLimitSnapshot || + action != uint8(request.Action) || variantReservation != request.ReservationID || + variantRoot != request.AuthorizationRoot || + variantApplyPlan != request.VariantApplyPlanHash || + variantSequence != request.VariantSequence.AuthorizationSequence || + !fraudAuthorized { + return false, nil + } + if requirePlan { + lockedPlan, err := frostPreSignLockedPlanHash( + resource, + ordered, + data1, + data2, + feeLimit, + ) + if err != nil || lockedPlan != request.LockedPlanHash { + return false, err + } + preAuthorization := frostPreSignPreAuthorizationABI{ + Action: action, + WalletPubKeyHash: reservationWalletPKH, + WalletID: reservationWalletID, + MembersIDsHash: membersHash, + SnapshotHash: snapshot, + ResourceHash: resource, + OrderedInputRoot: ordered, + ApplyPlanHash: variantApplyPlan, + ApplyPlanData1: data1, + ApplyPlanData2: data2, + FeeLimitSnapshot: feeLimit, + } + digest, err := adapter.callBytes32At( + ctx, + adapter.registry, + "preAuthorizationDigest", + block, + preAuthorization, + [32]byte(request.TransactionHash), + variantRoot, + ) + if err != nil || digest != request.AuthorizationID { + return false, err + } + } + return status == 1 && signingAllowed && latestAllowed && + latestHash == [32]byte(request.TransactionHash) && + latestSequence == request.VariantSequence.AuthorizationSequence, nil +} + +func frostPreSignLockedPlanHash( + resource [32]byte, + ordered [32]byte, + data1 [32]byte, + data2 [32]byte, + feeLimit uint64, +) ([32]byte, error) { + kinds := []string{"bytes32", "bytes32", "bytes32", "bytes32", "uint64"} + arguments := make(abi.Arguments, len(kinds)) + for i, kind := range kinds { + typeValue, err := abi.NewType(kind, "", nil) + if err != nil { + return [32]byte{}, err + } + arguments[i] = abi.Argument{Type: typeValue} + } + encoded, err := arguments.Pack(resource, ordered, data1, data2, feeLimit) + if err != nil { + return [32]byte{}, err + } + return [32]byte(crypto.Keccak256Hash(encoded)), nil +} diff --git a/pkg/frost/signing/native_ffi_executor_adapter.go b/pkg/frost/signing/native_ffi_executor_adapter.go index 12322fe7d6..b9a509d781 100644 --- a/pkg/frost/signing/native_ffi_executor_adapter.go +++ b/pkg/frost/signing/native_ffi_executor_adapter.go @@ -17,6 +17,7 @@ type NativeExecutionFFISigningRequest struct { Message *big.Int SessionID string RoastSessionID string + AuthorizationGuard func(context.Context) error SigningIntent *SigningIntent MemberIndex group.MemberIndex GroupSize int @@ -105,6 +106,7 @@ func (nefea *nativeExecutionFFIExecutorAdapter) Execute( Message: request.Message, SessionID: request.SessionID, RoastSessionID: request.RoastSessionID, + AuthorizationGuard: request.AuthorizationGuard, SigningIntent: cloneSigningIntent(request.SigningIntent), MemberIndex: request.MemberIndex, GroupSize: request.GroupSize, @@ -153,6 +155,9 @@ func (nefea *nativeExecutionFFIExecutorAdapter) Execute( return nil, orchErr } if interactiveSignature != nil { + if err := validateAuthorizationGuard(ctx, request.AuthorizationGuard); err != nil { + return nil, err + } return &Result{ Signature: interactiveSignature, Attempt: cloneAttempt(request.Attempt), @@ -175,6 +180,9 @@ func (nefea *nativeExecutionFFIExecutorAdapter) Execute( ) } + if err := validateAuthorizationGuard(ctx, request.AuthorizationGuard); err != nil { + return nil, err + } signature, err := nefea.primitive.Sign(ctx, logger, ffiRequest) if err != nil { return nil, err @@ -183,6 +191,9 @@ func (nefea *nativeExecutionFFIExecutorAdapter) Execute( if signature == nil { return nil, fmt.Errorf("native FFI signing primitive returned nil signature") } + if err := validateAuthorizationGuard(ctx, request.AuthorizationGuard); err != nil { + return nil, err + } return &Result{ Signature: signature, diff --git a/pkg/frost/signing/native_ffi_executor_adapter_test.go b/pkg/frost/signing/native_ffi_executor_adapter_test.go index c4c0d45041..5667311960 100644 --- a/pkg/frost/signing/native_ffi_executor_adapter_test.go +++ b/pkg/frost/signing/native_ffi_executor_adapter_test.go @@ -18,6 +18,7 @@ type mockNativeExecutionFFISigningPrimitive struct { lastRequest *NativeExecutionFFISigningRequest signature *frost.Signature signErr error + afterSign func() registerCalls int lastChannel net.BroadcastChannel } @@ -29,6 +30,9 @@ func (mnefsp *mockNativeExecutionFFISigningPrimitive) Sign( ) (*frost.Signature, error) { mnefsp.signCalls++ mnefsp.lastRequest = request + if mnefsp.afterSign != nil { + mnefsp.afterSign() + } return mnefsp.signature, mnefsp.signErr } @@ -256,6 +260,40 @@ func TestNativeExecutionFFIExecutorAdapter_Execute_DelegatesToPrimitive( } } +func TestNativeExecutionFFIExecutorAdapter_Execute_RevalidatesBeforeSignatureRelease( + t *testing.T, +) { + authorized := true + primitive := &mockNativeExecutionFFISigningPrimitive{ + signature: &frost.Signature{R: [frost.SignatureComponentSize]byte{0x01}}, + afterSign: func() { authorized = false }, + } + executor, err := NewNativeExecutionFFIExecutorAdapter(primitive) + if err != nil { + t.Fatal(err) + } + result, err := executor.Execute(context.Background(), nil, &Request{ + Message: big.NewInt(123), + SignerMaterial: &NativeSignerMaterial{ + Format: NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0xaa}, + }, + AuthorizationGuard: func(context.Context) error { + if !authorized { + return errors.New("authorization reorged") + } + return nil + }, + }) + if result != nil || err == nil || + !errors.Is(err, ErrTerminalSigningFailure) { + t.Fatalf("unexpected post-sign authorization result: [%v] [%v]", result, err) + } + if primitive.signCalls != 1 { + t.Fatal("test did not reach the native signature boundary") + } +} + func TestNativeExecutionFFIExecutorAdapter_Execute_GenericSigningIntentIsNil( t *testing.T, ) { diff --git a/pkg/frost/signing/request.go b/pkg/frost/signing/request.go index 21225b494b..e0d808ddda 100644 --- a/pkg/frost/signing/request.go +++ b/pkg/frost/signing/request.go @@ -1,6 +1,7 @@ package signing import ( + "context" "fmt" "math/big" @@ -13,6 +14,10 @@ import ( type Request struct { Message *big.Int SessionID string + // AuthorizationGuard revalidates an external authorization immediately + // before secret nonce/share boundaries. It is nil for signing flows that do + // not use an external authorization protocol. + AuthorizationGuard func(context.Context) error // SigningIntent carries a narrowly typed authorization artifact for messages // that are not transaction sighashes. It is nil for generic and transaction // signing. Today the only supported value is a heartbeat intent created with @@ -45,6 +50,26 @@ type Request struct { Attempt *Attempt } +func validateAuthorizationGuard( + ctx context.Context, + guard func(context.Context) error, +) error { + if guard == nil { + return nil + } + if err := guard(ctx); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf( + "%w: external signing authorization is invalid: %v", + ErrTerminalSigningFailure, + err, + ) + } + return nil +} + // SigningIntent is a closed, immutable signing-intent value. Its fields stay // private so callers cannot manufacture an unvalidated intent shape; exported // constructors are the only way to create one. diff --git a/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go b/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go index ef015c3b1d..6c3d738c01 100644 --- a/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go +++ b/pkg/frost/signing/roast_interactive_signing_drive_frost_native.go @@ -130,6 +130,9 @@ func driveInteractiveRoastSigningIfEnabled( } collector := roast.NewRound2Collector(deps.Verifier) + if err := validateAuthorizationGuard(ctx, request.AuthorizationGuard); err != nil { + return nil, err + } runner, err := newInteractiveSigningRunner( active, @@ -145,6 +148,7 @@ func driveInteractiveRoastSigningIfEnabled( if err != nil { return nil, fmt.Errorf("interactive ROAST signing: build runner: %w", err) } + runner.authorizationGuard = request.AuthorizationGuard signatureBytes, err := runner.Run(ctx) if err != nil { diff --git a/pkg/frost/signing/roast_runner_frost_native.go b/pkg/frost/signing/roast_runner_frost_native.go index 3a66fd18c0..d22db718c0 100644 --- a/pkg/frost/signing/roast_runner_frost_native.go +++ b/pkg/frost/signing/roast_runner_frost_native.go @@ -51,6 +51,9 @@ type interactiveSigningRunner struct { messageDigest []byte threshold uint16 signingIntent *SigningIntent + // authorizationGuard is set by the tBTC transaction-signing adapter. It is + // checked immediately before the native session, nonce, and share boundaries. + authorizationGuard func(context.Context) error // includedMembers is the attempt's included set as a lookup, cached at // construction. It gates which shares the collector retains as evidence (any // included member's, even a non-signer observer's divergent share), distinct @@ -150,6 +153,10 @@ func (r *interactiveSigningRunner) Run(ctx context.Context) ([]byte, error) { contextHash := binding.ContextHash() elected := binding.ElectedCoordinator() + if err := validateAuthorizationGuard(ctx, r.authorizationGuard); err != nil { + return nil, err + } + // 1. Derive the canonical attempt context + per-participant FROST identifiers // from the engine (single source of truth - the runner never re-implements the // engine's domain-separated derivations). Cross-check the engine-derived @@ -186,6 +193,9 @@ func (r *interactiveSigningRunner) Run(ctx context.Context) ([]byte, error) { // 2. Open the interactive session with the engine-derived context; the engine // returns the attempt id used for every subsequent round. + if err := validateAuthorizationGuard(ctx, r.authorizationGuard); err != nil { + return nil, err + } open, err := r.engine.InteractiveSessionOpen( binding.SessionID(), uint16(r.member), @@ -240,10 +250,19 @@ func (r *interactiveSigningRunner) Run(ctx context.Context) ([]byte, error) { } // 3. Round 1: our commitments, broadcast to the group (own kept locally). + if err := validateAuthorizationGuard(ctx, r.authorizationGuard); err != nil { + return nil, err + } ownCommitments, err := r.engine.InteractiveRound1(binding.SessionID(), attemptID, uint16(r.member)) if err != nil { return nil, fmt.Errorf("roast runner: round 1: %w", err) } + // The native call can block while authorization changes on the host chain. + // Revalidate after nonce creation and immediately before the commitment leaves + // the process; the pre-call guard alone leaves a TOCTOU release window. + if err := validateAuthorizationGuard(ctx, r.authorizationGuard); err != nil { + return nil, err + } r.broadcast(RunnerMsgCommitments, contextHash, ownCommitments) // 4. Only the elected coordinator collects commitments - it alone builds the @@ -308,10 +327,19 @@ func (r *interactiveSigningRunner) Run(ctx context.Context) ([]byte, error) { // resident - the cleanup defer aborts them on success. shares := map[group.MemberIndex][]byte{} if memberInSet(r.member, signerSet) { + if err := validateAuthorizationGuard(ctx, r.authorizationGuard); err != nil { + return nil, err + } ownShare, err := r.engine.InteractiveRound2(binding.SessionID(), attemptID, uint16(r.member), pkg.SigningPackageBytes) if err != nil { return nil, fmt.Errorf("roast runner: round 2: %w", err) } + // Round 2 is the irreversible signing boundary: it may block while a + // reservation is settled or conflicted. Never hand its result to envelope + // signing or transport without a post-native authorization check. + if err := validateAuthorizationGuard(ctx, r.authorizationGuard); err != nil { + return nil, err + } // Round 2 consumed our round-1 nonces: a successful signer prunes without // aborting; only a non-signing observer still needs the abort. signedRound2 = true @@ -322,6 +350,11 @@ func (r *interactiveSigningRunner) Run(ctx context.Context) ([]byte, error) { if err := r.collector.RecordShareSubmission(ownSubmission); err != nil { return nil, fmt.Errorf("roast runner: record own share submission: %w", err) } + // Envelope signing/collector work is another scheduling window. This check + // is intentionally adjacent to broadcast, the actual secret-share release. + if err := validateAuthorizationGuard(ctx, r.authorizationGuard); err != nil { + return nil, err + } r.broadcast(RunnerMsgShareSubmission, contextHash, ownSubmissionEnvelope) shares[r.member] = ownShare } @@ -361,6 +394,9 @@ func (r *interactiveSigningRunner) Run(ctx context.Context) ([]byte, error) { if err != nil { return nil, fmt.Errorf("roast runner: aggregate: %w", err) } + if err := validateAuthorizationGuard(ctx, r.authorizationGuard); err != nil { + return nil, err + } // 10. Mark the attempt succeeded so the cleanup path produces no transition // bundle for a completed attempt. diff --git a/pkg/frost/signing/roast_runner_frost_native_test.go b/pkg/frost/signing/roast_runner_frost_native_test.go index 94c8751d56..71b8929dc3 100644 --- a/pkg/frost/signing/roast_runner_frost_native_test.go +++ b/pkg/frost/signing/roast_runner_frost_native_test.go @@ -231,6 +231,67 @@ func TestInteractiveSigningRunner_HappyPath(t *testing.T) { buildInteractiveSigningHarness(t, 3, 2).runAndAssertAllSucceed(t) } +func TestInteractiveSigningRunner_RevalidatesAfterRound1BeforeCommitmentRelease( + t *testing.T, +) { + h := buildInteractiveSigningHarness(t, 1, 1) + observer := h.bus.Subscribe() + guardCalls := 0 + h.runners[0].authorizationGuard = func(context.Context) error { + guardCalls++ + // Initial, pre-open, and pre-round-1 checks pass. Invalidate while the + // native round-1 operation is in flight. + if guardCalls == 4 { + return fmt.Errorf("reservation settled during round 1") + } + return nil + } + + if _, err := h.runners[0].Run(context.Background()); err == nil { + t.Fatal("expected the post-round-1 authorization guard to fail") + } + select { + case <-observer.Commitments(): + t.Fatal("round-1 commitment escaped after authorization invalidation") + default: + } + if h.engines[0].abortCallCount() != 1 { + t.Fatal("invalidated round-1 session was not aborted") + } +} + +func TestInteractiveSigningRunner_RevalidatesAfterRound2BeforeShareRelease( + t *testing.T, +) { + h := buildInteractiveSigningHarness(t, 1, 1) + observer := h.bus.Subscribe() + guardCalls := 0 + h.runners[0].authorizationGuard = func(context.Context) error { + guardCalls++ + // Invalidate during native round 2. The produced share must never be + // envelope-signed or broadcast. + if guardCalls == 6 { + return fmt.Errorf("reservation conflicted during round 2") + } + return nil + } + + if _, err := h.runners[0].Run(context.Background()); err == nil { + t.Fatal("expected the post-round-2 authorization guard to fail") + } + if h.engines[0].round2CallCount() != 1 { + t.Fatal("test did not reach native round 2") + } + select { + case <-observer.Shares(): + t.Fatal("signature share escaped after authorization invalidation") + default: + } + if h.engines[0].abortCallCount() != 1 { + t.Fatal("invalidated round-2 session was not aborted") + } +} + // A non-responsive (offline) included member must NOT stall the attempt: the // coordinator finalizes over the first t responsive committers. Group size 3, // threshold 2, one NON-COORDINATOR member never runs (never commits), so the two diff --git a/pkg/frost/signing/signing.go b/pkg/frost/signing/signing.go index 3ea4ab3a63..3a427c5f3a 100644 --- a/pkg/frost/signing/signing.go +++ b/pkg/frost/signing/signing.go @@ -60,6 +60,9 @@ func ExecuteRequest( clonedRequest := *request clonedRequest.Attempt = cloneAttempt(request.Attempt) + if err := validateAuthorizationGuard(ctx, clonedRequest.AuthorizationGuard); err != nil { + return nil, err + } return currentExecutionBackend().Execute( ctx, diff --git a/pkg/frost/signing/signing_test.go b/pkg/frost/signing/signing_test.go index f54ff8cfe4..b5e9db2f10 100644 --- a/pkg/frost/signing/signing_test.go +++ b/pkg/frost/signing/signing_test.go @@ -2,6 +2,7 @@ package signing import ( "context" + "errors" "math/big" "reflect" "testing" @@ -86,6 +87,26 @@ func TestExecuteRequest_NilRequest(t *testing.T) { } } +func TestExecuteRequest_AuthorizationGuardPrecedesBackend(t *testing.T) { + ResetExecutionBackend() + t.Cleanup(ResetExecutionBackend) + backend := &mockExecutionBackend{name: "mock", result: &Result{}} + if err := SetExecutionBackend(backend); err != nil { + t.Fatal(err) + } + guardErr := errors.New("authorization reorged") + result, err := ExecuteRequest(context.Background(), nil, &Request{ + AuthorizationGuard: func(context.Context) error { return guardErr }, + }) + if result != nil || err == nil || + !errors.Is(err, ErrTerminalSigningFailure) { + t.Fatalf("unexpected authorization-guard result: [%v] [%v]", result, err) + } + if backend.executeCalls != 0 { + t.Fatal("authorization failure reached the signing backend") + } +} + func TestExecuteRequest_ClonesAttempt(t *testing.T) { ResetExecutionBackend() t.Cleanup(ResetExecutionBackend) diff --git a/pkg/tbtc/bitcoin_broadcast_outbox.go b/pkg/tbtc/bitcoin_broadcast_outbox.go new file mode 100644 index 0000000000..db7ad0c171 --- /dev/null +++ b/pkg/tbtc/bitcoin_broadcast_outbox.go @@ -0,0 +1,1716 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "golang.org/x/sys/unix" +) + +const ( + bitcoinBroadcastOutboxRecordVersion = 5 + bitcoinBroadcastOutboxFileSuffix = ".json" + bitcoinBroadcastOutboxTempSuffix = ".tmp" + bitcoinBroadcastOutboxLockFile = ".lock" + + defaultBitcoinBroadcastOutboxReplayInterval = time.Minute + defaultBitcoinBroadcastArchiveConfirmations = 100 + defaultBitcoinBroadcastDeepReconcileBatch = 4 +) + +var errBitcoinBroadcastQuarantined = errors.New( + "canonical Bitcoin broadcast authorization does not permit broadcast", +) + +type canonicalBitcoinBroadcastChain interface { + bitcoin.Chain + bitcoin.CanonicalTransactionStatusSource +} + +// FrostBitcoinBroadcastOutpoint is one exact input committed by a durable +// authorization-status request. +type FrostBitcoinBroadcastOutpoint struct { + TransactionHash bitcoin.Hash + OutputIndex uint32 +} + +// FrostBitcoinBroadcastAuthorizationStatusRequest is the ABI-neutral identity +// a concrete Ethereum adapter must revalidate against canonical finalized +// state before the outbox can broadcast. It deliberately carries both the +// record's pinned and the currently active activation profiles, plus the +// complete reservation and variant tuple. +type FrostBitcoinBroadcastAuthorizationStatusRequest struct { + ActivationProfileHash [32]byte + ActiveActivationProfileHash [32]byte + TransactionHash bitcoin.Hash + WalletPublicKeyHash [20]byte + WalletID [32]byte + Action FrostPreSignAction + OrderedOutpoints []FrostBitcoinBroadcastOutpoint + AuthorizationID [32]byte + ReservationID [32]byte + AuthorizationRoot [32]byte + SnapshotHash [32]byte + ResourceHash [32]byte + OrderedInputRoot [32]byte + LockedPlanHash [32]byte + VariantApplyPlanHash [32]byte + FeeLimitSnapshot uint64 + FinalizedBlock uint64 + FinalizedBlockHash [32]byte + FinalizedTransactionIndex uint32 + FinalizedLogIndex uint32 + VariantSequence FrostPreSignVariantSequence +} + +// ComputeHash returns a deterministic request commitment for exact response +// binding. This is an internal adapter protocol, not a guessed Solidity ABI. +func (fbasr *FrostBitcoinBroadcastAuthorizationStatusRequest) ComputeHash() [32]byte { + if fbasr == nil { + return [32]byte{} + } + hasher := sha256.New() + hasher.Write([]byte("tbtc-frost-bitcoin-broadcast-authorization-status-v2")) + hasher.Write(fbasr.ActivationProfileHash[:]) + hasher.Write(fbasr.ActiveActivationProfileHash[:]) + hasher.Write(fbasr.TransactionHash[:]) + hasher.Write(fbasr.WalletPublicKeyHash[:]) + hasher.Write(fbasr.WalletID[:]) + hasher.Write([]byte{byte(fbasr.Action)}) + writeUint64 := func(value uint64) { + var encoded [8]byte + for i := 0; i < len(encoded); i++ { + encoded[len(encoded)-1-i] = byte(value >> (8 * i)) + } + hasher.Write(encoded[:]) + } + writeUint32 := func(value uint32) { + var encoded [4]byte + for i := 0; i < len(encoded); i++ { + encoded[len(encoded)-1-i] = byte(value >> (8 * i)) + } + hasher.Write(encoded[:]) + } + writeUint32(uint32(len(fbasr.OrderedOutpoints))) + for _, outpoint := range fbasr.OrderedOutpoints { + hasher.Write(outpoint.TransactionHash[:]) + writeUint32(outpoint.OutputIndex) + } + for _, value := range [][32]byte{ + fbasr.AuthorizationID, + fbasr.ReservationID, + fbasr.AuthorizationRoot, + fbasr.SnapshotHash, + fbasr.ResourceHash, + fbasr.OrderedInputRoot, + fbasr.LockedPlanHash, + fbasr.VariantApplyPlanHash, + } { + hasher.Write(value[:]) + } + writeUint64(fbasr.FeeLimitSnapshot) + writeUint64(fbasr.FinalizedBlock) + hasher.Write(fbasr.FinalizedBlockHash[:]) + writeUint32(fbasr.FinalizedTransactionIndex) + writeUint32(fbasr.FinalizedLogIndex) + hasher.Write(fbasr.VariantSequence.AuthorizationSequence[:]) + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result +} + +// FrostBitcoinBroadcastAuthorizationStatus is returned from a canonical, +// finalized Ethereum read. Canonical=false is never interpreted as permission. +// BroadcastAllowed may be false for a still-canonical, already-settled record; +// such a record can be reconciled but cannot be broadcast again. +type FrostBitcoinBroadcastAuthorizationStatus struct { + RequestHash [32]byte + Canonical bool + BroadcastAllowed bool +} + +// FrostBitcoinBroadcastAuthorizationStatusSource is intentionally only a hook +// until the reviewed COMPLETE ABI is stable. Activation has no default +// implementation and fails closed unless the configured chain supplies it. +type FrostBitcoinBroadcastAuthorizationStatusSource interface { + GetCanonicalFrostBitcoinBroadcastAuthorizationStatus( + context.Context, + *FrostBitcoinBroadcastAuthorizationStatusRequest, + ) (*FrostBitcoinBroadcastAuthorizationStatus, error) +} + +// bitcoinBroadcastAuthorization is the complete finalized authorization +// identity needed to audit and safely recover a signed transaction. Fields +// that define one reservation's semantic input/resource plan are immutable +// across every authorized RBF variant. +type bitcoinBroadcastAuthorization struct { + ActivationProfileHash [32]byte + AuthorizationID [32]byte + ReservationID [32]byte + AuthorizationRoot [32]byte + SnapshotHash [32]byte + ResourceHash [32]byte + OrderedInputRoot [32]byte + LockedPlanHash [32]byte + VariantApplyPlanHash [32]byte + FeeLimitSnapshot uint64 + FinalizedBlock uint64 + FinalizedBlockHash [32]byte + FinalizedTransactionIndex uint32 + FinalizedLogIndex uint32 + VariantSequence FrostPreSignVariantSequence +} + +type bitcoinBroadcastOutpoint struct { + TransactionHash bitcoin.Hash `json:"transactionHash"` + OutputIndex uint32 `json:"outputIndex"` +} + +type bitcoinBroadcastConfirmation struct { + Confirmations uint `json:"confirmations"` + BlockHeight uint `json:"blockHeight"` + BlockHash bitcoin.Hash `json:"blockHash"` + Canonical bool `json:"canonical"` + ObservedAtUnix int64 `json:"observedAtUnix"` +} + +type bitcoinBroadcastQuarantine struct { + ActiveActivationProfileHash [32]byte `json:"activeActivationProfileHash"` + ObservedAtUnix int64 `json:"observedAtUnix"` +} + +type bitcoinBroadcastOutboxRecord struct { + Version uint32 `json:"version"` + TransactionHash bitcoin.Hash `json:"transactionHash"` + WitnessTransactionHash bitcoin.Hash `json:"witnessTransactionHash"` + UnsignedTransactionHash bitcoin.Hash `json:"unsignedTransactionHash"` + RawTransaction []byte `json:"rawTransaction"` + WalletPublicKeyHash [20]byte `json:"walletPublicKeyHash"` + WalletID [32]byte `json:"walletID"` + Action FrostPreSignAction `json:"action"` + OrderedOutpoints []bitcoinBroadcastOutpoint `json:"orderedOutpoints"` + InputSetHash [32]byte `json:"inputSetHash"` + Authorization bitcoinBroadcastAuthorization `json:"authorization"` + CreatedAtUnix int64 `json:"createdAtUnix"` + UpdatedAtUnix int64 `json:"updatedAtUnix"` + FirstBroadcastAtUnix int64 `json:"firstBroadcastAtUnix"` + LastAttemptUnix int64 `json:"lastAttemptUnix"` + BroadcastAttempts uint64 `json:"broadcastAttempts"` + Confirmation *bitcoinBroadcastConfirmation `json:"confirmation,omitempty"` + Quarantine *bitcoinBroadcastQuarantine `json:"quarantine,omitempty"` +} + +type bitcoinBroadcastOutboxEnvelope struct { + Payload json.RawMessage `json:"payload"` + Checksum [32]byte `json:"checksum"` +} + +// bitcoinBroadcastOutbox is an exclusively owned, crash-safe signed- +// transaction journal and rebroadcaster. Every mutation is committed with a +// temp-file write, file fsync, atomic rename, and directory fsync. +type bitcoinBroadcastOutbox struct { + directory string + btcChain canonicalBitcoinBroadcastChain + authorizationStatusSource FrostBitcoinBroadcastAuthorizationStatusSource + activationProfileHash [32]byte + replayInterval time.Duration + archiveConfirmations uint + deepReconcileBatch int + deepReconcileCursor int + now func() time.Time + + replayMutex sync.Mutex + mutex sync.Mutex + records map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord + lockFile *os.File + closed bool + recovered bool + + persistFailureHook func(*bitcoinBroadcastOutboxRecord) error +} + +func newBitcoinBroadcastOutbox( + directory string, + btcChain canonicalBitcoinBroadcastChain, + authorizationStatusSource FrostBitcoinBroadcastAuthorizationStatusSource, + activationProfileHash [32]byte, +) (*bitcoinBroadcastOutbox, error) { + if strings.TrimSpace(directory) == "" { + return nil, fmt.Errorf("Bitcoin broadcast outbox directory is empty") + } + if btcChain == nil { + return nil, fmt.Errorf("Bitcoin broadcast outbox chain is nil") + } + if authorizationStatusSource == nil { + return nil, fmt.Errorf("Bitcoin broadcast authorization status source is nil") + } + if activationProfileHash == [32]byte{} { + return nil, fmt.Errorf("Bitcoin broadcast activation profile hash is zero") + } + + cleanDirectory, err := filepath.Abs(filepath.Clean(directory)) + if err != nil { + return nil, fmt.Errorf("cannot resolve Bitcoin broadcast outbox directory: [%w]", err) + } + if err := os.MkdirAll(cleanDirectory, 0700); err != nil { + return nil, fmt.Errorf("cannot create Bitcoin broadcast outbox: [%w]", err) + } + if err := validateSecureBitcoinBroadcastDirectory(cleanDirectory); err != nil { + return nil, err + } + if err := syncDirectory(cleanDirectory); err != nil { + return nil, fmt.Errorf("cannot sync Bitcoin broadcast outbox directory: [%w]", err) + } + + lockFile, err := acquireBitcoinBroadcastOutboxLock(cleanDirectory) + if err != nil { + return nil, err + } + outbox := &bitcoinBroadcastOutbox{ + directory: cleanDirectory, + btcChain: btcChain, + authorizationStatusSource: authorizationStatusSource, + activationProfileHash: activationProfileHash, + replayInterval: defaultBitcoinBroadcastOutboxReplayInterval, + archiveConfirmations: defaultBitcoinBroadcastArchiveConfirmations, + deepReconcileBatch: defaultBitcoinBroadcastDeepReconcileBatch, + now: time.Now, + records: make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord), + lockFile: lockFile, + } + if err := outbox.load(); err != nil { + _ = outbox.close() + return nil, err + } + + return outbox, nil +} + +func acquireBitcoinBroadcastOutboxLock(directory string) (*os.File, error) { + path := filepath.Join(directory, bitcoinBroadcastOutboxLockFile) + file, err := openSecureBitcoinBroadcastFile( + path, + unix.O_CREAT|unix.O_RDWR, + 0600, + ) + if err != nil { + return nil, fmt.Errorf("cannot open Bitcoin broadcast outbox lock: [%w]", err) + } + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + _ = file.Close() + return nil, fmt.Errorf("Bitcoin broadcast outbox is already owned by another process") + } + if err := file.Truncate(0); err != nil { + _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) + _ = file.Close() + return nil, fmt.Errorf("cannot initialize Bitcoin outbox lock: [%w]", err) + } + if _, err := file.WriteAt( + []byte(strconv.Itoa(os.Getpid())+"\n"), + 0, + ); err != nil { + _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) + _ = file.Close() + return nil, fmt.Errorf("cannot write Bitcoin outbox lock: [%w]", err) + } + if err := file.Sync(); err != nil { + _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) + _ = file.Close() + return nil, fmt.Errorf("cannot sync Bitcoin outbox lock: [%w]", err) + } + if err := syncDirectory(directory); err != nil { + _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) + _ = file.Close() + return nil, fmt.Errorf("cannot sync Bitcoin outbox lock directory: [%w]", err) + } + + return file, nil +} + +func validateSecureBitcoinBroadcastDirectory(directory string) error { + info, err := os.Lstat(directory) + if err != nil { + return fmt.Errorf("cannot inspect Bitcoin broadcast outbox directory: [%w]", err) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("Bitcoin broadcast outbox path is not a real directory") + } + if info.Mode().Perm() != 0700 { + return fmt.Errorf( + "Bitcoin broadcast outbox directory permissions [%o] are not 0700", + info.Mode().Perm(), + ) + } + if err := validateBitcoinBroadcastOwner(info); err != nil { + return err + } + return nil +} + +func validateBitcoinBroadcastOwner(info os.FileInfo) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("cannot determine Bitcoin broadcast storage owner") + } + if stat.Uid != uint32(os.Geteuid()) { + return fmt.Errorf( + "Bitcoin broadcast storage is owned by uid [%d], expected [%d]", + stat.Uid, + os.Geteuid(), + ) + } + return nil +} + +func openSecureBitcoinBroadcastFile( + path string, + flags int, + mode uint32, +) (*os.File, error) { + fd, err := unix.Open( + path, + flags|unix.O_CLOEXEC|unix.O_NOFOLLOW, + mode, + ) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), path) + if file == nil { + _ = unix.Close(fd) + return nil, fmt.Errorf("cannot wrap Bitcoin broadcast storage descriptor") + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + _ = file.Close() + return nil, fmt.Errorf("Bitcoin broadcast storage file is not regular") + } + if info.Mode().Perm() != os.FileMode(mode).Perm() { + _ = file.Close() + return nil, fmt.Errorf( + "Bitcoin broadcast storage file permissions [%o] are not [%o]", + info.Mode().Perm(), + os.FileMode(mode).Perm(), + ) + } + if err := validateBitcoinBroadcastOwner(info); err != nil { + _ = file.Close() + return nil, err + } + return file, nil +} + +func (bbo *bitcoinBroadcastOutbox) close() error { + bbo.mutex.Lock() + defer bbo.mutex.Unlock() + if bbo.closed { + return nil + } + bbo.closed = true + if bbo.lockFile == nil { + return nil + } + unlockErr := unix.Flock(int(bbo.lockFile.Fd()), unix.LOCK_UN) + closeErr := bbo.lockFile.Close() + bbo.lockFile = nil + if unlockErr != nil { + return unlockErr + } + return closeErr +} + +// enqueue durably records tx before its signature may be returned. Repeating +// the exact operation is idempotent. Both directions of reservation binding +// are enforced: one input set cannot move between reservations, and one +// reservation cannot acquire a different ordered input or semantic plan. +func (bbo *bitcoinBroadcastOutbox) enqueue( + tx *bitcoin.Transaction, + walletPublicKeyHash [20]byte, + walletID [32]byte, + action FrostPreSignAction, + unsignedTransactionHash bitcoin.Hash, + authorization bitcoinBroadcastAuthorization, +) error { + if tx == nil { + return fmt.Errorf("cannot enqueue nil Bitcoin transaction") + } + if walletPublicKeyHash == [20]byte{} || walletID == [32]byte{} { + return fmt.Errorf("cannot enqueue transaction without wallet alias and ID") + } + if action < FrostPreSignActionDepositSweep || action > FrostPreSignActionMovedFundsSweep { + return fmt.Errorf("cannot enqueue transaction with invalid action [%d]", action) + } + if err := validateBitcoinBroadcastAuthorization(authorization); err != nil { + return err + } + if authorization.ActivationProfileHash != bbo.activationProfileHash { + return fmt.Errorf("Bitcoin broadcast authorization activation profile mismatch") + } + + rawTransaction := tx.Serialize(bitcoin.Witness) + if len(rawTransaction) == 0 { + return fmt.Errorf("cannot serialize signed Bitcoin transaction") + } + transactionHash := tx.Hash() + if transactionHash != unsignedTransactionHash { + return fmt.Errorf("signed transaction txid differs from authorized unsigned transaction") + } + orderedOutpoints, inputSetHash, err := bitcoinTransactionOutpoints(tx) + if err != nil { + return err + } + now := bbo.now().Unix() + record := &bitcoinBroadcastOutboxRecord{ + Version: bitcoinBroadcastOutboxRecordVersion, + TransactionHash: transactionHash, + WitnessTransactionHash: tx.WitnessHash(), + UnsignedTransactionHash: unsignedTransactionHash, + RawTransaction: append([]byte{}, rawTransaction...), + WalletPublicKeyHash: walletPublicKeyHash, + WalletID: walletID, + Action: action, + OrderedOutpoints: orderedOutpoints, + InputSetHash: inputSetHash, + Authorization: authorization, + CreatedAtUnix: now, + UpdatedAtUnix: now, + } + if err := validateBitcoinBroadcastOutboxRecord(record); err != nil { + return err + } + + bbo.mutex.Lock() + defer bbo.mutex.Unlock() + if bbo.closed { + return fmt.Errorf("Bitcoin broadcast outbox is closed") + } + if existing, ok := bbo.records[transactionHash]; ok { + if sameBitcoinBroadcastOperation(existing, record) { + return nil + } + return fmt.Errorf( + "Bitcoin transaction [%x] is already bound to another durable outbox record", + transactionHash, + ) + } + if err := validateBitcoinBroadcastRecordBindings(record, bbo.records); err != nil { + return err + } + if err := validateNewBitcoinBroadcastVariantSequence(record, bbo.records); err != nil { + return err + } + if err := bbo.commitRecord(record); err != nil { + return fmt.Errorf("cannot persist signed Bitcoin transaction: [%w]", err) + } + bbo.records[transactionHash] = cloneBitcoinBroadcastOutboxRecord(record) + return nil +} + +func validateBitcoinBroadcastAuthorization( + authorization bitcoinBroadcastAuthorization, +) error { + for name, value := range map[string][32]byte{ + "activation profile hash": authorization.ActivationProfileHash, + "authorization ID": authorization.AuthorizationID, + "reservation ID": authorization.ReservationID, + "authorization root": authorization.AuthorizationRoot, + "snapshot hash": authorization.SnapshotHash, + "resource hash": authorization.ResourceHash, + "ordered input root": authorization.OrderedInputRoot, + "locked plan hash": authorization.LockedPlanHash, + "variant apply plan hash": authorization.VariantApplyPlanHash, + "finalized block hash": authorization.FinalizedBlockHash, + } { + if value == [32]byte{} { + return fmt.Errorf("cannot enqueue transaction without %s", name) + } + } + if authorization.FinalizedBlock == 0 || + authorization.VariantSequence.AuthorizationSequence == [32]byte{} { + return fmt.Errorf("cannot enqueue transaction without finalized variant ordering") + } + return nil +} + +func (bbo *bitcoinBroadcastOutbox) frostBitcoinBroadcastAuthorizationStatusRequest( + record *bitcoinBroadcastOutboxRecord, +) *FrostBitcoinBroadcastAuthorizationStatusRequest { + outpoints := make( + []FrostBitcoinBroadcastOutpoint, + len(record.OrderedOutpoints), + ) + for i, outpoint := range record.OrderedOutpoints { + outpoints[i] = FrostBitcoinBroadcastOutpoint{ + TransactionHash: outpoint.TransactionHash, + OutputIndex: outpoint.OutputIndex, + } + } + authorization := record.Authorization + return &FrostBitcoinBroadcastAuthorizationStatusRequest{ + ActivationProfileHash: authorization.ActivationProfileHash, + ActiveActivationProfileHash: bbo.activationProfileHash, + TransactionHash: record.TransactionHash, + WalletPublicKeyHash: record.WalletPublicKeyHash, + WalletID: record.WalletID, + Action: record.Action, + OrderedOutpoints: outpoints, + AuthorizationID: authorization.AuthorizationID, + ReservationID: authorization.ReservationID, + AuthorizationRoot: authorization.AuthorizationRoot, + SnapshotHash: authorization.SnapshotHash, + ResourceHash: authorization.ResourceHash, + OrderedInputRoot: authorization.OrderedInputRoot, + LockedPlanHash: authorization.LockedPlanHash, + VariantApplyPlanHash: authorization.VariantApplyPlanHash, + FeeLimitSnapshot: authorization.FeeLimitSnapshot, + FinalizedBlock: authorization.FinalizedBlock, + FinalizedBlockHash: authorization.FinalizedBlockHash, + FinalizedTransactionIndex: authorization.FinalizedTransactionIndex, + FinalizedLogIndex: authorization.FinalizedLogIndex, + VariantSequence: authorization.VariantSequence, + } +} + +func (bbo *bitcoinBroadcastOutbox) canonicalAuthorizationStatus( + ctx context.Context, + record *bitcoinBroadcastOutboxRecord, +) (*FrostBitcoinBroadcastAuthorizationStatus, error) { + request := bbo.frostBitcoinBroadcastAuthorizationStatusRequest(record) + status, err := bbo.authorizationStatusSource. + GetCanonicalFrostBitcoinBroadcastAuthorizationStatus(ctx, request) + if err != nil { + return nil, fmt.Errorf( + "cannot revalidate canonical Bitcoin broadcast authorization: [%w]", + err, + ) + } + if status == nil || + status.RequestHash != request.ComputeHash() || + !status.Canonical { + return nil, fmt.Errorf("canonical Bitcoin broadcast authorization identity is invalid") + } + return status, nil +} + +func (bbo *bitcoinBroadcastOutbox) start(ctx context.Context) error { + if ctx == nil { + return fmt.Errorf("Bitcoin broadcast outbox context is nil") + } + if err := bbo.replayOnceWithContext(ctx); err != nil { + return err + } + bbo.mutex.Lock() + if bbo.closed { + bbo.mutex.Unlock() + return fmt.Errorf("Bitcoin broadcast outbox is closed") + } + bbo.recovered = true + bbo.mutex.Unlock() + + go func() { + ticker := time.NewTicker(bbo.replayInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := bbo.replayOnceWithContext(ctx); err != nil { + logger.Errorf("Bitcoin broadcast outbox replay failed: [%v]", err) + } + } + } + }() + + return nil +} + +type bitcoinBroadcastOutboxActivationSnapshot struct { + Recovered bool + PendingReservationCount uint64 + AmbiguousReservationCount uint64 + QuarantineCount uint64 +} + +// activationSnapshot captures all activation-relevant outbox state under one +// mutex acquisition. It deliberately groups records by reservation so an RBF +// history counts as one pending operation, while multiple confirmed variants +// are surfaced as an ambiguity that blocks activation. +func (bbo *bitcoinBroadcastOutbox) activationSnapshot() ( + bitcoinBroadcastOutboxActivationSnapshot, + error, +) { + bbo.mutex.Lock() + defer bbo.mutex.Unlock() + if bbo.closed { + return bitcoinBroadcastOutboxActivationSnapshot{}, + fmt.Errorf("Bitcoin broadcast outbox is closed") + } + type reservationState struct { + pending bool + confirmedVariant bitcoin.Hash + ambiguous bool + } + reservations := make(map[[32]byte]*reservationState) + quarantineCount := uint64(0) + for _, record := range bbo.records { + reservationID := record.Authorization.ReservationID + state := reservations[reservationID] + if state == nil { + state = &reservationState{} + reservations[reservationID] = state + } + if record.Quarantine != nil { + quarantineCount++ + } + if record.Confirmation == nil || !record.Confirmation.Canonical { + state.pending = true + continue + } + if state.confirmedVariant != (bitcoin.Hash{}) && + state.confirmedVariant != record.TransactionHash { + state.ambiguous = true + } + state.confirmedVariant = record.TransactionHash + } + snapshot := bitcoinBroadcastOutboxActivationSnapshot{ + Recovered: bbo.recovered, + QuarantineCount: quarantineCount, + } + for _, state := range reservations { + if state.pending && state.confirmedVariant == (bitcoin.Hash{}) { + snapshot.PendingReservationCount++ + } + if state.ambiguous { + snapshot.AmbiguousReservationCount++ + } + } + return snapshot, nil +} + +// replayOnce is the test/internal synchronous entry point. +func (bbo *bitcoinBroadcastOutbox) replayOnce() error { + return bbo.replayOnceWithContext(context.Background()) +} + +type bitcoinBroadcastReplayCandidate struct { + reservationID [32]byte + record *bitcoinBroadcastOutboxRecord +} + +// replayOnceWithContext checks only the latest/confirmed record of each active +// reservation. Deeply confirmed history is reconciled in a fixed-size rotating +// batch, so canonical RPC work is bounded independently of archived history. +func (bbo *bitcoinBroadcastOutbox) replayOnceWithContext(ctx context.Context) error { + bbo.replayMutex.Lock() + defer bbo.replayMutex.Unlock() + + active, archived, err := bbo.replayCandidates() + if err != nil { + return err + } + for _, candidate := range append(active, archived...) { + refreshed, err := bbo.refreshConfirmation(ctx, candidate.record) + if err != nil { + return err + } + if refreshed.Confirmation != nil || refreshed.Quarantine != nil { + continue + } + latest, err := bbo.latestBroadcastRecord(candidate.reservationID) + if err != nil { + return err + } + broadcastErr, broadcastRecord, err := bbo.broadcastAuthorizedRecord(ctx, latest) + if err != nil { + if errors.Is(err, errBitcoinBroadcastQuarantined) { + continue + } + return err + } + if broadcastErr != nil { + continue + } + if _, err := bbo.refreshConfirmation(ctx, broadcastRecord); err != nil { + return err + } + } + return nil +} + +func (bbo *bitcoinBroadcastOutbox) replayCandidates() ( + []bitcoinBroadcastReplayCandidate, + []bitcoinBroadcastReplayCandidate, + error, +) { + bbo.mutex.Lock() + defer bbo.mutex.Unlock() + if bbo.closed { + return nil, nil, fmt.Errorf("Bitcoin broadcast outbox is closed") + } + type reservationState struct { + latest *bitcoinBroadcastOutboxRecord + confirmed *bitcoinBroadcastOutboxRecord + } + states := make(map[[32]byte]*reservationState) + for _, record := range bbo.records { + reservationID := record.Authorization.ReservationID + state := states[reservationID] + if state == nil { + state = &reservationState{} + states[reservationID] = state + } + if state.latest == nil || laterBitcoinBroadcastVariant(record, state.latest) { + state.latest = record + } + if record.Confirmation != nil { + if state.confirmed != nil && + state.confirmed.TransactionHash != record.TransactionHash { + return nil, nil, fmt.Errorf( + "Bitcoin reservation [%x] has multiple confirmed variants", + reservationID, + ) + } + state.confirmed = record + } + } + active := make([]bitcoinBroadcastReplayCandidate, 0, len(states)) + archived := make([]bitcoinBroadcastReplayCandidate, 0, len(states)) + for reservationID, state := range states { + record := state.latest + if state.confirmed != nil { + record = state.confirmed + } + candidate := bitcoinBroadcastReplayCandidate{ + reservationID: reservationID, + record: cloneBitcoinBroadcastOutboxRecord(record), + } + if record.Confirmation != nil && + record.Confirmation.Canonical && + record.Confirmation.Confirmations >= bbo.archiveConfirmations { + archived = append(archived, candidate) + } else { + active = append(active, candidate) + } + } + sortCandidates := func(candidates []bitcoinBroadcastReplayCandidate) { + sort.Slice(candidates, func(i, j int) bool { + return bytes.Compare( + candidates[i].reservationID[:], + candidates[j].reservationID[:], + ) < 0 + }) + } + sortCandidates(active) + sortCandidates(archived) + if len(archived) == 0 || bbo.deepReconcileBatch <= 0 { + return active, nil, nil + } + batchSize := bbo.deepReconcileBatch + if batchSize > len(archived) { + batchSize = len(archived) + } + start := bbo.deepReconcileCursor % len(archived) + batch := make([]bitcoinBroadcastReplayCandidate, 0, batchSize) + for i := 0; i < batchSize; i++ { + batch = append(batch, archived[(start+i)%len(archived)]) + } + bbo.deepReconcileCursor = (start + batchSize) % len(archived) + return active, batch, nil +} + +// refreshConfirmation performs external reads against a clone, then commits +// and swaps only after the new envelope is durable. A Bitcoin RPC failure +// preserves confirmation evidence but may still durably record a canonical +// authorization quarantine; any persistence failure leaves shared state +// byte-for-byte unchanged. +func (bbo *bitcoinBroadcastOutbox) refreshConfirmation( + ctx context.Context, + record *bitcoinBroadcastOutboxRecord, +) (*bitcoinBroadcastOutboxRecord, error) { + authorizationStatus, err := bbo.canonicalAuthorizationStatus(ctx, record) + if err != nil { + return nil, err + } + next, authorizationChanged := bbo.recordWithAuthorizationStatus( + record, + authorizationStatus, + ) + + status, err := bbo.btcChain.GetCanonicalTransactionStatus(record.TransactionHash) + if err != nil { + if !authorizationChanged { + return cloneBitcoinBroadcastOutboxRecord(record), nil + } + if err := bbo.persistAndSwapRecord(record, next); err != nil { + return nil, fmt.Errorf( + "cannot persist Bitcoin broadcast quarantine observation: [%w]", + err, + ) + } + return cloneBitcoinBroadcastOutboxRecord(next), nil + } + if status == nil { + return nil, fmt.Errorf("canonical Bitcoin transaction status is nil") + } + confirmationChanged := false + if !status.Found || status.Confirmations == 0 { + if next.Confirmation != nil { + next.Confirmation = nil + confirmationChanged = true + } + } else { + if status.BlockHeight == 0 || status.BlockHash == (bitcoin.Hash{}) { + return nil, fmt.Errorf("canonical Bitcoin confirmation lacks block identity") + } + confirmation := &bitcoinBroadcastConfirmation{ + Confirmations: status.Confirmations, + BlockHeight: status.BlockHeight, + BlockHash: status.BlockHash, + Canonical: true, + ObservedAtUnix: bbo.now().Unix(), + } + if !sameBitcoinBroadcastConfirmation(next.Confirmation, confirmation) { + next.Confirmation = confirmation + confirmationChanged = true + } + } + if !authorizationChanged && !confirmationChanged { + return cloneBitcoinBroadcastOutboxRecord(record), nil + } + next.UpdatedAtUnix = maxBitcoinBroadcastTimestamp( + next.UpdatedAtUnix, + bbo.now().Unix(), + ) + if next.Confirmation != nil && confirmationChanged { + next.Confirmation.ObservedAtUnix = next.UpdatedAtUnix + } + if err := bbo.persistAndSwapRecord(record, next); err != nil { + return nil, fmt.Errorf("cannot persist Bitcoin reconciliation observation: [%w]", err) + } + return cloneBitcoinBroadcastOutboxRecord(next), nil +} + +func (bbo *bitcoinBroadcastOutbox) recordWithAuthorizationStatus( + record *bitcoinBroadcastOutboxRecord, + status *FrostBitcoinBroadcastAuthorizationStatus, +) (*bitcoinBroadcastOutboxRecord, bool) { + next := cloneBitcoinBroadcastOutboxRecord(record) + if status.BroadcastAllowed { + if next.Quarantine == nil { + return next, false + } + next.Quarantine = nil + next.UpdatedAtUnix = maxBitcoinBroadcastTimestamp( + next.UpdatedAtUnix, + bbo.now().Unix(), + ) + return next, true + } + if next.Quarantine != nil && + next.Quarantine.ActiveActivationProfileHash == bbo.activationProfileHash { + return next, false + } + observedAt := maxBitcoinBroadcastTimestamp( + next.UpdatedAtUnix, + bbo.now().Unix(), + ) + next.Quarantine = &bitcoinBroadcastQuarantine{ + ActiveActivationProfileHash: bbo.activationProfileHash, + ObservedAtUnix: observedAt, + } + next.UpdatedAtUnix = observedAt + return next, true +} + +func maxBitcoinBroadcastTimestamp(left int64, right int64) int64 { + if left > right { + return left + } + return right +} + +func (bbo *bitcoinBroadcastOutbox) latestBroadcastRecord( + reservationID [32]byte, +) (*bitcoinBroadcastOutboxRecord, error) { + bbo.mutex.Lock() + defer bbo.mutex.Unlock() + if bbo.closed { + return nil, fmt.Errorf("Bitcoin broadcast outbox is closed") + } + var latest *bitcoinBroadcastOutboxRecord + for _, record := range bbo.records { + if record.Authorization.ReservationID != reservationID { + continue + } + if record.Confirmation != nil { + return nil, fmt.Errorf( + "Bitcoin reservation [%x] already has a confirmed variant", + reservationID, + ) + } + if latest == nil || laterBitcoinBroadcastVariant(record, latest) { + latest = record + } + } + if latest == nil { + return nil, fmt.Errorf("Bitcoin reservation [%x] is absent", reservationID) + } + return cloneBitcoinBroadcastOutboxRecord(latest), nil +} + +func (bbo *bitcoinBroadcastOutbox) broadcastAuthorizedRecord( + ctx context.Context, + record *bitcoinBroadcastOutboxRecord, +) (error, *bitcoinBroadcastOutboxRecord, error) { + authorizationStatus, err := bbo.canonicalAuthorizationStatus(ctx, record) + if err != nil { + return nil, nil, err + } + next, authorizationChanged := bbo.recordWithAuthorizationStatus( + record, + authorizationStatus, + ) + if !authorizationStatus.BroadcastAllowed { + if authorizationChanged { + if err := bbo.persistAndSwapRecord(record, next); err != nil { + return nil, nil, fmt.Errorf( + "cannot persist Bitcoin broadcast quarantine observation: [%w]", + err, + ) + } + } + return nil, cloneBitcoinBroadcastOutboxRecord(next), + errBitcoinBroadcastQuarantined + } + tx := &bitcoin.Transaction{} + if err := tx.Deserialize(record.RawTransaction); err != nil { + return nil, nil, fmt.Errorf( + "cannot deserialize durable Bitcoin transaction [%x]: [%w]", + record.TransactionHash, + err, + ) + } + broadcastErr := bbo.btcChain.BroadcastTransaction(tx) + now := bbo.now().Unix() + if next.FirstBroadcastAtUnix == 0 { + next.FirstBroadcastAtUnix = now + } + next.BroadcastAttempts++ + next.LastAttemptUnix = now + next.UpdatedAtUnix = maxBitcoinBroadcastTimestamp(next.UpdatedAtUnix, now) + if err := bbo.persistAndSwapRecord(record, next); err != nil { + return broadcastErr, nil, fmt.Errorf("cannot persist Bitcoin broadcast attempt: [%w]", err) + } + return broadcastErr, cloneBitcoinBroadcastOutboxRecord(next), nil +} + +func (bbo *bitcoinBroadcastOutbox) broadcastTransaction( + ctx context.Context, + transactionHash bitcoin.Hash, +) error { + bbo.replayMutex.Lock() + defer bbo.replayMutex.Unlock() + + bbo.mutex.Lock() + record := bbo.records[transactionHash] + if record != nil { + record = cloneBitcoinBroadcastOutboxRecord(record) + } + bbo.mutex.Unlock() + if record == nil { + return fmt.Errorf( + "Bitcoin transaction [%x] is not in the durable authorized outbox", + transactionHash, + ) + } + latest, err := bbo.latestBroadcastRecord(record.Authorization.ReservationID) + if err != nil { + return err + } + if latest.TransactionHash != transactionHash { + return fmt.Errorf( + "Bitcoin transaction [%x] is not the latest authorized reservation variant", + transactionHash, + ) + } + broadcastErr, _, err := bbo.broadcastAuthorizedRecord(ctx, latest) + if err != nil { + return err + } + return broadcastErr +} + +func (bbo *bitcoinBroadcastOutbox) persistAndSwapRecord( + expected *bitcoinBroadcastOutboxRecord, + next *bitcoinBroadcastOutboxRecord, +) error { + bbo.mutex.Lock() + defer bbo.mutex.Unlock() + if bbo.closed { + return fmt.Errorf("Bitcoin broadcast outbox is closed") + } + current := bbo.records[expected.TransactionHash] + if current == nil || !reflect.DeepEqual(current, expected) { + return fmt.Errorf("Bitcoin broadcast outbox record changed concurrently") + } + if err := validateBitcoinBroadcastRecordTransition(current, next); err != nil { + return err + } + if err := bbo.commitRecord(next); err != nil { + return err + } + bbo.records[next.TransactionHash] = cloneBitcoinBroadcastOutboxRecord(next) + return nil +} + +func sameBitcoinBroadcastConfirmation( + left *bitcoinBroadcastConfirmation, + right *bitcoinBroadcastConfirmation, +) bool { + if left == nil || right == nil { + return left == right + } + return left.Confirmations == right.Confirmations && + left.BlockHeight == right.BlockHeight && + left.BlockHash == right.BlockHash && + left.Canonical == right.Canonical +} + +func laterBitcoinBroadcastVariant( + left *bitcoinBroadcastOutboxRecord, + right *bitcoinBroadcastOutboxRecord, +) bool { + comparison := compareFrostPreSignVariantSequence( + left.Authorization.VariantSequence, + right.Authorization.VariantSequence, + ) + if comparison != 0 { + return comparison > 0 + } + return bytes.Compare(left.TransactionHash[:], right.TransactionHash[:]) > 0 +} + +func compareFrostPreSignVariantSequence( + left FrostPreSignVariantSequence, + right FrostPreSignVariantSequence, +) int { + return bytes.Compare( + left.AuthorizationSequence[:], + right.AuthorizationSequence[:], + ) +} + +func (bbo *bitcoinBroadcastOutbox) load() error { + entries, err := os.ReadDir(bbo.directory) + if err != nil { + return fmt.Errorf("cannot read Bitcoin broadcast outbox: [%w]", err) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + + finalEntries := make([]os.DirEntry, 0) + temporaryEntries := make([]os.DirEntry, 0) + for _, entry := range entries { + if entry.Name() == bitcoinBroadcastOutboxLockFile { + continue + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf( + "symbolic link in Bitcoin broadcast outbox: [%s]", + entry.Name(), + ) + } + if entry.IsDir() { + return fmt.Errorf("unexpected directory in Bitcoin broadcast outbox: [%s]", entry.Name()) + } + switch { + case strings.HasSuffix(entry.Name(), bitcoinBroadcastOutboxTempSuffix): + temporaryEntries = append(temporaryEntries, entry) + case strings.HasSuffix(entry.Name(), bitcoinBroadcastOutboxFileSuffix): + finalEntries = append(finalEntries, entry) + default: + return fmt.Errorf("unexpected file in Bitcoin broadcast outbox: [%s]", entry.Name()) + } + } + + for _, entry := range finalEntries { + record, err := bbo.readRecord(entry.Name()) + if err != nil { + return err + } + if entry.Name() != bitcoinBroadcastOutboxRecordFileName(record.TransactionHash) { + return fmt.Errorf("Bitcoin outbox record filename/hash mismatch: [%s]", entry.Name()) + } + if err := bbo.addLoadedRecord(record); err != nil { + return err + } + } + + type interruptedRecord struct { + name string + record *bitcoinBroadcastOutboxRecord + } + interrupted := make([]interruptedRecord, 0, len(temporaryEntries)) + seenTemporaryHashes := make(map[bitcoin.Hash]struct{}) + prospective := make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord, len(bbo.records)) + for hash, record := range bbo.records { + prospective[hash] = record + } + for _, entry := range temporaryEntries { + record, err := bbo.readRecord(entry.Name()) + if err != nil { + return fmt.Errorf( + "interrupted Bitcoin outbox temp [%s] is partial or corrupt; refusing startup: [%w]", + entry.Name(), + err, + ) + } + finalName := bitcoinBroadcastOutboxRecordFileName(record.TransactionHash) + if !strings.HasPrefix(entry.Name(), finalName+"-") { + return fmt.Errorf("Bitcoin outbox temp filename/hash mismatch: [%s]", entry.Name()) + } + if _, exists := seenTemporaryHashes[record.TransactionHash]; exists { + return fmt.Errorf( + "ambiguous Bitcoin outbox state: multiple temporary records exist for [%x]", + record.TransactionHash, + ) + } + seenTemporaryHashes[record.TransactionHash] = struct{}{} + if current := prospective[record.TransactionHash]; current != nil { + if err := validateBitcoinBroadcastRecordTransition(current, record); err != nil { + return fmt.Errorf( + "interrupted Bitcoin outbox update [%s] is invalid: [%w]", + entry.Name(), + err, + ) + } + } else { + if err := validateBitcoinBroadcastRecordBindings(record, prospective); err != nil { + return err + } + if err := validateNewBitcoinBroadcastVariantSequence(record, prospective); err != nil { + return err + } + } + prospective[record.TransactionHash] = record + interrupted = append(interrupted, interruptedRecord{entry.Name(), record}) + } + + promoted := false + for _, item := range interrupted { + finalName := bitcoinBroadcastOutboxRecordFileName(item.record.TransactionHash) + if err := os.Rename( + filepath.Join(bbo.directory, item.name), + filepath.Join(bbo.directory, finalName), + ); err != nil { + return fmt.Errorf("cannot promote durable Bitcoin outbox temp: [%w]", err) + } + promoted = true + bbo.records[item.record.TransactionHash] = item.record + } + if promoted { + if err := syncDirectory(bbo.directory); err != nil { + return fmt.Errorf("cannot sync promoted Bitcoin outbox records: [%w]", err) + } + } + + return nil +} + +func (bbo *bitcoinBroadcastOutbox) readRecord( + name string, +) (*bitcoinBroadcastOutboxRecord, error) { + file, err := openSecureBitcoinBroadcastFile( + filepath.Join(bbo.directory, name), + unix.O_RDONLY, + 0600, + ) + if err != nil { + return nil, fmt.Errorf("cannot open Bitcoin outbox record [%s]: [%w]", name, err) + } + data, err := io.ReadAll(file) + closeErr := file.Close() + if err != nil { + return nil, fmt.Errorf("cannot read Bitcoin outbox record [%s]: [%w]", name, err) + } + if closeErr != nil { + return nil, fmt.Errorf("cannot close Bitcoin outbox record [%s]: [%w]", name, closeErr) + } + record, err := decodeBitcoinBroadcastOutboxRecord(data) + if err != nil { + return nil, fmt.Errorf("corrupted Bitcoin outbox record [%s]: [%w]", name, err) + } + if err := validateBitcoinBroadcastOutboxRecord(record); err != nil { + return nil, fmt.Errorf("invalid Bitcoin outbox record [%s]: [%w]", name, err) + } + return record, nil +} + +func (bbo *bitcoinBroadcastOutbox) addLoadedRecord( + record *bitcoinBroadcastOutboxRecord, +) error { + if _, exists := bbo.records[record.TransactionHash]; exists { + return fmt.Errorf("duplicate Bitcoin outbox transaction [%x]", record.TransactionHash) + } + if err := validateBitcoinBroadcastRecordBindings(record, bbo.records); err != nil { + return err + } + bbo.records[record.TransactionHash] = record + return nil +} + +func validateBitcoinBroadcastRecordBindings( + record *bitcoinBroadcastOutboxRecord, + records map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord, +) error { + for _, existing := range records { + sameInputs := existing.InputSetHash == record.InputSetHash && + equalBitcoinBroadcastOutpoints(existing.OrderedOutpoints, record.OrderedOutpoints) + sameReservation := existing.Authorization.ReservationID == record.Authorization.ReservationID + if sameInputs && !sameReservation { + return fmt.Errorf( + "Bitcoin input set is already bound to reservation [%x]", + existing.Authorization.ReservationID, + ) + } + if sameReservation && !sameInputs { + return fmt.Errorf( + "Bitcoin reservation [%x] is bound to another ordered input set", + record.Authorization.ReservationID, + ) + } + if sameReservation && !sameBitcoinBroadcastReservationSemantics(existing, record) { + return fmt.Errorf( + "Bitcoin reservation [%x] has conflicting wallet/action/resource semantics", + record.Authorization.ReservationID, + ) + } + if sameReservation && + existing.Authorization.VariantSequence == record.Authorization.VariantSequence && + existing.TransactionHash != record.TransactionHash { + return fmt.Errorf( + "Bitcoin reservation [%x] has duplicate variant sequence", + record.Authorization.ReservationID, + ) + } + } + return nil +} + +func validateNewBitcoinBroadcastVariantSequence( + record *bitcoinBroadcastOutboxRecord, + records map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord, +) error { + for _, existing := range records { + if existing.Authorization.ReservationID != record.Authorization.ReservationID { + continue + } + if compareFrostPreSignVariantSequence( + record.Authorization.VariantSequence, + existing.Authorization.VariantSequence, + ) <= 0 { + return fmt.Errorf( + "Bitcoin reservation [%x] variant sequence is duplicate or retrograde", + record.Authorization.ReservationID, + ) + } + } + return nil +} + +func sameBitcoinBroadcastReservationSemantics( + left *bitcoinBroadcastOutboxRecord, + right *bitcoinBroadcastOutboxRecord, +) bool { + return left.WalletPublicKeyHash == right.WalletPublicKeyHash && + left.WalletID == right.WalletID && + left.Action == right.Action && + left.Authorization.SnapshotHash == right.Authorization.SnapshotHash && + left.Authorization.ResourceHash == right.Authorization.ResourceHash && + left.Authorization.OrderedInputRoot == right.Authorization.OrderedInputRoot && + left.Authorization.LockedPlanHash == right.Authorization.LockedPlanHash && + left.Authorization.FeeLimitSnapshot == right.Authorization.FeeLimitSnapshot +} + +func sameBitcoinBroadcastOperation( + left *bitcoinBroadcastOutboxRecord, + right *bitcoinBroadcastOutboxRecord, +) bool { + return left.Version == right.Version && + left.TransactionHash == right.TransactionHash && + left.WitnessTransactionHash == right.WitnessTransactionHash && + left.UnsignedTransactionHash == right.UnsignedTransactionHash && + bytes.Equal(left.RawTransaction, right.RawTransaction) && + left.WalletPublicKeyHash == right.WalletPublicKeyHash && + left.WalletID == right.WalletID && + left.Action == right.Action && + equalBitcoinBroadcastOutpoints(left.OrderedOutpoints, right.OrderedOutpoints) && + left.InputSetHash == right.InputSetHash && + left.Authorization == right.Authorization +} + +func validateBitcoinBroadcastRecordTransition( + current *bitcoinBroadcastOutboxRecord, + next *bitcoinBroadcastOutboxRecord, +) error { + if current == nil || next == nil { + return fmt.Errorf("Bitcoin broadcast record transition contains nil state") + } + if err := validateBitcoinBroadcastOutboxRecord(next); err != nil { + return err + } + if !sameBitcoinBroadcastOperation(current, next) || + current.CreatedAtUnix != next.CreatedAtUnix { + return fmt.Errorf("Bitcoin broadcast record transition changed immutable identity") + } + if next.UpdatedAtUnix < current.UpdatedAtUnix || + next.BroadcastAttempts < current.BroadcastAttempts || + next.BroadcastAttempts > current.BroadcastAttempts+1 { + return fmt.Errorf("Bitcoin broadcast record transition is retrograde or skips state") + } + if next.BroadcastAttempts == current.BroadcastAttempts { + if next.FirstBroadcastAtUnix != current.FirstBroadcastAtUnix || + next.LastAttemptUnix != current.LastAttemptUnix { + return fmt.Errorf("Bitcoin confirmation transition changed broadcast counters") + } + } else { + if !reflect.DeepEqual(next.Confirmation, current.Confirmation) || + next.LastAttemptUnix < current.LastAttemptUnix || + next.LastAttemptUnix <= 0 { + return fmt.Errorf("Bitcoin broadcast-attempt transition changed confirmation evidence") + } + if current.FirstBroadcastAtUnix == 0 { + if next.FirstBroadcastAtUnix <= 0 { + return fmt.Errorf("first Bitcoin broadcast attempt lacks timestamp") + } + } else if next.FirstBroadcastAtUnix != current.FirstBroadcastAtUnix { + return fmt.Errorf("Bitcoin broadcast transition changed first-attempt timestamp") + } + } + if current.Confirmation != nil && next.Confirmation != nil && + next.Confirmation.ObservedAtUnix < current.Confirmation.ObservedAtUnix { + return fmt.Errorf("Bitcoin confirmation observation is retrograde") + } + if current.Quarantine != nil && next.Quarantine != nil && + next.Quarantine.ObservedAtUnix < current.Quarantine.ObservedAtUnix { + return fmt.Errorf("Bitcoin quarantine observation is retrograde") + } + return nil +} + +func (bbo *bitcoinBroadcastOutbox) commitRecord( + record *bitcoinBroadcastOutboxRecord, +) error { + if bbo.persistFailureHook != nil { + if err := bbo.persistFailureHook(record); err != nil { + return err + } + } + return bbo.persistRecord(record) +} + +func (bbo *bitcoinBroadcastOutbox) persistRecord( + record *bitcoinBroadcastOutboxRecord, +) error { + data, err := encodeBitcoinBroadcastOutboxRecord(record) + if err != nil { + return err + } + finalName := bitcoinBroadcastOutboxRecordFileName(record.TransactionHash) + finalPath := filepath.Join(bbo.directory, finalName) + temporaryFile, err := os.CreateTemp( + bbo.directory, + finalName+"-*"+bitcoinBroadcastOutboxTempSuffix, + ) + if err != nil { + return err + } + temporaryPath := temporaryFile.Name() + removeTemporary := true + defer func() { + if removeTemporary { + _ = os.Remove(temporaryPath) + } + }() + + if err := temporaryFile.Chmod(0600); err != nil { + _ = temporaryFile.Close() + return err + } + if _, err := temporaryFile.Write(data); err != nil { + _ = temporaryFile.Close() + return err + } + if err := temporaryFile.Sync(); err != nil { + _ = temporaryFile.Close() + return err + } + if err := temporaryFile.Close(); err != nil { + return err + } + if info, err := os.Lstat(finalPath); err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("Bitcoin outbox final path is not a regular file") + } + if info.Mode().Perm() != 0600 { + return fmt.Errorf("Bitcoin outbox final file permissions are unsafe") + } + if err := validateBitcoinBroadcastOwner(info); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + if err := os.Rename(temporaryPath, finalPath); err != nil { + return err + } + removeTemporary = false + return syncDirectory(bbo.directory) +} + +func encodeBitcoinBroadcastOutboxRecord( + record *bitcoinBroadcastOutboxRecord, +) ([]byte, error) { + payload, err := json.Marshal(record) + if err != nil { + return nil, err + } + envelope := bitcoinBroadcastOutboxEnvelope{ + Payload: payload, + Checksum: sha256.Sum256(payload), + } + return json.Marshal(&envelope) +} + +func decodeBitcoinBroadcastOutboxRecord( + data []byte, +) (*bitcoinBroadcastOutboxRecord, error) { + var envelope bitcoinBroadcastOutboxEnvelope + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&envelope); err != nil { + return nil, err + } + if err := requireJSONEOF(decoder); err != nil { + return nil, fmt.Errorf("trailing outbox envelope data: [%w]", err) + } + if len(envelope.Payload) == 0 { + return nil, fmt.Errorf("record payload is empty") + } + if sha256.Sum256(envelope.Payload) != envelope.Checksum { + return nil, fmt.Errorf("record checksum mismatch") + } + + return decodeBitcoinBroadcastOutboxPayload(envelope.Payload) +} + +func decodeBitcoinBroadcastOutboxPayload( + payload []byte, +) (*bitcoinBroadcastOutboxRecord, error) { + var record bitcoinBroadcastOutboxRecord + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&record); err != nil { + return nil, err + } + if err := requireJSONEOF(decoder); err != nil { + return nil, fmt.Errorf("trailing outbox payload data: [%w]", err) + } + return &record, nil +} + +func requireJSONEOF(decoder *json.Decoder) error { + var trailing json.RawMessage + err := decoder.Decode(&trailing) + if err == io.EOF { + return nil + } + if err != nil { + return err + } + return fmt.Errorf("additional JSON value") +} + +func validateBitcoinBroadcastOutboxRecord( + record *bitcoinBroadcastOutboxRecord, +) error { + if record == nil { + return fmt.Errorf("record is nil") + } + if record.Version != bitcoinBroadcastOutboxRecordVersion { + return fmt.Errorf("unsupported record version [%d]", record.Version) + } + if err := validateBitcoinBroadcastAuthorization(record.Authorization); err != nil { + return err + } + if record.WalletPublicKeyHash == [20]byte{} || record.WalletID == [32]byte{} { + return fmt.Errorf("record wallet alias or ID is empty") + } + if record.Action < FrostPreSignActionDepositSweep || + record.Action > FrostPreSignActionMovedFundsSweep { + return fmt.Errorf("record action [%d] is invalid", record.Action) + } + if len(record.RawTransaction) == 0 { + return fmt.Errorf("raw transaction is empty") + } + if record.CreatedAtUnix <= 0 || record.UpdatedAtUnix < record.CreatedAtUnix { + return fmt.Errorf("record state timestamps are invalid") + } + if record.BroadcastAttempts == 0 { + if record.FirstBroadcastAtUnix != 0 || record.LastAttemptUnix != 0 { + return fmt.Errorf("record has broadcast timestamps without attempts") + } + } else if record.FirstBroadcastAtUnix <= 0 || + record.LastAttemptUnix < record.FirstBroadcastAtUnix { + return fmt.Errorf("record broadcast timestamps are invalid") + } + if record.Confirmation != nil { + if record.Confirmation.Confirmations == 0 || record.Confirmation.ObservedAtUnix <= 0 { + return fmt.Errorf("record confirmation evidence is invalid") + } + if record.Confirmation.Canonical && + (record.Confirmation.BlockHeight == 0 || + record.Confirmation.BlockHash == (bitcoin.Hash{})) { + return fmt.Errorf("canonical confirmation evidence lacks block identity") + } + } + if record.Quarantine != nil { + if record.Quarantine.ActiveActivationProfileHash == [32]byte{} || + record.Quarantine.ObservedAtUnix <= 0 || + record.Quarantine.ObservedAtUnix > record.UpdatedAtUnix { + return fmt.Errorf("record broadcast quarantine evidence is invalid") + } + } + + tx := &bitcoin.Transaction{} + if err := tx.Deserialize(record.RawTransaction); err != nil { + return fmt.Errorf("cannot deserialize raw transaction: [%w]", err) + } + if tx.Hash() != record.TransactionHash || + tx.Hash() != record.UnsignedTransactionHash { + return fmt.Errorf("raw transaction txid/unsigned hash mismatch") + } + if tx.WitnessHash() != record.WitnessTransactionHash { + return fmt.Errorf("raw transaction wtxid mismatch") + } + if !bytes.Equal(tx.Serialize(bitcoin.Witness), record.RawTransaction) { + return fmt.Errorf("raw transaction is not canonically encoded") + } + orderedOutpoints, inputSetHash, err := bitcoinTransactionOutpoints(tx) + if err != nil { + return err + } + if inputSetHash != record.InputSetHash || + !equalBitcoinBroadcastOutpoints(orderedOutpoints, record.OrderedOutpoints) { + return fmt.Errorf("transaction ordered outpoints mismatch") + } + + return nil +} + +func bitcoinTransactionOutpoints( + tx *bitcoin.Transaction, +) ([]bitcoinBroadcastOutpoint, [32]byte, error) { + if tx == nil || len(tx.Inputs) == 0 { + return nil, [32]byte{}, fmt.Errorf("Bitcoin transaction has no inputs") + } + outpoints := make([]bitcoinBroadcastOutpoint, len(tx.Inputs)) + hasher := sha256.New() + hasher.Write([]byte("tbtc-bitcoin-broadcast-outbox-ordered-inputs-v2")) + for i, input := range tx.Inputs { + if input == nil || input.Outpoint == nil { + return nil, [32]byte{}, fmt.Errorf("Bitcoin transaction input [%d] has no outpoint", i) + } + outpoints[i] = bitcoinBroadcastOutpoint{ + TransactionHash: input.Outpoint.TransactionHash, + OutputIndex: input.Outpoint.OutputIndex, + } + hasher.Write(input.Outpoint.TransactionHash[:]) + var index [4]byte + index[0] = byte(input.Outpoint.OutputIndex) + index[1] = byte(input.Outpoint.OutputIndex >> 8) + index[2] = byte(input.Outpoint.OutputIndex >> 16) + index[3] = byte(input.Outpoint.OutputIndex >> 24) + hasher.Write(index[:]) + } + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return outpoints, result, nil +} + +func equalBitcoinBroadcastOutpoints( + left []bitcoinBroadcastOutpoint, + right []bitcoinBroadcastOutpoint, +) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func bitcoinBroadcastOutboxRecordFileName(hash bitcoin.Hash) string { + return hex.EncodeToString(hash[:]) + bitcoinBroadcastOutboxFileSuffix +} + +func cloneBitcoinBroadcastOutboxRecord( + record *bitcoinBroadcastOutboxRecord, +) *bitcoinBroadcastOutboxRecord { + clone := *record + clone.RawTransaction = append([]byte{}, record.RawTransaction...) + clone.OrderedOutpoints = append([]bitcoinBroadcastOutpoint{}, record.OrderedOutpoints...) + if record.Confirmation != nil { + confirmation := *record.Confirmation + clone.Confirmation = &confirmation + } + if record.Quarantine != nil { + quarantine := *record.Quarantine + clone.Quarantine = &quarantine + } + return &clone +} + +func syncDirectory(directory string) error { + fd, err := unix.Open( + filepath.Clean(directory), + unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0, + ) + if err != nil { + return err + } + file := os.NewFile(uintptr(fd), directory) + if file == nil { + _ = unix.Close(fd) + return fmt.Errorf("cannot wrap Bitcoin outbox directory descriptor") + } + if err := file.Sync(); err != nil { + _ = file.Close() + return err + } + return file.Close() +} diff --git a/pkg/tbtc/bitcoin_broadcast_outbox_test.go b/pkg/tbtc/bitcoin_broadcast_outbox_test.go new file mode 100644 index 0000000000..76a57f42a5 --- /dev/null +++ b/pkg/tbtc/bitcoin_broadcast_outbox_test.go @@ -0,0 +1,1408 @@ +package tbtc + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/bitcoin" +) + +var ( + testOutboxWalletPublicKeyHash = [20]byte{0xa1} + testOutboxWalletID = [32]byte{0xb2} + testOutboxActivationProfile = [32]byte{0xc3} +) + +func TestBitcoinBroadcastOutbox_PersistsAndRestoresFullRecord(t *testing.T) { + directory := t.TempDir() + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, directory, chain) + + tx := testOutboxTransaction(1, 9000) + authorization := testBitcoinBroadcastAuthorization(1, 1, 17) + enqueueTestBitcoinTransaction(t, outbox, tx, authorization) + + record := outbox.records[tx.Hash()] + if record == nil { + t.Fatal("transaction record was not stored") + } + if record.TransactionHash != tx.Hash() || + record.WitnessTransactionHash != tx.WitnessHash() || + record.UnsignedTransactionHash != tx.Hash() { + t.Fatal("txid/wtxid/unsigned hash evidence differs") + } + if record.WalletPublicKeyHash != testOutboxWalletPublicKeyHash || + record.WalletID != testOutboxWalletID || + record.Action != FrostPreSignActionDepositSweep { + t.Fatal("wallet alias/ID/action evidence differs") + } + if len(record.OrderedOutpoints) != 1 || + record.OrderedOutpoints[0].TransactionHash != (bitcoin.Hash{1}) || + record.OrderedOutpoints[0].OutputIndex != 1 { + t.Fatal("ordered outpoint evidence differs") + } + if err := outbox.close(); err != nil { + t.Fatal(err) + } + + restored := openTestBitcoinBroadcastOutbox(t, directory, chain) + defer restored.close() + if len(restored.records) != 1 { + t.Fatalf("unexpected restored record count: [%d]", len(restored.records)) + } + record = restored.records[tx.Hash()] + if record == nil || record.Authorization != authorization { + t.Fatalf("unexpected restored authorization: [%+v]", record) + } + if string(record.RawTransaction) != string(tx.Serialize(bitcoin.Witness)) { + t.Fatal("restored raw transaction differs") + } +} + +func TestBitcoinBroadcastOutbox_StartupFailsOnCorruption(t *testing.T) { + directory := t.TempDir() + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, directory, chain) + tx := testOutboxTransaction(2, 8000) + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(2, 2, 1), + ) + if err := outbox.close(); err != nil { + t.Fatal(err) + } + + path := filepath.Join(directory, bitcoinBroadcastOutboxRecordFileName(tx.Hash())) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + data[len(data)/2] ^= 0x01 + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + if _, err := newTestBitcoinBroadcastOutbox(directory, chain); err == nil { + t.Fatal("expected corrupted outbox startup to fail") + } +} + +func TestBitcoinBroadcastOutbox_StrictJSONRejectsTrailingValues(t *testing.T) { + record := testBitcoinBroadcastOutboxRecord( + testOutboxTransaction(3, 8000), + testBitcoinBroadcastAuthorization(3, 3, 1), + ) + encoded, err := encodeBitcoinBroadcastOutboxRecord(record) + if err != nil { + t.Fatal(err) + } + if _, err := decodeBitcoinBroadcastOutboxRecord(append(encoded, []byte(` {}`)...)); err == nil { + t.Fatal("expected trailing envelope value to fail") + } + + payload, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if _, err := decodeBitcoinBroadcastOutboxPayload(append(payload, []byte(` {}`)...)); err == nil { + t.Fatal("expected trailing payload value to fail") + } +} + +func TestBitcoinBroadcastOutbox_CrashPointRecovery(t *testing.T) { + tests := map[string]struct { + writeInterrupted func(*testing.T, string, *bitcoinBroadcastOutboxRecord) + expectSuccess bool + }{ + "before write fails closed": { + writeInterrupted: func(t *testing.T, directory string, record *bitcoinBroadcastOutboxRecord) { + name := bitcoinBroadcastOutboxRecordFileName(record.TransactionHash) + "-empty.tmp" + if err := os.WriteFile(filepath.Join(directory, name), nil, 0600); err != nil { + t.Fatal(err) + } + }, + }, + "partial write fails closed": { + writeInterrupted: func(t *testing.T, directory string, record *bitcoinBroadcastOutboxRecord) { + name := bitcoinBroadcastOutboxRecordFileName(record.TransactionHash) + "-partial.tmp" + if err := os.WriteFile(filepath.Join(directory, name), []byte(`{"payload":`), 0600); err != nil { + t.Fatal(err) + } + }, + }, + "fsynced temp before rename is promoted": { + writeInterrupted: func(t *testing.T, directory string, record *bitcoinBroadcastOutboxRecord) { + data, err := encodeBitcoinBroadcastOutboxRecord(record) + if err != nil { + t.Fatal(err) + } + name := bitcoinBroadcastOutboxRecordFileName(record.TransactionHash) + "-complete.tmp" + file, err := os.OpenFile(filepath.Join(directory, name), os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(data); err != nil { + t.Fatal(err) + } + if err := file.Sync(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + }, + expectSuccess: true, + }, + "rename before directory fsync is retained": { + writeInterrupted: func(t *testing.T, directory string, record *bitcoinBroadcastOutboxRecord) { + data, err := encodeBitcoinBroadcastOutboxRecord(record) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(directory, bitcoinBroadcastOutboxRecordFileName(record.TransactionHash)) + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(data); err != nil { + t.Fatal(err) + } + if err := file.Sync(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + }, + expectSuccess: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + directory := t.TempDir() + chain := newOutboxTestBitcoinChain() + tx := testOutboxTransaction(3, 7000) + record := testBitcoinBroadcastOutboxRecord( + tx, + testBitcoinBroadcastAuthorization(3, 3, 1), + ) + test.writeInterrupted(t, directory, record) + + outbox, err := newTestBitcoinBroadcastOutbox(directory, chain) + if !test.expectSuccess { + if err == nil { + outbox.close() + t.Fatal("expected interrupted outbox startup to fail closed") + } + return + } + if err != nil { + t.Fatal(err) + } + defer outbox.close() + if outbox.records[tx.Hash()] == nil { + t.Fatal("complete interrupted record was not recovered") + } + finalPath := filepath.Join(directory, bitcoinBroadcastOutboxRecordFileName(tx.Hash())) + if _, err := os.Stat(finalPath); err != nil { + t.Fatalf("recovered final record is absent: [%v]", err) + } + }) + } +} + +func TestBitcoinBroadcastOutbox_ValidFinalAndTempUpdateRecovers(t *testing.T) { + directory := t.TempDir() + tx := testOutboxTransaction(4, 7000) + current := testBitcoinBroadcastOutboxRecord( + tx, + testBitcoinBroadcastAuthorization(4, 4, 1), + ) + data, err := encodeBitcoinBroadcastOutboxRecord(current) + if err != nil { + t.Fatal(err) + } + finalName := bitcoinBroadcastOutboxRecordFileName(tx.Hash()) + if err := os.WriteFile(filepath.Join(directory, finalName), data, 0600); err != nil { + t.Fatal(err) + } + next := cloneBitcoinBroadcastOutboxRecord(current) + next.BroadcastAttempts = 1 + next.FirstBroadcastAtUnix = current.UpdatedAtUnix + 1 + next.LastAttemptUnix = current.UpdatedAtUnix + 1 + next.UpdatedAtUnix = current.UpdatedAtUnix + 1 + data, err = encodeBitcoinBroadcastOutboxRecord(next) + if err != nil { + t.Fatal(err) + } + tempName := finalName + "-interrupted.tmp" + if err := os.WriteFile(filepath.Join(directory, tempName), data, 0600); err != nil { + t.Fatal(err) + } + outbox, err := newTestBitcoinBroadcastOutbox(directory, newOutboxTestBitcoinChain()) + if err != nil { + t.Fatal(err) + } + defer outbox.close() + if outbox.records[tx.Hash()].BroadcastAttempts != 1 { + t.Fatal("valid fsynced update temp was not promoted over the old final") + } + if _, err := os.Lstat(filepath.Join(directory, tempName)); !os.IsNotExist(err) { + t.Fatal("promoted update temp still exists") + } +} + +func TestBitcoinBroadcastOutbox_InvalidFinalAndTempUpdateFailsClosed(t *testing.T) { + directory := t.TempDir() + tx := testOutboxTransaction(4, 7000) + current := testBitcoinBroadcastOutboxRecord( + tx, + testBitcoinBroadcastAuthorization(4, 4, 1), + ) + data, err := encodeBitcoinBroadcastOutboxRecord(current) + if err != nil { + t.Fatal(err) + } + finalName := bitcoinBroadcastOutboxRecordFileName(tx.Hash()) + if err := os.WriteFile(filepath.Join(directory, finalName), data, 0600); err != nil { + t.Fatal(err) + } + invalid := cloneBitcoinBroadcastOutboxRecord(current) + invalid.Authorization.AuthorizationID = [32]byte{0xff} + data, err = encodeBitcoinBroadcastOutboxRecord(invalid) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, finalName+"-invalid.tmp"), data, 0600); err != nil { + t.Fatal(err) + } + if _, err := newTestBitcoinBroadcastOutbox(directory, newOutboxTestBitcoinChain()); err == nil { + t.Fatal("expected invalid final/temp transition to fail closed") + } +} + +func TestBitcoinBroadcastOutbox_ExclusiveOwnershipAndStaleLock(t *testing.T) { + directory := t.TempDir() + chain := newOutboxTestBitcoinChain() + first := openTestBitcoinBroadcastOutbox(t, directory, chain) + if _, err := newTestBitcoinBroadcastOutbox(directory, chain); err == nil { + t.Fatal("expected concurrent outbox open to fail") + } + if err := first.close(); err != nil { + t.Fatal(err) + } + + // The lock file remains after a clean close just as it does after process + // death. The kernel lock, not file deletion, determines ownership. + if _, err := os.Stat(filepath.Join(directory, bitcoinBroadcastOutboxLockFile)); err != nil { + t.Fatal("expected stale lock file to remain") + } + second := openTestBitcoinBroadcastOutbox(t, directory, chain) + defer second.close() +} + +func TestBitcoinBroadcastOutbox_StorageHardening(t *testing.T) { + newOutbox := func(directory string) (*bitcoinBroadcastOutbox, error) { + return newBitcoinBroadcastOutbox( + directory, + newOutboxTestBitcoinChain(), + newOutboxTestAuthorizationStatusSource(), + testOutboxActivationProfile, + ) + } + + t.Run("directory symlink", func(t *testing.T) { + parent := t.TempDir() + realDirectory := filepath.Join(parent, "real") + if err := os.Mkdir(realDirectory, 0700); err != nil { + t.Fatal(err) + } + link := filepath.Join(parent, "link") + if err := os.Symlink(realDirectory, link); err != nil { + t.Fatal(err) + } + if _, err := newOutbox(link); err == nil { + t.Fatal("expected symlink outbox directory to fail") + } + }) + + t.Run("unsafe directory permissions", func(t *testing.T) { + directory := t.TempDir() + if err := os.Chmod(directory, 0755); err != nil { + t.Fatal(err) + } + if _, err := newOutbox(directory); err == nil { + t.Fatal("expected group/world-readable outbox directory to fail") + } + }) + + t.Run("lock symlink", func(t *testing.T) { + directory := t.TempDir() + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "target") + if err := os.WriteFile(target, nil, 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink( + target, + filepath.Join(directory, bitcoinBroadcastOutboxLockFile), + ); err != nil { + t.Fatal(err) + } + if _, err := newOutbox(directory); err == nil { + t.Fatal("expected symlink lock file to fail") + } + }) + + t.Run("record symlink", func(t *testing.T) { + directory := t.TempDir() + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + tx := testOutboxTransaction(16, 7000) + target := filepath.Join(t.TempDir(), "record") + if err := os.WriteFile(target, []byte("not followed"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink( + target, + filepath.Join(directory, bitcoinBroadcastOutboxRecordFileName(tx.Hash())), + ); err != nil { + t.Fatal(err) + } + if _, err := newOutbox(directory); err == nil { + t.Fatal("expected symlink record file to fail") + } + }) + + t.Run("unsafe record permissions", func(t *testing.T) { + directory := t.TempDir() + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + tx := testOutboxTransaction(17, 7000) + record := testBitcoinBroadcastOutboxRecord( + tx, + testBitcoinBroadcastAuthorization(18, 17, 1), + ) + data, err := encodeBitcoinBroadcastOutboxRecord(record) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(directory, bitcoinBroadcastOutboxRecordFileName(tx.Hash())), + data, + 0644, + ); err != nil { + t.Fatal(err) + } + if _, err := newOutbox(directory); err == nil { + t.Fatal("expected unsafe record permissions to fail") + } + }) +} + +func TestBitcoinBroadcastOutbox_IdempotenceAndBidirectionalReservationBinding(t *testing.T) { + outbox := openTestBitcoinBroadcastOutbox(t, t.TempDir(), newOutboxTestBitcoinChain()) + defer outbox.close() + + first := testOutboxTransaction(5, 7000) + firstAuthorization := testBitcoinBroadcastAuthorization(5, 5, 1) + enqueueTestBitcoinTransaction(t, outbox, first, firstAuthorization) + enqueueTestBitcoinTransaction(t, outbox, first, firstAuthorization) + if len(outbox.records) != 1 { + t.Fatalf("unexpected record count after idempotent enqueue: [%d]", len(outbox.records)) + } + + // Same ordered input set and reservation is an independently authorized RBF + // variant when the immutable reservation semantics remain equal. + replacement := testOutboxTransaction(5, 6500) + enqueueTestBitcoinTransaction( + t, + outbox, + replacement, + testBitcoinBroadcastAuthorization(6, 5, 2), + ) + + conflictingReservation := testOutboxTransaction(5, 6000) + err := enqueueTestBitcoinTransactionError( + outbox, + conflictingReservation, + testBitcoinBroadcastAuthorization(7, 9, 3), + ) + if err == nil { + t.Fatal("expected the same input set under another reservation to fail") + } + + differentInputs := testOutboxTransaction(8, 6000) + err = enqueueTestBitcoinTransactionError( + outbox, + differentInputs, + testBitcoinBroadcastAuthorization(8, 5, 3), + ) + if err == nil { + t.Fatal("expected one reservation with another input set to fail") + } + + // The per-variant apply plan is expected to change for an RBF replacement. + changedVariantApply := testBitcoinBroadcastAuthorization(9, 5, 3) + changedVariantApply.VariantApplyPlanHash = [32]byte{0xff} + enqueueTestBitcoinTransaction( + t, + outbox, + testOutboxTransaction(5, 5500), + changedVariantApply, + ) + duplicateSequence := testBitcoinBroadcastAuthorization(10, 5, 3) + err = enqueueTestBitcoinTransactionError( + outbox, + testOutboxTransaction(5, 5250), + duplicateSequence, + ) + if err == nil { + t.Fatal("expected duplicate authorization event position to fail") + } + retrogradeSequence := testBitcoinBroadcastAuthorization(11, 5, 2) + err = enqueueTestBitcoinTransactionError( + outbox, + testOutboxTransaction(5, 5100), + retrogradeSequence, + ) + if err == nil { + t.Fatal("expected retrograde authorization event position to fail") + } + + conflictingPlan := testBitcoinBroadcastAuthorization(12, 5, 4) + conflictingPlan.LockedPlanHash = [32]byte{0xff} + err = enqueueTestBitcoinTransactionError( + outbox, + testOutboxTransaction(5, 5000), + conflictingPlan, + ) + if err == nil { + t.Fatal("expected one reservation with another semantic plan to fail") + } +} + +func TestBitcoinBroadcastOutbox_StartupRejectsReservationWithDifferentInputs(t *testing.T) { + directory := t.TempDir() + first := testBitcoinBroadcastOutboxRecord( + testOutboxTransaction(9, 7000), + testBitcoinBroadcastAuthorization(10, 9, 1), + ) + second := testBitcoinBroadcastOutboxRecord( + testOutboxTransaction(10, 6500), + testBitcoinBroadcastAuthorization(11, 9, 2), + ) + for _, record := range []*bitcoinBroadcastOutboxRecord{first, second} { + data, err := encodeBitcoinBroadcastOutboxRecord(record) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(directory, bitcoinBroadcastOutboxRecordFileName(record.TransactionHash)), + data, + 0600, + ); err != nil { + t.Fatal(err) + } + } + if _, err := newTestBitcoinBroadcastOutbox(directory, newOutboxTestBitcoinChain()); err == nil { + t.Fatal("expected conflicting persisted reservation inputs to fail startup") + } +} + +func TestBitcoinBroadcastOutbox_ReplaysLatestVariantAndRecoversFromReorg(t *testing.T) { + directory := t.TempDir() + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, directory, chain) + + oldVariant := testOutboxTransaction(11, 7000) + newVariant := testOutboxTransaction(11, 6000) + enqueueTestBitcoinTransaction( + t, + outbox, + oldVariant, + testBitcoinBroadcastAuthorization(12, 11, 1), + ) + enqueueTestBitcoinTransaction( + t, + outbox, + newVariant, + testBitcoinBroadcastAuthorization(13, 11, 2), + ) + + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if chain.broadcastCount(oldVariant.Hash()) != 0 || + chain.broadcastCount(newVariant.Hash()) != 1 { + t.Fatal("outbox did not choose the latest authorized RBF variant") + } + + chain.setCanonicalStatus(newVariant.Hash(), &bitcoin.CanonicalTransactionStatus{ + Found: true, + Confirmations: 2, + BlockHeight: 800000, + BlockHash: bitcoin.Hash{0xcc}, + }) + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if outbox.records[newVariant.Hash()].Confirmation == nil { + t.Fatal("canonical confirmation was not persisted") + } + confirmedBroadcasts := chain.broadcastCount(newVariant.Hash()) + + // An RPC error is not authenticated absence and must preserve evidence. + chain.setCanonicalError(newVariant.Hash(), errors.New("index unavailable")) + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if outbox.records[newVariant.Hash()].Confirmation == nil || + chain.broadcastCount(newVariant.Hash()) != confirmedBroadcasts { + t.Fatal("RPC error erased confirmation evidence or triggered rebroadcast") + } + + // Authenticated canonical absence models a reorg. The prior evidence is + // downgraded and the latest authorized variant resumes immediately. + chain.setCanonicalError(newVariant.Hash(), nil) + chain.setCanonicalStatus(newVariant.Hash(), &bitcoin.CanonicalTransactionStatus{Found: false}) + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if outbox.records[newVariant.Hash()].Confirmation != nil { + t.Fatal("reorg did not downgrade durable confirmation evidence") + } + if chain.broadcastCount(newVariant.Hash()) != confirmedBroadcasts+1 { + t.Fatal("reorged transaction was not rebroadcast") + } + + if err := outbox.close(); err != nil { + t.Fatal(err) + } + restarted := openTestBitcoinBroadcastOutbox(t, directory, chain) + defer restarted.close() + if err := restarted.replayOnce(); err != nil { + t.Fatal(err) + } + if chain.broadcastCount(newVariant.Hash()) != confirmedBroadcasts+2 { + t.Fatal("reorg recovery did not remain active after restart") + } +} + +func TestBitcoinBroadcastOutbox_PersistenceFailureDoesNotPublishConfirmationMutation( + t *testing.T, +) { + directory := t.TempDir() + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, directory, chain) + defer outbox.close() + tx := testOutboxTransaction(12, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(14, 12, 1), + ) + chain.setCanonicalStatus(tx.Hash(), &bitcoin.CanonicalTransactionStatus{ + Found: true, + Confirmations: 2, + BlockHeight: 800001, + BlockHash: bitcoin.Hash{0xdd}, + }) + outbox.persistFailureHook = func(record *bitcoinBroadcastOutboxRecord) error { + return errors.New("injected fsync failure") + } + if err := outbox.replayOnce(); err == nil { + t.Fatal("expected injected confirmation persistence failure") + } + if outbox.records[tx.Hash()].Confirmation != nil { + t.Fatal("non-durable confirmation leaked into live outbox state") + } + outbox.persistFailureHook = nil + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if outbox.records[tx.Hash()].Confirmation == nil { + t.Fatal("confirmation was not retried after persistence recovered") + } + if chain.broadcastCount(tx.Hash()) != 0 { + t.Fatal("confirmed transaction was broadcast before durable evidence") + } +} + +func TestBitcoinBroadcastOutbox_PersistenceFailureDoesNotPublishAttemptCounters( + t *testing.T, +) { + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, t.TempDir(), chain) + defer outbox.close() + tx := testOutboxTransaction(13, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(15, 13, 1), + ) + outbox.persistFailureHook = func(record *bitcoinBroadcastOutboxRecord) error { + if record.BroadcastAttempts > 0 { + return errors.New("injected attempt fsync failure") + } + return nil + } + if err := outbox.replayOnce(); err == nil { + t.Fatal("expected injected attempt persistence failure") + } + if outbox.records[tx.Hash()].BroadcastAttempts != 0 { + t.Fatal("non-durable attempt counters leaked into live outbox state") + } + if chain.broadcastCount(tx.Hash()) != 1 { + t.Fatal("test did not reach the external Bitcoin broadcast boundary") + } + outbox.persistFailureHook = nil + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if outbox.records[tx.Hash()].BroadcastAttempts != 1 || + chain.broadcastCount(tx.Hash()) != 2 { + t.Fatal("failed broadcast attempt was not safely retried") + } +} + +func TestBitcoinBroadcastOutbox_PersistenceFailureDoesNotPublishQuarantine( + t *testing.T, +) { + directory := t.TempDir() + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, directory, chain) + tx := testOutboxTransaction(14, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(16, 14, 1), + ) + if err := outbox.close(); err != nil { + t.Fatal(err) + } + + statusSource := newOutboxTestAuthorizationStatusSource() + statusSource.setBroadcastAllowed(false) + rotatedProfile := [32]byte{0xed} + restarted := openTestBitcoinBroadcastOutboxWithProfile( + t, + directory, + chain, + statusSource, + rotatedProfile, + ) + defer restarted.close() + restarted.persistFailureHook = func(record *bitcoinBroadcastOutboxRecord) error { + if record.Quarantine != nil { + return errors.New("injected quarantine fsync failure") + } + return nil + } + if err := restarted.replayOnce(); err == nil { + t.Fatal("expected injected quarantine persistence failure") + } + if restarted.records[tx.Hash()].Quarantine != nil { + t.Fatal("non-durable quarantine leaked into live outbox state") + } + if chain.broadcastCount(tx.Hash()) != 0 { + t.Fatal("failed quarantine mutation reached Bitcoin broadcast") + } + + restarted.persistFailureHook = nil + if err := restarted.replayOnce(); err != nil { + t.Fatal(err) + } + quarantine := restarted.records[tx.Hash()].Quarantine + if quarantine == nil || + quarantine.ActiveActivationProfileHash != rotatedProfile || + quarantine.ObservedAtUnix <= 0 { + t.Fatal("quarantine was not retried after persistence recovered") + } +} + +func TestBitcoinBroadcastOutbox_CanonicalAuthorizationFailurePrecedesBroadcast( + t *testing.T, +) { + directory := t.TempDir() + chain := newOutboxTestBitcoinChain() + statusSource := newOutboxTestAuthorizationStatusSource() + outbox := openTestBitcoinBroadcastOutboxWithStatusSource( + t, + directory, + chain, + statusSource, + ) + tx := testOutboxTransaction(14, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(16, 14, 1), + ) + if err := outbox.close(); err != nil { + t.Fatal(err) + } + + statusSource.canonical = false + restarted := openTestBitcoinBroadcastOutboxWithStatusSource( + t, + directory, + chain, + statusSource, + ) + defer restarted.close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := restarted.start(ctx); err == nil { + t.Fatal("expected startup to fail on canonical authorization conflict") + } + if chain.broadcastCount(tx.Hash()) != 0 || + restarted.records[tx.Hash()].BroadcastAttempts != 0 { + t.Fatal("authorization conflict reached Bitcoin broadcast") + } +} + +func TestBitcoinBroadcastOutbox_FirstBroadcastRequiresCurrentPermission(t *testing.T) { + chain := newOutboxTestBitcoinChain() + statusSource := newOutboxTestAuthorizationStatusSource() + outbox := openTestBitcoinBroadcastOutboxWithStatusSource( + t, + t.TempDir(), + chain, + statusSource, + ) + defer outbox.close() + tx := testOutboxTransaction(18, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(19, 18, 1), + ) + statusSource.broadcastAllowed = false + if err := outbox.broadcastTransaction(context.Background(), tx.Hash()); err == nil { + t.Fatal("expected current authorization to deny first broadcast") + } + if chain.broadcastCount(tx.Hash()) != 0 || + outbox.records[tx.Hash()].BroadcastAttempts != 0 { + t.Fatal("denied first broadcast crossed the Bitcoin boundary") + } + statusSource.broadcastAllowed = true + if err := outbox.broadcastTransaction(context.Background(), tx.Hash()); err != nil { + t.Fatal(err) + } + if chain.broadcastCount(tx.Hash()) != 1 || + outbox.records[tx.Hash()].BroadcastAttempts != 1 { + t.Fatal("authorized first broadcast did not durably record its attempt") + } +} + +func TestBitcoinBroadcastOutbox_ProfileRotationQuarantinesOldUnconfirmedRecord( + t *testing.T, +) { + directory := t.TempDir() + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, directory, chain) + tx := testOutboxTransaction(15, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(17, 15, 1), + ) + if err := outbox.close(); err != nil { + t.Fatal(err) + } + + rotatedProfile := [32]byte{0xee} + statusSource := newOutboxTestAuthorizationStatusSource() + statusSource.setBroadcastAllowed(false) + restarted := openTestBitcoinBroadcastOutboxWithProfile( + t, + directory, + chain, + statusSource, + rotatedProfile, + ) + defer restarted.close() + if err := restarted.replayOnce(); err != nil { + t.Fatal(err) + } + record := restarted.records[tx.Hash()] + if record == nil || + record.Authorization.ActivationProfileHash != testOutboxActivationProfile { + t.Fatal("old-profile record was not preserved across rotation") + } + if record.Quarantine == nil || + record.Quarantine.ActiveActivationProfileHash != rotatedProfile { + t.Fatal("old-profile record was not durably quarantined") + } + request := statusSource.lastStatusRequest() + if request == nil || + request.ActivationProfileHash != testOutboxActivationProfile || + request.ActiveActivationProfileHash != rotatedProfile || + request.TransactionHash != tx.Hash() { + t.Fatal("rotation status request did not bind old and active profiles") + } + if chain.canonicalStatusCallCount() == 0 { + t.Fatal("rotation quarantine skipped canonical Bitcoin reconciliation") + } + if chain.broadcastCount(tx.Hash()) != 0 || record.BroadcastAttempts != 0 { + t.Fatal("quarantined old-profile record crossed the Bitcoin boundary") + } + + statusSource.setBroadcastAllowed(true) + if err := restarted.replayOnce(); err != nil { + t.Fatal(err) + } + record = restarted.records[tx.Hash()] + if record.Quarantine != nil || record.BroadcastAttempts != 1 || + chain.broadcastCount(tx.Hash()) != 1 { + t.Fatal("exact active-generation reauthorization did not release quarantine") + } +} + +func TestBitcoinBroadcastOutbox_ProfileRotationReconcilesOldConfirmation( + t *testing.T, +) { + directory := t.TempDir() + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, directory, chain) + tx := testOutboxTransaction(16, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(18, 16, 1), + ) + chain.setCanonicalStatus(tx.Hash(), &bitcoin.CanonicalTransactionStatus{ + Found: true, + Confirmations: defaultBitcoinBroadcastArchiveConfirmations, + BlockHeight: 800016, + BlockHash: bitcoin.Hash{0x16, 0xcc}, + }) + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if outbox.records[tx.Hash()].Confirmation == nil { + t.Fatal("old-profile confirmation was not prepared") + } + if err := outbox.close(); err != nil { + t.Fatal(err) + } + + chain.setCanonicalStatus(tx.Hash(), &bitcoin.CanonicalTransactionStatus{ + Found: true, + Confirmations: defaultBitcoinBroadcastArchiveConfirmations + 1, + BlockHeight: 800016, + BlockHash: bitcoin.Hash{0x16, 0xcc}, + }) + rotatedProfile := [32]byte{0xef} + statusSource := newOutboxTestAuthorizationStatusSource() + statusSource.setBroadcastAllowed(false) + restarted := openTestBitcoinBroadcastOutboxWithProfile( + t, + directory, + chain, + statusSource, + rotatedProfile, + ) + defer restarted.close() + if err := restarted.replayOnce(); err != nil { + t.Fatal(err) + } + record := restarted.records[tx.Hash()] + if record.Confirmation == nil || + record.Confirmation.Confirmations != defaultBitcoinBroadcastArchiveConfirmations+1 { + t.Fatal("old-profile confirmation stopped reconciling after rotation") + } + if record.Quarantine == nil || + record.Quarantine.ActiveActivationProfileHash != rotatedProfile { + t.Fatal("old confirmed record was not quarantined under the active profile") + } + if chain.broadcastCount(tx.Hash()) != 0 { + t.Fatal("confirmed old-profile record was broadcast during rotation") + } +} + +func TestBitcoinBroadcastOutbox_ProfileRotationReconcilesOldReorg( + t *testing.T, +) { + directory := t.TempDir() + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, directory, chain) + tx := testOutboxTransaction(17, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(19, 17, 1), + ) + chain.setCanonicalStatus(tx.Hash(), &bitcoin.CanonicalTransactionStatus{ + Found: true, + Confirmations: defaultBitcoinBroadcastArchiveConfirmations, + BlockHeight: 800017, + BlockHash: bitcoin.Hash{0x17, 0xcc}, + }) + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if err := outbox.close(); err != nil { + t.Fatal(err) + } + + chain.setCanonicalStatus( + tx.Hash(), + &bitcoin.CanonicalTransactionStatus{Found: false}, + ) + rotatedProfile := [32]byte{0xf0} + statusSource := newOutboxTestAuthorizationStatusSource() + statusSource.setBroadcastAllowed(false) + restarted := openTestBitcoinBroadcastOutboxWithProfile( + t, + directory, + chain, + statusSource, + rotatedProfile, + ) + if err := restarted.replayOnce(); err != nil { + restarted.close() + t.Fatal(err) + } + record := restarted.records[tx.Hash()] + if record.Confirmation != nil || record.Quarantine == nil { + restarted.close() + t.Fatal("old-profile reorg was not durably reconciled and quarantined") + } + if chain.broadcastCount(tx.Hash()) != 0 || record.BroadcastAttempts != 0 { + restarted.close() + t.Fatal("reorged old-profile record was broadcast without reauthorization") + } + if err := restarted.close(); err != nil { + t.Fatal(err) + } + + statusSource = newOutboxTestAuthorizationStatusSource() + statusSource.setBroadcastAllowed(false) + restarted = openTestBitcoinBroadcastOutboxWithProfile( + t, + directory, + chain, + statusSource, + rotatedProfile, + ) + defer restarted.close() + if restarted.records[tx.Hash()].Confirmation != nil || + restarted.records[tx.Hash()].Quarantine == nil { + t.Fatal("reorg quarantine was not durable across restart") + } + if err := restarted.replayOnce(); err != nil { + t.Fatal(err) + } + if chain.broadcastCount(tx.Hash()) != 0 { + t.Fatal("persisted reorg quarantine was silently replayed") + } +} + +func TestBitcoinBroadcastOutbox_DeepReconciliationWorkIsBounded(t *testing.T) { + chain := newOutboxTestBitcoinChain() + statusSource := newOutboxTestAuthorizationStatusSource() + outbox := openTestBitcoinBroadcastOutboxWithStatusSource( + t, + t.TempDir(), + chain, + statusSource, + ) + defer outbox.close() + outbox.deepReconcileBatch = 3 + + const historySize = 24 + for i := 1; i <= historySize; i++ { + tx := testOutboxTransaction(byte(i), 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(byte(i), byte(i), 1), + ) + chain.setCanonicalStatus(tx.Hash(), &bitcoin.CanonicalTransactionStatus{ + Found: true, + Confirmations: defaultBitcoinBroadcastArchiveConfirmations, + BlockHeight: uint(800100 + i), + BlockHash: bitcoin.Hash{byte(i), 0xaa}, + }) + } + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + chain.resetStatusCallCount() + statusSource.resetCallCount() + + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if chain.canonicalStatusCallCount() > uint(outbox.deepReconcileBatch) || + statusSource.callCount() > outbox.deepReconcileBatch { + t.Fatalf( + "archived replay work grew with history: bitcoin=[%d] ethereum=[%d] batch=[%d]", + chain.canonicalStatusCallCount(), + statusSource.callCount(), + outbox.deepReconcileBatch, + ) + } +} + +func openTestBitcoinBroadcastOutbox( + t *testing.T, + directory string, + chain canonicalBitcoinBroadcastChain, +) *bitcoinBroadcastOutbox { + t.Helper() + outbox, err := newTestBitcoinBroadcastOutbox(directory, chain) + if err != nil { + t.Fatal(err) + } + return outbox +} + +func openTestBitcoinBroadcastOutboxWithStatusSource( + t *testing.T, + directory string, + chain canonicalBitcoinBroadcastChain, + statusSource FrostBitcoinBroadcastAuthorizationStatusSource, +) *bitcoinBroadcastOutbox { + return openTestBitcoinBroadcastOutboxWithProfile( + t, + directory, + chain, + statusSource, + testOutboxActivationProfile, + ) +} + +func openTestBitcoinBroadcastOutboxWithProfile( + t *testing.T, + directory string, + chain canonicalBitcoinBroadcastChain, + statusSource FrostBitcoinBroadcastAuthorizationStatusSource, + activationProfileHash [32]byte, +) *bitcoinBroadcastOutbox { + t.Helper() + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + outbox, err := newBitcoinBroadcastOutbox( + directory, + chain, + statusSource, + activationProfileHash, + ) + if err != nil { + t.Fatal(err) + } + return outbox +} + +func newTestBitcoinBroadcastOutbox( + directory string, + chain canonicalBitcoinBroadcastChain, +) (*bitcoinBroadcastOutbox, error) { + if err := os.Chmod(directory, 0700); err != nil { + return nil, err + } + return newBitcoinBroadcastOutbox( + directory, + chain, + newOutboxTestAuthorizationStatusSource(), + testOutboxActivationProfile, + ) +} + +func enqueueTestBitcoinTransaction( + t *testing.T, + outbox *bitcoinBroadcastOutbox, + tx *bitcoin.Transaction, + authorization bitcoinBroadcastAuthorization, +) { + t.Helper() + if err := enqueueTestBitcoinTransactionError(outbox, tx, authorization); err != nil { + t.Fatal(err) + } +} + +func enqueueTestBitcoinTransactionError( + outbox *bitcoinBroadcastOutbox, + tx *bitcoin.Transaction, + authorization bitcoinBroadcastAuthorization, +) error { + return outbox.enqueue( + tx, + testOutboxWalletPublicKeyHash, + testOutboxWalletID, + FrostPreSignActionDepositSweep, + tx.Hash(), + authorization, + ) +} + +func testBitcoinBroadcastAuthorization( + authorizationByte byte, + reservationByte byte, + sequence uint64, +) bitcoinBroadcastAuthorization { + sequenceWord := [32]byte{} + binary.BigEndian.PutUint64(sequenceWord[24:], sequence) + return bitcoinBroadcastAuthorization{ + ActivationProfileHash: testOutboxActivationProfile, + AuthorizationID: [32]byte{authorizationByte}, + ReservationID: [32]byte{reservationByte}, + AuthorizationRoot: [32]byte{authorizationByte, reservationByte}, + SnapshotHash: [32]byte{reservationByte, 0x01}, + ResourceHash: [32]byte{reservationByte, 0x02}, + OrderedInputRoot: [32]byte{reservationByte, 0x03}, + LockedPlanHash: [32]byte{reservationByte, 0x04}, + VariantApplyPlanHash: [32]byte{authorizationByte, 0x05}, + FeeLimitSnapshot: 10000, + FinalizedBlock: 100 + sequence, + FinalizedBlockHash: [32]byte{reservationByte, byte(sequence)}, + FinalizedTransactionIndex: uint32(sequence), + FinalizedLogIndex: 0, + VariantSequence: FrostPreSignVariantSequence{ + AuthorizationSequence: sequenceWord, + }, + } +} + +func testBitcoinBroadcastOutboxRecord( + tx *bitcoin.Transaction, + authorization bitcoinBroadcastAuthorization, +) *bitcoinBroadcastOutboxRecord { + outpoints, inputSetHash, err := bitcoinTransactionOutpoints(tx) + if err != nil { + panic(err) + } + now := time.Unix(1700000000, 0).Unix() + return &bitcoinBroadcastOutboxRecord{ + Version: bitcoinBroadcastOutboxRecordVersion, + TransactionHash: tx.Hash(), + WitnessTransactionHash: tx.WitnessHash(), + UnsignedTransactionHash: tx.Hash(), + RawTransaction: tx.Serialize(bitcoin.Witness), + WalletPublicKeyHash: testOutboxWalletPublicKeyHash, + WalletID: testOutboxWalletID, + Action: FrostPreSignActionDepositSweep, + OrderedOutpoints: outpoints, + InputSetHash: inputSetHash, + Authorization: authorization, + CreatedAtUnix: now, + UpdatedAtUnix: now, + } +} + +func testOutboxTransaction(inputByte byte, outputValue int64) *bitcoin.Transaction { + return &bitcoin.Transaction{ + Version: 2, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{inputByte}, + OutputIndex: 1, + }, + Witness: [][]byte{{0x01, 0x02}}, + Sequence: 0xfffffffd, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: outputValue, + PublicKeyScript: bitcoin.Script{0x51}, + }, + }, + } +} + +type outboxTestBitcoinChain struct { + *localBitcoinChain + + mutex sync.Mutex + statuses map[bitcoin.Hash]*bitcoin.CanonicalTransactionStatus + statusErrs map[bitcoin.Hash]error + broadcasts map[bitcoin.Hash]uint + statusCalls uint +} + +type outboxTestAuthorizationStatusSource struct { + mutex sync.Mutex + err error + canonical bool + broadcastAllowed bool + calls int + lastRequest *FrostBitcoinBroadcastAuthorizationStatusRequest +} + +func newOutboxTestAuthorizationStatusSource() *outboxTestAuthorizationStatusSource { + return &outboxTestAuthorizationStatusSource{ + canonical: true, + broadcastAllowed: true, + } +} + +func (otass *outboxTestAuthorizationStatusSource) GetCanonicalFrostBitcoinBroadcastAuthorizationStatus( + ctx context.Context, + request *FrostBitcoinBroadcastAuthorizationStatusRequest, +) (*FrostBitcoinBroadcastAuthorizationStatus, error) { + otass.mutex.Lock() + defer otass.mutex.Unlock() + otass.calls++ + requestClone := *request + requestClone.OrderedOutpoints = append( + []FrostBitcoinBroadcastOutpoint{}, + request.OrderedOutpoints..., + ) + otass.lastRequest = &requestClone + if otass.err != nil { + return nil, otass.err + } + return &FrostBitcoinBroadcastAuthorizationStatus{ + RequestHash: request.ComputeHash(), + Canonical: otass.canonical, + BroadcastAllowed: otass.broadcastAllowed, + }, nil +} + +func (otass *outboxTestAuthorizationStatusSource) setBroadcastAllowed( + allowed bool, +) { + otass.mutex.Lock() + defer otass.mutex.Unlock() + otass.broadcastAllowed = allowed +} + +func (otass *outboxTestAuthorizationStatusSource) lastStatusRequest() *FrostBitcoinBroadcastAuthorizationStatusRequest { + otass.mutex.Lock() + defer otass.mutex.Unlock() + if otass.lastRequest == nil { + return nil + } + clone := *otass.lastRequest + clone.OrderedOutpoints = append( + []FrostBitcoinBroadcastOutpoint{}, + otass.lastRequest.OrderedOutpoints..., + ) + return &clone +} + +func (otass *outboxTestAuthorizationStatusSource) callCount() int { + otass.mutex.Lock() + defer otass.mutex.Unlock() + return otass.calls +} + +func (otass *outboxTestAuthorizationStatusSource) resetCallCount() { + otass.mutex.Lock() + defer otass.mutex.Unlock() + otass.calls = 0 +} + +func newOutboxTestBitcoinChain() *outboxTestBitcoinChain { + return &outboxTestBitcoinChain{ + localBitcoinChain: newLocalBitcoinChain(), + statuses: make(map[bitcoin.Hash]*bitcoin.CanonicalTransactionStatus), + statusErrs: make(map[bitcoin.Hash]error), + broadcasts: make(map[bitcoin.Hash]uint), + } +} + +func (otbc *outboxTestBitcoinChain) GetCanonicalTransactionStatus( + hash bitcoin.Hash, +) (*bitcoin.CanonicalTransactionStatus, error) { + otbc.mutex.Lock() + defer otbc.mutex.Unlock() + otbc.statusCalls++ + if err := otbc.statusErrs[hash]; err != nil { + return nil, err + } + status, ok := otbc.statuses[hash] + if !ok { + return &bitcoin.CanonicalTransactionStatus{Found: false}, nil + } + clone := *status + return &clone, nil +} + +func (otbc *outboxTestBitcoinChain) BroadcastTransaction( + tx *bitcoin.Transaction, +) error { + otbc.mutex.Lock() + defer otbc.mutex.Unlock() + otbc.broadcasts[tx.Hash()]++ + return nil +} + +func (otbc *outboxTestBitcoinChain) setCanonicalStatus( + hash bitcoin.Hash, + status *bitcoin.CanonicalTransactionStatus, +) { + otbc.mutex.Lock() + defer otbc.mutex.Unlock() + clone := *status + otbc.statuses[hash] = &clone +} + +func (otbc *outboxTestBitcoinChain) setCanonicalError( + hash bitcoin.Hash, + err error, +) { + otbc.mutex.Lock() + defer otbc.mutex.Unlock() + otbc.statusErrs[hash] = err +} + +func (otbc *outboxTestBitcoinChain) broadcastCount(hash bitcoin.Hash) uint { + otbc.mutex.Lock() + defer otbc.mutex.Unlock() + return otbc.broadcasts[hash] +} + +func (otbc *outboxTestBitcoinChain) resetStatusCallCount() { + otbc.mutex.Lock() + defer otbc.mutex.Unlock() + otbc.statusCalls = 0 +} + +func (otbc *outboxTestBitcoinChain) canonicalStatusCallCount() uint { + otbc.mutex.Lock() + defer otbc.mutex.Unlock() + return otbc.statusCalls +} + +func (otbc *outboxTestBitcoinChain) GetTransactionConfirmations( + hash bitcoin.Hash, +) (uint, error) { + status, err := otbc.GetCanonicalTransactionStatus(hash) + if err != nil { + return 0, err + } + if !status.Found { + return 0, fmt.Errorf("transaction not found") + } + return status.Confirmations, nil +} diff --git a/pkg/tbtc/coordination.go b/pkg/tbtc/coordination.go index 57a78f79f0..7db94195fd 100644 --- a/pkg/tbtc/coordination.go +++ b/pkg/tbtc/coordination.go @@ -306,6 +306,11 @@ type coordinationExecutor struct { waitForBlockFn waitForBlockFn + // suppressHeartbeat is true for transaction-only FROST wallets. The + // finalized COMPLETE authorization protocol has no heartbeat action, so + // proposing one would only burn a coordination window. + suppressHeartbeat bool + // metricsRecorder is optional and used for recording performance metrics metricsRecorder interface { IncrementCounter(name string, value float64) @@ -637,7 +642,7 @@ func (ce *coordinationExecutor) getActionsChecklist( // Drawing a decision about heartbeat does not require secure randomness. // Use first 8 bytes of the seed to initialize the RNG. rng := rand.New(rand.NewSource(int64(binary.BigEndian.Uint64(seed[:8])))) - if rng.Float64() < coordinationHeartbeatProbability { + if !ce.suppressHeartbeat && rng.Float64() < coordinationHeartbeatProbability { actions = append(actions, ActionHeartbeat) } diff --git a/pkg/tbtc/coordination_test.go b/pkg/tbtc/coordination_test.go index 0bf7a45164..bb16bb72ea 100644 --- a/pkg/tbtc/coordination_test.go +++ b/pkg/tbtc/coordination_test.go @@ -678,6 +678,33 @@ func TestCoordinationExecutor_GetActionsChecklist(t *testing.T) { } } +func TestCoordinationExecutor_GetActionsChecklist_SuppressesFrostHeartbeat( + t *testing.T, +) { + window := newCoordinationWindow(3600) + seed := sha256.Sum256( + big.NewInt(int64(window.coordinationBlock) + 2).Bytes(), + ) + executor := &coordinationExecutor{suppressHeartbeat: true} + checklist := executor.getActionsChecklist( + window.index(), + seed, + window.coordinationBlock, + ) + if slices.Contains(checklist, ActionHeartbeat) { + t.Fatal("transaction-only FROST coordination proposed a heartbeat") + } + expected := []WalletActionType{ + ActionRedemption, + ActionDepositSweep, + ActionMovedFundsSweep, + ActionMovingFunds, + } + if diff := deep.Equal(checklist, expected); diff != nil { + t.Fatalf("unexpected FROST action checklist: [%v]", diff) + } +} + // TestCoordinationExecutor_GetActionsChecklist_PostActivation verifies // post-activation behavior where DepositSweep and MovedFundsSweep appear // on every coordination window, MovingFunds remains gated to every 4th diff --git a/pkg/tbtc/deposit.go b/pkg/tbtc/deposit.go index 7339820595..608594fffe 100644 --- a/pkg/tbtc/deposit.go +++ b/pkg/tbtc/deposit.go @@ -38,6 +38,11 @@ type Deposit struct { // Utxo is the unspent output of the deposit funding transaction that // represents the deposit on the Bitcoin chain. Utxo *bitcoin.UnspentTransactionOutput + // FundingTx is the canonical stripped funding transaction retained during + // proposal validation. It is required to reproduce the exact COMPLETE_V2 + // Taproot deposit authorization calldata and is never accepted from an + // unvalidated coordinator payload. + FundingTx *bitcoin.Transaction // Depositor is the depositor's address on the host chain. Depositor chain.Address // BlindingFactor is an 8-byte arbitrary value that allows to distinguish diff --git a/pkg/tbtc/deposit_sweep.go b/pkg/tbtc/deposit_sweep.go index bf1961f661..d556d64289 100644 --- a/pkg/tbtc/deposit_sweep.go +++ b/pkg/tbtc/deposit_sweep.go @@ -116,6 +116,7 @@ func newDepositSweepAction( signingExecutor, waitForBlockFn, ) + transactionExecutor.action = ActionDepositSweep return &depositSweepAction{ logger: logger, @@ -242,6 +243,13 @@ func (dsa *depositSweepAction) execute() error { } signingStartTime := time.Now() + dsa.transactionExecutor.frostPreSignActionContext = &FrostPreSignActionContext{ + DepositSweep: &FrostPreSignDepositSweepActionContext{ + Proposal: dsa.proposal, + Deposits: validatedDeposits, + MainUtxo: walletMainUtxo, + }, + } sweepTx, err := dsa.transactionExecutor.signTransaction( signTxLogger, unsignedSweepTx, @@ -545,6 +553,7 @@ func ValidateDepositSweepProposal( }(), FundingTx: fundingTx, } + depositExtraInfo[i].Deposit.FundingTx = fundingTx } if taprootDepositsCount > 0 && taprootDepositsCount != len(proposal.DepositsKeys) { diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go new file mode 100644 index 0000000000..39ba7b1cdf --- /dev/null +++ b/pkg/tbtc/frost_activation_handshake.go @@ -0,0 +1,569 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "math/big" + "mime" + "net" + "net/http" + "net/url" + "path" + "sort" + "strings" + "sync" + "time" + + "golang.org/x/sys/unix" +) + +const ( + frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v1" + frostActivationReadinessSnapshotSchema = "tbtc-p2tr-frost-signer-readiness-snapshot/v1" + frostActivationInventorySchema = "tbtc-p2tr-frost-wallet-group-inventory/v1" + frostActivationMaximumJSONSize = 64 * 1024 +) + +type frostActivationEthereumPoint struct { + BlockNumber uint64 `json:"blockNumber"` + BlockHash string `json:"blockHash"` +} + +type frostActivationChallenge struct { + Nonce string `json:"nonce"` + ManifestHash string `json:"manifestHash"` + EthereumPoint frostActivationEthereumPoint `json:"ethereumPoint"` +} + +type frostActivationHandshakeRequest struct { + Schema string `json:"schema"` + Challenge frostActivationChallenge `json:"challenge"` +} + +type frostActivationWalletGroupInventory struct { + Schema string `json:"schema"` + Point frostActivationEthereumPoint `json:"point"` + SnapshotGeneration uint64 `json:"snapshotGeneration"` + InventoryRoot string `json:"inventoryRoot"` + WalletCount uint64 `json:"walletCount"` + MinimumActualGroupSize uint64 `json:"minimumActualGroupSize"` + MaximumActualGroupSize uint64 `json:"maximumActualGroupSize"` + MembershipAmbiguityCount uint64 `json:"membershipAmbiguityCount"` + GroupSizeViolationCount uint64 `json:"groupSizeViolationCount"` + Complete bool `json:"complete"` +} + +type frostActivationReadinessSnapshot struct { + Schema string `json:"schema"` + ManifestHash string `json:"manifestHash"` + DurableSessionStoreFingerprint string `json:"durableSessionStoreFingerprint"` + FrostWalletGroupInventory frostActivationWalletGroupInventory `json:"frostWalletGroupInventory"` +} + +type frostActivationHandshakeState struct { + ProtocolID string `json:"protocolID"` + ReservationProtocolID string `json:"reservationProtocolID"` + BitcoinOutboxProtocolID string `json:"bitcoinOutboxProtocolID"` + SigningPolicyHash string `json:"signingPolicyHash"` + DurableSessionStoreFingerprint string `json:"durableSessionStoreFingerprint"` + CompleteRouterAddress string `json:"completeRouterAddress"` + AuthorizationRegistryAddress string `json:"authorizationRegistryAddress"` + Threshold uint64 `json:"threshold"` + MaximumGroupSize uint64 `json:"maximumGroupSize"` + RetainedGroupInventoryProtocolID string `json:"retainedGroupInventoryProtocolID"` + FrostWalletGroupInventory frostActivationWalletGroupInventory `json:"frostWalletGroupInventory"` + FinalizedReservationReadbackEnforced bool `json:"finalizedReservationReadbackEnforced"` + ExactTransactionAuthorizationRootEnforced bool `json:"exactTransactionAuthorizationRootEnforced"` + NonceShareGateEnforced bool `json:"nonceShareGateEnforced"` + DurableBitcoinOutboxRecovered bool `json:"durableBitcoinOutboxRecovered"` + QuarantineFailClosed bool `json:"quarantineFailClosed"` + Healthy bool `json:"healthy"` +} + +type frostActivationHandshakePayload struct { + Kind string `json:"kind"` + Nonce string `json:"nonce"` + ManifestHash string `json:"manifestHash"` + EthereumPoint frostActivationEthereumPoint `json:"ethereumPoint"` + State frostActivationHandshakeState `json:"state"` +} + +type frostActivationSignedHandshake struct { + Payload frostActivationHandshakePayload `json:"payload"` + SignerPublicKeySPKI string `json:"signerPublicKeySpki"` + Signature string `json:"signature"` +} + +type frostActivationHandshakeExporter struct { + endpoint *url.URL + readinessPath string + privateKey ed25519.PrivateKey + publicKeySPKI string + manifest FrostPreSignActivationRuntimeManifest + pointVerifier FrostPreSignActivationPointVerifier + outbox *bitcoinBroadcastOutbox + + mutex sync.Mutex + listener net.Listener + server *http.Server + closed bool +} + +func newFrostActivationHandshakeExporter( + endpoint string, + privateKeyPath string, + readinessPath string, + manifest FrostPreSignActivationRuntimeManifest, + pointVerifier FrostPreSignActivationPointVerifier, + outbox *bitcoinBroadcastOutbox, +) (*frostActivationHandshakeExporter, error) { + parsedEndpoint, err := validateFrostActivationHandshakeEndpoint(endpoint) + if err != nil { + return nil, err + } + if strings.TrimSpace(readinessPath) == "" { + return nil, fmt.Errorf("FROST activation readiness snapshot path is empty") + } + if manifest.ManifestHash == [32]byte{} || + manifest.AttestationSignerKeyHash == [32]byte{} || + manifest.Threshold != 51 || manifest.MaximumGroupSize != 100 || + pointVerifier == nil || outbox == nil { + return nil, fmt.Errorf("FROST activation handshake dependencies are invalid") + } + privateKey, publicKeyDER, err := loadFrostActivationAttestationKey(privateKeyPath) + if err != nil { + return nil, err + } + if sha256.Sum256(publicKeyDER) != manifest.AttestationSignerKeyHash { + return nil, fmt.Errorf("FROST activation attestation key differs from signed manifest") + } + exporter := &frostActivationHandshakeExporter{ + endpoint: parsedEndpoint, + readinessPath: readinessPath, + privateKey: privateKey, + publicKeySPKI: base64.StdEncoding.EncodeToString(publicKeyDER), + manifest: manifest, + pointVerifier: pointVerifier, + outbox: outbox, + } + if _, err := exporter.readReadinessSnapshot(); err != nil { + return nil, err + } + return exporter, nil +} + +func validateFrostActivationHandshakeEndpoint(value string) (*url.URL, error) { + endpoint, err := url.Parse(value) + if err != nil || endpoint.Scheme != "http" || endpoint.User != nil || + endpoint.RawQuery != "" || endpoint.Fragment != "" || endpoint.RawPath != "" || + endpoint.Path == "" || path.Clean(endpoint.Path) != endpoint.Path { + return nil, fmt.Errorf("FROST activation handshake endpoint is invalid") + } + host := net.ParseIP(endpoint.Hostname()) + if host == nil || !host.IsLoopback() || endpoint.Port() == "" || endpoint.Port() == "0" { + return nil, fmt.Errorf("FROST activation handshake endpoint must be numeric loopback with a fixed port") + } + if _, err := net.LookupPort("tcp", endpoint.Port()); err != nil { + return nil, fmt.Errorf("FROST activation handshake endpoint port is invalid: [%w]", err) + } + return endpoint, nil +} + +func loadFrostActivationAttestationKey( + keyPath string, +) (ed25519.PrivateKey, []byte, error) { + data, err := readSecureFrostActivationFile(keyPath, 16*1024) + if err != nil { + return nil, nil, fmt.Errorf("cannot read FROST activation attestation key: [%w]", err) + } + block, rest := pem.Decode(data) + if block == nil || block.Type != "PRIVATE KEY" || len(bytes.TrimSpace(rest)) != 0 { + return nil, nil, fmt.Errorf("FROST activation attestation key must be one PKCS#8 PEM block") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("cannot parse FROST activation attestation key: [%w]", err) + } + privateKey, ok := parsed.(ed25519.PrivateKey) + if !ok || len(privateKey) != ed25519.PrivateKeySize { + return nil, nil, fmt.Errorf("FROST activation attestation key is not Ed25519") + } + publicKeyDER, err := x509.MarshalPKIXPublicKey(privateKey.Public()) + if err != nil { + return nil, nil, fmt.Errorf("cannot encode FROST activation attestation public key: [%w]", err) + } + return append(ed25519.PrivateKey{}, privateKey...), publicKeyDER, nil +} + +func readSecureFrostActivationFile(filePath string, limit int64) ([]byte, error) { + if strings.TrimSpace(filePath) == "" { + return nil, fmt.Errorf("path is empty") + } + file, err := openSecureBitcoinBroadcastFile(filePath, unix.O_RDONLY, 0600) + if err != nil { + return nil, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil { + return nil, err + } + if len(data) == 0 || int64(len(data)) > limit { + return nil, fmt.Errorf("secure activation file size is invalid") + } + return data, nil +} + +func (fahe *frostActivationHandshakeExporter) readReadinessSnapshot() ( + *frostActivationReadinessSnapshot, + error, +) { + data, err := readSecureFrostActivationFile( + fahe.readinessPath, + frostActivationMaximumJSONSize, + ) + if err != nil { + return nil, fmt.Errorf("cannot read FROST readiness snapshot: [%w]", err) + } + snapshot := &frostActivationReadinessSnapshot{} + if err := decodeStrictFrostActivationJSON(data, snapshot); err != nil { + return nil, fmt.Errorf("cannot decode FROST readiness snapshot: [%w]", err) + } + if snapshot.Schema != frostActivationReadinessSnapshotSchema || + snapshot.ManifestHash != frostActivationHex32(fahe.manifest.ManifestHash) || + snapshot.DurableSessionStoreFingerprint != fahe.manifest.DurableSessionStoreFingerprint { + return nil, fmt.Errorf("FROST readiness snapshot differs from signed activation manifest") + } + if err := validateFrostActivationInventory(snapshot.FrostWalletGroupInventory); err != nil { + return nil, err + } + return snapshot, nil +} + +func validateFrostActivationInventory( + inventory frostActivationWalletGroupInventory, +) error { + if inventory.Schema != frostActivationInventorySchema || !inventory.Complete || + inventory.MembershipAmbiguityCount != 0 || inventory.GroupSizeViolationCount != 0 || + inventory.Point.BlockNumber == 0 { + return fmt.Errorf("FROST retained-group inventory is incomplete or ambiguous") + } + if _, err := parseFrostActivationHex32(inventory.Point.BlockHash); err != nil { + return fmt.Errorf("invalid FROST inventory block hash: [%w]", err) + } + root, err := parseFrostActivationHex32(inventory.InventoryRoot) + if err != nil || root == [32]byte{} { + return fmt.Errorf("invalid FROST inventory root") + } + if inventory.WalletCount == 0 { + if inventory.MinimumActualGroupSize != 0 || inventory.MaximumActualGroupSize != 0 { + return fmt.Errorf("empty FROST inventory has nonzero group bounds") + } + } else if inventory.MinimumActualGroupSize < 51 || + inventory.MinimumActualGroupSize > inventory.MaximumActualGroupSize || + inventory.MaximumActualGroupSize > 100 { + return fmt.Errorf("FROST inventory group bounds are invalid") + } + return nil +} + +func (fahe *frostActivationHandshakeExporter) start(ctx context.Context) error { + if ctx == nil { + return fmt.Errorf("FROST activation handshake context is nil") + } + fahe.mutex.Lock() + defer fahe.mutex.Unlock() + if fahe.listener != nil || fahe.closed { + return fmt.Errorf("FROST activation handshake exporter is already started or closed") + } + listener, err := net.Listen("tcp", fahe.endpoint.Host) + if err != nil { + return fmt.Errorf("cannot listen for FROST activation handshake: [%w]", err) + } + server := &http.Server{ + Handler: http.HandlerFunc(fahe.serveHTTP), + ReadHeaderTimeout: 2 * time.Second, + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + IdleTimeout: 5 * time.Second, + MaxHeaderBytes: 4096, + } + fahe.listener = listener + fahe.server = server + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + logger.Errorf("FROST activation handshake exporter failed: [%v]", err) + } + }() + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Errorf("cannot stop FROST activation handshake exporter: [%v]", err) + } + }() + return nil +} + +func (fahe *frostActivationHandshakeExporter) close() error { + fahe.mutex.Lock() + defer fahe.mutex.Unlock() + if fahe.closed { + return nil + } + fahe.closed = true + if fahe.server == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return fahe.server.Shutdown(ctx) +} + +func (fahe *frostActivationHandshakeExporter) serveHTTP( + responseWriter http.ResponseWriter, + request *http.Request, +) { + responseWriter.Header().Set("Cache-Control", "no-store") + if request.Method != http.MethodPost || request.URL.Path != fahe.endpoint.Path { + http.Error(responseWriter, "not found", http.StatusNotFound) + return + } + remoteHost, _, err := net.SplitHostPort(request.RemoteAddr) + if err != nil || net.ParseIP(remoteHost) == nil || !net.ParseIP(remoteHost).IsLoopback() { + http.Error(responseWriter, "forbidden", http.StatusForbidden) + return + } + mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + http.Error(responseWriter, "content type must be application/json", http.StatusUnsupportedMediaType) + return + } + request.Body = http.MaxBytesReader(responseWriter, request.Body, 4096) + defer request.Body.Close() + handshakeRequest := &frostActivationHandshakeRequest{} + decoder := json.NewDecoder(request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(handshakeRequest); err != nil { + http.Error(responseWriter, "invalid request", http.StatusBadRequest) + return + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + http.Error(responseWriter, "invalid request", http.StatusBadRequest) + return + } + handshake, err := fahe.attest(request.Context(), handshakeRequest) + if err != nil { + logger.Warnf("refusing FROST activation handshake: [%v]", err) + http.Error(responseWriter, "activation state is not ready", http.StatusServiceUnavailable) + return + } + responseWriter.Header().Set("Content-Type", "application/json") + encoder := json.NewEncoder(responseWriter) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(handshake); err != nil { + logger.Errorf("cannot encode FROST activation handshake: [%v]", err) + } +} + +func (fahe *frostActivationHandshakeExporter) attest( + ctx context.Context, + request *frostActivationHandshakeRequest, +) (*frostActivationSignedHandshake, error) { + if request == nil || request.Schema != frostActivationHandshakeSchema { + return nil, fmt.Errorf("unsupported FROST activation handshake schema") + } + nonce, err := parseFrostActivationHex32(request.Challenge.Nonce) + if err != nil || nonce == [32]byte{} { + return nil, fmt.Errorf("FROST activation challenge nonce is invalid") + } + manifestHash, err := parseFrostActivationHex32(request.Challenge.ManifestHash) + if err != nil || manifestHash != fahe.manifest.ManifestHash { + return nil, fmt.Errorf("FROST activation challenge manifest hash mismatch") + } + blockHash, err := parseFrostActivationHex32(request.Challenge.EthereumPoint.BlockHash) + if err != nil || request.Challenge.EthereumPoint.BlockNumber == 0 { + return nil, fmt.Errorf("FROST activation challenge Ethereum point is invalid") + } + snapshot, err := fahe.readReadinessSnapshot() + if err != nil { + return nil, err + } + if snapshot.FrostWalletGroupInventory.Point != request.Challenge.EthereumPoint { + return nil, fmt.Errorf("FROST readiness inventory is not at the challenged point") + } + finality := FrostPreSignFinality{ + BlockNumber: request.Challenge.EthereumPoint.BlockNumber, + BlockHash: blockHash, + } + if err := fahe.pointVerifier.VerifyFrostPreSignActivationPoint(ctx, finality); err != nil { + return nil, fmt.Errorf("cannot verify FROST activation point: [%w]", err) + } + outboxSnapshot, err := fahe.outbox.activationSnapshot() + if err != nil { + return nil, err + } + if !outboxSnapshot.Recovered || outboxSnapshot.AmbiguousReservationCount != 0 || + outboxSnapshot.QuarantineCount != 0 { + return nil, fmt.Errorf("durable Bitcoin outbox is not activation-ready") + } + state := frostActivationHandshakeState{ + ProtocolID: frostActivationHex32(fahe.manifest.SignerProtocolID), + ReservationProtocolID: frostActivationHex32(fahe.manifest.ReservationProtocolID), + BitcoinOutboxProtocolID: frostActivationHex32(fahe.manifest.BitcoinOutboxProtocolID), + SigningPolicyHash: frostActivationHex32(fahe.manifest.SigningPolicyHash), + DurableSessionStoreFingerprint: snapshot.DurableSessionStoreFingerprint, + CompleteRouterAddress: frostActivationHex20(fahe.manifest.CompleteRouterAddress), + AuthorizationRegistryAddress: frostActivationHex20(fahe.manifest.AuthorizationRegistryAddress), + Threshold: fahe.manifest.Threshold, + MaximumGroupSize: fahe.manifest.MaximumGroupSize, + RetainedGroupInventoryProtocolID: frostActivationHex32(fahe.manifest.RetainedGroupInventoryProtocolID), + FrostWalletGroupInventory: snapshot.FrostWalletGroupInventory, + FinalizedReservationReadbackEnforced: true, + ExactTransactionAuthorizationRootEnforced: true, + NonceShareGateEnforced: true, + DurableBitcoinOutboxRecovered: true, + QuarantineFailClosed: true, + Healthy: true, + } + payload := frostActivationHandshakePayload{ + Kind: "frost-signer", + Nonce: request.Challenge.Nonce, + ManifestHash: request.Challenge.ManifestHash, + EthereumPoint: request.Challenge.EthereumPoint, + State: state, + } + canonicalPayload, err := canonicalFrostActivationValue(payload) + if err != nil { + return nil, err + } + signature := ed25519.Sign(fahe.privateKey, canonicalPayload) + return &frostActivationSignedHandshake{ + Payload: payload, + SignerPublicKeySPKI: fahe.publicKeySPKI, + Signature: base64.StdEncoding.EncodeToString(signature), + }, nil +} + +func decodeStrictFrostActivationJSON(data []byte, target interface{}) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + decoder.UseNumber() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("JSON contains trailing data") + } + return nil +} + +func canonicalFrostActivationValue(value interface{}) ([]byte, error) { + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + var decoded interface{} + if err := decoder.Decode(&decoded); err != nil { + return nil, err + } + buffer := bytes.NewBuffer(nil) + if err := writeCanonicalFrostActivationJSON(buffer, decoded); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func writeCanonicalFrostActivationJSON(buffer *bytes.Buffer, value interface{}) error { + switch typed := value.(type) { + case nil: + buffer.WriteString("null") + case bool: + if typed { + buffer.WriteString("true") + } else { + buffer.WriteString("false") + } + case string: + encoded, _ := json.Marshal(typed) + buffer.Write(encoded) + case json.Number: + raw := typed.String() + if strings.ContainsAny(raw, ".eE") { + return fmt.Errorf("canonical JSON number is not an integer") + } + integer, ok := new(big.Int).SetString(raw, 10) + limit := big.NewInt(9007199254740991) + if !ok || integer.Cmp(new(big.Int).Neg(limit)) < 0 || integer.Cmp(limit) > 0 { + return fmt.Errorf("canonical JSON number is unsafe") + } + buffer.WriteString(integer.String()) + case []interface{}: + buffer.WriteByte('[') + for index, item := range typed { + if index > 0 { + buffer.WriteByte(',') + } + if err := writeCanonicalFrostActivationJSON(buffer, item); err != nil { + return err + } + } + buffer.WriteByte(']') + case map[string]interface{}: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + buffer.WriteByte('{') + for index, key := range keys { + if index > 0 { + buffer.WriteByte(',') + } + encodedKey, _ := json.Marshal(key) + buffer.Write(encodedKey) + buffer.WriteByte(':') + if err := writeCanonicalFrostActivationJSON(buffer, typed[key]); err != nil { + return err + } + } + buffer.WriteByte('}') + default: + return fmt.Errorf("unsupported canonical JSON value [%T]", value) + } + return nil +} + +func parseFrostActivationHex32(value string) ([32]byte, error) { + if len(value) != 66 || !strings.HasPrefix(value, "0x") || + value != strings.ToLower(value) { + return [32]byte{}, fmt.Errorf("value is not canonical bytes32") + } + decoded, err := hex.DecodeString(value[2:]) + if err != nil || len(decoded) != 32 { + return [32]byte{}, fmt.Errorf("value is not bytes32") + } + result := [32]byte{} + copy(result[:], decoded) + return result, nil +} + +func frostActivationHex32(value [32]byte) string { + return "0x" + hex.EncodeToString(value[:]) +} + +func frostActivationHex20(value [20]byte) string { + return "0x" + hex.EncodeToString(value[:]) +} diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go new file mode 100644 index 0000000000..e963fdb4c1 --- /dev/null +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -0,0 +1,301 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/bitcoin" +) + +type testFrostActivationPointVerifier struct { + err error + point FrostPreSignFinality +} + +func (tfapv *testFrostActivationPointVerifier) VerifyFrostPreSignActivationPoint( + ctx context.Context, + point FrostPreSignFinality, +) error { + tfapv.point = point + return tfapv.err +} + +func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { + directory := t.TempDir() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatal(err) + } + privateKeyPath := filepath.Join(directory, "attestation-key.pem") + if err := os.WriteFile(privateKeyPath, pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: privateKeyDER, + }), 0600); err != nil { + t.Fatal(err) + } + publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + manifest := testFrostActivationRuntimeManifest(sha256.Sum256(publicKeyDER)) + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + readinessPath := filepath.Join(directory, "readiness.json") + writeTestFrostActivationReadiness(t, readinessPath, manifest, point) + endpoint := testLoopbackEndpoint(t) + verifier := &testFrostActivationPointVerifier{} + outbox := &bitcoinBroadcastOutbox{ + records: make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord), + recovered: true, + } + exporter, err := newFrostActivationHandshakeExporter( + endpoint, + privateKeyPath, + readinessPath, + manifest, + verifier, + outbox, + ) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := exporter.start(ctx); err != nil { + t.Fatal(err) + } + defer exporter.close() + + nonce := [32]byte{0x77} + request := frostActivationHandshakeRequest{ + Schema: frostActivationHandshakeSchema, + Challenge: frostActivationChallenge{ + Nonce: frostActivationHex32(nonce), + ManifestHash: frostActivationHex32(manifest.ManifestHash), + EthereumPoint: point, + }, + } + response := postTestFrostActivationHandshake(t, endpoint, request) + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + t.Fatalf("unexpected status [%d]: %s", response.StatusCode, body) + } + handshake := &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { + t.Fatal(err) + } + if handshake.Payload.Kind != "frost-signer" || + handshake.Payload.Nonce != request.Challenge.Nonce || + handshake.Payload.ManifestHash != request.Challenge.ManifestHash || + !handshake.Payload.State.Healthy || + !handshake.Payload.State.DurableBitcoinOutboxRecovered || + verifier.point.BlockNumber != point.BlockNumber || + frostActivationHex32(verifier.point.BlockHash) != point.BlockHash { + t.Fatalf("unexpected handshake: %+v", handshake) + } + canonicalPayload, err := canonicalFrostActivationValue(handshake.Payload) + if err != nil { + t.Fatal(err) + } + signature, err := base64.StdEncoding.Strict().DecodeString(handshake.Signature) + if err != nil || !ed25519.Verify(publicKey, canonicalPayload, signature) { + t.Fatal("handshake signature did not verify over canonical payload") + } +} + +func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { + directory := t.TempDir() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + privateKeyDER, _ := x509.MarshalPKCS8PrivateKey(privateKey) + privateKeyPath := filepath.Join(directory, "attestation-key.pem") + if err := os.WriteFile(privateKeyPath, pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: privateKeyDER, + }), 0600); err != nil { + t.Fatal(err) + } + publicKeyDER, _ := x509.MarshalPKIXPublicKey(publicKey) + manifest := testFrostActivationRuntimeManifest(sha256.Sum256(publicKeyDER)) + point := frostActivationEthereumPoint{ + BlockNumber: 7, + BlockHash: frostActivationHex32([32]byte{0x11}), + } + readinessPath := filepath.Join(directory, "readiness.json") + writeTestFrostActivationReadiness(t, readinessPath, manifest, point) + endpoint := testLoopbackEndpoint(t) + outbox := &bitcoinBroadcastOutbox{ + records: map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord{ + {0x01}: { + TransactionHash: bitcoin.Hash{0x01}, + Authorization: bitcoinBroadcastAuthorization{ + ReservationID: [32]byte{0x02}, + }, + Quarantine: &bitcoinBroadcastQuarantine{ + ActiveActivationProfileHash: [32]byte{0x03}, + ObservedAtUnix: 1, + }, + }, + }, + recovered: true, + } + exporter, err := newFrostActivationHandshakeExporter( + endpoint, + privateKeyPath, + readinessPath, + manifest, + &testFrostActivationPointVerifier{}, + outbox, + ) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := exporter.start(ctx); err != nil { + t.Fatal(err) + } + defer exporter.close() + + request := frostActivationHandshakeRequest{ + Schema: frostActivationHandshakeSchema, + Challenge: frostActivationChallenge{ + Nonce: frostActivationHex32([32]byte{0x22}), + ManifestHash: frostActivationHex32(manifest.ManifestHash), + EthereumPoint: point, + }, + } + response := postTestFrostActivationHandshake(t, endpoint, request) + defer response.Body.Close() + if response.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("quarantined outbox returned status [%d]", response.StatusCode) + } +} + +func TestCanonicalFrostActivationValue_MatchesRuntimeOrdering(t *testing.T) { + value := map[string]interface{}{ + "state": map[string]interface{}{"healthy": true, "count": 3}, + "nonce": "0x01", + "kind": "frost-signer", + } + encoded, err := canonicalFrostActivationValue(value) + if err != nil { + t.Fatal(err) + } + expected := `{"kind":"frost-signer","nonce":"0x01","state":{"count":3,"healthy":true}}` + if string(encoded) != expected { + t.Fatalf("unexpected canonical JSON\nexpected: %s\nactual: %s", expected, encoded) + } +} + +func testFrostActivationRuntimeManifest( + keyHash [32]byte, +) FrostPreSignActivationRuntimeManifest { + return FrostPreSignActivationRuntimeManifest{ + ManifestHash: [32]byte{0x10}, + SignerProtocolID: [32]byte{0x11}, + ReservationProtocolID: [32]byte{0x12}, + BitcoinOutboxProtocolID: [32]byte{0x13}, + SigningPolicyHash: [32]byte{0x14}, + DurableSessionStoreFingerprint: "store-fingerprint", + CompleteRouterAddress: [20]byte{0x15}, + AuthorizationRegistryAddress: [20]byte{0x16}, + AttestationSignerKeyHash: keyHash, + Threshold: 51, + MaximumGroupSize: 100, + RetainedGroupInventoryProtocolID: [32]byte{0x17}, + } +} + +func writeTestFrostActivationReadiness( + t *testing.T, + filePath string, + manifest FrostPreSignActivationRuntimeManifest, + point frostActivationEthereumPoint, +) { + t.Helper() + snapshot := frostActivationReadinessSnapshot{ + Schema: frostActivationReadinessSnapshotSchema, + ManifestHash: frostActivationHex32(manifest.ManifestHash), + DurableSessionStoreFingerprint: manifest.DurableSessionStoreFingerprint, + FrostWalletGroupInventory: frostActivationWalletGroupInventory{ + Schema: frostActivationInventorySchema, + Point: point, + SnapshotGeneration: 9, + InventoryRoot: frostActivationHex32([32]byte{0x18}), + WalletCount: 1, + MinimumActualGroupSize: 51, + MaximumActualGroupSize: 100, + MembershipAmbiguityCount: 0, + GroupSizeViolationCount: 0, + Complete: true, + }, + } + data, err := json.Marshal(snapshot) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filePath, data, 0600); err != nil { + t.Fatal(err) + } +} + +func testLoopbackEndpoint(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatal(err) + } + return fmt.Sprintf("http://%s/frost-activation", address) +} + +func postTestFrostActivationHandshake( + t *testing.T, + endpoint string, + request frostActivationHandshakeRequest, +) *http.Response { + t.Helper() + data, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + httpRequest, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + httpRequest.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 3 * time.Second} + response, err := client.Do(httpRequest) + if err != nil { + t.Fatal(err) + } + return response +} diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go new file mode 100644 index 0000000000..f055780efe --- /dev/null +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -0,0 +1,1933 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "reflect" + "sort" + "sync" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + ethereumCrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +const ( + frostPreSignAuthorizationMessageTypePrefix = "tbtc/frost_pre_sign_authorization/" + frostPreSignAuthorizationThreshold = 51 + frostPreSignAuthorizationMaximumSeats = 100 + frostPreSignAuthorizationMaximumInputs = 21 + + frostPreSignReservationProtocolDomain = "tbtc/p2tr-pre-signing-reservation/threshold-v1" + frostPreSignSigningPolicyDomain = "tbtc/p2tr-pre-signing-policy/default-no-annex-51-seats-v1" + frostPreSignChallengeIdentityDomain = "tbtc-p2tr-signature-fraud-authorization-v3" + frostCompleteEvidenceProtocolDomain = "tbtc/p2tr-signature-fraud/evidence/complete-v2" +) + +// FrostPreSignAction is the compact action identity committed by the on-chain +// reservation registry. It intentionally excludes heartbeat/arbitrary-message +// signing. +type FrostPreSignAction uint8 + +const ( + FrostPreSignActionDepositSweep FrostPreSignAction = iota + 1 + FrostPreSignActionRedemption + FrostPreSignActionMovingFunds + FrostPreSignActionMovedFundsSweep +) + +func frostPreSignAction(action WalletActionType) (FrostPreSignAction, error) { + switch action { + case ActionDepositSweep: + return FrostPreSignActionDepositSweep, nil + case ActionRedemption: + return FrostPreSignActionRedemption, nil + case ActionMovingFunds: + return FrostPreSignActionMovingFunds, nil + case ActionMovedFundsSweep: + return FrostPreSignActionMovedFundsSweep, nil + default: + return 0, fmt.Errorf( + "wallet action [%s] is not eligible for FROST pre-sign authorization", + action, + ) + } +} + +// FrostPreSignTransaction is the exact, stripped Bitcoin transaction and +// BIP-341 signing batch proposed by a wallet. TransactionHash uses keep-core's +// raw SHA256d digest byte order; it is never display-byte-reversed. Every +// SignatureHashes item is exactly 32 bytes, preserving leading zeroes lost by +// big.Int.Bytes(). +type FrostPreSignTransaction struct { + Action FrostPreSignAction + WalletPublicKeyHash [20]byte + Version [4]byte + InputVector []byte + OutputVector []byte + Locktime [4]byte + RawTransaction []byte + TransactionHash bitcoin.Hash + InputValues []uint64 + SigningKeys [][32]byte + SignatureHashes [][32]byte + SighashTypes []uint8 + SpendTypes []uint8 + ActionContext *FrostPreSignActionContext +} + +// FrostPreSignActionContext carries only data that has already passed the +// ordinary action validation path. The Ethereum COMPLETE_V2 adapter encodes +// exactly one branch into P2TRPreSigning actionData; nil, multiple, or +// action-mismatched branches fail closed before preview. +type FrostPreSignActionContext struct { + DepositSweep *FrostPreSignDepositSweepActionContext + Redemption *FrostPreSignRedemptionActionContext + MovingFunds *FrostPreSignMovingFundsActionContext + MovedFundsSweep *FrostPreSignMovedFundsSweepActionContext +} + +type FrostPreSignDepositSweepActionContext struct { + Proposal *DepositSweepProposal + Deposits []*Deposit + MainUtxo *bitcoin.UnspentTransactionOutput +} + +type FrostPreSignRedemptionActionContext struct { + Proposal *RedemptionProposal + MainUtxo *bitcoin.UnspentTransactionOutput +} + +type FrostPreSignMovingFundsActionContext struct { + Proposal *MovingFundsProposal + MainUtxo *bitcoin.UnspentTransactionOutput +} + +type FrostPreSignMovedFundsSweepActionContext struct { + Proposal *MovedFundsSweepProposal + MainUtxo *bitcoin.UnspentTransactionOutput +} + +func newFrostPreSignTransaction( + action WalletActionType, + walletPublicKeyHash [20]byte, + unsignedTx *bitcoin.TransactionBuilder, + signatureHashes []*big.Int, +) (*FrostPreSignTransaction, error) { + preSignAction, err := frostPreSignAction(action) + if err != nil { + return nil, err + } + if unsignedTx == nil { + return nil, fmt.Errorf("unsigned transaction builder is nil") + } + if !unsignedTx.HasOnlyTaprootKeyPathInputs() { + return nil, fmt.Errorf( + "FROST pre-sign authorization requires only P2TR key-path inputs", + ) + } + + transaction := unsignedTx.UnsignedTransaction() + if transaction == nil || len(transaction.Inputs) == 0 { + return nil, fmt.Errorf("unsigned transaction has no inputs") + } + if len(transaction.Inputs) > frostPreSignAuthorizationMaximumInputs { + return nil, fmt.Errorf( + "FROST pre-sign authorization input count [%d] exceeds maximum [%d]", + len(transaction.Inputs), + frostPreSignAuthorizationMaximumInputs, + ) + } + if len(transaction.Outputs) == 0 { + return nil, fmt.Errorf("unsigned transaction has no outputs") + } + if len(signatureHashes) != len(transaction.Inputs) { + return nil, fmt.Errorf( + "signature hash count [%d] does not match input count [%d]", + len(signatureHashes), + len(transaction.Inputs), + ) + } + for i, input := range transaction.Inputs { + if input == nil || input.Outpoint == nil { + return nil, fmt.Errorf("unsigned transaction input [%d] has no outpoint", i) + } + if len(input.SignatureScript) != 0 || len(input.Witness) != 0 { + return nil, fmt.Errorf( + "unsigned P2TR input [%d] contains pre-signing witness data", + i, + ) + } + } + + inputs, _, err := unsignedTx.UnsignedTransactionIO() + if err != nil { + return nil, fmt.Errorf("cannot extract unsigned transaction metadata: [%w]", err) + } + if len(inputs) != len(transaction.Inputs) { + return nil, fmt.Errorf("unsigned transaction metadata count mismatch") + } + + inputValues := make([]uint64, len(inputs)) + signingKeys := make([][32]byte, len(inputs)) + for i, input := range inputs { + scriptBytes, err := hex.DecodeString(input.ScriptPubKeyHex) + if err != nil { + return nil, fmt.Errorf("cannot decode input [%d] script: [%w]", i, err) + } + signingKey, err := bitcoin.ExtractTaprootKey(bitcoin.Script(scriptBytes)) + if err != nil { + return nil, fmt.Errorf("cannot extract input [%d] P2TR signing key: [%w]", i, err) + } + if signingKey == [32]byte{} { + return nil, fmt.Errorf("input [%d] P2TR signing key is zero", i) + } + + inputValues[i] = input.ValueSats + signingKeys[i] = signingKey + } + + canonicalSignatureHashes, err := unsignedTx.ComputeSignatureHashes() + if err != nil { + return nil, fmt.Errorf( + "cannot independently compute FROST signature hashes: [%w]", + err, + ) + } + if len(canonicalSignatureHashes) != len(signatureHashes) { + return nil, fmt.Errorf("canonical signature hash count mismatch") + } + fixedSignatureHashes := make([][32]byte, len(signatureHashes)) + for i, signatureHash := range signatureHashes { + supplied, err := fixedFrostPreSignSignatureHash(signatureHash) + if err != nil { + return nil, fmt.Errorf("signature hash [%d] is invalid: [%w]", i, err) + } + canonical, err := fixedFrostPreSignSignatureHash( + canonicalSignatureHashes[i], + ) + if err != nil { + return nil, fmt.Errorf( + "canonical signature hash [%d] is invalid: [%w]", + i, + err, + ) + } + if supplied != canonical { + return nil, fmt.Errorf( + "signature hash [%d] differs from canonical BIP-341 digest", + i, + ) + } + fixedSignatureHashes[i] = canonical + } + + rawTransaction := transaction.Serialize(bitcoin.Standard) + if len(rawTransaction) == 0 { + return nil, fmt.Errorf("cannot serialize stripped unsigned transaction") + } + version := transaction.SerializeVersion() + inputVector := transaction.SerializeInputs() + outputVector := transaction.SerializeOutputs() + locktime := transaction.SerializeLocktime() + expectedRaw := make([]byte, 0, len(rawTransaction)) + expectedRaw = append(expectedRaw, version[:]...) + expectedRaw = append(expectedRaw, inputVector...) + expectedRaw = append(expectedRaw, outputVector...) + expectedRaw = append(expectedRaw, locktime[:]...) + if !bytes.Equal(rawTransaction, expectedRaw) { + return nil, fmt.Errorf("stripped transaction serialization is inconsistent") + } + + result := &FrostPreSignTransaction{ + Action: preSignAction, + WalletPublicKeyHash: walletPublicKeyHash, + Version: version, + InputVector: append([]byte{}, inputVector...), + OutputVector: append([]byte{}, outputVector...), + Locktime: locktime, + RawTransaction: append([]byte{}, rawTransaction...), + TransactionHash: bitcoin.ComputeHash(rawTransaction), + InputValues: inputValues, + SigningKeys: signingKeys, + SignatureHashes: fixedSignatureHashes, + // TransactionBuilder's P2TR path is deliberately frozen to BIP-341 + // SIGHASH_DEFAULT (0) and key-path/no-annex spend_type (0). + SighashTypes: make([]uint8, len(inputs)), + SpendTypes: make([]uint8, len(inputs)), + } + if err := result.validate(); err != nil { + return nil, err + } + + return result, nil +} + +func fixedFrostPreSignSignatureHash( + signatureHash *big.Int, +) ([32]byte, error) { + result := [32]byte{} + if signatureHash == nil { + return result, fmt.Errorf("value is nil") + } + if signatureHash.Sign() < 0 || signatureHash.BitLen() > 256 { + return result, fmt.Errorf("value does not fit 32 bytes") + } + signatureHash.FillBytes(result[:]) + return result, nil +} + +func (fpst *FrostPreSignTransaction) validate() error { + if fpst == nil { + return fmt.Errorf("FROST pre-sign transaction is nil") + } + if fpst.Action < FrostPreSignActionDepositSweep || + fpst.Action > FrostPreSignActionMovedFundsSweep { + return fmt.Errorf("unknown FROST pre-sign action [%d]", fpst.Action) + } + inputsCount := len(fpst.InputValues) + if inputsCount == 0 || inputsCount > frostPreSignAuthorizationMaximumInputs { + return fmt.Errorf("invalid FROST pre-sign input count [%d]", inputsCount) + } + if len(fpst.SigningKeys) != inputsCount || + len(fpst.SignatureHashes) != inputsCount || + len(fpst.SighashTypes) != inputsCount || + len(fpst.SpendTypes) != inputsCount { + return fmt.Errorf("FROST pre-sign batch vectors are not aligned") + } + if len(fpst.InputVector) == 0 || len(fpst.OutputVector) == 0 { + return fmt.Errorf("FROST pre-sign transaction vectors are empty") + } + raw := make([]byte, 0, 8+len(fpst.InputVector)+len(fpst.OutputVector)) + raw = append(raw, fpst.Version[:]...) + raw = append(raw, fpst.InputVector...) + raw = append(raw, fpst.OutputVector...) + raw = append(raw, fpst.Locktime[:]...) + if !bytes.Equal(raw, fpst.RawTransaction) { + return fmt.Errorf("FROST pre-sign raw transaction bytes mismatch") + } + if bitcoin.ComputeHash(raw) != fpst.TransactionHash { + return fmt.Errorf("FROST pre-sign transaction SHA256d mismatch") + } + for i, signingKey := range fpst.SigningKeys { + if signingKey == [32]byte{} { + return fmt.Errorf("FROST pre-sign signing key [%d] is zero", i) + } + if fpst.SighashTypes[i] != 0 { + return fmt.Errorf( + "FROST pre-sign input [%d] is not SIGHASH_DEFAULT", + i, + ) + } + if fpst.SpendTypes[i] != 0 { + return fmt.Errorf( + "FROST pre-sign input [%d] is not key-path/no-annex", + i, + ) + } + } + canonicalSignatureHashes, err := fpst.computeDefaultSignatureHashes() + if err != nil { + return fmt.Errorf( + "cannot independently reconstruct FROST pre-sign signature hashes: [%w]", + err, + ) + } + for i := range canonicalSignatureHashes { + if canonicalSignatureHashes[i] != fpst.SignatureHashes[i] { + return fmt.Errorf( + "FROST pre-sign signature hash [%d] differs from the stripped transaction", + i, + ) + } + } + + return nil +} + +// computeDefaultSignatureHashes reconstructs the exact BIP-341 +// SIGHASH_DEFAULT/key-path/no-annex digest batch from the immutable stripped +// transaction and the UTXO values/output keys. This is intentionally separate +// from the builder computation used at construction: a backend cannot mutate a +// cached digest, value, or signing key and still pass authorization validation. +func (fpst *FrostPreSignTransaction) computeDefaultSignatureHashes() ( + [][32]byte, + error, +) { + if fpst == nil { + return nil, fmt.Errorf("FROST pre-sign transaction is nil") + } + + msgTx := wire.NewMsgTx(wire.TxVersion) + reader := bytes.NewReader(fpst.RawTransaction) + if err := msgTx.Deserialize(reader); err != nil { + return nil, fmt.Errorf("cannot decode stripped transaction: [%w]", err) + } + if reader.Len() != 0 { + return nil, fmt.Errorf( + "stripped transaction has [%d] trailing bytes", + reader.Len(), + ) + } + var canonicalRaw bytes.Buffer + if err := msgTx.SerializeNoWitness(&canonicalRaw); err != nil { + return nil, fmt.Errorf("cannot re-encode stripped transaction: [%w]", err) + } + if !bytes.Equal(canonicalRaw.Bytes(), fpst.RawTransaction) { + return nil, fmt.Errorf("stripped transaction is not canonical no-witness encoding") + } + if len(msgTx.TxIn) != len(fpst.InputValues) { + return nil, fmt.Errorf( + "decoded input count [%d] differs from metadata [%d]", + len(msgTx.TxIn), + len(fpst.InputValues), + ) + } + if len(msgTx.TxOut) == 0 { + return nil, fmt.Errorf("decoded transaction has no outputs") + } + + var prevouts bytes.Buffer + var amounts bytes.Buffer + var scriptPubKeys bytes.Buffer + var sequences bytes.Buffer + var outputs bytes.Buffer + for i, input := range msgTx.TxIn { + if _, err := prevouts.Write(input.PreviousOutPoint.Hash[:]); err != nil { + return nil, err + } + if err := binary.Write( + &prevouts, + binary.LittleEndian, + input.PreviousOutPoint.Index, + ); err != nil { + return nil, err + } + if err := binary.Write( + &amounts, + binary.LittleEndian, + fpst.InputValues[i], + ); err != nil { + return nil, err + } + p2trScript := make([]byte, 34) + p2trScript[0] = 0x51 // OP_1. + p2trScript[1] = 0x20 // 32-byte witness program. + copy(p2trScript[2:], fpst.SigningKeys[i][:]) + if err := wire.WriteVarBytes(&scriptPubKeys, 0, p2trScript); err != nil { + return nil, fmt.Errorf( + "cannot encode input [%d] P2TR script: [%w]", + i, + err, + ) + } + if err := binary.Write( + &sequences, + binary.LittleEndian, + input.Sequence, + ); err != nil { + return nil, err + } + } + for i, output := range msgTx.TxOut { + if err := wire.WriteTxOut(&outputs, 0, 0, output); err != nil { + return nil, fmt.Errorf( + "cannot encode transaction output [%d]: [%w]", + i, + err, + ) + } + } + + hashPrevouts := sha256.Sum256(prevouts.Bytes()) + hashAmounts := sha256.Sum256(amounts.Bytes()) + hashScriptPubKeys := sha256.Sum256(scriptPubKeys.Bytes()) + hashSequences := sha256.Sum256(sequences.Bytes()) + hashOutputs := sha256.Sum256(outputs.Bytes()) + + result := make([][32]byte, len(msgTx.TxIn)) + for i := range msgTx.TxIn { + var sigMsg bytes.Buffer + sigMsg.WriteByte(0x00) // Epoch. + sigMsg.WriteByte(0x00) // SIGHASH_DEFAULT. + if err := binary.Write(&sigMsg, binary.LittleEndian, msgTx.Version); err != nil { + return nil, err + } + if err := binary.Write(&sigMsg, binary.LittleEndian, msgTx.LockTime); err != nil { + return nil, err + } + sigMsg.Write(hashPrevouts[:]) + sigMsg.Write(hashAmounts[:]) + sigMsg.Write(hashScriptPubKeys[:]) + sigMsg.Write(hashSequences[:]) + sigMsg.Write(hashOutputs[:]) + sigMsg.WriteByte(0x00) // Key path, no annex. + if err := binary.Write( + &sigMsg, + binary.LittleEndian, + uint32(i), + ); err != nil { + return nil, err + } + + digest := chainhash.TaggedHash([]byte("TapSighash"), sigMsg.Bytes()) + copy(result[i][:], digest[:]) + } + + return result, nil +} + +func cloneFrostPreSignTransaction( + transaction *FrostPreSignTransaction, +) *FrostPreSignTransaction { + if transaction == nil { + return nil + } + result := *transaction + result.InputVector = append([]byte{}, transaction.InputVector...) + result.OutputVector = append([]byte{}, transaction.OutputVector...) + result.RawTransaction = append([]byte{}, transaction.RawTransaction...) + result.InputValues = append([]uint64{}, transaction.InputValues...) + result.SigningKeys = append([][32]byte{}, transaction.SigningKeys...) + result.SignatureHashes = append([][32]byte{}, transaction.SignatureHashes...) + result.SighashTypes = append([]uint8{}, transaction.SighashTypes...) + result.SpendTypes = append([]uint8{}, transaction.SpendTypes...) + result.ActionContext = cloneFrostPreSignActionContext(transaction.ActionContext) + return &result +} + +func cloneFrostPreSignActionContext( + context *FrostPreSignActionContext, +) *FrostPreSignActionContext { + if context == nil { + return nil + } + result := &FrostPreSignActionContext{} + if source := context.DepositSweep; source != nil { + deposits := make([]*Deposit, len(source.Deposits)) + for i, deposit := range source.Deposits { + deposits[i] = cloneFrostPreSignDeposit(deposit) + } + result.DepositSweep = &FrostPreSignDepositSweepActionContext{ + Proposal: cloneFrostPreSignDepositSweepProposal(source.Proposal), + Deposits: deposits, + MainUtxo: cloneFrostPreSignUtxo(source.MainUtxo), + } + } + if source := context.Redemption; source != nil { + result.Redemption = &FrostPreSignRedemptionActionContext{ + Proposal: cloneFrostPreSignRedemptionProposal(source.Proposal), + MainUtxo: cloneFrostPreSignUtxo(source.MainUtxo), + } + } + if source := context.MovingFunds; source != nil { + result.MovingFunds = &FrostPreSignMovingFundsActionContext{ + Proposal: cloneFrostPreSignMovingFundsProposal(source.Proposal), + MainUtxo: cloneFrostPreSignUtxo(source.MainUtxo), + } + } + if source := context.MovedFundsSweep; source != nil { + result.MovedFundsSweep = &FrostPreSignMovedFundsSweepActionContext{ + Proposal: cloneFrostPreSignMovedFundsSweepProposal(source.Proposal), + MainUtxo: cloneFrostPreSignUtxo(source.MainUtxo), + } + } + return result +} + +func cloneFrostPreSignUtxo( + utxo *bitcoin.UnspentTransactionOutput, +) *bitcoin.UnspentTransactionOutput { + if utxo == nil { + return nil + } + result := *utxo + if utxo.Outpoint != nil { + outpoint := *utxo.Outpoint + result.Outpoint = &outpoint + } + return &result +} + +func cloneFrostPreSignBitcoinTransaction( + transaction *bitcoin.Transaction, +) *bitcoin.Transaction { + if transaction == nil { + return nil + } + result := &bitcoin.Transaction{} + if err := result.Deserialize(transaction.Serialize(bitcoin.Standard)); err != nil { + return nil + } + return result +} + +func cloneFrostPreSignDeposit(deposit *Deposit) *Deposit { + if deposit == nil { + return nil + } + result := *deposit + result.Utxo = cloneFrostPreSignUtxo(deposit.Utxo) + result.FundingTx = cloneFrostPreSignBitcoinTransaction(deposit.FundingTx) + if deposit.WalletXOnlyPublicKey != nil { + value := *deposit.WalletXOnlyPublicKey + result.WalletXOnlyPublicKey = &value + } + if deposit.RefundXOnlyPublicKey != nil { + value := *deposit.RefundXOnlyPublicKey + result.RefundXOnlyPublicKey = &value + } + if deposit.Vault != nil { + value := *deposit.Vault + result.Vault = &value + } + if deposit.ExtraData != nil { + value := *deposit.ExtraData + result.ExtraData = &value + } + return &result +} + +func cloneFrostPreSignDepositSweepProposal( + proposal *DepositSweepProposal, +) *DepositSweepProposal { + if proposal == nil { + return nil + } + result := *proposal + result.DepositsKeys = append(result.DepositsKeys[:0:0], proposal.DepositsKeys...) + result.DepositsRevealBlocks = make([]*big.Int, len(proposal.DepositsRevealBlocks)) + for i, block := range proposal.DepositsRevealBlocks { + if block != nil { + result.DepositsRevealBlocks[i] = new(big.Int).Set(block) + } + } + if proposal.SweepTxFee != nil { + result.SweepTxFee = new(big.Int).Set(proposal.SweepTxFee) + } + return &result +} + +func cloneFrostPreSignRedemptionProposal( + proposal *RedemptionProposal, +) *RedemptionProposal { + if proposal == nil { + return nil + } + result := *proposal + result.RedeemersOutputScripts = make([]bitcoin.Script, len(proposal.RedeemersOutputScripts)) + for i, script := range proposal.RedeemersOutputScripts { + result.RedeemersOutputScripts[i] = append(bitcoin.Script{}, script...) + } + if proposal.RedemptionTxFee != nil { + result.RedemptionTxFee = new(big.Int).Set(proposal.RedemptionTxFee) + } + return &result +} + +func cloneFrostPreSignMovingFundsProposal( + proposal *MovingFundsProposal, +) *MovingFundsProposal { + if proposal == nil { + return nil + } + result := *proposal + result.TargetWallets = append([][20]byte{}, proposal.TargetWallets...) + if proposal.MovingFundsTxFee != nil { + result.MovingFundsTxFee = new(big.Int).Set(proposal.MovingFundsTxFee) + } + return &result +} + +func cloneFrostPreSignMovedFundsSweepProposal( + proposal *MovedFundsSweepProposal, +) *MovedFundsSweepProposal { + if proposal == nil { + return nil + } + result := *proposal + if proposal.SweepTxFee != nil { + result.SweepTxFee = new(big.Int).Set(proposal.SweepTxFee) + } + return &result +} + +// FrostPreSignFinality pins all post-relay reads to one finalized block. A +// backend must reject a block-number/hash mismatch instead of silently reading +// the canonical block at that height after a reorganization. +type FrostPreSignFinality struct { + RelayTransactionHash [32]byte + BlockNumber uint64 + BlockHash [32]byte + TransactionIndex uint32 + LogIndex uint32 + // AuthorizationSequence is the registry's uint256 monotonic sequence from + // P2TRAuthorizedVariantAdvanced, encoded as a canonical big-endian word. + // It is zero only for a generic current-finalized checkpoint. + AuthorizationSequence [32]byte +} + +// FrostPreSignVariantSequence is the registry's canonical global order of +// authorization variants. It is independent of block/log ordering and remains +// monotonic even when several RBF variants finalize in one block. +type FrostPreSignVariantSequence struct { + AuthorizationSequence [32]byte +} + +func frostPreSignVariantSequence( + finality FrostPreSignFinality, +) FrostPreSignVariantSequence { + return FrostPreSignVariantSequence{ + AuthorizationSequence: finality.AuthorizationSequence, + } +} + +// FrostPreSignActivationProfile is the node operator's immutable, local trust +// anchor for one reviewed deployment. The backend cannot choose these values: +// its prepared proposal must match this profile before any seat attestation is +// signed. ProfileHash pins the canonical field serialization and is intended +// to be copied from the signed deployment manifest. +type FrostPreSignActivationProfile struct { + DomainChainID [32]byte + ActivationManifestHash [32]byte + ImplementationSetHash [32]byte + BridgeAddress [20]byte + RegistryAddress [20]byte + CompleteRouter [20]byte + FrostRegistry [20]byte + ProposalValidator [20]byte + SortitionPool [20]byte + BridgeCodeHash [32]byte + RegistryCodeHash [32]byte + CompleteRouterCodeHash [32]byte + FrostRegistryCodeHash [32]byte + ProposalValidatorCodeHash [32]byte + SortitionPoolCodeHash [32]byte + ReservationProtocolID [32]byte + EvidenceProtocolID [32]byte + SigningPolicyHash [32]byte + ProfileHash [32]byte +} + +// ComputeHash returns the canonical activation-profile commitment. +func (fpsap FrostPreSignActivationProfile) ComputeHash() [32]byte { + hasher := sha256.New() + hasher.Write([]byte("tbtc-frost-pre-sign-activation-profile-v4")) + hasher.Write(fpsap.DomainChainID[:]) + hasher.Write(fpsap.ActivationManifestHash[:]) + hasher.Write(fpsap.ImplementationSetHash[:]) + hasher.Write(fpsap.BridgeAddress[:]) + hasher.Write(fpsap.RegistryAddress[:]) + hasher.Write(fpsap.CompleteRouter[:]) + hasher.Write(fpsap.FrostRegistry[:]) + hasher.Write(fpsap.ProposalValidator[:]) + hasher.Write(fpsap.SortitionPool[:]) + hasher.Write(fpsap.BridgeCodeHash[:]) + hasher.Write(fpsap.RegistryCodeHash[:]) + hasher.Write(fpsap.CompleteRouterCodeHash[:]) + hasher.Write(fpsap.FrostRegistryCodeHash[:]) + hasher.Write(fpsap.ProposalValidatorCodeHash[:]) + hasher.Write(fpsap.SortitionPoolCodeHash[:]) + hasher.Write(fpsap.ReservationProtocolID[:]) + hasher.Write(fpsap.EvidenceProtocolID[:]) + hasher.Write(fpsap.SigningPolicyHash[:]) + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result +} + +func (fpsap FrostPreSignActivationProfile) validate() error { + for name, value := range map[string][32]byte{ + "domain chain ID": fpsap.DomainChainID, + "activation manifest hash": fpsap.ActivationManifestHash, + "implementation set hash": fpsap.ImplementationSetHash, + "Bridge code hash": fpsap.BridgeCodeHash, + "authorization registry hash": fpsap.RegistryCodeHash, + "COMPLETE router code hash": fpsap.CompleteRouterCodeHash, + "FROST registry code hash": fpsap.FrostRegistryCodeHash, + "proposal validator code hash": fpsap.ProposalValidatorCodeHash, + "sortition pool code hash": fpsap.SortitionPoolCodeHash, + "reservation protocol ID": fpsap.ReservationProtocolID, + "evidence protocol ID": fpsap.EvidenceProtocolID, + "signing policy hash": fpsap.SigningPolicyHash, + "profile hash": fpsap.ProfileHash, + } { + if value == [32]byte{} { + return fmt.Errorf("FROST pre-sign activation %s is zero", name) + } + } + for name, value := range map[string][20]byte{ + "Bridge address": fpsap.BridgeAddress, + "authorization registry address": fpsap.RegistryAddress, + "COMPLETE router address": fpsap.CompleteRouter, + "FROST registry address": fpsap.FrostRegistry, + "proposal validator address": fpsap.ProposalValidator, + "sortition pool address": fpsap.SortitionPool, + } { + if value == [20]byte{} { + return fmt.Errorf("FROST pre-sign activation %s is zero", name) + } + } + if fpsap.ReservationProtocolID != frostPreSignReservationProtocolID() { + return fmt.Errorf("FROST pre-sign reservation protocol ID is not COMPLETE_V2") + } + if fpsap.SigningPolicyHash != frostPreSignSigningPolicyHash() { + return fmt.Errorf("FROST pre-sign signing policy is not COMPLETE_V2") + } + if fpsap.EvidenceProtocolID != frostCompleteEvidenceProtocolID() { + return fmt.Errorf("FROST fraud evidence protocol is not COMPLETE_V2") + } + if fpsap.ComputeHash() != fpsap.ProfileHash { + return fmt.Errorf("FROST pre-sign activation profile hash mismatch") + } + return nil +} + +// ValidateForProduction validates the immutable activation trust anchor. It is +// exported for deployment-specific chain adapters that must reject malformed +// signed manifests before exposing themselves as authorization backends. +func (fpsap FrostPreSignActivationProfile) ValidateForProduction() error { + return fpsap.validate() +} + +func (fpsap FrostPreSignActivationProfile) validateProposal( + proposal *FrostPreSignAuthorizationProposal, +) error { + if err := fpsap.validate(); err != nil { + return err + } + if proposal == nil || + proposal.DomainChainID != fpsap.DomainChainID || + proposal.ActivationManifestHash != fpsap.ActivationManifestHash || + proposal.ImplementationSetHash != fpsap.ImplementationSetHash || + proposal.BridgeAddress != fpsap.BridgeAddress || + proposal.RegistryAddress != fpsap.RegistryAddress || + proposal.CompleteRouter != fpsap.CompleteRouter || + proposal.FrostRegistry != fpsap.FrostRegistry || + proposal.ProposalValidator != fpsap.ProposalValidator || + proposal.SortitionPool != fpsap.SortitionPool || + proposal.BridgeCodeHash != fpsap.BridgeCodeHash || + proposal.RegistryCodeHash != fpsap.RegistryCodeHash || + proposal.CompleteRouterCodeHash != fpsap.CompleteRouterCodeHash || + proposal.FrostRegistryCodeHash != fpsap.FrostRegistryCodeHash || + proposal.ProposalValidatorCodeHash != fpsap.ProposalValidatorCodeHash || + proposal.SortitionPoolCodeHash != fpsap.SortitionPoolCodeHash || + proposal.ReservationProtocolID != fpsap.ReservationProtocolID || + proposal.EvidenceProtocolID != fpsap.EvidenceProtocolID || + proposal.SigningPolicyHash != fpsap.SigningPolicyHash { + return fmt.Errorf( + "FROST pre-sign proposal differs from the local activation profile", + ) + } + return nil +} + +// FrostPreSignAuthorizationProposal adds the exact state-derived reservation +// plan and immutable deployment expectations to a Bitcoin signing batch. The +// backend obtains these values from the canonical Bitcoin indexer and pinned +// Ethereum views before seat attestations are collected. +type FrostPreSignAuthorizationProposal struct { + Transaction *FrostPreSignTransaction + + WalletID [32]byte + SnapshotHash [32]byte + ResourceHash [32]byte + OrderedInputRoot [32]byte + ApplyPlanHash [32]byte + ApplyPlanData1 [32]byte + ApplyPlanData2 [32]byte + FeeLimitSnapshot uint64 + ResourceIDs [][32]byte + WalletMembersIDs []uint32 + WalletMembersIDsHash [32]byte + + ReservationID [32]byte + AuthorizationRoot [32]byte + Digest [32]byte + + DomainChainID [32]byte + ActivationManifestHash [32]byte + ImplementationSetHash [32]byte + BridgeAddress [20]byte + RegistryAddress [20]byte + CompleteRouter [20]byte + FrostRegistry [20]byte + ProposalValidator [20]byte + SortitionPool [20]byte + BridgeCodeHash [32]byte + RegistryCodeHash [32]byte + CompleteRouterCodeHash [32]byte + FrostRegistryCodeHash [32]byte + ProposalValidatorCodeHash [32]byte + SortitionPoolCodeHash [32]byte + ReservationProtocolID [32]byte + EvidenceProtocolID [32]byte + SigningPolicyHash [32]byte + PreparationFinality FrostPreSignFinality +} + +func (fpsap *FrostPreSignAuthorizationProposal) validate() error { + if fpsap == nil { + return fmt.Errorf("FROST pre-sign authorization proposal is nil") + } + if err := fpsap.Transaction.validate(); err != nil { + return err + } + for name, value := range map[string][32]byte{ + "wallet ID": fpsap.WalletID, + "snapshot hash": fpsap.SnapshotHash, + "resource hash": fpsap.ResourceHash, + "ordered input root": fpsap.OrderedInputRoot, + "apply plan hash": fpsap.ApplyPlanHash, + "reservation ID": fpsap.ReservationID, + "authorization root": fpsap.AuthorizationRoot, + "authorization digest": fpsap.Digest, + "wallet members IDs hash": fpsap.WalletMembersIDsHash, + "domain chain ID": fpsap.DomainChainID, + "activation manifest hash": fpsap.ActivationManifestHash, + "implementation set hash": fpsap.ImplementationSetHash, + "Bridge code hash": fpsap.BridgeCodeHash, + "authorization registry code hash": fpsap.RegistryCodeHash, + "COMPLETE router code hash": fpsap.CompleteRouterCodeHash, + "FROST registry code hash": fpsap.FrostRegistryCodeHash, + "proposal validator code hash": fpsap.ProposalValidatorCodeHash, + "sortition pool code hash": fpsap.SortitionPoolCodeHash, + "reservation protocol ID": fpsap.ReservationProtocolID, + "evidence protocol ID": fpsap.EvidenceProtocolID, + "signing policy hash": fpsap.SigningPolicyHash, + } { + if value == [32]byte{} { + return fmt.Errorf("FROST pre-sign %s is zero", name) + } + } + for name, value := range map[string][20]byte{ + "Bridge address": fpsap.BridgeAddress, + "authorization registry address": fpsap.RegistryAddress, + "COMPLETE router address": fpsap.CompleteRouter, + "FROST registry address": fpsap.FrostRegistry, + "proposal validator address": fpsap.ProposalValidator, + "sortition pool address": fpsap.SortitionPool, + } { + if value == [20]byte{} { + return fmt.Errorf("FROST pre-sign %s is zero", name) + } + } + if len(fpsap.ResourceIDs) == 0 || len(fpsap.ResourceIDs) > 64 { + return fmt.Errorf("invalid FROST pre-sign resource count [%d]", len(fpsap.ResourceIDs)) + } + for i, resourceID := range fpsap.ResourceIDs { + if resourceID == [32]byte{} { + return fmt.Errorf("FROST pre-sign resource ID [%d] is zero", i) + } + if i > 0 && bytes.Compare(fpsap.ResourceIDs[i-1][:], resourceID[:]) >= 0 { + return fmt.Errorf("FROST pre-sign resource IDs are not sorted and unique") + } + } + if len(fpsap.WalletMembersIDs) < frostPreSignAuthorizationThreshold || + len(fpsap.WalletMembersIDs) > frostPreSignAuthorizationMaximumSeats { + return fmt.Errorf( + "invalid FROST wallet member count [%d]", + len(fpsap.WalletMembersIDs), + ) + } + for i, memberID := range fpsap.WalletMembersIDs { + if memberID == 0 { + return fmt.Errorf("FROST wallet member ID [%d] is zero", i) + } + } + if fpsap.PreparationFinality.BlockNumber == 0 || + fpsap.PreparationFinality.BlockHash == [32]byte{} { + return fmt.Errorf("FROST pre-sign preparation is not pinned to finality") + } + if fpsap.ReservationProtocolID != frostPreSignReservationProtocolID() { + return fmt.Errorf("FROST pre-sign reservation protocol ID is not COMPLETE_V2") + } + if fpsap.SigningPolicyHash != frostPreSignSigningPolicyHash() { + return fmt.Errorf("FROST pre-sign signing policy is not COMPLETE_V2") + } + if fpsap.EvidenceProtocolID != frostCompleteEvidenceProtocolID() { + return fmt.Errorf("FROST fraud evidence protocol is not COMPLETE_V2") + } + if err := fpsap.validateLocalCommitments(); err != nil { + return err + } + + return nil +} + +func frostPreSignReservationProtocolID() [32]byte { + return frostPreSignKeccak256([]byte(frostPreSignReservationProtocolDomain)) +} + +func frostPreSignSigningPolicyHash() [32]byte { + return frostPreSignKeccak256([]byte(frostPreSignSigningPolicyDomain)) +} + +func frostCompleteEvidenceProtocolID() [32]byte { + return frostPreSignKeccak256([]byte(frostCompleteEvidenceProtocolDomain)) +} + +func frostPreSignKeccak256(data []byte) [32]byte { + digest := ethereumCrypto.Keccak256(data) + result := [32]byte{} + copy(result[:], digest) + return result +} + +func frostPreSignKeccak256Words(words ...[32]byte) [32]byte { + encoded := make([]byte, 0, len(words)*32) + for _, word := range words { + encoded = append(encoded, word[:]...) + } + return frostPreSignKeccak256(encoded) +} + +func frostPreSignABIUint8(value uint8) [32]byte { + result := [32]byte{} + result[31] = value + return result +} + +func frostPreSignABIUint32(value uint32) [32]byte { + result := [32]byte{} + binary.BigEndian.PutUint32(result[28:], value) + return result +} + +func frostPreSignABIUint64(value uint64) [32]byte { + result := [32]byte{} + binary.BigEndian.PutUint64(result[24:], value) + return result +} + +// Solidity ABI encodes fixed bytesN left-aligned, unlike address and integer +// values, which are right-aligned in a 32-byte word. +func frostPreSignABIBytes20(value [20]byte) [32]byte { + result := [32]byte{} + copy(result[:20], value[:]) + return result +} + +func frostPreSignABIAddress(value [20]byte) [32]byte { + result := [32]byte{} + copy(result[12:], value[:]) + return result +} + +func frostPreSignABIBytes32Array(values [][32]byte) []byte { + // abi.encode(bytes32[]) = head offset || array length || elements. + encoded := make([]byte, 0, 64+len(values)*32) + offset := frostPreSignABIUint32(32) + length := frostPreSignABIUint64(uint64(len(values))) + encoded = append(encoded, offset[:]...) + encoded = append(encoded, length[:]...) + for _, value := range values { + encoded = append(encoded, value[:]...) + } + return encoded +} + +func frostPreSignABIUint32Array(values []uint32) []byte { + // abi.encode(uint32[]) = head offset || array length || padded elements. + encoded := make([]byte, 0, 64+len(values)*32) + offset := frostPreSignABIUint32(32) + length := frostPreSignABIUint64(uint64(len(values))) + encoded = append(encoded, offset[:]...) + encoded = append(encoded, length[:]...) + for _, value := range values { + word := frostPreSignABIUint32(value) + encoded = append(encoded, word[:]...) + } + return encoded +} + +func (fpsap *FrostPreSignAuthorizationProposal) computeAuthorizationRoot() ( + [32]byte, + error, +) { + if fpsap == nil || fpsap.Transaction == nil { + return [32]byte{}, fmt.Errorf("FROST pre-sign authorization proposal is nil") + } + if len(fpsap.Transaction.SigningKeys) != len(fpsap.Transaction.SignatureHashes) { + return [32]byte{}, fmt.Errorf("FROST pre-sign signing-key/digest vectors are not aligned") + } + + identities := make([][32]byte, len(fpsap.Transaction.SigningKeys)) + for i := range identities { + preimage := make( + []byte, + 0, + len(frostPreSignChallengeIdentityDomain)+32+20+32+32+32, + ) + preimage = append(preimage, []byte(frostPreSignChallengeIdentityDomain)...) + preimage = append(preimage, fpsap.DomainChainID[:]...) + preimage = append(preimage, fpsap.BridgeAddress[:]...) + preimage = append(preimage, fpsap.WalletID[:]...) + preimage = append(preimage, fpsap.Transaction.SigningKeys[i][:]...) + preimage = append(preimage, fpsap.Transaction.SignatureHashes[i][:]...) + identities[i] = sha256.Sum256(preimage) + } + + return frostPreSignKeccak256(frostPreSignABIBytes32Array(identities)), nil +} + +func (fpsap *FrostPreSignAuthorizationProposal) computeLockedPlanHash() [32]byte { + return frostPreSignKeccak256Words( + fpsap.ResourceHash, + fpsap.OrderedInputRoot, + fpsap.ApplyPlanData1, + fpsap.ApplyPlanData2, + frostPreSignABIUint64(fpsap.FeeLimitSnapshot), + ) +} + +func (fpsap *FrostPreSignAuthorizationProposal) computeReservationID() [32]byte { + walletScopeHash := frostPreSignKeccak256Words( + frostPreSignABIUint8(uint8(fpsap.Transaction.Action)), + frostPreSignABIBytes20(fpsap.Transaction.WalletPublicKeyHash), + fpsap.WalletID, + fpsap.WalletMembersIDsHash, + fpsap.SnapshotHash, + ) + return frostPreSignKeccak256Words( + fpsap.ReservationProtocolID, + fpsap.DomainChainID, + frostPreSignABIAddress(fpsap.BridgeAddress), + frostPreSignABIAddress(fpsap.RegistryAddress), + frostPreSignABIAddress(fpsap.FrostRegistry), + frostPreSignABIAddress(fpsap.ProposalValidator), + walletScopeHash, + fpsap.computeLockedPlanHash(), + ) +} + +func (fpsap *FrostPreSignAuthorizationProposal) computeDigest( + authorizationRoot [32]byte, +) [32]byte { + return frostPreSignKeccak256Words( + fpsap.ReservationProtocolID, + fpsap.SigningPolicyHash, + fpsap.DomainChainID, + frostPreSignABIAddress(fpsap.BridgeAddress), + frostPreSignABIAddress(fpsap.RegistryAddress), + frostPreSignABIAddress(fpsap.FrostRegistry), + frostPreSignABIAddress(fpsap.ProposalValidator), + fpsap.computeReservationID(), + [32]byte(fpsap.Transaction.TransactionHash), + fpsap.ApplyPlanHash, + authorizationRoot, + ) +} + +func (fpsap *FrostPreSignAuthorizationProposal) validateLocalCommitments() error { + expectedMembersHash := frostPreSignKeccak256( + frostPreSignABIUint32Array(fpsap.WalletMembersIDs), + ) + if fpsap.WalletMembersIDsHash != expectedMembersHash { + return fmt.Errorf("FROST pre-sign wallet members IDs hash differs from local ABI encoding") + } + expectedResourceHash := frostPreSignKeccak256( + frostPreSignABIBytes32Array(fpsap.ResourceIDs), + ) + if fpsap.ResourceHash != expectedResourceHash { + return fmt.Errorf("FROST pre-sign resource hash differs from local ABI encoding") + } + expectedAuthorizationRoot, err := fpsap.computeAuthorizationRoot() + if err != nil { + return err + } + if fpsap.AuthorizationRoot != expectedAuthorizationRoot { + return fmt.Errorf("FROST pre-sign authorization root differs from local COMPLETE_V2 computation") + } + if fpsap.ReservationID != fpsap.computeReservationID() { + return fmt.Errorf("FROST pre-sign reservation ID differs from local COMPLETE_V2 computation") + } + if fpsap.Digest != fpsap.computeDigest(expectedAuthorizationRoot) { + return fmt.Errorf("FROST pre-sign authorization digest differs from local COMPLETE_V2 computation") + } + return nil +} + +func cloneFrostPreSignAuthorizationProposal( + proposal *FrostPreSignAuthorizationProposal, +) *FrostPreSignAuthorizationProposal { + if proposal == nil { + return nil + } + result := *proposal + result.Transaction = cloneFrostPreSignTransaction(proposal.Transaction) + result.ResourceIDs = append([][32]byte{}, proposal.ResourceIDs...) + result.WalletMembersIDs = append([]uint32{}, proposal.WalletMembersIDs...) + return &result +} + +// FrostPreSignSeatAttestation is ABI-shaped: indices are one-based seat +// positions, strictly increasing, and signatures are packed in matching order. +// Operators are intentionally not deduplicated because one ordinary Ethereum +// operator key may occupy multiple distinct wallet seats. +type FrostPreSignSeatAttestation struct { + WalletMembersIDs []uint32 + SigningMemberIndices []uint8 + Signatures []byte +} + +// FrostPreSignAuthorizationState is the complete state re-read at one pinned +// finalized block. It contains enough information to reject proxy/crosslink or +// code changes, archived wallets, altered reservations, and mismatched RBF +// variants before native signing starts. +type FrostPreSignAuthorizationState struct { + Finality FrostPreSignFinality + + DomainChainID [32]byte + ActivationManifestHash [32]byte + ImplementationSetHash [32]byte + BridgeAddress [20]byte + RegistryAddress [20]byte + CompleteRouter [20]byte + FrostRegistry [20]byte + ProposalValidator [20]byte + SortitionPool [20]byte + BridgeCodeHash [32]byte + RegistryCodeHash [32]byte + CompleteRouterCodeHash [32]byte + FrostRegistryCodeHash [32]byte + ProposalValidatorCodeHash [32]byte + SortitionPoolCodeHash [32]byte + ReservationProtocolID [32]byte + EvidenceProtocolID [32]byte + SigningPolicyHash [32]byte + + WalletActive bool + WalletID [32]byte + WalletPublicKeyHash [20]byte + WalletMembersIDsHash [32]byte + WalletXOnlyOutputKey [32]byte + + ActiveReservationID [32]byte + ReservationWalletID [32]byte + ReservationWalletPublicKeyHash [20]byte + ReservationSnapshotHash [32]byte + ReservationResourceHash [32]byte + ReservationOrderedInputRoot [32]byte + ReservationApplyPlanData1 [32]byte + ReservationApplyPlanData2 [32]byte + ReservationFeeLimitSnapshot uint64 + ReservationAction FrostPreSignAction + ReservationActive bool + + VariantTransactionHash bitcoin.Hash + VariantReservationID [32]byte + VariantAuthorizationRoot [32]byte + VariantApplyPlanHash [32]byte + VariantAuthorizationSequence [32]byte + VariantFraudDefenseAuthorized bool + VariantSigningAllowed bool + + LatestVariantTransactionHash bitcoin.Hash + LatestVariantAuthorizationSequence [32]byte + LatestVariantSigningAllowed bool +} + +// FrostPreSignAuthorizationBackend is the ABI-independent anchoring boundary. +// The Ethereum implementation owns exact ABI packing, canonical-indexer plan +// derivation, relay submission, receipt finality, and block-hash-pinned calls. +// There is deliberately no permissive/default implementation: a network must +// supply a backend compiled against its reviewed COMPLETE ABI before node +// activation can pass the fail-closed startup checks. +type FrostPreSignAuthorizationBackend interface { + PrepareFrostPreSignAuthorization( + context.Context, + *FrostPreSignTransaction, + []chain.Address, + ) (*FrostPreSignAuthorizationProposal, error) + RelayFrostPreSignAuthorization( + context.Context, + *FrostPreSignAuthorizationProposal, + *FrostPreSignSeatAttestation, + ) ([32]byte, error) + WaitForFrostPreSignAuthorizationFinality( + context.Context, + [32]byte, + *FrostPreSignAuthorizationProposal, + ) (*FrostPreSignFinality, error) + // CurrentFrostPreSignFinality returns the latest canonical finalized block + // checkpoint. RelayTransactionHash/transaction/log positions are ignored for + // this checkpoint; BlockNumber and BlockHash are mandatory. Release guards + // use it to detect a reservation settled or conflicted after its relay block. + CurrentFrostPreSignFinality( + context.Context, + ) (*FrostPreSignFinality, error) + ReadFrostPreSignAuthorizationState( + context.Context, + *FrostPreSignAuthorizationProposal, + FrostPreSignFinality, + ) (*FrostPreSignAuthorizationState, error) +} + +// FrostPreSignAuthorizationConfigurator is implemented by production chain +// adapters that load and independently verify a deployment manifest before +// exposing the authorization backend. +type FrostPreSignAuthorizationConfigurator interface { + ConfigureFrostPreSignAuthorization( + context.Context, + string, + string, + string, + ) (*FrostPreSignActivationProfile, error) +} + +// FrostPreSignActivationPointVerifier authenticates one exact finalized +// Ethereum point and all deployment/proxy/library bindings there. Runtime +// readiness attestations use it instead of assuming the latest finalized head +// stayed unchanged while an activation audit was in flight. +type FrostPreSignActivationPointVerifier interface { + VerifyFrostPreSignActivationPoint( + context.Context, + FrostPreSignFinality, + ) error +} + +// FrostPreSignActivationRuntimeManifest is the signer-facing subset of the +// authenticated production activation envelope. It is immutable for the +// process lifetime and feeds the nonce-bound runtime status exporter. +type FrostPreSignActivationRuntimeManifest struct { + ManifestHash [32]byte + SignerProtocolID [32]byte + ReservationProtocolID [32]byte + BitcoinOutboxProtocolID [32]byte + SigningPolicyHash [32]byte + DurableSessionStoreFingerprint string + CompleteRouterAddress [20]byte + AuthorizationRegistryAddress [20]byte + AttestationSignerKeyHash [32]byte + Threshold uint64 + MaximumGroupSize uint64 + RetainedGroupInventoryProtocolID [32]byte +} + +type FrostPreSignActivationRuntimeManifestSource interface { + FrostPreSignActivationRuntimeManifest() ( + FrostPreSignActivationRuntimeManifest, + error, + ) +} + +type frostPreSignAuthorization struct { + ActivationProfileHash [32]byte + AuthorizationID [32]byte + ReservationID [32]byte + VariantRoot [32]byte + TransactionHash bitcoin.Hash + Finality FrostPreSignFinality + VariantSequence FrostPreSignVariantSequence + proposal *FrostPreSignAuthorizationProposal +} + +type frostPreSignAuthorizationGate interface { + authorize( + context.Context, + *FrostPreSignTransaction, + ) (*frostPreSignAuthorization, error) + revalidate( + context.Context, + *frostPreSignAuthorization, + ) error +} + +type thresholdFrostPreSignAuthorizationGate struct { + backend FrostPreSignAuthorizationBackend + activationProfile FrostPreSignActivationProfile + signing chain.Signing + broadcastChannel net.BroadcastChannel + membershipValidator *group.MembershipValidator + wallet wallet + localMemberIndexes []group.MemberIndex + threshold int +} + +func newThresholdFrostPreSignAuthorizationGate( + backend FrostPreSignAuthorizationBackend, + activationProfile FrostPreSignActivationProfile, + signing chain.Signing, + broadcastChannel net.BroadcastChannel, + membershipValidator *group.MembershipValidator, + wallet wallet, + localMemberIndexes []group.MemberIndex, +) (*thresholdFrostPreSignAuthorizationGate, error) { + if backend == nil { + return nil, fmt.Errorf("FROST pre-sign authorization backend is nil") + } + if err := activationProfile.validate(); err != nil { + return nil, err + } + if signing == nil { + return nil, fmt.Errorf("FROST pre-sign Ethereum signer is nil") + } + if broadcastChannel == nil { + return nil, fmt.Errorf("FROST pre-sign broadcast channel is nil") + } + if membershipValidator == nil { + return nil, fmt.Errorf("FROST pre-sign membership validator is nil") + } + if len(localMemberIndexes) == 0 { + return nil, fmt.Errorf("FROST pre-sign gate controls no wallet seats") + } + + seen := make(map[group.MemberIndex]struct{}) + indexes := make([]group.MemberIndex, 0, len(localMemberIndexes)) + for _, memberIndex := range localMemberIndexes { + if memberIndex == 0 || int(memberIndex) > wallet.groupSize() { + return nil, fmt.Errorf("invalid local FROST wallet seat [%d]", memberIndex) + } + if _, ok := seen[memberIndex]; ok { + continue + } + seen[memberIndex] = struct{}{} + indexes = append(indexes, memberIndex) + } + sort.Slice(indexes, func(i, j int) bool { return indexes[i] < indexes[j] }) + + registerFrostPreSignAuthorizationUnmarshaller(broadcastChannel) + return &thresholdFrostPreSignAuthorizationGate{ + backend: backend, + activationProfile: activationProfile, + signing: signing, + broadcastChannel: broadcastChannel, + membershipValidator: membershipValidator, + wallet: wallet, + localMemberIndexes: indexes, + threshold: frostPreSignAuthorizationThreshold, + }, nil +} + +func (tfpsag *thresholdFrostPreSignAuthorizationGate) authorize( + ctx context.Context, + transaction *FrostPreSignTransaction, +) (*frostPreSignAuthorization, error) { + if ctx == nil { + return nil, fmt.Errorf("FROST pre-sign authorization context is nil") + } + if err := transaction.validate(); err != nil { + return nil, err + } + // Freeze the locally constructed signing batch before crossing the backend + // boundary. Passing the caller's pointer would let an adapter mutate both the + // proposal and the comparison target in place, making pointer-equal data pass + // reflect.DeepEqual after its canonical BIP-341 digests changed. + frozenTransaction := cloneFrostPreSignTransaction(transaction) + + preparedProposal, err := tfpsag.backend.PrepareFrostPreSignAuthorization( + ctx, + cloneFrostPreSignTransaction(frozenTransaction), + append([]chain.Address{}, tfpsag.wallet.signingGroupOperators...), + ) + if err != nil { + return nil, fmt.Errorf("cannot prepare FROST pre-sign authorization: [%w]", err) + } + proposal := cloneFrostPreSignAuthorizationProposal(preparedProposal) + if err := proposal.validate(); err != nil { + return nil, fmt.Errorf("invalid FROST pre-sign authorization proposal: [%w]", err) + } + if !reflect.DeepEqual(proposal.Transaction, frozenTransaction) { + return nil, fmt.Errorf("authorization backend changed the proposed Bitcoin signing batch") + } + if len(proposal.WalletMembersIDs) != tfpsag.wallet.groupSize() { + return nil, fmt.Errorf( + "authorization wallet member count [%d] differs from local wallet [%d]", + len(proposal.WalletMembersIDs), + tfpsag.wallet.groupSize(), + ) + } + if err := tfpsag.activationProfile.validateProposal(proposal); err != nil { + return nil, err + } + + attestation, err := tfpsag.collectSeatAttestations(ctx, proposal) + if err != nil { + return nil, err + } + relayTransactionHash, err := tfpsag.backend.RelayFrostPreSignAuthorization( + ctx, + proposal, + attestation, + ) + if err != nil { + return nil, fmt.Errorf("cannot relay FROST pre-sign authorization: [%w]", err) + } + if relayTransactionHash == [32]byte{} { + return nil, fmt.Errorf("FROST pre-sign relay transaction hash is zero") + } + + finality, err := tfpsag.backend.WaitForFrostPreSignAuthorizationFinality( + ctx, + relayTransactionHash, + proposal, + ) + if err != nil { + return nil, fmt.Errorf("FROST pre-sign authorization did not finalize: [%w]", err) + } + if finality == nil || finality.RelayTransactionHash != relayTransactionHash || + finality.BlockNumber == 0 || finality.BlockHash == [32]byte{} || + finality.AuthorizationSequence == [32]byte{} { + return nil, fmt.Errorf("invalid FROST pre-sign authorization finality proof") + } + + authorization := &frostPreSignAuthorization{ + ActivationProfileHash: tfpsag.activationProfile.ProfileHash, + AuthorizationID: proposal.Digest, + ReservationID: proposal.ReservationID, + VariantRoot: proposal.AuthorizationRoot, + TransactionHash: frozenTransaction.TransactionHash, + Finality: *finality, + VariantSequence: frostPreSignVariantSequence(*finality), + proposal: proposal, + } + if err := tfpsag.revalidate(ctx, authorization); err != nil { + return nil, err + } + + return authorization, nil +} + +func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( + ctx context.Context, + authorization *frostPreSignAuthorization, +) error { + if authorization == nil || authorization.proposal == nil { + return fmt.Errorf("FROST pre-sign authorization is nil") + } + if err := authorization.proposal.validate(); err != nil { + return fmt.Errorf("FROST pre-sign authorization proposal changed: [%w]", err) + } + if authorization.ActivationProfileHash != tfpsag.activationProfile.ProfileHash || + authorization.AuthorizationID != authorization.proposal.Digest || + authorization.ReservationID != authorization.proposal.ReservationID || + authorization.VariantRoot != authorization.proposal.AuthorizationRoot || + authorization.TransactionHash != authorization.proposal.Transaction.TransactionHash || + authorization.VariantSequence != frostPreSignVariantSequence(authorization.Finality) { + return fmt.Errorf("FROST pre-sign authorization identity changed") + } + + if err := tfpsag.validatePinnedAuthorizationStateTwice( + ctx, + authorization.proposal, + authorization.Finality, + authorization.VariantSequence, + ); err != nil { + return err + } + + // The relay block remains a necessary canonicality witness, but it is not a + // current authorization oracle. A reservation may be settled or conflicted in + // any later finalized block. Pin the latest finalized head and require the + // exact reservation/variant to remain active there before every nonce, share, + // signature, enqueue, or replay release boundary. + currentFinality, err := tfpsag.backend.CurrentFrostPreSignFinality(ctx) + if err != nil { + return fmt.Errorf("cannot obtain current FROST pre-sign finality: [%w]", err) + } + if currentFinality == nil || + currentFinality.BlockNumber < authorization.Finality.BlockNumber || + currentFinality.BlockHash == [32]byte{} { + return fmt.Errorf("invalid current FROST pre-sign finalized checkpoint") + } + if currentFinality.BlockNumber == authorization.Finality.BlockNumber && + currentFinality.BlockHash != authorization.Finality.BlockHash { + return fmt.Errorf("current FROST pre-sign checkpoint conflicts with relay finality") + } + if err := tfpsag.validatePinnedAuthorizationStateTwice( + ctx, + authorization.proposal, + *currentFinality, + authorization.VariantSequence, + ); err != nil { + return err + } + + return nil +} + +func (tfpsag *thresholdFrostPreSignAuthorizationGate) validatePinnedAuthorizationStateTwice( + ctx context.Context, + proposal *FrostPreSignAuthorizationProposal, + finality FrostPreSignFinality, + variantSequence FrostPreSignVariantSequence, +) error { + first, err := tfpsag.backend.ReadFrostPreSignAuthorizationState( + ctx, + proposal, + finality, + ) + if err != nil { + return fmt.Errorf("cannot read finalized FROST pre-sign authorization: [%w]", err) + } + if err := validateFrostPreSignAuthorizationState( + proposal, + finality, + variantSequence, + first, + ); err != nil { + return err + } + second, err := tfpsag.backend.ReadFrostPreSignAuthorizationState( + ctx, + proposal, + finality, + ) + if err != nil { + return fmt.Errorf("cannot re-read finalized FROST pre-sign authorization: [%w]", err) + } + if err := validateFrostPreSignAuthorizationState( + proposal, + finality, + variantSequence, + second, + ); err != nil { + return err + } + if !reflect.DeepEqual(first, second) { + return fmt.Errorf("finalized FROST pre-sign authorization changed between pinned reads") + } + return nil +} + +func validateFrostPreSignAuthorizationState( + proposal *FrostPreSignAuthorizationProposal, + finality FrostPreSignFinality, + variantSequence FrostPreSignVariantSequence, + state *FrostPreSignAuthorizationState, +) error { + if state == nil { + return fmt.Errorf("finalized FROST pre-sign authorization state is nil") + } + if state.Finality != finality { + return fmt.Errorf("finalized FROST pre-sign block number/hash changed") + } + if state.DomainChainID != proposal.DomainChainID || + state.ActivationManifestHash != proposal.ActivationManifestHash || + state.ImplementationSetHash != proposal.ImplementationSetHash || + state.BridgeAddress != proposal.BridgeAddress || + state.RegistryAddress != proposal.RegistryAddress || + state.CompleteRouter != proposal.CompleteRouter || + state.FrostRegistry != proposal.FrostRegistry || + state.ProposalValidator != proposal.ProposalValidator || + state.SortitionPool != proposal.SortitionPool || + state.BridgeCodeHash != proposal.BridgeCodeHash || + state.RegistryCodeHash != proposal.RegistryCodeHash || + state.CompleteRouterCodeHash != proposal.CompleteRouterCodeHash || + state.FrostRegistryCodeHash != proposal.FrostRegistryCodeHash || + state.ProposalValidatorCodeHash != proposal.ProposalValidatorCodeHash || + state.SortitionPoolCodeHash != proposal.SortitionPoolCodeHash || + state.ReservationProtocolID != proposal.ReservationProtocolID || + state.EvidenceProtocolID != proposal.EvidenceProtocolID || + state.SigningPolicyHash != proposal.SigningPolicyHash { + return fmt.Errorf("finalized FROST deployment domain/crosslink/code mismatch") + } + if !state.WalletActive || + state.WalletID != proposal.WalletID || + state.WalletPublicKeyHash != proposal.Transaction.WalletPublicKeyHash || + state.WalletMembersIDsHash != proposal.WalletMembersIDsHash || + state.WalletXOnlyOutputKey != proposal.WalletID { + return fmt.Errorf("FROST wallet is not active under the finalized registry crosslink") + } + if state.ActiveReservationID != proposal.ReservationID || + state.ReservationWalletID != proposal.WalletID || + state.ReservationWalletPublicKeyHash != proposal.Transaction.WalletPublicKeyHash || + state.ReservationSnapshotHash != proposal.SnapshotHash || + state.ReservationResourceHash != proposal.ResourceHash || + state.ReservationOrderedInputRoot != proposal.OrderedInputRoot || + state.ReservationApplyPlanData1 != proposal.ApplyPlanData1 || + state.ReservationApplyPlanData2 != proposal.ApplyPlanData2 || + state.ReservationFeeLimitSnapshot != proposal.FeeLimitSnapshot || + state.ReservationAction != proposal.Transaction.Action || + !state.ReservationActive { + return fmt.Errorf("finalized FROST reservation differs from the attested proposal") + } + if state.VariantTransactionHash != proposal.Transaction.TransactionHash || + state.VariantReservationID != proposal.ReservationID || + state.VariantAuthorizationRoot != proposal.AuthorizationRoot || + state.VariantApplyPlanHash != proposal.ApplyPlanHash || + state.VariantAuthorizationSequence != variantSequence.AuthorizationSequence || + !state.VariantFraudDefenseAuthorized || + !state.VariantSigningAllowed { + return fmt.Errorf("finalized FROST transaction variant differs from the attested proposal") + } + if state.LatestVariantTransactionHash != proposal.Transaction.TransactionHash || + state.LatestVariantAuthorizationSequence != variantSequence.AuthorizationSequence || + !state.LatestVariantSigningAllowed { + return fmt.Errorf("finalized FROST transaction variant has been superseded") + } + + return nil +} + +type frostPreSignAuthorizationMessage struct { + SenderIDValue uint32 `json:"senderID"` + Digest []byte `json:"digest"` + PublicKey []byte `json:"publicKey"` + Signature []byte `json:"signature"` +} + +func (fpsam *frostPreSignAuthorizationMessage) SenderID() group.MemberIndex { + return group.MemberIndex(fpsam.SenderIDValue) +} + +func (fpsam *frostPreSignAuthorizationMessage) Type() string { + return frostPreSignAuthorizationMessageTypePrefix + "seat_attestation" +} + +func (fpsam *frostPreSignAuthorizationMessage) Marshal() ([]byte, error) { + return json.Marshal(fpsam) +} + +func (fpsam *frostPreSignAuthorizationMessage) Unmarshal(data []byte) error { + if err := json.Unmarshal(data, fpsam); err != nil { + return err + } + if fpsam.SenderID() == 0 { + return fmt.Errorf("sender seat is zero") + } + if len(fpsam.Digest) != 32 { + return fmt.Errorf("authorization digest length [%d] is not 32", len(fpsam.Digest)) + } + if len(fpsam.PublicKey) == 0 { + return fmt.Errorf("operator public key is empty") + } + if len(fpsam.Signature) != 65 { + return fmt.Errorf("operator signature length [%d] is not 65", len(fpsam.Signature)) + } + return nil +} + +func registerFrostPreSignAuthorizationUnmarshaller(channel net.BroadcastChannel) { + channel.SetUnmarshaler(func() net.TaggedUnmarshaler { + return &frostPreSignAuthorizationMessage{} + }) +} + +func (tfpsag *thresholdFrostPreSignAuthorizationGate) collectSeatAttestations( + ctx context.Context, + proposal *FrostPreSignAuthorizationProposal, +) (*FrostPreSignSeatAttestation, error) { + signature, err := tfpsag.signing.Sign(proposal.Digest[:]) + if err != nil { + return nil, fmt.Errorf("cannot sign FROST pre-sign authorization digest: [%w]", err) + } + if len(signature) != 65 { + return nil, fmt.Errorf("FROST pre-sign Ethereum signature length [%d] is not 65", len(signature)) + } + + localSeats := make(map[group.MemberIndex]struct{}) + signatures := make(map[group.MemberIndex][]byte) + for _, memberIndex := range tfpsag.localMemberIndexes { + localSeats[memberIndex] = struct{}{} + signatures[memberIndex] = append([]byte{}, signature...) + message := &frostPreSignAuthorizationMessage{ + SenderIDValue: uint32(memberIndex), + Digest: append([]byte{}, proposal.Digest[:]...), + PublicKey: append([]byte{}, tfpsag.signing.PublicKey()...), + Signature: append([]byte{}, signature...), + } + if err := tfpsag.broadcastChannel.Send( + ctx, + message, + net.BackoffRetransmissionStrategy, + ); err != nil { + return nil, fmt.Errorf("cannot broadcast FROST pre-sign seat attestation: [%w]", err) + } + } + + // Admit at most one message per authenticated remote seat. A Byzantine seat + // can always withhold its own attestation, but it must not be able to enqueue + // unlimited invalid retransmissions, keep a finite buffer full, and make the + // other honest seats' messages drop. With one slot per remote seat, groupSize+1 + // is a strict upper bound even when callbacks run concurrently. + messageChannel := make(chan *frostPreSignAuthorizationMessage, len(proposal.WalletMembersIDs)+1) + seenRemoteSeats := make(map[group.MemberIndex]struct{}) + var seenRemoteSeatsMutex sync.Mutex + receiveCtx, cancelReceive := context.WithCancel(ctx) + defer cancelReceive() + tfpsag.broadcastChannel.Recv(receiveCtx, func(message net.Message) { + payload, ok := message.Payload().(*frostPreSignAuthorizationMessage) + if !ok || payload == nil { + return + } + if !frostPreSignTransportPublicKeyMatches( + payload.PublicKey, + message.SenderPublicKey(), + ) { + return + } + seat := payload.SenderID() + if _, isLocal := localSeats[seat]; isLocal || seat == 0 || int(seat) > len(proposal.WalletMembersIDs) { + return + } + if !tfpsag.membershipValidator.IsValidMembership(seat, message.SenderPublicKey()) { + return + } + if !claimFrostPreSignRemoteSeat( + seat, + len(proposal.WalletMembersIDs), + localSeats, + seenRemoteSeats, + &seenRemoteSeatsMutex, + ) { + return + } + select { + case messageChannel <- payload: + default: + logger.Warnf("dropping FROST pre-sign seat attestation [%d]; collector buffer full", seat) + } + }) + + for len(signatures) < tfpsag.threshold { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("FROST pre-sign seat attestation collection interrupted: [%w]", ctx.Err()) + case message := <-messageChannel: + seat := message.SenderID() + if !bytes.Equal(message.Digest, proposal.Digest[:]) { + continue + } + valid, err := tfpsag.signing.VerifyWithPublicKey( + proposal.Digest[:], + message.Signature, + message.PublicKey, + ) + if err != nil || !valid { + continue + } + expectedOperator := tfpsag.wallet.signingGroupOperators[seat-1] + actualOperator := tfpsag.signing.PublicKeyBytesToAddress(message.PublicKey) + if actualOperator != expectedOperator { + continue + } + if existing, ok := signatures[seat]; ok { + if !bytes.Equal(existing, message.Signature) { + logger.Warnf("dropping conflicting FROST pre-sign signature for seat [%d]", seat) + } + continue + } + signatures[seat] = append([]byte{}, message.Signature...) + } + } + + return buildFrostPreSignSeatAttestation( + proposal.WalletMembersIDs, + signatures, + tfpsag.threshold, + ) +} + +func claimFrostPreSignRemoteSeat( + seat group.MemberIndex, + walletSize int, + localSeats map[group.MemberIndex]struct{}, + seenRemoteSeats map[group.MemberIndex]struct{}, + mutex *sync.Mutex, +) bool { + if seat == 0 || int(seat) > walletSize || + seenRemoteSeats == nil || mutex == nil { + return false + } + if _, isLocal := localSeats[seat]; isLocal { + return false + } + mutex.Lock() + defer mutex.Unlock() + if _, seen := seenRemoteSeats[seat]; seen { + return false + } + seenRemoteSeats[seat] = struct{}{} + return true +} + +func frostPreSignTransportPublicKeyMatches( + payloadPublicKey []byte, + transportPublicKey []byte, +) bool { + return len(payloadPublicKey) > 0 && + bytes.Equal(payloadPublicKey, transportPublicKey) +} + +func buildFrostPreSignSeatAttestation( + walletMembersIDs []uint32, + signatures map[group.MemberIndex][]byte, + threshold int, +) (*FrostPreSignSeatAttestation, error) { + if threshold <= 0 || len(signatures) < threshold { + return nil, fmt.Errorf( + "insufficient FROST pre-sign seat attestations [%d/%d]", + len(signatures), + threshold, + ) + } + seats := make([]int, 0, len(signatures)) + for seat := range signatures { + seats = append(seats, int(seat)) + } + sort.Ints(seats) + if len(seats) > threshold { + seats = seats[:threshold] + } + indices := make([]uint8, 0, len(seats)) + packedSignatures := make([]byte, 0, len(seats)*65) + for _, seat := range seats { + if seat > 255 { + return nil, fmt.Errorf("FROST pre-sign seat [%d] does not fit uint8", seat) + } + if seat <= 0 || seat > len(walletMembersIDs) { + return nil, fmt.Errorf("FROST pre-sign seat [%d] is outside the wallet", seat) + } + if len(signatures[group.MemberIndex(seat)]) != 65 { + return nil, fmt.Errorf("FROST pre-sign seat [%d] signature is not 65 bytes", seat) + } + indices = append(indices, uint8(seat)) + packedSignatures = append(packedSignatures, signatures[group.MemberIndex(seat)]...) + } + + return &FrostPreSignSeatAttestation{ + WalletMembersIDs: append([]uint32{}, walletMembersIDs...), + SigningMemberIndices: indices, + Signatures: packedSignatures, + }, nil +} + +func frostPreSignTransactionIdentity(transaction *FrostPreSignTransaction) [32]byte { + if transaction == nil { + return [32]byte{} + } + hasher := sha256.New() + hasher.Write([]byte("tbtc-frost-pre-sign-transaction-v1")) + hasher.Write([]byte{byte(transaction.Action)}) + hasher.Write(transaction.WalletPublicKeyHash[:]) + hasher.Write(transaction.TransactionHash[:]) + for _, signatureHash := range transaction.SignatureHashes { + hasher.Write(signatureHash[:]) + } + for i := range transaction.SighashTypes { + hasher.Write([]byte{transaction.SighashTypes[i], transaction.SpendTypes[i]}) + } + for _, value := range transaction.InputValues { + var valueBytes [8]byte + binary.BigEndian.PutUint64(valueBytes[:], value) + hasher.Write(valueBytes[:]) + } + for _, signingKey := range transaction.SigningKeys { + hasher.Write(signingKey[:]) + } + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result +} diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go new file mode 100644 index 0000000000..17ca5e4e54 --- /dev/null +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -0,0 +1,847 @@ +package tbtc + +import ( + "context" + "encoding/hex" + "math/big" + "strings" + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +func testFrostPreSignTransaction( + t *testing.T, + unsignedTx *bitcoin.TransactionBuilder, +) *FrostPreSignTransaction { + t.Helper() + signatureHashes, err := unsignedTx.ComputeSignatureHashes() + if err != nil { + t.Fatal(err) + } + transaction, err := newFrostPreSignTransaction( + ActionDepositSweep, + [20]byte{0x11}, + unsignedTx, + signatureHashes, + ) + if err != nil { + t.Fatal(err) + } + return transaction +} + +func TestFrostPreSignTransaction_StrippedHashAndLeadingZeroSighash(t *testing.T) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + signatureHashes, err := unsignedTx.ComputeSignatureHashes() + if err != nil { + t.Fatal(err) + } + transaction, err := newFrostPreSignTransaction( + ActionDepositSweep, + [20]byte{0x11}, + unsignedTx, + signatureHashes, + ) + if err != nil { + t.Fatal(err) + } + + expectedRaw := unsignedTx.UnsignedTransaction().Serialize(bitcoin.Standard) + if string(transaction.RawTransaction) != string(expectedRaw) { + t.Fatal("pre-sign proposal did not retain exact stripped bytes") + } + if transaction.TransactionHash != bitcoin.ComputeHash(expectedRaw) { + t.Fatal("pre-sign proposal reversed or changed the raw SHA256d txid") + } + if transaction.TransactionHash != unsignedTx.UnsignedTransaction().Hash() { + t.Fatal("pre-sign proposal hash differs from keep-core internal txid") + } + if len(transaction.SignatureHashes) != 1 { + t.Fatal("unexpected signature hash count") + } + expectedSignatureHash, err := fixedFrostPreSignSignatureHash(signatureHashes[0]) + if err != nil { + t.Fatal(err) + } + if transaction.SignatureHashes[0] != expectedSignatureHash { + t.Fatal("canonical fixed-width sighash was not preserved") + } + if transaction.SighashTypes[0] != 0 || transaction.SpendTypes[0] != 0 { + t.Fatal("proposal is not frozen to DEFAULT/key-path/no-annex") + } +} + +func TestFixedFrostPreSignSignatureHash_PreservesLeadingZeroes(t *testing.T) { + result, err := fixedFrostPreSignSignatureHash(big.NewInt(1)) + if err != nil { + t.Fatal(err) + } + for i := 0; i < len(result)-1; i++ { + if result[i] != 0 { + t.Fatal("leading-zero sighash padding was not preserved") + } + } + if result[len(result)-1] != 1 { + t.Fatal("unexpected fixed-width sighash value") + } +} + +func TestFrostPreSignTransaction_RejectsCallerSuppliedSighashMismatch( + t *testing.T, +) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + canonical, err := unsignedTx.ComputeSignatureHashes() + if err != nil { + t.Fatal(err) + } + malicious := new(big.Int).Xor(canonical[0], big.NewInt(1)) + if _, err := newFrostPreSignTransaction( + ActionDepositSweep, + [20]byte{0x11}, + unsignedTx, + []*big.Int{malicious}, + ); err == nil || !strings.Contains(err.Error(), "canonical BIP-341 digest") { + t.Fatalf("unexpected mismatched-sighash result: [%v]", err) + } +} + +func TestFrostPreSignTransaction_RejectsFlexibleSighashAndNonKeyPathMode(t *testing.T) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + transaction := testFrostPreSignTransaction(t, unsignedTx) + + transaction.SighashTypes[0] = 1 + if err := transaction.validate(); err == nil || + !strings.Contains(err.Error(), "SIGHASH_DEFAULT") { + t.Fatalf("unexpected flexible-sighash validation result: [%v]", err) + } + transaction.SighashTypes[0] = 0 + transaction.SpendTypes[0] = 1 + if err := transaction.validate(); err == nil || + !strings.Contains(err.Error(), "key-path/no-annex") { + t.Fatalf("unexpected spend-mode validation result: [%v]", err) + } +} + +func TestFrostPreSignTransaction_RejectsPostConstructionSighashMetadataMutation( + t *testing.T, +) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + transaction := testFrostPreSignTransaction(t, unsignedTx) + + changedValue := cloneFrostPreSignTransaction(transaction) + changedValue.InputValues[0]++ + if err := changedValue.validate(); err == nil || + !strings.Contains(err.Error(), "differs from the stripped transaction") { + t.Fatalf("mutated input value retained a cached BIP-341 digest: [%v]", err) + } + + changedKey := cloneFrostPreSignTransaction(transaction) + changedKey.SigningKeys[0][0] ^= 0xff + if err := changedKey.validate(); err == nil || + !strings.Contains(err.Error(), "differs from the stripped transaction") { + t.Fatalf("mutated signing key retained a cached BIP-341 digest: [%v]", err) + } +} + +func TestValidateFrostPreSignAuthorizationState_RejectsReorgAndInactiveWallet(t *testing.T) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + transaction := testFrostPreSignTransaction(t, unsignedTx) + proposal := completeTestFrostPreSignProposal(transaction) + finality := FrostPreSignFinality{ + RelayTransactionHash: [32]byte{0x71}, + BlockNumber: 100, + BlockHash: [32]byte{0x72}, + AuthorizationSequence: [32]byte{31: 1}, + } + state := completeTestFrostPreSignState(proposal, finality) + sequence := frostPreSignVariantSequence(finality) + if err := validateFrostPreSignAuthorizationState(proposal, finality, sequence, state); err != nil { + t.Fatalf("valid finalized state was rejected: [%v]", err) + } + + reorged := *state + reorged.Finality.BlockHash = [32]byte{0xff} + if err := validateFrostPreSignAuthorizationState(proposal, finality, sequence, &reorged); err == nil { + t.Fatal("expected pinned finalized block hash mismatch to fail") + } + inactive := *state + inactive.WalletActive = false + if err := validateFrostPreSignAuthorizationState(proposal, finality, sequence, &inactive); err == nil { + t.Fatal("expected inactive/archived wallet to fail") + } + superseded := *state + superseded.VariantSigningAllowed = false + superseded.LatestVariantTransactionHash = bitcoin.Hash{0xff} + superseded.LatestVariantAuthorizationSequence = [32]byte{31: 2} + if err := validateFrostPreSignAuthorizationState(proposal, finality, sequence, &superseded); err == nil { + t.Fatal("expected a superseded RBF variant to fail signing authorization") + } +} + +func TestThresholdFrostPreSignAuthorizationGate_RevalidatesCurrentFinalizedState( + t *testing.T, +) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + transaction := testFrostPreSignTransaction(t, unsignedTx) + proposal := completeTestFrostPreSignProposal(transaction) + relayFinality := FrostPreSignFinality{ + RelayTransactionHash: [32]byte{0x71}, + BlockNumber: 100, + BlockHash: [32]byte{0x72}, + AuthorizationSequence: [32]byte{31: 1}, + } + currentFinality := FrostPreSignFinality{ + BlockNumber: 101, + BlockHash: [32]byte{0x73}, + } + backend := &testFrostPreSignAuthorizationBackend{ + proposal: proposal, + currentFinality: ¤tFinality, + states: map[uint64]*FrostPreSignAuthorizationState{ + relayFinality.BlockNumber: completeTestFrostPreSignState( + proposal, + relayFinality, + ), + currentFinality.BlockNumber: completeTestFrostPreSignState( + proposal, + currentFinality, + ), + }, + } + gate := &thresholdFrostPreSignAuthorizationGate{ + backend: backend, + activationProfile: activationProfileForTestProposal(proposal), + } + authorization := &frostPreSignAuthorization{ + ActivationProfileHash: gate.activationProfile.ProfileHash, + AuthorizationID: proposal.Digest, + ReservationID: proposal.ReservationID, + VariantRoot: proposal.AuthorizationRoot, + TransactionHash: proposal.Transaction.TransactionHash, + Finality: relayFinality, + VariantSequence: frostPreSignVariantSequence(relayFinality), + proposal: proposal, + } + if err := gate.revalidate(context.Background(), authorization); err != nil { + t.Fatalf("current active authorization was rejected: [%v]", err) + } + + superseded := *backend.states[currentFinality.BlockNumber] + superseded.VariantSigningAllowed = false + superseded.LatestVariantTransactionHash = bitcoin.Hash{0xff} + superseded.LatestVariantAuthorizationSequence = [32]byte{31: 2} + backend.states[currentFinality.BlockNumber] = &superseded + if err := gate.revalidate(context.Background(), authorization); err == nil || + !strings.Contains(err.Error(), "transaction variant") { + t.Fatalf("superseded current authorization was accepted: [%v]", err) + } +} + +func TestFrostPreSignActivationProfile_IndependentlyPinsProposal(t *testing.T) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + transaction := testFrostPreSignTransaction(t, unsignedTx) + proposal := completeTestFrostPreSignProposal(transaction) + profile := activationProfileForTestProposal(proposal) + if err := profile.validateProposal(proposal); err != nil { + t.Fatalf("valid locally pinned proposal was rejected: [%v]", err) + } + + backendSubstitution := *proposal + backendSubstitution.RegistryCodeHash = [32]byte{0xff} + if err := profile.validateProposal(&backendSubstitution); err == nil { + t.Fatal("backend-controlled code hash bypassed local activation profile") + } + tamperedProfile := profile + tamperedProfile.BridgeAddress[0] ^= 0xff + if err := tamperedProfile.validate(); err == nil { + t.Fatal("activation profile mutation did not invalidate its manifest hash") + } +} + +func TestFrostPreSignAuthorizationProposal_COMPLETEV2CommitmentVectors( + t *testing.T, +) { + repeat := func(value byte, count int) []byte { + result := make([]byte, count) + for i := range result { + result[i] = value + } + return result + } + bytes20 := func(value byte) [20]byte { + result := [20]byte{} + copy(result[:], repeat(value, len(result))) + return result + } + bytes32 := func(value byte) [32]byte { + result := [32]byte{} + copy(result[:], repeat(value, len(result))) + return result + } + fromHex := func(value string) [32]byte { + decoded, err := hex.DecodeString(value) + if err != nil || len(decoded) != 32 { + t.Fatalf("invalid expected commitment vector [%s]: [%v]", value, err) + } + result := [32]byte{} + copy(result[:], decoded) + return result + } + + baseProposal := &FrostPreSignAuthorizationProposal{ + Transaction: &FrostPreSignTransaction{ + Action: FrostPreSignActionDepositSweep, + WalletPublicKeyHash: bytes20(0x55), + TransactionHash: bitcoin.Hash(bytes32(0xdd)), + SigningKeys: [][32]byte{bytes32(0xf1), bytes32(0xf2)}, + SignatureHashes: [][32]byte{bytes32(0xa1), bytes32(0xa2)}, + }, + WalletID: bytes32(0x66), + WalletMembersIDsHash: bytes32(0x77), + SnapshotHash: bytes32(0x88), + ResourceHash: bytes32(0x99), + OrderedInputRoot: bytes32(0xaa), + ApplyPlanHash: bytes32(0xee), + FeeLimitSnapshot: 12345, + DomainChainID: [32]byte{31: 1}, + BridgeAddress: bytes20(0x11), + RegistryAddress: bytes20(0x22), + FrostRegistry: bytes20(0x33), + ProposalValidator: bytes20(0x44), + ReservationProtocolID: frostPreSignReservationProtocolID(), + SigningPolicyHash: frostPreSignSigningPolicyHash(), + } + + authorizationRoot, err := baseProposal.computeAuthorizationRoot() + if err != nil { + t.Fatal(err) + } + for name, actualExpected := range map[string][2][32]byte{ + "reservation protocol ID": { + baseProposal.ReservationProtocolID, + fromHex("abd8644248fc5423764f05fbeee2ba1c29e4e7067062568d624974470830571f"), + }, + "signing policy hash": { + baseProposal.SigningPolicyHash, + fromHex("742307b79bb33abdff195fbb3e5b3aebdccdec6a7194c4659c71a46223dbebf0"), + }, + "authorization root": { + authorizationRoot, + fromHex("1cbed17d3761265884413bd0b96f1afc08cb83ca4d97cd1a922e3a453b569297"), + }, + } { + if actualExpected[0] != actualExpected[1] { + t.Fatalf("%s differs from Solidity/ethers vector: [%x]", name, actualExpected[0]) + } + } + + // These vectors were independently generated with ethers' Solidity ABI + // encoder. COMPLETE_V2 binds two plan-data words for deposit sweep, one for + // redemption, and two intentional zero words for both moving-funds actions. + for _, test := range []struct { + name string + action FrostPreSignAction + applyPlanData1 [32]byte + applyPlanData2 [32]byte + lockedPlanHash string + reservationID string + authorizationDig string + }{ + { + "deposit sweep", + FrostPreSignActionDepositSweep, + bytes32(0xbb), + bytes32(0xcc), + "992974de2148fe99daf8dd8bf45f62f39927e9ee4e00d346c519ab5664b99970", + "0a1aa347dc581bc1b611c8f487b331616b55503cb031d7724feda25b00ed0fae", + "71c5a0592f0ac17c7ce7ee04ab6d211318a850fa6995acf34c8826269d8c5c8b", + }, + { + "redemption", + FrostPreSignActionRedemption, + bytes32(0xbb), + [32]byte{}, + "8e4d3b1c018e48f2fe7ff0b798ae3475309f07ecba1b6495c65da1f80448b213", + "6b2a67b13cf42b5fab432b66d6a0df560b0a97eb20bb23b17fde80261ec10767", + "ea513ab39cc9119f1c082d4df5593d571fe8505ffb8915351f878e502d54f298", + }, + { + "moving funds", + FrostPreSignActionMovingFunds, + [32]byte{}, + [32]byte{}, + "dd16ba2ae1af6da990302560cbefad6b7dd80cce6ed4cf09d19265d6d3166b9e", + "1e21da4908e2a150a39f9c01a49c45976f8b452abbcb93dacafebb56196462c6", + "f1d45fdb68c0a714e7252d99849dc83e9d8dc79ce5385a520c9c8ef71a30fb38", + }, + { + "moved funds sweep", + FrostPreSignActionMovedFundsSweep, + [32]byte{}, + [32]byte{}, + "dd16ba2ae1af6da990302560cbefad6b7dd80cce6ed4cf09d19265d6d3166b9e", + "95a5b080aa3436611347f8cd00674fdff9e100a8b3df224cd4c1278ad62bed5d", + "7a028a7a2c1cb69db8525531d2ea96ac1b1d5c8b573b790ba1f08612ec8c00b8", + }, + } { + t.Run(test.name, func(t *testing.T) { + proposal := *baseProposal + transaction := *baseProposal.Transaction + transaction.Action = test.action + proposal.Transaction = &transaction + proposal.ApplyPlanData1 = test.applyPlanData1 + proposal.ApplyPlanData2 = test.applyPlanData2 + + for name, actualExpected := range map[string][2][32]byte{ + "locked plan hash": { + proposal.computeLockedPlanHash(), + fromHex(test.lockedPlanHash), + }, + "reservation ID": { + proposal.computeReservationID(), + fromHex(test.reservationID), + }, + "pre-authorization digest": { + proposal.computeDigest(authorizationRoot), + fromHex(test.authorizationDig), + }, + } { + if actualExpected[0] != actualExpected[1] { + t.Fatalf( + "%s differs from Solidity/ethers vector: [%x]", + name, + actualExpected[0], + ) + } + } + }) + } +} + +func TestFrostPreSignAuthorizationProposal_AcceptsActionSpecificZeroPlanData( + t *testing.T, +) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + + for _, test := range []struct { + name string + action FrostPreSignAction + applyPlanData1 [32]byte + }{ + {"redemption", FrostPreSignActionRedemption, [32]byte{0x38}}, + {"moving funds", FrostPreSignActionMovingFunds, [32]byte{}}, + {"moved funds sweep", FrostPreSignActionMovedFundsSweep, [32]byte{}}, + } { + t.Run(test.name, func(t *testing.T) { + transaction := testFrostPreSignTransaction(t, unsignedTx) + transaction.Action = test.action + proposal := completeTestFrostPreSignProposal(transaction) + proposal.ApplyPlanData1 = test.applyPlanData1 + proposal.ApplyPlanData2 = [32]byte{} + proposal.ReservationID = proposal.computeReservationID() + proposal.Digest = proposal.computeDigest(proposal.AuthorizationRoot) + + if err := proposal.validate(); err != nil { + t.Fatalf("valid action-specific zero plan data was rejected: [%v]", err) + } + }) + } +} + +func TestFrostPreSignAuthorizationProposal_RejectsBackendCommitmentSubstitution( + t *testing.T, +) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + transaction := testFrostPreSignTransaction(t, unsignedTx) + valid := completeTestFrostPreSignProposal(transaction) + if err := valid.validate(); err != nil { + t.Fatalf("valid locally recomputed proposal was rejected: [%v]", err) + } + + for name, mutate := range map[string]func(*FrostPreSignAuthorizationProposal){ + "digest": func(proposal *FrostPreSignAuthorizationProposal) { + proposal.Digest[0] ^= 0xff + }, + "authorization root": func(proposal *FrostPreSignAuthorizationProposal) { + proposal.AuthorizationRoot[0] ^= 0xff + }, + "reservation ID": func(proposal *FrostPreSignAuthorizationProposal) { + proposal.ReservationID[0] ^= 0xff + }, + "wallet members hash": func(proposal *FrostPreSignAuthorizationProposal) { + proposal.WalletMembersIDsHash[0] ^= 0xff + }, + "resource hash": func(proposal *FrostPreSignAuthorizationProposal) { + proposal.ResourceHash[0] ^= 0xff + }, + } { + t.Run(name, func(t *testing.T) { + proposal := cloneFrostPreSignAuthorizationProposal(valid) + mutate(proposal) + if err := proposal.validate(); err == nil { + t.Fatal("backend-controlled commitment substitution was accepted") + } + }) + } +} + +func TestThresholdFrostPreSignAuthorizationGate_ProfileMismatchPrecedesSeatSignature( + t *testing.T, +) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + transaction := testFrostPreSignTransaction(t, unsignedTx) + proposal := completeTestFrostPreSignProposal(transaction) + profile := activationProfileForTestProposal(proposal) + proposal.RegistryCodeHash = [32]byte{0xff} + countingSigner := &countingFrostPreSignChainSigning{ + Signing: Connect().Signing(), + } + gate := &thresholdFrostPreSignAuthorizationGate{ + backend: &testFrostPreSignAuthorizationBackend{proposal: proposal}, + activationProfile: profile, + signing: countingSigner, + wallet: wallet{ + signingGroupOperators: make( + []chain.Address, + frostPreSignAuthorizationMaximumSeats, + ), + }, + localMemberIndexes: []group.MemberIndex{1}, + threshold: frostPreSignAuthorizationThreshold, + } + + if _, err := gate.authorize(context.Background(), transaction); err == nil { + t.Fatal("expected local activation profile mismatch to fail") + } + if countingSigner.signCalls != 0 { + t.Fatal("seat signature was produced before local activation-profile validation") + } +} + +func TestBuildFrostPreSignSeatAttestation_UsesExactlyLowestThresholdSeats(t *testing.T) { + members := make([]uint32, frostPreSignAuthorizationMaximumSeats) + for i := range members { + members[i] = uint32(i + 1) + } + signatures := make(map[group.MemberIndex][]byte) + for seat := group.MemberIndex(1); seat <= 60; seat++ { + signature := make([]byte, 65) + signature[0] = byte(seat) + signatures[seat] = signature + } + + attestation, err := buildFrostPreSignSeatAttestation( + members, + signatures, + frostPreSignAuthorizationThreshold, + ) + if err != nil { + t.Fatal(err) + } + if len(attestation.SigningMemberIndices) != frostPreSignAuthorizationThreshold || + len(attestation.Signatures) != frostPreSignAuthorizationThreshold*65 { + t.Fatal("attestation did not contain exactly the Solidity threshold") + } + for i, seat := range attestation.SigningMemberIndices { + if seat != uint8(i+1) || attestation.Signatures[i*65] != seat { + t.Fatal("attestation did not select deterministic lowest unique seats") + } + } +} + +func TestFrostPreSignTransportPublicKeyMatches(t *testing.T) { + if !frostPreSignTransportPublicKeyMatches([]byte{1, 2}, []byte{1, 2}) { + t.Fatal("matching payload and transport key was rejected") + } + if frostPreSignTransportPublicKeyMatches([]byte{1, 2}, []byte{1, 3}) { + t.Fatal("payload public key substitution was accepted") + } +} + +func TestClaimFrostPreSignRemoteSeat_BoundsAdmissionPerAuthenticatedSeat( + t *testing.T, +) { + local := map[group.MemberIndex]struct{}{1: {}} + seen := make(map[group.MemberIndex]struct{}) + mutex := &sync.Mutex{} + + if !claimFrostPreSignRemoteSeat(2, 100, local, seen, mutex) { + t.Fatal("first remote-seat attestation was not admitted") + } + for i := 0; i < 1000; i++ { + if claimFrostPreSignRemoteSeat(2, 100, local, seen, mutex) { + t.Fatal("duplicate Byzantine-seat flood was admitted") + } + } + if claimFrostPreSignRemoteSeat(1, 100, local, seen, mutex) { + t.Fatal("local-seat network echo was admitted") + } + if claimFrostPreSignRemoteSeat(0, 100, local, seen, mutex) || + claimFrostPreSignRemoteSeat(101, 100, local, seen, mutex) { + t.Fatal("out-of-range seat was admitted") + } +} + +type testFrostPreSignAuthorizationGate struct { + mutex sync.Mutex + authorizeErr error + revalidateErr error + authorizeCalls int + revalidateCalls int + finalizedBlock uint64 + proposal *FrostPreSignAuthorizationProposal +} + +func (tfpsag *testFrostPreSignAuthorizationGate) authorize( + ctx context.Context, + transaction *FrostPreSignTransaction, +) (*frostPreSignAuthorization, error) { + tfpsag.authorizeCalls++ + if tfpsag.authorizeErr != nil { + return nil, tfpsag.authorizeErr + } + proposal := completeTestFrostPreSignProposal(transaction) + if tfpsag.proposal != nil { + proposal = tfpsag.proposal + proposal.Transaction = transaction + } + finalizedBlock := tfpsag.finalizedBlock + if finalizedBlock == 0 { + finalizedBlock = 10 + } + authorization := &frostPreSignAuthorization{ + ActivationProfileHash: testOutboxActivationProfile, + AuthorizationID: proposal.Digest, + ReservationID: proposal.ReservationID, + VariantRoot: proposal.AuthorizationRoot, + TransactionHash: transaction.TransactionHash, + Finality: FrostPreSignFinality{ + RelayTransactionHash: [32]byte{0x71}, + BlockNumber: finalizedBlock, + BlockHash: [32]byte{0x72}, + AuthorizationSequence: [32]byte{31: 1}, + }, + VariantSequence: FrostPreSignVariantSequence{ + AuthorizationSequence: [32]byte{31: 1}, + }, + proposal: proposal, + } + return authorization, nil +} + +func (tfpsag *testFrostPreSignAuthorizationGate) revalidate( + ctx context.Context, + authorization *frostPreSignAuthorization, +) error { + tfpsag.mutex.Lock() + defer tfpsag.mutex.Unlock() + tfpsag.revalidateCalls++ + return tfpsag.revalidateErr +} + +func (tfpsag *testFrostPreSignAuthorizationGate) setRevalidateError(err error) { + tfpsag.mutex.Lock() + defer tfpsag.mutex.Unlock() + tfpsag.revalidateErr = err +} + +func completeTestFrostPreSignProposal( + transaction *FrostPreSignTransaction, +) *FrostPreSignAuthorizationProposal { + members := make([]uint32, frostPreSignAuthorizationMaximumSeats) + for i := range members { + members[i] = uint32(i + 1) + } + resourceIDs := [][32]byte{{0x26}} + proposal := &FrostPreSignAuthorizationProposal{ + Transaction: transaction, + WalletID: [32]byte{0x21}, + SnapshotHash: [32]byte{0x22}, + OrderedInputRoot: [32]byte{0x24}, + ApplyPlanHash: [32]byte{0x25}, + ApplyPlanData1: [32]byte{0x38}, + ApplyPlanData2: [32]byte{0x39}, + FeeLimitSnapshot: 10000, + ResourceIDs: resourceIDs, + WalletMembersIDs: members, + DomainChainID: [32]byte{0x2b}, + ActivationManifestHash: [32]byte{0x3e}, + ImplementationSetHash: [32]byte{0x3f}, + BridgeAddress: [20]byte{0x2c}, + RegistryAddress: [20]byte{0x2d}, + CompleteRouter: [20]byte{0x3c}, + FrostRegistry: [20]byte{0x2e}, + ProposalValidator: [20]byte{0x3a}, + SortitionPool: [20]byte{0x2f}, + BridgeCodeHash: [32]byte{0x30}, + RegistryCodeHash: [32]byte{0x31}, + CompleteRouterCodeHash: [32]byte{0x3d}, + FrostRegistryCodeHash: [32]byte{0x32}, + ProposalValidatorCodeHash: [32]byte{0x3b}, + SortitionPoolCodeHash: [32]byte{0x33}, + ReservationProtocolID: frostPreSignReservationProtocolID(), + EvidenceProtocolID: frostCompleteEvidenceProtocolID(), + SigningPolicyHash: frostPreSignSigningPolicyHash(), + PreparationFinality: FrostPreSignFinality{ + RelayTransactionHash: [32]byte{0x36}, + BlockNumber: 8, + BlockHash: [32]byte{0x37}, + }, + } + proposal.WalletMembersIDsHash = frostPreSignKeccak256( + frostPreSignABIUint32Array(members), + ) + proposal.ResourceHash = frostPreSignKeccak256( + frostPreSignABIBytes32Array(resourceIDs), + ) + proposal.AuthorizationRoot, _ = proposal.computeAuthorizationRoot() + proposal.ReservationID = proposal.computeReservationID() + proposal.Digest = proposal.computeDigest(proposal.AuthorizationRoot) + return proposal +} + +func completeTestFrostPreSignState( + proposal *FrostPreSignAuthorizationProposal, + finality FrostPreSignFinality, +) *FrostPreSignAuthorizationState { + return &FrostPreSignAuthorizationState{ + Finality: finality, + DomainChainID: proposal.DomainChainID, + ActivationManifestHash: proposal.ActivationManifestHash, + ImplementationSetHash: proposal.ImplementationSetHash, + BridgeAddress: proposal.BridgeAddress, + RegistryAddress: proposal.RegistryAddress, + CompleteRouter: proposal.CompleteRouter, + FrostRegistry: proposal.FrostRegistry, + ProposalValidator: proposal.ProposalValidator, + SortitionPool: proposal.SortitionPool, + BridgeCodeHash: proposal.BridgeCodeHash, + RegistryCodeHash: proposal.RegistryCodeHash, + CompleteRouterCodeHash: proposal.CompleteRouterCodeHash, + FrostRegistryCodeHash: proposal.FrostRegistryCodeHash, + ProposalValidatorCodeHash: proposal.ProposalValidatorCodeHash, + SortitionPoolCodeHash: proposal.SortitionPoolCodeHash, + ReservationProtocolID: proposal.ReservationProtocolID, + EvidenceProtocolID: proposal.EvidenceProtocolID, + SigningPolicyHash: proposal.SigningPolicyHash, + WalletActive: true, + WalletID: proposal.WalletID, + WalletPublicKeyHash: proposal.Transaction.WalletPublicKeyHash, + WalletMembersIDsHash: proposal.WalletMembersIDsHash, + WalletXOnlyOutputKey: proposal.WalletID, + ActiveReservationID: proposal.ReservationID, + ReservationWalletID: proposal.WalletID, + ReservationWalletPublicKeyHash: proposal.Transaction.WalletPublicKeyHash, + ReservationSnapshotHash: proposal.SnapshotHash, + ReservationResourceHash: proposal.ResourceHash, + ReservationOrderedInputRoot: proposal.OrderedInputRoot, + ReservationApplyPlanData1: proposal.ApplyPlanData1, + ReservationApplyPlanData2: proposal.ApplyPlanData2, + ReservationFeeLimitSnapshot: proposal.FeeLimitSnapshot, + ReservationAction: proposal.Transaction.Action, + ReservationActive: true, + VariantTransactionHash: proposal.Transaction.TransactionHash, + VariantReservationID: proposal.ReservationID, + VariantAuthorizationRoot: proposal.AuthorizationRoot, + VariantApplyPlanHash: proposal.ApplyPlanHash, + VariantAuthorizationSequence: [32]byte{31: 1}, + VariantFraudDefenseAuthorized: true, + VariantSigningAllowed: true, + LatestVariantTransactionHash: proposal.Transaction.TransactionHash, + LatestVariantAuthorizationSequence: [32]byte{31: 1}, + LatestVariantSigningAllowed: true, + } +} + +func activationProfileForTestProposal( + proposal *FrostPreSignAuthorizationProposal, +) FrostPreSignActivationProfile { + profile := FrostPreSignActivationProfile{ + DomainChainID: proposal.DomainChainID, + ActivationManifestHash: proposal.ActivationManifestHash, + ImplementationSetHash: proposal.ImplementationSetHash, + BridgeAddress: proposal.BridgeAddress, + RegistryAddress: proposal.RegistryAddress, + CompleteRouter: proposal.CompleteRouter, + FrostRegistry: proposal.FrostRegistry, + ProposalValidator: proposal.ProposalValidator, + SortitionPool: proposal.SortitionPool, + BridgeCodeHash: proposal.BridgeCodeHash, + RegistryCodeHash: proposal.RegistryCodeHash, + CompleteRouterCodeHash: proposal.CompleteRouterCodeHash, + FrostRegistryCodeHash: proposal.FrostRegistryCodeHash, + ProposalValidatorCodeHash: proposal.ProposalValidatorCodeHash, + SortitionPoolCodeHash: proposal.SortitionPoolCodeHash, + ReservationProtocolID: proposal.ReservationProtocolID, + EvidenceProtocolID: proposal.EvidenceProtocolID, + SigningPolicyHash: proposal.SigningPolicyHash, + } + profile.ProfileHash = profile.ComputeHash() + return profile +} + +type countingFrostPreSignChainSigning struct { + chain.Signing + signCalls int +} + +func (cfpscs *countingFrostPreSignChainSigning) Sign(message []byte) ([]byte, error) { + cfpscs.signCalls++ + return cfpscs.Signing.Sign(message) +} + +type testFrostPreSignAuthorizationBackend struct { + proposal *FrostPreSignAuthorizationProposal + currentFinality *FrostPreSignFinality + states map[uint64]*FrostPreSignAuthorizationState +} + +func (tfpsab *testFrostPreSignAuthorizationBackend) PrepareFrostPreSignAuthorization( + ctx context.Context, + transaction *FrostPreSignTransaction, + walletOperators []chain.Address, +) (*FrostPreSignAuthorizationProposal, error) { + return tfpsab.proposal, nil +} + +func (tfpsab *testFrostPreSignAuthorizationBackend) RelayFrostPreSignAuthorization( + ctx context.Context, + proposal *FrostPreSignAuthorizationProposal, + attestation *FrostPreSignSeatAttestation, +) ([32]byte, error) { + return [32]byte{}, nil +} + +func (tfpsab *testFrostPreSignAuthorizationBackend) WaitForFrostPreSignAuthorizationFinality( + ctx context.Context, + relayTransactionHash [32]byte, + proposal *FrostPreSignAuthorizationProposal, +) (*FrostPreSignFinality, error) { + return nil, nil +} + +func (tfpsab *testFrostPreSignAuthorizationBackend) CurrentFrostPreSignFinality( + ctx context.Context, +) (*FrostPreSignFinality, error) { + if tfpsab.currentFinality == nil { + return nil, nil + } + result := *tfpsab.currentFinality + return &result, nil +} + +func (tfpsab *testFrostPreSignAuthorizationBackend) ReadFrostPreSignAuthorizationState( + ctx context.Context, + proposal *FrostPreSignAuthorizationProposal, + finality FrostPreSignFinality, +) (*FrostPreSignAuthorizationState, error) { + if tfpsab.states == nil || tfpsab.states[finality.BlockNumber] == nil { + return nil, nil + } + result := *tfpsab.states[finality.BlockNumber] + return &result, nil +} diff --git a/pkg/tbtc/moved_funds_sweep.go b/pkg/tbtc/moved_funds_sweep.go index a18e53983d..7d5017e9b1 100644 --- a/pkg/tbtc/moved_funds_sweep.go +++ b/pkg/tbtc/moved_funds_sweep.go @@ -113,6 +113,7 @@ func newMovedFundsSweepAction( signingExecutor, waitForBlockFn, ) + transactionExecutor.action = ActionMovedFundsSweep return &movedFundsSweepAction{ logger: logger, @@ -220,6 +221,12 @@ func (mfsa *movedFundsSweepAction) execute() error { return fmt.Errorf("invalid proposal expiry block") } + mfsa.transactionExecutor.frostPreSignActionContext = &FrostPreSignActionContext{ + MovedFundsSweep: &FrostPreSignMovedFundsSweepActionContext{ + Proposal: mfsa.proposal, + MainUtxo: walletMainUtxo, + }, + } movedFundsSweepTx, err := mfsa.transactionExecutor.signTransaction( signTxLogger, unsignedMovedFundsSweepTx, diff --git a/pkg/tbtc/moving_funds.go b/pkg/tbtc/moving_funds.go index 8c4a1d2534..4aa17a3c89 100644 --- a/pkg/tbtc/moving_funds.go +++ b/pkg/tbtc/moving_funds.go @@ -110,6 +110,7 @@ func newMovingFundsAction( signingExecutor, waitForBlockFn, ) + transactionExecutor.action = ActionMovingFunds return &movingFundsAction{ logger: logger, @@ -235,6 +236,12 @@ func (mfa *movingFundsAction) execute() error { return fmt.Errorf("invalid proposal expiry block") } + mfa.transactionExecutor.frostPreSignActionContext = &FrostPreSignActionContext{ + MovingFunds: &FrostPreSignMovingFundsActionContext{ + Proposal: mfa.proposal, + MainUtxo: walletMainUtxo, + }, + } movingFundsTx, err := mfa.transactionExecutor.signTransaction( signTxLogger, unsignedMovingFundsTx, diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index b0e0909b02..b02fc3195b 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -65,6 +65,11 @@ type node struct { netProvider net.Provider walletRegistry *walletRegistry + frostPreSignAuthorizationBackend FrostPreSignAuthorizationBackend + frostPreSignActivationProfile FrostPreSignActivationProfile + bitcoinBroadcastOutbox *bitcoinBroadcastOutbox + frostActivationHandshakeExporter *frostActivationHandshakeExporter + // walletDispatcher ensures only one action is executed by a wallet at // a time. All possible activities of a created wallet must be represented // by appropriate actions dispatched through this component. @@ -231,6 +236,116 @@ func newNode( ) } + if config.EnableFrostPreSignAuthorization { + activationProfile := config.FrostPreSignActivationProfile + if configurator, ok := chain.(FrostPreSignAuthorizationConfigurator); ok { + if config.FrostPreSignActivationManifestPath == "" { + return nil, fmt.Errorf( + "cannot enable production FROST pre-sign authorization without an activation manifest", + ) + } + configuredProfile, err := configurator.ConfigureFrostPreSignAuthorization( + context.Background(), + config.FrostPreSignActivationManifestPath, + config.FrostPreSignActivationEnvelopeSignerKeyHash, + config.FrostPreSignLinkedLibraryDescriptorSetHash, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot configure production FROST pre-sign authorization: [%w]", + err, + ) + } + if activationProfile != nil && + *activationProfile != *configuredProfile { + return nil, fmt.Errorf( + "configured FROST activation profile differs from the verified manifest", + ) + } + activationProfile = configuredProfile + } + if activationProfile == nil { + return nil, fmt.Errorf( + "cannot enable FROST pre-sign authorization without a local activation profile", + ) + } + verifiedActivationProfile := *activationProfile + if err := verifiedActivationProfile.validate(); err != nil { + return nil, fmt.Errorf( + "cannot enable FROST pre-sign authorization with invalid activation profile: [%w]", + err, + ) + } + frostChain, ok := chain.(FrostDKGChain) + if !ok || !frostChain.FrostWalletRegistryAvailable() { + return nil, fmt.Errorf( + "cannot enable FROST pre-sign authorization without a configured FROST wallet registry", + ) + } + backend, ok := chain.(FrostPreSignAuthorizationBackend) + if !ok { + return nil, fmt.Errorf( + "cannot enable FROST pre-sign authorization: anchoring chain does not implement the authorization backend", + ) + } + canonicalBitcoinChain, ok := btcChain.(canonicalBitcoinBroadcastChain) + if !ok { + return nil, fmt.Errorf( + "cannot enable FROST pre-sign authorization: Bitcoin backend is not an authenticated canonical transaction index", + ) + } + authorizationStatusSource, ok := chain.(FrostBitcoinBroadcastAuthorizationStatusSource) + if !ok { + return nil, fmt.Errorf( + "cannot enable FROST pre-sign authorization: anchoring chain does not implement canonical broadcast-authorization revalidation", + ) + } + outbox, err := newBitcoinBroadcastOutbox( + config.BitcoinBroadcastOutboxDirectory, + canonicalBitcoinChain, + authorizationStatusSource, + verifiedActivationProfile.ProfileHash, + ) + if err != nil { + return nil, fmt.Errorf("cannot initialize durable Bitcoin broadcast outbox: [%w]", err) + } + node.frostPreSignAuthorizationBackend = backend + node.frostPreSignActivationProfile = verifiedActivationProfile + node.bitcoinBroadcastOutbox = outbox + manifestSource, ok := chain.(FrostPreSignActivationRuntimeManifestSource) + if !ok { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot enable FROST activation handshake: chain does not expose the authenticated runtime manifest", + ) + } + pointVerifier, ok := chain.(FrostPreSignActivationPointVerifier) + if !ok { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot enable FROST activation handshake: chain cannot verify exact finalized deployment points", + ) + } + runtimeManifest, err := manifestSource.FrostPreSignActivationRuntimeManifest() + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf("cannot read authenticated FROST runtime manifest: [%w]", err) + } + exporter, err := newFrostActivationHandshakeExporter( + config.FrostActivationHandshakeURL, + config.FrostActivationHandshakePrivateKeyPath, + config.FrostActivationReadinessSnapshotPath, + runtimeManifest, + pointVerifier, + outbox, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf("cannot initialize FROST activation handshake: [%w]", err) + } + node.frostActivationHandshakeExporter = exporter + } + return node, nil } @@ -533,6 +648,32 @@ func (n *node) getSigningExecutor( n.waitForBlockHeight, signingAttemptsLimit, ) + if n.frostPreSignAuthorizationBackend != nil { + localMemberIndexes := make([]group.MemberIndex, 0, len(signers)) + for _, signer := range signers { + localMemberIndexes = append( + localMemberIndexes, + signer.signingGroupMemberIndex, + ) + } + gate, err := newThresholdFrostPreSignAuthorizationGate( + n.frostPreSignAuthorizationBackend, + n.frostPreSignActivationProfile, + n.chain.Signing(), + broadcastChannel, + membershipValidator, + wallet, + localMemberIndexes, + ) + if err != nil { + return nil, false, fmt.Errorf( + "cannot create FROST pre-sign authorization gate: [%w]", + err, + ) + } + executor.preSignAuthorizationGate = gate + executor.broadcastOutbox = n.bitcoinBroadcastOutbox + } // Wire metrics recorder if available if n.performanceMetrics != nil { @@ -638,6 +779,9 @@ func (n *node) getCoordinationExecutor( n.protocolLatch, n.waitForBlockHeight, ) + executor.suppressHeartbeat = signingMaterialUsesSchnorrSignatures( + signers[0].signingMaterial(), + ) // Wire metrics recorder if available if n.performanceMetrics != nil { @@ -804,6 +948,15 @@ func (n *node) handleHeartbeatProposal( logger.Errorf("cannot marshal wallet public key: [%v]", err) return } + signers := n.walletRegistry.getSigners(wallet.publicKey) + if len(signers) > 0 && + signingMaterialUsesSchnorrSignatures(signers[0].signingMaterial()) { + logger.Infof( + "ignoring heartbeat request for transaction-only FROST wallet [0x%x]", + walletPublicKeyBytes, + ) + return + } signingExecutor, ok, err := n.getSigningExecutor(wallet.publicKey) if err != nil { @@ -1718,8 +1871,8 @@ func withCancelOnBlock( go func() { defer cancelBlockCtx() - err := waitForBlockFn(ctx, block) - if err != nil { + err := waitForBlockFn(blockCtx, block) + if err != nil && blockCtx.Err() == nil { logger.Errorf( "failed to wait for block [%v]; "+ "context cancelled earlier than expected", diff --git a/pkg/tbtc/node_test.go b/pkg/tbtc/node_test.go index 517c72eb1b..b3bdd775a1 100644 --- a/pkg/tbtc/node_test.go +++ b/pkg/tbtc/node_test.go @@ -855,6 +855,59 @@ func TestNode_HandleHeartbeatProposal_DispatchesAction(t *testing.T) { } } +func TestNode_HandleHeartbeatProposal_SuppressesFrostWallet(t *testing.T) { + n, signer := setupNodeForHandlerTests(t) + registeredSigners := n.walletRegistry.getSigners(signer.wallet.publicKey) + if len(registeredSigners) == 0 { + t.Fatal("test wallet has no registered signer") + } + registeredSigners[0].signerMaterial = &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{ + "keyGroup":"frost-heartbeat-filter-test", + "keyGroupSource":"dkg-persisted" + }`), + } + + n.handleHeartbeatProposal( + signer.wallet, + &HeartbeatProposal{Message: [16]byte{0x04}}, + 10, + 100, + ) + if count := dispatchedActionsCount(n); count != 0 { + t.Fatalf("FROST heartbeat reached wallet dispatch: [%d]", count) + } + if len(n.signingExecutors) != 0 || len(n.inactivityClaimExecutors) != 0 { + t.Fatal("FROST heartbeat created signing/inactivity side effects") + } +} + +func TestNode_GetCoordinationExecutorSuppressesFrostHeartbeat(t *testing.T) { + n, signer := setupNodeForHandlerTests(t) + registeredSigners := n.walletRegistry.getSigners(signer.wallet.publicKey) + if len(registeredSigners) == 0 { + t.Fatal("test wallet has no registered signer") + } + registeredSigners[0].signerMaterial = &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: []byte(`{ + "keyGroup":"frost-coordination-filter-test", + "keyGroupSource":"dkg-persisted" + }`), + } + executor, ok, err := n.getCoordinationExecutor(signer.wallet.publicKey) + if err != nil { + t.Fatal(err) + } + if !ok || executor == nil { + t.Fatal("FROST coordination executor was not created") + } + if !executor.suppressHeartbeat { + t.Fatal("FROST coordination executor did not suppress heartbeat") + } +} + // TestNode_HandleDepositSweepProposal_WalletNotControlled verifies that // handleDepositSweepProposal skips dispatch for an uncontrolled wallet. func TestNode_HandleDepositSweepProposal_WalletNotControlled(t *testing.T) { diff --git a/pkg/tbtc/redemption.go b/pkg/tbtc/redemption.go index 654b27467b..78b9832ea3 100644 --- a/pkg/tbtc/redemption.go +++ b/pkg/tbtc/redemption.go @@ -47,23 +47,6 @@ const ( // the transaction is known on the Bitcoin chain. This delay is needed // as spreading the transaction over the Bitcoin network takes time. redemptionBroadcastCheckDelay = 1 * time.Minute - // redemptionChangeDustLimit is the minimum satoshi value of the change - // output of a redemption transaction. A change output below the Bitcoin - // dust threshold makes the transaction non-standard and modern Bitcoin - // nodes reject it outright at relay time ("dust, tx with dust output - // must be 0-fee"), so such a transaction can never confirm. The exact - // dust threshold depends on the change output's script type: at the - // default 3 sat/vB dust relay fee it is 294 satoshi for a P2WPKH change - // and 330 satoshi for a P2TR change. A single conservative constant of - // 546 satoshi (the historical P2PKH dust limit and the highest dust - // threshold among standard output types) is used to cover any change - // script type with margin. A positive change below this limit is omitted - // from the transaction and its value is folded into the transaction fee. - // This is safe on-chain: the Bridge accepts redemption transactions - // without a change output and validates redeemer output values - // individually, so leaving a sub-dust remainder to the miner does not - // violate any per-request constraint. - redemptionChangeDustLimit = 546 ) // RedemptionProposal represents a redemption proposal issued by a wallet's @@ -161,6 +144,7 @@ func newRedemptionAction( signingExecutor, waitForBlockFn, ) + transactionExecutor.action = ActionRedemption feeDistribution := withRedemptionTotalFee(proposal.RedemptionTxFee.Int64()) @@ -267,32 +251,6 @@ func (ra *redemptionAction) execute() error { ) } - _, _, _, redemptionTxMaxTotalFee, _, _, _, err := - ra.chain.GetRedemptionParameters() - if err != nil { - if ra.metricsRecorder != nil { - ra.metricsRecorder.IncrementCounter(clientinfo.MetricRedemptionExecutionsFailedTotal, 1) - } - return fmt.Errorf( - "error while getting redemption parameters: [%v]", - err, - ) - } - - err = validateRedemptionTransactionFee( - unsignedRedemptionTx, - redemptionTxMaxTotalFee, - ) - if err != nil { - if ra.metricsRecorder != nil { - ra.metricsRecorder.IncrementCounter(clientinfo.MetricRedemptionExecutionsFailedTotal, 1) - } - return fmt.Errorf( - "error while validating redemption transaction fee: [%v]", - err, - ) - } - signTxLogger := ra.logger.With( zap.String("step", "signTransaction"), ) @@ -305,6 +263,12 @@ func (ra *redemptionAction) execute() error { return fmt.Errorf("invalid proposal expiry block") } + ra.transactionExecutor.frostPreSignActionContext = &FrostPreSignActionContext{ + Redemption: &FrostPreSignRedemptionActionContext{ + Proposal: ra.proposal, + MainUtxo: walletMainUtxo, + }, + } redemptionTx, err := ra.transactionExecutor.signTransaction( signTxLogger, unsignedRedemptionTx, @@ -549,30 +513,6 @@ func assembleRedemptionTransaction( totalRedemptionOutputsValue - totalFee - // Note that the change value is independent of the transaction fee: the - // fee is subtracted from the redeemer outputs while the change carries - // the treasury-fee portion of the redeemed amounts that physically stays - // with the wallet. If that remainder is positive but below the dust - // limit, a change output carrying it would make the whole transaction - // non-relayable. Omit the change output in that case and let the sub-dust - // remainder become part of the transaction fee. The transaction is then - // shaped exactly like a natural zero-change redemption which is supported - // by the downstream signing/broadcast pipeline and the Bridge. - if changeOutputValue > 0 && changeOutputValue < redemptionChangeDustLimit { - logger.Warnf( - "redemption transaction change of [%v] satoshi is below "+ - "the dust limit of [%v] satoshi; omitting the change output "+ - "and folding its value into the transaction fee which "+ - "grows from [%v] to [%v] satoshi", - changeOutputValue, - int64(redemptionChangeDustLimit), - totalFee, - totalFee+changeOutputValue, - ) - - changeOutputValue = 0 - } - // If we can have a non-zero change, construct it. if changeOutputValue > 0 { var changeOutputScript bitcoin.Script @@ -624,30 +564,3 @@ func assembleRedemptionTransaction( return builder, nil } - -func validateRedemptionTransactionFee( - transaction *bitcoin.TransactionBuilder, - maxTotalFee uint64, -) error { - actualFee := transaction.TotalInputsValue() - for _, output := range transaction.UnsignedTransaction().Outputs { - actualFee -= output.Value - } - - if actualFee < 0 { - return fmt.Errorf( - "redemption transaction fee is negative: [%v]", - actualFee, - ) - } - - if uint64(actualFee) > maxTotalFee { - return fmt.Errorf( - "redemption transaction fee [%v] exceeds maximum total fee [%v]", - actualFee, - maxTotalFee, - ) - } - - return nil -} diff --git a/pkg/tbtc/redemption_test.go b/pkg/tbtc/redemption_test.go index 3d4ee79ff4..b2c35b8cb9 100644 --- a/pkg/tbtc/redemption_test.go +++ b/pkg/tbtc/redemption_test.go @@ -2,7 +2,6 @@ package tbtc import ( "context" - "fmt" "math/big" "testing" "time" @@ -11,7 +10,6 @@ import ( "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" - "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/tbtc/internal/test" ) @@ -179,109 +177,6 @@ func TestRedemptionAction_Execute(t *testing.T) { } } -func TestRedemptionAction_RejectsTransactionFeeAboveMaximumBeforeSigning( - t *testing.T, -) { - const mainUtxoValue = int64(24372) - - hostChain := Connect() - bitcoinChain := newLocalBitcoinChain() - redeemingWallet := generateWallet(big.NewInt(111)) - walletPublicKey := redeemingWallet.publicKey - walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) - walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) - if err != nil { - t.Fatal(err) - } - - fundingTx := &bitcoin.Transaction{ - Version: 1, - Inputs: []*bitcoin.TransactionInput{ - { - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: bitcoin.Hash{0x01}, - OutputIndex: 0, - }, - Sequence: 0xffffffff, - }, - }, - Outputs: []*bitcoin.TransactionOutput{ - { - Value: mainUtxoValue, - PublicKeyScript: walletScript, - }, - }, - } - if err := bitcoinChain.BroadcastTransaction(fundingTx); err != nil { - t.Fatal(err) - } - - walletMainUtxo := &bitcoin.UnspentTransactionOutput{ - Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: fundingTx.Hash(), - OutputIndex: 0, - }, - Value: mainUtxoValue, - } - - redeemerScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{0x01}) - if err != nil { - t.Fatal(err) - } - - request := &RedemptionRequest{ - Redeemer: chain.Address("redeemer"), - RedeemerOutputScript: redeemerScript, - RequestedAmount: 24372, - TreasuryFee: 12, - TxMaxFee: 110, - RequestedAt: time.Now(), - } - hostChain.setPendingRedemptionRequest(walletPublicKeyHash, request) - - proposal := &RedemptionProposal{ - RedeemersOutputScripts: []bitcoin.Script{redeemerScript}, - RedemptionTxFee: big.NewInt(110), - } - if err := hostChain.setRedemptionProposalValidationResult( - walletPublicKeyHash, - proposal, - true, - ); err != nil { - t.Fatal(err) - } - - hostChain.setWallet(walletPublicKeyHash, &WalletChainData{ - MainUtxoHash: hostChain.ComputeMainUtxoHash(walletMainUtxo), - }) - hostChain.setRedemptionParameters(0, 0, 0, 121, 0, nil, 0) - - action := newRedemptionAction( - logger.With(), - hostChain, - bitcoinChain, - redeemingWallet, - newMockWalletSigningExecutor(), - proposal, - 100, - 100+redemptionProposalValidityBlocks, - func(ctx context.Context, blockHeight uint64) error { return nil }, - ) - - err = action.execute() - if err == nil { - t.Fatal("expected redemption action to reject an excessive actual fee") - } - - testutils.AssertStringsEqual( - t, - "error", - "error while validating redemption transaction fee: "+ - "[redemption transaction fee [122] exceeds maximum total fee [121]]", - err.Error(), - ) -} - func TestAssembleRedemptionTransaction(t *testing.T) { scenarios, err := test.LoadRedemptionTestScenarios() if err != nil { @@ -372,226 +267,6 @@ func TestAssembleRedemptionTransaction(t *testing.T) { } } -func TestAssembleRedemptionTransaction_SubDustChange(t *testing.T) { - // The main UTXO value and the sub-dust case numbers reproduce a live - // testnet4 redemption of the wallet's full main UTXO whose 12-satoshi - // change output made the transaction non-relayable: - // "dust, tx with dust output must be 0-fee". - const mainUtxoValue = int64(24372) - - walletPublicKey := testWalletPublicKeyFromXOnly( - t, - "2336f65004d8f122f1fe947ebd009a8b4add3a0d937356d568e30f7fcc2e4008", - ) - - walletXOnlyKey, err := walletXOnlyPublicKey(walletPublicKey) - if err != nil { - t.Fatal(err) - } - walletChangeScript, err := bitcoin.PayToTaproot(walletXOnlyKey) - if err != nil { - t.Fatal(err) - } - - redeemerScript, err := bitcoin.PayToWitnessPublicKeyHash( - [20]byte{ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, - 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, - }, - ) - if err != nil { - t.Fatal(err) - } - - var tests = map[string]struct { - requestedAmount uint64 - treasuryFee uint64 - feeShare int64 - maxTotalFee uint64 - expectedFeeValidationError string - // expectedOutputs holds the expected output values in order. A -1 - // value denotes the wallet change output; any other value denotes - // the redeemer output. - expectedOutputValues []int64 - expectedChangeValue int64 - expectedImpliedFee int64 - }{ - // change = mainUtxoValue - (requestedAmount - treasuryFee) = 12. - // The sub-dust change must be omitted, the redeemer output must be - // left untouched, and the implied fee must grow by the change value. - "sub-dust change above the maximum total fee is rejected": { - requestedAmount: 24372, - treasuryFee: 12, - feeShare: 110, - maxTotalFee: 121, - expectedOutputValues: []int64{24250}, - expectedChangeValue: 0, - expectedImpliedFee: 122, - expectedFeeValidationError: "redemption transaction fee [122] " + - "exceeds maximum total fee [121]", - }, - // An actual fee exactly equal to the maximum total fee remains valid. - "sub-dust change at the maximum total fee is omitted": { - requestedAmount: 24372, - treasuryFee: 12, - feeShare: 110, - maxTotalFee: 122, - expectedOutputValues: []int64{24250}, - expectedChangeValue: 0, - expectedImpliedFee: 122, - }, - // change = 24372 - (23839 - 12) = 545, just below the dust limit. - "change just below the dust limit is omitted": { - requestedAmount: 23839, - treasuryFee: 12, - feeShare: 110, - maxTotalFee: 655, - expectedOutputValues: []int64{23717}, - expectedChangeValue: 0, - expectedImpliedFee: 655, - }, - // change = 24372 - (23838 - 12) = 546, exactly at the dust limit. - "change at the dust limit is kept": { - requestedAmount: 23838, - treasuryFee: 12, - feeShare: 110, - maxTotalFee: 110, - expectedOutputValues: []int64{23716, 546}, - expectedChangeValue: 546, - expectedImpliedFee: 110, - }, - // change = 24372 - (24384 - 12) = 0. Same behavior as before the - // dust guard: no change output at all. - "zero change produces no change output": { - requestedAmount: 24384, - treasuryFee: 12, - feeShare: 110, - maxTotalFee: 110, - expectedOutputValues: []int64{24262}, - expectedChangeValue: 0, - expectedImpliedFee: 110, - }, - } - - for testName, test := range tests { - t.Run(testName, func(t *testing.T) { - bitcoinChain := newLocalBitcoinChain() - - walletMainUtxo := testTaprootWalletMainUtxoWithValue( - t, - bitcoinChain, - walletPublicKey, - mainUtxoValue, - ) - - requests := []*RedemptionRequest{ - { - Redeemer: chain.Address("redeemer"), - RedeemerOutputScript: redeemerScript, - RequestedAmount: test.requestedAmount, - TreasuryFee: test.treasuryFee, - TxMaxFee: 1000, - RequestedAt: time.Now(), - }, - } - - feeDistribution := func(requests []*RedemptionRequest) []int64 { - return []int64{test.feeShare} - } - - builder, err := assembleRedemptionTransaction( - bitcoinChain, - walletPublicKey, - walletMainUtxo, - requests, - feeDistribution, - RedemptionChangeLast, - ) - if err != nil { - t.Fatal(err) - } - - outputs := builder.UnsignedTransaction().Outputs - - testutils.AssertIntsEqual( - t, - "output count", - len(test.expectedOutputValues), - len(outputs), - ) - - totalOutputsValue := int64(0) - for i, output := range outputs { - totalOutputsValue += output.Value - - testutils.AssertIntsEqual( - t, - fmt.Sprintf("output [%v] value", i), - int(test.expectedOutputValues[i]), - int(output.Value), - ) - - // The RedemptionChangeLast shape is used so the change - // output, if present, is the last output. - expectedScript := redeemerScript - if test.expectedChangeValue > 0 && i == len(outputs)-1 { - expectedScript = walletChangeScript - } - - testutils.AssertBytesEqual( - t, - expectedScript, - output.PublicKeyScript, - ) - } - - actualFee := builder.TotalInputsValue() - totalOutputsValue - - // The implied transaction fee is the difference between the - // input value and the total outputs value. When a sub-dust - // change is omitted, its value must become part of the fee. - testutils.AssertIntsEqual( - t, - "implied transaction fee", - int(test.expectedImpliedFee), - int(actualFee), - ) - - err = validateRedemptionTransactionFee(builder, test.maxTotalFee) - if len(test.expectedFeeValidationError) == 0 { - if err != nil { - t.Fatalf("unexpected fee validation error: [%v]", err) - } - } else { - if err == nil { - t.Fatal("expected fee validation error") - } - - testutils.AssertStringsEqual( - t, - "fee validation error", - test.expectedFeeValidationError, - err.Error(), - ) - } - - // Make sure the downstream sighash computation works for the - // resulting transaction shape. - sigHashes, err := builder.ComputeSignatureHashes() - if err != nil { - t.Fatal(err) - } - - testutils.AssertIntsEqual( - t, - "sighash count", - 1, - len(sigHashes), - ) - }) - } -} - func TestWithRedemptionTotalFee(t *testing.T) { var tests = map[string]struct { totalFee int64 diff --git a/pkg/tbtc/signing.go b/pkg/tbtc/signing.go index 9bf8092a50..22b5bb2306 100644 --- a/pkg/tbtc/signing.go +++ b/pkg/tbtc/signing.go @@ -53,6 +53,26 @@ func bindTaprootPolicyArtifactForSigning( keyGroupID string, unsignedTx *bitcoin.TransactionBuilder, inputIndex int, +) (string, error) { + return bindTaprootPolicyArtifactForAuthorizedSigning( + message, + taprootMerkleRoot, + startBlock, + keyGroupID, + unsignedTx, + inputIndex, + nil, + ) +} + +func bindTaprootPolicyArtifactForAuthorizedSigning( + message *big.Int, + taprootMerkleRoot *[32]byte, + startBlock uint64, + keyGroupID string, + unsignedTx *bitcoin.TransactionBuilder, + inputIndex int, + authorizationID *[32]byte, ) (string, error) { if unsignedTx == nil { return "", nil @@ -63,11 +83,16 @@ func bindTaprootPolicyArtifactForSigning( ) } - roastSID := roastSessionID( + if authorizationID != nil && *authorizationID == [32]byte{} { + return "", fmt.Errorf("pre-sign authorization ID is zero") + } + + roastSID := roastSessionIDWithAuthorization( message, taprootMerkleRoot, startBlock, keyGroupID, + authorizationID, ) if err := bindTaprootTxViaNativeSignerFn( roastSID, @@ -87,6 +112,24 @@ func signingSessionID( startBlock uint64, attemptNumber uint, keyGroupID string, +) string { + return signingSessionIDWithAuthorization( + message, + taprootMerkleRoot, + startBlock, + attemptNumber, + keyGroupID, + nil, + ) +} + +func signingSessionIDWithAuthorization( + message *big.Int, + taprootMerkleRoot *[32]byte, + startBlock uint64, + attemptNumber uint, + keyGroupID string, + authorizationID *[32]byte, ) string { // keyGroupID makes the attempt-specific session id WALLET-unique, like // roastSessionID: it is otherwise derived from message/root/(block) and would @@ -104,6 +147,10 @@ func signingSessionID( keyPathDigest.Write([]byte(message.Text(16))) keyPathDigest.Write([]byte{0}) keyPathDigest.Write([]byte(keyGroupID)) + if authorizationID != nil { + keyPathDigest.Write([]byte{0}) + keyPathDigest.Write(authorizationID[:]) + } return fmt.Sprintf("kp-%x-%v", keyPathDigest.Sum(nil), attemptNumber) } @@ -118,6 +165,10 @@ func signingSessionID( sessionDigest.Write(startBlockBytes[:]) sessionDigest.Write([]byte{0}) sessionDigest.Write([]byte(keyGroupID)) + if authorizationID != nil { + sessionDigest.Write([]byte{0}) + sessionDigest.Write(authorizationID[:]) + } return fmt.Sprintf("tr-%x-%v", sessionDigest.Sum(nil), attemptNumber) } @@ -135,6 +186,22 @@ func roastSessionID( taprootMerkleRoot *[32]byte, startBlock uint64, keyGroupID string, +) string { + return roastSessionIDWithAuthorization( + message, + taprootMerkleRoot, + startBlock, + keyGroupID, + nil, + ) +} + +func roastSessionIDWithAuthorization( + message *big.Int, + taprootMerkleRoot *[32]byte, + startBlock uint64, + keyGroupID string, + authorizationID *[32]byte, ) string { // keyGroupID makes the stable ROAST session id WALLET-unique. It is derived from // message/root/startBlock -- NOT the wallet -- so on a node controlling two FROST @@ -161,6 +228,10 @@ func roastSessionID( keyPathDigest.Write(keyPathStartBlock[:]) keyPathDigest.Write([]byte{0}) keyPathDigest.Write([]byte(keyGroupID)) + if authorizationID != nil { + keyPathDigest.Write([]byte{0}) + keyPathDigest.Write(authorizationID[:]) + } return fmt.Sprintf("roast-kp-%x", keyPathDigest.Sum(nil)) } @@ -175,6 +246,10 @@ func roastSessionID( sessionDigest.Write(startBlockBytes[:]) sessionDigest.Write([]byte{0}) sessionDigest.Write([]byte(keyGroupID)) + if authorizationID != nil { + sessionDigest.Write([]byte{0}) + sessionDigest.Write(authorizationID[:]) + } return fmt.Sprintf("roast-tr-%x", sessionDigest.Sum(nil)) } @@ -211,6 +286,9 @@ type signingExecutor struct { // signing-attempt liveness gauges. It is shared across all wallets' // executors of this node (acquired from the metrics recorder). livenessTracker *clientinfo.SigningAttemptLivenessTracker + + preSignAuthorizationGate frostPreSignAuthorizationGate + broadcastOutbox *bitcoinBroadcastOutbox } // signingLivenessTrackerProvider is implemented by metrics recorders that @@ -254,6 +332,21 @@ func (se *signingExecutor) usesSchnorrSignatures() bool { return false } +func (se *signingExecutor) frostPreSignGate() frostPreSignAuthorizationGate { + return se.preSignAuthorizationGate +} + +func (se *signingExecutor) bitcoinOutbox() *bitcoinBroadcastOutbox { + return se.broadcastOutbox +} + +func (se *signingExecutor) currentSigningBlock() (uint64, error) { + if se.getCurrentBlockFn == nil { + return 0, fmt.Errorf("current signing block source is nil") + } + return se.getCurrentBlockFn() +} + // signBatch performs the signing process for each message from the given // messages batch, one after another. If at least one message cannot be signed, // this function returns an error. If all messages were signed successfully, @@ -280,6 +373,8 @@ func (se *signingExecutor) signBatchWithTaprootMerkleRoots( taprootMerkleRoots, startBlock, nil, + nil, + nil, ) } @@ -305,6 +400,41 @@ func (se *signingExecutor) signBatchWithTaprootTransaction( taprootMerkleRoots, startBlock, unsignedTx, + nil, + nil, + ) +} + +// signBatchWithAuthorizedTaprootTransaction is the only wallet-transaction +// entry point that may reach native FROST signing. authorizationID is folded +// into both the stable ROAST namespace and every attempt session namespace. +func (se *signingExecutor) signBatchWithAuthorizedTaprootTransaction( + ctx context.Context, + messages []*big.Int, + taprootMerkleRoots []*[32]byte, + startBlock uint64, + unsignedTx *bitcoin.TransactionBuilder, + authorizationID [32]byte, + authorizationGuard func(context.Context) error, +) ([]*frost.Signature, error) { + if authorizationID == [32]byte{} { + return nil, fmt.Errorf("pre-sign authorization ID is zero") + } + if unsignedTx == nil { + return nil, fmt.Errorf("unsigned transaction builder is nil") + } + if authorizationGuard == nil { + return nil, fmt.Errorf("pre-sign authorization guard is nil") + } + + return se.signBatchWithTaprootPolicy( + ctx, + messages, + taprootMerkleRoots, + startBlock, + unsignedTx, + &authorizationID, + authorizationGuard, ) } @@ -314,7 +444,17 @@ func (se *signingExecutor) signBatchWithTaprootPolicy( taprootMerkleRoots []*[32]byte, startBlock uint64, unsignedTx *bitcoin.TransactionBuilder, + authorizationID *[32]byte, + authorizationGuard func(context.Context) error, ) ([]*frost.Signature, error) { + // This check must precede policy-artifact binding: binding enters the native + // signer and may allocate session state even before nonce generation. + if se.usesSchnorrSignatures() && + (authorizationID == nil || *authorizationID == [32]byte{}) { + return nil, fmt.Errorf( + "FROST signing requires a finalized transaction authorization", + ) + } if taprootMerkleRoots != nil && len(taprootMerkleRoots) != len(messages) { return nil, fmt.Errorf( "taproot merkle roots count [%v] does not match messages count [%v]", @@ -387,13 +527,26 @@ func (se *signingExecutor) signBatchWithTaprootPolicy( taprootMerkleRoot = taprootMerkleRoots[i] } - policyBoundRoastSID, err := bindTaprootPolicyArtifactForSigning( + if authorizationID != nil { + if authorizationGuard == nil { + return nil, fmt.Errorf("pre-sign authorization guard is nil") + } + if err := authorizationGuard(ctx); err != nil { + return nil, fmt.Errorf( + "pre-sign authorization invalid before input [%d] policy binding: [%w]", + i, + err, + ) + } + } + policyBoundRoastSID, err := bindTaprootPolicyArtifactForAuthorizedSigning( message, taprootMerkleRoot, signingStartBlock, roastKeyGroupID, unsignedTx, i, + authorizationID, ) if err != nil { return nil, fmt.Errorf( @@ -403,13 +556,27 @@ func (se *signingExecutor) signBatchWithTaprootPolicy( ) } - signature, _, endBlock, err := se.signWithTaprootMerkleRootForSession( - ctx, - message, - taprootMerkleRoot, - signingStartBlock, - policyBoundRoastSID, - ) + var signature *frost.Signature + var endBlock uint64 + if authorizationID != nil { + signature, _, endBlock, err = se.signWithTaprootMerkleRootForAuthorizedSession( + ctx, + message, + taprootMerkleRoot, + signingStartBlock, + policyBoundRoastSID, + authorizationID, + authorizationGuard, + ) + } else { + signature, _, endBlock, err = se.signWithTaprootMerkleRootForSession( + ctx, + message, + taprootMerkleRoot, + signingStartBlock, + policyBoundRoastSID, + ) + } if err != nil { // Error metrics are recorded in the sign() method for all error paths. return nil, err @@ -451,6 +618,11 @@ func (se *signingExecutor) signHeartbeat( heartbeatMessage [16]byte, startBlock uint64, ) (*frost.Signature, *signingActivityReport, uint64, error) { + if se.usesSchnorrSignatures() { + return nil, nil, 0, fmt.Errorf( + "FROST heartbeat signing is disabled; the finalized pre-sign registry authorizes only reserved Bitcoin transactions", + ) + } messageToSign := heartbeatSigningMessage(heartbeatMessage) return se.signWithTaprootMerkleRootForSessionAndIntent( @@ -500,6 +672,30 @@ func (se *signingExecutor) signWithTaprootMerkleRootForSession( ) } +func (se *signingExecutor) signWithTaprootMerkleRootForAuthorizedSession( + ctx context.Context, + message *big.Int, + taprootMerkleRoot *[32]byte, + startBlock uint64, + policyBoundRoastSessionID string, + authorizationID *[32]byte, + authorizationGuard func(context.Context) error, +) (*frost.Signature, *signingActivityReport, uint64, error) { + if authorizationID == nil || *authorizationID == [32]byte{} { + return nil, nil, 0, fmt.Errorf("pre-sign authorization ID is missing") + } + return se.signWithTaprootMerkleRootForSessionIntentAndAuthorization( + ctx, + message, + taprootMerkleRoot, + startBlock, + policyBoundRoastSessionID, + nil, + authorizationID, + authorizationGuard, + ) +} + func (se *signingExecutor) signWithTaprootMerkleRootForSessionAndIntent( ctx context.Context, message *big.Int, @@ -508,6 +704,36 @@ func (se *signingExecutor) signWithTaprootMerkleRootForSessionAndIntent( policyBoundRoastSessionID string, signingIntent *signing.SigningIntent, ) (*frost.Signature, *signingActivityReport, uint64, error) { + return se.signWithTaprootMerkleRootForSessionIntentAndAuthorization( + ctx, + message, + taprootMerkleRoot, + startBlock, + policyBoundRoastSessionID, + signingIntent, + nil, + nil, + ) +} + +func (se *signingExecutor) signWithTaprootMerkleRootForSessionIntentAndAuthorization( + ctx context.Context, + message *big.Int, + taprootMerkleRoot *[32]byte, + startBlock uint64, + policyBoundRoastSessionID string, + signingIntent *signing.SigningIntent, + authorizationID *[32]byte, + authorizationGuard func(context.Context) error, +) (*frost.Signature, *signingActivityReport, uint64, error) { + if se.usesSchnorrSignatures() && + (authorizationID == nil || + *authorizationID == [32]byte{} || + authorizationGuard == nil) { + return nil, nil, 0, fmt.Errorf( + "FROST signing requires a finalized transaction authorization", + ) + } if lockAcquired := se.lock.TryAcquire(1); !lockAcquired { // Record failure metrics for lock acquisition failure if se.metricsRecorder != nil { @@ -568,7 +794,13 @@ func (se *signingExecutor) signWithTaprootMerkleRootForSessionAndIntent( // attempts. Computed once; constant across signers and attempts. roastSID := policyBoundRoastSessionID if roastSID == "" { - roastSID = roastSessionID(message, taprootMerkleRoot, startBlock, roastKeyGroupID) + roastSID = roastSessionIDWithAuthorization( + message, + taprootMerkleRoot, + startBlock, + roastKeyGroupID, + authorizationID, + ) } for _, currentSigner := range se.signers { @@ -649,13 +881,14 @@ func (se *signingExecutor) signWithTaprootMerkleRootForSessionAndIntent( loopCtx, signingLogger, &signing.Request{ - Message: message, - RoastSessionID: roastSID, - SigningIntent: signingIntent, - MemberIndex: signer.signingGroupMemberIndex, - SignerMaterial: signer.signingMaterial(), - TaprootMerkleRoot: taprootMerkleRoot, - GroupSize: wallet.groupSize(), + Message: message, + RoastSessionID: roastSID, + SigningIntent: signingIntent, + AuthorizationGuard: authorizationGuard, + MemberIndex: signer.signingGroupMemberIndex, + SignerMaterial: signer.signingMaterial(), + TaprootMerkleRoot: taprootMerkleRoot, + GroupSize: wallet.groupSize(), DishonestThreshold: wallet.groupDishonestThreshold( se.groupParameters.HonestThreshold, ), @@ -733,27 +966,29 @@ func (se *signingExecutor) signWithTaprootMerkleRootForSessionAndIntent( se.waitForBlockFn, ) - sessionID := signingSessionID( + sessionID := signingSessionIDWithAuthorization( message, taprootMerkleRoot, startBlock, attempt.number, roastKeyGroupID, + authorizationID, ) result, err := signing.ExecuteRequest( attemptCtx, signingAttemptLogger, &signing.Request{ - Message: message, - SessionID: sessionID, - RoastSessionID: roastSID, - SigningIntent: signingIntent, - MemberIndex: signer.signingGroupMemberIndex, - SignerMaterial: signer.signingMaterial(), - PrivateKeyShare: signer.privateKeyShare, - TaprootMerkleRoot: taprootMerkleRoot, - GroupSize: wallet.groupSize(), + Message: message, + SessionID: sessionID, + RoastSessionID: roastSID, + AuthorizationGuard: authorizationGuard, + SigningIntent: signingIntent, + MemberIndex: signer.signingGroupMemberIndex, + SignerMaterial: signer.signingMaterial(), + PrivateKeyShare: signer.privateKeyShare, + TaprootMerkleRoot: taprootMerkleRoot, + GroupSize: wallet.groupSize(), DishonestThreshold: wallet.groupDishonestThreshold( se.groupParameters.HonestThreshold, ), diff --git a/pkg/tbtc/signing_test.go b/pkg/tbtc/signing_test.go index 8b2c0e30d5..523a8e3327 100644 --- a/pkg/tbtc/signing_test.go +++ b/pkg/tbtc/signing_test.go @@ -167,6 +167,112 @@ func TestRoastSessionID_StableAndBinds(t *testing.T) { } } +func TestFrostSigningSessionIDs_BindFinalizedAuthorization(t *testing.T) { + message := big.NewInt(1) + var merkleRoot [32]byte + merkleRoot[0] = 2 + authorizationA := [32]byte{0xa1} + authorizationB := [32]byte{0xb2} + + for _, root := range []*[32]byte{nil, &merkleRoot} { + attemptA := signingSessionIDWithAuthorization( + message, + root, + 25300, + 12, + strings.Repeat("ab", 33), + &authorizationA, + ) + attemptAReplay := signingSessionIDWithAuthorization( + message, + root, + 25300, + 12, + strings.Repeat("ab", 33), + &authorizationA, + ) + attemptB := signingSessionIDWithAuthorization( + message, + root, + 25300, + 12, + strings.Repeat("ab", 33), + &authorizationB, + ) + if attemptA != attemptAReplay || attemptA == attemptB { + t.Fatal("attempt session ID is not deterministically bound to authorization") + } + if len(attemptA) > 128 || len(attemptB) > 128 { + t.Fatal("authorization-bound attempt session ID exceeds signer limit") + } + + roastA := roastSessionIDWithAuthorization( + message, + root, + 25300, + strings.Repeat("ab", 33), + &authorizationA, + ) + roastAReplay := roastSessionIDWithAuthorization( + message, + root, + 25300, + strings.Repeat("ab", 33), + &authorizationA, + ) + roastB := roastSessionIDWithAuthorization( + message, + root, + 25300, + strings.Repeat("ab", 33), + &authorizationB, + ) + if roastA != roastAReplay || roastA == roastB { + t.Fatal("ROAST session ID is not deterministically bound to authorization") + } + if len(roastA) > 128 || len(roastB) > 128 { + t.Fatal("authorization-bound ROAST session ID exceeds signer limit") + } + } +} + +func TestSigningExecutor_UnauthorizedFrostPathFailsBeforeNativePolicyBinding(t *testing.T) { + executor := &signingExecutor{ + signers: []*signer{{signerMaterial: struct{}{}}}, + } + originalBind := bindTaprootTxViaNativeSignerFn + t.Cleanup(func() { bindTaprootTxViaNativeSignerFn = originalBind }) + bindCalls := 0 + bindTaprootTxViaNativeSignerFn = func( + roastSessionID string, + unsignedTx *bitcoin.TransactionBuilder, + inputIndex int, + message *big.Int, + ) error { + bindCalls++ + return nil + } + + _, err := executor.signBatchWithTaprootTransaction( + context.Background(), + []*big.Int{big.NewInt(1)}, + []*[32]byte{nil}, + 1, + bitcoin.NewTransactionBuilder(newLocalBitcoinChain()), + ) + if err == nil || !strings.Contains(err.Error(), "finalized transaction authorization") { + t.Fatalf("unexpected unauthorized FROST result: [%v]", err) + } + if bindCalls != 0 { + t.Fatal("unauthorized FROST path reached native policy binding") + } + + _, _, _, err = executor.signHeartbeat(context.Background(), [16]byte{1}, 1) + if err == nil || !strings.Contains(err.Error(), "heartbeat signing is disabled") { + t.Fatalf("unexpected FROST heartbeat result: [%v]", err) + } +} + func TestSigningExecutor_Sign(t *testing.T) { executor := setupSigningExecutor(t) diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 86fcdc407c..d302c0e324 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -121,6 +121,46 @@ type Config struct { // ECDSA drain and after the legacy pool is retired. This flag is an opt-out // for operators that manage FROST pool membership out of band. DisableFrostSortitionPoolMonitoring bool + // EnableFrostPreSignAuthorization activates finalized on-chain authorization + // for reserved FROST Bitcoin transactions. When false, FROST wallet + // transaction signing remains fail-closed before native nonce generation. + // Enabling does not select a permissive default adapter: the configured + // chain must provide the deployment-specific authorization backend and the + // Bitcoin chain must provide authenticated canonical status, or startup + // fails before coordination. + EnableFrostPreSignAuthorization bool + // FrostPreSignActivationManifestPath points at the strict production + // manifest whose chain, contract, runtime-code, crosslink, and protocol + // commitments are verified at a finalized Ethereum block during startup. + // Production chain adapters reject activation without this file. + FrostPreSignActivationManifestPath string + // FrostPreSignActivationEnvelopeSignerKeyHash is the lowercase 0x-prefixed + // SHA-256 digest of the DER SubjectPublicKeyInfo for the Ed25519 activation + // authority. It authenticates the signed production manifest independently + // of the runtime handshake key embedded in that manifest. + FrostPreSignActivationEnvelopeSignerKeyHash string + // FrostPreSignLinkedLibraryDescriptorSetHash independently pins the + // compiler-derived recursive Solidity link layout expected by this signer + // build. It must match the signed manifest's global descriptor-set hash. + FrostPreSignLinkedLibraryDescriptorSetHash string + // FrostPreSignActivationProfile pins the reviewed chain, contract addresses, + // runtime code hashes, and protocol/policy identifiers independently of the + // anchoring backend. It is mandatory when activation is enabled. + FrostPreSignActivationProfile *FrostPreSignActivationProfile + // BitcoinBroadcastOutboxDirectory is the exclusively owned crash-safe + // journal for signed FROST Bitcoin transactions. It is mandatory when + // EnableFrostPreSignAuthorization is true and must reside on durable local + // storage supporting fsync, atomic rename, and advisory file locks. + BitcoinBroadcastOutboxDirectory string + // FrostActivationHandshakeURL is the exact numeric-loopback HTTP URL used + // by the independent activation auditor for nonce-bound signer readiness. + FrostActivationHandshakeURL string + // FrostActivationHandshakePrivateKeyPath contains one owner-only PKCS#8 + // Ed25519 private key whose SPKI hash is pinned by the signed manifest. + FrostActivationHandshakePrivateKeyPath string + // FrostActivationReadinessSnapshotPath contains the exact independently + // reconciled retained FROST group inventory and durable signer-store identity. + FrostActivationReadinessSnapshotPath string } // Initialize kicks off the TBTC by initializing internal state, ensuring @@ -225,8 +265,31 @@ func Initialize( // started), so an invalid backend fails Initialize with no protocol or // pre-params side effects. + if node.bitcoinBroadcastOutbox != nil { + if err := node.bitcoinBroadcastOutbox.start(ctx); err != nil { + _ = node.bitcoinBroadcastOutbox.close() + return fmt.Errorf( + "cannot replay durable Bitcoin broadcast outbox before coordination: [%w]", + err, + ) + } + if err := node.frostActivationHandshakeExporter.start(ctx); err != nil { + _ = node.bitcoinBroadcastOutbox.close() + return fmt.Errorf( + "cannot start FROST activation handshake exporter: [%w]", + err, + ) + } + } + err = node.runCoordinationLayer(ctx) if err != nil { + if node.frostActivationHandshakeExporter != nil { + _ = node.frostActivationHandshakeExporter.close() + } + if node.bitcoinBroadcastOutbox != nil { + _ = node.bitcoinBroadcastOutbox.close() + } return fmt.Errorf("cannot run coordination layer: [%w]", err) } diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index d69db8db2b..5f4387094c 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -315,6 +315,27 @@ type taprootPolicyBoundWalletSigningExecutor interface { ) ([]*frost.Signature, error) } +type authorizedTaprootPolicyBoundWalletSigningExecutor interface { + signBatchWithAuthorizedTaprootTransaction( + ctx context.Context, + messages []*big.Int, + taprootMerkleRoots []*[32]byte, + startBlock uint64, + unsignedTx *bitcoin.TransactionBuilder, + authorizationID [32]byte, + authorizationGuard func(context.Context) error, + ) ([]*frost.Signature, error) +} + +type frostTransactionSafetyProvider interface { + frostPreSignGate() frostPreSignAuthorizationGate + bitcoinOutbox() *bitcoinBroadcastOutbox +} + +type signingCurrentBlockProvider interface { + currentSigningBlock() (uint64, error) +} + // walletTransactionExecutor is a component allowing to sign and broadcast // wallet Bitcoin transactions. type walletTransactionExecutor struct { @@ -324,6 +345,122 @@ type walletTransactionExecutor struct { signingExecutor walletSigningExecutor waitForBlockFn waitForBlockFn + + action WalletActionType + frostPreSignActionContext *FrostPreSignActionContext + preSignAuthorizationGate frostPreSignAuthorizationGate + broadcastOutbox *bitcoinBroadcastOutbox + frostAuthorizationMonitorInterval time.Duration +} + +const defaultFrostAuthorizationMonitorInterval = time.Second + +// frostPreSignAuthorizationMonitor keeps the pinned Ethereum authorization +// live for the entire native signing window. All explicit nonce/share guards +// and the periodic monitor serialize through validationMutex so a backend never +// observes overlapping reads for one authorization. +type frostPreSignAuthorizationMonitor struct { + gate frostPreSignAuthorizationGate + authorization *frostPreSignAuthorization + ctx context.Context + cancel context.CancelFunc + interval time.Duration + + validationMutex sync.Mutex + errorMutex sync.Mutex + authorizationErr error + done chan struct{} +} + +func newFrostPreSignAuthorizationMonitor( + parent context.Context, + gate frostPreSignAuthorizationGate, + authorization *frostPreSignAuthorization, + interval time.Duration, +) *frostPreSignAuthorizationMonitor { + if interval <= 0 { + interval = defaultFrostAuthorizationMonitorInterval + } + ctx, cancel := context.WithCancel(parent) + monitor := &frostPreSignAuthorizationMonitor{ + gate: gate, + authorization: authorization, + ctx: ctx, + cancel: cancel, + interval: interval, + done: make(chan struct{}), + } + go monitor.run() + return monitor +} + +func (fpsam *frostPreSignAuthorizationMonitor) run() { + defer close(fpsam.done) + ticker := time.NewTicker(fpsam.interval) + defer ticker.Stop() + for { + select { + case <-fpsam.ctx.Done(): + return + case <-ticker.C: + _ = fpsam.revalidate(fpsam.ctx) + } + } +} + +func (fpsam *frostPreSignAuthorizationMonitor) revalidate( + ctx context.Context, +) error { + if err := fpsam.err(); err != nil { + return err + } + fpsam.validationMutex.Lock() + defer fpsam.validationMutex.Unlock() + if err := fpsam.err(); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if err := fpsam.gate.revalidate(ctx, fpsam.authorization); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + authorizationErr := fmt.Errorf( + "FROST finalized authorization changed during native signing: [%w]", + err, + ) + fpsam.errorMutex.Lock() + if fpsam.authorizationErr == nil { + fpsam.authorizationErr = authorizationErr + } + authorizationErr = fpsam.authorizationErr + fpsam.errorMutex.Unlock() + fpsam.cancel() + return authorizationErr + } + return nil +} + +func (fpsam *frostPreSignAuthorizationMonitor) validateNow() error { + return fpsam.revalidate(fpsam.ctx) +} + +func (fpsam *frostPreSignAuthorizationMonitor) guard( + ctx context.Context, +) error { + return fpsam.revalidate(ctx) +} + +func (fpsam *frostPreSignAuthorizationMonitor) err() error { + fpsam.errorMutex.Lock() + defer fpsam.errorMutex.Unlock() + return fpsam.authorizationErr +} + +func (fpsam *frostPreSignAuthorizationMonitor) stop() { + fpsam.cancel() + <-fpsam.done } var buildTaprootTxViaNativeSignerFn = buildTaprootTxViaNativeSigner @@ -337,12 +474,17 @@ func newWalletTransactionExecutor( signingExecutor walletSigningExecutor, waitForBlockFn waitForBlockFn, ) *walletTransactionExecutor { - return &walletTransactionExecutor{ + executor := &walletTransactionExecutor{ btcChain: btcChain, executingWallet: executingWallet, signingExecutor: signingExecutor, waitForBlockFn: waitForBlockFn, } + if provider, ok := signingExecutor.(frostTransactionSafetyProvider); ok { + executor.preSignAuthorizationGate = provider.frostPreSignGate() + executor.broadcastOutbox = provider.bitcoinOutbox() + } + return executor } // signTransaction performs signing of an unsigned Bitcoin transaction @@ -397,52 +539,164 @@ func (wte *walletTransactionExecutor) signTransaction( ) } - signTxLogger.Infof("signing transaction's sig hashes") - - signingCtx, cancelSigningCtx := withCancelOnBlock( - context.Background(), - signingTimeoutBlock, - wte.waitForBlockFn, - ) - defer cancelSigningCtx() - var signatures []*frost.Signature + var authorization *frostPreSignAuthorization + var authorizationMonitor *frostPreSignAuthorizationMonitor + effectiveSigningStartBlock := signingStartBlock taprootMerkleRoots := unsignedTx.TaprootKeyPathInputMerkleRoots() - policyBoundSigningExecutor, policyBindingAvailable := - wte.signingExecutor.(taprootPolicyBoundWalletSigningExecutor) + if usesSchnorrSignatures { - if !policyBindingAvailable { + authorizedSigningExecutor, ok := + wte.signingExecutor.(authorizedTaprootPolicyBoundWalletSigningExecutor) + if !ok { return nil, fmt.Errorf( - "Schnorr signing executor does not support transaction policy binding", + "Schnorr signing executor does not support finalized transaction authorization binding", ) } - signatures, err = policyBoundSigningExecutor.signBatchWithTaprootTransaction( - signingCtx, - sigHashes, - taprootMerkleRoots, - signingStartBlock, + if wte.preSignAuthorizationGate == nil { + return nil, fmt.Errorf( + "FROST signing is disabled: pre-sign authorization gate is unavailable", + ) + } + if wte.broadcastOutbox == nil { + return nil, fmt.Errorf( + "FROST signing is disabled: durable Bitcoin broadcast outbox is unavailable", + ) + } + currentBlockProvider, ok := wte.signingExecutor.(signingCurrentBlockProvider) + if !ok { + return nil, fmt.Errorf( + "FROST signing is disabled: current anchoring block source is unavailable", + ) + } + + preSignTransaction, buildErr := newFrostPreSignTransaction( + wte.action, + bitcoin.PublicKeyHash(wte.executingWallet.publicKey), unsignedTx, + sigHashes, ) - } else if hasTaprootMerkleRoots(taprootMerkleRoots) { - tweakedSigningExecutor, ok := wte.signingExecutor.(taprootTweakedWalletSigningExecutor) - if !ok { + if buildErr != nil { + return nil, fmt.Errorf("cannot build FROST pre-sign proposal: [%w]", buildErr) + } + preSignTransaction.ActionContext = cloneFrostPreSignActionContext( + wte.frostPreSignActionContext, + ) + + authorizationCtx, cancelAuthorizationCtx := withCancelOnBlock( + context.Background(), + signingTimeoutBlock, + wte.waitForBlockFn, + ) + authorization, err = wte.preSignAuthorizationGate.authorize( + authorizationCtx, + preSignTransaction, + ) + if err == nil { + err = wte.preSignAuthorizationGate.revalidate( + authorizationCtx, + authorization, + ) + } + if err == nil { + err = authorizationCtx.Err() + } + cancelAuthorizationCtx() + if err != nil { return nil, fmt.Errorf( - "taproot tweaked signing requires signer support", + "FROST pre-sign authorization failed before nonce generation: [%w]", + err, ) } - signatures, err = tweakedSigningExecutor.signBatchWithTaprootMerkleRoots( + effectiveSigningStartBlock, err = currentBlockProvider.currentSigningBlock() + if err != nil { + return nil, fmt.Errorf( + "cannot determine fresh post-authorization signing block: [%w]", + err, + ) + } + if effectiveSigningStartBlock < authorization.Finality.BlockNumber { + effectiveSigningStartBlock = authorization.Finality.BlockNumber + } + if effectiveSigningStartBlock >= signingTimeoutBlock { + return nil, fmt.Errorf( + "FROST authorization finalized at/after signing timeout block [%d]", + signingTimeoutBlock, + ) + } + + // Create a fresh absolute-timeout context after finality. Reusing the + // pre-authorization session start can make ROAST windows stale before the + // first native bind/nonce operation. + signingCtx, cancelSigningCtx := withCancelOnBlock( + context.Background(), + signingTimeoutBlock, + wte.waitForBlockFn, + ) + defer cancelSigningCtx() + if err := wte.preSignAuthorizationGate.revalidate( signingCtx, + authorization, + ); err != nil { + return nil, fmt.Errorf( + "FROST finalized authorization changed before native signing: [%w]", + err, + ) + } + authorizationMonitor = newFrostPreSignAuthorizationMonitor( + signingCtx, + wte.preSignAuthorizationGate, + authorization, + wte.frostAuthorizationMonitorInterval, + ) + defer authorizationMonitor.stop() + + signTxLogger.Infof("signing transaction's sig hashes") + signatures, err = authorizedSigningExecutor.signBatchWithAuthorizedTaprootTransaction( + authorizationMonitor.ctx, sigHashes, taprootMerkleRoots, - signingStartBlock, + effectiveSigningStartBlock, + unsignedTx, + authorization.AuthorizationID, + authorizationMonitor.guard, ) + if authorizationErr := authorizationMonitor.err(); authorizationErr != nil { + err = authorizationErr + } else if err == nil { + err = authorizationMonitor.validateNow() + } } else { - signatures, err = wte.signingExecutor.signBatch( - signingCtx, - sigHashes, - signingStartBlock, + signTxLogger.Infof("signing transaction's sig hashes") + signingCtx, cancelSigningCtx := withCancelOnBlock( + context.Background(), + signingTimeoutBlock, + wte.waitForBlockFn, ) + defer cancelSigningCtx() + + if hasTaprootMerkleRoots(taprootMerkleRoots) { + tweakedSigningExecutor, ok := wte.signingExecutor.(taprootTweakedWalletSigningExecutor) + if !ok { + return nil, fmt.Errorf( + "taproot tweaked signing requires signer support", + ) + } + + signatures, err = tweakedSigningExecutor.signBatchWithTaprootMerkleRoots( + signingCtx, + sigHashes, + taprootMerkleRoots, + effectiveSigningStartBlock, + ) + } else { + signatures, err = wte.signingExecutor.signBatch( + signingCtx, + sigHashes, + effectiveSigningStartBlock, + ) + } } if err != nil { return nil, fmt.Errorf( @@ -473,8 +727,43 @@ func (wte *walletTransactionExecutor) signTransaction( ) } - signTxLogger.Infof("transaction created successfully") + if usesSchnorrSignatures { + if err := authorizationMonitor.validateNow(); err != nil { + return nil, err + } + proposal := authorization.proposal + if err := wte.broadcastOutbox.enqueue( + tx, + proposal.Transaction.WalletPublicKeyHash, + proposal.WalletID, + proposal.Transaction.Action, + proposal.Transaction.TransactionHash, + bitcoinBroadcastAuthorization{ + ActivationProfileHash: authorization.ActivationProfileHash, + AuthorizationID: authorization.AuthorizationID, + ReservationID: authorization.ReservationID, + AuthorizationRoot: authorization.VariantRoot, + SnapshotHash: proposal.SnapshotHash, + ResourceHash: proposal.ResourceHash, + OrderedInputRoot: proposal.OrderedInputRoot, + LockedPlanHash: proposal.computeLockedPlanHash(), + VariantApplyPlanHash: proposal.ApplyPlanHash, + FeeLimitSnapshot: proposal.FeeLimitSnapshot, + FinalizedBlock: authorization.Finality.BlockNumber, + FinalizedBlockHash: authorization.Finality.BlockHash, + FinalizedTransactionIndex: authorization.Finality.TransactionIndex, + FinalizedLogIndex: authorization.Finality.LogIndex, + VariantSequence: authorization.VariantSequence, + }, + ); err != nil { + return nil, fmt.Errorf( + "cannot durably enqueue signed FROST transaction: [%w]", + err, + ) + } + } + signTxLogger.Infof("transaction created successfully") return tx, nil } @@ -907,7 +1196,20 @@ func (wte *walletTransactionExecutor) broadcastTransaction( broadcastAttempt, ) - err := wte.btcChain.BroadcastTransaction(tx) + var err error + if wte.usesSchnorrSignatures() { + if wte.broadcastOutbox == nil { + return fmt.Errorf( + "FROST Bitcoin broadcast requires the durable authorized outbox", + ) + } + err = wte.broadcastOutbox.broadcastTransaction( + broadcastCtx, + txHash, + ) + } else { + err = wte.btcChain.BroadcastTransaction(tx) + } if err != nil { broadcastTxLogger.Warnf( "broadcasting failed: [%v]; transaction could be "+ diff --git a/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go b/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go index 6c6c46ac45..e17304d727 100644 --- a/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go +++ b/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go @@ -11,6 +11,7 @@ import ( "math/big" "strings" "testing" + "time" btcec2 "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" @@ -709,23 +710,34 @@ func TestWalletTransactionExecutor_SignTransaction_PolicyBoundSchnorrChargesOnly return true } mandatoryBuildCalls := 0 + outbox := openTestBitcoinBroadcastOutbox( + t, + t.TempDir(), + newOutboxTestBitcoinChain(), + ) + t.Cleanup(func() { _ = outbox.close() }) wte := &walletTransactionExecutor{ executingWallet: generateWallet(big.NewInt(111)), signingExecutor: &deterministicSchnorrSigningExecutorForTaproot{ - privateKey: privateKey, + privateKey: privateKey, + currentBlock: 20, beforePolicyBinding: func() error { mandatoryBuildCalls++ return consumeBuildToken() }, }, waitForBlockFn: func(ctx context.Context, block uint64) error { - return nil + <-ctx.Done() + return ctx.Err() }, + action: ActionDepositSweep, + preSignAuthorizationGate: &testFrostPreSignAuthorizationGate{}, + broadcastOutbox: outbox, } logger := &warningCaptureLogger{} - tx, err := wte.signTransaction(logger, unsignedTx, 0, 0) + tx, err := wte.signTransaction(logger, unsignedTx, 1, 1000) if err != nil { t.Fatalf("unexpected signTransaction error: [%v]", err) } @@ -837,18 +849,29 @@ func TestWalletTransactionExecutor_SignTransaction_AppliesTaprootKeyPathSignatur Value: 90000, PublicKeyScript: outputScript, }) + outbox := openTestBitcoinBroadcastOutbox( + t, + t.TempDir(), + newOutboxTestBitcoinChain(), + ) + t.Cleanup(func() { _ = outbox.close() }) wte := &walletTransactionExecutor{ executingWallet: generateWallet(big.NewInt(111)), signingExecutor: &deterministicSchnorrSigningExecutorForTaproot{ - privateKey: privateKey, + privateKey: privateKey, + currentBlock: 20, }, waitForBlockFn: func(ctx context.Context, block uint64) error { - return nil + <-ctx.Done() + return ctx.Err() }, + action: ActionDepositSweep, + preSignAuthorizationGate: &testFrostPreSignAuthorizationGate{}, + broadcastOutbox: outbox, } - tx, err := wte.signTransaction(&warningCaptureLogger{}, unsignedTx, 0, 0) + tx, err := wte.signTransaction(&warningCaptureLogger{}, unsignedTx, 1, 1000) if err != nil { t.Fatalf("unexpected signTransaction error: [%v]", err) } @@ -981,18 +1004,29 @@ func TestWalletTransactionExecutor_SignTransaction_AppliesTweakedTaprootKeyPathS tweakedPrivateKey, _ := btcec2.PrivKeyFromBytes(tweakedPrivateKeyBytes) signingExecutor := &taprootMerkleRootRecordingSchnorrSigningExecutor{ - privateKey: tweakedPrivateKey, + privateKey: tweakedPrivateKey, + currentBlock: 20, } + outbox := openTestBitcoinBroadcastOutbox( + t, + t.TempDir(), + newOutboxTestBitcoinChain(), + ) + t.Cleanup(func() { _ = outbox.close() }) wte := &walletTransactionExecutor{ executingWallet: generateWallet(big.NewInt(111)), signingExecutor: signingExecutor, waitForBlockFn: func(ctx context.Context, block uint64) error { - return nil + <-ctx.Done() + return ctx.Err() }, + action: ActionDepositSweep, + preSignAuthorizationGate: &testFrostPreSignAuthorizationGate{}, + broadcastOutbox: outbox, } - tx, err := wte.signTransaction(&warningCaptureLogger{}, unsignedTx, 0, 0) + tx, err := wte.signTransaction(&warningCaptureLogger{}, unsignedTx, 1, 1000) if err != nil { t.Fatalf("unexpected signTransaction error: [%v]", err) } @@ -1033,6 +1067,226 @@ func TestWalletTransactionExecutor_SignTransaction_AppliesTweakedTaprootKeyPathS } } +func TestWalletTransactionExecutor_SignTransaction_FrostAuthorizationFailurePrecedesNativeBinding( + t *testing.T, +) { + testCases := map[string]*testFrostPreSignAuthorizationGate{ + "authorization denied": { + authorizeErr: errors.New("authorization denied"), + }, + "finalized state changed": { + revalidateErr: errors.New("finalized state changed"), + }, + } + + for name, gate := range testCases { + t.Run(name, func(t *testing.T) { + unsignedTx, privateKey := buildTaprootKeyPathUnsignedTxForTest(t) + nativeBindingCalls := 0 + signingExecutor := &deterministicSchnorrSigningExecutorForTaproot{ + privateKey: privateKey, + currentBlock: 20, + beforePolicyBinding: func() error { + nativeBindingCalls++ + return nil + }, + } + outbox := openTestBitcoinBroadcastOutbox( + t, + t.TempDir(), + newOutboxTestBitcoinChain(), + ) + t.Cleanup(func() { _ = outbox.close() }) + + wte := &walletTransactionExecutor{ + executingWallet: generateWallet(big.NewInt(111)), + signingExecutor: signingExecutor, + waitForBlockFn: blockingTestWaitForBlock, + action: ActionDepositSweep, + preSignAuthorizationGate: gate, + broadcastOutbox: outbox, + } + + _, err := wte.signTransaction( + &warningCaptureLogger{}, + unsignedTx, + 1, + 1000, + ) + if err == nil || !strings.Contains(err.Error(), "before nonce generation") { + t.Fatalf("unexpected authorization failure: [%v]", err) + } + if signingExecutor.authorizedCalls != 0 || nativeBindingCalls != 0 { + t.Fatal("native signing was reached before finalized authorization") + } + outbox.mutex.Lock() + recordCount := len(outbox.records) + outbox.mutex.Unlock() + if recordCount != 0 { + t.Fatal("authorization failure wrote a broadcast outbox record") + } + }) + } +} + +func TestWalletTransactionExecutor_SignTransaction_ReorgDuringNativeSigningCancelsBeforeSignature( + t *testing.T, +) { + unsignedTx, privateKey := buildTaprootKeyPathUnsignedTxForTest(t) + gate := &testFrostPreSignAuthorizationGate{finalizedBlock: 500} + signingExecutor := &deterministicSchnorrSigningExecutorForTaproot{ + privateKey: privateKey, + currentBlock: 520, + duringAuthorizedSigning: func(ctx context.Context) error { + gate.setRevalidateError(errors.New("injected finalized-block reorg")) + <-ctx.Done() + return errors.New("native signing canceled before signature production") + }, + } + outbox := openTestBitcoinBroadcastOutbox( + t, + t.TempDir(), + newOutboxTestBitcoinChain(), + ) + t.Cleanup(func() { _ = outbox.close() }) + wte := &walletTransactionExecutor{ + executingWallet: generateWallet(big.NewInt(111)), + signingExecutor: signingExecutor, + waitForBlockFn: blockingTestWaitForBlock, + action: ActionDepositSweep, + preSignAuthorizationGate: gate, + broadcastOutbox: outbox, + frostAuthorizationMonitorInterval: time.Millisecond, + } + + tx, err := wte.signTransaction( + &warningCaptureLogger{}, + unsignedTx, + 10, + 600, + ) + if err == nil || + !strings.Contains(err.Error(), "changed during native signing") { + t.Fatalf("unexpected mid-signing reorg result: [%v]", err) + } + if tx != nil { + t.Fatal("mid-signing reorg released a signed transaction") + } + if signingExecutor.signatureCalls != 0 { + t.Fatal("mid-signing reorg reached signature production") + } + outbox.mutex.Lock() + recordCount := len(outbox.records) + outbox.mutex.Unlock() + if recordCount != 0 { + t.Fatal("mid-signing reorg wrote a broadcast outbox record") + } +} + +func TestWalletTransactionExecutor_SignTransaction_UsesFreshPostFinalityBlockAndPersistsBeforeReturn( + t *testing.T, +) { + unsignedTx, privateKey := buildTaprootKeyPathUnsignedTxForTest(t) + signingExecutor := &deterministicSchnorrSigningExecutorForTaproot{ + privateKey: privateKey, + currentBlock: 520, + } + gate := &testFrostPreSignAuthorizationGate{finalizedBlock: 500} + outbox := openTestBitcoinBroadcastOutbox( + t, + t.TempDir(), + newOutboxTestBitcoinChain(), + ) + t.Cleanup(func() { _ = outbox.close() }) + executingWallet := generateWallet(big.NewInt(111)) + wte := &walletTransactionExecutor{ + executingWallet: executingWallet, + signingExecutor: signingExecutor, + waitForBlockFn: blockingTestWaitForBlock, + action: ActionDepositSweep, + preSignAuthorizationGate: gate, + broadcastOutbox: outbox, + } + + tx, err := wte.signTransaction( + &warningCaptureLogger{}, + unsignedTx, + 10, + 600, + ) + if err != nil { + t.Fatalf("unexpected signTransaction error: [%v]", err) + } + if signingExecutor.authorizedCalls != 1 || + len(signingExecutor.authorizedStarts) != 1 || + signingExecutor.authorizedStarts[0] != 520 { + t.Fatalf( + "signing did not use the fresh post-finality block: [%v]", + signingExecutor.authorizedStarts, + ) + } + if gate.revalidateCalls < 6 { + t.Fatalf("finalized state was not guarded through signing: [%d]", gate.revalidateCalls) + } + + outbox.mutex.Lock() + record, ok := outbox.records[tx.Hash()] + outbox.mutex.Unlock() + if !ok { + t.Fatal("signed transaction returned before durable outbox insertion") + } + if record.Authorization.AuthorizationID == [32]byte{} || + record.Authorization.ReservationID == [32]byte{} || + record.Authorization.FinalizedBlock != 500 || + record.Action != FrostPreSignActionDepositSweep || + record.WalletPublicKeyHash != bitcoin.PublicKeyHash(executingWallet.publicKey) { + t.Fatalf("persisted record lost finalized authorization identity: [%+v]", record) + } +} + +func TestWalletTransactionExecutor_SignTransaction_RejectsAuthorizationAtTimeoutBoundary( + t *testing.T, +) { + unsignedTx, privateKey := buildTaprootKeyPathUnsignedTxForTest(t) + signingExecutor := &deterministicSchnorrSigningExecutorForTaproot{ + privateKey: privateKey, + currentBlock: 599, + } + gate := &testFrostPreSignAuthorizationGate{finalizedBlock: 600} + outbox := openTestBitcoinBroadcastOutbox( + t, + t.TempDir(), + newOutboxTestBitcoinChain(), + ) + t.Cleanup(func() { _ = outbox.close() }) + wte := &walletTransactionExecutor{ + executingWallet: generateWallet(big.NewInt(111)), + signingExecutor: signingExecutor, + waitForBlockFn: blockingTestWaitForBlock, + action: ActionDepositSweep, + preSignAuthorizationGate: gate, + broadcastOutbox: outbox, + } + + _, err := wte.signTransaction( + &warningCaptureLogger{}, + unsignedTx, + 10, + 600, + ) + if err == nil || !strings.Contains(err.Error(), "at/after signing timeout") { + t.Fatalf("unexpected timeout-boundary result: [%v]", err) + } + if signingExecutor.authorizedCalls != 0 { + t.Fatal("native signing reached the timeout boundary") + } +} + +func blockingTestWaitForBlock(ctx context.Context, block uint64) error { + <-ctx.Done() + return ctx.Err() +} + func TestWalletTransactionExecutor_SignTransaction_RejectsMixedTaprootAndLegacyInputsBeforeSigning( t *testing.T, ) { @@ -1523,8 +1777,14 @@ func (desefbts *deterministicECDSASigningExecutorForBuildTaprootTxSubstitution) } type deterministicSchnorrSigningExecutorForTaproot struct { - privateKey *btcec2.PrivateKey - beforePolicyBinding func() error + privateKey *btcec2.PrivateKey + beforePolicyBinding func() error + duringAuthorizedSigning func(context.Context) error + currentBlock uint64 + authorizedCalls int + signatureCalls int + authorizedStarts []uint64 + authorizationIDs [][32]byte } // nonPolicyBoundTaprootSigningExecutor returns valid Schnorr signature bytes @@ -1548,6 +1808,7 @@ func (dsseft *deterministicSchnorrSigningExecutorForTaproot) signBatch( messages []*big.Int, startBlock uint64, ) ([]*frost.Signature, error) { + dsseft.signatureCalls++ signatures := make([]*frost.Signature, 0, len(messages)) for _, message := range messages { @@ -1590,10 +1851,62 @@ func (dsseft *deterministicSchnorrSigningExecutorForTaproot) signBatchWithTaproo return dsseft.signBatch(ctx, messages, startBlock) } +func (dsseft *deterministicSchnorrSigningExecutorForTaproot) signBatchWithAuthorizedTaprootTransaction( + ctx context.Context, + messages []*big.Int, + taprootMerkleRoots []*[32]byte, + startBlock uint64, + unsignedTx *bitcoin.TransactionBuilder, + authorizationID [32]byte, + authorizationGuard func(context.Context) error, +) ([]*frost.Signature, error) { + if authorizationID == [32]byte{} { + return nil, errors.New("zero authorization ID") + } + if authorizationGuard == nil { + return nil, errors.New("nil authorization guard") + } + if err := authorizationGuard(ctx); err != nil { + return nil, err + } + dsseft.authorizedCalls++ + dsseft.authorizedStarts = append(dsseft.authorizedStarts, startBlock) + dsseft.authorizationIDs = append(dsseft.authorizationIDs, authorizationID) + if dsseft.duringAuthorizedSigning != nil { + if err := dsseft.duringAuthorizedSigning(ctx); err != nil { + return nil, err + } + } + signatures, err := dsseft.signBatchWithTaprootTransaction( + ctx, + messages, + taprootMerkleRoots, + startBlock, + unsignedTx, + ) + if err != nil { + return nil, err + } + if err := authorizationGuard(ctx); err != nil { + return nil, err + } + return signatures, nil +} + +func (dsseft *deterministicSchnorrSigningExecutorForTaproot) currentSigningBlock() (uint64, error) { + if dsseft.currentBlock == 0 { + return 20, nil + } + return dsseft.currentBlock, nil +} + type taprootMerkleRootRecordingSchnorrSigningExecutor struct { privateKey *btcec2.PrivateKey signBatchCalled bool taprootMerkleRoots []*[32]byte + currentBlock uint64 + authorizedCalls int + authorizedStarts []uint64 } func (tmrrsse *taprootMerkleRootRecordingSchnorrSigningExecutor) signBatch( @@ -1662,6 +1975,49 @@ func (tmrrsse *taprootMerkleRootRecordingSchnorrSigningExecutor) signBatchWithTa ) } +func (tmrrsse *taprootMerkleRootRecordingSchnorrSigningExecutor) signBatchWithAuthorizedTaprootTransaction( + ctx context.Context, + messages []*big.Int, + taprootMerkleRoots []*[32]byte, + startBlock uint64, + unsignedTx *bitcoin.TransactionBuilder, + authorizationID [32]byte, + authorizationGuard func(context.Context) error, +) ([]*frost.Signature, error) { + if authorizationID == [32]byte{} { + return nil, errors.New("zero authorization ID") + } + if authorizationGuard == nil { + return nil, errors.New("nil authorization guard") + } + if err := authorizationGuard(ctx); err != nil { + return nil, err + } + tmrrsse.authorizedCalls++ + tmrrsse.authorizedStarts = append(tmrrsse.authorizedStarts, startBlock) + signatures, err := tmrrsse.signBatchWithTaprootTransaction( + ctx, + messages, + taprootMerkleRoots, + startBlock, + unsignedTx, + ) + if err != nil { + return nil, err + } + if err := authorizationGuard(ctx); err != nil { + return nil, err + } + return signatures, nil +} + +func (tmrrsse *taprootMerkleRootRecordingSchnorrSigningExecutor) currentSigningBlock() (uint64, error) { + if tmrrsse.currentBlock == 0 { + return 20, nil + } + return tmrrsse.currentBlock, nil +} + type unexpectedSigningExecutorForBuildTaprootTxError struct{} func (usefbte *unexpectedSigningExecutorForBuildTaprootTxError) signBatch( From 27c310ca17c8a9a76c47ef53980c8598c9f68214 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 21 Jul 2026 16:20:23 -0400 Subject: [PATCH 02/93] Implement crash-safe FROST retained-group readiness --- .../tbtc_frost_pre_sign_authorization.go | 242 ++- ...tbtc_frost_retained_group_manifest_test.go | 119 + pkg/tbtc/frost_activation_handshake.go | 235 +- pkg/tbtc/frost_activation_handshake_test.go | 212 +- pkg/tbtc/frost_pre_sign_authorization.go | 2 + pkg/tbtc/frost_retained_group_journal.go | 1926 +++++++++++++++++ pkg/tbtc/frost_retained_group_journal_test.go | 773 +++++++ pkg/tbtc/node.go | 24 +- pkg/tbtc/tbtc.go | 22 +- 9 files changed, 3391 insertions(+), 164 deletions(-) create mode 100644 pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go create mode 100644 pkg/tbtc/frost_retained_group_journal.go create mode 100644 pkg/tbtc/frost_retained_group_journal_test.go diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index 9eb26238e1..96146cac0e 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -168,28 +168,50 @@ type frostPreSignManifestEthereum struct { FrostArchive json.RawMessage `json:"frostArchive"` } +type frostPreSignManifestCanonicalJournal struct { + StoreID string `json:"storeID"` + StoreFingerprint string `json:"storeFingerprint"` + ClusterFingerprint string `json:"clusterFingerprint"` + Checkpoint frostPreSignManifestPoint `json:"checkpoint"` + DescriptorSetHash string `json:"descriptorSetHash"` + SourceTrustDomainID string `json:"sourceTrustDomainID"` + SourceEndpointFingerprint string `json:"sourceEndpointFingerprint"` + SourceOperatorFingerprint string `json:"sourceOperatorFingerprint"` + MinimumGeneration uint64 `json:"minimumGeneration"` +} + +type frostPreSignManifestQuarantineJournal struct { + ProtocolID string `json:"protocolID"` + StoreID string `json:"storeID"` + StoreFingerprint string `json:"storeFingerprint"` + ClusterFingerprint string `json:"clusterFingerprint"` + MinimumGeneration uint64 `json:"minimumGeneration"` +} + type frostPreSignManifestFrostSigner struct { - TrustDomainID string `json:"trustDomainID"` - DurableSessionStoreFingerprint string `json:"durableSessionStoreFingerprint"` - ProtocolID string `json:"protocolID"` - ReservationProtocolID string `json:"reservationProtocolID"` - BitcoinOutboxProtocolID string `json:"bitcoinOutboxProtocolID"` - SigningPolicyHash string `json:"signingPolicyHash"` - CompleteRouterAddress string `json:"completeRouterAddress"` - AuthorizationRegistryAddress string `json:"authorizationRegistryAddress"` - AttestationSignerKeyHash string `json:"attestationSignerKeyHash"` - HandshakeEndpointFingerprint string `json:"handshakeEndpointFingerprint"` - HandshakeOperatorFingerprint string `json:"handshakeOperatorFingerprint"` - Threshold uint64 `json:"threshold"` - MaximumGroupSize uint64 `json:"maximumGroupSize"` - RetainedGroupInventoryProtocolID string `json:"retainedGroupInventoryProtocolID"` - ExactRetainedGroupInventoryRequired bool `json:"exactRetainedGroupInventoryRequired"` - FinalizedReservationReceiptRequired bool `json:"finalizedReservationReceiptRequired"` - ExactReservationIdentityRequired bool `json:"exactReservationIdentityRequired"` - AuthorizationRootRequired bool `json:"authorizationRootRequired"` - DurableSessionPersistenceRequired bool `json:"durableSessionPersistenceRequired"` - DurableBitcoinOutboxRequired bool `json:"durableBitcoinOutboxRequired"` - QuarantineFailClosed bool `json:"quarantineFailClosed"` + TrustDomainID string `json:"trustDomainID"` + DurableSessionStoreFingerprint string `json:"durableSessionStoreFingerprint"` + ProtocolID string `json:"protocolID"` + ReservationProtocolID string `json:"reservationProtocolID"` + BitcoinOutboxProtocolID string `json:"bitcoinOutboxProtocolID"` + SigningPolicyHash string `json:"signingPolicyHash"` + CompleteRouterAddress string `json:"completeRouterAddress"` + AuthorizationRegistryAddress string `json:"authorizationRegistryAddress"` + AttestationSignerKeyHash string `json:"attestationSignerKeyHash"` + HandshakeEndpointFingerprint string `json:"handshakeEndpointFingerprint"` + HandshakeOperatorFingerprint string `json:"handshakeOperatorFingerprint"` + Threshold uint64 `json:"threshold"` + MaximumGroupSize uint64 `json:"maximumGroupSize"` + RetainedGroupInventoryProtocolID string `json:"retainedGroupInventoryProtocolID"` + CanonicalJournal frostPreSignManifestCanonicalJournal `json:"canonicalJournal"` + QuarantineJournal frostPreSignManifestQuarantineJournal `json:"quarantineJournal"` + ExactRetainedGroupInventoryRequired bool `json:"exactRetainedGroupInventoryRequired"` + FinalizedReservationReceiptRequired bool `json:"finalizedReservationReceiptRequired"` + ExactReservationIdentityRequired bool `json:"exactReservationIdentityRequired"` + AuthorizationRootRequired bool `json:"authorizationRootRequired"` + DurableSessionPersistenceRequired bool `json:"durableSessionPersistenceRequired"` + DurableBitcoinOutboxRequired bool `json:"durableBitcoinOutboxRequired"` + QuarantineFailClosed bool `json:"quarantineFailClosed"` } type frostPreSignActivationManifest struct { @@ -567,11 +589,94 @@ func validateFrostPreSignActivationManifest( "signing policy": frost.SigningPolicyHash, "attestation signer key": frost.AttestationSignerKeyHash, "retained group inventory protocol": frost.RetainedGroupInventoryProtocolID, + "quarantine journal protocol": frost.QuarantineJournal.ProtocolID, } { if _, err := frostPreSignParseBytes32(value); err != nil { return fmt.Errorf("invalid FROST activation %s: [%w]", name, err) } } + durableSessionStoreFingerprint, err := frostPreSignParseBytes32( + frost.DurableSessionStoreFingerprint, + ) + if err != nil || durableSessionStoreFingerprint == [32]byte{} { + return fmt.Errorf("invalid FROST durable session store fingerprint") + } + journal := frost.CanonicalJournal + if strings.TrimSpace(journal.StoreID) == "" || len(journal.StoreID) > 255 || + strings.TrimSpace(journal.SourceTrustDomainID) == "" || + len(journal.SourceTrustDomainID) > 128 || + journal.Checkpoint.BlockNumber == 0 || + journal.Checkpoint.BlockNumber > manifest.Ethereum.Checkpoint.BlockNumber { + return fmt.Errorf("FROST canonical retained-group journal manifest is incomplete") + } + journalCheckpointHash, err := frostPreSignParseBytes32(journal.Checkpoint.BlockHash) + if err != nil || journalCheckpointHash == [32]byte{} { + return fmt.Errorf("invalid FROST canonical journal checkpoint hash: [%w]", err) + } + journalValues := map[string]string{ + "store fingerprint": journal.StoreFingerprint, + "cluster fingerprint": journal.ClusterFingerprint, + "descriptor set hash": journal.DescriptorSetHash, + "source endpoint fingerprint": journal.SourceEndpointFingerprint, + "source operator fingerprint": journal.SourceOperatorFingerprint, + } + parsedJournalValues := make(map[string][32]byte, len(journalValues)) + for name, value := range journalValues { + parsed, err := frostPreSignParseBytes32(value) + if err != nil || parsed == [32]byte{} { + return fmt.Errorf("invalid FROST canonical journal %s", name) + } + parsedJournalValues[name] = parsed + } + for name, value := range map[string]string{ + "Ethereum source endpoint": manifest.Ethereum.SourceEndpointFingerprint, + "Ethereum verifier endpoint": manifest.Ethereum.VerifierEndpointFingerprint, + "runtime handshake endpoint": frost.HandshakeEndpointFingerprint, + } { + parsed, err := frostPreSignParseBytes32(value) + if err != nil || parsed == parsedJournalValues["source endpoint fingerprint"] { + return fmt.Errorf("FROST canonical journal source endpoint is not independent of %s", name) + } + } + for name, value := range map[string]string{ + "Ethereum source operator": manifest.Ethereum.SourceOperatorFingerprint, + "Ethereum verifier operator": manifest.Ethereum.VerifierOperatorFingerprint, + "runtime handshake operator": frost.HandshakeOperatorFingerprint, + } { + parsed, err := frostPreSignParseBytes32(value) + if err != nil || parsed == parsedJournalValues["source operator fingerprint"] { + return fmt.Errorf("FROST canonical journal source operator is not independent of %s", name) + } + } + for name, value := range map[string]string{ + "Ethereum source": manifest.Ethereum.SourceTrustDomainID, + "Ethereum verifier": manifest.Ethereum.VerifierTrustDomainID, + "runtime signer": frost.TrustDomainID, + } { + if strings.TrimSpace(value) == "" || value == journal.SourceTrustDomainID { + return fmt.Errorf("FROST canonical journal source trust domain is not independent of %s", name) + } + } + quarantine := frost.QuarantineJournal + if strings.TrimSpace(quarantine.StoreID) == "" || len(quarantine.StoreID) > 255 { + return fmt.Errorf("FROST quarantine journal manifest is incomplete") + } + quarantineStoreFingerprint, err := frostPreSignParseBytes32(quarantine.StoreFingerprint) + if err != nil || quarantineStoreFingerprint == [32]byte{} { + return fmt.Errorf("invalid FROST quarantine journal store fingerprint") + } + quarantineClusterFingerprint, err := frostPreSignParseBytes32(quarantine.ClusterFingerprint) + if err != nil || quarantineClusterFingerprint == [32]byte{} { + return fmt.Errorf("invalid FROST quarantine journal cluster fingerprint") + } + if journal.StoreID == manifest.Ethereum.StoreID || + journal.StoreID == quarantine.StoreID || + parsedJournalValues["store fingerprint"] == quarantineStoreFingerprint || + parsedJournalValues["cluster fingerprint"] == quarantineClusterFingerprint || + durableSessionStoreFingerprint == parsedJournalValues["store fingerprint"] || + durableSessionStoreFingerprint == quarantineStoreFingerprint { + return fmt.Errorf("FROST canonical journal storage identities are not independent") + } return nil } @@ -1201,7 +1306,11 @@ func (adapter *frostPreSignEthereumAdapter) verifyDeploymentAt( if err != nil || validatorBridge != common.Address(profile.BridgeAddress) { return fmt.Errorf("proposal validator Bridge crosslink mismatch: [%w]", err) } - return nil + // Bracket every historical deployment read with a second finalized-head + // and exact-hash check. A target header that exists is not sufficient: the + // target must remain at or below the RPC's independently reported finalized + // tip for the entire read set. + return adapter.requireCanonicalFinality(ctx, finality) } func (adapter *frostPreSignEthereumAdapter) verifyLinkedLibrariesAt( @@ -1268,6 +1377,16 @@ func (adapter *frostPreSignEthereumAdapter) requireCanonicalFinality( if finality == nil || finality.BlockNumber == 0 || finality.BlockHash == [32]byte{} { return fmt.Errorf("Ethereum finality checkpoint is invalid") } + before, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) + if err != nil { + return err + } + if finality.BlockNumber > before.BlockNumber { + return fmt.Errorf("Ethereum checkpoint [%d] is above finalized head [%d]", finality.BlockNumber, before.BlockNumber) + } + if finality.BlockNumber == before.BlockNumber && finality.BlockHash != before.BlockHash { + return fmt.Errorf("Ethereum checkpoint disagrees with finalized head") + } header, err := adapter.chain.client.HeaderByNumber( ctx, new(big.Int).SetUint64(finality.BlockNumber), @@ -1278,6 +1397,26 @@ func (adapter *frostPreSignEthereumAdapter) requireCanonicalFinality( if header == nil || [32]byte(header.Hash()) != finality.BlockHash { return fmt.Errorf("finalized Ethereum block hash mismatch") } + after, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) + if err != nil { + return err + } + if after.BlockNumber < before.BlockNumber || + finality.BlockNumber > after.BlockNumber || + (finality.BlockNumber == after.BlockNumber && finality.BlockHash != after.BlockHash) || + (before.BlockNumber == after.BlockNumber && before.BlockHash != after.BlockHash) { + return fmt.Errorf("Ethereum finalized head changed inconsistently while verifying checkpoint") + } + headerAfter, err := adapter.chain.client.HeaderByNumber( + ctx, + new(big.Int).SetUint64(finality.BlockNumber), + ) + if err != nil { + return fmt.Errorf("cannot bracket finalized Ethereum header: [%w]", err) + } + if headerAfter == nil || [32]byte(headerAfter.Hash()) != finality.BlockHash { + return fmt.Errorf("finalized Ethereum block hash changed while verifying checkpoint") + } return nil } @@ -1384,6 +1523,44 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( if err != nil { return tbtc.FrostPreSignActivationRuntimeManifest{}, err } + quarantine := frost.QuarantineJournal + quarantineJournalProtocolID, err := parse(quarantine.ProtocolID) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + journal := frost.CanonicalJournal + storeFingerprint, err := parse(journal.StoreFingerprint) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + clusterFingerprint, err := parse(journal.ClusterFingerprint) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + descriptorSetHash, err := parse(journal.DescriptorSetHash) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + sourceEndpointFingerprint, err := parse(journal.SourceEndpointFingerprint) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + sourceOperatorFingerprint, err := parse(journal.SourceOperatorFingerprint) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + quarantineStoreFingerprint, err := parse(quarantine.StoreFingerprint) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + quarantineClusterFingerprint, err := parse(quarantine.ClusterFingerprint) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + checkpointHash, err := parse(journal.Checkpoint.BlockHash) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } return tbtc.FrostPreSignActivationRuntimeManifest{ ManifestHash: adapter.profile.ActivationManifestHash, SignerProtocolID: signerProtocolID, @@ -1397,6 +1574,27 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( Threshold: frost.Threshold, MaximumGroupSize: frost.MaximumGroupSize, RetainedGroupInventoryProtocolID: retainedGroupInventoryProtocolID, + CanonicalJournal: tbtc.FrostRetainedGroupCanonicalJournalManifest{ + StoreID: journal.StoreID, + StoreFingerprint: storeFingerprint, + ClusterFingerprint: clusterFingerprint, + Checkpoint: tbtc.FrostPreSignFinality{ + BlockNumber: journal.Checkpoint.BlockNumber, + BlockHash: checkpointHash, + }, + DescriptorSetHash: descriptorSetHash, + SourceTrustDomainID: journal.SourceTrustDomainID, + SourceEndpointFingerprint: sourceEndpointFingerprint, + SourceOperatorFingerprint: sourceOperatorFingerprint, + MinimumGeneration: journal.MinimumGeneration, + }, + QuarantineJournal: tbtc.FrostRetainedGroupQuarantineJournalManifest{ + ProtocolID: quarantineJournalProtocolID, + StoreID: quarantine.StoreID, + StoreFingerprint: quarantineStoreFingerprint, + ClusterFingerprint: quarantineClusterFingerprint, + MinimumGeneration: quarantine.MinimumGeneration, + }, }, nil } diff --git a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go new file mode 100644 index 0000000000..7357c51177 --- /dev/null +++ b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go @@ -0,0 +1,119 @@ +package ethereum + +import ( + "fmt" + "strings" + "testing" +) + +func testManifestHex32(value byte) string { + return fmt.Sprintf("0x%02x%s", value, strings.Repeat("00", 31)) +} + +func testFrostJournalActivationManifest() *frostPreSignActivationManifest { + checkpointHash := testManifestHex32(0x02) + return &frostPreSignActivationManifest{ + Schema: frostPreSignManifestVersion, + ActivationSequence: 1, + ActivationID: testManifestHex32(0x01), + Environment: "test", + manifestHash: [32]byte{0x99}, + Ethereum: frostPreSignManifestEthereum{ + ChainID: 1, + Checkpoint: frostPreSignManifestPoint{BlockNumber: 10, BlockHash: checkpointHash}, + StoreID: "primary-store", + SourceTrustDomainID: "primary-source", + SourceEndpointFingerprint: testManifestHex32(0x03), + SourceOperatorFingerprint: testManifestHex32(0x04), + SourceHistoryStoreID: "primary-history", + SourceHistoryStoreFingerprint: testManifestHex32(0x05), + VerifierTrustDomainID: "primary-verifier", + VerifierEndpointFingerprint: testManifestHex32(0x06), + VerifierOperatorFingerprint: testManifestHex32(0x07), + VerifierHistoryStoreID: "verifier-history", + VerifierHistoryStoreFingerprint: testManifestHex32(0x08), + }, + FrostSigner: frostPreSignManifestFrostSigner{ + TrustDomainID: "runtime-signer", + DurableSessionStoreFingerprint: testManifestHex32(0x09), + ProtocolID: testManifestHex32(0x10), + ReservationProtocolID: testManifestHex32(0x11), + BitcoinOutboxProtocolID: testManifestHex32(0x12), + SigningPolicyHash: testManifestHex32(0x13), + AttestationSignerKeyHash: testManifestHex32(0x14), + HandshakeEndpointFingerprint: testManifestHex32(0x15), + HandshakeOperatorFingerprint: testManifestHex32(0x16), + Threshold: 51, + MaximumGroupSize: 100, + RetainedGroupInventoryProtocolID: testManifestHex32(0x17), + ExactRetainedGroupInventoryRequired: true, + FinalizedReservationReceiptRequired: true, + ExactReservationIdentityRequired: true, + AuthorizationRootRequired: true, + DurableSessionPersistenceRequired: true, + DurableBitcoinOutboxRequired: true, + QuarantineFailClosed: true, + CanonicalJournal: frostPreSignManifestCanonicalJournal{ + StoreID: "canonical-journal-store", + StoreFingerprint: testManifestHex32(0x20), + ClusterFingerprint: testManifestHex32(0x21), + Checkpoint: frostPreSignManifestPoint{BlockNumber: 1, BlockHash: testManifestHex32(0x28)}, + DescriptorSetHash: testManifestHex32(0x22), + SourceTrustDomainID: "independent-journal-source", + SourceEndpointFingerprint: testManifestHex32(0x23), + SourceOperatorFingerprint: testManifestHex32(0x24), + MinimumGeneration: 7, + }, + QuarantineJournal: frostPreSignManifestQuarantineJournal{ + ProtocolID: testManifestHex32(0x25), + StoreID: "quarantine-journal-store", + StoreFingerprint: testManifestHex32(0x26), + ClusterFingerprint: testManifestHex32(0x27), + MinimumGeneration: 0, + }, + }, + } +} + +func TestValidateFrostPreSignActivationManifest_CanonicalJournal(t *testing.T) { + manifest := testFrostJournalActivationManifest() + if err := validateFrostPreSignActivationManifest(manifest); err != nil { + t.Fatal(err) + } + t.Run("source endpoint alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.CanonicalJournal.SourceEndpointFingerprint = + manifest.Ethereum.SourceEndpointFingerprint + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "not independent") { + t.Fatalf("expected independent-source validation failure, got [%v]", err) + } + }) + t.Run("quarantine store alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.StoreFingerprint = + manifest.FrostSigner.CanonicalJournal.StoreFingerprint + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "storage identities") { + t.Fatalf("expected independent-store validation failure, got [%v]", err) + } + }) + t.Run("malformed durable session fingerprint", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.DurableSessionStoreFingerprint = "operator-authored-label" + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "durable session store fingerprint") { + t.Fatalf("expected durable-session fingerprint failure, got [%v]", err) + } + }) +} + +func TestFrostPreSignDecodeStrictJSON_RejectsUnknownCanonicalJournalField(t *testing.T) { + data := []byte(`{"storeID":"id","unknown":true}`) + if err := frostPreSignDecodeStrictJSON( + data, + &frostPreSignManifestCanonicalJournal{}, + ); err == nil { + t.Fatal("expected unknown canonical-journal field to be rejected") + } +} diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 39ba7b1cdf..fc2377fa14 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -27,10 +27,8 @@ import ( ) const ( - frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v1" - frostActivationReadinessSnapshotSchema = "tbtc-p2tr-frost-signer-readiness-snapshot/v1" - frostActivationInventorySchema = "tbtc-p2tr-frost-wallet-group-inventory/v1" - frostActivationMaximumJSONSize = 64 * 1024 + frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v1" + frostActivationInventorySchema = "tbtc-p2tr-frost-wallet-group-inventory/v1" ) type frostActivationEthereumPoint struct { @@ -49,6 +47,20 @@ type frostActivationHandshakeRequest struct { Challenge frostActivationChallenge `json:"challenge"` } +type frostActivationCanonicalJournalState struct { + StoreID string `json:"storeID"` + StoreFingerprint string `json:"storeFingerprint"` + ClusterFingerprint string `json:"clusterFingerprint"` + Checkpoint frostActivationEthereumPoint `json:"checkpoint"` + Current frostActivationEthereumPoint `json:"current"` + DescriptorSetHash string `json:"descriptorSetHash"` + SourceTrustDomainID string `json:"sourceTrustDomainID"` + SourceEndpointFingerprint string `json:"sourceEndpointFingerprint"` + SourceOperatorFingerprint string `json:"sourceOperatorFingerprint"` + Generation uint64 `json:"generation"` + Complete bool `json:"complete"` +} + type frostActivationWalletGroupInventory struct { Schema string `json:"schema"` Point frostActivationEthereumPoint `json:"point"` @@ -62,31 +74,37 @@ type frostActivationWalletGroupInventory struct { Complete bool `json:"complete"` } -type frostActivationReadinessSnapshot struct { - Schema string `json:"schema"` - ManifestHash string `json:"manifestHash"` - DurableSessionStoreFingerprint string `json:"durableSessionStoreFingerprint"` - FrostWalletGroupInventory frostActivationWalletGroupInventory `json:"frostWalletGroupInventory"` +type frostActivationQuarantineJournalState struct { + ProtocolID string `json:"protocolID"` + StoreID string `json:"storeID"` + StoreFingerprint string `json:"storeFingerprint"` + ClusterFingerprint string `json:"clusterFingerprint"` + Root string `json:"root"` + Generation uint64 `json:"generation"` + CurrentQuarantineCount uint64 `json:"currentQuarantineCount"` + Complete bool `json:"complete"` } type frostActivationHandshakeState struct { - ProtocolID string `json:"protocolID"` - ReservationProtocolID string `json:"reservationProtocolID"` - BitcoinOutboxProtocolID string `json:"bitcoinOutboxProtocolID"` - SigningPolicyHash string `json:"signingPolicyHash"` - DurableSessionStoreFingerprint string `json:"durableSessionStoreFingerprint"` - CompleteRouterAddress string `json:"completeRouterAddress"` - AuthorizationRegistryAddress string `json:"authorizationRegistryAddress"` - Threshold uint64 `json:"threshold"` - MaximumGroupSize uint64 `json:"maximumGroupSize"` - RetainedGroupInventoryProtocolID string `json:"retainedGroupInventoryProtocolID"` - FrostWalletGroupInventory frostActivationWalletGroupInventory `json:"frostWalletGroupInventory"` - FinalizedReservationReadbackEnforced bool `json:"finalizedReservationReadbackEnforced"` - ExactTransactionAuthorizationRootEnforced bool `json:"exactTransactionAuthorizationRootEnforced"` - NonceShareGateEnforced bool `json:"nonceShareGateEnforced"` - DurableBitcoinOutboxRecovered bool `json:"durableBitcoinOutboxRecovered"` - QuarantineFailClosed bool `json:"quarantineFailClosed"` - Healthy bool `json:"healthy"` + ProtocolID string `json:"protocolID"` + ReservationProtocolID string `json:"reservationProtocolID"` + BitcoinOutboxProtocolID string `json:"bitcoinOutboxProtocolID"` + SigningPolicyHash string `json:"signingPolicyHash"` + DurableSessionStoreFingerprint string `json:"durableSessionStoreFingerprint"` + CompleteRouterAddress string `json:"completeRouterAddress"` + AuthorizationRegistryAddress string `json:"authorizationRegistryAddress"` + Threshold uint64 `json:"threshold"` + MaximumGroupSize uint64 `json:"maximumGroupSize"` + RetainedGroupInventoryProtocolID string `json:"retainedGroupInventoryProtocolID"` + FrostWalletGroupInventory frostActivationWalletGroupInventory `json:"frostWalletGroupInventory"` + CanonicalJournal frostActivationCanonicalJournalState `json:"canonicalJournal"` + QuarantineJournal frostActivationQuarantineJournalState `json:"quarantineJournal"` + FinalizedReservationReadbackEnforced bool `json:"finalizedReservationReadbackEnforced"` + ExactTransactionAuthorizationRootEnforced bool `json:"exactTransactionAuthorizationRootEnforced"` + NonceShareGateEnforced bool `json:"nonceShareGateEnforced"` + DurableBitcoinOutboxRecovered bool `json:"durableBitcoinOutboxRecovered"` + QuarantineFailClosed bool `json:"quarantineFailClosed"` + Healthy bool `json:"healthy"` } type frostActivationHandshakePayload struct { @@ -105,12 +123,12 @@ type frostActivationSignedHandshake struct { type frostActivationHandshakeExporter struct { endpoint *url.URL - readinessPath string privateKey ed25519.PrivateKey publicKeySPKI string manifest FrostPreSignActivationRuntimeManifest pointVerifier FrostPreSignActivationPointVerifier outbox *bitcoinBroadcastOutbox + journal *frostRetainedGroupJournal mutex sync.Mutex listener net.Listener @@ -121,22 +139,29 @@ type frostActivationHandshakeExporter struct { func newFrostActivationHandshakeExporter( endpoint string, privateKeyPath string, - readinessPath string, manifest FrostPreSignActivationRuntimeManifest, pointVerifier FrostPreSignActivationPointVerifier, outbox *bitcoinBroadcastOutbox, + journal *frostRetainedGroupJournal, ) (*frostActivationHandshakeExporter, error) { parsedEndpoint, err := validateFrostActivationHandshakeEndpoint(endpoint) if err != nil { return nil, err } - if strings.TrimSpace(readinessPath) == "" { - return nil, fmt.Errorf("FROST activation readiness snapshot path is empty") - } + durableSessionStoreFingerprint, durableSessionStoreFingerprintErr := + parseFrostActivationHex32(manifest.DurableSessionStoreFingerprint) if manifest.ManifestHash == [32]byte{} || manifest.AttestationSignerKeyHash == [32]byte{} || manifest.Threshold != 51 || manifest.MaximumGroupSize != 100 || - pointVerifier == nil || outbox == nil { + manifest.CanonicalJournal.StoreFingerprint == [32]byte{} || + manifest.QuarantineJournal.ProtocolID == [32]byte{} || + manifest.CanonicalJournal.StoreID == manifest.QuarantineJournal.StoreID || + manifest.CanonicalJournal.StoreFingerprint == manifest.QuarantineJournal.StoreFingerprint || + manifest.CanonicalJournal.ClusterFingerprint == manifest.QuarantineJournal.ClusterFingerprint || + durableSessionStoreFingerprintErr != nil || durableSessionStoreFingerprint == [32]byte{} || + durableSessionStoreFingerprint == manifest.CanonicalJournal.StoreFingerprint || + durableSessionStoreFingerprint == manifest.QuarantineJournal.StoreFingerprint || + pointVerifier == nil || outbox == nil || journal == nil { return nil, fmt.Errorf("FROST activation handshake dependencies are invalid") } privateKey, publicKeyDER, err := loadFrostActivationAttestationKey(privateKeyPath) @@ -148,15 +173,12 @@ func newFrostActivationHandshakeExporter( } exporter := &frostActivationHandshakeExporter{ endpoint: parsedEndpoint, - readinessPath: readinessPath, privateKey: privateKey, publicKeySPKI: base64.StdEncoding.EncodeToString(publicKeyDER), manifest: manifest, pointVerifier: pointVerifier, outbox: outbox, - } - if _, err := exporter.readReadinessSnapshot(); err != nil { - return nil, err + journal: journal, } return exporter, nil } @@ -223,59 +245,6 @@ func readSecureFrostActivationFile(filePath string, limit int64) ([]byte, error) return data, nil } -func (fahe *frostActivationHandshakeExporter) readReadinessSnapshot() ( - *frostActivationReadinessSnapshot, - error, -) { - data, err := readSecureFrostActivationFile( - fahe.readinessPath, - frostActivationMaximumJSONSize, - ) - if err != nil { - return nil, fmt.Errorf("cannot read FROST readiness snapshot: [%w]", err) - } - snapshot := &frostActivationReadinessSnapshot{} - if err := decodeStrictFrostActivationJSON(data, snapshot); err != nil { - return nil, fmt.Errorf("cannot decode FROST readiness snapshot: [%w]", err) - } - if snapshot.Schema != frostActivationReadinessSnapshotSchema || - snapshot.ManifestHash != frostActivationHex32(fahe.manifest.ManifestHash) || - snapshot.DurableSessionStoreFingerprint != fahe.manifest.DurableSessionStoreFingerprint { - return nil, fmt.Errorf("FROST readiness snapshot differs from signed activation manifest") - } - if err := validateFrostActivationInventory(snapshot.FrostWalletGroupInventory); err != nil { - return nil, err - } - return snapshot, nil -} - -func validateFrostActivationInventory( - inventory frostActivationWalletGroupInventory, -) error { - if inventory.Schema != frostActivationInventorySchema || !inventory.Complete || - inventory.MembershipAmbiguityCount != 0 || inventory.GroupSizeViolationCount != 0 || - inventory.Point.BlockNumber == 0 { - return fmt.Errorf("FROST retained-group inventory is incomplete or ambiguous") - } - if _, err := parseFrostActivationHex32(inventory.Point.BlockHash); err != nil { - return fmt.Errorf("invalid FROST inventory block hash: [%w]", err) - } - root, err := parseFrostActivationHex32(inventory.InventoryRoot) - if err != nil || root == [32]byte{} { - return fmt.Errorf("invalid FROST inventory root") - } - if inventory.WalletCount == 0 { - if inventory.MinimumActualGroupSize != 0 || inventory.MaximumActualGroupSize != 0 { - return fmt.Errorf("empty FROST inventory has nonzero group bounds") - } - } else if inventory.MinimumActualGroupSize < 51 || - inventory.MinimumActualGroupSize > inventory.MaximumActualGroupSize || - inventory.MaximumActualGroupSize > 100 { - return fmt.Errorf("FROST inventory group bounds are invalid") - } - return nil -} - func (fahe *frostActivationHandshakeExporter) start(ctx context.Context) error { if ctx == nil { return fmt.Errorf("FROST activation handshake context is nil") @@ -395,13 +364,6 @@ func (fahe *frostActivationHandshakeExporter) attest( if err != nil || request.Challenge.EthereumPoint.BlockNumber == 0 { return nil, fmt.Errorf("FROST activation challenge Ethereum point is invalid") } - snapshot, err := fahe.readReadinessSnapshot() - if err != nil { - return nil, err - } - if snapshot.FrostWalletGroupInventory.Point != request.Challenge.EthereumPoint { - return nil, fmt.Errorf("FROST readiness inventory is not at the challenged point") - } finality := FrostPreSignFinality{ BlockNumber: request.Challenge.EthereumPoint.BlockNumber, BlockHash: blockHash, @@ -409,6 +371,25 @@ func (fahe *frostActivationHandshakeExporter) attest( if err := fahe.pointVerifier.VerifyFrostPreSignActivationPoint(ctx, finality); err != nil { return nil, fmt.Errorf("cannot verify FROST activation point: [%w]", err) } + journalSnapshot, err := fahe.journal.reconcile(ctx, finality) + if err != nil { + return nil, fmt.Errorf("cannot reconcile canonical FROST retained groups: [%w]", err) + } + journalManifest := fahe.manifest.CanonicalJournal + quarantineManifest := fahe.manifest.QuarantineJournal + if !journalSnapshot.Complete || journalSnapshot.CurrentPoint != finality || + journalSnapshot.StoreID != journalManifest.StoreID || + journalSnapshot.StoreFingerprint != journalManifest.StoreFingerprint || + journalSnapshot.ClusterFingerprint != journalManifest.ClusterFingerprint || + journalSnapshot.SnapshotGeneration < journalManifest.MinimumGeneration || + journalSnapshot.QuarantineProtocolID != quarantineManifest.ProtocolID || + journalSnapshot.QuarantineStoreID != quarantineManifest.StoreID || + journalSnapshot.QuarantineStoreFingerprint != quarantineManifest.StoreFingerprint || + journalSnapshot.QuarantineClusterFingerprint != quarantineManifest.ClusterFingerprint || + journalSnapshot.QuarantineGeneration < quarantineManifest.MinimumGeneration || + journalSnapshot.QuarantineRoot == [32]byte{} || journalSnapshot.QuarantineCount != 0 { + return nil, fmt.Errorf("canonical FROST retained-group journal is not activation-ready") + } outboxSnapshot, err := fahe.outbox.activationSnapshot() if err != nil { return nil, err @@ -417,18 +398,58 @@ func (fahe *frostActivationHandshakeExporter) attest( outboxSnapshot.QuarantineCount != 0 { return nil, fmt.Errorf("durable Bitcoin outbox is not activation-ready") } + if err := fahe.pointVerifier.VerifyFrostPreSignActivationPoint(ctx, finality); err != nil { + return nil, fmt.Errorf("FROST activation point changed during readiness reconciliation: [%w]", err) + } state := frostActivationHandshakeState{ - ProtocolID: frostActivationHex32(fahe.manifest.SignerProtocolID), - ReservationProtocolID: frostActivationHex32(fahe.manifest.ReservationProtocolID), - BitcoinOutboxProtocolID: frostActivationHex32(fahe.manifest.BitcoinOutboxProtocolID), - SigningPolicyHash: frostActivationHex32(fahe.manifest.SigningPolicyHash), - DurableSessionStoreFingerprint: snapshot.DurableSessionStoreFingerprint, - CompleteRouterAddress: frostActivationHex20(fahe.manifest.CompleteRouterAddress), - AuthorizationRegistryAddress: frostActivationHex20(fahe.manifest.AuthorizationRegistryAddress), - Threshold: fahe.manifest.Threshold, - MaximumGroupSize: fahe.manifest.MaximumGroupSize, - RetainedGroupInventoryProtocolID: frostActivationHex32(fahe.manifest.RetainedGroupInventoryProtocolID), - FrostWalletGroupInventory: snapshot.FrostWalletGroupInventory, + ProtocolID: frostActivationHex32(fahe.manifest.SignerProtocolID), + ReservationProtocolID: frostActivationHex32(fahe.manifest.ReservationProtocolID), + BitcoinOutboxProtocolID: frostActivationHex32(fahe.manifest.BitcoinOutboxProtocolID), + SigningPolicyHash: frostActivationHex32(fahe.manifest.SigningPolicyHash), + DurableSessionStoreFingerprint: fahe.manifest.DurableSessionStoreFingerprint, + CompleteRouterAddress: frostActivationHex20(fahe.manifest.CompleteRouterAddress), + AuthorizationRegistryAddress: frostActivationHex20(fahe.manifest.AuthorizationRegistryAddress), + Threshold: fahe.manifest.Threshold, + MaximumGroupSize: fahe.manifest.MaximumGroupSize, + RetainedGroupInventoryProtocolID: frostActivationHex32(fahe.manifest.RetainedGroupInventoryProtocolID), + FrostWalletGroupInventory: frostActivationWalletGroupInventory{ + Schema: frostActivationInventorySchema, + Point: request.Challenge.EthereumPoint, + SnapshotGeneration: journalSnapshot.SnapshotGeneration, + InventoryRoot: frostActivationHex32(journalSnapshot.InventoryRoot), + WalletCount: journalSnapshot.WalletCount, + MinimumActualGroupSize: journalSnapshot.MinimumActualGroupSize, + MaximumActualGroupSize: journalSnapshot.MaximumActualGroupSize, + MembershipAmbiguityCount: 0, + GroupSizeViolationCount: 0, + Complete: true, + }, + CanonicalJournal: frostActivationCanonicalJournalState{ + StoreID: journalSnapshot.StoreID, + StoreFingerprint: frostActivationHex32(journalSnapshot.StoreFingerprint), + ClusterFingerprint: frostActivationHex32(journalSnapshot.ClusterFingerprint), + Checkpoint: frostActivationEthereumPoint{ + BlockNumber: journalManifest.Checkpoint.BlockNumber, + BlockHash: frostActivationHex32(journalManifest.Checkpoint.BlockHash), + }, + Current: request.Challenge.EthereumPoint, + DescriptorSetHash: frostActivationHex32(journalManifest.DescriptorSetHash), + SourceTrustDomainID: journalManifest.SourceTrustDomainID, + SourceEndpointFingerprint: frostActivationHex32(journalManifest.SourceEndpointFingerprint), + SourceOperatorFingerprint: frostActivationHex32(journalManifest.SourceOperatorFingerprint), + Generation: journalSnapshot.SnapshotGeneration, + Complete: true, + }, + QuarantineJournal: frostActivationQuarantineJournalState{ + ProtocolID: frostActivationHex32(journalSnapshot.QuarantineProtocolID), + StoreID: journalSnapshot.QuarantineStoreID, + StoreFingerprint: frostActivationHex32(journalSnapshot.QuarantineStoreFingerprint), + ClusterFingerprint: frostActivationHex32(journalSnapshot.QuarantineClusterFingerprint), + Root: frostActivationHex32(journalSnapshot.QuarantineRoot), + Generation: journalSnapshot.QuarantineGeneration, + CurrentQuarantineCount: journalSnapshot.QuarantineCount, + Complete: true, + }, FinalizedReservationReadbackEnforced: true, ExactTransactionAuthorizationRootEnforced: true, NonceShareGateEnforced: true, diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index e963fdb4c1..9780ca6682 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -16,15 +16,19 @@ import ( "net/http" "os" "path/filepath" + "reflect" + "sort" "testing" "time" "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/chain" ) type testFrostActivationPointVerifier struct { err error point FrostPreSignFinality + calls uint64 } func (tfapv *testFrostActivationPointVerifier) VerifyFrostPreSignActivationPoint( @@ -32,9 +36,60 @@ func (tfapv *testFrostActivationPointVerifier) VerifyFrostPreSignActivationPoint point FrostPreSignFinality, ) error { tfapv.point = point + tfapv.calls++ return tfapv.err } +type testFrostRetainedGroupHistorySource struct { + manifest FrostRetainedGroupCanonicalJournalManifest + target FrostPreSignFinality +} + +func (source *testFrostRetainedGroupHistorySource) Identity( + context.Context, +) (FrostRetainedGroupHistoryIdentity, error) { + return FrostRetainedGroupHistoryIdentity{ + TrustDomainID: source.manifest.SourceTrustDomainID, + EndpointFingerprint: source.manifest.SourceEndpointFingerprint, + OperatorFingerprint: source.manifest.SourceOperatorFingerprint, + }, nil +} + +func (source *testFrostRetainedGroupHistorySource) FinalizedHead( + context.Context, +) (FrostPreSignFinality, error) { + return source.target, nil +} + +func (source *testFrostRetainedGroupHistorySource) VerifyPoint( + context.Context, + FrostPreSignFinality, +) error { + return nil +} + +func (source *testFrostRetainedGroupHistorySource) ReadCompleteHistory( + _ context.Context, + from FrostPreSignFinality, + to FrostPreSignFinality, +) (*FrostRetainedGroupHistory, error) { + return &FrostRetainedGroupHistory{ + From: from, + To: to, + Complete: true, + EmptyAtFrom: true, + DescriptorSetHash: source.manifest.DescriptorSetHash, + }, nil +} + +func (source *testFrostRetainedGroupHistorySource) ResolveOperatorID( + context.Context, + chain.Address, + FrostPreSignFinality, +) (chain.OperatorID, error) { + return 1, nil +} + func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { directory := t.TempDir() publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) @@ -61,8 +116,7 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { BlockNumber: 123, BlockHash: frostActivationHex32([32]byte{0x44}), } - readinessPath := filepath.Join(directory, "readiness.json") - writeTestFrostActivationReadiness(t, readinessPath, manifest, point) + journal := testFrostRetainedGroupJournal(t, manifest, point) endpoint := testLoopbackEndpoint(t) verifier := &testFrostActivationPointVerifier{} outbox := &bitcoinBroadcastOutbox{ @@ -72,10 +126,10 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { exporter, err := newFrostActivationHandshakeExporter( endpoint, privateKeyPath, - readinessPath, manifest, verifier, outbox, + journal, ) if err != nil { t.Fatal(err) @@ -112,7 +166,8 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { !handshake.Payload.State.Healthy || !handshake.Payload.State.DurableBitcoinOutboxRecovered || verifier.point.BlockNumber != point.BlockNumber || - frostActivationHex32(verifier.point.BlockHash) != point.BlockHash { + frostActivationHex32(verifier.point.BlockHash) != point.BlockHash || + verifier.calls != 2 { t.Fatalf("unexpected handshake: %+v", handshake) } canonicalPayload, err := canonicalFrostActivationValue(handshake.Payload) @@ -123,6 +178,53 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { if err != nil || !ed25519.Verify(publicKey, canonicalPayload, signature) { t.Fatal("handshake signature did not verify over canonical payload") } + assertFrostActivationObjectKeys(t, handshake.Payload.State, []string{ + "authorizationRegistryAddress", "bitcoinOutboxProtocolID", "canonicalJournal", + "completeRouterAddress", "durableBitcoinOutboxRecovered", "durableSessionStoreFingerprint", + "exactTransactionAuthorizationRootEnforced", "finalizedReservationReadbackEnforced", + "frostWalletGroupInventory", "healthy", "maximumGroupSize", "nonceShareGateEnforced", + "protocolID", "quarantineFailClosed", "quarantineJournal", "reservationProtocolID", + "retainedGroupInventoryProtocolID", "signingPolicyHash", "threshold", + }) + assertFrostActivationObjectKeys(t, handshake.Payload.State.FrostWalletGroupInventory, []string{ + "complete", "groupSizeViolationCount", "inventoryRoot", "maximumActualGroupSize", + "membershipAmbiguityCount", "minimumActualGroupSize", "point", "schema", + "snapshotGeneration", "walletCount", + }) + assertFrostActivationObjectKeys(t, handshake.Payload.State.CanonicalJournal, []string{ + "checkpoint", "clusterFingerprint", "complete", "current", "descriptorSetHash", + "generation", "sourceEndpointFingerprint", "sourceOperatorFingerprint", + "sourceTrustDomainID", "storeFingerprint", "storeID", + }) + assertFrostActivationObjectKeys(t, handshake.Payload.State.QuarantineJournal, []string{ + "clusterFingerprint", "complete", "currentQuarantineCount", "generation", + "protocolID", "root", "storeFingerprint", "storeID", + }) +} + +func assertFrostActivationObjectKeys( + t *testing.T, + value interface{}, + expected []string, +) { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + object := make(map[string]json.RawMessage) + if err := json.Unmarshal(encoded, &object); err != nil { + t.Fatal(err) + } + actual := make([]string, 0, len(object)) + for key := range object { + actual = append(actual, key) + } + sort.Strings(actual) + sort.Strings(expected) + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("unexpected handshake object keys\nexpected: %v\nactual: %v", expected, actual) + } } func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { @@ -145,8 +247,7 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { BlockNumber: 7, BlockHash: frostActivationHex32([32]byte{0x11}), } - readinessPath := filepath.Join(directory, "readiness.json") - writeTestFrostActivationReadiness(t, readinessPath, manifest, point) + journal := testFrostRetainedGroupJournal(t, manifest, point) endpoint := testLoopbackEndpoint(t) outbox := &bitcoinBroadcastOutbox{ records: map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord{ @@ -157,7 +258,7 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { }, Quarantine: &bitcoinBroadcastQuarantine{ ActiveActivationProfileHash: [32]byte{0x03}, - ObservedAtUnix: 1, + ObservedAtUnix: 1, }, }, }, @@ -166,10 +267,10 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { exporter, err := newFrostActivationHandshakeExporter( endpoint, privateKeyPath, - readinessPath, manifest, &testFrostActivationPointVerifier{}, outbox, + journal, ) if err != nil { t.Fatal(err) @@ -221,47 +322,96 @@ func testFrostActivationRuntimeManifest( ReservationProtocolID: [32]byte{0x12}, BitcoinOutboxProtocolID: [32]byte{0x13}, SigningPolicyHash: [32]byte{0x14}, - DurableSessionStoreFingerprint: "store-fingerprint", + DurableSessionStoreFingerprint: frostActivationHex32([32]byte{0x09}), CompleteRouterAddress: [20]byte{0x15}, AuthorizationRegistryAddress: [20]byte{0x16}, AttestationSignerKeyHash: keyHash, Threshold: 51, MaximumGroupSize: 100, RetainedGroupInventoryProtocolID: [32]byte{0x17}, + CanonicalJournal: FrostRetainedGroupCanonicalJournalManifest{ + StoreID: "journal-store-id", + StoreFingerprint: [32]byte{0x18}, + ClusterFingerprint: [32]byte{0x19}, + Checkpoint: FrostPreSignFinality{ + BlockNumber: 1, + BlockHash: [32]byte{0x20}, + }, + DescriptorSetHash: [32]byte{0x21}, + SourceTrustDomainID: "independent-journal-source", + SourceEndpointFingerprint: [32]byte{0x22}, + SourceOperatorFingerprint: [32]byte{0x23}, + MinimumGeneration: 1, + }, + QuarantineJournal: FrostRetainedGroupQuarantineJournalManifest{ + ProtocolID: [32]byte{0x25}, + StoreID: "quarantine-store-id", + StoreFingerprint: [32]byte{0x26}, + ClusterFingerprint: [32]byte{0x27}, + MinimumGeneration: 0, + }, } } -func writeTestFrostActivationReadiness( +func testFrostRetainedGroupJournal( t *testing.T, - filePath string, manifest FrostPreSignActivationRuntimeManifest, point frostActivationEthereumPoint, -) { +) *frostRetainedGroupJournal { t.Helper() - snapshot := frostActivationReadinessSnapshot{ - Schema: frostActivationReadinessSnapshotSchema, - ManifestHash: frostActivationHex32(manifest.ManifestHash), - DurableSessionStoreFingerprint: manifest.DurableSessionStoreFingerprint, - FrostWalletGroupInventory: frostActivationWalletGroupInventory{ - Schema: frostActivationInventorySchema, - Point: point, - SnapshotGeneration: 9, - InventoryRoot: frostActivationHex32([32]byte{0x18}), - WalletCount: 1, - MinimumActualGroupSize: 51, - MaximumActualGroupSize: 100, - MembershipAmbiguityCount: 0, - GroupSizeViolationCount: 0, - Complete: true, - }, - } - data, err := json.Marshal(snapshot) + blockHash, err := parseFrostActivationHex32(point.BlockHash) if err != nil { t.Fatal(err) } - if err := os.WriteFile(filePath, data, 0600); err != nil { + target := FrostPreSignFinality{BlockNumber: point.BlockNumber, BlockHash: blockHash} + state := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + CurrentPoint: target, + SnapshotGeneration: 9, + Wallets: []frostRetainedGroupWalletState{}, + } + quarantineRoot := sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)) + state.InventoryRoot, _, _, _, err = frostRetainedGroupInventoryRoot(state) + if err != nil { t.Fatal(err) } + source := &testFrostRetainedGroupHistorySource{ + manifest: manifest.CanonicalJournal, + target: target, + } + return &frostRetainedGroupJournal{ + metadata: frostRetainedGroupJournalMetadata{ + StoreID: manifest.CanonicalJournal.StoreID, + StoreFingerprint: manifest.CanonicalJournal.StoreFingerprint, + ClusterFingerprint: manifest.CanonicalJournal.ClusterFingerprint, + Checkpoint: manifest.CanonicalJournal.Checkpoint, + DescriptorSetHash: manifest.CanonicalJournal.DescriptorSetHash, + SourceTrustDomainID: manifest.CanonicalJournal.SourceTrustDomainID, + SourceEndpointFingerprint: manifest.CanonicalJournal.SourceEndpointFingerprint, + SourceOperatorFingerprint: manifest.CanonicalJournal.SourceOperatorFingerprint, + }, + quarantineMetadata: frostRetainedGroupQuarantineMetadata{ + Schema: frostRetainedGroupQuarantineMetadataSchema, + ManifestHash: manifest.ManifestHash, + ProtocolID: manifest.QuarantineJournal.ProtocolID, + StoreID: manifest.QuarantineJournal.StoreID, + StoreFingerprint: manifest.QuarantineJournal.StoreFingerprint, + ClusterFingerprint: manifest.QuarantineJournal.ClusterFingerprint, + Checkpoint: manifest.CanonicalJournal.Checkpoint, + }, + minimumGeneration: manifest.CanonicalJournal.MinimumGeneration, + quarantineMinimumGeneration: manifest.QuarantineJournal.MinimumGeneration, + source: source, + walletRegistry: &walletRegistry{walletCache: make(map[string]*walletCacheValue)}, + operatorAddress: chain.Address("0x01"), + state: state, + quarantineState: frostRetainedGroupQuarantineJournalState{ + Schema: frostRetainedGroupQuarantineStateSchema, + CurrentPoint: target, + Root: quarantineRoot, + Quarantines: []frostRetainedGroupQuarantineState{}, + }, + } } func testLoopbackEndpoint(t *testing.T) string { diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index f055780efe..51f39b3838 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1313,6 +1313,8 @@ type FrostPreSignActivationRuntimeManifest struct { Threshold uint64 MaximumGroupSize uint64 RetainedGroupInventoryProtocolID [32]byte + CanonicalJournal FrostRetainedGroupCanonicalJournalManifest + QuarantineJournal FrostRetainedGroupQuarantineJournalManifest } type FrostPreSignActivationRuntimeManifestSource interface { diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go new file mode 100644 index 0000000000..9e8dd58cce --- /dev/null +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -0,0 +1,1926 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + + "github.com/keep-network/keep-core/pkg/chain" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" + "golang.org/x/sys/unix" +) + +const ( + frostRetainedGroupJournalMetadataSchema = "tbtc-frost-retained-group-journal-metadata/v1" + frostRetainedGroupJournalBatchSchema = "tbtc-frost-retained-group-journal-batch/v1" + frostRetainedGroupJournalStateSchema = "tbtc-frost-retained-group-journal-state/v1" + frostRetainedGroupJournalSnapshotSchema = "tbtc-frost-retained-group-journal-snapshot/v1" + frostRetainedGroupJournalLockFile = ".lock" + frostRetainedGroupJournalMetadataFile = "metadata.json" + frostRetainedGroupJournalStateFile = "state.json" + frostRetainedGroupJournalBatchPrefix = "batch-" + frostRetainedGroupJournalFileSuffix = ".json" + frostRetainedGroupJournalTempSuffix = ".tmp" + frostRetainedGroupJournalMaximumFile = 8 * 1024 * 1024 + frostRetainedGroupCanonicalDirectory = "canonical" + frostRetainedGroupQuarantineDirectory = "quarantine" + + frostRetainedGroupQuarantineMetadataSchema = "tbtc-frost-retained-group-quarantine-metadata/v1" + frostRetainedGroupQuarantineBatchSchema = "tbtc-frost-retained-group-quarantine-batch/v1" + frostRetainedGroupQuarantineStateSchema = "tbtc-frost-retained-group-quarantine-state/v1" + + frostRetainedGroupInventoryEntriesDomain = "tbtc-p2tr-frost-wallet-group-inventory-entries-v1\x00" + frostRetainedGroupInventoryLeafDomain = "tbtc-p2tr-frost-wallet-group-inventory-leaf-v1\x00" + frostRetainedGroupInventoryNodeDomain = "tbtc-p2tr-frost-wallet-group-inventory-node-v1\x00" + frostRetainedGroupInventoryRootDomain = "tbtc-p2tr-frost-wallet-group-inventory-root-v1\x00" + frostRetainedGroupBatchDomain = "tbtc-frost-retained-group-journal-batch-v1\x00" + frostRetainedGroupQuarantineBatchDomain = "tbtc-frost-retained-group-quarantine-batch-v1\x00" + frostRetainedGroupQuarantineDomain = "tbtc-frost-retained-group-quarantine-v1\x00" +) + +// FrostRetainedGroupEventPoint identifies one canonical Ethereum log. The +// transaction identity and ordering indexes prove Registry closure follows +// the matching Bridge terminal transition in the same transaction. +type FrostRetainedGroupEventPoint struct { + BlockNumber uint64 `json:"blockNumber"` + BlockHash [32]byte `json:"blockHash"` + TransactionHash [32]byte `json:"transactionHash"` + TransactionIndex uint32 `json:"transactionIndex"` + LogIndex uint32 `json:"logIndex"` +} + +func (frgep FrostRetainedGroupEventPoint) valid() bool { + return frgep.BlockNumber > 0 && frgep.BlockHash != [32]byte{} && + frgep.TransactionHash != [32]byte{} +} + +func compareFrostRetainedGroupEventPoints( + left FrostRetainedGroupEventPoint, + right FrostRetainedGroupEventPoint, +) int { + if left.BlockNumber < right.BlockNumber { + return -1 + } + if left.BlockNumber > right.BlockNumber { + return 1 + } + if left.TransactionIndex < right.TransactionIndex { + return -1 + } + if left.TransactionIndex > right.TransactionIndex { + return 1 + } + if left.LogIndex < right.LogIndex { + return -1 + } + if left.LogIndex > right.LogIndex { + return 1 + } + return bytes.Compare(left.TransactionHash[:], right.TransactionHash[:]) +} + +type FrostRetainedGroupMutationKind string + +const ( + FrostRetainedGroupAdmissionMutation FrostRetainedGroupMutationKind = "admission" + FrostRetainedGroupMovingFundsMutation FrostRetainedGroupMutationKind = "moving-funds" + FrostRetainedGroupClosingMutation FrostRetainedGroupMutationKind = "closing" + FrostRetainedGroupClosedMutation FrostRetainedGroupMutationKind = "closed" + FrostRetainedGroupTerminatedMutation FrostRetainedGroupMutationKind = "terminated" + FrostRetainedGroupRegistryClosureMutation FrostRetainedGroupMutationKind = "registry-closure" + FrostRetainedGroupQuarantineMutation FrostRetainedGroupMutationKind = "quarantine" + FrostRetainedGroupQuarantineLiftMutation FrostRetainedGroupMutationKind = "quarantine-lift" + FrostRetainedGroupRecoveryRequiredMutation FrostRetainedGroupMutationKind = "recovery-required" +) + +type FrostRetainedGroupLifecycle string + +const ( + FrostRetainedGroupLive FrostRetainedGroupLifecycle = "Live" + FrostRetainedGroupMovingFunds FrostRetainedGroupLifecycle = "MovingFunds" + FrostRetainedGroupClosing FrostRetainedGroupLifecycle = "Closing" + FrostRetainedGroupClosed FrostRetainedGroupLifecycle = "Closed" + FrostRetainedGroupTerminated FrostRetainedGroupLifecycle = "Terminated" +) + +func (frgl FrostRetainedGroupLifecycle) terminal() bool { + return frgl == FrostRetainedGroupClosed || frgl == FrostRetainedGroupTerminated +} + +// FrostRetainedGroupMutation is one complete source-authenticated semantic +// history item. Admission carries exact ordered DKG operator IDs. A lift is +// accepted only with a nonzero authentication commitment for its exact ID. +type FrostRetainedGroupMutation struct { + Point FrostRetainedGroupEventPoint `json:"point"` + Kind FrostRetainedGroupMutationKind `json:"kind"` + WalletID [32]byte `json:"walletID"` + WalletPublicKeyHash [20]byte `json:"walletPublicKeyHash"` + OperatorIDs []uint32 `json:"operatorIDs,omitempty"` + RetainedGroupHash [32]byte `json:"retainedGroupHash,omitempty"` + CreationPoint FrostRetainedGroupEventPoint `json:"creationPoint,omitempty"` + BridgeRegistrationPoint FrostRetainedGroupEventPoint `json:"bridgeRegistrationPoint,omitempty"` + QuarantineID [32]byte `json:"quarantineID,omitempty"` + EvidenceHash [32]byte `json:"evidenceHash,omitempty"` + AuthenticationHash [32]byte `json:"authenticationHash,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type FrostRetainedGroupHistoryIdentity struct { + TrustDomainID string + EndpointFingerprint [32]byte + OperatorFingerprint [32]byte +} + +type FrostRetainedGroupHistory struct { + From FrostPreSignFinality + To FrostPreSignFinality + Mutations []FrostRetainedGroupMutation + Complete bool + EmptyAtFrom bool + DescriptorSetHash [32]byte +} + +// FrostRetainedGroupHistorySource is independent of the primary deployment +// verifier. It authenticates canonicality, completeness, ordering, DKG +// admissions, and operator-ID resolution at the exact requested point. +type FrostRetainedGroupHistorySource interface { + Identity(context.Context) (FrostRetainedGroupHistoryIdentity, error) + FinalizedHead(context.Context) (FrostPreSignFinality, error) + VerifyPoint(context.Context, FrostPreSignFinality) error + ReadCompleteHistory( + context.Context, + FrostPreSignFinality, + FrostPreSignFinality, + ) (*FrostRetainedGroupHistory, error) + ResolveOperatorID( + context.Context, + chain.Address, + FrostPreSignFinality, + ) (chain.OperatorID, error) +} + +type FrostRetainedGroupCanonicalJournalManifest struct { + StoreID string + StoreFingerprint [32]byte + ClusterFingerprint [32]byte + // Checkpoint is the exclusive, canonical empty-inventory baseline. It must + // precede every FROST creation or lifecycle event returned by the source. + Checkpoint FrostPreSignFinality + DescriptorSetHash [32]byte + SourceTrustDomainID string + SourceEndpointFingerprint [32]byte + SourceOperatorFingerprint [32]byte + MinimumGeneration uint64 +} + +type FrostRetainedGroupQuarantineJournalManifest struct { + ProtocolID [32]byte + StoreID string + StoreFingerprint [32]byte + ClusterFingerprint [32]byte + MinimumGeneration uint64 +} + +type frostRetainedGroupJournalMetadata struct { + Schema string `json:"schema"` + ManifestHash [32]byte `json:"manifestHash"` + StoreID string `json:"storeID"` + StoreFingerprint [32]byte `json:"storeFingerprint"` + ClusterFingerprint [32]byte `json:"clusterFingerprint"` + Checkpoint FrostPreSignFinality `json:"checkpoint"` + DescriptorSetHash [32]byte `json:"descriptorSetHash"` + SourceTrustDomainID string `json:"sourceTrustDomainID"` + SourceEndpointFingerprint [32]byte `json:"sourceEndpointFingerprint"` + SourceOperatorFingerprint [32]byte `json:"sourceOperatorFingerprint"` +} + +type frostRetainedGroupWalletState struct { + WalletID [32]byte `json:"walletID"` + WalletPublicKeyHash [20]byte `json:"walletPublicKeyHash"` + OperatorIDs []uint32 `json:"operatorIDs"` + RetainedGroupHash [32]byte `json:"retainedGroupHash"` + Lifecycle FrostRetainedGroupLifecycle `json:"lifecycle"` + CreationPoint FrostRetainedGroupEventPoint `json:"creationPoint"` + BridgeRegistrationPoint FrostRetainedGroupEventPoint `json:"bridgeRegistrationPoint"` + LifecyclePoint FrostRetainedGroupEventPoint `json:"lifecyclePoint"` + LastBridgePoint FrostRetainedGroupEventPoint `json:"lastBridgePoint"` + RegistryClosurePoint FrostRetainedGroupEventPoint `json:"registryClosurePoint"` + RegistryClosed bool `json:"registryClosed"` +} + +type frostRetainedGroupQuarantineState struct { + QuarantineID [32]byte `json:"quarantineID"` + WalletID [32]byte `json:"walletID"` + EvidenceHash [32]byte `json:"evidenceHash"` + Reason string `json:"reason"` + RecoveryRequired bool `json:"recoveryRequired"` + RaisedAt FrostRetainedGroupEventPoint `json:"raisedAt"` +} + +type frostRetainedGroupJournalState struct { + Schema string `json:"schema"` + BatchSequence uint64 `json:"batchSequence"` + CurrentPoint FrostPreSignFinality `json:"currentPoint"` + SnapshotGeneration uint64 `json:"snapshotGeneration"` + BatchRoot [32]byte `json:"batchRoot"` + InventoryRoot [32]byte `json:"inventoryRoot"` + Wallets []frostRetainedGroupWalletState `json:"wallets"` +} + +type frostRetainedGroupJournalBatch struct { + Schema string `json:"schema"` + Sequence uint64 `json:"sequence"` + From FrostPreSignFinality `json:"from"` + To FrostPreSignFinality `json:"to"` + PriorBatchRoot [32]byte `json:"priorBatchRoot"` + Mutations []FrostRetainedGroupMutation `json:"mutations"` + Checksum [32]byte `json:"checksum"` +} + +type frostRetainedGroupQuarantineMetadata struct { + Schema string `json:"schema"` + ManifestHash [32]byte `json:"manifestHash"` + ProtocolID [32]byte `json:"protocolID"` + StoreID string `json:"storeID"` + StoreFingerprint [32]byte `json:"storeFingerprint"` + ClusterFingerprint [32]byte `json:"clusterFingerprint"` + Checkpoint FrostPreSignFinality `json:"checkpoint"` +} + +type frostRetainedGroupQuarantineJournalState struct { + Schema string `json:"schema"` + BatchSequence uint64 `json:"batchSequence"` + CurrentPoint FrostPreSignFinality `json:"currentPoint"` + Generation uint64 `json:"generation"` + BatchRoot [32]byte `json:"batchRoot"` + Root [32]byte `json:"root"` + Quarantines []frostRetainedGroupQuarantineState `json:"quarantines"` +} + +type frostRetainedGroupQuarantineJournalBatch struct { + Schema string `json:"schema"` + Sequence uint64 `json:"sequence"` + From FrostPreSignFinality `json:"from"` + To FrostPreSignFinality `json:"to"` + PriorBatchRoot [32]byte `json:"priorBatchRoot"` + Mutations []FrostRetainedGroupMutation `json:"mutations"` + Checksum [32]byte `json:"checksum"` +} + +type frostRetainedGroupEnvelope struct { + Payload json.RawMessage `json:"payload"` + Checksum [32]byte `json:"checksum"` +} + +type frostRetainedGroupJournalSnapshot struct { + Schema string + StoreID string + StoreFingerprint [32]byte + ClusterFingerprint [32]byte + CurrentPoint FrostPreSignFinality + SnapshotGeneration uint64 + BatchRoot [32]byte + InventoryRoot [32]byte + WalletCount uint64 + MinimumActualGroupSize uint64 + MaximumActualGroupSize uint64 + QuarantineProtocolID [32]byte + QuarantineStoreID string + QuarantineStoreFingerprint [32]byte + QuarantineClusterFingerprint [32]byte + QuarantineMinimumGeneration uint64 + QuarantineGeneration uint64 + QuarantineRoot [32]byte + QuarantineCount uint64 + LocalSessionCount uint64 + Complete bool +} + +type frostRetainedGroupJournal struct { + mutex sync.Mutex + rootDirectory string + directory string + quarantineDirectory string + metadata frostRetainedGroupJournalMetadata + quarantineMetadata frostRetainedGroupQuarantineMetadata + minimumGeneration uint64 + quarantineMinimumGeneration uint64 + source FrostRetainedGroupHistorySource + walletRegistry *walletRegistry + operatorAddress chain.Address + lockFile *os.File + quarantineLockFile *os.File + state frostRetainedGroupJournalState + quarantineState frostRetainedGroupQuarantineJournalState + mutations []FrostRetainedGroupMutation + quarantineMutations []FrostRetainedGroupMutation + persistFailureHook func(string) error + closed bool +} + +func newFrostRetainedGroupJournal( + directory string, + manifestHash [32]byte, + manifest FrostRetainedGroupCanonicalJournalManifest, + quarantineManifest FrostRetainedGroupQuarantineJournalManifest, + source FrostRetainedGroupHistorySource, + walletRegistry *walletRegistry, + operatorAddress chain.Address, +) (*frostRetainedGroupJournal, error) { + if strings.TrimSpace(directory) == "" || manifestHash == [32]byte{} || + strings.TrimSpace(manifest.StoreID) == "" || + manifest.StoreFingerprint == [32]byte{} || + manifest.ClusterFingerprint == [32]byte{} || + manifest.Checkpoint.BlockNumber == 0 || manifest.Checkpoint.BlockHash == [32]byte{} || + manifest.DescriptorSetHash == [32]byte{} || + strings.TrimSpace(manifest.SourceTrustDomainID) == "" || + manifest.SourceEndpointFingerprint == [32]byte{} || + manifest.SourceOperatorFingerprint == [32]byte{} || + quarantineManifest.ProtocolID == [32]byte{} || + strings.TrimSpace(quarantineManifest.StoreID) == "" || + quarantineManifest.StoreFingerprint == [32]byte{} || + quarantineManifest.ClusterFingerprint == [32]byte{} || + manifest.StoreID == quarantineManifest.StoreID || + manifest.StoreFingerprint == quarantineManifest.StoreFingerprint || + manifest.ClusterFingerprint == quarantineManifest.ClusterFingerprint || + source == nil || walletRegistry == nil || + strings.TrimSpace(string(operatorAddress)) == "" { + return nil, fmt.Errorf("FROST retained-group journal dependencies are incomplete") + } + cleanRootDirectory, err := filepath.Abs(filepath.Clean(directory)) + if err != nil { + return nil, fmt.Errorf("cannot resolve FROST retained-group journal directory: [%w]", err) + } + if err := os.MkdirAll(cleanRootDirectory, 0700); err != nil { + return nil, fmt.Errorf("cannot create FROST retained-group journal: [%w]", err) + } + if err := validateSecureBitcoinBroadcastDirectory(cleanRootDirectory); err != nil { + return nil, fmt.Errorf("invalid FROST retained-group journal directory: [%w]", err) + } + if err := validateFrostRetainedGroupJournalRoot(cleanRootDirectory); err != nil { + return nil, err + } + canonicalDirectory := filepath.Join(cleanRootDirectory, frostRetainedGroupCanonicalDirectory) + quarantineDirectory := filepath.Join(cleanRootDirectory, frostRetainedGroupQuarantineDirectory) + for _, child := range []string{canonicalDirectory, quarantineDirectory} { + if err := os.MkdirAll(child, 0700); err != nil { + return nil, fmt.Errorf("cannot create FROST retained-group journal store: [%w]", err) + } + if err := validateSecureBitcoinBroadcastDirectory(child); err != nil { + return nil, fmt.Errorf("invalid FROST retained-group journal store: [%w]", err) + } + } + if err := syncDirectory(cleanRootDirectory); err != nil { + return nil, fmt.Errorf("cannot sync FROST retained-group journal directory: [%w]", err) + } + lockFile, err := acquireFrostRetainedGroupJournalLock(canonicalDirectory) + if err != nil { + return nil, err + } + quarantineLockFile, err := acquireFrostRetainedGroupJournalLock(quarantineDirectory) + if err != nil { + _ = unix.Flock(int(lockFile.Fd()), unix.LOCK_UN) + _ = lockFile.Close() + return nil, err + } + journal := &frostRetainedGroupJournal{ + rootDirectory: cleanRootDirectory, + directory: canonicalDirectory, + quarantineDirectory: quarantineDirectory, + metadata: frostRetainedGroupJournalMetadata{ + Schema: frostRetainedGroupJournalMetadataSchema, + ManifestHash: manifestHash, + StoreID: manifest.StoreID, + StoreFingerprint: manifest.StoreFingerprint, + ClusterFingerprint: manifest.ClusterFingerprint, + Checkpoint: manifest.Checkpoint, + DescriptorSetHash: manifest.DescriptorSetHash, + SourceTrustDomainID: manifest.SourceTrustDomainID, + SourceEndpointFingerprint: manifest.SourceEndpointFingerprint, + SourceOperatorFingerprint: manifest.SourceOperatorFingerprint, + }, + quarantineMetadata: frostRetainedGroupQuarantineMetadata{ + Schema: frostRetainedGroupQuarantineMetadataSchema, + ManifestHash: manifestHash, + ProtocolID: quarantineManifest.ProtocolID, + StoreID: quarantineManifest.StoreID, + StoreFingerprint: quarantineManifest.StoreFingerprint, + ClusterFingerprint: quarantineManifest.ClusterFingerprint, + Checkpoint: manifest.Checkpoint, + }, + minimumGeneration: manifest.MinimumGeneration, + quarantineMinimumGeneration: quarantineManifest.MinimumGeneration, + source: source, + walletRegistry: walletRegistry, + operatorAddress: operatorAddress, + lockFile: lockFile, + quarantineLockFile: quarantineLockFile, + } + if err := journal.initialize(); err != nil { + _ = journal.close() + return nil, err + } + return journal, nil +} + +func validateFrostRetainedGroupJournalRoot(directory string) error { + entries, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("cannot read FROST retained-group journal root: [%w]", err) + } + for _, entry := range entries { + if entry.Type()&os.ModeSymlink != 0 || + (entry.Name() != frostRetainedGroupCanonicalDirectory && + entry.Name() != frostRetainedGroupQuarantineDirectory) || + !entry.IsDir() { + return fmt.Errorf("unsafe entry in FROST retained-group journal root: [%s]", entry.Name()) + } + } + return nil +} + +func acquireFrostRetainedGroupJournalLock(directory string) (*os.File, error) { + lockPath := filepath.Join(directory, frostRetainedGroupJournalLockFile) + file, err := openSecureBitcoinBroadcastFile(lockPath, unix.O_CREAT|unix.O_RDWR, 0600) + if err != nil { + return nil, fmt.Errorf("cannot open FROST retained-group journal lock: [%w]", err) + } + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + _ = file.Close() + return nil, fmt.Errorf("FROST retained-group journal is already owned by another process") + } + if err := file.Truncate(0); err != nil { + _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) + _ = file.Close() + return nil, err + } + if _, err := file.WriteString(strconv.Itoa(os.Getpid()) + "\n"); err != nil { + _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) + _ = file.Close() + return nil, err + } + if err := file.Sync(); err != nil { + _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) + _ = file.Close() + return nil, err + } + if err := syncDirectory(directory); err != nil { + _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) + _ = file.Close() + return nil, err + } + return file, nil +} + +func (frgj *frostRetainedGroupJournal) close() error { + frgj.mutex.Lock() + defer frgj.mutex.Unlock() + if frgj.closed { + return nil + } + frgj.closed = true + var result error + for _, lock := range []*os.File{frgj.quarantineLockFile, frgj.lockFile} { + if lock == nil { + continue + } + if err := unix.Flock(int(lock.Fd()), unix.LOCK_UN); err != nil && result == nil { + result = err + } + if err := lock.Close(); err != nil && result == nil { + result = err + } + } + frgj.lockFile = nil + frgj.quarantineLockFile = nil + return result +} + +func (frgj *frostRetainedGroupJournal) initialize() error { + if err := frgj.verifyHistorySourceIdentity(context.Background()); err != nil { + return err + } + + entries, err := os.ReadDir(frgj.directory) + if err != nil { + return fmt.Errorf("cannot read FROST retained-group journal: [%w]", err) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + metadataExists := false + stateExists := false + batchNames := make([]string, 0) + for _, entry := range entries { + name := entry.Name() + if name == frostRetainedGroupJournalLockFile { + continue + } + if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() { + return fmt.Errorf("unsafe entry in FROST retained-group journal: [%s]", name) + } + switch { + case name == frostRetainedGroupJournalMetadataFile: + metadataExists = true + case name == frostRetainedGroupJournalStateFile: + stateExists = true + case strings.HasPrefix(name, frostRetainedGroupJournalBatchPrefix) && + strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix): + batchNames = append(batchNames, name) + case strings.HasSuffix(name, frostRetainedGroupJournalTempSuffix): + return fmt.Errorf("interrupted FROST retained-group journal temp is present: [%s]", name) + default: + return fmt.Errorf("unexpected file in FROST retained-group journal: [%s]", name) + } + } + + if metadataExists { + stored := frostRetainedGroupJournalMetadata{} + if err := frgj.readEnvelope(frostRetainedGroupJournalMetadataFile, &stored); err != nil { + return fmt.Errorf("cannot read FROST retained-group journal metadata: [%w]", err) + } + if stored != frgj.metadata { + return fmt.Errorf("FROST retained-group journal metadata differs from signed manifest") + } + } else { + if stateExists || len(batchNames) != 0 { + return fmt.Errorf("FROST retained-group journal has state without immutable metadata") + } + if err := frgj.persistEnvelope(frostRetainedGroupJournalMetadataFile, &frgj.metadata, false); err != nil { + return fmt.Errorf("cannot persist FROST retained-group journal metadata: [%w]", err) + } + } + + initial := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + CurrentPoint: frgj.metadata.Checkpoint, + Wallets: []frostRetainedGroupWalletState{}, + } + initial.InventoryRoot, _, _, _, err = frostRetainedGroupInventoryRoot(initial) + if err != nil { + return err + } + + stored := initial + if stateExists { + if err := frgj.readEnvelope(frostRetainedGroupJournalStateFile, &stored); err != nil { + return fmt.Errorf("cannot read FROST retained-group journal state: [%w]", err) + } + if stored.Schema != frostRetainedGroupJournalStateSchema { + return fmt.Errorf("unsupported FROST retained-group journal state schema") + } + } else if len(batchNames) != 0 { + return fmt.Errorf("FROST retained-group journal has batches without state checkpoint") + } + + rebuilt := initial + rebuiltMutations := make([]FrostRetainedGroupMutation, 0) + matchedStored := stored.BatchSequence == 0 + if matchedStored { + if err := equalFrostRetainedGroupStates(stored, rebuilt); err != nil { + return err + } + } + for index, name := range batchNames { + expectedSequence := uint64(index + 1) + if name != frostRetainedGroupBatchFileName(expectedSequence) { + return fmt.Errorf("FROST retained-group journal batch sequence has a gap at [%d]", expectedSequence) + } + batch := frostRetainedGroupJournalBatch{} + if err := frgj.readEnvelope(name, &batch); err != nil { + return fmt.Errorf("cannot read FROST retained-group journal batch [%d]: [%w]", expectedSequence, err) + } + if err := validateFrostRetainedGroupBatch(batch, rebuilt); err != nil { + return fmt.Errorf("invalid FROST retained-group journal batch [%d]: [%w]", expectedSequence, err) + } + if err := applyFrostRetainedGroupMutations(&rebuilt, batch.Mutations); err != nil { + return fmt.Errorf("cannot replay FROST retained-group batch [%d]: [%w]", expectedSequence, err) + } + rebuilt.BatchSequence = batch.Sequence + rebuilt.CurrentPoint = batch.To + rebuilt.BatchRoot = frostRetainedGroupBatchRoot(batch.PriorBatchRoot, batch.Checksum) + rebuilt.InventoryRoot, _, _, _, err = frostRetainedGroupInventoryRoot(rebuilt) + if err != nil { + return err + } + rebuiltMutations = append(rebuiltMutations, cloneFrostRetainedGroupMutations(batch.Mutations)...) + if rebuilt.BatchSequence == stored.BatchSequence { + if err := equalFrostRetainedGroupStates(stored, rebuilt); err != nil { + return err + } + matchedStored = true + } + } + if stored.BatchSequence > uint64(len(batchNames)) || !matchedStored { + return fmt.Errorf("FROST retained-group state checkpoint has no exact batch prefix") + } + frgj.state = rebuilt + frgj.mutations = rebuiltMutations + if !stateExists || stored.BatchSequence != rebuilt.BatchSequence { + if err := frgj.persistEnvelope(frostRetainedGroupJournalStateFile, &rebuilt, true); err != nil { + return fmt.Errorf("cannot integrate orphan FROST retained-group batch: [%w]", err) + } + } + return frgj.initializeQuarantine() +} + +func (frgj *frostRetainedGroupJournal) verifyHistorySourceIdentity( + ctx context.Context, +) error { + identity, err := frgj.source.Identity(ctx) + if err != nil { + return fmt.Errorf("cannot authenticate FROST retained-group history source: [%w]", err) + } + if identity.TrustDomainID != frgj.metadata.SourceTrustDomainID || + identity.EndpointFingerprint != frgj.metadata.SourceEndpointFingerprint || + identity.OperatorFingerprint != frgj.metadata.SourceOperatorFingerprint { + return fmt.Errorf("FROST retained-group history source identity differs from signed manifest") + } + return nil +} + +func equalFrostRetainedGroupStates( + stored frostRetainedGroupJournalState, + rebuilt frostRetainedGroupJournalState, +) error { + storedBytes, err := json.Marshal(stored) + if err != nil { + return err + } + rebuiltBytes, err := json.Marshal(rebuilt) + if err != nil { + return err + } + if !bytes.Equal(storedBytes, rebuiltBytes) { + return fmt.Errorf("FROST retained-group state differs from exact journal prefix") + } + return nil +} + +func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { + entries, err := os.ReadDir(frgj.quarantineDirectory) + if err != nil { + return fmt.Errorf("cannot read FROST retained-group quarantine journal: [%w]", err) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + metadataExists := false + stateExists := false + batchNames := make([]string, 0) + for _, entry := range entries { + name := entry.Name() + if name == frostRetainedGroupJournalLockFile { + continue + } + if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() { + return fmt.Errorf("unsafe entry in FROST retained-group quarantine journal: [%s]", name) + } + switch { + case name == frostRetainedGroupJournalMetadataFile: + metadataExists = true + case name == frostRetainedGroupJournalStateFile: + stateExists = true + case strings.HasPrefix(name, frostRetainedGroupJournalBatchPrefix) && + strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix): + batchNames = append(batchNames, name) + case strings.HasSuffix(name, frostRetainedGroupJournalTempSuffix): + return fmt.Errorf("interrupted FROST retained-group quarantine temp is present: [%s]", name) + default: + return fmt.Errorf("unexpected file in FROST retained-group quarantine journal: [%s]", name) + } + } + + if metadataExists { + stored := frostRetainedGroupQuarantineMetadata{} + if err := readFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + frostRetainedGroupJournalMetadataFile, + &stored, + ); err != nil { + return fmt.Errorf("cannot read FROST retained-group quarantine metadata: [%w]", err) + } + if stored != frgj.quarantineMetadata { + return fmt.Errorf("FROST retained-group quarantine metadata differs from signed manifest") + } + } else { + if stateExists || len(batchNames) != 0 { + return fmt.Errorf("FROST retained-group quarantine journal has state without immutable metadata") + } + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + frostRetainedGroupJournalMetadataFile, + &frgj.quarantineMetadata, + false, + ); err != nil { + return fmt.Errorf("cannot persist FROST retained-group quarantine metadata: [%w]", err) + } + } + + initial := frostRetainedGroupQuarantineJournalState{ + Schema: frostRetainedGroupQuarantineStateSchema, + CurrentPoint: frgj.quarantineMetadata.Checkpoint, + Root: sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)), + Quarantines: []frostRetainedGroupQuarantineState{}, + } + stored := initial + if stateExists { + if err := readFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + frostRetainedGroupJournalStateFile, + &stored, + ); err != nil { + return fmt.Errorf("cannot read FROST retained-group quarantine state: [%w]", err) + } + if stored.Schema != frostRetainedGroupQuarantineStateSchema { + return fmt.Errorf("unsupported FROST retained-group quarantine state schema") + } + } else if len(batchNames) != 0 { + return fmt.Errorf("FROST retained-group quarantine journal has batches without state checkpoint") + } + + rebuilt := initial + rebuiltMutations := make([]FrostRetainedGroupMutation, 0) + matchedStored := stored.BatchSequence == 0 + if matchedStored { + if err := equalFrostRetainedGroupQuarantineStates(stored, rebuilt); err != nil { + return err + } + } + for index, name := range batchNames { + expectedSequence := uint64(index + 1) + if name != frostRetainedGroupBatchFileName(expectedSequence) { + return fmt.Errorf("FROST retained-group quarantine batch sequence has a gap at [%d]", expectedSequence) + } + batch := frostRetainedGroupQuarantineJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + name, + &batch, + ); err != nil { + return fmt.Errorf("cannot read FROST retained-group quarantine batch [%d]: [%w]", expectedSequence, err) + } + if err := validateFrostRetainedGroupQuarantineBatch(batch, rebuilt); err != nil { + return fmt.Errorf("invalid FROST retained-group quarantine batch [%d]: [%w]", expectedSequence, err) + } + if err := applyFrostRetainedGroupQuarantineMutations(&rebuilt, batch.Mutations); err != nil { + return fmt.Errorf("cannot replay FROST retained-group quarantine batch [%d]: [%w]", expectedSequence, err) + } + rebuilt.BatchSequence = batch.Sequence + rebuilt.CurrentPoint = batch.To + rebuilt.BatchRoot = frostRetainedGroupQuarantineBatchRoot(batch.PriorBatchRoot, batch.Checksum) + rebuiltMutations = append(rebuiltMutations, cloneFrostRetainedGroupMutations(batch.Mutations)...) + if rebuilt.BatchSequence == stored.BatchSequence { + if err := equalFrostRetainedGroupQuarantineStates(stored, rebuilt); err != nil { + return err + } + matchedStored = true + } + } + if stored.BatchSequence > uint64(len(batchNames)) || !matchedStored { + return fmt.Errorf("FROST retained-group quarantine state checkpoint has no exact batch prefix") + } + frgj.quarantineState = rebuilt + frgj.quarantineMutations = rebuiltMutations + if !stateExists || stored.BatchSequence != rebuilt.BatchSequence { + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + frostRetainedGroupJournalStateFile, + &rebuilt, + true, + ); err != nil { + return fmt.Errorf("cannot integrate orphan FROST retained-group quarantine batch: [%w]", err) + } + } + return nil +} + +func equalFrostRetainedGroupQuarantineStates( + stored frostRetainedGroupQuarantineJournalState, + rebuilt frostRetainedGroupQuarantineJournalState, +) error { + storedBytes, err := json.Marshal(stored) + if err != nil { + return err + } + rebuiltBytes, err := json.Marshal(rebuilt) + if err != nil { + return err + } + if !bytes.Equal(storedBytes, rebuiltBytes) { + return fmt.Errorf("FROST retained-group quarantine state differs from exact journal prefix") + } + return nil +} + +func frostRetainedGroupBatchFileName(sequence uint64) string { + return fmt.Sprintf("%s%020d%s", frostRetainedGroupJournalBatchPrefix, sequence, frostRetainedGroupJournalFileSuffix) +} + +func validateFrostRetainedGroupBatch( + batch frostRetainedGroupJournalBatch, + prior frostRetainedGroupJournalState, +) error { + if batch.Schema != frostRetainedGroupJournalBatchSchema || + batch.Sequence != prior.BatchSequence+1 || batch.From != prior.CurrentPoint || + batch.PriorBatchRoot != prior.BatchRoot || batch.To.BlockNumber < batch.From.BlockNumber || + batch.To.BlockHash == [32]byte{} || batch.Checksum == [32]byte{} { + return fmt.Errorf("batch header is invalid") + } + declared := batch.Checksum + batch.Checksum = [32]byte{} + payload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + return err + } + if sha256.Sum256(payload) != declared { + return fmt.Errorf("batch checksum mismatch") + } + if err := validateFrostRetainedGroupBatchMutationBounds(batch.From, batch.To, batch.Mutations); err != nil { + return err + } + for _, mutation := range batch.Mutations { + if isFrostRetainedGroupQuarantineMutation(mutation.Kind) { + return fmt.Errorf("canonical batch contains a quarantine mutation") + } + } + return nil +} + +func validateFrostRetainedGroupQuarantineBatch( + batch frostRetainedGroupQuarantineJournalBatch, + prior frostRetainedGroupQuarantineJournalState, +) error { + if batch.Schema != frostRetainedGroupQuarantineBatchSchema || + batch.Sequence != prior.BatchSequence+1 || batch.From != prior.CurrentPoint || + batch.PriorBatchRoot != prior.BatchRoot || batch.To.BlockNumber < batch.From.BlockNumber || + batch.To.BlockHash == [32]byte{} || batch.Checksum == [32]byte{} { + return fmt.Errorf("quarantine batch header is invalid") + } + declared := batch.Checksum + batch.Checksum = [32]byte{} + payload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + return err + } + if sha256.Sum256(payload) != declared { + return fmt.Errorf("quarantine batch checksum mismatch") + } + if err := validateFrostRetainedGroupBatchMutationBounds(batch.From, batch.To, batch.Mutations); err != nil { + return err + } + for _, mutation := range batch.Mutations { + if !isFrostRetainedGroupQuarantineMutation(mutation.Kind) { + return fmt.Errorf("quarantine batch contains a canonical inventory mutation") + } + } + return nil +} + +func validateFrostRetainedGroupBatchMutationBounds( + from FrostPreSignFinality, + to FrostPreSignFinality, + mutations []FrostRetainedGroupMutation, +) error { + var previous FrostRetainedGroupEventPoint + for index, mutation := range mutations { + if !mutation.Point.valid() || mutation.Point.BlockNumber <= from.BlockNumber || + mutation.Point.BlockNumber > to.BlockNumber || + (index > 0 && compareFrostRetainedGroupEventPoints(previous, mutation.Point) >= 0) { + return fmt.Errorf("batch mutations are outside cursor bounds or not strictly ordered") + } + if index > 0 && mutation.Point.BlockNumber == previous.BlockNumber && + mutation.Point.BlockHash != previous.BlockHash { + return fmt.Errorf("batch mutations disagree on canonical block hash") + } + previous = mutation.Point + } + return nil +} + +func frostRetainedGroupBatchRoot(prior [32]byte, checksum [32]byte) [32]byte { + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupBatchDomain)) + hasher.Write(prior[:]) + hasher.Write(checksum[:]) + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result +} + +func frostRetainedGroupQuarantineBatchRoot( + prior [32]byte, + checksum [32]byte, +) [32]byte { + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupQuarantineBatchDomain)) + hasher.Write(prior[:]) + hasher.Write(checksum[:]) + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result +} + +func (frgj *frostRetainedGroupJournal) readEnvelope(name string, target interface{}) error { + return readFrostRetainedGroupEnvelopeAt(frgj.directory, name, target) +} + +func readFrostRetainedGroupEnvelopeAt( + directory string, + name string, + target interface{}, +) error { + file, err := openSecureBitcoinBroadcastFile(filepath.Join(directory, name), unix.O_RDONLY, 0600) + if err != nil { + return err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, frostRetainedGroupJournalMaximumFile+1)) + if err != nil { + return err + } + if len(data) == 0 || len(data) > frostRetainedGroupJournalMaximumFile { + return fmt.Errorf("journal file size is invalid") + } + envelope := frostRetainedGroupEnvelope{} + if err := decodeStrictFrostActivationJSON(data, &envelope); err != nil { + return err + } + if len(envelope.Payload) == 0 || sha256.Sum256(envelope.Payload) != envelope.Checksum { + return fmt.Errorf("journal file checksum mismatch") + } + return decodeStrictFrostActivationJSON(envelope.Payload, target) +} + +func (frgj *frostRetainedGroupJournal) persistEnvelope( + name string, + payload interface{}, + replace bool, +) error { + return persistFrostRetainedGroupEnvelopeAt(frgj.directory, name, payload, replace) +} + +func persistFrostRetainedGroupEnvelopeAt( + directory string, + name string, + payload interface{}, + replace bool, +) error { + payloadBytes, err := json.Marshal(payload) + if err != nil { + return err + } + envelopeBytes, err := json.Marshal(frostRetainedGroupEnvelope{ + Payload: payloadBytes, + Checksum: sha256.Sum256(payloadBytes), + }) + if err != nil { + return err + } + finalPath := filepath.Join(directory, name) + if info, err := os.Lstat(finalPath); err == nil { + if !replace { + return fmt.Errorf("immutable journal file already exists: [%s]", name) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || + info.Mode().Perm() != 0600 { + return fmt.Errorf("journal destination is unsafe: [%s]", name) + } + if err := validateBitcoinBroadcastOwner(info); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + temporary, err := os.CreateTemp(directory, name+"-*"+frostRetainedGroupJournalTempSuffix) + if err != nil { + return err + } + temporaryPath := temporary.Name() + remove := true + defer func() { + if remove { + _ = os.Remove(temporaryPath) + } + }() + if err := temporary.Chmod(0600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(envelopeBytes); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if !replace { + // Link is an atomic no-replace publication: unlike Rename it cannot + // overwrite an immutable batch or metadata file that appeared after + // the destination check above. + if err := os.Link(temporaryPath, finalPath); err != nil { + return fmt.Errorf("cannot publish immutable journal file [%s]: [%w]", name, err) + } + if err := os.Remove(temporaryPath); err != nil { + return err + } + remove = false + return syncDirectory(directory) + } + if err := os.Rename(temporaryPath, finalPath); err != nil { + return err + } + remove = false + return syncDirectory(directory) +} + +func applyFrostRetainedGroupMutations( + state *frostRetainedGroupJournalState, + mutations []FrostRetainedGroupMutation, +) error { + if state == nil { + return fmt.Errorf("FROST retained-group state is nil") + } + wallets := make(map[[32]byte]frostRetainedGroupWalletState, len(state.Wallets)) + for _, wallet := range state.Wallets { + if wallet.WalletID == [32]byte{} || wallets[wallet.WalletID].WalletID != [32]byte{} { + return fmt.Errorf("FROST retained-group wallet state is duplicate or invalid") + } + wallet.OperatorIDs = append([]uint32{}, wallet.OperatorIDs...) + wallets[wallet.WalletID] = wallet + } + var previous FrostRetainedGroupEventPoint + for index, mutation := range mutations { + if !mutation.Point.valid() || (index > 0 && + compareFrostRetainedGroupEventPoints(previous, mutation.Point) >= 0) { + return fmt.Errorf("FROST retained-group mutations are not strictly ordered") + } + if index > 0 && mutation.Point.BlockNumber == previous.BlockNumber && + mutation.Point.BlockHash != previous.BlockHash { + return fmt.Errorf("FROST retained-group mutations disagree on canonical block hash") + } + previous = mutation.Point + wallet := wallets[mutation.WalletID] + switch mutation.Kind { + case FrostRetainedGroupAdmissionMutation: + if mutation.WalletID == [32]byte{} || mutation.WalletPublicKeyHash == [20]byte{} || + len(mutation.OperatorIDs) < 51 || len(mutation.OperatorIDs) > 100 || + mutation.RetainedGroupHash == [32]byte{} || + !mutation.CreationPoint.valid() || !mutation.BridgeRegistrationPoint.valid() || + mutation.Point != mutation.BridgeRegistrationPoint || + !sameFrostRetainedGroupTransaction(mutation.CreationPoint, mutation.BridgeRegistrationPoint) || + compareFrostRetainedGroupEventPoints(mutation.CreationPoint, mutation.BridgeRegistrationPoint) >= 0 || + wallet.WalletID != [32]byte{} || mutation.QuarantineID != [32]byte{} { + return fmt.Errorf("invalid or duplicate FROST retained-group admission") + } + for _, operatorID := range mutation.OperatorIDs { + if operatorID == 0 { + return fmt.Errorf("FROST retained-group admission contains zero operator ID") + } + } + wallets[mutation.WalletID] = frostRetainedGroupWalletState{ + WalletID: mutation.WalletID, + WalletPublicKeyHash: mutation.WalletPublicKeyHash, + OperatorIDs: append([]uint32{}, mutation.OperatorIDs...), + RetainedGroupHash: mutation.RetainedGroupHash, + Lifecycle: FrostRetainedGroupLive, + CreationPoint: mutation.CreationPoint, + BridgeRegistrationPoint: mutation.BridgeRegistrationPoint, + LifecyclePoint: mutation.BridgeRegistrationPoint, + LastBridgePoint: mutation.Point, + } + state.SnapshotGeneration++ + case FrostRetainedGroupMovingFundsMutation, + FrostRetainedGroupClosingMutation, + FrostRetainedGroupClosedMutation, + FrostRetainedGroupTerminatedMutation: + if wallet.WalletID == [32]byte{} || + mutation.WalletPublicKeyHash != wallet.WalletPublicKeyHash || + len(mutation.OperatorIDs) != 0 || wallet.Lifecycle.terminal() { + return fmt.Errorf("invalid FROST retained-group lifecycle mutation") + } + next := FrostRetainedGroupLifecycle("") + switch mutation.Kind { + case FrostRetainedGroupMovingFundsMutation: + next = FrostRetainedGroupMovingFunds + case FrostRetainedGroupClosingMutation: + next = FrostRetainedGroupClosing + case FrostRetainedGroupClosedMutation: + next = FrostRetainedGroupClosed + case FrostRetainedGroupTerminatedMutation: + next = FrostRetainedGroupTerminated + } + if !validFrostRetainedGroupTransition(wallet.Lifecycle, next) { + return fmt.Errorf("invalid FROST retained-group transition [%s -> %s]", wallet.Lifecycle, next) + } + wallet.Lifecycle = next + wallet.LifecyclePoint = mutation.Point + wallet.LastBridgePoint = mutation.Point + wallets[mutation.WalletID] = wallet + state.SnapshotGeneration++ + case FrostRetainedGroupRegistryClosureMutation: + if wallet.WalletID == [32]byte{} || !wallet.Lifecycle.terminal() || wallet.RegistryClosed || + mutation.WalletPublicKeyHash != wallet.WalletPublicKeyHash || + mutation.Point.BlockNumber != wallet.LastBridgePoint.BlockNumber || + mutation.Point.BlockHash != wallet.LastBridgePoint.BlockHash || + mutation.Point.TransactionHash != wallet.LastBridgePoint.TransactionHash || + mutation.Point.TransactionIndex != wallet.LastBridgePoint.TransactionIndex || + mutation.Point.LogIndex <= wallet.LastBridgePoint.LogIndex { + return fmt.Errorf("FROST Registry closure does not follow its Bridge terminal event") + } + wallet.RegistryClosed = true + wallet.RegistryClosurePoint = mutation.Point + wallets[mutation.WalletID] = wallet + state.SnapshotGeneration++ + case FrostRetainedGroupQuarantineMutation, + FrostRetainedGroupRecoveryRequiredMutation, + FrostRetainedGroupQuarantineLiftMutation: + return fmt.Errorf("quarantine mutation cannot enter canonical retained-group journal") + default: + return fmt.Errorf("unknown FROST retained-group mutation kind [%s]", mutation.Kind) + } + } + state.Wallets = state.Wallets[:0] + for _, wallet := range wallets { + state.Wallets = append(state.Wallets, wallet) + } + sort.Slice(state.Wallets, func(i, j int) bool { + return bytes.Compare(state.Wallets[i].WalletID[:], state.Wallets[j].WalletID[:]) < 0 + }) + return nil +} + +func isFrostRetainedGroupQuarantineMutation( + kind FrostRetainedGroupMutationKind, +) bool { + return kind == FrostRetainedGroupQuarantineMutation || + kind == FrostRetainedGroupRecoveryRequiredMutation || + kind == FrostRetainedGroupQuarantineLiftMutation +} + +func frostRetainedGroupQuarantineMutations( + mutations []FrostRetainedGroupMutation, +) []FrostRetainedGroupMutation { + result := make([]FrostRetainedGroupMutation, 0) + for _, mutation := range mutations { + if isFrostRetainedGroupQuarantineMutation(mutation.Kind) { + result = append(result, mutation) + } + } + return cloneFrostRetainedGroupMutations(result) +} + +func frostRetainedGroupCanonicalMutations( + mutations []FrostRetainedGroupMutation, +) []FrostRetainedGroupMutation { + result := make([]FrostRetainedGroupMutation, 0) + for _, mutation := range mutations { + if !isFrostRetainedGroupQuarantineMutation(mutation.Kind) { + result = append(result, mutation) + } + } + return cloneFrostRetainedGroupMutations(result) +} + +func applyFrostRetainedGroupQuarantineMutations( + state *frostRetainedGroupQuarantineJournalState, + mutations []FrostRetainedGroupMutation, +) error { + if state == nil { + return fmt.Errorf("FROST retained-group quarantine state is nil") + } + quarantines := make( + map[[32]byte]frostRetainedGroupQuarantineState, + len(state.Quarantines), + ) + for _, quarantine := range state.Quarantines { + if quarantine.QuarantineID == [32]byte{} || + quarantines[quarantine.QuarantineID].QuarantineID != [32]byte{} { + return fmt.Errorf("FROST retained-group quarantine state is duplicate or invalid") + } + quarantines[quarantine.QuarantineID] = quarantine + } + var previous FrostRetainedGroupEventPoint + for index, mutation := range mutations { + if !isFrostRetainedGroupQuarantineMutation(mutation.Kind) { + return fmt.Errorf("canonical inventory mutation cannot enter quarantine journal") + } + if !mutation.Point.valid() || (index > 0 && + compareFrostRetainedGroupEventPoints(previous, mutation.Point) >= 0) { + return fmt.Errorf("FROST retained-group quarantine mutations are not strictly ordered") + } + if index > 0 && mutation.Point.BlockNumber == previous.BlockNumber && + mutation.Point.BlockHash != previous.BlockHash { + return fmt.Errorf("FROST retained-group quarantine mutations disagree on canonical block hash") + } + previous = mutation.Point + switch mutation.Kind { + case FrostRetainedGroupQuarantineMutation, FrostRetainedGroupRecoveryRequiredMutation: + if mutation.QuarantineID == [32]byte{} || mutation.EvidenceHash == [32]byte{} || + strings.TrimSpace(mutation.Reason) == "" || + quarantines[mutation.QuarantineID].QuarantineID != [32]byte{} { + return fmt.Errorf("invalid or duplicate FROST retained-group quarantine") + } + quarantines[mutation.QuarantineID] = frostRetainedGroupQuarantineState{ + QuarantineID: mutation.QuarantineID, + WalletID: mutation.WalletID, + EvidenceHash: mutation.EvidenceHash, + Reason: mutation.Reason, + RecoveryRequired: mutation.Kind == FrostRetainedGroupRecoveryRequiredMutation, + RaisedAt: mutation.Point, + } + case FrostRetainedGroupQuarantineLiftMutation: + quarantine := quarantines[mutation.QuarantineID] + if quarantine.QuarantineID == [32]byte{} || mutation.AuthenticationHash == [32]byte{} || + mutation.WalletID != quarantine.WalletID { + return fmt.Errorf("unauthenticated or unknown FROST retained-group quarantine lift") + } + delete(quarantines, mutation.QuarantineID) + } + appendFrostRetainedGroupQuarantineRoot(state, mutation) + } + state.Quarantines = state.Quarantines[:0] + for _, quarantine := range quarantines { + state.Quarantines = append(state.Quarantines, quarantine) + } + sort.Slice(state.Quarantines, func(i, j int) bool { + return bytes.Compare(state.Quarantines[i].QuarantineID[:], state.Quarantines[j].QuarantineID[:]) < 0 + }) + return nil +} + +func validFrostRetainedGroupTransition( + current FrostRetainedGroupLifecycle, + next FrostRetainedGroupLifecycle, +) bool { + switch current { + case FrostRetainedGroupLive: + return next == FrostRetainedGroupMovingFunds || next == FrostRetainedGroupClosing || + next == FrostRetainedGroupTerminated + case FrostRetainedGroupMovingFunds: + return next == FrostRetainedGroupClosing || next == FrostRetainedGroupTerminated + case FrostRetainedGroupClosing: + return next == FrostRetainedGroupClosed || next == FrostRetainedGroupTerminated + default: + return false + } +} + +func sameFrostRetainedGroupTransaction( + left FrostRetainedGroupEventPoint, + right FrostRetainedGroupEventPoint, +) bool { + return left.BlockNumber == right.BlockNumber && + left.BlockHash == right.BlockHash && + left.TransactionHash == right.TransactionHash && + left.TransactionIndex == right.TransactionIndex +} + +func appendFrostRetainedGroupQuarantineRoot( + state *frostRetainedGroupQuarantineJournalState, + mutation FrostRetainedGroupMutation, +) { + payload, _ := frostRetainedGroupCanonicalValue(mutation) + leaf := sha256.Sum256(payload) + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupQuarantineDomain)) + hasher.Write(state.Root[:]) + hasher.Write(leaf[:]) + copy(state.Root[:], hasher.Sum(nil)) + state.Generation++ +} + +func frostRetainedGroupInventoryRoot( + state frostRetainedGroupJournalState, +) ([32]byte, uint64, uint64, uint64, error) { + type inventoryEventPoint struct { + BlockNumber uint64 `json:"blockNumber"` + BlockHash string `json:"blockHash"` + TransactionHash string `json:"transactionHash"` + TransactionIndex uint32 `json:"transactionIndex"` + LogIndex uint32 `json:"logIndex"` + } + type inventoryEntry struct { + WalletID string `json:"walletID"` + RetainedGroupHash string `json:"retainedGroupHash"` + ActualGroupSize uint64 `json:"actualGroupSize"` + Lifecycle string `json:"lifecycle"` + CreationPoint inventoryEventPoint `json:"creationPoint"` + BridgeRegistrationPoint inventoryEventPoint `json:"bridgeRegistrationPoint"` + LifecyclePoint inventoryEventPoint `json:"lifecyclePoint"` + RegistryClosurePoint *inventoryEventPoint `json:"registryClosurePoint,omitempty"` + } + eventPoint := func(point FrostRetainedGroupEventPoint) inventoryEventPoint { + return inventoryEventPoint{ + BlockNumber: point.BlockNumber, + BlockHash: "0x" + hex.EncodeToString(point.BlockHash[:]), + TransactionHash: "0x" + hex.EncodeToString(point.TransactionHash[:]), + TransactionIndex: point.TransactionIndex, + LogIndex: point.LogIndex, + } + } + entries := make([]inventoryEntry, 0) + minimumSize := uint64(0) + maximumSize := uint64(0) + for _, wallet := range state.Wallets { + size := uint64(len(wallet.OperatorIDs)) + if wallet.WalletID == [32]byte{} || size < 51 || size > 100 || + wallet.RetainedGroupHash == [32]byte{} || + !wallet.CreationPoint.valid() || !wallet.BridgeRegistrationPoint.valid() || + !wallet.LifecyclePoint.valid() || + !sameFrostRetainedGroupTransaction(wallet.CreationPoint, wallet.BridgeRegistrationPoint) || + compareFrostRetainedGroupEventPoints(wallet.CreationPoint, wallet.BridgeRegistrationPoint) >= 0 || + compareFrostRetainedGroupEventPoints(wallet.BridgeRegistrationPoint, wallet.LifecyclePoint) > 0 || + (wallet.BridgeRegistrationPoint.BlockNumber == wallet.LifecyclePoint.BlockNumber && + wallet.BridgeRegistrationPoint.BlockHash != wallet.LifecyclePoint.BlockHash) || + wallet.LifecyclePoint.BlockNumber > state.CurrentPoint.BlockNumber || + (wallet.LifecyclePoint.BlockNumber == state.CurrentPoint.BlockNumber && + wallet.LifecyclePoint.BlockHash != state.CurrentPoint.BlockHash) || + wallet.Lifecycle.terminal() != wallet.RegistryClosed || + (wallet.RegistryClosed && + (!sameFrostRetainedGroupTransaction(wallet.LifecyclePoint, wallet.RegistryClosurePoint) || + compareFrostRetainedGroupEventPoints(wallet.LifecyclePoint, wallet.RegistryClosurePoint) >= 0)) { + return [32]byte{}, 0, 0, 0, fmt.Errorf("FROST retained-group inventory has invalid group size") + } + if minimumSize == 0 || size < minimumSize { + minimumSize = size + } + if size > maximumSize { + maximumSize = size + } + lifecycle := "" + switch wallet.Lifecycle { + case FrostRetainedGroupLive: + lifecycle = "live" + case FrostRetainedGroupMovingFunds: + lifecycle = "moving-funds" + case FrostRetainedGroupClosing: + lifecycle = "closing" + case FrostRetainedGroupClosed: + lifecycle = "closed" + case FrostRetainedGroupTerminated: + lifecycle = "terminated" + default: + return [32]byte{}, 0, 0, 0, fmt.Errorf("FROST retained-group inventory has unknown lifecycle") + } + entry := inventoryEntry{ + WalletID: "0x" + hex.EncodeToString(wallet.WalletID[:]), + RetainedGroupHash: "0x" + hex.EncodeToString(wallet.RetainedGroupHash[:]), + ActualGroupSize: size, + Lifecycle: lifecycle, + CreationPoint: eventPoint(wallet.CreationPoint), + BridgeRegistrationPoint: eventPoint(wallet.BridgeRegistrationPoint), + LifecyclePoint: eventPoint(wallet.LifecyclePoint), + } + if wallet.RegistryClosed { + registryPoint := eventPoint(wallet.RegistryClosurePoint) + entry.RegistryClosurePoint = ®istryPoint + } + entries = append(entries, entry) + } + // ASCII ordering of lowercase fixed-width wallet IDs is identical to byte + // ordering and is stated explicitly here to make the wire commitment clear. + sort.Slice(entries, func(i, j int) bool { return entries[i].WalletID < entries[j].WalletID }) + for index := 1; index < len(entries); index++ { + if entries[index-1].WalletID == entries[index].WalletID { + return [32]byte{}, 0, 0, 0, fmt.Errorf("FROST retained-group inventory has ambiguous wallet membership") + } + } + accumulator := sha256.Sum256([]byte(frostRetainedGroupInventoryEntriesDomain)) + for _, entry := range entries { + canonical, err := frostRetainedGroupCanonicalValue(entry) + if err != nil { + return [32]byte{}, 0, 0, 0, err + } + leafHasher := sha256.New() + leafHasher.Write([]byte(frostRetainedGroupInventoryLeafDomain)) + leafHasher.Write(canonical) + leaf := leafHasher.Sum(nil) + nodeHasher := sha256.New() + nodeHasher.Write([]byte(frostRetainedGroupInventoryNodeDomain)) + nodeHasher.Write(accumulator[:]) + nodeHasher.Write(leaf) + copy(accumulator[:], nodeHasher.Sum(nil)) + } + rootMetadata := struct { + Point struct { + BlockHash string `json:"blockHash"` + BlockNumber uint64 `json:"blockNumber"` + } `json:"point"` + SnapshotGeneration uint64 `json:"snapshotGeneration"` + WalletCount uint64 `json:"walletCount"` + }{SnapshotGeneration: state.SnapshotGeneration, WalletCount: uint64(len(entries))} + rootMetadata.Point.BlockNumber = state.CurrentPoint.BlockNumber + rootMetadata.Point.BlockHash = "0x" + hex.EncodeToString(state.CurrentPoint.BlockHash[:]) + canonicalMetadata, err := frostRetainedGroupCanonicalValue(rootMetadata) + if err != nil { + return [32]byte{}, 0, 0, 0, err + } + rootHasher := sha256.New() + rootHasher.Write([]byte(frostRetainedGroupInventoryRootDomain)) + rootHasher.Write(canonicalMetadata) + rootHasher.Write(accumulator[:]) + var root [32]byte + copy(root[:], rootHasher.Sum(nil)) + return root, uint64(len(entries)), minimumSize, maximumSize, nil +} + +func frostRetainedGroupCanonicalValue(value interface{}) ([]byte, error) { + return canonicalFrostActivationValue(value) +} + +func cloneFrostRetainedGroupMutations( + mutations []FrostRetainedGroupMutation, +) []FrostRetainedGroupMutation { + result := make([]FrostRetainedGroupMutation, len(mutations)) + copy(result, mutations) + for index := range result { + result[index].OperatorIDs = append([]uint32{}, mutations[index].OperatorIDs...) + } + return result +} + +func (frgj *frostRetainedGroupJournal) reconcile( + ctx context.Context, + target FrostPreSignFinality, +) (*frostRetainedGroupJournalSnapshot, error) { + if ctx == nil { + return nil, fmt.Errorf("FROST retained-group reconciliation context is nil") + } + frgj.mutex.Lock() + defer frgj.mutex.Unlock() + if frgj.closed { + return nil, fmt.Errorf("FROST retained-group journal is closed") + } + if target.BlockNumber == 0 || target.BlockHash == [32]byte{} || + target.BlockNumber < frgj.metadata.Checkpoint.BlockNumber || + target.BlockNumber < frgj.state.CurrentPoint.BlockNumber || + target.BlockNumber < frgj.quarantineState.CurrentPoint.BlockNumber { + return nil, fmt.Errorf("FROST retained-group target is invalid or retrograde") + } + if err := frgj.verifyHistorySourceIdentity(ctx); err != nil { + return nil, err + } + beforeHead, err := frgj.source.FinalizedHead(ctx) + if err != nil { + return nil, fmt.Errorf("cannot read independent finalized head before journal replay: [%w]", err) + } + if beforeHead.BlockNumber < target.BlockNumber || beforeHead.BlockHash == [32]byte{} { + return nil, fmt.Errorf("FROST retained-group target is above independent finalized head") + } + if beforeHead.BlockNumber == target.BlockNumber && beforeHead.BlockHash != target.BlockHash { + return nil, fmt.Errorf("independent finalized head disagrees with challenged target") + } + if err := frgj.source.VerifyPoint(ctx, beforeHead); err != nil { + return nil, fmt.Errorf("cannot verify independent finalized head before journal replay: [%w]", err) + } + for name, point := range map[string]FrostPreSignFinality{ + "signed checkpoint": frgj.metadata.Checkpoint, + "durable canonical cursor": frgj.state.CurrentPoint, + "durable quarantine cursor": frgj.quarantineState.CurrentPoint, + "challenged target": target, + } { + if err := frgj.source.VerifyPoint(ctx, point); err != nil { + return nil, fmt.Errorf("cannot verify FROST retained-group %s: [%w]", name, err) + } + } + history, err := frgj.source.ReadCompleteHistory(ctx, frgj.metadata.Checkpoint, target) + if err != nil { + return nil, fmt.Errorf("cannot reconstruct complete FROST retained-group history: [%w]", err) + } + if history == nil || !history.Complete || !history.EmptyAtFrom || + history.From != frgj.metadata.Checkpoint || + history.To != target || history.DescriptorSetHash != frgj.metadata.DescriptorSetHash { + return nil, fmt.Errorf("FROST retained-group history receipt is incomplete or differently bound") + } + if err := validateCompleteFrostRetainedGroupHistory(history); err != nil { + return nil, err + } + canonicalInventoryMutations := frostRetainedGroupCanonicalMutations(history.Mutations) + if len(frgj.mutations) > len(canonicalInventoryMutations) { + return nil, fmt.Errorf("durable FROST retained-group journal has events absent from canonical history") + } + for index := range frgj.mutations { + durable, err := frostRetainedGroupCanonicalValue(frgj.mutations[index]) + if err != nil { + return nil, err + } + canonical, err := frostRetainedGroupCanonicalValue(canonicalInventoryMutations[index]) + if err != nil { + return nil, err + } + if !bytes.Equal(durable, canonical) { + return nil, fmt.Errorf("canonical FROST retained-group history rewrote, omitted, or reordered event [%d]", index) + } + } + canonicalQuarantineMutations := frostRetainedGroupQuarantineMutations(history.Mutations) + if len(frgj.quarantineMutations) > len(canonicalQuarantineMutations) { + return nil, fmt.Errorf("durable FROST quarantine journal has events absent from canonical history") + } + for index := range frgj.quarantineMutations { + durable, err := frostRetainedGroupCanonicalValue(frgj.quarantineMutations[index]) + if err != nil { + return nil, err + } + canonical, err := frostRetainedGroupCanonicalValue(canonicalQuarantineMutations[index]) + if err != nil { + return nil, err + } + if !bytes.Equal(durable, canonical) { + return nil, fmt.Errorf("canonical FROST retained-group history rewrote, omitted, or reordered quarantine event [%d]", index) + } + } + suffix := cloneFrostRetainedGroupMutations( + canonicalInventoryMutations[len(frgj.mutations):], + ) + for _, mutation := range suffix { + if mutation.Point.BlockNumber <= frgj.state.CurrentPoint.BlockNumber || + mutation.Point.BlockNumber > target.BlockNumber { + return nil, fmt.Errorf("canonical FROST retained-group suffix is outside durable cursor bounds") + } + } + if target != frgj.state.CurrentPoint || len(suffix) != 0 { + candidate := cloneFrostRetainedGroupState(frgj.state) + if err := applyFrostRetainedGroupMutations(&candidate, suffix); err != nil { + return nil, err + } + batch := frostRetainedGroupJournalBatch{ + Schema: frostRetainedGroupJournalBatchSchema, + Sequence: frgj.state.BatchSequence + 1, + From: frgj.state.CurrentPoint, + To: target, + PriorBatchRoot: frgj.state.BatchRoot, + Mutations: suffix, + } + checksumPayload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + return nil, err + } + batch.Checksum = sha256.Sum256(checksumPayload) + candidate.BatchSequence = batch.Sequence + candidate.CurrentPoint = target + candidate.BatchRoot = frostRetainedGroupBatchRoot(batch.PriorBatchRoot, batch.Checksum) + candidate.InventoryRoot, _, _, _, err = frostRetainedGroupInventoryRoot(candidate) + if err != nil { + return nil, err + } + if err := frgj.persistEnvelope(frostRetainedGroupBatchFileName(batch.Sequence), &batch, false); err != nil { + return nil, fmt.Errorf("cannot append FROST retained-group journal batch: [%w]", err) + } + if frgj.persistFailureHook != nil { + if err := frgj.persistFailureHook("after-batch-before-state"); err != nil { + return nil, err + } + } + if err := frgj.persistEnvelope(frostRetainedGroupJournalStateFile, &candidate, true); err != nil { + return nil, fmt.Errorf("cannot checkpoint FROST retained-group journal state: [%w]", err) + } + frgj.state = candidate + frgj.mutations = append(frgj.mutations, suffix...) + } + quarantineSuffix := cloneFrostRetainedGroupMutations( + canonicalQuarantineMutations[len(frgj.quarantineMutations):], + ) + for _, mutation := range quarantineSuffix { + if mutation.Point.BlockNumber <= frgj.quarantineState.CurrentPoint.BlockNumber || + mutation.Point.BlockNumber > target.BlockNumber { + return nil, fmt.Errorf("canonical FROST quarantine suffix is outside durable cursor bounds") + } + } + if target != frgj.quarantineState.CurrentPoint || len(quarantineSuffix) != 0 { + candidate := cloneFrostRetainedGroupQuarantineState(frgj.quarantineState) + if err := applyFrostRetainedGroupQuarantineMutations(&candidate, quarantineSuffix); err != nil { + return nil, err + } + batch := frostRetainedGroupQuarantineJournalBatch{ + Schema: frostRetainedGroupQuarantineBatchSchema, + Sequence: frgj.quarantineState.BatchSequence + 1, + From: frgj.quarantineState.CurrentPoint, + To: target, + PriorBatchRoot: frgj.quarantineState.BatchRoot, + Mutations: quarantineSuffix, + } + checksumPayload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + return nil, err + } + batch.Checksum = sha256.Sum256(checksumPayload) + candidate.BatchSequence = batch.Sequence + candidate.CurrentPoint = target + candidate.BatchRoot = frostRetainedGroupQuarantineBatchRoot( + batch.PriorBatchRoot, + batch.Checksum, + ) + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + frostRetainedGroupBatchFileName(batch.Sequence), + &batch, + false, + ); err != nil { + return nil, fmt.Errorf("cannot append FROST retained-group quarantine batch: [%w]", err) + } + if frgj.persistFailureHook != nil { + if err := frgj.persistFailureHook("after-quarantine-batch-before-state"); err != nil { + return nil, err + } + } + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + frostRetainedGroupJournalStateFile, + &candidate, + true, + ); err != nil { + return nil, fmt.Errorf("cannot checkpoint FROST retained-group quarantine state: [%w]", err) + } + frgj.quarantineState = candidate + frgj.quarantineMutations = append(frgj.quarantineMutations, quarantineSuffix...) + } + afterHead, err := frgj.source.FinalizedHead(ctx) + if err != nil { + return nil, fmt.Errorf("cannot read independent finalized head after journal replay: [%w]", err) + } + if afterHead.BlockNumber < beforeHead.BlockNumber || + afterHead.BlockNumber < target.BlockNumber || afterHead.BlockHash == [32]byte{} || + (beforeHead.BlockNumber == afterHead.BlockNumber && beforeHead.BlockHash != afterHead.BlockHash) { + return nil, fmt.Errorf("independent finalized head changed inconsistently during journal replay") + } + if afterHead.BlockNumber == target.BlockNumber && afterHead.BlockHash != target.BlockHash { + return nil, fmt.Errorf("independent finalized head disagrees with challenged target after replay") + } + if err := frgj.source.VerifyPoint(ctx, afterHead); err != nil { + return nil, fmt.Errorf("cannot verify independent finalized head after journal replay: [%w]", err) + } + if err := frgj.source.VerifyPoint(ctx, target); err != nil { + return nil, fmt.Errorf("challenged FROST retained-group point changed during replay: [%w]", err) + } + localSessionCount, err := frgj.reconcileLocalSessions(ctx, target) + if err != nil { + return nil, err + } + root, walletCount, minimumSize, maximumSize, err := frostRetainedGroupInventoryRoot(frgj.state) + if err != nil { + return nil, err + } + if root != frgj.state.InventoryRoot || frgj.state.SnapshotGeneration < frgj.minimumGeneration { + return nil, fmt.Errorf("FROST retained-group journal generation or inventory root is not activation-ready") + } + if frgj.quarantineState.CurrentPoint != target || + frgj.quarantineState.Generation < frgj.quarantineMinimumGeneration || + frgj.quarantineState.Root == [32]byte{} { + return nil, fmt.Errorf("independent FROST quarantine journal is not activation-ready") + } + return &frostRetainedGroupJournalSnapshot{ + Schema: frostRetainedGroupJournalSnapshotSchema, + StoreID: frgj.metadata.StoreID, + StoreFingerprint: frgj.metadata.StoreFingerprint, + ClusterFingerprint: frgj.metadata.ClusterFingerprint, + CurrentPoint: frgj.state.CurrentPoint, + SnapshotGeneration: frgj.state.SnapshotGeneration, + BatchRoot: frgj.state.BatchRoot, + InventoryRoot: root, + WalletCount: walletCount, + MinimumActualGroupSize: minimumSize, + MaximumActualGroupSize: maximumSize, + QuarantineProtocolID: frgj.quarantineMetadata.ProtocolID, + QuarantineStoreID: frgj.quarantineMetadata.StoreID, + QuarantineStoreFingerprint: frgj.quarantineMetadata.StoreFingerprint, + QuarantineClusterFingerprint: frgj.quarantineMetadata.ClusterFingerprint, + QuarantineMinimumGeneration: frgj.quarantineMinimumGeneration, + QuarantineGeneration: frgj.quarantineState.Generation, + QuarantineRoot: frgj.quarantineState.Root, + QuarantineCount: uint64(len(frgj.quarantineState.Quarantines)), + LocalSessionCount: localSessionCount, + Complete: true, + }, nil +} + +func validateCompleteFrostRetainedGroupHistory( + history *FrostRetainedGroupHistory, +) error { + var previous FrostRetainedGroupEventPoint + for index, mutation := range history.Mutations { + if !mutation.Point.valid() || mutation.Point.BlockNumber <= history.From.BlockNumber || + mutation.Point.BlockNumber > history.To.BlockNumber || + (index > 0 && compareFrostRetainedGroupEventPoints(previous, mutation.Point) >= 0) { + return fmt.Errorf("complete FROST retained-group history has invalid event bounds or ordering") + } + if index > 0 && mutation.Point.BlockNumber == previous.BlockNumber && + mutation.Point.BlockHash != previous.BlockHash { + return fmt.Errorf("complete FROST retained-group history has conflicting block hashes") + } + previous = mutation.Point + } + probe := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + CurrentPoint: history.From, + Wallets: []frostRetainedGroupWalletState{}, + } + if err := applyFrostRetainedGroupMutations( + &probe, + frostRetainedGroupCanonicalMutations(history.Mutations), + ); err != nil { + return fmt.Errorf("complete FROST retained-group history is semantically invalid: [%w]", err) + } + quarantineProbe := frostRetainedGroupQuarantineJournalState{ + Schema: frostRetainedGroupQuarantineStateSchema, + CurrentPoint: history.From, + Root: sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)), + Quarantines: []frostRetainedGroupQuarantineState{}, + } + if err := applyFrostRetainedGroupQuarantineMutations( + &quarantineProbe, + frostRetainedGroupQuarantineMutations(history.Mutations), + ); err != nil { + return fmt.Errorf("complete FROST quarantine history is semantically invalid: [%w]", err) + } + return nil +} + +func cloneFrostRetainedGroupState( + state frostRetainedGroupJournalState, +) frostRetainedGroupJournalState { + result := state + result.Wallets = append([]frostRetainedGroupWalletState{}, state.Wallets...) + for index := range result.Wallets { + result.Wallets[index].OperatorIDs = append([]uint32{}, state.Wallets[index].OperatorIDs...) + } + return result +} + +func cloneFrostRetainedGroupQuarantineState( + state frostRetainedGroupQuarantineJournalState, +) frostRetainedGroupQuarantineJournalState { + result := state + result.Quarantines = append( + []frostRetainedGroupQuarantineState{}, + state.Quarantines..., + ) + return result +} + +type frostLocalSessionSnapshot struct { + WalletID [32]byte + WalletPublicKeyHash [20]byte + OperatorAddresses []chain.Address + ControlledSeats []group.MemberIndex +} + +func (wr *walletRegistry) frostLocalSessionSnapshot() ( + []frostLocalSessionSnapshot, + error, +) { + if wr == nil { + return nil, fmt.Errorf("wallet registry is nil") + } + wr.mutex.Lock() + defer wr.mutex.Unlock() + result := make([]frostLocalSessionSnapshot, 0) + for _, value := range wr.walletCache { + if value == nil || len(value.signers) == 0 { + return nil, fmt.Errorf("wallet registry contains an empty session") + } + nativeSigners := make([]*signer, 0) + for _, signer := range value.signers { + if signer == nil { + return nil, fmt.Errorf("wallet registry contains a nil signer") + } + switch signer.signingMaterial().(type) { + case *frostsigning.NativeSignerMaterial, frostsigning.NativeSignerMaterial: + nativeSigners = append(nativeSigners, signer) + } + } + if len(nativeSigners) == 0 { + continue + } + if len(nativeSigners) != len(value.signers) { + return nil, fmt.Errorf("wallet registry mixes FROST and legacy signer material") + } + wallet := nativeSigners[0].wallet + session := frostLocalSessionSnapshot{ + WalletID: value.walletID, + WalletPublicKeyHash: value.walletPublicKeyHash, + OperatorAddresses: append([]chain.Address{}, wallet.signingGroupOperators...), + ControlledSeats: make([]group.MemberIndex, 0, len(nativeSigners)), + } + seenSeats := make(map[group.MemberIndex]struct{}) + for _, signer := range nativeSigners { + if signer.wallet.publicKey == nil || + len(signer.wallet.signingGroupOperators) != len(session.OperatorAddresses) || + signer.signingGroupMemberIndex == 0 || + int(signer.signingGroupMemberIndex) > len(session.OperatorAddresses) { + return nil, fmt.Errorf("FROST local session signer is malformed") + } + for index := range session.OperatorAddresses { + if signer.wallet.signingGroupOperators[index] != session.OperatorAddresses[index] { + return nil, fmt.Errorf("FROST local session operators disagree") + } + } + if _, exists := seenSeats[signer.signingGroupMemberIndex]; exists { + return nil, fmt.Errorf("FROST local session repeats a controlled seat") + } + seenSeats[signer.signingGroupMemberIndex] = struct{}{} + session.ControlledSeats = append(session.ControlledSeats, signer.signingGroupMemberIndex) + } + sort.Slice(session.ControlledSeats, func(i, j int) bool { + return session.ControlledSeats[i] < session.ControlledSeats[j] + }) + result = append(result, session) + } + sort.Slice(result, func(i, j int) bool { + return bytes.Compare(result[i].WalletID[:], result[j].WalletID[:]) < 0 + }) + return result, nil +} + +func (frgj *frostRetainedGroupJournal) reconcileLocalSessions( + ctx context.Context, + point FrostPreSignFinality, +) (uint64, error) { + localOperatorID, err := frgj.source.ResolveOperatorID(ctx, frgj.operatorAddress, point) + if err != nil || localOperatorID == 0 { + return 0, fmt.Errorf("cannot resolve local FROST operator ID at challenged point: [%w]", err) + } + sessions, err := frgj.walletRegistry.frostLocalSessionSnapshot() + if err != nil { + return 0, err + } + sessionsByWallet := make(map[[32]byte]frostLocalSessionSnapshot, len(sessions)) + for _, session := range sessions { + if _, exists := sessionsByWallet[session.WalletID]; exists { + return 0, fmt.Errorf("duplicate FROST local session wallet ID") + } + sessionsByWallet[session.WalletID] = session + } + operatorIDCache := make(map[chain.Address]chain.OperatorID) + for _, wallet := range frgj.state.Wallets { + session, hasSession := sessionsByWallet[wallet.WalletID] + containsLocalOperator := false + for _, operatorID := range wallet.OperatorIDs { + if chain.OperatorID(operatorID) == localOperatorID { + containsLocalOperator = true + break + } + } + if wallet.Lifecycle.terminal() { + if hasSession { + return 0, fmt.Errorf("terminal FROST retained group still has a local session") + } + continue + } + if containsLocalOperator != hasSession { + return 0, fmt.Errorf("FROST local-session presence differs from retained-group membership") + } + if !hasSession { + continue + } + if session.WalletPublicKeyHash != wallet.WalletPublicKeyHash || + len(session.OperatorAddresses) != len(wallet.OperatorIDs) { + return 0, fmt.Errorf("FROST local session identity or group size differs from canonical DKG") + } + resolvedIDs := make([]chain.OperatorID, len(session.OperatorAddresses)) + for index, address := range session.OperatorAddresses { + operatorID, exists := operatorIDCache[address] + if !exists { + operatorID, err = frgj.source.ResolveOperatorID(ctx, address, point) + if err != nil || operatorID == 0 { + return 0, fmt.Errorf("cannot resolve FROST DKG operator at challenged point: [%w]", err) + } + operatorIDCache[address] = operatorID + } + resolvedIDs[index] = operatorID + if uint32(operatorID) != wallet.OperatorIDs[index] { + return 0, fmt.Errorf("FROST local session operator ordering differs from canonical DKG") + } + } + expectedSeats := make([]group.MemberIndex, 0) + for index, operatorID := range resolvedIDs { + if operatorID == localOperatorID { + expectedSeats = append(expectedSeats, group.MemberIndex(index+1)) + } + } + if len(expectedSeats) != len(session.ControlledSeats) { + return 0, fmt.Errorf("FROST local controlled-seat count differs from canonical DKG") + } + for index := range expectedSeats { + if expectedSeats[index] != session.ControlledSeats[index] { + return 0, fmt.Errorf("FROST local controlled seats differ from canonical DKG") + } + } + delete(sessionsByWallet, wallet.WalletID) + } + if len(sessionsByWallet) != 0 { + return 0, fmt.Errorf("FROST local session has no canonical retained group") + } + return uint64(len(sessions)), nil +} diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go new file mode 100644 index 0000000000..68acb6ea96 --- /dev/null +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -0,0 +1,773 @@ +package tbtc + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/chain" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type journalHistorySource struct { + identity FrostRetainedGroupHistoryIdentity + checkpoint FrostPreSignFinality + head FrostPreSignFinality + descriptor [32]byte + mutations []FrostRetainedGroupMutation + complete bool + emptyAtFrom bool + points map[uint64][32]byte + operators map[chain.Address]chain.OperatorID + verifyErr error +} + +func (jhs *journalHistorySource) Identity( + context.Context, +) (FrostRetainedGroupHistoryIdentity, error) { + return jhs.identity, nil +} + +func (jhs *journalHistorySource) FinalizedHead( + context.Context, +) (FrostPreSignFinality, error) { + return jhs.head, nil +} + +func (jhs *journalHistorySource) VerifyPoint( + _ context.Context, + point FrostPreSignFinality, +) error { + if jhs.verifyErr != nil { + return jhs.verifyErr + } + if expected, ok := jhs.points[point.BlockNumber]; !ok || expected != point.BlockHash { + return fmt.Errorf("point is not canonical") + } + return nil +} + +func (jhs *journalHistorySource) ReadCompleteHistory( + _ context.Context, + from FrostPreSignFinality, + to FrostPreSignFinality, +) (*FrostRetainedGroupHistory, error) { + mutations := make([]FrostRetainedGroupMutation, 0) + for _, mutation := range jhs.mutations { + if mutation.Point.BlockNumber <= to.BlockNumber { + mutations = append(mutations, mutation) + } + } + return &FrostRetainedGroupHistory{ + From: from, + To: to, + Mutations: cloneFrostRetainedGroupMutations(mutations), + Complete: jhs.complete, + EmptyAtFrom: jhs.emptyAtFrom, + DescriptorSetHash: jhs.descriptor, + }, nil +} + +func (jhs *journalHistorySource) ResolveOperatorID( + _ context.Context, + address chain.Address, + _ FrostPreSignFinality, +) (chain.OperatorID, error) { + operatorID := jhs.operators[address] + if operatorID == 0 { + return 0, fmt.Errorf("unknown operator") + } + return operatorID, nil +} + +type journalTestFixture struct { + manifest FrostRetainedGroupCanonicalJournalManifest + quarantine FrostRetainedGroupQuarantineJournalManifest + manifestHash [32]byte + checkpoint FrostPreSignFinality + target FrostPreSignFinality + later FrostPreSignFinality + walletID [32]byte + walletPKH [20]byte + operatorIDs []uint32 + operatorAddrs []chain.Address + localOperator chain.Address + registry *walletRegistry + source *journalHistorySource + admission FrostRetainedGroupMutation +} + +func newJournalTestFixture(t *testing.T) *journalTestFixture { + t.Helper() + checkpoint := FrostPreSignFinality{BlockNumber: 1, BlockHash: [32]byte{0x01}} + target := FrostPreSignFinality{BlockNumber: 10, BlockHash: [32]byte{0x0a}} + later := FrostPreSignFinality{BlockNumber: 20, BlockHash: [32]byte{0x14}} + operatorIDs := make([]uint32, 51) + operatorAddrs := make([]chain.Address, 51) + operators := make(map[chain.Address]chain.OperatorID, 51) + for index := range operatorIDs { + operatorIDs[index] = uint32(index + 1) + operatorAddrs[index] = chain.Address(fmt.Sprintf("operator-%02d", index+1)) + operators[operatorAddrs[index]] = chain.OperatorID(index + 1) + } + walletID := [32]byte{0x91} + walletPKH := [20]byte{0x92} + localOperator := operatorAddrs[6] + publicKey := &ecdsa.PublicKey{Curve: elliptic.P256()} + localSigner := &signer{ + wallet: wallet{ + publicKey: publicKey, + signingGroupOperators: append([]chain.Address{}, operatorAddrs...), + }, + signingGroupMemberIndex: group.MemberIndex(7), + signerMaterial: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, + Payload: []byte{0x01}, + }, + } + registry := &walletRegistry{ + walletCache: map[string]*walletCacheValue{ + "wallet": { + walletPublicKeyHash: walletPKH, + walletID: walletID, + signers: []*signer{localSigner}, + }, + }, + } + manifest := FrostRetainedGroupCanonicalJournalManifest{ + StoreID: "journal-store-uuid", + StoreFingerprint: [32]byte{0x31}, + ClusterFingerprint: [32]byte{0x32}, + Checkpoint: checkpoint, + DescriptorSetHash: [32]byte{0x33}, + SourceTrustDomainID: "independent-journal-source", + SourceEndpointFingerprint: [32]byte{0x34}, + SourceOperatorFingerprint: [32]byte{0x35}, + MinimumGeneration: 1, + } + quarantine := FrostRetainedGroupQuarantineJournalManifest{ + ProtocolID: [32]byte{0x41}, + StoreID: "quarantine-store-uuid", + StoreFingerprint: [32]byte{0x42}, + ClusterFingerprint: [32]byte{0x43}, + } + source := &journalHistorySource{ + identity: FrostRetainedGroupHistoryIdentity{ + TrustDomainID: manifest.SourceTrustDomainID, + EndpointFingerprint: manifest.SourceEndpointFingerprint, + OperatorFingerprint: manifest.SourceOperatorFingerprint, + }, + checkpoint: checkpoint, + head: FrostPreSignFinality{BlockNumber: 100, BlockHash: [32]byte{0x64}}, + descriptor: manifest.DescriptorSetHash, + complete: true, + emptyAtFrom: true, + points: map[uint64][32]byte{ + 1: checkpoint.BlockHash, + 10: target.BlockHash, + 20: later.BlockHash, + 100: {0x64}, + }, + operators: operators, + } + admission := FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 2, + BlockHash: [32]byte{0x02}, + TransactionHash: [32]byte{0xa2}, + TransactionIndex: 1, + LogIndex: 5, + }, + Kind: FrostRetainedGroupAdmissionMutation, + WalletID: walletID, + WalletPublicKeyHash: walletPKH, + OperatorIDs: append([]uint32{}, operatorIDs...), + RetainedGroupHash: [32]byte{0x93}, + CreationPoint: FrostRetainedGroupEventPoint{BlockNumber: 2, BlockHash: [32]byte{0x02}, TransactionHash: [32]byte{0xa2}, TransactionIndex: 1, LogIndex: 4}, + BridgeRegistrationPoint: FrostRetainedGroupEventPoint{BlockNumber: 2, BlockHash: [32]byte{0x02}, TransactionHash: [32]byte{0xa2}, TransactionIndex: 1, LogIndex: 5}, + } + source.mutations = []FrostRetainedGroupMutation{admission} + return &journalTestFixture{ + manifest: manifest, + quarantine: quarantine, + manifestHash: [32]byte{0x42}, + checkpoint: checkpoint, + target: target, + later: later, + walletID: walletID, + walletPKH: walletPKH, + operatorIDs: operatorIDs, + operatorAddrs: operatorAddrs, + localOperator: localOperator, + registry: registry, + source: source, + admission: admission, + } +} + +func (fixture *journalTestFixture) openJournal( + t *testing.T, + directory string, +) *frostRetainedGroupJournal { + t.Helper() + journal, err := newFrostRetainedGroupJournal( + directory, + fixture.manifestHash, + fixture.manifest, + fixture.quarantine, + fixture.source, + fixture.registry, + fixture.localOperator, + ) + if err != nil { + t.Fatal(err) + } + return journal +} + +func TestFrostRetainedGroupJournal_ReconcilesAndRejectsRewrittenHistory( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + defer journal.close() + snapshot, err := journal.reconcile(context.Background(), fixture.target) + if err != nil { + t.Fatal(err) + } + if !snapshot.Complete || snapshot.SnapshotGeneration != 1 || + snapshot.WalletCount != 1 || snapshot.LocalSessionCount != 1 || + snapshot.QuarantineCount != 0 || snapshot.InventoryRoot == [32]byte{} { + t.Fatalf("unexpected journal snapshot: %+v", snapshot) + } + + fixture.source.mutations[0].OperatorIDs[0] = 51 + if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || + !strings.Contains(err.Error(), "rewrote, omitted, or reordered") { + t.Fatalf("expected canonical prefix rewrite failure, got [%v]", err) + } + fixture.source.mutations[0] = fixture.admission + fixture.source.complete = false + if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || + !strings.Contains(err.Error(), "incomplete") { + t.Fatalf("expected incomplete-history failure, got [%v]", err) + } + fixture.source.complete = true + fixture.source.emptyAtFrom = false + if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || + !strings.Contains(err.Error(), "incomplete") { + t.Fatalf("expected nonempty-checkpoint failure, got [%v]", err) + } +} + +func TestFrostRetainedGroupJournal_IntegratesCommittedOrphanBatchExactlyOnce( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + journal.persistFailureHook = func(stage string) error { + if stage != "after-batch-before-state" { + t.Fatalf("unexpected failure stage [%s]", stage) + } + return fmt.Errorf("simulated crash") + } + if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || + !strings.Contains(err.Error(), "simulated crash") { + t.Fatalf("expected simulated crash, got [%v]", err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + snapshot, err := restarted.reconcile(context.Background(), fixture.target) + if err != nil { + t.Fatal(err) + } + if snapshot.SnapshotGeneration != 1 || restarted.state.BatchSequence != 1 || + len(restarted.mutations) != 1 { + t.Fatalf("orphan batch was not integrated exactly once: %+v", restarted.state) + } +} + +func TestFrostRetainedGroupJournal_QuarantineAndAuthenticatedLiftAreIndependent( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + quarantine := FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, + TransactionHash: [32]byte{0xa5}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupRecoveryRequiredMutation, + WalletID: fixture.walletID, + QuarantineID: [32]byte{0x51}, + EvidenceHash: [32]byte{0x52}, + Reason: "manual recovery is required", + } + fixture.source.mutations = append(fixture.source.mutations, quarantine) + journal := fixture.openJournal(t, filepath.Join(t.TempDir(), "journal")) + defer journal.close() + first, err := journal.reconcile(context.Background(), fixture.target) + if err != nil { + t.Fatal(err) + } + if first.SnapshotGeneration != 1 || first.QuarantineGeneration != 1 || + first.QuarantineCount != 1 { + t.Fatalf("unexpected quarantined snapshot: %+v", first) + } + if journal.directory == journal.quarantineDirectory { + t.Fatal("canonical and quarantine journals share a physical store") + } + if len(journal.mutations) != 1 || + len(journal.quarantineMutations) != 1 || + isFrostRetainedGroupQuarantineMutation(journal.mutations[0].Kind) || + !isFrostRetainedGroupQuarantineMutation(journal.quarantineMutations[0].Kind) { + t.Fatal("canonical and quarantine mutations were not durably partitioned") + } + for _, metadataPath := range []string{ + filepath.Join(journal.directory, frostRetainedGroupJournalMetadataFile), + filepath.Join(journal.quarantineDirectory, frostRetainedGroupJournalMetadataFile), + } { + if info, err := os.Lstat(metadataPath); err != nil || + !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { + t.Fatalf("independent journal metadata is not durable and private: [%s] [%v]", metadataPath, err) + } + } + lift := FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + }, + Kind: FrostRetainedGroupQuarantineLiftMutation, + WalletID: fixture.walletID, + QuarantineID: quarantine.QuarantineID, + AuthenticationHash: [32]byte{0x53}, + } + fixture.source.mutations = append(fixture.source.mutations, lift) + second, err := journal.reconcile(context.Background(), fixture.later) + if err != nil { + t.Fatal(err) + } + if second.SnapshotGeneration != 1 || second.QuarantineGeneration != 2 || + second.QuarantineCount != 0 || second.QuarantineRoot == first.QuarantineRoot { + t.Fatalf("unexpected lifted snapshot: %+v", second) + } +} + +func TestFrostRetainedGroupJournal_IntegratesQuarantineOrphanBatchExactlyOnce( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + fixture.source.mutations = append( + fixture.source.mutations, + FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, + TransactionHash: [32]byte{0xa5}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupQuarantineMutation, + WalletID: fixture.walletID, + QuarantineID: [32]byte{0x61}, + EvidenceHash: [32]byte{0x62}, + Reason: "independent quarantine crash test", + }, + ) + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + journal.persistFailureHook = func(stage string) error { + switch stage { + case "after-batch-before-state": + return nil + case "after-quarantine-batch-before-state": + return fmt.Errorf("simulated quarantine crash") + default: + t.Fatalf("unexpected failure stage [%s]", stage) + return nil + } + } + if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || + !strings.Contains(err.Error(), "simulated quarantine crash") { + t.Fatalf("expected simulated quarantine crash, got [%v]", err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + snapshot, err := restarted.reconcile(context.Background(), fixture.target) + if err != nil { + t.Fatal(err) + } + if snapshot.QuarantineGeneration != 1 || snapshot.QuarantineCount != 1 || + restarted.quarantineState.BatchSequence != 1 || + len(restarted.quarantineMutations) != 1 { + t.Fatalf("quarantine orphan batch was not integrated exactly once: %+v", restarted.quarantineState) + } +} + +func TestApplyFrostRetainedGroupMutations_EnforcesLifecycleAndRegistryClosure( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + state := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + CurrentPoint: fixture.checkpoint, + Wallets: []frostRetainedGroupWalletState{}, + } + closing := lifecycleMutation(fixture, 3, 1, FrostRetainedGroupClosingMutation, [32]byte{0xb3}) + closed := lifecycleMutation(fixture, 4, 1, FrostRetainedGroupClosedMutation, [32]byte{0xb4}) + registryClosed := lifecycleMutation(fixture, 4, 2, FrostRetainedGroupRegistryClosureMutation, [32]byte{0xb4}) + if err := applyFrostRetainedGroupMutations( + &state, + []FrostRetainedGroupMutation{fixture.admission, closing, closed, registryClosed}, + ); err != nil { + t.Fatal(err) + } + if state.SnapshotGeneration != 4 || !state.Wallets[0].RegistryClosed || + state.Wallets[0].Lifecycle != FrostRetainedGroupClosed { + t.Fatalf("unexpected lifecycle state: %+v", state) + } + + invalid := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + CurrentPoint: fixture.checkpoint, + Wallets: []frostRetainedGroupWalletState{}, + } + directClose := lifecycleMutation(fixture, 3, 1, FrostRetainedGroupClosedMutation, [32]byte{0xc3}) + if err := applyFrostRetainedGroupMutations( + &invalid, + []FrostRetainedGroupMutation{fixture.admission, directClose}, + ); err == nil { + t.Fatal("expected Live -> Closed transition to fail") + } +} + +func lifecycleMutation( + fixture *journalTestFixture, + block uint64, + logIndex uint32, + kind FrostRetainedGroupMutationKind, + transactionHash [32]byte, +) FrostRetainedGroupMutation { + return FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: block, + BlockHash: [32]byte{byte(block)}, + TransactionHash: transactionHash, + TransactionIndex: 1, + LogIndex: logIndex, + }, + Kind: kind, + WalletID: fixture.walletID, + WalletPublicKeyHash: fixture.walletPKH, + } +} + +func TestFrostRetainedGroupJournal_RejectsIdentityMismatchAndConcurrentOwner( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + defer journal.close() + if _, err := newFrostRetainedGroupJournal( + directory, + fixture.manifestHash, + fixture.manifest, + fixture.quarantine, + fixture.source, + fixture.registry, + fixture.localOperator, + ); err == nil || !strings.Contains(err.Error(), "already owned") { + t.Fatalf("expected exclusive-lock failure, got [%v]", err) + } + + otherDirectory := filepath.Join(t.TempDir(), "identity") + fixture.source.identity.EndpointFingerprint = [32]byte{0xff} + if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || + !strings.Contains(err.Error(), "identity differs") { + t.Fatalf("expected runtime identity mismatch, got [%v]", err) + } + if _, err := newFrostRetainedGroupJournal( + otherDirectory, + fixture.manifestHash, + fixture.manifest, + fixture.quarantine, + fixture.source, + fixture.registry, + fixture.localOperator, + ); err == nil || !strings.Contains(err.Error(), "identity differs") { + t.Fatalf("expected identity mismatch, got [%v]", err) + } +} + +func TestFrostRetainedGroupJournal_RejectsSymlinkEntry(t *testing.T) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(t.TempDir(), "target"), filepath.Join(directory, "evil")); err != nil { + t.Fatal(err) + } + if _, err := newFrostRetainedGroupJournal( + directory, + fixture.manifestHash, + fixture.manifest, + fixture.quarantine, + fixture.source, + fixture.registry, + fixture.localOperator, + ); err == nil || !strings.Contains(err.Error(), "unsafe entry") { + t.Fatalf("expected symlink rejection, got [%v]", err) + } +} + +func TestFrostRetainedGroupJournal_RejectsCorruptOrPublicStoreFiles(t *testing.T) { + t.Run("quarantine batch checksum", func(t *testing.T) { + fixture := newJournalTestFixture(t) + fixture.source.mutations = append( + fixture.source.mutations, + FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, + TransactionHash: [32]byte{0xa5}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupQuarantineMutation, + WalletID: fixture.walletID, + QuarantineID: [32]byte{0x71}, + EvidenceHash: [32]byte{0x72}, + Reason: "checksum test", + }, + ) + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + if _, err := journal.reconcile(context.Background(), fixture.target); err != nil { + t.Fatal(err) + } + quarantineBatchPath := filepath.Join( + journal.quarantineDirectory, + frostRetainedGroupBatchFileName(1), + ) + if err := journal.close(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(quarantineBatchPath) + if err != nil { + t.Fatal(err) + } + envelope := frostRetainedGroupEnvelope{} + if err := json.Unmarshal(data, &envelope); err != nil { + t.Fatal(err) + } + envelope.Checksum[0] ^= 0xff + data, err = json.Marshal(envelope) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(quarantineBatchPath, data, 0600); err != nil { + t.Fatal(err) + } + if _, err := newFrostRetainedGroupJournal( + directory, + fixture.manifestHash, + fixture.manifest, + fixture.quarantine, + fixture.source, + fixture.registry, + fixture.localOperator, + ); err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("expected quarantine checksum failure, got [%v]", err) + } + }) + + t.Run("canonical metadata permissions", func(t *testing.T) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + metadataPath := filepath.Join(journal.directory, frostRetainedGroupJournalMetadataFile) + if err := journal.close(); err != nil { + t.Fatal(err) + } + if err := os.Chmod(metadataPath, 0644); err != nil { + t.Fatal(err) + } + if _, err := newFrostRetainedGroupJournal( + directory, + fixture.manifestHash, + fixture.manifest, + fixture.quarantine, + fixture.source, + fixture.registry, + fixture.localOperator, + ); err == nil { + t.Fatal("expected public canonical metadata to be rejected") + } + }) +} + +func TestFrostRetainedGroupJournal_RejectsLocalSessionReconciliationDrift( + t *testing.T, +) { + t.Run("missing controlled group", func(t *testing.T) { + fixture := newJournalTestFixture(t) + fixture.registry.walletCache = make(map[string]*walletCacheValue) + journal := fixture.openJournal(t, filepath.Join(t.TempDir(), "journal")) + defer journal.close() + if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || + !strings.Contains(err.Error(), "presence differs") { + t.Fatalf("expected missing local-session failure, got [%v]", err) + } + }) + + t.Run("nonmember local group", func(t *testing.T) { + fixture := newJournalTestFixture(t) + fixture.source.mutations[0].OperatorIDs[6] = 52 + journal := fixture.openJournal(t, filepath.Join(t.TempDir(), "journal")) + defer journal.close() + if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || + !strings.Contains(err.Error(), "presence differs") { + t.Fatalf("expected nonmember local-session failure, got [%v]", err) + } + }) + + t.Run("terminal group retained locally", func(t *testing.T) { + fixture := newJournalTestFixture(t) + fixture.source.mutations = append( + fixture.source.mutations, + lifecycleMutation(fixture, 3, 1, FrostRetainedGroupClosingMutation, [32]byte{0xb3}), + lifecycleMutation(fixture, 4, 1, FrostRetainedGroupClosedMutation, [32]byte{0xb4}), + lifecycleMutation(fixture, 4, 2, FrostRetainedGroupRegistryClosureMutation, [32]byte{0xb4}), + ) + journal := fixture.openJournal(t, filepath.Join(t.TempDir(), "journal")) + defer journal.close() + if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || + !strings.Contains(err.Error(), "terminal FROST retained group") { + t.Fatalf("expected terminal local-session failure, got [%v]", err) + } + }) + + t.Run("operator ordering mismatch", func(t *testing.T) { + fixture := newJournalTestFixture(t) + signer := fixture.registry.walletCache["wallet"].signers[0] + signer.wallet.signingGroupOperators[0], signer.wallet.signingGroupOperators[1] = + signer.wallet.signingGroupOperators[1], signer.wallet.signingGroupOperators[0] + journal := fixture.openJournal(t, filepath.Join(t.TempDir(), "journal")) + defer journal.close() + if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || + !strings.Contains(err.Error(), "operator ordering differs") { + t.Fatalf("expected operator-ordering failure, got [%v]", err) + } + }) +} + +func TestFrostRetainedGroupInventoryRoot_IsOrderIndependent(t *testing.T) { + state := frostRetainedGroupJournalState{ + CurrentPoint: FrostPreSignFinality{ + BlockNumber: 120, + BlockHash: repeatedJournalTestBytes32(0x12), + }, + SnapshotGeneration: 9, + Wallets: []frostRetainedGroupWalletState{ + journalTestInventoryWallet(0x03, 100, FrostRetainedGroupClosed), + journalTestInventoryWallet(0x01, 51, FrostRetainedGroupLive), + journalTestInventoryWallet(0x02, 73, FrostRetainedGroupClosing), + }, + } + root, count, minimum, maximum, err := frostRetainedGroupInventoryRoot(state) + if err != nil { + t.Fatal(err) + } + state.Wallets[0], state.Wallets[2] = state.Wallets[2], state.Wallets[0] + reordered, _, _, _, err := frostRetainedGroupInventoryRoot(state) + if err != nil { + t.Fatal(err) + } + // Fixed vector produced by computeP2TRFrostWalletGroupInventory at runtime + // commit cb39161d6 for the same three entries and snapshot point. + expected := [32]byte{ + 0x9d, 0xd9, 0xca, 0x84, 0xc5, 0x20, 0x8c, 0x6b, + 0x73, 0x62, 0xe3, 0x72, 0xb7, 0x94, 0xfc, 0x93, + 0xbc, 0x88, 0xc1, 0x61, 0x8e, 0xa1, 0x0f, 0x74, + 0xe4, 0x13, 0x51, 0xeb, 0x9a, 0x54, 0xfb, 0xe3, + } + if root != expected || root != reordered || count != 3 || minimum != 51 || maximum != 100 { + t.Fatalf("unexpected inventory commitment [%x]", root) + } +} + +func repeatedJournalTestBytes32(value byte) [32]byte { + result := [32]byte{} + for index := range result { + result[index] = value + } + return result +} + +func journalTestInventoryWallet( + walletByte byte, + groupSize int, + lifecycle FrostRetainedGroupLifecycle, +) frostRetainedGroupWalletState { + retainedGroupHash := repeatedJournalTestBytes32(0xab) + retainedGroupHash[len(retainedGroupHash)-1] = walletByte + creation := FrostRetainedGroupEventPoint{ + BlockNumber: 20, + BlockHash: repeatedJournalTestBytes32(0x20), + TransactionHash: repeatedJournalTestBytes32(0x21), + TransactionIndex: 1, + LogIndex: 4, + } + registration := creation + registration.LogIndex = 5 + lifecyclePoint := registration + if lifecycle != FrostRetainedGroupLive { + lifecyclePoint = FrostRetainedGroupEventPoint{ + BlockNumber: 80, + BlockHash: repeatedJournalTestBytes32(0x80), + TransactionHash: repeatedJournalTestBytes32(0x81), + TransactionIndex: 1, + LogIndex: 2, + } + } + wallet := frostRetainedGroupWalletState{ + WalletID: repeatedJournalTestBytes32(walletByte), + OperatorIDs: make([]uint32, groupSize), + RetainedGroupHash: retainedGroupHash, + Lifecycle: lifecycle, + CreationPoint: creation, + BridgeRegistrationPoint: registration, + LifecyclePoint: lifecyclePoint, + LastBridgePoint: lifecyclePoint, + } + if lifecycle.terminal() { + wallet.RegistryClosed = true + wallet.RegistryClosurePoint = lifecyclePoint + wallet.RegistryClosurePoint.LogIndex = 3 + } + return wallet +} diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index b02fc3195b..ae100afcfb 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -68,6 +68,7 @@ type node struct { frostPreSignAuthorizationBackend FrostPreSignAuthorizationBackend frostPreSignActivationProfile FrostPreSignActivationProfile bitcoinBroadcastOutbox *bitcoinBroadcastOutbox + frostRetainedGroupJournal *frostRetainedGroupJournal frostActivationHandshakeExporter *frostActivationHandshakeExporter // walletDispatcher ensures only one action is executed by a wallet at @@ -331,15 +332,36 @@ func newNode( _ = outbox.close() return nil, fmt.Errorf("cannot read authenticated FROST runtime manifest: [%w]", err) } + if config.FrostRetainedGroupHistorySource == nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot enable FROST activation handshake without an independent retained-group history source", + ) + } + journal, err := newFrostRetainedGroupJournal( + config.FrostRetainedGroupJournalDirectory, + runtimeManifest.ManifestHash, + runtimeManifest.CanonicalJournal, + runtimeManifest.QuarantineJournal, + config.FrostRetainedGroupHistorySource, + walletRegistry, + operatorAddress, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf("cannot initialize canonical FROST retained-group journal: [%w]", err) + } + node.frostRetainedGroupJournal = journal exporter, err := newFrostActivationHandshakeExporter( config.FrostActivationHandshakeURL, config.FrostActivationHandshakePrivateKeyPath, - config.FrostActivationReadinessSnapshotPath, runtimeManifest, pointVerifier, outbox, + journal, ) if err != nil { + _ = journal.close() _ = outbox.close() return nil, fmt.Errorf("cannot initialize FROST activation handshake: [%w]", err) } diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index d302c0e324..58bff4c6d6 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -158,9 +158,14 @@ type Config struct { // FrostActivationHandshakePrivateKeyPath contains one owner-only PKCS#8 // Ed25519 private key whose SPKI hash is pinned by the signed manifest. FrostActivationHandshakePrivateKeyPath string - // FrostActivationReadinessSnapshotPath contains the exact independently - // reconciled retained FROST group inventory and durable signer-store identity. - FrostActivationReadinessSnapshotPath string + // FrostRetainedGroupJournalDirectory is the exclusively owned durable root + // whose distinct canonical lifecycle/DKG and quarantine sub-journals feed + // the activation handshake. + FrostRetainedGroupJournalDirectory string + // FrostRetainedGroupHistorySource is an independently authenticated, + // receipt-complete source. It must not share the primary Ethereum adapter's + // trust domain, endpoint, operator, or history store. + FrostRetainedGroupHistorySource FrostRetainedGroupHistorySource } // Initialize kicks off the TBTC by initializing internal state, ensuring @@ -267,6 +272,7 @@ func Initialize( if node.bitcoinBroadcastOutbox != nil { if err := node.bitcoinBroadcastOutbox.start(ctx); err != nil { + _ = node.frostRetainedGroupJournal.close() _ = node.bitcoinBroadcastOutbox.close() return fmt.Errorf( "cannot replay durable Bitcoin broadcast outbox before coordination: [%w]", @@ -274,12 +280,19 @@ func Initialize( ) } if err := node.frostActivationHandshakeExporter.start(ctx); err != nil { + _ = node.frostRetainedGroupJournal.close() _ = node.bitcoinBroadcastOutbox.close() return fmt.Errorf( "cannot start FROST activation handshake exporter: [%w]", err, ) } + go func() { + <-ctx.Done() + _ = node.frostActivationHandshakeExporter.close() + _ = node.frostRetainedGroupJournal.close() + _ = node.bitcoinBroadcastOutbox.close() + }() } err = node.runCoordinationLayer(ctx) @@ -287,6 +300,9 @@ func Initialize( if node.frostActivationHandshakeExporter != nil { _ = node.frostActivationHandshakeExporter.close() } + if node.frostRetainedGroupJournal != nil { + _ = node.frostRetainedGroupJournal.close() + } if node.bitcoinBroadcastOutbox != nil { _ = node.bitcoinBroadcastOutbox.close() } From 2dfd96eeb3990d1e9b36d165bb8790ab68bc0eab Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 21 Jul 2026 18:14:48 -0400 Subject: [PATCH 03/93] Bind FROST activation to signer store identity --- ...e_tbtc_signer_registration_frost_native.go | 44 ++++ .../native_tbtc_signer_store_identity.go | 213 ++++++++++++++++++ ...tive_tbtc_signer_store_identity_default.go | 18 ++ ...tbtc_signer_store_identity_default_test.go | 17 ++ .../native_tbtc_signer_store_identity_test.go | 133 +++++++++++ pkg/tbtc/frost_activation_handshake.go | 20 +- pkg/tbtc/frost_activation_handshake_test.go | 4 +- pkg/tbtc/frost_durable_session_store.go | 103 +++++++++ pkg/tbtc/frost_durable_session_store_test.go | 132 +++++++++++ pkg/tbtc/frost_pre_sign_authorization.go | 12 + pkg/tbtc/frost_pre_sign_authorization_test.go | 2 + pkg/tbtc/node.go | 21 +- 12 files changed, 713 insertions(+), 6 deletions(-) create mode 100644 pkg/frost/signing/native_tbtc_signer_store_identity.go create mode 100644 pkg/frost/signing/native_tbtc_signer_store_identity_default.go create mode 100644 pkg/frost/signing/native_tbtc_signer_store_identity_default_test.go create mode 100644 pkg/frost/signing/native_tbtc_signer_store_identity_test.go create mode 100644 pkg/tbtc/frost_durable_session_store.go create mode 100644 pkg/tbtc/frost_durable_session_store_test.go diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go index 5c4bfbc598..1f0e742d85 100644 --- a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go @@ -79,6 +79,7 @@ typedef TbtcSignerResult (*tbtc_init_signer_config_fn)( const uint8_t* request_ptr, size_t request_len ); +typedef TbtcSignerResult (*tbtc_durable_store_identity_fn)(void); typedef void (*tbtc_free_buffer_fn)(uint8_t* ptr, size_t len); static TbtcSignerResult unavailable_tbtc_signer_result(void) { @@ -282,6 +283,19 @@ static TbtcSignerResult tbtc_signer_init_signer_config(const uint8_t* request_pt return init_signer_config(request_ptr, request_len); } +static TbtcSignerResult tbtc_signer_durable_store_identity(void) { + tbtc_durable_store_identity_fn durable_store_identity = + (tbtc_durable_store_identity_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_durable_store_identity" + ); + if (durable_store_identity == NULL) { + return unavailable_tbtc_signer_result(); + } + + return durable_store_identity(); +} + static void tbtc_signer_free_buffer(uint8_t* ptr, size_t len) { tbtc_free_buffer_fn free_buffer = (tbtc_free_buffer_fn)dlsym( RTLD_DEFAULT, @@ -1850,6 +1864,36 @@ func InstallNativeTBTCSignerConfig( return result, nil } +// ReadNativeTBTCSignerDurableStoreIdentity asks the linked signer for the +// identity of the store it has actually opened and locked. A stale library +// without frost_tbtc_durable_store_identity fails closed; config JSON is not a +// substitute for this runtime readback. +func ReadNativeTBTCSignerDurableStoreIdentity() ( + *NativeTBTCSignerDurableStoreIdentity, + error, +) { + if err := ensureTBTCSignerABICompatible(); err != nil { + return nil, err + } + + responsePayload, err := parseBuildTaggedTBTCSignerResult( + "DurableStoreIdentity", + C.tbtc_signer_durable_store_identity(), + ) + if err != nil { + return nil, err + } + + identity, err := DecodeNativeTBTCSignerDurableStoreIdentity(responsePayload) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "DurableStoreIdentity", + err.Error(), + ) + } + return identity, nil +} + // ---------------------------------------------------------------------------- // Phase 7.3 interactive signing session bridge: open / round1 / round2 / abort. // diff --git a/pkg/frost/signing/native_tbtc_signer_store_identity.go b/pkg/frost/signing/native_tbtc_signer_store_identity.go new file mode 100644 index 0000000000..37f620f020 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_store_identity.go @@ -0,0 +1,213 @@ +package signing + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "regexp" + "strings" +) + +const ( + // NativeTBTCSignerDurableStoreIdentitySchema is the versioned readback + // contract implemented by libfrost_tbtc. The identity is about the store + // the signer actually opened and locked. It is deliberately not the + // fingerprint of the signer init configuration, which cannot prove where + // session replay markers and key packages are being persisted. + NativeTBTCSignerDurableStoreIdentitySchema = "tbtc-signer-durable-session-store-identity/v1" + + nativeTBTCSignerDurableStoreIdentityDomain = "tbtc-signer-durable-session-store-fingerprint-v1\x00" +) + +var nativeTBTCSignerStoreBackendPattern = regexp.MustCompile( + `^[a-z0-9][a-z0-9._-]{0,63}$`, +) + +// NativeTBTCSignerDurableStoreIdentity is a validated runtime identity for +// the durable store libfrost_tbtc is actively using. StoreID must be created +// by the signer and persist across process restarts. The path, filesystem, +// and lock fingerprints must be derived from no-follow handles held by the +// signer, not copied from configuration text. +// +// The state file itself is atomically replaced on every write, so its inode is +// not a stable store identity. The signer-side contract instead binds the +// persistent store ID, canonical opened path, storage root, and exclusive lock +// object. It must fail readback if a symlink or replacement makes those opened +// identities differ from the live path lookup. +type NativeTBTCSignerDurableStoreIdentity struct { + Schema string + Backend string + StoreID [32]byte + CanonicalPathFingerprint [32]byte + FilesystemFingerprint [32]byte + LockFingerprint [32]byte + Fingerprint [32]byte +} + +type nativeTBTCSignerDurableStoreIdentityWire struct { + Schema string `json:"schema"` + Backend string `json:"backend"` + StoreID string `json:"store_id"` + CanonicalPathFingerprint string `json:"canonical_path_fingerprint"` + FilesystemFingerprint string `json:"filesystem_fingerprint"` + LockFingerprint string `json:"lock_fingerprint"` + Fingerprint string `json:"fingerprint"` + Durable *bool `json:"durable"` + ExclusiveLockHeld *bool `json:"exclusive_lock_held"` + SymlinkFree *bool `json:"symlink_free"` + ReplacementProtected *bool `json:"replacement_protected"` +} + +// DecodeNativeTBTCSignerDurableStoreIdentity validates a libfrost_tbtc +// readback. The four affirmative safety claims are mandatory rather than +// advisory: missing and false are both rejected. +func DecodeNativeTBTCSignerDurableStoreIdentity( + payload []byte, +) (*NativeTBTCSignerDurableStoreIdentity, error) { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + + wire := &nativeTBTCSignerDurableStoreIdentityWire{} + if err := decoder.Decode(wire); err != nil { + return nil, fmt.Errorf("cannot decode durable signer store identity: %w", err) + } + if err := rejectTrailingJSON(decoder); err != nil { + return nil, err + } + + if wire.Schema != NativeTBTCSignerDurableStoreIdentitySchema { + return nil, fmt.Errorf("unsupported durable signer store identity schema") + } + if !nativeTBTCSignerStoreBackendPattern.MatchString(wire.Backend) { + return nil, fmt.Errorf("invalid durable signer store backend") + } + for label, value := range map[string]*bool{ + "durability": wire.Durable, + "exclusive lock": wire.ExclusiveLockHeld, + "symlink safety": wire.SymlinkFree, + "replacement protection": wire.ReplacementProtected, + } { + if value == nil || !*value { + return nil, fmt.Errorf("durable signer store %s is not proven", label) + } + } + + identity := &NativeTBTCSignerDurableStoreIdentity{ + Schema: wire.Schema, + Backend: wire.Backend, + } + values := []struct { + label string + encoded string + destination *[32]byte + }{ + {"store ID", wire.StoreID, &identity.StoreID}, + {"canonical path fingerprint", wire.CanonicalPathFingerprint, &identity.CanonicalPathFingerprint}, + {"filesystem fingerprint", wire.FilesystemFingerprint, &identity.FilesystemFingerprint}, + {"lock fingerprint", wire.LockFingerprint, &identity.LockFingerprint}, + {"store fingerprint", wire.Fingerprint, &identity.Fingerprint}, + } + for _, value := range values { + decoded, err := decodeNativeTBTCSignerStoreBytes32(value.encoded) + if err != nil { + return nil, fmt.Errorf("invalid durable signer %s: %w", value.label, err) + } + *value.destination = decoded + } + + computed, err := ComputeNativeTBTCSignerDurableStoreFingerprint(identity) + if err != nil { + return nil, err + } + if computed != identity.Fingerprint { + return nil, fmt.Errorf( + "durable signer store fingerprint does not bind the reported runtime identity", + ) + } + + return identity, nil +} + +func rejectTrailingJSON(decoder *json.Decoder) error { + var trailing json.RawMessage + err := decoder.Decode(&trailing) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("cannot decode durable signer store identity: %w", err) + } + return fmt.Errorf("durable signer store identity contains trailing JSON") +} + +func decodeNativeTBTCSignerStoreBytes32(value string) ([32]byte, error) { + var result [32]byte + if value != strings.ToLower(value) || !strings.HasPrefix(value, "0x") || + len(value) != 66 { + return result, fmt.Errorf("expected canonical lowercase 0x-prefixed bytes32") + } + decoded, err := hex.DecodeString(value[2:]) + if err != nil || len(decoded) != len(result) { + return result, fmt.Errorf("expected canonical lowercase 0x-prefixed bytes32") + } + copy(result[:], decoded) + if result == [32]byte{} { + return result, fmt.Errorf("value is zero") + } + return result, nil +} + +// ComputeNativeTBTCSignerDurableStoreFingerprint derives the manifest-bound +// fingerprint from runtime store identity, not signer configuration content. +// All variable-width fields are length-prefixed to keep the transcript +// unambiguous. +func ComputeNativeTBTCSignerDurableStoreFingerprint( + identity *NativeTBTCSignerDurableStoreIdentity, +) ([32]byte, error) { + if identity == nil || + identity.Schema != NativeTBTCSignerDurableStoreIdentitySchema || + !nativeTBTCSignerStoreBackendPattern.MatchString(identity.Backend) || + identity.StoreID == [32]byte{} || + identity.CanonicalPathFingerprint == [32]byte{} || + identity.FilesystemFingerprint == [32]byte{} || + identity.LockFingerprint == [32]byte{} { + return [32]byte{}, fmt.Errorf("durable signer store identity is incomplete") + } + + digest := sha256.New() + digest.Write([]byte(nativeTBTCSignerDurableStoreIdentityDomain)) + writeNativeTBTCSignerStoreFingerprintField(digest, []byte(identity.Schema)) + writeNativeTBTCSignerStoreFingerprintField(digest, []byte(identity.Backend)) + writeNativeTBTCSignerStoreFingerprintField(digest, identity.StoreID[:]) + writeNativeTBTCSignerStoreFingerprintField( + digest, + identity.CanonicalPathFingerprint[:], + ) + writeNativeTBTCSignerStoreFingerprintField( + digest, + identity.FilesystemFingerprint[:], + ) + writeNativeTBTCSignerStoreFingerprintField(digest, identity.LockFingerprint[:]) + + var result [32]byte + copy(result[:], digest.Sum(nil)) + return result, nil +} + +type nativeTBTCSignerStoreFingerprintWriter interface { + Write([]byte) (int, error) +} + +func writeNativeTBTCSignerStoreFingerprintField( + destination nativeTBTCSignerStoreFingerprintWriter, + value []byte, +) { + var length [4]byte + binary.BigEndian.PutUint32(length[:], uint32(len(value))) + destination.Write(length[:]) + destination.Write(value) +} diff --git a/pkg/frost/signing/native_tbtc_signer_store_identity_default.go b/pkg/frost/signing/native_tbtc_signer_store_identity_default.go new file mode 100644 index 0000000000..df0b830942 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_store_identity_default.go @@ -0,0 +1,18 @@ +//go:build !(frost_native && frost_tbtc_signer && cgo) + +package signing + +import "fmt" + +// ReadNativeTBTCSignerDurableStoreIdentity is unavailable without the native +// tbtc-signer bridge. Production FROST activation treats this as fatal; it +// must never fall back to a configured path or config fingerprint. +func ReadNativeTBTCSignerDurableStoreIdentity() ( + *NativeTBTCSignerDurableStoreIdentity, + error, +) { + return nil, fmt.Errorf( + "%w: tbtc-signer bridge operation [DurableStoreIdentity] is unavailable in this build", + ErrNativeCryptographyUnavailable, + ) +} diff --git a/pkg/frost/signing/native_tbtc_signer_store_identity_default_test.go b/pkg/frost/signing/native_tbtc_signer_store_identity_default_test.go new file mode 100644 index 0000000000..60fead67b5 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_store_identity_default_test.go @@ -0,0 +1,17 @@ +//go:build !(frost_native && frost_tbtc_signer && cgo) + +package signing + +import ( + "errors" + "testing" +) + +func TestReadNativeTBTCSignerDurableStoreIdentityFailsClosedWithoutBridge( + t *testing.T, +) { + identity, err := ReadNativeTBTCSignerDurableStoreIdentity() + if identity != nil || !errors.Is(err, ErrNativeCryptographyUnavailable) { + t.Fatalf("expected unavailable identity readback, got [%+v] [%v]", identity, err) + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_store_identity_test.go b/pkg/frost/signing/native_tbtc_signer_store_identity_test.go new file mode 100644 index 0000000000..b363d3c9db --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_store_identity_test.go @@ -0,0 +1,133 @@ +package signing + +import ( + "encoding/hex" + "fmt" + "strings" + "testing" +) + +func TestDecodeNativeTBTCSignerDurableStoreIdentity(t *testing.T) { + identity := testNativeTBTCSignerDurableStoreIdentity(t) + payload := testNativeTBTCSignerDurableStoreIdentityPayload(identity, true, true) + + decoded, err := DecodeNativeTBTCSignerDurableStoreIdentity(payload) + if err != nil { + t.Fatalf("cannot decode valid durable store identity: [%v]", err) + } + if *decoded != *identity { + t.Fatalf("unexpected decoded identity\nexpected: [%+v]\nactual: [%+v]", identity, decoded) + } +} + +func TestDecodeNativeTBTCSignerDurableStoreIdentityRejectsUnboundIdentity( + t *testing.T, +) { + tests := map[string]func(*NativeTBTCSignerDurableStoreIdentity){ + "wrong backend": func(identity *NativeTBTCSignerDurableStoreIdentity) { + identity.Backend = "different-backend" + }, + "wrong canonical path": func(identity *NativeTBTCSignerDurableStoreIdentity) { + identity.CanonicalPathFingerprint[0] ^= 0xff + }, + "wrong filesystem": func(identity *NativeTBTCSignerDurableStoreIdentity) { + identity.FilesystemFingerprint[0] ^= 0xff + }, + "replaced lock": func(identity *NativeTBTCSignerDurableStoreIdentity) { + identity.LockFingerprint[0] ^= 0xff + }, + "wrong fingerprint": func(identity *NativeTBTCSignerDurableStoreIdentity) { + identity.Fingerprint[0] ^= 0xff + }, + } + + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + identity := testNativeTBTCSignerDurableStoreIdentity(t) + mutate(identity) + _, err := DecodeNativeTBTCSignerDurableStoreIdentity( + testNativeTBTCSignerDurableStoreIdentityPayload(identity, true, true), + ) + if err == nil || !strings.Contains(err.Error(), "does not bind") { + t.Fatalf("expected identity-binding failure, got [%v]", err) + } + }) + } +} + +func TestDecodeNativeTBTCSignerDurableStoreIdentityRejectsUnsafePathState( + t *testing.T, +) { + identity := testNativeTBTCSignerDurableStoreIdentity(t) + for name, payload := range map[string][]byte{ + "symlink": testNativeTBTCSignerDurableStoreIdentityPayload( + identity, + false, + true, + ), + "replacement": testNativeTBTCSignerDurableStoreIdentityPayload( + identity, + true, + false, + ), + } { + t.Run(name, func(t *testing.T) { + if _, err := DecodeNativeTBTCSignerDurableStoreIdentity(payload); err == nil { + t.Fatal("expected unsafe store path state to be rejected") + } + }) + } +} + +func testNativeTBTCSignerDurableStoreIdentity( + t *testing.T, +) *NativeTBTCSignerDurableStoreIdentity { + t.Helper() + identity := &NativeTBTCSignerDurableStoreIdentity{ + Schema: NativeTBTCSignerDurableStoreIdentitySchema, + Backend: "encrypted-file-v1", + StoreID: [32]byte{0x01}, + CanonicalPathFingerprint: [32]byte{0x02}, + FilesystemFingerprint: [32]byte{0x03}, + LockFingerprint: [32]byte{0x04}, + } + fingerprint, err := ComputeNativeTBTCSignerDurableStoreFingerprint(identity) + if err != nil { + t.Fatal(err) + } + identity.Fingerprint = fingerprint + return identity +} + +func testNativeTBTCSignerDurableStoreIdentityPayload( + identity *NativeTBTCSignerDurableStoreIdentity, + symlinkFree bool, + replacementProtected bool, +) []byte { + hex32 := func(value [32]byte) string { + return "0x" + hex.EncodeToString(value[:]) + } + return []byte(fmt.Sprintf(`{ + "schema":%q, + "backend":%q, + "store_id":%q, + "canonical_path_fingerprint":%q, + "filesystem_fingerprint":%q, + "lock_fingerprint":%q, + "fingerprint":%q, + "durable":true, + "exclusive_lock_held":true, + "symlink_free":%t, + "replacement_protected":%t + }`, + identity.Schema, + identity.Backend, + hex32(identity.StoreID), + hex32(identity.CanonicalPathFingerprint), + hex32(identity.FilesystemFingerprint), + hex32(identity.LockFingerprint), + hex32(identity.Fingerprint), + symlinkFree, + replacementProtected, + )) +} diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index fc2377fa14..08909e7552 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -127,6 +127,7 @@ type frostActivationHandshakeExporter struct { publicKeySPKI string manifest FrostPreSignActivationRuntimeManifest pointVerifier FrostPreSignActivationPointVerifier + storeBinding *frostDurableSessionStoreBinding outbox *bitcoinBroadcastOutbox journal *frostRetainedGroupJournal @@ -141,6 +142,7 @@ func newFrostActivationHandshakeExporter( privateKeyPath string, manifest FrostPreSignActivationRuntimeManifest, pointVerifier FrostPreSignActivationPointVerifier, + storeBinding *frostDurableSessionStoreBinding, outbox *bitcoinBroadcastOutbox, journal *frostRetainedGroupJournal, ) (*frostActivationHandshakeExporter, error) { @@ -161,9 +163,15 @@ func newFrostActivationHandshakeExporter( durableSessionStoreFingerprintErr != nil || durableSessionStoreFingerprint == [32]byte{} || durableSessionStoreFingerprint == manifest.CanonicalJournal.StoreFingerprint || durableSessionStoreFingerprint == manifest.QuarantineJournal.StoreFingerprint || - pointVerifier == nil || outbox == nil || journal == nil { + pointVerifier == nil || storeBinding == nil || outbox == nil || journal == nil { return nil, fmt.Errorf("FROST activation handshake dependencies are invalid") } + boundStoreFingerprint, err := storeBinding.verify() + if err != nil || boundStoreFingerprint != durableSessionStoreFingerprint { + return nil, fmt.Errorf( + "FROST activation handshake durable session store is not bound to the signed manifest", + ) + } privateKey, publicKeyDER, err := loadFrostActivationAttestationKey(privateKeyPath) if err != nil { return nil, err @@ -177,6 +185,7 @@ func newFrostActivationHandshakeExporter( publicKeySPKI: base64.StdEncoding.EncodeToString(publicKeyDER), manifest: manifest, pointVerifier: pointVerifier, + storeBinding: storeBinding, outbox: outbox, journal: journal, } @@ -401,12 +410,19 @@ func (fahe *frostActivationHandshakeExporter) attest( if err := fahe.pointVerifier.VerifyFrostPreSignActivationPoint(ctx, finality); err != nil { return nil, fmt.Errorf("FROST activation point changed during readiness reconciliation: [%w]", err) } + durableSessionStoreFingerprint, err := fahe.storeBinding.verify() + if err != nil { + return nil, fmt.Errorf( + "FROST durable session store changed during readiness reconciliation: [%w]", + err, + ) + } state := frostActivationHandshakeState{ ProtocolID: frostActivationHex32(fahe.manifest.SignerProtocolID), ReservationProtocolID: frostActivationHex32(fahe.manifest.ReservationProtocolID), BitcoinOutboxProtocolID: frostActivationHex32(fahe.manifest.BitcoinOutboxProtocolID), SigningPolicyHash: frostActivationHex32(fahe.manifest.SigningPolicyHash), - DurableSessionStoreFingerprint: fahe.manifest.DurableSessionStoreFingerprint, + DurableSessionStoreFingerprint: frostActivationHex32(durableSessionStoreFingerprint), CompleteRouterAddress: frostActivationHex20(fahe.manifest.CompleteRouterAddress), AuthorizationRegistryAddress: frostActivationHex20(fahe.manifest.AuthorizationRegistryAddress), Threshold: fahe.manifest.Threshold, diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index 9780ca6682..1caf6a72e7 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -128,6 +128,7 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { privateKeyPath, manifest, verifier, + testFrostDurableSessionStoreBinding(t), outbox, journal, ) @@ -269,6 +270,7 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { privateKeyPath, manifest, &testFrostActivationPointVerifier{}, + testFrostDurableSessionStoreBinding(t), outbox, journal, ) @@ -322,7 +324,7 @@ func testFrostActivationRuntimeManifest( ReservationProtocolID: [32]byte{0x12}, BitcoinOutboxProtocolID: [32]byte{0x13}, SigningPolicyHash: [32]byte{0x14}, - DurableSessionStoreFingerprint: frostActivationHex32([32]byte{0x09}), + DurableSessionStoreFingerprint: frostActivationHex32(testFrostDurableSessionStoreIdentity().Fingerprint), CompleteRouterAddress: [20]byte{0x15}, AuthorizationRegistryAddress: [20]byte{0x16}, AttestationSignerKeyHash: keyHash, diff --git a/pkg/tbtc/frost_durable_session_store.go b/pkg/tbtc/frost_durable_session_store.go new file mode 100644 index 0000000000..0dc94d6a22 --- /dev/null +++ b/pkg/tbtc/frost_durable_session_store.go @@ -0,0 +1,103 @@ +package tbtc + +import ( + "fmt" + "sync" + + "github.com/keep-network/keep-core/pkg/frost/signing" +) + +type frostDurableSessionStoreIdentityReader func() ( + *signing.NativeTBTCSignerDurableStoreIdentity, + error, +) + +// frostDurableSessionStoreBinding pins the store libfrost_tbtc actually has +// open to the fingerprint in the authenticated activation manifest. It is +// checked at startup and again at every authorization/readiness boundary, so +// path, backend, lock, or storage replacement cannot be hidden by echoing the +// manifest value. +type frostDurableSessionStoreBinding struct { + expectedFingerprint [32]byte + readIdentity frostDurableSessionStoreIdentityReader + + mutex sync.Mutex + baseline *signing.NativeTBTCSignerDurableStoreIdentity +} + +func newFrostDurableSessionStoreBinding( + expectedFingerprint string, + readIdentity frostDurableSessionStoreIdentityReader, +) (*frostDurableSessionStoreBinding, error) { + parsed, err := parseFrostActivationHex32(expectedFingerprint) + if err != nil || parsed == [32]byte{} { + return nil, fmt.Errorf( + "invalid FROST durable session store fingerprint in activation manifest", + ) + } + if readIdentity == nil { + return nil, fmt.Errorf("FROST durable session store identity reader is nil") + } + + binding := &frostDurableSessionStoreBinding{ + expectedFingerprint: parsed, + readIdentity: readIdentity, + } + if _, err := binding.verify(); err != nil { + return nil, err + } + return binding, nil +} + +func (fdssb *frostDurableSessionStoreBinding) verify() ( + [32]byte, + error, +) { + if fdssb == nil || fdssb.readIdentity == nil || + fdssb.expectedFingerprint == [32]byte{} { + return [32]byte{}, fmt.Errorf("FROST durable session store binding is unavailable") + } + + identity, err := fdssb.readIdentity() + if err != nil { + return [32]byte{}, fmt.Errorf( + "cannot read active FROST durable session store identity: %w", + err, + ) + } + if identity == nil { + return [32]byte{}, fmt.Errorf( + "active FROST durable session store identity is nil", + ) + } + readback := *identity + computed, err := signing.ComputeNativeTBTCSignerDurableStoreFingerprint(&readback) + if err != nil { + return [32]byte{}, fmt.Errorf( + "active FROST durable session store identity is invalid: %w", + err, + ) + } + if readback.Fingerprint != computed { + return [32]byte{}, fmt.Errorf( + "active FROST durable session store returned a self-inconsistent fingerprint", + ) + } + if computed != fdssb.expectedFingerprint { + return [32]byte{}, fmt.Errorf( + "active FROST durable session store differs from the signed activation manifest", + ) + } + + fdssb.mutex.Lock() + defer fdssb.mutex.Unlock() + if fdssb.baseline != nil && *fdssb.baseline != readback { + return [32]byte{}, fmt.Errorf( + "active FROST durable session store identity changed after startup", + ) + } + baseline := readback + fdssb.baseline = &baseline + + return computed, nil +} diff --git a/pkg/tbtc/frost_durable_session_store_test.go b/pkg/tbtc/frost_durable_session_store_test.go new file mode 100644 index 0000000000..7119d10b6b --- /dev/null +++ b/pkg/tbtc/frost_durable_session_store_test.go @@ -0,0 +1,132 @@ +package tbtc + +import ( + "fmt" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/signing" +) + +func TestFrostDurableSessionStoreBindingAcceptsStableRestartIdentity( + t *testing.T, +) { + identity := testFrostDurableSessionStoreIdentity() + reads := 0 + binding, err := newFrostDurableSessionStoreBinding( + frostActivationHex32(identity.Fingerprint), + func() (*signing.NativeTBTCSignerDurableStoreIdentity, error) { + reads++ + restartedReadback := *identity + return &restartedReadback, nil + }, + ) + if err != nil { + t.Fatalf("cannot bind stable signer store: [%v]", err) + } + if _, err := binding.verify(); err != nil { + t.Fatalf("stable signer-store identity changed across restart: [%v]", err) + } + if reads != 2 { + t.Fatalf("expected startup and restart readbacks, got [%d]", reads) + } +} + +func TestFrostDurableSessionStoreBindingRejectsRuntimeIdentityMismatch( + t *testing.T, +) { + original := testFrostDurableSessionStoreIdentity() + tests := map[string]func(*signing.NativeTBTCSignerDurableStoreIdentity){ + "wrong path": func(identity *signing.NativeTBTCSignerDurableStoreIdentity) { + identity.CanonicalPathFingerprint[0] ^= 0xff + }, + "wrong backend": func(identity *signing.NativeTBTCSignerDurableStoreIdentity) { + identity.Backend = "encrypted-database-v1" + }, + "replacement": func(identity *signing.NativeTBTCSignerDurableStoreIdentity) { + identity.FilesystemFingerprint[0] ^= 0xff + identity.LockFingerprint[0] ^= 0xff + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + actual := *original + mutate(&actual) + fingerprint, err := signing.ComputeNativeTBTCSignerDurableStoreFingerprint(&actual) + if err != nil { + t.Fatal(err) + } + actual.Fingerprint = fingerprint + + _, err = newFrostDurableSessionStoreBinding( + frostActivationHex32(original.Fingerprint), + func() (*signing.NativeTBTCSignerDurableStoreIdentity, error) { + return &actual, nil + }, + ) + if err == nil || !strings.Contains(err.Error(), "signed activation manifest") { + t.Fatalf("expected manifest binding failure, got [%v]", err) + } + }) + } +} + +func TestFrostDurableSessionStoreBindingRejectsReadbackFailureAndWrongFingerprint( + t *testing.T, +) { + identity := testFrostDurableSessionStoreIdentity() + _, err := newFrostDurableSessionStoreBinding( + frostActivationHex32(identity.Fingerprint), + func() (*signing.NativeTBTCSignerDurableStoreIdentity, error) { + return nil, fmt.Errorf("native identity symbol unavailable") + }, + ) + if err == nil || !strings.Contains(err.Error(), "native identity symbol unavailable") { + t.Fatalf("expected native readback failure, got [%v]", err) + } + + inconsistent := *identity + inconsistent.Fingerprint[0] ^= 0xff + _, err = newFrostDurableSessionStoreBinding( + frostActivationHex32(identity.Fingerprint), + func() (*signing.NativeTBTCSignerDurableStoreIdentity, error) { + return &inconsistent, nil + }, + ) + if err == nil || !strings.Contains(err.Error(), "self-inconsistent") { + t.Fatalf("expected self-inconsistent identity failure, got [%v]", err) + } +} + +func testFrostDurableSessionStoreIdentity() *signing.NativeTBTCSignerDurableStoreIdentity { + identity := &signing.NativeTBTCSignerDurableStoreIdentity{ + Schema: signing.NativeTBTCSignerDurableStoreIdentitySchema, + Backend: "encrypted-file-v1", + StoreID: [32]byte{0x31}, + CanonicalPathFingerprint: [32]byte{0x32}, + FilesystemFingerprint: [32]byte{0x33}, + LockFingerprint: [32]byte{0x34}, + } + fingerprint, err := signing.ComputeNativeTBTCSignerDurableStoreFingerprint(identity) + if err != nil { + panic(err) + } + identity.Fingerprint = fingerprint + return identity +} + +func testFrostDurableSessionStoreBinding(t *testing.T) *frostDurableSessionStoreBinding { + t.Helper() + identity := testFrostDurableSessionStoreIdentity() + binding, err := newFrostDurableSessionStoreBinding( + frostActivationHex32(identity.Fingerprint), + func() (*signing.NativeTBTCSignerDurableStoreIdentity, error) { + readback := *identity + return &readback, nil + }, + ) + if err != nil { + t.Fatal(err) + } + return binding +} diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index 51f39b3838..bacc6cb65a 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1349,6 +1349,7 @@ type frostPreSignAuthorizationGate interface { type thresholdFrostPreSignAuthorizationGate struct { backend FrostPreSignAuthorizationBackend activationProfile FrostPreSignActivationProfile + storeBinding *frostDurableSessionStoreBinding signing chain.Signing broadcastChannel net.BroadcastChannel membershipValidator *group.MembershipValidator @@ -1360,6 +1361,7 @@ type thresholdFrostPreSignAuthorizationGate struct { func newThresholdFrostPreSignAuthorizationGate( backend FrostPreSignAuthorizationBackend, activationProfile FrostPreSignActivationProfile, + storeBinding *frostDurableSessionStoreBinding, signing chain.Signing, broadcastChannel net.BroadcastChannel, membershipValidator *group.MembershipValidator, @@ -1372,6 +1374,9 @@ func newThresholdFrostPreSignAuthorizationGate( if err := activationProfile.validate(); err != nil { return nil, err } + if _, err := storeBinding.verify(); err != nil { + return nil, fmt.Errorf("FROST durable session store is not activation-ready: [%w]", err) + } if signing == nil { return nil, fmt.Errorf("FROST pre-sign Ethereum signer is nil") } @@ -1403,6 +1408,7 @@ func newThresholdFrostPreSignAuthorizationGate( return &thresholdFrostPreSignAuthorizationGate{ backend: backend, activationProfile: activationProfile, + storeBinding: storeBinding, signing: signing, broadcastChannel: broadcastChannel, membershipValidator: membershipValidator, @@ -1419,6 +1425,9 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) authorize( if ctx == nil { return nil, fmt.Errorf("FROST pre-sign authorization context is nil") } + if _, err := tfpsag.storeBinding.verify(); err != nil { + return nil, fmt.Errorf("FROST durable session store binding failed: [%w]", err) + } if err := transaction.validate(); err != nil { return nil, err } @@ -1505,6 +1514,9 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( ctx context.Context, authorization *frostPreSignAuthorization, ) error { + if _, err := tfpsag.storeBinding.verify(); err != nil { + return fmt.Errorf("FROST durable session store binding failed: [%w]", err) + } if authorization == nil || authorization.proposal == nil { return fmt.Errorf("FROST pre-sign authorization is nil") } diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go index 17ca5e4e54..f20b271f58 100644 --- a/pkg/tbtc/frost_pre_sign_authorization_test.go +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -215,6 +215,7 @@ func TestThresholdFrostPreSignAuthorizationGate_RevalidatesCurrentFinalizedState gate := &thresholdFrostPreSignAuthorizationGate{ backend: backend, activationProfile: activationProfileForTestProposal(proposal), + storeBinding: testFrostDurableSessionStoreBinding(t), } authorization := &frostPreSignAuthorization{ ActivationProfileHash: gate.activationProfile.ProfileHash, @@ -503,6 +504,7 @@ func TestThresholdFrostPreSignAuthorizationGate_ProfileMismatchPrecedesSeatSigna gate := &thresholdFrostPreSignAuthorizationGate{ backend: &testFrostPreSignAuthorizationBackend{proposal: proposal}, activationProfile: profile, + storeBinding: testFrostDurableSessionStoreBinding(t), signing: countingSigner, wallet: wallet{ signingGroupOperators: make( diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index ae100afcfb..4348b37751 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -67,6 +67,7 @@ type node struct { frostPreSignAuthorizationBackend FrostPreSignAuthorizationBackend frostPreSignActivationProfile FrostPreSignActivationProfile + frostDurableSessionStoreBinding *frostDurableSessionStoreBinding bitcoinBroadcastOutbox *bitcoinBroadcastOutbox frostRetainedGroupJournal *frostRetainedGroupJournal frostActivationHandshakeExporter *frostActivationHandshakeExporter @@ -310,9 +311,6 @@ func newNode( if err != nil { return nil, fmt.Errorf("cannot initialize durable Bitcoin broadcast outbox: [%w]", err) } - node.frostPreSignAuthorizationBackend = backend - node.frostPreSignActivationProfile = verifiedActivationProfile - node.bitcoinBroadcastOutbox = outbox manifestSource, ok := chain.(FrostPreSignActivationRuntimeManifestSource) if !ok { _ = outbox.close() @@ -332,6 +330,17 @@ func newNode( _ = outbox.close() return nil, fmt.Errorf("cannot read authenticated FROST runtime manifest: [%w]", err) } + storeBinding, err := newFrostDurableSessionStoreBinding( + runtimeManifest.DurableSessionStoreFingerprint, + signing.ReadNativeTBTCSignerDurableStoreIdentity, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot bind the active FROST durable session store to the signed manifest: [%w]", + err, + ) + } if config.FrostRetainedGroupHistorySource == nil { _ = outbox.close() return nil, fmt.Errorf( @@ -357,6 +366,7 @@ func newNode( config.FrostActivationHandshakePrivateKeyPath, runtimeManifest, pointVerifier, + storeBinding, outbox, journal, ) @@ -365,6 +375,10 @@ func newNode( _ = outbox.close() return nil, fmt.Errorf("cannot initialize FROST activation handshake: [%w]", err) } + node.frostPreSignAuthorizationBackend = backend + node.frostPreSignActivationProfile = verifiedActivationProfile + node.frostDurableSessionStoreBinding = storeBinding + node.bitcoinBroadcastOutbox = outbox node.frostActivationHandshakeExporter = exporter } @@ -681,6 +695,7 @@ func (n *node) getSigningExecutor( gate, err := newThresholdFrostPreSignAuthorizationGate( n.frostPreSignAuthorizationBackend, n.frostPreSignActivationProfile, + n.frostDurableSessionStoreBinding, n.chain.Signing(), broadcastChannel, membershipValidator, From 11a86297aff1b296fb3f222592772d61e7745303 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 24 Jul 2026 18:41:57 -0400 Subject: [PATCH 04/93] wip(tbtc): ABI 3.5 inventory/state-witness consumers and signing readiness Adds Go consumers for the Rust signer's ABI 3.5 inventory and state-witness symbols, plus interactive-signing readiness checks wired into the node. INCOMPLETE - committed to preserve the work, not because it is ready: - Native inventory parsing is unfixed. The Rust keyGroup is a lowercase 66-character compressed SEC1 key; the parser must accept both valid 64-character x-only test keys and 66-character compressed keys via TaprootOutputKeyFromTBTCSignerKey, and require the resulting x-only key to equal walletID. Frozen vector to pin: walletID = 11*32, keyGroup = 02 + 11*32, threshold 2, participants 3, epoch 0, public commitment 33*32, seat 1/3 commitments 44*32 and 55*32, expected inventory commitment bd6ec36fa27a57dd9926883bb2ff4dee7ececd28de940df7294f0e0f0dedd150. - The read-only state anchor is still an interface. It needs a concrete authenticated Read + CompareAndSwap service with a durable PostgreSQL schema, fresh nonce, store/protocol/trust-domain/operator/authority/client identity binding, signed responses with bounded ancestry verification, unknown-outcome recovery by readback, and rejection of stale, divergent, ahead, over-window or wrong-identity states. It must anchor every security-relevant native mutation before Round1 commitments or Round2 shares leave the process. An interface-only implementation is not acceptable. - Casing asymmetry to verify against the Rust side: DurableStoreIdentityResult is snake_case, while RetainedKeyPackageInventoryResult, StateWitnessProofRequest (deny_unknown_fields, maximumEntries is u16 bounded 1..=256) and StateWitnessProofResult are camelCase. Verified only that `go build ./pkg/tbtc/... ./pkg/frost/...` succeeds. No test run, no cgo integration run, no cross-language vector check. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012KZb3W6TwYtKTw1mb2sfpS --- .../signing/native_tbtc_signer_abi_version.go | 9 +- .../native_tbtc_signer_abi_version_test.go | 7 +- .../signing/native_tbtc_signer_inventory.go | 481 ++++++++++++++++++ .../native_tbtc_signer_inventory_cgo.go | 164 ++++++ .../native_tbtc_signer_inventory_default.go | 24 + .../native_tbtc_signer_inventory_test.go | 176 +++++++ pkg/tbtc/frost_activation_handshake.go | 66 ++- pkg/tbtc/frost_activation_handshake_test.go | 65 ++- ...t_interactive_signing_readiness_default.go | 7 + ...eractive_signing_readiness_frost_native.go | 14 + ...ive_signing_readiness_frost_native_test.go | 53 ++ pkg/tbtc/frost_native_signer_readiness.go | 396 ++++++++++++++ .../frost_native_signer_readiness_test.go | 281 ++++++++++ pkg/tbtc/frost_pre_sign_authorization.go | 52 +- pkg/tbtc/frost_pre_sign_authorization_test.go | 89 +++- pkg/tbtc/node.go | 61 ++- pkg/tbtc/tbtc.go | 5 + 17 files changed, 1915 insertions(+), 35 deletions(-) create mode 100644 pkg/frost/signing/native_tbtc_signer_inventory.go create mode 100644 pkg/frost/signing/native_tbtc_signer_inventory_cgo.go create mode 100644 pkg/frost/signing/native_tbtc_signer_inventory_default.go create mode 100644 pkg/frost/signing/native_tbtc_signer_inventory_test.go create mode 100644 pkg/tbtc/frost_interactive_signing_readiness_default.go create mode 100644 pkg/tbtc/frost_interactive_signing_readiness_frost_native.go create mode 100644 pkg/tbtc/frost_interactive_signing_readiness_frost_native_test.go create mode 100644 pkg/tbtc/frost_native_signer_readiness.go create mode 100644 pkg/tbtc/frost_native_signer_readiness_test.go diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version.go b/pkg/frost/signing/native_tbtc_signer_abi_version.go index ceac7e2252..3110462cce 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version.go @@ -23,10 +23,11 @@ const ( // Minor 1 adds the typed heartbeat signing intent required to authorize // non-transaction messages while the signing-policy firewall is on. Minor 2 // adds the heartbeat rate-limit configuration and dedicated rejection metric. - // Minor 3 adds the canary-evidence configuration, including its independent - // policy-sample minimum, and pins this bridge to the complete - // durability/rollout-assurance signer stack. - requiredTBTCSignerABIMinMinor uint32 = 3 + // Minor 3 adds the canary-evidence configuration. Minor 4 adds descriptor- + // bound durable-store identity. Minor 5 adds exact retained key-package + // inventory plus the append-only state-witness proof required to detect + // rollback of key packages and durable replay markers. + requiredTBTCSignerABIMinMinor uint32 = 5 ) // ErrTBTCSignerABIIncompatible marks a linked libfrost_tbtc whose FFI contract version diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go index 39c48a12a4..62bf65c7fb 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go @@ -91,11 +91,12 @@ func TestParseTBTCSignerABIVersion(t *testing.T) { func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { // Pins the bridge's current required contract: major 3 adds the BIP-341 // transaction artifact, minor 1 adds heartbeat intent authorization, and - // minor 2 adds heartbeat rate limiting/metrics; minor 3 adds canary-evidence - // configuration. The matching library version is compatible; a different + // minor 2 adds heartbeat rate limiting/metrics; minor 3 adds canary-evidence, + // minor 4 adds runtime store identity, and minor 5 adds retained-key inventory + // and state-witness proofs. The matching library version is compatible; a different // major is not. A regression here means the required constants drifted from // what the bridge actually speaks. - if requiredTBTCSignerABIMajor != 3 || requiredTBTCSignerABIMinMinor != 3 { + if requiredTBTCSignerABIMajor != 3 || requiredTBTCSignerABIMinMinor != 5 { t.Fatalf( "unexpected required tbtc-signer ABI: [%d.%d]", requiredTBTCSignerABIMajor, diff --git a/pkg/frost/signing/native_tbtc_signer_inventory.go b/pkg/frost/signing/native_tbtc_signer_inventory.go new file mode 100644 index 0000000000..6b948723c6 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_inventory.go @@ -0,0 +1,481 @@ +package signing + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "strings" +) + +const ( + NativeTBTCSignerRetainedKeyPackageInventorySchema = "tbtc-signer-retained-key-package-inventory/v1" + NativeTBTCSignerStateWitnessProofRequestSchema = "tbtc-signer-state-witness-proof-request/v1" + NativeTBTCSignerStateWitnessProofSchema = "tbtc-signer-state-witness-proof/v1" + + NativeTBTCSignerStateWitnessProofMaximumEntries uint16 = 256 + + nativeTBTCSignerRetainedKeyPackageInventoryCommitmentDomain = "tbtc-signer-retained-key-package-inventory-commitment-v1\x00" + nativeTBTCSignerStateWitnessCommitmentDomain = "tbtc-signer-state-witness-commitment-v1\x00" +) + +// NativeTBTCSignerRetainedKeyPackage identifies one locally held key package. +// KeyPackageCommitment binds only canonical public package material. Secret +// signing-share bytes must never cross the native ABI. +type NativeTBTCSignerRetainedKeyPackage struct { + ParticipantSeat uint16 + KeyPackageCommitment [32]byte +} + +// NativeTBTCSignerRetainedKeyGroup is the exact native key-package inventory +// for one wallet. ShareEpoch is independent of the legacy refresh telemetry: +// it advances only when the signer atomically replaces the real FROST key and +// public packages. The current protocol has no such replacement and therefore +// requires epoch zero. +type NativeTBTCSignerRetainedKeyGroup struct { + WalletID [32]byte + KeyGroup string + Threshold uint16 + ParticipantCount uint16 + ShareEpoch uint64 + PublicKeyPackageCommitment [32]byte + KeyPackages []NativeTBTCSignerRetainedKeyPackage +} + +// NativeTBTCSignerRetainedKeyPackageInventory is a descriptor-locked snapshot +// of the native signer. The state witness covers every durable engine-state +// mutation, including replay markers; InventoryCommitment covers the sorted +// public key-package inventory alone. +type NativeTBTCSignerRetainedKeyPackageInventory struct { + Schema string + StoreFingerprint [32]byte + StateGeneration uint64 + StateCommitment [32]byte + PreviousStateCommitment [32]byte + StateImageDigest [32]byte + InventoryCommitment [32]byte + Entries []NativeTBTCSignerRetainedKeyGroup +} + +type nativeTBTCSignerRetainedKeyPackageWire struct { + ParticipantSeat uint16 `json:"participantSeat"` + KeyPackageCommitment string `json:"keyPackageCommitment"` +} + +type nativeTBTCSignerRetainedKeyGroupWire struct { + WalletID string `json:"walletID"` + KeyGroup string `json:"keyGroup"` + Threshold uint16 `json:"threshold"` + ParticipantCount uint16 `json:"participantCount"` + ShareEpoch *uint64 `json:"shareEpoch"` + PublicKeyPackageCommitment string `json:"publicKeyPackageCommitment"` + KeyPackages []nativeTBTCSignerRetainedKeyPackageWire `json:"keyPackages"` +} + +type nativeTBTCSignerRetainedKeyPackageInventoryWire struct { + Schema string `json:"schema"` + StoreFingerprint string `json:"storeFingerprint"` + StateGeneration uint64 `json:"stateGeneration"` + StateCommitment string `json:"stateCommitment"` + PreviousStateCommitment string `json:"previousStateCommitment"` + StateImageDigest string `json:"stateImageDigest"` + InventoryCommitment string `json:"inventoryCommitment"` + Entries *[]nativeTBTCSignerRetainedKeyGroupWire `json:"entries"` +} + +// DecodeNativeTBTCSignerRetainedKeyPackageInventory validates the exact wire +// contract returned by frost_tbtc_retained_key_package_inventory. +func DecodeNativeTBTCSignerRetainedKeyPackageInventory( + payload []byte, +) (*NativeTBTCSignerRetainedKeyPackageInventory, error) { + wire := &nativeTBTCSignerRetainedKeyPackageInventoryWire{} + if err := decodeStrictNativeTBTCSignerJSON(payload, wire, "retained key-package inventory"); err != nil { + return nil, err + } + if wire.Schema != NativeTBTCSignerRetainedKeyPackageInventorySchema { + return nil, fmt.Errorf("unsupported retained key-package inventory schema") + } + if wire.StateGeneration == 0 { + return nil, fmt.Errorf("retained key-package inventory state generation is zero") + } + + result := &NativeTBTCSignerRetainedKeyPackageInventory{ + Schema: wire.Schema, + StateGeneration: wire.StateGeneration, + } + if wire.Entries == nil { + return nil, fmt.Errorf("retained key-package inventory entries are missing") + } + result.Entries = make([]NativeTBTCSignerRetainedKeyGroup, len(*wire.Entries)) + bytes32Values := []struct { + label string + encoded string + destination *[32]byte + }{ + {"store fingerprint", wire.StoreFingerprint, &result.StoreFingerprint}, + {"state commitment", wire.StateCommitment, &result.StateCommitment}, + {"previous state commitment", wire.PreviousStateCommitment, &result.PreviousStateCommitment}, + {"state image digest", wire.StateImageDigest, &result.StateImageDigest}, + {"inventory commitment", wire.InventoryCommitment, &result.InventoryCommitment}, + } + for _, value := range bytes32Values { + decoded, err := decodeNativeTBTCSignerStoreBytes32(value.encoded) + if err != nil { + return nil, fmt.Errorf("invalid retained key-package %s: %w", value.label, err) + } + *value.destination = decoded + } + computedStateCommitment := ComputeNativeTBTCSignerStateWitnessCommitment( + result.StoreFingerprint, + result.StateGeneration, + result.PreviousStateCommitment, + result.StateImageDigest, + ) + if computedStateCommitment != result.StateCommitment { + return nil, fmt.Errorf("retained key-package state commitment mismatch") + } + + var previousWalletID [32]byte + for entryIndex, entryWire := range *wire.Entries { + entry := &result.Entries[entryIndex] + walletID, err := decodeNativeTBTCSignerStoreBytes32(entryWire.WalletID) + if err != nil { + return nil, fmt.Errorf("invalid retained key-package wallet ID: %w", err) + } + if entryIndex > 0 && bytes.Compare(previousWalletID[:], walletID[:]) >= 0 { + return nil, fmt.Errorf("retained key-package wallet entries are not strictly sorted") + } + previousWalletID = walletID + entry.WalletID = walletID + + if entryWire.KeyGroup != strings.ToLower(entryWire.KeyGroup) || + len(entryWire.KeyGroup) != 64 || strings.HasPrefix(entryWire.KeyGroup, "0x") { + return nil, fmt.Errorf("retained key group is not canonical lowercase x-only hex") + } + keyGroupBytes, err := hex.DecodeString(entryWire.KeyGroup) + if err != nil || len(keyGroupBytes) != 32 { + return nil, fmt.Errorf("retained key group is not canonical lowercase x-only hex") + } + if !bytes.Equal(keyGroupBytes, walletID[:]) { + return nil, fmt.Errorf("retained key group does not identify its wallet") + } + entry.KeyGroup = entryWire.KeyGroup + entry.Threshold = entryWire.Threshold + entry.ParticipantCount = entryWire.ParticipantCount + if entryWire.ShareEpoch == nil { + return nil, fmt.Errorf("retained key group share epoch is missing") + } + entry.ShareEpoch = *entryWire.ShareEpoch + if entry.Threshold == 0 || entry.ParticipantCount == 0 || + entry.Threshold > entry.ParticipantCount || entry.ParticipantCount > 100 { + return nil, fmt.Errorf("retained key group threshold or participant count is invalid") + } + publicCommitment, err := decodeNativeTBTCSignerStoreBytes32( + entryWire.PublicKeyPackageCommitment, + ) + if err != nil { + return nil, fmt.Errorf("invalid retained public key-package commitment: %w", err) + } + entry.PublicKeyPackageCommitment = publicCommitment + if len(entryWire.KeyPackages) == 0 || len(entryWire.KeyPackages) > int(entry.ParticipantCount) { + return nil, fmt.Errorf("retained key-package seat inventory is empty or oversized") + } + entry.KeyPackages = make( + []NativeTBTCSignerRetainedKeyPackage, + len(entryWire.KeyPackages), + ) + var previousSeat uint16 + for packageIndex, packageWire := range entryWire.KeyPackages { + if packageWire.ParticipantSeat == 0 || + packageWire.ParticipantSeat > entry.ParticipantCount || + (packageIndex > 0 && packageWire.ParticipantSeat <= previousSeat) { + return nil, fmt.Errorf("retained key-package seats are invalid or not strictly sorted") + } + previousSeat = packageWire.ParticipantSeat + commitment, err := decodeNativeTBTCSignerStoreBytes32( + packageWire.KeyPackageCommitment, + ) + if err != nil { + return nil, fmt.Errorf("invalid retained key-package commitment: %w", err) + } + entry.KeyPackages[packageIndex] = NativeTBTCSignerRetainedKeyPackage{ + ParticipantSeat: packageWire.ParticipantSeat, + KeyPackageCommitment: commitment, + } + } + } + + computed := ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment(result.Entries) + if computed != result.InventoryCommitment { + return nil, fmt.Errorf("retained key-package inventory commitment mismatch") + } + return result, nil +} + +// ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment implements the +// language-independent commitment transcript used by the native signer. +func ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment( + entries []NativeTBTCSignerRetainedKeyGroup, +) [32]byte { + digest := sha256.New() + digest.Write([]byte(nativeTBTCSignerRetainedKeyPackageInventoryCommitmentDomain)) + writeNativeTBTCSignerUint32(digest, uint32(len(entries))) + for _, entry := range entries { + digest.Write(entry.WalletID[:]) + writeNativeTBTCSignerStoreFingerprintField(digest, []byte(entry.KeyGroup)) + writeNativeTBTCSignerUint16(digest, entry.Threshold) + writeNativeTBTCSignerUint16(digest, entry.ParticipantCount) + writeNativeTBTCSignerUint64(digest, entry.ShareEpoch) + digest.Write(entry.PublicKeyPackageCommitment[:]) + writeNativeTBTCSignerUint32(digest, uint32(len(entry.KeyPackages))) + for _, keyPackage := range entry.KeyPackages { + writeNativeTBTCSignerUint16(digest, keyPackage.ParticipantSeat) + digest.Write(keyPackage.KeyPackageCommitment[:]) + } + } + var result [32]byte + copy(result[:], digest.Sum(nil)) + return result +} + +// ComputeNativeTBTCSignerStateWitnessCommitment binds one durable signer-state +// image to its store, generation, and direct hash-chain parent. +func ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint [32]byte, + generation uint64, + previousStateCommitment [32]byte, + stateImageDigest [32]byte, +) [32]byte { + digest := sha256.New() + digest.Write([]byte(nativeTBTCSignerStateWitnessCommitmentDomain)) + digest.Write(storeFingerprint[:]) + writeNativeTBTCSignerUint64(digest, generation) + digest.Write(previousStateCommitment[:]) + digest.Write(stateImageDigest[:]) + var result [32]byte + copy(result[:], digest.Sum(nil)) + return result +} + +type NativeTBTCSignerStateWitnessProofRequest struct { + Schema string + StoreFingerprint [32]byte + AncestorGeneration uint64 + AncestorCommitment [32]byte + TargetGeneration uint64 + TargetCommitment [32]byte + MaximumEntries uint16 +} + +type NativeTBTCSignerStateWitnessProofEntry struct { + Generation uint64 + PreviousStateCommitment [32]byte + StateCommitment [32]byte + StateImageDigest [32]byte +} + +type NativeTBTCSignerStateWitnessProof struct { + Schema string + StoreFingerprint [32]byte + AncestorGeneration uint64 + AncestorCommitment [32]byte + TargetGeneration uint64 + TargetCommitment [32]byte + Complete bool + Entries []NativeTBTCSignerStateWitnessProofEntry +} + +type nativeTBTCSignerStateWitnessProofRequestWire struct { + Schema string `json:"schema"` + StoreFingerprint string `json:"storeFingerprint"` + AncestorGeneration uint64 `json:"ancestorGeneration"` + AncestorCommitment string `json:"ancestorCommitment"` + TargetGeneration uint64 `json:"targetGeneration"` + TargetCommitment string `json:"targetCommitment"` + MaximumEntries uint16 `json:"maximumEntries"` +} + +type nativeTBTCSignerStateWitnessProofEntryWire struct { + Generation uint64 `json:"generation"` + PreviousStateCommitment string `json:"previousStateCommitment"` + StateCommitment string `json:"stateCommitment"` + StateImageDigest string `json:"stateImageDigest"` +} + +type nativeTBTCSignerStateWitnessProofWire struct { + Schema string `json:"schema"` + StoreFingerprint string `json:"storeFingerprint"` + AncestorGeneration uint64 `json:"ancestorGeneration"` + AncestorCommitment string `json:"ancestorCommitment"` + TargetGeneration uint64 `json:"targetGeneration"` + TargetCommitment string `json:"targetCommitment"` + Complete *bool `json:"complete"` + Entries *[]nativeTBTCSignerStateWitnessProofEntryWire `json:"entries"` +} + +func (request *NativeTBTCSignerStateWitnessProofRequest) MarshalJSON() ([]byte, error) { + if err := request.validate(); err != nil { + return nil, err + } + return json.Marshal(nativeTBTCSignerStateWitnessProofRequestWire{ + Schema: request.Schema, + StoreFingerprint: nativeTBTCSignerBytes32(request.StoreFingerprint), + AncestorGeneration: request.AncestorGeneration, + AncestorCommitment: nativeTBTCSignerBytes32(request.AncestorCommitment), + TargetGeneration: request.TargetGeneration, + TargetCommitment: nativeTBTCSignerBytes32(request.TargetCommitment), + MaximumEntries: request.MaximumEntries, + }) +} + +func (request *NativeTBTCSignerStateWitnessProofRequest) validate() error { + if request == nil || request.Schema != NativeTBTCSignerStateWitnessProofRequestSchema || + request.StoreFingerprint == [32]byte{} || request.AncestorGeneration == 0 || + request.AncestorCommitment == [32]byte{} || request.TargetGeneration == 0 || + request.TargetCommitment == [32]byte{} || + request.TargetGeneration < request.AncestorGeneration || + request.MaximumEntries == 0 || + request.MaximumEntries > NativeTBTCSignerStateWitnessProofMaximumEntries { + return fmt.Errorf("native signer state-witness proof request is invalid") + } + if request.TargetGeneration == request.AncestorGeneration && + request.TargetCommitment != request.AncestorCommitment { + return fmt.Errorf("native signer state-witness equal-generation commitments differ") + } + return nil +} + +func DecodeNativeTBTCSignerStateWitnessProof( + payload []byte, +) (*NativeTBTCSignerStateWitnessProof, error) { + wire := &nativeTBTCSignerStateWitnessProofWire{} + if err := decodeStrictNativeTBTCSignerJSON(payload, wire, "state-witness proof"); err != nil { + return nil, err + } + if wire.Schema != NativeTBTCSignerStateWitnessProofSchema { + return nil, fmt.Errorf("unsupported native signer state-witness proof schema") + } + result := &NativeTBTCSignerStateWitnessProof{ + Schema: wire.Schema, + AncestorGeneration: wire.AncestorGeneration, + TargetGeneration: wire.TargetGeneration, + } + if wire.Complete == nil || wire.Entries == nil { + return nil, fmt.Errorf("native signer state-witness proof completeness or entries are missing") + } + result.Complete = *wire.Complete + result.Entries = make( + []NativeTBTCSignerStateWitnessProofEntry, + len(*wire.Entries), + ) + values := []struct { + label string + encoded string + destination *[32]byte + }{ + {"store fingerprint", wire.StoreFingerprint, &result.StoreFingerprint}, + {"ancestor commitment", wire.AncestorCommitment, &result.AncestorCommitment}, + {"target commitment", wire.TargetCommitment, &result.TargetCommitment}, + } + for _, value := range values { + decoded, err := decodeNativeTBTCSignerStoreBytes32(value.encoded) + if err != nil { + return nil, fmt.Errorf("invalid state-witness %s: %w", value.label, err) + } + *value.destination = decoded + } + if result.AncestorGeneration == 0 || result.TargetGeneration == 0 || + result.TargetGeneration < result.AncestorGeneration || + len(result.Entries) > int(NativeTBTCSignerStateWitnessProofMaximumEntries) { + return nil, fmt.Errorf("native signer state-witness proof bounds are invalid") + } + + previousGeneration := result.AncestorGeneration + previousCommitment := result.AncestorCommitment + for index, entryWire := range *wire.Entries { + entry := &result.Entries[index] + entry.Generation = entryWire.Generation + entryValues := []struct { + label string + encoded string + destination *[32]byte + }{ + {"previous state commitment", entryWire.PreviousStateCommitment, &entry.PreviousStateCommitment}, + {"state commitment", entryWire.StateCommitment, &entry.StateCommitment}, + {"state image digest", entryWire.StateImageDigest, &entry.StateImageDigest}, + } + for _, value := range entryValues { + decoded, err := decodeNativeTBTCSignerStoreBytes32(value.encoded) + if err != nil { + return nil, fmt.Errorf("invalid state-witness proof entry %s: %w", value.label, err) + } + *value.destination = decoded + } + if entry.Generation != previousGeneration+1 || + entry.PreviousStateCommitment != previousCommitment || + entry.Generation > result.TargetGeneration { + return nil, fmt.Errorf("native signer state-witness proof is not a contiguous chain") + } + computed := ComputeNativeTBTCSignerStateWitnessCommitment( + result.StoreFingerprint, + entry.Generation, + entry.PreviousStateCommitment, + entry.StateImageDigest, + ) + if computed != entry.StateCommitment { + return nil, fmt.Errorf("native signer state-witness proof commitment mismatch") + } + previousGeneration = entry.Generation + previousCommitment = entry.StateCommitment + } + if result.Complete { + if previousGeneration != result.TargetGeneration || + previousCommitment != result.TargetCommitment { + return nil, fmt.Errorf("complete native signer state-witness proof does not reach its target") + } + } else if len(result.Entries) == 0 || previousGeneration >= result.TargetGeneration { + return nil, fmt.Errorf("incomplete native signer state-witness proof made no bounded progress") + } + return result, nil +} + +func decodeStrictNativeTBTCSignerJSON(payload []byte, target interface{}, subject string) error { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return fmt.Errorf("cannot decode native signer %s: %w", subject, err) + } + var trailing json.RawMessage + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("native signer %s contains trailing JSON", subject) + } + return fmt.Errorf("cannot decode native signer %s trailing data: %w", subject, err) + } + return nil +} + +func nativeTBTCSignerBytes32(value [32]byte) string { + return "0x" + hex.EncodeToString(value[:]) +} + +func writeNativeTBTCSignerUint16(destination nativeTBTCSignerStoreFingerprintWriter, value uint16) { + var encoded [2]byte + binary.BigEndian.PutUint16(encoded[:], value) + destination.Write(encoded[:]) +} + +func writeNativeTBTCSignerUint32(destination nativeTBTCSignerStoreFingerprintWriter, value uint32) { + var encoded [4]byte + binary.BigEndian.PutUint32(encoded[:], value) + destination.Write(encoded[:]) +} + +func writeNativeTBTCSignerUint64(destination nativeTBTCSignerStoreFingerprintWriter, value uint64) { + var encoded [8]byte + binary.BigEndian.PutUint64(encoded[:], value) + destination.Write(encoded[:]) +} diff --git a/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go b/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go new file mode 100644 index 0000000000..4ccdb0dafe --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go @@ -0,0 +1,164 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +/* +#cgo linux LDFLAGS: -ldl +#cgo freebsd LDFLAGS: -ldl +#include +#include +#include +#include + +typedef struct { + uint8_t* ptr; + size_t len; +} TbtcSignerInventoryBuffer; + +typedef struct { + int32_t status_code; + TbtcSignerInventoryBuffer buffer; +} TbtcSignerInventoryResult; + +typedef TbtcSignerInventoryResult (*tbtc_retained_key_package_inventory_fn)(void); +typedef TbtcSignerInventoryResult (*tbtc_state_witness_proof_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef void (*tbtc_inventory_free_buffer_fn)(uint8_t* ptr, size_t len); + +static TbtcSignerInventoryResult unavailable_tbtc_signer_inventory_result(void) { + TbtcSignerInventoryResult result; + result.status_code = -1; + result.buffer.ptr = NULL; + result.buffer.len = 0; + return result; +} + +static TbtcSignerInventoryResult tbtc_signer_retained_key_package_inventory(void) { + tbtc_retained_key_package_inventory_fn operation = + (tbtc_retained_key_package_inventory_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_retained_key_package_inventory" + ); + if (operation == NULL) { + return unavailable_tbtc_signer_inventory_result(); + } + return operation(); +} + +static TbtcSignerInventoryResult tbtc_signer_state_witness_proof( + const uint8_t* request_ptr, + size_t request_len +) { + tbtc_state_witness_proof_fn operation = (tbtc_state_witness_proof_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_state_witness_proof" + ); + if (operation == NULL) { + return unavailable_tbtc_signer_inventory_result(); + } + return operation(request_ptr, request_len); +} + +static void tbtc_signer_inventory_free_buffer(uint8_t* ptr, size_t len) { + tbtc_inventory_free_buffer_fn free_buffer = + (tbtc_inventory_free_buffer_fn)dlsym(RTLD_DEFAULT, "frost_tbtc_free_buffer"); + if (free_buffer != NULL) { + free_buffer(ptr, len); + } +} +*/ +import "C" + +import ( + "fmt" + "math" + "unsafe" +) + +func ReadNativeTBTCSignerRetainedKeyPackageInventory() ( + *NativeTBTCSignerRetainedKeyPackageInventory, + error, +) { + if err := ensureTBTCSignerABICompatible(); err != nil { + return nil, err + } + payload, err := parseNativeTBTCSignerInventoryResult( + "RetainedKeyPackageInventory", + C.tbtc_signer_retained_key_package_inventory(), + ) + if err != nil { + return nil, err + } + inventory, err := DecodeNativeTBTCSignerRetainedKeyPackageInventory(payload) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "RetainedKeyPackageInventory", + err.Error(), + ) + } + return inventory, nil +} + +func ReadNativeTBTCSignerStateWitnessProof( + request *NativeTBTCSignerStateWitnessProofRequest, +) (*NativeTBTCSignerStateWitnessProof, error) { + if err := ensureTBTCSignerABICompatible(); err != nil { + return nil, err + } + requestPayload, err := request.MarshalJSON() + if err != nil { + return nil, buildTaggedTBTCSignerOperationError("StateWitnessProof", err.Error()) + } + requestPointer := C.CBytes(requestPayload) + defer func() { + zeroBytes(unsafe.Slice((*byte)(requestPointer), len(requestPayload))) + C.free(requestPointer) + }() + payload, err := parseNativeTBTCSignerInventoryResult( + "StateWitnessProof", + C.tbtc_signer_state_witness_proof( + (*C.uint8_t)(requestPointer), + C.size_t(len(requestPayload)), + ), + ) + if err != nil { + return nil, err + } + proof, err := DecodeNativeTBTCSignerStateWitnessProof(payload) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError("StateWitnessProof", err.Error()) + } + return proof, nil +} + +func parseNativeTBTCSignerInventoryResult( + operation string, + result C.TbtcSignerInventoryResult, +) ([]byte, error) { + if result.buffer.ptr != nil { + defer C.tbtc_signer_inventory_free_buffer(result.buffer.ptr, result.buffer.len) + } + if uint64(result.buffer.len) > uint64(math.MaxInt32) { + return nil, buildTaggedTBTCSignerOperationError( + operation, + fmt.Sprintf("response buffer length [%d] exceeds maximum", uint64(result.buffer.len)), + ) + } + var payload []byte + if result.buffer.ptr != nil && result.buffer.len > 0 { + payload = C.GoBytes(unsafe.Pointer(result.buffer.ptr), C.int(result.buffer.len)) + } + if err := buildTaggedTBTCSignerResultStatusError( + operation, + int32(result.status_code), + payload, + ); err != nil { + return nil, err + } + if len(payload) == 0 { + return nil, buildTaggedTBTCSignerOperationError(operation, "response payload is empty") + } + return payload, nil +} diff --git a/pkg/frost/signing/native_tbtc_signer_inventory_default.go b/pkg/frost/signing/native_tbtc_signer_inventory_default.go new file mode 100644 index 0000000000..f7c633240b --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_inventory_default.go @@ -0,0 +1,24 @@ +//go:build !(frost_native && frost_tbtc_signer && cgo) + +package signing + +import "fmt" + +func ReadNativeTBTCSignerRetainedKeyPackageInventory() ( + *NativeTBTCSignerRetainedKeyPackageInventory, + error, +) { + return nil, fmt.Errorf( + "%w: tbtc-signer bridge operation [RetainedKeyPackageInventory] is unavailable in this build", + ErrNativeCryptographyUnavailable, + ) +} + +func ReadNativeTBTCSignerStateWitnessProof( + request *NativeTBTCSignerStateWitnessProofRequest, +) (*NativeTBTCSignerStateWitnessProof, error) { + return nil, fmt.Errorf( + "%w: tbtc-signer bridge operation [StateWitnessProof] is unavailable in this build", + ErrNativeCryptographyUnavailable, + ) +} diff --git a/pkg/frost/signing/native_tbtc_signer_inventory_test.go b/pkg/frost/signing/native_tbtc_signer_inventory_test.go new file mode 100644 index 0000000000..d52b5dd5da --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_inventory_test.go @@ -0,0 +1,176 @@ +package signing + +import ( + "encoding/hex" + "encoding/json" + "strings" + "testing" +) + +func TestDecodeNativeTBTCSignerRetainedKeyPackageInventory(t *testing.T) { + wire := testNativeTBTCSignerRetainedKeyPackageInventoryWire() + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + inventory, err := DecodeNativeTBTCSignerRetainedKeyPackageInventory(payload) + if err != nil { + t.Fatalf("valid native inventory was rejected: [%v]", err) + } + if inventory.StateGeneration != wire.StateGeneration || len(inventory.Entries) != 1 || + inventory.Entries[0].ShareEpoch != 0 || len(inventory.Entries[0].KeyPackages) != 1 { + t.Fatalf("unexpected decoded inventory: %+v", inventory) + } +} + +func TestDecodeNativeTBTCSignerRetainedKeyPackageInventoryRejectsSubstitution( + t *testing.T, +) { + tests := map[string]func(*nativeTBTCSignerRetainedKeyPackageInventoryWire){ + "missing entries": func(wire *nativeTBTCSignerRetainedKeyPackageInventoryWire) { + wire.Entries = nil + }, + "missing share epoch": func(wire *nativeTBTCSignerRetainedKeyPackageInventoryWire) { + (*wire.Entries)[0].ShareEpoch = nil + }, + "wrong key group": func(wire *nativeTBTCSignerRetainedKeyPackageInventoryWire) { + (*wire.Entries)[0].KeyGroup = strings.Repeat("09", 32) + }, + "wrong state image": func(wire *nativeTBTCSignerRetainedKeyPackageInventoryWire) { + wire.StateImageDigest = nativeTBTCSignerBytes32([32]byte{0x7f}) + }, + "wrong inventory commitment": func(wire *nativeTBTCSignerRetainedKeyPackageInventoryWire) { + wire.InventoryCommitment = nativeTBTCSignerBytes32([32]byte{0x7e}) + }, + "duplicate seat": func(wire *nativeTBTCSignerRetainedKeyPackageInventoryWire) { + (*wire.Entries)[0].KeyPackages = append( + (*wire.Entries)[0].KeyPackages, + (*wire.Entries)[0].KeyPackages[0], + ) + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + wire := testNativeTBTCSignerRetainedKeyPackageInventoryWire() + mutate(wire) + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeNativeTBTCSignerRetainedKeyPackageInventory(payload); err == nil { + t.Fatal("substituted native inventory was accepted") + } + }) + } +} + +func TestDecodeNativeTBTCSignerStateWitnessProof(t *testing.T) { + storeFingerprint := [32]byte{0x11} + ancestorCommitment := [32]byte{0x12} + firstImage := [32]byte{0x13} + firstCommitment := ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + 8, + ancestorCommitment, + firstImage, + ) + secondImage := [32]byte{0x14} + secondCommitment := ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + 9, + firstCommitment, + secondImage, + ) + complete := true + proofEntries := []nativeTBTCSignerStateWitnessProofEntryWire{ + { + Generation: 8, + PreviousStateCommitment: nativeTBTCSignerBytes32(ancestorCommitment), + StateCommitment: nativeTBTCSignerBytes32(firstCommitment), + StateImageDigest: nativeTBTCSignerBytes32(firstImage), + }, + { + Generation: 9, + PreviousStateCommitment: nativeTBTCSignerBytes32(firstCommitment), + StateCommitment: nativeTBTCSignerBytes32(secondCommitment), + StateImageDigest: nativeTBTCSignerBytes32(secondImage), + }, + } + wire := &nativeTBTCSignerStateWitnessProofWire{ + Schema: NativeTBTCSignerStateWitnessProofSchema, + StoreFingerprint: nativeTBTCSignerBytes32(storeFingerprint), + AncestorGeneration: 7, + AncestorCommitment: nativeTBTCSignerBytes32(ancestorCommitment), + TargetGeneration: 9, + TargetCommitment: nativeTBTCSignerBytes32(secondCommitment), + Complete: &complete, + Entries: &proofEntries, + } + payload, _ := json.Marshal(wire) + proof, err := DecodeNativeTBTCSignerStateWitnessProof(payload) + if err != nil { + t.Fatalf("valid state-witness proof was rejected: [%v]", err) + } + if !proof.Complete || len(proof.Entries) != 2 { + t.Fatalf("unexpected proof: %+v", proof) + } + + (*wire.Entries)[1].StateImageDigest = nativeTBTCSignerBytes32([32]byte{0xff}) + payload, _ = json.Marshal(wire) + if _, err := DecodeNativeTBTCSignerStateWitnessProof(payload); err == nil { + t.Fatal("state-witness proof with a forged image digest was accepted") + } +} + +func testNativeTBTCSignerRetainedKeyPackageInventoryWire() *nativeTBTCSignerRetainedKeyPackageInventoryWire { + storeFingerprint := [32]byte{0x01} + previousCommitment := [32]byte{0x02} + stateImageDigest := [32]byte{0x03} + walletID := [32]byte{0x04} + entries := []NativeTBTCSignerRetainedKeyGroup{ + { + WalletID: walletID, + KeyGroup: hex.EncodeToString(walletID[:]), + Threshold: 51, + ParticipantCount: 100, + ShareEpoch: 0, + PublicKeyPackageCommitment: [32]byte{0x05}, + KeyPackages: []NativeTBTCSignerRetainedKeyPackage{ + {ParticipantSeat: 3, KeyPackageCommitment: [32]byte{0x06}}, + }, + }, + } + shareEpoch := uint64(0) + wireEntries := []nativeTBTCSignerRetainedKeyGroupWire{ + { + WalletID: nativeTBTCSignerBytes32(walletID), + KeyGroup: hex.EncodeToString(walletID[:]), + Threshold: 51, + ParticipantCount: 100, + ShareEpoch: &shareEpoch, + PublicKeyPackageCommitment: nativeTBTCSignerBytes32([32]byte{0x05}), + KeyPackages: []nativeTBTCSignerRetainedKeyPackageWire{ + {ParticipantSeat: 3, KeyPackageCommitment: nativeTBTCSignerBytes32([32]byte{0x06})}, + }, + }, + } + return &nativeTBTCSignerRetainedKeyPackageInventoryWire{ + Schema: NativeTBTCSignerRetainedKeyPackageInventorySchema, + StoreFingerprint: nativeTBTCSignerBytes32(storeFingerprint), + StateGeneration: 7, + StateCommitment: nativeTBTCSignerBytes32( + ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + 7, + previousCommitment, + stateImageDigest, + ), + ), + PreviousStateCommitment: nativeTBTCSignerBytes32(previousCommitment), + StateImageDigest: nativeTBTCSignerBytes32(stateImageDigest), + InventoryCommitment: nativeTBTCSignerBytes32( + ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment(entries), + ), + Entries: &wireEntries, + } +} diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 08909e7552..b0ea9dfcb3 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -27,7 +27,7 @@ import ( ) const ( - frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v1" + frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v2" frostActivationInventorySchema = "tbtc-p2tr-frost-wallet-group-inventory/v1" ) @@ -85,6 +85,20 @@ type frostActivationQuarantineJournalState struct { Complete bool `json:"complete"` } +type frostActivationNativeSignerState struct { + Schema string `json:"schema"` + StoreFingerprint string `json:"storeFingerprint"` + StateGeneration uint64 `json:"stateGeneration"` + StateCommitment string `json:"stateCommitment"` + PreviousStateCommitment string `json:"previousStateCommitment"` + StateImageDigest string `json:"stateImageDigest"` + InventoryCommitment string `json:"inventoryCommitment"` + RetainedWalletCount uint64 `json:"retainedWalletCount"` + RetainedKeyPackageCount uint64 `json:"retainedKeyPackageCount"` + ExternalRollbackAnchorBound bool `json:"externalRollbackAnchorBound"` + Complete bool `json:"complete"` +} + type frostActivationHandshakeState struct { ProtocolID string `json:"protocolID"` ReservationProtocolID string `json:"reservationProtocolID"` @@ -99,6 +113,8 @@ type frostActivationHandshakeState struct { FrostWalletGroupInventory frostActivationWalletGroupInventory `json:"frostWalletGroupInventory"` CanonicalJournal frostActivationCanonicalJournalState `json:"canonicalJournal"` QuarantineJournal frostActivationQuarantineJournalState `json:"quarantineJournal"` + NativeSignerState frostActivationNativeSignerState `json:"nativeSignerState"` + InteractiveSigningReady bool `json:"interactiveSigningReady"` FinalizedReservationReadbackEnforced bool `json:"finalizedReservationReadbackEnforced"` ExactTransactionAuthorizationRootEnforced bool `json:"exactTransactionAuthorizationRootEnforced"` NonceShareGateEnforced bool `json:"nonceShareGateEnforced"` @@ -129,7 +145,7 @@ type frostActivationHandshakeExporter struct { pointVerifier FrostPreSignActivationPointVerifier storeBinding *frostDurableSessionStoreBinding outbox *bitcoinBroadcastOutbox - journal *frostRetainedGroupJournal + readiness frostProductionSignerReadinessVerifier mutex sync.Mutex listener net.Listener @@ -144,7 +160,7 @@ func newFrostActivationHandshakeExporter( pointVerifier FrostPreSignActivationPointVerifier, storeBinding *frostDurableSessionStoreBinding, outbox *bitcoinBroadcastOutbox, - journal *frostRetainedGroupJournal, + readiness frostProductionSignerReadinessVerifier, ) (*frostActivationHandshakeExporter, error) { parsedEndpoint, err := validateFrostActivationHandshakeEndpoint(endpoint) if err != nil { @@ -163,7 +179,7 @@ func newFrostActivationHandshakeExporter( durableSessionStoreFingerprintErr != nil || durableSessionStoreFingerprint == [32]byte{} || durableSessionStoreFingerprint == manifest.CanonicalJournal.StoreFingerprint || durableSessionStoreFingerprint == manifest.QuarantineJournal.StoreFingerprint || - pointVerifier == nil || storeBinding == nil || outbox == nil || journal == nil { + pointVerifier == nil || storeBinding == nil || outbox == nil || readiness == nil { return nil, fmt.Errorf("FROST activation handshake dependencies are invalid") } boundStoreFingerprint, err := storeBinding.verify() @@ -187,7 +203,7 @@ func newFrostActivationHandshakeExporter( pointVerifier: pointVerifier, storeBinding: storeBinding, outbox: outbox, - journal: journal, + readiness: readiness, } return exporter, nil } @@ -380,9 +396,14 @@ func (fahe *frostActivationHandshakeExporter) attest( if err := fahe.pointVerifier.VerifyFrostPreSignActivationPoint(ctx, finality); err != nil { return nil, fmt.Errorf("cannot verify FROST activation point: [%w]", err) } - journalSnapshot, err := fahe.journal.reconcile(ctx, finality) + readinessSnapshot, err := fahe.readiness.verifyFrostProductionSignerReadiness(ctx, finality) if err != nil { - return nil, fmt.Errorf("cannot reconcile canonical FROST retained groups: [%w]", err) + return nil, fmt.Errorf("cannot verify production FROST signer readiness: [%w]", err) + } + journalSnapshot := readinessSnapshot.Journal + nativeSignerSnapshot := readinessSnapshot.Inventory + if journalSnapshot == nil || nativeSignerSnapshot == nil { + return nil, fmt.Errorf("production FROST signer readiness snapshot is incomplete") } journalManifest := fahe.manifest.CanonicalJournal quarantineManifest := fahe.manifest.QuarantineJournal @@ -466,13 +487,34 @@ func (fahe *frostActivationHandshakeExporter) attest( CurrentQuarantineCount: journalSnapshot.QuarantineCount, Complete: true, }, + NativeSignerState: frostActivationNativeSignerState{ + Schema: nativeSignerSnapshot.Schema, + StoreFingerprint: frostActivationHex32(nativeSignerSnapshot.StoreFingerprint), + StateGeneration: nativeSignerSnapshot.StateGeneration, + StateCommitment: frostActivationHex32(nativeSignerSnapshot.StateCommitment), + PreviousStateCommitment: frostActivationHex32(nativeSignerSnapshot.PreviousStateCommitment), + StateImageDigest: frostActivationHex32(nativeSignerSnapshot.StateImageDigest), + InventoryCommitment: frostActivationHex32(nativeSignerSnapshot.InventoryCommitment), + RetainedWalletCount: nativeSignerSnapshot.WalletCount, + RetainedKeyPackageCount: nativeSignerSnapshot.KeyPackageCount, + ExternalRollbackAnchorBound: true, + Complete: true, + }, + InteractiveSigningReady: readinessSnapshot.InteractiveSigningReady, FinalizedReservationReadbackEnforced: true, ExactTransactionAuthorizationRootEnforced: true, - NonceShareGateEnforced: true, - DurableBitcoinOutboxRecovered: true, - QuarantineFailClosed: true, - Healthy: true, - } + NonceShareGateEnforced: readinessSnapshot.InteractiveSigningReady && + nativeSignerSnapshot.StateGeneration > 0 && + nativeSignerSnapshot.StateCommitment != [32]byte{}, + DurableBitcoinOutboxRecovered: outboxSnapshot.Recovered, + QuarantineFailClosed: journalSnapshot.QuarantineCount == 0, + } + state.Healthy = state.InteractiveSigningReady && + state.NonceShareGateEnforced && + state.DurableBitcoinOutboxRecovered && + state.QuarantineFailClosed && + state.NativeSignerState.Complete && + state.NativeSignerState.ExternalRollbackAnchorBound payload := frostActivationHandshakePayload{ Kind: "frost-signer", Nonce: request.Challenge.Nonce, diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index 1caf6a72e7..1dc8bfc7ed 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -45,6 +45,43 @@ type testFrostRetainedGroupHistorySource struct { target FrostPreSignFinality } +type testFrostProductionSignerReadiness struct { + journal *frostRetainedGroupJournal + interactive bool + err error + calls uint64 +} + +func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSignerReadiness( + ctx context.Context, + point FrostPreSignFinality, +) (*frostProductionSignerReadinessSnapshot, error) { + readiness.calls++ + if readiness.err != nil { + return nil, readiness.err + } + if !readiness.interactive { + return nil, fmt.Errorf("interactive signer is not ready") + } + journalSnapshot, err := readiness.journal.reconcile(ctx, point) + if err != nil { + return nil, err + } + return &frostProductionSignerReadinessSnapshot{ + Journal: journalSnapshot, + Inventory: &frostNativeSignerInventorySnapshot{ + Schema: "tbtc-signer-retained-key-package-inventory/v1", + StoreFingerprint: testFrostDurableSessionStoreIdentity().Fingerprint, + StateGeneration: 7, + StateCommitment: [32]byte{0x31}, + PreviousStateCommitment: [32]byte{0x30}, + StateImageDigest: [32]byte{0x33}, + InventoryCommitment: [32]byte{0x32}, + }, + InteractiveSigningReady: true, + }, nil +} + func (source *testFrostRetainedGroupHistorySource) Identity( context.Context, ) (FrostRetainedGroupHistoryIdentity, error) { @@ -123,6 +160,10 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { records: make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord), recovered: true, } + readiness := &testFrostProductionSignerReadiness{ + journal: journal, + interactive: true, + } exporter, err := newFrostActivationHandshakeExporter( endpoint, privateKeyPath, @@ -130,7 +171,7 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { verifier, testFrostDurableSessionStoreBinding(t), outbox, - journal, + readiness, ) if err != nil { t.Fatal(err) @@ -165,6 +206,8 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { handshake.Payload.Nonce != request.Challenge.Nonce || handshake.Payload.ManifestHash != request.Challenge.ManifestHash || !handshake.Payload.State.Healthy || + !handshake.Payload.State.InteractiveSigningReady || + !handshake.Payload.State.NonceShareGateEnforced || !handshake.Payload.State.DurableBitcoinOutboxRecovered || verifier.point.BlockNumber != point.BlockNumber || frostActivationHex32(verifier.point.BlockHash) != point.BlockHash || @@ -184,6 +227,7 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { "completeRouterAddress", "durableBitcoinOutboxRecovered", "durableSessionStoreFingerprint", "exactTransactionAuthorizationRootEnforced", "finalizedReservationReadbackEnforced", "frostWalletGroupInventory", "healthy", "maximumGroupSize", "nonceShareGateEnforced", + "interactiveSigningReady", "nativeSignerState", "protocolID", "quarantineFailClosed", "quarantineJournal", "reservationProtocolID", "retainedGroupInventoryProtocolID", "signingPolicyHash", "threshold", }) @@ -201,6 +245,11 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { "clusterFingerprint", "complete", "currentQuarantineCount", "generation", "protocolID", "root", "storeFingerprint", "storeID", }) + assertFrostActivationObjectKeys(t, handshake.Payload.State.NativeSignerState, []string{ + "complete", "externalRollbackAnchorBound", "inventoryCommitment", + "previousStateCommitment", "retainedKeyPackageCount", "retainedWalletCount", + "schema", "stateCommitment", "stateGeneration", "stateImageDigest", "storeFingerprint", + }) } func assertFrostActivationObjectKeys( @@ -265,6 +314,7 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { }, recovered: true, } + readiness := &testFrostProductionSignerReadiness{journal: journal, interactive: true} exporter, err := newFrostActivationHandshakeExporter( endpoint, privateKeyPath, @@ -272,7 +322,7 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { &testFrostActivationPointVerifier{}, testFrostDurableSessionStoreBinding(t), outbox, - journal, + readiness, ) if err != nil { t.Fatal(err) @@ -297,6 +347,17 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { if response.StatusCode != http.StatusServiceUnavailable { t.Fatalf("quarantined outbox returned status [%d]", response.StatusCode) } + response.Body.Close() + + outbox.mutex.Lock() + outbox.records = make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord) + outbox.mutex.Unlock() + readiness.interactive = false + response = postTestFrostActivationHandshake(t, endpoint, request) + defer response.Body.Close() + if response.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("unready interactive signer returned status [%d]", response.StatusCode) + } } func TestCanonicalFrostActivationValue_MatchesRuntimeOrdering(t *testing.T) { diff --git a/pkg/tbtc/frost_interactive_signing_readiness_default.go b/pkg/tbtc/frost_interactive_signing_readiness_default.go new file mode 100644 index 0000000000..5efad4eabb --- /dev/null +++ b/pkg/tbtc/frost_interactive_signing_readiness_default.go @@ -0,0 +1,7 @@ +//go:build !frost_native + +package tbtc + +func currentFrostInteractiveSigningReadiness() bool { + return false +} diff --git a/pkg/tbtc/frost_interactive_signing_readiness_frost_native.go b/pkg/tbtc/frost_interactive_signing_readiness_frost_native.go new file mode 100644 index 0000000000..f405a6081a --- /dev/null +++ b/pkg/tbtc/frost_interactive_signing_readiness_frost_native.go @@ -0,0 +1,14 @@ +//go:build frost_native + +package tbtc + +import frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + +func currentFrostInteractiveSigningReadiness() bool { + return completeFrostInteractiveSigningReadiness( + frostsigning.InteractiveSigningReady(), + frostsigning.InteractiveSigningOnlyEnabled(), + frostsigning.NativeExecutionAvailable(), + frostsigning.CurrentExecutionBackendName() == frostsigning.NativeExecutionBackendName, + ) +} diff --git a/pkg/tbtc/frost_interactive_signing_readiness_frost_native_test.go b/pkg/tbtc/frost_interactive_signing_readiness_frost_native_test.go new file mode 100644 index 0000000000..2bc65d5d9e --- /dev/null +++ b/pkg/tbtc/frost_interactive_signing_readiness_frost_native_test.go @@ -0,0 +1,53 @@ +//go:build frost_native + +package tbtc + +import ( + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +func TestCurrentFrostInteractiveSigningReadinessRejectsAbsentAndPartialRuntime( + t *testing.T, +) { + frostsigning.ResetInteractiveSigningEngineProviderForTest() + t.Cleanup(frostsigning.ResetInteractiveSigningEngineProviderForTest) + + t.Run("all flags and engine absent", func(t *testing.T) { + t.Setenv(frostsigning.InteractiveSigningOptInEnvVar, "") + t.Setenv(frostsigning.RoastRetryReadinessOptInEnvVar, "") + t.Setenv(frostsigning.InteractiveSigningOnlyEnvVar, "") + if currentFrostInteractiveSigningReadiness() { + t.Fatal("production readiness accepted an absent interactive runtime") + } + }) + + t.Run("only interactive opt-in", func(t *testing.T) { + t.Setenv(frostsigning.InteractiveSigningOptInEnvVar, "true") + t.Setenv(frostsigning.RoastRetryReadinessOptInEnvVar, "") + t.Setenv(frostsigning.InteractiveSigningOnlyEnvVar, "") + if currentFrostInteractiveSigningReadiness() { + t.Fatal("production readiness accepted partial interactive flags") + } + }) + + t.Run("missing interactive-only gate", func(t *testing.T) { + t.Setenv(frostsigning.InteractiveSigningOptInEnvVar, "true") + t.Setenv(frostsigning.RoastRetryReadinessOptInEnvVar, "true") + t.Setenv(frostsigning.InteractiveSigningOnlyEnvVar, "") + if currentFrostInteractiveSigningReadiness() { + t.Fatal("production readiness accepted coarse-fallback mode") + } + }) + + t.Run("engine absent with every flag", func(t *testing.T) { + t.Setenv(frostsigning.InteractiveSigningOptInEnvVar, "true") + t.Setenv(frostsigning.RoastRetryReadinessOptInEnvVar, "true") + t.Setenv(frostsigning.InteractiveSigningOnlyEnvVar, "true") + frostsigning.ResetInteractiveSigningEngineProviderForTest() + if currentFrostInteractiveSigningReadiness() { + t.Fatal("production readiness accepted an absent interactive engine") + } + }) +} diff --git a/pkg/tbtc/frost_native_signer_readiness.go b/pkg/tbtc/frost_native_signer_readiness.go new file mode 100644 index 0000000000..c8e86ad5c5 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_readiness.go @@ -0,0 +1,396 @@ +package tbtc + +import ( + "bytes" + "context" + "fmt" + "sort" + "sync" + + "github.com/keep-network/keep-core/pkg/chain" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +const frostNativeSignerStateWitnessMaximumPages = 16 + +func completeFrostInteractiveSigningReadiness( + interactivePathReady bool, + interactiveOnly bool, + nativeExecutionAvailable bool, + nativeBackendSelected bool, +) bool { + return interactivePathReady && interactiveOnly && + nativeExecutionAvailable && nativeBackendSelected +} + +// FrostNativeSignerStateWitnessAnchor is an independently durable accepted +// tip of the native signer's append-only state-witness chain. It is dynamic +// state and deliberately is not folded into the immutable activation manifest +// or stable store identity. +type FrostNativeSignerStateWitnessAnchor struct { + StoreFingerprint [32]byte + Generation uint64 + Commitment [32]byte +} + +// FrostNativeSignerStateWitnessAnchorSource supplies the last state witness +// accepted by the independent activation/watchtower system. Its backing store +// must be outside the signer state directory and must atomically persist an +// accepted handshake before treating that handshake as active. +// +// A local signer file cannot implement this interface safely: coordinated +// rollback of that file and the signer state would otherwise be invisible. +type FrostNativeSignerStateWitnessAnchorSource interface { + ReadFrostNativeSignerStateWitnessAnchor( + context.Context, + ) (*FrostNativeSignerStateWitnessAnchor, error) +} + +type frostNativeSignerInventoryReader func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, +) + +type frostNativeSignerStateWitnessProofReader func( + *frostsigning.NativeTBTCSignerStateWitnessProofRequest, +) (*frostsigning.NativeTBTCSignerStateWitnessProof, error) + +type frostNativeSignerInventoryExpectation struct { + WalletID [32]byte + Threshold uint16 + ParticipantCount uint16 + ShareEpoch uint64 + ParticipantSeats []uint16 +} + +type frostNativeSignerInventorySnapshot struct { + Schema string + StoreFingerprint [32]byte + StateGeneration uint64 + StateCommitment [32]byte + PreviousStateCommitment [32]byte + StateImageDigest [32]byte + InventoryCommitment [32]byte + WalletCount uint64 + KeyPackageCount uint64 +} + +type frostNativeSignerInventoryBinding struct { + storeBinding *frostDurableSessionStoreBinding + anchorSource FrostNativeSignerStateWitnessAnchorSource + readInventory frostNativeSignerInventoryReader + readProof frostNativeSignerStateWitnessProofReader + + mutex sync.Mutex + baseline *FrostNativeSignerStateWitnessAnchor +} + +func newFrostNativeSignerInventoryBinding( + storeBinding *frostDurableSessionStoreBinding, + anchorSource FrostNativeSignerStateWitnessAnchorSource, + readInventory frostNativeSignerInventoryReader, + readProof frostNativeSignerStateWitnessProofReader, +) (*frostNativeSignerInventoryBinding, error) { + if storeBinding == nil || anchorSource == nil || readInventory == nil || readProof == nil { + return nil, fmt.Errorf("FROST native signer inventory dependencies are incomplete") + } + if _, err := storeBinding.verify(); err != nil { + return nil, fmt.Errorf("FROST native signer inventory store is not bound: [%w]", err) + } + return &frostNativeSignerInventoryBinding{ + storeBinding: storeBinding, + anchorSource: anchorSource, + readInventory: readInventory, + readProof: readProof, + }, nil +} + +func (binding *frostNativeSignerInventoryBinding) verify( + ctx context.Context, + expected []frostNativeSignerInventoryExpectation, +) (*frostNativeSignerInventorySnapshot, error) { + if binding == nil { + return nil, fmt.Errorf("FROST native signer inventory binding is nil") + } + if ctx == nil { + return nil, fmt.Errorf("FROST native signer inventory context is nil") + } + binding.mutex.Lock() + defer binding.mutex.Unlock() + + storeFingerprint, err := binding.storeBinding.verify() + if err != nil { + return nil, fmt.Errorf("FROST native signer store binding failed: [%w]", err) + } + anchor, err := binding.anchorSource.ReadFrostNativeSignerStateWitnessAnchor(ctx) + if err != nil { + return nil, fmt.Errorf("cannot read independent native signer state-witness anchor: [%w]", err) + } + if err := validateFrostNativeSignerStateWitnessAnchor(anchor, storeFingerprint); err != nil { + return nil, err + } + + inventory, err := binding.readInventory() + if err != nil { + return nil, fmt.Errorf("cannot read native retained key-package inventory: [%w]", err) + } + if inventory == nil || inventory.StoreFingerprint != storeFingerprint { + return nil, fmt.Errorf("native retained key-package inventory is absent or belongs to another store") + } + target := FrostNativeSignerStateWitnessAnchor{ + StoreFingerprint: inventory.StoreFingerprint, + Generation: inventory.StateGeneration, + Commitment: inventory.StateCommitment, + } + if err := binding.verifyAncestry(anchor, &target); err != nil { + return nil, fmt.Errorf("native signer state does not descend from the independent anchor: [%w]", err) + } + if binding.baseline != nil { + if err := binding.verifyAncestry(binding.baseline, &target); err != nil { + return nil, fmt.Errorf("native signer state does not descend from the process baseline: [%w]", err) + } + } + if err := verifyFrostNativeSignerInventoryEntries(inventory.Entries, expected); err != nil { + return nil, err + } + + binding.baseline = &FrostNativeSignerStateWitnessAnchor{ + StoreFingerprint: target.StoreFingerprint, + Generation: target.Generation, + Commitment: target.Commitment, + } + keyPackageCount := uint64(0) + for _, entry := range inventory.Entries { + keyPackageCount += uint64(len(entry.KeyPackages)) + } + return &frostNativeSignerInventorySnapshot{ + Schema: inventory.Schema, + StoreFingerprint: inventory.StoreFingerprint, + StateGeneration: inventory.StateGeneration, + StateCommitment: inventory.StateCommitment, + PreviousStateCommitment: inventory.PreviousStateCommitment, + StateImageDigest: inventory.StateImageDigest, + InventoryCommitment: inventory.InventoryCommitment, + WalletCount: uint64(len(inventory.Entries)), + KeyPackageCount: keyPackageCount, + }, nil +} + +func validateFrostNativeSignerStateWitnessAnchor( + anchor *FrostNativeSignerStateWitnessAnchor, + storeFingerprint [32]byte, +) error { + if anchor == nil || anchor.StoreFingerprint != storeFingerprint || + anchor.Generation == 0 || anchor.Commitment == [32]byte{} { + return fmt.Errorf("independent native signer state-witness anchor is invalid") + } + return nil +} + +func (binding *frostNativeSignerInventoryBinding) verifyAncestry( + ancestor *FrostNativeSignerStateWitnessAnchor, + target *FrostNativeSignerStateWitnessAnchor, +) error { + if ancestor == nil || target == nil || + ancestor.StoreFingerprint != target.StoreFingerprint || + ancestor.Generation == 0 || target.Generation == 0 || + ancestor.Commitment == [32]byte{} || target.Commitment == [32]byte{} || + target.Generation < ancestor.Generation { + return fmt.Errorf("state-witness ancestry bounds are invalid") + } + if target.Generation == ancestor.Generation && target.Commitment != ancestor.Commitment { + return fmt.Errorf("equal state-witness generations have different commitments") + } + + cursorGeneration := ancestor.Generation + cursorCommitment := ancestor.Commitment + for page := 0; page < frostNativeSignerStateWitnessMaximumPages; page++ { + request := &frostsigning.NativeTBTCSignerStateWitnessProofRequest{ + Schema: frostsigning.NativeTBTCSignerStateWitnessProofRequestSchema, + StoreFingerprint: target.StoreFingerprint, + AncestorGeneration: cursorGeneration, + AncestorCommitment: cursorCommitment, + TargetGeneration: target.Generation, + TargetCommitment: target.Commitment, + MaximumEntries: frostsigning.NativeTBTCSignerStateWitnessProofMaximumEntries, + } + proof, err := binding.readProof(request) + if err != nil { + return fmt.Errorf("cannot read native signer state-witness proof: [%w]", err) + } + if proof == nil || proof.Schema != frostsigning.NativeTBTCSignerStateWitnessProofSchema || + proof.StoreFingerprint != request.StoreFingerprint || + proof.AncestorGeneration != request.AncestorGeneration || + proof.AncestorCommitment != request.AncestorCommitment || + proof.TargetGeneration != request.TargetGeneration || + proof.TargetCommitment != request.TargetCommitment || + len(proof.Entries) > int(request.MaximumEntries) { + return fmt.Errorf("native signer returned a proof for different ancestry bounds") + } + if proof.Complete { + return nil + } + if len(proof.Entries) == 0 { + return fmt.Errorf("native signer state-witness proof made no progress") + } + last := proof.Entries[len(proof.Entries)-1] + cursorGeneration = last.Generation + cursorCommitment = last.StateCommitment + } + return fmt.Errorf( + "native signer state-witness ancestry exceeds the bounded proof window [%d]", + frostNativeSignerStateWitnessMaximumPages*int(frostsigning.NativeTBTCSignerStateWitnessProofMaximumEntries), + ) +} + +func verifyFrostNativeSignerInventoryEntries( + actual []frostsigning.NativeTBTCSignerRetainedKeyGroup, + expected []frostNativeSignerInventoryExpectation, +) error { + expectedCopy := append([]frostNativeSignerInventoryExpectation{}, expected...) + for index := range expectedCopy { + expectedCopy[index].ParticipantSeats = append( + []uint16{}, + expectedCopy[index].ParticipantSeats..., + ) + sort.Slice(expectedCopy[index].ParticipantSeats, func(i, j int) bool { + return expectedCopy[index].ParticipantSeats[i] < expectedCopy[index].ParticipantSeats[j] + }) + } + sort.Slice(expectedCopy, func(i, j int) bool { + return bytes.Compare(expectedCopy[i].WalletID[:], expectedCopy[j].WalletID[:]) < 0 + }) + if len(actual) != len(expectedCopy) { + return fmt.Errorf( + "native retained key-group count [%d] differs from canonical local membership count [%d]", + len(actual), len(expectedCopy), + ) + } + for index := range expectedCopy { + actualEntry := actual[index] + expectedEntry := expectedCopy[index] + if actualEntry.WalletID != expectedEntry.WalletID || + actualEntry.Threshold != expectedEntry.Threshold || + actualEntry.ParticipantCount != expectedEntry.ParticipantCount || + actualEntry.ShareEpoch != expectedEntry.ShareEpoch || + len(actualEntry.KeyPackages) != len(expectedEntry.ParticipantSeats) { + return fmt.Errorf("native retained key group differs from canonical wallet membership or epoch") + } + for packageIndex, expectedSeat := range expectedEntry.ParticipantSeats { + if actualEntry.KeyPackages[packageIndex].ParticipantSeat != expectedSeat { + return fmt.Errorf("native retained key-package seats differ from canonical local seats") + } + } + } + return nil +} + +type frostProductionSignerReadinessSnapshot struct { + Journal *frostRetainedGroupJournalSnapshot + Inventory *frostNativeSignerInventorySnapshot + InteractiveSigningReady bool +} + +type frostProductionSignerReadinessVerifier interface { + verifyFrostProductionSignerReadiness( + context.Context, + FrostPreSignFinality, + ) (*frostProductionSignerReadinessSnapshot, error) +} + +type frostProductionSignerReadiness struct { + interactiveSigningReady func() bool + journal *frostRetainedGroupJournal + inventoryBinding *frostNativeSignerInventoryBinding +} + +func newFrostProductionSignerReadiness( + interactiveSigningReady func() bool, + journal *frostRetainedGroupJournal, + inventoryBinding *frostNativeSignerInventoryBinding, +) (*frostProductionSignerReadiness, error) { + if interactiveSigningReady == nil || journal == nil || inventoryBinding == nil { + return nil, fmt.Errorf("FROST production signer readiness dependencies are incomplete") + } + return &frostProductionSignerReadiness{ + interactiveSigningReady: interactiveSigningReady, + journal: journal, + inventoryBinding: inventoryBinding, + }, nil +} + +func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerReadiness( + ctx context.Context, + point FrostPreSignFinality, +) (*frostProductionSignerReadinessSnapshot, error) { + if readiness == nil || readiness.interactiveSigningReady == nil || + !readiness.interactiveSigningReady() { + return nil, fmt.Errorf("interactive FROST signing engine is not ready") + } + journalSnapshot, err := readiness.journal.reconcile(ctx, point) + if err != nil { + return nil, fmt.Errorf("cannot reconcile canonical FROST retained groups: [%w]", err) + } + expected, err := readiness.journal.nativeSignerInventoryExpectations(ctx, point) + if err != nil { + return nil, err + } + firstInventory, err := readiness.inventoryBinding.verify(ctx, expected) + if err != nil { + return nil, err + } + secondInventory, err := readiness.inventoryBinding.verify(ctx, expected) + if err != nil { + return nil, err + } + if *firstInventory != *secondInventory { + return nil, fmt.Errorf("native signer state changed during readiness reconciliation") + } + if !readiness.interactiveSigningReady() { + return nil, fmt.Errorf("interactive FROST signing engine became unavailable during reconciliation") + } + return &frostProductionSignerReadinessSnapshot{ + Journal: journalSnapshot, + Inventory: secondInventory, + InteractiveSigningReady: true, + }, nil +} + +func (journal *frostRetainedGroupJournal) nativeSignerInventoryExpectations( + ctx context.Context, + point FrostPreSignFinality, +) ([]frostNativeSignerInventoryExpectation, error) { + if journal == nil || ctx == nil { + return nil, fmt.Errorf("FROST retained-group journal expectation context is invalid") + } + localOperatorID, err := journal.source.ResolveOperatorID(ctx, journal.operatorAddress, point) + if err != nil || localOperatorID == 0 { + return nil, fmt.Errorf("cannot resolve local operator for native signer inventory: [%w]", err) + } + + journal.mutex.Lock() + defer journal.mutex.Unlock() + if journal.closed || journal.state.CurrentPoint != point { + return nil, fmt.Errorf("canonical retained-group journal moved during native signer reconciliation") + } + expected := make([]frostNativeSignerInventoryExpectation, 0) + for _, wallet := range journal.state.Wallets { + seats := make([]uint16, 0) + for index, operatorID := range wallet.OperatorIDs { + if chain.OperatorID(operatorID) == localOperatorID { + seats = append(seats, uint16(index+1)) + } + } + if len(seats) == 0 { + continue + } + expected = append(expected, frostNativeSignerInventoryExpectation{ + WalletID: wallet.WalletID, + Threshold: frostPreSignAuthorizationThreshold, + ParticipantCount: uint16(len(wallet.OperatorIDs)), + ShareEpoch: 0, + ParticipantSeats: seats, + }) + } + return expected, nil +} diff --git a/pkg/tbtc/frost_native_signer_readiness_test.go b/pkg/tbtc/frost_native_signer_readiness_test.go new file mode 100644 index 0000000000..9894103aad --- /dev/null +++ b/pkg/tbtc/frost_native_signer_readiness_test.go @@ -0,0 +1,281 @@ +package tbtc + +import ( + "context" + "encoding/hex" + "fmt" + "strings" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +type testFrostNativeSignerStateWitnessAnchorSource struct { + anchor *FrostNativeSignerStateWitnessAnchor + err error +} + +func (source *testFrostNativeSignerStateWitnessAnchorSource) ReadFrostNativeSignerStateWitnessAnchor( + context.Context, +) (*FrostNativeSignerStateWitnessAnchor, error) { + if source.err != nil { + return nil, source.err + } + if source.anchor == nil { + return nil, nil + } + result := *source.anchor + return &result, nil +} + +func TestCompleteFrostInteractiveSigningReadiness_RequiresEveryRuntimeGate( + t *testing.T, +) { + tests := []struct { + name string + interactivePathReady bool + interactiveOnly bool + nativeExecution bool + nativeBackend bool + expected bool + }{ + {"all absent", false, false, false, false, false}, + {"engine and flags absent", false, true, false, true, false}, + {"interactive opt-ins absent", false, true, true, true, false}, + {"interactive-only absent", true, false, true, true, false}, + {"native engine absent", true, true, false, true, false}, + {"native backend absent", true, true, true, false, false}, + {"complete interactive path", true, true, true, true, true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := completeFrostInteractiveSigningReadiness( + test.interactivePathReady, + test.interactiveOnly, + test.nativeExecution, + test.nativeBackend, + ) + if actual != test.expected { + t.Fatalf("unexpected readiness: got [%v], want [%v]", actual, test.expected) + } + }) + } +} + +func TestVerifyFrostNativeSignerInventoryEntriesRejectsMismatch(t *testing.T) { + walletID := [32]byte{0x21} + valid := []frostsigning.NativeTBTCSignerRetainedKeyGroup{ + { + WalletID: walletID, + KeyGroup: hex.EncodeToString(walletID[:]), + Threshold: 51, + ParticipantCount: 100, + ShareEpoch: 0, + PublicKeyPackageCommitment: [32]byte{0x22}, + KeyPackages: []frostsigning.NativeTBTCSignerRetainedKeyPackage{ + {ParticipantSeat: 4, KeyPackageCommitment: [32]byte{0x23}}, + }, + }, + } + expected := []frostNativeSignerInventoryExpectation{ + { + WalletID: walletID, + Threshold: 51, + ParticipantCount: 100, + ShareEpoch: 0, + ParticipantSeats: []uint16{4}, + }, + } + if err := verifyFrostNativeSignerInventoryEntries(valid, expected); err != nil { + t.Fatalf("valid native inventory was rejected: [%v]", err) + } + + tests := map[string]func([]frostsigning.NativeTBTCSignerRetainedKeyGroup) []frostsigning.NativeTBTCSignerRetainedKeyGroup{ + "missing group": func([]frostsigning.NativeTBTCSignerRetainedKeyGroup) []frostsigning.NativeTBTCSignerRetainedKeyGroup { + return nil + }, + "wrong wallet": func(entries []frostsigning.NativeTBTCSignerRetainedKeyGroup) []frostsigning.NativeTBTCSignerRetainedKeyGroup { + entries[0].WalletID[0] ^= 0xff + return entries + }, + "wrong participant seat": func(entries []frostsigning.NativeTBTCSignerRetainedKeyGroup) []frostsigning.NativeTBTCSignerRetainedKeyGroup { + entries[0].KeyPackages[0].ParticipantSeat = 5 + return entries + }, + "stale share epoch": func(entries []frostsigning.NativeTBTCSignerRetainedKeyGroup) []frostsigning.NativeTBTCSignerRetainedKeyGroup { + entries[0].ShareEpoch = 1 + return entries + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + entries := append([]frostsigning.NativeTBTCSignerRetainedKeyGroup{}, valid...) + entries[0].KeyPackages = append( + []frostsigning.NativeTBTCSignerRetainedKeyPackage{}, + valid[0].KeyPackages..., + ) + if err := verifyFrostNativeSignerInventoryEntries(mutate(entries), expected); err == nil { + t.Fatal("mismatched native inventory was accepted") + } + }) + } +} + +func TestFrostNativeSignerInventoryBindingRequiresAnchoredDescendant(t *testing.T) { + storeBinding := testFrostDurableSessionStoreBinding(t) + storeFingerprint, err := storeBinding.verify() + if err != nil { + t.Fatal(err) + } + ancestor := [32]byte{0x41} + stateImage := [32]byte{0x42} + target := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + 2, + ancestor, + stateImage, + ) + walletID := [32]byte{0x43} + entries := []frostsigning.NativeTBTCSignerRetainedKeyGroup{ + { + WalletID: walletID, + KeyGroup: hex.EncodeToString(walletID[:]), + Threshold: 51, + ParticipantCount: 100, + PublicKeyPackageCommitment: [32]byte{0x44}, + KeyPackages: []frostsigning.NativeTBTCSignerRetainedKeyPackage{ + {ParticipantSeat: 2, KeyPackageCommitment: [32]byte{0x45}}, + }, + }, + } + inventory := &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Schema: frostsigning.NativeTBTCSignerRetainedKeyPackageInventorySchema, + StoreFingerprint: storeFingerprint, + StateGeneration: 2, + StateCommitment: target, + PreviousStateCommitment: ancestor, + StateImageDigest: stateImage, + InventoryCommitment: frostsigning.ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment( + entries, + ), + Entries: entries, + } + anchorSource := &testFrostNativeSignerStateWitnessAnchorSource{ + anchor: &FrostNativeSignerStateWitnessAnchor{ + StoreFingerprint: storeFingerprint, + Generation: 1, + Commitment: ancestor, + }, + } + proofReader := func( + request *frostsigning.NativeTBTCSignerStateWitnessProofRequest, + ) (*frostsigning.NativeTBTCSignerStateWitnessProof, error) { + proof := &frostsigning.NativeTBTCSignerStateWitnessProof{ + Schema: frostsigning.NativeTBTCSignerStateWitnessProofSchema, + StoreFingerprint: request.StoreFingerprint, + AncestorGeneration: request.AncestorGeneration, + AncestorCommitment: request.AncestorCommitment, + TargetGeneration: request.TargetGeneration, + TargetCommitment: request.TargetCommitment, + Complete: true, + } + if request.AncestorGeneration == request.TargetGeneration { + return proof, nil + } + if request.AncestorGeneration != 1 || request.AncestorCommitment != ancestor { + return nil, fmt.Errorf("unknown ancestor") + } + proof.Entries = []frostsigning.NativeTBTCSignerStateWitnessProofEntry{ + { + Generation: 2, + PreviousStateCommitment: ancestor, + StateCommitment: target, + StateImageDigest: stateImage, + }, + } + return proof, nil + } + binding, err := newFrostNativeSignerInventoryBinding( + storeBinding, + anchorSource, + func() (*frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error) { + return inventory, nil + }, + proofReader, + ) + if err != nil { + t.Fatal(err) + } + expected := []frostNativeSignerInventoryExpectation{ + { + WalletID: walletID, + Threshold: 51, + ParticipantCount: 100, + ParticipantSeats: []uint16{2}, + }, + } + if _, err := binding.verify(context.Background(), expected); err != nil { + t.Fatalf("anchored descendant inventory was rejected: [%v]", err) + } + + anchorSource.anchor = &FrostNativeSignerStateWitnessAnchor{ + StoreFingerprint: storeFingerprint, + Generation: 2, + Commitment: [32]byte{0xff}, + } + if _, err := binding.verify(context.Background(), expected); err == nil || + !strings.Contains(err.Error(), "equal state-witness generations") { + t.Fatalf("equal-generation rollback fork was accepted: [%v]", err) + } + + anchorSource.anchor = &FrostNativeSignerStateWitnessAnchor{ + StoreFingerprint: storeFingerprint, + Generation: 3, + Commitment: [32]byte{0xfe}, + } + if _, err := binding.verify(context.Background(), expected); err == nil || + !strings.Contains(err.Error(), "ancestry bounds") { + t.Fatalf("lower-generation rolled-back signer state was accepted: [%v]", err) + } +} + +func TestFrostRetainedGroupJournalNativeInventoryExpectationsIncludeTerminalGroups( + t *testing.T, +) { + point := FrostPreSignFinality{BlockNumber: 17, BlockHash: [32]byte{0x61}} + operatorIDs := make([]uint32, 51) + for index := range operatorIDs { + operatorIDs[index] = 2 + } + operatorIDs[1] = 1 + operatorIDs[2] = 1 + journal := &frostRetainedGroupJournal{ + source: &testFrostRetainedGroupHistorySource{}, + operatorAddress: "0x01", + state: frostRetainedGroupJournalState{ + CurrentPoint: point, + Wallets: []frostRetainedGroupWalletState{ + { + WalletID: [32]byte{0x62}, + OperatorIDs: operatorIDs, + Lifecycle: FrostRetainedGroupClosed, + }, + { + WalletID: [32]byte{0x63}, + OperatorIDs: []uint32{4, 5}, + Lifecycle: FrostRetainedGroupLive, + }, + }, + }, + } + expected, err := journal.nativeSignerInventoryExpectations(context.Background(), point) + if err != nil { + t.Fatal(err) + } + if len(expected) != 1 || expected[0].WalletID != [32]byte{0x62} || + expected[0].ParticipantCount != 51 || expected[0].ShareEpoch != 0 || + len(expected[0].ParticipantSeats) != 2 || + expected[0].ParticipantSeats[0] != 2 || expected[0].ParticipantSeats[1] != 3 { + t.Fatalf("unexpected native inventory expectations: %+v", expected) + } +} diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index bacc6cb65a..3c102638e2 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1350,6 +1350,7 @@ type thresholdFrostPreSignAuthorizationGate struct { backend FrostPreSignAuthorizationBackend activationProfile FrostPreSignActivationProfile storeBinding *frostDurableSessionStoreBinding + productionReadiness frostProductionSignerReadinessVerifier signing chain.Signing broadcastChannel net.BroadcastChannel membershipValidator *group.MembershipValidator @@ -1362,6 +1363,7 @@ func newThresholdFrostPreSignAuthorizationGate( backend FrostPreSignAuthorizationBackend, activationProfile FrostPreSignActivationProfile, storeBinding *frostDurableSessionStoreBinding, + productionReadiness frostProductionSignerReadinessVerifier, signing chain.Signing, broadcastChannel net.BroadcastChannel, membershipValidator *group.MembershipValidator, @@ -1377,6 +1379,9 @@ func newThresholdFrostPreSignAuthorizationGate( if _, err := storeBinding.verify(); err != nil { return nil, fmt.Errorf("FROST durable session store is not activation-ready: [%w]", err) } + if productionReadiness == nil { + return nil, fmt.Errorf("FROST production signer readiness verifier is nil") + } if signing == nil { return nil, fmt.Errorf("FROST pre-sign Ethereum signer is nil") } @@ -1409,6 +1414,7 @@ func newThresholdFrostPreSignAuthorizationGate( backend: backend, activationProfile: activationProfile, storeBinding: storeBinding, + productionReadiness: productionReadiness, signing: signing, broadcastChannel: broadcastChannel, membershipValidator: membershipValidator, @@ -1425,6 +1431,9 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) authorize( if ctx == nil { return nil, fmt.Errorf("FROST pre-sign authorization context is nil") } + if _, err := tfpsag.verifyCurrentProductionSignerReadiness(ctx); err != nil { + return nil, err + } if _, err := tfpsag.storeBinding.verify(); err != nil { return nil, fmt.Errorf("FROST durable session store binding failed: [%w]", err) } @@ -1514,6 +1523,10 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( ctx context.Context, authorization *frostPreSignAuthorization, ) error { + currentFinality, err := tfpsag.verifyCurrentProductionSignerReadiness(ctx) + if err != nil { + return err + } if _, err := tfpsag.storeBinding.verify(); err != nil { return fmt.Errorf("FROST durable session store binding failed: [%w]", err) } @@ -1546,12 +1559,7 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( // any later finalized block. Pin the latest finalized head and require the // exact reservation/variant to remain active there before every nonce, share, // signature, enqueue, or replay release boundary. - currentFinality, err := tfpsag.backend.CurrentFrostPreSignFinality(ctx) - if err != nil { - return fmt.Errorf("cannot obtain current FROST pre-sign finality: [%w]", err) - } - if currentFinality == nil || - currentFinality.BlockNumber < authorization.Finality.BlockNumber || + if currentFinality.BlockNumber < authorization.Finality.BlockNumber || currentFinality.BlockHash == [32]byte{} { return fmt.Errorf("invalid current FROST pre-sign finalized checkpoint") } @@ -1567,10 +1575,42 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( ); err != nil { return err } + if _, err := tfpsag.productionReadiness.verifyFrostProductionSignerReadiness( + ctx, + *currentFinality, + ); err != nil { + return fmt.Errorf("FROST signer readiness changed during authorization revalidation: [%w]", err) + } return nil } +func (tfpsag *thresholdFrostPreSignAuthorizationGate) verifyCurrentProductionSignerReadiness( + ctx context.Context, +) (*FrostPreSignFinality, error) { + if tfpsag == nil || tfpsag.backend == nil || tfpsag.productionReadiness == nil { + return nil, fmt.Errorf("FROST production signer readiness dependencies are incomplete") + } + if ctx == nil { + return nil, fmt.Errorf("FROST production signer readiness context is nil") + } + currentFinality, err := tfpsag.backend.CurrentFrostPreSignFinality(ctx) + if err != nil { + return nil, fmt.Errorf("cannot obtain current FROST pre-sign finality: [%w]", err) + } + if currentFinality == nil || currentFinality.BlockNumber == 0 || + currentFinality.BlockHash == [32]byte{} { + return nil, fmt.Errorf("invalid current FROST pre-sign finalized checkpoint") + } + if _, err := tfpsag.productionReadiness.verifyFrostProductionSignerReadiness( + ctx, + *currentFinality, + ); err != nil { + return nil, fmt.Errorf("FROST production signer is not authorization-ready: [%w]", err) + } + return currentFinality, nil +} + func (tfpsag *thresholdFrostPreSignAuthorizationGate) validatePinnedAuthorizationStateTwice( ctx context.Context, proposal *FrostPreSignAuthorizationProposal, diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go index f20b271f58..445e575faf 100644 --- a/pkg/tbtc/frost_pre_sign_authorization_test.go +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -3,6 +3,7 @@ package tbtc import ( "context" "encoding/hex" + "fmt" "math/big" "strings" "sync" @@ -13,6 +14,26 @@ import ( "github.com/keep-network/keep-core/pkg/protocol/group" ) +type testFrostProductionAuthorizationReadiness struct { + err error + calls uint64 + points []FrostPreSignFinality +} + +func (readiness *testFrostProductionAuthorizationReadiness) verifyFrostProductionSignerReadiness( + _ context.Context, + point FrostPreSignFinality, +) (*frostProductionSignerReadinessSnapshot, error) { + readiness.calls++ + readiness.points = append(readiness.points, point) + if readiness.err != nil { + return nil, readiness.err + } + return &frostProductionSignerReadinessSnapshot{ + InteractiveSigningReady: true, + }, nil +} + func testFrostPreSignTransaction( t *testing.T, unsignedTx *bitcoin.TransactionBuilder, @@ -213,9 +234,10 @@ func TestThresholdFrostPreSignAuthorizationGate_RevalidatesCurrentFinalizedState }, } gate := &thresholdFrostPreSignAuthorizationGate{ - backend: backend, - activationProfile: activationProfileForTestProposal(proposal), - storeBinding: testFrostDurableSessionStoreBinding(t), + backend: backend, + activationProfile: activationProfileForTestProposal(proposal), + storeBinding: testFrostDurableSessionStoreBinding(t), + productionReadiness: &testFrostProductionAuthorizationReadiness{}, } authorization := &frostPreSignAuthorization{ ActivationProfileHash: gate.activationProfile.ProfileHash, @@ -242,6 +264,54 @@ func TestThresholdFrostPreSignAuthorizationGate_RevalidatesCurrentFinalizedState } } +func TestThresholdFrostPreSignAuthorizationGate_RefusesUnreadyInteractiveSigner( + t *testing.T, +) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + transaction := testFrostPreSignTransaction(t, unsignedTx) + proposal := completeTestFrostPreSignProposal(transaction) + currentFinality := &FrostPreSignFinality{ + BlockNumber: 101, + BlockHash: [32]byte{0x73}, + } + for _, condition := range []string{ + "all interactive flags absent", + "interactive engine absent", + "interactive-only gate absent", + } { + t.Run(condition, func(t *testing.T) { + readiness := &testFrostProductionAuthorizationReadiness{ + err: fmt.Errorf("%s", condition), + } + gate := &thresholdFrostPreSignAuthorizationGate{ + backend: &testFrostPreSignAuthorizationBackend{ + proposal: proposal, + currentFinality: currentFinality, + }, + activationProfile: activationProfileForTestProposal(proposal), + storeBinding: testFrostDurableSessionStoreBinding(t), + productionReadiness: readiness, + } + if _, err := gate.authorize(context.Background(), transaction); err == nil || + !strings.Contains(err.Error(), "not authorization-ready") { + t.Fatalf("authorization accepted unready signer: [%v]", err) + } + if readiness.calls != 1 { + t.Fatalf("authorization readiness called [%d] times", readiness.calls) + } + + authorization := &frostPreSignAuthorization{proposal: proposal} + if err := gate.revalidate(context.Background(), authorization); err == nil || + !strings.Contains(err.Error(), "not authorization-ready") { + t.Fatalf("revalidation accepted unready signer: [%v]", err) + } + if readiness.calls != 2 { + t.Fatalf("revalidation readiness called [%d] total times", readiness.calls) + } + }) + } +} + func TestFrostPreSignActivationProfile_IndependentlyPinsProposal(t *testing.T) { unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) transaction := testFrostPreSignTransaction(t, unsignedTx) @@ -501,11 +571,16 @@ func TestThresholdFrostPreSignAuthorizationGate_ProfileMismatchPrecedesSeatSigna countingSigner := &countingFrostPreSignChainSigning{ Signing: Connect().Signing(), } + currentFinality := &FrostPreSignFinality{BlockNumber: 9, BlockHash: [32]byte{0x70}} gate := &thresholdFrostPreSignAuthorizationGate{ - backend: &testFrostPreSignAuthorizationBackend{proposal: proposal}, - activationProfile: profile, - storeBinding: testFrostDurableSessionStoreBinding(t), - signing: countingSigner, + backend: &testFrostPreSignAuthorizationBackend{ + proposal: proposal, + currentFinality: currentFinality, + }, + activationProfile: profile, + storeBinding: testFrostDurableSessionStoreBinding(t), + productionReadiness: &testFrostProductionAuthorizationReadiness{}, + signing: countingSigner, wallet: wallet{ signingGroupOperators: make( []chain.Address, diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index 4348b37751..300766e39a 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -68,6 +68,7 @@ type node struct { frostPreSignAuthorizationBackend FrostPreSignAuthorizationBackend frostPreSignActivationProfile FrostPreSignActivationProfile frostDurableSessionStoreBinding *frostDurableSessionStoreBinding + frostProductionSignerReadiness frostProductionSignerReadinessVerifier bitcoinBroadcastOutbox *bitcoinBroadcastOutbox frostRetainedGroupJournal *frostRetainedGroupJournal frostActivationHandshakeExporter *frostActivationHandshakeExporter @@ -239,6 +240,11 @@ func newNode( } if config.EnableFrostPreSignAuthorization { + if !currentFrostInteractiveSigningReadiness() { + return nil, fmt.Errorf( + "cannot enable FROST pre-sign authorization: interactive native signer is not ready", + ) + } activationProfile := config.FrostPreSignActivationProfile if configurator, ok := chain.(FrostPreSignAuthorizationConfigurator); ok { if config.FrostPreSignActivationManifestPath == "" { @@ -341,6 +347,19 @@ func newNode( err, ) } + inventoryBinding, err := newFrostNativeSignerInventoryBinding( + storeBinding, + config.FrostNativeSignerStateWitnessAnchorSource, + signing.ReadNativeTBTCSignerRetainedKeyPackageInventory, + signing.ReadNativeTBTCSignerStateWitnessProof, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot bind the native FROST key-package inventory and rollback witness: [%w]", + err, + ) + } if config.FrostRetainedGroupHistorySource == nil { _ = outbox.close() return nil, fmt.Errorf( @@ -361,6 +380,44 @@ func newNode( return nil, fmt.Errorf("cannot initialize canonical FROST retained-group journal: [%w]", err) } node.frostRetainedGroupJournal = journal + readiness, err := newFrostProductionSignerReadiness( + currentFrostInteractiveSigningReadiness, + journal, + inventoryBinding, + ) + if err != nil { + _ = journal.close() + _ = outbox.close() + return nil, fmt.Errorf("cannot initialize FROST signer readiness: [%w]", err) + } + startupFinality, err := backend.CurrentFrostPreSignFinality(context.Background()) + if err != nil { + _ = journal.close() + _ = outbox.close() + return nil, fmt.Errorf( + "cannot obtain the startup FROST signer-readiness checkpoint: [%w]", + err, + ) + } + if startupFinality == nil || startupFinality.BlockNumber == 0 || + startupFinality.BlockHash == [32]byte{} { + _ = journal.close() + _ = outbox.close() + return nil, fmt.Errorf( + "cannot obtain a valid startup FROST signer-readiness checkpoint", + ) + } + if _, err := readiness.verifyFrostProductionSignerReadiness( + context.Background(), + *startupFinality, + ); err != nil { + _ = journal.close() + _ = outbox.close() + return nil, fmt.Errorf( + "cannot enable FROST pre-sign authorization with an unready signer: [%w]", + err, + ) + } exporter, err := newFrostActivationHandshakeExporter( config.FrostActivationHandshakeURL, config.FrostActivationHandshakePrivateKeyPath, @@ -368,7 +425,7 @@ func newNode( pointVerifier, storeBinding, outbox, - journal, + readiness, ) if err != nil { _ = journal.close() @@ -378,6 +435,7 @@ func newNode( node.frostPreSignAuthorizationBackend = backend node.frostPreSignActivationProfile = verifiedActivationProfile node.frostDurableSessionStoreBinding = storeBinding + node.frostProductionSignerReadiness = readiness node.bitcoinBroadcastOutbox = outbox node.frostActivationHandshakeExporter = exporter } @@ -696,6 +754,7 @@ func (n *node) getSigningExecutor( n.frostPreSignAuthorizationBackend, n.frostPreSignActivationProfile, n.frostDurableSessionStoreBinding, + n.frostProductionSignerReadiness, n.chain.Signing(), broadcastChannel, membershipValidator, diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 58bff4c6d6..36efcb37a7 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -166,6 +166,11 @@ type Config struct { // receipt-complete source. It must not share the primary Ethereum adapter's // trust domain, endpoint, operator, or history store. FrostRetainedGroupHistorySource FrostRetainedGroupHistorySource + // FrostNativeSignerStateWitnessAnchorSource reads the independently durable, + // dynamic signer-state tip last accepted by the activation/watchtower system. + // It must not be backed by the native signer state directory. Production + // activation refuses to start without this rollback boundary. + FrostNativeSignerStateWitnessAnchorSource FrostNativeSignerStateWitnessAnchorSource } // Initialize kicks off the TBTC by initializing internal state, ensuring From 1928a6617f1d1caa41fbdb7c56538c99aaedbd38 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 24 Jul 2026 20:15:32 -0400 Subject: [PATCH 05/93] fix(tbtc)!: require signer ABI 4.0 and pin the v2-transcript lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lockstep bump for the signer's ABI 3.5 -> 4.0 change (keep-core #4198). Per this file's own rule, the Go constants and ci/frost-signer-pin.env move together with the lib commit that provides them. Under ABI 3, `durable_store_fingerprint` mixed the stable `.store-id` with the parent directory's path string and inode, the filesystem device, and the advisory lock file's inode — and that composite was the first field of every state commitment. So deleting the zero-byte lock file, restoring the data directory from backup at the same path, renaming it, or remounting where st_dev is unstable made every committed record unverifiable and the signer unstartable, with no in-band recovery: the failure is raised inside StateFileLock::acquire, before the corruption policy can apply. ABI 4.0 binds commitments to the stable `.store-id` alone. The path, filesystem and lock fingerprints are still computed and still enforced at open — they simply no longer enter any committed transcript. The symbol set is unchanged, but the VALUES of existing wire fields differ, so an ABI-3 library must not be linked by this bridge. requiredTBTCSignerABIMajor 3 -> 4 requiredTBTCSignerABIMinMinor 5 -> 0 FROST_SIGNER_MIRROR_REF 6e0fa9741 -> a5363e947 Minor 0 is the initial ABI-4 surface and carries forward every symbol added through 3.5 under the v2 identity and transcript schemas. Verified: go build ./pkg/frost/... ./pkg/tbtc/... clean; go test ./pkg/frost/signing/ passes, including the contract-pin guard. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012KZb3W6TwYtKTw1mb2sfpS --- ci/frost-signer-pin.env | 2 +- .../signing/native_tbtc_signer_abi_version.go | 30 +++++++++++++------ .../native_tbtc_signer_abi_version_test.go | 18 ++++++----- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/ci/frost-signer-pin.env b/ci/frost-signer-pin.env index 39b550eb20..8d95bd5d06 100644 --- a/ci/frost-signer-pin.env +++ b/ci/frost-signer-pin.env @@ -15,4 +15,4 @@ # # After the scaffold and mirror branches merge into one, replace the cross-branch # checkout with an in-tree cargo build and retire this pin (keep the gate). -FROST_SIGNER_MIRROR_REF=6e0fa9741ffb6b0bb5603eabe26fb4cc1b13ecd9 +FROST_SIGNER_MIRROR_REF=a5363e947239eb5871e4a810150d2bc0836ea7a3 diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version.go b/pkg/frost/signing/native_tbtc_signer_abi_version.go index 3110462cce..2411ac7e10 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version.go @@ -19,15 +19,27 @@ const ( // messages. The required request field and changed response semantics are an // incompatible JSON/crypto-contract change, so an ABI-2 library must not be // linked by this bridge. - requiredTBTCSignerABIMajor uint32 = 3 - // Minor 1 adds the typed heartbeat signing intent required to authorize - // non-transaction messages while the signing-policy firewall is on. Minor 2 - // adds the heartbeat rate-limit configuration and dedicated rejection metric. - // Minor 3 adds the canary-evidence configuration. Minor 4 adds descriptor- - // bound durable-store identity. Minor 5 adds exact retained key-package - // inventory plus the append-only state-witness proof required to detect - // rollback of key packages and durable replay markers. - requiredTBTCSignerABIMinMinor uint32 = 5 + // + // Major 4: the durable-store identity schema moved to + // `tbtc-signer-durable-session-store-identity/v2` and the state-witness + // transcript to v2. `durable_store_fingerprint` now binds ONLY the stable + // `.store-id`; the canonical-path, filesystem and lock fingerprints are still + // reported and still enforced at open, but no longer enter any committed + // transcript. Under ABI 3 they did, so a benign filesystem change - deleting + // the advisory lock file, restoring the data directory from backup, renaming + // it, or remounting where st_dev is unstable - made every committed record + // unverifiable and the signer unstartable, with no in-band recovery. The + // symbol set is unchanged, but the VALUES of existing wire fields differ, so + // an ABI-3 library must not be linked by this bridge and an ABI-3 journal is + // rejected by magic with a migration error rather than silently reset. + requiredTBTCSignerABIMajor uint32 = 4 + // Minor 0 is the initial ABI-4 surface: it carries forward every symbol added + // through ABI 3.5 (typed heartbeat signing intent, heartbeat rate-limit + // configuration and rejection metric, canary evidence, descriptor-bound + // durable-store identity, and exact retained key-package inventory plus the + // append-only state-witness proof required to detect rollback of key packages + // and durable replay markers) under the v2 identity and transcript schemas. + requiredTBTCSignerABIMinMinor uint32 = 0 ) // ErrTBTCSignerABIIncompatible marks a linked libfrost_tbtc whose FFI contract version diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go index 62bf65c7fb..be84cbcb42 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go @@ -89,14 +89,16 @@ func TestParseTBTCSignerABIVersion(t *testing.T) { } func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { - // Pins the bridge's current required contract: major 3 adds the BIP-341 - // transaction artifact, minor 1 adds heartbeat intent authorization, and - // minor 2 adds heartbeat rate limiting/metrics; minor 3 adds canary-evidence, - // minor 4 adds runtime store identity, and minor 5 adds retained-key inventory - // and state-witness proofs. The matching library version is compatible; a different - // major is not. A regression here means the required constants drifted from - // what the bridge actually speaks. - if requiredTBTCSignerABIMajor != 3 || requiredTBTCSignerABIMinMinor != 5 { + // Pins the bridge's current required contract: major 4 moves the durable-store + // identity schema and the state-witness transcript to v2, so that state + // commitments bind only the stable `.store-id` and no longer break when a + // benign filesystem change alters the lock file, directory inode, or device. + // The symbol set is unchanged from 3.5, but the VALUES of existing wire fields + // differ, so an ABI-3 library must not be linked. Minor 0 is the initial ABI-4 + // surface and carries forward everything added through 3.5. The matching + // library version is compatible; a different major is not. A regression here + // means the required constants drifted from what the bridge actually speaks. + if requiredTBTCSignerABIMajor != 4 || requiredTBTCSignerABIMinMinor != 0 { t.Fatalf( "unexpected required tbtc-signer ABI: [%d.%d]", requiredTBTCSignerABIMajor, From 521fcd5060d0d214931820581d85f8a21b131d6c Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 17:06:28 -0400 Subject: [PATCH 06/93] fix(tbtc): harden retained journal persistence paths --- .../signing/native_tbtc_signer_inventory.go | 13 +- .../native_tbtc_signer_store_identity.go | 14 +- pkg/tbtc/frost_retained_group_journal.go | 218 ++++++++++++++++-- pkg/tbtc/frost_retained_group_journal_test.go | 152 ++++++++++++ 4 files changed, 361 insertions(+), 36 deletions(-) diff --git a/pkg/frost/signing/native_tbtc_signer_inventory.go b/pkg/frost/signing/native_tbtc_signer_inventory.go index 6b948723c6..168a05bf06 100644 --- a/pkg/frost/signing/native_tbtc_signer_inventory.go +++ b/pkg/frost/signing/native_tbtc_signer_inventory.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "hash" "io" "strings" ) @@ -462,20 +463,20 @@ func nativeTBTCSignerBytes32(value [32]byte) string { return "0x" + hex.EncodeToString(value[:]) } -func writeNativeTBTCSignerUint16(destination nativeTBTCSignerStoreFingerprintWriter, value uint16) { +func writeNativeTBTCSignerUint16(destination hash.Hash, value uint16) { var encoded [2]byte binary.BigEndian.PutUint16(encoded[:], value) - destination.Write(encoded[:]) + _, _ = destination.Write(encoded[:]) } -func writeNativeTBTCSignerUint32(destination nativeTBTCSignerStoreFingerprintWriter, value uint32) { +func writeNativeTBTCSignerUint32(destination hash.Hash, value uint32) { var encoded [4]byte binary.BigEndian.PutUint32(encoded[:], value) - destination.Write(encoded[:]) + _, _ = destination.Write(encoded[:]) } -func writeNativeTBTCSignerUint64(destination nativeTBTCSignerStoreFingerprintWriter, value uint64) { +func writeNativeTBTCSignerUint64(destination hash.Hash, value uint64) { var encoded [8]byte binary.BigEndian.PutUint64(encoded[:], value) - destination.Write(encoded[:]) + _, _ = destination.Write(encoded[:]) } diff --git a/pkg/frost/signing/native_tbtc_signer_store_identity.go b/pkg/frost/signing/native_tbtc_signer_store_identity.go index 37f620f020..b2c42eafd1 100644 --- a/pkg/frost/signing/native_tbtc_signer_store_identity.go +++ b/pkg/frost/signing/native_tbtc_signer_store_identity.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "hash" "io" "regexp" "strings" @@ -198,16 +199,15 @@ func ComputeNativeTBTCSignerDurableStoreFingerprint( return result, nil } -type nativeTBTCSignerStoreFingerprintWriter interface { - Write([]byte) (int, error) -} - func writeNativeTBTCSignerStoreFingerprintField( - destination nativeTBTCSignerStoreFingerprintWriter, + destination hash.Hash, value []byte, ) { var length [4]byte binary.BigEndian.PutUint32(length[:], uint32(len(value))) - destination.Write(length[:]) - destination.Write(value) + // hash.Hash.Write is documented to never return an error. Discard both + // results explicitly so this infallible transcript operation cannot be + // mistaken for an unchecked fallible write. + _, _ = destination.Write(length[:]) + _, _ = destination.Write(value) } diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index 9e8dd58cce..d84ff847ad 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -3,9 +3,11 @@ package tbtc import ( "bytes" "context" + "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -937,6 +939,9 @@ func readFrostRetainedGroupEnvelopeAt( name string, target interface{}, ) error { + if err := validateFrostRetainedGroupJournalFileName(name); err != nil { + return err + } file, err := openSecureBitcoinBroadcastFile(filepath.Join(directory, name), unix.O_RDONLY, 0600) if err != nil { return err @@ -973,6 +978,9 @@ func persistFrostRetainedGroupEnvelopeAt( payload interface{}, replace bool, ) error { + if err := validateFrostRetainedGroupJournalFileName(name); err != nil { + return err + } payloadBytes, err := json.Marshal(payload) if err != nil { return err @@ -984,36 +992,39 @@ func persistFrostRetainedGroupEnvelopeAt( if err != nil { return err } - finalPath := filepath.Join(directory, name) - if info, err := os.Lstat(finalPath); err == nil { + + directoryFile, err := openFrostRetainedGroupJournalDirectory(directory) + if err != nil { + return err + } + defer directoryFile.Close() + directoryDescriptor := int(directoryFile.Fd()) + + if exists, err := validateFrostRetainedGroupJournalFileAt( + directoryDescriptor, + name, + ); err != nil { + return err + } else if exists { if !replace { return fmt.Errorf("immutable journal file already exists: [%s]", name) } - if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || - info.Mode().Perm() != 0600 { - return fmt.Errorf("journal destination is unsafe: [%s]", name) - } - if err := validateBitcoinBroadcastOwner(info); err != nil { - return err - } - } else if !os.IsNotExist(err) { - return err } - temporary, err := os.CreateTemp(directory, name+"-*"+frostRetainedGroupJournalTempSuffix) + + temporary, temporaryName, err := createFrostRetainedGroupJournalTemporaryFileAt( + directoryDescriptor, + directory, + name, + ) if err != nil { return err } - temporaryPath := temporary.Name() remove := true defer func() { if remove { - _ = os.Remove(temporaryPath) + _ = unix.Unlinkat(directoryDescriptor, temporaryName, 0) } }() - if err := temporary.Chmod(0600); err != nil { - _ = temporary.Close() - return err - } if _, err := temporary.Write(envelopeBytes); err != nil { _ = temporary.Close() return err @@ -1029,20 +1040,181 @@ func persistFrostRetainedGroupEnvelopeAt( // Link is an atomic no-replace publication: unlike Rename it cannot // overwrite an immutable batch or metadata file that appeared after // the destination check above. - if err := os.Link(temporaryPath, finalPath); err != nil { + if err := unix.Linkat( + directoryDescriptor, + temporaryName, + directoryDescriptor, + name, + 0, + ); err != nil { return fmt.Errorf("cannot publish immutable journal file [%s]: [%w]", name, err) } - if err := os.Remove(temporaryPath); err != nil { + if err := unix.Unlinkat(directoryDescriptor, temporaryName, 0); err != nil { return err } remove = false - return syncDirectory(directory) + return directoryFile.Sync() } - if err := os.Rename(temporaryPath, finalPath); err != nil { + if err := unix.Renameat( + directoryDescriptor, + temporaryName, + directoryDescriptor, + name, + ); err != nil { return err } remove = false - return syncDirectory(directory) + return directoryFile.Sync() +} + +func validateFrostRetainedGroupJournalFileName(name string) error { + if !filepath.IsLocal(name) || filepath.Base(name) != name { + return fmt.Errorf("FROST retained-group journal file name is not local: [%s]", name) + } + if name == frostRetainedGroupJournalMetadataFile || + name == frostRetainedGroupJournalStateFile { + return nil + } + if !strings.HasPrefix(name, frostRetainedGroupJournalBatchPrefix) || + !strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix) { + return fmt.Errorf("unsupported FROST retained-group journal file name: [%s]", name) + } + sequenceText := strings.TrimSuffix( + strings.TrimPrefix(name, frostRetainedGroupJournalBatchPrefix), + frostRetainedGroupJournalFileSuffix, + ) + if len(sequenceText) != 20 { + return fmt.Errorf("noncanonical FROST retained-group journal batch name: [%s]", name) + } + sequence, err := strconv.ParseUint(sequenceText, 10, 64) + if err != nil || sequence == 0 || + frostRetainedGroupBatchFileName(sequence) != name { + return fmt.Errorf("noncanonical FROST retained-group journal batch name: [%s]", name) + } + return nil +} + +func openFrostRetainedGroupJournalDirectory(directory string) (*os.File, error) { + if !filepath.IsAbs(directory) || filepath.Clean(directory) != directory { + return nil, fmt.Errorf("FROST retained-group journal directory is not canonical") + } + fd, err := unix.Open( + directory, + unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0, + ) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), directory) + if file == nil { + _ = unix.Close(fd) + return nil, fmt.Errorf("cannot wrap FROST retained-group journal directory descriptor") + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || + info.Mode().Perm() != 0700 { + _ = file.Close() + return nil, fmt.Errorf("FROST retained-group journal directory is unsafe") + } + if err := validateBitcoinBroadcastOwner(info); err != nil { + _ = file.Close() + return nil, err + } + return file, nil +} + +func validateFrostRetainedGroupJournalFileAt( + directoryDescriptor int, + name string, +) (bool, error) { + var info unix.Stat_t + err := unix.Fstatat( + directoryDescriptor, + name, + &info, + unix.AT_SYMLINK_NOFOLLOW, + ) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if uint32(info.Mode)&unix.S_IFMT != unix.S_IFREG || + uint32(info.Mode)&0777 != 0600 { + return false, fmt.Errorf("journal destination is unsafe: [%s]", name) + } + if info.Uid != uint32(os.Geteuid()) { + return false, fmt.Errorf( + "Bitcoin broadcast storage is owned by uid [%d], expected [%d]", + info.Uid, + os.Geteuid(), + ) + } + return true, nil +} + +func createFrostRetainedGroupJournalTemporaryFileAt( + directoryDescriptor int, + directory string, + finalName string, +) (*os.File, string, error) { + const maximumAttempts = 128 + var entropy [16]byte + for attempt := 0; attempt < maximumAttempts; attempt++ { + if _, err := io.ReadFull(rand.Reader, entropy[:]); err != nil { + return nil, "", fmt.Errorf("cannot generate journal temporary file name: [%w]", err) + } + name := finalName + "-" + hex.EncodeToString(entropy[:]) + + frostRetainedGroupJournalTempSuffix + fd, err := unix.Openat( + directoryDescriptor, + name, + unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0600, + ) + if errors.Is(err, unix.EEXIST) { + continue + } + if err != nil { + return nil, "", err + } + file := os.NewFile(uintptr(fd), filepath.Join(directory, name)) + if file == nil { + _ = unix.Close(fd) + _ = unix.Unlinkat(directoryDescriptor, name, 0) + return nil, "", fmt.Errorf("cannot wrap journal temporary file descriptor") + } + if err := file.Chmod(0600); err != nil { + _ = file.Close() + _ = unix.Unlinkat(directoryDescriptor, name, 0) + return nil, "", err + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + _ = unix.Unlinkat(directoryDescriptor, name, 0) + return nil, "", err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || + info.Mode().Perm() != 0600 { + _ = file.Close() + _ = unix.Unlinkat(directoryDescriptor, name, 0) + return nil, "", fmt.Errorf("journal temporary file is unsafe") + } + if err := validateBitcoinBroadcastOwner(info); err != nil { + _ = file.Close() + _ = unix.Unlinkat(directoryDescriptor, name, 0) + return nil, "", err + } + return file, name, nil + } + return nil, "", fmt.Errorf("cannot allocate a unique journal temporary file") } func applyFrostRetainedGroupMutations( diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index 68acb6ea96..76d39d8cac 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -1,6 +1,7 @@ package tbtc import ( + "bytes" "context" "crypto/ecdsa" "crypto/elliptic" @@ -544,6 +545,157 @@ func TestFrostRetainedGroupJournal_RejectsSymlinkEntry(t *testing.T) { } } +func TestPersistFrostRetainedGroupEnvelopeAt_RestrictsFilesToJournal( + t *testing.T, +) { + root := t.TempDir() + directory := filepath.Join(root, "journal") + if err := os.Mkdir(directory, 0700); err != nil { + t.Fatal(err) + } + outsidePath := filepath.Join(root, "outside.json") + outsideContents := []byte("must not change") + if err := os.WriteFile(outsidePath, outsideContents, 0600); err != nil { + t.Fatal(err) + } + + invalidNames := []string{ + "../outside.json", + filepath.Join("nested", frostRetainedGroupJournalStateFile), + outsidePath, + ".", + frostRetainedGroupJournalLockFile, + "batch-1.json", + "batch-00000000000000000000.json", + "batch-00000000000000000001.json/../../outside.json", + frostRetainedGroupJournalStateFile + "\x00", + } + for _, name := range invalidNames { + t.Run(name, func(t *testing.T) { + err := persistFrostRetainedGroupEnvelopeAt( + directory, + name, + map[string]uint64{"generation": 1}, + true, + ) + if err == nil { + t.Fatalf("expected unsafe journal file name [%q] to be rejected", name) + } + actual, readErr := os.ReadFile(outsidePath) + if readErr != nil { + t.Fatal(readErr) + } + if !bytes.Equal(actual, outsideContents) { + t.Fatalf("unsafe journal file name [%q] modified an outside file", name) + } + }) + } + if err := persistFrostRetainedGroupEnvelopeAt( + ".", + frostRetainedGroupJournalStateFile, + map[string]uint64{"generation": 1}, + true, + ); err == nil { + t.Fatal("expected a noncanonical journal directory to be rejected") + } + + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("rejected paths left files in the journal directory: [%v]", entries) + } +} + +func TestPersistFrostRetainedGroupEnvelopeAt_PreservesInternalFiles( + t *testing.T, +) { + directory := filepath.Join(t.TempDir(), "journal") + if err := os.Mkdir(directory, 0700); err != nil { + t.Fatal(err) + } + type payload struct { + Generation uint64 `json:"generation"` + } + + tests := []struct { + name string + replace bool + value uint64 + }{ + {frostRetainedGroupJournalMetadataFile, false, 1}, + {frostRetainedGroupJournalStateFile, true, 2}, + {frostRetainedGroupBatchFileName(1), false, 3}, + } + for _, test := range tests { + if err := persistFrostRetainedGroupEnvelopeAt( + directory, + test.name, + payload{Generation: test.value}, + test.replace, + ); err != nil { + t.Fatalf("cannot persist legitimate journal file [%s]: [%v]", test.name, err) + } + var actual payload + if err := readFrostRetainedGroupEnvelopeAt( + directory, + test.name, + &actual, + ); err != nil { + t.Fatalf("cannot read legitimate journal file [%s]: [%v]", test.name, err) + } + if actual.Generation != test.value { + t.Fatalf("unexpected journal file [%s] payload: [%+v]", test.name, actual) + } + info, err := os.Lstat(filepath.Join(directory, test.name)) + if err != nil { + t.Fatal(err) + } + if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { + t.Fatalf("legitimate journal file [%s] is not private and regular", test.name) + } + } + + if err := persistFrostRetainedGroupEnvelopeAt( + directory, + frostRetainedGroupJournalMetadataFile, + payload{Generation: 4}, + false, + ); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("expected immutable journal replacement to fail, got [%v]", err) + } + if err := persistFrostRetainedGroupEnvelopeAt( + directory, + frostRetainedGroupJournalStateFile, + payload{Generation: 5}, + true, + ); err != nil { + t.Fatal(err) + } + var replaced payload + if err := readFrostRetainedGroupEnvelopeAt( + directory, + frostRetainedGroupJournalStateFile, + &replaced, + ); err != nil { + t.Fatal(err) + } + if replaced.Generation != 5 { + t.Fatalf("replaceable journal state was not updated: [%+v]", replaced) + } + + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), frostRetainedGroupJournalTempSuffix) { + t.Fatalf("successful persistence left temporary file [%s]", entry.Name()) + } + } +} + func TestFrostRetainedGroupJournal_RejectsCorruptOrPublicStoreFiles(t *testing.T) { t.Run("quarantine batch checksum", func(t *testing.T) { fixture := newJournalTestFixture(t) From adb05d514ec0d62b72def029b9a7eb0122cf1c0f Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 17:37:03 -0400 Subject: [PATCH 07/93] Bind FROST activation to historical deployment epochs --- .../tbtc_frost_historical_deployment_test.go | 246 +++++ .../tbtc_frost_pre_sign_authorization.go | 902 +++++++++++++++--- ...tbtc_frost_retained_group_manifest_test.go | 1 + pkg/tbtc/frost_pre_sign_authorization.go | 154 ++- 4 files changed, 1145 insertions(+), 158 deletions(-) create mode 100644 pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go diff --git a/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go b/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go new file mode 100644 index 0000000000..1c571d1af4 --- /dev/null +++ b/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go @@ -0,0 +1,246 @@ +package ethereum + +import ( + "context" + "fmt" + "strings" + "testing" + + geth "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +func testFrostHistoricalContract( + t *testing.T, +) frostPreSignManifestContract { + t.Helper() + descriptorHash, err := frostPreSignLinkedLibraryInventoryHash(nil) + if err != nil { + t.Fatal(err) + } + address := strings.ToLower(common.HexToAddress( + "0x1111111111111111111111111111111111111111", + ).Hex()) + runtimeHash := common.HexToHash("0x010203").Hex() + startHash := common.HexToHash("0x0a").Hex() + return frostPreSignManifestContract{ + Address: address, + RuntimeCodeHash: runtimeHash, + ProtocolID: common.HexToHash("0x20").Hex(), + DeploymentBlock: 10, + RelevantEventStartBlock: 10, + LinkedLibraryDescriptorHash: common.Hash(descriptorHash).Hex(), + LinkedLibraries: []frostPreSignManifestLinkedLibrary{}, + Upgradeability: frostPreSignManifestUpgradeability{ + Kind: "immutable", + }, + HistoricalDeploymentEpochs: []frostPreSignManifestDeploymentEpoch{{ + Start: frostPreSignManifestPoint{ + BlockNumber: 10, + BlockHash: startHash, + }, + Address: address, + RuntimeCodeHash: runtimeHash, + LinkedLibraryDescriptorHash: common.Hash(descriptorHash).Hex(), + LinkedLibraries: []frostPreSignManifestLinkedLibrary{}, + Upgradeability: frostPreSignManifestUpgradeability{ + Kind: "immutable", + }, + }}, + } +} + +func TestFrostPreSignDeploymentPinFromManifest_HistoricalEpochs(t *testing.T) { + contract := testFrostHistoricalContract(t) + pin, err := frostPreSignDeploymentPinFromManifest( + "bridge", + "Bridge", + contract, + ) + if err != nil { + t.Fatal(err) + } + if len(pin.historicalEpochs) != 1 || + pin.historicalEpochs[0].start.BlockNumber != 10 || + pin.historicalEpochs[0].end != nil || + frostPreSignDeploymentDescriptorHash(pin) != + frostPreSignDeploymentDescriptorHash( + pin.historicalEpochs[0].descriptor, + ) { + t.Fatal("historical deployment epoch was not preserved exactly") + } + runtime := frostPreSignRuntimeDeploymentEvidence( + []frostPreSignDeploymentPin{pin}, + ) + if len(runtime) != 1 || + tbtc.ComputeFrostPreSignDeploymentEvidenceHash(runtime) != + frostPreSignDeploymentSetHash([]frostPreSignDeploymentPin{pin}) { + t.Fatal("runtime historical evidence changes the deployment-set commitment") + } +} + +func TestFrostPreSignDeploymentPinFromManifest_RejectsInvalidEpochRanges( + t *testing.T, +) { + tests := map[string]func(*frostPreSignManifestContract){ + "missing": func(contract *frostPreSignManifestContract) { + contract.HistoricalDeploymentEpochs = nil + }, + "first start differs from deployment": func(contract *frostPreSignManifestContract) { + contract.HistoricalDeploymentEpochs[0].Start.BlockNumber++ + }, + "final epoch is closed": func(contract *frostPreSignManifestContract) { + contract.HistoricalDeploymentEpochs[0].End = + &frostPreSignManifestPoint{ + BlockNumber: 11, + BlockHash: common.HexToHash("0x0b").Hex(), + } + }, + "gap": func(contract *frostPreSignManifestContract) { + first := contract.HistoricalDeploymentEpochs[0] + first.End = &frostPreSignManifestPoint{ + BlockNumber: 11, + BlockHash: common.HexToHash("0x0b").Hex(), + } + second := first + second.Start = frostPreSignManifestPoint{ + BlockNumber: 13, + BlockHash: common.HexToHash("0x0d").Hex(), + } + second.End = nil + contract.HistoricalDeploymentEpochs = + []frostPreSignManifestDeploymentEpoch{first, second} + }, + "overlap": func(contract *frostPreSignManifestContract) { + first := contract.HistoricalDeploymentEpochs[0] + first.End = &frostPreSignManifestPoint{ + BlockNumber: 12, + BlockHash: common.HexToHash("0x0c").Hex(), + } + second := first + second.Start = frostPreSignManifestPoint{ + BlockNumber: 12, + BlockHash: common.HexToHash("0x1c").Hex(), + } + second.End = nil + contract.HistoricalDeploymentEpochs = + []frostPreSignManifestDeploymentEpoch{first, second} + }, + "current descriptor mismatch": func(contract *frostPreSignManifestContract) { + contract.HistoricalDeploymentEpochs[0].RuntimeCodeHash = + common.HexToHash("0xff").Hex() + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + contract := testFrostHistoricalContract(t) + mutate(&contract) + if _, err := frostPreSignDeploymentPinFromManifest( + "bridge", + "Bridge", + contract, + ); err == nil { + t.Fatal("invalid historical deployment epochs were accepted") + } + }) + } +} + +type testFrostCanonicalRPCAPI struct { + points []rpc.BlockNumberOrHash +} + +func (api *testFrostCanonicalRPCAPI) GetCode( + _ context.Context, + _ common.Address, + point rpc.BlockNumberOrHash, +) (hexutil.Bytes, error) { + api.points = append(api.points, point) + return hexutil.Bytes{0x01}, nil +} + +func (api *testFrostCanonicalRPCAPI) GetStorageAt( + _ context.Context, + _ common.Address, + _ common.Hash, + point rpc.BlockNumberOrHash, +) (hexutil.Bytes, error) { + api.points = append(api.points, point) + return make(hexutil.Bytes, 32), nil +} + +func (api *testFrostCanonicalRPCAPI) Call( + _ context.Context, + _ map[string]interface{}, + point rpc.BlockNumberOrHash, +) (hexutil.Bytes, error) { + api.points = append(api.points, point) + return make(hexutil.Bytes, 32), nil +} + +type testFrostHeaderByHashReader struct{} + +func (*testFrostHeaderByHashReader) HeaderByHash( + _ context.Context, + hash common.Hash, +) (*types.Header, error) { + if hash == (common.Hash{}) { + return nil, fmt.Errorf("zero hash") + } + return &types.Header{}, nil +} + +func TestFrostPreSignCanonicalHashReader_RequiresCanonicalEIP1898State( + t *testing.T, +) { + server := rpc.NewServer() + api := &testFrostCanonicalRPCAPI{} + if err := server.RegisterName("eth", api); err != nil { + t.Fatal(err) + } + client := rpc.DialInProc(server) + defer client.Close() + reader := &frostPreSignCanonicalHashReader{ + headerReader: &testFrostHeaderByHashReader{}, + rpcClient: client, + } + blockHash := common.HexToHash("0x1234") + address := common.HexToAddress( + "0x1111111111111111111111111111111111111111", + ) + if _, err := reader.CodeAtHash( + context.Background(), + address, + blockHash, + ); err != nil { + t.Fatal(err) + } + if _, err := reader.StorageAtHash( + context.Background(), + address, + common.Hash{}, + blockHash, + ); err != nil { + t.Fatal(err) + } + if _, err := reader.CallContractAtHash( + context.Background(), + geth.CallMsg{To: &address, Data: []byte{0x01}}, + blockHash, + ); err != nil { + t.Fatal(err) + } + if len(api.points) != 3 { + t.Fatalf("unexpected exact-hash call count [%d]", len(api.points)) + } + for _, point := range api.points { + if point.BlockHash == nil || *point.BlockHash != blockHash || + !point.RequireCanonical || point.BlockNumber != nil { + t.Fatalf("state read did not require canonical hash [%+v]", point) + } + } +} diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index 96146cac0e..5b704dec38 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -23,6 +23,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/rpc" @@ -32,7 +33,7 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) -const frostPreSignManifestVersion = "tbtc-p2tr-fraud-production-activation/v2" +const frostPreSignManifestVersion = "tbtc-p2tr-fraud-production-activation/v3" const frostPreSignBridgeABIJSON = `[ {"type":"function","name":"previewP2TRTransactionAuthorization","stateMutability":"view","inputs":[{"name":"payload","type":"bytes"}],"outputs":[{"name":"","type":"bytes"}]}, @@ -62,6 +63,7 @@ const frostPreSignCrosslinkABIJSON = `[ {"type":"function","name":"preauthorizationProtocolID","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"bytes32"}]}, {"type":"function","name":"signingPolicyHash","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"bytes32"}]}, {"type":"function","name":"sortitionPool","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"address"}]}, + {"type":"function","name":"getOperatorID","stateMutability":"view","inputs":[{"name":"operator","type":"address"}],"outputs":[{"name":"","type":"uint32"}]}, {"type":"function","name":"getWallet","stateMutability":"view","inputs":[{"name":"walletID","type":"bytes32"}],"outputs":[{"name":"","type":"tuple","components":[{"name":"membersIdsHash","type":"bytes32"},{"name":"xOnlyOutputKey","type":"bytes32"}]}]} ]` @@ -119,19 +121,30 @@ type frostPreSignManifestLinkedLibrary struct { LinkedLibraries []frostPreSignManifestLinkedLibrary `json:"linkedLibraries"` } -type frostPreSignManifestContract struct { +type frostPreSignManifestDeploymentEpoch struct { + Start frostPreSignManifestPoint `json:"start"` + End *frostPreSignManifestPoint `json:"end,omitempty"` Address string `json:"address"` RuntimeCodeHash string `json:"runtimeCodeHash"` - ProtocolID string `json:"protocolID"` - DeploymentBlock uint64 `json:"deploymentBlock"` - RelevantEventStartBlock uint64 `json:"relevantEventStartBlock"` - BridgeAddress *string `json:"bridgeAddress,omitempty"` - SigningPolicyHash *string `json:"signingPolicyHash,omitempty"` LinkedLibraryDescriptorHash string `json:"linkedLibraryDescriptorHash"` LinkedLibraries []frostPreSignManifestLinkedLibrary `json:"linkedLibraries"` Upgradeability frostPreSignManifestUpgradeability `json:"upgradeability"` } +type frostPreSignManifestContract struct { + Address string `json:"address"` + RuntimeCodeHash string `json:"runtimeCodeHash"` + ProtocolID string `json:"protocolID"` + DeploymentBlock uint64 `json:"deploymentBlock"` + RelevantEventStartBlock uint64 `json:"relevantEventStartBlock"` + BridgeAddress *string `json:"bridgeAddress,omitempty"` + SigningPolicyHash *string `json:"signingPolicyHash,omitempty"` + LinkedLibraryDescriptorHash string `json:"linkedLibraryDescriptorHash"` + LinkedLibraries []frostPreSignManifestLinkedLibrary `json:"linkedLibraries"` + Upgradeability frostPreSignManifestUpgradeability `json:"upgradeability"` + HistoricalDeploymentEpochs []frostPreSignManifestDeploymentEpoch `json:"historicalDeploymentEpochs"` +} + type frostPreSignManifestContracts struct { Bridge frostPreSignManifestContract `json:"bridge"` CompleteRouter frostPreSignManifestContract `json:"completeRouter"` @@ -145,6 +158,7 @@ type frostPreSignManifestContracts struct { type frostPreSignManifestEthereum struct { ChainID uint64 `json:"chainID"` + GenesisBlockHash string `json:"genesisBlockHash"` Checkpoint frostPreSignManifestPoint `json:"checkpoint"` ScanStartBlock uint64 `json:"scanStartBlock"` ConfirmationDepth uint64 `json:"confirmationDepth"` @@ -231,6 +245,8 @@ type frostPreSignActivationManifest struct { type frostPreSignDeploymentPin struct { role string name string + deploymentBlock uint64 + relevantEventStartBlock uint64 address [20]byte runtimeCodeHash [32]byte upgradeability string @@ -242,6 +258,13 @@ type frostPreSignDeploymentPin struct { adminSlotValue [32]byte linkedLibraryDescriptorHash [32]byte linkedLibraries []frostPreSignLinkedLibraryPin + historicalEpochs []frostPreSignDeploymentEpochPin +} + +type frostPreSignDeploymentEpochPin struct { + start tbtc.FrostPreSignFinality + end *tbtc.FrostPreSignFinality + descriptor frostPreSignDeploymentPin } type frostPreSignLinkedLibraryPin struct { @@ -259,12 +282,26 @@ type frostPreSignEthereumAdapter struct { manifest frostPreSignActivationManifest deployments []frostPreSignDeploymentPin bridge *bind.BoundContract - registry *bind.BoundContract - router *bind.BoundContract mutex sync.RWMutex } +type frostPreSignExactHashReader interface { + HeaderByHash(context.Context, common.Hash) (*types.Header, error) + CodeAtHash(context.Context, common.Address, common.Hash) ([]byte, error) + StorageAtHash( + context.Context, + common.Address, + common.Hash, + common.Hash, + ) ([]byte, error) + CallContractAtHash( + context.Context, + geth.CallMsg, + common.Hash, + ) ([]byte, error) +} + type frostPreSignBitcoinTxInfo struct { Version [4]byte InputVector []byte @@ -468,6 +505,127 @@ func loadFrostPreSignActivationManifest( return manifest, nil } +type frostPreSignCanonicalHashReader struct { + headerReader interface { + HeaderByHash(context.Context, common.Hash) (*types.Header, error) + } + rpcClient *rpc.Client +} + +func (reader *frostPreSignCanonicalHashReader) HeaderByHash( + ctx context.Context, + blockHash common.Hash, +) (*types.Header, error) { + return reader.headerReader.HeaderByHash(ctx, blockHash) +} + +func (reader *frostPreSignCanonicalHashReader) CodeAtHash( + ctx context.Context, + account common.Address, + blockHash common.Hash, +) ([]byte, error) { + var result hexutil.Bytes + err := reader.rpcClient.CallContext( + ctx, + &result, + "eth_getCode", + account, + rpc.BlockNumberOrHashWithHash(blockHash, true), + ) + return result, err +} + +func (reader *frostPreSignCanonicalHashReader) StorageAtHash( + ctx context.Context, + account common.Address, + key common.Hash, + blockHash common.Hash, +) ([]byte, error) { + var result hexutil.Bytes + err := reader.rpcClient.CallContext( + ctx, + &result, + "eth_getStorageAt", + account, + key, + rpc.BlockNumberOrHashWithHash(blockHash, true), + ) + return result, err +} + +func (reader *frostPreSignCanonicalHashReader) CallContractAtHash( + ctx context.Context, + message geth.CallMsg, + blockHash common.Hash, +) ([]byte, error) { + var result hexutil.Bytes + err := reader.rpcClient.CallContext( + ctx, + &result, + "eth_call", + frostPreSignCallArgument(message), + rpc.BlockNumberOrHashWithHash(blockHash, true), + ) + return result, err +} + +func frostPreSignCallArgument(message geth.CallMsg) map[string]interface{} { + result := map[string]interface{}{ + "from": message.From, + "to": message.To, + } + if len(message.Data) > 0 { + result["input"] = hexutil.Bytes(message.Data) + } + if message.Value != nil { + result["value"] = (*hexutil.Big)(message.Value) + } + if message.Gas != 0 { + result["gas"] = hexutil.Uint64(message.Gas) + } + if message.GasPrice != nil { + result["gasPrice"] = (*hexutil.Big)(message.GasPrice) + } + if message.GasFeeCap != nil { + result["maxFeePerGas"] = (*hexutil.Big)(message.GasFeeCap) + } + if message.GasTipCap != nil { + result["maxPriorityFeePerGas"] = (*hexutil.Big)(message.GasTipCap) + } + if message.AccessList != nil { + result["accessList"] = message.AccessList + } + if message.BlobGasFeeCap != nil { + result["maxFeePerBlobGas"] = (*hexutil.Big)(message.BlobGasFeeCap) + } + if message.BlobHashes != nil { + result["blobVersionedHashes"] = message.BlobHashes + } + return result +} + +func (adapter *frostPreSignEthereumAdapter) exactHashReader() ( + frostPreSignExactHashReader, + error, +) { + headerReader, ok := adapter.chain.client.(interface { + HeaderByHash(context.Context, common.Hash) (*types.Header, error) + }) + if !ok { + return nil, fmt.Errorf("Ethereum client does not expose exact-block-hash headers") + } + rpcProvider, ok := adapter.chain.client.(interface { + Client() *rpc.Client + }) + if !ok || rpcProvider.Client() == nil { + return nil, fmt.Errorf("Ethereum client does not expose canonical EIP-1898 reads") + } + return &frostPreSignCanonicalHashReader{ + headerReader: headerReader, + rpcClient: rpcProvider.Client(), + }, nil +} + func frostPreSignDecodeStrictJSON(data []byte, target interface{}) error { decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() @@ -567,6 +725,12 @@ func validateFrostPreSignActivationManifest( manifest.Ethereum.ChainID == 0 || manifest.manifestHash == [32]byte{} { return fmt.Errorf("FROST activation payload is incomplete") } + genesisHash, err := frostPreSignParseBytes32( + manifest.Ethereum.GenesisBlockHash, + ) + if err != nil || genesisHash == [32]byte{} { + return fmt.Errorf("FROST activation genesis block hash is invalid") + } if _, err := frostPreSignParseBytes32(manifest.ActivationID); err != nil { return fmt.Errorf("invalid FROST activation ID: [%w]", err) } @@ -702,6 +866,18 @@ func newFrostPreSignEthereumAdapter( common.Address(profile.FrostRegistry) != tc.frostWalletRegistryAddr { return nil, fmt.Errorf("activation manifest differs from configured Bridge/FROST registry") } + expectedGenesisHash, err := frostPreSignParseBytes32( + manifest.Ethereum.GenesisBlockHash, + ) + if err != nil { + return nil, err + } + genesisHeader, err := tc.client.HeaderByNumber(ctx, big.NewInt(0)) + if err != nil || genesisHeader == nil || + genesisHeader.Number == nil || genesisHeader.Number.Sign() != 0 || + genesisHeader.Hash() != common.Hash(expectedGenesisHash) { + return nil, fmt.Errorf("activation manifest genesis block differs from connected Ethereum chain: [%w]", err) + } finality, err := frostPreSignCurrentFinality(ctx, tc.client) if err != nil { return nil, err @@ -715,14 +891,6 @@ func newFrostPreSignEthereumAdapter( common.Address(profile.BridgeAddress), frostPreSignBridgeABI, tc.client, tc.client, tc.client, ), - registry: bind.NewBoundContract( - common.Address(profile.RegistryAddress), frostPreSignRegistryABI, - tc.client, tc.client, tc.client, - ), - router: bind.NewBoundContract( - common.Address(profile.CompleteRouter), frostPreSignCrosslinkABI, - tc.client, tc.client, tc.client, - ), } if err := adapter.verifyDeploymentAt(ctx, finality); err != nil { return nil, fmt.Errorf("FROST activation manifest verification failed: [%w]", err) @@ -832,6 +1000,115 @@ func frostPreSignDeploymentPinFromManifest( role string, name string, contract frostPreSignManifestContract, +) (frostPreSignDeploymentPin, error) { + pin, err := frostPreSignDeploymentDescriptorPinFromManifest( + role, + name, + contract, + ) + if err != nil { + return pin, err + } + pin.deploymentBlock = contract.DeploymentBlock + pin.relevantEventStartBlock = contract.RelevantEventStartBlock + if len(contract.HistoricalDeploymentEpochs) == 0 || + len(contract.HistoricalDeploymentEpochs) > 64 { + return pin, fmt.Errorf("%s historical deployment epochs are missing or exceed the limit", name) + } + pin.historicalEpochs = make( + []frostPreSignDeploymentEpochPin, + 0, + len(contract.HistoricalDeploymentEpochs), + ) + for index, manifestEpoch := range contract.HistoricalDeploymentEpochs { + epochContract := frostPreSignManifestContract{ + Address: manifestEpoch.Address, + RuntimeCodeHash: manifestEpoch.RuntimeCodeHash, + ProtocolID: contract.ProtocolID, + DeploymentBlock: contract.DeploymentBlock, + RelevantEventStartBlock: contract.RelevantEventStartBlock, + LinkedLibraryDescriptorHash: manifestEpoch.LinkedLibraryDescriptorHash, + LinkedLibraries: manifestEpoch.LinkedLibraries, + Upgradeability: manifestEpoch.Upgradeability, + } + descriptor, err := frostPreSignDeploymentDescriptorPinFromManifest( + role, + fmt.Sprintf("%s historical epoch %d", name, index), + epochContract, + ) + if err != nil { + return pin, err + } + descriptor.name = name + start, err := frostPreSignManifestFinality(manifestEpoch.Start) + if err != nil { + return pin, fmt.Errorf("invalid %s historical epoch [%d] start: [%w]", name, index, err) + } + var end *tbtc.FrostPreSignFinality + if manifestEpoch.End != nil { + parsedEnd, err := frostPreSignManifestFinality(*manifestEpoch.End) + if err != nil { + return pin, fmt.Errorf("invalid %s historical epoch [%d] end: [%w]", name, index, err) + } + end = &parsedEnd + } + if index == 0 && start.BlockNumber != contract.DeploymentBlock { + return pin, fmt.Errorf("%s historical epochs do not start at deployment", name) + } + if end != nil && end.BlockNumber < start.BlockNumber { + return pin, fmt.Errorf("%s historical epoch [%d] has an inverted range", name, index) + } + if index+1 < len(contract.HistoricalDeploymentEpochs) && end == nil { + return pin, fmt.Errorf("%s historical epoch [%d] is open before the final epoch", name, index) + } + if index+1 == len(contract.HistoricalDeploymentEpochs) && end != nil { + return pin, fmt.Errorf("%s final historical deployment epoch is not open", name) + } + if index > 0 { + previous := pin.historicalEpochs[index-1] + if previous.end == nil || + previous.end.BlockNumber == ^uint64(0) || + previous.end.BlockNumber+1 != start.BlockNumber { + return pin, fmt.Errorf("%s historical deployment epochs have a gap or overlap", name) + } + } + pin.historicalEpochs = append( + pin.historicalEpochs, + frostPreSignDeploymentEpochPin{ + start: start, + end: end, + descriptor: descriptor, + }, + ) + } + lastEpoch := pin.historicalEpochs[len(pin.historicalEpochs)-1] + if contract.RelevantEventStartBlock < pin.historicalEpochs[0].start.BlockNumber || + (lastEpoch.end != nil && + contract.RelevantEventStartBlock > lastEpoch.end.BlockNumber) || + frostPreSignDeploymentDescriptorHash(pin) != + frostPreSignDeploymentDescriptorHash(lastEpoch.descriptor) { + return pin, fmt.Errorf("%s historical deployment epochs do not cover events or current descriptor", name) + } + return pin, nil +} + +func frostPreSignManifestFinality( + point frostPreSignManifestPoint, +) (tbtc.FrostPreSignFinality, error) { + blockHash, err := frostPreSignParseBytes32(point.BlockHash) + if err != nil || point.BlockNumber == 0 || blockHash == [32]byte{} { + return tbtc.FrostPreSignFinality{}, fmt.Errorf("manifest point is invalid") + } + return tbtc.FrostPreSignFinality{ + BlockNumber: point.BlockNumber, + BlockHash: blockHash, + }, nil +} + +func frostPreSignDeploymentDescriptorPinFromManifest( + role string, + name string, + contract frostPreSignManifestContract, ) (frostPreSignDeploymentPin, error) { pin := frostPreSignDeploymentPin{role: role, name: name} var err error @@ -1042,28 +1319,58 @@ func frostPreSignLinkedLibraryInventoryHash( func frostPreSignLinkedLibraryDescriptorSetHash( deployments []frostPreSignDeploymentPin, ) ([32]byte, error) { - type contractDescriptor struct { - ContractRole string `json:"contractRole"` + type epochDescriptor struct { + StartBlock uint64 `json:"startBlock"` + EndBlock *uint64 `json:"endBlock"` CodeKind string `json:"codeKind"` LinkedLibraries []frostPreSignLinkedLibraryDescriptor `json:"linkedLibraries"` } + type contractDescriptor struct { + ContractRole string `json:"contractRole"` + CodeKind string `json:"codeKind"` + LinkedLibraries []frostPreSignLinkedLibraryDescriptor `json:"linkedLibraries"` + HistoricalEpochs []epochDescriptor `json:"historicalEpochs"` + } contracts := make([]contractDescriptor, 0, len(deployments)) for _, deployment := range deployments { codeKind := "runtime" if deployment.upgradeability == "eip1967" { codeKind = "implementation-runtime" } + historicalEpochs := make( + []epochDescriptor, + 0, + len(deployment.historicalEpochs), + ) + for _, epoch := range deployment.historicalEpochs { + epochCodeKind := "runtime" + if epoch.descriptor.upgradeability == "eip1967" { + epochCodeKind = "implementation-runtime" + } + var endBlock *uint64 + if epoch.end != nil { + value := epoch.end.BlockNumber + endBlock = &value + } + historicalEpochs = append(historicalEpochs, epochDescriptor{ + StartBlock: epoch.start.BlockNumber, + EndBlock: endBlock, + CodeKind: epochCodeKind, + LinkedLibraries: frostPreSignLinkedLibraryDescriptors(epoch.descriptor.linkedLibraries), + }) + } contracts = append(contracts, contractDescriptor{ - ContractRole: deployment.role, - CodeKind: codeKind, - LinkedLibraries: frostPreSignLinkedLibraryDescriptors(deployment.linkedLibraries), + ContractRole: deployment.role, + CodeKind: codeKind, + LinkedLibraries: frostPreSignLinkedLibraryDescriptors(deployment.linkedLibraries), + HistoricalEpochs: historicalEpochs, }) } sort.Slice(contracts, func(i, j int) bool { return contracts[i].ContractRole < contracts[j].ContractRole }) canonical, err := frostPreSignCanonicalValue(map[string]interface{}{ - "schema": "tbtc-p2tr-linked-library-descriptor-set/v1", + "schema": "tbtc-p2tr-linked-library-descriptor-set/v2", "contracts": contracts, }) if err != nil { @@ -1086,27 +1393,90 @@ func frostPreSignSlotValueBindsAddress(value [32]byte, address [20]byte) bool { func frostPreSignDeploymentSetHash(deployments []frostPreSignDeploymentPin) [32]byte { hasher := sha256.New() - hasher.Write([]byte("tbtc-frost-pre-sign-deployment-set-v1\x00")) + hasher.Write([]byte("tbtc-frost-pre-sign-deployment-set-v2\x00")) for _, deployment := range deployments { - hasher.Write([]byte(deployment.name)) - hasher.Write([]byte{0}) - hasher.Write(deployment.address[:]) - hasher.Write(deployment.runtimeCodeHash[:]) - hasher.Write([]byte(deployment.upgradeability)) - hasher.Write([]byte{0}) - hasher.Write(deployment.implementationAddress[:]) - hasher.Write(deployment.implementationCodeHash[:]) - hasher.Write(deployment.adminAddress[:]) - hasher.Write(deployment.adminCodeHash[:]) - hasher.Write(deployment.implementationSlotValue[:]) - hasher.Write(deployment.adminSlotValue[:]) - hasher.Write(deployment.linkedLibraryDescriptorHash[:]) + frostPreSignWriteHashString(hasher, deployment.role) + frostPreSignWriteHashString(hasher, deployment.name) + frostPreSignWriteHashUint64(hasher, deployment.deploymentBlock) + frostPreSignWriteHashUint64(hasher, deployment.relevantEventStartBlock) + descriptorHash := frostPreSignDeploymentDescriptorHash(deployment) + hasher.Write(descriptorHash[:]) + frostPreSignWriteHashUint64(hasher, uint64(len(deployment.historicalEpochs))) + for _, epoch := range deployment.historicalEpochs { + frostPreSignWriteHashUint64(hasher, epoch.start.BlockNumber) + hasher.Write(epoch.start.BlockHash[:]) + if epoch.end == nil { + hasher.Write([]byte{0}) + } else { + hasher.Write([]byte{1}) + frostPreSignWriteHashUint64(hasher, epoch.end.BlockNumber) + hasher.Write(epoch.end.BlockHash[:]) + } + epochDescriptorHash := frostPreSignDeploymentDescriptorHash( + epoch.descriptor, + ) + hasher.Write(epochDescriptorHash[:]) + } } result := [32]byte{} copy(result[:], hasher.Sum(nil)) return result } +func frostPreSignDeploymentDescriptorHash( + deployment frostPreSignDeploymentPin, +) [32]byte { + hasher := sha256.New() + hasher.Write([]byte("tbtc-frost-deployment-descriptor-v1\x00")) + hasher.Write(deployment.address[:]) + hasher.Write(deployment.runtimeCodeHash[:]) + frostPreSignWriteHashString(hasher, deployment.upgradeability) + hasher.Write(deployment.implementationAddress[:]) + hasher.Write(deployment.implementationCodeHash[:]) + hasher.Write(deployment.adminAddress[:]) + hasher.Write(deployment.adminCodeHash[:]) + hasher.Write(deployment.implementationSlotValue[:]) + hasher.Write(deployment.adminSlotValue[:]) + hasher.Write(deployment.linkedLibraryDescriptorHash[:]) + frostPreSignWriteHashUint64(hasher, uint64(len(deployment.linkedLibraries))) + for _, library := range deployment.linkedLibraries { + frostPreSignWriteLinkedLibraryHash(hasher, library) + } + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result +} + +func frostPreSignWriteLinkedLibraryHash( + hasher io.Writer, + library frostPreSignLinkedLibraryPin, +) { + frostPreSignWriteHashString(hasher, library.protocolRole) + _, _ = hasher.Write(library.address[:]) + _, _ = hasher.Write(library.runtimeCodeHash[:]) + _, _ = hasher.Write(library.linkedLibraryDescriptorHash[:]) + frostPreSignWriteHashUint64(hasher, uint64(len(library.references))) + for _, reference := range library.references { + frostPreSignWriteHashUint64(hasher, reference.Start) + frostPreSignWriteHashUint64(hasher, reference.Length) + } + frostPreSignWriteHashUint64(hasher, uint64(len(library.linkedLibraries))) + for _, child := range library.linkedLibraries { + frostPreSignWriteLinkedLibraryHash(hasher, child) + } +} + +func frostPreSignWriteHashString(hasher io.Writer, value string) { + frostPreSignWriteHashUint64(hasher, uint64(len(value))) + _, _ = hasher.Write([]byte(value)) +} + +func frostPreSignWriteHashUint64(hasher io.Writer, value uint64) { + buffer := [8]byte{} + binary.BigEndian.PutUint64(buffer[:], value) + _, _ = hasher.Write(buffer[:]) +} + func frostPreSignParseAddress(value string) ([20]byte, error) { if !common.IsHexAddress(value) || len(value) != 42 || value != strings.ToLower(value) || !strings.HasPrefix(value, "0x") { @@ -1158,26 +1528,19 @@ func (adapter *frostPreSignEthereumAdapter) verifyDeploymentAt( if err := adapter.requireCanonicalFinality(ctx, finality); err != nil { return err } - block := new(big.Int).SetUint64(finality.BlockNumber) - profile := adapter.profile - storageReader, ok := adapter.chain.client.(interface { - StorageAt( - context.Context, - common.Address, - common.Hash, - *big.Int, - ) ([]byte, error) - }) - if !ok { - return fmt.Errorf("Ethereum client does not expose finalized storage reads") + exactReader, err := adapter.exactHashReader() + if err != nil { + return err } + blockHash := common.Hash(finality.BlockHash) + profile := adapter.profile implementationSlot := frostPreSignEIP1967Slot("eip1967.proxy.implementation") adminSlot := frostPreSignEIP1967Slot("eip1967.proxy.admin") for _, expected := range adapter.deployments { - code, err := adapter.chain.client.CodeAt( + code, err := exactReader.CodeAtHash( ctx, common.Address(expected.address), - block, + blockHash, ) if err != nil { return fmt.Errorf("cannot read %s runtime code: [%w]", expected.name, err) @@ -1187,20 +1550,20 @@ func (adapter *frostPreSignEthereumAdapter) verifyDeploymentAt( return fmt.Errorf("%s runtime code hash mismatch", expected.name) } ownerCode := code - implementationValue, err := storageReader.StorageAt( + implementationValue, err := exactReader.StorageAtHash( ctx, common.Address(expected.address), implementationSlot, - block, + blockHash, ) if err != nil || len(implementationValue) != 32 { return fmt.Errorf("cannot read %s EIP-1967 implementation slot: [%w]", expected.name, err) } - adminValue, err := storageReader.StorageAt( + adminValue, err := exactReader.StorageAtHash( ctx, common.Address(expected.address), adminSlot, - block, + blockHash, ) if err != nil || len(adminValue) != 32 { return fmt.Errorf("cannot read %s EIP-1967 admin slot: [%w]", expected.name, err) @@ -1216,20 +1579,20 @@ func (adapter *frostPreSignEthereumAdapter) verifyDeploymentAt( !bytes.Equal(adminValue, expected.adminSlotValue[:]) { return fmt.Errorf("%s EIP-1967 slot value mismatch", expected.name) } - implementationCode, err := adapter.chain.client.CodeAt( + implementationCode, err := exactReader.CodeAtHash( ctx, common.Address(expected.implementationAddress), - block, + blockHash, ) if err != nil || len(implementationCode) == 0 || [32]byte(crypto.Keccak256Hash(implementationCode)) != expected.implementationCodeHash { return fmt.Errorf("%s implementation runtime code hash mismatch: [%w]", expected.name, err) } ownerCode = implementationCode - adminCode, err := adapter.chain.client.CodeAt( + adminCode, err := exactReader.CodeAtHash( ctx, common.Address(expected.adminAddress), - block, + blockHash, ) if err != nil || [32]byte(crypto.Keccak256Hash(adminCode)) != expected.adminCodeHash { return fmt.Errorf("%s admin runtime code hash mismatch: [%w]", expected.name, err) @@ -1242,21 +1605,40 @@ func (adapter *frostPreSignEthereumAdapter) verifyDeploymentAt( expected.name, ownerCode, expected.linkedLibraries, - block, + blockHash, + exactReader, ); err != nil { return err } } - bridgeRouter, err := adapter.callAddressAt(ctx, adapter.bridge, "p2trFraudRouter", block) + bridgeRouter, err := adapter.callAddressAtHash( + ctx, + common.Address(profile.BridgeAddress), + frostPreSignBridgeABI, + "p2trFraudRouter", + blockHash, + ) if err != nil || bridgeRouter != common.Address(profile.CompleteRouter) { return fmt.Errorf("Bridge COMPLETE router crosslink mismatch: [%w]", err) } - routerBridge, err := adapter.callAddressAt(ctx, adapter.router, "bridge", block) + routerBridge, err := adapter.callAddressAtHash( + ctx, + common.Address(profile.CompleteRouter), + frostPreSignCrosslinkABI, + "bridge", + blockHash, + ) if err != nil || routerBridge != common.Address(profile.BridgeAddress) { return fmt.Errorf("COMPLETE router Bridge crosslink mismatch: [%w]", err) } - routerRegistry, err := adapter.callAddressAt(ctx, adapter.router, "authorizationRegistry", block) + routerRegistry, err := adapter.callAddressAtHash( + ctx, + common.Address(profile.CompleteRouter), + frostPreSignCrosslinkABI, + "authorizationRegistry", + blockHash, + ) if err != nil || routerRegistry != common.Address(profile.RegistryAddress) { return fmt.Errorf("COMPLETE router registry crosslink mismatch: [%w]", err) } @@ -1265,13 +1647,25 @@ func (adapter *frostPreSignEthereumAdapter) verifyDeploymentAt( "preauthorizationProtocolID": profile.ReservationProtocolID, "signingPolicyHash": profile.SigningPolicyHash, } { - actual, err := adapter.callBytes32At(ctx, adapter.router, method, block) + actual, err := adapter.callBytes32AtHash( + ctx, + common.Address(profile.CompleteRouter), + frostPreSignCrosslinkABI, + method, + blockHash, + ) if err != nil || actual != expected { return fmt.Errorf("COMPLETE router %s mismatch: [%w]", method, err) } } - config, err := adapter.callAt(ctx, adapter.registry, "protocolConfig", block) + config, err := adapter.callAtHash( + ctx, + common.Address(profile.RegistryAddress), + frostPreSignRegistryABI, + "protocolConfig", + blockHash, + ) if err != nil || len(config) != 6 { return fmt.Errorf("cannot read authorization registry protocol config: [%w]", err) } @@ -1290,19 +1684,23 @@ func (adapter *frostPreSignEthereumAdapter) verifyDeploymentAt( return fmt.Errorf("authorization registry protocol config mismatch") } - frostContract := bind.NewBoundContract( - common.Address(profile.FrostRegistry), frostPreSignCrosslinkABI, - adapter.chain.client, adapter.chain.client, adapter.chain.client, + sortitionPool, err := adapter.callAddressAtHash( + ctx, + common.Address(profile.FrostRegistry), + frostPreSignCrosslinkABI, + "sortitionPool", + blockHash, ) - sortitionPool, err := adapter.callAddressAt(ctx, frostContract, "sortitionPool", block) if err != nil || sortitionPool != common.Address(profile.SortitionPool) { return fmt.Errorf("FROST registry sortition pool crosslink mismatch: [%w]", err) } - validatorContract := bind.NewBoundContract( - common.Address(profile.ProposalValidator), frostPreSignCrosslinkABI, - adapter.chain.client, adapter.chain.client, adapter.chain.client, + validatorBridge, err := adapter.callAddressAtHash( + ctx, + common.Address(profile.ProposalValidator), + frostPreSignCrosslinkABI, + "bridge", + blockHash, ) - validatorBridge, err := adapter.callAddressAt(ctx, validatorContract, "bridge", block) if err != nil || validatorBridge != common.Address(profile.BridgeAddress) { return fmt.Errorf("proposal validator Bridge crosslink mismatch: [%w]", err) } @@ -1318,7 +1716,8 @@ func (adapter *frostPreSignEthereumAdapter) verifyLinkedLibrariesAt( owner string, ownerCode []byte, libraries []frostPreSignLinkedLibraryPin, - block *big.Int, + blockHash common.Hash, + reader frostPreSignExactHashReader, ) error { for _, library := range libraries { for _, reference := range library.references { @@ -1337,10 +1736,10 @@ func (adapter *frostPreSignEthereumAdapter) verifyLinkedLibrariesAt( ) } } - libraryCode, err := adapter.chain.client.CodeAt( + libraryCode, err := reader.CodeAtHash( ctx, common.Address(library.address), - block, + blockHash, ) if err != nil || len(libraryCode) == 0 || [32]byte(crypto.Keccak256Hash(libraryCode)) != library.runtimeCodeHash { @@ -1356,7 +1755,8 @@ func (adapter *frostPreSignEthereumAdapter) verifyLinkedLibrariesAt( owner+"/"+library.protocolRole, libraryCode, library.linkedLibraries, - block, + blockHash, + reader, ); err != nil { return err } @@ -1387,6 +1787,23 @@ func (adapter *frostPreSignEthereumAdapter) requireCanonicalFinality( if finality.BlockNumber == before.BlockNumber && finality.BlockHash != before.BlockHash { return fmt.Errorf("Ethereum checkpoint disagrees with finalized head") } + exactReader, err := adapter.exactHashReader() + if err != nil { + return err + } + exactHeader, err := exactReader.HeaderByHash( + ctx, + common.Hash(finality.BlockHash), + ) + if err != nil { + return fmt.Errorf("cannot read exact finalized Ethereum header: [%w]", err) + } + if exactHeader == nil || exactHeader.Number == nil || + !exactHeader.Number.IsUint64() || + exactHeader.Number.Uint64() != finality.BlockNumber || + exactHeader.Hash() != common.Hash(finality.BlockHash) { + return fmt.Errorf("exact finalized Ethereum header mismatch") + } header, err := adapter.chain.client.HeaderByNumber( ctx, new(big.Int).SetUint64(finality.BlockNumber), @@ -1417,55 +1834,98 @@ func (adapter *frostPreSignEthereumAdapter) requireCanonicalFinality( if headerAfter == nil || [32]byte(headerAfter.Hash()) != finality.BlockHash { return fmt.Errorf("finalized Ethereum block hash changed while verifying checkpoint") } + exactHeaderAfter, err := exactReader.HeaderByHash( + ctx, + common.Hash(finality.BlockHash), + ) + if err != nil { + return fmt.Errorf("cannot bracket exact finalized Ethereum header: [%w]", err) + } + if exactHeaderAfter == nil || exactHeaderAfter.Number == nil || + !exactHeaderAfter.Number.IsUint64() || + exactHeaderAfter.Number.Uint64() != finality.BlockNumber || + exactHeaderAfter.Hash() != common.Hash(finality.BlockHash) { + return fmt.Errorf("exact finalized Ethereum header changed while verifying checkpoint") + } return nil } -func (adapter *frostPreSignEthereumAdapter) callAt( +func (adapter *frostPreSignEthereumAdapter) callAtHash( ctx context.Context, - contract *bind.BoundContract, + address common.Address, + contractABI abi.ABI, method string, - block *big.Int, + blockHash common.Hash, parameters ...interface{}, ) ([]interface{}, error) { - if contract == nil { - return nil, fmt.Errorf("contract binding is nil") - } - var result []interface{} - err := contract.Call( - &bind.CallOpts{ - Context: ctx, - From: adapter.chain.key.Address, - BlockNumber: block, + if address == (common.Address{}) || blockHash == (common.Hash{}) { + return nil, fmt.Errorf("exact-hash contract call identity is incomplete") + } + exactReader, err := adapter.exactHashReader() + if err != nil { + return nil, err + } + callData, err := contractABI.Pack(method, parameters...) + if err != nil { + return nil, fmt.Errorf("cannot encode exact-hash call [%s]: [%w]", method, err) + } + output, err := exactReader.CallContractAtHash( + ctx, + geth.CallMsg{ + From: adapter.chain.key.Address, + To: &address, + Data: callData, }, - &result, - method, - parameters..., + blockHash, ) - return result, err + if err != nil { + return nil, err + } + abiMethod, ok := contractABI.Methods[method] + if !ok { + return nil, fmt.Errorf("exact-hash call method [%s] is absent", method) + } + return abiMethod.Outputs.Unpack(output) } -func (adapter *frostPreSignEthereumAdapter) callAddressAt( +func (adapter *frostPreSignEthereumAdapter) callAddressAtHash( ctx context.Context, - contract *bind.BoundContract, + address common.Address, + contractABI abi.ABI, method string, - block *big.Int, + blockHash common.Hash, parameters ...interface{}, ) (common.Address, error) { - result, err := adapter.callAt(ctx, contract, method, block, parameters...) + result, err := adapter.callAtHash( + ctx, + address, + contractABI, + method, + blockHash, + parameters..., + ) if err != nil || len(result) != 1 { return common.Address{}, err } return *abi.ConvertType(result[0], new(common.Address)).(*common.Address), nil } -func (adapter *frostPreSignEthereumAdapter) callBytes32At( +func (adapter *frostPreSignEthereumAdapter) callBytes32AtHash( ctx context.Context, - contract *bind.BoundContract, + address common.Address, + contractABI abi.ABI, method string, - block *big.Int, + blockHash common.Hash, parameters ...interface{}, ) ([32]byte, error) { - result, err := adapter.callAt(ctx, contract, method, block, parameters...) + result, err := adapter.callAtHash( + ctx, + address, + contractABI, + method, + blockHash, + parameters..., + ) if err != nil || len(result) != 1 { return [32]byte{}, err } @@ -1523,6 +1983,16 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( if err != nil { return tbtc.FrostPreSignActivationRuntimeManifest{}, err } + linkedLibraryDescriptorSetHash, err := parse( + adapter.manifest.Ethereum.LinkedLibraryDescriptorSetHash, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + genesisBlockHash, err := parse(adapter.manifest.Ethereum.GenesisBlockHash) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } quarantine := frost.QuarantineJournal quarantineJournalProtocolID, err := parse(quarantine.ProtocolID) if err != nil { @@ -1563,6 +2033,12 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( } return tbtc.FrostPreSignActivationRuntimeManifest{ ManifestHash: adapter.profile.ActivationManifestHash, + DomainChainID: adapter.profile.DomainChainID, + GenesisBlockHash: genesisBlockHash, + ProfileHash: adapter.profile.ProfileHash, + ImplementationSetHash: adapter.profile.ImplementationSetHash, + LinkedLibraryDescriptorSetHash: linkedLibraryDescriptorSetHash, + Deployments: frostPreSignRuntimeDeploymentEvidence(adapter.deployments), SignerProtocolID: signerProtocolID, ReservationProtocolID: adapter.profile.ReservationProtocolID, BitcoinOutboxProtocolID: bitcoinOutboxProtocolID, @@ -1598,6 +2074,87 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( }, nil } +func frostPreSignRuntimeDeploymentEvidence( + deployments []frostPreSignDeploymentPin, +) []tbtc.FrostPreSignDeploymentEvidence { + result := make([]tbtc.FrostPreSignDeploymentEvidence, 0, len(deployments)) + for _, deployment := range deployments { + epochs := make( + []tbtc.FrostPreSignDeploymentEpochEvidence, + 0, + len(deployment.historicalEpochs), + ) + for _, epoch := range deployment.historicalEpochs { + var end *tbtc.FrostPreSignFinality + if epoch.end != nil { + copied := *epoch.end + end = &copied + } + epochs = append(epochs, tbtc.FrostPreSignDeploymentEpochEvidence{ + Start: epoch.start, + End: end, + Descriptor: frostPreSignRuntimeDeploymentDescriptor(epoch.descriptor), + }) + } + result = append(result, tbtc.FrostPreSignDeploymentEvidence{ + Role: deployment.role, + Name: deployment.name, + DeploymentBlock: deployment.deploymentBlock, + RelevantEventStartBlock: deployment.relevantEventStartBlock, + Current: frostPreSignRuntimeDeploymentDescriptor(deployment), + HistoricalEpochs: epochs, + }) + } + return result +} + +func frostPreSignRuntimeDeploymentDescriptor( + deployment frostPreSignDeploymentPin, +) tbtc.FrostPreSignDeploymentDescriptorEvidence { + return tbtc.FrostPreSignDeploymentDescriptorEvidence{ + Address: deployment.address, + RuntimeCodeHash: deployment.runtimeCodeHash, + Upgradeability: deployment.upgradeability, + ImplementationAddress: deployment.implementationAddress, + ImplementationCodeHash: deployment.implementationCodeHash, + AdminAddress: deployment.adminAddress, + AdminCodeHash: deployment.adminCodeHash, + ImplementationSlotValue: deployment.implementationSlotValue, + AdminSlotValue: deployment.adminSlotValue, + LinkedLibraryDescriptorHash: deployment.linkedLibraryDescriptorHash, + LinkedLibraries: frostPreSignRuntimeLinkedLibraries(deployment.linkedLibraries), + DescriptorHash: frostPreSignDeploymentDescriptorHash(deployment), + } +} + +func frostPreSignRuntimeLinkedLibraries( + libraries []frostPreSignLinkedLibraryPin, +) []tbtc.FrostPreSignLinkedLibraryEvidence { + result := make([]tbtc.FrostPreSignLinkedLibraryEvidence, 0, len(libraries)) + for _, library := range libraries { + references := make( + []tbtc.FrostPreSignLinkedLibraryReference, + 0, + len(library.references), + ) + for _, reference := range library.references { + references = append(references, tbtc.FrostPreSignLinkedLibraryReference{ + Start: reference.Start, + Length: reference.Length, + }) + } + result = append(result, tbtc.FrostPreSignLinkedLibraryEvidence{ + ProtocolRole: library.protocolRole, + Address: library.address, + RuntimeCodeHash: library.runtimeCodeHash, + References: references, + LinkedLibraryDescriptorHash: library.linkedLibraryDescriptorHash, + LinkedLibraries: frostPreSignRuntimeLinkedLibraries(library.linkedLibraries), + }) + } + return result +} + func (adapter *frostPreSignEthereumAdapter) prepare( ctx context.Context, transaction *tbtc.FrostPreSignTransaction, @@ -1613,8 +2170,8 @@ func (adapter *frostPreSignEthereumAdapter) prepare( if err := adapter.verifyDeploymentAt(ctx, finality); err != nil { return nil, err } - block := new(big.Int).SetUint64(finality.BlockNumber) - members, err := adapter.resolveWalletMembersAt(ctx, walletOperators, block) + blockHash := common.Hash(finality.BlockHash) + members, err := adapter.resolveWalletMembersAt(ctx, walletOperators, blockHash) if err != nil { return nil, err } @@ -1641,11 +2198,12 @@ func (adapter *frostPreSignEthereumAdapter) prepare( if err != nil { return nil, fmt.Errorf("cannot encode COMPLETE preview payload: [%w]", err) } - result, err := adapter.callAt( + result, err := adapter.callAtHash( ctx, - adapter.bridge, + common.Address(adapter.profile.BridgeAddress), + frostPreSignBridgeABI, "previewP2TRTransactionAuthorization", - block, + blockHash, payload, ) if err != nil || len(result) != 1 { @@ -1673,7 +2231,7 @@ func (adapter *frostPreSignEthereumAdapter) prepare( ctx, transaction, preview.WalletID, - block, + blockHash, ) if err != nil { return nil, err @@ -1686,6 +2244,9 @@ func (adapter *frostPreSignEthereumAdapter) prepare( return nil, fmt.Errorf("COMPLETE preview resource commitments differ from local derivation") } + if err := adapter.requireCanonicalFinality(ctx, finality); err != nil { + return nil, fmt.Errorf("preparation finality changed during exact-hash reads: [%w]", err) + } profile := adapter.profile return &tbtc.FrostPreSignAuthorizationProposal{ Transaction: transaction, @@ -1735,7 +2296,7 @@ func (tc *TbtcChain) frostPreSignAdapter() (*frostPreSignEthereumAdapter, error) func (adapter *frostPreSignEthereumAdapter) resolveWalletMembersAt( ctx context.Context, operators []chain.Address, - block *big.Int, + blockHash common.Hash, ) ([]uint32, error) { if len(operators) < 51 || len(operators) > 100 { return nil, fmt.Errorf("invalid FROST wallet seat count [%d]", len(operators)) @@ -1748,14 +2309,19 @@ func (adapter *frostPreSignEthereumAdapter) resolveWalletMembersAt( if !common.IsHexAddress(operator.String()) { return nil, fmt.Errorf("invalid FROST wallet operator address [%s]", operator) } - var err error - id, err = adapter.chain.frostSortitionPool.GetOperatorIDAtBlock( - common.HexToAddress(operator.String()), - block, + operatorAddress := common.HexToAddress(operator.String()) + callResult, err := adapter.callAtHash( + ctx, + common.Address(adapter.profile.SortitionPool), + frostPreSignCrosslinkABI, + "getOperatorID", + blockHash, + operatorAddress, ) - if err != nil { + if err != nil || len(callResult) != 1 { return nil, fmt.Errorf("cannot resolve finalized FROST seat [%d]: [%w]", i+1, err) } + id = *abi.ConvertType(callResult[0], new(uint32)).(*uint32) if id == 0 { return nil, fmt.Errorf("FROST wallet seat [%d] has no sortition-pool ID", i+1) } @@ -1899,7 +2465,7 @@ func (adapter *frostPreSignEthereumAdapter) deriveResourcesAt( ctx context.Context, transaction *tbtc.FrostPreSignTransaction, walletID [32]byte, - block *big.Int, + blockHash common.Hash, ) ([][32]byte, [32]byte, error) { bitcoinTx := &bitcoin.Transaction{} if err := bitcoinTx.Deserialize(transaction.RawTransaction); err != nil { @@ -1958,7 +2524,14 @@ func (adapter *frostPreSignEthereumAdapter) deriveResourcesAt( } case tbtc.FrostPreSignActionMovingFunds: for _, target := range transaction.ActionContext.MovingFunds.Proposal.TargetWallets { - targetID, err := adapter.callBytes32At(ctx, adapter.bridge, "walletID", block, target) + targetID, err := adapter.callBytes32AtHash( + ctx, + common.Address(adapter.profile.BridgeAddress), + frostPreSignBridgeABI, + "walletID", + blockHash, + target, + ) if err != nil || targetID == [32]byte{} { return nil, [32]byte{}, fmt.Errorf("cannot resolve moving-funds target wallet ID: [%w]", err) } @@ -2306,13 +2879,14 @@ func (adapter *frostPreSignEthereumAdapter) readAuthorizationState( if err := adapter.verifyDeploymentAt(ctx, &finality); err != nil { return nil, err } - block := new(big.Int).SetUint64(finality.BlockNumber) + blockHash := common.Hash(finality.BlockHash) - lifecycle, err := adapter.callAt( + lifecycle, err := adapter.callAtHash( ctx, - adapter.bridge, + common.Address(adapter.profile.BridgeAddress), + frostPreSignBridgeABI, "frostLifecycleContext", - block, + blockHash, proposal.Transaction.WalletPublicKeyHash, ) if err != nil || len(lifecycle) != 2 { @@ -2321,11 +2895,12 @@ func (adapter *frostPreSignEthereumAdapter) readAuthorizationState( lifecycleRegistry := *abi.ConvertType(lifecycle[0], new(common.Address)).(*common.Address) lifecycleWalletID := *abi.ConvertType(lifecycle[1], new([32]byte)).(*[32]byte) - walletResult, err := adapter.callAt( + walletResult, err := adapter.callAtHash( ctx, - adapter.bridge, + common.Address(adapter.profile.BridgeAddress), + frostPreSignBridgeABI, "wallets", - block, + blockHash, proposal.Transaction.WalletPublicKeyHash, ) if err != nil || len(walletResult) != 1 { @@ -2333,15 +2908,12 @@ func (adapter *frostPreSignEthereumAdapter) readAuthorizationState( } wallet := *abi.ConvertType(walletResult[0], new(frostPreSignWalletABI)).(*frostPreSignWalletABI) - frostContract := bind.NewBoundContract( - common.Address(adapter.profile.FrostRegistry), frostPreSignCrosslinkABI, - adapter.chain.client, adapter.chain.client, adapter.chain.client, - ) - frostWalletResult, err := adapter.callAt( + frostWalletResult, err := adapter.callAtHash( ctx, - frostContract, + common.Address(adapter.profile.FrostRegistry), + frostPreSignCrosslinkABI, "getWallet", - block, + blockHash, proposal.WalletID, ) if err != nil || len(frostWalletResult) != 1 { @@ -2352,47 +2924,54 @@ func (adapter *frostPreSignEthereumAdapter) readAuthorizationState( new(frostPreSignRegistryWalletABI), ).(*frostPreSignRegistryWalletABI) - activeReservation, err := adapter.callBytes32At( + activeReservation, err := adapter.callBytes32AtHash( ctx, - adapter.registry, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, "activeReservation", - block, + blockHash, proposal.Transaction.WalletPublicKeyHash, ) if err != nil { return nil, fmt.Errorf("cannot read finalized active reservation: [%w]", err) } - reservation, err := adapter.callAt( + reservation, err := adapter.callAtHash( ctx, - adapter.registry, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, "getReservation", - block, + blockHash, proposal.ReservationID, ) if err != nil || len(reservation) != 11 { return nil, fmt.Errorf("cannot read finalized reservation: [%w]", err) } - variant, err := adapter.callAt( + variant, err := adapter.callAtHash( ctx, - adapter.registry, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, "getAuthorizedVariantStatus", - block, + blockHash, [32]byte(proposal.Transaction.TransactionHash), ) if err != nil || len(variant) != 6 { return nil, fmt.Errorf("cannot read finalized authorized variant: [%w]", err) } - latest, err := adapter.callAt( + latest, err := adapter.callAtHash( ctx, - adapter.registry, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, "latestAuthorizedVariant", - block, + blockHash, proposal.ReservationID, ) if err != nil || len(latest) != 3 { return nil, fmt.Errorf("cannot read finalized latest variant: [%w]", err) } + if err := adapter.requireCanonicalFinality(ctx, &finality); err != nil { + return nil, fmt.Errorf("authorization-state finality changed during exact-hash reads: [%w]", err) + } profile := adapter.profile return &tbtc.FrostPreSignAuthorizationState{ Finality: finality, @@ -2479,19 +3058,21 @@ func (adapter *frostPreSignEthereumAdapter) canonicalBroadcastStatus( if err := adapter.requireCanonicalFinality(ctx, &historical); err != nil { return nil, err } - historicalBlock := new(big.Int).SetUint64(request.FinalizedBlock) if err := adapter.validateHistoricalBroadcastEvent(ctx, request); err != nil { return nil, err } canonical, err := adapter.validateBroadcastAuthorizationAt( ctx, request, - historicalBlock, + common.Hash(request.FinalizedBlockHash), true, ) if err != nil { return nil, err } + if err := adapter.requireCanonicalFinality(ctx, &historical); err != nil { + return nil, fmt.Errorf("historical broadcast finality changed during exact-hash reads: [%w]", err) + } if !canonical { return &tbtc.FrostBitcoinBroadcastAuthorizationStatus{ RequestHash: requestHash, @@ -2512,12 +3093,15 @@ func (adapter *frostPreSignEthereumAdapter) canonicalBroadcastStatus( allowed, err = adapter.validateBroadcastAuthorizationAt( ctx, request, - new(big.Int).SetUint64(current.BlockNumber), + common.Hash(current.BlockHash), false, ) if err != nil { return nil, err } + if err := adapter.requireCanonicalFinality(ctx, current); err != nil { + return nil, fmt.Errorf("current broadcast finality changed during exact-hash reads: [%w]", err) + } } return &tbtc.FrostBitcoinBroadcastAuthorizationStatus{ RequestHash: requestHash, @@ -2555,34 +3139,37 @@ func (adapter *frostPreSignEthereumAdapter) validateHistoricalBroadcastEvent( func (adapter *frostPreSignEthereumAdapter) validateBroadcastAuthorizationAt( ctx context.Context, request *tbtc.FrostBitcoinBroadcastAuthorizationStatusRequest, - block *big.Int, + blockHash common.Hash, requirePlan bool, ) (bool, error) { - reservation, err := adapter.callAt( + reservation, err := adapter.callAtHash( ctx, - adapter.registry, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, "getReservation", - block, + blockHash, request.ReservationID, ) if err != nil || len(reservation) != 11 { return false, fmt.Errorf("cannot read canonical broadcast reservation: [%w]", err) } - variant, err := adapter.callAt( + variant, err := adapter.callAtHash( ctx, - adapter.registry, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, "getAuthorizedVariantStatus", - block, + blockHash, [32]byte(request.TransactionHash), ) if err != nil || len(variant) != 6 { return false, fmt.Errorf("cannot read canonical broadcast variant: [%w]", err) } - latest, err := adapter.callAt( + latest, err := adapter.callAtHash( ctx, - adapter.registry, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, "latestAuthorizedVariant", - block, + blockHash, request.ReservationID, ) if err != nil || len(latest) != 3 { @@ -2643,11 +3230,12 @@ func (adapter *frostPreSignEthereumAdapter) validateBroadcastAuthorizationAt( ApplyPlanData2: data2, FeeLimitSnapshot: feeLimit, } - digest, err := adapter.callBytes32At( + digest, err := adapter.callBytes32AtHash( ctx, - adapter.registry, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, "preAuthorizationDigest", - block, + blockHash, preAuthorization, [32]byte(request.TransactionHash), variantRoot, diff --git a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go index 7357c51177..c8ec23949e 100644 --- a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go +++ b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go @@ -20,6 +20,7 @@ func testFrostJournalActivationManifest() *frostPreSignActivationManifest { manifestHash: [32]byte{0x99}, Ethereum: frostPreSignManifestEthereum{ ChainID: 1, + GenesisBlockHash: testManifestHex32(0x30), Checkpoint: frostPreSignManifestPoint{BlockNumber: 10, BlockHash: checkpointHash}, StoreID: "primary-store", SourceTrustDomainID: "primary-source", diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index 3c102638e2..5f11db610a 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -722,7 +722,7 @@ type FrostPreSignActivationProfile struct { // ComputeHash returns the canonical activation-profile commitment. func (fpsap FrostPreSignActivationProfile) ComputeHash() [32]byte { hasher := sha256.New() - hasher.Write([]byte("tbtc-frost-pre-sign-activation-profile-v4")) + hasher.Write([]byte("tbtc-frost-pre-sign-activation-profile-v5")) hasher.Write(fpsap.DomainChainID[:]) hasher.Write(fpsap.ActivationManifestHash[:]) hasher.Write(fpsap.ImplementationSetHash[:]) @@ -1300,8 +1300,160 @@ type FrostPreSignActivationPointVerifier interface { // FrostPreSignActivationRuntimeManifest is the signer-facing subset of the // authenticated production activation envelope. It is immutable for the // process lifetime and feeds the nonce-bound runtime status exporter. +type FrostPreSignLinkedLibraryReference struct { + Start uint64 + Length uint64 +} + +type FrostPreSignLinkedLibraryEvidence struct { + ProtocolRole string + Address [20]byte + RuntimeCodeHash [32]byte + References []FrostPreSignLinkedLibraryReference + LinkedLibraryDescriptorHash [32]byte + LinkedLibraries []FrostPreSignLinkedLibraryEvidence +} + +type FrostPreSignDeploymentDescriptorEvidence struct { + Address [20]byte + RuntimeCodeHash [32]byte + Upgradeability string + ImplementationAddress [20]byte + ImplementationCodeHash [32]byte + AdminAddress [20]byte + AdminCodeHash [32]byte + ImplementationSlotValue [32]byte + AdminSlotValue [32]byte + LinkedLibraryDescriptorHash [32]byte + LinkedLibraries []FrostPreSignLinkedLibraryEvidence + DescriptorHash [32]byte +} + +type FrostPreSignDeploymentEpochEvidence struct { + Start FrostPreSignFinality + End *FrostPreSignFinality + Descriptor FrostPreSignDeploymentDescriptorEvidence +} + +type FrostPreSignDeploymentEvidence struct { + Role string + Name string + DeploymentBlock uint64 + RelevantEventStartBlock uint64 + Current FrostPreSignDeploymentDescriptorEvidence + HistoricalEpochs []FrostPreSignDeploymentEpochEvidence +} + +func (descriptor FrostPreSignDeploymentDescriptorEvidence) ComputeHash() [32]byte { + hasher := sha256.New() + hasher.Write([]byte("tbtc-frost-deployment-descriptor-v1\x00")) + hasher.Write(descriptor.Address[:]) + hasher.Write(descriptor.RuntimeCodeHash[:]) + frostPreSignWriteCommitmentString(hasher, descriptor.Upgradeability) + hasher.Write(descriptor.ImplementationAddress[:]) + hasher.Write(descriptor.ImplementationCodeHash[:]) + hasher.Write(descriptor.AdminAddress[:]) + hasher.Write(descriptor.AdminCodeHash[:]) + hasher.Write(descriptor.ImplementationSlotValue[:]) + hasher.Write(descriptor.AdminSlotValue[:]) + hasher.Write(descriptor.LinkedLibraryDescriptorHash[:]) + frostPreSignWriteCommitmentUint64(hasher, uint64(len(descriptor.LinkedLibraries))) + for _, library := range descriptor.LinkedLibraries { + frostPreSignWriteLinkedLibraryCommitment(hasher, library) + } + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result +} + +func ComputeFrostPreSignDeploymentEvidenceHash( + deployments []FrostPreSignDeploymentEvidence, +) [32]byte { + hasher := sha256.New() + hasher.Write([]byte("tbtc-frost-pre-sign-deployment-set-v2\x00")) + for _, deployment := range deployments { + frostPreSignWriteCommitmentString(hasher, deployment.Role) + frostPreSignWriteCommitmentString(hasher, deployment.Name) + frostPreSignWriteCommitmentUint64(hasher, deployment.DeploymentBlock) + frostPreSignWriteCommitmentUint64( + hasher, + deployment.RelevantEventStartBlock, + ) + currentHash := deployment.Current.ComputeHash() + hasher.Write(currentHash[:]) + frostPreSignWriteCommitmentUint64( + hasher, + uint64(len(deployment.HistoricalEpochs)), + ) + for _, epoch := range deployment.HistoricalEpochs { + frostPreSignWriteCommitmentUint64(hasher, epoch.Start.BlockNumber) + hasher.Write(epoch.Start.BlockHash[:]) + if epoch.End == nil { + hasher.Write([]byte{0}) + } else { + hasher.Write([]byte{1}) + frostPreSignWriteCommitmentUint64( + hasher, + epoch.End.BlockNumber, + ) + hasher.Write(epoch.End.BlockHash[:]) + } + descriptorHash := epoch.Descriptor.ComputeHash() + hasher.Write(descriptorHash[:]) + } + } + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result +} + +func frostPreSignWriteLinkedLibraryCommitment( + hasher interface{ Write([]byte) (int, error) }, + library FrostPreSignLinkedLibraryEvidence, +) { + frostPreSignWriteCommitmentString(hasher, library.ProtocolRole) + _, _ = hasher.Write(library.Address[:]) + _, _ = hasher.Write(library.RuntimeCodeHash[:]) + _, _ = hasher.Write(library.LinkedLibraryDescriptorHash[:]) + frostPreSignWriteCommitmentUint64(hasher, uint64(len(library.References))) + for _, reference := range library.References { + frostPreSignWriteCommitmentUint64(hasher, reference.Start) + frostPreSignWriteCommitmentUint64(hasher, reference.Length) + } + frostPreSignWriteCommitmentUint64( + hasher, + uint64(len(library.LinkedLibraries)), + ) + for _, child := range library.LinkedLibraries { + frostPreSignWriteLinkedLibraryCommitment(hasher, child) + } +} + +func frostPreSignWriteCommitmentString( + hasher interface{ Write([]byte) (int, error) }, + value string, +) { + frostPreSignWriteCommitmentUint64(hasher, uint64(len(value))) + _, _ = hasher.Write([]byte(value)) +} + +func frostPreSignWriteCommitmentUint64( + hasher interface{ Write([]byte) (int, error) }, + value uint64, +) { + buffer := [8]byte{} + binary.BigEndian.PutUint64(buffer[:], value) + _, _ = hasher.Write(buffer[:]) +} + type FrostPreSignActivationRuntimeManifest struct { ManifestHash [32]byte + DomainChainID [32]byte + GenesisBlockHash [32]byte + ProfileHash [32]byte + ImplementationSetHash [32]byte + LinkedLibraryDescriptorSetHash [32]byte + Deployments []FrostPreSignDeploymentEvidence SignerProtocolID [32]byte ReservationProtocolID [32]byte BitcoinOutboxProtocolID [32]byte From b534e19fb0cf30af38449abd8bd3514087cb0d1d Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 17:40:59 -0400 Subject: [PATCH 08/93] fix(tbtc): bind signer readiness to ABI 4.1 state --- .github/workflows/frost-cgo-integration.yml | 3 + .../signing/attempt_context_from_request.go | 17 - .../signing/dkg_group_pubkey_extraction.go | 88 ----- ...ffi_primitive_transitional_frost_native.go | 45 --- .../signing/native_tbtc_signer_abi_version.go | 29 +- .../native_tbtc_signer_abi_version_test.go | 13 +- .../signing/native_tbtc_signer_inventory.go | 55 ++- .../native_tbtc_signer_inventory_test.go | 141 +++++++ .../signing/native_tbtc_signer_material.go | 46 +++ ...ner_readback_real_cgo_frost_native_test.go | 89 +++++ .../native_tbtc_signer_store_identity.go | 33 +- .../native_tbtc_signer_store_identity_test.go | 108 +++++- pkg/frost/signing/tbtc_signer_key.go | 108 ++++++ pkg/tbtc/frost_durable_session_store.go | 8 +- pkg/tbtc/frost_durable_session_store_test.go | 40 +- pkg/tbtc/frost_native_signer_readiness.go | 69 +++- .../frost_native_signer_readiness_test.go | 122 +++++- pkg/tbtc/frost_retained_group_journal.go | 33 ++ pkg/tbtc/frost_retained_group_journal_test.go | 45 ++- pkg/tbtc/frost_retained_key_group_binding.go | 357 ++++++++++++++++++ .../frost_retained_key_group_binding_test.go | 290 ++++++++++++++ pkg/tbtc/registry.go | 137 ++++++- 22 files changed, 1623 insertions(+), 253 deletions(-) create mode 100644 pkg/frost/signing/native_tbtc_signer_readback_real_cgo_frost_native_test.go create mode 100644 pkg/frost/signing/tbtc_signer_key.go create mode 100644 pkg/tbtc/frost_retained_key_group_binding.go create mode 100644 pkg/tbtc/frost_retained_key_group_binding_test.go diff --git a/.github/workflows/frost-cgo-integration.yml b/.github/workflows/frost-cgo-integration.yml index 28963148fd..6b4cb87c74 100644 --- a/.github/workflows/frost-cgo-integration.yml +++ b/.github/workflows/frost-cgo-integration.yml @@ -93,6 +93,9 @@ jobs: frost_tbtc_new_signing_package \ frost_tbtc_interactive_aggregate \ frost_tbtc_verify_signature_share \ + frost_tbtc_durable_store_identity \ + frost_tbtc_retained_key_package_inventory \ + frost_tbtc_state_witness_proof \ frost_tbtc_version \ frost_tbtc_abi_version; do if ! nm -D --defined-only "$lib" | grep -q " ${sym}$"; then diff --git a/pkg/frost/signing/attempt_context_from_request.go b/pkg/frost/signing/attempt_context_from_request.go index a8a7e1d7c3..2a1981dfc4 100644 --- a/pkg/frost/signing/attempt_context_from_request.go +++ b/pkg/frost/signing/attempt_context_from_request.go @@ -186,23 +186,6 @@ func membersDifference(all, remove []group.MemberIndex) []group.MemberIndex { return out } -// KeyGroupIDFromSignerMaterial returns the canonical FROST key-group handle for the -// given native signer material -- the exact string BuildAttemptContextFromRequest -// stores as AttemptContext.KeyGroupID. ROAST-retry wiring uses it to scope the -// coordinator registry by wallet key group at the registration and lookup sites that -// hold signer material rather than a fully-built AttemptContext (the interactive -// drive registration and the transition-controller). Returns an error for material -// whose format has no derivable key-group handle. -func KeyGroupIDFromSignerMaterial(signerMaterial *NativeSignerMaterial) (string, error) { - if signerMaterial == nil { - return "", fmt.Errorf("key group id: signer material is nil") - } - // deriveKeyGroupID ignores the DKG public key for the only supported format - // (FrostTBTCSignerV1, whose KeyGroup string is the handle); pass nil rather than - // re-extracting it, and let deriveKeyGroupID reject unsupported formats. - return deriveKeyGroupID(signerMaterial, nil) -} - // deriveKeyGroupID computes the AttemptContext KeyGroupID field // from the signer material plus the already-extracted DKG group // public key. The derivation is format-aware: diff --git a/pkg/frost/signing/dkg_group_pubkey_extraction.go b/pkg/frost/signing/dkg_group_pubkey_extraction.go index db4b3880ae..7c4c70f163 100644 --- a/pkg/frost/signing/dkg_group_pubkey_extraction.go +++ b/pkg/frost/signing/dkg_group_pubkey_extraction.go @@ -3,12 +3,8 @@ package signing import ( - "encoding/hex" "errors" "fmt" - - "github.com/btcsuite/btcd/btcec/v2" - "github.com/keep-network/keep-core/pkg/frost" ) // ErrUnsupportedSignerMaterialFormat is returned by @@ -84,26 +80,6 @@ func ExtractDkgGroupPublicKeyFromMaterial( } } -// ExtractTaprootOutputKeyFromMaterial returns the 32-byte x-only Taproot -// output key committed to by native FROST signer material. -func ExtractTaprootOutputKeyFromMaterial( - signerMaterial *NativeSignerMaterial, -) ([]byte, error) { - if signerMaterial == nil { - return nil, fmt.Errorf("taproot output key: signer material is nil") - } - - switch signerMaterial.Format { - case NativeSignerMaterialFormatFrostTBTCSignerV1: - return extractTaprootOutputKeyFromTBTCSignerV1(signerMaterial) - default: - return nil, fmt.Errorf( - "taproot output key: unsupported signer-material format [%s]", - signerMaterial.Format, - ) - } -} - func extractDkgGroupPublicKeyFromTBTCSignerV1( signerMaterial *NativeSignerMaterial, ) ([]byte, error) { @@ -121,67 +97,3 @@ func extractDkgGroupPublicKeyFromTBTCSignerV1( } return []byte(payload.KeyGroup), nil } - -func extractTaprootOutputKeyFromTBTCSignerV1( - signerMaterial *NativeSignerMaterial, -) ([]byte, error) { - payload, err := decodeBuildTaggedTBTCSignerMaterialPayload(signerMaterial) - if err != nil { - return nil, fmt.Errorf( - "taproot output key: decode FrostTBTCSignerV1: %w", - err, - ) - } - if payload.KeyGroupSource != NativeTBTCSignerKeyGroupSourceDKGPersisted { - return nil, fmt.Errorf( - "taproot output key: FrostTBTCSignerV1 key group source [%s] is not [%s]", - payload.KeyGroupSource, - NativeTBTCSignerKeyGroupSourceDKGPersisted, - ) - } - - outputKeyHex := payload.TaprootOutputKey - if outputKeyHex == "" { - outputKeyHex = payload.KeyGroup - } - - outputKey, err := TaprootOutputKeyFromTBTCSignerKey(outputKeyHex) - if err != nil { - return nil, fmt.Errorf( - "taproot output key: FrostTBTCSignerV1 key material is invalid: %w", - err, - ) - } - - return outputKey, nil -} - -// TaprootOutputKeyFromTBTCSignerKey converts tbtc-signer key material to the -// x-only BIP-340 output key committed to by P2TR wallet scripts. Current -// tbtc-signer DKG results expose the group verifying key as a compressed -// secp256k1 key-group handle, while older test material may already carry the -// x-only key. -func TaprootOutputKeyFromTBTCSignerKey(keyHex string) ([]byte, error) { - raw, err := hex.DecodeString(keyHex) - if err != nil { - return nil, err - } - - switch len(raw) { - case frost.OutputKeySize: - return raw, nil - case 1 + frost.OutputKeySize: - publicKey, err := btcec.ParsePubKey(raw) - if err != nil { - return nil, err - } - return publicKey.X().FillBytes(make([]byte, frost.OutputKeySize)), nil - default: - return nil, fmt.Errorf( - "must be %d-byte x-only or %d-byte compressed key, got %d bytes", - frost.OutputKeySize, - 1+frost.OutputKeySize, - len(raw), - ) - } -} diff --git a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go index 53948f06c5..ebde579ebe 100644 --- a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go +++ b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go @@ -4,7 +4,6 @@ package signing import ( "context" - "encoding/json" "fmt" "os" "strings" @@ -328,50 +327,6 @@ func decodeBuildTaggedLegacyPrivateKeyShare( return privateKeyShare, nil } -func decodeBuildTaggedTBTCSignerMaterialPayload( - signerMaterial *NativeSignerMaterial, -) (*NativeTBTCSignerMaterialPayload, error) { - if signerMaterial == nil { - return nil, fmt.Errorf( - "%w: signer material is nil", - ErrNativeCryptographyUnavailable, - ) - } - - if signerMaterial.Format != NativeSignerMaterialFormatFrostTBTCSignerV1 { - return nil, fmt.Errorf( - "%w: unsupported signer material format: [%s]", - ErrNativeCryptographyUnavailable, - signerMaterial.Format, - ) - } - - if len(signerMaterial.Payload) == 0 { - return nil, fmt.Errorf( - "%w: signer material payload is empty", - ErrNativeCryptographyUnavailable, - ) - } - - var payload NativeTBTCSignerMaterialPayload - if err := json.Unmarshal(signerMaterial.Payload, &payload); err != nil { - return nil, fmt.Errorf( - "%w: cannot unmarshal tbtc-signer payload: [%v]", - ErrNativeCryptographyUnavailable, - err, - ) - } - - if payload.KeyGroup == "" { - return nil, fmt.Errorf( - "%w: tbtc-signer key group is empty", - ErrNativeCryptographyUnavailable, - ) - } - - return &payload, nil -} - // decodeBuildTaggedTBTCSignerSignature decodes and canonicality-checks a // BIP-340 signature produced by the native FROST signer. It is shared with the // interactive ROAST signing drive (the go-forward path), which aggregates the diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version.go b/pkg/frost/signing/native_tbtc_signer_abi_version.go index 2411ac7e10..91637c1016 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version.go @@ -20,26 +20,17 @@ const ( // incompatible JSON/crypto-contract change, so an ABI-2 library must not be // linked by this bridge. // - // Major 4: the durable-store identity schema moved to - // `tbtc-signer-durable-session-store-identity/v2` and the state-witness - // transcript to v2. `durable_store_fingerprint` now binds ONLY the stable - // `.store-id`; the canonical-path, filesystem and lock fingerprints are still - // reported and still enforced at open, but no longer enter any committed - // transcript. Under ABI 3 they did, so a benign filesystem change - deleting - // the advisory lock file, restoring the data directory from backup, renaming - // it, or remounting where st_dev is unstable - made every committed record - // unverifiable and the signer unstartable, with no in-band recovery. The - // symbol set is unchanged, but the VALUES of existing wire fields differ, so - // an ABI-3 library must not be linked by this bridge and an ABI-3 journal is - // rejected by magic with a migration error rather than silently reset. + // Major 4: RefreshShares no longer returns synthetic replacement material. + // It fails closed with cryptographic_refresh_not_supported until a real + // multi-round refresh protocol exists. The changed status and response + // semantics are incompatible with ABI 3. requiredTBTCSignerABIMajor uint32 = 4 - // Minor 0 is the initial ABI-4 surface: it carries forward every symbol added - // through ABI 3.5 (typed heartbeat signing intent, heartbeat rate-limit - // configuration and rejection metric, canary evidence, descriptor-bound - // durable-store identity, and exact retained key-package inventory plus the - // append-only state-witness proof required to detect rollback of key packages - // and durable replay markers) under the v2 identity and transcript schemas. - requiredTBTCSignerABIMinMinor uint32 = 0 + // Minor 1 adds the durable-store identity, exact retained key-package + // inventory, and paginated state-witness proof readbacks. These are new + // symbols and response types, so ABI-4.0 callers remain valid and ignore + // them. Their first public contract uses the v2 stable-store and witness + // transcripts; bridges that consume them must require at least 4.1. + requiredTBTCSignerABIMinMinor uint32 = 1 ) // ErrTBTCSignerABIIncompatible marks a linked libfrost_tbtc whose FFI contract version diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go index be84cbcb42..b51896dcbe 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go @@ -93,12 +93,10 @@ func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { // identity schema and the state-witness transcript to v2, so that state // commitments bind only the stable `.store-id` and no longer break when a // benign filesystem change alters the lock file, directory inode, or device. - // The symbol set is unchanged from 3.5, but the VALUES of existing wire fields - // differ, so an ABI-3 library must not be linked. Minor 0 is the initial ABI-4 - // surface and carries forward everything added through 3.5. The matching - // library version is compatible; a different major is not. A regression here - // means the required constants drifted from what the bridge actually speaks. - if requiredTBTCSignerABIMajor != 4 || requiredTBTCSignerABIMinMinor != 0 { + // Minor 1 adds the identity, retained inventory, and state-witness proof + // readbacks to the already deployed ABI-4 surface. The matching library + // version is compatible; ABI 4.0 and a different major are not. + if requiredTBTCSignerABIMajor != 4 || requiredTBTCSignerABIMinMinor != 1 { t.Fatalf( "unexpected required tbtc-signer ABI: [%d.%d]", requiredTBTCSignerABIMajor, @@ -108,6 +106,9 @@ func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, requiredTBTCSignerABIMinMinor); err != nil { t.Fatalf("the required contract version must be self-compatible: %v", err) } + if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, 0); err == nil { + t.Fatal("ABI 4.0 without readiness readbacks must be incompatible") + } if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor+1, requiredTBTCSignerABIMinMinor); err == nil { t.Fatal("a higher major must be incompatible") } diff --git a/pkg/frost/signing/native_tbtc_signer_inventory.go b/pkg/frost/signing/native_tbtc_signer_inventory.go index 168a05bf06..8640d472fc 100644 --- a/pkg/frost/signing/native_tbtc_signer_inventory.go +++ b/pkg/frost/signing/native_tbtc_signer_inventory.go @@ -20,7 +20,8 @@ const ( NativeTBTCSignerStateWitnessProofMaximumEntries uint16 = 256 nativeTBTCSignerRetainedKeyPackageInventoryCommitmentDomain = "tbtc-signer-retained-key-package-inventory-commitment-v1\x00" - nativeTBTCSignerStateWitnessCommitmentDomain = "tbtc-signer-state-witness-commitment-v1\x00" + nativeTBTCSignerStateWitnessGenesisDomain = "tbtc-signer-state-witness-genesis-v2\x00" + nativeTBTCSignerStateWitnessCommitmentDomain = "tbtc-signer-state-witness-commitment-v2\x00" ) // NativeTBTCSignerRetainedKeyPackage identifies one locally held key package. @@ -153,16 +154,24 @@ func DecodeNativeTBTCSignerRetainedKeyPackageInventory( entry.WalletID = walletID if entryWire.KeyGroup != strings.ToLower(entryWire.KeyGroup) || - len(entryWire.KeyGroup) != 64 || strings.HasPrefix(entryWire.KeyGroup, "0x") { - return nil, fmt.Errorf("retained key group is not canonical lowercase x-only hex") + (len(entryWire.KeyGroup) != 64 && len(entryWire.KeyGroup) != 66) || + strings.HasPrefix(entryWire.KeyGroup, "0x") { + return nil, fmt.Errorf( + "retained key group is not canonical lowercase x-only or compressed SEC1 hex", + ) } - keyGroupBytes, err := hex.DecodeString(entryWire.KeyGroup) - if err != nil || len(keyGroupBytes) != 32 { - return nil, fmt.Errorf("retained key group is not canonical lowercase x-only hex") + outputKey, err := TaprootOutputKeyFromTBTCSignerKey(entryWire.KeyGroup) + if err != nil || len(outputKey) != len(walletID) { + return nil, fmt.Errorf( + "retained key group is not canonical lowercase x-only or compressed SEC1 hex", + ) } - if !bytes.Equal(keyGroupBytes, walletID[:]) { + if !bytes.Equal(outputKey, walletID[:]) { return nil, fmt.Errorf("retained key group does not identify its wallet") } + // Keep the exact Rust key-group handle. It is part of the inventory + // commitment and is also the lookup key used by interactive signing; the + // x-only projection above is only the canonical wallet identity. entry.KeyGroup = entryWire.KeyGroup entry.Threshold = entryWire.Threshold entry.ParticipantCount = entryWire.ParticipantCount @@ -222,19 +231,21 @@ func ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment( entries []NativeTBTCSignerRetainedKeyGroup, ) [32]byte { digest := sha256.New() - digest.Write([]byte(nativeTBTCSignerRetainedKeyPackageInventoryCommitmentDomain)) + _, _ = digest.Write( + []byte(nativeTBTCSignerRetainedKeyPackageInventoryCommitmentDomain), + ) writeNativeTBTCSignerUint32(digest, uint32(len(entries))) for _, entry := range entries { - digest.Write(entry.WalletID[:]) + _, _ = digest.Write(entry.WalletID[:]) writeNativeTBTCSignerStoreFingerprintField(digest, []byte(entry.KeyGroup)) writeNativeTBTCSignerUint16(digest, entry.Threshold) writeNativeTBTCSignerUint16(digest, entry.ParticipantCount) writeNativeTBTCSignerUint64(digest, entry.ShareEpoch) - digest.Write(entry.PublicKeyPackageCommitment[:]) + _, _ = digest.Write(entry.PublicKeyPackageCommitment[:]) writeNativeTBTCSignerUint32(digest, uint32(len(entry.KeyPackages))) for _, keyPackage := range entry.KeyPackages { writeNativeTBTCSignerUint16(digest, keyPackage.ParticipantSeat) - digest.Write(keyPackage.KeyPackageCommitment[:]) + _, _ = digest.Write(keyPackage.KeyPackageCommitment[:]) } } var result [32]byte @@ -242,6 +253,20 @@ func ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment( return result } +// ComputeNativeTBTCSignerStateWitnessGenesis derives the root preceding a +// store's first state-witness record. It is exported so independent anchor +// implementations and cross-language tests can reproduce the Rust transcript. +func ComputeNativeTBTCSignerStateWitnessGenesis( + storeFingerprint [32]byte, +) [32]byte { + digest := sha256.New() + _, _ = digest.Write([]byte(nativeTBTCSignerStateWitnessGenesisDomain)) + _, _ = digest.Write(storeFingerprint[:]) + var result [32]byte + copy(result[:], digest.Sum(nil)) + return result +} + // ComputeNativeTBTCSignerStateWitnessCommitment binds one durable signer-state // image to its store, generation, and direct hash-chain parent. func ComputeNativeTBTCSignerStateWitnessCommitment( @@ -251,11 +276,11 @@ func ComputeNativeTBTCSignerStateWitnessCommitment( stateImageDigest [32]byte, ) [32]byte { digest := sha256.New() - digest.Write([]byte(nativeTBTCSignerStateWitnessCommitmentDomain)) - digest.Write(storeFingerprint[:]) + _, _ = digest.Write([]byte(nativeTBTCSignerStateWitnessCommitmentDomain)) + _, _ = digest.Write(storeFingerprint[:]) writeNativeTBTCSignerUint64(digest, generation) - digest.Write(previousStateCommitment[:]) - digest.Write(stateImageDigest[:]) + _, _ = digest.Write(previousStateCommitment[:]) + _, _ = digest.Write(stateImageDigest[:]) var result [32]byte copy(result[:], digest.Sum(nil)) return result diff --git a/pkg/frost/signing/native_tbtc_signer_inventory_test.go b/pkg/frost/signing/native_tbtc_signer_inventory_test.go index d52b5dd5da..bfd09bfcd1 100644 --- a/pkg/frost/signing/native_tbtc_signer_inventory_test.go +++ b/pkg/frost/signing/native_tbtc_signer_inventory_test.go @@ -1,6 +1,7 @@ package signing import ( + "crypto/sha256" "encoding/hex" "encoding/json" "strings" @@ -23,6 +24,146 @@ func TestDecodeNativeTBTCSignerRetainedKeyPackageInventory(t *testing.T) { } } +func TestDecodeNativeTBTCSignerRetainedKeyPackageInventoryAcceptsCompressedKeyGroup( + t *testing.T, +) { + const compressedKeyGroup = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + walletIDBytes, err := hex.DecodeString(compressedKeyGroup[2:]) + if err != nil { + t.Fatal(err) + } + var walletID [32]byte + copy(walletID[:], walletIDBytes) + + wire := testNativeTBTCSignerRetainedKeyPackageInventoryWire() + (*wire.Entries)[0].WalletID = nativeTBTCSignerBytes32(walletID) + (*wire.Entries)[0].KeyGroup = compressedKeyGroup + entries := []NativeTBTCSignerRetainedKeyGroup{ + { + WalletID: walletID, + KeyGroup: compressedKeyGroup, + Threshold: 51, + ParticipantCount: 100, + ShareEpoch: 0, + PublicKeyPackageCommitment: [32]byte{0x05}, + KeyPackages: []NativeTBTCSignerRetainedKeyPackage{ + {ParticipantSeat: 3, KeyPackageCommitment: [32]byte{0x06}}, + }, + }, + } + wire.InventoryCommitment = nativeTBTCSignerBytes32( + ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment(entries), + ) + + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + inventory, err := DecodeNativeTBTCSignerRetainedKeyPackageInventory(payload) + if err != nil { + t.Fatalf("valid compressed native key group was rejected: [%v]", err) + } + if inventory.Entries[0].KeyGroup != compressedKeyGroup || + inventory.Entries[0].WalletID != walletID { + t.Fatalf("compressed key group was not retained exactly: [%+v]", inventory.Entries[0]) + } +} + +func TestNativeTBTCSignerInventoryCommitmentMatchesRustFrozenVector(t *testing.T) { + entries := []NativeTBTCSignerRetainedKeyGroup{ + { + WalletID: repeatedNativeTBTCSignerBytes32(0x11), + KeyGroup: "02" + strings.Repeat("11", 32), + Threshold: 2, + ParticipantCount: 3, + ShareEpoch: 0, + PublicKeyPackageCommitment: repeatedNativeTBTCSignerBytes32(0x33), + KeyPackages: []NativeTBTCSignerRetainedKeyPackage{ + { + ParticipantSeat: 1, + KeyPackageCommitment: repeatedNativeTBTCSignerBytes32(0x44), + }, + { + ParticipantSeat: 3, + KeyPackageCommitment: repeatedNativeTBTCSignerBytes32(0x55), + }, + }, + }, + } + + actual := ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment(entries) + const expected = "bd6ec36fa27a57dd9926883bb2ff4dee7ececd28de940df7294f0e0f0dedd150" + if hex.EncodeToString(actual[:]) != expected { + t.Fatalf("unexpected inventory commitment: [%x]", actual) + } +} + +func TestNativeTBTCSignerStateWitnessCommitmentMatchesRustV2Vector(t *testing.T) { + storeFingerprint := repeatedNativeTBTCSignerBytes32(0x11) + genesis := ComputeNativeTBTCSignerStateWitnessGenesis(storeFingerprint) + const expectedGenesis = "44085b42d29bf25f06207142f9e2db58eaf86f88d92b6e18104161ce59e98a89" + if hex.EncodeToString(genesis[:]) != expectedGenesis { + t.Fatalf("unexpected v2 state-witness genesis: [%x]", genesis) + } + + actual := ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + 42, + repeatedNativeTBTCSignerBytes32(0x22), + repeatedNativeTBTCSignerBytes32(0x33), + ) + const expected = "ea5eb04a4776357e59875f683390a2ff4b7dd511ad394e588dfab147f94fa867" + if hex.EncodeToString(actual[:]) != expected { + t.Fatalf("unexpected v2 state-witness commitment: [%x]", actual) + } + + retiredV1GenesisInput := append( + []byte("tbtc-signer-state-witness-genesis-v1\x00"), + storeFingerprint[:]..., + ) + retiredV1Genesis := sha256.Sum256(retiredV1GenesisInput) + const expectedRetiredV1Genesis = "639ab6bce7b111044aa40cbe05d2a79a789c47d83e0dbf5ac83af3e2c8717775" + if hex.EncodeToString(retiredV1Genesis[:]) != expectedRetiredV1Genesis { + t.Fatalf("unexpected retired v1 state-witness genesis: [%x]", retiredV1Genesis) + } + if retiredV1Genesis == genesis { + t.Fatal("v2 state-witness genesis aliases the retired v1 transcript") + } +} + +func TestDecodeNativeTBTCSignerRetainedKeyPackageInventoryRejectsRetiredV1Commitment( + t *testing.T, +) { + wire := testNativeTBTCSignerRetainedKeyPackageInventoryWire() + wire.StateGeneration = 42 + wire.PreviousStateCommitment = nativeTBTCSignerBytes32( + repeatedNativeTBTCSignerBytes32(0x22), + ) + wire.StateImageDigest = nativeTBTCSignerBytes32( + repeatedNativeTBTCSignerBytes32(0x33), + ) + retiredV1, err := hex.DecodeString( + "903d154bca4b0e46f2cadda81db9559bdf2d719956065266f55bd845e64b7ced", + ) + if err != nil { + t.Fatal(err) + } + var retiredV1Commitment [32]byte + copy(retiredV1Commitment[:], retiredV1) + wire.StoreFingerprint = nativeTBTCSignerBytes32( + repeatedNativeTBTCSignerBytes32(0x11), + ) + wire.StateCommitment = nativeTBTCSignerBytes32(retiredV1Commitment) + + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeNativeTBTCSignerRetainedKeyPackageInventory(payload); err == nil { + t.Fatal("retired v1 state-witness commitment was accepted under the v2 ABI") + } +} + func TestDecodeNativeTBTCSignerRetainedKeyPackageInventoryRejectsSubstitution( t *testing.T, ) { diff --git a/pkg/frost/signing/native_tbtc_signer_material.go b/pkg/frost/signing/native_tbtc_signer_material.go index 6e0f458630..f4e4aae49b 100644 --- a/pkg/frost/signing/native_tbtc_signer_material.go +++ b/pkg/frost/signing/native_tbtc_signer_material.go @@ -1,6 +1,8 @@ package signing import ( + "encoding/json" + "fmt" "os" "strings" ) @@ -46,6 +48,50 @@ type NativeTBTCSignerDKGParticipant struct { PublicKeyHex string `json:"publicKeyHex"` } +func decodeBuildTaggedTBTCSignerMaterialPayload( + signerMaterial *NativeSignerMaterial, +) (*NativeTBTCSignerMaterialPayload, error) { + if signerMaterial == nil { + return nil, fmt.Errorf( + "%w: signer material is nil", + ErrNativeCryptographyUnavailable, + ) + } + + if signerMaterial.Format != NativeSignerMaterialFormatFrostTBTCSignerV1 { + return nil, fmt.Errorf( + "%w: unsupported signer material format: [%s]", + ErrNativeCryptographyUnavailable, + signerMaterial.Format, + ) + } + + if len(signerMaterial.Payload) == 0 { + return nil, fmt.Errorf( + "%w: signer material payload is empty", + ErrNativeCryptographyUnavailable, + ) + } + + var payload NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(signerMaterial.Payload, &payload); err != nil { + return nil, fmt.Errorf( + "%w: cannot unmarshal tbtc-signer payload: [%v]", + ErrNativeCryptographyUnavailable, + err, + ) + } + + if payload.KeyGroup == "" { + return nil, fmt.Errorf( + "%w: tbtc-signer key group is empty", + ErrNativeCryptographyUnavailable, + ) + } + + return &payload, nil +} + // AcceptScaffoldKeyGroupEnabled reports whether the operator has opted into // accepting scaffold-era (legacy-wallet-pubkey) key-group material. Without // this, the signer material resolver and the FFI signing primitive both diff --git a/pkg/frost/signing/native_tbtc_signer_readback_real_cgo_frost_native_test.go b/pkg/frost/signing/native_tbtc_signer_readback_real_cgo_frost_native_test.go new file mode 100644 index 0000000000..b7cfa57072 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_readback_real_cgo_frost_native_test.go @@ -0,0 +1,89 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "bytes" + "fmt" + "testing" +) + +// TestRealCgoSignerReadinessReadbacks exercises every ABI-4.1 readiness +// readback against the actually linked libfrost_tbtc. Pure Go decoder tests +// cannot detect a stale library that reports a compatible-looking ABI version +// while omitting a symbol or emitting a different transcript. +func TestRealCgoSignerReadinessReadbacks(t *testing.T) { + setupRealCgoSignerState(t) + + identity, err := ReadNativeTBTCSignerDurableStoreIdentity() + skipFrostUnavailable(t, "durable store identity", err) + if identity == nil { + t.Fatal("durable store identity readback is nil") + } + + initialInventory, err := ReadNativeTBTCSignerRetainedKeyPackageInventory() + skipFrostUnavailable(t, "retained key-package inventory", err) + if initialInventory == nil { + t.Fatal("retained key-package inventory readback is nil") + } + if initialInventory.StoreFingerprint != identity.Fingerprint { + t.Fatal("inventory and identity readbacks belong to different stores") + } + + proof, err := ReadNativeTBTCSignerStateWitnessProof( + &NativeTBTCSignerStateWitnessProofRequest{ + Schema: NativeTBTCSignerStateWitnessProofRequestSchema, + StoreFingerprint: initialInventory.StoreFingerprint, + AncestorGeneration: initialInventory.StateGeneration, + AncestorCommitment: initialInventory.StateCommitment, + TargetGeneration: initialInventory.StateGeneration, + TargetCommitment: initialInventory.StateCommitment, + MaximumEntries: 16, + }, + ) + skipFrostUnavailable(t, "state-witness proof", err) + if proof == nil || !proof.Complete || len(proof.Entries) != 0 { + t.Fatalf("unexpected equal-tip state-witness proof: [%+v]", proof) + } + + engine := &buildTaggedTBTCSignerEngine{} + sessionID := fmt.Sprintf( + "real-cgo-readback-session-%d", + realCgoSessionSeq.Add(1), + ) + keyGroup := runRealCgoDKGKeyGroup( + t, + engine, + sessionID, + []byte{1, 2}, + 2, + ) + if len(keyGroup) != 66 { + t.Fatalf("Rust DKG returned a non-compressed key-group handle: [%s]", keyGroup) + } + outputKey, err := TaprootOutputKeyFromTBTCSignerKey(keyGroup) + if err != nil { + t.Fatalf("cannot derive x-only wallet ID from real key group: [%v]", err) + } + + inventory, err := ReadNativeTBTCSignerRetainedKeyPackageInventory() + skipFrostUnavailable(t, "retained key-package inventory after DKG", err) + found := false + for _, entry := range inventory.Entries { + if entry.KeyGroup != keyGroup { + continue + } + if !bytes.Equal(entry.WalletID[:], outputKey) { + t.Fatal("real compressed key group does not identify its inventory wallet") + } + if entry.Threshold != 2 || entry.ParticipantCount != 2 || + len(entry.KeyPackages) != 2 { + t.Fatalf("unexpected real retained key group: [%+v]", entry) + } + found = true + break + } + if !found { + t.Fatalf("real persisted key group [%s] is absent from inventory", keyGroup) + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_store_identity.go b/pkg/frost/signing/native_tbtc_signer_store_identity.go index b2c42eafd1..1d6b8be4a0 100644 --- a/pkg/frost/signing/native_tbtc_signer_store_identity.go +++ b/pkg/frost/signing/native_tbtc_signer_store_identity.go @@ -19,9 +19,9 @@ const ( // the signer actually opened and locked. It is deliberately not the // fingerprint of the signer init configuration, which cannot prove where // session replay markers and key packages are being persisted. - NativeTBTCSignerDurableStoreIdentitySchema = "tbtc-signer-durable-session-store-identity/v1" + NativeTBTCSignerDurableStoreIdentitySchema = "tbtc-signer-durable-session-store-identity/v2" - nativeTBTCSignerDurableStoreIdentityDomain = "tbtc-signer-durable-session-store-fingerprint-v1\x00" + nativeTBTCSignerDurableStoreIdentityDomain = "tbtc-signer-durable-session-store-fingerprint-v2\x00" ) var nativeTBTCSignerStoreBackendPattern = regexp.MustCompile( @@ -35,10 +35,12 @@ var nativeTBTCSignerStoreBackendPattern = regexp.MustCompile( // signer, not copied from configuration text. // // The state file itself is atomically replaced on every write, so its inode is -// not a stable store identity. The signer-side contract instead binds the -// persistent store ID, canonical opened path, storage root, and exclusive lock -// object. It must fail readback if a symlink or replacement makes those opened -// identities differ from the live path lookup. +// not a stable store identity. The v2 committed identity binds the backend and +// persistent store ID. Canonical opened-path, storage-root, and exclusive-lock +// fingerprints remain mandatory runtime diagnostics: the signer must fail +// readback if a symlink or replacement makes those opened identities differ +// from the live path lookup, while a safe restore may change them across +// process restarts without changing the stable committed identity. type NativeTBTCSignerDurableStoreIdentity struct { Schema string Backend string @@ -164,8 +166,12 @@ func decodeNativeTBTCSignerStoreBytes32(value string) ([32]byte, error) { // ComputeNativeTBTCSignerDurableStoreFingerprint derives the manifest-bound // fingerprint from runtime store identity, not signer configuration content. -// All variable-width fields are length-prefixed to keep the transcript -// unambiguous. +// The v2 transcript binds only the schema, backend, and fsynced stable StoreID. +// The path, filesystem, and lock fingerprints remain mandatory diagnostics and +// are revalidated by the signer, but must not enter a cross-restart commitment: +// their path, device, and inode values can change after a safe restore, rename, +// remount, or advisory-lock recreation. All variable-width committed fields are +// length-prefixed to keep the transcript unambiguous. func ComputeNativeTBTCSignerDurableStoreFingerprint( identity *NativeTBTCSignerDurableStoreIdentity, ) ([32]byte, error) { @@ -180,19 +186,10 @@ func ComputeNativeTBTCSignerDurableStoreFingerprint( } digest := sha256.New() - digest.Write([]byte(nativeTBTCSignerDurableStoreIdentityDomain)) + _, _ = digest.Write([]byte(nativeTBTCSignerDurableStoreIdentityDomain)) writeNativeTBTCSignerStoreFingerprintField(digest, []byte(identity.Schema)) writeNativeTBTCSignerStoreFingerprintField(digest, []byte(identity.Backend)) writeNativeTBTCSignerStoreFingerprintField(digest, identity.StoreID[:]) - writeNativeTBTCSignerStoreFingerprintField( - digest, - identity.CanonicalPathFingerprint[:], - ) - writeNativeTBTCSignerStoreFingerprintField( - digest, - identity.FilesystemFingerprint[:], - ) - writeNativeTBTCSignerStoreFingerprintField(digest, identity.LockFingerprint[:]) var result [32]byte copy(result[:], digest.Sum(nil)) diff --git a/pkg/frost/signing/native_tbtc_signer_store_identity_test.go b/pkg/frost/signing/native_tbtc_signer_store_identity_test.go index b363d3c9db..808121ae51 100644 --- a/pkg/frost/signing/native_tbtc_signer_store_identity_test.go +++ b/pkg/frost/signing/native_tbtc_signer_store_identity_test.go @@ -27,14 +27,8 @@ func TestDecodeNativeTBTCSignerDurableStoreIdentityRejectsUnboundIdentity( "wrong backend": func(identity *NativeTBTCSignerDurableStoreIdentity) { identity.Backend = "different-backend" }, - "wrong canonical path": func(identity *NativeTBTCSignerDurableStoreIdentity) { - identity.CanonicalPathFingerprint[0] ^= 0xff - }, - "wrong filesystem": func(identity *NativeTBTCSignerDurableStoreIdentity) { - identity.FilesystemFingerprint[0] ^= 0xff - }, - "replaced lock": func(identity *NativeTBTCSignerDurableStoreIdentity) { - identity.LockFingerprint[0] ^= 0xff + "wrong store ID": func(identity *NativeTBTCSignerDurableStoreIdentity) { + identity.StoreID[0] ^= 0xff }, "wrong fingerprint": func(identity *NativeTBTCSignerDurableStoreIdentity) { identity.Fingerprint[0] ^= 0xff @@ -55,6 +49,96 @@ func TestDecodeNativeTBTCSignerDurableStoreIdentityRejectsUnboundIdentity( } } +func TestDecodeNativeTBTCSignerDurableStoreIdentityDoesNotBindVolatileDiagnostics( + t *testing.T, +) { + identity := testNativeTBTCSignerDurableStoreIdentity(t) + identity.CanonicalPathFingerprint[0] ^= 0xff + identity.FilesystemFingerprint[0] ^= 0xff + identity.LockFingerprint[0] ^= 0xff + + decoded, err := DecodeNativeTBTCSignerDurableStoreIdentity( + testNativeTBTCSignerDurableStoreIdentityPayload(identity, true, true), + ) + if err != nil { + t.Fatalf("v2 identity rejected changed diagnostic descriptors: [%v]", err) + } + if decoded.Fingerprint != identity.Fingerprint { + t.Fatal("v2 stable store fingerprint changed with diagnostic descriptors") + } +} + +func TestComputeNativeTBTCSignerDurableStoreFingerprintMatchesRustV2Vectors( + t *testing.T, +) { + tests := []struct { + storeID byte + expected string + }{ + { + 0x11, + "8bb8d21c69e78916e8f165b0c861c0d84c5d7af5393f75b0321fe048f772abba", + }, + { + 0x24, + "52fcbfc4b2c6a93645106a32c62113192cac30b934b905e1ad357792c4ce8628", + }, + } + + for _, test := range tests { + identity := &NativeTBTCSignerDurableStoreIdentity{ + Schema: "tbtc-signer-durable-session-store-identity/v2", + Backend: "encrypted-file-v1", + StoreID: repeatedNativeTBTCSignerBytes32(test.storeID), + CanonicalPathFingerprint: [32]byte{0x01}, + FilesystemFingerprint: [32]byte{0x02}, + LockFingerprint: [32]byte{0x03}, + } + actual, err := ComputeNativeTBTCSignerDurableStoreFingerprint(identity) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(actual[:]) != test.expected { + t.Fatalf( + "unexpected v2 store fingerprint for store ID 0x%02x: [%x]", + test.storeID, + actual, + ) + } + } +} + +func TestNativeTBTCSignerStateWitnessChainMatchesRustV2Vector(t *testing.T) { + identity := &NativeTBTCSignerDurableStoreIdentity{ + Schema: NativeTBTCSignerDurableStoreIdentitySchema, + Backend: "encrypted-file-v1", + StoreID: repeatedNativeTBTCSignerBytes32(0x11), + CanonicalPathFingerprint: [32]byte{0x01}, + FilesystemFingerprint: [32]byte{0x02}, + LockFingerprint: [32]byte{0x03}, + } + fingerprint, err := ComputeNativeTBTCSignerDurableStoreFingerprint(identity) + if err != nil { + t.Fatal(err) + } + genesis := ComputeNativeTBTCSignerStateWitnessGenesis(fingerprint) + const expectedGenesis = "3179b8bc6614b0951b703f9c418b17cf7cd8b7f1bef1f86587385d4c150efab2" + if hex.EncodeToString(genesis[:]) != expectedGenesis { + t.Fatalf("unexpected derived state-witness genesis: [%x]", genesis) + } + + commitment := ComputeNativeTBTCSignerStateWitnessCommitment( + fingerprint, + 1, + genesis, + repeatedNativeTBTCSignerBytes32(0x33), + ) + const expectedCommitment = "5387626d5314b17b324f9a7df1ab16fcbf10917a137527bf33c71847e1b77da0" + if hex.EncodeToString(commitment[:]) != expectedCommitment { + t.Fatalf("unexpected derived state-witness commitment: [%x]", commitment) + } +} + func TestDecodeNativeTBTCSignerDurableStoreIdentityRejectsUnsafePathState( t *testing.T, ) { @@ -79,6 +163,14 @@ func TestDecodeNativeTBTCSignerDurableStoreIdentityRejectsUnsafePathState( } } +func repeatedNativeTBTCSignerBytes32(value byte) [32]byte { + var result [32]byte + for index := range result { + result[index] = value + } + return result +} + func testNativeTBTCSignerDurableStoreIdentity( t *testing.T, ) *NativeTBTCSignerDurableStoreIdentity { diff --git a/pkg/frost/signing/tbtc_signer_key.go b/pkg/frost/signing/tbtc_signer_key.go new file mode 100644 index 0000000000..31b39e9a07 --- /dev/null +++ b/pkg/frost/signing/tbtc_signer_key.go @@ -0,0 +1,108 @@ +package signing + +import ( + "encoding/hex" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/keep-network/keep-core/pkg/frost" +) + +// KeyGroupIDFromSignerMaterial returns the exact FROST key-group handle stored +// in native signer material. +func KeyGroupIDFromSignerMaterial( + signerMaterial *NativeSignerMaterial, +) (string, error) { + if signerMaterial == nil { + return "", fmt.Errorf("key group id: signer material is nil") + } + payload, err := decodeBuildTaggedTBTCSignerMaterialPayload(signerMaterial) + if err != nil { + return "", fmt.Errorf("key group id: %w", err) + } + return payload.KeyGroup, nil +} + +// ExtractTaprootOutputKeyFromMaterial returns the 32-byte x-only Taproot +// output key committed to by native FROST signer material. +func ExtractTaprootOutputKeyFromMaterial( + signerMaterial *NativeSignerMaterial, +) ([]byte, error) { + if signerMaterial == nil { + return nil, fmt.Errorf("taproot output key: signer material is nil") + } + + switch signerMaterial.Format { + case NativeSignerMaterialFormatFrostTBTCSignerV1: + return extractTaprootOutputKeyFromTBTCSignerV1(signerMaterial) + default: + return nil, fmt.Errorf( + "taproot output key: unsupported signer-material format [%s]", + signerMaterial.Format, + ) + } +} + +func extractTaprootOutputKeyFromTBTCSignerV1( + signerMaterial *NativeSignerMaterial, +) ([]byte, error) { + payload, err := decodeBuildTaggedTBTCSignerMaterialPayload(signerMaterial) + if err != nil { + return nil, fmt.Errorf( + "taproot output key: decode FrostTBTCSignerV1: %w", + err, + ) + } + if payload.KeyGroupSource != NativeTBTCSignerKeyGroupSourceDKGPersisted { + return nil, fmt.Errorf( + "taproot output key: FrostTBTCSignerV1 key group source [%s] is not [%s]", + payload.KeyGroupSource, + NativeTBTCSignerKeyGroupSourceDKGPersisted, + ) + } + + outputKeyHex := payload.TaprootOutputKey + if outputKeyHex == "" { + outputKeyHex = payload.KeyGroup + } + + outputKey, err := TaprootOutputKeyFromTBTCSignerKey(outputKeyHex) + if err != nil { + return nil, fmt.Errorf( + "taproot output key: FrostTBTCSignerV1 key material is invalid: %w", + err, + ) + } + + return outputKey, nil +} + +// TaprootOutputKeyFromTBTCSignerKey converts tbtc-signer key material to the +// x-only BIP-340 output key committed to by P2TR wallet scripts. Current +// tbtc-signer DKG results expose the group verifying key as a compressed +// secp256k1 key-group handle, while older persisted material may already carry +// the x-only key. +func TaprootOutputKeyFromTBTCSignerKey(keyHex string) ([]byte, error) { + raw, err := hex.DecodeString(keyHex) + if err != nil { + return nil, err + } + + switch len(raw) { + case frost.OutputKeySize: + return raw, nil + case 1 + frost.OutputKeySize: + publicKey, err := btcec.ParsePubKey(raw) + if err != nil { + return nil, err + } + return publicKey.X().FillBytes(make([]byte, frost.OutputKeySize)), nil + default: + return nil, fmt.Errorf( + "must be %d-byte x-only or %d-byte compressed key, got %d bytes", + frost.OutputKeySize, + 1+frost.OutputKeySize, + len(raw), + ) + } +} diff --git a/pkg/tbtc/frost_durable_session_store.go b/pkg/tbtc/frost_durable_session_store.go index 0dc94d6a22..1167a63a89 100644 --- a/pkg/tbtc/frost_durable_session_store.go +++ b/pkg/tbtc/frost_durable_session_store.go @@ -14,9 +14,11 @@ type frostDurableSessionStoreIdentityReader func() ( // frostDurableSessionStoreBinding pins the store libfrost_tbtc actually has // open to the fingerprint in the authenticated activation manifest. It is -// checked at startup and again at every authorization/readiness boundary, so -// path, backend, lock, or storage replacement cannot be hidden by echoing the -// manifest value. +// checked at startup and again at every authorization/readiness boundary. The +// v2 manifest identity is the stable schema/backend/store ID tuple. Runtime +// path, filesystem, and lock diagnostics are mandatory safety evidence and +// are pinned to their startup readback for the life of this process, but may +// legitimately change across a safe restore or restart. type frostDurableSessionStoreBinding struct { expectedFingerprint [32]byte readIdentity frostDurableSessionStoreIdentityReader diff --git a/pkg/tbtc/frost_durable_session_store_test.go b/pkg/tbtc/frost_durable_session_store_test.go index 7119d10b6b..e52ed026ac 100644 --- a/pkg/tbtc/frost_durable_session_store_test.go +++ b/pkg/tbtc/frost_durable_session_store_test.go @@ -37,15 +37,11 @@ func TestFrostDurableSessionStoreBindingRejectsRuntimeIdentityMismatch( ) { original := testFrostDurableSessionStoreIdentity() tests := map[string]func(*signing.NativeTBTCSignerDurableStoreIdentity){ - "wrong path": func(identity *signing.NativeTBTCSignerDurableStoreIdentity) { - identity.CanonicalPathFingerprint[0] ^= 0xff - }, "wrong backend": func(identity *signing.NativeTBTCSignerDurableStoreIdentity) { identity.Backend = "encrypted-database-v1" }, - "replacement": func(identity *signing.NativeTBTCSignerDurableStoreIdentity) { - identity.FilesystemFingerprint[0] ^= 0xff - identity.LockFingerprint[0] ^= 0xff + "wrong store ID": func(identity *signing.NativeTBTCSignerDurableStoreIdentity) { + identity.StoreID[0] ^= 0xff }, } for name, mutate := range tests { @@ -71,6 +67,38 @@ func TestFrostDurableSessionStoreBindingRejectsRuntimeIdentityMismatch( } } +func TestFrostDurableSessionStoreBindingAllowsDiagnosticChangeAcrossRestartButNotRuntime( + t *testing.T, +) { + original := testFrostDurableSessionStoreIdentity() + restarted := *original + restarted.CanonicalPathFingerprint[0] ^= 0xff + restarted.FilesystemFingerprint[0] ^= 0xff + restarted.LockFingerprint[0] ^= 0xff + restarted.Fingerprint, _ = + signing.ComputeNativeTBTCSignerDurableStoreFingerprint(&restarted) + + current := restarted + binding, err := newFrostDurableSessionStoreBinding( + frostActivationHex32(original.Fingerprint), + func() (*signing.NativeTBTCSignerDurableStoreIdentity, error) { + readback := current + return &readback, nil + }, + ) + if err != nil { + t.Fatalf("safe restart diagnostics changed stable v2 identity: [%v]", err) + } + + current.LockFingerprint[0] ^= 0xff + current.Fingerprint, _ = + signing.ComputeNativeTBTCSignerDurableStoreFingerprint(¤t) + if _, err := binding.verify(); err == nil || + !strings.Contains(err.Error(), "changed after startup") { + t.Fatalf("runtime diagnostic replacement was accepted: [%v]", err) + } +} + func TestFrostDurableSessionStoreBindingRejectsReadbackFailureAndWrongFingerprint( t *testing.T, ) { diff --git a/pkg/tbtc/frost_native_signer_readiness.go b/pkg/tbtc/frost_native_signer_readiness.go index c8e86ad5c5..6b2709255b 100644 --- a/pkg/tbtc/frost_native_signer_readiness.go +++ b/pkg/tbtc/frost_native_signer_readiness.go @@ -57,6 +57,7 @@ type frostNativeSignerStateWitnessProofReader func( type frostNativeSignerInventoryExpectation struct { WalletID [32]byte + KeyGroup string Threshold uint16 ParticipantCount uint16 ShareEpoch uint64 @@ -269,6 +270,16 @@ func verifyFrostNativeSignerInventoryEntries( for index := range expectedCopy { actualEntry := actual[index] expectedEntry := expectedCopy[index] + if expectedEntry.KeyGroup == "" { + return fmt.Errorf( + "canonical local signer key-group binding is empty", + ) + } + if actualEntry.KeyGroup != expectedEntry.KeyGroup { + return fmt.Errorf( + "native retained key group differs from exact local signer material", + ) + } if actualEntry.WalletID != expectedEntry.WalletID || actualEntry.Threshold != expectedEntry.Threshold || actualEntry.ParticipantCount != expectedEntry.ParticipantCount || @@ -331,7 +342,8 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead if err != nil { return nil, fmt.Errorf("cannot reconcile canonical FROST retained groups: [%w]", err) } - expected, err := readiness.journal.nativeSignerInventoryExpectations(ctx, point) + expected, registryRevision, err := + readiness.journal.nativeSignerInventoryExpectations(ctx, point) if err != nil { return nil, err } @@ -346,6 +358,13 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead if *firstInventory != *secondInventory { return nil, fmt.Errorf("native signer state changed during readiness reconciliation") } + if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( + registryRevision, + ) { + return nil, fmt.Errorf( + "local FROST signer registry changed during readiness reconciliation", + ) + } if !readiness.interactiveSigningReady() { return nil, fmt.Errorf("interactive FROST signing engine became unavailable during reconciliation") } @@ -359,19 +378,31 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead func (journal *frostRetainedGroupJournal) nativeSignerInventoryExpectations( ctx context.Context, point FrostPreSignFinality, -) ([]frostNativeSignerInventoryExpectation, error) { +) ([]frostNativeSignerInventoryExpectation, uint64, error) { if journal == nil || ctx == nil { - return nil, fmt.Errorf("FROST retained-group journal expectation context is invalid") + return nil, 0, fmt.Errorf("FROST retained-group journal expectation context is invalid") } localOperatorID, err := journal.source.ResolveOperatorID(ctx, journal.operatorAddress, point) if err != nil || localOperatorID == 0 { - return nil, fmt.Errorf("cannot resolve local operator for native signer inventory: [%w]", err) + return nil, 0, fmt.Errorf("cannot resolve local operator for native signer inventory: [%w]", err) + } + sessions, retainedKeyGroups, registryRevision, err := + journal.walletRegistry.frostReadinessMaterialSnapshot() + if err != nil { + return nil, 0, fmt.Errorf("cannot resolve local FROST signer material: [%w]", err) + } + sessionsByWallet := make(map[[32]byte]frostLocalSessionSnapshot, len(sessions)) + for _, session := range sessions { + if _, exists := sessionsByWallet[session.WalletID]; exists { + return nil, 0, fmt.Errorf("duplicate FROST local session wallet ID") + } + sessionsByWallet[session.WalletID] = session } journal.mutex.Lock() defer journal.mutex.Unlock() if journal.closed || journal.state.CurrentPoint != point { - return nil, fmt.Errorf("canonical retained-group journal moved during native signer reconciliation") + return nil, 0, fmt.Errorf("canonical retained-group journal moved during native signer reconciliation") } expected := make([]frostNativeSignerInventoryExpectation, 0) for _, wallet := range journal.state.Wallets { @@ -384,13 +415,39 @@ func (journal *frostRetainedGroupJournal) nativeSignerInventoryExpectations( if len(seats) == 0 { continue } + keyGroup, hasBinding := retainedKeyGroups[wallet.WalletID] + if !hasBinding || keyGroup == "" { + return nil, 0, fmt.Errorf( + "canonical FROST retained group has no durable local key-group binding", + ) + } + session, hasSession := sessionsByWallet[wallet.WalletID] + if wallet.Lifecycle.terminal() { + if hasSession { + return nil, 0, fmt.Errorf( + "canonical terminal FROST retained group still has an active local session", + ) + } + } else { + if !hasSession || session.KeyGroup == "" { + return nil, 0, fmt.Errorf( + "canonical live FROST retained group has no resolved local key-group handle", + ) + } + if session.KeyGroup != keyGroup { + return nil, 0, fmt.Errorf( + "active FROST key-group handle differs from durable local binding", + ) + } + } expected = append(expected, frostNativeSignerInventoryExpectation{ WalletID: wallet.WalletID, + KeyGroup: keyGroup, Threshold: frostPreSignAuthorizationThreshold, ParticipantCount: uint16(len(wallet.OperatorIDs)), ShareEpoch: 0, ParticipantSeats: seats, }) } - return expected, nil + return expected, registryRevision, nil } diff --git a/pkg/tbtc/frost_native_signer_readiness_test.go b/pkg/tbtc/frost_native_signer_readiness_test.go index 9894103aad..14f613e208 100644 --- a/pkg/tbtc/frost_native_signer_readiness_test.go +++ b/pkg/tbtc/frost_native_signer_readiness_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "fmt" + "os" "strings" "testing" @@ -80,6 +81,7 @@ func TestVerifyFrostNativeSignerInventoryEntriesRejectsMismatch(t *testing.T) { expected := []frostNativeSignerInventoryExpectation{ { WalletID: walletID, + KeyGroup: hex.EncodeToString(walletID[:]), Threshold: 51, ParticipantCount: 100, ShareEpoch: 0, @@ -98,6 +100,10 @@ func TestVerifyFrostNativeSignerInventoryEntriesRejectsMismatch(t *testing.T) { entries[0].WalletID[0] ^= 0xff return entries }, + "wrong key group": func(entries []frostsigning.NativeTBTCSignerRetainedKeyGroup) []frostsigning.NativeTBTCSignerRetainedKeyGroup { + entries[0].KeyGroup = strings.Repeat("09", 32) + return entries + }, "wrong participant seat": func(entries []frostsigning.NativeTBTCSignerRetainedKeyGroup) []frostsigning.NativeTBTCSignerRetainedKeyGroup { entries[0].KeyPackages[0].ParticipantSeat = 5 return entries @@ -209,6 +215,7 @@ func TestFrostNativeSignerInventoryBindingRequiresAnchoredDescendant(t *testing. expected := []frostNativeSignerInventoryExpectation{ { WalletID: walletID, + KeyGroup: hex.EncodeToString(walletID[:]), Threshold: 51, ParticipantCount: 100, ParticipantSeats: []uint16{2}, @@ -249,8 +256,16 @@ func TestFrostRetainedGroupJournalNativeInventoryExpectationsIncludeTerminalGrou } operatorIDs[1] = 1 operatorIDs[2] = 1 + terminalWalletID := [32]byte{0x62} + terminalKeyGroup := hex.EncodeToString(terminalWalletID[:]) journal := &frostRetainedGroupJournal{ - source: &testFrostRetainedGroupHistorySource{}, + source: &testFrostRetainedGroupHistorySource{}, + walletRegistry: &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + retainedFrostKeyGroups: map[[32]byte]string{ + terminalWalletID: terminalKeyGroup, + }, + }, operatorAddress: "0x01", state: frostRetainedGroupJournalState{ CurrentPoint: point, @@ -268,14 +283,115 @@ func TestFrostRetainedGroupJournalNativeInventoryExpectationsIncludeTerminalGrou }, }, } - expected, err := journal.nativeSignerInventoryExpectations(context.Background(), point) + expected, _, err := journal.nativeSignerInventoryExpectations(context.Background(), point) if err != nil { t.Fatal(err) } - if len(expected) != 1 || expected[0].WalletID != [32]byte{0x62} || + if len(expected) != 1 || expected[0].WalletID != terminalWalletID || + expected[0].KeyGroup != terminalKeyGroup || expected[0].ParticipantCount != 51 || expected[0].ShareEpoch != 0 || len(expected[0].ParticipantSeats) != 2 || expected[0].ParticipantSeats[0] != 2 || expected[0].ParticipantSeats[1] != 3 { t.Fatalf("unexpected native inventory expectations: %+v", expected) } + + journal.walletRegistry.retainedFrostKeyGroups = nil + if _, _, err := journal.nativeSignerInventoryExpectations( + context.Background(), + point, + ); err == nil || !strings.Contains(err.Error(), "no durable local key-group binding") { + t.Fatalf("terminal group without durable key-group binding was accepted: [%v]", err) + } +} + +func TestFrostProductionSignerReadinessRejectsConcurrentRegistryChange( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + keyGroup := hex.EncodeToString(fixture.walletID[:]) + fixture.registry.retainedFrostKeyGroups = map[[32]byte]string{ + fixture.walletID: keyGroup, + } + journalDirectory := t.TempDir() + if err := os.Chmod(journalDirectory, 0700); err != nil { + t.Fatal(err) + } + journal := fixture.openJournal(t, journalDirectory) + + storeBinding := testFrostDurableSessionStoreBinding(t) + storeFingerprint, err := storeBinding.verify() + if err != nil { + t.Fatal(err) + } + stateCommitment := [32]byte{0x71} + entries := []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ + WalletID: fixture.walletID, + KeyGroup: keyGroup, + Threshold: frostPreSignAuthorizationThreshold, + ParticipantCount: uint16(len(fixture.operatorIDs)), + KeyPackages: []frostsigning.NativeTBTCSignerRetainedKeyPackage{{ + ParticipantSeat: 7, + }}, + }} + inventory := &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Schema: frostsigning.NativeTBTCSignerRetainedKeyPackageInventorySchema, + StoreFingerprint: storeFingerprint, + StateGeneration: 1, + StateCommitment: stateCommitment, + InventoryCommitment: frostsigning.ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment(entries), + Entries: entries, + } + anchorSource := &testFrostNativeSignerStateWitnessAnchorSource{ + anchor: &FrostNativeSignerStateWitnessAnchor{ + StoreFingerprint: storeFingerprint, + Generation: 1, + Commitment: stateCommitment, + }, + } + inventoryReads := 0 + inventoryBinding, err := newFrostNativeSignerInventoryBinding( + storeBinding, + anchorSource, + func() (*frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error) { + inventoryReads++ + if inventoryReads == 2 { + fixture.registry.mutex.Lock() + fixture.registry.revision++ + fixture.registry.mutex.Unlock() + } + return inventory, nil + }, + func( + request *frostsigning.NativeTBTCSignerStateWitnessProofRequest, + ) (*frostsigning.NativeTBTCSignerStateWitnessProof, error) { + return &frostsigning.NativeTBTCSignerStateWitnessProof{ + Schema: frostsigning.NativeTBTCSignerStateWitnessProofSchema, + StoreFingerprint: request.StoreFingerprint, + AncestorGeneration: request.AncestorGeneration, + AncestorCommitment: request.AncestorCommitment, + TargetGeneration: request.TargetGeneration, + TargetCommitment: request.TargetCommitment, + Complete: true, + }, nil + }, + ) + if err != nil { + t.Fatal(err) + } + readiness, err := newFrostProductionSignerReadiness( + func() bool { return true }, + journal, + inventoryBinding, + ) + if err != nil { + t.Fatal(err) + } + + _, err = readiness.verifyFrostProductionSignerReadiness( + context.Background(), + fixture.target, + ) + if err == nil || !strings.Contains(err.Error(), "registry changed") { + t.Fatalf("concurrent local registry change was accepted: [%v]", err) + } } diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index d84ff847ad..f91e66abd4 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -1944,6 +1944,7 @@ func cloneFrostRetainedGroupQuarantineState( type frostLocalSessionSnapshot struct { WalletID [32]byte WalletPublicKeyHash [20]byte + KeyGroup string OperatorAddresses []chain.Address ControlledSeats []group.MemberIndex } @@ -1957,6 +1958,13 @@ func (wr *walletRegistry) frostLocalSessionSnapshot() ( } wr.mutex.Lock() defer wr.mutex.Unlock() + return wr.frostLocalSessionSnapshotLocked() +} + +func (wr *walletRegistry) frostLocalSessionSnapshotLocked() ( + []frostLocalSessionSnapshot, + error, +) { result := make([]frostLocalSessionSnapshot, 0) for _, value := range wr.walletCache { if value == nil || len(value.signers) == 0 { @@ -1998,6 +2006,31 @@ func (wr *walletRegistry) frostLocalSessionSnapshot() ( return nil, fmt.Errorf("FROST local session operators disagree") } } + var material *frostsigning.NativeSignerMaterial + switch value := signer.signingMaterial().(type) { + case *frostsigning.NativeSignerMaterial: + material = value + case frostsigning.NativeSignerMaterial: + valueCopy := value + material = &valueCopy + default: + return nil, fmt.Errorf("FROST local session signer material is unavailable") + } + keyGroup, err := frostKeyGroupFromSignerMaterial( + material, + session.WalletID, + ) + if err != nil { + return nil, fmt.Errorf( + "FROST local session key-group handle does not identify its wallet: [%w]", + err, + ) + } + if session.KeyGroup == "" { + session.KeyGroup = keyGroup + } else if session.KeyGroup != keyGroup { + return nil, fmt.Errorf("FROST local session key-group handles disagree") + } if _, exists := seenSeats[signer.signingGroupMemberIndex]; exists { return nil, fmt.Errorf("FROST local session repeats a controlled seat") } diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index 76d39d8cac..18062cdaa2 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -122,6 +122,15 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { walletPKH := [20]byte{0x92} localOperator := operatorAddrs[6] publicKey := &ecdsa.PublicKey{Curve: elliptic.P256()} + signerMaterialPayload, err := json.Marshal( + frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: fmt.Sprintf("%x", walletID), + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceDKGPersisted, + }, + ) + if err != nil { + t.Fatal(err) + } localSigner := &signer{ wallet: wallet{ publicKey: publicKey, @@ -129,8 +138,8 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { }, signingGroupMemberIndex: group.MemberIndex(7), signerMaterial: &frostsigning.NativeSignerMaterial{ - Format: frostsigning.NativeSignerMaterialFormatFrostUniFFIV1, - Payload: []byte{0x01}, + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: signerMaterialPayload, }, } registry := &walletRegistry{ @@ -213,6 +222,38 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { } } +func TestFrostLocalSessionSnapshotBindsExactSignerMaterial(t *testing.T) { + fixture := newJournalTestFixture(t) + sessions, err := fixture.registry.frostLocalSessionSnapshot() + if err != nil { + t.Fatal(err) + } + expectedKeyGroup := fmt.Sprintf("%x", fixture.walletID) + if len(sessions) != 1 || sessions[0].WalletID != fixture.walletID || + sessions[0].KeyGroup != expectedKeyGroup { + t.Fatalf("unexpected local FROST session snapshot: [%+v]", sessions) + } + + mismatchedPayload, err := json.Marshal( + frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: strings.Repeat("22", 32), + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceDKGPersisted, + }, + ) + if err != nil { + t.Fatal(err) + } + fixture.registry.walletCache["wallet"].signers[0].signerMaterial = + &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: mismatchedPayload, + } + if _, err := fixture.registry.frostLocalSessionSnapshot(); err == nil || + !strings.Contains(err.Error(), "does not identify its wallet") { + t.Fatalf("mismatched local key-group material was accepted: [%v]", err) + } +} + func (fixture *journalTestFixture) openJournal( t *testing.T, directory string, diff --git a/pkg/tbtc/frost_retained_key_group_binding.go b/pkg/tbtc/frost_retained_key_group_binding.go new file mode 100644 index 0000000000..0ffc8d3cba --- /dev/null +++ b/pkg/tbtc/frost_retained_key_group_binding.go @@ -0,0 +1,357 @@ +package tbtc + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "strings" + "sync" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +const ( + frostRetainedKeyGroupBindingDirectory = "frost-retained-key-groups" + frostRetainedKeyGroupBindingSchema = "tbtc-frost-retained-key-group-binding/v1" + frostRetainedKeyGroupBindingDomain = "tbtc-frost-retained-key-group-binding-v1\x00" + frostRetainedKeyGroupBindingMaxSize = 1024 +) + +type frostRetainedKeyGroupBinding struct { + Schema string `json:"schema"` + WalletID [32]byte `json:"walletID"` + KeyGroup string `json:"keyGroup"` + Checksum [32]byte `json:"checksum"` +} + +func frostRetainedKeyGroupBindingFile(walletID [32]byte) string { + return hex.EncodeToString(walletID[:]) + ".json" +} + +func frostRetainedKeyGroupBindingChecksum( + walletID [32]byte, + keyGroup string, +) [32]byte { + digest := sha256.New() + _, _ = digest.Write([]byte(frostRetainedKeyGroupBindingDomain)) + _, _ = digest.Write(walletID[:]) + var keyGroupLength [2]byte + binary.BigEndian.PutUint16(keyGroupLength[:], uint16(len(keyGroup))) + _, _ = digest.Write(keyGroupLength[:]) + _, _ = digest.Write([]byte(keyGroup)) + var result [32]byte + copy(result[:], digest.Sum(nil)) + return result +} + +func validateFrostKeyGroupForWallet( + keyGroup string, + walletID [32]byte, +) error { + if walletID == [32]byte{} { + return fmt.Errorf("wallet ID is zero") + } + if keyGroup != strings.ToLower(keyGroup) || + (len(keyGroup) != 64 && len(keyGroup) != 66) || + strings.HasPrefix(keyGroup, "0x") { + return fmt.Errorf( + "key group is not canonical lowercase x-only or compressed SEC1 hex", + ) + } + outputKey, err := frostsigning.TaprootOutputKeyFromTBTCSignerKey(keyGroup) + if err != nil || !bytes.Equal(outputKey, walletID[:]) { + return fmt.Errorf("key group does not identify its wallet") + } + return nil +} + +func frostKeyGroupFromSignerMaterial( + material *frostsigning.NativeSignerMaterial, + walletID [32]byte, +) (string, error) { + keyGroup, err := frostsigning.KeyGroupIDFromSignerMaterial(material) + if err != nil { + return "", err + } + if err := validateFrostKeyGroupForWallet(keyGroup, walletID); err != nil { + return "", err + } + outputKey, err := frostsigning.ExtractTaprootOutputKeyFromMaterial(material) + if err != nil || !bytes.Equal(outputKey, walletID[:]) { + return "", fmt.Errorf( + "signer material Taproot output key does not identify its wallet", + ) + } + return keyGroup, nil +} + +func frostKeyGroupFromWalletCacheValue( + value *walletCacheValue, +) (string, bool, error) { + if value == nil || len(value.signers) == 0 { + return "", false, fmt.Errorf("wallet cache contains an empty session") + } + + var keyGroup string + nativeCount := 0 + for _, walletSigner := range value.signers { + if walletSigner == nil { + return "", false, fmt.Errorf("wallet cache contains a nil signer") + } + var material *frostsigning.NativeSignerMaterial + switch typed := walletSigner.signingMaterial().(type) { + case *frostsigning.NativeSignerMaterial: + material = typed + case frostsigning.NativeSignerMaterial: + materialCopy := typed + material = &materialCopy + default: + continue + } + if material == nil { + return "", false, fmt.Errorf("wallet cache contains nil native signer material") + } + if material.Format != + frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1 { + continue + } + nativeCount++ + resolved, err := frostKeyGroupFromSignerMaterial( + material, + value.walletID, + ) + if err != nil { + return "", false, err + } + if keyGroup == "" { + keyGroup = resolved + } else if keyGroup != resolved { + return "", false, fmt.Errorf( + "FROST wallet signer key-group handles disagree", + ) + } + } + + if nativeCount == 0 { + return "", false, nil + } + if nativeCount != len(value.signers) { + return "", false, fmt.Errorf( + "wallet cache mixes FROST and legacy signer material", + ) + } + return keyGroup, true, nil +} + +func (ws *walletStorage) saveRetainedFrostKeyGroupBinding( + walletID [32]byte, + keyGroup string, +) error { + if ws == nil || ws.persistence == nil { + return fmt.Errorf("wallet storage persistence is unavailable") + } + if err := validateFrostKeyGroupForWallet(keyGroup, walletID); err != nil { + return err + } + binding := frostRetainedKeyGroupBinding{ + Schema: frostRetainedKeyGroupBindingSchema, + WalletID: walletID, + KeyGroup: keyGroup, + Checksum: frostRetainedKeyGroupBindingChecksum(walletID, keyGroup), + } + encoded, err := json.Marshal(&binding) + if err != nil { + return err + } + return ws.persistence.Save( + encoded, + frostRetainedKeyGroupBindingDirectory, + frostRetainedKeyGroupBindingFile(walletID), + ) +} + +func ensureRetainedFrostKeyGroupBinding( + ws *walletStorage, + bindings map[[32]byte]string, + walletID [32]byte, + keyGroup string, +) error { + if bindings == nil { + return fmt.Errorf("retained FROST key-group binding map is nil") + } + if existing, ok := bindings[walletID]; ok { + if existing != keyGroup { + return fmt.Errorf( + "retained FROST wallet key group conflicts with durable binding", + ) + } + return nil + } + if err := ws.saveRetainedFrostKeyGroupBinding(walletID, keyGroup); err != nil { + return err + } + bindings[walletID] = keyGroup + return nil +} + +func (ws *walletStorage) loadRetainedFrostKeyGroupBindings() ( + map[[32]byte]string, + error, +) { + if ws == nil || ws.persistence == nil { + return nil, fmt.Errorf("wallet storage persistence is unavailable") + } + result := make(map[[32]byte]string) + descriptors, readErrors := ws.persistence.ReadAll() + var wg sync.WaitGroup + var mutex sync.Mutex + var firstErr error + setError := func(err error) { + mutex.Lock() + defer mutex.Unlock() + if firstErr == nil { + firstErr = err + } + } + + wg.Add(2) + go func() { + defer wg.Done() + for descriptor := range descriptors { + if descriptor.Directory() != + frostRetainedKeyGroupBindingDirectory { + continue + } + content, err := descriptor.Content() + if err != nil { + setError(fmt.Errorf( + "cannot read retained key-group binding [%s]: [%w]", + descriptor.Name(), + err, + )) + continue + } + if len(content) == 0 || + len(content) > frostRetainedKeyGroupBindingMaxSize { + setError(fmt.Errorf( + "retained key-group binding [%s] has invalid size", + descriptor.Name(), + )) + continue + } + binding := frostRetainedKeyGroupBinding{} + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&binding); err != nil { + setError(fmt.Errorf( + "cannot decode retained key-group binding [%s]: [%w]", + descriptor.Name(), + err, + )) + continue + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + setError(fmt.Errorf( + "retained key-group binding [%s] has trailing data", + descriptor.Name(), + )) + continue + } + if binding.Schema != frostRetainedKeyGroupBindingSchema || + binding.WalletID == [32]byte{} || + descriptor.Name() != + frostRetainedKeyGroupBindingFile(binding.WalletID) || + binding.Checksum != frostRetainedKeyGroupBindingChecksum( + binding.WalletID, + binding.KeyGroup, + ) { + setError(fmt.Errorf( + "retained key-group binding [%s] is invalid", + descriptor.Name(), + )) + continue + } + if err := validateFrostKeyGroupForWallet( + binding.KeyGroup, + binding.WalletID, + ); err != nil { + setError(fmt.Errorf( + "retained key-group binding [%s] is invalid: [%w]", + descriptor.Name(), + err, + )) + continue + } + mutex.Lock() + if _, exists := result[binding.WalletID]; exists { + if firstErr == nil { + firstErr = fmt.Errorf( + "duplicate retained key-group binding for wallet [0x%x]", + binding.WalletID, + ) + } + } else { + result[binding.WalletID] = binding.KeyGroup + } + mutex.Unlock() + } + }() + go func() { + defer wg.Done() + for err := range readErrors { + if err != nil { + setError(fmt.Errorf( + "cannot enumerate retained key-group bindings: [%w]", + err, + )) + } + } + }() + wg.Wait() + if firstErr != nil { + return nil, firstErr + } + return result, nil +} + +func (wr *walletRegistry) frostReadinessMaterialSnapshot() ( + []frostLocalSessionSnapshot, + map[[32]byte]string, + uint64, + error, +) { + if wr == nil { + return nil, nil, 0, fmt.Errorf("wallet registry is nil") + } + wr.mutex.Lock() + defer wr.mutex.Unlock() + sessions, err := wr.frostLocalSessionSnapshotLocked() + if err != nil { + return nil, nil, 0, err + } + result := make(map[[32]byte]string, len(wr.retainedFrostKeyGroups)) + for walletID, keyGroup := range wr.retainedFrostKeyGroups { + if err := validateFrostKeyGroupForWallet(keyGroup, walletID); err != nil { + return nil, nil, 0, fmt.Errorf( + "retained FROST key-group binding is invalid: [%w]", + err, + ) + } + result[walletID] = keyGroup + } + return sessions, result, wr.revision, nil +} + +func (wr *walletRegistry) frostReadinessRevisionMatches(revision uint64) bool { + if wr == nil { + return false + } + wr.mutex.Lock() + defer wr.mutex.Unlock() + return wr.revision == revision +} diff --git a/pkg/tbtc/frost_retained_key_group_binding_test.go b/pkg/tbtc/frost_retained_key_group_binding_test.go new file mode 100644 index 0000000000..a34c78f422 --- /dev/null +++ b/pkg/tbtc/frost_retained_key_group_binding_test.go @@ -0,0 +1,290 @@ +package tbtc + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/pkg/bitcoin" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +const ( + frostBindingGeneratorX = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + frostBindingEvenKey = "02" + frostBindingGeneratorX + frostBindingOddKey = "03" + frostBindingGeneratorX +) + +func frostBindingWalletID(t *testing.T) [32]byte { + t.Helper() + decoded, err := hex.DecodeString(frostBindingGeneratorX) + if err != nil { + t.Fatal(err) + } + var result [32]byte + copy(result[:], decoded) + return result +} + +func frostBindingSignerMaterial(t *testing.T, keyGroup string) *frostsigning.NativeSignerMaterial { + t.Helper() + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: keyGroup, + TaprootOutputKey: frostBindingGeneratorX, + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceDKGPersisted, + }) + if err != nil { + t.Fatal(err) + } + return &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } +} + +func frostBindingWalletPublicKey() *ecdsa.PublicKey { + x, y := elliptic.P256().ScalarBaseMult([]byte{1}) + return &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} +} + +func TestWalletRegistryArchiveFrostWalletPersistsExactKeyGroupBinding( + t *testing.T, +) { + walletID := frostBindingWalletID(t) + publicKey := frostBindingWalletPublicKey() + publicKeyHash := [20]byte{0x41} + persistenceHandle := &mockPersistenceHandle{} + registry := &walletRegistry{ + walletCache: map[string]*walletCacheValue{ + getWalletStorageKey(publicKey): { + walletID: walletID, + walletPublicKeyHash: publicKeyHash, + signers: []*signer{{ + wallet: wallet{publicKey: publicKey}, + signerMaterial: frostBindingSignerMaterial(t, frostBindingEvenKey), + }}, + }, + }, + walletStorage: newWalletStorage(persistenceHandle), + retainedFrostKeyGroups: make(map[[32]byte]string), + } + + if err := registry.archiveWallet(publicKeyHash); err != nil { + t.Fatal(err) + } + if registry.retainedFrostKeyGroups[walletID] != frostBindingEvenKey { + t.Fatal("registry did not retain the exact FROST key-group handle") + } + if len(registry.walletCache) != 0 || len(persistenceHandle.archived) != 1 { + t.Fatalf("wallet was not archived: cache=%d archive=%v", len(registry.walletCache), persistenceHandle.archived) + } + loaded, err := registry.walletStorage.loadRetainedFrostKeyGroupBindings() + if err != nil { + t.Fatal(err) + } + if loaded[walletID] != frostBindingEvenKey { + t.Fatalf("durable key-group binding mismatch: [%v]", loaded) + } +} + +func TestWalletRegistryArchiveFrostWalletRejectsExactParityConflict( + t *testing.T, +) { + walletID := frostBindingWalletID(t) + publicKey := frostBindingWalletPublicKey() + publicKeyHash := [20]byte{0x42} + persistenceHandle := &mockPersistenceHandle{} + registry := &walletRegistry{ + walletCache: map[string]*walletCacheValue{ + getWalletStorageKey(publicKey): { + walletID: walletID, + walletPublicKeyHash: publicKeyHash, + signers: []*signer{{ + wallet: wallet{publicKey: publicKey}, + signerMaterial: frostBindingSignerMaterial(t, frostBindingEvenKey), + }}, + }, + }, + walletStorage: newWalletStorage(persistenceHandle), + retainedFrostKeyGroups: map[[32]byte]string{ + walletID: frostBindingOddKey, + }, + } + + err := registry.archiveWallet(publicKeyHash) + if err == nil || !strings.Contains(err.Error(), "conflicts with durable binding") { + t.Fatalf("opposite-parity key-group conflict was accepted: [%v]", err) + } + if len(persistenceHandle.archived) != 0 || len(registry.walletCache) != 1 { + t.Fatal("wallet changed after key-group binding conflict") + } +} + +func TestNewWalletRegistryBackfillsFrostKeyGroupBindingBeforeArchive( + t *testing.T, +) { + walletID := frostBindingWalletID(t) + walletSigner := createMockSigner(t) + walletSigner.signerMaterial = + frostBindingSignerMaterial(t, frostBindingEvenKey) + encoded, err := walletSigner.Marshal() + if err != nil { + t.Fatal(err) + } + persistenceHandle := &mockPersistenceHandle{ + saved: []persistence.DataDescriptor{&mockDescriptor{ + name: "membership_1", + directory: getWalletStorageKey(walletSigner.wallet.publicKey), + content: encoded, + }}, + } + + registry, err := newWalletRegistry( + persistenceHandle, + func(*ecdsa.PublicKey) ([32]byte, error) { + return walletID, nil + }, + ) + if err != nil { + t.Fatal(err) + } + if registry.retainedFrostKeyGroups[walletID] != frostBindingEvenKey { + t.Fatal("startup did not backfill the exact active FROST key group") + } + loaded, err := registry.walletStorage.loadRetainedFrostKeyGroupBindings() + if err != nil { + t.Fatal(err) + } + if loaded[walletID] != frostBindingEvenKey { + t.Fatal("startup FROST key-group backfill was not durable") + } +} + +type failingFrostBindingPersistence struct { + *mockPersistenceHandle +} + +func (ffbp *failingFrostBindingPersistence) Save( + data []byte, + directory string, + name string, +) error { + if directory == frostRetainedKeyGroupBindingDirectory { + return fmt.Errorf("injected binding save failure") + } + return ffbp.mockPersistenceHandle.Save(data, directory, name) +} + +func TestWalletRegistryArchiveFrostWalletBindingFailureLeavesWalletActive( + t *testing.T, +) { + walletID := frostBindingWalletID(t) + publicKey := frostBindingWalletPublicKey() + publicKeyHash := [20]byte{0x43} + persistenceHandle := &failingFrostBindingPersistence{ + mockPersistenceHandle: &mockPersistenceHandle{}, + } + registry := &walletRegistry{ + walletCache: map[string]*walletCacheValue{ + getWalletStorageKey(publicKey): { + walletID: walletID, + walletPublicKeyHash: publicKeyHash, + signers: []*signer{{ + wallet: wallet{publicKey: publicKey}, + signerMaterial: frostBindingSignerMaterial(t, frostBindingEvenKey), + }}, + }, + }, + walletStorage: newWalletStorage(persistenceHandle), + retainedFrostKeyGroups: make(map[[32]byte]string), + } + + err := registry.archiveWallet(publicKeyHash) + if err == nil || !strings.Contains(err.Error(), "injected binding save failure") { + t.Fatalf("binding persistence failure was ignored: [%v]", err) + } + if len(persistenceHandle.archived) != 0 || len(registry.walletCache) != 1 { + t.Fatal("wallet was archived after binding persistence failed") + } +} + +func TestRetainedFrostKeyGroupBindingSurvivesRealPersistenceArchiveAndRestart( + t *testing.T, +) { + walletID := frostBindingWalletID(t) + storagePath := t.TempDir() + handle, err := persistence.NewProtectedDiskHandle(storagePath) + if err != nil { + t.Fatal(err) + } + calculateWalletID := func(*ecdsa.PublicKey) ([32]byte, error) { + return walletID, nil + } + registry, err := newWalletRegistry(handle, calculateWalletID) + if err != nil { + t.Fatal(err) + } + walletSigner := createMockSigner(t) + walletSigner.signerMaterial = + frostBindingSignerMaterial(t, frostBindingEvenKey) + if err := registry.registerSigner(walletSigner); err != nil { + t.Fatal(err) + } + if err := registry.archiveWallet( + bitcoin.PublicKeyHash(walletSigner.wallet.publicKey), + ); err != nil { + t.Fatal(err) + } + + reopenedHandle, err := persistence.NewProtectedDiskHandle(storagePath) + if err != nil { + t.Fatal(err) + } + reopened, err := newWalletRegistry(reopenedHandle, calculateWalletID) + if err != nil { + t.Fatal(err) + } + if len(reopened.walletCache) != 0 { + t.Fatal("archived wallet signer reappeared after restart") + } + if reopened.retainedFrostKeyGroups[walletID] != frostBindingEvenKey { + t.Fatal("exact retained key-group binding did not survive restart") + } +} + +func TestVerifyFrostNativeSignerInventoryEntriesRejectsOppositeParityKeyGroup( + t *testing.T, +) { + walletID := frostBindingWalletID(t) + actual := []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ + WalletID: walletID, + KeyGroup: frostBindingOddKey, + Threshold: 51, + ParticipantCount: 100, + KeyPackages: []frostsigning.NativeTBTCSignerRetainedKeyPackage{{ + ParticipantSeat: 1, + }}, + }} + expected := []frostNativeSignerInventoryExpectation{{ + WalletID: walletID, + KeyGroup: frostBindingEvenKey, + Threshold: 51, + ParticipantCount: 100, + ParticipantSeats: []uint16{1}, + }} + err := verifyFrostNativeSignerInventoryEntries(actual, expected) + if err == nil || !strings.Contains(err.Error(), "exact local signer material") { + t.Fatalf("opposite-parity native key group was accepted: [%v]", err) + } + + expected[0].KeyGroup = "" + err = verifyFrostNativeSignerInventoryEntries(actual, expected) + if err == nil || !strings.Contains(err.Error(), "binding is empty") { + t.Fatalf("empty expected native key group was accepted: [%v]", err) + } +} diff --git a/pkg/tbtc/registry.go b/pkg/tbtc/registry.go index 8cd59a8caa..ac0c222c45 100644 --- a/pkg/tbtc/registry.go +++ b/pkg/tbtc/registry.go @@ -31,6 +31,19 @@ type walletRegistry struct { // wallet persistence. walletStorage *walletStorage + // retainedFrostKeyGroups contains public, exact key-group handles captured + // before FROST wallet signer records are archived. The active wallet cache + // intentionally drops terminal wallets, while the native signer retains + // their key packages; this durable binding lets activation readiness + // reconcile those retained packages without trusting the native inventory + // to identify itself. + retainedFrostKeyGroups map[[32]byte]string + + // revision advances after each successful active-session registration or + // archive. Readiness pins it across its Go/Rust reconciliation so a wallet + // cannot disappear or gain a local seat mid-check. + revision uint64 + // calculateWalletIdFunc calculates the ECDSA wallet ID based on the // provided wallet public key. calculateWalletIdFunc CalculateWalletIdFunc @@ -53,6 +66,14 @@ func newWalletRegistry( calculateWalletIdFunc CalculateWalletIdFunc, ) (*walletRegistry, error) { walletStorage := newWalletStorage(persistence) + retainedFrostKeyGroups, err := + walletStorage.loadRetainedFrostKeyGroupBindings() + if err != nil { + return nil, fmt.Errorf( + "could not load retained FROST key-group bindings: [%w]", + err, + ) + } // Pre-populate the wallet cache using the wallet storage. walletCache := make(map[string]*walletCacheValue) @@ -85,6 +106,27 @@ func newWalletRegistry( walletID: walletID, signers: signers, } + keyGroup, isFrostWallet, err := + frostKeyGroupFromWalletCacheValue(walletCache[walletStorageKey]) + if err != nil { + return nil, fmt.Errorf( + "cannot resolve stored FROST wallet key group: [%w]", + err, + ) + } + if isFrostWallet { + if err := ensureRetainedFrostKeyGroupBinding( + walletStorage, + retainedFrostKeyGroups, + walletID, + keyGroup, + ); err != nil { + return nil, fmt.Errorf( + "cannot persist stored FROST wallet key-group binding: [%w]", + err, + ) + } + } logger.Infof( "wallet signing group [0x%v] loaded from storage "+ @@ -99,9 +141,10 @@ func newWalletRegistry( } return &walletRegistry{ - walletCache: walletCache, - walletStorage: walletStorage, - calculateWalletIdFunc: calculateWalletIdFunc, + walletCache: walletCache, + walletStorage: walletStorage, + retainedFrostKeyGroups: retainedFrostKeyGroups, + calculateWalletIdFunc: calculateWalletIdFunc, }, nil } @@ -121,41 +164,70 @@ func (wr *walletRegistry) getWalletsPublicKeys() []*ecdsa.PublicKey { } // registerSigner registers the given signer using in the walletRegistry. -func (wr *walletRegistry) registerSigner(signer *signer) error { +func (wr *walletRegistry) registerSigner(walletSigner *signer) error { wr.mutex.Lock() defer wr.mutex.Unlock() - err := wr.walletStorage.saveSigner(signer) - if err != nil { - return fmt.Errorf("cannot save signer in the storage: [%w]", err) + if walletSigner == nil || walletSigner.wallet.publicKey == nil { + return fmt.Errorf("cannot register malformed signer") } - walletStorageKey := getWalletStorageKey(signer.wallet.publicKey) + walletStorageKey := getWalletStorageKey(walletSigner.wallet.publicKey) + candidate, exists := wr.walletCache[walletStorageKey] // If the wallet cache does not have the given entry yet, initialize // the value and compute the wallet ID and wallet public key hash. This way, // the hashes are computed only once. No need to initialize signers slice as // appending works with nil values. - if _, ok := wr.walletCache[walletStorageKey]; !ok { + if !exists { walletID, err := calculateWalletIDForSigner( - signer, + walletSigner, wr.calculateWalletIdFunc, ) if err != nil { return fmt.Errorf("cannot calculate wallet ID: [%v]", err) } - wr.walletCache[walletStorageKey] = &walletCacheValue{ - walletPublicKeyHash: bitcoin.PublicKeyHash(signer.wallet.publicKey), + candidate = &walletCacheValue{ + walletPublicKeyHash: bitcoin.PublicKeyHash(walletSigner.wallet.publicKey), walletID: walletID, } } + candidate = &walletCacheValue{ + walletPublicKeyHash: candidate.walletPublicKeyHash, + walletID: candidate.walletID, + signers: append( + append([]*signer{}, candidate.signers...), + walletSigner, + ), + } - wr.walletCache[walletStorageKey].signers = append( - wr.walletCache[walletStorageKey].signers, - signer, - ) + keyGroup, isFrostWallet, err := frostKeyGroupFromWalletCacheValue(candidate) + if err != nil { + return fmt.Errorf("cannot resolve FROST wallet key group: [%w]", err) + } + if isFrostWallet { + if wr.retainedFrostKeyGroups == nil { + wr.retainedFrostKeyGroups = make(map[[32]byte]string) + } + if err := ensureRetainedFrostKeyGroupBinding( + wr.walletStorage, + wr.retainedFrostKeyGroups, + candidate.walletID, + keyGroup, + ); err != nil { + return fmt.Errorf( + "cannot persist FROST wallet key-group binding: [%w]", + err, + ) + } + } + if err := wr.walletStorage.saveSigner(walletSigner); err != nil { + return fmt.Errorf("cannot save signer in the storage: [%w]", err) + } + wr.walletCache[walletStorageKey] = candidate + wr.revision++ return nil } @@ -220,12 +292,14 @@ func (wr *walletRegistry) archiveWallet( defer wr.mutex.Unlock() var walletPublicKey *ecdsa.PublicKey + var walletValue *walletCacheValue for _, value := range wr.walletCache { if value.walletPublicKeyHash == walletPublicKeyHash { // All signers belong to one wallet. Take the wallet public key from // the first signer. walletPublicKey = value.signers[0].wallet.publicKey + walletValue = value break } } @@ -236,14 +310,39 @@ func (wr *walletRegistry) archiveWallet( walletStorageKey := getWalletStorageKey(walletPublicKey) + keyGroup, isFrostWallet, err := frostKeyGroupFromWalletCacheValue(walletValue) + if err != nil { + return fmt.Errorf( + "could not resolve FROST wallet key group before archive: [%w]", + err, + ) + } + if isFrostWallet { + if wr.retainedFrostKeyGroups == nil { + wr.retainedFrostKeyGroups = make(map[[32]byte]string) + } + if err := ensureRetainedFrostKeyGroupBinding( + wr.walletStorage, + wr.retainedFrostKeyGroups, + walletValue.walletID, + keyGroup, + ); err != nil { + return fmt.Errorf( + "could not persist retained FROST wallet key group: [%w]", + err, + ) + } + } + // Archive the entire wallet storage. - err := wr.walletStorage.archiveWallet(walletStorageKey) + err = wr.walletStorage.archiveWallet(walletStorageKey) if err != nil { return fmt.Errorf("could not archive wallet: [%v]", err) } // Remove the wallet from the wallet cache. delete(wr.walletCache, walletStorageKey) + wr.revision++ return nil } @@ -319,6 +418,10 @@ func (ws *walletStorage) loadSigners() map[string][]*signer { go func() { for descriptor := range descriptorsChan { + if descriptor.Directory() == + frostRetainedKeyGroupBindingDirectory { + continue + } content, err := descriptor.Content() if err != nil { logger.Errorf( From 937aabad1e83f36fcc2e0b5b327bb8c231246c56 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 17:44:03 -0400 Subject: [PATCH 09/93] test(tbtc): use secp256k1 retained binding fixture --- pkg/tbtc/frost_retained_key_group_binding_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/tbtc/frost_retained_key_group_binding_test.go b/pkg/tbtc/frost_retained_key_group_binding_test.go index a34c78f422..c8ad3c1a7f 100644 --- a/pkg/tbtc/frost_retained_key_group_binding_test.go +++ b/pkg/tbtc/frost_retained_key_group_binding_test.go @@ -2,13 +2,13 @@ package tbtc import ( "crypto/ecdsa" - "crypto/elliptic" "encoding/hex" "encoding/json" "fmt" "strings" "testing" + btcec2 "github.com/btcsuite/btcd/btcec/v2" "github.com/keep-network/keep-common/pkg/persistence" "github.com/keep-network/keep-core/pkg/bitcoin" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" @@ -48,8 +48,10 @@ func frostBindingSignerMaterial(t *testing.T, keyGroup string) *frostsigning.Nat } func frostBindingWalletPublicKey() *ecdsa.PublicKey { - x, y := elliptic.P256().ScalarBaseMult([]byte{1}) - return &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} + privateKeyBytes := make([]byte, 32) + privateKeyBytes[len(privateKeyBytes)-1] = 1 + _, publicKey := btcec2.PrivKeyFromBytes(privateKeyBytes) + return publicKey.ToECDSA() } func TestWalletRegistryArchiveFrostWalletPersistsExactKeyGroupBinding( From 8470083ab10d8c16845b197634364b951ba5a189 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 18:50:35 -0400 Subject: [PATCH 10/93] Add authenticated native signer state anchoring --- .github/workflows/frost-cgo-integration.yml | 3 + .../tbtc_frost_pre_sign_authorization.go | 333 +++- ...tbtc_frost_retained_group_manifest_test.go | 105 +- ...ffi_primitive_transitional_frost_native.go | 19 +- ...e_tbtc_signer_registration_frost_native.go | 103 +- .../signing/native_tbtc_signer_abi_version.go | 6 +- .../native_tbtc_signer_abi_version_test.go | 12 +- ...igner_anchor_real_cgo_frost_native_test.go | 366 ++++ .../signing/native_tbtc_signer_init_config.go | 113 ++ .../native_tbtc_signer_init_config_test.go | 95 ++ .../native_tbtc_signer_inventory_cgo.go | 160 ++ .../native_tbtc_signer_inventory_default.go | 28 + ...ner_readback_real_cgo_frost_native_test.go | 2 +- .../native_tbtc_signer_secure_config.go | 89 + .../native_tbtc_signer_secure_config_test.go | 74 + ...native_tbtc_signer_state_anchor_barrier.go | 494 ++++++ ...e_tbtc_signer_state_anchor_barrier_test.go | 559 ++++++ .../native_tbtc_signer_state_witness_tip.go | 454 +++++ ...tive_tbtc_signer_state_witness_tip_test.go | 306 ++++ ...l_cgo_interactive_e2e_frost_native_test.go | 1 + pkg/tbtc/frost_activation_handshake.go | 7 +- pkg/tbtc/frost_activation_handshake_test.go | 15 +- .../frost_native_signer_anchor_binding.go | 614 +++++++ ...frost_native_signer_anchor_binding_test.go | 591 +++++++ pkg/tbtc/frost_native_signer_anchor_client.go | 1514 +++++++++++++++++ .../frost_native_signer_anchor_client_test.go | 1317 ++++++++++++++ .../frost_native_signer_anchor_history.go | 289 ++++ pkg/tbtc/frost_native_signer_anchor_keys.go | 84 + .../frost_native_signer_anchor_keys_test.go | 132 ++ .../frost_native_signer_anchor_protocol.go | 1406 +++++++++++++++ pkg/tbtc/frost_native_signer_readiness.go | 187 +- .../frost_native_signer_readiness_test.go | 222 ++- pkg/tbtc/frost_pre_sign_authorization.go | 16 + pkg/tbtc/node.go | 109 +- pkg/tbtc/tbtc.go | 18 +- 35 files changed, 9554 insertions(+), 289 deletions(-) create mode 100644 pkg/frost/signing/native_tbtc_signer_anchor_real_cgo_frost_native_test.go create mode 100644 pkg/frost/signing/native_tbtc_signer_init_config_test.go create mode 100644 pkg/frost/signing/native_tbtc_signer_secure_config.go create mode 100644 pkg/frost/signing/native_tbtc_signer_secure_config_test.go create mode 100644 pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go create mode 100644 pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go create mode 100644 pkg/frost/signing/native_tbtc_signer_state_witness_tip.go create mode 100644 pkg/frost/signing/native_tbtc_signer_state_witness_tip_test.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_binding.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_binding_test.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_client.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_client_test.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_history.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_keys.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_keys_test.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_protocol.go diff --git a/.github/workflows/frost-cgo-integration.yml b/.github/workflows/frost-cgo-integration.yml index 6b4cb87c74..c501b48307 100644 --- a/.github/workflows/frost-cgo-integration.yml +++ b/.github/workflows/frost-cgo-integration.yml @@ -96,6 +96,9 @@ jobs: frost_tbtc_durable_store_identity \ frost_tbtc_retained_key_package_inventory \ frost_tbtc_state_witness_proof \ + frost_tbtc_state_witness_tip \ + frost_tbtc_acknowledge_state_witness_checkpoint \ + frost_tbtc_recover_state_witness_checkpoint \ frost_tbtc_version \ frost_tbtc_abi_version; do if ! nm -D --defined-only "$lib" | grep -q " ${sym}$"; then diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index 5b704dec38..2684dca39e 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -30,6 +30,7 @@ import ( "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" tbtcabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/tbtc" ) @@ -202,30 +203,62 @@ type frostPreSignManifestQuarantineJournal struct { MinimumGeneration uint64 `json:"minimumGeneration"` } +type frostPreSignManifestNativeSignerAnchor struct { + ProtocolID string `json:"protocolID"` + StreamID string `json:"streamID"` + TrustDomainID string `json:"trustDomainID"` + EndpointLeafSPKIHash string `json:"endpointLeafSpkiHash"` + OnlineKeyHash string `json:"onlineKeyHash"` + OperatorFingerprint string `json:"operatorFingerprint"` + HistoryStoreID string `json:"historyStoreID"` + HistoryStoreFingerprint string `json:"historyStoreFingerprint"` + HistoryClusterFingerprint string `json:"historyClusterFingerprint"` + OfflineAuthorityHash string `json:"offlineAuthorityHash"` + ClientSPKIHash string `json:"clientSpkiHash"` + SignerStoreFingerprint string `json:"signerStoreFingerprint"` + TransportBinding string `json:"transportBinding"` + MinimumServiceEpoch uint64 `json:"minimumServiceEpoch"` + MinimumRevision uint64 `json:"minimumRevision"` + MinimumEventRoot string `json:"minimumEventRoot"` + MinimumAcknowledgementDigest string `json:"minimumAcknowledgementDigest"` + MinimumCheckpoint frostPreSignManifestNativeSignerCheckpoint `json:"minimumCheckpoint"` + WitnessMaximumRecords uint64 `json:"witnessMaximumRecords"` + WitnessRotationThresholdRecords uint64 `json:"witnessRotationThresholdRecords"` +} + +type frostPreSignManifestNativeSignerCheckpoint struct { + StoreFingerprint string `json:"storeFingerprint"` + Generation uint64 `json:"generation"` + PreviousStateCommitment string `json:"previousStateCommitment"` + StateImageDigest string `json:"stateImageDigest"` + StateCommitment string `json:"stateCommitment"` +} + type frostPreSignManifestFrostSigner struct { - TrustDomainID string `json:"trustDomainID"` - DurableSessionStoreFingerprint string `json:"durableSessionStoreFingerprint"` - ProtocolID string `json:"protocolID"` - ReservationProtocolID string `json:"reservationProtocolID"` - BitcoinOutboxProtocolID string `json:"bitcoinOutboxProtocolID"` - SigningPolicyHash string `json:"signingPolicyHash"` - CompleteRouterAddress string `json:"completeRouterAddress"` - AuthorizationRegistryAddress string `json:"authorizationRegistryAddress"` - AttestationSignerKeyHash string `json:"attestationSignerKeyHash"` - HandshakeEndpointFingerprint string `json:"handshakeEndpointFingerprint"` - HandshakeOperatorFingerprint string `json:"handshakeOperatorFingerprint"` - Threshold uint64 `json:"threshold"` - MaximumGroupSize uint64 `json:"maximumGroupSize"` - RetainedGroupInventoryProtocolID string `json:"retainedGroupInventoryProtocolID"` - CanonicalJournal frostPreSignManifestCanonicalJournal `json:"canonicalJournal"` - QuarantineJournal frostPreSignManifestQuarantineJournal `json:"quarantineJournal"` - ExactRetainedGroupInventoryRequired bool `json:"exactRetainedGroupInventoryRequired"` - FinalizedReservationReceiptRequired bool `json:"finalizedReservationReceiptRequired"` - ExactReservationIdentityRequired bool `json:"exactReservationIdentityRequired"` - AuthorizationRootRequired bool `json:"authorizationRootRequired"` - DurableSessionPersistenceRequired bool `json:"durableSessionPersistenceRequired"` - DurableBitcoinOutboxRequired bool `json:"durableBitcoinOutboxRequired"` - QuarantineFailClosed bool `json:"quarantineFailClosed"` + TrustDomainID string `json:"trustDomainID"` + DurableSessionStoreFingerprint string `json:"durableSessionStoreFingerprint"` + ProtocolID string `json:"protocolID"` + ReservationProtocolID string `json:"reservationProtocolID"` + BitcoinOutboxProtocolID string `json:"bitcoinOutboxProtocolID"` + SigningPolicyHash string `json:"signingPolicyHash"` + CompleteRouterAddress string `json:"completeRouterAddress"` + AuthorizationRegistryAddress string `json:"authorizationRegistryAddress"` + AttestationSignerKeyHash string `json:"attestationSignerKeyHash"` + HandshakeEndpointFingerprint string `json:"handshakeEndpointFingerprint"` + HandshakeOperatorFingerprint string `json:"handshakeOperatorFingerprint"` + Threshold uint64 `json:"threshold"` + MaximumGroupSize uint64 `json:"maximumGroupSize"` + RetainedGroupInventoryProtocolID string `json:"retainedGroupInventoryProtocolID"` + CanonicalJournal frostPreSignManifestCanonicalJournal `json:"canonicalJournal"` + QuarantineJournal frostPreSignManifestQuarantineJournal `json:"quarantineJournal"` + NativeSignerAnchor frostPreSignManifestNativeSignerAnchor `json:"nativeSignerAnchor"` + ExactRetainedGroupInventoryRequired bool `json:"exactRetainedGroupInventoryRequired"` + FinalizedReservationReceiptRequired bool `json:"finalizedReservationReceiptRequired"` + ExactReservationIdentityRequired bool `json:"exactReservationIdentityRequired"` + AuthorizationRootRequired bool `json:"authorizationRootRequired"` + DurableSessionPersistenceRequired bool `json:"durableSessionPersistenceRequired"` + DurableBitcoinOutboxRequired bool `json:"durableBitcoinOutboxRequired"` + QuarantineFailClosed bool `json:"quarantineFailClosed"` } type frostPreSignActivationManifest struct { @@ -502,6 +535,14 @@ func loadFrostPreSignActivationManifest( "FROST runtime attestation key must differ from the activation authority key", ) } + anchorOfflineAuthorityHash, err := frostPreSignParseBytes32( + manifest.FrostSigner.NativeSignerAnchor.OfflineAuthorityHash, + ) + if err != nil || anchorOfflineAuthorityHash != trustedKeyHash { + return nil, fmt.Errorf( + "FROST native signer anchor offline authority differs from the activation authority", + ) + } return manifest, nil } @@ -765,6 +806,52 @@ func validateFrostPreSignActivationManifest( if err != nil || durableSessionStoreFingerprint == [32]byte{} { return fmt.Errorf("invalid FROST durable session store fingerprint") } + anchorManifest, err := frostPreSignNativeSignerAnchorManifest(manifest) + if err != nil { + return err + } + anchorIdentity := anchorManifest.Identity + if expectedStreamID := tbtc.ComputeFrostNativeSignerAnchorStreamID( + anchorIdentity, + ); expectedStreamID != anchorIdentity.StreamID { + return fmt.Errorf("FROST native signer anchor stream ID mismatch") + } + if anchorIdentity.SignerStoreFingerprint != durableSessionStoreFingerprint { + return fmt.Errorf( + "FROST native signer anchor store differs from the durable signer store", + ) + } + if anchorIdentity.TrustDomainID == frost.TrustDomainID || + anchorIdentity.TrustDomainID == manifest.Ethereum.SourceTrustDomainID || + anchorIdentity.TrustDomainID == manifest.Ethereum.VerifierTrustDomainID || + anchorIdentity.TrustDomainID == frost.CanonicalJournal.SourceTrustDomainID { + return fmt.Errorf( + "FROST native signer anchor trust domain is not independent", + ) + } + if anchorIdentity.HistoryStoreID == manifest.Ethereum.StoreID || + anchorIdentity.HistoryStoreID == manifest.Ethereum.SourceHistoryStoreID || + anchorIdentity.HistoryStoreID == manifest.Ethereum.VerifierHistoryStoreID || + anchorIdentity.HistoryStoreID == frost.CanonicalJournal.StoreID || + anchorIdentity.HistoryStoreID == frost.QuarantineJournal.StoreID { + return fmt.Errorf( + "FROST native signer anchor history store is not independent", + ) + } + attestationKeyHash, err := frostPreSignParseBytes32( + frost.AttestationSignerKeyHash, + ) + if err != nil { + return fmt.Errorf("invalid FROST runtime attestation key") + } + if anchorIdentity.OnlineKeyHash == anchorIdentity.OfflineAuthorityHash || + anchorIdentity.OnlineKeyHash == anchorIdentity.ClientSPKIHash || + anchorIdentity.OnlineKeyHash == attestationKeyHash || + anchorIdentity.OfflineAuthorityHash == anchorIdentity.ClientSPKIHash || + anchorIdentity.OfflineAuthorityHash == attestationKeyHash || + anchorIdentity.ClientSPKIHash == attestationKeyHash { + return fmt.Errorf("FROST native signer anchor authority keys are not independent") + } journal := frost.CanonicalJournal if strings.TrimSpace(journal.StoreID) == "" || len(journal.StoreID) > 255 || strings.TrimSpace(journal.SourceTrustDomainID) == "" || @@ -841,9 +928,202 @@ func validateFrostPreSignActivationManifest( durableSessionStoreFingerprint == quarantineStoreFingerprint { return fmt.Errorf("FROST canonical journal storage identities are not independent") } + for name, value := range map[string][32]byte{ + "canonical journal store": parsedJournalValues["store fingerprint"], + "canonical journal cluster": parsedJournalValues["cluster fingerprint"], + "quarantine journal store": quarantineStoreFingerprint, + "quarantine journal cluster": quarantineClusterFingerprint, + "durable native signer store": durableSessionStoreFingerprint, + } { + if anchorIdentity.HistoryStoreFingerprint == value || + anchorIdentity.HistoryClusterFingerprint == value { + return fmt.Errorf( + "FROST native signer anchor history identity aliases %s", + name, + ) + } + } + for name, encoded := range map[string]string{ + "Ethereum source endpoint": manifest.Ethereum.SourceEndpointFingerprint, + "Ethereum verifier endpoint": manifest.Ethereum.VerifierEndpointFingerprint, + "canonical journal endpoint": journal.SourceEndpointFingerprint, + "runtime handshake endpoint": frost.HandshakeEndpointFingerprint, + } { + value, err := frostPreSignParseBytes32(encoded) + if err != nil { + return fmt.Errorf("invalid %s fingerprint", name) + } + if anchorIdentity.EndpointLeafSPKIHash != [32]byte{} && + anchorIdentity.EndpointLeafSPKIHash == value { + return fmt.Errorf( + "FROST native signer anchor endpoint aliases %s", + name, + ) + } + } + for name, encoded := range map[string]string{ + "Ethereum source operator": manifest.Ethereum.SourceOperatorFingerprint, + "Ethereum verifier operator": manifest.Ethereum.VerifierOperatorFingerprint, + "canonical journal operator": journal.SourceOperatorFingerprint, + "runtime handshake operator": frost.HandshakeOperatorFingerprint, + } { + value, err := frostPreSignParseBytes32(encoded) + if err != nil { + return fmt.Errorf("invalid %s fingerprint", name) + } + if anchorIdentity.OperatorFingerprint == value { + return fmt.Errorf( + "FROST native signer anchor operator aliases %s", + name, + ) + } + } return nil } +func frostPreSignNativeSignerAnchorIdentity( + manifest *frostPreSignActivationManifest, +) (tbtc.FrostNativeSignerAnchorIdentity, error) { + result := tbtc.FrostNativeSignerAnchorIdentity{} + if manifest == nil { + return result, fmt.Errorf("FROST native signer anchor manifest is nil") + } + anchor := manifest.FrostSigner.NativeSignerAnchor + if strings.TrimSpace(anchor.TrustDomainID) == "" || + len(anchor.TrustDomainID) > 128 || + strings.TrimSpace(anchor.HistoryStoreID) == "" || + len(anchor.HistoryStoreID) > 255 { + return result, fmt.Errorf("FROST native signer anchor identity is incomplete") + } + result.ActivationManifestHash = manifest.manifestHash + result.ActivationManifestSequence = manifest.ActivationSequence + result.TrustDomainID = anchor.TrustDomainID + result.HistoryStoreID = anchor.HistoryStoreID + result.WitnessMaximumRecords = anchor.WitnessMaximumRecords + result.WitnessRotationThresholdRecords = + anchor.WitnessRotationThresholdRecords + values := []struct { + label string + encoded string + destination *[32]byte + allowZero bool + }{ + {"protocol ID", anchor.ProtocolID, &result.ProtocolID, false}, + {"stream ID", anchor.StreamID, &result.StreamID, false}, + {"endpoint leaf SPKI hash", anchor.EndpointLeafSPKIHash, &result.EndpointLeafSPKIHash, true}, + {"online key hash", anchor.OnlineKeyHash, &result.OnlineKeyHash, false}, + {"operator fingerprint", anchor.OperatorFingerprint, &result.OperatorFingerprint, false}, + {"history store fingerprint", anchor.HistoryStoreFingerprint, &result.HistoryStoreFingerprint, false}, + {"history cluster fingerprint", anchor.HistoryClusterFingerprint, &result.HistoryClusterFingerprint, false}, + {"offline authority hash", anchor.OfflineAuthorityHash, &result.OfflineAuthorityHash, false}, + {"client SPKI hash", anchor.ClientSPKIHash, &result.ClientSPKIHash, false}, + {"signer store fingerprint", anchor.SignerStoreFingerprint, &result.SignerStoreFingerprint, false}, + {"transport binding", anchor.TransportBinding, &result.TransportBinding, false}, + } + for _, value := range values { + parsed, err := frostPreSignParseBytes32(value.encoded) + if err != nil || (!value.allowZero && parsed == [32]byte{}) { + return result, fmt.Errorf( + "invalid FROST native signer anchor %s", + value.label, + ) + } + *value.destination = parsed + } + return result, nil +} + +func frostPreSignNativeSignerAnchorManifest( + manifest *frostPreSignActivationManifest, +) (tbtc.FrostNativeSignerAnchorManifest, error) { + result := tbtc.FrostNativeSignerAnchorManifest{} + identity, err := frostPreSignNativeSignerAnchorIdentity(manifest) + if err != nil { + return result, err + } + anchor := manifest.FrostSigner.NativeSignerAnchor + if anchor.MinimumServiceEpoch == 0 || anchor.MinimumRevision == 0 || + anchor.WitnessMaximumRecords < 2 || + anchor.WitnessMaximumRecords > 1_000_000 || + anchor.WitnessRotationThresholdRecords < 2 || + anchor.WitnessRotationThresholdRecords > + anchor.WitnessMaximumRecords-2 { + return result, fmt.Errorf( + "FROST native signer anchor floor or witness geometry is invalid", + ) + } + result.Identity = identity + result.MinimumServiceEpoch = anchor.MinimumServiceEpoch + result.MinimumRevision = anchor.MinimumRevision + result.WitnessMaximumRecords = anchor.WitnessMaximumRecords + result.WitnessRotationThresholdRecords = + anchor.WitnessRotationThresholdRecords + for label, input := range map[string]struct { + encoded string + destination *[32]byte + }{ + "minimum event root": { + anchor.MinimumEventRoot, + &result.MinimumEventRoot, + }, + "minimum acknowledgement digest": { + anchor.MinimumAcknowledgementDigest, + &result.MinimumAcknowledgementDigest, + }, + } { + parsed, err := frostPreSignParseBytes32(input.encoded) + if err != nil || parsed == [32]byte{} { + return result, fmt.Errorf("invalid FROST native signer anchor %s", label) + } + *input.destination = parsed + } + checkpoint := anchor.MinimumCheckpoint + if checkpoint.Generation == 0 { + return result, fmt.Errorf( + "FROST native signer anchor minimum checkpoint generation is zero", + ) + } + result.MinimumCheckpoint.Generation = checkpoint.Generation + checkpointValues := []struct { + label string + encoded string + destination *[32]byte + }{ + {"store fingerprint", checkpoint.StoreFingerprint, &result.MinimumCheckpoint.StoreFingerprint}, + {"previous state commitment", checkpoint.PreviousStateCommitment, &result.MinimumCheckpoint.PreviousStateCommitment}, + {"state image digest", checkpoint.StateImageDigest, &result.MinimumCheckpoint.StateImageDigest}, + {"state commitment", checkpoint.StateCommitment, &result.MinimumCheckpoint.StateCommitment}, + } + for _, value := range checkpointValues { + parsed, err := frostPreSignParseBytes32(value.encoded) + if err != nil || parsed == [32]byte{} { + return result, fmt.Errorf( + "invalid FROST native signer anchor minimum checkpoint %s", + value.label, + ) + } + *value.destination = parsed + } + if result.MinimumCheckpoint.StoreFingerprint != + identity.SignerStoreFingerprint { + return result, fmt.Errorf( + "FROST native signer anchor minimum checkpoint belongs to another store", + ) + } + computed := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + result.MinimumCheckpoint.StoreFingerprint, + result.MinimumCheckpoint.Generation, + result.MinimumCheckpoint.PreviousStateCommitment, + result.MinimumCheckpoint.StateImageDigest, + ) + if computed != result.MinimumCheckpoint.StateCommitment { + return result, fmt.Errorf( + "FROST native signer anchor minimum checkpoint commitment is invalid", + ) + } + return result, nil +} + func newFrostPreSignEthereumAdapter( ctx context.Context, tc *TbtcChain, @@ -2031,6 +2311,12 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( if err != nil { return tbtc.FrostPreSignActivationRuntimeManifest{}, err } + nativeSignerAnchor, err := frostPreSignNativeSignerAnchorManifest( + &adapter.manifest, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } return tbtc.FrostPreSignActivationRuntimeManifest{ ManifestHash: adapter.profile.ActivationManifestHash, DomainChainID: adapter.profile.DomainChainID, @@ -2050,6 +2336,7 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( Threshold: frost.Threshold, MaximumGroupSize: frost.MaximumGroupSize, RetainedGroupInventoryProtocolID: retainedGroupInventoryProtocolID, + NativeSignerAnchor: nativeSignerAnchor, CanonicalJournal: tbtc.FrostRetainedGroupCanonicalJournalManifest{ StoreID: journal.StoreID, StoreFingerprint: storeFingerprint, diff --git a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go index c8ec23949e..3fce462bfa 100644 --- a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go +++ b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go @@ -4,6 +4,9 @@ import ( "fmt" "strings" "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tbtc" ) func testManifestHex32(value byte) string { @@ -12,7 +15,16 @@ func testManifestHex32(value byte) string { func testFrostJournalActivationManifest() *frostPreSignActivationManifest { checkpointHash := testManifestHex32(0x02) - return &frostPreSignActivationManifest{ + signerStoreFingerprint := [32]byte{0x09} + previousStateCommitment := [32]byte{0x50} + stateImageDigest := [32]byte{0x51} + stateCommitment := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + signerStoreFingerprint, + 1, + previousStateCommitment, + stateImageDigest, + ) + manifest := &frostPreSignActivationManifest{ Schema: frostPreSignManifestVersion, ActivationSequence: 1, ActivationID: testManifestHex32(0x01), @@ -72,8 +84,44 @@ func testFrostJournalActivationManifest() *frostPreSignActivationManifest { ClusterFingerprint: testManifestHex32(0x27), MinimumGeneration: 0, }, + NativeSignerAnchor: frostPreSignManifestNativeSignerAnchor{ + ProtocolID: testManifestHex32(0x40), + StreamID: testManifestHex32(0x41), + TrustDomainID: "independent-native-anchor", + EndpointLeafSPKIHash: testManifestHex32(0x42), + OnlineKeyHash: testManifestHex32(0x43), + OperatorFingerprint: testManifestHex32(0x44), + HistoryStoreID: "native-anchor-history", + HistoryStoreFingerprint: testManifestHex32(0x45), + HistoryClusterFingerprint: testManifestHex32(0x46), + OfflineAuthorityHash: testManifestHex32(0x47), + ClientSPKIHash: testManifestHex32(0x48), + SignerStoreFingerprint: testManifestHex32(0x09), + TransportBinding: testManifestHex32(0x49), + MinimumServiceEpoch: 1, + MinimumRevision: 1, + MinimumEventRoot: testManifestHex32(0x4a), + MinimumAcknowledgementDigest: testManifestHex32(0x4b), + MinimumCheckpoint: frostPreSignManifestNativeSignerCheckpoint{ + StoreFingerprint: testManifestHex32(0x09), + Generation: 1, + PreviousStateCommitment: fmt.Sprintf("0x%x", previousStateCommitment[:]), + StateImageDigest: fmt.Sprintf("0x%x", stateImageDigest[:]), + StateCommitment: fmt.Sprintf("0x%x", stateCommitment[:]), + }, + WitnessMaximumRecords: 100, + WitnessRotationThresholdRecords: 8, + }, }, } + anchorIdentity, err := frostPreSignNativeSignerAnchorIdentity(manifest) + if err != nil { + panic(err) + } + streamID := tbtc.ComputeFrostNativeSignerAnchorStreamID(anchorIdentity) + manifest.FrostSigner.NativeSignerAnchor.StreamID = + fmt.Sprintf("0x%x", streamID[:]) + return manifest } func TestValidateFrostPreSignActivationManifest_CanonicalJournal(t *testing.T) { @@ -107,6 +155,61 @@ func TestValidateFrostPreSignActivationManifest_CanonicalJournal(t *testing.T) { t.Fatalf("expected durable-session fingerprint failure, got [%v]", err) } }) + t.Run("native anchor stream mismatch", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.NativeSignerAnchor.StreamID = testManifestHex32(0xee) + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "stream ID mismatch") { + t.Fatalf("expected native anchor stream failure, got [%v]", err) + } + }) + t.Run("native anchor store mismatch", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.NativeSignerAnchor.SignerStoreFingerprint = + testManifestHex32(0xee) + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "minimum checkpoint belongs to another store") { + t.Fatalf("expected native anchor store failure, got [%v]", err) + } + }) + t.Run("native anchor checkpoint tamper", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.NativeSignerAnchor.MinimumCheckpoint.StateImageDigest = + testManifestHex32(0xee) + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "checkpoint commitment is invalid") { + t.Fatalf("expected native anchor checkpoint failure, got [%v]", err) + } + }) + t.Run("native anchor witness geometry", func(t *testing.T) { + for name, mutate := range map[string]func(*frostPreSignManifestNativeSignerAnchor){ + "maximum too large": func(anchor *frostPreSignManifestNativeSignerAnchor) { + anchor.WitnessMaximumRecords = 1_000_001 + }, + "rotation leaves no crash margin": func(anchor *frostPreSignManifestNativeSignerAnchor) { + anchor.WitnessMaximumRecords = 10 + anchor.WitnessRotationThresholdRecords = 9 + }, + } { + t.Run(name, func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + mutate(&manifest.FrostSigner.NativeSignerAnchor) + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "witness geometry") { + t.Fatalf("expected native anchor geometry failure, got [%v]", err) + } + }) + } + }) + t.Run("native anchor authority alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.NativeSignerAnchor.OnlineKeyHash = + manifest.FrostSigner.NativeSignerAnchor.ClientSPKIHash + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "authority keys are not independent") { + t.Fatalf("expected native anchor authority failure, got [%v]", err) + } + }) } func TestFrostPreSignDecodeStrictJSON_RejectsUnknownCanonicalJournalField(t *testing.T) { diff --git a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go index ebde579ebe..68b55725de 100644 --- a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go +++ b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go @@ -51,7 +51,7 @@ func installConfiguredTBTCSignerInitConfig() error { return nil } - configJSON, err := os.ReadFile(configPath) + configJSON, err := readSecureNativeTBTCSignerInitConfig(configPath) if err != nil { err = fmt.Errorf( "read tbtc-signer init config [%s]: %w", @@ -66,6 +66,7 @@ func installConfiguredTBTCSignerInitConfig() error { ) return err } + defer zeroBytes(configJSON) result, err := InstallNativeTBTCSignerConfig(configJSON) if err != nil { @@ -82,6 +83,22 @@ func installConfiguredTBTCSignerInitConfig() error { ) return err } + if err := recordNativeTBTCSignerInstalledStateAnchorConfig( + configJSON, + result.ConfigFingerprint, + ); err != nil { + err = fmt.Errorf( + "bind installed tbtc-signer anchor config from [%s]: %w", + configPath, + err, + ) + registrationLogger.Errorf( + "tbtc-signer anchor config binding failed; FROST-native engine "+ + "registration fails closed: [%v]", + err, + ) + return err + } registrationLogger.Infof( "installed tbtc-signer init config from [%s]: fingerprint [%s], "+ diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go index 1f0e742d85..6a95529bff 100644 --- a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go @@ -296,7 +296,17 @@ static TbtcSignerResult tbtc_signer_durable_store_identity(void) { return durable_store_identity(); } -static void tbtc_signer_free_buffer(uint8_t* ptr, size_t len) { +static int tbtc_signer_free_buffer_available(void) { + return dlsym(RTLD_DEFAULT, "frost_tbtc_free_buffer") != NULL; +} + +static void tbtc_signer_scrub_and_free_buffer(uint8_t* ptr, size_t len) { + if (ptr != NULL) { + volatile uint8_t* cursor = (volatile uint8_t*)ptr; + for (size_t index = 0; index < len; index++) { + cursor[index] = 0; + } + } tbtc_free_buffer_fn free_buffer = (tbtc_free_buffer_fn)dlsym( RTLD_DEFAULT, "frost_tbtc_free_buffer" @@ -1620,6 +1630,9 @@ func decodeBuildTaggedTBTCSignerBuildTaprootTxResponse( } func callBuildTaggedTBTCSignerVersion() ([]byte, error) { + if err := ensureTBTCSignerFreeBufferAvailable(); err != nil { + return nil, err + } result := C.tbtc_signer_version() return parseBuildTaggedTBTCSignerResult("Version", result) } @@ -1630,6 +1643,9 @@ func callBuildTaggedTBTCSignerVersion() ([]byte, error) { // incompatibility. It deliberately does NOT pass through callBuildTaggedTBTCSignerOperation // (it takes no request and must not recurse into the ABI gate). func callBuildTaggedTBTCSignerABIVersion() ([]byte, error) { + if err := ensureTBTCSignerFreeBufferAvailable(); err != nil { + return nil, err + } result := C.tbtc_signer_abi_version() return parseBuildTaggedTBTCSignerResult("ABIVersion", result) } @@ -1738,19 +1754,45 @@ func callBuildTaggedTBTCSignerOperation( ) } - requestPtr := C.CBytes(requestPayload) - requestLen := len(requestPayload) - defer func() { - // Scrub the secret request bytes from the C heap before releasing them. - // The request payload can carry signing-share / nonce material, and a - // plain C.free does not overwrite; this mirrors the Go-side zeroBytes - // hygiene applied to the caller's own copy. - zeroBytes(unsafe.Slice((*byte)(requestPtr), requestLen)) - C.free(requestPtr) - }() + var result C.TbtcSignerResult + return executeNativeTBTCSignerStateAnchoredOutput( + operation, + func() { + requestPtr := C.CBytes(requestPayload) + requestLen := len(requestPayload) + defer func() { + // Scrub secret request bytes immediately after the native call. + zeroBytes(unsafe.Slice((*byte)(requestPtr), requestLen)) + C.free(requestPtr) + }() + result = call( + (*C.uint8_t)(requestPtr), + C.size_t(requestLen), + ) + }, + func() ([]byte, error) { + return parseBuildTaggedTBTCSignerResult(operation, result) + }, + func() { + discardBuildTaggedTBTCSignerResult(result) + }, + ) +} + +func discardBuildTaggedTBTCSignerResult(result C.TbtcSignerResult) { + if result.buffer.ptr != nil { + C.tbtc_signer_scrub_and_free_buffer(result.buffer.ptr, result.buffer.len) + } +} - result := call((*C.uint8_t)(requestPtr), C.size_t(len(requestPayload))) - return parseBuildTaggedTBTCSignerResult(operation, result) +func ensureTBTCSignerFreeBufferAvailable() error { + if C.tbtc_signer_free_buffer_available() == 0 { + return fmt.Errorf( + "%w: tbtc-signer buffer release symbol is unavailable", + ErrNativeCryptographyUnavailable, + ) + } + return nil } func parseBuildTaggedTBTCSignerResult( @@ -1763,7 +1805,10 @@ func parseBuildTaggedTBTCSignerResult( // `result.buffer.ptr == nil`, so skip the deferred free in that case to // avoid handing a NULL pointer to Rust's `frost_tbtc_free_buffer`. if result.buffer.ptr != nil { - defer C.tbtc_signer_free_buffer(result.buffer.ptr, result.buffer.len) + defer C.tbtc_signer_scrub_and_free_buffer( + result.buffer.ptr, + result.buffer.len, + ) } statusCode := int32(result.status_code) @@ -1827,13 +1872,31 @@ func buildTaggedTBTCSignerResultStatusError( func callBuildTaggedTBTCSignerInitSignerConfig( requestPayload []byte, ) ([]byte, error) { - return callBuildTaggedTBTCSignerOperation( - "InitSignerConfig", - requestPayload, - func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { - return C.tbtc_signer_init_signer_config(requestPtr, requestLen) - }, + // This dedicated helper is the sole compile-time bootstrap exception to the + // process-global state anchor. InitSignerConfig must open/lock the durable + // store before its tip can be reconciled, and the Rust ABI guarantees an + // initial or config-identical install neither mutates signer state nor emits + // protocol material. + if err := ensureTBTCSignerABICompatible(); err != nil { + return nil, err + } + if len(requestPayload) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "InitSignerConfig", + "request payload is empty", + ) + } + requestPtr := C.CBytes(requestPayload) + requestLen := len(requestPayload) + defer func() { + zeroBytes(unsafe.Slice((*byte)(requestPtr), requestLen)) + C.free(requestPtr) + }() + result := C.tbtc_signer_init_signer_config( + (*C.uint8_t)(requestPtr), + C.size_t(requestLen), ) + return parseBuildTaggedTBTCSignerResult("InitSignerConfig", result) } // InstallNativeTBTCSignerConfig installs the tbtc-signer's init-time diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version.go b/pkg/frost/signing/native_tbtc_signer_abi_version.go index 91637c1016..4b7cd4b460 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version.go @@ -30,7 +30,11 @@ const ( // symbols and response types, so ABI-4.0 callers remain valid and ignore // them. Their first public contract uses the v2 stable-store and witness // transcripts; bridges that consume them must require at least 4.1. - requiredTBTCSignerABIMinMinor uint32 = 1 + // + // Minor 2 adds the constant-size state-witness tip and signed checkpoint + // acknowledgement symbols required by the protocol-output barrier. This + // build must reject 4.1 before it can reach a missing symbol via dlsym. + requiredTBTCSignerABIMinMinor uint32 = 2 ) // ErrTBTCSignerABIIncompatible marks a linked libfrost_tbtc whose FFI contract version diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go index b51896dcbe..da66c9dab4 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go @@ -93,10 +93,11 @@ func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { // identity schema and the state-witness transcript to v2, so that state // commitments bind only the stable `.store-id` and no longer break when a // benign filesystem change alters the lock file, directory inode, or device. - // Minor 1 adds the identity, retained inventory, and state-witness proof - // readbacks to the already deployed ABI-4 surface. The matching library - // version is compatible; ABI 4.0 and a different major are not. - if requiredTBTCSignerABIMajor != 4 || requiredTBTCSignerABIMinMinor != 1 { + // Minor 2 adds the constant-size state tip and signed external-anchor + // acknowledgement required before any protocol output can leave Go. The + // matching library version is compatible; ABI 4.1 and a different major + // are not. + if requiredTBTCSignerABIMajor != 4 || requiredTBTCSignerABIMinMinor != 2 { t.Fatalf( "unexpected required tbtc-signer ABI: [%d.%d]", requiredTBTCSignerABIMajor, @@ -109,6 +110,9 @@ func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, 0); err == nil { t.Fatal("ABI 4.0 without readiness readbacks must be incompatible") } + if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, 1); err == nil { + t.Fatal("ABI 4.1 without the output-barrier tip/ack symbols must be incompatible") + } if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor+1, requiredTBTCSignerABIMinMinor); err == nil { t.Fatal("a higher major must be incompatible") } diff --git a/pkg/frost/signing/native_tbtc_signer_anchor_real_cgo_frost_native_test.go b/pkg/frost/signing/native_tbtc_signer_anchor_real_cgo_frost_native_test.go new file mode 100644 index 0000000000..347e131a7d --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_anchor_real_cgo_frost_native_test.go @@ -0,0 +1,366 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "sync" + "testing" + "time" +) + +var realCgoTestSignerAnchor struct { + sync.Mutex + privateKey ed25519.PrivateKey + binding [32]byte + latest NativeTBTCSignerStateWitnessTip +} + +type realCgoTestSignerAnchorCommitter struct{} + +func (committer *realCgoTestSignerAnchorCommitter) VerifyNativeTBTCSignerStateTip( + _ context.Context, + local NativeTBTCSignerStateWitnessTip, +) error { + if local != realCgoTestSignerAnchor.latest { + return fmt.Errorf("real-cgo test signer tip differs from its test anchor") + } + return nil +} + +func (committer *realCgoTestSignerAnchorCommitter) CommitNativeTBTCSignerStateTransition( + _ context.Context, + _ string, + expected NativeTBTCSignerStateWitnessTip, + candidate NativeTBTCSignerStateWitnessTip, +) (*NativeTBTCSignerStateWitnessTip, error) { + if expected != realCgoTestSignerAnchor.latest { + return nil, fmt.Errorf( + "real-cgo test anchor expected tip changed before commit", + ) + } + acknowledgement, err := realCgoTestSignerAnchorAcknowledgement( + candidate, + expected.AnchorServiceEpoch, + expected.AnchorRevision+1, + expected.AnchorEventRoot, + ) + if err != nil { + return nil, err + } + result, err := + AcknowledgeNativeTBTCSignerStateWitnessCheckpoint(acknowledgement) + if err != nil { + return nil, err + } + if result == nil || !result.Acknowledged { + return nil, fmt.Errorf("real-cgo test acknowledgement was not installed") + } + readback, err := ReadNativeTBTCSignerStateWitnessTip() + if err != nil { + return nil, err + } + realCgoTestSignerAnchor.latest = *readback + return readback, nil +} + +// setupRealCgoSignerStateAnchor gives the real-cgo suites an actual ABI-4.2 +// acknowledgement path. The independent-service half is deliberately an +// in-process test double, but Rust still verifies the frozen signed +// acknowledgement transcript and durably installs it before the central FFI +// barrier releases any native output. +func setupRealCgoSignerStateAnchor(t *testing.T) { + t.Helper() + realCgoTestSignerAnchor.Lock() + defer realCgoTestSignerAnchor.Unlock() + + if realCgoTestSignerAnchor.privateKey == nil { + seed := sha256.Sum256( + []byte("keep-core/real-cgo/native-signer-anchor-test-key/v1"), + ) + realCgoTestSignerAnchor.privateKey = + ed25519.NewKeyFromSeed(seed[:]) + realCgoTestSignerAnchor.binding = sha256.Sum256( + []byte("keep-core/real-cgo/native-signer-anchor-binding/v1"), + ) + } + publicKey := realCgoTestSignerAnchor.privateKey.Public().(ed25519.PublicKey) + publicKeySPKI, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + publicKeySPKIHash := sha256.Sum256(publicKeySPKI) + t.Setenv( + "TBTC_SIGNER_STATE_ANCHOR_BINDING_HASH", + realCgoTestHex32(realCgoTestSignerAnchor.binding), + ) + t.Setenv( + "TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY", + realCgoTestHex32([32]byte(publicKey)), + ) + t.Setenv( + "TBTC_SIGNER_STATE_ANCHOR_RESPONSE_PUBLIC_KEY_SPKI_SHA256", + realCgoTestHex32(publicKeySPKIHash), + ) + t.Setenv("TBTC_SIGNER_STATE_WITNESS_MAX_RECORDS", "4096") + t.Setenv("TBTC_SIGNER_STATE_WITNESS_ROTATION_THRESHOLD_RECORDS", "1024") + + tip, err := ReadNativeTBTCSignerStateWitnessTip() + skipFrostUnavailable(t, "state-witness tip", err) + if err != nil { + t.Fatalf("cannot read real-cgo signer state tip: %v", err) + } + if tip.AnchorBindingHash == [32]byte{} { + acknowledgement, err := realCgoTestSignerAnchorAcknowledgement( + *tip, + 1, + 1, + [32]byte{}, + ) + if err != nil { + t.Fatal(err) + } + if _, err := + AcknowledgeNativeTBTCSignerStateWitnessCheckpoint( + acknowledgement, + ); err != nil { + t.Fatalf("cannot install initial real-cgo test anchor: %v", err) + } + tip, err = ReadNativeTBTCSignerStateWitnessTip() + if err != nil { + t.Fatal(err) + } + } + if tip.AnchorBindingHash != realCgoTestSignerAnchor.binding || + tip.AnchorServiceEpoch != 1 || tip.AnchorRevision == 0 { + t.Fatalf("real-cgo signer has an unexpected persisted test anchor: %+v", tip) + } + realCgoTestSignerAnchor.latest = *tip + + barrier := &globalNativeTBTCSignerStateAnchorBarrier + barrier.mutex.Lock() + installed := barrier.installed + if installed && barrier.expectedAnchorBindingHash != + realCgoTestSignerAnchor.binding { + barrier.mutex.Unlock() + t.Fatal("another test installed a different native signer anchor") + } + barrier.mutex.Unlock() + if installed { + return + } + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: tip, + ExpectedAnchorBindingHash: realCgoTestSignerAnchor.binding, + MinimumAnchorServiceEpoch: 1, + ReadTip: ReadNativeTBTCSignerStateWitnessTip, + Committer: &realCgoTestSignerAnchorCommitter{}, + Timeout: 15 * time.Second, + }, + ); err != nil { + t.Fatalf("cannot install real-cgo test signer anchor barrier: %v", err) + } +} + +func realCgoTestSignerAnchorAcknowledgement( + tip NativeTBTCSignerStateWitnessTip, + serviceEpoch uint64, + revision uint64, + previousEventRoot [32]byte, +) ([]byte, error) { + if serviceEpoch == 0 || revision == 0 || + tip.StoreFingerprint == [32]byte{} || + tip.StateCommitment == [32]byte{} { + return nil, fmt.Errorf("real-cgo test acknowledgement input is invalid") + } + operationID := sha256.Sum256(append( + []byte("real-cgo-test-anchor-operation/v1\x00"), + tip.StateCommitment[:]..., + )) + transitionDigest := sha256.Sum256(append( + []byte("real-cgo-test-anchor-transition/v1\x00"), + operationID[:]..., + )) + requestDigest := sha256.Sum256(append( + []byte("real-cgo-test-anchor-request/v1\x00"), + operationID[:]..., + )) + nonce := sha256.Sum256(append( + []byte("real-cgo-test-anchor-nonce/v1\x00"), + transitionDigest[:]..., + )) + committedAt := uint64(time.Now().UnixMilli()) + expiresAt := committedAt + uint64((20*time.Second)/time.Millisecond) + eventRoot := realCgoTestSignerAnchorEventRoot( + realCgoTestSignerAnchor.binding, + serviceEpoch, + revision, + previousEventRoot, + requestDigest, + nonce, + tip, + operationID, + transitionDigest, + committedAt, + expiresAt, + ) + signingDigest := realCgoTestSignerAnchorSigningDigest( + realCgoTestSignerAnchor.binding, + requestDigest, + nonce, + serviceEpoch, + revision, + previousEventRoot, + eventRoot, + tip, + operationID, + transitionDigest, + committedAt, + expiresAt, + ) + signature := ed25519.Sign( + realCgoTestSignerAnchor.privateKey, + signingDigest[:], + ) + wire := struct { + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + RequestDigest string `json:"requestDigest"` + Nonce string `json:"nonce"` + Status string `json:"status"` + ServiceEpoch string `json:"serviceEpoch"` + Revision string `json:"revision"` + PreviousEventRoot string `json:"previousEventRoot"` + EventRoot string `json:"eventRoot"` + Checkpoint struct { + StoreFingerprint string `json:"storeFingerprint"` + Generation string `json:"generation"` + PreviousStateCommitment string `json:"previousStateCommitment"` + StateImageDigest string `json:"stateImageDigest"` + StateCommitment string `json:"stateCommitment"` + } `json:"checkpoint"` + OperationID string `json:"operationID"` + TransitionDigest string `json:"transitionDigest"` + CommittedAtUnixMs string `json:"committedAtUnixMs"` + ExpiresAtUnixMs string `json:"expiresAtUnixMs"` + Signature string `json:"signature"` + }{ + Schema: "tbtc-signer-state-witness-checkpoint-ack/v1", + BindingHash: realCgoTestHex32(realCgoTestSignerAnchor.binding), + RequestDigest: realCgoTestHex32(requestDigest), + Nonce: realCgoTestHex32(nonce), + Status: "applied", + ServiceEpoch: strconv.FormatUint(serviceEpoch, 10), + Revision: strconv.FormatUint(revision, 10), + PreviousEventRoot: realCgoTestHex32(previousEventRoot), + EventRoot: realCgoTestHex32(eventRoot), + OperationID: realCgoTestHex32(operationID), + TransitionDigest: realCgoTestHex32(transitionDigest), + CommittedAtUnixMs: strconv.FormatUint(committedAt, 10), + ExpiresAtUnixMs: strconv.FormatUint(expiresAt, 10), + Signature: "0x" + hex.EncodeToString(signature), + } + wire.Checkpoint.StoreFingerprint = + realCgoTestHex32(tip.StoreFingerprint) + wire.Checkpoint.Generation = strconv.FormatUint(tip.Generation, 10) + wire.Checkpoint.PreviousStateCommitment = + realCgoTestHex32(tip.PreviousStateCommitment) + wire.Checkpoint.StateImageDigest = + realCgoTestHex32(tip.StateImageDigest) + wire.Checkpoint.StateCommitment = + realCgoTestHex32(tip.StateCommitment) + return json.Marshal(wire) +} + +func realCgoTestSignerAnchorSigningDigest( + binding [32]byte, + requestDigest [32]byte, + nonce [32]byte, + serviceEpoch uint64, + revision uint64, + previousEventRoot [32]byte, + eventRoot [32]byte, + tip NativeTBTCSignerStateWitnessTip, + operationID [32]byte, + transitionDigest [32]byte, + committedAt uint64, + expiresAt uint64, +) [32]byte { + buffer := bytes.NewBuffer(nil) + buffer.WriteString("tbtc-native-signer-state-anchor-service-response/v1\x00") + buffer.Write(binding[:]) + buffer.Write(requestDigest[:]) + buffer.Write(nonce[:]) + buffer.WriteByte(1) + writeRealCgoTestUint64(buffer, serviceEpoch) + writeRealCgoTestUint64(buffer, revision) + buffer.Write(previousEventRoot[:]) + buffer.Write(eventRoot[:]) + writeRealCgoTestCheckpoint(buffer, tip) + buffer.Write(operationID[:]) + buffer.Write(transitionDigest[:]) + writeRealCgoTestUint64(buffer, committedAt) + writeRealCgoTestUint64(buffer, expiresAt) + return sha256.Sum256(buffer.Bytes()) +} + +func realCgoTestSignerAnchorEventRoot( + binding [32]byte, + serviceEpoch uint64, + revision uint64, + previousEventRoot [32]byte, + requestDigest [32]byte, + nonce [32]byte, + tip NativeTBTCSignerStateWitnessTip, + operationID [32]byte, + transitionDigest [32]byte, + committedAt uint64, + expiresAt uint64, +) [32]byte { + buffer := bytes.NewBuffer(nil) + buffer.WriteString("tbtc-native-signer-state-anchor-event/v1\x00") + buffer.Write(binding[:]) + writeRealCgoTestUint64(buffer, serviceEpoch) + writeRealCgoTestUint64(buffer, revision) + buffer.Write(previousEventRoot[:]) + buffer.Write(requestDigest[:]) + buffer.Write(nonce[:]) + buffer.WriteByte(1) + writeRealCgoTestCheckpoint(buffer, tip) + buffer.Write(operationID[:]) + buffer.Write(transitionDigest[:]) + writeRealCgoTestUint64(buffer, committedAt) + writeRealCgoTestUint64(buffer, expiresAt) + return sha256.Sum256(buffer.Bytes()) +} + +func writeRealCgoTestCheckpoint( + buffer *bytes.Buffer, + tip NativeTBTCSignerStateWitnessTip, +) { + buffer.Write(tip.StoreFingerprint[:]) + writeRealCgoTestUint64(buffer, tip.Generation) + buffer.Write(tip.PreviousStateCommitment[:]) + buffer.Write(tip.StateImageDigest[:]) + buffer.Write(tip.StateCommitment[:]) +} + +func writeRealCgoTestUint64(buffer *bytes.Buffer, value uint64) { + var encoded [8]byte + binary.BigEndian.PutUint64(encoded[:], value) + buffer.Write(encoded[:]) +} + +func realCgoTestHex32(value [32]byte) string { + return "0x" + hex.EncodeToString(value[:]) +} diff --git a/pkg/frost/signing/native_tbtc_signer_init_config.go b/pkg/frost/signing/native_tbtc_signer_init_config.go index 725c2942d3..bd39d082ac 100644 --- a/pkg/frost/signing/native_tbtc_signer_init_config.go +++ b/pkg/frost/signing/native_tbtc_signer_init_config.go @@ -1,5 +1,11 @@ package signing +import ( + "encoding/json" + "fmt" + "sync" +) + // TBTCSignerInitConfigPathEnv optionally points at a JSON file holding the // tbtc-signer init-time operational configuration. When set, the // configuration is installed via frost_tbtc_init_signer_config during native @@ -29,3 +35,110 @@ type NativeTBTCSignerInitConfigResult struct { ConfigFingerprint string `json:"config_fingerprint"` ConfiguredKeyCount uint32 `json:"configured_key_count"` } + +// NativeTBTCSignerInstalledStateAnchorConfig is the exact anchor-sensitive +// subset of the JSON successfully installed into Rust. Production activation +// compares it with the signed manifest instead of re-reading mutable +// environment variables or trusting a file that may have changed after init. +type NativeTBTCSignerInstalledStateAnchorConfig struct { + BindingHash [32]byte + ResponsePublicKey [32]byte + ResponsePublicKeySPKISHA256 [32]byte + WitnessMaximumRecords uint64 + WitnessRotationThresholdRecords uint64 + ConfigFingerprint string +} + +var nativeTBTCSignerInstalledStateAnchorConfig struct { + sync.RWMutex + value *NativeTBTCSignerInstalledStateAnchorConfig +} + +func recordNativeTBTCSignerInstalledStateAnchorConfig( + configJSON []byte, + configFingerprint string, +) error { + wire := struct { + StateWitnessMaximumRecords *uint64 `json:"state_witness_max_records"` + StateAnchorBindingHash *string `json:"state_anchor_binding_hash"` + StateAnchorResponsePublicKey *string `json:"state_anchor_response_public_key"` + StateAnchorResponsePublicKeySPKISHA256 *string `json:"state_anchor_response_public_key_spki_sha256"` + StateWitnessRotationThresholdRecords *uint64 `json:"state_witness_rotation_threshold_records"` + }{} + if err := json.Unmarshal(configJSON, &wire); err != nil { + return fmt.Errorf("cannot decode installed signer anchor configuration: %w", err) + } + allAbsent := wire.StateWitnessMaximumRecords == nil && + wire.StateAnchorBindingHash == nil && + wire.StateAnchorResponsePublicKey == nil && + wire.StateAnchorResponsePublicKeySPKISHA256 == nil && + wire.StateWitnessRotationThresholdRecords == nil + if allAbsent { + return nil + } + if wire.StateWitnessMaximumRecords == nil || + wire.StateAnchorBindingHash == nil || + wire.StateAnchorResponsePublicKey == nil || + wire.StateAnchorResponsePublicKeySPKISHA256 == nil || + wire.StateWitnessRotationThresholdRecords == nil || + configFingerprint == "" { + return fmt.Errorf("installed signer anchor configuration is incomplete") + } + bindingHash, err := decodeNativeTBTCSignerStoreBytes32( + *wire.StateAnchorBindingHash, + ) + if err != nil { + return fmt.Errorf("installed signer anchor binding hash is invalid: %w", err) + } + responsePublicKey, err := decodeNativeTBTCSignerStoreBytes32( + *wire.StateAnchorResponsePublicKey, + ) + if err != nil { + return fmt.Errorf("installed signer anchor response key is invalid: %w", err) + } + responseSPKIHash, err := decodeNativeTBTCSignerStoreBytes32( + *wire.StateAnchorResponsePublicKeySPKISHA256, + ) + if err != nil { + return fmt.Errorf("installed signer anchor response SPKI hash is invalid: %w", err) + } + maximum := *wire.StateWitnessMaximumRecords + threshold := *wire.StateWitnessRotationThresholdRecords + if maximum < 2 || maximum > 1_000_000 || threshold < 2 || + threshold > maximum-2 { + return fmt.Errorf("installed signer witness geometry is invalid") + } + value := &NativeTBTCSignerInstalledStateAnchorConfig{ + BindingHash: bindingHash, + ResponsePublicKey: responsePublicKey, + ResponsePublicKeySPKISHA256: responseSPKIHash, + WitnessMaximumRecords: maximum, + WitnessRotationThresholdRecords: threshold, + ConfigFingerprint: configFingerprint, + } + nativeTBTCSignerInstalledStateAnchorConfig.Lock() + defer nativeTBTCSignerInstalledStateAnchorConfig.Unlock() + if nativeTBTCSignerInstalledStateAnchorConfig.value != nil && + *nativeTBTCSignerInstalledStateAnchorConfig.value != *value { + return fmt.Errorf("installed signer anchor configuration changed") + } + nativeTBTCSignerInstalledStateAnchorConfig.value = value + return nil +} + +// ReadInstalledNativeTBTCSignerStateAnchorConfig returns only material from +// the exact config bytes accepted by Rust. A nil result means the signer used +// transitional environment fallback and production anchor activation must +// fail closed. +func ReadInstalledNativeTBTCSignerStateAnchorConfig() ( + *NativeTBTCSignerInstalledStateAnchorConfig, + error, +) { + nativeTBTCSignerInstalledStateAnchorConfig.RLock() + defer nativeTBTCSignerInstalledStateAnchorConfig.RUnlock() + if nativeTBTCSignerInstalledStateAnchorConfig.value == nil { + return nil, fmt.Errorf("native signer anchor configuration was not installed") + } + copy := *nativeTBTCSignerInstalledStateAnchorConfig.value + return ©, nil +} diff --git a/pkg/frost/signing/native_tbtc_signer_init_config_test.go b/pkg/frost/signing/native_tbtc_signer_init_config_test.go new file mode 100644 index 0000000000..03072904c8 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_init_config_test.go @@ -0,0 +1,95 @@ +package signing + +import ( + "strings" + "testing" +) + +func TestRecordNativeTBTCSignerInstalledStateAnchorConfig(t *testing.T) { + resetInstalledNativeTBTCSignerStateAnchorConfigForTest() + t.Cleanup(resetInstalledNativeTBTCSignerStateAnchorConfigForTest) + + hash := func(value string) string { + return "0x" + strings.Repeat(value, 64) + } + config := []byte(`{ + "state_witness_max_records": 1024, + "state_anchor_binding_hash": "` + hash("1") + `", + "state_anchor_response_public_key": "` + hash("2") + `", + "state_anchor_response_public_key_spki_sha256": "` + hash("3") + `", + "state_witness_rotation_threshold_records": 128 + }`) + if err := recordNativeTBTCSignerInstalledStateAnchorConfig( + config, + "config-fingerprint", + ); err != nil { + t.Fatalf("valid installed anchor config was rejected: %v", err) + } + result, err := ReadInstalledNativeTBTCSignerStateAnchorConfig() + if err != nil { + t.Fatal(err) + } + if result.BindingHash[0] != 0x11 || + result.ResponsePublicKey[0] != 0x22 || + result.ResponsePublicKeySPKISHA256[0] != 0x33 || + result.WitnessMaximumRecords != 1024 || + result.WitnessRotationThresholdRecords != 128 || + result.ConfigFingerprint != "config-fingerprint" { + t.Fatalf("unexpected installed anchor config: %+v", result) + } + if err := recordNativeTBTCSignerInstalledStateAnchorConfig( + config, + "config-fingerprint", + ); err != nil { + t.Fatalf("identical config reinstall was rejected: %v", err) + } +} + +func TestRecordNativeTBTCSignerInstalledStateAnchorConfigRejectsPartialOrChanged( + t *testing.T, +) { + resetInstalledNativeTBTCSignerStateAnchorConfigForTest() + t.Cleanup(resetInstalledNativeTBTCSignerStateAnchorConfigForTest) + + if err := recordNativeTBTCSignerInstalledStateAnchorConfig( + []byte(`{"state_witness_max_records":1024}`), + "config-fingerprint", + ); err == nil { + t.Fatal("partial installed anchor config was accepted") + } + + hash := func(value string) string { + return "0x" + strings.Repeat(value, 64) + } + valid := []byte(`{ + "state_witness_max_records": 1024, + "state_anchor_binding_hash": "` + hash("1") + `", + "state_anchor_response_public_key": "` + hash("2") + `", + "state_anchor_response_public_key_spki_sha256": "` + hash("3") + `", + "state_witness_rotation_threshold_records": 128 + }`) + if err := recordNativeTBTCSignerInstalledStateAnchorConfig( + valid, + "config-fingerprint", + ); err != nil { + t.Fatal(err) + } + changed := []byte(strings.Replace( + string(valid), + `"state_witness_max_records": 1024`, + `"state_witness_max_records": 2048`, + 1, + )) + if err := recordNativeTBTCSignerInstalledStateAnchorConfig( + changed, + "config-fingerprint-2", + ); err == nil { + t.Fatal("conflicting installed anchor config was accepted") + } +} + +func resetInstalledNativeTBTCSignerStateAnchorConfigForTest() { + nativeTBTCSignerInstalledStateAnchorConfig.Lock() + defer nativeTBTCSignerInstalledStateAnchorConfig.Unlock() + nativeTBTCSignerInstalledStateAnchorConfig.value = nil +} diff --git a/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go b/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go index 4ccdb0dafe..30ce791a33 100644 --- a/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go +++ b/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go @@ -21,6 +21,15 @@ typedef struct { } TbtcSignerInventoryResult; typedef TbtcSignerInventoryResult (*tbtc_retained_key_package_inventory_fn)(void); +typedef TbtcSignerInventoryResult (*tbtc_state_witness_tip_fn)(void); +typedef TbtcSignerInventoryResult (*tbtc_acknowledge_state_witness_checkpoint_fn)( + const uint8_t* request_ptr, + size_t request_len +); +typedef TbtcSignerInventoryResult (*tbtc_recover_state_witness_checkpoint_fn)( + const uint8_t* request_ptr, + size_t request_len +); typedef TbtcSignerInventoryResult (*tbtc_state_witness_proof_fn)( const uint8_t* request_ptr, size_t request_len @@ -47,6 +56,48 @@ static TbtcSignerInventoryResult tbtc_signer_retained_key_package_inventory(void return operation(); } +static TbtcSignerInventoryResult tbtc_signer_state_witness_tip(void) { + tbtc_state_witness_tip_fn operation = + (tbtc_state_witness_tip_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_state_witness_tip" + ); + if (operation == NULL) { + return unavailable_tbtc_signer_inventory_result(); + } + return operation(); +} + +static TbtcSignerInventoryResult tbtc_signer_acknowledge_state_witness_checkpoint( + const uint8_t* request_ptr, + size_t request_len +) { + tbtc_acknowledge_state_witness_checkpoint_fn operation = + (tbtc_acknowledge_state_witness_checkpoint_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_acknowledge_state_witness_checkpoint" + ); + if (operation == NULL) { + return unavailable_tbtc_signer_inventory_result(); + } + return operation(request_ptr, request_len); +} + +static TbtcSignerInventoryResult tbtc_signer_recover_state_witness_checkpoint( + const uint8_t* request_ptr, + size_t request_len +) { + tbtc_recover_state_witness_checkpoint_fn operation = + (tbtc_recover_state_witness_checkpoint_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_recover_state_witness_checkpoint" + ); + if (operation == NULL) { + return unavailable_tbtc_signer_inventory_result(); + } + return operation(request_ptr, request_len); +} + static TbtcSignerInventoryResult tbtc_signer_state_witness_proof( const uint8_t* request_ptr, size_t request_len @@ -101,6 +152,115 @@ func ReadNativeTBTCSignerRetainedKeyPackageInventory() ( return inventory, nil } +// ReadNativeTBTCSignerStateWitnessTip reads the constant-size durable state +// checkpoint used by the request/output barrier. A stale native library +// without frost_tbtc_state_witness_tip fails closed. +func ReadNativeTBTCSignerStateWitnessTip() ( + *NativeTBTCSignerStateWitnessTip, + error, +) { + if err := ensureTBTCSignerABICompatible(); err != nil { + return nil, err + } + payload, err := parseNativeTBTCSignerInventoryResult( + "StateWitnessTip", + C.tbtc_signer_state_witness_tip(), + ) + if err != nil { + return nil, err + } + tip, err := DecodeNativeTBTCSignerStateWitnessTip(payload) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "StateWitnessTip", + err.Error(), + ) + } + return tip, nil +} + +// AcknowledgeNativeTBTCSignerStateWitnessCheckpoint installs the exact signed +// remote CAS response into Rust's descriptor-bound anchor metadata. This is an +// internal half of the output barrier and deliberately bypasses the +// request-taking operation guard to avoid recursion. +func AcknowledgeNativeTBTCSignerStateWitnessCheckpoint( + signedAcknowledgementJSON []byte, +) (*NativeTBTCSignerStateWitnessCheckpointAcknowledgementResult, error) { + if err := ensureTBTCSignerABICompatible(); err != nil { + return nil, err + } + if len(signedAcknowledgementJSON) == 0 || + len(signedAcknowledgementJSON) > 64*1024 { + return nil, buildTaggedTBTCSignerOperationError( + "AcknowledgeStateWitnessCheckpoint", + "signed acknowledgement size is invalid", + ) + } + requestPointer := C.CBytes(signedAcknowledgementJSON) + defer func() { + zeroBytes(unsafe.Slice((*byte)(requestPointer), len(signedAcknowledgementJSON))) + C.free(requestPointer) + }() + payload, err := parseNativeTBTCSignerInventoryResult( + "AcknowledgeStateWitnessCheckpoint", + C.tbtc_signer_acknowledge_state_witness_checkpoint( + (*C.uint8_t)(requestPointer), + C.size_t(len(signedAcknowledgementJSON)), + ), + ) + if err != nil { + return nil, err + } + result, err := + DecodeNativeTBTCSignerStateWitnessCheckpointAcknowledgementResult(payload) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "AcknowledgeStateWitnessCheckpoint", + err.Error(), + ) + } + return result, nil +} + +func RecoverNativeTBTCSignerStateWitnessCheckpoint( + exactReadResponseJSON []byte, +) (*NativeTBTCSignerStateWitnessCheckpointRecoveryResult, error) { + if err := ensureTBTCSignerABICompatible(); err != nil { + return nil, err + } + if len(exactReadResponseJSON) == 0 || len(exactReadResponseJSON) > 256*1024 { + return nil, buildTaggedTBTCSignerOperationError( + "RecoverStateWitnessCheckpoint", + "signed read recovery response size is invalid", + ) + } + requestPointer := C.CBytes(exactReadResponseJSON) + defer func() { + zeroBytes(unsafe.Slice((*byte)(requestPointer), len(exactReadResponseJSON))) + C.free(requestPointer) + }() + payload, err := parseNativeTBTCSignerInventoryResult( + "RecoverStateWitnessCheckpoint", + C.tbtc_signer_recover_state_witness_checkpoint( + (*C.uint8_t)(requestPointer), + C.size_t(len(exactReadResponseJSON)), + ), + ) + if err != nil { + return nil, err + } + result, err := DecodeNativeTBTCSignerStateWitnessCheckpointRecoveryResult( + payload, + ) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "RecoverStateWitnessCheckpoint", + err.Error(), + ) + } + return result, nil +} + func ReadNativeTBTCSignerStateWitnessProof( request *NativeTBTCSignerStateWitnessProofRequest, ) (*NativeTBTCSignerStateWitnessProof, error) { diff --git a/pkg/frost/signing/native_tbtc_signer_inventory_default.go b/pkg/frost/signing/native_tbtc_signer_inventory_default.go index f7c633240b..195da32d0c 100644 --- a/pkg/frost/signing/native_tbtc_signer_inventory_default.go +++ b/pkg/frost/signing/native_tbtc_signer_inventory_default.go @@ -14,6 +14,34 @@ func ReadNativeTBTCSignerRetainedKeyPackageInventory() ( ) } +func ReadNativeTBTCSignerStateWitnessTip() ( + *NativeTBTCSignerStateWitnessTip, + error, +) { + return nil, fmt.Errorf( + "%w: tbtc-signer bridge operation [StateWitnessTip] is unavailable in this build", + ErrNativeCryptographyUnavailable, + ) +} + +func AcknowledgeNativeTBTCSignerStateWitnessCheckpoint( + signedAcknowledgementJSON []byte, +) (*NativeTBTCSignerStateWitnessCheckpointAcknowledgementResult, error) { + return nil, fmt.Errorf( + "%w: tbtc-signer bridge operation [AcknowledgeStateWitnessCheckpoint] is unavailable in this build", + ErrNativeCryptographyUnavailable, + ) +} + +func RecoverNativeTBTCSignerStateWitnessCheckpoint( + exactReadResponseJSON []byte, +) (*NativeTBTCSignerStateWitnessCheckpointRecoveryResult, error) { + return nil, fmt.Errorf( + "%w: tbtc-signer bridge operation [RecoverStateWitnessCheckpoint] is unavailable in this build", + ErrNativeCryptographyUnavailable, + ) +} + func ReadNativeTBTCSignerStateWitnessProof( request *NativeTBTCSignerStateWitnessProofRequest, ) (*NativeTBTCSignerStateWitnessProof, error) { diff --git a/pkg/frost/signing/native_tbtc_signer_readback_real_cgo_frost_native_test.go b/pkg/frost/signing/native_tbtc_signer_readback_real_cgo_frost_native_test.go index b7cfa57072..0be8dd480d 100644 --- a/pkg/frost/signing/native_tbtc_signer_readback_real_cgo_frost_native_test.go +++ b/pkg/frost/signing/native_tbtc_signer_readback_real_cgo_frost_native_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -// TestRealCgoSignerReadinessReadbacks exercises every ABI-4.1 readiness +// TestRealCgoSignerReadinessReadbacks exercises every ABI-4.2 readiness // readback against the actually linked libfrost_tbtc. Pure Go decoder tests // cannot detect a stale library that reports a compatible-looking ABI version // while omitting a symbol or emitting a different transcript. diff --git a/pkg/frost/signing/native_tbtc_signer_secure_config.go b/pkg/frost/signing/native_tbtc_signer_secure_config.go new file mode 100644 index 0000000000..59419cace3 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_secure_config.go @@ -0,0 +1,89 @@ +package signing + +import ( + "fmt" + "io" + "os" + "strings" + "syscall" + + "golang.org/x/sys/unix" +) + +const nativeTBTCSignerInitConfigMaximumBytes int64 = 1024 * 1024 + +// readSecureNativeTBTCSignerInitConfig opens the operator-selected config +// without following the final path component and validates the opened +// descriptor itself. The config can carry state_key_command, so treating it as +// ordinary mutable configuration would permit local command substitution +// between launcher checks and signer initialization. +func readSecureNativeTBTCSignerInitConfig(path string) ([]byte, error) { + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("native signer init config path is empty") + } + fd, err := unix.Open( + path, + unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0, + ) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), path) + if file == nil { + _ = unix.Close(fd) + return nil, fmt.Errorf( + "cannot wrap native signer init config descriptor", + ) + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("native signer init config is not a regular file") + } + if info.Mode().Perm() != 0600 { + return nil, fmt.Errorf( + "native signer init config permissions [%o] are not 0600", + info.Mode().Perm(), + ) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return nil, fmt.Errorf("cannot determine native signer init config owner") + } + if stat.Uid != uint32(os.Geteuid()) { + return nil, fmt.Errorf( + "native signer init config is owned by uid [%d], expected [%d]", + stat.Uid, + os.Geteuid(), + ) + } + if info.Size() <= 0 || + info.Size() > nativeTBTCSignerInitConfigMaximumBytes { + return nil, fmt.Errorf("native signer init config size is invalid") + } + result, err := io.ReadAll(io.LimitReader( + file, + nativeTBTCSignerInitConfigMaximumBytes+1, + )) + if err != nil { + zeroNativeTBTCSignerConfigBytes(result) + return nil, err + } + if len(result) == 0 || + int64(len(result)) > nativeTBTCSignerInitConfigMaximumBytes { + zeroNativeTBTCSignerConfigBytes(result) + return nil, fmt.Errorf("native signer init config size is invalid") + } + return result, nil +} + +func zeroNativeTBTCSignerConfigBytes(value []byte) { + for index := range value { + value[index] = 0 + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_secure_config_test.go b/pkg/frost/signing/native_tbtc_signer_secure_config_test.go new file mode 100644 index 0000000000..a29d332c6f --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_secure_config_test.go @@ -0,0 +1,74 @@ +package signing + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestReadSecureNativeTBTCSignerInitConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "signer-config.json") + expected := []byte(`{"state_path":"/secure/state"}`) + if err := os.WriteFile(path, expected, 0600); err != nil { + t.Fatal(err) + } + actual, err := readSecureNativeTBTCSignerInitConfig(path) + if err != nil { + t.Fatalf("cannot read secure native signer config: %v", err) + } + if !bytes.Equal(actual, expected) { + t.Fatal("secure native signer config bytes changed") + } +} + +func TestReadSecureNativeTBTCSignerInitConfigRejectsUnsafeFiles(t *testing.T) { + t.Run("symlink", func(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "target.json") + link := filepath.Join(directory, "signer-config.json") + if err := os.WriteFile(target, []byte(`{}`), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := readSecureNativeTBTCSignerInitConfig(link); err == nil { + t.Fatal("symlinked native signer init config was accepted") + } + }) + + t.Run("group readable", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "signer-config.json") + if err := os.WriteFile(path, []byte(`{}`), 0640); err != nil { + t.Fatal(err) + } + if _, err := readSecureNativeTBTCSignerInitConfig(path); err == nil { + t.Fatal("group-readable native signer init config was accepted") + } + }) + + t.Run("directory", func(t *testing.T) { + path := t.TempDir() + if err := os.Chmod(path, 0600); err != nil { + t.Fatal(err) + } + if _, err := readSecureNativeTBTCSignerInitConfig(path); err == nil { + t.Fatal("directory native signer init config was accepted") + } + }) + + t.Run("oversized", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "signer-config.json") + payload := bytes.Repeat( + []byte{'x'}, + int(nativeTBTCSignerInitConfigMaximumBytes)+1, + ) + if err := os.WriteFile(path, payload, 0600); err != nil { + t.Fatal(err) + } + if _, err := readSecureNativeTBTCSignerInitConfig(path); err == nil { + t.Fatal("oversized native signer init config was accepted") + } + }) +} diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go new file mode 100644 index 0000000000..edd332875a --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go @@ -0,0 +1,494 @@ +package signing + +import ( + "context" + "errors" + "fmt" + "sync" + "time" +) + +var ( + // ErrNativeTBTCSignerStateAnchorUnavailable marks a request-taking native + // signer call attempted before its independent state anchor was installed. + ErrNativeTBTCSignerStateAnchorUnavailable = errors.New( + "native tbtc signer state anchor is unavailable", + ) + + // ErrNativeTBTCSignerStateAnchorTerminal marks a process-terminal anchor + // failure. It intentionally does not wrap ErrNativeCryptographyUnavailable: + // callers must never route an anchor failure into a legacy implementation. + ErrNativeTBTCSignerStateAnchorTerminal = errors.New( + "native tbtc signer state anchor is terminally poisoned", + ) +) + +const defaultNativeTBTCSignerStateAnchorTimeout = 15 * time.Second + +// NativeTBTCSignerStateAnchorCommitter durably commits a transition to the +// independent linearizable anchor, installs the exact signed acknowledgement +// into Rust, and returns the resulting exact Rust tip readback. It is invoked +// while the process-global signer mutation lock is held. +type NativeTBTCSignerStateAnchorCommitter interface { + VerifyNativeTBTCSignerStateTip( + context.Context, + NativeTBTCSignerStateWitnessTip, + ) error + CommitNativeTBTCSignerStateTransition( + context.Context, + string, + NativeTBTCSignerStateWitnessTip, + NativeTBTCSignerStateWitnessTip, + ) (*NativeTBTCSignerStateWitnessTip, error) +} + +// NativeTBTCSignerStateAnchorBarrierConfig installs the process-global output +// barrier. InitialTip must have already passed startup reconciliation against +// the independent service. +type NativeTBTCSignerStateAnchorBarrierConfig struct { + InitialTip *NativeTBTCSignerStateWitnessTip + ExpectedAnchorBindingHash [32]byte + MinimumAnchorServiceEpoch uint64 + ReadTip func() (*NativeTBTCSignerStateWitnessTip, error) + Committer NativeTBTCSignerStateAnchorCommitter + Timeout time.Duration +} + +type nativeTBTCSignerStateAnchorBarrier struct { + mutex sync.Mutex + + installed bool + poisoned error + tip NativeTBTCSignerStateWitnessTip + readTip func() (*NativeTBTCSignerStateWitnessTip, error) + committer NativeTBTCSignerStateAnchorCommitter + timeout time.Duration + + expectedAnchorBindingHash [32]byte + minimumAnchorServiceEpoch uint64 +} + +var globalNativeTBTCSignerStateAnchorBarrier nativeTBTCSignerStateAnchorBarrier + +// InstallNativeTBTCSignerStateAnchorBarrier installs one immutable process +// binding. Re-installation is rejected even if identical so tests, reload +// paths, and partial startup cannot silently exchange anchor authorities. +func InstallNativeTBTCSignerStateAnchorBarrier( + config NativeTBTCSignerStateAnchorBarrierConfig, +) error { + if config.InitialTip == nil || config.ReadTip == nil || + config.Committer == nil || + config.ExpectedAnchorBindingHash == [32]byte{} || + config.MinimumAnchorServiceEpoch == 0 { + return fmt.Errorf("native tbtc signer state anchor dependencies are incomplete") + } + initial := *config.InitialTip + if err := validateNativeTBTCSignerStateWitnessTip(&initial); err != nil { + return fmt.Errorf("native tbtc signer initial anchor tip is invalid: %w", err) + } + if initial.AnchorBindingHash != config.ExpectedAnchorBindingHash || + initial.AnchorServiceEpoch < config.MinimumAnchorServiceEpoch || + initial.AnchorRevision == 0 || + initial.AnchorEventRoot == [32]byte{} || + initial.AnchorAcknowledgementDigest == [32]byte{} { + return fmt.Errorf( + "native tbtc signer initial tip lacks the pinned signed anchor acknowledgement", + ) + } + timeout := config.Timeout + if timeout == 0 { + timeout = defaultNativeTBTCSignerStateAnchorTimeout + } + if timeout < time.Millisecond || timeout > time.Minute { + return fmt.Errorf("native tbtc signer state anchor timeout is invalid") + } + + barrier := &globalNativeTBTCSignerStateAnchorBarrier + barrier.mutex.Lock() + defer barrier.mutex.Unlock() + if barrier.installed { + return fmt.Errorf("native tbtc signer state anchor is already installed") + } + readback, err := config.ReadTip() + if err != nil { + return fmt.Errorf("cannot read native tbtc signer initial state tip: %w", err) + } + if readback == nil || *readback != initial { + return fmt.Errorf("native tbtc signer initial state tip changed before installation") + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if err := config.Committer.VerifyNativeTBTCSignerStateTip( + ctx, + initial, + ); err != nil { + return fmt.Errorf( + "cannot authenticate native tbtc signer initial remote anchor: %w", + err, + ) + } + + barrier.installed = true + barrier.tip = initial + barrier.readTip = config.ReadTip + barrier.committer = config.Committer + barrier.timeout = timeout + barrier.expectedAnchorBindingHash = config.ExpectedAnchorBindingHash + barrier.minimumAnchorServiceEpoch = config.MinimumAnchorServiceEpoch + return nil +} + +// nativeTBTCSignerStateAnchorLease serializes the native call, its post-call +// readback, remote CAS, Rust acknowledgement install, and final readback. +type nativeTBTCSignerStateAnchorLease struct { + barrier *nativeTBTCSignerStateAnchorBarrier + operation string + expected NativeTBTCSignerStateWitnessTip + completed bool +} + +// executeNativeTBTCSignerStateAnchoredOutput is the single source-to-sink +// ordering seam for request-taking FFI calls. invoke may populate an opaque +// Rust-owned result, but releaseOutput (which may copy/parse it) cannot run +// until the remote commit and Rust acknowledgement readback complete. Every +// pre-release failure invokes discard exactly once. +func executeNativeTBTCSignerStateAnchoredOutput( + operation string, + invoke func(), + releaseOutput func() ([]byte, error), + discard func(), +) ([]byte, error) { + if invoke == nil || releaseOutput == nil || discard == nil { + return nil, fmt.Errorf("native tbtc signer output barrier callbacks are incomplete") + } + lease, err := beginNativeTBTCSignerStateAnchoredOperation(operation) + if err != nil { + return nil, err + } + invoked := false + released := false + defer func() { + if invoked && !released { + discard() + } + lease.release() + }() + + invoked = true + invoke() + if err := lease.commit(); err != nil { + return nil, err + } + released = true + return releaseOutput() +} + +func beginNativeTBTCSignerStateAnchoredOperation( + operation string, +) (*nativeTBTCSignerStateAnchorLease, error) { + barrier := &globalNativeTBTCSignerStateAnchorBarrier + barrier.mutex.Lock() + if !barrier.installed { + barrier.mutex.Unlock() + return nil, fmt.Errorf( + "%w: request-taking operation [%s] is blocked", + ErrNativeTBTCSignerStateAnchorUnavailable, + operation, + ) + } + if barrier.poisoned != nil { + err := barrier.poisoned + barrier.mutex.Unlock() + return nil, fmt.Errorf("%w: %v", ErrNativeTBTCSignerStateAnchorTerminal, err) + } + + readback, err := barrier.readTip() + if err != nil { + return nil, poisonAndUnlockNativeTBTCSignerStateAnchor( + barrier, + fmt.Errorf("cannot read pre-operation state tip: %w", err), + ) + } + if readback == nil || *readback != barrier.tip { + return nil, poisonAndUnlockNativeTBTCSignerStateAnchor( + barrier, + fmt.Errorf("pre-operation state tip differs from the committed process tip"), + ) + } + ctx, cancel := context.WithTimeout(context.Background(), barrier.timeout) + defer cancel() + if err := barrier.committer.VerifyNativeTBTCSignerStateTip( + ctx, + *readback, + ); err != nil { + return nil, poisonAndUnlockNativeTBTCSignerStateAnchor( + barrier, + fmt.Errorf("cannot authenticate pre-operation remote state anchor: %w", err), + ) + } + + return &nativeTBTCSignerStateAnchorLease{ + barrier: barrier, + operation: operation, + expected: *readback, + }, nil +} + +// commit observes the Rust tip after the native call even when that call +// returned an application error. If Rust advanced, it blocks until the exact +// candidate and acknowledgement are durable outside the signer and read back +// from Rust. The caller may parse or copy its native response only after this +// method succeeds. +func (lease *nativeTBTCSignerStateAnchorLease) commit() error { + if lease == nil || lease.barrier == nil || lease.completed { + return fmt.Errorf("native tbtc signer state anchor lease is invalid") + } + barrier := lease.barrier + candidate, err := barrier.readTip() + if err != nil { + return lease.poison(fmt.Errorf("cannot read post-operation state tip: %w", err)) + } + if candidate == nil { + return lease.poison(fmt.Errorf("post-operation state tip is nil")) + } + if err := validateNativeTBTCSignerStateTransition( + &lease.expected, + candidate, + ); err != nil { + return lease.poison(err) + } + + if *candidate == lease.expected { + barrier.tip = *candidate + lease.completed = true + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), barrier.timeout) + defer cancel() + acknowledged, err := barrier.committer.CommitNativeTBTCSignerStateTransition( + ctx, + lease.operation, + lease.expected, + *candidate, + ) + if err != nil { + return lease.poison(fmt.Errorf( + "cannot commit native tbtc signer state transition: %w", + err, + )) + } + if acknowledged == nil { + return lease.poison(fmt.Errorf("state anchor returned a nil acknowledged tip")) + } + if !sameNativeTBTCSignerStateCheckpoint(acknowledged, candidate) { + return lease.poison(fmt.Errorf( + "state anchor acknowledged a different native signer checkpoint", + )) + } + if err := validateNativeTBTCSignerAcknowledgedTip( + candidate, + acknowledged, + barrier.expectedAnchorBindingHash, + barrier.minimumAnchorServiceEpoch, + ); err != nil { + return lease.poison(err) + } + + finalReadback, err := barrier.readTip() + if err != nil { + return lease.poison(fmt.Errorf( + "cannot read acknowledged native tbtc signer state tip: %w", + err, + )) + } + if finalReadback == nil || *finalReadback != *acknowledged { + return lease.poison(fmt.Errorf( + "native signer did not durably install the exact anchor acknowledgement", + )) + } + + barrier.tip = *acknowledged + lease.completed = true + return nil +} + +func (lease *nativeTBTCSignerStateAnchorLease) poison(cause error) error { + if lease == nil || lease.barrier == nil { + return fmt.Errorf("%w: %v", ErrNativeTBTCSignerStateAnchorTerminal, cause) + } + lease.barrier.poisoned = cause + lease.completed = true + return fmt.Errorf("%w: %v", ErrNativeTBTCSignerStateAnchorTerminal, cause) +} + +func (lease *nativeTBTCSignerStateAnchorLease) release() { + if lease == nil || lease.barrier == nil { + return + } + if !lease.completed { + lease.barrier.poisoned = fmt.Errorf( + "native signer operation [%s] escaped without anchor completion", + lease.operation, + ) + } + lease.barrier.mutex.Unlock() + lease.barrier = nil +} + +func poisonAndUnlockNativeTBTCSignerStateAnchor( + barrier *nativeTBTCSignerStateAnchorBarrier, + cause error, +) error { + barrier.poisoned = cause + barrier.mutex.Unlock() + return fmt.Errorf("%w: %v", ErrNativeTBTCSignerStateAnchorTerminal, cause) +} + +func validateNativeTBTCSignerStateWitnessTip( + tip *NativeTBTCSignerStateWitnessTip, +) error { + if tip == nil || tip.Schema != NativeTBTCSignerStateWitnessTipSchema || + tip.StoreFingerprint == [32]byte{} || tip.Generation == 0 || + tip.PreviousStateCommitment == [32]byte{} || + tip.StateCommitment == [32]byte{} || + tip.WitnessBaseGeneration == 0 || + tip.WitnessBaseGeneration > tip.Generation || + tip.WitnessBaseCommitment == [32]byte{} { + return fmt.Errorf("native signer state-witness tip is incomplete") + } + computed := ComputeNativeTBTCSignerStateWitnessCommitment( + tip.StoreFingerprint, + tip.Generation, + tip.PreviousStateCommitment, + tip.StateImageDigest, + ) + if computed != tip.StateCommitment { + return fmt.Errorf("native signer state-witness tip commitment is invalid") + } + hasAnchor := tip.AnchorBindingHash != [32]byte{} + if hasAnchor != (tip.AnchorServiceEpoch != 0) || + hasAnchor != (tip.AnchorRevision != 0) || + hasAnchor != (tip.AnchorEventRoot != [32]byte{}) || + hasAnchor != (tip.AnchorAcknowledgementDigest != [32]byte{}) { + return fmt.Errorf("native signer anchor acknowledgement metadata is partial") + } + return nil +} + +func validateNativeTBTCSignerStateTransition( + expected *NativeTBTCSignerStateWitnessTip, + candidate *NativeTBTCSignerStateWitnessTip, +) error { + if err := validateNativeTBTCSignerStateWitnessTip(expected); err != nil { + return fmt.Errorf("committed native signer state tip is invalid: %w", err) + } + if err := validateNativeTBTCSignerStateWitnessTip(candidate); err != nil { + return fmt.Errorf("candidate native signer state tip is invalid: %w", err) + } + if expected.StoreFingerprint != candidate.StoreFingerprint || + candidate.Generation < expected.Generation || + candidate.WitnessBaseGeneration < expected.WitnessBaseGeneration || + candidate.WitnessBaseGeneration > expected.Generation { + return fmt.Errorf("native signer state transition has invalid store or generation bounds") + } + if candidate.WitnessBaseGeneration == expected.WitnessBaseGeneration && + candidate.WitnessBaseCommitment == expected.WitnessBaseCommitment { + // Base is unchanged. + } else if candidate.WitnessBaseGeneration == expected.Generation && + candidate.WitnessBaseCommitment == expected.StateCommitment { + // Rust may lazily rotate to the exact already-acknowledged checkpoint. + } else { + return fmt.Errorf( + "native signer witness base did not remain fixed or rotate to the committed tip", + ) + } + if candidate.Generation == expected.Generation { + if *candidate != *expected { + return fmt.Errorf("native signer state changed without advancing its generation") + } + return nil + } + if candidate.StateCommitment == expected.StateCommitment { + return fmt.Errorf("native signer state generation advanced without a new commitment") + } + // A signer operation may not forge or erase the separately persisted remote + // acknowledgement. Only the post-CAS acknowledgement call can change it. + if candidate.AnchorBindingHash != expected.AnchorBindingHash || + candidate.AnchorServiceEpoch != expected.AnchorServiceEpoch || + candidate.AnchorRevision != expected.AnchorRevision || + candidate.AnchorEventRoot != expected.AnchorEventRoot || + candidate.AnchorAcknowledgementDigest != expected.AnchorAcknowledgementDigest { + return fmt.Errorf("native signer operation changed anchor acknowledgement metadata") + } + return nil +} + +func validateNativeTBTCSignerAcknowledgedTip( + candidate *NativeTBTCSignerStateWitnessTip, + acknowledged *NativeTBTCSignerStateWitnessTip, + expectedBindingHash [32]byte, + minimumServiceEpoch uint64, +) error { + if err := validateNativeTBTCSignerStateWitnessTip(acknowledged); err != nil { + return fmt.Errorf("acknowledged native signer state tip is invalid: %w", err) + } + if acknowledged.AnchorBindingHash != expectedBindingHash || + acknowledged.AnchorServiceEpoch < minimumServiceEpoch || + acknowledged.AnchorRevision == 0 || + acknowledged.AnchorEventRoot == [32]byte{} || + acknowledged.AnchorAcknowledgementDigest == [32]byte{} { + return fmt.Errorf("native signer state acknowledgement metadata is absent") + } + if acknowledged.WitnessBaseGeneration == candidate.WitnessBaseGeneration && + acknowledged.WitnessBaseCommitment == candidate.WitnessBaseCommitment { + // Base is unchanged. + } else if acknowledged.WitnessBaseGeneration == candidate.Generation && + acknowledged.WitnessBaseCommitment == candidate.StateCommitment { + // Rust rotated exactly to the newly acknowledged checkpoint. + } else { + return fmt.Errorf( + "native signer acknowledgement rotated to an unacknowledged witness base", + ) + } + if candidate.AnchorBindingHash != [32]byte{} && + acknowledged.AnchorBindingHash != candidate.AnchorBindingHash { + return fmt.Errorf("native signer state acknowledgement binding changed") + } + if candidate.AnchorServiceEpoch != 0 { + if candidate.AnchorRevision == ^uint64(0) || + acknowledged.AnchorServiceEpoch != candidate.AnchorServiceEpoch || + acknowledged.AnchorRevision != candidate.AnchorRevision+1 { + return fmt.Errorf( + "native signer state acknowledgement did not advance by one revision in the pinned service epoch", + ) + } + } + return nil +} + +func sameNativeTBTCSignerStateCheckpoint( + left *NativeTBTCSignerStateWitnessTip, + right *NativeTBTCSignerStateWitnessTip, +) bool { + return left != nil && right != nil && + left.StoreFingerprint == right.StoreFingerprint && + left.Generation == right.Generation && + left.PreviousStateCommitment == right.PreviousStateCommitment && + left.StateImageDigest == right.StateImageDigest && + left.StateCommitment == right.StateCommitment +} + +func resetNativeTBTCSignerStateAnchorBarrierForTest() { + barrier := &globalNativeTBTCSignerStateAnchorBarrier + barrier.mutex.Lock() + defer barrier.mutex.Unlock() + barrier.installed = false + barrier.poisoned = nil + barrier.tip = NativeTBTCSignerStateWitnessTip{} + barrier.readTip = nil + barrier.committer = nil + barrier.timeout = 0 + barrier.expectedAnchorBindingHash = [32]byte{} + barrier.minimumAnchorServiceEpoch = 0 +} diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go new file mode 100644 index 0000000000..bcaa29d68e --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go @@ -0,0 +1,559 @@ +package signing + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +type testNativeTBTCSignerStateAnchorCommitter struct { + mutex sync.Mutex + err error + verifyErr error + calls int + verifyCalls int + operation string + expected NativeTBTCSignerStateWitnessTip + candidate NativeTBTCSignerStateWitnessTip + acknowledge func(NativeTBTCSignerStateWitnessTip) NativeTBTCSignerStateWitnessTip + current *NativeTBTCSignerStateWitnessTip + commitStart chan struct{} + allowCommit chan struct{} + startOnce sync.Once +} + +func (committer *testNativeTBTCSignerStateAnchorCommitter) VerifyNativeTBTCSignerStateTip( + ctx context.Context, + local NativeTBTCSignerStateWitnessTip, +) error { + committer.mutex.Lock() + defer committer.mutex.Unlock() + committer.verifyCalls++ + return committer.verifyErr +} + +func (committer *testNativeTBTCSignerStateAnchorCommitter) CommitNativeTBTCSignerStateTransition( + ctx context.Context, + operation string, + expected NativeTBTCSignerStateWitnessTip, + candidate NativeTBTCSignerStateWitnessTip, +) (*NativeTBTCSignerStateWitnessTip, error) { + if committer.commitStart != nil { + committer.startOnce.Do(func() { close(committer.commitStart) }) + } + if committer.allowCommit != nil { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-committer.allowCommit: + } + } + committer.mutex.Lock() + defer committer.mutex.Unlock() + committer.calls++ + committer.operation = operation + committer.expected = expected + committer.candidate = candidate + if committer.err != nil { + return nil, committer.err + } + acknowledged := committer.acknowledge(candidate) + *committer.current = acknowledged + return &acknowledged, nil +} + +func TestNativeTBTCSignerOutputBarrierDoesNotReleaseBeforeAcknowledgement( + t *testing.T, +) { + for _, nativeErr := range []bool{false, true} { + name := "native-success" + if nativeErr { + name = "native-error" + } + t.Run(name, func(t *testing.T) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + candidate := testNativeTBTCSignerStateWitnessTip( + 2, + initial.StateCommitment, + ) + current := initial + commitStart := make(chan struct{}) + allowCommit := make(chan struct{}) + committer := &testNativeTBTCSignerStateAnchorCommitter{ + current: ¤t, + commitStart: commitStart, + allowCommit: allowCommit, + acknowledge: func(candidate NativeTBTCSignerStateWitnessTip) NativeTBTCSignerStateWitnessTip { + candidate.AnchorServiceEpoch = 1 + candidate.AnchorRevision = 2 + candidate.AnchorEventRoot = [32]byte{13} + candidate.AnchorAcknowledgementDigest = [32]byte{14} + return candidate + }, + } + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := current + return ©, nil + }, + Committer: committer, + }, + ); err != nil { + t.Fatal(err) + } + + releaseCalled := make(chan struct{}) + returned := make(chan error, 1) + var discardCount atomic.Int32 + go func() { + payload, err := executeNativeTBTCSignerStateAnchoredOutput( + "InteractiveRound1", + func() { + current = candidate + }, + func() ([]byte, error) { + close(releaseCalled) + if nativeErr { + return nil, errors.New("native operation failed") + } + return []byte("sentinel-output"), nil + }, + func() { + discardCount.Add(1) + }, + ) + if !nativeErr && string(payload) != "sentinel-output" { + err = errors.New("sentinel output was not released") + } + returned <- err + }() + + select { + case <-commitStart: + case <-time.After(time.Second): + t.Fatal("remote commit did not start") + } + select { + case <-releaseCalled: + t.Fatal("native output was parsed before anchor acknowledgement") + default: + } + select { + case <-returned: + t.Fatal("native call returned before anchor acknowledgement") + default: + } + + close(allowCommit) + select { + case err := <-returned: + if nativeErr && (err == nil || + err.Error() != "native operation failed") { + t.Fatalf("native error was not returned after acknowledgement: %v", err) + } + if !nativeErr && err != nil { + t.Fatalf("anchored output was not released: %v", err) + } + case <-time.After(time.Second): + t.Fatal("native call did not return after acknowledgement") + } + if discardCount.Load() != 0 { + t.Fatal("successfully anchored native result was discarded") + } + }) + } +} + +func TestNativeTBTCSignerOutputBarrierDiscardsExactlyOnceOnAnchorFailure( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + candidate := testNativeTBTCSignerStateWitnessTip(2, initial.StateCommitment) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{ + current: ¤t, + err: errors.New("CAS outcome cannot be authenticated"), + } + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := current + return ©, nil + }, + Committer: committer, + }, + ); err != nil { + t.Fatal(err) + } + var releaseCount atomic.Int32 + var discardCount atomic.Int32 + _, err := executeNativeTBTCSignerStateAnchoredOutput( + "InteractiveRound2", + func() { + current = candidate + }, + func() ([]byte, error) { + releaseCount.Add(1) + return []byte("must-not-escape"), nil + }, + func() { + discardCount.Add(1) + }, + ) + if !errors.Is(err, ErrNativeTBTCSignerStateAnchorTerminal) { + t.Fatalf("anchor failure did not poison output barrier: %v", err) + } + if releaseCount.Load() != 0 || discardCount.Load() != 1 { + t.Fatalf( + "anchor failure release/discard counts are [%d/%d], want [0/1]", + releaseCount.Load(), + discardCount.Load(), + ) + } +} + +func TestNativeTBTCSignerOutputBarrierDiscardsAndPoisonsOnInvokePanic( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{ + current: ¤t, + } + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := current + return ©, nil + }, + Committer: committer, + }, + ); err != nil { + t.Fatal(err) + } + + var discardCount atomic.Int32 + func() { + defer func() { + if recover() == nil { + t.Fatal("native invoke panic did not propagate") + } + }() + _, _ = executeNativeTBTCSignerStateAnchoredOutput( + "InteractiveRound1", + func() { panic("native call panic") }, + func() ([]byte, error) { + t.Fatal("panicking native call released output") + return nil, nil + }, + func() { discardCount.Add(1) }, + ) + }() + if discardCount.Load() != 1 { + t.Fatalf( + "panicking native call discard count is [%d], want [1]", + discardCount.Load(), + ) + } + if _, err := beginNativeTBTCSignerStateAnchoredOperation( + "InteractiveRound2", + ); !errors.Is(err, ErrNativeTBTCSignerStateAnchorTerminal) { + t.Fatalf("panicking native call did not poison the barrier: %v", err) + } +} + +func TestNativeTBTCSignerStateAnchorBarrierCommitsBeforeCompletion(t *testing.T) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{ + current: ¤t, + acknowledge: func(candidate NativeTBTCSignerStateWitnessTip) NativeTBTCSignerStateWitnessTip { + candidate.AnchorBindingHash = [32]byte{10} + candidate.AnchorServiceEpoch = 1 + candidate.AnchorRevision = 2 + candidate.AnchorEventRoot = [32]byte{11} + candidate.AnchorAcknowledgementDigest = [32]byte{12} + return candidate + }, + } + readTip := func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := current + return ©, nil + } + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + ReadTip: readTip, + Committer: committer, + }, + ); err != nil { + t.Fatalf("cannot install state anchor barrier: %v", err) + } + + lease, err := beginNativeTBTCSignerStateAnchoredOperation("InteractiveRound2") + if err != nil { + t.Fatalf("cannot begin anchored operation: %v", err) + } + candidate := testNativeTBTCSignerStateWitnessTip(2, initial.StateCommitment) + current = candidate + if err := lease.commit(); err != nil { + lease.release() + t.Fatalf("cannot commit anchored operation: %v", err) + } + lease.release() + + if committer.calls != 1 || committer.operation != "InteractiveRound2" || + committer.expected != initial || committer.candidate != candidate { + t.Fatal("state transition committer did not receive the exact operation and tips") + } + if current.AnchorAcknowledgementDigest == [32]byte{} { + t.Fatal("signed acknowledgement was not installed before completion") + } + if committer.verifyCalls != 2 { + t.Fatal("startup and pre-operation authenticated remote reads were not required") + } +} + +func TestNativeTBTCSignerStateAnchorBarrierChecksTipWithoutMutation(t *testing.T) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{ + current: ¤t, + acknowledge: func(candidate NativeTBTCSignerStateWitnessTip) NativeTBTCSignerStateWitnessTip { + return candidate + }, + } + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := current + return ©, nil + }, + Committer: committer, + }, + ); err != nil { + t.Fatal(err) + } + lease, err := beginNativeTBTCSignerStateAnchoredOperation("VerifySignatureShare") + if err != nil { + t.Fatal(err) + } + if err := lease.commit(); err != nil { + lease.release() + t.Fatal(err) + } + lease.release() + if committer.calls != 0 { + t.Fatal("unchanged Rust tip unexpectedly issued a remote CAS") + } +} + +func TestNativeTBTCSignerStateAnchorBarrierPoisonsAfterCommitFailure(t *testing.T) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{ + current: ¤t, + err: errors.New("unknown CAS outcome"), + } + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := current + return ©, nil + }, + Committer: committer, + }, + ); err != nil { + t.Fatal(err) + } + + lease, err := beginNativeTBTCSignerStateAnchoredOperation("InteractiveRound1") + if err != nil { + t.Fatal(err) + } + current = testNativeTBTCSignerStateWitnessTip(2, initial.StateCommitment) + err = lease.commit() + lease.release() + if !errors.Is(err, ErrNativeTBTCSignerStateAnchorTerminal) { + t.Fatalf("commit failure did not terminally poison the barrier: %v", err) + } + if _, err := beginNativeTBTCSignerStateAnchoredOperation( + "InteractiveRound2", + ); !errors.Is(err, ErrNativeTBTCSignerStateAnchorTerminal) { + t.Fatalf("poisoned barrier allowed another operation: %v", err) + } +} + +func TestNativeTBTCSignerStateAnchorBarrierRejectsPreMigrationInitialTip( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + preMigration := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + postMigration := testNativeTBTCSignerStateWitnessTip( + 2, + preMigration.StateCommitment, + ) + committer := &testNativeTBTCSignerStateAnchorCommitter{} + err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &preMigration, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := postMigration + return ©, nil + }, + Committer: committer, + }, + ) + if err == nil { + t.Fatal("tip captured before migration-induced advancement was accepted") + } +} + +func TestNativeTBTCSignerStateAnchorBarrierRejectsUnacknowledgedInitialTip( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + initial.AnchorBindingHash = [32]byte{} + initial.AnchorServiceEpoch = 0 + initial.AnchorRevision = 0 + initial.AnchorEventRoot = [32]byte{} + initial.AnchorAcknowledgementDigest = [32]byte{} + err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := initial + return ©, nil + }, + Committer: &testNativeTBTCSignerStateAnchorCommitter{}, + }, + ) + if err == nil { + t.Fatal("unacknowledged initial state tip was accepted") + } +} + +func TestValidateNativeTBTCSignerAcknowledgedTipRequiresNextRevisionInPinnedEpoch( + t *testing.T, +) { + candidate := testNativeTBTCSignerStateWitnessTip(2, [32]byte{3}) + valid := candidate + valid.AnchorRevision++ + valid.AnchorEventRoot = [32]byte{0x41} + valid.AnchorAcknowledgementDigest = [32]byte{0x42} + if err := validateNativeTBTCSignerAcknowledgedTip( + &candidate, + &valid, + candidate.AnchorBindingHash, + candidate.AnchorServiceEpoch, + ); err != nil { + t.Fatalf("next acknowledgement revision was rejected: %v", err) + } + + for name, mutate := range map[string]func(*NativeTBTCSignerStateWitnessTip){ + "service epoch changed": func(tip *NativeTBTCSignerStateWitnessTip) { + tip.AnchorServiceEpoch++ + }, + "revision skipped": func(tip *NativeTBTCSignerStateWitnessTip) { + tip.AnchorRevision++ + }, + "revision did not advance": func(tip *NativeTBTCSignerStateWitnessTip) { + tip.AnchorRevision = candidate.AnchorRevision + }, + } { + t.Run(name, func(t *testing.T) { + invalid := valid + mutate(&invalid) + if err := validateNativeTBTCSignerAcknowledgedTip( + &candidate, + &invalid, + candidate.AnchorBindingHash, + candidate.AnchorServiceEpoch, + ); err == nil { + t.Fatal("invalid acknowledgement epoch/revision was accepted") + } + }) + } +} + +func testNativeTBTCSignerStateWitnessTip( + generation uint64, + previousCommitment [32]byte, +) NativeTBTCSignerStateWitnessTip { + storeFingerprint := [32]byte{1} + stateImageDigest := [32]byte{byte(generation + 10)} + tip := NativeTBTCSignerStateWitnessTip{ + Schema: NativeTBTCSignerStateWitnessTipSchema, + StoreFingerprint: storeFingerprint, + Generation: generation, + PreviousStateCommitment: previousCommitment, + StateImageDigest: stateImageDigest, + WitnessBaseGeneration: 1, + AnchorBindingHash: [32]byte{10}, + AnchorServiceEpoch: 1, + AnchorRevision: 1, + AnchorEventRoot: [32]byte{11}, + AnchorAcknowledgementDigest: [32]byte{12}, + } + tip.StateCommitment = ComputeNativeTBTCSignerStateWitnessCommitment( + tip.StoreFingerprint, + tip.Generation, + tip.PreviousStateCommitment, + tip.StateImageDigest, + ) + if generation == 1 { + tip.WitnessBaseCommitment = tip.StateCommitment + } else { + tip.WitnessBaseCommitment = + testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}).StateCommitment + } + return tip +} diff --git a/pkg/frost/signing/native_tbtc_signer_state_witness_tip.go b/pkg/frost/signing/native_tbtc_signer_state_witness_tip.go new file mode 100644 index 0000000000..4b21b27e4d --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_witness_tip.go @@ -0,0 +1,454 @@ +package signing + +import ( + "encoding/hex" + "fmt" + "math" + "strconv" + "strings" +) + +const NativeTBTCSignerStateWitnessTipSchema = "tbtc-signer-state-witness-tip/v1" + +const NativeTBTCSignerStateWitnessCheckpointAcknowledgementResultSchema = "tbtc-signer-state-witness-checkpoint-ack-result/v1" +const NativeTBTCSignerStateWitnessCheckpointRecoveryResultSchema = "tbtc-signer-state-witness-checkpoint-recovery-result/v1" + +// NativeTBTCSignerStateWitnessTip is the constant-size durable state readback +// used around every request-taking native signer call. The five Anchor fields +// are either all zero (no remote acknowledgement has been installed in Rust +// yet) or all non-zero (the last acknowledgement Rust durably bound to the +// store). WitnessBase identifies the oldest proofable record after any +// authenticated history rotation. +type NativeTBTCSignerStateWitnessTip struct { + Schema string + StoreFingerprint [32]byte + Generation uint64 + PreviousStateCommitment [32]byte + StateImageDigest [32]byte + StateCommitment [32]byte + WitnessBaseGeneration uint64 + WitnessBaseCommitment [32]byte + AnchorBindingHash [32]byte + AnchorServiceEpoch uint64 + AnchorRevision uint64 + AnchorEventRoot [32]byte + AnchorAcknowledgementDigest [32]byte +} + +type nativeTBTCSignerStateWitnessTipWire struct { + Schema string `json:"schema"` + StoreFingerprint string `json:"storeFingerprint"` + Generation string `json:"generation"` + PreviousStateCommitment string `json:"previousStateCommitment"` + StateImageDigest string `json:"stateImageDigest"` + StateCommitment string `json:"stateCommitment"` + WitnessBaseGeneration string `json:"witnessBaseGeneration"` + WitnessBaseCommitment string `json:"witnessBaseCommitment"` + AnchorBindingHash string `json:"anchorBindingHash"` + AnchorServiceEpoch string `json:"anchorServiceEpoch"` + AnchorRevision string `json:"anchorRevision"` + AnchorEventRoot string `json:"anchorEventRoot"` + AnchorAcknowledgementDigest string `json:"anchorAcknowledgementDigest"` +} + +// NativeTBTCSignerStateWitnessCheckpointAcknowledgementResult confirms the +// exact remote acknowledgement Rust durably installed in its descriptor-bound +// .state-anchor metadata. Installing it never changes the EngineState +// checkpoint five-tuple. +type NativeTBTCSignerStateWitnessCheckpointAcknowledgementResult struct { + Schema string + Acknowledged bool + Idempotent bool + Rotated bool + StoreFingerprint [32]byte + Generation uint64 + StateCommitment [32]byte + WitnessBaseGeneration uint64 + WitnessBaseCommitment [32]byte + AnchorServiceEpoch uint64 + AnchorServiceRevision uint64 + AnchorEventRoot [32]byte + AnchorAcknowledgementDigest [32]byte +} + +type nativeTBTCSignerStateWitnessCheckpointAcknowledgementResultWire struct { + Schema string `json:"schema"` + Acknowledged *bool `json:"acknowledged"` + Idempotent *bool `json:"idempotent"` + Rotated *bool `json:"rotated"` + StoreFingerprint string `json:"storeFingerprint"` + Generation string `json:"generation"` + StateCommitment string `json:"stateCommitment"` + WitnessBaseGeneration string `json:"witnessBaseGeneration"` + WitnessBaseCommitment string `json:"witnessBaseCommitment"` + AnchorServiceEpoch string `json:"anchorServiceEpoch"` + AnchorServiceRevision string `json:"anchorServiceRevision"` + AnchorEventRoot string `json:"anchorEventRoot"` + AnchorAcknowledgementDigest string `json:"anchorAcknowledgementDigest"` +} + +type NativeTBTCSignerStateWitnessCheckpointRecoveryResult struct { + Schema string + Recovered bool + Idempotent bool + Rotated bool + StoreFingerprint [32]byte + Generation uint64 + StateCommitment [32]byte + WitnessBaseGeneration uint64 + WitnessBaseCommitment [32]byte + AnchorServiceEpoch uint64 + AnchorServiceRevision uint64 + AnchorEventRoot [32]byte + AnchorAcknowledgementDigest [32]byte +} + +type nativeTBTCSignerStateWitnessCheckpointRecoveryResultWire struct { + Schema string `json:"schema"` + Recovered *bool `json:"recovered"` + Idempotent *bool `json:"idempotent"` + Rotated *bool `json:"rotated"` + StoreFingerprint string `json:"storeFingerprint"` + Generation string `json:"generation"` + StateCommitment string `json:"stateCommitment"` + WitnessBaseGeneration string `json:"witnessBaseGeneration"` + WitnessBaseCommitment string `json:"witnessBaseCommitment"` + AnchorServiceEpoch string `json:"anchorServiceEpoch"` + AnchorServiceRevision string `json:"anchorServiceRevision"` + AnchorEventRoot string `json:"anchorEventRoot"` + AnchorAcknowledgementDigest string `json:"anchorAcknowledgementDigest"` +} + +// DecodeNativeTBTCSignerStateWitnessTip validates the exact wire contract +// returned by frost_tbtc_state_witness_tip. Decimal integers deliberately use +// JSON strings so every implementation must agree on the full uint64 range. +func DecodeNativeTBTCSignerStateWitnessTip( + payload []byte, +) (*NativeTBTCSignerStateWitnessTip, error) { + wire := &nativeTBTCSignerStateWitnessTipWire{} + if err := decodeStrictNativeTBTCSignerJSON( + payload, + wire, + "state-witness tip", + ); err != nil { + return nil, err + } + if wire.Schema != NativeTBTCSignerStateWitnessTipSchema { + return nil, fmt.Errorf("unsupported native signer state-witness tip schema") + } + + generation, err := decodeNativeTBTCSignerCanonicalUint64(wire.Generation) + if err != nil || generation == 0 { + return nil, fmt.Errorf("invalid native signer state-witness tip generation") + } + witnessBaseGeneration, err := decodeNativeTBTCSignerCanonicalUint64( + wire.WitnessBaseGeneration, + ) + if err != nil || witnessBaseGeneration == 0 || + witnessBaseGeneration > generation { + return nil, fmt.Errorf("invalid native signer state-witness base generation") + } + anchorServiceEpoch, err := decodeNativeTBTCSignerCanonicalUint64( + wire.AnchorServiceEpoch, + ) + if err != nil { + return nil, fmt.Errorf("invalid native signer anchor service epoch") + } + anchorRevision, err := decodeNativeTBTCSignerCanonicalUint64( + wire.AnchorRevision, + ) + if err != nil { + return nil, fmt.Errorf("invalid native signer anchor revision") + } + + result := &NativeTBTCSignerStateWitnessTip{ + Schema: wire.Schema, + Generation: generation, + WitnessBaseGeneration: witnessBaseGeneration, + AnchorServiceEpoch: anchorServiceEpoch, + AnchorRevision: anchorRevision, + } + requiredBytes32 := []struct { + label string + encoded string + destination *[32]byte + }{ + {"store fingerprint", wire.StoreFingerprint, &result.StoreFingerprint}, + {"previous state commitment", wire.PreviousStateCommitment, &result.PreviousStateCommitment}, + {"state image digest", wire.StateImageDigest, &result.StateImageDigest}, + {"state commitment", wire.StateCommitment, &result.StateCommitment}, + {"witness base commitment", wire.WitnessBaseCommitment, &result.WitnessBaseCommitment}, + } + for _, value := range requiredBytes32 { + decoded, err := decodeNativeTBTCSignerCanonicalBytes32(value.encoded, false) + if err != nil { + return nil, fmt.Errorf( + "invalid native signer state-witness tip %s: %w", + value.label, + err, + ) + } + *value.destination = decoded + } + optionalBytes32 := []struct { + label string + encoded string + destination *[32]byte + }{ + {"anchor binding hash", wire.AnchorBindingHash, &result.AnchorBindingHash}, + {"anchor event root", wire.AnchorEventRoot, &result.AnchorEventRoot}, + {"anchor acknowledgement digest", wire.AnchorAcknowledgementDigest, &result.AnchorAcknowledgementDigest}, + } + for _, value := range optionalBytes32 { + decoded, err := decodeNativeTBTCSignerCanonicalBytes32(value.encoded, true) + if err != nil { + return nil, fmt.Errorf( + "invalid native signer state-witness tip %s: %w", + value.label, + err, + ) + } + *value.destination = decoded + } + + computed := ComputeNativeTBTCSignerStateWitnessCommitment( + result.StoreFingerprint, + result.Generation, + result.PreviousStateCommitment, + result.StateImageDigest, + ) + if computed != result.StateCommitment { + return nil, fmt.Errorf("native signer state-witness tip commitment mismatch") + } + if result.WitnessBaseGeneration == result.Generation && + result.WitnessBaseCommitment != result.StateCommitment { + return nil, fmt.Errorf( + "native signer state-witness base at the tip has a different commitment", + ) + } + + hasAnchorHash := result.AnchorBindingHash != [32]byte{} + hasAnchorEpoch := result.AnchorServiceEpoch != 0 + hasAnchorRevision := result.AnchorRevision != 0 + hasAnchorRoot := result.AnchorEventRoot != [32]byte{} + hasAnchorAcknowledgement := result.AnchorAcknowledgementDigest != [32]byte{} + if hasAnchorHash != hasAnchorEpoch || + hasAnchorHash != hasAnchorRevision || + hasAnchorHash != hasAnchorRoot || + hasAnchorHash != hasAnchorAcknowledgement { + return nil, fmt.Errorf( + "native signer state-witness anchor metadata is not all-zero or all-nonzero", + ) + } + + return result, nil +} + +// DecodeNativeTBTCSignerStateWitnessCheckpointAcknowledgementResult validates +// the exact response from frost_tbtc_acknowledge_state_witness_checkpoint. +func DecodeNativeTBTCSignerStateWitnessCheckpointAcknowledgementResult( + payload []byte, +) (*NativeTBTCSignerStateWitnessCheckpointAcknowledgementResult, error) { + wire := &nativeTBTCSignerStateWitnessCheckpointAcknowledgementResultWire{} + if err := decodeStrictNativeTBTCSignerJSON( + payload, + wire, + "state-witness checkpoint acknowledgement result", + ); err != nil { + return nil, err + } + if wire.Schema != + NativeTBTCSignerStateWitnessCheckpointAcknowledgementResultSchema { + return nil, fmt.Errorf( + "unsupported native signer state-witness acknowledgement result schema", + ) + } + if wire.Acknowledged == nil || wire.Idempotent == nil || wire.Rotated == nil { + return nil, fmt.Errorf( + "native signer state-witness acknowledgement flags are missing", + ) + } + + result := &NativeTBTCSignerStateWitnessCheckpointAcknowledgementResult{ + Schema: wire.Schema, + Acknowledged: *wire.Acknowledged, + Idempotent: *wire.Idempotent, + Rotated: *wire.Rotated, + } + decimalFields := []struct { + label string + encoded string + destination *uint64 + }{ + {"generation", wire.Generation, &result.Generation}, + {"witness base generation", wire.WitnessBaseGeneration, &result.WitnessBaseGeneration}, + {"anchor service epoch", wire.AnchorServiceEpoch, &result.AnchorServiceEpoch}, + {"anchor service revision", wire.AnchorServiceRevision, &result.AnchorServiceRevision}, + } + for _, field := range decimalFields { + decoded, err := decodeNativeTBTCSignerCanonicalUint64(field.encoded) + if err != nil { + return nil, fmt.Errorf( + "invalid native signer acknowledgement %s: %w", + field.label, + err, + ) + } + *field.destination = decoded + } + bytes32Fields := []struct { + label string + encoded string + destination *[32]byte + }{ + {"store fingerprint", wire.StoreFingerprint, &result.StoreFingerprint}, + {"state commitment", wire.StateCommitment, &result.StateCommitment}, + {"witness base commitment", wire.WitnessBaseCommitment, &result.WitnessBaseCommitment}, + {"anchor event root", wire.AnchorEventRoot, &result.AnchorEventRoot}, + {"anchor acknowledgement digest", wire.AnchorAcknowledgementDigest, &result.AnchorAcknowledgementDigest}, + } + for _, field := range bytes32Fields { + decoded, err := decodeNativeTBTCSignerCanonicalBytes32(field.encoded, false) + if err != nil { + return nil, fmt.Errorf( + "invalid native signer acknowledgement %s: %w", + field.label, + err, + ) + } + *field.destination = decoded + } + if !result.Acknowledged || result.Generation == 0 || + result.WitnessBaseGeneration == 0 || + result.WitnessBaseGeneration > result.Generation || + result.AnchorServiceEpoch == 0 || result.AnchorServiceRevision == 0 { + return nil, fmt.Errorf( + "native signer state-witness acknowledgement result is incomplete", + ) + } + if result.WitnessBaseGeneration == result.Generation && + result.WitnessBaseCommitment != result.StateCommitment { + return nil, fmt.Errorf( + "native signer acknowledgement witness base differs at the tip", + ) + } + return result, nil +} + +func DecodeNativeTBTCSignerStateWitnessCheckpointRecoveryResult( + payload []byte, +) (*NativeTBTCSignerStateWitnessCheckpointRecoveryResult, error) { + wire := &nativeTBTCSignerStateWitnessCheckpointRecoveryResultWire{} + if err := decodeStrictNativeTBTCSignerJSON( + payload, + wire, + "state-witness checkpoint recovery result", + ); err != nil { + return nil, err + } + if wire.Schema != NativeTBTCSignerStateWitnessCheckpointRecoveryResultSchema || + wire.Recovered == nil || wire.Idempotent == nil || wire.Rotated == nil || + !*wire.Recovered { + return nil, fmt.Errorf( + "native signer state-witness recovery result is incomplete", + ) + } + result := &NativeTBTCSignerStateWitnessCheckpointRecoveryResult{ + Schema: wire.Schema, + Recovered: *wire.Recovered, + Idempotent: *wire.Idempotent, + Rotated: *wire.Rotated, + } + decimalFields := []struct { + label string + encoded string + destination *uint64 + }{ + {"generation", wire.Generation, &result.Generation}, + {"witness base generation", wire.WitnessBaseGeneration, &result.WitnessBaseGeneration}, + {"anchor service epoch", wire.AnchorServiceEpoch, &result.AnchorServiceEpoch}, + {"anchor service revision", wire.AnchorServiceRevision, &result.AnchorServiceRevision}, + } + for _, field := range decimalFields { + decoded, err := decodeNativeTBTCSignerCanonicalUint64(field.encoded) + if err != nil { + return nil, fmt.Errorf( + "invalid native signer recovery %s: %w", + field.label, + err, + ) + } + *field.destination = decoded + } + bytes32Fields := []struct { + label string + encoded string + destination *[32]byte + }{ + {"store fingerprint", wire.StoreFingerprint, &result.StoreFingerprint}, + {"state commitment", wire.StateCommitment, &result.StateCommitment}, + {"witness base commitment", wire.WitnessBaseCommitment, &result.WitnessBaseCommitment}, + {"anchor event root", wire.AnchorEventRoot, &result.AnchorEventRoot}, + {"anchor acknowledgement digest", wire.AnchorAcknowledgementDigest, &result.AnchorAcknowledgementDigest}, + } + for _, field := range bytes32Fields { + decoded, err := decodeNativeTBTCSignerCanonicalBytes32(field.encoded, false) + if err != nil { + return nil, fmt.Errorf( + "invalid native signer recovery %s: %w", + field.label, + err, + ) + } + *field.destination = decoded + } + if result.Generation == 0 || result.WitnessBaseGeneration == 0 || + result.WitnessBaseGeneration > result.Generation || + result.AnchorServiceEpoch == 0 || result.AnchorServiceRevision == 0 || + (result.WitnessBaseGeneration == result.Generation && + result.WitnessBaseCommitment != result.StateCommitment) { + return nil, fmt.Errorf( + "native signer state-witness recovery result has invalid bounds", + ) + } + return result, nil +} + +func decodeNativeTBTCSignerCanonicalUint64(value string) (uint64, error) { + if value == "" || (len(value) > 1 && value[0] == '0') { + return 0, fmt.Errorf("expected canonical unsigned decimal") + } + for _, character := range value { + if character < '0' || character > '9' { + return 0, fmt.Errorf("expected canonical unsigned decimal") + } + } + if len(value) > len(strconv.FormatUint(math.MaxUint64, 10)) { + return 0, fmt.Errorf("unsigned decimal overflows uint64") + } + result, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("unsigned decimal overflows uint64") + } + return result, nil +} + +func decodeNativeTBTCSignerCanonicalBytes32( + value string, + allowZero bool, +) ([32]byte, error) { + result := [32]byte{} + if value != strings.ToLower(value) || !strings.HasPrefix(value, "0x") || + len(value) != 66 { + return result, fmt.Errorf("expected canonical lowercase 0x-prefixed bytes32") + } + decoded, err := hex.DecodeString(value[2:]) + if err != nil || len(decoded) != len(result) { + return result, fmt.Errorf("expected canonical lowercase 0x-prefixed bytes32") + } + copy(result[:], decoded) + if !allowZero && result == [32]byte{} { + return result, fmt.Errorf("value is zero") + } + return result, nil +} diff --git a/pkg/frost/signing/native_tbtc_signer_state_witness_tip_test.go b/pkg/frost/signing/native_tbtc_signer_state_witness_tip_test.go new file mode 100644 index 0000000000..4c2699b7eb --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_witness_tip_test.go @@ -0,0 +1,306 @@ +package signing + +import ( + "fmt" + "strings" + "testing" +) + +func TestDecodeNativeTBTCSignerStateWitnessTip(t *testing.T) { + storeFingerprint := [32]byte{1} + previousCommitment := [32]byte{2} + stateImageDigest := [32]byte{3} + stateCommitment := ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + 7, + previousCommitment, + stateImageDigest, + ) + payload := nativeTBTCSignerStateWitnessTipTestPayload( + storeFingerprint, + 7, + previousCommitment, + stateImageDigest, + stateCommitment, + 7, + stateCommitment, + [32]byte{}, + "0", + "0", + [32]byte{}, + [32]byte{}, + "", + ) + + tip, err := DecodeNativeTBTCSignerStateWitnessTip([]byte(payload)) + if err != nil { + t.Fatalf("cannot decode valid state-witness tip: %v", err) + } + if tip.Generation != 7 || tip.StateCommitment != stateCommitment || + tip.WitnessBaseGeneration != 7 || + tip.WitnessBaseCommitment != stateCommitment { + t.Fatal("decoded state-witness tip differs from the wire payload") + } +} + +func TestDecodeNativeTBTCSignerStateWitnessTipRejectsNonCanonicalPayloads( + t *testing.T, +) { + storeFingerprint := [32]byte{1} + previousCommitment := [32]byte{2} + stateImageDigest := [32]byte{3} + stateCommitment := ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + 7, + previousCommitment, + stateImageDigest, + ) + valid := func(extra string) string { + return nativeTBTCSignerStateWitnessTipTestPayload( + storeFingerprint, + 7, + previousCommitment, + stateImageDigest, + stateCommitment, + 7, + stateCommitment, + [32]byte{}, + "0", + "0", + [32]byte{}, + [32]byte{}, + extra, + ) + } + + tests := map[string]string{ + "leading-zero generation": strings.Replace( + valid(""), + `"generation":"7"`, + `"generation":"07"`, + 1, + ), + "numeric generation": strings.Replace( + valid(""), + `"generation":"7"`, + `"generation":7`, + 1, + ), + "overflow generation": strings.Replace( + valid(""), + `"generation":"7"`, + `"generation":"18446744073709551616"`, + 1, + ), + "uppercase bytes32": strings.Replace( + valid(""), + nativeTBTCSignerBytes32(storeFingerprint), + strings.ToUpper(nativeTBTCSignerBytes32(storeFingerprint)), + 1, + ), + "unknown field": valid(`,"future":true`), + "trailing JSON": valid("") + `{}`, + "commitment mismatch": strings.Replace( + valid(""), + nativeTBTCSignerBytes32(stateCommitment), + nativeTBTCSignerBytes32([32]byte{9}), + 1, + ), + } + + for name, payload := range tests { + t.Run(name, func(t *testing.T) { + if _, err := DecodeNativeTBTCSignerStateWitnessTip( + []byte(payload), + ); err == nil { + t.Fatal("non-canonical state-witness tip was accepted") + } + }) + } +} + +func TestDecodeNativeTBTCSignerStateWitnessTipRejectsPartialAnchorMetadata( + t *testing.T, +) { + storeFingerprint := [32]byte{1} + previousCommitment := [32]byte{2} + stateImageDigest := [32]byte{3} + stateCommitment := ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + 7, + previousCommitment, + stateImageDigest, + ) + payload := nativeTBTCSignerStateWitnessTipTestPayload( + storeFingerprint, + 7, + previousCommitment, + stateImageDigest, + stateCommitment, + 7, + stateCommitment, + [32]byte{4}, + "0", + "0", + [32]byte{}, + [32]byte{}, + "", + ) + if _, err := DecodeNativeTBTCSignerStateWitnessTip( + []byte(payload), + ); err == nil { + t.Fatal("partial state-witness anchor metadata was accepted") + } +} + +func TestDecodeNativeTBTCSignerStateWitnessCheckpointAcknowledgementResult( + t *testing.T, +) { + storeFingerprint := [32]byte{1} + stateCommitment := [32]byte{2} + payload := fmt.Sprintf( + `{"schema":"%s","acknowledged":true,"idempotent":false,"rotated":true,"storeFingerprint":"%s","generation":"9","stateCommitment":"%s","witnessBaseGeneration":"9","witnessBaseCommitment":"%s","anchorServiceEpoch":"2","anchorServiceRevision":"3","anchorEventRoot":"%s","anchorAcknowledgementDigest":"%s"}`, + NativeTBTCSignerStateWitnessCheckpointAcknowledgementResultSchema, + nativeTBTCSignerBytes32(storeFingerprint), + nativeTBTCSignerBytes32(stateCommitment), + nativeTBTCSignerBytes32(stateCommitment), + nativeTBTCSignerBytes32([32]byte{3}), + nativeTBTCSignerBytes32([32]byte{4}), + ) + result, err := + DecodeNativeTBTCSignerStateWitnessCheckpointAcknowledgementResult( + []byte(payload), + ) + if err != nil { + t.Fatalf("cannot decode valid checkpoint acknowledgement result: %v", err) + } + if !result.Acknowledged || !result.Rotated || result.Idempotent || + result.Generation != 9 || result.AnchorServiceEpoch != 2 || + result.AnchorServiceRevision != 3 { + t.Fatal("decoded checkpoint acknowledgement result differs from payload") + } + + for name, invalid := range map[string]string{ + "missing acknowledged": strings.Replace( + payload, + `"acknowledged":true,`, + "", + 1, + ), + "numeric generation": strings.Replace( + payload, + `"generation":"9"`, + `"generation":9`, + 1, + ), + "unacknowledged": strings.Replace( + payload, + `"acknowledged":true`, + `"acknowledged":false`, + 1, + ), + "unknown field": strings.TrimSuffix(payload, "}") + `,"future":true}`, + } { + t.Run(name, func(t *testing.T) { + if _, err := + DecodeNativeTBTCSignerStateWitnessCheckpointAcknowledgementResult( + []byte(invalid), + ); err == nil { + t.Fatal("invalid checkpoint acknowledgement result was accepted") + } + }) + } +} + +func TestDecodeNativeTBTCSignerStateWitnessCheckpointRecoveryResult( + t *testing.T, +) { + storeFingerprint := [32]byte{1} + stateCommitment := [32]byte{2} + payload := fmt.Sprintf( + `{"schema":"%s","recovered":true,"idempotent":false,"rotated":true,"storeFingerprint":"%s","generation":"9","stateCommitment":"%s","witnessBaseGeneration":"9","witnessBaseCommitment":"%s","anchorServiceEpoch":"2","anchorServiceRevision":"3","anchorEventRoot":"%s","anchorAcknowledgementDigest":"%s"}`, + NativeTBTCSignerStateWitnessCheckpointRecoveryResultSchema, + nativeTBTCSignerBytes32(storeFingerprint), + nativeTBTCSignerBytes32(stateCommitment), + nativeTBTCSignerBytes32(stateCommitment), + nativeTBTCSignerBytes32([32]byte{3}), + nativeTBTCSignerBytes32([32]byte{4}), + ) + result, err := + DecodeNativeTBTCSignerStateWitnessCheckpointRecoveryResult( + []byte(payload), + ) + if err != nil { + t.Fatalf("cannot decode valid checkpoint recovery result: %v", err) + } + if !result.Recovered || !result.Rotated || result.Idempotent || + result.Generation != 9 || result.AnchorServiceEpoch != 2 || + result.AnchorServiceRevision != 3 { + t.Fatal("decoded checkpoint recovery result differs from payload") + } + + for name, invalid := range map[string]string{ + "missing recovered": strings.Replace( + payload, + `"recovered":true,`, + "", + 1, + ), + "not recovered": strings.Replace( + payload, + `"recovered":true`, + `"recovered":false`, + 1, + ), + "leading-zero revision": strings.Replace( + payload, + `"anchorServiceRevision":"3"`, + `"anchorServiceRevision":"03"`, + 1, + ), + "unknown field": strings.TrimSuffix(payload, "}") + `,"future":true}`, + } { + t.Run(name, func(t *testing.T) { + if _, err := + DecodeNativeTBTCSignerStateWitnessCheckpointRecoveryResult( + []byte(invalid), + ); err == nil { + t.Fatal("invalid checkpoint recovery result was accepted") + } + }) + } +} + +func nativeTBTCSignerStateWitnessTipTestPayload( + storeFingerprint [32]byte, + generation uint64, + previousStateCommitment [32]byte, + stateImageDigest [32]byte, + stateCommitment [32]byte, + witnessBaseGeneration uint64, + witnessBaseCommitment [32]byte, + anchorBindingHash [32]byte, + anchorServiceEpoch string, + anchorRevision string, + anchorEventRoot [32]byte, + anchorAcknowledgementDigest [32]byte, + extra string, +) string { + return fmt.Sprintf( + `{"schema":"%s","storeFingerprint":"%s","generation":"%d","previousStateCommitment":"%s","stateImageDigest":"%s","stateCommitment":"%s","witnessBaseGeneration":"%d","witnessBaseCommitment":"%s","anchorBindingHash":"%s","anchorServiceEpoch":"%s","anchorRevision":"%s","anchorEventRoot":"%s","anchorAcknowledgementDigest":"%s"%s}`, + NativeTBTCSignerStateWitnessTipSchema, + nativeTBTCSignerBytes32(storeFingerprint), + generation, + nativeTBTCSignerBytes32(previousStateCommitment), + nativeTBTCSignerBytes32(stateImageDigest), + nativeTBTCSignerBytes32(stateCommitment), + witnessBaseGeneration, + nativeTBTCSignerBytes32(witnessBaseCommitment), + nativeTBTCSignerBytes32(anchorBindingHash), + anchorServiceEpoch, + anchorRevision, + nativeTBTCSignerBytes32(anchorEventRoot), + nativeTBTCSignerBytes32(anchorAcknowledgementDigest), + extra, + ) +} diff --git a/pkg/frost/signing/roast_real_cgo_interactive_e2e_frost_native_test.go b/pkg/frost/signing/roast_real_cgo_interactive_e2e_frost_native_test.go index 289882ddf0..8e9021fa47 100644 --- a/pkg/frost/signing/roast_real_cgo_interactive_e2e_frost_native_test.go +++ b/pkg/frost/signing/roast_real_cgo_interactive_e2e_frost_native_test.go @@ -486,6 +486,7 @@ func setupRealCgoSignerState(t *testing.T) { t.Fatalf("create signer state dir: %v", err) } t.Setenv("TBTC_SIGNER_STATE_PATH", filepath.Join(stateDir, "signer-state")) + setupRealCgoSignerStateAnchor(t) } // isPreMultiSeatConflict reports whether an InteractiveSessionOpen error is the diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index b0ea9dfcb3..e310749536 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -405,6 +405,11 @@ func (fahe *frostActivationHandshakeExporter) attest( if journalSnapshot == nil || nativeSignerSnapshot == nil { return nil, fmt.Errorf("production FROST signer readiness snapshot is incomplete") } + if !nativeSignerSnapshot.ExternalRollbackAnchorBound { + return nil, fmt.Errorf( + "native signer state lacks an authenticated external rollback anchor", + ) + } journalManifest := fahe.manifest.CanonicalJournal quarantineManifest := fahe.manifest.QuarantineJournal if !journalSnapshot.Complete || journalSnapshot.CurrentPoint != finality || @@ -497,7 +502,7 @@ func (fahe *frostActivationHandshakeExporter) attest( InventoryCommitment: frostActivationHex32(nativeSignerSnapshot.InventoryCommitment), RetainedWalletCount: nativeSignerSnapshot.WalletCount, RetainedKeyPackageCount: nativeSignerSnapshot.KeyPackageCount, - ExternalRollbackAnchorBound: true, + ExternalRollbackAnchorBound: nativeSignerSnapshot.ExternalRollbackAnchorBound, Complete: true, }, InteractiveSigningReady: readinessSnapshot.InteractiveSigningReady, diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index 1dc8bfc7ed..85f78cdcc1 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -70,13 +70,14 @@ func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSigner return &frostProductionSignerReadinessSnapshot{ Journal: journalSnapshot, Inventory: &frostNativeSignerInventorySnapshot{ - Schema: "tbtc-signer-retained-key-package-inventory/v1", - StoreFingerprint: testFrostDurableSessionStoreIdentity().Fingerprint, - StateGeneration: 7, - StateCommitment: [32]byte{0x31}, - PreviousStateCommitment: [32]byte{0x30}, - StateImageDigest: [32]byte{0x33}, - InventoryCommitment: [32]byte{0x32}, + Schema: "tbtc-signer-retained-key-package-inventory/v1", + StoreFingerprint: testFrostDurableSessionStoreIdentity().Fingerprint, + StateGeneration: 7, + StateCommitment: [32]byte{0x31}, + PreviousStateCommitment: [32]byte{0x30}, + StateImageDigest: [32]byte{0x33}, + InventoryCommitment: [32]byte{0x32}, + ExternalRollbackAnchorBound: true, }, InteractiveSigningReady: true, }, nil diff --git a/pkg/tbtc/frost_native_signer_anchor_binding.go b/pkg/tbtc/frost_native_signer_anchor_binding.go new file mode 100644 index 0000000000..1a9d022173 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_binding.go @@ -0,0 +1,614 @@ +package tbtc + +import ( + "context" + "fmt" + "sync" + "time" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +type frostNativeSignerStateWitnessTipReader func() ( + *frostsigning.NativeTBTCSignerStateWitnessTip, + error, +) + +type frostNativeSignerStateWitnessAcknowledger func( + []byte, +) (*frostsigning.NativeTBTCSignerStateWitnessCheckpointAcknowledgementResult, error) + +type frostNativeSignerStateWitnessRecoverer func( + []byte, +) (*frostsigning.NativeTBTCSignerStateWitnessCheckpointRecoveryResult, error) + +// frostNativeSignerAnchorBinding is the only production adapter between the +// central FFI output barrier and the authenticated remote checkpoint service. +// Its callbacks use only guard-bypassing readback/proof/acknowledgement symbols; +// they must never re-enter a request-taking signer operation while the global +// signer barrier lock is held. +type frostNativeSignerAnchorBinding struct { + store FrostNativeSignerStateWitnessAnchorStore + identity FrostNativeSignerAnchorIdentity + bindingHash [32]byte + floor FrostNativeSignerAnchorManifest + + readTip frostNativeSignerStateWitnessTipReader + readProof frostNativeSignerStateWitnessProofReader + acknowledge frostNativeSignerStateWitnessAcknowledger + recover frostNativeSignerStateWitnessRecoverer + now func() time.Time + + mutex sync.Mutex +} + +func newFrostNativeSignerAnchorBinding( + store FrostNativeSignerStateWitnessAnchorStore, + manifest FrostNativeSignerAnchorManifest, + readTip frostNativeSignerStateWitnessTipReader, + readProof frostNativeSignerStateWitnessProofReader, + acknowledge frostNativeSignerStateWitnessAcknowledger, + recover frostNativeSignerStateWitnessRecoverer, +) (*frostNativeSignerAnchorBinding, error) { + if store == nil || readTip == nil || readProof == nil || + acknowledge == nil || recover == nil { + return nil, fmt.Errorf("FROST native signer anchor dependencies are incomplete") + } + identity := manifest.Identity + if identity.StreamID != ComputeFrostNativeSignerAnchorStreamID(identity) { + return nil, fmt.Errorf("FROST native signer anchor stream identity is invalid") + } + if manifest.MinimumServiceEpoch == 0 || manifest.MinimumRevision == 0 || + manifest.MinimumEventRoot == [32]byte{} || + manifest.MinimumAcknowledgementDigest == [32]byte{} || + manifest.MinimumCheckpoint.StoreFingerprint != + identity.SignerStoreFingerprint || + manifest.WitnessMaximumRecords != identity.WitnessMaximumRecords || + manifest.WitnessRotationThresholdRecords != + identity.WitnessRotationThresholdRecords { + return nil, fmt.Errorf("FROST native signer anchor manifest floor is invalid") + } + computedFloorCommitment := + frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + manifest.MinimumCheckpoint.StoreFingerprint, + manifest.MinimumCheckpoint.Generation, + manifest.MinimumCheckpoint.PreviousStateCommitment, + manifest.MinimumCheckpoint.StateImageDigest, + ) + if manifest.MinimumCheckpoint.Generation == 0 || + computedFloorCommitment != manifest.MinimumCheckpoint.StateCommitment { + return nil, fmt.Errorf( + "FROST native signer anchor manifest checkpoint is invalid", + ) + } + bindingHash := ComputeFrostNativeSignerAnchorBindingHash(identity) + if bindingHash == [32]byte{} { + return nil, fmt.Errorf("FROST native signer anchor binding hash is zero") + } + return &frostNativeSignerAnchorBinding{ + store: store, + identity: identity, + bindingHash: bindingHash, + floor: manifest, + readTip: readTip, + readProof: readProof, + acknowledge: acknowledge, + recover: recover, + now: time.Now, + }, nil +} + +// reconcileStartup forces Rust state loading/migration through the cheap tip +// symbol, then authenticates the complete independent service history from the +// offline floor to a twice-read stable target. A local tip ahead of that target +// may be caught up with a bounded Rust proof; a remote tip ahead of/divergent +// from local is a rollback/fork failure. +func (binding *frostNativeSignerAnchorBinding) reconcileStartup( + ctx context.Context, +) (*frostsigning.NativeTBTCSignerStateWitnessTip, error) { + if binding == nil || ctx == nil { + return nil, fmt.Errorf("FROST native signer anchor startup context is invalid") + } + binding.mutex.Lock() + defer binding.mutex.Unlock() + + local, err := binding.readTip() + if err != nil { + return nil, fmt.Errorf("cannot read startup native signer state tip: %w", err) + } + if err := binding.validateLocalTip(local); err != nil { + return nil, err + } + floor := binding.manifestFloorReference() + history, err := + binding.store.ReadFrostNativeSignerStateWitnessAnchorHistory(ctx, floor) + if err != nil { + // In particular, an absent stream remains a hard failure. The online + // signer is never authorized to create its own rollback boundary. + return nil, fmt.Errorf( + "cannot authenticate startup native signer anchor history: %w", + err, + ) + } + serviceCommitments, remote, previousRemote, err := + binding.validateStartupHistory(history) + if err != nil { + return nil, err + } + + localCheckpoint := frostNativeSignerCheckpointFromTip(*local) + switch { + case localCheckpoint.Generation < remote.Checkpoint.Generation: + return nil, fmt.Errorf( + "startup native signer local state is behind the authenticated remote anchor", + ) + case localCheckpoint.Generation == remote.Checkpoint.Generation && + localCheckpoint != remote.Checkpoint: + return nil, fmt.Errorf( + "startup native signer local state forks the authenticated remote anchor", + ) + case localCheckpoint == remote.Checkpoint: + if err := binding.validateLocalHistorySplice( + *local, + serviceCommitments, + ); err != nil { + return nil, err + } + if binding.localTipMatchesRemoteRecord(*local, remote) { + copy := *local + return ©, nil + } + if binding.localTipHasNoAnchor(*local) { + return binding.recoverAcknowledgementLocked(*local, remote) + } + if previousRemote == nil || + !binding.localTipHasAnchorReference(*local, *previousRemote) { + return nil, fmt.Errorf( + "local native signer anchor metadata is neither absent, exact, nor the authenticated immediately preceding revision", + ) + } + return binding.recoverAcknowledgementLocked(*local, remote) + default: + // The local state advanced durably before its remote CAS completed. + // Its anchor metadata must still be the exact authenticated remote + // target observed immediately before that operation. + if !binding.localTipHasAnchorReference( + *local, + frostNativeSignerAnchorReferenceFromRecord(remote), + ) { + return nil, fmt.Errorf( + "startup native signer local-ahead state is not based on the exact remote anchor", + ) + } + proof, err := binding.collectProofLocked( + remote.Checkpoint, + localCheckpoint, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot prove startup local-ahead native signer state: %w", + err, + ) + } + result, err := + binding.store.CompareAndSwapFrostNativeSignerStateWitnessAnchor( + ctx, + remote.Checkpoint, + localCheckpoint, + proof, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot commit startup local-ahead native signer state: %w", + err, + ) + } + if result == nil || + result.Acknowledgement.Checkpoint != localCheckpoint || + result.Acknowledgement.BindingHash != binding.bindingHash { + return nil, fmt.Errorf( + "startup native signer anchor CAS acknowledged a different binding or checkpoint", + ) + } + return binding.installCASResultLocked(*local, result) + } +} + +func (binding *frostNativeSignerAnchorBinding) VerifyNativeTBTCSignerStateTip( + ctx context.Context, + local frostsigning.NativeTBTCSignerStateWitnessTip, +) error { + if binding == nil || ctx == nil { + return fmt.Errorf("FROST native signer anchor verification context is invalid") + } + binding.mutex.Lock() + defer binding.mutex.Unlock() + if err := binding.validateLocalTip(&local); err != nil { + return err + } + remote, err := binding.store.ReadFrostNativeSignerStateWitnessAnchor(ctx) + if err != nil { + return fmt.Errorf("cannot read native signer remote anchor: %w", err) + } + if err := binding.validateRemoteRecord(remote); err != nil { + return err + } + if !binding.localTipMatchesRemoteRecord(local, remote) { + return fmt.Errorf( + "local native signer state tip differs from the authenticated remote anchor", + ) + } + return nil +} + +func (binding *frostNativeSignerAnchorBinding) CommitNativeTBTCSignerStateTransition( + ctx context.Context, + operation string, + expected frostsigning.NativeTBTCSignerStateWitnessTip, + candidate frostsigning.NativeTBTCSignerStateWitnessTip, +) (*frostsigning.NativeTBTCSignerStateWitnessTip, error) { + if binding == nil || ctx == nil { + return nil, fmt.Errorf("FROST native signer anchor commit context is invalid") + } + if operation == "" { + return nil, fmt.Errorf("FROST native signer operation is empty") + } + binding.mutex.Lock() + defer binding.mutex.Unlock() + if err := binding.validateLocalTip(&expected); err != nil { + return nil, fmt.Errorf("invalid expected native signer tip: %w", err) + } + if err := binding.validateLocalTip(&candidate); err != nil { + return nil, fmt.Errorf("invalid candidate native signer tip: %w", err) + } + expectedCheckpoint := frostNativeSignerCheckpointFromTip(expected) + candidateCheckpoint := frostNativeSignerCheckpointFromTip(candidate) + proof, err := binding.collectProofLocked( + expectedCheckpoint, + candidateCheckpoint, + ) + if err != nil { + return nil, err + } + result, err := + binding.store.CompareAndSwapFrostNativeSignerStateWitnessAnchor( + ctx, + expectedCheckpoint, + candidateCheckpoint, + proof, + ) + if err != nil { + return nil, err + } + if result == nil || + result.Acknowledgement.Checkpoint != candidateCheckpoint || + result.Acknowledgement.BindingHash != binding.bindingHash { + return nil, fmt.Errorf( + "native signer anchor CAS acknowledged a different binding or checkpoint", + ) + } + return binding.installCASResultLocked(candidate, result) +} + +func (binding *frostNativeSignerAnchorBinding) collectProofLocked( + expected FrostNativeSignerStateWitnessCheckpoint, + candidate FrostNativeSignerStateWitnessCheckpoint, +) ([]frostsigning.NativeTBTCSignerStateWitnessProofEntry, error) { + if candidate.Generation <= expected.Generation || + candidate.StoreFingerprint != expected.StoreFingerprint { + return nil, fmt.Errorf("native signer anchor proof bounds are invalid") + } + capacity := FrostNativeSignerAnchorMaximumProofEntries + generationDelta := candidate.Generation - expected.Generation + if generationDelta < uint64(capacity) { + capacity = int(generationDelta) + } + result := make( + []frostsigning.NativeTBTCSignerStateWitnessProofEntry, + 0, + capacity, + ) + cursorGeneration := expected.Generation + cursorCommitment := expected.StateCommitment + for page := 0; page < frostNativeSignerStateWitnessMaximumPages; page++ { + request := &frostsigning.NativeTBTCSignerStateWitnessProofRequest{ + Schema: frostsigning.NativeTBTCSignerStateWitnessProofRequestSchema, + StoreFingerprint: candidate.StoreFingerprint, + AncestorGeneration: cursorGeneration, + AncestorCommitment: cursorCommitment, + TargetGeneration: candidate.Generation, + TargetCommitment: candidate.StateCommitment, + MaximumEntries: frostsigning.NativeTBTCSignerStateWitnessProofMaximumEntries, + } + proof, err := binding.readProof(request) + if err != nil { + return nil, fmt.Errorf("cannot read native signer state-witness proof: %w", err) + } + if proof == nil || proof.Schema != frostsigning.NativeTBTCSignerStateWitnessProofSchema || + proof.StoreFingerprint != request.StoreFingerprint || + proof.AncestorGeneration != request.AncestorGeneration || + proof.AncestorCommitment != request.AncestorCommitment || + proof.TargetGeneration != request.TargetGeneration || + proof.TargetCommitment != request.TargetCommitment || + len(proof.Entries) == 0 || + len(proof.Entries) > int(request.MaximumEntries) { + return nil, fmt.Errorf( + "native signer returned a proof for different or empty ancestry bounds", + ) + } + result = append(result, proof.Entries...) + if len(result) > FrostNativeSignerAnchorMaximumProofEntries { + return nil, fmt.Errorf( + "native signer state-witness ancestry exceeds the bounded proof window", + ) + } + last := proof.Entries[len(proof.Entries)-1] + cursorGeneration = last.Generation + cursorCommitment = last.StateCommitment + if proof.Complete { + if cursorGeneration != candidate.Generation || + cursorCommitment != candidate.StateCommitment || + last.PreviousStateCommitment != candidate.PreviousStateCommitment || + last.StateImageDigest != candidate.StateImageDigest { + return nil, fmt.Errorf( + "complete native signer proof does not reach the exact candidate checkpoint", + ) + } + return result, nil + } + } + return nil, fmt.Errorf( + "native signer state-witness ancestry exceeds the bounded proof window", + ) +} + +func (binding *frostNativeSignerAnchorBinding) installAcknowledgementLocked( + candidate frostsigning.NativeTBTCSignerStateWitnessTip, + record *FrostNativeSignerStateWitnessAnchorRecord, +) (*frostsigning.NativeTBTCSignerStateWitnessTip, error) { + if err := binding.validateRemoteRecord(record); err != nil { + return nil, err + } + if record.Checkpoint != frostNativeSignerCheckpointFromTip(candidate) { + return nil, fmt.Errorf( + "native signer acknowledgement does not identify the candidate checkpoint", + ) + } + nowUnixMillis := binding.now().UnixMilli() + if nowUnixMillis < 0 || + record.AcknowledgementExpires <= uint64(nowUnixMillis) { + return nil, fmt.Errorf( + "native signer acknowledgement expired before it could be installed", + ) + } + result, err := binding.acknowledge(record.AcknowledgementJSON) + if err != nil { + return nil, fmt.Errorf( + "cannot install signed native signer checkpoint acknowledgement: %w", + err, + ) + } + if result == nil || + result.StoreFingerprint != candidate.StoreFingerprint || + result.Generation != candidate.Generation || + result.StateCommitment != candidate.StateCommitment || + result.AnchorServiceEpoch != record.ServiceEpoch || + result.AnchorServiceRevision != record.Revision || + result.AnchorEventRoot != record.EventRoot || + result.AnchorAcknowledgementDigest != record.AcknowledgementDigest { + return nil, fmt.Errorf( + "native signer acknowledgement readback differs from the signed remote record", + ) + } + baseUnchanged := result.WitnessBaseGeneration == + candidate.WitnessBaseGeneration && + result.WitnessBaseCommitment == candidate.WitnessBaseCommitment + baseRotatedToCandidate := result.WitnessBaseGeneration == + candidate.Generation && + result.WitnessBaseCommitment == candidate.StateCommitment + if !baseUnchanged && !baseRotatedToCandidate { + return nil, fmt.Errorf( + "native signer acknowledgement rotated to an unauthenticated witness base", + ) + } + expected := candidate + expected.WitnessBaseGeneration = result.WitnessBaseGeneration + expected.WitnessBaseCommitment = result.WitnessBaseCommitment + expected.AnchorBindingHash = binding.bindingHash + expected.AnchorServiceEpoch = record.ServiceEpoch + expected.AnchorRevision = record.Revision + expected.AnchorEventRoot = record.EventRoot + expected.AnchorAcknowledgementDigest = record.AcknowledgementDigest + readback, err := binding.readTip() + if err != nil { + return nil, fmt.Errorf( + "cannot read native signer tip after acknowledgement install: %w", + err, + ) + } + if readback == nil || *readback != expected { + return nil, fmt.Errorf( + "native signer did not durably install the exact signed acknowledgement", + ) + } + return readback, nil +} + +func (binding *frostNativeSignerAnchorBinding) installCASResultLocked( + candidate frostsigning.NativeTBTCSignerStateWitnessTip, + result *FrostNativeSignerStateWitnessAnchorCASResult, +) (*frostsigning.NativeTBTCSignerStateWitnessTip, error) { + if result == nil { + return nil, fmt.Errorf("native signer anchor CAS result is nil") + } + record := frostNativeSignerAnchorRecord(&result.Acknowledgement) + if len(record.ReadRecoveryJSON) != 0 { + return binding.recoverAcknowledgementLocked(candidate, record) + } + return binding.installAcknowledgementLocked(candidate, record) +} + +func (binding *frostNativeSignerAnchorBinding) recoverAcknowledgementLocked( + candidate frostsigning.NativeTBTCSignerStateWitnessTip, + record *FrostNativeSignerStateWitnessAnchorRecord, +) (*frostsigning.NativeTBTCSignerStateWitnessTip, error) { + if err := binding.validateRemoteRecord(record); err != nil { + return nil, err + } + if record.Checkpoint != frostNativeSignerCheckpointFromTip(candidate) || + len(record.ReadRecoveryJSON) == 0 { + return nil, fmt.Errorf( + "native signer recovery certificate does not identify the local checkpoint", + ) + } + nowUnixMillis := binding.now().UnixMilli() + if nowUnixMillis < 0 || + record.ReadRecoveryExpires <= uint64(nowUnixMillis) { + return nil, fmt.Errorf( + "native signer recovery certificate expired before it could be installed", + ) + } + result, err := binding.recover(record.ReadRecoveryJSON) + if err != nil { + return nil, fmt.Errorf( + "cannot recover signed native signer checkpoint acknowledgement: %w", + err, + ) + } + if result == nil || !result.Recovered || + result.StoreFingerprint != candidate.StoreFingerprint || + result.Generation != candidate.Generation || + result.StateCommitment != candidate.StateCommitment || + result.AnchorServiceEpoch != record.ServiceEpoch || + result.AnchorServiceRevision != record.Revision || + result.AnchorEventRoot != record.EventRoot || + result.AnchorAcknowledgementDigest != record.AcknowledgementDigest { + return nil, fmt.Errorf( + "native signer recovery readback differs from the signed remote record", + ) + } + baseUnchanged := result.WitnessBaseGeneration == + candidate.WitnessBaseGeneration && + result.WitnessBaseCommitment == candidate.WitnessBaseCommitment + baseRotatedToCandidate := result.WitnessBaseGeneration == + candidate.Generation && + result.WitnessBaseCommitment == candidate.StateCommitment + if !baseUnchanged && !baseRotatedToCandidate { + return nil, fmt.Errorf( + "native signer recovery rotated to an unauthenticated witness base", + ) + } + expected := candidate + expected.WitnessBaseGeneration = result.WitnessBaseGeneration + expected.WitnessBaseCommitment = result.WitnessBaseCommitment + expected.AnchorBindingHash = binding.bindingHash + expected.AnchorServiceEpoch = record.ServiceEpoch + expected.AnchorRevision = record.Revision + expected.AnchorEventRoot = record.EventRoot + expected.AnchorAcknowledgementDigest = record.AcknowledgementDigest + readback, err := binding.readTip() + if err != nil { + return nil, fmt.Errorf( + "cannot read native signer tip after recovery: %w", + err, + ) + } + if readback == nil || *readback != expected { + return nil, fmt.Errorf( + "native signer did not durably recover the exact signed acknowledgement", + ) + } + return readback, nil +} + +func (binding *frostNativeSignerAnchorBinding) validateLocalTip( + tip *frostsigning.NativeTBTCSignerStateWitnessTip, +) error { + if tip == nil || tip.Schema != frostsigning.NativeTBTCSignerStateWitnessTipSchema || + tip.StoreFingerprint != binding.identity.SignerStoreFingerprint || + tip.Generation == 0 || tip.StateCommitment == [32]byte{} || + tip.WitnessBaseGeneration == 0 || + tip.WitnessBaseGeneration > tip.Generation || + tip.WitnessBaseCommitment == [32]byte{} || + (tip.WitnessBaseGeneration == tip.Generation && + tip.WitnessBaseCommitment != tip.StateCommitment) { + return fmt.Errorf("native signer state-witness tip is invalid or belongs to another store") + } + computed := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + tip.StoreFingerprint, + tip.Generation, + tip.PreviousStateCommitment, + tip.StateImageDigest, + ) + if computed != tip.StateCommitment { + return fmt.Errorf("native signer state-witness tip commitment is invalid") + } + hasAnchor := tip.AnchorBindingHash != [32]byte{} + if hasAnchor != (tip.AnchorServiceEpoch != 0) || + hasAnchor != (tip.AnchorRevision != 0) || + hasAnchor != (tip.AnchorEventRoot != [32]byte{}) || + hasAnchor != (tip.AnchorAcknowledgementDigest != [32]byte{}) { + return fmt.Errorf("native signer state-witness anchor metadata is partial") + } + return nil +} + +func (binding *frostNativeSignerAnchorBinding) validateRemoteRecord( + record *FrostNativeSignerStateWitnessAnchorRecord, +) error { + if record == nil || + record.BindingHash != binding.bindingHash || + record.Checkpoint.StoreFingerprint != binding.identity.SignerStoreFingerprint || + record.Checkpoint.Generation == 0 || + record.Checkpoint.StateCommitment == [32]byte{} || + record.OperationID == [32]byte{} || + record.TransitionDigest == [32]byte{} || + record.ServiceEpoch != binding.floor.MinimumServiceEpoch || + record.Revision < binding.floor.MinimumRevision || + record.EventRoot == [32]byte{} || + record.AcknowledgementDigest == [32]byte{} || + len(record.AcknowledgementJSON) == 0 { + return fmt.Errorf("authenticated native signer anchor record is incomplete") + } + computed := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + record.Checkpoint.StoreFingerprint, + record.Checkpoint.Generation, + record.Checkpoint.PreviousStateCommitment, + record.Checkpoint.StateImageDigest, + ) + if computed != record.Checkpoint.StateCommitment { + return fmt.Errorf("authenticated native signer anchor checkpoint is invalid") + } + if (record.Revision == 1 && record.PreviousEventRoot != [32]byte{}) || + (record.Revision > 1 && record.PreviousEventRoot == [32]byte{}) { + return fmt.Errorf( + "authenticated native signer anchor event predecessor is invalid", + ) + } + return nil +} + +func (binding *frostNativeSignerAnchorBinding) localTipMatchesRemoteRecord( + local frostsigning.NativeTBTCSignerStateWitnessTip, + remote *FrostNativeSignerStateWitnessAnchorRecord, +) bool { + return remote != nil && + frostNativeSignerCheckpointFromTip(local) == remote.Checkpoint && + local.AnchorBindingHash == binding.bindingHash && + local.AnchorServiceEpoch == remote.ServiceEpoch && + local.AnchorRevision == remote.Revision && + local.AnchorEventRoot == remote.EventRoot && + local.AnchorAcknowledgementDigest == remote.AcknowledgementDigest +} + +func frostNativeSignerCheckpointFromTip( + tip frostsigning.NativeTBTCSignerStateWitnessTip, +) FrostNativeSignerStateWitnessCheckpoint { + return FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: tip.StoreFingerprint, + Generation: tip.Generation, + PreviousStateCommitment: tip.PreviousStateCommitment, + StateImageDigest: tip.StateImageDigest, + StateCommitment: tip.StateCommitment, + } +} diff --git a/pkg/tbtc/frost_native_signer_anchor_binding_test.go b/pkg/tbtc/frost_native_signer_anchor_binding_test.go new file mode 100644 index 0000000000..d8f3fa16bb --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_binding_test.go @@ -0,0 +1,591 @@ +package tbtc + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +type testAnchorBindingStore struct { + history *FrostNativeSignerStateWitnessAnchorHistory + record *FrostNativeSignerStateWitnessAnchorRecord + proofEntries map[uint64]frostsigning.NativeTBTCSignerStateWitnessProofEntry + identity FrostNativeSignerAnchorIdentity + historyCalls int + readCalls int + casCalls int + lastExpected FrostNativeSignerStateWitnessCheckpoint + lastCandidate FrostNativeSignerStateWitnessCheckpoint +} + +func (store *testAnchorBindingStore) ReadFrostNativeSignerStateWitnessAnchor( + context.Context, +) (*FrostNativeSignerStateWitnessAnchorRecord, error) { + store.readCalls++ + if store.record == nil { + return nil, fmt.Errorf("anchor record is absent") + } + result := *store.record + return &result, nil +} + +func (store *testAnchorBindingStore) ReadFrostNativeSignerStateWitnessAnchorHistory( + _ context.Context, + floor FrostNativeSignerStateWitnessAnchorReference, +) (*FrostNativeSignerStateWitnessAnchorHistory, error) { + store.historyCalls++ + if store.history == nil || store.history.Floor != floor { + return nil, fmt.Errorf("unexpected anchor history floor") + } + return store.history, nil +} + +func (store *testAnchorBindingStore) CompareAndSwapFrostNativeSignerStateWitnessAnchor( + _ context.Context, + expected FrostNativeSignerStateWitnessCheckpoint, + candidate FrostNativeSignerStateWitnessCheckpoint, + proof []frostsigning.NativeTBTCSignerStateWitnessProofEntry, +) (*FrostNativeSignerStateWitnessAnchorCASResult, error) { + store.casCalls++ + store.lastExpected = expected + store.lastCandidate = candidate + if store.record == nil || store.record.Checkpoint != expected { + return nil, fmt.Errorf("test anchor CAS expected checkpoint mismatch") + } + current := frostNativeSignerAnchorReferenceFromRecord(store.record) + acknowledgement := testAnchorBindingAcknowledgement( + store.identity, + current, + candidate, + proof, + time.Now().Add(20*time.Second), + ) + store.record = frostNativeSignerAnchorRecord(&acknowledgement) + return &FrostNativeSignerStateWitnessAnchorCASResult{ + Acknowledgement: acknowledgement, + }, nil +} + +type testAnchorBindingFixture struct { + binding *frostNativeSignerAnchorBinding + store *testAnchorBindingStore + tip frostsigning.NativeTBTCSignerStateWitnessTip + floor FrostNativeSignerStateWitnessAnchorReference + target FrostNativeSignerStateWitnessAnchorReference + now time.Time + + recoverCalls int + acknowledgeCalls int +} + +func newTestAnchorBindingFixture( + t *testing.T, + descendant bool, +) *testAnchorBindingFixture { + t.Helper() + now := time.Now().Truncate(time.Millisecond) + storeFingerprint := [32]byte{0x11} + identity := FrostNativeSignerAnchorIdentity{ + ProtocolID: [32]byte{0x12}, + ActivationManifestHash: [32]byte{0x13}, + ActivationManifestSequence: 1, + TrustDomainID: "test-native-anchor", + EndpointLeafSPKIHash: [32]byte{0x14}, + OnlineKeyHash: [32]byte{0x15}, + OperatorFingerprint: [32]byte{0x16}, + HistoryStoreID: "test-anchor-history", + HistoryStoreFingerprint: [32]byte{0x17}, + HistoryClusterFingerprint: [32]byte{0x18}, + OfflineAuthorityHash: [32]byte{0x19}, + ClientSPKIHash: [32]byte{0x1a}, + SignerStoreFingerprint: storeFingerprint, + TransportBinding: [32]byte{0x1b}, + WitnessMaximumRecords: 100, + WitnessRotationThresholdRecords: 8, + } + identity.StreamID = ComputeFrostNativeSignerAnchorStreamID(identity) + + floorCheckpoint := testAnchorBindingCheckpoint( + storeFingerprint, + 1, + [32]byte{0x21}, + [32]byte{0x22}, + ) + floor := FrostNativeSignerStateWitnessAnchorReference{ + ServiceEpoch: 7, + Revision: 1, + EventRoot: [32]byte{0x23}, + AcknowledgementDigest: [32]byte{0x24}, + Checkpoint: floorCheckpoint, + } + target := floor + proofEntries := make( + map[uint64]frostsigning.NativeTBTCSignerStateWitnessProofEntry, + ) + events := []FrostNativeSignerStateWitnessAnchorHistoryEvent{} + var targetAcknowledgement *FrostNativeSignerCheckpointAcknowledgement + if descendant { + targetCheckpoint := testAnchorBindingCheckpoint( + storeFingerprint, + 2, + floorCheckpoint.StateCommitment, + [32]byte{0x25}, + ) + proof := []frostsigning.NativeTBTCSignerStateWitnessProofEntry{{ + Generation: targetCheckpoint.Generation, + PreviousStateCommitment: targetCheckpoint.PreviousStateCommitment, + StateImageDigest: targetCheckpoint.StateImageDigest, + StateCommitment: targetCheckpoint.StateCommitment, + }} + proofEntries[2] = proof[0] + acknowledgement := testAnchorBindingAcknowledgement( + identity, + floor, + targetCheckpoint, + proof, + now.Add(-time.Minute), + ) + acknowledgement.ExactReadRecovery = []byte(`{"fresh":"read"}`) + acknowledgement.ReadRecoveryExpiresAt = + uint64(now.Add(20 * time.Second).UnixMilli()) + targetAcknowledgement = &acknowledgement + target = frostNativeSignerAnchorReferenceFromAcknowledgement( + &acknowledgement, + ) + events = append(events, FrostNativeSignerStateWitnessAnchorHistoryEvent{ + Acknowledgement: acknowledgement, + WitnessProof: proof, + }) + } + + var record *FrostNativeSignerStateWitnessAnchorRecord + if targetAcknowledgement != nil { + record = frostNativeSignerAnchorRecord(targetAcknowledgement) + } else { + record = &FrostNativeSignerStateWitnessAnchorRecord{ + Checkpoint: floor.Checkpoint, + BindingHash: ComputeFrostNativeSignerAnchorBindingHash(identity), + AcknowledgementDigest: floor.AcknowledgementDigest, + OperationID: [32]byte{0x26}, + TransitionDigest: [32]byte{0x27}, + ServiceEpoch: floor.ServiceEpoch, + Revision: floor.Revision, + EventRoot: floor.EventRoot, + AcknowledgementJSON: []byte(`{"floor":"ack"}`), + AcknowledgementExpires: uint64(now.Add(-time.Minute).UnixMilli()), + ReadRecoveryJSON: []byte(`{"fresh":"floor-read"}`), + ReadRecoveryExpires: uint64(now.Add(20 * time.Second).UnixMilli()), + } + } + store := &testAnchorBindingStore{ + record: record, + proofEntries: proofEntries, + identity: identity, + } + store.history = &FrostNativeSignerStateWitnessAnchorHistory{ + Floor: floor, + Target: target, + Events: events, + FinalRead: record, + } + fixture := &testAnchorBindingFixture{ + store: store, + floor: floor, + target: target, + now: now, + } + fixture.tip = testAnchorBindingTip( + target.Checkpoint, + floor.Checkpoint, + ComputeFrostNativeSignerAnchorBindingHash(identity), + target, + ) + manifest := FrostNativeSignerAnchorManifest{ + Identity: identity, + MinimumServiceEpoch: floor.ServiceEpoch, + MinimumRevision: floor.Revision, + MinimumEventRoot: floor.EventRoot, + MinimumAcknowledgementDigest: floor.AcknowledgementDigest, + MinimumCheckpoint: floor.Checkpoint, + WitnessMaximumRecords: identity.WitnessMaximumRecords, + WitnessRotationThresholdRecords: identity.WitnessRotationThresholdRecords, + } + binding, err := newFrostNativeSignerAnchorBinding( + store, + manifest, + func() (*frostsigning.NativeTBTCSignerStateWitnessTip, error) { + result := fixture.tip + return &result, nil + }, + func( + request *frostsigning.NativeTBTCSignerStateWitnessProofRequest, + ) (*frostsigning.NativeTBTCSignerStateWitnessProof, error) { + return store.proof(request) + }, + func( + []byte, + ) (*frostsigning.NativeTBTCSignerStateWitnessCheckpointAcknowledgementResult, error) { + fixture.acknowledgeCalls++ + return fixture.installRecordAsAcknowledgement(false), nil + }, + func( + []byte, + ) (*frostsigning.NativeTBTCSignerStateWitnessCheckpointRecoveryResult, error) { + fixture.recoverCalls++ + return fixture.installRecordAsRecovery(), nil + }, + ) + if err != nil { + t.Fatal(err) + } + binding.now = func() time.Time { return fixture.now } + fixture.binding = binding + return fixture +} + +func (store *testAnchorBindingStore) proof( + request *frostsigning.NativeTBTCSignerStateWitnessProofRequest, +) (*frostsigning.NativeTBTCSignerStateWitnessProof, error) { + if request == nil { + return nil, fmt.Errorf("nil proof request") + } + entries := make( + []frostsigning.NativeTBTCSignerStateWitnessProofEntry, + 0, + request.TargetGeneration-request.AncestorGeneration, + ) + previousCommitment := request.AncestorCommitment + for generation := request.AncestorGeneration + 1; ; generation++ { + entry, ok := store.proofEntries[generation] + if !ok || entry.PreviousStateCommitment != previousCommitment { + return nil, fmt.Errorf("missing test proof generation [%d]", generation) + } + entries = append(entries, entry) + previousCommitment = entry.StateCommitment + if generation == request.TargetGeneration { + break + } + } + return &frostsigning.NativeTBTCSignerStateWitnessProof{ + Schema: frostsigning.NativeTBTCSignerStateWitnessProofSchema, + StoreFingerprint: request.StoreFingerprint, + AncestorGeneration: request.AncestorGeneration, + AncestorCommitment: request.AncestorCommitment, + TargetGeneration: request.TargetGeneration, + TargetCommitment: request.TargetCommitment, + Complete: true, + Entries: entries, + }, nil +} + +func (fixture *testAnchorBindingFixture) installRecordAsAcknowledgement( + idempotent bool, +) *frostsigning.NativeTBTCSignerStateWitnessCheckpointAcknowledgementResult { + record := fixture.store.record + fixture.tip.AnchorBindingHash = record.BindingHash + fixture.tip.AnchorServiceEpoch = record.ServiceEpoch + fixture.tip.AnchorRevision = record.Revision + fixture.tip.AnchorEventRoot = record.EventRoot + fixture.tip.AnchorAcknowledgementDigest = record.AcknowledgementDigest + return &frostsigning.NativeTBTCSignerStateWitnessCheckpointAcknowledgementResult{ + Schema: frostsigning.NativeTBTCSignerStateWitnessCheckpointAcknowledgementResultSchema, + Acknowledged: true, + Idempotent: idempotent, + StoreFingerprint: fixture.tip.StoreFingerprint, + Generation: fixture.tip.Generation, + StateCommitment: fixture.tip.StateCommitment, + WitnessBaseGeneration: fixture.tip.WitnessBaseGeneration, + WitnessBaseCommitment: fixture.tip.WitnessBaseCommitment, + AnchorServiceEpoch: record.ServiceEpoch, + AnchorServiceRevision: record.Revision, + AnchorEventRoot: record.EventRoot, + AnchorAcknowledgementDigest: record.AcknowledgementDigest, + } +} + +func (fixture *testAnchorBindingFixture) installRecordAsRecovery() *frostsigning.NativeTBTCSignerStateWitnessCheckpointRecoveryResult { + acknowledgement := fixture.installRecordAsAcknowledgement(true) + return &frostsigning.NativeTBTCSignerStateWitnessCheckpointRecoveryResult{ + Schema: frostsigning.NativeTBTCSignerStateWitnessCheckpointRecoveryResultSchema, + Recovered: true, + Idempotent: acknowledgement.Idempotent, + Rotated: acknowledgement.Rotated, + StoreFingerprint: acknowledgement.StoreFingerprint, + Generation: acknowledgement.Generation, + StateCommitment: acknowledgement.StateCommitment, + WitnessBaseGeneration: acknowledgement.WitnessBaseGeneration, + WitnessBaseCommitment: acknowledgement.WitnessBaseCommitment, + AnchorServiceEpoch: acknowledgement.AnchorServiceEpoch, + AnchorServiceRevision: acknowledgement.AnchorServiceRevision, + AnchorEventRoot: acknowledgement.AnchorEventRoot, + AnchorAcknowledgementDigest: acknowledgement.AnchorAcknowledgementDigest, + } +} + +func TestFrostNativeSignerAnchorBindingReconcilesAuthenticatedDescendant( + t *testing.T, +) { + fixture := newTestAnchorBindingFixture(t, true) + result, err := fixture.binding.reconcileStartup(context.Background()) + if err != nil { + t.Fatalf("authenticated descendant startup was rejected: %v", err) + } + if *result != fixture.tip || fixture.store.historyCalls != 1 || + fixture.recoverCalls != 0 || fixture.store.casCalls != 0 { + t.Fatal("authenticated descendant did not reconcile exactly") + } +} + +func TestFrostNativeSignerAnchorBindingRecoversMissingOrPreviousAnchor( + t *testing.T, +) { + for _, descendant := range []bool{false, true} { + name := "floor" + if descendant { + name = "descendant" + } + t.Run(name+" missing anchor", func(t *testing.T) { + fixture := newTestAnchorBindingFixture(t, descendant) + clearTestAnchorBindingTipAnchor(&fixture.tip) + result, err := fixture.binding.reconcileStartup(context.Background()) + if err != nil { + t.Fatalf("missing exact-checkpoint anchor was not recovered: %v", err) + } + if result.AnchorRevision != fixture.target.Revision || + fixture.recoverCalls != 1 { + t.Fatal("missing anchor recovery did not install exact target") + } + }) + } + + t.Run("immediately preceding anchor", func(t *testing.T) { + fixture := newTestAnchorBindingFixture(t, true) + setTestAnchorBindingTipAnchor( + &fixture.tip, + ComputeFrostNativeSignerAnchorBindingHash( + fixture.binding.identity, + ), + fixture.floor, + ) + result, err := fixture.binding.reconcileStartup(context.Background()) + if err != nil { + t.Fatalf("immediately preceding anchor was not recovered: %v", err) + } + if result.AnchorRevision != fixture.target.Revision || + fixture.recoverCalls != 1 { + t.Fatal("preceding anchor recovery did not install exact target") + } + }) +} + +func TestFrostNativeSignerAnchorBindingCatchesUpLocalAheadState(t *testing.T) { + fixture := newTestAnchorBindingFixture(t, true) + targetCheckpoint := fixture.target.Checkpoint + localCheckpoint := testAnchorBindingCheckpoint( + targetCheckpoint.StoreFingerprint, + 3, + targetCheckpoint.StateCommitment, + [32]byte{0x31}, + ) + fixture.store.proofEntries[3] = + frostsigning.NativeTBTCSignerStateWitnessProofEntry{ + Generation: 3, + PreviousStateCommitment: localCheckpoint.PreviousStateCommitment, + StateImageDigest: localCheckpoint.StateImageDigest, + StateCommitment: localCheckpoint.StateCommitment, + } + fixture.tip = testAnchorBindingTip( + localCheckpoint, + fixture.floor.Checkpoint, + ComputeFrostNativeSignerAnchorBindingHash(fixture.binding.identity), + fixture.target, + ) + result, err := fixture.binding.reconcileStartup(context.Background()) + if err != nil { + t.Fatalf("local-ahead crash state was not caught up: %v", err) + } + if result.Generation != 3 || fixture.store.casCalls != 1 || + fixture.acknowledgeCalls != 1 || + fixture.store.lastExpected != targetCheckpoint || + fixture.store.lastCandidate != localCheckpoint { + t.Fatal("local-ahead startup did not CAS and install the exact checkpoint") + } +} + +func TestFrostNativeSignerAnchorBindingRejectsRollbackForkAndPartialAnchor( + t *testing.T, +) { + t.Run("remote ahead", func(t *testing.T) { + fixture := newTestAnchorBindingFixture(t, true) + fixture.tip = testAnchorBindingTip( + fixture.floor.Checkpoint, + fixture.floor.Checkpoint, + ComputeFrostNativeSignerAnchorBindingHash(fixture.binding.identity), + fixture.floor, + ) + if _, err := fixture.binding.reconcileStartup( + context.Background(), + ); err == nil || !strings.Contains(err.Error(), "behind") { + t.Fatalf("remote-ahead rollback was accepted: %v", err) + } + }) + + t.Run("equal generation fork", func(t *testing.T) { + fixture := newTestAnchorBindingFixture(t, true) + fork := testAnchorBindingCheckpoint( + fixture.target.Checkpoint.StoreFingerprint, + fixture.target.Checkpoint.Generation, + fixture.floor.Checkpoint.StateCommitment, + [32]byte{0xee}, + ) + fixture.tip = testAnchorBindingTip( + fork, + fixture.floor.Checkpoint, + ComputeFrostNativeSignerAnchorBindingHash(fixture.binding.identity), + fixture.target, + ) + if _, err := fixture.binding.reconcileStartup( + context.Background(), + ); err == nil || !strings.Contains(err.Error(), "forks") { + t.Fatalf("equal-generation fork was accepted: %v", err) + } + }) + + t.Run("partial anchor", func(t *testing.T) { + fixture := newTestAnchorBindingFixture(t, true) + clearTestAnchorBindingTipAnchor(&fixture.tip) + fixture.tip.AnchorBindingHash = + ComputeFrostNativeSignerAnchorBindingHash(fixture.binding.identity) + if _, err := fixture.binding.reconcileStartup( + context.Background(), + ); err == nil || !strings.Contains(err.Error(), "partial") { + t.Fatalf("partial local anchor was accepted: %v", err) + } + }) + + t.Run("expired fresh Read", func(t *testing.T) { + fixture := newTestAnchorBindingFixture(t, true) + clearTestAnchorBindingTipAnchor(&fixture.tip) + fixture.store.record.ReadRecoveryExpires = + uint64(fixture.now.UnixMilli()) + if _, err := fixture.binding.reconcileStartup( + context.Background(), + ); err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("expired recovery wrapper was accepted: %v", err) + } + }) +} + +func testAnchorBindingCheckpoint( + storeFingerprint [32]byte, + generation uint64, + previous [32]byte, + image [32]byte, +) FrostNativeSignerStateWitnessCheckpoint { + return FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: storeFingerprint, + Generation: generation, + PreviousStateCommitment: previous, + StateImageDigest: image, + StateCommitment: frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + generation, + previous, + image, + ), + } +} + +func testAnchorBindingAcknowledgement( + identity FrostNativeSignerAnchorIdentity, + previous FrostNativeSignerStateWitnessAnchorReference, + candidate FrostNativeSignerStateWitnessCheckpoint, + proof []frostsigning.NativeTBTCSignerStateWitnessProofEntry, + expires time.Time, +) FrostNativeSignerCheckpointAcknowledgement { + operationID := [32]byte{byte(candidate.Generation + 0x40)} + acknowledgement := FrostNativeSignerCheckpointAcknowledgement{ + BindingHash: ComputeFrostNativeSignerAnchorBindingHash(identity), + RequestDigest: [32]byte{0x41}, + Nonce: [32]byte{0x42}, + Status: "applied", + ServiceEpoch: previous.ServiceEpoch, + Revision: previous.Revision + 1, + PreviousEventRoot: previous.EventRoot, + Checkpoint: candidate, + OperationID: operationID, + TransitionDigest: computeFrostNativeSignerAnchorTransitionDigest( + identity, + operationID, + previous.Checkpoint, + candidate, + proof, + ), + CommittedAtUnixMs: uint64(expires.Add(-20 * time.Second).UnixMilli()), + ExpiresAtUnixMs: uint64(expires.UnixMilli()), + SigningDigest: [32]byte{0x43}, + Signature: [64]byte{0x44}, + ExactAcknowledgement: []byte(fmt.Sprintf( + `{"testAcknowledgementRevision":"%d"}`, + previous.Revision+1, + )), + } + acknowledgement.EventRoot = + computeFrostNativeSignerAnchorEventRoot(acknowledgement) + acknowledgement.AcknowledgementDigest = + computeFrostNativeSignerCheckpointAcknowledgementDigest( + acknowledgement.SigningDigest, + acknowledgement.Signature, + identity.OnlineKeyHash, + ) + return acknowledgement +} + +func testAnchorBindingTip( + checkpoint FrostNativeSignerStateWitnessCheckpoint, + base FrostNativeSignerStateWitnessCheckpoint, + bindingHash [32]byte, + reference FrostNativeSignerStateWitnessAnchorReference, +) frostsigning.NativeTBTCSignerStateWitnessTip { + return frostsigning.NativeTBTCSignerStateWitnessTip{ + Schema: frostsigning.NativeTBTCSignerStateWitnessTipSchema, + StoreFingerprint: checkpoint.StoreFingerprint, + Generation: checkpoint.Generation, + PreviousStateCommitment: checkpoint.PreviousStateCommitment, + StateImageDigest: checkpoint.StateImageDigest, + StateCommitment: checkpoint.StateCommitment, + WitnessBaseGeneration: base.Generation, + WitnessBaseCommitment: base.StateCommitment, + AnchorBindingHash: bindingHash, + AnchorServiceEpoch: reference.ServiceEpoch, + AnchorRevision: reference.Revision, + AnchorEventRoot: reference.EventRoot, + AnchorAcknowledgementDigest: reference.AcknowledgementDigest, + } +} + +func clearTestAnchorBindingTipAnchor( + tip *frostsigning.NativeTBTCSignerStateWitnessTip, +) { + tip.AnchorBindingHash = [32]byte{} + tip.AnchorServiceEpoch = 0 + tip.AnchorRevision = 0 + tip.AnchorEventRoot = [32]byte{} + tip.AnchorAcknowledgementDigest = [32]byte{} +} + +func setTestAnchorBindingTipAnchor( + tip *frostsigning.NativeTBTCSignerStateWitnessTip, + bindingHash [32]byte, + reference FrostNativeSignerStateWitnessAnchorReference, +) { + tip.AnchorBindingHash = bindingHash + tip.AnchorServiceEpoch = reference.ServiceEpoch + tip.AnchorRevision = reference.Revision + tip.AnchorEventRoot = reference.EventRoot + tip.AnchorAcknowledgementDigest = reference.AcknowledgementDigest +} diff --git a/pkg/tbtc/frost_native_signer_anchor_client.go b/pkg/tbtc/frost_native_signer_anchor_client.go new file mode 100644 index 0000000000..37ca1435ec --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_client.go @@ -0,0 +1,1514 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "mime" + "net" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "sync" + "time" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +const ( + frostNativeSignerAnchorDefaultRequestTimeout = 3 * time.Second + frostNativeSignerAnchorMaximumRequestTimeout = 30 * time.Second + frostNativeSignerAnchorDefaultClockSkew = 5 * time.Second + frostNativeSignerAnchorMaximumClockSkew = 5 * time.Second + frostNativeSignerAnchorDefaultAcknowledgementLifetime = 30 * time.Second + frostNativeSignerAnchorMaximumAcknowledgementLifetime = 30 * time.Second +) + +// FrostNativeSignerAnchorClientConfig contains runtime transport material and +// the complete identity extracted from an independently authenticated +// activation manifest. OnlinePublicKeySPKI and ClientPrivateKey are copied by +// the constructor; their hashes must exactly match Identity. +type FrostNativeSignerAnchorClientConfig struct { + Endpoint string + RequestTimeout time.Duration + MaximumClockSkew time.Duration + MaximumAcknowledgementLifetime time.Duration + TLSRootCAs *x509.CertPool + ClientPrivateKey ed25519.PrivateKey + OnlinePublicKeySPKI []byte + Identity FrostNativeSignerAnchorIdentity + Random io.Reader + Now func() time.Time +} + +// FrostNativeSignerAnchorClient is a serialized fail-closed authenticated +// client for the independent state-witness history service. +type FrostNativeSignerAnchorClient struct { + readEndpoint string + advanceEndpoint string + historyEndpoint string + httpClient *http.Client + requestTimeout time.Duration + clockSkew time.Duration + maximumAckLife time.Duration + + identity FrostNativeSignerAnchorIdentity + bindingHash [32]byte + clientKey ed25519.PrivateKey + clientSPKIDER []byte + clientSPKIBase64 string + onlineKey ed25519.PublicKey + random io.Reader + now func() time.Time + + mutex sync.Mutex + last *FrostNativeSignerCheckpointAcknowledgement + readPermit *FrostNativeSignerStateWitnessCheckpoint + readPermitExpires uint64 + poisoned error +} + +// NewFrostNativeSignerAnchorClient validates all immutable pins before it +// constructs a transport. HTTPS uses normal PKIX verification plus an exact +// leaf-SPKI pin; plaintext HTTP is restricted to a canonical numeric loopback +// endpoint. Proxies and redirects are always disabled. +func NewFrostNativeSignerAnchorClient( + config FrostNativeSignerAnchorClientConfig, +) (*FrostNativeSignerAnchorClient, error) { + endpoint, https, err := validateFrostNativeSignerAnchorEndpoint(config.Endpoint) + if err != nil { + return nil, err + } + if err := validateFrostNativeSignerAnchorIdentity(config.Identity, https); err != nil { + return nil, fmt.Errorf("invalid native signer anchor identity: %w", err) + } + if ComputeFrostNativeSignerAnchorTransportBinding(config.Endpoint) != + config.Identity.TransportBinding { + return nil, fmt.Errorf("native signer anchor endpoint differs from its transport binding") + } + + if len(config.ClientPrivateKey) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("native signer anchor client key is not Ed25519") + } + clientSPKIDER, err := x509.MarshalPKIXPublicKey(config.ClientPrivateKey.Public()) + if err != nil { + return nil, fmt.Errorf("cannot encode native signer anchor client key: %w", err) + } + if sha256.Sum256(clientSPKIDER) != config.Identity.ClientSPKIHash { + return nil, fmt.Errorf("native signer anchor client key differs from its identity") + } + onlineKey, err := parseFrostNativeSignerAnchorOnlineKey(config.OnlinePublicKeySPKI) + if err != nil { + return nil, err + } + if sha256.Sum256(config.OnlinePublicKeySPKI) != config.Identity.OnlineKeyHash { + return nil, fmt.Errorf("native signer anchor online key differs from its identity") + } + + requestTimeout := config.RequestTimeout + if requestTimeout == 0 { + requestTimeout = frostNativeSignerAnchorDefaultRequestTimeout + } + if requestTimeout <= 0 || requestTimeout > frostNativeSignerAnchorMaximumRequestTimeout { + return nil, fmt.Errorf("native signer anchor request timeout is invalid") + } + clockSkew := config.MaximumClockSkew + if clockSkew == 0 { + clockSkew = frostNativeSignerAnchorDefaultClockSkew + } + if clockSkew < 0 || clockSkew > frostNativeSignerAnchorMaximumClockSkew { + return nil, fmt.Errorf("native signer anchor maximum clock skew is invalid") + } + maximumAckLife := config.MaximumAcknowledgementLifetime + if maximumAckLife == 0 { + maximumAckLife = frostNativeSignerAnchorDefaultAcknowledgementLifetime + } + if maximumAckLife <= 0 || + maximumAckLife > frostNativeSignerAnchorMaximumAcknowledgementLifetime { + return nil, fmt.Errorf("native signer anchor acknowledgement lifetime is invalid") + } + randomSource := config.Random + if randomSource == nil { + randomSource = rand.Reader + } + now := config.Now + if now == nil { + now = time.Now + } + + transport := &http.Transport{ + Proxy: nil, + DialContext: (&net.Dialer{Timeout: requestTimeout, KeepAlive: -1}).DialContext, + DisableKeepAlives: true, + DisableCompression: true, + ForceAttemptHTTP2: false, + MaxConnsPerHost: 1, + ResponseHeaderTimeout: requestTimeout, + TLSHandshakeTimeout: requestTimeout, + ExpectContinueTimeout: time.Second, + MaxResponseHeaderBytes: 16 * 1024, + } + if https { + expectedLeafSPKIHash := config.Identity.EndpointLeafSPKIHash + var rootCAs *x509.CertPool + if config.TLSRootCAs != nil { + rootCAs = config.TLSRootCAs.Clone() + } + transport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: rootCAs, + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.VerifiedChains) == 0 || len(state.PeerCertificates) == 0 { + return fmt.Errorf("native signer anchor TLS peer is not PKIX-verified") + } + if sha256.Sum256(state.PeerCertificates[0].RawSubjectPublicKeyInfo) != + expectedLeafSPKIHash { + return fmt.Errorf("native signer anchor TLS leaf SPKI mismatch") + } + return nil + }, + } + } else if config.TLSRootCAs != nil { + return nil, fmt.Errorf("loopback HTTP native signer anchor cannot configure TLS roots") + } + + readEndpoint := frostNativeSignerAnchorOperationEndpoint(endpoint, "read") + advanceEndpoint := frostNativeSignerAnchorOperationEndpoint(endpoint, "advance") + historyEndpoint := frostNativeSignerAnchorOperationEndpoint(endpoint, "history") + // Copy secret material only after every fallible validation/construction + // step. Failed constructors therefore never leave an additional secret copy + // waiting for garbage collection. + clientKey := append(ed25519.PrivateKey{}, config.ClientPrivateKey...) + return &FrostNativeSignerAnchorClient{ + readEndpoint: readEndpoint, + advanceEndpoint: advanceEndpoint, + historyEndpoint: historyEndpoint, + httpClient: &http.Client{ + Transport: transport, + Timeout: requestTimeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return fmt.Errorf("native signer anchor redirects are disabled") + }, + }, + requestTimeout: requestTimeout, + clockSkew: clockSkew, + maximumAckLife: maximumAckLife, + identity: config.Identity, + bindingHash: ComputeFrostNativeSignerAnchorBindingHash(config.Identity), + clientKey: clientKey, + clientSPKIDER: append([]byte{}, clientSPKIDER...), + clientSPKIBase64: base64StdEncoding(clientSPKIDER), + onlineKey: append(ed25519.PublicKey{}, onlineKey...), + random: randomSource, + now: now, + }, nil +} + +// ReadFrostNativeSignerStateWitnessAnchor performs a fresh nonce-bound signed +// remote read. Callers must execute this read before every native operation; +// cached records are exposed only for monotonicity enforcement, never as a +// substitute for the remote read. +func (client *FrostNativeSignerAnchorClient) ReadFrostNativeSignerStateWitnessAnchor( + ctx context.Context, +) (*FrostNativeSignerStateWitnessAnchorRecord, error) { + if client == nil { + return nil, fmt.Errorf("native signer anchor client is nil") + } + if ctx == nil { + return nil, fmt.Errorf("native signer anchor context is nil") + } + client.mutex.Lock() + defer client.mutex.Unlock() + if client.poisoned != nil { + return nil, fmt.Errorf("native signer anchor client is poisoned: %w", client.poisoned) + } + acknowledgement, err := client.readLocked(ctx) + if err != nil { + return nil, err + } + if err := client.acceptMonotonicAcknowledgementLocked(acknowledgement); err != nil { + client.poisoned = err + return nil, fmt.Errorf("native signer anchor client is poisoned: %w", err) + } + checkpoint := acknowledgement.Checkpoint + client.readPermit = &checkpoint + client.readPermitExpires = acknowledgement.ReadRecoveryExpiresAt + return frostNativeSignerAnchorRecord(acknowledgement), nil +} + +// CompareAndSwapFrostNativeSignerStateWitnessAnchor atomically advances the +// independent checkpoint. A transport-ambiguous CAS is reconciled by a fresh +// signed read: only the exact candidate plus operation/transition succeeds, +// and only the exact expected checkpoint permits one retry. +func (client *FrostNativeSignerAnchorClient) CompareAndSwapFrostNativeSignerStateWitnessAnchor( + ctx context.Context, + expected FrostNativeSignerStateWitnessCheckpoint, + candidate FrostNativeSignerStateWitnessCheckpoint, + proof []frostsigning.NativeTBTCSignerStateWitnessProofEntry, +) (*FrostNativeSignerStateWitnessAnchorCASResult, error) { + if client == nil { + return nil, fmt.Errorf("native signer anchor client is nil") + } + if ctx == nil { + return nil, fmt.Errorf("native signer anchor context is nil") + } + if err := validateFrostNativeSignerAnchorTransition( + expected, + candidate, + proof, + client.identity.SignerStoreFingerprint, + ); err != nil { + return nil, err + } + proofCopy := append([]frostsigning.NativeTBTCSignerStateWitnessProofEntry{}, proof...) + + client.mutex.Lock() + defer client.mutex.Unlock() + if client.poisoned != nil { + return nil, fmt.Errorf("native signer anchor client is poisoned: %w", client.poisoned) + } + nowUnixMs := client.now().UnixMilli() + if client.last == nil || client.last.Checkpoint != expected || + client.readPermit == nil || *client.readPermit != expected || + nowUnixMs < 0 || client.readPermitExpires <= uint64(nowUnixMs) { + return nil, fmt.Errorf( + "native signer anchor CAS requires a fresh authenticated exact-expected read", + ) + } + client.readPermit = nil + client.readPermitExpires = 0 + operationID, err := client.randomBytes32() + if err != nil { + return nil, fmt.Errorf("cannot create native signer anchor operation ID: %w", err) + } + transitionDigest := computeFrostNativeSignerAnchorTransitionDigest( + client.identity, + operationID, + expected, + candidate, + proofCopy, + ) + + acknowledgement, ambiguous, err := client.casAttemptLocked( + ctx, + expected, + candidate, + proofCopy, + operationID, + transitionDigest, + ) + if err == nil { + if err := client.acceptMonotonicAcknowledgementLocked(acknowledgement); err != nil { + client.poisoned = err + return nil, fmt.Errorf("native signer anchor client is poisoned: %w", err) + } + return &FrostNativeSignerStateWitnessAnchorCASResult{ + Acknowledgement: *acknowledgement, + }, nil + } + if !ambiguous { + return nil, err + } + + recovered, retry, recoveryErr := client.reconcileAmbiguousCASLocked( + ctx, + expected, + candidate, + operationID, + transitionDigest, + ) + if recoveryErr != nil { + client.poisoned = recoveryErr + return nil, fmt.Errorf("native signer anchor CAS outcome is unsafe: %w", recoveryErr) + } + if recovered != nil { + return &FrostNativeSignerStateWitnessAnchorCASResult{ + Acknowledgement: *recovered, + Recovered: true, + }, nil + } + if !retry { + return nil, fmt.Errorf("native signer anchor CAS failed without a safe retry state: %w", err) + } + + acknowledgement, ambiguous, retryErr := client.casAttemptLocked( + ctx, + expected, + candidate, + proofCopy, + operationID, + transitionDigest, + ) + if retryErr == nil { + if err := client.acceptMonotonicAcknowledgementLocked(acknowledgement); err != nil { + client.poisoned = err + return nil, fmt.Errorf("native signer anchor client is poisoned: %w", err) + } + return &FrostNativeSignerStateWitnessAnchorCASResult{ + Acknowledgement: *acknowledgement, + Recovered: true, + }, nil + } + if !ambiguous { + return nil, retryErr + } + recovered, retry, recoveryErr = client.reconcileAmbiguousCASLocked( + ctx, + expected, + candidate, + operationID, + transitionDigest, + ) + if recoveryErr != nil { + client.poisoned = recoveryErr + return nil, fmt.Errorf("native signer anchor CAS retry outcome is unsafe: %w", recoveryErr) + } + if recovered != nil { + return &FrostNativeSignerStateWitnessAnchorCASResult{ + Acknowledgement: *recovered, + Recovered: true, + }, nil + } + if retry { + return nil, fmt.Errorf( + "native signer anchor CAS remained ambiguous but a fresh signed read retained the exact expected checkpoint", + ) + } + return nil, retryErr +} + +func (client *FrostNativeSignerAnchorClient) reconcileAmbiguousCASLocked( + ctx context.Context, + expected FrostNativeSignerStateWitnessCheckpoint, + candidate FrostNativeSignerStateWitnessCheckpoint, + operationID [32]byte, + transitionDigest [32]byte, +) (*FrostNativeSignerCheckpointAcknowledgement, bool, error) { + acknowledgement, err := client.readLocked(ctx) + if err != nil { + return nil, false, fmt.Errorf("cannot reconcile with a fresh authenticated read: %w", err) + } + switch acknowledgement.Checkpoint { + case candidate: + if acknowledgement.OperationID != operationID || + acknowledgement.TransitionDigest != transitionDigest { + return nil, false, fmt.Errorf( + "candidate checkpoint is bound to another operation or transition", + ) + } + if err := client.acceptMonotonicAcknowledgementLocked(acknowledgement); err != nil { + return nil, false, err + } + return acknowledgement, false, nil + case expected: + if err := client.acceptMonotonicAcknowledgementLocked(acknowledgement); err != nil { + return nil, false, err + } + return nil, true, nil + default: + return nil, false, fmt.Errorf( + "authenticated checkpoint is neither the exact expected nor exact candidate state", + ) + } +} + +func (client *FrostNativeSignerAnchorClient) ReadFrostNativeSignerStateWitnessAnchorHistory( + ctx context.Context, + floor FrostNativeSignerStateWitnessAnchorReference, +) (*FrostNativeSignerStateWitnessAnchorHistory, error) { + if client == nil { + return nil, fmt.Errorf("native signer anchor client is nil") + } + if ctx == nil { + return nil, fmt.Errorf("native signer anchor history context is nil") + } + if floor.Revision == ^uint64(0) { + return nil, fmt.Errorf("native signer anchor history floor revision cannot advance") + } + + client.mutex.Lock() + defer client.mutex.Unlock() + if client.poisoned != nil { + return nil, fmt.Errorf("native signer anchor client is poisoned: %w", client.poisoned) + } + client.readPermit = nil + client.readPermitExpires = 0 + firstTargetAcknowledgement, err := client.readLocked(ctx) + if err != nil { + return nil, fmt.Errorf("cannot read native signer anchor history target: %w", err) + } + target := frostNativeSignerAnchorReferenceFromAcknowledgement( + firstTargetAcknowledgement, + ) + if err := validateFrostNativeSignerAnchorHistoryBounds( + floor, + target, + client.identity.SignerStoreFingerprint, + ); err != nil { + return nil, err + } + if client.last != nil { + lastReference := frostNativeSignerAnchorReferenceFromAcknowledgement(client.last) + if lastReference != floor && lastReference != target { + return nil, fmt.Errorf( + "native signer anchor history floor differs from the authenticated process baseline", + ) + } + if lastReference == target && + !equalFrostNativeSignerCheckpointAcknowledgements( + client.last, + firstTargetAcknowledgement, + ) { + return nil, fmt.Errorf( + "native signer anchor target acknowledgement differs at an equal revision", + ) + } + } + + startRevision := floor.Revision + 1 + current := floor + events := make([]FrostNativeSignerStateWitnessAnchorHistoryEvent, 0) + totalProofEntries := 0 + complete := false + for page := 0; page < FrostNativeSignerAnchorMaximumHistoryPages; page++ { + remainingEvents := FrostNativeSignerAnchorMaximumHistoryEvents - len(events) + remainingProof := FrostNativeSignerAnchorMaximumHistoryProofEntries - + totalProofEntries + if remainingEvents <= 0 || remainingProof <= 0 { + return nil, fmt.Errorf("native signer anchor history exceeds aggregate bounds") + } + maximumEvents := remainingEvents + if maximumEvents > FrostNativeSignerAnchorMaximumHistoryEventsPerPage { + maximumEvents = FrostNativeSignerAnchorMaximumHistoryEventsPerPage + } + maximumProofEntries := remainingProof + if maximumProofEntries > + FrostNativeSignerAnchorMaximumHistoryProofEntriesPerPage { + maximumProofEntries = + FrostNativeSignerAnchorMaximumHistoryProofEntriesPerPage + } + pageEvents, pageCurrent, nextRevision, pageComplete, err := + client.readHistoryPageLocked( + ctx, + floor, + target, + current, + startRevision, + uint64(maximumEvents), + uint64(maximumProofEntries), + ) + if err != nil { + return nil, err + } + for _, event := range pageEvents { + totalProofEntries += len(event.WitnessProof) + } + events = append(events, pageEvents...) + current = pageCurrent + if pageComplete { + complete = true + break + } + startRevision = nextRevision + } + if !complete || current != target { + return nil, fmt.Errorf("native signer anchor history did not reach the exact target") + } + + finalAcknowledgement, err := client.readLocked(ctx) + if err != nil { + return nil, fmt.Errorf("cannot perform final native signer anchor read: %w", err) + } + if frostNativeSignerAnchorReferenceFromAcknowledgement(finalAcknowledgement) != target { + return nil, fmt.Errorf("native signer anchor changed after history validation") + } + if !equalFrostNativeSignerCheckpointAcknowledgements( + firstTargetAcknowledgement, + finalAcknowledgement, + ) { + return nil, fmt.Errorf("native signer anchor target acknowledgement changed during history validation") + } + copy := *finalAcknowledgement + copy.ExactAcknowledgement = append( + []byte{}, + finalAcknowledgement.ExactAcknowledgement..., + ) + copy.ExactReadRecovery = append( + []byte{}, + finalAcknowledgement.ExactReadRecovery..., + ) + client.last = © + checkpoint := finalAcknowledgement.Checkpoint + client.readPermit = &checkpoint + client.readPermitExpires = finalAcknowledgement.ReadRecoveryExpiresAt + return &FrostNativeSignerStateWitnessAnchorHistory{ + Floor: floor, + Target: target, + Events: events, + FinalRead: frostNativeSignerAnchorRecord(finalAcknowledgement), + }, nil +} + +func (client *FrostNativeSignerAnchorClient) readHistoryPageLocked( + ctx context.Context, + floor FrostNativeSignerStateWitnessAnchorReference, + target FrostNativeSignerStateWitnessAnchorReference, + prior FrostNativeSignerStateWitnessAnchorReference, + startRevision uint64, + maximumEvents uint64, + maximumProofEntries uint64, +) ( + []FrostNativeSignerStateWitnessAnchorHistoryEvent, + FrostNativeSignerStateWitnessAnchorReference, + uint64, + bool, + error, +) { + nonce, err := client.randomBytes32() + if err != nil { + return nil, prior, 0, false, fmt.Errorf( + "cannot create native signer anchor history nonce: %w", + err, + ) + } + transcript := frostNativeSignerAnchorHistoryRequestTranscript( + client.identity, + nonce, + floor, + target, + startRevision, + maximumEvents, + maximumProofEntries, + client.clientSPKIDER, + ) + requestDigest := sha256.Sum256(transcript) + request := frostNativeSignerAnchorHistoryRequest{ + Schema: FrostNativeSignerAnchorHistoryRequestSchema, + Payload: frostNativeSignerAnchorHistoryRequestPayload{ + Kind: "history", + Nonce: frostNativeSignerAnchorHex32(nonce), + BindingHash: frostNativeSignerAnchorHex32(client.bindingHash), + Identity: frostNativeSignerAnchorIdentityToWire(client.identity), + FloorRef: frostNativeSignerAnchorHistoryReferenceToWire(floor), + TargetRef: frostNativeSignerAnchorHistoryReferenceToWire(target), + StartRevision: strconv.FormatUint(startRevision, 10), + MaximumEvents: strconv.FormatUint(maximumEvents, 10), + MaximumProofEntries: strconv.FormatUint(maximumProofEntries, 10), + }, + ClientPublicKeySPKI: client.clientSPKIBase64, + Signature: frostNativeSignerAnchorSignatureHex( + ed25519.Sign(client.clientKey, transcript), + ), + } + payload, err := json.Marshal(request) + if err != nil { + return nil, prior, 0, false, err + } + responseBytes, _, err := client.postWithResponseLimit( + ctx, + client.historyEndpoint, + payload, + frostNativeSignerAnchorMaximumHistoryResponseBytes, + ) + if err != nil { + return nil, prior, 0, false, err + } + response := frostNativeSignerAnchorHistoryResponse{} + if err := decodeStrictFrostNativeSignerAnchorJSON( + responseBytes, + &response, + ); err != nil { + return nil, prior, 0, false, err + } + return client.validateHistoryPageLocked( + response, + requestDigest, + nonce, + floor, + target, + prior, + startRevision, + maximumEvents, + maximumProofEntries, + ) +} + +func (client *FrostNativeSignerAnchorClient) validateHistoryPageLocked( + response frostNativeSignerAnchorHistoryResponse, + requestDigest [32]byte, + nonce [32]byte, + floor FrostNativeSignerStateWitnessAnchorReference, + target FrostNativeSignerStateWitnessAnchorReference, + prior FrostNativeSignerStateWitnessAnchorReference, + startRevision uint64, + maximumEvents uint64, + maximumProofEntries uint64, +) ( + []FrostNativeSignerStateWitnessAnchorHistoryEvent, + FrostNativeSignerStateWitnessAnchorReference, + uint64, + bool, + error, +) { + if response.Schema != FrostNativeSignerAnchorHistoryResponseSchema || + response.Events == nil { + return nil, prior, 0, false, fmt.Errorf( + "native signer anchor history response is incomplete", + ) + } + bindingHash, err := frostNativeSignerAnchorParseHex32(response.BindingHash) + if err != nil || bindingHash != client.bindingHash { + return nil, prior, 0, false, fmt.Errorf("history response binding hash mismatch") + } + responseRequestDigest, err := frostNativeSignerAnchorParseHex32( + response.RequestDigest, + ) + if err != nil || responseRequestDigest != requestDigest { + return nil, prior, 0, false, fmt.Errorf("history response request digest mismatch") + } + responseNonce, err := frostNativeSignerAnchorParseHex32(response.Nonce) + if err != nil || responseNonce != nonce { + return nil, prior, 0, false, fmt.Errorf("history response nonce mismatch") + } + serviceEpoch, err := frostNativeSignerAnchorParseUint64(response.ServiceEpoch) + if err != nil || serviceEpoch != target.ServiceEpoch { + return nil, prior, 0, false, fmt.Errorf("history response service epoch mismatch") + } + responseFloor, err := frostNativeSignerAnchorHistoryReferenceFromWire( + response.FloorRef, + ) + if err != nil || responseFloor != floor { + return nil, prior, 0, false, fmt.Errorf("history response floor mismatch") + } + responseTarget, err := frostNativeSignerAnchorHistoryReferenceFromWire( + response.TargetRef, + ) + if err != nil || responseTarget != target { + return nil, prior, 0, false, fmt.Errorf("history response target mismatch") + } + responseStart, err := frostNativeSignerAnchorParseUint64(response.StartRevision) + if err != nil || responseStart != startRevision || + startRevision != prior.Revision+1 { + return nil, prior, 0, false, fmt.Errorf("history response start revision mismatch") + } + nextRevision, err := frostNativeSignerAnchorParseUint64(response.NextRevision) + if err != nil { + return nil, prior, 0, false, err + } + eventCount, err := frostNativeSignerAnchorParseUint64(response.EventCount) + if err != nil || eventCount != uint64(len(*response.Events)) || + eventCount > maximumEvents || + eventCount > FrostNativeSignerAnchorMaximumHistoryEventsPerPage { + return nil, prior, 0, false, fmt.Errorf("history response event count is invalid") + } + proofEntryCount, err := frostNativeSignerAnchorParseUint64( + response.ProofEntryCount, + ) + if err != nil || proofEntryCount > maximumProofEntries || + proofEntryCount > FrostNativeSignerAnchorMaximumHistoryProofEntriesPerPage { + return nil, prior, 0, false, fmt.Errorf("history response proof count is invalid") + } + + events := make([]FrostNativeSignerStateWitnessAnchorHistoryEvent, len(*response.Events)) + eventDigests := make([][32]byte, len(*response.Events)) + actualProofCount := uint64(0) + for index, wireEvent := range *response.Events { + proof, err := frostNativeSignerAnchorProofFromWire(wireEvent.WitnessProof) + if err != nil { + return nil, prior, 0, false, fmt.Errorf("invalid history witness proof: %w", err) + } + actualProofCount += uint64(len(proof)) + if actualProofCount > proofEntryCount { + return nil, prior, 0, false, fmt.Errorf("history witness proof count exceeds its summary") + } + acknowledgement, err := client.verifyAcknowledgement( + wireEvent.CheckpointAck, + nil, + nil, + nil, + nil, + false, + "applied", + "already-applied", + ) + if err != nil { + return nil, prior, 0, false, fmt.Errorf( + "invalid history checkpoint acknowledgement: %w", + err, + ) + } + events[index] = FrostNativeSignerStateWitnessAnchorHistoryEvent{ + Acknowledgement: *acknowledgement, + WitnessProof: proof, + } + eventDigests[index] = computeFrostNativeSignerAnchorHistoryEventDigest( + acknowledgement.Revision, + acknowledgement.AcknowledgementDigest, + wireEvent.CheckpointAck, + proof, + ) + } + if actualProofCount != proofEntryCount { + return nil, prior, 0, false, fmt.Errorf("history witness proof count mismatch") + } + responseDigest, err := frostNativeSignerAnchorHistoryResponseTranscript( + response, + eventDigests, + ) + if err != nil { + return nil, prior, 0, false, err + } + responseSignature, err := frostNativeSignerAnchorParseSignature(response.Signature) + if err != nil || !ed25519.Verify(client.onlineKey, responseDigest, responseSignature[:]) { + return nil, prior, 0, false, fmt.Errorf("history response signature is invalid") + } + committedAt, err := frostNativeSignerAnchorParseUint64(response.CommittedAtUnixMs) + if err != nil { + return nil, prior, 0, false, err + } + expiresAt, err := frostNativeSignerAnchorParseUint64(response.ExpiresAtUnixMs) + if err != nil { + return nil, prior, 0, false, err + } + nowUnixMs := client.now().UnixMilli() + if nowUnixMs < 0 || committedAt == 0 || expiresAt <= committedAt || + expiresAt-committedAt > uint64(client.maximumAckLife/time.Millisecond) || + committedAt > uint64(nowUnixMs)+uint64(client.clockSkew/time.Millisecond) || + expiresAt <= uint64(nowUnixMs) { + return nil, prior, 0, false, fmt.Errorf( + "history response is stale or has an invalid lifetime", + ) + } + + current := prior + for index := range events { + event := &events[index] + acknowledgement := &event.Acknowledgement + if acknowledgement.ServiceEpoch != target.ServiceEpoch || + acknowledgement.Revision != current.Revision+1 || + acknowledgement.PreviousEventRoot != current.EventRoot { + return nil, prior, 0, false, fmt.Errorf( + "history acknowledgement event chain is discontinuous", + ) + } + if err := validateFrostNativeSignerAnchorTransition( + current.Checkpoint, + acknowledgement.Checkpoint, + event.WitnessProof, + client.identity.SignerStoreFingerprint, + ); err != nil { + return nil, prior, 0, false, fmt.Errorf( + "invalid history native state transition: %w", + err, + ) + } + expectedTransitionDigest := computeFrostNativeSignerAnchorTransitionDigest( + client.identity, + acknowledgement.OperationID, + current.Checkpoint, + acknowledgement.Checkpoint, + event.WitnessProof, + ) + if acknowledgement.TransitionDigest != expectedTransitionDigest { + return nil, prior, 0, false, fmt.Errorf( + "history checkpoint transition digest mismatch", + ) + } + current = frostNativeSignerAnchorReferenceFromAcknowledgement(acknowledgement) + } + + switch response.Status { + case "partial": + if len(events) == 0 || nextRevision != current.Revision+1 || + nextRevision > target.Revision || current == target { + return nil, prior, 0, false, fmt.Errorf("invalid partial history page") + } + return events, current, nextRevision, false, nil + case "complete": + if nextRevision != 0 || current != target || + (len(events) == 0 && prior != target) { + return nil, prior, 0, false, fmt.Errorf("invalid complete history page") + } + return events, current, 0, true, nil + default: + return nil, prior, 0, false, fmt.Errorf("unsupported history response status") + } +} + +func frostNativeSignerAnchorReferenceFromAcknowledgement( + acknowledgement *FrostNativeSignerCheckpointAcknowledgement, +) FrostNativeSignerStateWitnessAnchorReference { + if acknowledgement == nil { + return FrostNativeSignerStateWitnessAnchorReference{} + } + return FrostNativeSignerStateWitnessAnchorReference{ + ServiceEpoch: acknowledgement.ServiceEpoch, + Revision: acknowledgement.Revision, + EventRoot: acknowledgement.EventRoot, + AcknowledgementDigest: acknowledgement.AcknowledgementDigest, + Checkpoint: acknowledgement.Checkpoint, + } +} + +func (client *FrostNativeSignerAnchorClient) readLocked( + ctx context.Context, +) (*FrostNativeSignerCheckpointAcknowledgement, error) { + nonce, err := client.randomBytes32() + if err != nil { + return nil, fmt.Errorf("cannot create native signer anchor read nonce: %w", err) + } + transcript := frostNativeSignerAnchorReadRequestTranscript( + client.identity, + nonce, + client.clientSPKIDER, + ) + requestDigest := sha256.Sum256(transcript) + request := frostNativeSignerAnchorReadRequest{ + Schema: FrostNativeSignerAnchorReadRequestSchema, + Payload: frostNativeSignerAnchorReadRequestPayload{ + Kind: "read", + Nonce: frostNativeSignerAnchorHex32(nonce), + BindingHash: frostNativeSignerAnchorHex32(client.bindingHash), + Identity: frostNativeSignerAnchorIdentityToWire(client.identity), + }, + ClientPublicKeySPKI: client.clientSPKIBase64, + Signature: frostNativeSignerAnchorSignatureHex(ed25519.Sign(client.clientKey, transcript)), + } + payload, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("cannot encode native signer anchor read: %w", err) + } + response, _, err := client.post(ctx, client.readEndpoint, payload) + if err != nil { + return nil, err + } + readResponse := frostNativeSignerAnchorReadResponse{} + if err := decodeStrictFrostNativeSignerAnchorJSON(response, &readResponse); err != nil { + return nil, fmt.Errorf("invalid native signer anchor read response: %w", err) + } + if readResponse.Schema != FrostNativeSignerAnchorReadResponseSchema { + return nil, fmt.Errorf("unsupported native signer anchor read response schema") + } + responseDigest, err := frostNativeSignerAnchorReadResponseTranscript(readResponse) + if err != nil { + return nil, fmt.Errorf("invalid native signer anchor read response: %w", err) + } + responseSignature, err := frostNativeSignerAnchorParseSignature(readResponse.Signature) + if err != nil || !ed25519.Verify(client.onlineKey, responseDigest, responseSignature[:]) { + return nil, fmt.Errorf("native signer anchor read response signature is invalid") + } + responseBindingHash, err := frostNativeSignerAnchorParseHex32(readResponse.BindingHash) + if err != nil || responseBindingHash != client.bindingHash { + return nil, fmt.Errorf("native signer anchor read binding hash mismatch") + } + responseRequestDigest, err := frostNativeSignerAnchorParseHex32(readResponse.RequestDigest) + if err != nil || responseRequestDigest != requestDigest { + return nil, fmt.Errorf("native signer anchor read request digest mismatch") + } + responseNonce, err := frostNativeSignerAnchorParseHex32(readResponse.Nonce) + if err != nil || responseNonce != nonce { + return nil, fmt.Errorf("native signer anchor read nonce mismatch") + } + if readResponse.Status != "present" { + return nil, fmt.Errorf("native signer anchor stream is absent") + } + if readResponse.Checkpoint == nil { + return nil, fmt.Errorf("native signer anchor read checkpoint is absent") + } + checkpoint, err := frostNativeSignerAnchorCheckpointFromWire(*readResponse.Checkpoint) + if err != nil { + return nil, err + } + operationID, err := frostNativeSignerAnchorParseHex32(readResponse.OperationID) + if err != nil { + return nil, err + } + transitionDigest, err := frostNativeSignerAnchorParseHex32(readResponse.TransitionDigest) + if err != nil { + return nil, err + } + acknowledgement, err := client.verifyAcknowledgement( + readResponse.CheckpointAck, + nil, + nil, + &checkpoint, + &operationID, + false, + "applied", + "already-applied", + ) + if err != nil { + return nil, fmt.Errorf("invalid stored native signer checkpoint acknowledgement: %w", err) + } + if acknowledgement.TransitionDigest != transitionDigest { + return nil, fmt.Errorf("native signer anchor read transition digest mismatch") + } + serviceEpoch, err := frostNativeSignerAnchorParseUint64(readResponse.ServiceEpoch) + if err != nil { + return nil, err + } + revision, err := frostNativeSignerAnchorParseUint64(readResponse.Revision) + if err != nil { + return nil, err + } + eventRoot, err := frostNativeSignerAnchorParseHex32(readResponse.EventRoot) + if err != nil { + return nil, err + } + committedAt, err := frostNativeSignerAnchorParseUint64(readResponse.CommittedAtUnixMs) + if err != nil { + return nil, err + } + expiresAt, err := frostNativeSignerAnchorParseUint64(readResponse.ExpiresAtUnixMs) + if err != nil { + return nil, err + } + nowUnixMs := client.now().UnixMilli() + if nowUnixMs < 0 || committedAt == 0 || expiresAt <= committedAt || + expiresAt-committedAt > uint64(client.maximumAckLife/time.Millisecond) || + committedAt > uint64(nowUnixMs)+uint64(client.clockSkew/time.Millisecond) || + expiresAt <= uint64(nowUnixMs) { + return nil, fmt.Errorf("native signer anchor read response is stale or has an invalid lifetime") + } + acknowledgementDigest, err := frostNativeSignerAnchorParseHex32( + readResponse.CheckpointAckDigest, + ) + if err != nil || + serviceEpoch != acknowledgement.ServiceEpoch || + revision != acknowledgement.Revision || + eventRoot != acknowledgement.EventRoot || + acknowledgementDigest != acknowledgement.AcknowledgementDigest { + return nil, fmt.Errorf("native signer anchor read summary differs from its stored acknowledgement") + } + acknowledgement.ExactReadRecovery = append([]byte{}, response...) + acknowledgement.ReadRecoveryExpiresAt = expiresAt + return acknowledgement, nil +} + +func (client *FrostNativeSignerAnchorClient) casAttemptLocked( + ctx context.Context, + expected FrostNativeSignerStateWitnessCheckpoint, + candidate FrostNativeSignerStateWitnessCheckpoint, + proof []frostsigning.NativeTBTCSignerStateWitnessProofEntry, + operationID [32]byte, + transitionDigest [32]byte, +) (*FrostNativeSignerCheckpointAcknowledgement, bool, error) { + nonce, err := client.randomBytes32() + if err != nil { + return nil, false, fmt.Errorf("cannot create native signer anchor CAS nonce: %w", err) + } + transcript := frostNativeSignerAnchorCASRequestTranscript( + client.identity, + nonce, + operationID, + transitionDigest, + expected, + candidate, + proof, + client.clientSPKIDER, + ) + requestDigest := sha256.Sum256(transcript) + request := frostNativeSignerAnchorCASRequest{ + Schema: FrostNativeSignerAnchorCASRequestSchema, + Payload: frostNativeSignerAnchorCASRequestPayload{ + Kind: "advance", + Nonce: frostNativeSignerAnchorHex32(nonce), + BindingHash: frostNativeSignerAnchorHex32(client.bindingHash), + Identity: frostNativeSignerAnchorIdentityToWire(client.identity), + OperationID: frostNativeSignerAnchorHex32(operationID), + TransitionDigest: frostNativeSignerAnchorHex32(transitionDigest), + Expected: frostNativeSignerAnchorCheckpointToWire(expected), + Candidate: frostNativeSignerAnchorCheckpointToWire(candidate), + Proof: frostNativeSignerAnchorProofToWire(proof), + }, + ClientPublicKeySPKI: client.clientSPKIBase64, + Signature: frostNativeSignerAnchorSignatureHex(ed25519.Sign(client.clientKey, transcript)), + } + payload, err := json.Marshal(request) + if err != nil { + return nil, false, fmt.Errorf("cannot encode native signer anchor CAS: %w", err) + } + if len(payload) > frostNativeSignerAnchorMaximumRequestBytes { + return nil, false, fmt.Errorf("native signer anchor CAS request exceeds the size bound") + } + response, sent, err := client.post(ctx, client.advanceEndpoint, payload) + if err != nil { + return nil, sent, err + } + acknowledgement, err := client.verifyAcknowledgement( + response, + &requestDigest, + &nonce, + &candidate, + &operationID, + true, + "applied", + "already-applied", + ) + if err != nil { + // The server may have committed before returning a malformed, truncated, + // or stale response, so any post-send verification error is ambiguous. + return nil, true, fmt.Errorf("invalid native signer anchor CAS acknowledgement: %w", err) + } + if acknowledgement.TransitionDigest != transitionDigest { + return nil, true, fmt.Errorf("native signer anchor CAS transition digest mismatch") + } + return acknowledgement, false, nil +} + +func (client *FrostNativeSignerAnchorClient) verifyAcknowledgement( + payload []byte, + requestDigest *[32]byte, + nonce *[32]byte, + expectedCheckpoint *FrostNativeSignerStateWitnessCheckpoint, + expectedOperationID *[32]byte, + requireFresh bool, + allowedStatuses ...string, +) (*FrostNativeSignerCheckpointAcknowledgement, error) { + wire := frostNativeSignerAnchorAcknowledgementWire{} + if err := decodeStrictFrostNativeSignerAnchorJSON(payload, &wire); err != nil { + return nil, err + } + if wire.Schema != FrostNativeSignerCheckpointAcknowledgementSchema { + return nil, fmt.Errorf("unsupported checkpoint acknowledgement schema") + } + statusAllowed := false + for _, status := range allowedStatuses { + statusAllowed = statusAllowed || wire.Status == status + } + if !statusAllowed { + return nil, fmt.Errorf("checkpoint acknowledgement status is invalid") + } + transcript, err := frostNativeSignerAnchorAcknowledgementTranscript(wire) + if err != nil { + return nil, err + } + signature, err := frostNativeSignerAnchorParseSignature(wire.Signature) + if err != nil { + return nil, err + } + if !ed25519.Verify(client.onlineKey, transcript, signature[:]) { + return nil, fmt.Errorf("checkpoint acknowledgement signature is invalid") + } + acknowledgement, err := frostNativeSignerAnchorAcknowledgementFromWire(wire) + if err != nil { + return nil, err + } + if acknowledgement.BindingHash != client.bindingHash || + (requestDigest != nil && acknowledgement.RequestDigest != *requestDigest) || + (nonce != nil && acknowledgement.Nonce != *nonce) { + return nil, fmt.Errorf("checkpoint acknowledgement request binding mismatch") + } + if expectedCheckpoint != nil && acknowledgement.Checkpoint != *expectedCheckpoint { + return nil, fmt.Errorf("checkpoint acknowledgement does not contain the exact candidate") + } + if expectedOperationID != nil && acknowledgement.OperationID != *expectedOperationID { + return nil, fmt.Errorf("checkpoint acknowledgement operation ID mismatch") + } + if err := validateFrostNativeSignerAnchorCheckpoint( + acknowledgement.Checkpoint, + client.identity.SignerStoreFingerprint, + ); err != nil { + return nil, err + } + if acknowledgement.OperationID == [32]byte{} || + acknowledgement.TransitionDigest == [32]byte{} || + acknowledgement.ServiceEpoch == 0 || + acknowledgement.Revision == 0 || + acknowledgement.EventRoot == [32]byte{} || + (acknowledgement.Revision == 1 && + acknowledgement.PreviousEventRoot != [32]byte{}) || + (acknowledgement.Revision > 1 && + acknowledgement.PreviousEventRoot == [32]byte{}) { + return nil, fmt.Errorf("checkpoint acknowledgement audit identity is invalid") + } + if computeFrostNativeSignerAnchorEventRoot(*acknowledgement) != acknowledgement.EventRoot { + return nil, fmt.Errorf("checkpoint acknowledgement event root mismatch") + } + nowUnixMs := client.now().UnixMilli() + if nowUnixMs < 0 || + acknowledgement.CommittedAtUnixMs == 0 || + acknowledgement.ExpiresAtUnixMs <= acknowledgement.CommittedAtUnixMs || + acknowledgement.ExpiresAtUnixMs-acknowledgement.CommittedAtUnixMs > + uint64(client.maximumAckLife/time.Millisecond) || + acknowledgement.CommittedAtUnixMs > + uint64(nowUnixMs)+uint64(client.clockSkew/time.Millisecond) || + (requireFresh && acknowledgement.ExpiresAtUnixMs <= uint64(nowUnixMs)) { + return nil, fmt.Errorf("checkpoint acknowledgement is stale or has an invalid lifetime") + } + acknowledgement.Signature = signature + copy(acknowledgement.SigningDigest[:], transcript) + acknowledgement.AcknowledgementDigest = + computeFrostNativeSignerCheckpointAcknowledgementDigest( + acknowledgement.SigningDigest, + signature, + client.identity.OnlineKeyHash, + ) + acknowledgement.ExactAcknowledgement = append([]byte{}, payload...) + return acknowledgement, nil +} + +func frostNativeSignerAnchorAcknowledgementFromWire( + wire frostNativeSignerAnchorAcknowledgementWire, +) (*FrostNativeSignerCheckpointAcknowledgement, error) { + result := &FrostNativeSignerCheckpointAcknowledgement{Status: wire.Status} + var err error + if result.BindingHash, err = frostNativeSignerAnchorParseHex32(wire.BindingHash); err != nil { + return nil, err + } + if result.RequestDigest, err = frostNativeSignerAnchorParseHex32(wire.RequestDigest); err != nil { + return nil, err + } + if result.Nonce, err = frostNativeSignerAnchorParseHex32(wire.Nonce); err != nil { + return nil, err + } + if result.ServiceEpoch, err = frostNativeSignerAnchorParseUint64(wire.ServiceEpoch); err != nil { + return nil, err + } + if result.Revision, err = frostNativeSignerAnchorParseUint64(wire.Revision); err != nil { + return nil, err + } + if result.PreviousEventRoot, err = frostNativeSignerAnchorParseHex32(wire.PreviousEventRoot); err != nil { + return nil, err + } + if result.EventRoot, err = frostNativeSignerAnchorParseHex32(wire.EventRoot); err != nil { + return nil, err + } + if result.Checkpoint, err = frostNativeSignerAnchorCheckpointFromWire(wire.Checkpoint); err != nil { + return nil, err + } + if result.OperationID, err = frostNativeSignerAnchorParseHex32(wire.OperationID); err != nil { + return nil, err + } + if result.TransitionDigest, err = frostNativeSignerAnchorParseHex32(wire.TransitionDigest); err != nil { + return nil, err + } + if result.CommittedAtUnixMs, err = frostNativeSignerAnchorParseUint64(wire.CommittedAtUnixMs); err != nil { + return nil, err + } + if result.ExpiresAtUnixMs, err = frostNativeSignerAnchorParseUint64(wire.ExpiresAtUnixMs); err != nil { + return nil, err + } + return result, nil +} + +func computeFrostNativeSignerCheckpointAcknowledgementDigest( + signingDigest [32]byte, + signature [ed25519.SignatureSize]byte, + onlineKeyHash [32]byte, +) [32]byte { + hasher := sha256.New() + hasher.Write([]byte("tbtc-signer-state-anchor-acknowledgement/v1\x00")) + hasher.Write(signingDigest[:]) + hasher.Write(signature[:]) + hasher.Write(onlineKeyHash[:]) + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result +} + +func (client *FrostNativeSignerAnchorClient) acceptMonotonicAcknowledgementLocked( + acknowledgement *FrostNativeSignerCheckpointAcknowledgement, +) error { + if acknowledgement == nil { + return fmt.Errorf("checkpoint acknowledgement is nil") + } + if client.last != nil { + if acknowledgement.ServiceEpoch != client.last.ServiceEpoch { + return fmt.Errorf("checkpoint acknowledgement service epoch changed") + } + if acknowledgement.ServiceEpoch == client.last.ServiceEpoch { + switch { + case acknowledgement.Revision < client.last.Revision: + return fmt.Errorf("checkpoint acknowledgement revision rolled back") + case acknowledgement.Revision == client.last.Revision: + if !equalFrostNativeSignerCheckpointAcknowledgements( + acknowledgement, + client.last, + ) { + return fmt.Errorf("equal checkpoint acknowledgement revisions differ") + } + return nil + case acknowledgement.Revision != client.last.Revision+1 || + acknowledgement.PreviousEventRoot != client.last.EventRoot: + return fmt.Errorf("checkpoint acknowledgement event chain is discontinuous") + } + } + if acknowledgement.Checkpoint.Generation < client.last.Checkpoint.Generation { + return fmt.Errorf("checkpoint acknowledgement generation rolled back") + } + if acknowledgement.Checkpoint.Generation == client.last.Checkpoint.Generation && + acknowledgement.Checkpoint != client.last.Checkpoint { + return fmt.Errorf("equal checkpoint generations have different state") + } + } + copy := *acknowledgement + copy.ExactAcknowledgement = append([]byte{}, acknowledgement.ExactAcknowledgement...) + copy.ExactReadRecovery = append([]byte{}, acknowledgement.ExactReadRecovery...) + client.last = © + return nil +} + +func frostNativeSignerAnchorRecord( + acknowledgement *FrostNativeSignerCheckpointAcknowledgement, +) *FrostNativeSignerStateWitnessAnchorRecord { + return &FrostNativeSignerStateWitnessAnchorRecord{ + Checkpoint: acknowledgement.Checkpoint, + BindingHash: acknowledgement.BindingHash, + AcknowledgementDigest: acknowledgement.AcknowledgementDigest, + OperationID: acknowledgement.OperationID, + TransitionDigest: acknowledgement.TransitionDigest, + ServiceEpoch: acknowledgement.ServiceEpoch, + Revision: acknowledgement.Revision, + PreviousEventRoot: acknowledgement.PreviousEventRoot, + EventRoot: acknowledgement.EventRoot, + AcknowledgementJSON: append([]byte{}, acknowledgement.ExactAcknowledgement...), + AcknowledgementExpires: acknowledgement.ExpiresAtUnixMs, + ReadRecoveryJSON: append([]byte{}, acknowledgement.ExactReadRecovery...), + ReadRecoveryExpires: acknowledgement.ReadRecoveryExpiresAt, + } +} + +func equalFrostNativeSignerCheckpointAcknowledgements( + left *FrostNativeSignerCheckpointAcknowledgement, + right *FrostNativeSignerCheckpointAcknowledgement, +) bool { + if left == nil || right == nil { + return left == right + } + return left.BindingHash == right.BindingHash && + left.RequestDigest == right.RequestDigest && + left.Nonce == right.Nonce && + left.Status == right.Status && + left.ServiceEpoch == right.ServiceEpoch && + left.Revision == right.Revision && + left.PreviousEventRoot == right.PreviousEventRoot && + left.EventRoot == right.EventRoot && + left.Checkpoint == right.Checkpoint && + left.OperationID == right.OperationID && + left.TransitionDigest == right.TransitionDigest && + left.CommittedAtUnixMs == right.CommittedAtUnixMs && + left.ExpiresAtUnixMs == right.ExpiresAtUnixMs && + left.Signature == right.Signature && + left.SigningDigest == right.SigningDigest && + left.AcknowledgementDigest == right.AcknowledgementDigest && + bytes.Equal(left.ExactAcknowledgement, right.ExactAcknowledgement) +} + +func computeFrostNativeSignerAnchorEventRoot( + acknowledgement FrostNativeSignerCheckpointAcknowledgement, +) [32]byte { + status := byte(0) + switch acknowledgement.Status { + case "applied": + status = 0x01 + case "already-applied": + status = 0x02 + default: + return [32]byte{} + } + buffer := bytes.NewBuffer(nil) + buffer.WriteString("tbtc-native-signer-state-anchor-event/v1\x00") + buffer.Write(acknowledgement.BindingHash[:]) + _ = binary.Write(buffer, binary.BigEndian, acknowledgement.ServiceEpoch) + _ = binary.Write(buffer, binary.BigEndian, acknowledgement.Revision) + buffer.Write(acknowledgement.PreviousEventRoot[:]) + buffer.Write(acknowledgement.RequestDigest[:]) + buffer.Write(acknowledgement.Nonce[:]) + buffer.WriteByte(status) + buffer.Write(acknowledgement.Checkpoint.StoreFingerprint[:]) + _ = binary.Write(buffer, binary.BigEndian, acknowledgement.Checkpoint.Generation) + buffer.Write(acknowledgement.Checkpoint.PreviousStateCommitment[:]) + buffer.Write(acknowledgement.Checkpoint.StateImageDigest[:]) + buffer.Write(acknowledgement.Checkpoint.StateCommitment[:]) + buffer.Write(acknowledgement.OperationID[:]) + buffer.Write(acknowledgement.TransitionDigest[:]) + _ = binary.Write(buffer, binary.BigEndian, acknowledgement.CommittedAtUnixMs) + _ = binary.Write(buffer, binary.BigEndian, acknowledgement.ExpiresAtUnixMs) + return sha256.Sum256(buffer.Bytes()) +} + +func (client *FrostNativeSignerAnchorClient) post( + ctx context.Context, + endpoint string, + payload []byte, +) ([]byte, bool, error) { + return client.postWithResponseLimit( + ctx, + endpoint, + payload, + frostNativeSignerAnchorMaximumResponseBytes, + ) +} + +func (client *FrostNativeSignerAnchorClient) postWithResponseLimit( + ctx context.Context, + endpoint string, + payload []byte, + responseLimit int64, +) ([]byte, bool, error) { + if len(payload) == 0 || len(payload) > frostNativeSignerAnchorMaximumRequestBytes { + return nil, false, fmt.Errorf("native signer anchor request size is invalid") + } + if responseLimit <= 0 || + responseLimit > frostNativeSignerAnchorMaximumHistoryResponseBytes { + return nil, false, fmt.Errorf("native signer anchor response limit is invalid") + } + requestContext, cancel := context.WithTimeout(ctx, client.requestTimeout) + defer cancel() + request, err := http.NewRequestWithContext( + requestContext, + http.MethodPost, + endpoint, + bytes.NewReader(payload), + ) + if err != nil { + return nil, false, fmt.Errorf("cannot construct native signer anchor request: %w", err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("Cache-Control", "no-store") + response, err := client.httpClient.Do(request) + if err != nil { + return nil, true, fmt.Errorf("native signer anchor request failed: %w", err) + } + defer response.Body.Close() + limited := io.LimitReader(response.Body, responseLimit+1) + body, readErr := io.ReadAll(limited) + if readErr != nil { + return nil, true, fmt.Errorf("cannot read native signer anchor response: %w", readErr) + } + if len(body) == 0 || int64(len(body)) > responseLimit { + return nil, true, fmt.Errorf("native signer anchor response size is invalid") + } + if response.StatusCode != http.StatusOK { + return nil, true, fmt.Errorf( + "native signer anchor returned HTTP status [%d]", + response.StatusCode, + ) + } + mediaType, parameters, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" || len(parameters) != 0 { + return nil, true, fmt.Errorf("native signer anchor response content type is invalid") + } + return body, true, nil +} + +func (client *FrostNativeSignerAnchorClient) randomBytes32() ([32]byte, error) { + var result [32]byte + if _, err := io.ReadFull(client.random, result[:]); err != nil { + return [32]byte{}, err + } + if result == [32]byte{} { + return [32]byte{}, fmt.Errorf("random value is zero") + } + return result, nil +} + +func parseFrostNativeSignerAnchorOnlineKey(der []byte) (ed25519.PublicKey, error) { + if len(der) == 0 { + return nil, fmt.Errorf("native signer anchor online key is empty") + } + parsed, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return nil, fmt.Errorf("cannot parse native signer anchor online key: %w", err) + } + publicKey, ok := parsed.(ed25519.PublicKey) + if !ok || len(publicKey) != ed25519.PublicKeySize { + return nil, fmt.Errorf("native signer anchor online key is not Ed25519") + } + canonical, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil || !bytes.Equal(canonical, der) { + return nil, fmt.Errorf("native signer anchor online key SPKI is not canonical DER") + } + return append(ed25519.PublicKey{}, publicKey...), nil +} + +func validateFrostNativeSignerAnchorEndpoint( + value string, +) (*url.URL, bool, error) { + if value == "" || len(value) > 2048 || strings.Contains(value, "%") { + return nil, false, fmt.Errorf("native signer anchor endpoint is invalid") + } + endpoint, err := url.Parse(value) + if err != nil || endpoint.String() != value || + (endpoint.Scheme != "https" && endpoint.Scheme != "http") || + endpoint.User != nil || endpoint.Opaque != "" || + endpoint.RawQuery != "" || endpoint.Fragment != "" || + endpoint.RawPath != "" || endpoint.Host == "" || + endpoint.Path == "" || path.Clean(endpoint.Path) != endpoint.Path || + (endpoint.Path != "/" && strings.HasSuffix(endpoint.Path, "/")) { + return nil, false, fmt.Errorf("native signer anchor endpoint is not canonical") + } + hostname := endpoint.Hostname() + if hostname == "" || hostname != strings.ToLower(hostname) { + return nil, false, fmt.Errorf("native signer anchor endpoint host is not canonical") + } + ip := net.ParseIP(hostname) + if ip != nil && ip.String() != hostname { + return nil, false, fmt.Errorf("native signer anchor endpoint IP is not canonical") + } + if ip == nil && !frostNativeSignerAnchorCanonicalDNSName(hostname) { + return nil, false, fmt.Errorf("native signer anchor endpoint DNS name is invalid") + } + if endpoint.Port() != "" { + port, err := strconv.ParseUint(endpoint.Port(), 10, 16) + if err != nil || port == 0 || strconv.FormatUint(port, 10) != endpoint.Port() { + return nil, false, fmt.Errorf("native signer anchor endpoint port is invalid") + } + } + if endpoint.Scheme == "http" { + if ip == nil || !ip.IsLoopback() || endpoint.Port() == "" { + return nil, false, fmt.Errorf( + "native signer anchor HTTP endpoint must be numeric loopback with a fixed port", + ) + } + return endpoint, false, nil + } + return endpoint, true, nil +} + +func frostNativeSignerAnchorCanonicalDNSName(hostname string) bool { + if len(hostname) == 0 || len(hostname) > 253 || strings.HasSuffix(hostname, ".") { + return false + } + for _, label := range strings.Split(hostname, ".") { + if len(label) == 0 || len(label) > 63 || + label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, character := range label { + if (character < 'a' || character > 'z') && + (character < '0' || character > '9') && + character != '-' { + return false + } + } + } + return true +} + +func frostNativeSignerAnchorOperationEndpoint(endpoint *url.URL, operation string) string { + copy := *endpoint + if copy.Path == "/" { + copy.Path += operation + } else { + copy.Path += "/" + operation + } + return copy.String() +} + +func base64StdEncoding(value []byte) string { + return base64.StdEncoding.EncodeToString(value) +} diff --git a/pkg/tbtc/frost_native_signer_anchor_client_test.go b/pkg/tbtc/frost_native_signer_anchor_client_test.go new file mode 100644 index 0000000000..1aa63bbd38 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_client_test.go @@ -0,0 +1,1317 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +func TestFrostNativeSignerAnchorStreamIDFrozenAndStable(t *testing.T) { + identity := FrostNativeSignerAnchorIdentity{ + ProtocolID: testFrostNativeSignerAnchorBytes32(0x11), + TrustDomainID: "prod.example", + SignerStoreFingerprint: testFrostNativeSignerAnchorBytes32(0x22), + } + expected, err := hex.DecodeString( + "661f6dcc9958944992d82db22cf4986e70991ff4db3680e1014cbd6a90775661", + ) + if err != nil { + t.Fatal(err) + } + actual := ComputeFrostNativeSignerAnchorStreamID(identity) + if !bytes.Equal(actual[:], expected) { + t.Fatalf("unexpected frozen stream ID [%x]", actual) + } + + rotatingMutations := []func(*FrostNativeSignerAnchorIdentity){ + func(value *FrostNativeSignerAnchorIdentity) { + value.ActivationManifestHash = testFrostNativeSignerAnchorBytes32(1) + }, + func(value *FrostNativeSignerAnchorIdentity) { value.ActivationManifestSequence = 9 }, + func(value *FrostNativeSignerAnchorIdentity) { + value.EndpointLeafSPKIHash = testFrostNativeSignerAnchorBytes32(2) + }, + func(value *FrostNativeSignerAnchorIdentity) { + value.OnlineKeyHash = testFrostNativeSignerAnchorBytes32(3) + }, + func(value *FrostNativeSignerAnchorIdentity) { + value.OperatorFingerprint = testFrostNativeSignerAnchorBytes32(4) + }, + func(value *FrostNativeSignerAnchorIdentity) { value.HistoryStoreID = "rotated" }, + func(value *FrostNativeSignerAnchorIdentity) { + value.HistoryStoreFingerprint = testFrostNativeSignerAnchorBytes32(5) + }, + func(value *FrostNativeSignerAnchorIdentity) { + value.HistoryClusterFingerprint = testFrostNativeSignerAnchorBytes32(6) + }, + func(value *FrostNativeSignerAnchorIdentity) { + value.OfflineAuthorityHash = testFrostNativeSignerAnchorBytes32(7) + }, + func(value *FrostNativeSignerAnchorIdentity) { + value.ClientSPKIHash = testFrostNativeSignerAnchorBytes32(8) + }, + func(value *FrostNativeSignerAnchorIdentity) { + value.TransportBinding = testFrostNativeSignerAnchorBytes32(9) + }, + func(value *FrostNativeSignerAnchorIdentity) { value.WitnessMaximumRecords = 100 }, + func(value *FrostNativeSignerAnchorIdentity) { + value.WitnessRotationThresholdRecords = 50 + }, + } + baseBinding := ComputeFrostNativeSignerAnchorBindingHash(identity) + for index, mutate := range rotatingMutations { + mutated := identity + mutate(&mutated) + if ComputeFrostNativeSignerAnchorStreamID(mutated) != actual { + t.Fatalf("rotating field mutation [%d] changed the stable stream", index) + } + if ComputeFrostNativeSignerAnchorBindingHash(mutated) == baseBinding { + t.Fatalf("rotating field mutation [%d] did not change the binding", index) + } + } + stableMutations := []func(*FrostNativeSignerAnchorIdentity){ + func(value *FrostNativeSignerAnchorIdentity) { + value.ProtocolID = testFrostNativeSignerAnchorBytes32(0x31) + }, + func(value *FrostNativeSignerAnchorIdentity) { value.TrustDomainID = "other.example" }, + func(value *FrostNativeSignerAnchorIdentity) { + value.SignerStoreFingerprint = testFrostNativeSignerAnchorBytes32(0x32) + }, + } + for index, mutate := range stableMutations { + mutated := identity + mutate(&mutated) + if ComputeFrostNativeSignerAnchorStreamID(mutated) == actual { + t.Fatalf("stable field mutation [%d] did not change the stream", index) + } + } +} + +func TestFrostNativeSignerCheckpointAcknowledgementFrozenVector(t *testing.T) { + wire := frostNativeSignerAnchorAcknowledgementWire{ + Schema: FrostNativeSignerCheckpointAcknowledgementSchema, + BindingHash: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x11)), + RequestDigest: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x22)), + Nonce: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x33)), + Status: "applied", + ServiceEpoch: "2", + Revision: "3", + PreviousEventRoot: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x44)), + EventRoot: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x55)), + Checkpoint: frostNativeSignerAnchorCheckpointWire{ + StoreFingerprint: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x66)), + Generation: "7", + PreviousStateCommitment: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x77)), + StateImageDigest: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x88)), + StateCommitment: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x99)), + }, + OperationID: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0xaa)), + TransitionDigest: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0xbb)), + CommittedAtUnixMs: "1700000000000", + ExpiresAtUnixMs: "1700000030000", + } + digest, err := frostNativeSignerAnchorAcknowledgementTranscript(wire) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(digest) != + "55f88c32a0b168003cedfb88cf47a467b607dbd1f2ab6f20ddc7976bd396b239" { + t.Fatalf("unexpected Rust service-response digest [%x]", digest) + } + + privateKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x01}, ed25519.SeedSize)) + signature := ed25519.Sign(privateKey, digest) + if hex.EncodeToString(signature) != + "0a60e68808285197c4ddb4b68dc10439aad6cbde085fd93b7cf863b7abf8197131d73f35304862ea80dc5cfd88d0cac80f9fa42b54efa036b0a82956c62f0608" { + t.Fatalf("unexpected frozen Ed25519 signature [%x]", signature) + } + publicKeyDER, err := x509.MarshalPKIXPublicKey(privateKey.Public()) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(publicKeyDER) != + "302a300506032b65700321008a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c" { + t.Fatalf("unexpected frozen public key [%x]", publicKeyDER) + } + var signatureArray [ed25519.SignatureSize]byte + copy(signatureArray[:], signature) + var signingDigest [32]byte + copy(signingDigest[:], digest) + acknowledgementDigest := computeFrostNativeSignerCheckpointAcknowledgementDigest( + signingDigest, + signatureArray, + sha256.Sum256(publicKeyDER), + ) + if hex.EncodeToString(acknowledgementDigest[:]) != + "4c30e2aa6a048993fede1a754a0567a6faef8180544398ff284567f722c6ad01" { + t.Fatalf("unexpected frozen acknowledgement digest [%x]", acknowledgementDigest) + } +} + +func TestFrostNativeSignerAnchorEventRootFrozenVector(t *testing.T) { + acknowledgement := FrostNativeSignerCheckpointAcknowledgement{ + BindingHash: testFrostNativeSignerAnchorBytes32(0x11), + RequestDigest: testFrostNativeSignerAnchorBytes32(0x22), + Nonce: testFrostNativeSignerAnchorBytes32(0x33), + Status: "applied", + ServiceEpoch: 2, + Revision: 3, + PreviousEventRoot: testFrostNativeSignerAnchorBytes32(0x44), + Checkpoint: FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: testFrostNativeSignerAnchorBytes32(0x66), + Generation: 7, + PreviousStateCommitment: testFrostNativeSignerAnchorBytes32(0x77), + StateImageDigest: testFrostNativeSignerAnchorBytes32(0x88), + StateCommitment: testFrostNativeSignerAnchorBytes32(0x99), + }, + OperationID: testFrostNativeSignerAnchorBytes32(0xaa), + TransitionDigest: testFrostNativeSignerAnchorBytes32(0xbb), + CommittedAtUnixMs: 1700000000000, + ExpiresAtUnixMs: 1700000030000, + } + actual := computeFrostNativeSignerAnchorEventRoot(acknowledgement) + if hex.EncodeToString(actual[:]) != + "251cf2f635ea82533f55d323104232ecfd47a748a45fbbe16e8ed212c8c69a90" { + t.Fatalf("unexpected frozen event root [%x]", actual) + } +} + +func TestFrostNativeSignerAnchorReadResponseFrozenVector(t *testing.T) { + checkpoint := frostNativeSignerAnchorCheckpointWire{ + StoreFingerprint: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x55)), + Generation: "6", + PreviousStateCommitment: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x66)), + StateImageDigest: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x77)), + StateCommitment: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x88)), + } + response := frostNativeSignerAnchorReadResponse{ + Schema: FrostNativeSignerAnchorReadResponseSchema, + BindingHash: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x11)), + RequestDigest: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x22)), + Nonce: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x33)), + Status: "present", + ServiceEpoch: "2", + Revision: "3", + EventRoot: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x44)), + Checkpoint: &checkpoint, + OperationID: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0x99)), + TransitionDigest: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0xaa)), + CommittedAtUnixMs: "1700000000000", + ExpiresAtUnixMs: "1700000030000", + CheckpointAck: json.RawMessage(`{"x":1}`), + CheckpointAckDigest: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(0xbb)), + } + digest, err := frostNativeSignerAnchorReadResponseTranscript(response) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(digest) != + "bc595335e39a91bdaf49fc749f6df910be31385ad394089ba633bec359f47a20" { + t.Fatalf("unexpected frozen read-response digest [%x]", digest) + } +} + +func TestFrostNativeSignerAnchorHistoryFrozenVectors(t *testing.T) { + proof := []frostsigning.NativeTBTCSignerStateWitnessProofEntry{ + { + Generation: 4, + PreviousStateCommitment: testFrostNativeSignerAnchorBytes32(0x22), + StateImageDigest: testFrostNativeSignerAnchorBytes32(0x33), + StateCommitment: testFrostNativeSignerAnchorBytes32(0x44), + }, + { + Generation: 5, + PreviousStateCommitment: testFrostNativeSignerAnchorBytes32(0x44), + StateImageDigest: testFrostNativeSignerAnchorBytes32(0x55), + StateCommitment: testFrostNativeSignerAnchorBytes32(0x66), + }, + } + eventDigest := computeFrostNativeSignerAnchorHistoryEventDigest( + 3, + testFrostNativeSignerAnchorBytes32(0x11), + []byte(`{"x":1}`), + proof, + ) + if hex.EncodeToString(eventDigest[:]) != + "2c97251a24d2444e4e6b1e6fa28e1956847d6a652e7cbd7dd18b4b0ce2aba167" { + t.Fatalf("unexpected frozen history event digest [%x]", eventDigest) + } + floor := FrostNativeSignerStateWitnessAnchorReference{ + ServiceEpoch: 4, + Revision: 1, + EventRoot: testFrostNativeSignerAnchorBytes32(0x04), + AcknowledgementDigest: testFrostNativeSignerAnchorBytes32(0x05), + Checkpoint: FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: testFrostNativeSignerAnchorBytes32(0x06), + Generation: 7, + PreviousStateCommitment: testFrostNativeSignerAnchorBytes32(0x07), + StateImageDigest: testFrostNativeSignerAnchorBytes32(0x08), + StateCommitment: testFrostNativeSignerAnchorBytes32(0x09), + }, + } + target := FrostNativeSignerStateWitnessAnchorReference{ + ServiceEpoch: 4, + Revision: 3, + EventRoot: testFrostNativeSignerAnchorBytes32(0x0a), + AcknowledgementDigest: testFrostNativeSignerAnchorBytes32(0x0b), + Checkpoint: FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: testFrostNativeSignerAnchorBytes32(0x06), + Generation: 9, + PreviousStateCommitment: testFrostNativeSignerAnchorBytes32(0x0c), + StateImageDigest: testFrostNativeSignerAnchorBytes32(0x0d), + StateCommitment: testFrostNativeSignerAnchorBytes32(0x0e), + }, + } + events := []frostNativeSignerAnchorHistoryEventWire{{ + CheckpointAck: json.RawMessage(`{"x":1}`), + WitnessProof: frostNativeSignerAnchorProofToWire(proof), + }} + response := frostNativeSignerAnchorHistoryResponse{ + Schema: FrostNativeSignerAnchorHistoryResponseSchema, + BindingHash: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(1)), + RequestDigest: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(2)), + Nonce: frostNativeSignerAnchorHex32(testFrostNativeSignerAnchorBytes32(3)), + Status: "partial", + ServiceEpoch: "4", + FloorRef: frostNativeSignerAnchorHistoryReferenceToWire(floor), + TargetRef: frostNativeSignerAnchorHistoryReferenceToWire(target), + StartRevision: "2", + NextRevision: "3", + EventCount: "1", + ProofEntryCount: "2", + Events: &events, + CommittedAtUnixMs: "1700000000000", + ExpiresAtUnixMs: "1700000030000", + } + responseDigest, err := frostNativeSignerAnchorHistoryResponseTranscript( + response, + [][32]byte{eventDigest}, + ) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(responseDigest) != + "3832295275cf6ef5dc3f0653191215dd141c197ec4c1c856787f4b64381c792e" { + t.Fatalf("unexpected frozen history response digest [%x]", responseDigest) + } +} + +func TestDecodeStrictFrostNativeSignerAnchorJSONRejectsParserDifferentials(t *testing.T) { + tests := map[string]string{ + "duplicate top level": `{"schema":"a","schema":"b"}`, + "duplicate payload": `{"payload":{"kind":"read","kind":"advance"}}`, + "duplicate identity": `{"identity":{"protocolID":"a","protocolID":"b"}}`, + "duplicate checkpoint": `{"checkpoint":{"generation":"1","generation":"2"}}`, + "duplicate nested acknowledgement": `{"checkpointAck":{"status":"applied","status":"already-applied"}}`, + "case folded alias": `{"schema":"a","Schema":"b"}`, + "non ASCII member": `{"sch\u0065ma":"a","státus":"b"}`, + "trailing value": `{"schema":"a"}{"schema":"b"}`, + } + for name, payload := range tests { + t.Run(name, func(t *testing.T) { + target := map[string]interface{}{} + if err := decodeStrictFrostNativeSignerAnchorJSON( + []byte(payload), + &target, + ); err == nil { + t.Fatal("expected hardened JSON rejection") + } + }) + } +} + +func TestFrostNativeSignerAnchorJSONDepthBound(t *testing.T) { + atLimit := strings.Repeat("[", frostNativeSignerAnchorMaximumJSONDepth) + + "0" + + strings.Repeat("]", frostNativeSignerAnchorMaximumJSONDepth) + if err := preflightFrostNativeSignerAnchorJSON([]byte(atLimit)); err != nil { + t.Fatalf("expected JSON at the depth bound: %v", err) + } + overLimit := "[" + atLimit + "]" + if err := preflightFrostNativeSignerAnchorJSON( + []byte(overLimit), + ); err == nil || !strings.Contains(err.Error(), "depth bound") { + t.Fatalf("expected JSON depth rejection, got [%v]", err) + } +} + +func TestFrostNativeSignerAnchorCanonicalUint64(t *testing.T) { + for _, value := range []string{"0", "1", "18446744073709551615"} { + if _, err := frostNativeSignerAnchorParseUint64(value); err != nil { + t.Fatalf("expected canonical uint64 [%s]: %v", value, err) + } + } + for _, value := range []string{"", "00", "01", "+1", "-1", "1.0", "18446744073709551616"} { + if _, err := frostNativeSignerAnchorParseUint64(value); err == nil { + t.Fatalf("expected non-canonical uint64 rejection [%s]", value) + } + } +} + +func TestValidateFrostNativeSignerAnchorEndpoint(t *testing.T) { + valid := []string{ + "http://127.0.0.1:8080/anchor", + "http://[::1]:8080/anchor", + "https://anchor.example/anchor", + "https://anchor.example:8443/anchor", + } + for _, value := range valid { + if _, _, err := validateFrostNativeSignerAnchorEndpoint(value); err != nil { + t.Fatalf("expected valid endpoint [%s]: %v", value, err) + } + } + invalid := []string{ + "http://localhost:8080/anchor", + "http://127.0.0.1/anchor", + "http://127.0.0.1:08080/anchor", + "https://user@anchor.example/anchor", + "https://anchor.example/anchor?query=1", + "https://anchor.example/anchor#fragment", + "https://ANCHOR.example/anchor", + "https://anchor.example/a/../anchor", + "https://anchor.example/anchor/", + "https://anchor.example/%61nchor", + } + for _, value := range invalid { + if _, _, err := validateFrostNativeSignerAnchorEndpoint(value); err == nil { + t.Fatalf("expected invalid endpoint rejection [%s]", value) + } + } +} + +func TestFrostNativeSignerAnchorClientRequiresPKIXAndLeafSPKIPin(t *testing.T) { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + server := &httptest.Server{ + Listener: listener, + Config: &http.Server{ + Handler: http.HandlerFunc( + func(writer http.ResponseWriter, _ *http.Request) { + http.Error( + writer, + "expected test response", + http.StatusInternalServerError, + ) + }, + ), + }, + } + server.StartTLS() + defer server.Close() + endpoint := server.URL + "/anchor" + clientPrivate := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x31}, ed25519.SeedSize), + ) + onlinePrivate := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x32}, ed25519.SeedSize), + ) + clientSPKI, _ := x509.MarshalPKIXPublicKey(clientPrivate.Public()) + onlineSPKI, _ := x509.MarshalPKIXPublicKey(onlinePrivate.Public()) + identity := FrostNativeSignerAnchorIdentity{ + ProtocolID: testFrostNativeSignerAnchorBytes32(1), + ActivationManifestHash: testFrostNativeSignerAnchorBytes32(2), + ActivationManifestSequence: 1, + TrustDomainID: "tls-test.example", + EndpointLeafSPKIHash: sha256.Sum256( + server.Certificate().RawSubjectPublicKeyInfo, + ), + OnlineKeyHash: sha256.Sum256(onlineSPKI), + OperatorFingerprint: testFrostNativeSignerAnchorBytes32(3), + HistoryStoreID: "tls-history", + HistoryStoreFingerprint: testFrostNativeSignerAnchorBytes32(4), + HistoryClusterFingerprint: testFrostNativeSignerAnchorBytes32(5), + OfflineAuthorityHash: testFrostNativeSignerAnchorBytes32(6), + ClientSPKIHash: sha256.Sum256(clientSPKI), + SignerStoreFingerprint: testFrostNativeSignerAnchorBytes32(7), + TransportBinding: ComputeFrostNativeSignerAnchorTransportBinding(endpoint), + WitnessMaximumRecords: 100, + WitnessRotationThresholdRecords: 50, + } + identity.StreamID = ComputeFrostNativeSignerAnchorStreamID(identity) + roots := x509.NewCertPool() + roots.AddCert(server.Certificate()) + newClient := func( + identity FrostNativeSignerAnchorIdentity, + roots *x509.CertPool, + ) *FrostNativeSignerAnchorClient { + client, err := NewFrostNativeSignerAnchorClient( + FrostNativeSignerAnchorClientConfig{ + Endpoint: endpoint, + TLSRootCAs: roots, + ClientPrivateKey: clientPrivate, + OnlinePublicKeySPKI: onlineSPKI, + Identity: identity, + }, + ) + if err != nil { + t.Fatal(err) + } + return client + } + validClient := newClient(identity, roots) + if _, err := validClient.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ); err == nil || !strings.Contains(err.Error(), "HTTP status [500]") { + t.Fatalf("expected request to pass TLS and reach the handler, got [%v]", err) + } + + wrongPinIdentity := identity + wrongPinIdentity.EndpointLeafSPKIHash = testFrostNativeSignerAnchorBytes32(0xee) + wrongPinClient := newClient(wrongPinIdentity, roots) + if _, err := wrongPinClient.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ); err == nil || !strings.Contains(err.Error(), "TLS leaf SPKI mismatch") { + t.Fatalf("expected TLS leaf SPKI rejection, got [%v]", err) + } + + untrustedClient := newClient(identity, nil) + if _, err := untrustedClient.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ); err == nil { + t.Fatal("expected normal PKIX verification to reject the untrusted test root") + } +} + +func TestFrostNativeSignerAnchorAcknowledgementTimeBounds(t *testing.T) { + environment := newTestFrostNativeSignerAnchorEnvironment(t, "normal") + defer environment.server.Close() + + environment.client.maximumAckLife = 29 * time.Second + if _, err := environment.client.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ); err == nil { + t.Fatal("expected acknowledgement lifetime rejection below the 30s boundary") + } + environment.client.maximumAckLife = 30 * time.Second + if _, err := environment.client.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ); err != nil { + t.Fatalf("expected exact 30s acknowledgement lifetime: %v", err) + } +} + +func TestFrostNativeSignerAnchorClientRepeatedReadAndCASRecovery(t *testing.T) { + tests := []struct { + name string + mode string + }{ + {"apply then lose response", "apply-ambiguous"}, + {"retain expected then retry", "expected-ambiguous"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + environment := newTestFrostNativeSignerAnchorEnvironment(t, test.mode) + defer environment.server.Close() + + first, err := environment.client.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ) + if err != nil { + t.Fatal(err) + } + second, err := environment.client.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ) + if err != nil { + t.Fatalf("repeated fresh read failed: %v", err) + } + if first.AcknowledgementDigest != second.AcknowledgementDigest || + !bytes.Equal(first.AcknowledgementJSON, second.AcknowledgementJSON) { + t.Fatal("repeated read changed the exact stored acknowledgement") + } + + result, err := environment.client.CompareAndSwapFrostNativeSignerStateWitnessAnchor( + context.Background(), + environment.expected, + environment.candidate, + environment.proof, + ) + if err != nil { + t.Fatal(err) + } + if !result.Recovered || + result.Acknowledgement.Checkpoint != environment.candidate || + len(result.Acknowledgement.ExactAcknowledgement) == 0 { + t.Fatal("ambiguous CAS did not return the exact recovered candidate receipt") + } + }) + } +} + +func TestFrostNativeSignerAnchorClientCASRequiresAuthenticatedRead(t *testing.T) { + environment := newTestFrostNativeSignerAnchorEnvironment(t, "normal") + defer environment.server.Close() + if _, err := environment.client.CompareAndSwapFrostNativeSignerStateWitnessAnchor( + context.Background(), + environment.expected, + environment.candidate, + environment.proof, + ); err == nil || !strings.Contains(err.Error(), "requires a fresh authenticated") { + t.Fatalf("expected missing-read rejection, got [%v]", err) + } +} + +func TestFrostNativeSignerAnchorClientHistoryPublishesTargetAtomically(t *testing.T) { + for _, publicReadFirst := range []bool{false, true} { + name := "internal target reads" + if publicReadFirst { + name = "legacy public target read first" + } + t.Run(name, func(t *testing.T) { + environment := newTestFrostNativeSignerAnchorEnvironment(t, "history") + defer environment.server.Close() + if publicReadFirst { + record, err := environment.client.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ) + if err != nil { + t.Fatal(err) + } + if record.Revision != environment.historyTarget.Revision { + t.Fatal("public target read did not return the history target") + } + } + history, err := + environment.client.ReadFrostNativeSignerStateWitnessAnchorHistory( + context.Background(), + environment.historyFloor, + ) + if err != nil { + t.Fatal(err) + } + if history.Floor != environment.historyFloor || + history.Target != environment.historyTarget || + len(history.Events) != 3 || + history.FinalRead == nil || + history.FinalRead.Revision != environment.historyTarget.Revision || + environment.client.last == nil || + frostNativeSignerAnchorReferenceFromAcknowledgement( + environment.client.last, + ) != environment.historyTarget { + t.Fatal("history did not atomically publish the exact validated target") + } + }) + } +} + +func TestFrostNativeSignerAnchorClientHistoryRejectsChangedEqualRevisionAck(t *testing.T) { + environment := newTestFrostNativeSignerAnchorEnvironment(t, "history") + defer environment.server.Close() + if _, err := environment.client.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ); err != nil { + t.Fatal(err) + } + environment.client.last.ExactAcknowledgement = append( + environment.client.last.ExactAcknowledgement, + ' ', + ) + if _, err := environment.client.ReadFrostNativeSignerStateWitnessAnchorHistory( + context.Background(), + environment.historyFloor, + ); err == nil || !strings.Contains(err.Error(), "differs at an equal revision") { + t.Fatalf("expected equal-revision acknowledgement rejection, got [%v]", err) + } +} + +func TestFrostNativeSignerAnchorClientAcceptsEmptyCompleteHistory(t *testing.T) { + environment := newTestFrostNativeSignerAnchorEnvironment(t, "history-empty") + defer environment.server.Close() + history, err := environment.client.ReadFrostNativeSignerStateWitnessAnchorHistory( + context.Background(), + environment.historyFloor, + ) + if err != nil { + t.Fatal(err) + } + if history.Floor != environment.historyFloor || + history.Target != environment.historyTarget || + len(history.Events) != 0 || + history.FinalRead == nil || + history.FinalRead.Revision != environment.historyTarget.Revision { + t.Fatal("empty complete history did not publish its exact target") + } +} + +func TestFrostNativeSignerAnchorClientPoisonsDivergentAmbiguousCAS(t *testing.T) { + environment := newTestFrostNativeSignerAnchorEnvironment(t, "divergent-ambiguous") + defer environment.server.Close() + if _, err := environment.client.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ); err != nil { + t.Fatal(err) + } + if _, err := environment.client.CompareAndSwapFrostNativeSignerStateWitnessAnchor( + context.Background(), + environment.expected, + environment.candidate, + environment.proof, + ); err == nil || !strings.Contains(err.Error(), "neither the exact expected nor exact candidate") { + t.Fatalf("expected divergent CAS poison, got [%v]", err) + } + if _, err := environment.client.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ); err == nil || !strings.Contains(err.Error(), "poisoned") { + t.Fatalf("expected persistent poison, got [%v]", err) + } +} + +func TestFrostNativeSignerAnchorClientRejectsSignedAbsentStream(t *testing.T) { + environment := newTestFrostNativeSignerAnchorEnvironment(t, "absent") + defer environment.server.Close() + if _, err := environment.client.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ); err == nil || !strings.Contains(err.Error(), "stream is absent") { + t.Fatalf("expected signed absent stream rejection, got [%v]", err) + } +} + +type testFrostNativeSignerAnchorEnvironment struct { + server *httptest.Server + client *FrostNativeSignerAnchorClient + expected FrostNativeSignerStateWitnessCheckpoint + candidate FrostNativeSignerStateWitnessCheckpoint + proof []frostsigning.NativeTBTCSignerStateWitnessProofEntry + historyFloor FrostNativeSignerStateWitnessAnchorReference + historyTarget FrostNativeSignerStateWitnessAnchorReference +} + +func newTestFrostNativeSignerAnchorEnvironment( + t *testing.T, + mode string, +) *testFrostNativeSignerAnchorEnvironment { + t.Helper() + now := time.UnixMilli(1700000000000) + clientPrivate := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x41}, ed25519.SeedSize)) + onlinePrivate := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x42}, ed25519.SeedSize)) + clientSPKI, err := x509.MarshalPKIXPublicKey(clientPrivate.Public()) + if err != nil { + t.Fatal(err) + } + onlineSPKI, err := x509.MarshalPKIXPublicKey(onlinePrivate.Public()) + if err != nil { + t.Fatal(err) + } + + storeFingerprint := testFrostNativeSignerAnchorBytes32(0x51) + expected := testFrostNativeSignerAnchorCheckpoint( + storeFingerprint, + 1, + testFrostNativeSignerAnchorBytes32(0x52), + 0x53, + ) + candidate := testFrostNativeSignerAnchorCheckpoint( + storeFingerprint, + 2, + expected.StateCommitment, + 0x54, + ) + divergent := testFrostNativeSignerAnchorCheckpoint( + storeFingerprint, + 2, + expected.StateCommitment, + 0x55, + ) + proof := []frostsigning.NativeTBTCSignerStateWitnessProofEntry{{ + Generation: candidate.Generation, + PreviousStateCommitment: candidate.PreviousStateCommitment, + StateImageDigest: candidate.StateImageDigest, + StateCommitment: candidate.StateCommitment, + }} + + var identity FrostNativeSignerAnchorIdentity + var currentJSON []byte + var current *FrostNativeSignerCheckpointAcknowledgement + var historyFloor FrostNativeSignerStateWitnessAnchorReference + var historyTarget FrostNativeSignerStateWitnessAnchorReference + var historyEvents []FrostNativeSignerStateWitnessAnchorHistoryEvent + advanceCalls := 0 + handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/anchor/read": + payload, _ := io.ReadAll(request.Body) + readRequest := frostNativeSignerAnchorReadRequest{} + if err := decodeStrictFrostNativeSignerAnchorJSON(payload, &readRequest); err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + nonce, err := frostNativeSignerAnchorParseHex32(readRequest.Payload.Nonce) + if err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + transcript := frostNativeSignerAnchorReadRequestTranscript( + identity, + nonce, + clientSPKI, + ) + requestSignature, err := frostNativeSignerAnchorParseSignature(readRequest.Signature) + if err != nil || + !ed25519.Verify(clientPrivate.Public().(ed25519.PublicKey), transcript, requestSignature[:]) { + http.Error(writer, "invalid signature", http.StatusUnauthorized) + return + } + requestDigest := sha256.Sum256(transcript) + if mode == "absent" { + response := testFrostNativeSignerAnchorAbsentReadResponse( + t, + identity, + requestDigest, + nonce, + onlinePrivate, + ) + _, _ = writer.Write(response) + return + } + response := testFrostNativeSignerAnchorReadResponse( + t, + identity, + requestDigest, + nonce, + current, + currentJSON, + onlinePrivate, + ) + _, _ = writer.Write(response) + case "/anchor/advance": + payload, _ := io.ReadAll(request.Body) + advanceRequest := frostNativeSignerAnchorCASRequest{} + if err := decodeStrictFrostNativeSignerAnchorJSON(payload, &advanceRequest); err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + nonce, _ := frostNativeSignerAnchorParseHex32(advanceRequest.Payload.Nonce) + operationID, _ := frostNativeSignerAnchorParseHex32( + advanceRequest.Payload.OperationID, + ) + transitionDigest, _ := frostNativeSignerAnchorParseHex32( + advanceRequest.Payload.TransitionDigest, + ) + transcript := frostNativeSignerAnchorCASRequestTranscript( + identity, + nonce, + operationID, + transitionDigest, + expected, + candidate, + proof, + clientSPKI, + ) + requestDigest := sha256.Sum256(transcript) + advanceCalls++ + shouldApply := mode != "expected-ambiguous" || advanceCalls > 1 + target := candidate + if mode == "divergent-ambiguous" { + target = divergent + shouldApply = true + } + if shouldApply { + current, currentJSON = testFrostNativeSignerAnchorAcknowledgement( + t, + identity, + target, + operationID, + transitionDigest, + requestDigest, + nonce, + "applied", + current.ServiceEpoch, + current.Revision+1, + current.EventRoot, + now, + onlinePrivate, + ) + } + if advanceCalls == 1 && mode != "normal" { + http.Error(writer, "response lost", http.StatusInternalServerError) + return + } + _, _ = writer.Write(currentJSON) + case "/anchor/history": + payload, _ := io.ReadAll(request.Body) + historyRequest := frostNativeSignerAnchorHistoryRequest{} + if err := decodeStrictFrostNativeSignerAnchorJSON(payload, &historyRequest); err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + nonce, _ := frostNativeSignerAnchorParseHex32(historyRequest.Payload.Nonce) + startRevision, _ := frostNativeSignerAnchorParseUint64( + historyRequest.Payload.StartRevision, + ) + maximumEvents, _ := frostNativeSignerAnchorParseUint64( + historyRequest.Payload.MaximumEvents, + ) + maximumProofEntries, _ := frostNativeSignerAnchorParseUint64( + historyRequest.Payload.MaximumProofEntries, + ) + transcript := frostNativeSignerAnchorHistoryRequestTranscript( + identity, + nonce, + historyFloor, + historyTarget, + startRevision, + maximumEvents, + maximumProofEntries, + clientSPKI, + ) + requestSignature, err := frostNativeSignerAnchorParseSignature( + historyRequest.Signature, + ) + if err != nil || !ed25519.Verify( + clientPrivate.Public().(ed25519.PublicKey), + transcript, + requestSignature[:], + ) { + http.Error(writer, "invalid signature", http.StatusUnauthorized) + return + } + if historyFloor == historyTarget { + response := testFrostNativeSignerAnchorHistoryResponse( + t, + identity, + sha256.Sum256(transcript), + nonce, + historyFloor, + historyTarget, + startRevision, + nil, + now, + onlinePrivate, + ) + _, _ = writer.Write(response) + return + } + startIndex := int(startRevision - historyFloor.Revision - 1) + if startIndex < 0 || startIndex >= len(historyEvents) { + http.Error(writer, "invalid history cursor", http.StatusBadRequest) + return + } + endIndex := len(historyEvents) + if startIndex == 0 { + // Exercise the boundary where the next partial-page cursor is + // exactly the target revision. + endIndex = 2 + } + pageEvents := historyEvents[startIndex:endIndex] + response := testFrostNativeSignerAnchorHistoryResponse( + t, + identity, + sha256.Sum256(transcript), + nonce, + historyFloor, + historyTarget, + startRevision, + pageEvents, + now, + onlinePrivate, + ) + _, _ = writer.Write(response) + default: + http.NotFound(writer, request) + } + }) + server := httptest.NewServer(handler) + endpoint := server.URL + "/anchor" + identity = FrostNativeSignerAnchorIdentity{ + ProtocolID: testFrostNativeSignerAnchorBytes32(0x61), + ActivationManifestHash: testFrostNativeSignerAnchorBytes32(0x62), + ActivationManifestSequence: 1, + TrustDomainID: "test.example", + OnlineKeyHash: sha256.Sum256(onlineSPKI), + OperatorFingerprint: testFrostNativeSignerAnchorBytes32(0x63), + HistoryStoreID: "history-store-1", + HistoryStoreFingerprint: testFrostNativeSignerAnchorBytes32(0x64), + HistoryClusterFingerprint: testFrostNativeSignerAnchorBytes32(0x65), + OfflineAuthorityHash: testFrostNativeSignerAnchorBytes32(0x66), + ClientSPKIHash: sha256.Sum256(clientSPKI), + SignerStoreFingerprint: storeFingerprint, + TransportBinding: ComputeFrostNativeSignerAnchorTransportBinding(endpoint), + WitnessMaximumRecords: 1000, + WitnessRotationThresholdRecords: 900, + } + identity.StreamID = ComputeFrostNativeSignerAnchorStreamID(identity) + current, currentJSON = testFrostNativeSignerAnchorAcknowledgement( + t, + identity, + expected, + testFrostNativeSignerAnchorBytes32(0x71), + testFrostNativeSignerAnchorBytes32(0x72), + testFrostNativeSignerAnchorBytes32(0x73), + testFrostNativeSignerAnchorBytes32(0x74), + "applied", + 1, + 1, + [32]byte{}, + now, + onlinePrivate, + ) + if mode == "history-empty" { + historyFloor = frostNativeSignerAnchorReferenceFromAcknowledgement(current) + historyTarget = historyFloor + } + if mode == "history" { + historyFloor = frostNativeSignerAnchorReferenceFromAcknowledgement(current) + third := testFrostNativeSignerAnchorCheckpoint( + storeFingerprint, + 3, + candidate.StateCommitment, + 0x56, + ) + fourth := testFrostNativeSignerAnchorCheckpoint( + storeFingerprint, + 4, + third.StateCommitment, + 0x57, + ) + checkpoints := []FrostNativeSignerStateWitnessCheckpoint{ + candidate, + third, + fourth, + } + priorCheckpoint := expected + for index, checkpoint := range checkpoints { + eventProof := []frostsigning.NativeTBTCSignerStateWitnessProofEntry{{ + Generation: checkpoint.Generation, + PreviousStateCommitment: checkpoint.PreviousStateCommitment, + StateImageDigest: checkpoint.StateImageDigest, + StateCommitment: checkpoint.StateCommitment, + }} + operationID := testFrostNativeSignerAnchorBytes32(byte(0x81 + index)) + transitionDigest := computeFrostNativeSignerAnchorTransitionDigest( + identity, + operationID, + priorCheckpoint, + checkpoint, + eventProof, + ) + current, currentJSON = testFrostNativeSignerAnchorAcknowledgement( + t, + identity, + checkpoint, + operationID, + transitionDigest, + testFrostNativeSignerAnchorBytes32(byte(0x91+index)), + testFrostNativeSignerAnchorBytes32(byte(0xa1+index)), + "applied", + 1, + uint64(index+2), + current.EventRoot, + now, + onlinePrivate, + ) + historyEvents = append( + historyEvents, + FrostNativeSignerStateWitnessAnchorHistoryEvent{ + Acknowledgement: *current, + WitnessProof: eventProof, + }, + ) + priorCheckpoint = checkpoint + } + historyTarget = frostNativeSignerAnchorReferenceFromAcknowledgement(current) + } + client, err := NewFrostNativeSignerAnchorClient(FrostNativeSignerAnchorClientConfig{ + Endpoint: endpoint, + ClientPrivateKey: clientPrivate, + OnlinePublicKeySPKI: onlineSPKI, + Identity: identity, + Now: func() time.Time { return now }, + }) + if err != nil { + server.Close() + t.Fatal(err) + } + return &testFrostNativeSignerAnchorEnvironment{ + server: server, + client: client, + expected: expected, + candidate: candidate, + proof: proof, + historyFloor: historyFloor, + historyTarget: historyTarget, + } +} + +func testFrostNativeSignerAnchorAbsentReadResponse( + t *testing.T, + identity FrostNativeSignerAnchorIdentity, + requestDigest [32]byte, + nonce [32]byte, + onlinePrivate ed25519.PrivateKey, +) []byte { + t.Helper() + response := frostNativeSignerAnchorReadResponse{ + Schema: FrostNativeSignerAnchorReadResponseSchema, + BindingHash: frostNativeSignerAnchorHex32( + ComputeFrostNativeSignerAnchorBindingHash(identity), + ), + RequestDigest: frostNativeSignerAnchorHex32(requestDigest), + Nonce: frostNativeSignerAnchorHex32(nonce), + Status: "absent", + ServiceEpoch: "0", + Revision: "0", + EventRoot: frostNativeSignerAnchorHex32([32]byte{}), + Checkpoint: nil, + OperationID: frostNativeSignerAnchorHex32([32]byte{}), + TransitionDigest: frostNativeSignerAnchorHex32([32]byte{}), + CommittedAtUnixMs: "0", + ExpiresAtUnixMs: "0", + CheckpointAck: json.RawMessage("null"), + CheckpointAckDigest: frostNativeSignerAnchorHex32([32]byte{}), + } + digest, err := frostNativeSignerAnchorReadResponseTranscript(response) + if err != nil { + t.Fatal(err) + } + response.Signature = frostNativeSignerAnchorSignatureHex( + ed25519.Sign(onlinePrivate, digest), + ) + payload, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + return payload +} + +func testFrostNativeSignerAnchorAcknowledgement( + t *testing.T, + identity FrostNativeSignerAnchorIdentity, + checkpoint FrostNativeSignerStateWitnessCheckpoint, + operationID [32]byte, + transitionDigest [32]byte, + requestDigest [32]byte, + nonce [32]byte, + status string, + serviceEpoch uint64, + revision uint64, + previousEventRoot [32]byte, + now time.Time, + onlinePrivate ed25519.PrivateKey, +) (*FrostNativeSignerCheckpointAcknowledgement, []byte) { + t.Helper() + acknowledgement := FrostNativeSignerCheckpointAcknowledgement{ + BindingHash: ComputeFrostNativeSignerAnchorBindingHash(identity), + RequestDigest: requestDigest, + Nonce: nonce, + Status: status, + ServiceEpoch: serviceEpoch, + Revision: revision, + PreviousEventRoot: previousEventRoot, + Checkpoint: checkpoint, + OperationID: operationID, + TransitionDigest: transitionDigest, + CommittedAtUnixMs: uint64(now.Add(-time.Second).UnixMilli()), + ExpiresAtUnixMs: uint64(now.Add(29 * time.Second).UnixMilli()), + } + acknowledgement.EventRoot = computeFrostNativeSignerAnchorEventRoot(acknowledgement) + wire := frostNativeSignerAnchorAcknowledgementWire{ + Schema: FrostNativeSignerCheckpointAcknowledgementSchema, + BindingHash: frostNativeSignerAnchorHex32(acknowledgement.BindingHash), + RequestDigest: frostNativeSignerAnchorHex32(requestDigest), + Nonce: frostNativeSignerAnchorHex32(nonce), + Status: status, + ServiceEpoch: fmt.Sprint(serviceEpoch), + Revision: fmt.Sprint(revision), + PreviousEventRoot: frostNativeSignerAnchorHex32(previousEventRoot), + EventRoot: frostNativeSignerAnchorHex32(acknowledgement.EventRoot), + Checkpoint: frostNativeSignerAnchorCheckpointToWire(checkpoint), + OperationID: frostNativeSignerAnchorHex32(operationID), + TransitionDigest: frostNativeSignerAnchorHex32(transitionDigest), + CommittedAtUnixMs: fmt.Sprint(acknowledgement.CommittedAtUnixMs), + ExpiresAtUnixMs: fmt.Sprint(acknowledgement.ExpiresAtUnixMs), + } + signingDigest, err := frostNativeSignerAnchorAcknowledgementTranscript(wire) + if err != nil { + t.Fatal(err) + } + signature := ed25519.Sign(onlinePrivate, signingDigest) + wire.Signature = frostNativeSignerAnchorSignatureHex(signature) + copy(acknowledgement.SigningDigest[:], signingDigest) + copy(acknowledgement.Signature[:], signature) + onlineSPKI, _ := x509.MarshalPKIXPublicKey(onlinePrivate.Public()) + acknowledgement.AcknowledgementDigest = + computeFrostNativeSignerCheckpointAcknowledgementDigest( + acknowledgement.SigningDigest, + acknowledgement.Signature, + sha256.Sum256(onlineSPKI), + ) + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + acknowledgement.ExactAcknowledgement = append([]byte{}, payload...) + return &acknowledgement, payload +} + +func testFrostNativeSignerAnchorReadResponse( + t *testing.T, + identity FrostNativeSignerAnchorIdentity, + requestDigest [32]byte, + nonce [32]byte, + acknowledgement *FrostNativeSignerCheckpointAcknowledgement, + acknowledgementJSON []byte, + onlinePrivate ed25519.PrivateKey, +) []byte { + t.Helper() + checkpointWire := frostNativeSignerAnchorCheckpointToWire(acknowledgement.Checkpoint) + response := frostNativeSignerAnchorReadResponse{ + Schema: FrostNativeSignerAnchorReadResponseSchema, + BindingHash: frostNativeSignerAnchorHex32( + ComputeFrostNativeSignerAnchorBindingHash(identity), + ), + RequestDigest: frostNativeSignerAnchorHex32(requestDigest), + Nonce: frostNativeSignerAnchorHex32(nonce), + Status: "present", + ServiceEpoch: fmt.Sprint(acknowledgement.ServiceEpoch), + Revision: fmt.Sprint(acknowledgement.Revision), + EventRoot: frostNativeSignerAnchorHex32(acknowledgement.EventRoot), + Checkpoint: &checkpointWire, + OperationID: frostNativeSignerAnchorHex32(acknowledgement.OperationID), + TransitionDigest: frostNativeSignerAnchorHex32(acknowledgement.TransitionDigest), + CommittedAtUnixMs: "1700000000000", + ExpiresAtUnixMs: "1700000030000", + CheckpointAck: json.RawMessage(append([]byte{}, acknowledgementJSON...)), + CheckpointAckDigest: frostNativeSignerAnchorHex32( + acknowledgement.AcknowledgementDigest, + ), + } + digest, err := frostNativeSignerAnchorReadResponseTranscript(response) + if err != nil { + t.Fatal(err) + } + response.Signature = frostNativeSignerAnchorSignatureHex( + ed25519.Sign(onlinePrivate, digest), + ) + payload, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + return payload +} + +func testFrostNativeSignerAnchorHistoryResponse( + t *testing.T, + identity FrostNativeSignerAnchorIdentity, + requestDigest [32]byte, + nonce [32]byte, + floor FrostNativeSignerStateWitnessAnchorReference, + target FrostNativeSignerStateWitnessAnchorReference, + startRevision uint64, + events []FrostNativeSignerStateWitnessAnchorHistoryEvent, + now time.Time, + onlinePrivate ed25519.PrivateKey, +) []byte { + t.Helper() + wireEvents := make([]frostNativeSignerAnchorHistoryEventWire, len(events)) + eventDigests := make([][32]byte, len(events)) + proofEntryCount := 0 + for index, event := range events { + rawAcknowledgement := append( + []byte{}, + event.Acknowledgement.ExactAcknowledgement..., + ) + wireEvents[index] = frostNativeSignerAnchorHistoryEventWire{ + CheckpointAck: json.RawMessage(rawAcknowledgement), + WitnessProof: frostNativeSignerAnchorProofToWire(event.WitnessProof), + } + proofEntryCount += len(event.WitnessProof) + eventDigests[index] = computeFrostNativeSignerAnchorHistoryEventDigest( + event.Acknowledgement.Revision, + event.Acknowledgement.AcknowledgementDigest, + rawAcknowledgement, + event.WitnessProof, + ) + } + status := "complete" + nextRevision := uint64(0) + if len(events) == 0 { + if floor != target { + t.Fatal("empty history response requires equal floor and target") + } + } else if frostNativeSignerAnchorReferenceFromAcknowledgement( + &events[len(events)-1].Acknowledgement, + ) != target { + status = "partial" + nextRevision = events[len(events)-1].Acknowledgement.Revision + 1 + } + response := frostNativeSignerAnchorHistoryResponse{ + Schema: FrostNativeSignerAnchorHistoryResponseSchema, + BindingHash: frostNativeSignerAnchorHex32( + ComputeFrostNativeSignerAnchorBindingHash(identity), + ), + RequestDigest: frostNativeSignerAnchorHex32(requestDigest), + Nonce: frostNativeSignerAnchorHex32(nonce), + Status: status, + ServiceEpoch: fmt.Sprint(target.ServiceEpoch), + FloorRef: frostNativeSignerAnchorHistoryReferenceToWire(floor), + TargetRef: frostNativeSignerAnchorHistoryReferenceToWire(target), + StartRevision: fmt.Sprint(startRevision), + NextRevision: fmt.Sprint(nextRevision), + EventCount: fmt.Sprint(len(events)), + ProofEntryCount: fmt.Sprint(proofEntryCount), + Events: &wireEvents, + CommittedAtUnixMs: fmt.Sprint(now.UnixMilli()), + ExpiresAtUnixMs: fmt.Sprint(now.Add(30 * time.Second).UnixMilli()), + } + digest, err := frostNativeSignerAnchorHistoryResponseTranscript( + response, + eventDigests, + ) + if err != nil { + t.Fatal(err) + } + response.Signature = frostNativeSignerAnchorSignatureHex( + ed25519.Sign(onlinePrivate, digest), + ) + payload, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + return payload +} + +func testFrostNativeSignerAnchorCheckpoint( + storeFingerprint [32]byte, + generation uint64, + previousCommitment [32]byte, + imageByte byte, +) FrostNativeSignerStateWitnessCheckpoint { + imageDigest := testFrostNativeSignerAnchorBytes32(imageByte) + return FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: storeFingerprint, + Generation: generation, + PreviousStateCommitment: previousCommitment, + StateImageDigest: imageDigest, + StateCommitment: frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + generation, + previousCommitment, + imageDigest, + ), + } +} + +func testFrostNativeSignerAnchorBytes32(value byte) [32]byte { + var result [32]byte + for index := range result { + result[index] = value + } + return result +} diff --git a/pkg/tbtc/frost_native_signer_anchor_history.go b/pkg/tbtc/frost_native_signer_anchor_history.go new file mode 100644 index 0000000000..af920422a4 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_history.go @@ -0,0 +1,289 @@ +package tbtc + +import ( + "bytes" + "fmt" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +func (binding *frostNativeSignerAnchorBinding) manifestFloorReference() FrostNativeSignerStateWitnessAnchorReference { + return FrostNativeSignerStateWitnessAnchorReference{ + ServiceEpoch: binding.floor.MinimumServiceEpoch, + Revision: binding.floor.MinimumRevision, + EventRoot: binding.floor.MinimumEventRoot, + AcknowledgementDigest: binding.floor.MinimumAcknowledgementDigest, + Checkpoint: binding.floor.MinimumCheckpoint, + } +} + +// validateStartupHistory independently rechecks the semantic history returned +// by the authenticated client. The client verifies both signed wrappers and +// every embedded acknowledgement; this layer proves that those signed events +// are the exact state-transition chain authorized by the offline floor. +func (binding *frostNativeSignerAnchorBinding) validateStartupHistory( + history *FrostNativeSignerStateWitnessAnchorHistory, +) ( + map[uint64][32]byte, + *FrostNativeSignerStateWitnessAnchorRecord, + *FrostNativeSignerStateWitnessAnchorReference, + error, +) { + floor := binding.manifestFloorReference() + if history == nil || history.FinalRead == nil || + history.Floor != floor { + return nil, nil, nil, fmt.Errorf( + "startup native signer anchor history does not begin at the exact offline floor", + ) + } + if err := validateFrostNativeSignerAnchorHistoryBounds( + history.Floor, + history.Target, + binding.identity.SignerStoreFingerprint, + ); err != nil { + return nil, nil, nil, fmt.Errorf( + "startup native signer anchor history bounds are invalid: %w", + err, + ) + } + remote := history.FinalRead + if err := binding.validateRemoteRecord(remote); err != nil { + return nil, nil, nil, err + } + if frostNativeSignerAnchorReferenceFromRecord(remote) != history.Target { + return nil, nil, nil, fmt.Errorf( + "startup native signer final Read differs from the authenticated history target", + ) + } + nowUnixMillis := binding.now().UnixMilli() + if nowUnixMillis < 0 || len(remote.ReadRecoveryJSON) == 0 || + remote.ReadRecoveryExpires <= uint64(nowUnixMillis) { + return nil, nil, nil, fmt.Errorf( + "startup native signer final Read recovery certificate is absent or expired", + ) + } + if history.Target.Revision-history.Floor.Revision != uint64(len(history.Events)) { + return nil, nil, nil, fmt.Errorf( + "startup native signer anchor history event count is discontinuous", + ) + } + + serviceCommitments := map[uint64][32]byte{ + floor.Checkpoint.Generation: floor.Checkpoint.StateCommitment, + } + current := floor + var targetPrevious *FrostNativeSignerStateWitnessAnchorReference + totalProofEntries := 0 + for index := range history.Events { + event := &history.Events[index] + acknowledgement := &event.Acknowledgement + if acknowledgement.BindingHash != binding.bindingHash || + acknowledgement.RequestDigest == [32]byte{} || + acknowledgement.Nonce == [32]byte{} || + acknowledgement.ServiceEpoch != floor.ServiceEpoch || + current.Revision == ^uint64(0) || + acknowledgement.Revision != current.Revision+1 || + acknowledgement.PreviousEventRoot != current.EventRoot || + acknowledgement.OperationID == [32]byte{} || + acknowledgement.TransitionDigest == [32]byte{} || + len(acknowledgement.ExactAcknowledgement) == 0 { + return nil, nil, nil, fmt.Errorf( + "startup native signer history acknowledgement [%d] is incomplete or discontinuous", + index, + ) + } + if acknowledgement.Status != "applied" && + acknowledgement.Status != "already-applied" { + return nil, nil, nil, fmt.Errorf( + "startup native signer history acknowledgement [%d] has an invalid status", + index, + ) + } + if computeFrostNativeSignerAnchorEventRoot(*acknowledgement) != + acknowledgement.EventRoot { + return nil, nil, nil, fmt.Errorf( + "startup native signer history acknowledgement [%d] has an invalid event root", + index, + ) + } + computedAcknowledgementDigest := + computeFrostNativeSignerCheckpointAcknowledgementDigest( + acknowledgement.SigningDigest, + acknowledgement.Signature, + binding.identity.OnlineKeyHash, + ) + if computedAcknowledgementDigest != + acknowledgement.AcknowledgementDigest { + return nil, nil, nil, fmt.Errorf( + "startup native signer history acknowledgement [%d] has an invalid digest", + index, + ) + } + totalProofEntries += len(event.WitnessProof) + if totalProofEntries > FrostNativeSignerAnchorMaximumHistoryProofEntries { + return nil, nil, nil, fmt.Errorf( + "startup native signer history proof exceeds its aggregate bound", + ) + } + if err := validateFrostNativeSignerAnchorTransition( + current.Checkpoint, + acknowledgement.Checkpoint, + event.WitnessProof, + binding.identity.SignerStoreFingerprint, + ); err != nil { + return nil, nil, nil, fmt.Errorf( + "startup native signer history transition [%d] is invalid: %w", + index, + err, + ) + } + expectedTransitionDigest := + computeFrostNativeSignerAnchorTransitionDigest( + binding.identity, + acknowledgement.OperationID, + current.Checkpoint, + acknowledgement.Checkpoint, + event.WitnessProof, + ) + if acknowledgement.TransitionDigest != expectedTransitionDigest { + return nil, nil, nil, fmt.Errorf( + "startup native signer history transition [%d] digest mismatch", + index, + ) + } + for _, entry := range event.WitnessProof { + if _, exists := serviceCommitments[entry.Generation]; exists { + return nil, nil, nil, fmt.Errorf( + "startup native signer history repeats state generation [%d]", + entry.Generation, + ) + } + serviceCommitments[entry.Generation] = entry.StateCommitment + } + previous := current + targetPrevious = &previous + current = frostNativeSignerAnchorReferenceFromAcknowledgement( + acknowledgement, + ) + } + if current != history.Target { + return nil, nil, nil, fmt.Errorf( + "startup native signer anchor history does not reach its exact target", + ) + } + if len(history.Events) > 0 { + last := &history.Events[len(history.Events)-1].Acknowledgement + if !binding.remoteRecordMatchesAcknowledgement(remote, last) { + return nil, nil, nil, fmt.Errorf( + "startup native signer final Read differs from the final history acknowledgement", + ) + } + } + return serviceCommitments, remote, targetPrevious, nil +} + +// validateLocalHistorySplice proves the local witness from a commitment that +// also appears in the independently authenticated service chain. If Rust has +// pruned pre-base records, the service history supplies floor→base and Rust +// supplies base→tip; otherwise Rust proves floor→tip directly. +func (binding *frostNativeSignerAnchorBinding) validateLocalHistorySplice( + local frostsigning.NativeTBTCSignerStateWitnessTip, + serviceCommitments map[uint64][32]byte, +) error { + floor := binding.floor.MinimumCheckpoint + var splice FrostNativeSignerStateWitnessCheckpoint + if local.WitnessBaseGeneration <= floor.Generation { + splice = floor + } else { + serviceCommitment, ok := + serviceCommitments[local.WitnessBaseGeneration] + if !ok || serviceCommitment != local.WitnessBaseCommitment { + return fmt.Errorf( + "local native signer witness base is absent from the authenticated service history", + ) + } + splice = FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: binding.identity.SignerStoreFingerprint, + Generation: local.WitnessBaseGeneration, + StateCommitment: local.WitnessBaseCommitment, + } + } + localCheckpoint := frostNativeSignerCheckpointFromTip(local) + if splice.Generation == localCheckpoint.Generation { + if splice.StateCommitment != localCheckpoint.StateCommitment { + return fmt.Errorf( + "local native signer witness splice commitment differs at equal generation", + ) + } + return nil + } + if splice.Generation > localCheckpoint.Generation { + return fmt.Errorf( + "local native signer witness splice is ahead of the local tip", + ) + } + if _, err := binding.collectProofLocked(splice, localCheckpoint); err != nil { + return fmt.Errorf( + "cannot prove local native signer witness ancestry from the authenticated service chain: %w", + err, + ) + } + return nil +} + +func frostNativeSignerAnchorReferenceFromRecord( + record *FrostNativeSignerStateWitnessAnchorRecord, +) FrostNativeSignerStateWitnessAnchorReference { + if record == nil { + return FrostNativeSignerStateWitnessAnchorReference{} + } + return FrostNativeSignerStateWitnessAnchorReference{ + ServiceEpoch: record.ServiceEpoch, + Revision: record.Revision, + EventRoot: record.EventRoot, + AcknowledgementDigest: record.AcknowledgementDigest, + Checkpoint: record.Checkpoint, + } +} + +func (binding *frostNativeSignerAnchorBinding) localTipHasAnchorReference( + local frostsigning.NativeTBTCSignerStateWitnessTip, + reference FrostNativeSignerStateWitnessAnchorReference, +) bool { + return local.AnchorBindingHash == binding.bindingHash && + local.AnchorServiceEpoch == reference.ServiceEpoch && + local.AnchorRevision == reference.Revision && + local.AnchorEventRoot == reference.EventRoot && + local.AnchorAcknowledgementDigest == reference.AcknowledgementDigest +} + +func (binding *frostNativeSignerAnchorBinding) localTipHasNoAnchor( + local frostsigning.NativeTBTCSignerStateWitnessTip, +) bool { + return local.AnchorBindingHash == [32]byte{} && + local.AnchorServiceEpoch == 0 && + local.AnchorRevision == 0 && + local.AnchorEventRoot == [32]byte{} && + local.AnchorAcknowledgementDigest == [32]byte{} +} + +func (binding *frostNativeSignerAnchorBinding) remoteRecordMatchesAcknowledgement( + record *FrostNativeSignerStateWitnessAnchorRecord, + acknowledgement *FrostNativeSignerCheckpointAcknowledgement, +) bool { + return record != nil && acknowledgement != nil && + record.Checkpoint == acknowledgement.Checkpoint && + record.BindingHash == acknowledgement.BindingHash && + record.AcknowledgementDigest == + acknowledgement.AcknowledgementDigest && + record.OperationID == acknowledgement.OperationID && + record.TransitionDigest == acknowledgement.TransitionDigest && + record.ServiceEpoch == acknowledgement.ServiceEpoch && + record.Revision == acknowledgement.Revision && + record.PreviousEventRoot == acknowledgement.PreviousEventRoot && + record.EventRoot == acknowledgement.EventRoot && + bytes.Equal( + record.AcknowledgementJSON, + acknowledgement.ExactAcknowledgement, + ) +} diff --git a/pkg/tbtc/frost_native_signer_anchor_keys.go b/pkg/tbtc/frost_native_signer_anchor_keys.go new file mode 100644 index 0000000000..b40a36f3e2 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_keys.go @@ -0,0 +1,84 @@ +package tbtc + +import ( + "bytes" + "crypto/ed25519" + "crypto/x509" + "encoding/pem" + "fmt" +) + +func loadFrostNativeSignerAnchorClientPrivateKey( + path string, +) (ed25519.PrivateKey, error) { + data, err := readSecureFrostActivationFile(path, 16*1024) + if err != nil { + return nil, fmt.Errorf( + "cannot read FROST native signer anchor client key: %w", + err, + ) + } + defer zeroFrostNativeSignerKeyBytes(data) + block, rest := pem.Decode(data) + if block == nil || block.Type != "PRIVATE KEY" || + len(bytes.TrimSpace(rest)) != 0 { + return nil, fmt.Errorf( + "FROST native signer anchor client key must be one PKCS#8 PEM block", + ) + } + defer zeroFrostNativeSignerKeyBytes(block.Bytes) + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf( + "cannot parse FROST native signer anchor client key: %w", + err, + ) + } + privateKey, ok := parsed.(ed25519.PrivateKey) + if !ok || len(privateKey) != ed25519.PrivateKeySize { + return nil, fmt.Errorf( + "FROST native signer anchor client key is not Ed25519", + ) + } + result := append(ed25519.PrivateKey{}, privateKey...) + zeroFrostNativeSignerKeyBytes(privateKey) + return result, nil +} + +func loadFrostNativeSignerAnchorOnlinePublicKeySPKI( + path string, +) ([]byte, ed25519.PublicKey, error) { + der, err := readSecureFrostActivationFile(path, 16*1024) + if err != nil { + return nil, nil, fmt.Errorf( + "cannot read FROST native signer anchor online key: %w", + err, + ) + } + parsed, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return nil, nil, fmt.Errorf( + "cannot parse FROST native signer anchor online SPKI: %w", + err, + ) + } + publicKey, ok := parsed.(ed25519.PublicKey) + if !ok || len(publicKey) != ed25519.PublicKeySize { + return nil, nil, fmt.Errorf( + "FROST native signer anchor online key is not Ed25519", + ) + } + canonical, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil || !bytes.Equal(canonical, der) { + return nil, nil, fmt.Errorf( + "FROST native signer anchor online key is not canonical DER SPKI", + ) + } + return append([]byte{}, der...), append(ed25519.PublicKey{}, publicKey...), nil +} + +func zeroFrostNativeSignerKeyBytes(value []byte) { + for index := range value { + value[index] = 0 + } +} diff --git a/pkg/tbtc/frost_native_signer_anchor_keys_test.go b/pkg/tbtc/frost_native_signer_anchor_keys_test.go new file mode 100644 index 0000000000..a0b6ec22cb --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_keys_test.go @@ -0,0 +1,132 @@ +package tbtc + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "testing" +) + +func TestLoadFrostNativeSignerAnchorKeys(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatal(err) + } + publicDER, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + privatePath := filepath.Join(directory, "client-key.pem") + publicPath := filepath.Join(directory, "online-key.der") + if err := os.WriteFile( + privatePath, + pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: privateDER, + }), + 0600, + ); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(publicPath, publicDER, 0600); err != nil { + t.Fatal(err) + } + + loadedPrivate, err := + loadFrostNativeSignerAnchorClientPrivateKey(privatePath) + if err != nil { + t.Fatalf("cannot load valid client private key: %v", err) + } + if !loadedPrivate.Equal(privateKey) { + t.Fatal("loaded client private key differs") + } + loadedSPKI, loadedPublic, err := + loadFrostNativeSignerAnchorOnlinePublicKeySPKI(publicPath) + if err != nil { + t.Fatalf("cannot load valid online public key: %v", err) + } + if string(loadedSPKI) != string(publicDER) || + !loadedPublic.Equal(publicKey) { + t.Fatal("loaded online public key differs") + } +} + +func TestLoadFrostNativeSignerAnchorKeysRejectsUnsafeFiles(t *testing.T) { + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatal(err) + } + privatePEM := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: privateDER, + }) + publicDER, err := x509.MarshalPKIXPublicKey(privateKey.Public()) + if err != nil { + t.Fatal(err) + } + + t.Run("group-readable private key", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "client-key.pem") + if err := os.WriteFile(path, privatePEM, 0640); err != nil { + t.Fatal(err) + } + if _, err := loadFrostNativeSignerAnchorClientPrivateKey(path); err == nil { + t.Fatal("group-readable client private key was accepted") + } + }) + + t.Run("private key symlink", func(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "target.pem") + link := filepath.Join(directory, "client-key.pem") + if err := os.WriteFile(target, privatePEM, 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := loadFrostNativeSignerAnchorClientPrivateKey(link); err == nil { + t.Fatal("symlinked client private key was accepted") + } + }) + + t.Run("noncanonical public SPKI", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "online-key.der") + noncanonical := append(append([]byte{}, publicDER...), 0) + if err := os.WriteFile(path, noncanonical, 0600); err != nil { + t.Fatal(err) + } + if _, _, err := + loadFrostNativeSignerAnchorOnlinePublicKeySPKI(path); err == nil { + t.Fatal("noncanonical online public-key SPKI was accepted") + } + }) + + t.Run("public key symlink", func(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "target.der") + link := filepath.Join(directory, "online-key.der") + if err := os.WriteFile(target, publicDER, 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, _, err := + loadFrostNativeSignerAnchorOnlinePublicKeySPKI(link); err == nil { + t.Fatal("symlinked online public key was accepted") + } + }) +} diff --git a/pkg/tbtc/frost_native_signer_anchor_protocol.go b/pkg/tbtc/frost_native_signer_anchor_protocol.go new file mode 100644 index 0000000000..1ee60112ad --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_protocol.go @@ -0,0 +1,1406 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "unicode/utf8" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +const ( + FrostNativeSignerAnchorReadRequestSchema = "tbtc-frost-native-signer-state-anchor-read-request/v1" + FrostNativeSignerAnchorReadResponseSchema = "tbtc-frost-native-signer-state-anchor-read-response/v1" + FrostNativeSignerAnchorCASRequestSchema = "tbtc-frost-native-signer-state-anchor-advance-request/v1" + FrostNativeSignerAnchorHistoryRequestSchema = "tbtc-frost-native-signer-state-anchor-history-request/v1" + FrostNativeSignerAnchorHistoryResponseSchema = "tbtc-frost-native-signer-state-anchor-history-response/v1" + + // FrostNativeSignerCheckpointAcknowledgementSchema is consumed verbatim by + // the native signer after Go verifies the online authority signature and + // exact checkpoint. Do not wrap or re-encode acknowledgement response bytes. + FrostNativeSignerCheckpointAcknowledgementSchema = "tbtc-signer-state-witness-checkpoint-ack/v1" + + FrostNativeSignerAnchorMaximumProofEntries = 4096 + FrostNativeSignerAnchorMaximumHistoryEventsPerPage = 256 + FrostNativeSignerAnchorMaximumHistoryProofEntriesPerPage = 4096 + FrostNativeSignerAnchorMaximumHistoryEvents = 4096 + FrostNativeSignerAnchorMaximumHistoryProofEntries = 4096 + FrostNativeSignerAnchorMaximumHistoryPages = 16 + frostNativeSignerAnchorMaximumJSONDepth = 32 + + frostNativeSignerAnchorMaximumRequestBytes = 2 * 1024 * 1024 + frostNativeSignerAnchorMaximumResponseBytes = 256 * 1024 + frostNativeSignerAnchorMaximumHistoryResponseBytes = 4 * 1024 * 1024 + + frostNativeSignerAnchorStreamDomain = "tbtc-frost-native-signer-anchor-stream-v1\x00" + frostNativeSignerAnchorBindingDomain = "tbtc-frost-native-signer-anchor-binding-v1\x00" + frostNativeSignerAnchorTransportDomain = "tbtc-frost-native-signer-anchor-transport-v1\x00" + frostNativeSignerAnchorReadRequestDomain = "tbtc-frost-native-signer-anchor-read-request-v1\x00" + frostNativeSignerAnchorCASRequestDomain = "tbtc-frost-native-signer-anchor-cas-request-v1\x00" + frostNativeSignerAnchorHistoryRequestDomain = "tbtc-frost-native-signer-anchor-history-request-v1\x00" + frostNativeSignerAnchorTransitionDomain = "tbtc-frost-native-signer-anchor-transition-v1\x00" +) + +// FrostNativeSignerAnchorIdentity is the complete authenticated identity of +// one signer-to-history-service binding. StreamID is deliberately independent +// of activation-manifest epochs and all infrastructure/key rotations; +// BindingHash binds those mutable, offline-authorized pins for each request. +type FrostNativeSignerAnchorIdentity struct { + ProtocolID [32]byte + StreamID [32]byte + ActivationManifestHash [32]byte + ActivationManifestSequence uint64 + TrustDomainID string + EndpointLeafSPKIHash [32]byte + OnlineKeyHash [32]byte + OperatorFingerprint [32]byte + HistoryStoreID string + HistoryStoreFingerprint [32]byte + HistoryClusterFingerprint [32]byte + OfflineAuthorityHash [32]byte + ClientSPKIHash [32]byte + SignerStoreFingerprint [32]byte + TransportBinding [32]byte + WitnessMaximumRecords uint64 + WitnessRotationThresholdRecords uint64 +} + +// FrostNativeSignerStateWitnessCheckpoint is the full externally anchored +// native state image. StateCommitment must authenticate all other witness +// fields according to the native signer commitment transcript. +type FrostNativeSignerStateWitnessCheckpoint struct { + StoreFingerprint [32]byte + Generation uint64 + PreviousStateCommitment [32]byte + StateImageDigest [32]byte + StateCommitment [32]byte +} + +// FrostNativeSignerStateWitnessAnchorRecord is a signed history-service +// readback. A present stream always retains the exact acknowledgement JSON for +// its latest transition so an ambiguous CAS can be recovered without inventing +// acknowledgement bytes locally. +type FrostNativeSignerStateWitnessAnchorRecord struct { + Checkpoint FrostNativeSignerStateWitnessCheckpoint + BindingHash [32]byte + AcknowledgementDigest [32]byte + OperationID [32]byte + TransitionDigest [32]byte + ServiceEpoch uint64 + Revision uint64 + PreviousEventRoot [32]byte + EventRoot [32]byte + AcknowledgementJSON []byte + AcknowledgementExpires uint64 + ReadRecoveryJSON []byte + ReadRecoveryExpires uint64 +} + +// FrostNativeSignerCheckpointAcknowledgement is the parsed representation of +// the exact Rust checkpoint-acknowledgement schema. +type FrostNativeSignerCheckpointAcknowledgement struct { + BindingHash [32]byte + RequestDigest [32]byte + Nonce [32]byte + Status string + ServiceEpoch uint64 + Revision uint64 + PreviousEventRoot [32]byte + EventRoot [32]byte + Checkpoint FrostNativeSignerStateWitnessCheckpoint + OperationID [32]byte + TransitionDigest [32]byte + CommittedAtUnixMs uint64 + ExpiresAtUnixMs uint64 + Signature [ed25519.SignatureSize]byte + SigningDigest [32]byte + AcknowledgementDigest [32]byte + ExactAcknowledgement []byte + ExactReadRecovery []byte + ReadRecoveryExpiresAt uint64 +} + +// FrostNativeSignerStateWitnessAnchorCASResult is returned only after an exact +// candidate acknowledgement, or an equivalent signed read recovery, passes +// every identity, request, transition, freshness, and Ed25519 check. +type FrostNativeSignerStateWitnessAnchorCASResult struct { + Acknowledgement FrostNativeSignerCheckpointAcknowledgement + Recovered bool +} + +type FrostNativeSignerStateWitnessAnchorReference struct { + ServiceEpoch uint64 + Revision uint64 + EventRoot [32]byte + AcknowledgementDigest [32]byte + Checkpoint FrostNativeSignerStateWitnessCheckpoint +} + +type FrostNativeSignerStateWitnessAnchorHistoryEvent struct { + Acknowledgement FrostNativeSignerCheckpointAcknowledgement + WitnessProof []frostsigning.NativeTBTCSignerStateWitnessProofEntry +} + +type FrostNativeSignerStateWitnessAnchorHistory struct { + Floor FrostNativeSignerStateWitnessAnchorReference + Target FrostNativeSignerStateWitnessAnchorReference + Events []FrostNativeSignerStateWitnessAnchorHistoryEvent + FinalRead *FrostNativeSignerStateWitnessAnchorRecord +} + +// FrostNativeSignerStateWitnessAnchorStore is the production-facing interface +// consumed by signer readiness. Implementations must serialize operations and +// fail closed after observing an authenticated state outside an in-flight +// CAS's exact expected/candidate set. +type FrostNativeSignerStateWitnessAnchorStore interface { + ReadFrostNativeSignerStateWitnessAnchor( + context.Context, + ) (*FrostNativeSignerStateWitnessAnchorRecord, error) + CompareAndSwapFrostNativeSignerStateWitnessAnchor( + context.Context, + FrostNativeSignerStateWitnessCheckpoint, + FrostNativeSignerStateWitnessCheckpoint, + []frostsigning.NativeTBTCSignerStateWitnessProofEntry, + ) (*FrostNativeSignerStateWitnessAnchorCASResult, error) + ReadFrostNativeSignerStateWitnessAnchorHistory( + context.Context, + FrostNativeSignerStateWitnessAnchorReference, + ) (*FrostNativeSignerStateWitnessAnchorHistory, error) +} + +type frostNativeSignerAnchorIdentityWire struct { + ProtocolID string `json:"protocolID"` + StreamID string `json:"streamID"` + ActivationManifestHash string `json:"activationManifestHash"` + ActivationManifestSequence string `json:"activationManifestSequence"` + TrustDomainID string `json:"trustDomainID"` + EndpointLeafSPKIHash string `json:"endpointLeafSpkiHash"` + OnlineKeyHash string `json:"onlineKeyHash"` + OperatorFingerprint string `json:"operatorFingerprint"` + HistoryStoreID string `json:"historyStoreID"` + HistoryStoreFingerprint string `json:"historyStoreFingerprint"` + HistoryClusterFingerprint string `json:"historyClusterFingerprint"` + OfflineAuthorityHash string `json:"offlineAuthorityHash"` + ClientSPKIHash string `json:"clientSpkiHash"` + SignerStoreFingerprint string `json:"signerStoreFingerprint"` + TransportBinding string `json:"transportBinding"` + WitnessMaximumRecords string `json:"witnessMaximumRecords"` + WitnessRotationThresholdRecords string `json:"witnessRotationThresholdRecords"` +} + +type frostNativeSignerAnchorCheckpointWire struct { + StoreFingerprint string `json:"storeFingerprint"` + Generation string `json:"generation"` + PreviousStateCommitment string `json:"previousStateCommitment"` + StateImageDigest string `json:"stateImageDigest"` + StateCommitment string `json:"stateCommitment"` +} + +type frostNativeSignerAnchorProofEntryWire struct { + Generation string `json:"generation"` + PreviousStateCommitment string `json:"previousStateCommitment"` + StateImageDigest string `json:"stateImageDigest"` + StateCommitment string `json:"stateCommitment"` +} + +type frostNativeSignerAnchorReadRequestPayload struct { + Kind string `json:"kind"` + Nonce string `json:"nonce"` + BindingHash string `json:"bindingHash"` + Identity frostNativeSignerAnchorIdentityWire `json:"identity"` +} + +type frostNativeSignerAnchorReadRequest struct { + Schema string `json:"schema"` + Payload frostNativeSignerAnchorReadRequestPayload `json:"payload"` + ClientPublicKeySPKI string `json:"clientPublicKeySpki"` + Signature string `json:"signature"` +} + +type frostNativeSignerAnchorCASRequestPayload struct { + Kind string `json:"kind"` + Nonce string `json:"nonce"` + BindingHash string `json:"bindingHash"` + Identity frostNativeSignerAnchorIdentityWire `json:"identity"` + OperationID string `json:"operationID"` + TransitionDigest string `json:"transitionDigest"` + Expected frostNativeSignerAnchorCheckpointWire `json:"expected"` + Candidate frostNativeSignerAnchorCheckpointWire `json:"candidate"` + Proof []frostNativeSignerAnchorProofEntryWire `json:"proof"` +} + +type frostNativeSignerAnchorCASRequest struct { + Schema string `json:"schema"` + Payload frostNativeSignerAnchorCASRequestPayload `json:"payload"` + ClientPublicKeySPKI string `json:"clientPublicKeySpki"` + Signature string `json:"signature"` +} + +type frostNativeSignerAnchorAcknowledgementWire struct { + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + RequestDigest string `json:"requestDigest"` + Nonce string `json:"nonce"` + Status string `json:"status"` + ServiceEpoch string `json:"serviceEpoch"` + Revision string `json:"revision"` + PreviousEventRoot string `json:"previousEventRoot"` + EventRoot string `json:"eventRoot"` + Checkpoint frostNativeSignerAnchorCheckpointWire `json:"checkpoint"` + OperationID string `json:"operationID"` + TransitionDigest string `json:"transitionDigest"` + CommittedAtUnixMs string `json:"committedAtUnixMs"` + ExpiresAtUnixMs string `json:"expiresAtUnixMs"` + Signature string `json:"signature"` +} + +type frostNativeSignerAnchorReadResponse struct { + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + RequestDigest string `json:"requestDigest"` + Nonce string `json:"nonce"` + Status string `json:"status"` + ServiceEpoch string `json:"serviceEpoch"` + Revision string `json:"revision"` + EventRoot string `json:"eventRoot"` + Checkpoint *frostNativeSignerAnchorCheckpointWire `json:"checkpoint"` + OperationID string `json:"operationID"` + TransitionDigest string `json:"transitionDigest"` + CommittedAtUnixMs string `json:"committedAtUnixMs"` + ExpiresAtUnixMs string `json:"expiresAtUnixMs"` + CheckpointAck json.RawMessage `json:"checkpointAck"` + CheckpointAckDigest string `json:"checkpointAckDigest"` + Signature string `json:"signature"` +} + +type frostNativeSignerAnchorHistoryReferenceWire struct { + ServiceEpoch string `json:"serviceEpoch"` + Revision string `json:"revision"` + EventRoot string `json:"eventRoot"` + CheckpointAckDigest string `json:"checkpointAckDigest"` + Checkpoint frostNativeSignerAnchorCheckpointWire `json:"checkpoint"` +} + +type frostNativeSignerAnchorHistoryRequestPayload struct { + Kind string `json:"kind"` + Nonce string `json:"nonce"` + BindingHash string `json:"bindingHash"` + Identity frostNativeSignerAnchorIdentityWire `json:"identity"` + FloorRef frostNativeSignerAnchorHistoryReferenceWire `json:"floorRef"` + TargetRef frostNativeSignerAnchorHistoryReferenceWire `json:"targetRef"` + StartRevision string `json:"startRevision"` + MaximumEvents string `json:"maximumEvents"` + MaximumProofEntries string `json:"maximumProofEntries"` +} + +type frostNativeSignerAnchorHistoryRequest struct { + Schema string `json:"schema"` + Payload frostNativeSignerAnchorHistoryRequestPayload `json:"payload"` + ClientPublicKeySPKI string `json:"clientPublicKeySpki"` + Signature string `json:"signature"` +} + +type frostNativeSignerAnchorHistoryEventWire struct { + CheckpointAck json.RawMessage `json:"checkpointAck"` + WitnessProof []frostNativeSignerAnchorProofEntryWire `json:"witnessProof"` +} + +type frostNativeSignerAnchorHistoryResponse struct { + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + RequestDigest string `json:"requestDigest"` + Nonce string `json:"nonce"` + Status string `json:"status"` + ServiceEpoch string `json:"serviceEpoch"` + FloorRef frostNativeSignerAnchorHistoryReferenceWire `json:"floorRef"` + TargetRef frostNativeSignerAnchorHistoryReferenceWire `json:"targetRef"` + StartRevision string `json:"startRevision"` + NextRevision string `json:"nextRevision"` + EventCount string `json:"eventCount"` + ProofEntryCount string `json:"proofEntryCount"` + Events *[]frostNativeSignerAnchorHistoryEventWire `json:"events"` + CommittedAtUnixMs string `json:"committedAtUnixMs"` + ExpiresAtUnixMs string `json:"expiresAtUnixMs"` + Signature string `json:"signature"` +} + +func frostNativeSignerAnchorIdentityToWire( + identity FrostNativeSignerAnchorIdentity, +) frostNativeSignerAnchorIdentityWire { + return frostNativeSignerAnchorIdentityWire{ + ProtocolID: frostNativeSignerAnchorHex32(identity.ProtocolID), + StreamID: frostNativeSignerAnchorHex32(identity.StreamID), + ActivationManifestHash: frostNativeSignerAnchorHex32(identity.ActivationManifestHash), + ActivationManifestSequence: strconv.FormatUint(identity.ActivationManifestSequence, 10), + TrustDomainID: identity.TrustDomainID, + EndpointLeafSPKIHash: frostNativeSignerAnchorHex32(identity.EndpointLeafSPKIHash), + OnlineKeyHash: frostNativeSignerAnchorHex32(identity.OnlineKeyHash), + OperatorFingerprint: frostNativeSignerAnchorHex32(identity.OperatorFingerprint), + HistoryStoreID: identity.HistoryStoreID, + HistoryStoreFingerprint: frostNativeSignerAnchorHex32(identity.HistoryStoreFingerprint), + HistoryClusterFingerprint: frostNativeSignerAnchorHex32(identity.HistoryClusterFingerprint), + OfflineAuthorityHash: frostNativeSignerAnchorHex32(identity.OfflineAuthorityHash), + ClientSPKIHash: frostNativeSignerAnchorHex32(identity.ClientSPKIHash), + SignerStoreFingerprint: frostNativeSignerAnchorHex32(identity.SignerStoreFingerprint), + TransportBinding: frostNativeSignerAnchorHex32(identity.TransportBinding), + WitnessMaximumRecords: strconv.FormatUint(identity.WitnessMaximumRecords, 10), + WitnessRotationThresholdRecords: strconv.FormatUint( + identity.WitnessRotationThresholdRecords, + 10, + ), + } +} + +func frostNativeSignerAnchorCheckpointToWire( + checkpoint FrostNativeSignerStateWitnessCheckpoint, +) frostNativeSignerAnchorCheckpointWire { + return frostNativeSignerAnchorCheckpointWire{ + StoreFingerprint: frostNativeSignerAnchorHex32(checkpoint.StoreFingerprint), + Generation: strconv.FormatUint(checkpoint.Generation, 10), + PreviousStateCommitment: frostNativeSignerAnchorHex32(checkpoint.PreviousStateCommitment), + StateImageDigest: frostNativeSignerAnchorHex32(checkpoint.StateImageDigest), + StateCommitment: frostNativeSignerAnchorHex32(checkpoint.StateCommitment), + } +} + +func frostNativeSignerAnchorProofToWire( + proof []frostsigning.NativeTBTCSignerStateWitnessProofEntry, +) []frostNativeSignerAnchorProofEntryWire { + result := make([]frostNativeSignerAnchorProofEntryWire, len(proof)) + for index, entry := range proof { + result[index] = frostNativeSignerAnchorProofEntryWire{ + Generation: strconv.FormatUint(entry.Generation, 10), + PreviousStateCommitment: frostNativeSignerAnchorHex32(entry.PreviousStateCommitment), + StateImageDigest: frostNativeSignerAnchorHex32(entry.StateImageDigest), + StateCommitment: frostNativeSignerAnchorHex32(entry.StateCommitment), + } + } + return result +} + +func frostNativeSignerAnchorHistoryReferenceToWire( + reference FrostNativeSignerStateWitnessAnchorReference, +) frostNativeSignerAnchorHistoryReferenceWire { + return frostNativeSignerAnchorHistoryReferenceWire{ + ServiceEpoch: strconv.FormatUint(reference.ServiceEpoch, 10), + Revision: strconv.FormatUint(reference.Revision, 10), + EventRoot: frostNativeSignerAnchorHex32(reference.EventRoot), + CheckpointAckDigest: frostNativeSignerAnchorHex32(reference.AcknowledgementDigest), + Checkpoint: frostNativeSignerAnchorCheckpointToWire(reference.Checkpoint), + } +} + +func frostNativeSignerAnchorHistoryReferenceFromWire( + wire frostNativeSignerAnchorHistoryReferenceWire, +) (FrostNativeSignerStateWitnessAnchorReference, error) { + serviceEpoch, err := frostNativeSignerAnchorParseUint64(wire.ServiceEpoch) + if err != nil { + return FrostNativeSignerStateWitnessAnchorReference{}, err + } + revision, err := frostNativeSignerAnchorParseUint64(wire.Revision) + if err != nil { + return FrostNativeSignerStateWitnessAnchorReference{}, err + } + eventRoot, err := frostNativeSignerAnchorParseHex32(wire.EventRoot) + if err != nil { + return FrostNativeSignerStateWitnessAnchorReference{}, err + } + acknowledgementDigest, err := frostNativeSignerAnchorParseHex32( + wire.CheckpointAckDigest, + ) + if err != nil { + return FrostNativeSignerStateWitnessAnchorReference{}, err + } + checkpoint, err := frostNativeSignerAnchorCheckpointFromWire(wire.Checkpoint) + if err != nil { + return FrostNativeSignerStateWitnessAnchorReference{}, err + } + return FrostNativeSignerStateWitnessAnchorReference{ + ServiceEpoch: serviceEpoch, + Revision: revision, + EventRoot: eventRoot, + AcknowledgementDigest: acknowledgementDigest, + Checkpoint: checkpoint, + }, nil +} + +func frostNativeSignerAnchorProofFromWire( + wire []frostNativeSignerAnchorProofEntryWire, +) ([]frostsigning.NativeTBTCSignerStateWitnessProofEntry, error) { + result := make([]frostsigning.NativeTBTCSignerStateWitnessProofEntry, len(wire)) + for index, entry := range wire { + generation, err := frostNativeSignerAnchorParseUint64(entry.Generation) + if err != nil { + return nil, err + } + previous, err := frostNativeSignerAnchorParseHex32(entry.PreviousStateCommitment) + if err != nil { + return nil, err + } + image, err := frostNativeSignerAnchorParseHex32(entry.StateImageDigest) + if err != nil { + return nil, err + } + commitment, err := frostNativeSignerAnchorParseHex32(entry.StateCommitment) + if err != nil { + return nil, err + } + result[index] = frostsigning.NativeTBTCSignerStateWitnessProofEntry{ + Generation: generation, + PreviousStateCommitment: previous, + StateImageDigest: image, + StateCommitment: commitment, + } + } + return result, nil +} + +// ComputeFrostNativeSignerAnchorStreamID derives the stable history stream. +// Activation manifest hash/sequence and online/TLS keys are intentionally +// omitted so an offline-authorized rotation cannot create an empty new stream. +func ComputeFrostNativeSignerAnchorStreamID( + identity FrostNativeSignerAnchorIdentity, +) [32]byte { + transcript := newFrostNativeSignerAnchorTranscript(frostNativeSignerAnchorStreamDomain) + transcript.bytes32("protocolID", identity.ProtocolID) + transcript.string("trustDomainID", identity.TrustDomainID) + transcript.bytes32("signerStoreFingerprint", identity.SignerStoreFingerprint) + return sha256.Sum256(transcript.bytes()) +} + +// ComputeFrostNativeSignerAnchorBindingHash binds the current manifest epoch +// and rotating online/TLS pins to the stable stream. +func ComputeFrostNativeSignerAnchorBindingHash( + identity FrostNativeSignerAnchorIdentity, +) [32]byte { + transcript := newFrostNativeSignerAnchorTranscript(frostNativeSignerAnchorBindingDomain) + frostNativeSignerAnchorWriteIdentity(transcript, identity) + return sha256.Sum256(transcript.bytes()) +} + +// ComputeFrostNativeSignerAnchorTransportBinding commits to the exact canonical +// base endpoint configured for the client. +func ComputeFrostNativeSignerAnchorTransportBinding(endpoint string) [32]byte { + transcript := newFrostNativeSignerAnchorTranscript(frostNativeSignerAnchorTransportDomain) + transcript.string("endpoint", endpoint) + return sha256.Sum256(transcript.bytes()) +} + +func frostNativeSignerAnchorWriteIdentity( + transcript *frostNativeSignerAnchorTranscript, + identity FrostNativeSignerAnchorIdentity, +) { + transcript.bytes32("protocolID", identity.ProtocolID) + transcript.bytes32("streamID", identity.StreamID) + transcript.bytes32("activationManifestHash", identity.ActivationManifestHash) + transcript.uint64("activationManifestSequence", identity.ActivationManifestSequence) + transcript.string("trustDomainID", identity.TrustDomainID) + transcript.bytes32("endpointLeafSpkiHash", identity.EndpointLeafSPKIHash) + transcript.bytes32("onlineKeyHash", identity.OnlineKeyHash) + transcript.bytes32("operatorFingerprint", identity.OperatorFingerprint) + transcript.string("historyStoreID", identity.HistoryStoreID) + transcript.bytes32("historyStoreFingerprint", identity.HistoryStoreFingerprint) + transcript.bytes32("historyClusterFingerprint", identity.HistoryClusterFingerprint) + transcript.bytes32("offlineAuthorityHash", identity.OfflineAuthorityHash) + transcript.bytes32("clientSpkiHash", identity.ClientSPKIHash) + transcript.bytes32("signerStoreFingerprint", identity.SignerStoreFingerprint) + transcript.bytes32("transportBinding", identity.TransportBinding) + transcript.uint64("witnessMaximumRecords", identity.WitnessMaximumRecords) + transcript.uint64( + "witnessRotationThresholdRecords", + identity.WitnessRotationThresholdRecords, + ) +} + +func frostNativeSignerAnchorWriteCheckpoint( + transcript *frostNativeSignerAnchorTranscript, + prefix string, + checkpoint FrostNativeSignerStateWitnessCheckpoint, +) { + transcript.bytes32(prefix+".storeFingerprint", checkpoint.StoreFingerprint) + transcript.uint64(prefix+".generation", checkpoint.Generation) + transcript.bytes32(prefix+".previousStateCommitment", checkpoint.PreviousStateCommitment) + transcript.bytes32(prefix+".stateImageDigest", checkpoint.StateImageDigest) + transcript.bytes32(prefix+".stateCommitment", checkpoint.StateCommitment) +} + +func frostNativeSignerAnchorReadRequestTranscript( + identity FrostNativeSignerAnchorIdentity, + nonce [32]byte, + clientSPKIDER []byte, +) []byte { + transcript := newFrostNativeSignerAnchorTranscript(frostNativeSignerAnchorReadRequestDomain) + transcript.string("schema", FrostNativeSignerAnchorReadRequestSchema) + transcript.string("kind", "read") + frostNativeSignerAnchorWriteIdentity(transcript, identity) + transcript.bytes32("bindingHash", ComputeFrostNativeSignerAnchorBindingHash(identity)) + transcript.bytes32("nonce", nonce) + transcript.field("clientPublicKeySpki", clientSPKIDER) + return transcript.bytes() +} + +func frostNativeSignerAnchorCASRequestTranscript( + identity FrostNativeSignerAnchorIdentity, + nonce [32]byte, + operationID [32]byte, + transitionDigest [32]byte, + expected FrostNativeSignerStateWitnessCheckpoint, + candidate FrostNativeSignerStateWitnessCheckpoint, + proof []frostsigning.NativeTBTCSignerStateWitnessProofEntry, + clientSPKIDER []byte, +) []byte { + transcript := newFrostNativeSignerAnchorTranscript(frostNativeSignerAnchorCASRequestDomain) + transcript.string("schema", FrostNativeSignerAnchorCASRequestSchema) + transcript.string("kind", "advance") + frostNativeSignerAnchorWriteIdentity(transcript, identity) + transcript.bytes32("bindingHash", ComputeFrostNativeSignerAnchorBindingHash(identity)) + transcript.bytes32("nonce", nonce) + transcript.bytes32("operationID", operationID) + transcript.bytes32("transitionDigest", transitionDigest) + frostNativeSignerAnchorWriteCheckpoint(transcript, "expected", expected) + frostNativeSignerAnchorWriteCheckpoint(transcript, "candidate", candidate) + transcript.uint64("proof.count", uint64(len(proof))) + for index, entry := range proof { + prefix := "proof." + strconv.Itoa(index) + transcript.uint64(prefix+".generation", entry.Generation) + transcript.bytes32(prefix+".previousStateCommitment", entry.PreviousStateCommitment) + transcript.bytes32(prefix+".stateImageDigest", entry.StateImageDigest) + transcript.bytes32(prefix+".stateCommitment", entry.StateCommitment) + } + transcript.field("clientPublicKeySpki", clientSPKIDER) + return transcript.bytes() +} + +func frostNativeSignerAnchorHistoryRequestTranscript( + identity FrostNativeSignerAnchorIdentity, + nonce [32]byte, + floor FrostNativeSignerStateWitnessAnchorReference, + target FrostNativeSignerStateWitnessAnchorReference, + startRevision uint64, + maximumEvents uint64, + maximumProofEntries uint64, + clientSPKIDER []byte, +) []byte { + transcript := newFrostNativeSignerAnchorTranscript( + frostNativeSignerAnchorHistoryRequestDomain, + ) + transcript.string("schema", FrostNativeSignerAnchorHistoryRequestSchema) + transcript.string("kind", "history") + frostNativeSignerAnchorWriteIdentity(transcript, identity) + transcript.bytes32("bindingHash", ComputeFrostNativeSignerAnchorBindingHash(identity)) + transcript.bytes32("nonce", nonce) + frostNativeSignerAnchorWriteHistoryReference(transcript, "floor", floor) + frostNativeSignerAnchorWriteHistoryReference(transcript, "target", target) + transcript.uint64("startRevision", startRevision) + transcript.uint64("maximumEvents", maximumEvents) + transcript.uint64("maximumProofEntries", maximumProofEntries) + transcript.field("clientPublicKeySpki", clientSPKIDER) + return transcript.bytes() +} + +func frostNativeSignerAnchorWriteHistoryReference( + transcript *frostNativeSignerAnchorTranscript, + prefix string, + reference FrostNativeSignerStateWitnessAnchorReference, +) { + transcript.uint64(prefix+".serviceEpoch", reference.ServiceEpoch) + transcript.uint64(prefix+".revision", reference.Revision) + transcript.bytes32(prefix+".eventRoot", reference.EventRoot) + transcript.bytes32( + prefix+".checkpointAckDigest", + reference.AcknowledgementDigest, + ) + frostNativeSignerAnchorWriteCheckpoint( + transcript, + prefix+".checkpoint", + reference.Checkpoint, + ) +} + +func computeFrostNativeSignerAnchorTransitionDigest( + identity FrostNativeSignerAnchorIdentity, + operationID [32]byte, + expected FrostNativeSignerStateWitnessCheckpoint, + candidate FrostNativeSignerStateWitnessCheckpoint, + proof []frostsigning.NativeTBTCSignerStateWitnessProofEntry, +) [32]byte { + transcript := newFrostNativeSignerAnchorTranscript(frostNativeSignerAnchorTransitionDomain) + transcript.bytes32("bindingHash", ComputeFrostNativeSignerAnchorBindingHash(identity)) + transcript.bytes32("operationID", operationID) + frostNativeSignerAnchorWriteCheckpoint(transcript, "expected", expected) + frostNativeSignerAnchorWriteCheckpoint(transcript, "candidate", candidate) + transcript.uint64("proof.count", uint64(len(proof))) + for index, entry := range proof { + prefix := "proof." + strconv.Itoa(index) + transcript.uint64(prefix+".generation", entry.Generation) + transcript.bytes32(prefix+".previousStateCommitment", entry.PreviousStateCommitment) + transcript.bytes32(prefix+".stateImageDigest", entry.StateImageDigest) + transcript.bytes32(prefix+".stateCommitment", entry.StateCommitment) + } + return sha256.Sum256(transcript.bytes()) +} + +func frostNativeSignerAnchorAcknowledgementTranscript( + wire frostNativeSignerAnchorAcknowledgementWire, +) ([]byte, error) { + checkpoint, err := frostNativeSignerAnchorCheckpointFromWire(wire.Checkpoint) + if err != nil { + return nil, err + } + serviceEpoch, err := frostNativeSignerAnchorParseUint64(wire.ServiceEpoch) + if err != nil { + return nil, fmt.Errorf("invalid service epoch: %w", err) + } + revision, err := frostNativeSignerAnchorParseUint64(wire.Revision) + if err != nil { + return nil, fmt.Errorf("invalid revision: %w", err) + } + committedAt, err := frostNativeSignerAnchorParseUint64(wire.CommittedAtUnixMs) + if err != nil { + return nil, fmt.Errorf("invalid commit time: %w", err) + } + expiresAt, err := frostNativeSignerAnchorParseUint64(wire.ExpiresAtUnixMs) + if err != nil { + return nil, fmt.Errorf("invalid expiry time: %w", err) + } + bytes32Fields := []struct { + name string + value string + }{ + {"bindingHash", wire.BindingHash}, + {"requestDigest", wire.RequestDigest}, + {"nonce", wire.Nonce}, + {"previousEventRoot", wire.PreviousEventRoot}, + {"eventRoot", wire.EventRoot}, + {"operationID", wire.OperationID}, + {"transitionDigest", wire.TransitionDigest}, + } + decoded := make(map[string][32]byte, len(bytes32Fields)) + for _, field := range bytes32Fields { + value, err := frostNativeSignerAnchorParseHex32(field.value) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", field.name, err) + } + decoded[field.name] = value + } + status := byte(0) + switch wire.Status { + case "applied": + status = 0x01 + case "already-applied": + status = 0x02 + default: + return nil, fmt.Errorf("invalid checkpoint acknowledgement status") + } + buffer := bytes.NewBuffer(nil) + write32 := func(name string) { + value := decoded[name] + buffer.Write(value[:]) + } + buffer.WriteString("tbtc-native-signer-state-anchor-service-response/v1\x00") + write32("bindingHash") + write32("requestDigest") + write32("nonce") + buffer.WriteByte(status) + _ = binary.Write(buffer, binary.BigEndian, serviceEpoch) + _ = binary.Write(buffer, binary.BigEndian, revision) + write32("previousEventRoot") + write32("eventRoot") + buffer.Write(checkpoint.StoreFingerprint[:]) + _ = binary.Write(buffer, binary.BigEndian, checkpoint.Generation) + buffer.Write(checkpoint.PreviousStateCommitment[:]) + buffer.Write(checkpoint.StateImageDigest[:]) + buffer.Write(checkpoint.StateCommitment[:]) + write32("operationID") + write32("transitionDigest") + _ = binary.Write(buffer, binary.BigEndian, committedAt) + _ = binary.Write(buffer, binary.BigEndian, expiresAt) + digest := sha256.Sum256(buffer.Bytes()) + return digest[:], nil +} + +func frostNativeSignerAnchorReadResponseTranscript( + response frostNativeSignerAnchorReadResponse, +) ([]byte, error) { + bindingHash, err := frostNativeSignerAnchorParseHex32(response.BindingHash) + if err != nil { + return nil, err + } + requestDigest, err := frostNativeSignerAnchorParseHex32(response.RequestDigest) + if err != nil { + return nil, err + } + nonce, err := frostNativeSignerAnchorParseHex32(response.Nonce) + if err != nil { + return nil, err + } + serviceEpoch, err := frostNativeSignerAnchorParseUint64(response.ServiceEpoch) + if err != nil { + return nil, err + } + revision, err := frostNativeSignerAnchorParseUint64(response.Revision) + if err != nil { + return nil, err + } + eventRoot, err := frostNativeSignerAnchorParseHex32(response.EventRoot) + if err != nil { + return nil, err + } + operationID, err := frostNativeSignerAnchorParseHex32(response.OperationID) + if err != nil { + return nil, err + } + transitionDigest, err := frostNativeSignerAnchorParseHex32(response.TransitionDigest) + if err != nil { + return nil, err + } + ackDigest, err := frostNativeSignerAnchorParseHex32(response.CheckpointAckDigest) + if err != nil { + return nil, err + } + committedAt, err := frostNativeSignerAnchorParseUint64(response.CommittedAtUnixMs) + if err != nil { + return nil, err + } + expiresAt, err := frostNativeSignerAnchorParseUint64(response.ExpiresAtUnixMs) + if err != nil { + return nil, err + } + status := byte(0) + checkpoint := FrostNativeSignerStateWitnessCheckpoint{} + rawAcknowledgementDigest := [32]byte{} + switch response.Status { + case "present": + status = 0x01 + if response.Checkpoint == nil || len(response.CheckpointAck) == 0 || + bytes.Equal(bytes.TrimSpace(response.CheckpointAck), []byte("null")) { + return nil, fmt.Errorf("present checkpoint or acknowledgement is absent") + } + checkpoint, err = frostNativeSignerAnchorCheckpointFromWire(*response.Checkpoint) + if err != nil { + return nil, err + } + rawAcknowledgementDigest = sha256.Sum256(response.CheckpointAck) + case "absent": + if response.Checkpoint != nil || + (len(response.CheckpointAck) != 0 && + !bytes.Equal(bytes.TrimSpace(response.CheckpointAck), []byte("null"))) || + serviceEpoch != 0 || revision != 0 || eventRoot != [32]byte{} || + operationID != [32]byte{} || transitionDigest != [32]byte{} || + committedAt != 0 || expiresAt != 0 || ackDigest != [32]byte{} { + return nil, fmt.Errorf("absent checkpoint response contains state") + } + default: + return nil, fmt.Errorf("invalid checkpoint read status") + } + buffer := bytes.NewBuffer(nil) + buffer.WriteString("tbtc-native-signer-state-anchor-read-response/v1\x00") + buffer.Write(bindingHash[:]) + buffer.Write(requestDigest[:]) + buffer.Write(nonce[:]) + buffer.WriteByte(status) + _ = binary.Write(buffer, binary.BigEndian, serviceEpoch) + _ = binary.Write(buffer, binary.BigEndian, revision) + buffer.Write(eventRoot[:]) + buffer.Write(checkpoint.StoreFingerprint[:]) + _ = binary.Write(buffer, binary.BigEndian, checkpoint.Generation) + buffer.Write(checkpoint.PreviousStateCommitment[:]) + buffer.Write(checkpoint.StateImageDigest[:]) + buffer.Write(checkpoint.StateCommitment[:]) + buffer.Write(operationID[:]) + buffer.Write(transitionDigest[:]) + _ = binary.Write(buffer, binary.BigEndian, committedAt) + _ = binary.Write(buffer, binary.BigEndian, expiresAt) + buffer.Write(ackDigest[:]) + buffer.Write(rawAcknowledgementDigest[:]) + digest := sha256.Sum256(buffer.Bytes()) + return digest[:], nil +} + +func computeFrostNativeSignerAnchorHistoryEventDigest( + revision uint64, + acknowledgementDigest [32]byte, + rawAcknowledgement []byte, + proof []frostsigning.NativeTBTCSignerStateWitnessProofEntry, +) [32]byte { + buffer := bytes.NewBuffer(nil) + buffer.WriteString("tbtc-native-signer-state-anchor-history-event/v1\x00") + _ = binary.Write(buffer, binary.BigEndian, revision) + buffer.Write(acknowledgementDigest[:]) + rawDigest := sha256.Sum256(rawAcknowledgement) + buffer.Write(rawDigest[:]) + _ = binary.Write(buffer, binary.BigEndian, uint32(len(proof))) + for _, entry := range proof { + _ = binary.Write(buffer, binary.BigEndian, entry.Generation) + buffer.Write(entry.PreviousStateCommitment[:]) + buffer.Write(entry.StateImageDigest[:]) + buffer.Write(entry.StateCommitment[:]) + } + return sha256.Sum256(buffer.Bytes()) +} + +func frostNativeSignerAnchorHistoryResponseTranscript( + response frostNativeSignerAnchorHistoryResponse, + eventDigests [][32]byte, +) ([]byte, error) { + bindingHash, err := frostNativeSignerAnchorParseHex32(response.BindingHash) + if err != nil { + return nil, err + } + requestDigest, err := frostNativeSignerAnchorParseHex32(response.RequestDigest) + if err != nil { + return nil, err + } + nonce, err := frostNativeSignerAnchorParseHex32(response.Nonce) + if err != nil { + return nil, err + } + serviceEpoch, err := frostNativeSignerAnchorParseUint64(response.ServiceEpoch) + if err != nil { + return nil, err + } + floor, err := frostNativeSignerAnchorHistoryReferenceFromWire(response.FloorRef) + if err != nil { + return nil, err + } + target, err := frostNativeSignerAnchorHistoryReferenceFromWire(response.TargetRef) + if err != nil { + return nil, err + } + startRevision, err := frostNativeSignerAnchorParseUint64(response.StartRevision) + if err != nil { + return nil, err + } + nextRevision, err := frostNativeSignerAnchorParseUint64(response.NextRevision) + if err != nil { + return nil, err + } + eventCount, err := frostNativeSignerAnchorParseUint64(response.EventCount) + if err != nil || eventCount > uint64(^uint32(0)) || + eventCount != uint64(len(eventDigests)) { + return nil, fmt.Errorf("history event count is invalid") + } + proofEntryCount, err := frostNativeSignerAnchorParseUint64(response.ProofEntryCount) + if err != nil || proofEntryCount > uint64(^uint32(0)) { + return nil, fmt.Errorf("history proof-entry count is invalid") + } + committedAt, err := frostNativeSignerAnchorParseUint64(response.CommittedAtUnixMs) + if err != nil { + return nil, err + } + expiresAt, err := frostNativeSignerAnchorParseUint64(response.ExpiresAtUnixMs) + if err != nil { + return nil, err + } + status := byte(0) + switch response.Status { + case "partial": + status = 0x01 + case "complete": + status = 0x02 + default: + return nil, fmt.Errorf("history response status is invalid") + } + buffer := bytes.NewBuffer(nil) + buffer.WriteString("tbtc-native-signer-state-anchor-history-response/v1\x00") + buffer.Write(bindingHash[:]) + buffer.Write(requestDigest[:]) + buffer.Write(nonce[:]) + buffer.WriteByte(status) + _ = binary.Write(buffer, binary.BigEndian, serviceEpoch) + frostNativeSignerAnchorWriteFixedHistoryReference(buffer, floor) + frostNativeSignerAnchorWriteFixedHistoryReference(buffer, target) + _ = binary.Write(buffer, binary.BigEndian, startRevision) + _ = binary.Write(buffer, binary.BigEndian, nextRevision) + _ = binary.Write(buffer, binary.BigEndian, uint32(eventCount)) + _ = binary.Write(buffer, binary.BigEndian, uint32(proofEntryCount)) + for _, digest := range eventDigests { + buffer.Write(digest[:]) + } + _ = binary.Write(buffer, binary.BigEndian, committedAt) + _ = binary.Write(buffer, binary.BigEndian, expiresAt) + digest := sha256.Sum256(buffer.Bytes()) + return digest[:], nil +} + +func frostNativeSignerAnchorWriteFixedHistoryReference( + buffer *bytes.Buffer, + reference FrostNativeSignerStateWitnessAnchorReference, +) { + _ = binary.Write(buffer, binary.BigEndian, reference.ServiceEpoch) + _ = binary.Write(buffer, binary.BigEndian, reference.Revision) + buffer.Write(reference.EventRoot[:]) + buffer.Write(reference.AcknowledgementDigest[:]) + buffer.Write(reference.Checkpoint.StoreFingerprint[:]) + _ = binary.Write(buffer, binary.BigEndian, reference.Checkpoint.Generation) + buffer.Write(reference.Checkpoint.PreviousStateCommitment[:]) + buffer.Write(reference.Checkpoint.StateImageDigest[:]) + buffer.Write(reference.Checkpoint.StateCommitment[:]) +} + +func frostNativeSignerAnchorCheckpointFromWire( + wire frostNativeSignerAnchorCheckpointWire, +) (FrostNativeSignerStateWitnessCheckpoint, error) { + generation, err := frostNativeSignerAnchorParseUint64(wire.Generation) + if err != nil { + return FrostNativeSignerStateWitnessCheckpoint{}, fmt.Errorf("invalid checkpoint generation: %w", err) + } + result := FrostNativeSignerStateWitnessCheckpoint{Generation: generation} + fields := []struct { + name string + value string + destination *[32]byte + }{ + {"store fingerprint", wire.StoreFingerprint, &result.StoreFingerprint}, + {"previous state commitment", wire.PreviousStateCommitment, &result.PreviousStateCommitment}, + {"state image digest", wire.StateImageDigest, &result.StateImageDigest}, + {"state commitment", wire.StateCommitment, &result.StateCommitment}, + } + for _, field := range fields { + value, err := frostNativeSignerAnchorParseHex32(field.value) + if err != nil { + return FrostNativeSignerStateWitnessCheckpoint{}, fmt.Errorf( + "invalid checkpoint %s: %w", + field.name, + err, + ) + } + *field.destination = value + } + return result, nil +} + +func validateFrostNativeSignerAnchorCheckpoint( + checkpoint FrostNativeSignerStateWitnessCheckpoint, + storeFingerprint [32]byte, +) error { + if checkpoint.StoreFingerprint != storeFingerprint || + checkpoint.Generation == 0 || + checkpoint.PreviousStateCommitment == [32]byte{} || + checkpoint.StateImageDigest == [32]byte{} || + checkpoint.StateCommitment == [32]byte{} { + return fmt.Errorf("state-witness checkpoint is incomplete or belongs to another store") + } + computed := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + checkpoint.StoreFingerprint, + checkpoint.Generation, + checkpoint.PreviousStateCommitment, + checkpoint.StateImageDigest, + ) + if computed != checkpoint.StateCommitment { + return fmt.Errorf("state-witness checkpoint commitment mismatch") + } + return nil +} + +func validateFrostNativeSignerAnchorHistoryBounds( + floor FrostNativeSignerStateWitnessAnchorReference, + target FrostNativeSignerStateWitnessAnchorReference, + storeFingerprint [32]byte, +) error { + for name, reference := range map[string]FrostNativeSignerStateWitnessAnchorReference{ + "floor": floor, + "target": target, + } { + if reference.ServiceEpoch == 0 || reference.Revision == 0 || + reference.EventRoot == [32]byte{} || + reference.AcknowledgementDigest == [32]byte{} { + return fmt.Errorf("history %s reference is incomplete", name) + } + if err := validateFrostNativeSignerAnchorCheckpoint( + reference.Checkpoint, + storeFingerprint, + ); err != nil { + return fmt.Errorf("invalid history %s checkpoint: %w", name, err) + } + } + if floor.ServiceEpoch != target.ServiceEpoch || + target.Revision < floor.Revision || + target.Revision-floor.Revision > FrostNativeSignerAnchorMaximumHistoryEvents { + return fmt.Errorf("history references are outside the bounded service epoch") + } + if target.Revision == floor.Revision && target != floor { + return fmt.Errorf("equal history revisions identify different references") + } + if target.Checkpoint.Generation < floor.Checkpoint.Generation || + target.Checkpoint.Generation-floor.Checkpoint.Generation > + FrostNativeSignerAnchorMaximumHistoryProofEntries { + return fmt.Errorf("history checkpoint generations are outside the bounded proof window") + } + return nil +} + +func validateFrostNativeSignerAnchorTransition( + expected FrostNativeSignerStateWitnessCheckpoint, + candidate FrostNativeSignerStateWitnessCheckpoint, + proof []frostsigning.NativeTBTCSignerStateWitnessProofEntry, + storeFingerprint [32]byte, +) error { + if err := validateFrostNativeSignerAnchorCheckpoint(expected, storeFingerprint); err != nil { + return fmt.Errorf("invalid expected checkpoint: %w", err) + } + if err := validateFrostNativeSignerAnchorCheckpoint(candidate, storeFingerprint); err != nil { + return fmt.Errorf("invalid candidate checkpoint: %w", err) + } + if candidate.Generation <= expected.Generation || + candidate.Generation-expected.Generation > FrostNativeSignerAnchorMaximumProofEntries || + uint64(len(proof)) != candidate.Generation-expected.Generation { + return fmt.Errorf("checkpoint transition is not a bounded strict advance") + } + if len(proof) == 0 || len(proof) > FrostNativeSignerAnchorMaximumProofEntries { + return fmt.Errorf("checkpoint proof length is invalid") + } + cursorGeneration := expected.Generation + cursorCommitment := expected.StateCommitment + for _, entry := range proof { + if entry.Generation != cursorGeneration+1 || + entry.PreviousStateCommitment != cursorCommitment || + entry.StateImageDigest == [32]byte{} || + entry.StateCommitment == [32]byte{} { + return fmt.Errorf("checkpoint proof is not contiguous") + } + computed := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + entry.Generation, + entry.PreviousStateCommitment, + entry.StateImageDigest, + ) + if computed != entry.StateCommitment { + return fmt.Errorf("checkpoint proof commitment mismatch") + } + cursorGeneration = entry.Generation + cursorCommitment = entry.StateCommitment + } + last := proof[len(proof)-1] + if last.Generation != candidate.Generation || + last.PreviousStateCommitment != candidate.PreviousStateCommitment || + last.StateImageDigest != candidate.StateImageDigest || + last.StateCommitment != candidate.StateCommitment { + return fmt.Errorf("checkpoint proof does not terminate at the candidate") + } + return nil +} + +func validateFrostNativeSignerAnchorIdentity( + identity FrostNativeSignerAnchorIdentity, + https bool, +) error { + required := map[string][32]byte{ + "protocol ID": identity.ProtocolID, + "stream ID": identity.StreamID, + "activation manifest hash": identity.ActivationManifestHash, + "online key hash": identity.OnlineKeyHash, + "operator fingerprint": identity.OperatorFingerprint, + "history store fingerprint": identity.HistoryStoreFingerprint, + "history cluster fingerprint": identity.HistoryClusterFingerprint, + "offline authority hash": identity.OfflineAuthorityHash, + "client SPKI hash": identity.ClientSPKIHash, + "signer store fingerprint": identity.SignerStoreFingerprint, + "transport binding": identity.TransportBinding, + } + if https { + required["endpoint leaf SPKI hash"] = identity.EndpointLeafSPKIHash + } else if identity.EndpointLeafSPKIHash != [32]byte{} { + return fmt.Errorf("loopback HTTP identity must use a zero endpoint leaf SPKI hash") + } + for name, value := range required { + if value == [32]byte{} { + return fmt.Errorf("%s is zero", name) + } + } + if identity.ActivationManifestSequence == 0 || + identity.WitnessMaximumRecords < 2 || + identity.WitnessMaximumRecords > 1_000_000 || + identity.WitnessRotationThresholdRecords < 2 || + identity.WitnessRotationThresholdRecords > + identity.WitnessMaximumRecords-2 || + !frostNativeSignerAnchorCanonicalIdentityString(identity.TrustDomainID, 256) || + !frostNativeSignerAnchorCanonicalIdentityString(identity.HistoryStoreID, 256) { + return fmt.Errorf("anchor identity strings or manifest sequence are invalid") + } + if ComputeFrostNativeSignerAnchorStreamID(identity) != identity.StreamID { + return fmt.Errorf("anchor stream ID does not match its stable identity") + } + return nil +} + +func frostNativeSignerAnchorCanonicalIdentityString(value string, maximum int) bool { + if value == "" || len(value) > maximum || !utf8.ValidString(value) || + strings.TrimSpace(value) != value { + return false + } + for _, character := range value { + if character < 0x21 || character > 0x7e { + return false + } + } + return true +} + +func frostNativeSignerAnchorParseUint64(value string) (uint64, error) { + if value == "" || (len(value) > 1 && value[0] == '0') { + return 0, fmt.Errorf("value is not canonical decimal uint64") + } + result, err := strconv.ParseUint(value, 10, 64) + if err != nil || strconv.FormatUint(result, 10) != value { + return 0, fmt.Errorf("value is not canonical decimal uint64") + } + return result, nil +} + +func frostNativeSignerAnchorParseHex32(value string) ([32]byte, error) { + if len(value) != 66 || !strings.HasPrefix(value, "0x") || + value != strings.ToLower(value) { + return [32]byte{}, fmt.Errorf("value is not canonical lowercase bytes32") + } + decoded, err := hex.DecodeString(value[2:]) + if err != nil || len(decoded) != 32 { + return [32]byte{}, fmt.Errorf("value is not canonical lowercase bytes32") + } + var result [32]byte + copy(result[:], decoded) + return result, nil +} + +func frostNativeSignerAnchorHex32(value [32]byte) string { + return "0x" + hex.EncodeToString(value[:]) +} + +func frostNativeSignerAnchorParseSignature(value string) ([ed25519.SignatureSize]byte, error) { + if len(value) != 2+2*ed25519.SignatureSize || + !strings.HasPrefix(value, "0x") || + value != strings.ToLower(value) { + return [ed25519.SignatureSize]byte{}, fmt.Errorf("signature is not canonical lowercase bytes64") + } + decoded, err := hex.DecodeString(value[2:]) + if err != nil || len(decoded) != ed25519.SignatureSize { + return [ed25519.SignatureSize]byte{}, fmt.Errorf("signature is not canonical lowercase bytes64") + } + var result [ed25519.SignatureSize]byte + copy(result[:], decoded) + return result, nil +} + +func frostNativeSignerAnchorSignatureHex(value []byte) string { + return "0x" + hex.EncodeToString(value) +} + +func frostNativeSignerAnchorCanonicalSPKI(value string) ([]byte, error) { + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil || base64.StdEncoding.EncodeToString(decoded) != value { + return nil, fmt.Errorf("SPKI is not canonical base64") + } + return decoded, nil +} + +func decodeStrictFrostNativeSignerAnchorJSON(data []byte, target interface{}) error { + if err := preflightFrostNativeSignerAnchorJSON(data); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("JSON contains trailing data") + } + return nil +} + +var frostNativeSignerAnchorJSONMembers = map[string]struct{}{ + "schema": {}, + "payload": {}, + "clientPublicKeySpki": {}, + "signature": {}, + "kind": {}, + "nonce": {}, + "bindingHash": {}, + "identity": {}, + "protocolID": {}, + "streamID": {}, + "activationManifestHash": {}, + "activationManifestSequence": {}, + "trustDomainID": {}, + "endpointLeafSpkiHash": {}, + "onlineKeyHash": {}, + "operatorFingerprint": {}, + "historyStoreID": {}, + "historyStoreFingerprint": {}, + "historyClusterFingerprint": {}, + "offlineAuthorityHash": {}, + "clientSpkiHash": {}, + "signerStoreFingerprint": {}, + "transportBinding": {}, + "witnessMaximumRecords": {}, + "witnessRotationThresholdRecords": {}, + "operationID": {}, + "transitionDigest": {}, + "expected": {}, + "candidate": {}, + "proof": {}, + "storeFingerprint": {}, + "generation": {}, + "previousStateCommitment": {}, + "stateImageDigest": {}, + "stateCommitment": {}, + "requestDigest": {}, + "status": {}, + "serviceEpoch": {}, + "revision": {}, + "previousEventRoot": {}, + "eventRoot": {}, + "checkpoint": {}, + "committedAtUnixMs": {}, + "expiresAtUnixMs": {}, + "checkpointAck": {}, + "checkpointAckDigest": {}, + "floorRef": {}, + "targetRef": {}, + "startRevision": {}, + "maximumEvents": {}, + "maximumProofEntries": {}, + "nextRevision": {}, + "eventCount": {}, + "proofEntryCount": {}, + "events": {}, + "witnessProof": {}, +} + +func preflightFrostNativeSignerAnchorJSON(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := scanFrostNativeSignerAnchorJSONValue(decoder, 0); err != nil { + return err + } + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("JSON contains trailing data") + } + return fmt.Errorf("invalid JSON trailing data: %w", err) + } + return nil +} + +func scanFrostNativeSignerAnchorJSONValue( + decoder *json.Decoder, + depth int, +) error { + if depth > frostNativeSignerAnchorMaximumJSONDepth { + return fmt.Errorf("JSON nesting exceeds the depth bound") + } + token, err := decoder.Token() + if err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + delimiter, isDelimiter := token.(json.Delim) + if !isDelimiter { + return nil + } + switch delimiter { + case '{': + seen := make(map[string]struct{}) + seenFolded := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return fmt.Errorf("invalid JSON object member: %w", err) + } + key, ok := keyToken.(string) + if !ok || !frostNativeSignerAnchorASCIIJSONMember(key) { + return fmt.Errorf("JSON object member name is not canonical ASCII") + } + if _, allowed := frostNativeSignerAnchorJSONMembers[key]; !allowed { + return fmt.Errorf("JSON object member name [%s] is not exact", key) + } + folded := strings.ToLower(key) + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("JSON object contains duplicate member [%s]", key) + } + if _, duplicate := seenFolded[folded]; duplicate { + return fmt.Errorf("JSON object contains case-folded duplicate member [%s]", key) + } + seen[key] = struct{}{} + seenFolded[folded] = struct{}{} + if err := scanFrostNativeSignerAnchorJSONValue(decoder, depth+1); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim('}') { + return fmt.Errorf("invalid JSON object termination") + } + case '[': + for decoder.More() { + if err := scanFrostNativeSignerAnchorJSONValue(decoder, depth+1); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim(']') { + return fmt.Errorf("invalid JSON array termination") + } + default: + return fmt.Errorf("unexpected JSON delimiter") + } + return nil +} + +func frostNativeSignerAnchorASCIIJSONMember(value string) bool { + if value == "" { + return false + } + for _, character := range value { + if character < 0x21 || character > 0x7e { + return false + } + } + return true +} + +type frostNativeSignerAnchorTranscript struct { + buffer bytes.Buffer +} + +func newFrostNativeSignerAnchorTranscript(domain string) *frostNativeSignerAnchorTranscript { + result := &frostNativeSignerAnchorTranscript{} + result.field("domain", []byte(domain)) + return result +} + +func (transcript *frostNativeSignerAnchorTranscript) field(name string, value []byte) { + frostNativeSignerAnchorWriteLengthPrefixed(&transcript.buffer, []byte(name)) + frostNativeSignerAnchorWriteLengthPrefixed(&transcript.buffer, value) +} + +func (transcript *frostNativeSignerAnchorTranscript) string(name string, value string) { + transcript.field(name, []byte(value)) +} + +func (transcript *frostNativeSignerAnchorTranscript) bytes32(name string, value [32]byte) { + transcript.field(name, value[:]) +} + +func (transcript *frostNativeSignerAnchorTranscript) uint64(name string, value uint64) { + transcript.string(name, strconv.FormatUint(value, 10)) +} + +func (transcript *frostNativeSignerAnchorTranscript) bytes() []byte { + return transcript.buffer.Bytes() +} + +func frostNativeSignerAnchorWriteLengthPrefixed(writer io.Writer, value []byte) { + // Transcript fields are bounded well below uint32 by construction. + _ = binary.Write(writer, binary.BigEndian, uint32(len(value))) + _, _ = writer.Write(value) +} diff --git a/pkg/tbtc/frost_native_signer_readiness.go b/pkg/tbtc/frost_native_signer_readiness.go index 6b2709255b..bfb3e8b30e 100644 --- a/pkg/tbtc/frost_native_signer_readiness.go +++ b/pkg/tbtc/frost_native_signer_readiness.go @@ -23,29 +23,6 @@ func completeFrostInteractiveSigningReadiness( nativeExecutionAvailable && nativeBackendSelected } -// FrostNativeSignerStateWitnessAnchor is an independently durable accepted -// tip of the native signer's append-only state-witness chain. It is dynamic -// state and deliberately is not folded into the immutable activation manifest -// or stable store identity. -type FrostNativeSignerStateWitnessAnchor struct { - StoreFingerprint [32]byte - Generation uint64 - Commitment [32]byte -} - -// FrostNativeSignerStateWitnessAnchorSource supplies the last state witness -// accepted by the independent activation/watchtower system. Its backing store -// must be outside the signer state directory and must atomically persist an -// accepted handshake before treating that handshake as active. -// -// A local signer file cannot implement this interface safely: coordinated -// rollback of that file and the signer state would otherwise be invisible. -type FrostNativeSignerStateWitnessAnchorSource interface { - ReadFrostNativeSignerStateWitnessAnchor( - context.Context, - ) (*FrostNativeSignerStateWitnessAnchor, error) -} - type frostNativeSignerInventoryReader func() ( *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error, @@ -65,34 +42,32 @@ type frostNativeSignerInventoryExpectation struct { } type frostNativeSignerInventorySnapshot struct { - Schema string - StoreFingerprint [32]byte - StateGeneration uint64 - StateCommitment [32]byte - PreviousStateCommitment [32]byte - StateImageDigest [32]byte - InventoryCommitment [32]byte - WalletCount uint64 - KeyPackageCount uint64 + Schema string + StoreFingerprint [32]byte + StateGeneration uint64 + StateCommitment [32]byte + PreviousStateCommitment [32]byte + StateImageDigest [32]byte + InventoryCommitment [32]byte + WalletCount uint64 + KeyPackageCount uint64 + ExternalRollbackAnchorBound bool } type frostNativeSignerInventoryBinding struct { storeBinding *frostDurableSessionStoreBinding - anchorSource FrostNativeSignerStateWitnessAnchorSource + anchorBinding *frostNativeSignerAnchorBinding readInventory frostNativeSignerInventoryReader - readProof frostNativeSignerStateWitnessProofReader - mutex sync.Mutex - baseline *FrostNativeSignerStateWitnessAnchor + mutex sync.Mutex } func newFrostNativeSignerInventoryBinding( storeBinding *frostDurableSessionStoreBinding, - anchorSource FrostNativeSignerStateWitnessAnchorSource, + anchorBinding *frostNativeSignerAnchorBinding, readInventory frostNativeSignerInventoryReader, - readProof frostNativeSignerStateWitnessProofReader, ) (*frostNativeSignerInventoryBinding, error) { - if storeBinding == nil || anchorSource == nil || readInventory == nil || readProof == nil { + if storeBinding == nil || anchorBinding == nil || readInventory == nil { return nil, fmt.Errorf("FROST native signer inventory dependencies are incomplete") } if _, err := storeBinding.verify(); err != nil { @@ -100,9 +75,8 @@ func newFrostNativeSignerInventoryBinding( } return &frostNativeSignerInventoryBinding{ storeBinding: storeBinding, - anchorSource: anchorSource, + anchorBinding: anchorBinding, readInventory: readInventory, - readProof: readProof, }, nil } @@ -123,14 +97,6 @@ func (binding *frostNativeSignerInventoryBinding) verify( if err != nil { return nil, fmt.Errorf("FROST native signer store binding failed: [%w]", err) } - anchor, err := binding.anchorSource.ReadFrostNativeSignerStateWitnessAnchor(ctx) - if err != nil { - return nil, fmt.Errorf("cannot read independent native signer state-witness anchor: [%w]", err) - } - if err := validateFrostNativeSignerStateWitnessAnchor(anchor, storeFingerprint); err != nil { - return nil, err - } - inventory, err := binding.readInventory() if err != nil { return nil, fmt.Errorf("cannot read native retained key-package inventory: [%w]", err) @@ -138,112 +104,53 @@ func (binding *frostNativeSignerInventoryBinding) verify( if inventory == nil || inventory.StoreFingerprint != storeFingerprint { return nil, fmt.Errorf("native retained key-package inventory is absent or belongs to another store") } - target := FrostNativeSignerStateWitnessAnchor{ - StoreFingerprint: inventory.StoreFingerprint, - Generation: inventory.StateGeneration, - Commitment: inventory.StateCommitment, + target := FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: inventory.StoreFingerprint, + Generation: inventory.StateGeneration, + PreviousStateCommitment: inventory.PreviousStateCommitment, + StateImageDigest: inventory.StateImageDigest, + StateCommitment: inventory.StateCommitment, + } + localTip, err := binding.anchorBinding.readTip() + if err != nil { + return nil, fmt.Errorf("cannot read native signer state tip: [%w]", err) } - if err := binding.verifyAncestry(anchor, &target); err != nil { - return nil, fmt.Errorf("native signer state does not descend from the independent anchor: [%w]", err) + if localTip == nil || frostNativeSignerCheckpointFromTip(*localTip) != target { + return nil, fmt.Errorf( + "native signer inventory and state-witness tip identify different checkpoints", + ) } - if binding.baseline != nil { - if err := binding.verifyAncestry(binding.baseline, &target); err != nil { - return nil, fmt.Errorf("native signer state does not descend from the process baseline: [%w]", err) - } + if err := binding.anchorBinding.VerifyNativeTBTCSignerStateTip( + ctx, + *localTip, + ); err != nil { + return nil, fmt.Errorf( + "native signer state is not bound to the authenticated external anchor: [%w]", + err, + ) } if err := verifyFrostNativeSignerInventoryEntries(inventory.Entries, expected); err != nil { return nil, err } - binding.baseline = &FrostNativeSignerStateWitnessAnchor{ - StoreFingerprint: target.StoreFingerprint, - Generation: target.Generation, - Commitment: target.Commitment, - } keyPackageCount := uint64(0) for _, entry := range inventory.Entries { keyPackageCount += uint64(len(entry.KeyPackages)) } return &frostNativeSignerInventorySnapshot{ - Schema: inventory.Schema, - StoreFingerprint: inventory.StoreFingerprint, - StateGeneration: inventory.StateGeneration, - StateCommitment: inventory.StateCommitment, - PreviousStateCommitment: inventory.PreviousStateCommitment, - StateImageDigest: inventory.StateImageDigest, - InventoryCommitment: inventory.InventoryCommitment, - WalletCount: uint64(len(inventory.Entries)), - KeyPackageCount: keyPackageCount, + Schema: inventory.Schema, + StoreFingerprint: inventory.StoreFingerprint, + StateGeneration: inventory.StateGeneration, + StateCommitment: inventory.StateCommitment, + PreviousStateCommitment: inventory.PreviousStateCommitment, + StateImageDigest: inventory.StateImageDigest, + InventoryCommitment: inventory.InventoryCommitment, + WalletCount: uint64(len(inventory.Entries)), + KeyPackageCount: keyPackageCount, + ExternalRollbackAnchorBound: true, }, nil } -func validateFrostNativeSignerStateWitnessAnchor( - anchor *FrostNativeSignerStateWitnessAnchor, - storeFingerprint [32]byte, -) error { - if anchor == nil || anchor.StoreFingerprint != storeFingerprint || - anchor.Generation == 0 || anchor.Commitment == [32]byte{} { - return fmt.Errorf("independent native signer state-witness anchor is invalid") - } - return nil -} - -func (binding *frostNativeSignerInventoryBinding) verifyAncestry( - ancestor *FrostNativeSignerStateWitnessAnchor, - target *FrostNativeSignerStateWitnessAnchor, -) error { - if ancestor == nil || target == nil || - ancestor.StoreFingerprint != target.StoreFingerprint || - ancestor.Generation == 0 || target.Generation == 0 || - ancestor.Commitment == [32]byte{} || target.Commitment == [32]byte{} || - target.Generation < ancestor.Generation { - return fmt.Errorf("state-witness ancestry bounds are invalid") - } - if target.Generation == ancestor.Generation && target.Commitment != ancestor.Commitment { - return fmt.Errorf("equal state-witness generations have different commitments") - } - - cursorGeneration := ancestor.Generation - cursorCommitment := ancestor.Commitment - for page := 0; page < frostNativeSignerStateWitnessMaximumPages; page++ { - request := &frostsigning.NativeTBTCSignerStateWitnessProofRequest{ - Schema: frostsigning.NativeTBTCSignerStateWitnessProofRequestSchema, - StoreFingerprint: target.StoreFingerprint, - AncestorGeneration: cursorGeneration, - AncestorCommitment: cursorCommitment, - TargetGeneration: target.Generation, - TargetCommitment: target.Commitment, - MaximumEntries: frostsigning.NativeTBTCSignerStateWitnessProofMaximumEntries, - } - proof, err := binding.readProof(request) - if err != nil { - return fmt.Errorf("cannot read native signer state-witness proof: [%w]", err) - } - if proof == nil || proof.Schema != frostsigning.NativeTBTCSignerStateWitnessProofSchema || - proof.StoreFingerprint != request.StoreFingerprint || - proof.AncestorGeneration != request.AncestorGeneration || - proof.AncestorCommitment != request.AncestorCommitment || - proof.TargetGeneration != request.TargetGeneration || - proof.TargetCommitment != request.TargetCommitment || - len(proof.Entries) > int(request.MaximumEntries) { - return fmt.Errorf("native signer returned a proof for different ancestry bounds") - } - if proof.Complete { - return nil - } - if len(proof.Entries) == 0 { - return fmt.Errorf("native signer state-witness proof made no progress") - } - last := proof.Entries[len(proof.Entries)-1] - cursorGeneration = last.Generation - cursorCommitment = last.StateCommitment - } - return fmt.Errorf( - "native signer state-witness ancestry exceeds the bounded proof window [%d]", - frostNativeSignerStateWitnessMaximumPages*int(frostsigning.NativeTBTCSignerStateWitnessProofMaximumEntries), - ) -} - func verifyFrostNativeSignerInventoryEntries( actual []frostsigning.NativeTBTCSignerRetainedKeyGroup, expected []frostNativeSignerInventoryExpectation, diff --git a/pkg/tbtc/frost_native_signer_readiness_test.go b/pkg/tbtc/frost_native_signer_readiness_test.go index 14f613e208..a329855e70 100644 --- a/pkg/tbtc/frost_native_signer_readiness_test.go +++ b/pkg/tbtc/frost_native_signer_readiness_test.go @@ -11,24 +11,93 @@ import ( frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" ) -type testFrostNativeSignerStateWitnessAnchorSource struct { - anchor *FrostNativeSignerStateWitnessAnchor +type testFrostNativeSignerStateWitnessAnchorStore struct { + record *FrostNativeSignerStateWitnessAnchorRecord err error } -func (source *testFrostNativeSignerStateWitnessAnchorSource) ReadFrostNativeSignerStateWitnessAnchor( +func (store *testFrostNativeSignerStateWitnessAnchorStore) ReadFrostNativeSignerStateWitnessAnchor( context.Context, -) (*FrostNativeSignerStateWitnessAnchor, error) { - if source.err != nil { - return nil, source.err +) (*FrostNativeSignerStateWitnessAnchorRecord, error) { + if store.err != nil { + return nil, store.err } - if source.anchor == nil { + if store.record == nil { return nil, nil } - result := *source.anchor + result := *store.record return &result, nil } +func (store *testFrostNativeSignerStateWitnessAnchorStore) CompareAndSwapFrostNativeSignerStateWitnessAnchor( + context.Context, + FrostNativeSignerStateWitnessCheckpoint, + FrostNativeSignerStateWitnessCheckpoint, + []frostsigning.NativeTBTCSignerStateWitnessProofEntry, +) (*FrostNativeSignerStateWitnessAnchorCASResult, error) { + return nil, fmt.Errorf("unexpected test anchor CAS") +} + +func (store *testFrostNativeSignerStateWitnessAnchorStore) ReadFrostNativeSignerStateWitnessAnchorHistory( + context.Context, + FrostNativeSignerStateWitnessAnchorReference, +) (*FrostNativeSignerStateWitnessAnchorHistory, error) { + return nil, fmt.Errorf("unexpected test anchor history read") +} + +func testFrostNativeSignerInventoryAnchorBinding( + storeFingerprint [32]byte, + checkpoint FrostNativeSignerStateWitnessCheckpoint, +) ( + *frostNativeSignerAnchorBinding, + *testFrostNativeSignerStateWitnessAnchorStore, +) { + bindingHash := [32]byte{0xa1} + eventRoot := [32]byte{0xa2} + acknowledgementDigest := [32]byte{0xa3} + tip := &frostsigning.NativeTBTCSignerStateWitnessTip{ + Schema: frostsigning.NativeTBTCSignerStateWitnessTipSchema, + StoreFingerprint: checkpoint.StoreFingerprint, + Generation: checkpoint.Generation, + PreviousStateCommitment: checkpoint.PreviousStateCommitment, + StateImageDigest: checkpoint.StateImageDigest, + StateCommitment: checkpoint.StateCommitment, + WitnessBaseGeneration: checkpoint.Generation, + WitnessBaseCommitment: checkpoint.StateCommitment, + AnchorBindingHash: bindingHash, + AnchorServiceEpoch: 1, + AnchorRevision: 1, + AnchorEventRoot: eventRoot, + AnchorAcknowledgementDigest: acknowledgementDigest, + } + store := &testFrostNativeSignerStateWitnessAnchorStore{ + record: &FrostNativeSignerStateWitnessAnchorRecord{ + Checkpoint: checkpoint, + BindingHash: bindingHash, + AcknowledgementDigest: acknowledgementDigest, + OperationID: [32]byte{0xa4}, + TransitionDigest: [32]byte{0xa5}, + ServiceEpoch: 1, + Revision: 1, + EventRoot: eventRoot, + AcknowledgementJSON: []byte(`{"test":"ack"}`), + }, + } + return &frostNativeSignerAnchorBinding{ + store: store, + identity: FrostNativeSignerAnchorIdentity{SignerStoreFingerprint: storeFingerprint}, + bindingHash: bindingHash, + floor: FrostNativeSignerAnchorManifest{ + MinimumServiceEpoch: 1, + MinimumRevision: 1, + }, + readTip: func() (*frostsigning.NativeTBTCSignerStateWitnessTip, error) { + result := *tip + return &result, nil + }, + }, store +} + func TestCompleteFrostInteractiveSigningReadiness_RequiresEveryRuntimeGate( t *testing.T, ) { @@ -127,7 +196,7 @@ func TestVerifyFrostNativeSignerInventoryEntriesRejectsMismatch(t *testing.T) { } } -func TestFrostNativeSignerInventoryBindingRequiresAnchoredDescendant(t *testing.T) { +func TestFrostNativeSignerInventoryBindingRequiresExactAuthenticatedAnchor(t *testing.T) { storeBinding := testFrostDurableSessionStoreBinding(t) storeFingerprint, err := storeBinding.verify() if err != nil { @@ -166,48 +235,21 @@ func TestFrostNativeSignerInventoryBindingRequiresAnchoredDescendant(t *testing. ), Entries: entries, } - anchorSource := &testFrostNativeSignerStateWitnessAnchorSource{ - anchor: &FrostNativeSignerStateWitnessAnchor{ - StoreFingerprint: storeFingerprint, - Generation: 1, - Commitment: ancestor, - }, - } - proofReader := func( - request *frostsigning.NativeTBTCSignerStateWitnessProofRequest, - ) (*frostsigning.NativeTBTCSignerStateWitnessProof, error) { - proof := &frostsigning.NativeTBTCSignerStateWitnessProof{ - Schema: frostsigning.NativeTBTCSignerStateWitnessProofSchema, - StoreFingerprint: request.StoreFingerprint, - AncestorGeneration: request.AncestorGeneration, - AncestorCommitment: request.AncestorCommitment, - TargetGeneration: request.TargetGeneration, - TargetCommitment: request.TargetCommitment, - Complete: true, - } - if request.AncestorGeneration == request.TargetGeneration { - return proof, nil - } - if request.AncestorGeneration != 1 || request.AncestorCommitment != ancestor { - return nil, fmt.Errorf("unknown ancestor") - } - proof.Entries = []frostsigning.NativeTBTCSignerStateWitnessProofEntry{ - { - Generation: 2, - PreviousStateCommitment: ancestor, - StateCommitment: target, - StateImageDigest: stateImage, - }, - } - return proof, nil + checkpoint := FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: storeFingerprint, + Generation: 2, + PreviousStateCommitment: ancestor, + StateImageDigest: stateImage, + StateCommitment: target, } + anchorBinding, anchorStore := + testFrostNativeSignerInventoryAnchorBinding(storeFingerprint, checkpoint) binding, err := newFrostNativeSignerInventoryBinding( storeBinding, - anchorSource, + anchorBinding, func() (*frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error) { return inventory, nil }, - proofReader, ) if err != nil { t.Fatal(err) @@ -225,23 +267,39 @@ func TestFrostNativeSignerInventoryBindingRequiresAnchoredDescendant(t *testing. t.Fatalf("anchored descendant inventory was rejected: [%v]", err) } - anchorSource.anchor = &FrostNativeSignerStateWitnessAnchor{ - StoreFingerprint: storeFingerprint, - Generation: 2, - Commitment: [32]byte{0xff}, + forkImage := [32]byte{0xff} + anchorStore.record.Checkpoint = FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: storeFingerprint, + Generation: 2, + PreviousStateCommitment: ancestor, + StateImageDigest: forkImage, + StateCommitment: frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + 2, + ancestor, + forkImage, + ), } if _, err := binding.verify(context.Background(), expected); err == nil || - !strings.Contains(err.Error(), "equal state-witness generations") { + !strings.Contains(err.Error(), "differs from the authenticated remote anchor") { t.Fatalf("equal-generation rollback fork was accepted: [%v]", err) } - anchorSource.anchor = &FrostNativeSignerStateWitnessAnchor{ - StoreFingerprint: storeFingerprint, - Generation: 3, - Commitment: [32]byte{0xfe}, + aheadImage := [32]byte{0xfe} + anchorStore.record.Checkpoint = FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: storeFingerprint, + Generation: 3, + PreviousStateCommitment: target, + StateImageDigest: aheadImage, + StateCommitment: frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + 3, + target, + aheadImage, + ), } if _, err := binding.verify(context.Background(), expected); err == nil || - !strings.Contains(err.Error(), "ancestry bounds") { + !strings.Contains(err.Error(), "differs from the authenticated remote anchor") { t.Fatalf("lower-generation rolled-back signer state was accepted: [%v]", err) } } @@ -323,7 +381,14 @@ func TestFrostProductionSignerReadinessRejectsConcurrentRegistryChange( if err != nil { t.Fatal(err) } - stateCommitment := [32]byte{0x71} + previousStateCommitment := [32]byte{0x71} + stateImageDigest := [32]byte{0x72} + stateCommitment := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + 1, + previousStateCommitment, + stateImageDigest, + ) entries := []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ WalletID: fixture.walletID, KeyGroup: keyGroup, @@ -334,24 +399,28 @@ func TestFrostProductionSignerReadinessRejectsConcurrentRegistryChange( }}, }} inventory := &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ - Schema: frostsigning.NativeTBTCSignerRetainedKeyPackageInventorySchema, - StoreFingerprint: storeFingerprint, - StateGeneration: 1, - StateCommitment: stateCommitment, - InventoryCommitment: frostsigning.ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment(entries), - Entries: entries, - } - anchorSource := &testFrostNativeSignerStateWitnessAnchorSource{ - anchor: &FrostNativeSignerStateWitnessAnchor{ - StoreFingerprint: storeFingerprint, - Generation: 1, - Commitment: stateCommitment, - }, + Schema: frostsigning.NativeTBTCSignerRetainedKeyPackageInventorySchema, + StoreFingerprint: storeFingerprint, + StateGeneration: 1, + StateCommitment: stateCommitment, + PreviousStateCommitment: previousStateCommitment, + StateImageDigest: stateImageDigest, + InventoryCommitment: frostsigning.ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment(entries), + Entries: entries, + } + checkpoint := FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: storeFingerprint, + Generation: 1, + PreviousStateCommitment: previousStateCommitment, + StateImageDigest: stateImageDigest, + StateCommitment: stateCommitment, } + anchorBinding, _ := + testFrostNativeSignerInventoryAnchorBinding(storeFingerprint, checkpoint) inventoryReads := 0 inventoryBinding, err := newFrostNativeSignerInventoryBinding( storeBinding, - anchorSource, + anchorBinding, func() (*frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error) { inventoryReads++ if inventoryReads == 2 { @@ -361,19 +430,6 @@ func TestFrostProductionSignerReadinessRejectsConcurrentRegistryChange( } return inventory, nil }, - func( - request *frostsigning.NativeTBTCSignerStateWitnessProofRequest, - ) (*frostsigning.NativeTBTCSignerStateWitnessProof, error) { - return &frostsigning.NativeTBTCSignerStateWitnessProof{ - Schema: frostsigning.NativeTBTCSignerStateWitnessProofSchema, - StoreFingerprint: request.StoreFingerprint, - AncestorGeneration: request.AncestorGeneration, - AncestorCommitment: request.AncestorCommitment, - TargetGeneration: request.TargetGeneration, - TargetCommitment: request.TargetCommitment, - Complete: true, - }, nil - }, ) if err != nil { t.Fatal(err) diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index 5f11db610a..d3dda7fd5a 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1467,6 +1467,22 @@ type FrostPreSignActivationRuntimeManifest struct { RetainedGroupInventoryProtocolID [32]byte CanonicalJournal FrostRetainedGroupCanonicalJournalManifest QuarantineJournal FrostRetainedGroupQuarantineJournalManifest + NativeSignerAnchor FrostNativeSignerAnchorManifest +} + +// FrostNativeSignerAnchorManifest carries the authenticated service identity +// and the offline-reviewed monotonic floor. Online startup may accept this +// exact state or a signed/proven descendant, never an arbitrary same-store +// stream head. +type FrostNativeSignerAnchorManifest struct { + Identity FrostNativeSignerAnchorIdentity + MinimumServiceEpoch uint64 + MinimumRevision uint64 + MinimumEventRoot [32]byte + MinimumAcknowledgementDigest [32]byte + MinimumCheckpoint FrostNativeSignerStateWitnessCheckpoint + WitnessMaximumRecords uint64 + WitnessRotationThresholdRecords uint64 } type FrostPreSignActivationRuntimeManifestSource interface { diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index 300766e39a..f5e1e04224 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -347,11 +347,116 @@ func newNode( err, ) } + anchorManifest := runtimeManifest.NativeSignerAnchor + clientPrivateKey, err := loadFrostNativeSignerAnchorClientPrivateKey( + config.FrostNativeSignerAnchorClientPrivateKeyPath, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot load FROST native signer anchor client key: [%w]", + err, + ) + } + defer zeroFrostNativeSignerKeyBytes(clientPrivateKey) + onlineKeySPKI, onlinePublicKey, err := + loadFrostNativeSignerAnchorOnlinePublicKeySPKI( + config.FrostNativeSignerAnchorOnlinePublicKeyPath, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot load FROST native signer anchor online key: [%w]", + err, + ) + } + installedAnchorConfig, err := + signing.ReadInstalledNativeTBTCSignerStateAnchorConfig() + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot verify installed native signer anchor configuration: [%w]", + err, + ) + } + onlineRawKey := [32]byte{} + copy(onlineRawKey[:], onlinePublicKey) + expectedAnchorBindingHash := + ComputeFrostNativeSignerAnchorBindingHash(anchorManifest.Identity) + if installedAnchorConfig.BindingHash != expectedAnchorBindingHash || + installedAnchorConfig.ResponsePublicKey != onlineRawKey || + installedAnchorConfig.ResponsePublicKeySPKISHA256 != + anchorManifest.Identity.OnlineKeyHash || + installedAnchorConfig.WitnessMaximumRecords != + anchorManifest.WitnessMaximumRecords || + installedAnchorConfig.WitnessRotationThresholdRecords != + anchorManifest.WitnessRotationThresholdRecords { + _ = outbox.close() + return nil, fmt.Errorf( + "installed native signer anchor configuration differs from the signed manifest", + ) + } + anchorClient, err := NewFrostNativeSignerAnchorClient( + FrostNativeSignerAnchorClientConfig{ + Endpoint: config.FrostNativeSignerAnchorURL, + RequestTimeout: config.FrostNativeSignerAnchorRequestTimeout, + ClientPrivateKey: clientPrivateKey, + OnlinePublicKeySPKI: onlineKeySPKI, + Identity: anchorManifest.Identity, + }, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot initialize authenticated native signer anchor client: [%w]", + err, + ) + } + anchorBinding, err := newFrostNativeSignerAnchorBinding( + anchorClient, + anchorManifest, + signing.ReadNativeTBTCSignerStateWitnessTip, + signing.ReadNativeTBTCSignerStateWitnessProof, + signing.AcknowledgeNativeTBTCSignerStateWitnessCheckpoint, + signing.RecoverNativeTBTCSignerStateWitnessCheckpoint, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot bind authenticated native signer anchor: [%w]", + err, + ) + } + startupSignerTip, err := anchorBinding.reconcileStartup( + context.Background(), + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot reconcile startup native signer anchor: [%w]", + err, + ) + } + if err := signing.InstallNativeTBTCSignerStateAnchorBarrier( + signing.NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: startupSignerTip, + ExpectedAnchorBindingHash: expectedAnchorBindingHash, + MinimumAnchorServiceEpoch: anchorManifest.MinimumServiceEpoch, + ReadTip: signing.ReadNativeTBTCSignerStateWitnessTip, + Committer: anchorBinding, + Timeout: config.FrostNativeSignerAnchorRequestTimeout, + }, + ); err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot install native signer protocol-output barrier: [%w]", + err, + ) + } inventoryBinding, err := newFrostNativeSignerInventoryBinding( storeBinding, - config.FrostNativeSignerStateWitnessAnchorSource, + anchorBinding, signing.ReadNativeTBTCSignerRetainedKeyPackageInventory, - signing.ReadNativeTBTCSignerStateWitnessProof, ) if err != nil { _ = outbox.close() diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 36efcb37a7..361fd85a79 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -166,11 +166,19 @@ type Config struct { // receipt-complete source. It must not share the primary Ethereum adapter's // trust domain, endpoint, operator, or history store. FrostRetainedGroupHistorySource FrostRetainedGroupHistorySource - // FrostNativeSignerStateWitnessAnchorSource reads the independently durable, - // dynamic signer-state tip last accepted by the activation/watchtower system. - // It must not be backed by the native signer state directory. Production - // activation refuses to start without this rollback boundary. - FrostNativeSignerStateWitnessAnchorSource FrostNativeSignerStateWitnessAnchorSource + // FrostNativeSignerAnchorURL is the exact authenticated checkpoint-service + // endpoint. HTTPS uses normal PKIX validation plus the manifest-pinned leaf + // SPKI. Plain HTTP is accepted only for a canonical numeric loopback host. + FrostNativeSignerAnchorURL string + // FrostNativeSignerAnchorClientPrivateKeyPath contains the owner-only PKCS#8 + // Ed25519 key authorized by the signed activation manifest. + FrostNativeSignerAnchorClientPrivateKeyPath string + // FrostNativeSignerAnchorOnlinePublicKeyPath contains the DER SubjectPublicKeyInfo + // for the online Ed25519 service key pinned by the signed manifest. + FrostNativeSignerAnchorOnlinePublicKeyPath string + // FrostNativeSignerAnchorRequestTimeout bounds each authenticated Read/CAS. + // Zero selects the protocol client's conservative production default. + FrostNativeSignerAnchorRequestTimeout time.Duration } // Initialize kicks off the TBTC by initializing internal state, ensuring From 1d75161f099018918ce692f7e7c115553b32121a Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 20:36:36 -0400 Subject: [PATCH 11/93] Harden native signer anchor admission and trust startup --- .github/workflows/frost-cgo-integration.yml | 3 + go.mod | 2 +- .../tbtc_frost_pre_sign_authorization.go | 149 +- ...tbtc_frost_retained_group_manifest_test.go | 58 +- .../signing/native_tbtc_signer_abi_version.go | 6 +- .../native_tbtc_signer_abi_version_test.go | 12 +- .../native_tbtc_signer_error_frost_native.go | 24 +- .../signing/native_tbtc_signer_init_config.go | 137 +- .../native_tbtc_signer_init_config_test.go | 131 +- .../native_tbtc_signer_inventory_cgo.go | 165 ++ .../native_tbtc_signer_inventory_default.go | 19 + ...native_tbtc_signer_state_anchor_barrier.go | 181 +- ...e_tbtc_signer_state_anchor_barrier_test.go | 519 ++++- .../native_tbtc_signer_state_anchor_trust.go | 526 +++++ ...tbtc_signer_state_anchor_trust_cgo_test.go | 54 + ...ive_tbtc_signer_state_anchor_trust_test.go | 171 ++ ...nner_interactive_aggregate_memo_default.go | 15 + ...interactive_aggregate_memo_frost_native.go | 121 +- ...active_aggregate_memo_frost_native_test.go | 137 ++ pkg/tbtc/frost_activation_handshake.go | 146 +- pkg/tbtc/frost_activation_handshake_test.go | 25 +- pkg/tbtc/frost_dkg_execution_frost_native.go | 19 + .../frost_native_signer_anchor_admission.go | 386 ++++ ...ost_native_signer_anchor_admission_test.go | 196 ++ .../frost_native_signer_anchor_binding.go | 154 +- ...frost_native_signer_anchor_binding_test.go | 200 +- pkg/tbtc/frost_native_signer_anchor_client.go | 134 +- .../frost_native_signer_anchor_client_test.go | 114 + .../frost_native_signer_anchor_history.go | 10 +- pkg/tbtc/frost_native_signer_anchor_keys.go | 24 + .../frost_native_signer_anchor_protocol.go | 14 +- pkg/tbtc/frost_native_signer_anchor_trust.go | 1839 +++++++++++++++++ ...frost_native_signer_anchor_trust_client.go | 392 ++++ ..._native_signer_anchor_trust_client_test.go | 132 ++ ...rost_native_signer_anchor_trust_startup.go | 608 ++++++ ...native_signer_anchor_trust_startup_test.go | 953 +++++++++ .../frost_native_signer_anchor_trust_test.go | 1613 +++++++++++++++ pkg/tbtc/frost_native_signer_readiness.go | 196 +- .../frost_native_signer_readiness_test.go | 202 +- pkg/tbtc/frost_pre_sign_authorization.go | 121 +- pkg/tbtc/frost_pre_sign_authorization_test.go | 20 +- pkg/tbtc/node.go | 308 ++- pkg/tbtc/signing.go | 16 + pkg/tbtc/tbtc.go | 5 + pkg/tbtc/wallet.go | 3 + 45 files changed, 9791 insertions(+), 469 deletions(-) create mode 100644 pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go create mode 100644 pkg/frost/signing/native_tbtc_signer_state_anchor_trust_cgo_test.go create mode 100644 pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go create mode 100644 pkg/frost/signing/roast_runner_interactive_aggregate_memo_default.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_admission.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_admission_test.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_trust.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_trust_client.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_trust_client_test.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_trust_startup.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_trust_startup_test.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_trust_test.go diff --git a/.github/workflows/frost-cgo-integration.yml b/.github/workflows/frost-cgo-integration.yml index c501b48307..b8db16b374 100644 --- a/.github/workflows/frost-cgo-integration.yml +++ b/.github/workflows/frost-cgo-integration.yml @@ -97,6 +97,9 @@ jobs: frost_tbtc_retained_key_package_inventory \ frost_tbtc_state_witness_proof \ frost_tbtc_state_witness_tip \ + frost_tbtc_state_anchor_trust_head \ + frost_tbtc_transition_state_witness_anchor \ + frost_tbtc_state_anchor_bootstrap_facts \ frost_tbtc_acknowledge_state_witness_checkpoint \ frost_tbtc_recover_state_witness_checkpoint \ frost_tbtc_version \ diff --git a/go.mod b/go.mod index d635c9e665..e3ad611b67 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/btcsuite/btcd/v2 v2.0.0-00010101000000-000000000000 github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce github.com/checksum0/go-electrum v0.0.0-20220912200153-b862ac442cf9 + github.com/decred/dcrd/dcrec/edwards/v2 v2.0.0 github.com/ethereum/go-ethereum v1.13.15 github.com/ferranbt/fastssz v0.1.2 github.com/go-test/deep v1.0.8 @@ -104,7 +105,6 @@ require ( github.com/crate-crypto/go-kzg-4844 v0.7.0 // indirect github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect github.com/deckarep/golang-set/v2 v2.1.0 // indirect - github.com/decred/dcrd/dcrec/edwards/v2 v2.0.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/deepmap/oapi-codegen v1.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index 2684dca39e..5ac502eab7 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -30,11 +30,10 @@ import ( "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" tbtcabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" - frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/tbtc" ) -const frostPreSignManifestVersion = "tbtc-p2tr-fraud-production-activation/v3" +const frostPreSignManifestVersion = "tbtc-p2tr-fraud-production-activation/v4" const frostPreSignBridgeABIJSON = `[ {"type":"function","name":"previewP2TRTransactionAuthorization","stateMutability":"view","inputs":[{"name":"payload","type":"bytes"}],"outputs":[{"name":"","type":"bytes"}]}, @@ -204,34 +203,21 @@ type frostPreSignManifestQuarantineJournal struct { } type frostPreSignManifestNativeSignerAnchor struct { - ProtocolID string `json:"protocolID"` - StreamID string `json:"streamID"` - TrustDomainID string `json:"trustDomainID"` - EndpointLeafSPKIHash string `json:"endpointLeafSpkiHash"` - OnlineKeyHash string `json:"onlineKeyHash"` - OperatorFingerprint string `json:"operatorFingerprint"` - HistoryStoreID string `json:"historyStoreID"` - HistoryStoreFingerprint string `json:"historyStoreFingerprint"` - HistoryClusterFingerprint string `json:"historyClusterFingerprint"` - OfflineAuthorityHash string `json:"offlineAuthorityHash"` - ClientSPKIHash string `json:"clientSpkiHash"` - SignerStoreFingerprint string `json:"signerStoreFingerprint"` - TransportBinding string `json:"transportBinding"` - MinimumServiceEpoch uint64 `json:"minimumServiceEpoch"` - MinimumRevision uint64 `json:"minimumRevision"` - MinimumEventRoot string `json:"minimumEventRoot"` - MinimumAcknowledgementDigest string `json:"minimumAcknowledgementDigest"` - MinimumCheckpoint frostPreSignManifestNativeSignerCheckpoint `json:"minimumCheckpoint"` - WitnessMaximumRecords uint64 `json:"witnessMaximumRecords"` - WitnessRotationThresholdRecords uint64 `json:"witnessRotationThresholdRecords"` -} - -type frostPreSignManifestNativeSignerCheckpoint struct { - StoreFingerprint string `json:"storeFingerprint"` - Generation uint64 `json:"generation"` - PreviousStateCommitment string `json:"previousStateCommitment"` - StateImageDigest string `json:"stateImageDigest"` - StateCommitment string `json:"stateCommitment"` + ProtocolID string `json:"protocolID"` + StreamID string `json:"streamID"` + TrustDomainID string `json:"trustDomainID"` + EndpointLeafSPKIHash string `json:"endpointLeafSpkiHash"` + OnlineKeyHash string `json:"onlineKeyHash"` + OperatorFingerprint string `json:"operatorFingerprint"` + HistoryStoreID string `json:"historyStoreID"` + HistoryStoreFingerprint string `json:"historyStoreFingerprint"` + HistoryClusterFingerprint string `json:"historyClusterFingerprint"` + OfflineAuthorityHash string `json:"offlineAuthorityHash"` + ClientSPKIHash string `json:"clientSpkiHash"` + SignerStoreFingerprint string `json:"signerStoreFingerprint"` + TransportBinding string `json:"transportBinding"` + WitnessMaximumRecords uint64 `json:"witnessMaximumRecords"` + WitnessRotationThresholdRecords uint64 `json:"witnessRotationThresholdRecords"` } type frostPreSignManifestFrostSigner struct { @@ -262,17 +248,18 @@ type frostPreSignManifestFrostSigner struct { } type frostPreSignActivationManifest struct { - Schema string `json:"schema"` - ActivationSequence uint64 `json:"activationSequence"` - ActivationID string `json:"activationID"` - Environment string `json:"environment"` - Migrations json.RawMessage `json:"migrations"` - Bitcoin json.RawMessage `json:"bitcoin"` - Ethereum frostPreSignManifestEthereum `json:"ethereum"` - ECDSACutover json.RawMessage `json:"ecdsaCutover"` - Outbox json.RawMessage `json:"outbox"` - FrostSigner frostPreSignManifestFrostSigner `json:"frostSigner"` - manifestHash [32]byte + Schema string `json:"schema"` + ActivationSequence uint64 `json:"activationSequence"` + ActivationID string `json:"activationID"` + Environment string `json:"environment"` + Migrations json.RawMessage `json:"migrations"` + Bitcoin json.RawMessage `json:"bitcoin"` + Ethereum frostPreSignManifestEthereum `json:"ethereum"` + ECDSACutover json.RawMessage `json:"ecdsaCutover"` + Outbox json.RawMessage `json:"outbox"` + FrostSigner frostPreSignManifestFrostSigner `json:"frostSigner"` + manifestHash [32]byte + activationAuthorityPublicKey [32]byte } type frostPreSignDeploymentPin struct { @@ -513,6 +500,14 @@ func loadFrostPreSignActivationManifest( if !ok { return nil, fmt.Errorf("FROST activation signer key is not Ed25519") } + if err := tbtc.ValidateFrostNativeSignerAnchorTrustEd25519PublicKey( + publicKey, + ); err != nil { + return nil, fmt.Errorf( + "FROST activation signer key point is invalid: [%w]", + err, + ) + } signature, err := base64.StdEncoding.Strict().DecodeString(envelope.Signature) if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonicalPayload, signature) { @@ -524,6 +519,7 @@ func loadFrostPreSignActivationManifest( return nil, fmt.Errorf("cannot decode FROST activation payload: [%w]", err) } manifest.manifestHash = payloadHash + copy(manifest.activationAuthorityPublicKey[:], publicKey) if err := validateFrostPreSignActivationManifest(manifest); err != nil { return nil, err } @@ -1042,85 +1038,19 @@ func frostPreSignNativeSignerAnchorManifest( return result, err } anchor := manifest.FrostSigner.NativeSignerAnchor - if anchor.MinimumServiceEpoch == 0 || anchor.MinimumRevision == 0 || - anchor.WitnessMaximumRecords < 2 || + if anchor.WitnessMaximumRecords < 2 || anchor.WitnessMaximumRecords > 1_000_000 || anchor.WitnessRotationThresholdRecords < 2 || anchor.WitnessRotationThresholdRecords > anchor.WitnessMaximumRecords-2 { return result, fmt.Errorf( - "FROST native signer anchor floor or witness geometry is invalid", + "FROST native signer witness geometry is invalid", ) } result.Identity = identity - result.MinimumServiceEpoch = anchor.MinimumServiceEpoch - result.MinimumRevision = anchor.MinimumRevision result.WitnessMaximumRecords = anchor.WitnessMaximumRecords result.WitnessRotationThresholdRecords = anchor.WitnessRotationThresholdRecords - for label, input := range map[string]struct { - encoded string - destination *[32]byte - }{ - "minimum event root": { - anchor.MinimumEventRoot, - &result.MinimumEventRoot, - }, - "minimum acknowledgement digest": { - anchor.MinimumAcknowledgementDigest, - &result.MinimumAcknowledgementDigest, - }, - } { - parsed, err := frostPreSignParseBytes32(input.encoded) - if err != nil || parsed == [32]byte{} { - return result, fmt.Errorf("invalid FROST native signer anchor %s", label) - } - *input.destination = parsed - } - checkpoint := anchor.MinimumCheckpoint - if checkpoint.Generation == 0 { - return result, fmt.Errorf( - "FROST native signer anchor minimum checkpoint generation is zero", - ) - } - result.MinimumCheckpoint.Generation = checkpoint.Generation - checkpointValues := []struct { - label string - encoded string - destination *[32]byte - }{ - {"store fingerprint", checkpoint.StoreFingerprint, &result.MinimumCheckpoint.StoreFingerprint}, - {"previous state commitment", checkpoint.PreviousStateCommitment, &result.MinimumCheckpoint.PreviousStateCommitment}, - {"state image digest", checkpoint.StateImageDigest, &result.MinimumCheckpoint.StateImageDigest}, - {"state commitment", checkpoint.StateCommitment, &result.MinimumCheckpoint.StateCommitment}, - } - for _, value := range checkpointValues { - parsed, err := frostPreSignParseBytes32(value.encoded) - if err != nil || parsed == [32]byte{} { - return result, fmt.Errorf( - "invalid FROST native signer anchor minimum checkpoint %s", - value.label, - ) - } - *value.destination = parsed - } - if result.MinimumCheckpoint.StoreFingerprint != - identity.SignerStoreFingerprint { - return result, fmt.Errorf( - "FROST native signer anchor minimum checkpoint belongs to another store", - ) - } - computed := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( - result.MinimumCheckpoint.StoreFingerprint, - result.MinimumCheckpoint.Generation, - result.MinimumCheckpoint.PreviousStateCommitment, - result.MinimumCheckpoint.StateImageDigest, - ) - if computed != result.MinimumCheckpoint.StateCommitment { - return result, fmt.Errorf( - "FROST native signer anchor minimum checkpoint commitment is invalid", - ) - } return result, nil } @@ -2337,6 +2267,7 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( MaximumGroupSize: frost.MaximumGroupSize, RetainedGroupInventoryProtocolID: retainedGroupInventoryProtocolID, NativeSignerAnchor: nativeSignerAnchor, + ActivationAuthorityPublicKey: adapter.manifest.activationAuthorityPublicKey, CanonicalJournal: tbtc.FrostRetainedGroupCanonicalJournalManifest{ StoreID: journal.StoreID, StoreFingerprint: storeFingerprint, diff --git a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go index 3fce462bfa..44603e7447 100644 --- a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go +++ b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/tbtc" ) @@ -15,15 +14,6 @@ func testManifestHex32(value byte) string { func testFrostJournalActivationManifest() *frostPreSignActivationManifest { checkpointHash := testManifestHex32(0x02) - signerStoreFingerprint := [32]byte{0x09} - previousStateCommitment := [32]byte{0x50} - stateImageDigest := [32]byte{0x51} - stateCommitment := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( - signerStoreFingerprint, - 1, - previousStateCommitment, - stateImageDigest, - ) manifest := &frostPreSignActivationManifest{ Schema: frostPreSignManifestVersion, ActivationSequence: 1, @@ -85,30 +75,19 @@ func testFrostJournalActivationManifest() *frostPreSignActivationManifest { MinimumGeneration: 0, }, NativeSignerAnchor: frostPreSignManifestNativeSignerAnchor{ - ProtocolID: testManifestHex32(0x40), - StreamID: testManifestHex32(0x41), - TrustDomainID: "independent-native-anchor", - EndpointLeafSPKIHash: testManifestHex32(0x42), - OnlineKeyHash: testManifestHex32(0x43), - OperatorFingerprint: testManifestHex32(0x44), - HistoryStoreID: "native-anchor-history", - HistoryStoreFingerprint: testManifestHex32(0x45), - HistoryClusterFingerprint: testManifestHex32(0x46), - OfflineAuthorityHash: testManifestHex32(0x47), - ClientSPKIHash: testManifestHex32(0x48), - SignerStoreFingerprint: testManifestHex32(0x09), - TransportBinding: testManifestHex32(0x49), - MinimumServiceEpoch: 1, - MinimumRevision: 1, - MinimumEventRoot: testManifestHex32(0x4a), - MinimumAcknowledgementDigest: testManifestHex32(0x4b), - MinimumCheckpoint: frostPreSignManifestNativeSignerCheckpoint{ - StoreFingerprint: testManifestHex32(0x09), - Generation: 1, - PreviousStateCommitment: fmt.Sprintf("0x%x", previousStateCommitment[:]), - StateImageDigest: fmt.Sprintf("0x%x", stateImageDigest[:]), - StateCommitment: fmt.Sprintf("0x%x", stateCommitment[:]), - }, + ProtocolID: testManifestHex32(0x40), + StreamID: testManifestHex32(0x41), + TrustDomainID: "independent-native-anchor", + EndpointLeafSPKIHash: testManifestHex32(0x42), + OnlineKeyHash: testManifestHex32(0x43), + OperatorFingerprint: testManifestHex32(0x44), + HistoryStoreID: "native-anchor-history", + HistoryStoreFingerprint: testManifestHex32(0x45), + HistoryClusterFingerprint: testManifestHex32(0x46), + OfflineAuthorityHash: testManifestHex32(0x47), + ClientSPKIHash: testManifestHex32(0x48), + SignerStoreFingerprint: testManifestHex32(0x09), + TransportBinding: testManifestHex32(0x49), WitnessMaximumRecords: 100, WitnessRotationThresholdRecords: 8, }, @@ -168,19 +147,10 @@ func TestValidateFrostPreSignActivationManifest_CanonicalJournal(t *testing.T) { manifest.FrostSigner.NativeSignerAnchor.SignerStoreFingerprint = testManifestHex32(0xee) if err := validateFrostPreSignActivationManifest(manifest); err == nil || - !strings.Contains(err.Error(), "minimum checkpoint belongs to another store") { + !strings.Contains(err.Error(), "differs from the durable signer store") { t.Fatalf("expected native anchor store failure, got [%v]", err) } }) - t.Run("native anchor checkpoint tamper", func(t *testing.T) { - manifest := testFrostJournalActivationManifest() - manifest.FrostSigner.NativeSignerAnchor.MinimumCheckpoint.StateImageDigest = - testManifestHex32(0xee) - if err := validateFrostPreSignActivationManifest(manifest); err == nil || - !strings.Contains(err.Error(), "checkpoint commitment is invalid") { - t.Fatalf("expected native anchor checkpoint failure, got [%v]", err) - } - }) t.Run("native anchor witness geometry", func(t *testing.T) { for name, mutate := range map[string]func(*frostPreSignManifestNativeSignerAnchor){ "maximum too large": func(anchor *frostPreSignManifestNativeSignerAnchor) { diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version.go b/pkg/frost/signing/native_tbtc_signer_abi_version.go index 4b7cd4b460..77f4f439e9 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version.go @@ -34,7 +34,11 @@ const ( // Minor 2 adds the constant-size state-witness tip and signed checkpoint // acknowledgement symbols required by the protocol-output barrier. This // build must reject 4.1 before it can reach a missing symbol via dlsym. - requiredTBTCSignerABIMinMinor uint32 = 2 + // + // Minor 3 adds the durable state-anchor trust-head/transition and bootstrap + // facts symbols. Production startup and offline provisioning require that + // complete surface and must reject an ABI-4.2 library before dlsym. + requiredTBTCSignerABIMinMinor uint32 = 3 ) // ErrTBTCSignerABIIncompatible marks a linked libfrost_tbtc whose FFI contract version diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go index da66c9dab4..957e4d675e 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go @@ -93,11 +93,10 @@ func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { // identity schema and the state-witness transcript to v2, so that state // commitments bind only the stable `.store-id` and no longer break when a // benign filesystem change alters the lock file, directory inode, or device. - // Minor 2 adds the constant-size state tip and signed external-anchor - // acknowledgement required before any protocol output can leave Go. The - // matching library version is compatible; ABI 4.1 and a different major - // are not. - if requiredTBTCSignerABIMajor != 4 || requiredTBTCSignerABIMinMinor != 2 { + // Minor 3 adds the trust transition/head and bootstrap-facts surface used + // before production signing can start. The matching library version is + // compatible; ABI 4.2 and a different major are not. + if requiredTBTCSignerABIMajor != 4 || requiredTBTCSignerABIMinMinor != 3 { t.Fatalf( "unexpected required tbtc-signer ABI: [%d.%d]", requiredTBTCSignerABIMajor, @@ -113,6 +112,9 @@ func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, 1); err == nil { t.Fatal("ABI 4.1 without the output-barrier tip/ack symbols must be incompatible") } + if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, 2); err == nil { + t.Fatal("ABI 4.2 without trust transition and bootstrap-facts symbols must be incompatible") + } if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor+1, requiredTBTCSignerABIMinMinor); err == nil { t.Fatal("a higher major must be incompatible") } diff --git a/pkg/frost/signing/native_tbtc_signer_error_frost_native.go b/pkg/frost/signing/native_tbtc_signer_error_frost_native.go index b98d3197a8..82d790e7c9 100644 --- a/pkg/frost/signing/native_tbtc_signer_error_frost_native.go +++ b/pkg/frost/signing/native_tbtc_signer_error_frost_native.go @@ -20,7 +20,8 @@ type buildTaggedTBTCSignerErrorResponse struct { // CandidateCulprits is populated only for the // aggregate_share_verification_failed error: the u16 Go member identifiers // whose shares failed verification (omitted for every other error). - CandidateCulprits []uint16 `json:"candidate_culprits,omitempty"` + CandidateCulprits []uint16 `json:"candidate_culprits,omitempty"` + StateAnchorTrustRecovery *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire `json:"state_anchor_trust_recovery,omitempty"` } // buildTaggedTBTCSignerStructuredError carries the FFI error envelope's @@ -34,7 +35,8 @@ type buildTaggedTBTCSignerStructuredError struct { Message string // CandidateCulprits carries the aggregate_share_verification_failed culprit // list when present; empty for every other error. - CandidateCulprits []uint16 + CandidateCulprits []uint16 + StateAnchorTrustRecovery *NativeTBTCSignerStateAnchorTrustRecoveryRequired } func (e *buildTaggedTBTCSignerStructuredError) Error() string { @@ -71,11 +73,27 @@ func buildTaggedTBTCSignerErrorPayload(payload []byte) *buildTaggedTBTCSignerStr } } - return &buildTaggedTBTCSignerStructuredError{ + structured := &buildTaggedTBTCSignerStructuredError{ Code: errorResponse.Code, Message: errorResponse.Message, CandidateCulprits: errorResponse.CandidateCulprits, } + if errorResponse.StateAnchorTrustRecovery != nil { + recovery, err := + decodeNativeTBTCSignerStateAnchorTrustRecoveryRequired( + errorResponse.StateAnchorTrustRecovery, + ) + if err != nil { + structured.Message = fmt.Sprintf( + "%s (invalid state-anchor trust-recovery context: %v)", + structured.Message, + err, + ) + return structured + } + structured.StateAnchorTrustRecovery = recovery + } + return structured } // InteractiveAggregateShareVerificationError is returned by InteractiveAggregate diff --git a/pkg/frost/signing/native_tbtc_signer_init_config.go b/pkg/frost/signing/native_tbtc_signer_init_config.go index bd39d082ac..08b3eb0d2a 100644 --- a/pkg/frost/signing/native_tbtc_signer_init_config.go +++ b/pkg/frost/signing/native_tbtc_signer_init_config.go @@ -1,6 +1,7 @@ package signing import ( + "crypto/sha256" "encoding/json" "fmt" "sync" @@ -27,6 +28,8 @@ import ( // (e.g. 0600), as with the signer state path. const TBTCSignerInitConfigPathEnv = "TBTC_SIGNER_INIT_CONFIG_PATH" +const NativeTBTCSignerStateAnchorBootstrapProvisioningPurpose = "state_anchor_bootstrap_provisioning" + // NativeTBTCSignerInitConfigResult captures the response of an init-time // signer-config installation (frost_tbtc_init_signer_config). type NativeTBTCSignerInitConfigResult struct { @@ -41,9 +44,17 @@ type NativeTBTCSignerInitConfigResult struct { // compares it with the signed manifest instead of re-reading mutable // environment variables or trusting a file that may have changed after init. type NativeTBTCSignerInstalledStateAnchorConfig struct { + ProtocolID [32]byte + StreamID [32]byte + ActivationManifestHash [32]byte + ActivationManifestSequence uint64 BindingHash [32]byte ResponsePublicKey [32]byte ResponsePublicKeySPKISHA256 [32]byte + OfflineAuthorityPublicKey [32]byte + OfflineAuthoritySPKISHA256 [32]byte + TrustCertificateSequence uint64 + TrustCertificateDigest [32]byte WitnessMaximumRecords uint64 WitnessRotationThresholdRecords uint64 ConfigFingerprint string @@ -59,31 +70,102 @@ func recordNativeTBTCSignerInstalledStateAnchorConfig( configFingerprint string, ) error { wire := struct { + Purpose *string `json:"purpose"` + StateAnchorProtocolID *string `json:"state_anchor_protocol_id"` + StateAnchorStreamID *string `json:"state_anchor_stream_id"` + StateAnchorActivationManifestHash *string `json:"state_anchor_activation_manifest_hash"` + StateAnchorActivationManifestSequence *uint64 `json:"state_anchor_activation_manifest_sequence"` StateWitnessMaximumRecords *uint64 `json:"state_witness_max_records"` StateAnchorBindingHash *string `json:"state_anchor_binding_hash"` StateAnchorResponsePublicKey *string `json:"state_anchor_response_public_key"` StateAnchorResponsePublicKeySPKISHA256 *string `json:"state_anchor_response_public_key_spki_sha256"` + StateAnchorOfflineAuthorityPublicKey *string `json:"state_anchor_offline_authority_public_key"` + StateAnchorOfflineAuthoritySPKISHA256 *string `json:"state_anchor_offline_authority_public_key_spki_sha256"` + StateAnchorTrustCertificateSequence *uint64 `json:"state_anchor_trust_certificate_sequence"` + StateAnchorTrustCertificateDigest *string `json:"state_anchor_trust_certificate_digest"` StateWitnessRotationThresholdRecords *uint64 `json:"state_witness_rotation_threshold_records"` }{} if err := json.Unmarshal(configJSON, &wire); err != nil { return fmt.Errorf("cannot decode installed signer anchor configuration: %w", err) } - allAbsent := wire.StateWitnessMaximumRecords == nil && + if wire.Purpose != nil && + *wire.Purpose == + NativeTBTCSignerStateAnchorBootstrapProvisioningPurpose { + hasRuntimeAnchorField := wire.StateAnchorProtocolID != nil || + wire.StateAnchorStreamID != nil || + wire.StateAnchorActivationManifestHash != nil || + wire.StateAnchorActivationManifestSequence != nil || + wire.StateAnchorBindingHash != nil || + wire.StateAnchorResponsePublicKey != nil || + wire.StateAnchorResponsePublicKeySPKISHA256 != nil || + wire.StateAnchorOfflineAuthorityPublicKey != nil || + wire.StateAnchorOfflineAuthoritySPKISHA256 != nil || + wire.StateAnchorTrustCertificateSequence != nil || + wire.StateAnchorTrustCertificateDigest != nil || + wire.StateWitnessRotationThresholdRecords != nil + if hasRuntimeAnchorField || + wire.StateWitnessMaximumRecords == nil || + *wire.StateWitnessMaximumRecords != 4 { + return fmt.Errorf( + "installed signer bootstrap-provisioning configuration contains runtime anchor fields or an invalid witness bound", + ) + } + + // Bootstrap provisioning creates the store identity and emits facts + // used to build the offline trust artifacts. It intentionally has no + // runtime anchor authority to pin into this process. + return nil + } + allAbsent := wire.StateAnchorProtocolID == nil && + wire.StateAnchorStreamID == nil && + wire.StateAnchorActivationManifestHash == nil && + wire.StateAnchorActivationManifestSequence == nil && + wire.StateWitnessMaximumRecords == nil && wire.StateAnchorBindingHash == nil && wire.StateAnchorResponsePublicKey == nil && wire.StateAnchorResponsePublicKeySPKISHA256 == nil && + wire.StateAnchorOfflineAuthorityPublicKey == nil && + wire.StateAnchorOfflineAuthoritySPKISHA256 == nil && + wire.StateAnchorTrustCertificateSequence == nil && + wire.StateAnchorTrustCertificateDigest == nil && wire.StateWitnessRotationThresholdRecords == nil if allAbsent { return nil } - if wire.StateWitnessMaximumRecords == nil || + if wire.StateAnchorProtocolID == nil || + wire.StateAnchorStreamID == nil || + wire.StateAnchorActivationManifestHash == nil || + wire.StateAnchorActivationManifestSequence == nil || + wire.StateWitnessMaximumRecords == nil || wire.StateAnchorBindingHash == nil || wire.StateAnchorResponsePublicKey == nil || wire.StateAnchorResponsePublicKeySPKISHA256 == nil || + wire.StateAnchorOfflineAuthorityPublicKey == nil || + wire.StateAnchorOfflineAuthoritySPKISHA256 == nil || + wire.StateAnchorTrustCertificateSequence == nil || + wire.StateAnchorTrustCertificateDigest == nil || wire.StateWitnessRotationThresholdRecords == nil || configFingerprint == "" { return fmt.Errorf("installed signer anchor configuration is incomplete") } + protocolID, err := decodeNativeTBTCSignerStoreBytes32( + *wire.StateAnchorProtocolID, + ) + if err != nil { + return fmt.Errorf("installed signer anchor protocol ID is invalid: %w", err) + } + streamID, err := decodeNativeTBTCSignerStoreBytes32( + *wire.StateAnchorStreamID, + ) + if err != nil { + return fmt.Errorf("installed signer anchor stream ID is invalid: %w", err) + } + manifestHash, err := decodeNativeTBTCSignerStoreBytes32( + *wire.StateAnchorActivationManifestHash, + ) + if err != nil { + return fmt.Errorf("installed signer activation manifest hash is invalid: %w", err) + } bindingHash, err := decodeNativeTBTCSignerStoreBytes32( *wire.StateAnchorBindingHash, ) @@ -102,16 +184,52 @@ func recordNativeTBTCSignerInstalledStateAnchorConfig( if err != nil { return fmt.Errorf("installed signer anchor response SPKI hash is invalid: %w", err) } + offlineAuthorityPublicKey, err := decodeNativeTBTCSignerStoreBytes32( + *wire.StateAnchorOfflineAuthorityPublicKey, + ) + if err != nil { + return fmt.Errorf("installed signer anchor offline authority key is invalid: %w", err) + } + offlineAuthoritySPKIHash, err := decodeNativeTBTCSignerStoreBytes32( + *wire.StateAnchorOfflineAuthoritySPKISHA256, + ) + if err != nil { + return fmt.Errorf("installed signer anchor offline authority SPKI hash is invalid: %w", err) + } + trustCertificateDigest, err := decodeNativeTBTCSignerStoreBytes32( + *wire.StateAnchorTrustCertificateDigest, + ) + if err != nil { + return fmt.Errorf("installed signer anchor trust certificate digest is invalid: %w", err) + } maximum := *wire.StateWitnessMaximumRecords threshold := *wire.StateWitnessRotationThresholdRecords - if maximum < 2 || maximum > 1_000_000 || threshold < 2 || + if protocolID == [32]byte{} || streamID == [32]byte{} || + manifestHash == [32]byte{} || + *wire.StateAnchorActivationManifestSequence == 0 || + nativeTBTCSignerEd25519SPKISHA256(responsePublicKey) != responseSPKIHash || + offlineAuthorityPublicKey == [32]byte{} || + offlineAuthoritySPKIHash == [32]byte{} || + nativeTBTCSignerEd25519SPKISHA256(offlineAuthorityPublicKey) != + offlineAuthoritySPKIHash || + *wire.StateAnchorTrustCertificateSequence == 0 || + trustCertificateDigest == [32]byte{} || + maximum < 2 || maximum > 1_000_000 || threshold < 2 || threshold > maximum-2 { return fmt.Errorf("installed signer witness geometry is invalid") } value := &NativeTBTCSignerInstalledStateAnchorConfig{ + ProtocolID: protocolID, + StreamID: streamID, + ActivationManifestHash: manifestHash, + ActivationManifestSequence: *wire.StateAnchorActivationManifestSequence, BindingHash: bindingHash, ResponsePublicKey: responsePublicKey, ResponsePublicKeySPKISHA256: responseSPKIHash, + OfflineAuthorityPublicKey: offlineAuthorityPublicKey, + OfflineAuthoritySPKISHA256: offlineAuthoritySPKIHash, + TrustCertificateSequence: *wire.StateAnchorTrustCertificateSequence, + TrustCertificateDigest: trustCertificateDigest, WitnessMaximumRecords: maximum, WitnessRotationThresholdRecords: threshold, ConfigFingerprint: configFingerprint, @@ -126,6 +244,19 @@ func recordNativeTBTCSignerInstalledStateAnchorConfig( return nil } +func nativeTBTCSignerEd25519SPKISHA256(publicKey [32]byte) [32]byte { + // RFC 8410 canonical DER SubjectPublicKeyInfo for Ed25519: + // SEQUENCE { SEQUENCE { OID 1.3.101.112 }, BIT STRING }. + prefix := [...]byte{ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, + 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + } + input := make([]byte, 0, len(prefix)+len(publicKey)) + input = append(input, prefix[:]...) + input = append(input, publicKey[:]...) + return sha256.Sum256(input) +} + // ReadInstalledNativeTBTCSignerStateAnchorConfig returns only material from // the exact config bytes accepted by Rust. A nil result means the signer used // transitional environment fallback and production anchor activation must diff --git a/pkg/frost/signing/native_tbtc_signer_init_config_test.go b/pkg/frost/signing/native_tbtc_signer_init_config_test.go index 03072904c8..5dd52bb7a2 100644 --- a/pkg/frost/signing/native_tbtc_signer_init_config_test.go +++ b/pkg/frost/signing/native_tbtc_signer_init_config_test.go @@ -1,6 +1,8 @@ package signing import ( + "encoding/hex" + "fmt" "strings" "testing" ) @@ -9,16 +11,7 @@ func TestRecordNativeTBTCSignerInstalledStateAnchorConfig(t *testing.T) { resetInstalledNativeTBTCSignerStateAnchorConfigForTest() t.Cleanup(resetInstalledNativeTBTCSignerStateAnchorConfigForTest) - hash := func(value string) string { - return "0x" + strings.Repeat(value, 64) - } - config := []byte(`{ - "state_witness_max_records": 1024, - "state_anchor_binding_hash": "` + hash("1") + `", - "state_anchor_response_public_key": "` + hash("2") + `", - "state_anchor_response_public_key_spki_sha256": "` + hash("3") + `", - "state_witness_rotation_threshold_records": 128 - }`) + config := testNativeTBTCSignerInstalledAnchorConfig(1024) if err := recordNativeTBTCSignerInstalledStateAnchorConfig( config, "config-fingerprint", @@ -29,9 +22,15 @@ func TestRecordNativeTBTCSignerInstalledStateAnchorConfig(t *testing.T) { if err != nil { t.Fatal(err) } - if result.BindingHash[0] != 0x11 || - result.ResponsePublicKey[0] != 0x22 || - result.ResponsePublicKeySPKISHA256[0] != 0x33 || + if result.ProtocolID[0] != 0x01 || + result.StreamID[0] != 0x02 || + result.ActivationManifestHash[0] != 0x03 || + result.ActivationManifestSequence != 7 || + result.BindingHash[0] != 0x04 || + result.ResponsePublicKey[0] != 0x05 || + result.OfflineAuthorityPublicKey[0] != 0x06 || + result.TrustCertificateSequence != 9 || + result.TrustCertificateDigest[0] != 0x07 || result.WitnessMaximumRecords != 1024 || result.WitnessRotationThresholdRecords != 128 || result.ConfigFingerprint != "config-fingerprint" { @@ -58,16 +57,7 @@ func TestRecordNativeTBTCSignerInstalledStateAnchorConfigRejectsPartialOrChanged t.Fatal("partial installed anchor config was accepted") } - hash := func(value string) string { - return "0x" + strings.Repeat(value, 64) - } - valid := []byte(`{ - "state_witness_max_records": 1024, - "state_anchor_binding_hash": "` + hash("1") + `", - "state_anchor_response_public_key": "` + hash("2") + `", - "state_anchor_response_public_key_spki_sha256": "` + hash("3") + `", - "state_witness_rotation_threshold_records": 128 - }`) + valid := testNativeTBTCSignerInstalledAnchorConfig(1024) if err := recordNativeTBTCSignerInstalledStateAnchorConfig( valid, "config-fingerprint", @@ -88,6 +78,101 @@ func TestRecordNativeTBTCSignerInstalledStateAnchorConfigRejectsPartialOrChanged } } +func TestRecordNativeTBTCSignerInstalledStateAnchorConfigAcceptsBootstrapProvisioning( + t *testing.T, +) { + resetInstalledNativeTBTCSignerStateAnchorConfigForTest() + t.Cleanup(resetInstalledNativeTBTCSignerStateAnchorConfigForTest) + + config := []byte(`{ + "purpose": "state_anchor_bootstrap_provisioning", + "profile": "production", + "state_path": "/var/lib/keep-client/tbtc-signer", + "state_witness_max_records": 4 + }`) + if err := recordNativeTBTCSignerInstalledStateAnchorConfig( + config, + "bootstrap-fingerprint", + ); err != nil { + t.Fatalf("bootstrap provisioning config was rejected: %v", err) + } + if _, err := ReadInstalledNativeTBTCSignerStateAnchorConfig(); err == nil { + t.Fatal("bootstrap provisioning installed runtime anchor authority") + } +} + +func TestRecordNativeTBTCSignerInstalledStateAnchorConfigRejectsProvisioningAuthority( + t *testing.T, +) { + resetInstalledNativeTBTCSignerStateAnchorConfigForTest() + t.Cleanup(resetInstalledNativeTBTCSignerStateAnchorConfigForTest) + + for _, config := range [][]byte{ + []byte(`{ + "purpose": "state_anchor_bootstrap_provisioning", + "profile": "production", + "state_path": "/var/lib/keep-client/tbtc-signer", + "state_witness_max_records": 5 + }`), + []byte(`{ + "purpose": "state_anchor_bootstrap_provisioning", + "profile": "production", + "state_path": "/var/lib/keep-client/tbtc-signer", + "state_witness_max_records": 4, + "state_anchor_binding_hash": "0x0100000000000000000000000000000000000000000000000000000000000000" + }`), + } { + if err := recordNativeTBTCSignerInstalledStateAnchorConfig( + config, + "bootstrap-fingerprint", + ); err == nil { + t.Fatalf( + "bootstrap provisioning authority or invalid witness bound was accepted: %s", + config, + ) + } + } +} + +func testNativeTBTCSignerInstalledAnchorConfig(maximum uint64) []byte { + bytes32 := func(first byte) [32]byte { + result := [32]byte{} + result[0] = first + return result + } + hex32 := func(value [32]byte) string { + return "0x" + hex.EncodeToString(value[:]) + } + responseKey := bytes32(0x05) + authorityKey := bytes32(0x06) + return []byte(fmt.Sprintf(`{ + "state_anchor_protocol_id": %q, + "state_anchor_stream_id": %q, + "state_anchor_activation_manifest_hash": %q, + "state_anchor_activation_manifest_sequence": 7, + "state_witness_max_records": %d, + "state_anchor_binding_hash": %q, + "state_anchor_response_public_key": %q, + "state_anchor_response_public_key_spki_sha256": %q, + "state_anchor_offline_authority_public_key": %q, + "state_anchor_offline_authority_public_key_spki_sha256": %q, + "state_anchor_trust_certificate_sequence": 9, + "state_anchor_trust_certificate_digest": %q, + "state_witness_rotation_threshold_records": 128 + }`, + hex32(bytes32(0x01)), + hex32(bytes32(0x02)), + hex32(bytes32(0x03)), + maximum, + hex32(bytes32(0x04)), + hex32(responseKey), + hex32(nativeTBTCSignerEd25519SPKISHA256(responseKey)), + hex32(authorityKey), + hex32(nativeTBTCSignerEd25519SPKISHA256(authorityKey)), + hex32(bytes32(0x07)), + )) +} + func resetInstalledNativeTBTCSignerStateAnchorConfigForTest() { nativeTBTCSignerInstalledStateAnchorConfig.Lock() defer nativeTBTCSignerInstalledStateAnchorConfig.Unlock() diff --git a/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go b/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go index 30ce791a33..8f2eae84c7 100644 --- a/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go +++ b/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go @@ -22,6 +22,11 @@ typedef struct { typedef TbtcSignerInventoryResult (*tbtc_retained_key_package_inventory_fn)(void); typedef TbtcSignerInventoryResult (*tbtc_state_witness_tip_fn)(void); +typedef TbtcSignerInventoryResult (*tbtc_state_anchor_trust_head_fn)(void); +typedef TbtcSignerInventoryResult (*tbtc_transition_state_witness_anchor_fn)( + const uint8_t* request_ptr, + size_t request_len +); typedef TbtcSignerInventoryResult (*tbtc_acknowledge_state_witness_checkpoint_fn)( const uint8_t* request_ptr, size_t request_len @@ -68,6 +73,33 @@ static TbtcSignerInventoryResult tbtc_signer_state_witness_tip(void) { return operation(); } +static TbtcSignerInventoryResult tbtc_signer_state_anchor_trust_head(void) { + tbtc_state_anchor_trust_head_fn operation = + (tbtc_state_anchor_trust_head_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_state_anchor_trust_head" + ); + if (operation == NULL) { + return unavailable_tbtc_signer_inventory_result(); + } + return operation(); +} + +static TbtcSignerInventoryResult tbtc_signer_transition_state_witness_anchor( + const uint8_t* request_ptr, + size_t request_len +) { + tbtc_transition_state_witness_anchor_fn operation = + (tbtc_transition_state_witness_anchor_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_transition_state_witness_anchor" + ); + if (operation == NULL) { + return unavailable_tbtc_signer_inventory_result(); + } + return operation(request_ptr, request_len); +} + static TbtcSignerInventoryResult tbtc_signer_acknowledge_state_witness_checkpoint( const uint8_t* request_ptr, size_t request_len @@ -123,11 +155,17 @@ static void tbtc_signer_inventory_free_buffer(uint8_t* ptr, size_t len) { import "C" import ( + "errors" "fmt" "math" "unsafe" ) +const ( + nativeTBTCSignerStateAnchorTrustHeadAbsentCode = "state_anchor_trust_head_absent" + nativeTBTCSignerStateAnchorTrustRecoveryRequiredCode = "state_anchor_trust_recovery_required" +) + func ReadNativeTBTCSignerRetainedKeyPackageInventory() ( *NativeTBTCSignerRetainedKeyPackageInventory, error, @@ -179,6 +217,133 @@ func ReadNativeTBTCSignerStateWitnessTip() ( return tip, nil } +// ReadNativeTBTCSignerStateAnchorTrustHead returns the descriptor-bound +// offline trust journal head without opening or mutating EngineState. +func ReadNativeTBTCSignerStateAnchorTrustHead() ( + *NativeTBTCSignerStateAnchorTrustHead, + error, +) { + if err := ensureTBTCSignerABICompatible(); err != nil { + return nil, err + } + payload, err := parseNativeTBTCSignerInventoryResult( + "StateAnchorTrustHead", + C.tbtc_signer_state_anchor_trust_head(), + ) + if err != nil { + return nil, classifyNativeTBTCSignerStateAnchorTrustHeadError(err) + } + head, err := DecodeNativeTBTCSignerStateAnchorTrustHead(payload) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "StateAnchorTrustHead", + err.Error(), + ) + } + return head, nil +} + +func classifyNativeTBTCSignerStateAnchorTrustHeadError(err error) error { + var structured *buildTaggedTBTCSignerStructuredError + if !errors.As(err, &structured) { + return err + } + if structured.Code == + nativeTBTCSignerStateAnchorTrustRecoveryRequiredCode && + structured.StateAnchorTrustRecovery != nil { + return newNativeTBTCSignerStateAnchorTrustRecoveryRequiredError( + structured.StateAnchorTrustRecovery, + err, + ) + } + if structured.Code == nativeTBTCSignerStateAnchorTrustHeadAbsentCode { + return fmt.Errorf( + "%w: %v", + ErrNativeTBTCSignerStateAnchorTrustHeadAbsent, + err, + ) + } + return err +} + +// TransitionNativeTBTCSignerStateWitnessAnchor is the sole startup-only trust +// bootstrap/rotation entry point. Rust rejects it once EngineState or the +// durable store has been opened. The request is already fully verified by Go, +// but Rust independently authenticates the certificate chain and fresh final +// Read before committing its crash-safe trust journal. +func TransitionNativeTBTCSignerStateWitnessAnchor( + requestJSON []byte, +) (*NativeTBTCSignerStateAnchorTrustTransitionResult, error) { + if err := ensureTBTCSignerABICompatible(); err != nil { + return nil, err + } + if len(requestJSON) == 0 || + len(requestJSON) > + NativeTBTCSignerStateAnchorTrustTransitionMaximumRequestBytes { + return nil, buildTaggedTBTCSignerOperationError( + "TransitionStateWitnessAnchor", + "trust-transition request size is invalid", + ) + } + requestPointer := C.CBytes(requestJSON) + defer func() { + zeroBytes(unsafe.Slice((*byte)(requestPointer), len(requestJSON))) + C.free(requestPointer) + }() + payload, err := parseNativeTBTCSignerInventoryResult( + "TransitionStateWitnessAnchor", + C.tbtc_signer_transition_state_witness_anchor( + (*C.uint8_t)(requestPointer), + C.size_t(len(requestJSON)), + ), + ) + if err != nil { + return nil, classifyNativeTBTCSignerStateAnchorTrustTransitionError(err) + } + result, err := DecodeNativeTBTCSignerStateAnchorTrustTransitionResult(payload) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "TransitionStateWitnessAnchor", + err.Error(), + ) + } + return result, nil +} + +func classifyNativeTBTCSignerStateAnchorTrustTransitionError( + err error, +) error { + var structured *buildTaggedTBTCSignerStructuredError + if !errors.As(err, &structured) || + structured.Code != + nativeTBTCSignerStateAnchorTrustRecoveryRequiredCode || + structured.StateAnchorTrustRecovery == nil { + return err + } + return newNativeTBTCSignerStateAnchorTrustRecoveryRequiredError( + structured.StateAnchorTrustRecovery, + err, + ) +} + +func newNativeTBTCSignerStateAnchorTrustRecoveryRequiredError( + recovery *NativeTBTCSignerStateAnchorTrustRecoveryRequired, + cause error, +) error { + if recovery == nil { + return cause + } + copy := *recovery + copy.OrderedCertificateDigests = append( + [][32]byte{}, + recovery.OrderedCertificateDigests..., + ) + return &NativeTBTCSignerStateAnchorTrustRecoveryRequiredError{ + Recovery: copy, + cause: cause, + } +} + // AcknowledgeNativeTBTCSignerStateWitnessCheckpoint installs the exact signed // remote CAS response into Rust's descriptor-bound anchor metadata. This is an // internal half of the output barrier and deliberately bypasses the diff --git a/pkg/frost/signing/native_tbtc_signer_inventory_default.go b/pkg/frost/signing/native_tbtc_signer_inventory_default.go index 195da32d0c..1087a2a95c 100644 --- a/pkg/frost/signing/native_tbtc_signer_inventory_default.go +++ b/pkg/frost/signing/native_tbtc_signer_inventory_default.go @@ -24,6 +24,25 @@ func ReadNativeTBTCSignerStateWitnessTip() ( ) } +func ReadNativeTBTCSignerStateAnchorTrustHead() ( + *NativeTBTCSignerStateAnchorTrustHead, + error, +) { + return nil, fmt.Errorf( + "%w: tbtc-signer bridge operation [StateAnchorTrustHead] is unavailable in this build", + ErrNativeCryptographyUnavailable, + ) +} + +func TransitionNativeTBTCSignerStateWitnessAnchor( + requestJSON []byte, +) (*NativeTBTCSignerStateAnchorTrustTransitionResult, error) { + return nil, fmt.Errorf( + "%w: tbtc-signer bridge operation [TransitionStateWitnessAnchor] is unavailable in this build", + ErrNativeCryptographyUnavailable, + ) +} + func AcknowledgeNativeTBTCSignerStateWitnessCheckpoint( signedAcknowledgementJSON []byte, ) (*NativeTBTCSignerStateWitnessCheckpointAcknowledgementResult, error) { diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go index edd332875a..17fdef02ed 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go @@ -23,7 +23,25 @@ var ( ) ) -const defaultNativeTBTCSignerStateAnchorTimeout = 15 * time.Second +const ( + defaultNativeTBTCSignerStateAnchorTimeout = 15 * time.Second + + // NativeTBTCSignerStateAnchorMaximumRevisionDistance is the frozen maximum + // number of service revisions that can remain restartable from one + // certified floor. Callers may choose a smaller conservative window but + // cannot enlarge this protocol bound. + NativeTBTCSignerStateAnchorMaximumRevisionDistance uint64 = 4096 + + // NativeTBTCSignerStateAnchorMaximumGenerationDistance is the frozen + // maximum number of Rust state generations that can remain provable from + // one certified floor. + NativeTBTCSignerStateAnchorMaximumGenerationDistance uint64 = 4096 + + // NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation is the + // maximum durable Rust state writes one request-taking call may perform: + // two sweep/repair writes followed by the operation's own write. + NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation uint64 = 3 +) // NativeTBTCSignerStateAnchorCommitter durably commits a transition to the // independent linearizable anchor, installs the exact signed acknowledgement @@ -46,26 +64,36 @@ type NativeTBTCSignerStateAnchorCommitter interface { // barrier. InitialTip must have already passed startup reconciliation against // the independent service. type NativeTBTCSignerStateAnchorBarrierConfig struct { - InitialTip *NativeTBTCSignerStateWitnessTip - ExpectedAnchorBindingHash [32]byte - MinimumAnchorServiceEpoch uint64 - ReadTip func() (*NativeTBTCSignerStateWitnessTip, error) - Committer NativeTBTCSignerStateAnchorCommitter - Timeout time.Duration + InitialTip *NativeTBTCSignerStateWitnessTip + ExpectedAnchorBindingHash [32]byte + MinimumAnchorServiceEpoch uint64 + MaximumAnchorRevisionDistance uint64 + MaximumStateGenerationDistance uint64 + MaximumStateGenerationAdvancePerOperation uint64 + ExpectedTrustHead *NativeTBTCSignerStateAnchorTrustHead + ReadTip func() (*NativeTBTCSignerStateWitnessTip, error) + ReadTrustHead func() (*NativeTBTCSignerStateAnchorTrustHead, error) + Committer NativeTBTCSignerStateAnchorCommitter + Timeout time.Duration } type nativeTBTCSignerStateAnchorBarrier struct { mutex sync.Mutex - installed bool - poisoned error - tip NativeTBTCSignerStateWitnessTip - readTip func() (*NativeTBTCSignerStateWitnessTip, error) - committer NativeTBTCSignerStateAnchorCommitter - timeout time.Duration - - expectedAnchorBindingHash [32]byte - minimumAnchorServiceEpoch uint64 + installed bool + poisoned error + tip NativeTBTCSignerStateWitnessTip + readTip func() (*NativeTBTCSignerStateWitnessTip, error) + readTrustHead func() (*NativeTBTCSignerStateAnchorTrustHead, error) + committer NativeTBTCSignerStateAnchorCommitter + timeout time.Duration + + expectedAnchorBindingHash [32]byte + minimumAnchorServiceEpoch uint64 + maximumAnchorRevisionDistance uint64 + maximumStateGenerationDistance uint64 + maximumStateGenerationAdvancePerOperation uint64 + expectedTrustHead NativeTBTCSignerStateAnchorTrustHead } var globalNativeTBTCSignerStateAnchorBarrier nativeTBTCSignerStateAnchorBarrier @@ -77,9 +105,19 @@ func InstallNativeTBTCSignerStateAnchorBarrier( config NativeTBTCSignerStateAnchorBarrierConfig, ) error { if config.InitialTip == nil || config.ReadTip == nil || + config.ReadTrustHead == nil || config.ExpectedTrustHead == nil || config.Committer == nil || config.ExpectedAnchorBindingHash == [32]byte{} || - config.MinimumAnchorServiceEpoch == 0 { + config.MinimumAnchorServiceEpoch == 0 || + config.MaximumAnchorRevisionDistance == 0 || + config.MaximumAnchorRevisionDistance > + NativeTBTCSignerStateAnchorMaximumRevisionDistance || + config.MaximumStateGenerationDistance == 0 || + config.MaximumStateGenerationDistance > + NativeTBTCSignerStateAnchorMaximumGenerationDistance || + config.MaximumStateGenerationAdvancePerOperation == 0 || + config.MaximumStateGenerationAdvancePerOperation > + NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation { return fmt.Errorf("native tbtc signer state anchor dependencies are incomplete") } initial := *config.InitialTip @@ -95,6 +133,30 @@ func InstallNativeTBTCSignerStateAnchorBarrier( "native tbtc signer initial tip lacks the pinned signed anchor acknowledgement", ) } + expectedTrustHead := *config.ExpectedTrustHead + if expectedTrustHead.Schema != NativeTBTCSignerStateAnchorTrustHeadSchema || + expectedTrustHead.CertificateSequence == 0 || + expectedTrustHead.CertificateDigest == [32]byte{} || + expectedTrustHead.BindingHash != config.ExpectedAnchorBindingHash || + expectedTrustHead.ServiceEpoch != initial.AnchorServiceEpoch || + expectedTrustHead.ServiceEpoch < config.MinimumAnchorServiceEpoch || + expectedTrustHead.CertifiedFloor.ServiceEpoch != + expectedTrustHead.ServiceEpoch || + expectedTrustHead.CertifiedFloor.Revision > initial.AnchorRevision || + initial.AnchorRevision-expectedTrustHead.CertifiedFloor.Revision > + config.MaximumAnchorRevisionDistance || + expectedTrustHead.CertifiedFloor.Checkpoint.StoreFingerprint != + initial.StoreFingerprint || + expectedTrustHead.CertifiedFloor.Checkpoint.Generation == 0 || + expectedTrustHead.CertifiedFloor.Checkpoint.Generation > + initial.Generation || + initial.Generation- + expectedTrustHead.CertifiedFloor.Checkpoint.Generation > + config.MaximumStateGenerationDistance { + return fmt.Errorf( + "native tbtc signer trust head differs from the initial anchor identity", + ) + } timeout := config.Timeout if timeout == 0 { timeout = defaultNativeTBTCSignerStateAnchorTimeout @@ -116,6 +178,15 @@ func InstallNativeTBTCSignerStateAnchorBarrier( if readback == nil || *readback != initial { return fmt.Errorf("native tbtc signer initial state tip changed before installation") } + trustReadback, err := config.ReadTrustHead() + if err != nil { + return fmt.Errorf("cannot read native tbtc signer trust head: %w", err) + } + if trustReadback == nil || *trustReadback != expectedTrustHead { + return fmt.Errorf( + "native tbtc signer trust head changed before barrier installation", + ) + } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if err := config.Committer.VerifyNativeTBTCSignerStateTip( @@ -131,10 +202,18 @@ func InstallNativeTBTCSignerStateAnchorBarrier( barrier.installed = true barrier.tip = initial barrier.readTip = config.ReadTip + barrier.readTrustHead = config.ReadTrustHead barrier.committer = config.Committer barrier.timeout = timeout barrier.expectedAnchorBindingHash = config.ExpectedAnchorBindingHash barrier.minimumAnchorServiceEpoch = config.MinimumAnchorServiceEpoch + barrier.maximumAnchorRevisionDistance = + config.MaximumAnchorRevisionDistance + barrier.maximumStateGenerationDistance = + config.MaximumStateGenerationDistance + barrier.maximumStateGenerationAdvancePerOperation = + config.MaximumStateGenerationAdvancePerOperation + barrier.expectedTrustHead = expectedTrustHead return nil } @@ -215,6 +294,51 @@ func beginNativeTBTCSignerStateAnchoredOperation( fmt.Errorf("pre-operation state tip differs from the committed process tip"), ) } + trustHead, err := barrier.readTrustHead() + if err != nil { + return nil, poisonAndUnlockNativeTBTCSignerStateAnchor( + barrier, + fmt.Errorf("cannot read pre-operation trust head: %w", err), + ) + } + if trustHead == nil || *trustHead != barrier.expectedTrustHead { + return nil, poisonAndUnlockNativeTBTCSignerStateAnchor( + barrier, + fmt.Errorf("pre-operation trust head differs from the installed identity"), + ) + } + if readback.AnchorRevision < + trustHead.CertifiedFloor.Revision || + readback.AnchorRevision-trustHead.CertifiedFloor.Revision >= + barrier.maximumAnchorRevisionDistance { + barrier.mutex.Unlock() + return nil, fmt.Errorf( + "%w: request-taking operation [%s] is blocked because the certified anchor revision window is exhausted; offline anchor rotation is required", + ErrNativeTBTCSignerStateAnchorUnavailable, + operation, + ) + } + certifiedFloorGeneration := + trustHead.CertifiedFloor.Checkpoint.Generation + if readback.Generation < certifiedFloorGeneration { + return nil, poisonAndUnlockNativeTBTCSignerStateAnchor( + barrier, + fmt.Errorf( + "pre-operation state generation precedes the certified floor", + ), + ) + } + generationDistance := readback.Generation - certifiedFloorGeneration + if generationDistance > barrier.maximumStateGenerationDistance || + barrier.maximumStateGenerationDistance-generationDistance < + barrier.maximumStateGenerationAdvancePerOperation { + barrier.mutex.Unlock() + return nil, fmt.Errorf( + "%w: request-taking operation [%s] is blocked because the certified signer-generation window cannot cover its maximum advance; offline anchor rotation is required", + ErrNativeTBTCSignerStateAnchorUnavailable, + operation, + ) + } ctx, cancel := context.WithTimeout(context.Background(), barrier.timeout) defer cancel() if err := barrier.committer.VerifyNativeTBTCSignerStateTip( @@ -257,6 +381,24 @@ func (lease *nativeTBTCSignerStateAnchorLease) commit() error { ); err != nil { return lease.poison(err) } + if candidate.Generation > lease.expected.Generation && + candidate.Generation-lease.expected.Generation > + barrier.maximumStateGenerationAdvancePerOperation { + return lease.poison(fmt.Errorf( + "native signer operation advanced [%d] generations, exceeding the per-operation bound [%d]", + candidate.Generation-lease.expected.Generation, + barrier.maximumStateGenerationAdvancePerOperation, + )) + } + certifiedFloorGeneration := + barrier.expectedTrustHead.CertifiedFloor.Checkpoint.Generation + if candidate.Generation < certifiedFloorGeneration || + candidate.Generation-certifiedFloorGeneration > + barrier.maximumStateGenerationDistance { + return lease.poison(fmt.Errorf( + "native signer operation exceeded the certified signer-generation window", + )) + } if *candidate == lease.expected { barrier.tip = *candidate @@ -487,8 +629,13 @@ func resetNativeTBTCSignerStateAnchorBarrierForTest() { barrier.poisoned = nil barrier.tip = NativeTBTCSignerStateWitnessTip{} barrier.readTip = nil + barrier.readTrustHead = nil barrier.committer = nil barrier.timeout = 0 barrier.expectedAnchorBindingHash = [32]byte{} barrier.minimumAnchorServiceEpoch = 0 + barrier.maximumAnchorRevisionDistance = 0 + barrier.maximumStateGenerationDistance = 0 + barrier.maximumStateGenerationAdvancePerOperation = 0 + barrier.expectedTrustHead = NativeTBTCSignerStateAnchorTrustHead{} } diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go index bcaa29d68e..71a082a2ce 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go @@ -25,6 +25,43 @@ type testNativeTBTCSignerStateAnchorCommitter struct { startOnce sync.Once } +func testNativeTBTCSignerStateAnchorTrustHead() *NativeTBTCSignerStateAnchorTrustHead { + floor := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + return &NativeTBTCSignerStateAnchorTrustHead{ + Schema: NativeTBTCSignerStateAnchorTrustHeadSchema, + CertificateSequence: 1, + CertificateDigest: [32]byte{0x21}, + ActivationManifestSequence: 1, + ActivationManifestHash: [32]byte{0x22}, + BindingHash: [32]byte{10}, + ResponsePublicKeySPKISHA256: [32]byte{0x23}, + OfflineAuthoritySPKISHA256: [32]byte{0x24}, + ServiceEpoch: 1, + CertifiedFloor: NativeTBTCSignerStateAnchorTrustReference{ + ServiceEpoch: 1, + Revision: 1, + EventRoot: [32]byte{0x25}, + AcknowledgementDigest: [32]byte{0x26}, + Checkpoint: NativeTBTCSignerStateAnchorCheckpoint{ + StoreFingerprint: floor.StoreFingerprint, + Generation: floor.Generation, + PreviousStateCommitment: floor.PreviousStateCommitment, + StateImageDigest: floor.StateImageDigest, + StateCommitment: floor.StateCommitment, + }, + }, + WitnessMaximumRecords: 4096, + WitnessRotationThresholdRecords: 1024, + } +} + +func readTestNativeTBTCSignerStateAnchorTrustHead() ( + *NativeTBTCSignerStateAnchorTrustHead, + error, +) { + return testNativeTBTCSignerStateAnchorTrustHead(), nil +} + func (committer *testNativeTBTCSignerStateAnchorCommitter) VerifyNativeTBTCSignerStateTip( ctx context.Context, local NativeTBTCSignerStateWitnessTip, @@ -99,14 +136,19 @@ func TestNativeTBTCSignerOutputBarrierDoesNotReleaseBeforeAcknowledgement( } if err := InstallNativeTBTCSignerStateAnchorBarrier( NativeTBTCSignerStateAnchorBarrierConfig{ - InitialTip: &initial, - ExpectedAnchorBindingHash: [32]byte{10}, - MinimumAnchorServiceEpoch: 1, + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: 4096, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { copy := current return ©, nil }, - Committer: committer, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, }, ); err != nil { t.Fatal(err) @@ -189,14 +231,19 @@ func TestNativeTBTCSignerOutputBarrierDiscardsExactlyOnceOnAnchorFailure( } if err := InstallNativeTBTCSignerStateAnchorBarrier( NativeTBTCSignerStateAnchorBarrierConfig{ - InitialTip: &initial, - ExpectedAnchorBindingHash: [32]byte{10}, - MinimumAnchorServiceEpoch: 1, + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: 4096, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { copy := current return ©, nil }, - Committer: committer, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, }, ); err != nil { t.Fatal(err) @@ -241,14 +288,19 @@ func TestNativeTBTCSignerOutputBarrierDiscardsAndPoisonsOnInvokePanic( } if err := InstallNativeTBTCSignerStateAnchorBarrier( NativeTBTCSignerStateAnchorBarrierConfig{ - InitialTip: &initial, - ExpectedAnchorBindingHash: [32]byte{10}, - MinimumAnchorServiceEpoch: 1, + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: 4096, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { copy := current return ©, nil }, - Committer: committer, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, }, ); err != nil { t.Fatal(err) @@ -307,11 +359,16 @@ func TestNativeTBTCSignerStateAnchorBarrierCommitsBeforeCompletion(t *testing.T) } if err := InstallNativeTBTCSignerStateAnchorBarrier( NativeTBTCSignerStateAnchorBarrierConfig{ - InitialTip: &initial, - ExpectedAnchorBindingHash: [32]byte{10}, - MinimumAnchorServiceEpoch: 1, - ReadTip: readTip, - Committer: committer, + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: 4096, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), + ReadTip: readTip, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, }, ); err != nil { t.Fatalf("cannot install state anchor barrier: %v", err) @@ -355,14 +412,19 @@ func TestNativeTBTCSignerStateAnchorBarrierChecksTipWithoutMutation(t *testing.T } if err := InstallNativeTBTCSignerStateAnchorBarrier( NativeTBTCSignerStateAnchorBarrierConfig{ - InitialTip: &initial, - ExpectedAnchorBindingHash: [32]byte{10}, - MinimumAnchorServiceEpoch: 1, + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: 4096, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { copy := current return ©, nil }, - Committer: committer, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, }, ); err != nil { t.Fatal(err) @@ -393,14 +455,19 @@ func TestNativeTBTCSignerStateAnchorBarrierPoisonsAfterCommitFailure(t *testing. } if err := InstallNativeTBTCSignerStateAnchorBarrier( NativeTBTCSignerStateAnchorBarrierConfig{ - InitialTip: &initial, - ExpectedAnchorBindingHash: [32]byte{10}, - MinimumAnchorServiceEpoch: 1, + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: 4096, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { copy := current return ©, nil }, - Committer: committer, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, }, ); err != nil { t.Fatal(err) @@ -437,14 +504,19 @@ func TestNativeTBTCSignerStateAnchorBarrierRejectsPreMigrationInitialTip( committer := &testNativeTBTCSignerStateAnchorCommitter{} err := InstallNativeTBTCSignerStateAnchorBarrier( NativeTBTCSignerStateAnchorBarrierConfig{ - InitialTip: &preMigration, - ExpectedAnchorBindingHash: [32]byte{10}, - MinimumAnchorServiceEpoch: 1, + InitialTip: &preMigration, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: 4096, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { copy := postMigration return ©, nil }, - Committer: committer, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, }, ) if err == nil { @@ -464,20 +536,405 @@ func TestNativeTBTCSignerStateAnchorBarrierRejectsUnacknowledgedInitialTip( initial.AnchorRevision = 0 initial.AnchorEventRoot = [32]byte{} initial.AnchorAcknowledgementDigest = [32]byte{} + err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: 4096, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := initial + return ©, nil + }, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: &testNativeTBTCSignerStateAnchorCommitter{}, + }, + ) + if err == nil { + t.Fatal("unacknowledged initial state tip was accepted") + } +} + +func TestNativeTBTCSignerStateAnchorBarrierRejectsUnboundedRevisionWindow( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) err := InstallNativeTBTCSignerStateAnchorBarrier( NativeTBTCSignerStateAnchorBarrierConfig{ InitialTip: &initial, ExpectedAnchorBindingHash: [32]byte{10}, MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: NativeTBTCSignerStateAnchorMaximumRevisionDistance + + 1, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { copy := initial return ©, nil }, - Committer: &testNativeTBTCSignerStateAnchorCommitter{}, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: &testNativeTBTCSignerStateAnchorCommitter{}, }, ) if err == nil { - t.Fatal("unacknowledged initial state tip was accepted") + t.Fatal("caller-controlled revision window exceeded the frozen bound") + } +} + +func TestNativeTBTCSignerStateAnchorBarrierRejectsUnboundedGenerationConfig( + t *testing.T, +) { + for _, test := range []struct { + name string + distance uint64 + advance uint64 + }{ + { + "distance", + NativeTBTCSignerStateAnchorMaximumGenerationDistance + 1, + NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + }, + { + "per-operation advance", + NativeTBTCSignerStateAnchorMaximumGenerationDistance, + NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation + 1, + }, + } { + t.Run(test.name, func(t *testing.T) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: NativeTBTCSignerStateAnchorMaximumRevisionDistance, + MaximumStateGenerationDistance: test.distance, + MaximumStateGenerationAdvancePerOperation: test.advance, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := initial + return ©, nil + }, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: &testNativeTBTCSignerStateAnchorCommitter{}, + }, + ) + if err == nil { + t.Fatal("caller-controlled generation bound exceeded the frozen maximum") + } + }) + } +} + +func TestNativeTBTCSignerStateAnchorBarrierRejectsInitialGenerationBeyondFloorWindow( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip( + 1+NativeTBTCSignerStateAnchorMaximumGenerationDistance+1, + [32]byte{2}, + ) + err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: NativeTBTCSignerStateAnchorMaximumRevisionDistance, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := initial + return ©, nil + }, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: &testNativeTBTCSignerStateAnchorCommitter{}, + }, + ) + if err == nil { + t.Fatal("initial generation beyond the certified floor window was accepted") + } +} + +func TestNativeTBTCSignerStateAnchorBarrierBlocksBeforeMutationAtRevisionBound( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + initial.AnchorRevision = + testNativeTBTCSignerStateAnchorTrustHead(). + CertifiedFloor.Revision + + NativeTBTCSignerStateAnchorMaximumRevisionDistance + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{ + current: ¤t, + } + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: NativeTBTCSignerStateAnchorMaximumRevisionDistance, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := current + return ©, nil + }, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, + }, + ); err != nil { + t.Fatal(err) + } + + invoked := false + released := false + discarded := false + _, err := executeNativeTBTCSignerStateAnchoredOutput( + "InteractiveRound1", + func() { + invoked = true + current.Generation++ + }, + func() ([]byte, error) { + released = true + return nil, nil + }, + func() { + discarded = true + }, + ) + if !errors.Is(err, ErrNativeTBTCSignerStateAnchorUnavailable) { + t.Fatalf("exhausted revision window was not blocked: %v", err) + } + if invoked || released || discarded || current != initial || + committer.calls != 0 || committer.verifyCalls != 1 { + t.Fatal( + "revision-bound admission mutated signer state or contacted the commit path", + ) + } +} + +func TestNativeTBTCSignerStateAnchorBarrierBlocksBeforeMutationWithoutGenerationCapacity( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + floorGeneration := + testNativeTBTCSignerStateAnchorTrustHead(). + CertifiedFloor.Checkpoint.Generation + initial := testNativeTBTCSignerStateWitnessTip( + floorGeneration+ + NativeTBTCSignerStateAnchorMaximumGenerationDistance-2, + [32]byte{2}, + ) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{current: ¤t} + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: NativeTBTCSignerStateAnchorMaximumRevisionDistance, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := current + return ©, nil + }, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, + }, + ); err != nil { + t.Fatal(err) + } + + invoked := false + released := false + discarded := false + _, err := executeNativeTBTCSignerStateAnchoredOutput( + "InteractiveRound1", + func() { + invoked = true + }, + func() ([]byte, error) { + released = true + return nil, nil + }, + func() { + discarded = true + }, + ) + if !errors.Is(err, ErrNativeTBTCSignerStateAnchorUnavailable) { + t.Fatalf("insufficient generation capacity was not blocked: %v", err) + } + if invoked || released || discarded || current != initial || + committer.calls != 0 || committer.verifyCalls != 1 { + t.Fatal( + "generation-bound admission mutated signer state or contacted the commit path", + ) + } +} + +func TestNativeTBTCSignerStateAnchorBarrierPoisonsOversizedGenerationAdvance( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{current: ¤t} + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: NativeTBTCSignerStateAnchorMaximumRevisionDistance, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := current + return ©, nil + }, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, + }, + ); err != nil { + t.Fatal(err) + } + + var discarded atomic.Int32 + _, err := executeNativeTBTCSignerStateAnchoredOutput( + "InteractiveRound2", + func() { + current = testNativeTBTCSignerStateWitnessTip( + initial.Generation+ + NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation+1, + initial.StateCommitment, + ) + }, + func() ([]byte, error) { + t.Fatal("oversized generation advance released native output") + return nil, nil + }, + func() { + discarded.Add(1) + }, + ) + if !errors.Is(err, ErrNativeTBTCSignerStateAnchorTerminal) || + discarded.Load() != 1 || committer.calls != 0 { + t.Fatalf( + "oversized generation advance was not terminally rejected: [%v] discarded [%d] commits [%d]", + err, + discarded.Load(), + committer.calls, + ) + } +} + +func TestNativeTBTCSignerStateAnchorBarrierGenerationCapacityAdvancesFasterThanRevision( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{ + current: ¤t, + acknowledge: func( + candidate NativeTBTCSignerStateWitnessTip, + ) NativeTBTCSignerStateWitnessTip { + candidate.AnchorRevision++ + candidate.AnchorEventRoot = + [32]byte{byte(candidate.AnchorRevision + 20)} + candidate.AnchorAcknowledgementDigest = + [32]byte{byte(candidate.AnchorRevision + 30)} + return candidate + }, + } + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: NativeTBTCSignerStateAnchorMaximumRevisionDistance, + MaximumStateGenerationDistance: 6, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := current + return ©, nil + }, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, + }, + ); err != nil { + t.Fatal(err) + } + + for i := 0; i < 2; i++ { + lease, err := beginNativeTBTCSignerStateAnchoredOperation( + "InteractiveRound1", + ) + if err != nil { + t.Fatal(err) + } + candidate := testNativeTBTCSignerStateWitnessTip( + current.Generation+2, + current.StateCommitment, + ) + candidate.AnchorBindingHash = current.AnchorBindingHash + candidate.AnchorServiceEpoch = current.AnchorServiceEpoch + candidate.AnchorRevision = current.AnchorRevision + candidate.AnchorEventRoot = current.AnchorEventRoot + candidate.AnchorAcknowledgementDigest = + current.AnchorAcknowledgementDigest + current = candidate + if err := lease.commit(); err != nil { + lease.release() + t.Fatal(err) + } + lease.release() + } + + if current.Generation != initial.Generation+4 || + current.AnchorRevision != initial.AnchorRevision+2 || + committer.calls != 2 { + t.Fatalf( + "unexpected dual-dimension advance [generation %d revision %d commits %d]", + current.Generation, + current.AnchorRevision, + committer.calls, + ) + } + if _, err := beginNativeTBTCSignerStateAnchoredOperation( + "InteractiveRound1", + ); !errors.Is(err, ErrNativeTBTCSignerStateAnchorUnavailable) { + t.Fatalf( + "generation capacity did not block before the revision window: %v", + err, + ) } } diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go new file mode 100644 index 0000000000..5090661eb7 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go @@ -0,0 +1,526 @@ +package signing + +import ( + "errors" + "fmt" +) + +const ( + NativeTBTCSignerStateAnchorTrustHeadSchema = "tbtc-signer-state-anchor-trust-head/v1" + NativeTBTCSignerStateAnchorTrustTransitionResultSchema = "tbtc-signer-state-anchor-trust-transition-result/v1" + NativeTBTCSignerStateAnchorTrustRecoveryRequiredSchema = "tbtc-signer-state-anchor-trust-recovery-required/v1" + // NativeTBTCSignerStateAnchorTrustTransitionMaximumRequestBytes is the + // shared Go-side admission bound for Rust's startup transition FFI. + NativeTBTCSignerStateAnchorTrustTransitionMaximumRequestBytes = 16 * 1024 * 1024 + NativeTBTCSignerStateAnchorTrustTransitionMaximumCertificateCount uint64 = 64 +) + +var ErrNativeTBTCSignerStateAnchorTrustHeadAbsent = errors.New( + "native tbtc signer state-anchor trust head is absent", +) + +// NativeTBTCSignerStateAnchorCheckpoint is the complete state commitment +// carried by certified floors and transition readback. +type NativeTBTCSignerStateAnchorCheckpoint struct { + StoreFingerprint [32]byte + Generation uint64 + PreviousStateCommitment [32]byte + StateImageDigest [32]byte + StateCommitment [32]byte +} + +// NativeTBTCSignerStateAnchorTrustReference identifies one exact signed +// service event. PreviousEventRoot is retained here because revision-one +// rotation certificates are the sole allowed cross-epoch predecessor-root +// exception. +type NativeTBTCSignerStateAnchorTrustReference struct { + ServiceEpoch uint64 + Revision uint64 + PreviousEventRoot [32]byte + EventRoot [32]byte + AcknowledgementDigest [32]byte + Checkpoint NativeTBTCSignerStateAnchorCheckpoint +} + +// NativeTBTCSignerStateAnchorTrustHead is the descriptor-bound offline trust +// journal head exposed by frost_tbtc_state_anchor_trust_head. The full +// certificate bytes remain private to the signer journal; this readback +// contains every value Go needs to compare with the verified manifest, +// certificate, service, and installed init configuration. +type NativeTBTCSignerStateAnchorTrustHead struct { + Schema string + CertificateSequence uint64 + CertificateDigest [32]byte + ActivationManifestSequence uint64 + ActivationManifestHash [32]byte + BindingHash [32]byte + ResponsePublicKeySPKISHA256 [32]byte + OfflineAuthoritySPKISHA256 [32]byte + ServiceEpoch uint64 + CertifiedFloor NativeTBTCSignerStateAnchorTrustReference + WitnessMaximumRecords uint64 + WitnessRotationThresholdRecords uint64 +} + +type NativeTBTCSignerStateAnchorTrustTransitionResult struct { + Schema string + Installed bool + Idempotent bool + AppliedCertificateCount uint64 + TrustHead NativeTBTCSignerStateAnchorTrustHead + CurrentCheckpoint NativeTBTCSignerStateAnchorCheckpoint + WitnessBaseCheckpoint NativeTBTCSignerStateAnchorCheckpoint + CurrentAnchorReference NativeTBTCSignerStateAnchorTrustReference +} + +// NativeTBTCSignerStateAnchorTrustRecoveryRequired is an unauthoritative, +// bounded selector for the exact certificate suffix held in a crash-recovery +// intent. Callers must match it against an independently authenticated local +// artifact and obtain a new signed remote Read before retrying. +type NativeTBTCSignerStateAnchorTrustRecoveryRequired struct { + Schema string + StoreFingerprint [32]byte + CertificateCount uint64 + FirstCertificateSequence uint64 + OrderedCertificateDigests [][32]byte + FinalCertificateSequence uint64 + FinalCertificateDigest [32]byte + TargetBindingHash [32]byte + TargetServiceEpoch uint64 + TargetRevision uint64 + TargetCheckpoint NativeTBTCSignerStateAnchorCheckpoint +} + +// NativeTBTCSignerStateAnchorTrustRecoveryRequiredError preserves the original +// bridge failure while exposing its strictly decoded recovery selector through +// errors.As. +type NativeTBTCSignerStateAnchorTrustRecoveryRequiredError struct { + Recovery NativeTBTCSignerStateAnchorTrustRecoveryRequired + cause error +} + +func (e *NativeTBTCSignerStateAnchorTrustRecoveryRequiredError) Error() string { + if e == nil { + return "" + } + return fmt.Sprintf( + "native tbtc signer state-anchor trust recovery is required for certificate suffix [%d..%d]", + e.Recovery.FirstCertificateSequence, + e.Recovery.FinalCertificateSequence, + ) +} + +func (e *NativeTBTCSignerStateAnchorTrustRecoveryRequiredError) Unwrap() error { + if e == nil { + return nil + } + return e.cause +} + +type nativeTBTCSignerStateAnchorCheckpointWire struct { + StoreFingerprint string `json:"storeFingerprint"` + Generation string `json:"generation"` + PreviousStateCommitment string `json:"previousStateCommitment"` + StateImageDigest string `json:"stateImageDigest"` + StateCommitment string `json:"stateCommitment"` +} + +type nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire struct { + Schema string `json:"schema"` + StoreFingerprint string `json:"storeFingerprint"` + CertificateCount string `json:"certificateCount"` + FirstCertificateSequence string `json:"firstCertificateSequence"` + OrderedCertificateDigests []string `json:"orderedCertificateDigests"` + FinalCertificateSequence string `json:"finalCertificateSequence"` + FinalCertificateDigest string `json:"finalCertificateDigest"` + TargetBindingHash string `json:"targetBindingHash"` + TargetServiceEpoch string `json:"targetServiceEpoch"` + TargetRevision string `json:"targetRevision"` + TargetCheckpoint nativeTBTCSignerStateAnchorCheckpointWire `json:"targetCheckpoint"` +} + +type nativeTBTCSignerStateAnchorTrustReferenceWire struct { + ServiceEpoch string `json:"serviceEpoch"` + Revision string `json:"revision"` + PreviousEventRoot string `json:"previousEventRoot"` + EventRoot string `json:"eventRoot"` + CheckpointAckDigest string `json:"checkpointAckDigest"` + Checkpoint nativeTBTCSignerStateAnchorCheckpointWire `json:"checkpoint"` +} + +type nativeTBTCSignerStateAnchorTrustHeadWire struct { + Schema string `json:"schema"` + CertificateSequence string `json:"certificateSequence"` + CertificateDigest string `json:"certificateDigest"` + ActivationManifestSequence string `json:"activationManifestSequence"` + ActivationManifestHash string `json:"activationManifestHash"` + BindingHash string `json:"bindingHash"` + ResponsePublicKeySPKISHA256 string `json:"responsePublicKeySpkiSha256"` + OfflineAuthoritySPKISHA256 string `json:"offlineAuthoritySpkiSha256"` + ServiceEpoch string `json:"serviceEpoch"` + CertifiedFloor nativeTBTCSignerStateAnchorTrustReferenceWire `json:"certifiedFloor"` + WitnessMaximumRecords string `json:"witnessMaximumRecords"` + WitnessRotationThresholdRecords string `json:"witnessRotationThresholdRecords"` +} + +type nativeTBTCSignerStateAnchorTrustTransitionResultWire struct { + Schema string `json:"schema"` + Installed *bool `json:"installed"` + Idempotent *bool `json:"idempotent"` + AppliedCertificateCount string `json:"appliedCertificateCount"` + TrustHead nativeTBTCSignerStateAnchorTrustHeadWire `json:"trustHead"` + CurrentCheckpoint nativeTBTCSignerStateAnchorCheckpointWire `json:"currentCheckpoint"` + WitnessBaseCheckpoint nativeTBTCSignerStateAnchorCheckpointWire `json:"witnessBaseCheckpoint"` + CurrentAnchorReference nativeTBTCSignerStateAnchorTrustReferenceWire `json:"currentAnchorReference"` +} + +func DecodeNativeTBTCSignerStateAnchorTrustHead( + payload []byte, +) (*NativeTBTCSignerStateAnchorTrustHead, error) { + wire := &nativeTBTCSignerStateAnchorTrustHeadWire{} + if err := decodeStrictNativeTBTCSignerJSON( + payload, + wire, + "state-anchor trust head", + ); err != nil { + return nil, err + } + return decodeNativeTBTCSignerStateAnchorTrustHeadWire(wire) +} + +func decodeNativeTBTCSignerStateAnchorTrustHeadWire( + wire *nativeTBTCSignerStateAnchorTrustHeadWire, +) (*NativeTBTCSignerStateAnchorTrustHead, error) { + if wire == nil || wire.Schema != NativeTBTCSignerStateAnchorTrustHeadSchema { + return nil, fmt.Errorf("unsupported native signer state-anchor trust-head schema") + } + result := &NativeTBTCSignerStateAnchorTrustHead{Schema: wire.Schema} + decimalFields := []struct { + label string + encoded string + destination *uint64 + }{ + {"certificate sequence", wire.CertificateSequence, &result.CertificateSequence}, + {"activation manifest sequence", wire.ActivationManifestSequence, &result.ActivationManifestSequence}, + {"service epoch", wire.ServiceEpoch, &result.ServiceEpoch}, + {"witness maximum records", wire.WitnessMaximumRecords, &result.WitnessMaximumRecords}, + {"witness rotation threshold", wire.WitnessRotationThresholdRecords, &result.WitnessRotationThresholdRecords}, + } + for _, field := range decimalFields { + decoded, err := decodeNativeTBTCSignerCanonicalUint64(field.encoded) + if err != nil { + return nil, fmt.Errorf("invalid trust-head %s: %w", field.label, err) + } + *field.destination = decoded + } + bytes32Fields := []struct { + label string + encoded string + destination *[32]byte + }{ + {"certificate digest", wire.CertificateDigest, &result.CertificateDigest}, + {"activation manifest hash", wire.ActivationManifestHash, &result.ActivationManifestHash}, + {"binding hash", wire.BindingHash, &result.BindingHash}, + {"response public key SPKI hash", wire.ResponsePublicKeySPKISHA256, &result.ResponsePublicKeySPKISHA256}, + {"offline authority SPKI hash", wire.OfflineAuthoritySPKISHA256, &result.OfflineAuthoritySPKISHA256}, + } + for _, field := range bytes32Fields { + decoded, err := decodeNativeTBTCSignerCanonicalBytes32(field.encoded, false) + if err != nil { + return nil, fmt.Errorf("invalid trust-head %s: %w", field.label, err) + } + *field.destination = decoded + } + floor, err := decodeNativeTBTCSignerStateAnchorTrustReference( + &wire.CertifiedFloor, + ) + if err != nil { + return nil, fmt.Errorf("invalid trust-head certified floor: %w", err) + } + result.CertifiedFloor = floor + if result.CertificateSequence == 0 || + result.ActivationManifestSequence == 0 || + result.ServiceEpoch == 0 || + result.ServiceEpoch != result.CertifiedFloor.ServiceEpoch || + result.CertifiedFloor.Revision != 1 || + result.WitnessMaximumRecords < 2 || + result.WitnessMaximumRecords > 1_000_000 || + result.WitnessRotationThresholdRecords < 2 || + result.WitnessRotationThresholdRecords > + result.WitnessMaximumRecords-2 { + return nil, fmt.Errorf("native signer state-anchor trust head is incomplete") + } + return result, nil +} + +func DecodeNativeTBTCSignerStateAnchorTrustTransitionResult( + payload []byte, +) (*NativeTBTCSignerStateAnchorTrustTransitionResult, error) { + wire := &nativeTBTCSignerStateAnchorTrustTransitionResultWire{} + if err := decodeStrictNativeTBTCSignerJSON( + payload, + wire, + "state-anchor trust transition result", + ); err != nil { + return nil, err + } + if wire.Schema != NativeTBTCSignerStateAnchorTrustTransitionResultSchema || + wire.Installed == nil || wire.Idempotent == nil { + return nil, fmt.Errorf( + "native signer state-anchor trust transition result is incomplete", + ) + } + applied, err := decodeNativeTBTCSignerCanonicalUint64( + wire.AppliedCertificateCount, + ) + if err != nil || applied > 64 { + return nil, fmt.Errorf( + "invalid native signer applied trust-certificate count", + ) + } + head, err := decodeNativeTBTCSignerStateAnchorTrustHeadWire(&wire.TrustHead) + if err != nil { + return nil, err + } + current, err := decodeNativeTBTCSignerStateAnchorCheckpoint( + &wire.CurrentCheckpoint, + ) + if err != nil { + return nil, fmt.Errorf("invalid transition current checkpoint: %w", err) + } + base, err := decodeNativeTBTCSignerStateAnchorCheckpoint( + &wire.WitnessBaseCheckpoint, + ) + if err != nil { + return nil, fmt.Errorf("invalid transition witness-base checkpoint: %w", err) + } + anchor, err := decodeNativeTBTCSignerStateAnchorTrustReference( + &wire.CurrentAnchorReference, + ) + if err != nil { + return nil, fmt.Errorf("invalid transition current anchor reference: %w", err) + } + result := &NativeTBTCSignerStateAnchorTrustTransitionResult{ + Schema: wire.Schema, + Installed: *wire.Installed, + Idempotent: *wire.Idempotent, + AppliedCertificateCount: applied, + TrustHead: *head, + CurrentCheckpoint: current, + WitnessBaseCheckpoint: base, + CurrentAnchorReference: anchor, + } + if !result.Installed || + (result.Idempotent && result.AppliedCertificateCount != 0) || + result.WitnessBaseCheckpoint.StoreFingerprint != + result.CurrentCheckpoint.StoreFingerprint || + result.WitnessBaseCheckpoint.Generation > + result.CurrentCheckpoint.Generation || + result.CurrentAnchorReference.Checkpoint != result.CurrentCheckpoint || + result.CurrentAnchorReference.ServiceEpoch != result.TrustHead.ServiceEpoch || + result.CurrentAnchorReference.Revision < + result.TrustHead.CertifiedFloor.Revision { + return nil, fmt.Errorf( + "native signer state-anchor trust transition readback is inconsistent", + ) + } + return result, nil +} + +func decodeNativeTBTCSignerStateAnchorTrustRecoveryRequired( + wire *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire, +) (*NativeTBTCSignerStateAnchorTrustRecoveryRequired, error) { + if wire == nil || + wire.Schema != + NativeTBTCSignerStateAnchorTrustRecoveryRequiredSchema { + return nil, fmt.Errorf( + "unsupported native signer state-anchor trust-recovery schema", + ) + } + result := &NativeTBTCSignerStateAnchorTrustRecoveryRequired{ + Schema: wire.Schema, + } + decimalFields := []struct { + encoded string + destination *uint64 + }{ + {wire.CertificateCount, &result.CertificateCount}, + {wire.FirstCertificateSequence, &result.FirstCertificateSequence}, + {wire.FinalCertificateSequence, &result.FinalCertificateSequence}, + {wire.TargetServiceEpoch, &result.TargetServiceEpoch}, + {wire.TargetRevision, &result.TargetRevision}, + } + for _, field := range decimalFields { + value, err := decodeNativeTBTCSignerCanonicalUint64(field.encoded) + if err != nil || value == 0 { + return nil, fmt.Errorf( + "native signer state-anchor trust-recovery decimal field is invalid", + ) + } + *field.destination = value + } + bytes32Fields := []struct { + encoded string + destination *[32]byte + }{ + {wire.StoreFingerprint, &result.StoreFingerprint}, + {wire.FinalCertificateDigest, &result.FinalCertificateDigest}, + {wire.TargetBindingHash, &result.TargetBindingHash}, + } + for _, field := range bytes32Fields { + value, err := decodeNativeTBTCSignerCanonicalBytes32( + field.encoded, + false, + ) + if err != nil { + return nil, fmt.Errorf( + "native signer state-anchor trust-recovery bytes32 field is invalid", + ) + } + *field.destination = value + } + if result.CertificateCount > + NativeTBTCSignerStateAnchorTrustTransitionMaximumCertificateCount || + uint64(len(wire.OrderedCertificateDigests)) != + result.CertificateCount || + result.FirstCertificateSequence > + ^uint64(0)-(result.CertificateCount-1) || + result.FinalCertificateSequence != + result.FirstCertificateSequence+result.CertificateCount-1 { + return nil, fmt.Errorf( + "native signer state-anchor trust-recovery certificate range is invalid", + ) + } + result.OrderedCertificateDigests = make( + [][32]byte, + len(wire.OrderedCertificateDigests), + ) + seenDigests := make(map[[32]byte]struct{}, len(wire.OrderedCertificateDigests)) + for index, encoded := range wire.OrderedCertificateDigests { + digest, err := decodeNativeTBTCSignerCanonicalBytes32(encoded, false) + if err != nil { + return nil, fmt.Errorf( + "native signer state-anchor trust-recovery certificate digest [%d] is invalid", + index, + ) + } + if _, exists := seenDigests[digest]; exists { + return nil, fmt.Errorf( + "native signer state-anchor trust-recovery certificate digests are not unique", + ) + } + seenDigests[digest] = struct{}{} + result.OrderedCertificateDigests[index] = digest + } + if result.OrderedCertificateDigests[len(result.OrderedCertificateDigests)-1] != + result.FinalCertificateDigest { + return nil, fmt.Errorf( + "native signer state-anchor trust-recovery final digest is inconsistent", + ) + } + checkpoint, err := decodeNativeTBTCSignerStateAnchorCheckpoint( + &wire.TargetCheckpoint, + ) + if err != nil { + return nil, fmt.Errorf( + "native signer state-anchor trust-recovery checkpoint is invalid: %w", + err, + ) + } + if checkpoint.StoreFingerprint != result.StoreFingerprint { + return nil, fmt.Errorf( + "native signer state-anchor trust-recovery checkpoint belongs to another store", + ) + } + result.TargetCheckpoint = checkpoint + return result, nil +} + +func decodeNativeTBTCSignerStateAnchorTrustReference( + wire *nativeTBTCSignerStateAnchorTrustReferenceWire, +) (NativeTBTCSignerStateAnchorTrustReference, error) { + result := NativeTBTCSignerStateAnchorTrustReference{} + if wire == nil { + return result, fmt.Errorf("reference is absent") + } + var err error + if result.ServiceEpoch, err = + decodeNativeTBTCSignerCanonicalUint64(wire.ServiceEpoch); err != nil { + return result, err + } + if result.Revision, err = + decodeNativeTBTCSignerCanonicalUint64(wire.Revision); err != nil { + return result, err + } + bytes32Fields := []struct { + encoded string + destination *[32]byte + allowZero bool + }{ + {wire.PreviousEventRoot, &result.PreviousEventRoot, true}, + {wire.EventRoot, &result.EventRoot, false}, + {wire.CheckpointAckDigest, &result.AcknowledgementDigest, false}, + } + for _, field := range bytes32Fields { + value, err := decodeNativeTBTCSignerCanonicalBytes32( + field.encoded, + field.allowZero, + ) + if err != nil { + return result, err + } + *field.destination = value + } + checkpoint, err := decodeNativeTBTCSignerStateAnchorCheckpoint( + &wire.Checkpoint, + ) + if err != nil { + return result, err + } + result.Checkpoint = checkpoint + if result.ServiceEpoch == 0 || result.Revision == 0 || + (result.Revision > 1 && result.PreviousEventRoot == [32]byte{}) { + return result, fmt.Errorf("reference audit identity is incomplete") + } + return result, nil +} + +func decodeNativeTBTCSignerStateAnchorCheckpoint( + wire *nativeTBTCSignerStateAnchorCheckpointWire, +) (NativeTBTCSignerStateAnchorCheckpoint, error) { + result := NativeTBTCSignerStateAnchorCheckpoint{} + if wire == nil { + return result, fmt.Errorf("checkpoint is absent") + } + generation, err := decodeNativeTBTCSignerCanonicalUint64(wire.Generation) + if err != nil || generation == 0 { + return result, fmt.Errorf("checkpoint generation is invalid") + } + result.Generation = generation + fields := []struct { + encoded string + destination *[32]byte + }{ + {wire.StoreFingerprint, &result.StoreFingerprint}, + {wire.PreviousStateCommitment, &result.PreviousStateCommitment}, + {wire.StateImageDigest, &result.StateImageDigest}, + {wire.StateCommitment, &result.StateCommitment}, + } + for _, field := range fields { + value, err := decodeNativeTBTCSignerCanonicalBytes32(field.encoded, false) + if err != nil { + return result, err + } + *field.destination = value + } + computed := ComputeNativeTBTCSignerStateWitnessCommitment( + result.StoreFingerprint, + result.Generation, + result.PreviousStateCommitment, + result.StateImageDigest, + ) + if computed != result.StateCommitment { + return result, fmt.Errorf("checkpoint commitment mismatch") + } + return result, nil +} diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_cgo_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_cgo_test.go new file mode 100644 index 0000000000..72b2530e3f --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_cgo_test.go @@ -0,0 +1,54 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "errors" + "fmt" + "testing" +) + +func TestClassifyNativeTBTCSignerStateAnchorTrustHeadError(t *testing.T) { + absent := fmt.Errorf( + "%w: %w", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: nativeTBTCSignerStateAnchorTrustHeadAbsentCode, + Message: "ordinary trust journal is absent", + }, + ) + classified := + classifyNativeTBTCSignerStateAnchorTrustHeadError(absent) + if !errors.Is( + classified, + ErrNativeTBTCSignerStateAnchorTrustHeadAbsent, + ) { + t.Fatal("typed no-journal error was not classified") + } + + for _, code := range []string{ + "", + "state_anchor_trust_journal_corrupt", + "permission_denied", + } { + failure := fmt.Errorf( + "%w: %w", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: code, + Message: "terminal failure", + }, + ) + classified = + classifyNativeTBTCSignerStateAnchorTrustHeadError(failure) + if errors.Is( + classified, + ErrNativeTBTCSignerStateAnchorTrustHeadAbsent, + ) { + t.Fatalf("terminal error code [%s] was classified as absence", code) + } + if !errors.Is(classified, ErrNativeBridgeOperationFailed) { + t.Fatalf("terminal error code [%s] lost its original cause", code) + } + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go new file mode 100644 index 0000000000..37579e38ba --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go @@ -0,0 +1,171 @@ +package signing + +import ( + "encoding/hex" + "encoding/json" + "strings" + "testing" +) + +func TestDecodeNativeTBTCSignerStateAnchorTrustTransitionResult(t *testing.T) { + checkpoint := testNativeTBTCSignerStateAnchorCheckpointWire(9, 0x11) + base := testNativeTBTCSignerStateAnchorCheckpointWire(8, 0x21) + reference := nativeTBTCSignerStateAnchorTrustReferenceWire{ + ServiceEpoch: "3", + Revision: "1", + PreviousEventRoot: testNativeTBTCSignerTrustHex32(0x31), + EventRoot: testNativeTBTCSignerTrustHex32(0x32), + CheckpointAckDigest: testNativeTBTCSignerTrustHex32(0x33), + Checkpoint: checkpoint, + } + head := nativeTBTCSignerStateAnchorTrustHeadWire{ + Schema: NativeTBTCSignerStateAnchorTrustHeadSchema, + CertificateSequence: "3", + CertificateDigest: testNativeTBTCSignerTrustHex32(0x41), + ActivationManifestSequence: "5", + ActivationManifestHash: testNativeTBTCSignerTrustHex32(0x42), + BindingHash: testNativeTBTCSignerTrustHex32(0x43), + ResponsePublicKeySPKISHA256: testNativeTBTCSignerTrustHex32(0x44), + OfflineAuthoritySPKISHA256: testNativeTBTCSignerTrustHex32(0x45), + ServiceEpoch: "3", + CertifiedFloor: reference, + WitnessMaximumRecords: "4096", + WitnessRotationThresholdRecords: "1024", + } + wire := nativeTBTCSignerStateAnchorTrustTransitionResultWire{ + Schema: NativeTBTCSignerStateAnchorTrustTransitionResultSchema, + Installed: testNativeTBTCSignerTrustBool(true), + Idempotent: testNativeTBTCSignerTrustBool(false), + AppliedCertificateCount: "2", + TrustHead: head, + CurrentCheckpoint: checkpoint, + WitnessBaseCheckpoint: base, + CurrentAnchorReference: reference, + } + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + result, err := DecodeNativeTBTCSignerStateAnchorTrustTransitionResult(payload) + if err != nil { + t.Fatalf("valid trust transition result was rejected: %v", err) + } + if !result.Installed || result.Idempotent || + result.AppliedCertificateCount != 2 || + result.TrustHead.CertificateSequence != 3 || + result.CurrentCheckpoint.Generation != 9 || + result.WitnessBaseCheckpoint.Generation != 8 || + result.CurrentAnchorReference.PreviousEventRoot[0] != 0x31 { + t.Fatalf("unexpected trust transition result: %+v", result) + } +} + +func TestDecodeNativeTBTCSignerStateAnchorTrustRejectsUnknownAndInconsistent( + t *testing.T, +) { + checkpoint := testNativeTBTCSignerStateAnchorCheckpointWire(9, 0x11) + reference := nativeTBTCSignerStateAnchorTrustReferenceWire{ + ServiceEpoch: "3", + Revision: "1", + PreviousEventRoot: testNativeTBTCSignerTrustHex32(0x31), + EventRoot: testNativeTBTCSignerTrustHex32(0x32), + CheckpointAckDigest: testNativeTBTCSignerTrustHex32(0x33), + Checkpoint: checkpoint, + } + head := nativeTBTCSignerStateAnchorTrustHeadWire{ + Schema: NativeTBTCSignerStateAnchorTrustHeadSchema, + CertificateSequence: "3", + CertificateDigest: testNativeTBTCSignerTrustHex32(0x41), + ActivationManifestSequence: "5", + ActivationManifestHash: testNativeTBTCSignerTrustHex32(0x42), + BindingHash: testNativeTBTCSignerTrustHex32(0x43), + ResponsePublicKeySPKISHA256: testNativeTBTCSignerTrustHex32(0x44), + OfflineAuthoritySPKISHA256: testNativeTBTCSignerTrustHex32(0x45), + ServiceEpoch: "3", + CertifiedFloor: reference, + WitnessMaximumRecords: "4096", + WitnessRotationThresholdRecords: "1024", + } + payload, err := json.Marshal(head) + if err != nil { + t.Fatal(err) + } + unknown := []byte(strings.Replace( + string(payload), + `"certificateSequence"`, + `"unknown":"x","certificateSequence"`, + 1, + )) + if _, err := DecodeNativeTBTCSignerStateAnchorTrustHead(unknown); err == nil { + t.Fatal("trust head with an unknown field was accepted") + } + + head.ServiceEpoch = "4" + payload, err = json.Marshal(head) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeNativeTBTCSignerStateAnchorTrustHead(payload); err == nil { + t.Fatal("trust head whose service epoch differs from its floor was accepted") + } + + head.ServiceEpoch = "3" + head.CertifiedFloor.Revision = "2" + payload, err = json.Marshal(head) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeNativeTBTCSignerStateAnchorTrustHead(payload); err == nil { + t.Fatal("trust head whose certified floor is not revision one was accepted") + } +} + +func testNativeTBTCSignerStateAnchorCheckpointWire( + generation uint64, + seed byte, +) nativeTBTCSignerStateAnchorCheckpointWire { + store := [32]byte{0x77} + previous := [32]byte{seed} + image := [32]byte{seed + 1} + commitment := ComputeNativeTBTCSignerStateWitnessCommitment( + store, + generation, + previous, + image, + ) + return nativeTBTCSignerStateAnchorCheckpointWire{ + StoreFingerprint: testNativeTBTCSignerTrustHexValue(store), + Generation: uint64ToCanonicalString(generation), + PreviousStateCommitment: testNativeTBTCSignerTrustHexValue(previous), + StateImageDigest: testNativeTBTCSignerTrustHexValue(image), + StateCommitment: testNativeTBTCSignerTrustHexValue(commitment), + } +} + +func testNativeTBTCSignerTrustHex32(first byte) string { + return testNativeTBTCSignerTrustHexValue([32]byte{first}) +} + +func testNativeTBTCSignerTrustHexValue(value [32]byte) string { + return "0x" + hex.EncodeToString(value[:]) +} + +func testNativeTBTCSignerTrustBool(value bool) *bool { + return &value +} + +func uint64ToCanonicalString(value uint64) string { + // Keep this test helper independent from the decoder under test. + const digits = "0123456789" + if value == 0 { + return "0" + } + var buffer [20]byte + index := len(buffer) + for value > 0 { + index-- + buffer[index] = digits[value%10] + value /= 10 + } + return string(buffer[index:]) +} diff --git a/pkg/frost/signing/roast_runner_interactive_aggregate_memo_default.go b/pkg/frost/signing/roast_runner_interactive_aggregate_memo_default.go new file mode 100644 index 0000000000..9e5a5106cf --- /dev/null +++ b/pkg/frost/signing/roast_runner_interactive_aggregate_memo_default.go @@ -0,0 +1,15 @@ +//go:build !frost_native + +package signing + +// InteractiveAggregateMemoSession is a build-safe no-op in binaries that do +// not include the native interactive signing engine. +type InteractiveAggregateMemoSession struct{} + +func BeginInteractiveAggregateMemoSession( + sessionID string, +) (*InteractiveAggregateMemoSession, error) { + return &InteractiveAggregateMemoSession{}, nil +} + +func (session *InteractiveAggregateMemoSession) Release() {} diff --git a/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native.go b/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native.go index 50cd38dc48..4c82469031 100644 --- a/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native.go +++ b/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native.go @@ -3,31 +3,104 @@ package signing import ( + "fmt" + "strings" "sync" - "time" ) -// interactiveAggregateMemoTTL bounds how long a memoized aggregate result is -// retained. It only needs to outlive the window in which a single signing -// attempt's local seats race to aggregate (bounded by the attempt timeout, tens -// of seconds), so a few minutes is safely conservative while keeping the memo -// from growing over the node's lifetime. -const interactiveAggregateMemoTTL = 5 * time.Minute - type interactiveAggregateEntry struct { once sync.Once signature []byte err error + owner *InteractiveAggregateMemoSession +} + +// InteractiveAggregateMemoSession owns every per-attempt aggregate result for +// one outer tBTC signing operation. The tBTC executor releases it only after +// all local-seat goroutines have joined, so no wall-clock eviction can cause a +// straggling seat to repeat the native Aggregate call. +type InteractiveAggregateMemoSession struct { + sessionID string + release sync.Once } var ( - interactiveAggregateMemoMu sync.Mutex - interactiveAggregateMemo = map[string]*interactiveAggregateEntry{} + interactiveAggregateMemoMu sync.Mutex + interactiveAggregateMemo = map[string]*interactiveAggregateEntry{} + interactiveAggregateMemoSessions = map[string]*InteractiveAggregateMemoSession{} ) -// aggregateInteractiveOnce runs aggregate AT MOST ONCE per key within this -// process and returns the same (signature, error) to every caller sharing that -// key. +// BeginInteractiveAggregateMemoSession binds aggregate memo lifetime to one +// outer signing operation. A duplicate live session is rejected rather than +// sharing state across independent operation owners. +func BeginInteractiveAggregateMemoSession( + sessionID string, +) (*InteractiveAggregateMemoSession, error) { + if sessionID == "" || strings.Contains(sessionID, "|") { + return nil, fmt.Errorf("interactive aggregate memo session ID is invalid") + } + + interactiveAggregateMemoMu.Lock() + defer interactiveAggregateMemoMu.Unlock() + + if _, exists := interactiveAggregateMemoSessions[sessionID]; exists { + return nil, fmt.Errorf( + "interactive aggregate memo session [%s] is already active", + sessionID, + ) + } + prefix := sessionID + "|" + for key := range interactiveAggregateMemo { + if strings.HasPrefix(key, prefix) { + return nil, fmt.Errorf( + "interactive aggregate memo session [%s] has unowned stale entries", + sessionID, + ) + } + } + + session := &InteractiveAggregateMemoSession{sessionID: sessionID} + interactiveAggregateMemoSessions[sessionID] = session + return session, nil +} + +// Release deletes exactly the entries owned by this session generation. The +// pointer identity guard prevents a delayed stale cleanup from deleting a +// newer operation that happens to reuse the same textual session ID. +func (session *InteractiveAggregateMemoSession) Release() { + if session == nil { + return + } + session.release.Do(func() { + releaseInteractiveAggregateMemoSession(session) + }) +} + +func releaseInteractiveAggregateMemoSession( + session *InteractiveAggregateMemoSession, +) { + if session == nil { + return + } + + interactiveAggregateMemoMu.Lock() + defer interactiveAggregateMemoMu.Unlock() + + if interactiveAggregateMemoSessions[session.sessionID] != session { + return + } + delete(interactiveAggregateMemoSessions, session.sessionID) + prefix := session.sessionID + "|" + for key, entry := range interactiveAggregateMemo { + if strings.HasPrefix(key, prefix) && entry.owner == session { + delete(interactiveAggregateMemo, key) + } + } +} + +// aggregateInteractiveOnce runs aggregate AT MOST ONCE per key for the active +// outer session and returns the same (signature, error) to every caller sharing +// that key. // // Why it exists: a multi-seat operator runs one interactiveSigningRunner // goroutine per LOCAL seat, and they all drive the SAME per-process interactive @@ -51,20 +124,18 @@ func aggregateInteractiveOnce( aggregate func() ([]byte, error), ) ([]byte, error) { interactiveAggregateMemoMu.Lock() + sessionID, _, hasAttemptSeparator := strings.Cut(key, "|") + owner := interactiveAggregateMemoSessions[sessionID] entry, ok := interactiveAggregateMemo[key] if !ok { - entry = &interactiveAggregateEntry{} + entry = &interactiveAggregateEntry{owner: owner} interactiveAggregateMemo[key] = entry - // Self-evict well after the attempt's signing window so the memo does not - // grow unbounded. Concurrent local seats share the entry via sync.Once long - // before this fires; a straggler that arrives after eviction simply - // re-aggregates (and, if the engine already consumed the marker, fails its - // own attempt into the existing retry path — never a wrong signature). - time.AfterFunc(interactiveAggregateMemoTTL, func() { - interactiveAggregateMemoMu.Lock() - delete(interactiveAggregateMemo, key) - interactiveAggregateMemoMu.Unlock() - }) + } else if hasAttemptSeparator && entry.owner != owner { + interactiveAggregateMemoMu.Unlock() + return nil, fmt.Errorf( + "interactive aggregate memo key [%s] belongs to another session owner", + key, + ) } interactiveAggregateMemoMu.Unlock() @@ -82,5 +153,7 @@ func aggregateInteractiveOnce( func ResetInteractiveAggregateMemoForTest() { interactiveAggregateMemoMu.Lock() interactiveAggregateMemo = map[string]*interactiveAggregateEntry{} + interactiveAggregateMemoSessions = + map[string]*InteractiveAggregateMemoSession{} interactiveAggregateMemoMu.Unlock() } diff --git a/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native_test.go b/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native_test.go index 8285077d5e..4536ca5b0e 100644 --- a/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native_test.go +++ b/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native_test.go @@ -124,3 +124,140 @@ func TestAggregateInteractiveOnce_ConcurrentCallersDedup(t *testing.T) { } } } + +func TestAggregateInteractiveOnce_LateSeatUsesOuterSessionLifetime(t *testing.T) { + ResetInteractiveAggregateMemoForTest() + t.Cleanup(ResetInteractiveAggregateMemoForTest) + + session, err := BeginInteractiveAggregateMemoSession("late-seat-session") + if err != nil { + t.Fatal(err) + } + defer session.Release() + + var calls int + first, err := aggregateInteractiveOnce( + "late-seat-session|attempt-1", + func() ([]byte, error) { + calls++ + return []byte("first"), nil + }, + ) + if err != nil { + t.Fatal(err) + } + + // This call represents a sibling seat arriving arbitrarily late, including + // after the former five-minute timer. Memo lifetime is now tied only to the + // outer executor join, so elapsed wall time cannot evict the result. + late, err := aggregateInteractiveOnce( + "late-seat-session|attempt-1", + func() ([]byte, error) { + calls++ + return []byte("late"), nil + }, + ) + if err != nil { + t.Fatal(err) + } + if calls != 1 || string(first) != "first" || string(late) != "first" { + t.Fatalf( + "late sibling repeated aggregate [calls %d first %q late %q]", + calls, + first, + late, + ) + } +} + +func TestInteractiveAggregateMemoSessionCleanupIsExactAndIdentityGuarded( + t *testing.T, +) { + ResetInteractiveAggregateMemoForTest() + t.Cleanup(ResetInteractiveAggregateMemoForTest) + + firstA, err := BeginInteractiveAggregateMemoSession("session-a") + if err != nil { + t.Fatal(err) + } + sessionB, err := BeginInteractiveAggregateMemoSession("session-a-long") + if err != nil { + t.Fatal(err) + } + defer sessionB.Release() + + var callsA int + var callsB int + if _, err := aggregateInteractiveOnce( + "session-a|attempt-1", + func() ([]byte, error) { + callsA++ + return []byte("a-1"), nil + }, + ); err != nil { + t.Fatal(err) + } + if _, err := aggregateInteractiveOnce( + "session-a-long|attempt-1", + func() ([]byte, error) { + callsB++ + return []byte("b-1"), nil + }, + ); err != nil { + t.Fatal(err) + } + + firstA.Release() + if result, err := aggregateInteractiveOnce( + "session-a-long|attempt-1", + func() ([]byte, error) { + callsB++ + return []byte("b-2"), nil + }, + ); err != nil || string(result) != "b-1" || callsB != 1 { + t.Fatalf( + "session-a cleanup crossed into another session [result %q calls %d err %v]", + result, + callsB, + err, + ) + } + + secondA, err := BeginInteractiveAggregateMemoSession("session-a") + if err != nil { + t.Fatal(err) + } + defer secondA.Release() + if result, err := aggregateInteractiveOnce( + "session-a|attempt-1", + func() ([]byte, error) { + callsA++ + return []byte("a-2"), nil + }, + ); err != nil || string(result) != "a-2" || callsA != 2 { + t.Fatalf( + "new session did not receive a fresh memo [result %q calls %d err %v]", + result, + callsA, + err, + ) + } + + // Simulate a delayed cleanup callback from the prior textual session. Its + // owner identity must not delete secondA's entry. + releaseInteractiveAggregateMemoSession(firstA) + if result, err := aggregateInteractiveOnce( + "session-a|attempt-1", + func() ([]byte, error) { + callsA++ + return []byte("a-3"), nil + }, + ); err != nil || string(result) != "a-2" || callsA != 2 { + t.Fatalf( + "stale cleanup deleted a newer session [result %q calls %d err %v]", + result, + callsA, + err, + ) + } +} diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index e310749536..5bfcad682c 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -27,8 +27,9 @@ import ( ) const ( - frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v2" - frostActivationInventorySchema = "tbtc-p2tr-frost-wallet-group-inventory/v1" + frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v3" + frostActivationInventorySchema = "tbtc-p2tr-frost-wallet-group-inventory/v1" + frostActivationHandshakeSignatureDomain = "tbtc-p2tr-production-activation-handshake-signature/v3\x00" ) type frostActivationEthereumPoint struct { @@ -86,17 +87,26 @@ type frostActivationQuarantineJournalState struct { } type frostActivationNativeSignerState struct { - Schema string `json:"schema"` - StoreFingerprint string `json:"storeFingerprint"` - StateGeneration uint64 `json:"stateGeneration"` - StateCommitment string `json:"stateCommitment"` - PreviousStateCommitment string `json:"previousStateCommitment"` - StateImageDigest string `json:"stateImageDigest"` - InventoryCommitment string `json:"inventoryCommitment"` - RetainedWalletCount uint64 `json:"retainedWalletCount"` - RetainedKeyPackageCount uint64 `json:"retainedKeyPackageCount"` - ExternalRollbackAnchorBound bool `json:"externalRollbackAnchorBound"` - Complete bool `json:"complete"` + Schema string `json:"schema"` + StoreFingerprint string `json:"storeFingerprint"` + StateGeneration uint64 `json:"stateGeneration"` + StateCommitment string `json:"stateCommitment"` + PreviousStateCommitment string `json:"previousStateCommitment"` + StateImageDigest string `json:"stateImageDigest"` + InventoryCommitment string `json:"inventoryCommitment"` + RetainedWalletCount uint64 `json:"retainedWalletCount"` + RetainedKeyPackageCount uint64 `json:"retainedKeyPackageCount"` + ExternalRollbackAnchorBound bool `json:"externalRollbackAnchorBound"` + TrustCertificateSequence uint64 `json:"trustCertificateSequence"` + TrustCertificateDigest string `json:"trustCertificateDigest"` + AnchorServiceEpoch uint64 `json:"anchorServiceEpoch"` + CertifiedFloorRevision uint64 `json:"certifiedFloorRevision"` + CertifiedFloorGeneration uint64 `json:"certifiedFloorGeneration"` + CurrentAnchorRevision uint64 `json:"currentAnchorRevision"` + RestartableRevisionHeadroom uint64 `json:"restartableRevisionHeadroom"` + RestartableGenerationHeadroom uint64 `json:"restartableGenerationHeadroom"` + AnchorRotationWarning bool `json:"anchorRotationWarning"` + Complete bool `json:"complete"` } type frostActivationHandshakeState struct { @@ -124,6 +134,7 @@ type frostActivationHandshakeState struct { } type frostActivationHandshakePayload struct { + Schema string `json:"schema"` Kind string `json:"kind"` Nonce string `json:"nonce"` ManifestHash string `json:"manifestHash"` @@ -405,9 +416,35 @@ func (fahe *frostActivationHandshakeExporter) attest( if journalSnapshot == nil || nativeSignerSnapshot == nil { return nil, fmt.Errorf("production FROST signer readiness snapshot is incomplete") } - if !nativeSignerSnapshot.ExternalRollbackAnchorBound { + if !nativeSignerSnapshot.ExternalRollbackAnchorBound || + nativeSignerSnapshot.TrustCertificateSequence == 0 || + nativeSignerSnapshot.TrustCertificateDigest == [32]byte{} || + nativeSignerSnapshot.AnchorServiceEpoch == 0 || + nativeSignerSnapshot.CertifiedFloorRevision == 0 || + nativeSignerSnapshot.CertifiedFloorGeneration == 0 || + nativeSignerSnapshot.CurrentAnchorRevision < + nativeSignerSnapshot.CertifiedFloorRevision || + nativeSignerSnapshot.RestartableRevisionHeadroom == 0 || + nativeSignerSnapshot.StateGeneration < + nativeSignerSnapshot.CertifiedFloorGeneration || + nativeSignerSnapshot.RestartableGenerationHeadroom == 0 || + nativeSignerSnapshot.CurrentAnchorRevision- + nativeSignerSnapshot.CertifiedFloorRevision+ + nativeSignerSnapshot.RestartableRevisionHeadroom != + FrostNativeSignerAnchorMaximumHistoryEvents || + nativeSignerSnapshot.StateGeneration- + nativeSignerSnapshot.CertifiedFloorGeneration+ + nativeSignerSnapshot.RestartableGenerationHeadroom != + FrostNativeSignerAnchorMaximumHistoryProofEntries || + nativeSignerSnapshot.AnchorRotationWarning != + frostNativeSignerAnchorRotationWarning( + minFrostNativeSignerAnchorHeadroom( + nativeSignerSnapshot.RestartableRevisionHeadroom, + nativeSignerSnapshot.RestartableGenerationHeadroom, + ), + ) { return nil, fmt.Errorf( - "native signer state lacks an authenticated external rollback anchor", + "native signer state lacks an authenticated external rollback anchor trust certificate", ) } journalManifest := fahe.manifest.CanonicalJournal @@ -493,17 +530,26 @@ func (fahe *frostActivationHandshakeExporter) attest( Complete: true, }, NativeSignerState: frostActivationNativeSignerState{ - Schema: nativeSignerSnapshot.Schema, - StoreFingerprint: frostActivationHex32(nativeSignerSnapshot.StoreFingerprint), - StateGeneration: nativeSignerSnapshot.StateGeneration, - StateCommitment: frostActivationHex32(nativeSignerSnapshot.StateCommitment), - PreviousStateCommitment: frostActivationHex32(nativeSignerSnapshot.PreviousStateCommitment), - StateImageDigest: frostActivationHex32(nativeSignerSnapshot.StateImageDigest), - InventoryCommitment: frostActivationHex32(nativeSignerSnapshot.InventoryCommitment), - RetainedWalletCount: nativeSignerSnapshot.WalletCount, - RetainedKeyPackageCount: nativeSignerSnapshot.KeyPackageCount, - ExternalRollbackAnchorBound: nativeSignerSnapshot.ExternalRollbackAnchorBound, - Complete: true, + Schema: nativeSignerSnapshot.Schema, + StoreFingerprint: frostActivationHex32(nativeSignerSnapshot.StoreFingerprint), + StateGeneration: nativeSignerSnapshot.StateGeneration, + StateCommitment: frostActivationHex32(nativeSignerSnapshot.StateCommitment), + PreviousStateCommitment: frostActivationHex32(nativeSignerSnapshot.PreviousStateCommitment), + StateImageDigest: frostActivationHex32(nativeSignerSnapshot.StateImageDigest), + InventoryCommitment: frostActivationHex32(nativeSignerSnapshot.InventoryCommitment), + RetainedWalletCount: nativeSignerSnapshot.WalletCount, + RetainedKeyPackageCount: nativeSignerSnapshot.KeyPackageCount, + ExternalRollbackAnchorBound: nativeSignerSnapshot.ExternalRollbackAnchorBound, + TrustCertificateSequence: nativeSignerSnapshot.TrustCertificateSequence, + TrustCertificateDigest: frostActivationHex32(nativeSignerSnapshot.TrustCertificateDigest), + AnchorServiceEpoch: nativeSignerSnapshot.AnchorServiceEpoch, + CertifiedFloorRevision: nativeSignerSnapshot.CertifiedFloorRevision, + CertifiedFloorGeneration: nativeSignerSnapshot.CertifiedFloorGeneration, + CurrentAnchorRevision: nativeSignerSnapshot.CurrentAnchorRevision, + RestartableRevisionHeadroom: nativeSignerSnapshot.RestartableRevisionHeadroom, + RestartableGenerationHeadroom: nativeSignerSnapshot.RestartableGenerationHeadroom, + AnchorRotationWarning: nativeSignerSnapshot.AnchorRotationWarning, + Complete: true, }, InteractiveSigningReady: readinessSnapshot.InteractiveSigningReady, FinalizedReservationReadbackEnforced: true, @@ -514,24 +560,22 @@ func (fahe *frostActivationHandshakeExporter) attest( DurableBitcoinOutboxRecovered: outboxSnapshot.Recovered, QuarantineFailClosed: journalSnapshot.QuarantineCount == 0, } - state.Healthy = state.InteractiveSigningReady && - state.NonceShareGateEnforced && - state.DurableBitcoinOutboxRecovered && - state.QuarantineFailClosed && - state.NativeSignerState.Complete && - state.NativeSignerState.ExternalRollbackAnchorBound + state.Healthy = frostActivationHandshakeHealthy(state) payload := frostActivationHandshakePayload{ + Schema: frostActivationHandshakeSchema, Kind: "frost-signer", Nonce: request.Challenge.Nonce, ManifestHash: request.Challenge.ManifestHash, EthereumPoint: request.Challenge.EthereumPoint, State: state, } - canonicalPayload, err := canonicalFrostActivationValue(payload) + signatureTranscript, err := frostActivationHandshakeSignatureTranscript( + payload, + ) if err != nil { return nil, err } - signature := ed25519.Sign(fahe.privateKey, canonicalPayload) + signature := ed25519.Sign(fahe.privateKey, signatureTranscript) return &frostActivationSignedHandshake{ Payload: payload, SignerPublicKeySPKI: fahe.publicKeySPKI, @@ -539,6 +583,40 @@ func (fahe *frostActivationHandshakeExporter) attest( }, nil } +func frostActivationHandshakeHealthy( + state frostActivationHandshakeState, +) bool { + return state.InteractiveSigningReady && + state.NonceShareGateEnforced && + state.DurableBitcoinOutboxRecovered && + state.QuarantineFailClosed && + state.NativeSignerState.Complete && + state.NativeSignerState.ExternalRollbackAnchorBound && + !state.NativeSignerState.AnchorRotationWarning +} + +func frostActivationHandshakeSignatureTranscript( + payload frostActivationHandshakePayload, +) ([]byte, error) { + if payload.Schema != frostActivationHandshakeSchema { + return nil, fmt.Errorf( + "unsupported FROST activation handshake payload schema", + ) + } + canonicalPayload, err := canonicalFrostActivationValue(payload) + if err != nil { + return nil, err + } + result := make( + []byte, + 0, + len(frostActivationHandshakeSignatureDomain)+len(canonicalPayload), + ) + result = append(result, frostActivationHandshakeSignatureDomain...) + result = append(result, canonicalPayload...) + return result, nil +} + func decodeStrictFrostActivationJSON(data []byte, target interface{}) error { decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index 85f78cdcc1..a2f16fdf6d 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -78,6 +78,16 @@ func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSigner StateImageDigest: [32]byte{0x33}, InventoryCommitment: [32]byte{0x32}, ExternalRollbackAnchorBound: true, + TrustCertificateSequence: 3, + TrustCertificateDigest: [32]byte{0x34}, + AnchorServiceEpoch: 1, + CertifiedFloorRevision: 1, + CertifiedFloorGeneration: 1, + CurrentAnchorRevision: 1, + RestartableRevisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + RestartableGenerationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries - + 6, + AnchorRotationWarning: false, }, InteractiveSigningReady: true, }, nil @@ -219,8 +229,12 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { if err != nil { t.Fatal(err) } + signatureTranscript := append( + []byte(frostActivationHandshakeSignatureDomain), + canonicalPayload..., + ) signature, err := base64.StdEncoding.Strict().DecodeString(handshake.Signature) - if err != nil || !ed25519.Verify(publicKey, canonicalPayload, signature) { + if err != nil || !ed25519.Verify(publicKey, signatureTranscript, signature) { t.Fatal("handshake signature did not verify over canonical payload") } assertFrostActivationObjectKeys(t, handshake.Payload.State, []string{ @@ -247,9 +261,14 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { "protocolID", "root", "storeFingerprint", "storeID", }) assertFrostActivationObjectKeys(t, handshake.Payload.State.NativeSignerState, []string{ - "complete", "externalRollbackAnchorBound", "inventoryCommitment", + "anchorRotationWarning", "anchorServiceEpoch", "certifiedFloorGeneration", + "certifiedFloorRevision", + "complete", "currentAnchorRevision", "externalRollbackAnchorBound", "inventoryCommitment", "previousStateCommitment", "retainedKeyPackageCount", "retainedWalletCount", - "schema", "stateCommitment", "stateGeneration", "stateImageDigest", "storeFingerprint", + "restartableGenerationHeadroom", "restartableRevisionHeadroom", + "schema", "stateCommitment", "stateGeneration", + "stateImageDigest", "storeFingerprint", + "trustCertificateDigest", "trustCertificateSequence", }) } diff --git a/pkg/tbtc/frost_dkg_execution_frost_native.go b/pkg/tbtc/frost_dkg_execution_frost_native.go index 80bca245c5..02e3fe5840 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native.go @@ -162,6 +162,25 @@ func executeFrostDKGIfPossible( ) return } + if node.frostNativeSignerAnchorAdmission == nil { + dkgLogger.Errorf( + "FROST DKG is blocked: native signer anchor admission is unavailable", + ) + return + } + anchorReservation, err := + node.frostNativeSignerAnchorAdmission.reserveDKG( + dkgCtx, + uint64(len(localActiveMemberIndexes)), + ) + if err != nil { + dkgLogger.Errorf( + "FROST DKG is blocked by native signer anchor admission: [%v]", + err, + ) + return + } + defer anchorReservation.Release() tbtcSignerMemberIndexes, err := finalFrostDKGMemberIndexes( activeMemberIndexes, diff --git a/pkg/tbtc/frost_native_signer_anchor_admission.go b/pkg/tbtc/frost_native_signer_anchor_admission.go new file mode 100644 index 0000000000..38571bc6a7 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_admission.go @@ -0,0 +1,386 @@ +package tbtc + +import ( + "context" + "fmt" + "sync" +) + +const ( + // One request-taking interactive signer call can durably advance the Rust + // generation up to three times: repair/retire an InteractiveState pending + // snapshot (two writes in the sweep prologue), then persist the operation's + // own mutation. The process output barrier still advances the remote anchor + // revision only once for the call's final checkpoint. + frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall uint64 = 3 +) + +type frostNativeSignerAnchorCapacity struct { + Revisions uint64 + Generations uint64 +} + +// frostNativeSignerAnchorAdmissionController accounts for both independent +// restart bounds of every production workflow that may mutate native signer +// state after startup: +// +// - service revisions retained by the external anchor; and +// - Rust state generations retained by the witness proof window. +// +// Pre-sign authorization and native DKG share one controller, so concurrent +// workflows cannot consume either dimension already promised to admitted work. +// Reservations retain their full worst-case cost until workflow exit. Because +// current headroom already reflects consumed work, this intentionally +// double-counts in-flight mutations for later admissions and is conservative. +type frostNativeSignerAnchorAdmissionController struct { + mutex sync.Mutex + + anchorBinding *frostNativeSignerAnchorBinding + // readHeadroom is test-only injection. Production always authenticates the + // current tip through anchorBinding while holding mutex. + readHeadroom func(context.Context) (frostNativeSignerAnchorCapacity, error) + + reserved frostNativeSignerAnchorCapacity +} + +type frostNativeSignerAnchorRevisionReservation struct { + controller *frostNativeSignerAnchorAdmissionController + cost frostNativeSignerAnchorCapacity + release sync.Once +} + +func newFrostNativeSignerAnchorAdmissionController( + anchorBinding *frostNativeSignerAnchorBinding, +) (*frostNativeSignerAnchorAdmissionController, error) { + if anchorBinding == nil { + return nil, fmt.Errorf( + "FROST native signer anchor admission binding is nil", + ) + } + + return &frostNativeSignerAnchorAdmissionController{ + anchorBinding: anchorBinding, + }, nil +} + +// reservePreSign reserves the entire upper bound before backend authorization +// or native signing begins. The readiness snapshot was independently +// reconciled twice, but may be stale by the time this mutex is acquired. The +// authenticated current tip is therefore read under the admission lock and the +// smaller headroom in each dimension is authoritative. +func (controller *frostNativeSignerAnchorAdmissionController) reservePreSign( + ctx context.Context, + snapshot *frostProductionSignerReadinessSnapshot, + inputCount uint64, + localSeatCount uint64, + maximumSigningAttempts uint64, +) (*frostNativeSignerAnchorRevisionReservation, error) { + cost, err := frostPreSignMaximumAnchorCapacityCost( + inputCount, + localSeatCount, + maximumSigningAttempts, + ) + if err != nil { + return nil, err + } + if snapshot == nil || snapshot.Inventory == nil { + return nil, fmt.Errorf( + "FROST pre-sign anchor admission has no authenticated inventory snapshot", + ) + } + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + snapshot.Inventory, + ); err != nil { + return nil, err + } + snapshotHeadroom := frostNativeSignerAnchorCapacity{ + Revisions: snapshot.Inventory.RestartableRevisionHeadroom, + Generations: snapshot.Inventory.RestartableGenerationHeadroom, + } + + return controller.reserve( + "FROST pre-sign authorization", + cost, + func() (frostNativeSignerAnchorCapacity, error) { + current, err := controller.currentRestartableHeadroom(ctx) + if err != nil { + return frostNativeSignerAnchorCapacity{}, err + } + return frostNativeSignerAnchorCapacity{ + Revisions: minFrostNativeSignerAnchorHeadroom( + snapshotHeadroom.Revisions, + current.Revisions, + ), + Generations: minFrostNativeSignerAnchorHeadroom( + snapshotHeadroom.Generations, + current.Generations, + ), + }, nil + }, + ) +} + +// reserveDKG accounts for one request-taking persistence call per local seat. +// DKG has no interactive sweep prologue, so each call advances at most one +// service revision and one Rust generation. +func (controller *frostNativeSignerAnchorAdmissionController) reserveDKG( + ctx context.Context, + localSeatCount uint64, +) (*frostNativeSignerAnchorRevisionReservation, error) { + if localSeatCount == 0 { + return nil, fmt.Errorf( + "FROST native DKG anchor admission controls no local seats", + ) + } + cost := frostNativeSignerAnchorCapacity{ + Revisions: localSeatCount, + Generations: localSeatCount, + } + + return controller.reserve( + "FROST native DKG", + cost, + func() (frostNativeSignerAnchorCapacity, error) { + return controller.currentRestartableHeadroom(ctx) + }, + ) +} + +func (controller *frostNativeSignerAnchorAdmissionController) reserve( + workflow string, + cost frostNativeSignerAnchorCapacity, + readHeadroom func() (frostNativeSignerAnchorCapacity, error), +) (*frostNativeSignerAnchorRevisionReservation, error) { + if controller == nil || readHeadroom == nil || + cost.Revisions == 0 || cost.Generations == 0 { + return nil, fmt.Errorf( + "%s anchor admission dependencies are incomplete", + workflow, + ) + } + + controller.mutex.Lock() + defer controller.mutex.Unlock() + + headroom, err := readHeadroom() + if err != nil { + return nil, fmt.Errorf( + "cannot determine %s anchor headroom: [%w]", + workflow, + err, + ) + } + minimumHeadroom := minFrostNativeSignerAnchorHeadroom( + headroom.Revisions, + headroom.Generations, + ) + if minimumHeadroom <= FrostNativeSignerAnchorRotationWarningHeadroom { + return nil, fmt.Errorf( + "%s is blocked with revision/generation headroom [%d/%d]; "+ + "offline anchor rotation is required before admitting new work", + workflow, + headroom.Revisions, + headroom.Generations, + ) + } + if controller.reserved.Revisions > headroom.Revisions || + cost.Revisions > + headroom.Revisions-controller.reserved.Revisions { + return nil, fmt.Errorf( + "%s requires [%d] anchor revisions but only [%d] are unreserved", + workflow, + cost.Revisions, + headroom.Revisions-controller.reserved.Revisions, + ) + } + if controller.reserved.Generations > headroom.Generations || + cost.Generations > + headroom.Generations-controller.reserved.Generations { + return nil, fmt.Errorf( + "%s requires [%d] signer generations but only [%d] are unreserved", + workflow, + cost.Generations, + headroom.Generations-controller.reserved.Generations, + ) + } + + controller.reserved.Revisions += cost.Revisions + controller.reserved.Generations += cost.Generations + return &frostNativeSignerAnchorRevisionReservation{ + controller: controller, + cost: cost, + }, nil +} + +func (controller *frostNativeSignerAnchorAdmissionController) currentRestartableHeadroom( + ctx context.Context, +) (frostNativeSignerAnchorCapacity, error) { + if controller == nil || controller.anchorBinding == nil { + if controller != nil && controller.readHeadroom != nil { + return controller.readHeadroom(ctx) + } + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST native signer anchor admission binding is unavailable", + ) + } + if ctx == nil { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST native signer anchor admission context is nil", + ) + } + + tip, err := controller.anchorBinding.readTip() + if err != nil { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "cannot read native signer state tip: [%w]", + err, + ) + } + if tip == nil { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "native signer state tip is nil", + ) + } + if err := controller.anchorBinding.VerifyNativeTBTCSignerStateTip( + ctx, + *tip, + ); err != nil { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "cannot authenticate native signer state tip: [%w]", + err, + ) + } + + revisionHeadroom, err := + controller.anchorBinding.restartableRevisionHeadroom( + tip.AnchorServiceEpoch, + tip.AnchorRevision, + ) + if err != nil { + return frostNativeSignerAnchorCapacity{}, err + } + generationHeadroom, err := + controller.anchorBinding.restartableGenerationHeadroom( + tip.Generation, + ) + if err != nil { + return frostNativeSignerAnchorCapacity{}, err + } + return frostNativeSignerAnchorCapacity{ + Revisions: revisionHeadroom, + Generations: generationHeadroom, + }, nil +} + +func (reservation *frostNativeSignerAnchorRevisionReservation) Release() { + if reservation == nil { + return + } + + reservation.release.Do(func() { + controller := reservation.controller + if controller == nil { + return + } + + controller.mutex.Lock() + defer controller.mutex.Unlock() + + if reservation.cost.Revisions > controller.reserved.Revisions || + reservation.cost.Generations > controller.reserved.Generations { + panic("FROST native signer anchor reservation accounting underflow") + } + controller.reserved.Revisions -= reservation.cost.Revisions + controller.reserved.Generations -= reservation.cost.Generations + }) +} + +// frostPreSignMaximumAnchorCapacityCost freezes the upper bound for one +// authorized transaction. For every input: +// +// - BuildTaprootTx is one request-taking call; +// - every attempt lets each local seat call Open, Round1, Round2, and Abort; +// - Aggregate is process-memoized to one call per attempt. +// +// Each of those calls can advance one service revision when its sweep prologue +// mutates otherwise-unrelated state. Each interactive call can advance up to +// three Rust generations (two sweep/repair writes plus its own write), so the +// proof-window cost is three times the anchored-call count. Attempt derivation, +// package/evidence handling, and authorization guards do not persist Rust state. +func frostPreSignMaximumAnchorCapacityCost( + inputCount uint64, + localSeatCount uint64, + maximumSigningAttempts uint64, +) (frostNativeSignerAnchorCapacity, error) { + if inputCount == 0 || + inputCount > uint64(frostPreSignAuthorizationMaximumInputs) { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST pre-sign anchor admission input count [%d] is outside [1,%d]", + inputCount, + frostPreSignAuthorizationMaximumInputs, + ) + } + if localSeatCount == 0 || + localSeatCount > uint64(frostPreSignAuthorizationMaximumSeats) { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST pre-sign anchor admission local seat count [%d] is outside [1,%d]", + localSeatCount, + frostPreSignAuthorizationMaximumSeats, + ) + } + if maximumSigningAttempts == 0 { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST pre-sign anchor admission signing-attempt limit is zero", + ) + } + + maxUint64 := ^uint64(0) + if localSeatCount > (maxUint64-1)/4 { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST pre-sign anchored-call cost overflow", + ) + } + perAttemptCalls := 4*localSeatCount + 1 + if maximumSigningAttempts > (maxUint64-1)/perAttemptCalls { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST pre-sign anchored-call cost overflow", + ) + } + perInputCalls := uint64(1) + maximumSigningAttempts*perAttemptCalls + if inputCount > maxUint64/perInputCalls { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST pre-sign anchored-call cost overflow", + ) + } + revisions := inputCount * perInputCalls + if revisions > maxUint64/ + frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST pre-sign generation cost overflow", + ) + } + generations := + revisions * frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall + + if revisions > FrostNativeSignerAnchorMaximumHistoryEvents { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST pre-sign workflow requires [%d] anchor revisions, exceeding "+ + "the certified history window [%d]", + revisions, + FrostNativeSignerAnchorMaximumHistoryEvents, + ) + } + if generations > FrostNativeSignerAnchorMaximumHistoryProofEntries { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST pre-sign workflow requires [%d] signer generations, exceeding "+ + "the certified proof window [%d]", + generations, + FrostNativeSignerAnchorMaximumHistoryProofEntries, + ) + } + + return frostNativeSignerAnchorCapacity{ + Revisions: revisions, + Generations: generations, + }, nil +} diff --git a/pkg/tbtc/frost_native_signer_anchor_admission_test.go b/pkg/tbtc/frost_native_signer_anchor_admission_test.go new file mode 100644 index 0000000000..497deea9a8 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_admission_test.go @@ -0,0 +1,196 @@ +package tbtc + +import ( + "context" + "strings" + "testing" +) + +func TestFrostPreSignMaximumAnchorCapacityCost(t *testing.T) { + cost, err := frostPreSignMaximumAnchorCapacityCost( + frostPreSignAuthorizationMaximumInputs, + 1, + signingAttemptsLimit, + ) + if err != nil { + t.Fatal(err) + } + if cost.Revisions != 546 || cost.Generations != 1638 { + t.Fatalf("unexpected single-seat maximum cost [%+v]", cost) + } + if cost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents || + cost.Generations > FrostNativeSignerAnchorMaximumHistoryProofEntries { + t.Fatalf( + "single-seat maximum [%+v] exceeds restart windows [%d/%d]", + cost, + FrostNativeSignerAnchorMaximumHistoryEvents, + FrostNativeSignerAnchorMaximumHistoryProofEntries, + ) + } + + multiSeatCost, err := frostPreSignMaximumAnchorCapacityCost( + frostPreSignAuthorizationMaximumInputs, + 2, + signingAttemptsLimit, + ) + if err != nil { + t.Fatal(err) + } + if multiSeatCost.Revisions != 966 || + multiSeatCost.Generations != 2898 { + t.Fatalf("unexpected multi-seat maximum cost [%+v]", multiSeatCost) + } + + if _, err := frostPreSignMaximumAnchorCapacityCost( + frostPreSignAuthorizationMaximumInputs, + 3, + signingAttemptsLimit, + ); err == nil || !strings.Contains(err.Error(), "proof window") { + t.Fatalf("oversized workflow was accepted: [%v]", err) + } +} + +func TestFrostNativeSignerAnchorAdmissionController_AtomicallyReservesNearWarning( + t *testing.T, +) { + currentHeadroom := frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorRotationWarningHeadroom + 1, + Generations: FrostNativeSignerAnchorRotationWarningHeadroom + 1, + } + controller := &frostNativeSignerAnchorAdmissionController{ + readHeadroom: func( + context.Context, + ) (frostNativeSignerAnchorCapacity, error) { + return currentHeadroom, nil + }, + } + snapshot := testFrostAnchorAdmissionReadinessSnapshot( + FrostNativeSignerAnchorRotationWarningHeadroom+1, + FrostNativeSignerAnchorRotationWarningHeadroom+1, + ) + + // Three inputs and one local seat reserve 78 revisions and 234 + // generations. Admission one unit above the warning boundary is safe + // because the full dual-dimensional cost is reserved before the workflow. + first, err := controller.reservePreSign( + context.Background(), + snapshot, + 3, + 1, + signingAttemptsLimit, + ) + if err != nil { + t.Fatal(err) + } + defer first.Release() + + // A concurrent workflow cannot spend the revisions promised to the first, + // even though both observed the same pre-mutation readiness snapshot. + if _, err := controller.reservePreSign( + context.Background(), + snapshot, + 1, + 1, + signingAttemptsLimit, + ); err == nil || !strings.Contains(err.Error(), "unreserved") { + t.Fatalf("concurrent reservation exceeded headroom: [%v]", err) + } + + // Crossing into the warning band does not revoke the admitted reservation. + // Boundary revalidation uses ordinary readiness (which rejects only zero); + // only a fresh admission is rejected here. + currentHeadroom = frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorRotationWarningHeadroom, + Generations: FrostNativeSignerAnchorRotationWarningHeadroom, + } + warningSnapshot := testFrostAnchorAdmissionReadinessSnapshot( + FrostNativeSignerAnchorRotationWarningHeadroom, + FrostNativeSignerAnchorRotationWarningHeadroom, + ) + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + warningSnapshot.Inventory, + ); err != nil { + t.Fatalf("in-flight readiness rejected warning headroom: [%v]", err) + } + if _, err := controller.reservePreSign( + context.Background(), + warningSnapshot, + 1, + 1, + signingAttemptsLimit, + ); err == nil || !strings.Contains(err.Error(), "rotation") { + t.Fatalf("new work was admitted inside rotation reserve: [%v]", err) + } + + first.Release() + currentHeadroom = frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorRotationWarningHeadroom + 1, + Generations: FrostNativeSignerAnchorRotationWarningHeadroom + 1, + } + if _, err := controller.reservePreSign( + context.Background(), + snapshot, + 1, + 1, + signingAttemptsLimit, + ); err != nil { + t.Fatalf("released reservation remained charged: [%v]", err) + } +} + +func TestFrostNativeSignerAnchorAdmissionController_RejectsStaleReadinessHeadroom( + t *testing.T, +) { + currentHeadroom := frostNativeSignerAnchorCapacity{ + Revisions: 400, + Generations: 300, + } + controller := &frostNativeSignerAnchorAdmissionController{ + readHeadroom: func( + context.Context, + ) (frostNativeSignerAnchorCapacity, error) { + return currentHeadroom, nil + }, + } + staleSnapshot := testFrostAnchorAdmissionReadinessSnapshot(400, 400) + + // Four inputs and one seat cost 104 revisions and 312 generations. The + // stale snapshot would admit it, but the authenticated current tip has + // advanced after that snapshot and must be authoritative. + if _, err := controller.reservePreSign( + context.Background(), + staleSnapshot, + 4, + 1, + signingAttemptsLimit, + ); err == nil || !strings.Contains(err.Error(), "unreserved") { + t.Fatalf("stale readiness headroom was trusted: [%v]", err) + } +} + +func testFrostAnchorAdmissionReadinessSnapshot( + revisionHeadroom uint64, + generationHeadroom uint64, +) *frostProductionSignerReadinessSnapshot { + return &frostProductionSignerReadinessSnapshot{ + Inventory: &frostNativeSignerInventorySnapshot{ + CertifiedFloorRevision: 1, + CertifiedFloorGeneration: 1, + CurrentAnchorRevision: 1 + + FrostNativeSignerAnchorMaximumHistoryEvents - + revisionHeadroom, + StateGeneration: 1 + + FrostNativeSignerAnchorMaximumHistoryProofEntries - + generationHeadroom, + RestartableRevisionHeadroom: revisionHeadroom, + RestartableGenerationHeadroom: generationHeadroom, + AnchorRotationWarning: frostNativeSignerAnchorRotationWarning( + minFrostNativeSignerAnchorHeadroom( + revisionHeadroom, + generationHeadroom, + ), + ), + }, + InteractiveSigningReady: true, + } +} diff --git a/pkg/tbtc/frost_native_signer_anchor_binding.go b/pkg/tbtc/frost_native_signer_anchor_binding.go index 1a9d022173..68faf12a88 100644 --- a/pkg/tbtc/frost_native_signer_anchor_binding.go +++ b/pkg/tbtc/frost_native_signer_anchor_binding.go @@ -28,10 +28,11 @@ type frostNativeSignerStateWitnessRecoverer func( // they must never re-enter a request-taking signer operation while the global // signer barrier lock is held. type frostNativeSignerAnchorBinding struct { - store FrostNativeSignerStateWitnessAnchorStore - identity FrostNativeSignerAnchorIdentity - bindingHash [32]byte - floor FrostNativeSignerAnchorManifest + store FrostNativeSignerStateWitnessAnchorStore + identity FrostNativeSignerAnchorIdentity + bindingHash [32]byte + floor FrostNativeSignerStateWitnessAnchorReference + floorPreviousEventRoot [32]byte readTip frostNativeSignerStateWitnessTipReader readProof frostNativeSignerStateWitnessProofReader @@ -45,6 +46,8 @@ type frostNativeSignerAnchorBinding struct { func newFrostNativeSignerAnchorBinding( store FrostNativeSignerStateWitnessAnchorStore, manifest FrostNativeSignerAnchorManifest, + floor FrostNativeSignerStateWitnessAnchorReference, + floorPreviousEventRoot [32]byte, readTip frostNativeSignerStateWitnessTipReader, readProof frostNativeSignerStateWitnessProofReader, acknowledge frostNativeSignerStateWitnessAcknowledger, @@ -58,27 +61,27 @@ func newFrostNativeSignerAnchorBinding( if identity.StreamID != ComputeFrostNativeSignerAnchorStreamID(identity) { return nil, fmt.Errorf("FROST native signer anchor stream identity is invalid") } - if manifest.MinimumServiceEpoch == 0 || manifest.MinimumRevision == 0 || - manifest.MinimumEventRoot == [32]byte{} || - manifest.MinimumAcknowledgementDigest == [32]byte{} || - manifest.MinimumCheckpoint.StoreFingerprint != + if floor.ServiceEpoch == 0 || floor.Revision == 0 || + floor.EventRoot == [32]byte{} || + floor.AcknowledgementDigest == [32]byte{} || + floor.Checkpoint.StoreFingerprint != identity.SignerStoreFingerprint || manifest.WitnessMaximumRecords != identity.WitnessMaximumRecords || manifest.WitnessRotationThresholdRecords != identity.WitnessRotationThresholdRecords { - return nil, fmt.Errorf("FROST native signer anchor manifest floor is invalid") + return nil, fmt.Errorf("FROST native signer certified floor is invalid") } computedFloorCommitment := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( - manifest.MinimumCheckpoint.StoreFingerprint, - manifest.MinimumCheckpoint.Generation, - manifest.MinimumCheckpoint.PreviousStateCommitment, - manifest.MinimumCheckpoint.StateImageDigest, + floor.Checkpoint.StoreFingerprint, + floor.Checkpoint.Generation, + floor.Checkpoint.PreviousStateCommitment, + floor.Checkpoint.StateImageDigest, ) - if manifest.MinimumCheckpoint.Generation == 0 || - computedFloorCommitment != manifest.MinimumCheckpoint.StateCommitment { + if floor.Checkpoint.Generation == 0 || + computedFloorCommitment != floor.Checkpoint.StateCommitment { return nil, fmt.Errorf( - "FROST native signer anchor manifest checkpoint is invalid", + "FROST native signer certified checkpoint is invalid", ) } bindingHash := ComputeFrostNativeSignerAnchorBindingHash(identity) @@ -86,15 +89,16 @@ func newFrostNativeSignerAnchorBinding( return nil, fmt.Errorf("FROST native signer anchor binding hash is zero") } return &frostNativeSignerAnchorBinding{ - store: store, - identity: identity, - bindingHash: bindingHash, - floor: manifest, - readTip: readTip, - readProof: readProof, - acknowledge: acknowledge, - recover: recover, - now: time.Now, + store: store, + identity: identity, + bindingHash: bindingHash, + floor: floor, + floorPreviousEventRoot: floorPreviousEventRoot, + readTip: readTip, + readProof: readProof, + acknowledge: acknowledge, + recover: recover, + now: time.Now, }, nil } @@ -172,6 +176,26 @@ func (binding *frostNativeSignerAnchorBinding) reconcileStartup( // The local state advanced durably before its remote CAS completed. // Its anchor metadata must still be the exact authenticated remote // target observed immediately before that operation. + headroom, err := binding.restartableRevisionHeadroom( + remote.ServiceEpoch, + remote.Revision, + ) + if err != nil { + return nil, err + } + if headroom == 0 { + return nil, fmt.Errorf( + "startup native signer local-ahead state cannot cross the certified-floor history bound; offline anchor rotation is required", + ) + } + if _, err := binding.restartableGenerationHeadroom( + localCheckpoint.Generation, + ); err != nil { + return nil, fmt.Errorf( + "startup native signer local-ahead state exceeds the certified-floor proof bound: %w", + err, + ) + } if !binding.localTipHasAnchorReference( *local, frostNativeSignerAnchorReferenceFromRecord(remote), @@ -261,6 +285,26 @@ func (binding *frostNativeSignerAnchorBinding) CommitNativeTBTCSignerStateTransi if err := binding.validateLocalTip(&candidate); err != nil { return nil, fmt.Errorf("invalid candidate native signer tip: %w", err) } + headroom, err := binding.restartableRevisionHeadroom( + expected.AnchorServiceEpoch, + expected.AnchorRevision, + ) + if err != nil { + return nil, err + } + if headroom == 0 { + return nil, fmt.Errorf( + "native signer anchor certified-floor history bound is exhausted; offline anchor rotation is required", + ) + } + if _, err := binding.restartableGenerationHeadroom( + candidate.Generation, + ); err != nil { + return nil, fmt.Errorf( + "native signer candidate exceeds the certified-floor proof bound: %w", + err, + ) + } expectedCheckpoint := frostNativeSignerCheckpointFromTip(expected) candidateCheckpoint := frostNativeSignerCheckpointFromTip(candidate) proof, err := binding.collectProofLocked( @@ -563,8 +607,13 @@ func (binding *frostNativeSignerAnchorBinding) validateRemoteRecord( record.Checkpoint.StateCommitment == [32]byte{} || record.OperationID == [32]byte{} || record.TransitionDigest == [32]byte{} || - record.ServiceEpoch != binding.floor.MinimumServiceEpoch || - record.Revision < binding.floor.MinimumRevision || + record.ServiceEpoch != binding.floor.ServiceEpoch || + record.Revision < binding.floor.Revision || + record.Revision-binding.floor.Revision > + FrostNativeSignerAnchorMaximumHistoryEvents || + record.Checkpoint.Generation < binding.floor.Checkpoint.Generation || + record.Checkpoint.Generation-binding.floor.Checkpoint.Generation > + FrostNativeSignerAnchorMaximumHistoryProofEntries || record.EventRoot == [32]byte{} || record.AcknowledgementDigest == [32]byte{} || len(record.AcknowledgementJSON) == 0 { @@ -579,8 +628,18 @@ func (binding *frostNativeSignerAnchorBinding) validateRemoteRecord( if computed != record.Checkpoint.StateCommitment { return fmt.Errorf("authenticated native signer anchor checkpoint is invalid") } - if (record.Revision == 1 && record.PreviousEventRoot != [32]byte{}) || - (record.Revision > 1 && record.PreviousEventRoot == [32]byte{}) { + isCertifiedFloor := record.ServiceEpoch == binding.floor.ServiceEpoch && + record.Revision == binding.floor.Revision && + record.EventRoot == binding.floor.EventRoot && + record.AcknowledgementDigest == binding.floor.AcknowledgementDigest && + record.Checkpoint == binding.floor.Checkpoint + if (isCertifiedFloor && + record.PreviousEventRoot != binding.floorPreviousEventRoot) || + (!isCertifiedFloor && + ((record.Revision == 1 && + record.PreviousEventRoot != [32]byte{}) || + (record.Revision > 1 && + record.PreviousEventRoot == [32]byte{}))) { return fmt.Errorf( "authenticated native signer anchor event predecessor is invalid", ) @@ -588,6 +647,43 @@ func (binding *frostNativeSignerAnchorBinding) validateRemoteRecord( return nil } +func (binding *frostNativeSignerAnchorBinding) restartableRevisionHeadroom( + serviceEpoch uint64, + revision uint64, +) (uint64, error) { + if binding == nil || serviceEpoch != binding.floor.ServiceEpoch || + revision < binding.floor.Revision { + return 0, fmt.Errorf( + "native signer anchor reference is outside its certified service-epoch floor", + ) + } + distance := revision - binding.floor.Revision + if distance > FrostNativeSignerAnchorMaximumHistoryEvents { + return 0, fmt.Errorf( + "native signer anchor reference exceeds the restartable certified-floor history bound", + ) + } + return FrostNativeSignerAnchorMaximumHistoryEvents - distance, nil +} + +func (binding *frostNativeSignerAnchorBinding) restartableGenerationHeadroom( + generation uint64, +) (uint64, error) { + if binding == nil || + generation < binding.floor.Checkpoint.Generation { + return 0, fmt.Errorf( + "native signer state generation is below its certified checkpoint floor", + ) + } + distance := generation - binding.floor.Checkpoint.Generation + if distance > FrostNativeSignerAnchorMaximumHistoryProofEntries { + return 0, fmt.Errorf( + "native signer state generation exceeds the restartable certified-floor proof bound", + ) + } + return FrostNativeSignerAnchorMaximumHistoryProofEntries - distance, nil +} + func (binding *frostNativeSignerAnchorBinding) localTipMatchesRemoteRecord( local frostsigning.NativeTBTCSignerStateWitnessTip, remote *FrostNativeSignerStateWitnessAnchorRecord, diff --git a/pkg/tbtc/frost_native_signer_anchor_binding_test.go b/pkg/tbtc/frost_native_signer_anchor_binding_test.go index d8f3fa16bb..0595449e7f 100644 --- a/pkg/tbtc/frost_native_signer_anchor_binding_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_binding_test.go @@ -206,17 +206,14 @@ func newTestAnchorBindingFixture( ) manifest := FrostNativeSignerAnchorManifest{ Identity: identity, - MinimumServiceEpoch: floor.ServiceEpoch, - MinimumRevision: floor.Revision, - MinimumEventRoot: floor.EventRoot, - MinimumAcknowledgementDigest: floor.AcknowledgementDigest, - MinimumCheckpoint: floor.Checkpoint, WitnessMaximumRecords: identity.WitnessMaximumRecords, WitnessRotationThresholdRecords: identity.WitnessRotationThresholdRecords, } binding, err := newFrostNativeSignerAnchorBinding( store, manifest, + floor, + [32]byte{}, func() (*frostsigning.NativeTBTCSignerStateWitnessTip, error) { result := fixture.tip return &result, nil @@ -416,6 +413,166 @@ func TestFrostNativeSignerAnchorBindingCatchesUpLocalAheadState(t *testing.T) { } } +func TestFrostNativeSignerAnchorBindingExactHeadRestartRecoversCrashWindowsInOnePass( + t *testing.T, +) { + t.Run("durable state before remote CAS", func(t *testing.T) { + fixture := newTestAnchorBindingFixture(t, true) + remoteCheckpoint := fixture.target.Checkpoint + localCheckpoint := testAnchorBindingCheckpoint( + remoteCheckpoint.StoreFingerprint, + remoteCheckpoint.Generation+1, + remoteCheckpoint.StateCommitment, + [32]byte{0x91}, + ) + fixture.store.proofEntries[localCheckpoint.Generation] = + frostsigning.NativeTBTCSignerStateWitnessProofEntry{ + Generation: localCheckpoint.Generation, + PreviousStateCommitment: localCheckpoint.PreviousStateCommitment, + StateImageDigest: localCheckpoint.StateImageDigest, + StateCommitment: localCheckpoint.StateCommitment, + } + fixture.tip = testAnchorBindingTip( + localCheckpoint, + fixture.floor.Checkpoint, + fixture.binding.bindingHash, + fixture.target, + ) + + result, err := fixture.binding.reconcileStartup( + context.Background(), + ) + if err != nil { + t.Fatalf("single restart did not repair pre-CAS crash: %v", err) + } + if fixture.store.casCalls != 1 || + fixture.acknowledgeCalls != 1 || + !fixture.binding.localTipMatchesRemoteRecord( + *result, + fixture.store.record, + ) { + t.Fatal("pre-CAS crash required more than one restart to converge") + } + }) + + t.Run("remote CAS before Rust acknowledgement", func(t *testing.T) { + fixture := newTestAnchorBindingFixture(t, true) + setTestAnchorBindingTipAnchor( + &fixture.tip, + fixture.binding.bindingHash, + fixture.floor, + ) + + result, err := fixture.binding.reconcileStartup( + context.Background(), + ) + if err != nil { + t.Fatalf("single restart did not repair post-CAS crash: %v", err) + } + if fixture.store.casCalls != 0 || + fixture.recoverCalls != 1 || + !fixture.binding.localTipMatchesRemoteRecord( + *result, + fixture.store.record, + ) { + t.Fatal("post-CAS crash required more than one restart to converge") + } + }) +} + +func TestFrostNativeSignerAnchorBindingEnforcesRestartableRevisionHeadroom( + t *testing.T, +) { + fixture := newTestAnchorBindingFixture(t, false) + floorRevision := fixture.floor.Revision + tests := []struct { + distance uint64 + expectedHeadroom uint64 + expectError bool + }{ + {distance: 4095, expectedHeadroom: 1}, + {distance: 4096, expectedHeadroom: 0}, + {distance: 4097, expectError: true}, + } + for _, test := range tests { + t.Run(fmt.Sprint(test.distance), func(t *testing.T) { + headroom, err := fixture.binding.restartableRevisionHeadroom( + fixture.floor.ServiceEpoch, + floorRevision+test.distance, + ) + if test.expectError { + if err == nil { + t.Fatal("out-of-window revision was accepted") + } + return + } + if err != nil || headroom != test.expectedHeadroom { + t.Fatalf( + "unexpected revision headroom [%d, %v]", + headroom, + err, + ) + } + }) + } + + fixture.store.record.Revision = floorRevision + 4097 + fixture.store.record.PreviousEventRoot = fixture.floor.EventRoot + if err := fixture.binding.validateRemoteRecord( + fixture.store.record, + ); err == nil { + t.Fatal("startup remote target beyond the restartable bound was accepted") + } +} + +func TestFrostNativeSignerAnchorBindingEnforcesRestartableGenerationHeadroom( + t *testing.T, +) { + fixture := newTestAnchorBindingFixture(t, false) + floorGeneration := fixture.floor.Checkpoint.Generation + tests := []struct { + distance uint64 + expectedHeadroom uint64 + expectError bool + }{ + {distance: 4095, expectedHeadroom: 1}, + {distance: 4096, expectedHeadroom: 0}, + {distance: 4097, expectError: true}, + } + for _, test := range tests { + t.Run(fmt.Sprint(test.distance), func(t *testing.T) { + headroom, err := fixture.binding.restartableGenerationHeadroom( + floorGeneration + test.distance, + ) + if test.expectError { + if err == nil { + t.Fatal("out-of-window generation was accepted") + } + return + } + if err != nil || headroom != test.expectedHeadroom { + t.Fatalf( + "unexpected generation headroom [%d, %v]", + headroom, + err, + ) + } + }) + } + + fixture.store.record.Checkpoint = testAnchorBindingCheckpoint( + fixture.floor.Checkpoint.StoreFingerprint, + floorGeneration+4097, + fixture.floor.Checkpoint.StateCommitment, + [32]byte{0xfd}, + ) + if err := fixture.binding.validateRemoteRecord( + fixture.store.record, + ); err == nil { + t.Fatal("remote target beyond the restartable generation bound was accepted") + } +} + func TestFrostNativeSignerAnchorBindingRejectsRollbackForkAndPartialAnchor( t *testing.T, ) { @@ -480,6 +637,39 @@ func TestFrostNativeSignerAnchorBindingRejectsRollbackForkAndPartialAnchor( }) } +func TestFrostNativeSignerAnchorBindingRejectsCertifiedFloorHistoryGapsAndForks( + t *testing.T, +) { + tests := map[string]func(*testAnchorBindingFixture){ + "skipped event": func(fixture *testAnchorBindingFixture) { + fixture.store.history.Events = nil + }, + "gapped revision": func(fixture *testAnchorBindingFixture) { + fixture.store.history.Events[0].Acknowledgement.Revision++ + }, + "forked parent": func(fixture *testAnchorBindingFixture) { + fixture.store.history.Events[0].Acknowledgement. + PreviousEventRoot[0] ^= 1 + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + fixture := newTestAnchorBindingFixture(t, true) + mutate(fixture) + if _, err := fixture.binding.reconcileStartup( + context.Background(), + ); err == nil { + t.Fatal("discontinuous certified-floor history was accepted") + } + if fixture.store.casCalls != 0 || + fixture.acknowledgeCalls != 0 || + fixture.recoverCalls != 0 { + t.Fatal("invalid history triggered a signer or service mutation") + } + }) + } +} + func testAnchorBindingCheckpoint( storeFingerprint [32]byte, generation uint64, diff --git a/pkg/tbtc/frost_native_signer_anchor_client.go b/pkg/tbtc/frost_native_signer_anchor_client.go index 37ca1435ec..4f8a36248d 100644 --- a/pkg/tbtc/frost_native_signer_anchor_client.go +++ b/pkg/tbtc/frost_native_signer_anchor_client.go @@ -63,14 +63,15 @@ type FrostNativeSignerAnchorClient struct { clockSkew time.Duration maximumAckLife time.Duration - identity FrostNativeSignerAnchorIdentity - bindingHash [32]byte - clientKey ed25519.PrivateKey - clientSPKIDER []byte - clientSPKIBase64 string - onlineKey ed25519.PublicKey - random io.Reader - now func() time.Time + identity FrostNativeSignerAnchorIdentity + bindingHash [32]byte + clientKey ed25519.PrivateKey + clientSPKIDER []byte + clientSPKIBase64 string + onlineKey ed25519.PublicKey + certifiedTrustFloor *FrostNativeSignerAnchorTrustCertificate + random io.Reader + now func() time.Time mutex sync.Mutex last *FrostNativeSignerCheckpointAcknowledgement @@ -85,6 +86,30 @@ type FrostNativeSignerAnchorClient struct { // endpoint. Proxies and redirects are always disabled. func NewFrostNativeSignerAnchorClient( config FrostNativeSignerAnchorClientConfig, +) (*FrostNativeSignerAnchorClient, error) { + return newFrostNativeSignerAnchorClient(config, nil) +} + +// newFrostNativeSignerAnchorClientWithTrustFloor is deliberately private. A +// revision-one acknowledgement with a non-zero predecessor crosses a service +// epoch and therefore cannot be admitted from caller-supplied client +// configuration. The only way to obtain the capability accepted here is the +// full offline-authority certificate-chain validator. +func newFrostNativeSignerAnchorClientWithTrustFloor( + config FrostNativeSignerAnchorClientConfig, + trustFloor *frostNativeSignerAnchorVerifiedTrustFloor, +) (*FrostNativeSignerAnchorClient, error) { + if trustFloor == nil { + return nil, fmt.Errorf( + "verified native signer anchor trust-floor capability is nil", + ) + } + return newFrostNativeSignerAnchorClient(config, trustFloor) +} + +func newFrostNativeSignerAnchorClient( + config FrostNativeSignerAnchorClientConfig, + trustFloor *frostNativeSignerAnchorVerifiedTrustFloor, ) (*FrostNativeSignerAnchorClient, error) { endpoint, https, err := validateFrostNativeSignerAnchorEndpoint(config.Endpoint) if err != nil { @@ -115,6 +140,28 @@ func NewFrostNativeSignerAnchorClient( if sha256.Sum256(config.OnlinePublicKeySPKI) != config.Identity.OnlineKeyHash { return nil, fmt.Errorf("native signer anchor online key differs from its identity") } + var certifiedTrustFloor *FrostNativeSignerAnchorTrustCertificate + if trustFloor != nil { + certificate := frostNativeSignerAnchorTrustCloneCertificate( + &trustFloor.certificate, + ) + rawOnlineKey := [ed25519.PublicKeySize]byte{} + copy(rawOnlineKey[:], onlineKey) + if certificate.ProtocolID != config.Identity.ProtocolID || + certificate.StreamID != config.Identity.StreamID || + certificate.SignerStoreFingerprint != + config.Identity.SignerStoreFingerprint || + certificate.To.BindingHash != + ComputeFrostNativeSignerAnchorBindingHash(config.Identity) || + certificate.To.ResponsePublicKey != rawOnlineKey || + certificate.To.ResponsePublicKeySPKISHA256 != + config.Identity.OnlineKeyHash { + return nil, fmt.Errorf( + "native signer certified trust floor differs from the client identity", + ) + } + certifiedTrustFloor = &certificate + } requestTimeout := config.RequestTimeout if requestTimeout == 0 { @@ -201,17 +248,18 @@ func NewFrostNativeSignerAnchorClient( return fmt.Errorf("native signer anchor redirects are disabled") }, }, - requestTimeout: requestTimeout, - clockSkew: clockSkew, - maximumAckLife: maximumAckLife, - identity: config.Identity, - bindingHash: ComputeFrostNativeSignerAnchorBindingHash(config.Identity), - clientKey: clientKey, - clientSPKIDER: append([]byte{}, clientSPKIDER...), - clientSPKIBase64: base64StdEncoding(clientSPKIDER), - onlineKey: append(ed25519.PublicKey{}, onlineKey...), - random: randomSource, - now: now, + requestTimeout: requestTimeout, + clockSkew: clockSkew, + maximumAckLife: maximumAckLife, + identity: config.Identity, + bindingHash: ComputeFrostNativeSignerAnchorBindingHash(config.Identity), + clientKey: clientKey, + clientSPKIDER: append([]byte{}, clientSPKIDER...), + clientSPKIBase64: base64StdEncoding(clientSPKIDER), + onlineKey: append(ed25519.PublicKey{}, onlineKey...), + certifiedTrustFloor: certifiedTrustFloor, + random: randomSource, + now: now, }, nil } @@ -938,16 +986,36 @@ func (client *FrostNativeSignerAnchorClient) readLocked( if err != nil { return nil, err } - acknowledgement, err := client.verifyAcknowledgement( - readResponse.CheckpointAck, - nil, - nil, - &checkpoint, - &operationID, - false, - "applied", - "already-applied", - ) + var acknowledgement *FrostNativeSignerCheckpointAcknowledgement + if client.certifiedTrustFloor != nil && + bytes.Equal( + readResponse.CheckpointAck, + client.certifiedTrustFloor.TargetAcknowledgement, + ) { + acknowledgement, err = + verifyFrostNativeSignerAnchorTrustTargetAcknowledgement( + client.certifiedTrustFloor, + readResponse.CheckpointAck, + ) + if err == nil && + (acknowledgement.Checkpoint != checkpoint || + acknowledgement.OperationID != operationID) { + err = fmt.Errorf( + "certified trust-floor acknowledgement differs from its Read summary", + ) + } + } else { + acknowledgement, err = client.verifyAcknowledgement( + readResponse.CheckpointAck, + nil, + nil, + &checkpoint, + &operationID, + false, + "applied", + "already-applied", + ) + } if err != nil { return nil, fmt.Errorf("invalid stored native signer checkpoint acknowledgement: %w", err) } @@ -1428,6 +1496,14 @@ func parseFrostNativeSignerAnchorOnlineKey(der []byte) (ed25519.PublicKey, error if !ok || len(publicKey) != ed25519.PublicKeySize { return nil, fmt.Errorf("native signer anchor online key is not Ed25519") } + if err := ValidateFrostNativeSignerAnchorTrustEd25519PublicKey( + publicKey, + ); err != nil { + return nil, fmt.Errorf( + "native signer anchor online key point is invalid: %w", + err, + ) + } canonical, err := x509.MarshalPKIXPublicKey(publicKey) if err != nil || !bytes.Equal(canonical, der) { return nil, fmt.Errorf("native signer anchor online key SPKI is not canonical DER") diff --git a/pkg/tbtc/frost_native_signer_anchor_client_test.go b/pkg/tbtc/frost_native_signer_anchor_client_test.go index 1aa63bbd38..2468e67b85 100644 --- a/pkg/tbtc/frost_native_signer_anchor_client_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_client_test.go @@ -101,6 +101,120 @@ func TestFrostNativeSignerAnchorStreamIDFrozenAndStable(t *testing.T) { } } +func TestFrostNativeSignerAnchorRestartBoundsAlignAcrossGoLayers( + t *testing.T, +) { + if FrostNativeSignerAnchorMaximumHistoryEvents != + frostsigning.NativeTBTCSignerStateAnchorMaximumRevisionDistance { + t.Fatalf( + "anchor history bound [%d] differs from signer barrier bound [%d]", + FrostNativeSignerAnchorMaximumHistoryEvents, + frostsigning.NativeTBTCSignerStateAnchorMaximumRevisionDistance, + ) + } + if FrostNativeSignerAnchorMaximumHistoryProofEntries != + frostsigning.NativeTBTCSignerStateAnchorMaximumGenerationDistance { + t.Fatalf( + "signer proof bound [%d] differs from signer barrier generation bound [%d]", + FrostNativeSignerAnchorMaximumHistoryProofEntries, + frostsigning.NativeTBTCSignerStateAnchorMaximumGenerationDistance, + ) + } + if frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall != + frostsigning.NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation { + t.Fatalf( + "admission per-call generation bound [%d] differs from output barrier bound [%d]", + frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall, + frostsigning.NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ) + } +} + +func TestFrostNativeSignerAnchorClientRejectsAliasedCryptographicRoles( + t *testing.T, +) { + privateKey := func(seedByte byte) ed25519.PrivateKey { + seed := make([]byte, ed25519.SeedSize) + for index := range seed { + seed[index] = seedByte + } + return ed25519.NewKeyFromSeed(seed) + } + spki := func(t *testing.T, publicKey ed25519.PublicKey) []byte { + t.Helper() + result, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + return result + } + clientKey := privateKey(0x11) + onlineKey := privateKey(0x22) + offlineKey := privateKey(0x33) + clientSPKI := spki(t, clientKey.Public().(ed25519.PublicKey)) + onlineSPKI := spki(t, onlineKey.Public().(ed25519.PublicKey)) + offlineSPKI := spki(t, offlineKey.Public().(ed25519.PublicKey)) + endpoint := "http://127.0.0.1:19487/v1/anchor" + identity := FrostNativeSignerAnchorIdentity{ + ProtocolID: testFrostNativeSignerAnchorBytes32(0x41), + ActivationManifestHash: testFrostNativeSignerAnchorBytes32(0x42), + ActivationManifestSequence: 1, + TrustDomainID: "role-separation.example", + OnlineKeyHash: sha256.Sum256(onlineSPKI), + OperatorFingerprint: testFrostNativeSignerAnchorBytes32(0x43), + HistoryStoreID: "role-separation-history", + HistoryStoreFingerprint: testFrostNativeSignerAnchorBytes32(0x44), + HistoryClusterFingerprint: testFrostNativeSignerAnchorBytes32(0x45), + OfflineAuthorityHash: sha256.Sum256(offlineSPKI), + ClientSPKIHash: sha256.Sum256(clientSPKI), + SignerStoreFingerprint: testFrostNativeSignerAnchorBytes32(0x46), + TransportBinding: ComputeFrostNativeSignerAnchorTransportBinding(endpoint), + WitnessMaximumRecords: 100, + WitnessRotationThresholdRecords: 50, + } + identity.StreamID = ComputeFrostNativeSignerAnchorStreamID(identity) + config := FrostNativeSignerAnchorClientConfig{ + Endpoint: endpoint, + ClientPrivateKey: clientKey, + OnlinePublicKeySPKI: onlineSPKI, + Identity: identity, + } + if _, err := NewFrostNativeSignerAnchorClient(config); err != nil { + t.Fatalf("distinct cryptographic roles were rejected: %v", err) + } + + tests := map[string]func( + *FrostNativeSignerAnchorClientConfig, + ){ + "client and online": func( + config *FrostNativeSignerAnchorClientConfig, + ) { + config.OnlinePublicKeySPKI = clientSPKI + config.Identity.OnlineKeyHash = sha256.Sum256(clientSPKI) + }, + "client and offline": func( + config *FrostNativeSignerAnchorClientConfig, + ) { + config.Identity.OfflineAuthorityHash = sha256.Sum256(clientSPKI) + }, + "online and offline": func( + config *FrostNativeSignerAnchorClientConfig, + ) { + config.Identity.OfflineAuthorityHash = sha256.Sum256(onlineSPKI) + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + candidate := config + mutate(&candidate) + if _, err := NewFrostNativeSignerAnchorClient(candidate); err == nil || + !strings.Contains(err.Error(), "pairwise distinct") { + t.Fatalf("aliased cryptographic roles were accepted: %v", err) + } + }) + } +} + func TestFrostNativeSignerCheckpointAcknowledgementFrozenVector(t *testing.T) { wire := frostNativeSignerAnchorAcknowledgementWire{ Schema: FrostNativeSignerCheckpointAcknowledgementSchema, diff --git a/pkg/tbtc/frost_native_signer_anchor_history.go b/pkg/tbtc/frost_native_signer_anchor_history.go index af920422a4..60b3688bfa 100644 --- a/pkg/tbtc/frost_native_signer_anchor_history.go +++ b/pkg/tbtc/frost_native_signer_anchor_history.go @@ -8,13 +8,7 @@ import ( ) func (binding *frostNativeSignerAnchorBinding) manifestFloorReference() FrostNativeSignerStateWitnessAnchorReference { - return FrostNativeSignerStateWitnessAnchorReference{ - ServiceEpoch: binding.floor.MinimumServiceEpoch, - Revision: binding.floor.MinimumRevision, - EventRoot: binding.floor.MinimumEventRoot, - AcknowledgementDigest: binding.floor.MinimumAcknowledgementDigest, - Checkpoint: binding.floor.MinimumCheckpoint, - } + return binding.floor } // validateStartupHistory independently rechecks the semantic history returned @@ -190,7 +184,7 @@ func (binding *frostNativeSignerAnchorBinding) validateLocalHistorySplice( local frostsigning.NativeTBTCSignerStateWitnessTip, serviceCommitments map[uint64][32]byte, ) error { - floor := binding.floor.MinimumCheckpoint + floor := binding.floor.Checkpoint var splice FrostNativeSignerStateWitnessCheckpoint if local.WitnessBaseGeneration <= floor.Generation { splice = floor diff --git a/pkg/tbtc/frost_native_signer_anchor_keys.go b/pkg/tbtc/frost_native_signer_anchor_keys.go index b40a36f3e2..1f2c4f7584 100644 --- a/pkg/tbtc/frost_native_signer_anchor_keys.go +++ b/pkg/tbtc/frost_native_signer_anchor_keys.go @@ -68,6 +68,14 @@ func loadFrostNativeSignerAnchorOnlinePublicKeySPKI( "FROST native signer anchor online key is not Ed25519", ) } + if err := ValidateFrostNativeSignerAnchorTrustEd25519PublicKey( + publicKey, + ); err != nil { + return nil, nil, fmt.Errorf( + "FROST native signer anchor online key point is invalid: %w", + err, + ) + } canonical, err := x509.MarshalPKIXPublicKey(publicKey) if err != nil || !bytes.Equal(canonical, der) { return nil, nil, fmt.Errorf( @@ -77,6 +85,22 @@ func loadFrostNativeSignerAnchorOnlinePublicKeySPKI( return append([]byte{}, der...), append(ed25519.PublicKey{}, publicKey...), nil } +func loadFrostNativeSignerAnchorTrustCertificateChain( + path string, +) ([]byte, error) { + data, err := readSecureFrostActivationFile( + path, + frostNativeSignerAnchorTrustMaximumTransitionRequestBytes, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot read FROST native signer anchor trust-certificate chain: %w", + err, + ) + } + return data, nil +} + func zeroFrostNativeSignerKeyBytes(value []byte) { for index := range value { value[index] = 0 diff --git a/pkg/tbtc/frost_native_signer_anchor_protocol.go b/pkg/tbtc/frost_native_signer_anchor_protocol.go index 1ee60112ad..3f2ab12a7a 100644 --- a/pkg/tbtc/frost_native_signer_anchor_protocol.go +++ b/pkg/tbtc/frost_native_signer_anchor_protocol.go @@ -36,7 +36,12 @@ const ( FrostNativeSignerAnchorMaximumHistoryEvents = 4096 FrostNativeSignerAnchorMaximumHistoryProofEntries = 4096 FrostNativeSignerAnchorMaximumHistoryPages = 16 - frostNativeSignerAnchorMaximumJSONDepth = 32 + // FrostNativeSignerAnchorRotationWarningHeadroom exposes an operational + // warning while a full history page of restartable revisions remains. + // Signing freezes at zero so an offline-authorized epoch rotation can + // always recover without crossing the bounded-history window. + FrostNativeSignerAnchorRotationWarningHeadroom = 256 + frostNativeSignerAnchorMaximumJSONDepth = 32 frostNativeSignerAnchorMaximumRequestBytes = 2 * 1024 * 1024 frostNativeSignerAnchorMaximumResponseBytes = 256 * 1024 @@ -1118,6 +1123,13 @@ func validateFrostNativeSignerAnchorIdentity( return fmt.Errorf("%s is zero", name) } } + if identity.ClientSPKIHash == identity.OnlineKeyHash || + identity.ClientSPKIHash == identity.OfflineAuthorityHash || + identity.OnlineKeyHash == identity.OfflineAuthorityHash { + return fmt.Errorf( + "anchor client, online response, and offline authority keys must be pairwise distinct", + ) + } if identity.ActivationManifestSequence == 0 || identity.WitnessMaximumRecords < 2 || identity.WitnessMaximumRecords > 1_000_000 || diff --git a/pkg/tbtc/frost_native_signer_anchor_trust.go b/pkg/tbtc/frost_native_signer_anchor_trust.go new file mode 100644 index 0000000000..2e63fc274e --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_trust.go @@ -0,0 +1,1839 @@ +package tbtc + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "math/big" + "strings" + "unicode/utf8" + + "github.com/decred/dcrd/dcrec/edwards/v2" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +var frostNativeSignerAnchorTrustEd25519IdentityY = big.NewInt(1) + +const ( + // FrostNativeSignerAnchorTrustCertificateSchema is the offline-authority + // signed bootstrap/rotation certificate wire schema. + FrostNativeSignerAnchorTrustCertificateSchema = "tbtc-frost-native-signer-state-anchor-trust-certificate/v1" + + // FrostNativeSignerAnchorTrustTransitionRequestSchema is the startup-only + // native signer ABI 4.3 request carrying a bounded certificate chain and a + // fresh target Read wrapper. + FrostNativeSignerAnchorTrustTransitionRequestSchema = "tbtc-signer-state-anchor-trust-transition/v1" + + FrostNativeSignerAnchorTrustMaximumCertificateChainLength = 64 + + // A certificate is persisted inside Rust's bounded 128 KiB trust-journal + // record. Keep a conservative envelope allowance so every certificate Go + // admits can be serialized with the record metadata before any mutation. + frostNativeSignerAnchorTrustMaximumCertificateBytes = 120 * 1024 + frostNativeSignerAnchorTrustMaximumAcknowledgementBytes = 64 * 1024 + frostNativeSignerAnchorTrustMaximumReadResponseBytes = 128 * 1024 + frostNativeSignerAnchorTrustMaximumTransitionRequestBytes = frostsigning.NativeTBTCSignerStateAnchorTrustTransitionMaximumRequestBytes + frostNativeSignerAnchorTrustMaximumJSONDepth = 32 + frostNativeSignerAnchorTrustCoreDomain = "tbtc-frost-native-signer-state-anchor-trust-transition-core/v1\x00" + frostNativeSignerAnchorTrustOperationIDDomain = "tbtc-frost-native-signer-state-anchor-trust-transition-operation-id/v1\x00" + frostNativeSignerAnchorTrustTransitionDigestDomain = "tbtc-frost-native-signer-state-anchor-trust-transition-digest/v1\x00" + frostNativeSignerAnchorTrustCertificateDomain = "tbtc-frost-native-signer-state-anchor-trust-certificate/v1\x00" + frostNativeSignerAnchorTrustCertificateDigestDomain = "tbtc-frost-native-signer-state-anchor-trust-certificate-digest/v1\x00" + frostNativeSignerAnchorTrustBootstrapKindByte byte = 1 + frostNativeSignerAnchorTrustRotationKindByte byte = 2 +) + +var frostNativeSignerAnchorTrustEd25519SPKIPrefix = [...]byte{ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, + 0x70, 0x03, 0x21, 0x00, +} + +// FrostNativeSignerAnchorTrustCertificateKind selects the only two +// offline-authorized trust transitions. +type FrostNativeSignerAnchorTrustCertificateKind string + +const ( + FrostNativeSignerAnchorTrustCertificateBootstrap FrostNativeSignerAnchorTrustCertificateKind = "bootstrap" + FrostNativeSignerAnchorTrustCertificateRotation FrostNativeSignerAnchorTrustCertificateKind = "rotation" +) + +// FrostNativeSignerAnchorTrustReference is the certificate-specific full +// service reference. Unlike ordinary history references, it includes the +// previous event root so an epoch boundary cannot erase ancestry. +type FrostNativeSignerAnchorTrustReference struct { + ServiceEpoch uint64 + Revision uint64 + PreviousEventRoot [32]byte + EventRoot [32]byte + AcknowledgementDigest [32]byte + Checkpoint FrostNativeSignerStateWitnessCheckpoint +} + +// FrostNativeSignerAnchorTrustEndpoint is the exact trust/configuration state +// on one side of a certificate. Public keys are raw Ed25519 bytes; their SPKI +// hashes commit to the canonical DER prefix specified by RFC 8410. +type FrostNativeSignerAnchorTrustEndpoint struct { + ActivationManifestHash [32]byte + ActivationManifestSequence uint64 + BindingHash [32]byte + ResponsePublicKey [ed25519.PublicKeySize]byte + ResponsePublicKeySPKISHA256 [32]byte + OfflineAuthorityPublicKey [ed25519.PublicKeySize]byte + OfflineAuthoritySPKISHA256 [32]byte + WitnessMaximumRecords uint64 + WitnessRotationThresholdRecords uint64 + Reference FrostNativeSignerAnchorTrustReference +} + +// FrostNativeSignerAnchorTrustCertificate is the parsed certificate. The +// target acknowledgement is retained byte-for-byte because its SHA-256, +// service signature, acknowledgement digest, and native recovery ABI all bind +// the exact JSON representation rather than a re-encoding. +type FrostNativeSignerAnchorTrustCertificate struct { + Kind FrostNativeSignerAnchorTrustCertificateKind + CertificateSequence uint64 + PreviousCertificateDigest [32]byte + ProtocolID [32]byte + StreamID [32]byte + SignerStoreFingerprint [32]byte + From *FrostNativeSignerAnchorTrustEndpoint + To FrostNativeSignerAnchorTrustEndpoint + CoreDigest [32]byte + CoreSignature [ed25519.SignatureSize]byte + OperationID [32]byte + TransitionDigest [32]byte + TargetAcknowledgement []byte + TargetAcknowledgementSHA256 [32]byte + FinalSignature [ed25519.SignatureSize]byte + CertificateDigest [32]byte +} + +// FrostNativeSignerAnchorTrustTransitionRequest is the parsed startup ABI +// request. TargetReadResponse remains exact signed JSON for the existing +// client/Rust semantic verifier. +type FrostNativeSignerAnchorTrustTransitionRequest struct { + CertificateChain []FrostNativeSignerAnchorTrustCertificate + TargetReadResponse []byte +} + +// FrostNativeSignerAnchorTrustCertificateHead is an authenticated external +// journal head. Production chain validation uses a prior head to authenticate +// a bounded missing suffix and always requires an exact expected final head. +type FrostNativeSignerAnchorTrustCertificateHead struct { + CertificateSequence uint64 + CertificateDigest [32]byte + ProtocolID [32]byte + StreamID [32]byte + SignerStoreFingerprint [32]byte + Endpoint FrostNativeSignerAnchorTrustEndpoint +} + +// frostNativeSignerAnchorVerifiedTrustFloor is an unexported admission +// capability minted only after the complete certificate suffix, deployment +// pins, offline-authority signatures, and target acknowledgements have been +// authenticated. It is intentionally not caller-constructible through the +// public anchor-client configuration. +type frostNativeSignerAnchorVerifiedTrustFloor struct { + certificate FrostNativeSignerAnchorTrustCertificate +} + +// FrostNativeSignerAnchorTrustTargetAcknowledgementValidator is the deliberate +// semantic seam between this pure certificate protocol and the existing anchor +// acknowledgement verifier. It must verify the target response-key signature, +// binding, derived operation/transition IDs, full target reference, and the +// certificate-contextual revision-1 parent. The generic same-epoch verifier +// must not be weakened to accept a non-zero revision-1 parent. +type FrostNativeSignerAnchorTrustTargetAcknowledgementValidator func( + certificate *FrostNativeSignerAnchorTrustCertificate, + rawAcknowledgement []byte, +) error + +// FrostNativeSignerAnchorTrustChainValidationOptions supplies deployment pins +// and the mandatory target-acknowledgement semantic verifier. Every expected +// pin and the expected final head are mandatory: accepting zero pins here +// would let an authority-signed but stale chain become its own rollback +// authority. +type FrostNativeSignerAnchorTrustChainValidationOptions struct { + AllowLegacyAdoption bool + + ExpectedProtocolID [32]byte + ExpectedStreamID [32]byte + ExpectedSignerStoreFingerprint [32]byte + ExpectedOfflineAuthorityPublicKey [ed25519.PublicKeySize]byte + ExpectedOfflineAuthoritySPKISHA256 [32]byte + PriorHead *FrostNativeSignerAnchorTrustCertificateHead + ExpectedHead *FrostNativeSignerAnchorTrustCertificateHead + + ValidateTargetAcknowledgement FrostNativeSignerAnchorTrustTargetAcknowledgementValidator +} + +type frostNativeSignerAnchorTrustCheckpointWire struct { + StoreFingerprint string `json:"storeFingerprint"` + Generation string `json:"generation"` + PreviousStateCommitment string `json:"previousStateCommitment"` + StateImageDigest string `json:"stateImageDigest"` + StateCommitment string `json:"stateCommitment"` +} + +type frostNativeSignerAnchorTrustReferenceWire struct { + ServiceEpoch string `json:"serviceEpoch"` + Revision string `json:"revision"` + PreviousEventRoot string `json:"previousEventRoot"` + EventRoot string `json:"eventRoot"` + CheckpointAckDigest string `json:"checkpointAckDigest"` + Checkpoint *frostNativeSignerAnchorTrustCheckpointWire `json:"checkpoint"` +} + +type frostNativeSignerAnchorTrustEndpointWire struct { + ActivationManifestHash string `json:"activationManifestHash"` + ActivationManifestSequence string `json:"activationManifestSequence"` + BindingHash string `json:"bindingHash"` + ResponsePublicKey string `json:"responsePublicKey"` + ResponsePublicKeySPKISHA256 string `json:"responsePublicKeySpkiSha256"` + OfflineAuthorityPublicKey string `json:"offlineAuthorityPublicKey"` + OfflineAuthoritySPKISHA256 string `json:"offlineAuthoritySpkiSha256"` + WitnessMaximumRecords string `json:"witnessMaximumRecords"` + WitnessRotationThresholdRecords string `json:"witnessRotationThresholdRecords"` + Reference *frostNativeSignerAnchorTrustReferenceWire `json:"reference"` +} + +type frostNativeSignerAnchorTrustCertificateWire struct { + Schema string `json:"schema"` + Kind string `json:"kind"` + CertificateSequence string `json:"certificateSequence"` + PreviousCertificateDigest string `json:"previousCertificateDigest"` + ProtocolID string `json:"protocolID"` + StreamID string `json:"streamID"` + SignerStoreFingerprint string `json:"signerStoreFingerprint"` + From *frostNativeSignerAnchorTrustEndpointWire `json:"from"` + To *frostNativeSignerAnchorTrustEndpointWire `json:"to"` + CoreDigest string `json:"coreDigest"` + CoreSignature string `json:"coreSignature"` + OperationID string `json:"operationID"` + TransitionDigest string `json:"transitionDigest"` + TargetAcknowledgementBase64 string `json:"targetAcknowledgementBase64"` + TargetAcknowledgementSHA256 string `json:"targetAcknowledgementSHA256"` + FinalSignature string `json:"finalSignature"` + CertificateDigest string `json:"certificateDigest"` +} + +type frostNativeSignerAnchorTrustTransitionRequestWire struct { + Schema string `json:"schema"` + CertificateChain *[]json.RawMessage `json:"certificateChain"` + TargetReadResponseBase64 string `json:"targetReadResponseBase64"` +} + +// ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256 hashes the canonical +// RFC 8410 SubjectPublicKeyInfo DER for a raw Ed25519 key. +func ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + publicKey [ed25519.PublicKeySize]byte, +) [32]byte { + hasher := sha256.New() + hasher.Write(frostNativeSignerAnchorTrustEd25519SPKIPrefix[:]) + hasher.Write(publicKey[:]) + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result +} + +// ComputeFrostNativeSignerAnchorTrustCoreDigest computes the fixed-width core +// authorization digest. Target event/ack fields are intentionally excluded: +// the service constructs them only after the offline authority signs the core. +func ComputeFrostNativeSignerAnchorTrustCoreDigest( + certificate *FrostNativeSignerAnchorTrustCertificate, +) ([32]byte, error) { + if certificate == nil { + return [32]byte{}, fmt.Errorf("native signer anchor trust certificate is nil") + } + kind, err := frostNativeSignerAnchorTrustKindByte(certificate.Kind) + if err != nil { + return [32]byte{}, err + } + buffer := bytes.NewBuffer(nil) + buffer.WriteString(frostNativeSignerAnchorTrustCoreDomain) + buffer.WriteByte(kind) + frostNativeSignerAnchorTrustWriteUint64(buffer, certificate.CertificateSequence) + buffer.Write(certificate.PreviousCertificateDigest[:]) + buffer.Write(certificate.ProtocolID[:]) + buffer.Write(certificate.StreamID[:]) + buffer.Write(certificate.SignerStoreFingerprint[:]) + if certificate.From == nil { + frostNativeSignerAnchorTrustWriteCoreFrom( + buffer, + FrostNativeSignerAnchorTrustEndpoint{}, + ) + } else { + frostNativeSignerAnchorTrustWriteCoreFrom(buffer, *certificate.From) + } + frostNativeSignerAnchorTrustWriteCoreTo(buffer, certificate.To) + return sha256.Sum256(buffer.Bytes()), nil +} + +// ComputeFrostNativeSignerAnchorTrustOperationID derives the unique operation +// identity solely from the signed core. +func ComputeFrostNativeSignerAnchorTrustOperationID( + coreDigest [32]byte, +) [32]byte { + buffer := bytes.NewBuffer(nil) + buffer.WriteString(frostNativeSignerAnchorTrustOperationIDDomain) + buffer.Write(coreDigest[:]) + return sha256.Sum256(buffer.Bytes()) +} + +// ComputeFrostNativeSignerAnchorTrustTransitionDigest derives the successor +// acknowledgement transition digest without introducing a certificate-digest +// cycle. +func ComputeFrostNativeSignerAnchorTrustTransitionDigest( + coreDigest [32]byte, + operationID [32]byte, +) [32]byte { + buffer := bytes.NewBuffer(nil) + buffer.WriteString(frostNativeSignerAnchorTrustTransitionDigestDomain) + buffer.Write(coreDigest[:]) + buffer.Write(operationID[:]) + return sha256.Sum256(buffer.Bytes()) +} + +// ComputeFrostNativeSignerAnchorTrustFinalDigest computes the digest signed +// after the service has constructed the exact successor acknowledgement. +func ComputeFrostNativeSignerAnchorTrustFinalDigest( + certificate *FrostNativeSignerAnchorTrustCertificate, +) ([32]byte, error) { + if certificate == nil { + return [32]byte{}, fmt.Errorf("native signer anchor trust certificate is nil") + } + buffer := bytes.NewBuffer(nil) + buffer.WriteString(frostNativeSignerAnchorTrustCertificateDomain) + buffer.Write(certificate.CoreDigest[:]) + buffer.Write(certificate.CoreSignature[:]) + buffer.Write(certificate.OperationID[:]) + buffer.Write(certificate.TransitionDigest[:]) + frostNativeSignerAnchorTrustWriteUint64( + buffer, + certificate.To.Reference.ServiceEpoch, + ) + frostNativeSignerAnchorTrustWriteUint64( + buffer, + certificate.To.Reference.Revision, + ) + buffer.Write(certificate.To.Reference.PreviousEventRoot[:]) + buffer.Write(certificate.To.Reference.EventRoot[:]) + buffer.Write(certificate.To.Reference.AcknowledgementDigest[:]) + frostNativeSignerAnchorTrustWriteCheckpoint( + buffer, + certificate.To.Reference.Checkpoint, + ) + buffer.Write(certificate.TargetAcknowledgementSHA256[:]) + return sha256.Sum256(buffer.Bytes()), nil +} + +// ComputeFrostNativeSignerAnchorTrustCertificateDigest derives the append-only +// journal identity from the final authority signature and immutable authority +// SPKI pin. +func ComputeFrostNativeSignerAnchorTrustCertificateDigest( + certificate *FrostNativeSignerAnchorTrustCertificate, +) ([32]byte, error) { + if certificate == nil { + return [32]byte{}, fmt.Errorf("native signer anchor trust certificate is nil") + } + finalDigest, err := ComputeFrostNativeSignerAnchorTrustFinalDigest(certificate) + if err != nil { + return [32]byte{}, err + } + buffer := bytes.NewBuffer(nil) + buffer.WriteString(frostNativeSignerAnchorTrustCertificateDigestDomain) + buffer.Write(finalDigest[:]) + buffer.Write(certificate.FinalSignature[:]) + buffer.Write(certificate.To.OfflineAuthoritySPKISHA256[:]) + return sha256.Sum256(buffer.Bytes()), nil +} + +// DecodeFrostNativeSignerAnchorTrustCertificate strictly decodes one bounded +// certificate without accepting case aliases, duplicate members, unknown +// members, non-canonical numbers/hex/base64, or parser trailing data. +func DecodeFrostNativeSignerAnchorTrustCertificate( + data []byte, +) (*FrostNativeSignerAnchorTrustCertificate, error) { + if err := frostNativeSignerAnchorTrustPreflightJSON( + data, + frostNativeSignerAnchorTrustMaximumCertificateBytes, + frostNativeSignerAnchorTrustCertificateJSONMembers, + ); err != nil { + return nil, err + } + members := make(map[string]json.RawMessage) + if err := json.Unmarshal(data, &members); err != nil { + return nil, err + } + if _, present := members["from"]; !present { + return nil, fmt.Errorf( + "native signer anchor trust certificate from endpoint is missing", + ) + } + wire := frostNativeSignerAnchorTrustCertificateWire{} + if err := frostNativeSignerAnchorTrustDecodeJSON(data, &wire); err != nil { + return nil, err + } + return frostNativeSignerAnchorTrustCertificateFromWire(wire) +} + +// EncodeFrostNativeSignerAnchorTrustCertificate emits the canonical JSON wire +// representation. Cryptographic validation remains an explicit separate step. +func EncodeFrostNativeSignerAnchorTrustCertificate( + certificate *FrostNativeSignerAnchorTrustCertificate, +) ([]byte, error) { + if certificate == nil { + return nil, fmt.Errorf("native signer anchor trust certificate is nil") + } + encoded, err := json.Marshal( + frostNativeSignerAnchorTrustCertificateToWire(certificate), + ) + if err != nil { + return nil, err + } + if len(encoded) > frostNativeSignerAnchorTrustMaximumCertificateBytes { + return nil, fmt.Errorf( + "native signer anchor trust certificate exceeds the durable record bound", + ) + } + return encoded, nil +} + +// DecodeFrostNativeSignerAnchorTrustCertificateChain decodes the secure config +// artifact: an exact top-level JSON array containing 1..64 independently +// bounded certificates. The fresh target Read is deliberately obtained later +// and is not part of this at-rest artifact. +func DecodeFrostNativeSignerAnchorTrustCertificateChain( + data []byte, +) ([]FrostNativeSignerAnchorTrustCertificate, error) { + if err := frostNativeSignerAnchorTrustPreflightJSONArray( + data, + frostNativeSignerAnchorTrustMaximumTransitionRequestBytes, + frostNativeSignerAnchorTrustCertificateJSONMembers, + ); err != nil { + return nil, err + } + var rawCertificates []json.RawMessage + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(&rawCertificates); err != nil { + return nil, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("JSON contains trailing data") + } + if len(rawCertificates) == 0 || + len(rawCertificates) > + FrostNativeSignerAnchorTrustMaximumCertificateChainLength { + return nil, fmt.Errorf( + "native signer anchor trust certificate chain length is invalid", + ) + } + result := make( + []FrostNativeSignerAnchorTrustCertificate, + len(rawCertificates), + ) + for index, rawCertificate := range rawCertificates { + certificate, err := DecodeFrostNativeSignerAnchorTrustCertificate( + rawCertificate, + ) + if err != nil { + return nil, fmt.Errorf( + "invalid native signer anchor trust certificate [%d]: %w", + index, + err, + ) + } + result[index] = *certificate + } + return result, nil +} + +// DecodeFrostNativeSignerAnchorTrustTransitionRequest strictly decodes the +// bounded ABI request and every certificate in its 1..64 chain. +func DecodeFrostNativeSignerAnchorTrustTransitionRequest( + data []byte, +) (*FrostNativeSignerAnchorTrustTransitionRequest, error) { + if err := frostNativeSignerAnchorTrustPreflightJSON( + data, + frostNativeSignerAnchorTrustMaximumTransitionRequestBytes, + frostNativeSignerAnchorTrustRequestJSONMembers, + ); err != nil { + return nil, err + } + wire := frostNativeSignerAnchorTrustTransitionRequestWire{} + if err := frostNativeSignerAnchorTrustDecodeJSON(data, &wire); err != nil { + return nil, err + } + if wire.Schema != FrostNativeSignerAnchorTrustTransitionRequestSchema || + wire.CertificateChain == nil || + len(*wire.CertificateChain) == 0 || + len(*wire.CertificateChain) > + FrostNativeSignerAnchorTrustMaximumCertificateChainLength { + return nil, fmt.Errorf("native signer anchor trust transition request is incomplete") + } + chain := make([]FrostNativeSignerAnchorTrustCertificate, len(*wire.CertificateChain)) + for index, certificateJSON := range *wire.CertificateChain { + certificate, err := DecodeFrostNativeSignerAnchorTrustCertificate( + certificateJSON, + ) + if err != nil { + return nil, fmt.Errorf( + "invalid native signer anchor trust certificate [%d]: %w", + index, + err, + ) + } + chain[index] = *certificate + } + readResponse, err := frostNativeSignerAnchorTrustDecodeBase64JSON( + wire.TargetReadResponseBase64, + frostNativeSignerAnchorTrustMaximumReadResponseBytes, + "target Read response", + ) + if err != nil { + return nil, err + } + return &FrostNativeSignerAnchorTrustTransitionRequest{ + CertificateChain: chain, + TargetReadResponse: readResponse, + }, nil +} + +// EncodeFrostNativeSignerAnchorTrustTransitionRequest emits canonical request +// JSON while retaining exact certificate acknowledgement and Read bytes. +func EncodeFrostNativeSignerAnchorTrustTransitionRequest( + request *FrostNativeSignerAnchorTrustTransitionRequest, +) ([]byte, error) { + if request == nil || + len(request.CertificateChain) == 0 || + len(request.CertificateChain) > + FrostNativeSignerAnchorTrustMaximumCertificateChainLength { + return nil, fmt.Errorf("native signer anchor trust transition request is invalid") + } + if err := frostNativeSignerAnchorTrustValidateEmbeddedJSON( + request.TargetReadResponse, + frostNativeSignerAnchorTrustMaximumReadResponseBytes, + "target Read response", + ); err != nil { + return nil, err + } + wireChain := make([]json.RawMessage, len(request.CertificateChain)) + for index := range request.CertificateChain { + encoded, err := EncodeFrostNativeSignerAnchorTrustCertificate( + &request.CertificateChain[index], + ) + if err != nil { + return nil, err + } + wireChain[index] = encoded + } + wire := frostNativeSignerAnchorTrustTransitionRequestWire{ + Schema: FrostNativeSignerAnchorTrustTransitionRequestSchema, + CertificateChain: &wireChain, + TargetReadResponseBase64: base64.StdEncoding.EncodeToString(request.TargetReadResponse), + } + encoded, err := json.Marshal(wire) + if err != nil { + return nil, err + } + if len(encoded) > frostNativeSignerAnchorTrustMaximumTransitionRequestBytes { + return nil, fmt.Errorf( + "native signer anchor trust transition request exceeds the FFI bound", + ) + } + return encoded, nil +} + +// ValidateFrostNativeSignerAnchorTrustCertificate validates all certificate +// structure, fixed transcripts, authority signatures, derived IDs, and exact +// target acknowledgement bytes before invoking the mandatory semantic seam. +func ValidateFrostNativeSignerAnchorTrustCertificate( + certificate *FrostNativeSignerAnchorTrustCertificate, + validateTargetAcknowledgement FrostNativeSignerAnchorTrustTargetAcknowledgementValidator, +) error { + if certificate == nil { + return fmt.Errorf("native signer anchor trust certificate is nil") + } + if validateTargetAcknowledgement == nil { + return fmt.Errorf( + "native signer anchor trust target acknowledgement validator is required", + ) + } + if certificate.ProtocolID == [32]byte{} || + certificate.StreamID == [32]byte{} || + certificate.SignerStoreFingerprint == [32]byte{} || + certificate.CertificateSequence == 0 { + return fmt.Errorf("native signer anchor trust certificate identity is incomplete") + } + if err := frostNativeSignerAnchorTrustValidateEndpoint( + &certificate.To, + certificate.SignerStoreFingerprint, + "to", + ); err != nil { + return err + } + + var authority [ed25519.PublicKeySize]byte + switch certificate.Kind { + case FrostNativeSignerAnchorTrustCertificateBootstrap: + if certificate.From != nil || + certificate.CertificateSequence != 1 || + certificate.PreviousCertificateDigest != [32]byte{} || + certificate.To.Reference.ServiceEpoch != 1 || + certificate.To.Reference.Revision != 1 || + certificate.To.Reference.PreviousEventRoot != [32]byte{} { + return fmt.Errorf("native signer anchor bootstrap certificate invariants are invalid") + } + authority = certificate.To.OfflineAuthorityPublicKey + case FrostNativeSignerAnchorTrustCertificateRotation: + if certificate.From == nil { + return fmt.Errorf("native signer anchor rotation certificate has no from endpoint") + } + if err := frostNativeSignerAnchorTrustValidateEndpoint( + certificate.From, + certificate.SignerStoreFingerprint, + "from", + ); err != nil { + return err + } + if certificate.To.OfflineAuthorityPublicKey != + certificate.From.OfflineAuthorityPublicKey || + certificate.To.OfflineAuthoritySPKISHA256 != + certificate.From.OfflineAuthoritySPKISHA256 { + return fmt.Errorf("native signer anchor offline authority rotation is unsupported") + } + if certificate.From.ActivationManifestSequence == ^uint64(0) || + certificate.To.ActivationManifestSequence != + certificate.From.ActivationManifestSequence+1 || + certificate.From.Reference.ServiceEpoch == ^uint64(0) || + certificate.To.Reference.ServiceEpoch != + certificate.From.Reference.ServiceEpoch+1 || + certificate.To.Reference.Revision != 1 || + certificate.To.Reference.PreviousEventRoot != + certificate.From.Reference.EventRoot || + certificate.To.Reference.Checkpoint != + certificate.From.Reference.Checkpoint || + certificate.To.WitnessMaximumRecords != + certificate.From.WitnessMaximumRecords || + certificate.To.WitnessRotationThresholdRecords != + certificate.From.WitnessRotationThresholdRecords { + return fmt.Errorf("native signer anchor rotation transition invariants are invalid") + } + if certificate.To.ActivationManifestHash == + certificate.From.ActivationManifestHash || + certificate.To.BindingHash == certificate.From.BindingHash { + return fmt.Errorf("native signer anchor rotation does not change its manifest binding") + } + switch { + case certificate.CertificateSequence == 1: + if certificate.PreviousCertificateDigest != [32]byte{} { + return fmt.Errorf("legacy-adoption certificate has a previous digest") + } + case certificate.PreviousCertificateDigest == [32]byte{}: + return fmt.Errorf("rotation certificate previous digest is absent") + } + authority = certificate.From.OfflineAuthorityPublicKey + default: + return fmt.Errorf("native signer anchor trust certificate kind is invalid") + } + + coreDigest, err := ComputeFrostNativeSignerAnchorTrustCoreDigest(certificate) + if err != nil || coreDigest != certificate.CoreDigest { + return fmt.Errorf("native signer anchor trust certificate core digest mismatch") + } + if !ed25519.Verify( + ed25519.PublicKey(authority[:]), + certificate.CoreDigest[:], + certificate.CoreSignature[:], + ) { + return fmt.Errorf("native signer anchor trust certificate core signature is invalid") + } + operationID := ComputeFrostNativeSignerAnchorTrustOperationID( + certificate.CoreDigest, + ) + if operationID != certificate.OperationID { + return fmt.Errorf("native signer anchor trust certificate operation ID mismatch") + } + transitionDigest := ComputeFrostNativeSignerAnchorTrustTransitionDigest( + certificate.CoreDigest, + certificate.OperationID, + ) + if transitionDigest != certificate.TransitionDigest { + return fmt.Errorf("native signer anchor trust certificate transition digest mismatch") + } + if err := frostNativeSignerAnchorTrustValidateEmbeddedJSON( + certificate.TargetAcknowledgement, + frostNativeSignerAnchorTrustMaximumAcknowledgementBytes, + "target acknowledgement", + ); err != nil { + return err + } + if sha256.Sum256(certificate.TargetAcknowledgement) != + certificate.TargetAcknowledgementSHA256 { + return fmt.Errorf("native signer anchor target acknowledgement hash mismatch") + } + finalDigest, err := ComputeFrostNativeSignerAnchorTrustFinalDigest(certificate) + if err != nil { + return err + } + if !ed25519.Verify( + ed25519.PublicKey(authority[:]), + finalDigest[:], + certificate.FinalSignature[:], + ) { + return fmt.Errorf("native signer anchor trust certificate final signature is invalid") + } + certificateDigest, err := + ComputeFrostNativeSignerAnchorTrustCertificateDigest(certificate) + if err != nil || certificateDigest != certificate.CertificateDigest { + return fmt.Errorf("native signer anchor trust certificate digest mismatch") + } + validatedCertificate := *certificate + validatedCertificate.TargetAcknowledgement = append( + []byte{}, + certificate.TargetAcknowledgement..., + ) + if certificate.From != nil { + from := *certificate.From + validatedCertificate.From = &from + } + rawAcknowledgement := append([]byte{}, certificate.TargetAcknowledgement...) + if err := validateTargetAcknowledgement( + &validatedCertificate, + rawAcknowledgement, + ); err != nil { + return fmt.Errorf( + "native signer anchor trust target acknowledgement is invalid: %w", + err, + ) + } + return nil +} + +// ValidateFrostNativeSignerAnchorTrustCertificateChain validates an exact +// 1..64 missing suffix. Without PriorHead, the suffix must start at sequence 1 +// with a bootstrap or explicitly allowed legacy adoption. With PriorHead, its +// first certificate must extend that authenticated head exactly. +func ValidateFrostNativeSignerAnchorTrustCertificateChain( + chain []FrostNativeSignerAnchorTrustCertificate, + options FrostNativeSignerAnchorTrustChainValidationOptions, +) error { + if len(chain) == 0 || + len(chain) > FrostNativeSignerAnchorTrustMaximumCertificateChainLength { + return fmt.Errorf("native signer anchor trust certificate chain length is invalid") + } + if err := frostNativeSignerAnchorTrustValidateChainOptions(options); err != nil { + return err + } + exactReplay := options.PriorHead != nil && + *options.PriorHead == *options.ExpectedHead + if exactReplay && len(chain) != 1 { + return fmt.Errorf( + "native signer anchor trust exact replay contains additional certificates", + ) + } + head := &chain[len(chain)-1] + if head.CertificateSequence != options.ExpectedHead.CertificateSequence || + head.CertificateDigest != options.ExpectedHead.CertificateDigest || + head.ProtocolID != options.ExpectedHead.ProtocolID || + head.StreamID != options.ExpectedHead.StreamID || + head.SignerStoreFingerprint != + options.ExpectedHead.SignerStoreFingerprint || + head.To != options.ExpectedHead.Endpoint { + return fmt.Errorf("native signer anchor trust certificate head mismatch") + } + for index := range chain { + certificate := &chain[index] + if index == 0 { + if exactReplay { + // The frozen ABI forbids an empty chain. Revalidate the exact + // already-installed head as the sole idempotent request item. + } else if options.PriorHead == nil { + if certificate.CertificateSequence != 1 || + certificate.PreviousCertificateDigest != [32]byte{} { + return fmt.Errorf( + "first native signer anchor trust certificate is not sequence one", + ) + } + if certificate.Kind == + FrostNativeSignerAnchorTrustCertificateRotation && + !options.AllowLegacyAdoption { + return fmt.Errorf( + "legacy native signer anchor adoption is not authorized", + ) + } + } else { + prior := options.PriorHead + if prior.CertificateSequence == ^uint64(0) || + certificate.Kind != + FrostNativeSignerAnchorTrustCertificateRotation || + certificate.CertificateSequence != + prior.CertificateSequence+1 || + certificate.PreviousCertificateDigest != + prior.CertificateDigest || + certificate.From == nil || + certificate.ProtocolID != prior.ProtocolID || + certificate.StreamID != prior.StreamID || + certificate.SignerStoreFingerprint != + prior.SignerStoreFingerprint { + return fmt.Errorf( + "first native signer anchor trust certificate does not extend the authenticated prior head", + ) + } + if !frostNativeSignerAnchorTrustStaticEndpointEqual( + *certificate.From, + prior.Endpoint, + ) { + return fmt.Errorf( + "first native signer anchor trust certificate changes the authenticated prior endpoint identity", + ) + } + if err := frostNativeSignerAnchorTrustValidateReferenceDescendant( + prior.Endpoint.Reference, + certificate.From.Reference, + ); err != nil { + return fmt.Errorf( + "first native signer anchor trust certificate from reference is not an authenticated prior-floor descendant: %w", + err, + ) + } + } + } else { + previous := &chain[index-1] + if previous.CertificateSequence == ^uint64(0) || + certificate.CertificateSequence != + previous.CertificateSequence+1 || + certificate.Kind != + FrostNativeSignerAnchorTrustCertificateRotation || + certificate.PreviousCertificateDigest != + previous.CertificateDigest || + certificate.From == nil || + certificate.ProtocolID != previous.ProtocolID || + certificate.StreamID != previous.StreamID || + certificate.SignerStoreFingerprint != + previous.SignerStoreFingerprint { + return fmt.Errorf( + "native signer anchor trust certificate [%d] does not extend its exact predecessor", + index, + ) + } + if !frostNativeSignerAnchorTrustStaticEndpointEqual( + *certificate.From, + previous.To, + ) { + return fmt.Errorf( + "native signer anchor trust certificate [%d] changes its predecessor endpoint identity", + index, + ) + } + if err := frostNativeSignerAnchorTrustValidateReferenceDescendant( + previous.To.Reference, + certificate.From.Reference, + ); err != nil { + return fmt.Errorf( + "native signer anchor trust certificate [%d] from reference is not a predecessor-floor descendant: %w", + index, + err, + ) + } + } + if certificate.ProtocolID != options.ExpectedProtocolID || + certificate.StreamID != options.ExpectedStreamID || + certificate.SignerStoreFingerprint != + options.ExpectedSignerStoreFingerprint || + certificate.To.OfflineAuthorityPublicKey != + options.ExpectedOfflineAuthorityPublicKey || + certificate.To.OfflineAuthoritySPKISHA256 != + options.ExpectedOfflineAuthoritySPKISHA256 { + return fmt.Errorf( + "native signer anchor trust certificate [%d] pin mismatch", + index, + ) + } + if err := ValidateFrostNativeSignerAnchorTrustCertificate( + certificate, + options.ValidateTargetAcknowledgement, + ); err != nil { + return fmt.Errorf( + "invalid native signer anchor trust certificate [%d]: %w", + index, + err, + ) + } + } + return nil +} + +func authenticateFrostNativeSignerAnchorTrustCertificateChain( + chain []FrostNativeSignerAnchorTrustCertificate, + options FrostNativeSignerAnchorTrustChainValidationOptions, +) (*frostNativeSignerAnchorVerifiedTrustFloor, error) { + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + chain, + options, + ); err != nil { + return nil, err + } + certificate := frostNativeSignerAnchorTrustCloneCertificate( + &chain[len(chain)-1], + ) + return &frostNativeSignerAnchorVerifiedTrustFloor{ + certificate: certificate, + }, nil +} + +func frostNativeSignerAnchorTrustCloneCertificate( + certificate *FrostNativeSignerAnchorTrustCertificate, +) FrostNativeSignerAnchorTrustCertificate { + if certificate == nil { + return FrostNativeSignerAnchorTrustCertificate{} + } + result := *certificate + result.TargetAcknowledgement = append( + []byte{}, + certificate.TargetAcknowledgement..., + ) + if certificate.From != nil { + from := *certificate.From + result.From = &from + } + return result +} + +func frostNativeSignerAnchorTrustStaticEndpointEqual( + left FrostNativeSignerAnchorTrustEndpoint, + right FrostNativeSignerAnchorTrustEndpoint, +) bool { + return left.ActivationManifestHash == right.ActivationManifestHash && + left.ActivationManifestSequence == + right.ActivationManifestSequence && + left.BindingHash == right.BindingHash && + left.ResponsePublicKey == right.ResponsePublicKey && + left.ResponsePublicKeySPKISHA256 == + right.ResponsePublicKeySPKISHA256 && + left.OfflineAuthorityPublicKey == + right.OfflineAuthorityPublicKey && + left.OfflineAuthoritySPKISHA256 == + right.OfflineAuthoritySPKISHA256 && + left.WitnessMaximumRecords == right.WitnessMaximumRecords && + left.WitnessRotationThresholdRecords == + right.WitnessRotationThresholdRecords +} + +func frostNativeSignerAnchorTrustValidateReferenceDescendant( + floor FrostNativeSignerAnchorTrustReference, + candidate FrostNativeSignerAnchorTrustReference, +) error { + if floor.Revision != 1 || + candidate.ServiceEpoch != floor.ServiceEpoch || + candidate.Revision < floor.Revision || + candidate.Revision-floor.Revision > + FrostNativeSignerAnchorMaximumHistoryEvents { + return fmt.Errorf( + "reference is outside the restartable certified service-epoch floor", + ) + } + if candidate.Revision == floor.Revision { + if candidate != floor { + return fmt.Errorf( + "equal reference revisions identify different events", + ) + } + return nil + } + if candidate.PreviousEventRoot == [32]byte{} || + candidate.Checkpoint.Generation < floor.Checkpoint.Generation { + return fmt.Errorf( + "later reference is unlinked or rolls back its checkpoint generation", + ) + } + if candidate.Checkpoint.Generation == floor.Checkpoint.Generation && + candidate.Checkpoint != floor.Checkpoint { + return fmt.Errorf( + "later reference forks the certified checkpoint at an equal generation", + ) + } + return nil +} + +// DecodeAndValidateFrostNativeSignerAnchorTrustTransitionRequest performs the +// complete pure-Go request/chain validation while leaving the fresh outer Read +// wrapper available for the existing contextual verifier. +func DecodeAndValidateFrostNativeSignerAnchorTrustTransitionRequest( + data []byte, + options FrostNativeSignerAnchorTrustChainValidationOptions, +) (*FrostNativeSignerAnchorTrustTransitionRequest, error) { + request, err := DecodeFrostNativeSignerAnchorTrustTransitionRequest(data) + if err != nil { + return nil, err + } + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + request.CertificateChain, + options, + ); err != nil { + return nil, err + } + return request, nil +} + +func frostNativeSignerAnchorTrustValidateChainOptions( + options FrostNativeSignerAnchorTrustChainValidationOptions, +) error { + if options.ExpectedProtocolID == [32]byte{} || + options.ExpectedStreamID == [32]byte{} || + options.ExpectedSignerStoreFingerprint == [32]byte{} || + options.ExpectedOfflineAuthorityPublicKey == + [ed25519.PublicKeySize]byte{} || + options.ExpectedOfflineAuthoritySPKISHA256 == [32]byte{} || + options.ExpectedHead == nil || + options.ValidateTargetAcknowledgement == nil { + return fmt.Errorf("native signer anchor trust validation pins are incomplete") + } + if ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + options.ExpectedOfflineAuthorityPublicKey, + ) != options.ExpectedOfflineAuthoritySPKISHA256 { + return fmt.Errorf("native signer anchor trust authority pins are inconsistent") + } + if err := frostNativeSignerAnchorTrustValidateHead( + options.ExpectedHead, + options, + "expected", + ); err != nil { + return err + } + if options.PriorHead != nil { + if err := frostNativeSignerAnchorTrustValidateHead( + options.PriorHead, + options, + "prior", + ); err != nil { + return err + } + if options.PriorHead.CertificateSequence >= + options.ExpectedHead.CertificateSequence && + *options.PriorHead != *options.ExpectedHead { + return fmt.Errorf( + "native signer anchor trust prior head does not precede the expected head", + ) + } + } + return nil +} + +func frostNativeSignerAnchorTrustValidateHead( + head *FrostNativeSignerAnchorTrustCertificateHead, + options FrostNativeSignerAnchorTrustChainValidationOptions, + name string, +) error { + if head == nil || + head.CertificateSequence == 0 || + head.CertificateDigest == [32]byte{} || + head.Endpoint.Reference.Revision != 1 || + head.ProtocolID != options.ExpectedProtocolID || + head.StreamID != options.ExpectedStreamID || + head.SignerStoreFingerprint != + options.ExpectedSignerStoreFingerprint || + head.Endpoint.OfflineAuthorityPublicKey != + options.ExpectedOfflineAuthorityPublicKey || + head.Endpoint.OfflineAuthoritySPKISHA256 != + options.ExpectedOfflineAuthoritySPKISHA256 { + return fmt.Errorf("native signer anchor trust %s head is incomplete", name) + } + if err := frostNativeSignerAnchorTrustValidateEndpoint( + &head.Endpoint, + head.SignerStoreFingerprint, + name+" head", + ); err != nil { + return err + } + return nil +} + +func frostNativeSignerAnchorTrustValidateEndpoint( + endpoint *FrostNativeSignerAnchorTrustEndpoint, + storeFingerprint [32]byte, + name string, +) error { + if endpoint == nil || + endpoint.ActivationManifestHash == [32]byte{} || + endpoint.ActivationManifestSequence == 0 || + endpoint.BindingHash == [32]byte{} || + endpoint.ResponsePublicKey == [ed25519.PublicKeySize]byte{} || + endpoint.OfflineAuthorityPublicKey == + [ed25519.PublicKeySize]byte{} || + endpoint.Reference.ServiceEpoch == 0 || + endpoint.Reference.Revision == 0 || + endpoint.Reference.EventRoot == [32]byte{} || + endpoint.Reference.AcknowledgementDigest == [32]byte{} { + return fmt.Errorf("native signer anchor trust %s endpoint is incomplete", name) + } + if ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + endpoint.ResponsePublicKey, + ) != endpoint.ResponsePublicKeySPKISHA256 { + return fmt.Errorf( + "native signer anchor trust %s response-key SPKI hash mismatch", + name, + ) + } + if err := frostNativeSignerAnchorTrustValidateEd25519Point( + endpoint.ResponsePublicKey, + ); err != nil { + return fmt.Errorf( + "native signer anchor trust %s response key is invalid: %w", + name, + err, + ) + } + if endpoint.ResponsePublicKey == endpoint.OfflineAuthorityPublicKey { + return fmt.Errorf( + "native signer anchor trust %s response and authority keys are not separated", + name, + ) + } + if ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + endpoint.OfflineAuthorityPublicKey, + ) != endpoint.OfflineAuthoritySPKISHA256 { + return fmt.Errorf( + "native signer anchor trust %s authority SPKI hash mismatch", + name, + ) + } + if err := frostNativeSignerAnchorTrustValidateEd25519Point( + endpoint.OfflineAuthorityPublicKey, + ); err != nil { + return fmt.Errorf( + "native signer anchor trust %s authority key is invalid: %w", + name, + err, + ) + } + if endpoint.WitnessMaximumRecords < 4 || + endpoint.WitnessMaximumRecords > 1_000_000 || + endpoint.WitnessRotationThresholdRecords < 2 || + endpoint.WitnessRotationThresholdRecords > + endpoint.WitnessMaximumRecords-2 { + return fmt.Errorf( + "native signer anchor trust %s witness geometry is invalid", + name, + ) + } + if err := validateFrostNativeSignerAnchorCheckpoint( + endpoint.Reference.Checkpoint, + storeFingerprint, + ); err != nil { + return fmt.Errorf( + "native signer anchor trust %s checkpoint is invalid: %w", + name, + err, + ) + } + return nil +} + +func frostNativeSignerAnchorTrustValidateEd25519Point( + publicKey [ed25519.PublicKeySize]byte, +) error { + point, err := edwards.ParsePubKey(publicKey[:]) + if err != nil || point == nil || + !bytes.Equal(point.Serialize(), publicKey[:]) { + return fmt.Errorf("non-canonical or off-curve Ed25519 point") + } + + // Go's crypto/ed25519 verifier accepts the identity public key with the + // trivial R=identity,S=0 signature, while Rust's ed25519-dalek + // verify_strict rejects small-order keys. Enforce the stronger common trust + // boundary explicitly: the key must be a non-identity member of the prime + // order subgroup. [l]P == identity excludes every non-trivial torsion + // component on the cofactor-8 Edwards25519 curve. + curve := edwards.Edwards() + if point.X.Sign() == 0 && + point.Y.Cmp(frostNativeSignerAnchorTrustEd25519IdentityY) == 0 { + return fmt.Errorf("identity Ed25519 point") + } + subgroupX, subgroupY := curve.ScalarMult( + point.X, + point.Y, + curve.Params().N.Bytes(), + ) + if subgroupX == nil || subgroupY == nil || + subgroupX.Sign() != 0 || + subgroupY.Cmp(frostNativeSignerAnchorTrustEd25519IdentityY) != 0 { + return fmt.Errorf("Ed25519 point is not in the prime-order subgroup") + } + return nil +} + +// ValidateFrostNativeSignerAnchorTrustEd25519PublicKey exposes the exact +// cross-language key predicate used before every trust-boundary Ed25519 +// verification. It rejects non-canonical, off-curve, identity, and torsion +// points even when the standard-library verifier would accept a degenerate +// signature for them. +func ValidateFrostNativeSignerAnchorTrustEd25519PublicKey( + publicKey []byte, +) error { + if len(publicKey) != ed25519.PublicKeySize { + return fmt.Errorf( + "Ed25519 public key length [%d] differs from [%d]", + len(publicKey), + ed25519.PublicKeySize, + ) + } + var fixed [ed25519.PublicKeySize]byte + copy(fixed[:], publicKey) + return frostNativeSignerAnchorTrustValidateEd25519Point(fixed) +} + +func frostNativeSignerAnchorTrustKindByte( + kind FrostNativeSignerAnchorTrustCertificateKind, +) (byte, error) { + switch kind { + case FrostNativeSignerAnchorTrustCertificateBootstrap: + return frostNativeSignerAnchorTrustBootstrapKindByte, nil + case FrostNativeSignerAnchorTrustCertificateRotation: + return frostNativeSignerAnchorTrustRotationKindByte, nil + default: + return 0, fmt.Errorf("native signer anchor trust certificate kind is invalid") + } +} + +func frostNativeSignerAnchorTrustWriteCoreFrom( + buffer *bytes.Buffer, + endpoint FrostNativeSignerAnchorTrustEndpoint, +) { + frostNativeSignerAnchorTrustWriteEndpointStatic(buffer, endpoint) + frostNativeSignerAnchorTrustWriteUint64( + buffer, + endpoint.Reference.ServiceEpoch, + ) + frostNativeSignerAnchorTrustWriteUint64( + buffer, + endpoint.Reference.Revision, + ) + buffer.Write(endpoint.Reference.PreviousEventRoot[:]) + buffer.Write(endpoint.Reference.EventRoot[:]) + buffer.Write(endpoint.Reference.AcknowledgementDigest[:]) + frostNativeSignerAnchorTrustWriteCheckpoint( + buffer, + endpoint.Reference.Checkpoint, + ) +} + +func frostNativeSignerAnchorTrustWriteCoreTo( + buffer *bytes.Buffer, + endpoint FrostNativeSignerAnchorTrustEndpoint, +) { + frostNativeSignerAnchorTrustWriteEndpointStatic(buffer, endpoint) + frostNativeSignerAnchorTrustWriteUint64( + buffer, + endpoint.Reference.ServiceEpoch, + ) + frostNativeSignerAnchorTrustWriteCheckpoint( + buffer, + endpoint.Reference.Checkpoint, + ) +} + +func frostNativeSignerAnchorTrustWriteEndpointStatic( + buffer *bytes.Buffer, + endpoint FrostNativeSignerAnchorTrustEndpoint, +) { + buffer.Write(endpoint.ActivationManifestHash[:]) + frostNativeSignerAnchorTrustWriteUint64( + buffer, + endpoint.ActivationManifestSequence, + ) + buffer.Write(endpoint.BindingHash[:]) + buffer.Write(endpoint.ResponsePublicKey[:]) + buffer.Write(endpoint.ResponsePublicKeySPKISHA256[:]) + buffer.Write(endpoint.OfflineAuthorityPublicKey[:]) + buffer.Write(endpoint.OfflineAuthoritySPKISHA256[:]) + frostNativeSignerAnchorTrustWriteUint64( + buffer, + endpoint.WitnessMaximumRecords, + ) + frostNativeSignerAnchorTrustWriteUint64( + buffer, + endpoint.WitnessRotationThresholdRecords, + ) +} + +func frostNativeSignerAnchorTrustWriteCheckpoint( + buffer *bytes.Buffer, + checkpoint FrostNativeSignerStateWitnessCheckpoint, +) { + buffer.Write(checkpoint.StoreFingerprint[:]) + frostNativeSignerAnchorTrustWriteUint64(buffer, checkpoint.Generation) + buffer.Write(checkpoint.PreviousStateCommitment[:]) + buffer.Write(checkpoint.StateImageDigest[:]) + buffer.Write(checkpoint.StateCommitment[:]) +} + +func frostNativeSignerAnchorTrustWriteUint64( + buffer *bytes.Buffer, + value uint64, +) { + _ = binary.Write(buffer, binary.BigEndian, value) +} + +func frostNativeSignerAnchorTrustCertificateFromWire( + wire frostNativeSignerAnchorTrustCertificateWire, +) (*FrostNativeSignerAnchorTrustCertificate, error) { + if wire.Schema != FrostNativeSignerAnchorTrustCertificateSchema || + wire.To == nil { + return nil, fmt.Errorf("native signer anchor trust certificate is incomplete") + } + certificate := &FrostNativeSignerAnchorTrustCertificate{ + Kind: FrostNativeSignerAnchorTrustCertificateKind(wire.Kind), + } + if _, err := frostNativeSignerAnchorTrustKindByte(certificate.Kind); err != nil { + return nil, err + } + var err error + if certificate.CertificateSequence, err = + frostNativeSignerAnchorParseUint64(wire.CertificateSequence); err != nil { + return nil, fmt.Errorf("invalid certificate sequence: %w", err) + } + bytes32Fields := []struct { + name string + value string + destination *[32]byte + }{ + {"previous certificate digest", wire.PreviousCertificateDigest, &certificate.PreviousCertificateDigest}, + {"protocol ID", wire.ProtocolID, &certificate.ProtocolID}, + {"stream ID", wire.StreamID, &certificate.StreamID}, + {"signer store fingerprint", wire.SignerStoreFingerprint, &certificate.SignerStoreFingerprint}, + {"core digest", wire.CoreDigest, &certificate.CoreDigest}, + {"operation ID", wire.OperationID, &certificate.OperationID}, + {"transition digest", wire.TransitionDigest, &certificate.TransitionDigest}, + {"target acknowledgement SHA-256", wire.TargetAcknowledgementSHA256, &certificate.TargetAcknowledgementSHA256}, + {"certificate digest", wire.CertificateDigest, &certificate.CertificateDigest}, + } + for _, field := range bytes32Fields { + value, err := frostNativeSignerAnchorParseHex32(field.value) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", field.name, err) + } + *field.destination = value + } + if certificate.CoreSignature, err = + frostNativeSignerAnchorTrustDecodeBase64Signature(wire.CoreSignature); err != nil { + return nil, fmt.Errorf("invalid core signature: %w", err) + } + if certificate.FinalSignature, err = + frostNativeSignerAnchorTrustDecodeBase64Signature(wire.FinalSignature); err != nil { + return nil, fmt.Errorf("invalid final signature: %w", err) + } + if wire.From != nil { + from, err := frostNativeSignerAnchorTrustEndpointFromWire(*wire.From) + if err != nil { + return nil, fmt.Errorf("invalid from endpoint: %w", err) + } + certificate.From = &from + } + if (certificate.Kind == FrostNativeSignerAnchorTrustCertificateBootstrap && + certificate.From != nil) || + (certificate.Kind == FrostNativeSignerAnchorTrustCertificateRotation && + certificate.From == nil) { + return nil, fmt.Errorf( + "native signer anchor trust certificate from endpoint does not match its kind", + ) + } + certificate.To, err = frostNativeSignerAnchorTrustEndpointFromWire(*wire.To) + if err != nil { + return nil, fmt.Errorf("invalid to endpoint: %w", err) + } + certificate.TargetAcknowledgement, err = + frostNativeSignerAnchorTrustDecodeBase64JSON( + wire.TargetAcknowledgementBase64, + frostNativeSignerAnchorTrustMaximumAcknowledgementBytes, + "target acknowledgement", + ) + if err != nil { + return nil, err + } + return certificate, nil +} + +func frostNativeSignerAnchorTrustEndpointFromWire( + wire frostNativeSignerAnchorTrustEndpointWire, +) (FrostNativeSignerAnchorTrustEndpoint, error) { + if wire.Reference == nil { + return FrostNativeSignerAnchorTrustEndpoint{}, fmt.Errorf("endpoint reference is absent") + } + result := FrostNativeSignerAnchorTrustEndpoint{} + var err error + if result.ActivationManifestSequence, err = + frostNativeSignerAnchorParseUint64(wire.ActivationManifestSequence); err != nil { + return result, fmt.Errorf("invalid activation manifest sequence: %w", err) + } + if result.WitnessMaximumRecords, err = + frostNativeSignerAnchorParseUint64(wire.WitnessMaximumRecords); err != nil { + return result, fmt.Errorf("invalid witness maximum records: %w", err) + } + if result.WitnessRotationThresholdRecords, err = + frostNativeSignerAnchorParseUint64( + wire.WitnessRotationThresholdRecords, + ); err != nil { + return result, fmt.Errorf("invalid witness rotation threshold: %w", err) + } + fields := []struct { + name string + value string + destination *[32]byte + }{ + {"activation manifest hash", wire.ActivationManifestHash, &result.ActivationManifestHash}, + {"binding hash", wire.BindingHash, &result.BindingHash}, + {"response public key", wire.ResponsePublicKey, &result.ResponsePublicKey}, + {"response public-key SPKI SHA-256", wire.ResponsePublicKeySPKISHA256, &result.ResponsePublicKeySPKISHA256}, + {"offline authority public key", wire.OfflineAuthorityPublicKey, &result.OfflineAuthorityPublicKey}, + {"offline authority SPKI SHA-256", wire.OfflineAuthoritySPKISHA256, &result.OfflineAuthoritySPKISHA256}, + } + for _, field := range fields { + value, err := frostNativeSignerAnchorParseHex32(field.value) + if err != nil { + return result, fmt.Errorf("invalid %s: %w", field.name, err) + } + *field.destination = value + } + result.Reference, err = + frostNativeSignerAnchorTrustReferenceFromWire(*wire.Reference) + if err != nil { + return result, err + } + return result, nil +} + +func frostNativeSignerAnchorTrustReferenceFromWire( + wire frostNativeSignerAnchorTrustReferenceWire, +) (FrostNativeSignerAnchorTrustReference, error) { + if wire.Checkpoint == nil { + return FrostNativeSignerAnchorTrustReference{}, fmt.Errorf( + "trust reference checkpoint is absent", + ) + } + result := FrostNativeSignerAnchorTrustReference{} + var err error + if result.ServiceEpoch, err = + frostNativeSignerAnchorParseUint64(wire.ServiceEpoch); err != nil { + return result, fmt.Errorf("invalid service epoch: %w", err) + } + if result.Revision, err = + frostNativeSignerAnchorParseUint64(wire.Revision); err != nil { + return result, fmt.Errorf("invalid revision: %w", err) + } + fields := []struct { + name string + value string + destination *[32]byte + }{ + {"previous event root", wire.PreviousEventRoot, &result.PreviousEventRoot}, + {"event root", wire.EventRoot, &result.EventRoot}, + {"checkpoint acknowledgement digest", wire.CheckpointAckDigest, &result.AcknowledgementDigest}, + } + for _, field := range fields { + value, err := frostNativeSignerAnchorParseHex32(field.value) + if err != nil { + return result, fmt.Errorf("invalid %s: %w", field.name, err) + } + *field.destination = value + } + result.Checkpoint, err = + frostNativeSignerAnchorTrustCheckpointFromWire(*wire.Checkpoint) + if err != nil { + return result, err + } + return result, nil +} + +func frostNativeSignerAnchorTrustCheckpointFromWire( + wire frostNativeSignerAnchorTrustCheckpointWire, +) (FrostNativeSignerStateWitnessCheckpoint, error) { + return frostNativeSignerAnchorCheckpointFromWire( + frostNativeSignerAnchorCheckpointWire{ + StoreFingerprint: wire.StoreFingerprint, + Generation: wire.Generation, + PreviousStateCommitment: wire.PreviousStateCommitment, + StateImageDigest: wire.StateImageDigest, + StateCommitment: wire.StateCommitment, + }, + ) +} + +func frostNativeSignerAnchorTrustCertificateToWire( + certificate *FrostNativeSignerAnchorTrustCertificate, +) frostNativeSignerAnchorTrustCertificateWire { + var from *frostNativeSignerAnchorTrustEndpointWire + if certificate.From != nil { + value := frostNativeSignerAnchorTrustEndpointToWire(*certificate.From) + from = &value + } + to := frostNativeSignerAnchorTrustEndpointToWire(certificate.To) + return frostNativeSignerAnchorTrustCertificateWire{ + Schema: FrostNativeSignerAnchorTrustCertificateSchema, + Kind: string(certificate.Kind), + CertificateSequence: fmt.Sprint(certificate.CertificateSequence), + PreviousCertificateDigest: frostNativeSignerAnchorHex32(certificate.PreviousCertificateDigest), + ProtocolID: frostNativeSignerAnchorHex32(certificate.ProtocolID), + StreamID: frostNativeSignerAnchorHex32(certificate.StreamID), + SignerStoreFingerprint: frostNativeSignerAnchorHex32(certificate.SignerStoreFingerprint), + From: from, + To: &to, + CoreDigest: frostNativeSignerAnchorHex32(certificate.CoreDigest), + CoreSignature: base64.StdEncoding.EncodeToString(certificate.CoreSignature[:]), + OperationID: frostNativeSignerAnchorHex32(certificate.OperationID), + TransitionDigest: frostNativeSignerAnchorHex32(certificate.TransitionDigest), + TargetAcknowledgementBase64: base64.StdEncoding.EncodeToString(certificate.TargetAcknowledgement), + TargetAcknowledgementSHA256: frostNativeSignerAnchorHex32(certificate.TargetAcknowledgementSHA256), + FinalSignature: base64.StdEncoding.EncodeToString(certificate.FinalSignature[:]), + CertificateDigest: frostNativeSignerAnchorHex32(certificate.CertificateDigest), + } +} + +func frostNativeSignerAnchorTrustEndpointToWire( + endpoint FrostNativeSignerAnchorTrustEndpoint, +) frostNativeSignerAnchorTrustEndpointWire { + reference := frostNativeSignerAnchorTrustReferenceToWire(endpoint.Reference) + return frostNativeSignerAnchorTrustEndpointWire{ + ActivationManifestHash: frostNativeSignerAnchorHex32(endpoint.ActivationManifestHash), + ActivationManifestSequence: fmt.Sprint(endpoint.ActivationManifestSequence), + BindingHash: frostNativeSignerAnchorHex32(endpoint.BindingHash), + ResponsePublicKey: frostNativeSignerAnchorHex32(endpoint.ResponsePublicKey), + ResponsePublicKeySPKISHA256: frostNativeSignerAnchorHex32(endpoint.ResponsePublicKeySPKISHA256), + OfflineAuthorityPublicKey: frostNativeSignerAnchorHex32(endpoint.OfflineAuthorityPublicKey), + OfflineAuthoritySPKISHA256: frostNativeSignerAnchorHex32(endpoint.OfflineAuthoritySPKISHA256), + WitnessMaximumRecords: fmt.Sprint(endpoint.WitnessMaximumRecords), + WitnessRotationThresholdRecords: fmt.Sprint(endpoint.WitnessRotationThresholdRecords), + Reference: &reference, + } +} + +func frostNativeSignerAnchorTrustReferenceToWire( + reference FrostNativeSignerAnchorTrustReference, +) frostNativeSignerAnchorTrustReferenceWire { + checkpoint := frostNativeSignerAnchorTrustCheckpointToWire(reference.Checkpoint) + return frostNativeSignerAnchorTrustReferenceWire{ + ServiceEpoch: fmt.Sprint(reference.ServiceEpoch), + Revision: fmt.Sprint(reference.Revision), + PreviousEventRoot: frostNativeSignerAnchorHex32(reference.PreviousEventRoot), + EventRoot: frostNativeSignerAnchorHex32(reference.EventRoot), + CheckpointAckDigest: frostNativeSignerAnchorHex32(reference.AcknowledgementDigest), + Checkpoint: &checkpoint, + } +} + +func frostNativeSignerAnchorTrustCheckpointToWire( + checkpoint FrostNativeSignerStateWitnessCheckpoint, +) frostNativeSignerAnchorTrustCheckpointWire { + return frostNativeSignerAnchorTrustCheckpointWire{ + StoreFingerprint: frostNativeSignerAnchorHex32(checkpoint.StoreFingerprint), + Generation: fmt.Sprint(checkpoint.Generation), + PreviousStateCommitment: frostNativeSignerAnchorHex32(checkpoint.PreviousStateCommitment), + StateImageDigest: frostNativeSignerAnchorHex32(checkpoint.StateImageDigest), + StateCommitment: frostNativeSignerAnchorHex32(checkpoint.StateCommitment), + } +} + +func frostNativeSignerAnchorTrustDecodeBase64JSON( + value string, + maximum int, + name string, +) ([]byte, error) { + if value == "" { + return nil, fmt.Errorf("native signer anchor %s is absent", name) + } + decoded, err := base64.StdEncoding.Strict().DecodeString(value) + if err != nil || base64.StdEncoding.EncodeToString(decoded) != value { + return nil, fmt.Errorf( + "native signer anchor %s is not canonical padded base64", + name, + ) + } + if err := frostNativeSignerAnchorTrustValidateEmbeddedJSON( + decoded, + maximum, + name, + ); err != nil { + return nil, err + } + return decoded, nil +} + +func frostNativeSignerAnchorTrustDecodeBase64Signature( + value string, +) ([ed25519.SignatureSize]byte, error) { + decoded, err := base64.StdEncoding.Strict().DecodeString(value) + if err != nil || + len(decoded) != ed25519.SignatureSize || + base64.StdEncoding.EncodeToString(decoded) != value { + return [ed25519.SignatureSize]byte{}, fmt.Errorf( + "signature is not canonical padded base64 bytes64", + ) + } + var result [ed25519.SignatureSize]byte + copy(result[:], decoded) + return result, nil +} + +func frostNativeSignerAnchorTrustValidateEmbeddedJSON( + data []byte, + maximum int, + name string, +) error { + if len(data) == 0 || len(data) > maximum || !utf8.Valid(data) { + return fmt.Errorf("native signer anchor %s size or UTF-8 is invalid", name) + } + if err := frostNativeSignerAnchorTrustPreflightJSON( + data, + maximum, + nil, + ); err != nil { + return fmt.Errorf("invalid native signer anchor %s JSON: %w", name, err) + } + return nil +} + +func frostNativeSignerAnchorTrustDecodeJSON( + data []byte, + target interface{}, +) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("JSON contains trailing data") + } + return nil +} + +func frostNativeSignerAnchorTrustPreflightJSON( + data []byte, + maximum int, + allowedMembers map[string]struct{}, +) error { + if len(data) == 0 || len(data) > maximum || !utf8.Valid(data) { + return fmt.Errorf("native signer anchor trust JSON size or UTF-8 is invalid") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return fmt.Errorf("native signer anchor trust JSON must be one object") + } + if err := frostNativeSignerAnchorTrustScanJSONObject( + decoder, + 0, + allowedMembers, + ); err != nil { + return err + } + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("JSON contains trailing data") + } + return fmt.Errorf("invalid JSON trailing data: %w", err) + } + return nil +} + +func frostNativeSignerAnchorTrustPreflightJSONArray( + data []byte, + maximum int, + allowedMembers map[string]struct{}, +) error { + if len(data) == 0 || len(data) > maximum || !utf8.Valid(data) { + return fmt.Errorf("native signer anchor trust JSON size or UTF-8 is invalid") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + token, err := decoder.Token() + if err != nil || token != json.Delim('[') { + return fmt.Errorf("native signer anchor trust chain JSON must be one array") + } + for decoder.More() { + if err := frostNativeSignerAnchorTrustScanJSONValue( + decoder, + 1, + allowedMembers, + ); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim(']') { + return fmt.Errorf("invalid JSON array termination") + } + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("JSON contains trailing data") + } + return fmt.Errorf("invalid JSON trailing data: %w", err) + } + return nil +} + +func frostNativeSignerAnchorTrustScanJSONObject( + decoder *json.Decoder, + depth int, + allowedMembers map[string]struct{}, +) error { + if depth > frostNativeSignerAnchorTrustMaximumJSONDepth { + return fmt.Errorf("JSON nesting exceeds the depth bound") + } + seen := make(map[string]struct{}) + seenFolded := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return fmt.Errorf("invalid JSON object member: %w", err) + } + key, ok := keyToken.(string) + if !ok || !frostNativeSignerAnchorTrustASCIIJSONMember(key) { + return fmt.Errorf("JSON object member name is not canonical ASCII") + } + if allowedMembers != nil { + if _, allowed := allowedMembers[key]; !allowed { + return fmt.Errorf("JSON object member name [%s] is not exact", key) + } + } + folded := strings.ToLower(key) + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("JSON object contains duplicate member [%s]", key) + } + if _, duplicate := seenFolded[folded]; duplicate { + return fmt.Errorf( + "JSON object contains case-folded duplicate member [%s]", + key, + ) + } + seen[key] = struct{}{} + seenFolded[folded] = struct{}{} + if err := frostNativeSignerAnchorTrustScanJSONValue( + decoder, + depth+1, + allowedMembers, + ); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim('}') { + return fmt.Errorf("invalid JSON object termination") + } + return nil +} + +func frostNativeSignerAnchorTrustScanJSONValue( + decoder *json.Decoder, + depth int, + allowedMembers map[string]struct{}, +) error { + if depth > frostNativeSignerAnchorTrustMaximumJSONDepth { + return fmt.Errorf("JSON nesting exceeds the depth bound") + } + token, err := decoder.Token() + if err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + delimiter, isDelimiter := token.(json.Delim) + if !isDelimiter { + return nil + } + switch delimiter { + case '{': + return frostNativeSignerAnchorTrustScanJSONObject( + decoder, + depth, + allowedMembers, + ) + case '[': + for decoder.More() { + if err := frostNativeSignerAnchorTrustScanJSONValue( + decoder, + depth+1, + allowedMembers, + ); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim(']') { + return fmt.Errorf("invalid JSON array termination") + } + return nil + default: + return fmt.Errorf("unexpected JSON delimiter") + } +} + +func frostNativeSignerAnchorTrustASCIIJSONMember(value string) bool { + if value == "" { + return false + } + for _, character := range value { + if character < 0x21 || character > 0x7e { + return false + } + } + return true +} + +var frostNativeSignerAnchorTrustCertificateJSONMembers = map[string]struct{}{ + "schema": {}, + "kind": {}, + "certificateSequence": {}, + "previousCertificateDigest": {}, + "protocolID": {}, + "streamID": {}, + "signerStoreFingerprint": {}, + "from": {}, + "to": {}, + "coreDigest": {}, + "coreSignature": {}, + "operationID": {}, + "transitionDigest": {}, + "targetAcknowledgementBase64": {}, + "targetAcknowledgementSHA256": {}, + "finalSignature": {}, + "certificateDigest": {}, + "activationManifestHash": {}, + "activationManifestSequence": {}, + "bindingHash": {}, + "responsePublicKey": {}, + "responsePublicKeySpkiSha256": {}, + "offlineAuthorityPublicKey": {}, + "offlineAuthoritySpkiSha256": {}, + "witnessMaximumRecords": {}, + "witnessRotationThresholdRecords": {}, + "reference": {}, + "serviceEpoch": {}, + "revision": {}, + "previousEventRoot": {}, + "eventRoot": {}, + "checkpointAckDigest": {}, + "checkpoint": {}, + "storeFingerprint": {}, + "generation": {}, + "previousStateCommitment": {}, + "stateImageDigest": {}, + "stateCommitment": {}, +} + +var frostNativeSignerAnchorTrustRequestJSONMembers = func() map[string]struct{} { + result := make( + map[string]struct{}, + len(frostNativeSignerAnchorTrustCertificateJSONMembers)+3, + ) + for name := range frostNativeSignerAnchorTrustCertificateJSONMembers { + result[name] = struct{}{} + } + result["certificateChain"] = struct{}{} + result["targetReadResponseBase64"] = struct{}{} + return result +}() diff --git a/pkg/tbtc/frost_native_signer_anchor_trust_client.go b/pkg/tbtc/frost_native_signer_anchor_trust_client.go new file mode 100644 index 0000000000..f8e39cbda6 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_trust_client.go @@ -0,0 +1,392 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/json" + "fmt" + "time" +) + +// FrostNativeSignerAnchorTrustTransitionTarget retains both the exact signed +// Read wrapper passed to Rust and the fully verified service reference it +// represents. Startup uses the latter to validate Rust's transition result +// before it opens the signer store. +type FrostNativeSignerAnchorTrustTransitionTarget struct { + ExactReadResponse []byte + Reference FrostNativeSignerAnchorTrustReference +} + +// ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement is the +// certificate-contextual acknowledgement verifier used by production chain +// validation. It is intentionally separate from the generic same-epoch +// verifier: only an offline-authority-signed trust certificate may authorize a +// revision-one event whose predecessor root links the previous service epoch. +func ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement( + certificate *FrostNativeSignerAnchorTrustCertificate, + rawAcknowledgement []byte, +) error { + _, err := verifyFrostNativeSignerAnchorTrustTargetAcknowledgement( + certificate, + rawAcknowledgement, + ) + return err +} + +func verifyFrostNativeSignerAnchorTrustTargetAcknowledgement( + certificate *FrostNativeSignerAnchorTrustCertificate, + rawAcknowledgement []byte, +) (*FrostNativeSignerCheckpointAcknowledgement, error) { + if certificate == nil || len(rawAcknowledgement) == 0 { + return nil, fmt.Errorf( + "native signer trust target acknowledgement dependencies are incomplete", + ) + } + if err := frostNativeSignerAnchorTrustValidateEd25519Point( + certificate.To.ResponsePublicKey, + ); err != nil { + return nil, fmt.Errorf( + "native signer trust target response key is invalid: %w", + err, + ) + } + wire := frostNativeSignerAnchorAcknowledgementWire{} + if err := decodeStrictFrostNativeSignerAnchorJSON( + rawAcknowledgement, + &wire, + ); err != nil { + return nil, err + } + if wire.Schema != FrostNativeSignerCheckpointAcknowledgementSchema || + wire.Status != "applied" { + return nil, fmt.Errorf( + "native signer trust target acknowledgement schema or status is invalid", + ) + } + signingDigest, err := frostNativeSignerAnchorAcknowledgementTranscript(wire) + if err != nil { + return nil, err + } + signature, err := frostNativeSignerAnchorParseSignature(wire.Signature) + if err != nil || !ed25519.Verify( + ed25519.PublicKey(certificate.To.ResponsePublicKey[:]), + signingDigest, + signature[:], + ) { + return nil, fmt.Errorf( + "native signer trust target acknowledgement signature is invalid", + ) + } + acknowledgement, err := frostNativeSignerAnchorAcknowledgementFromWire(wire) + if err != nil { + return nil, err + } + target := certificate.To.Reference + if acknowledgement.BindingHash != certificate.To.BindingHash || + acknowledgement.RequestDigest == [32]byte{} || + acknowledgement.Nonce == [32]byte{} || + acknowledgement.ServiceEpoch != target.ServiceEpoch || + acknowledgement.Revision != target.Revision || + acknowledgement.PreviousEventRoot != target.PreviousEventRoot || + acknowledgement.EventRoot != target.EventRoot || + acknowledgement.Checkpoint != target.Checkpoint || + acknowledgement.OperationID != certificate.OperationID || + acknowledgement.TransitionDigest != certificate.TransitionDigest { + return nil, fmt.Errorf( + "native signer trust target acknowledgement differs from its certificate", + ) + } + if acknowledgement.ServiceEpoch == 0 || acknowledgement.Revision != 1 || + acknowledgement.CommittedAtUnixMs == 0 || + acknowledgement.ExpiresAtUnixMs <= + acknowledgement.CommittedAtUnixMs || + acknowledgement.ExpiresAtUnixMs- + acknowledgement.CommittedAtUnixMs > + uint64(frostNativeSignerAnchorMaximumAcknowledgementLifetime/ + time.Millisecond) { + return nil, fmt.Errorf( + "native signer trust target acknowledgement lifetime is invalid", + ) + } + if err := validateFrostNativeSignerAnchorCheckpoint( + acknowledgement.Checkpoint, + certificate.SignerStoreFingerprint, + ); err != nil { + return nil, err + } + if computeFrostNativeSignerAnchorEventRoot(*acknowledgement) != + acknowledgement.EventRoot { + return nil, fmt.Errorf( + "native signer trust target acknowledgement event root is invalid", + ) + } + acknowledgement.Signature = signature + copy(acknowledgement.SigningDigest[:], signingDigest) + acknowledgement.AcknowledgementDigest = + computeFrostNativeSignerCheckpointAcknowledgementDigest( + acknowledgement.SigningDigest, + signature, + certificate.To.ResponsePublicKeySPKISHA256, + ) + if acknowledgement.AcknowledgementDigest != + target.AcknowledgementDigest { + return nil, fmt.Errorf( + "native signer trust target acknowledgement digest is invalid", + ) + } + acknowledgement.ExactAcknowledgement = + append([]byte{}, rawAcknowledgement...) + return acknowledgement, nil +} + +// readFrostNativeSignerAnchorTrustTransitionTarget performs the fresh final +// Read required immediately before the startup-only Rust transition. The +// authenticated trust-floor capability installed by the private constructor +// supplies the exact certificate. Only an already-installed exact replay may +// return a generic, strictly validated descendant under the same final +// binding. +func (client *FrostNativeSignerAnchorClient) readFrostNativeSignerAnchorTrustTransitionTarget( + ctx context.Context, + allowCompletedReplayDescendant bool, +) (*FrostNativeSignerAnchorTrustTransitionTarget, error) { + if client == nil || ctx == nil || client.certifiedTrustFloor == nil { + return nil, fmt.Errorf( + "native signer anchor trust-transition Read dependencies are incomplete", + ) + } + finalCertificate := client.certifiedTrustFloor + client.mutex.Lock() + defer client.mutex.Unlock() + if client.poisoned != nil { + return nil, fmt.Errorf( + "native signer anchor client is poisoned: %w", + client.poisoned, + ) + } + if finalCertificate.To.BindingHash != client.bindingHash || + finalCertificate.ProtocolID != client.identity.ProtocolID || + finalCertificate.StreamID != client.identity.StreamID || + finalCertificate.SignerStoreFingerprint != + client.identity.SignerStoreFingerprint || + finalCertificate.To.ResponsePublicKeySPKISHA256 != + client.identity.OnlineKeyHash { + return nil, fmt.Errorf( + "native signer trust certificate differs from the final client identity", + ) + } + + nonce, err := client.randomBytes32() + if err != nil { + return nil, fmt.Errorf( + "cannot create native signer trust-transition Read nonce: %w", + err, + ) + } + transcript := frostNativeSignerAnchorReadRequestTranscript( + client.identity, + nonce, + client.clientSPKIDER, + ) + requestDigest := sha256.Sum256(transcript) + request := frostNativeSignerAnchorReadRequest{ + Schema: FrostNativeSignerAnchorReadRequestSchema, + Payload: frostNativeSignerAnchorReadRequestPayload{ + Kind: "read", + Nonce: frostNativeSignerAnchorHex32(nonce), + BindingHash: frostNativeSignerAnchorHex32(client.bindingHash), + Identity: frostNativeSignerAnchorIdentityToWire(client.identity), + }, + ClientPublicKeySPKI: client.clientSPKIBase64, + Signature: frostNativeSignerAnchorSignatureHex( + ed25519.Sign(client.clientKey, transcript), + ), + } + payload, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf( + "cannot encode native signer trust-transition Read: %w", + err, + ) + } + response, _, err := client.post(ctx, client.readEndpoint, payload) + if err != nil { + return nil, err + } + readResponse := frostNativeSignerAnchorReadResponse{} + if err := decodeStrictFrostNativeSignerAnchorJSON( + response, + &readResponse, + ); err != nil { + return nil, fmt.Errorf( + "invalid native signer trust-transition Read response: %w", + err, + ) + } + if readResponse.Schema != FrostNativeSignerAnchorReadResponseSchema || + readResponse.Status != "present" || + readResponse.Checkpoint == nil { + return nil, fmt.Errorf( + "native signer trust-transition Read response is absent or unsupported", + ) + } + responseDigest, err := + frostNativeSignerAnchorReadResponseTranscript(readResponse) + if err != nil { + return nil, err + } + responseSignature, err := + frostNativeSignerAnchorParseSignature(readResponse.Signature) + if err != nil || !ed25519.Verify( + client.onlineKey, + responseDigest, + responseSignature[:], + ) { + return nil, fmt.Errorf( + "native signer trust-transition Read signature is invalid", + ) + } + responseBindingHash, err := + frostNativeSignerAnchorParseHex32(readResponse.BindingHash) + if err != nil || responseBindingHash != client.bindingHash { + return nil, fmt.Errorf( + "native signer trust-transition Read binding is invalid", + ) + } + responseRequestDigest, err := + frostNativeSignerAnchorParseHex32(readResponse.RequestDigest) + if err != nil || responseRequestDigest != requestDigest { + return nil, fmt.Errorf( + "native signer trust-transition Read request digest is invalid", + ) + } + responseNonce, err := frostNativeSignerAnchorParseHex32(readResponse.Nonce) + if err != nil || responseNonce != nonce { + return nil, fmt.Errorf( + "native signer trust-transition Read nonce is invalid", + ) + } + checkpoint, err := + frostNativeSignerAnchorCheckpointFromWire(*readResponse.Checkpoint) + if err != nil { + return nil, err + } + operationID, err := + frostNativeSignerAnchorParseHex32(readResponse.OperationID) + if err != nil { + return nil, err + } + transitionDigest, err := + frostNativeSignerAnchorParseHex32(readResponse.TransitionDigest) + if err != nil { + return nil, err + } + + var acknowledgement *FrostNativeSignerCheckpointAcknowledgement + if bytes.Equal( + readResponse.CheckpointAck, + finalCertificate.TargetAcknowledgement, + ) { + acknowledgement, err = + verifyFrostNativeSignerAnchorTrustTargetAcknowledgement( + finalCertificate, + readResponse.CheckpointAck, + ) + } else { + if !allowCompletedReplayDescendant { + return nil, fmt.Errorf( + "new native signer trust transition target is not the exact certified acknowledgement", + ) + } + acknowledgement, err = client.verifyAcknowledgement( + readResponse.CheckpointAck, + nil, + nil, + &checkpoint, + &operationID, + false, + "applied", + "already-applied", + ) + if err == nil && + (acknowledgement.ServiceEpoch != + finalCertificate.To.Reference.ServiceEpoch || + acknowledgement.Revision <= + finalCertificate.To.Reference.Revision) { + err = fmt.Errorf( + "native signer trust-transition Read is neither the certified floor nor a descendant", + ) + } + } + if err != nil { + return nil, fmt.Errorf( + "invalid native signer trust-transition stored acknowledgement: %w", + err, + ) + } + if acknowledgement.Checkpoint != checkpoint || + acknowledgement.OperationID != operationID || + acknowledgement.TransitionDigest != transitionDigest { + return nil, fmt.Errorf( + "native signer trust-transition Read summary differs from its acknowledgement", + ) + } + serviceEpoch, err := + frostNativeSignerAnchorParseUint64(readResponse.ServiceEpoch) + if err != nil { + return nil, err + } + revision, err := frostNativeSignerAnchorParseUint64(readResponse.Revision) + if err != nil { + return nil, err + } + eventRoot, err := frostNativeSignerAnchorParseHex32(readResponse.EventRoot) + if err != nil { + return nil, err + } + acknowledgementDigest, err := frostNativeSignerAnchorParseHex32( + readResponse.CheckpointAckDigest, + ) + if err != nil || + serviceEpoch != acknowledgement.ServiceEpoch || + revision != acknowledgement.Revision || + eventRoot != acknowledgement.EventRoot || + acknowledgementDigest != acknowledgement.AcknowledgementDigest { + return nil, fmt.Errorf( + "native signer trust-transition Read summary is inconsistent", + ) + } + committedAt, err := + frostNativeSignerAnchorParseUint64(readResponse.CommittedAtUnixMs) + if err != nil { + return nil, err + } + expiresAt, err := + frostNativeSignerAnchorParseUint64(readResponse.ExpiresAtUnixMs) + if err != nil { + return nil, err + } + nowUnixMs := client.now().UnixMilli() + if nowUnixMs < 0 || committedAt == 0 || expiresAt <= committedAt || + expiresAt-committedAt > + uint64(client.maximumAckLife/time.Millisecond) || + committedAt > + uint64(nowUnixMs)+uint64(client.clockSkew/time.Millisecond) || + expiresAt <= uint64(nowUnixMs) { + return nil, fmt.Errorf( + "native signer trust-transition Read wrapper is stale or invalid", + ) + } + return &FrostNativeSignerAnchorTrustTransitionTarget{ + ExactReadResponse: append([]byte{}, response...), + Reference: FrostNativeSignerAnchorTrustReference{ + ServiceEpoch: acknowledgement.ServiceEpoch, + Revision: acknowledgement.Revision, + PreviousEventRoot: acknowledgement.PreviousEventRoot, + EventRoot: acknowledgement.EventRoot, + AcknowledgementDigest: acknowledgement.AcknowledgementDigest, + Checkpoint: acknowledgement.Checkpoint, + }, + }, nil +} diff --git a/pkg/tbtc/frost_native_signer_anchor_trust_client_test.go b/pkg/tbtc/frost_native_signer_anchor_trust_client_test.go new file mode 100644 index 0000000000..c69f1d0d6b --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_trust_client_test.go @@ -0,0 +1,132 @@ +package tbtc + +import ( + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "testing" + "time" +) + +func TestValidateFrostNativeSignerAnchorTrustTargetAcknowledgementAllowsOnlyCertifiedEpochParent( + t *testing.T, +) { + onlinePrivate := ed25519.NewKeyFromSeed( + bytesRepeatForFrostNativeSignerTrustTest(0x41, ed25519.SeedSize), + ) + onlinePublic := onlinePrivate.Public().(ed25519.PublicKey) + onlineSPKI, err := x509.MarshalPKIXPublicKey(onlinePublic) + if err != nil { + t.Fatal(err) + } + storeFingerprint := [32]byte{0x51} + identity := FrostNativeSignerAnchorIdentity{ + ProtocolID: [32]byte{0x52}, + ActivationManifestHash: [32]byte{0x53}, + ActivationManifestSequence: 2, + TrustDomainID: "trust-client-test", + OnlineKeyHash: sha256.Sum256(onlineSPKI), + OperatorFingerprint: [32]byte{0x54}, + HistoryStoreID: "trust-client-history", + HistoryStoreFingerprint: [32]byte{0x55}, + HistoryClusterFingerprint: [32]byte{0x56}, + OfflineAuthorityHash: [32]byte{0x57}, + ClientSPKIHash: [32]byte{0x58}, + SignerStoreFingerprint: storeFingerprint, + TransportBinding: [32]byte{0x59}, + WitnessMaximumRecords: 4096, + WitnessRotationThresholdRecords: 1024, + } + identity.StreamID = ComputeFrostNativeSignerAnchorStreamID(identity) + checkpoint := testFrostNativeSignerAnchorCheckpoint( + storeFingerprint, + 7, + [32]byte{0x61}, + 0x62, + ) + operationID := [32]byte{0x63} + transitionDigest := [32]byte{0x64} + previousEventRoot := [32]byte{0x65} + now := time.Unix(1_900_000_000, 0) + acknowledgement, raw := testFrostNativeSignerAnchorAcknowledgement( + t, + identity, + checkpoint, + operationID, + transitionDigest, + [32]byte{0x66}, + [32]byte{0x67}, + "applied", + 2, + 1, + previousEventRoot, + now, + onlinePrivate, + ) + rawOnline := [32]byte{} + copy(rawOnline[:], onlinePublic) + certificate := &FrostNativeSignerAnchorTrustCertificate{ + Kind: FrostNativeSignerAnchorTrustCertificateRotation, + ProtocolID: identity.ProtocolID, + StreamID: identity.StreamID, + SignerStoreFingerprint: storeFingerprint, + OperationID: operationID, + TransitionDigest: transitionDigest, + To: FrostNativeSignerAnchorTrustEndpoint{ + BindingHash: ComputeFrostNativeSignerAnchorBindingHash(identity), + ResponsePublicKey: rawOnline, + ResponsePublicKeySPKISHA256: identity.OnlineKeyHash, + Reference: FrostNativeSignerAnchorTrustReference{ + ServiceEpoch: 2, + Revision: 1, + PreviousEventRoot: previousEventRoot, + EventRoot: acknowledgement.EventRoot, + AcknowledgementDigest: acknowledgement.AcknowledgementDigest, + Checkpoint: checkpoint, + }, + }, + } + if err := ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement( + certificate, + raw, + ); err != nil { + t.Fatalf("certified cross-epoch acknowledgement was rejected: %v", err) + } + + genericClient := &FrostNativeSignerAnchorClient{ + identity: identity, + bindingHash: certificate.To.BindingHash, + onlineKey: append(ed25519.PublicKey{}, onlinePublic...), + maximumAckLife: frostNativeSignerAnchorMaximumAcknowledgementLifetime, + clockSkew: frostNativeSignerAnchorMaximumClockSkew, + now: func() time.Time { return now }, + } + if _, err := genericClient.verifyAcknowledgement( + raw, + nil, + nil, + &checkpoint, + &operationID, + false, + "applied", + ); err == nil { + t.Fatal("generic same-epoch verifier accepted a non-zero revision-one parent") + } + + tampered := *certificate + tampered.To.Reference.PreviousEventRoot = [32]byte{0xff} + if err := ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement( + &tampered, + raw, + ); err == nil { + t.Fatal("trust verifier accepted a predecessor root not signed by the certificate") + } +} + +func bytesRepeatForFrostNativeSignerTrustTest(value byte, count int) []byte { + result := make([]byte, count) + for index := range result { + result[index] = value + } + return result +} diff --git a/pkg/tbtc/frost_native_signer_anchor_trust_startup.go b/pkg/tbtc/frost_native_signer_anchor_trust_startup.go new file mode 100644 index 0000000000..57863e2915 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_trust_startup.go @@ -0,0 +1,608 @@ +package tbtc + +import ( + "fmt" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +// validateFrostNativeSignerAnchorTrustExpectedHead derives the two exact head +// representations consumed during startup. It does so only after proving that +// the final certificate endpoint agrees with the independently authenticated +// runtime manifest and the exact configuration bytes already accepted by the +// native signer. +func validateFrostNativeSignerAnchorTrustExpectedHead( + runtimeManifest FrostPreSignActivationRuntimeManifest, + installed *frostsigning.NativeTBTCSignerInstalledStateAnchorConfig, + finalCertificate *FrostNativeSignerAnchorTrustCertificate, +) ( + *FrostNativeSignerAnchorTrustCertificateHead, + *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + error, +) { + if err := validateFrostNativeSignerAnchorTrustRuntimePins( + runtimeManifest, + installed, + ); err != nil { + return nil, nil, err + } + if finalCertificate == nil { + return nil, nil, fmt.Errorf( + "final native signer anchor trust certificate is nil", + ) + } + + identity := runtimeManifest.NativeSignerAnchor.Identity + anchorManifest := runtimeManifest.NativeSignerAnchor + expectedBindingHash := ComputeFrostNativeSignerAnchorBindingHash(identity) + if finalCertificate.CertificateSequence != + installed.TrustCertificateSequence || + finalCertificate.CertificateDigest != + installed.TrustCertificateDigest || + finalCertificate.ProtocolID != identity.ProtocolID || + finalCertificate.StreamID != identity.StreamID || + finalCertificate.SignerStoreFingerprint != + identity.SignerStoreFingerprint { + return nil, nil, fmt.Errorf( + "final native signer anchor trust certificate head pins differ from the installed configuration", + ) + } + + expectedEndpoint := FrostNativeSignerAnchorTrustEndpoint{ + ActivationManifestHash: runtimeManifest.ManifestHash, + ActivationManifestSequence: identity.ActivationManifestSequence, + BindingHash: expectedBindingHash, + ResponsePublicKey: installed.ResponsePublicKey, + ResponsePublicKeySPKISHA256: installed.ResponsePublicKeySPKISHA256, + OfflineAuthorityPublicKey: runtimeManifest.ActivationAuthorityPublicKey, + OfflineAuthoritySPKISHA256: identity.OfflineAuthorityHash, + WitnessMaximumRecords: anchorManifest.WitnessMaximumRecords, + WitnessRotationThresholdRecords: anchorManifest.WitnessRotationThresholdRecords, + Reference: finalCertificate.To.Reference, + } + if finalCertificate.To != expectedEndpoint { + return nil, nil, fmt.Errorf( + "final native signer anchor trust certificate endpoint differs from the runtime and installed pins", + ) + } + if err := frostNativeSignerAnchorTrustValidateEndpoint( + &expectedEndpoint, + identity.SignerStoreFingerprint, + "expected", + ); err != nil { + return nil, nil, err + } + + protocolHead := &FrostNativeSignerAnchorTrustCertificateHead{ + CertificateSequence: finalCertificate.CertificateSequence, + CertificateDigest: finalCertificate.CertificateDigest, + ProtocolID: identity.ProtocolID, + StreamID: identity.StreamID, + SignerStoreFingerprint: identity.SignerStoreFingerprint, + Endpoint: expectedEndpoint, + } + nativeHead := frostNativeSignerAnchorNativeTrustHead(protocolHead) + return protocolHead, &nativeHead, nil +} + +// reconstructFrostNativeSignerAnchorTrustPriorHead combines only authenticated +// native readback fields with stable local pins. The native ABI intentionally +// exposes only the online key's SPKI hash, so a missing-suffix transition may +// recover the raw prior key solely from the first certificate's From endpoint +// after checking its canonical SPKI hash. An exact completed replay instead +// recovers the final raw key from the independently installed configuration, +// which also permits replaying a bootstrap certificate with from:null. +func reconstructFrostNativeSignerAnchorTrustPriorHead( + readback *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + runtimeManifest FrostPreSignActivationRuntimeManifest, + installed *frostsigning.NativeTBTCSignerInstalledStateAnchorConfig, + firstCertificate *FrostNativeSignerAnchorTrustCertificate, +) (*FrostNativeSignerAnchorTrustCertificateHead, error) { + if err := validateFrostNativeSignerAnchorTrustRuntimePins( + runtimeManifest, + installed, + ); err != nil { + return nil, err + } + if readback == nil || firstCertificate == nil { + return nil, fmt.Errorf( + "native signer anchor prior trust-head inputs are incomplete", + ) + } + if readback.Schema != + frostsigning.NativeTBTCSignerStateAnchorTrustHeadSchema || + readback.CertificateSequence == 0 || + readback.CertificateDigest == [32]byte{} || + readback.ActivationManifestSequence == 0 || + readback.ActivationManifestHash == [32]byte{} || + readback.BindingHash == [32]byte{} || + readback.ResponsePublicKeySPKISHA256 == [32]byte{} || + readback.ServiceEpoch == 0 { + return nil, fmt.Errorf( + "native signer anchor prior trust-head readback is incomplete", + ) + } + + identity := runtimeManifest.NativeSignerAnchor.Identity + anchorManifest := runtimeManifest.NativeSignerAnchor + if readback.OfflineAuthoritySPKISHA256 != + installed.OfflineAuthoritySPKISHA256 || + readback.WitnessMaximumRecords != + anchorManifest.WitnessMaximumRecords || + readback.WitnessRotationThresholdRecords != + anchorManifest.WitnessRotationThresholdRecords || + readback.ServiceEpoch != readback.CertifiedFloor.ServiceEpoch || + readback.CertifiedFloor.Revision != 1 || + readback.CertifiedFloor.Checkpoint.StoreFingerprint != + identity.SignerStoreFingerprint { + return nil, fmt.Errorf( + "native signer anchor prior trust-head readback differs from stable local pins", + ) + } + if firstCertificate.ProtocolID != identity.ProtocolID || + firstCertificate.StreamID != identity.StreamID || + firstCertificate.SignerStoreFingerprint != + identity.SignerStoreFingerprint { + return nil, fmt.Errorf( + "first native signer anchor trust certificate differs from stable local pins", + ) + } + + exactReplay := readback.CertificateSequence == + installed.TrustCertificateSequence && + readback.CertificateDigest == installed.TrustCertificateDigest && + firstCertificate.CertificateSequence == readback.CertificateSequence && + firstCertificate.CertificateDigest == readback.CertificateDigest + + var responsePublicKey [32]byte + var certificateEndpoint *FrostNativeSignerAnchorTrustEndpoint + if exactReplay { + responsePublicKey = installed.ResponsePublicKey + certificateEndpoint = &firstCertificate.To + } else { + if firstCertificate.From == nil { + return nil, fmt.Errorf( + "first missing native signer anchor trust certificate has no prior endpoint", + ) + } + responsePublicKey = firstCertificate.From.ResponsePublicKey + certificateEndpoint = firstCertificate.From + } + if ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + responsePublicKey, + ) != readback.ResponsePublicKeySPKISHA256 || + responsePublicKey == installed.OfflineAuthorityPublicKey { + return nil, fmt.Errorf( + "native signer anchor prior response key differs from authenticated readback or aliases the offline authority", + ) + } + + endpoint := FrostNativeSignerAnchorTrustEndpoint{ + ActivationManifestHash: readback.ActivationManifestHash, + ActivationManifestSequence: readback.ActivationManifestSequence, + BindingHash: readback.BindingHash, + ResponsePublicKey: responsePublicKey, + ResponsePublicKeySPKISHA256: readback.ResponsePublicKeySPKISHA256, + OfflineAuthorityPublicKey: installed.OfflineAuthorityPublicKey, + OfflineAuthoritySPKISHA256: installed.OfflineAuthoritySPKISHA256, + WitnessMaximumRecords: readback.WitnessMaximumRecords, + WitnessRotationThresholdRecords: readback.WitnessRotationThresholdRecords, + Reference: frostNativeSignerAnchorTrustReferenceFromNative( + readback.CertifiedFloor, + ), + } + if exactReplay { + if *certificateEndpoint != endpoint { + return nil, fmt.Errorf( + "exact replay endpoint differs from authenticated native trust-head readback", + ) + } + } else { + if !frostNativeSignerAnchorTrustStaticEndpointEqual( + *certificateEndpoint, + endpoint, + ) { + return nil, fmt.Errorf( + "certificate prior endpoint identity differs from authenticated native trust-head readback", + ) + } + if err := frostNativeSignerAnchorTrustValidateReferenceDescendant( + endpoint.Reference, + certificateEndpoint.Reference, + ); err != nil { + return nil, fmt.Errorf( + "certificate prior reference is not a descendant of the authenticated native trust-head floor: %w", + err, + ) + } + } + if err := frostNativeSignerAnchorTrustValidateEndpoint( + &endpoint, + identity.SignerStoreFingerprint, + "prior", + ); err != nil { + return nil, err + } + + protocolHead := &FrostNativeSignerAnchorTrustCertificateHead{ + CertificateSequence: readback.CertificateSequence, + CertificateDigest: readback.CertificateDigest, + ProtocolID: identity.ProtocolID, + StreamID: identity.StreamID, + SignerStoreFingerprint: identity.SignerStoreFingerprint, + Endpoint: endpoint, + } + reconstructedReadback := frostNativeSignerAnchorNativeTrustHead(protocolHead) + if reconstructedReadback != *readback { + return nil, fmt.Errorf( + "reconstructed native signer anchor prior head differs from authenticated readback", + ) + } + return protocolHead, nil +} + +// selectFrostNativeSignerAnchorTrustTransitionChain converts the configured +// static artifact into the exact non-empty suffix Rust should apply. A +// completed restart replays only the final certificate, as required by the +// frozen ABI. A partially applied restart resumes strictly after the exact +// authenticated journal head. If the head immediately precedes the artifact, +// no item matches and the full configured suffix is retained for ordinary +// extension validation. +func selectFrostNativeSignerAnchorTrustTransitionChain( + configured []FrostNativeSignerAnchorTrustCertificate, + readback *frostsigning.NativeTBTCSignerStateAnchorTrustHead, +) ([]FrostNativeSignerAnchorTrustCertificate, error) { + if len(configured) == 0 || + len(configured) > + FrostNativeSignerAnchorTrustMaximumCertificateChainLength { + return nil, fmt.Errorf( + "configured native signer anchor trust certificate chain length is invalid", + ) + } + start := 0 + if readback != nil { + match := -1 + for index := range configured { + if configured[index].CertificateSequence == + readback.CertificateSequence && + configured[index].CertificateDigest == + readback.CertificateDigest { + if match >= 0 { + return nil, fmt.Errorf( + "configured native signer anchor trust chain contains the authenticated head more than once", + ) + } + match = index + } + } + switch { + case match == len(configured)-1: + // The transition request may not be empty. Replay the exact final + // item so Rust can return its explicit idempotent result. + start = match + case match >= 0: + start = match + 1 + } + } + result := append( + []FrostNativeSignerAnchorTrustCertificate{}, + configured[start:]..., + ) + if len(result) == 0 { + return nil, fmt.Errorf( + "native signer anchor trust transition suffix is empty", + ) + } + return result, nil +} + +func isFrostNativeSignerAnchorTrustExactHeadReplay( + prior *FrostNativeSignerAnchorTrustCertificateHead, + expected *FrostNativeSignerAnchorTrustCertificateHead, +) bool { + return prior != nil && expected != nil && *prior == *expected +} + +func validateFrostNativeSignerAnchorReconciledTransitionTarget( + tip *frostsigning.NativeTBTCSignerStateWitnessTip, + target *FrostNativeSignerAnchorTrustTransitionTarget, +) error { + // A nil target is intentional for an authenticated exact-head restart. + // Ordinary history reconciliation, not a stale pre-transition Read, is the + // authority for repairing either crash window on that path. + if target == nil { + return nil + } + if tip == nil || + frostNativeSignerCheckpointFromTip(*tip) != + target.Reference.Checkpoint || + tip.AnchorServiceEpoch != target.Reference.ServiceEpoch || + tip.AnchorRevision != target.Reference.Revision || + tip.AnchorEventRoot != target.Reference.EventRoot || + tip.AnchorAcknowledgementDigest != + target.Reference.AcknowledgementDigest { + return fmt.Errorf( + "reconciled native signer tip differs from the fresh trust-transition target", + ) + } + return nil +} + +func validateFrostNativeSignerAnchorTrustTransitionResult( + result *frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, + target *FrostNativeSignerAnchorTrustTransitionTarget, + finalCertificate *FrostNativeSignerAnchorTrustCertificate, + expectedProtocolHead *FrostNativeSignerAnchorTrustCertificateHead, + expectedNativeHead *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + priorHead *FrostNativeSignerAnchorTrustCertificateHead, + appliedChain []FrostNativeSignerAnchorTrustCertificate, +) error { + if result == nil || target == nil || finalCertificate == nil || + expectedProtocolHead == nil || expectedNativeHead == nil || + len(target.ExactReadResponse) == 0 || + len(appliedChain) == 0 || + len(appliedChain) > + FrostNativeSignerAnchorTrustMaximumCertificateChainLength { + return fmt.Errorf( + "native signer anchor trust-transition validation inputs are incomplete", + ) + } + if !result.Installed || result.TrustHead != *expectedNativeHead { + return fmt.Errorf( + "native signer anchor trust-transition result differs from the independently pinned head", + ) + } + currentReference := frostNativeSignerAnchorTrustReferenceFromNative( + result.CurrentAnchorReference, + ) + currentCheckpoint := frostNativeSignerAnchorTrustCheckpointFromNative( + result.CurrentCheckpoint, + ) + witnessBase := frostNativeSignerAnchorTrustCheckpointFromNative( + result.WitnessBaseCheckpoint, + ) + exactReplay := priorHead != nil && + *priorHead == *expectedProtocolHead + if !exactReplay && target.Reference != finalCertificate.To.Reference { + return fmt.Errorf( + "new native signer anchor trust transition does not use the exact certified target", + ) + } + if err := frostNativeSignerAnchorTrustValidateReferenceDescendant( + finalCertificate.To.Reference, + target.Reference, + ); err != nil { + return fmt.Errorf( + "fresh native signer anchor trust-transition target is not a certified-floor descendant: %w", + err, + ) + } + if currentReference != target.Reference || + currentCheckpoint != target.Reference.Checkpoint { + return fmt.Errorf( + "native signer anchor trust-transition state differs from the fresh certified target", + ) + } + + if exactReplay { + head := &appliedChain[len(appliedChain)-1] + if len(appliedChain) != 1 || + head.CertificateSequence != + expectedProtocolHead.CertificateSequence || + head.CertificateDigest != + expectedProtocolHead.CertificateDigest || + head.To != expectedProtocolHead.Endpoint || + !result.Idempotent || + result.AppliedCertificateCount != 0 { + return fmt.Errorf( + "native signer anchor completed-restart result is not an exact idempotent replay", + ) + } + floorCheckpoint := finalCertificate.To.Reference.Checkpoint + if witnessBase.StoreFingerprint != floorCheckpoint.StoreFingerprint || + witnessBase.Generation < floorCheckpoint.Generation || + witnessBase.Generation > currentCheckpoint.Generation || + (witnessBase.Generation == floorCheckpoint.Generation && + witnessBase != floorCheckpoint) || + (witnessBase.Generation == currentCheckpoint.Generation && + witnessBase != currentCheckpoint) { + return fmt.Errorf( + "native signer anchor completed-restart witness base is outside the retained certified segment", + ) + } + return nil + } + if result.Idempotent || + result.AppliedCertificateCount != uint64(len(appliedChain)) { + return fmt.Errorf( + "native signer anchor trust-transition applied count differs from the authenticated missing suffix", + ) + } + if witnessBase != finalCertificate.To.Reference.Checkpoint { + return fmt.Errorf( + "native signer anchor trust-transition witness base differs from the new certified floor", + ) + } + return nil +} + +func validateFrostNativeSignerAnchorTrustRuntimePins( + runtimeManifest FrostPreSignActivationRuntimeManifest, + installed *frostsigning.NativeTBTCSignerInstalledStateAnchorConfig, +) error { + if installed == nil { + return fmt.Errorf( + "installed native signer anchor configuration is nil", + ) + } + identity := runtimeManifest.NativeSignerAnchor.Identity + anchorManifest := runtimeManifest.NativeSignerAnchor + expectedBindingHash := ComputeFrostNativeSignerAnchorBindingHash(identity) + authoritySPKIHash := + ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + runtimeManifest.ActivationAuthorityPublicKey, + ) + responseSPKIHash := + ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + installed.ResponsePublicKey, + ) + installedAuthoritySPKIHash := + ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + installed.OfflineAuthorityPublicKey, + ) + + if runtimeManifest.ManifestHash == [32]byte{} || + identity.ProtocolID == [32]byte{} || + identity.StreamID == [32]byte{} || + identity.SignerStoreFingerprint == [32]byte{} || + identity.ActivationManifestSequence == 0 || + installed.TrustCertificateSequence == 0 || + installed.TrustCertificateDigest == [32]byte{} || + runtimeManifest.ActivationAuthorityPublicKey == [32]byte{} || + installed.ResponsePublicKey == [32]byte{} || + installed.OfflineAuthorityPublicKey == [32]byte{} { + return fmt.Errorf( + "native signer anchor runtime or installed trust pins are incomplete", + ) + } + if identity.ActivationManifestHash != runtimeManifest.ManifestHash || + identity.StreamID != ComputeFrostNativeSignerAnchorStreamID(identity) || + installed.ProtocolID != identity.ProtocolID || + installed.StreamID != identity.StreamID || + installed.ActivationManifestHash != runtimeManifest.ManifestHash || + installed.ActivationManifestSequence != + identity.ActivationManifestSequence || + installed.BindingHash != expectedBindingHash || + installed.ResponsePublicKeySPKISHA256 != responseSPKIHash || + identity.OnlineKeyHash != responseSPKIHash || + installed.OfflineAuthorityPublicKey != + runtimeManifest.ActivationAuthorityPublicKey || + installed.OfflineAuthoritySPKISHA256 != + installedAuthoritySPKIHash || + authoritySPKIHash != identity.OfflineAuthorityHash || + installed.OfflineAuthoritySPKISHA256 != authoritySPKIHash || + anchorManifest.WitnessMaximumRecords != + identity.WitnessMaximumRecords || + anchorManifest.WitnessRotationThresholdRecords != + identity.WitnessRotationThresholdRecords || + installed.WitnessMaximumRecords != + anchorManifest.WitnessMaximumRecords || + installed.WitnessRotationThresholdRecords != + anchorManifest.WitnessRotationThresholdRecords { + return fmt.Errorf( + "installed native signer anchor configuration differs from the authenticated runtime manifest", + ) + } + if identity.ClientSPKIHash == identity.OnlineKeyHash || + identity.ClientSPKIHash == identity.OfflineAuthorityHash || + identity.OnlineKeyHash == identity.OfflineAuthorityHash { + return fmt.Errorf( + "native signer anchor client, online response, and offline authority key roles have aliases", + ) + } + if installed.ResponsePublicKey == installed.OfflineAuthorityPublicKey { + return fmt.Errorf( + "native signer anchor online response key aliases the offline authority", + ) + } + if anchorManifest.WitnessMaximumRecords < 4 || + anchorManifest.WitnessMaximumRecords > 1_000_000 || + anchorManifest.WitnessRotationThresholdRecords < 2 || + anchorManifest.WitnessRotationThresholdRecords > + anchorManifest.WitnessMaximumRecords-2 { + return fmt.Errorf( + "native signer anchor witness geometry is invalid", + ) + } + return nil +} + +func frostNativeSignerAnchorNativeTrustHead( + head *FrostNativeSignerAnchorTrustCertificateHead, +) frostsigning.NativeTBTCSignerStateAnchorTrustHead { + return frostsigning.NativeTBTCSignerStateAnchorTrustHead{ + Schema: frostsigning.NativeTBTCSignerStateAnchorTrustHeadSchema, + CertificateSequence: head.CertificateSequence, + CertificateDigest: head.CertificateDigest, + ActivationManifestSequence: head.Endpoint.ActivationManifestSequence, + ActivationManifestHash: head.Endpoint.ActivationManifestHash, + BindingHash: head.Endpoint.BindingHash, + ResponsePublicKeySPKISHA256: head.Endpoint. + ResponsePublicKeySPKISHA256, + OfflineAuthoritySPKISHA256: head.Endpoint. + OfflineAuthoritySPKISHA256, + ServiceEpoch: head.Endpoint.Reference.ServiceEpoch, + CertifiedFloor: frostNativeSignerAnchorNativeTrustReference( + head.Endpoint.Reference, + ), + WitnessMaximumRecords: head.Endpoint.WitnessMaximumRecords, + WitnessRotationThresholdRecords: head.Endpoint. + WitnessRotationThresholdRecords, + } +} + +func frostNativeSignerAnchorNativeTrustReference( + reference FrostNativeSignerAnchorTrustReference, +) frostsigning.NativeTBTCSignerStateAnchorTrustReference { + return frostsigning.NativeTBTCSignerStateAnchorTrustReference{ + ServiceEpoch: reference.ServiceEpoch, + Revision: reference.Revision, + PreviousEventRoot: reference.PreviousEventRoot, + EventRoot: reference.EventRoot, + AcknowledgementDigest: reference.AcknowledgementDigest, + Checkpoint: frostNativeSignerAnchorNativeTrustCheckpoint( + reference.Checkpoint, + ), + } +} + +func frostNativeSignerAnchorNativeTrustCheckpoint( + checkpoint FrostNativeSignerStateWitnessCheckpoint, +) frostsigning.NativeTBTCSignerStateAnchorCheckpoint { + return frostsigning.NativeTBTCSignerStateAnchorCheckpoint{ + StoreFingerprint: checkpoint.StoreFingerprint, + Generation: checkpoint.Generation, + PreviousStateCommitment: checkpoint.PreviousStateCommitment, + StateImageDigest: checkpoint.StateImageDigest, + StateCommitment: checkpoint.StateCommitment, + } +} + +func frostNativeSignerAnchorTrustReferenceFromNative( + reference frostsigning.NativeTBTCSignerStateAnchorTrustReference, +) FrostNativeSignerAnchorTrustReference { + return FrostNativeSignerAnchorTrustReference{ + ServiceEpoch: reference.ServiceEpoch, + Revision: reference.Revision, + PreviousEventRoot: reference.PreviousEventRoot, + EventRoot: reference.EventRoot, + AcknowledgementDigest: reference.AcknowledgementDigest, + Checkpoint: frostNativeSignerAnchorTrustCheckpointFromNative( + reference.Checkpoint, + ), + } +} + +func frostNativeSignerAnchorTrustCheckpointFromNative( + checkpoint frostsigning.NativeTBTCSignerStateAnchorCheckpoint, +) FrostNativeSignerStateWitnessCheckpoint { + return FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: checkpoint.StoreFingerprint, + Generation: checkpoint.Generation, + PreviousStateCommitment: checkpoint.PreviousStateCommitment, + StateImageDigest: checkpoint.StateImageDigest, + StateCommitment: checkpoint.StateCommitment, + } +} + +func frostNativeSignerAnchorReferenceFromTrust( + reference FrostNativeSignerAnchorTrustReference, +) FrostNativeSignerStateWitnessAnchorReference { + return FrostNativeSignerStateWitnessAnchorReference{ + ServiceEpoch: reference.ServiceEpoch, + Revision: reference.Revision, + EventRoot: reference.EventRoot, + AcknowledgementDigest: reference.AcknowledgementDigest, + Checkpoint: reference.Checkpoint, + } +} diff --git a/pkg/tbtc/frost_native_signer_anchor_trust_startup_test.go b/pkg/tbtc/frost_native_signer_anchor_trust_startup_test.go new file mode 100644 index 0000000000..e4731dd3b6 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_trust_startup_test.go @@ -0,0 +1,953 @@ +package tbtc + +import ( + "bytes" + "crypto/ed25519" + "strings" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +type frostNativeSignerAnchorTrustStartupFixture struct { + runtime FrostPreSignActivationRuntimeManifest + installed frostsigning.NativeTBTCSignerInstalledStateAnchorConfig + certificate FrostNativeSignerAnchorTrustCertificate +} + +func TestValidateFrostNativeSignerAnchorTrustExpectedHead(t *testing.T) { + fixture := newFrostNativeSignerAnchorTrustStartupFixture() + + protocolHead, nativeHead, err := + validateFrostNativeSignerAnchorTrustExpectedHead( + fixture.runtime, + &fixture.installed, + &fixture.certificate, + ) + if err != nil { + t.Fatal(err) + } + if protocolHead.CertificateSequence != + fixture.certificate.CertificateSequence || + protocolHead.CertificateDigest != fixture.certificate.CertificateDigest || + protocolHead.ProtocolID != fixture.certificate.ProtocolID || + protocolHead.StreamID != fixture.certificate.StreamID || + protocolHead.SignerStoreFingerprint != + fixture.certificate.SignerStoreFingerprint || + protocolHead.Endpoint != fixture.certificate.To { + t.Fatal("unexpected protocol trust head") + } + expectedNativeHead := frostNativeSignerAnchorNativeTrustHead(protocolHead) + if *nativeHead != expectedNativeHead { + t.Fatal("unexpected native trust head") + } + + reference := frostNativeSignerAnchorReferenceFromTrust( + fixture.certificate.To.Reference, + ) + if reference.ServiceEpoch != fixture.certificate.To.Reference.ServiceEpoch || + reference.Revision != fixture.certificate.To.Reference.Revision || + reference.EventRoot != fixture.certificate.To.Reference.EventRoot || + reference.AcknowledgementDigest != + fixture.certificate.To.Reference.AcknowledgementDigest || + reference.Checkpoint != fixture.certificate.To.Reference.Checkpoint { + t.Fatal("unexpected ordinary anchor reference") + } +} + +func TestValidateFrostNativeSignerAnchorTrustExpectedHeadRejectsPinMismatch( + t *testing.T, +) { + tests := map[string]func(*frostNativeSignerAnchorTrustStartupFixture){ + "protocol": func(fixture *frostNativeSignerAnchorTrustStartupFixture) { + fixture.installed.ProtocolID[0] ^= 1 + }, + "stream": func(fixture *frostNativeSignerAnchorTrustStartupFixture) { + fixture.installed.StreamID[0] ^= 1 + }, + "signer store": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + ) { + fixture.certificate.SignerStoreFingerprint[0] ^= 1 + }, + "runtime manifest hash": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + ) { + fixture.runtime.ManifestHash[0] ^= 1 + }, + "manifest sequence": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + ) { + fixture.installed.ActivationManifestSequence++ + }, + "binding": func(fixture *frostNativeSignerAnchorTrustStartupFixture) { + fixture.installed.BindingHash[0] ^= 1 + }, + "online raw key": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + ) { + fixture.certificate.To.ResponsePublicKey[0] ^= 1 + }, + "online SPKI": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + ) { + fixture.installed.ResponsePublicKeySPKISHA256[0] ^= 1 + }, + "offline raw key": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + ) { + fixture.runtime.ActivationAuthorityPublicKey[0] ^= 1 + }, + "offline SPKI": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + ) { + fixture.runtime.NativeSignerAnchor.Identity. + OfflineAuthorityHash[0] ^= 1 + }, + "certificate sequence": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + ) { + fixture.certificate.CertificateSequence++ + }, + "certificate digest": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + ) { + fixture.certificate.CertificateDigest[0] ^= 1 + }, + "maximum records": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + ) { + fixture.certificate.To.WitnessMaximumRecords++ + }, + "rotation threshold": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + ) { + fixture.installed.WitnessRotationThresholdRecords-- + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + fixture := newFrostNativeSignerAnchorTrustStartupFixture() + mutate(&fixture) + if _, _, err := + validateFrostNativeSignerAnchorTrustExpectedHead( + fixture.runtime, + &fixture.installed, + &fixture.certificate, + ); err == nil { + t.Fatal("expected pin mismatch") + } + }) + } + +} + +func TestValidateFrostNativeSignerAnchorTrustExpectedHeadRejectsRoleAliasing( + t *testing.T, +) { + fixture := newFrostNativeSignerAnchorTrustStartupFixture() + authority := fixture.installed.OfflineAuthorityPublicKey + authorityHash := fixture.installed.OfflineAuthoritySPKISHA256 + fixture.installed.ResponsePublicKey = authority + fixture.installed.ResponsePublicKeySPKISHA256 = authorityHash + fixture.runtime.NativeSignerAnchor.Identity.OnlineKeyHash = authorityHash + fixture.installed.BindingHash = ComputeFrostNativeSignerAnchorBindingHash( + fixture.runtime.NativeSignerAnchor.Identity, + ) + fixture.certificate.To.ResponsePublicKey = authority + fixture.certificate.To.ResponsePublicKeySPKISHA256 = authorityHash + fixture.certificate.To.BindingHash = fixture.installed.BindingHash + + _, _, err := validateFrostNativeSignerAnchorTrustExpectedHead( + fixture.runtime, + &fixture.installed, + &fixture.certificate, + ) + if err == nil || !strings.Contains(err.Error(), "aliases") { + t.Fatalf("expected online/offline role-alias rejection, got [%v]", err) + } +} + +func TestReconstructFrostNativeSignerAnchorTrustPriorHead(t *testing.T) { + fixture, expectedPrior, readback := + newFrostNativeSignerAnchorTrustPriorStartupFixture() + + actual, err := reconstructFrostNativeSignerAnchorTrustPriorHead( + &readback, + fixture.runtime, + &fixture.installed, + &fixture.certificate, + ) + if err != nil { + t.Fatal(err) + } + if *actual != expectedPrior { + t.Fatal("reconstructed prior head differs from authenticated readback") + } +} + +func TestReconstructFrostNativeSignerAnchorTrustPriorHeadUsesCertifiedFloor( + t *testing.T, +) { + fixture, expectedPrior, readback := + newFrostNativeSignerAnchorTrustPriorStartupFixture() + from := *fixture.certificate.From + from.Reference.Revision = 7 + from.Reference.PreviousEventRoot = + frostNativeSignerAnchorTrustStartupBytes32(0x76) + from.Reference.EventRoot = + frostNativeSignerAnchorTrustStartupBytes32(0x77) + from.Reference.AcknowledgementDigest = + frostNativeSignerAnchorTrustStartupBytes32(0x78) + from.Reference.Checkpoint = + FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: expectedPrior.Endpoint.Reference.Checkpoint. + StoreFingerprint, + Generation: expectedPrior.Endpoint.Reference.Checkpoint. + Generation + 2, + PreviousStateCommitment: frostNativeSignerAnchorTrustStartupBytes32( + 0x79, + ), + StateImageDigest: frostNativeSignerAnchorTrustStartupBytes32( + 0x7a, + ), + } + from.Reference.Checkpoint.StateCommitment = + frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + from.Reference.Checkpoint.StoreFingerprint, + from.Reference.Checkpoint.Generation, + from.Reference.Checkpoint.PreviousStateCommitment, + from.Reference.Checkpoint.StateImageDigest, + ) + fixture.certificate.From = &from + + actual, err := reconstructFrostNativeSignerAnchorTrustPriorHead( + &readback, + fixture.runtime, + &fixture.installed, + &fixture.certificate, + ) + if err != nil { + t.Fatal(err) + } + if *actual != expectedPrior { + t.Fatal("prior reconstruction replaced the authenticated floor with certificate From") + } +} + +func TestReconstructFrostNativeSignerAnchorTrustPriorHeadRejectsUntrustedFrom( + t *testing.T, +) { + tests := map[string]func( + *frostNativeSignerAnchorTrustStartupFixture, + *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + ){ + "missing from": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + _ *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + ) { + fixture.certificate.From = nil + }, + "raw response key hash": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + _ *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + ) { + from := *fixture.certificate.From + from.ResponsePublicKey[0] ^= 1 + fixture.certificate.From = &from + }, + "from manifest hash": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + _ *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + ) { + from := *fixture.certificate.From + from.ActivationManifestHash[0] ^= 1 + fixture.certificate.From = &from + }, + "from binding": func( + fixture *frostNativeSignerAnchorTrustStartupFixture, + _ *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + ) { + from := *fixture.certificate.From + from.BindingHash[0] ^= 1 + fixture.certificate.From = &from + }, + "readback authority": func( + _ *frostNativeSignerAnchorTrustStartupFixture, + readback *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + ) { + readback.OfflineAuthoritySPKISHA256[0] ^= 1 + }, + "readback geometry": func( + _ *frostNativeSignerAnchorTrustStartupFixture, + readback *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + ) { + readback.WitnessMaximumRecords++ + }, + "readback store": func( + _ *frostNativeSignerAnchorTrustStartupFixture, + readback *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + ) { + readback.CertifiedFloor.Checkpoint.StoreFingerprint[0] ^= 1 + }, + "readback certified revision": func( + _ *frostNativeSignerAnchorTrustStartupFixture, + readback *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + ) { + readback.CertifiedFloor.Revision = 2 + }, + "readback schema": func( + _ *frostNativeSignerAnchorTrustStartupFixture, + readback *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + ) { + readback.Schema += "-unknown" + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + fixture, _, readback := + newFrostNativeSignerAnchorTrustPriorStartupFixture() + mutate(&fixture, &readback) + if _, err := reconstructFrostNativeSignerAnchorTrustPriorHead( + &readback, + fixture.runtime, + &fixture.installed, + &fixture.certificate, + ); err == nil { + t.Fatal("expected prior-head reconstruction rejection") + } + }) + } + +} + +func TestReconstructFrostNativeSignerAnchorTrustPriorHeadRejectsRoleAliasing( + t *testing.T, +) { + fixture, _, readback := + newFrostNativeSignerAnchorTrustPriorStartupFixture() + from := *fixture.certificate.From + from.ResponsePublicKey = fixture.installed.OfflineAuthorityPublicKey + from.ResponsePublicKeySPKISHA256 = + fixture.installed.OfflineAuthoritySPKISHA256 + fixture.certificate.From = &from + readback.ResponsePublicKeySPKISHA256 = + fixture.installed.OfflineAuthoritySPKISHA256 + + _, err := reconstructFrostNativeSignerAnchorTrustPriorHead( + &readback, + fixture.runtime, + &fixture.installed, + &fixture.certificate, + ) + if err == nil || !strings.Contains(err.Error(), "aliases") { + t.Fatalf("expected prior online/offline role-alias rejection, got [%v]", err) + } +} + +func TestReconstructFrostNativeSignerAnchorTrustPriorHeadExactBootstrapReplay( + t *testing.T, +) { + fixture := newFrostNativeSignerAnchorTrustStartupFixture() + fixture.certificate.Kind = + FrostNativeSignerAnchorTrustCertificateBootstrap + fixture.certificate.CertificateSequence = 1 + fixture.installed.TrustCertificateSequence = 1 + fixture.certificate.From = nil + + expected, readback, err := + validateFrostNativeSignerAnchorTrustExpectedHead( + fixture.runtime, + &fixture.installed, + &fixture.certificate, + ) + if err != nil { + t.Fatal(err) + } + actual, err := reconstructFrostNativeSignerAnchorTrustPriorHead( + readback, + fixture.runtime, + &fixture.installed, + &fixture.certificate, + ) + if err != nil { + t.Fatal(err) + } + if *actual != *expected { + t.Fatal("exact bootstrap replay did not reconstruct the final head") + } + + fixture.certificate.To.BindingHash[0] ^= 1 + if _, err := reconstructFrostNativeSignerAnchorTrustPriorHead( + readback, + fixture.runtime, + &fixture.installed, + &fixture.certificate, + ); err == nil { + t.Fatal("expected tampered exact-replay endpoint rejection") + } +} + +func TestSelectFrostNativeSignerAnchorTrustTransitionChainResumesMissingSuffix( + t *testing.T, +) { + bootstrap, authority := trustTestBootstrapCertificate(t) + second := trustTestRotationCertificate(t, bootstrap, authority, 0x81) + final := trustTestRotationCertificate(t, second, authority, 0x82) + configured := []FrostNativeSignerAnchorTrustCertificate{ + *bootstrap, + *second, + *final, + } + + priorHead := trustTestCertificateHead(second) + priorReadback := frostNativeSignerAnchorNativeTrustHead(priorHead) + suffix, err := selectFrostNativeSignerAnchorTrustTransitionChain( + configured, + &priorReadback, + ) + if err != nil { + t.Fatal(err) + } + if len(suffix) != 1 || + suffix[0].CertificateDigest != final.CertificateDigest { + t.Fatal("partially installed chain did not resume at its missing suffix") + } + options := trustTestChainOptions(final) + options.PriorHead = priorHead + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + suffix, + options, + ); err != nil { + t.Fatalf("selected partial-resume suffix is invalid: %v", err) + } + + // A configured artifact may itself already be a suffix whose first item + // immediately follows the authenticated head. + suffix, err = selectFrostNativeSignerAnchorTrustTransitionChain( + []FrostNativeSignerAnchorTrustCertificate{*final}, + &priorReadback, + ) + if err != nil || len(suffix) != 1 || + suffix[0].CertificateDigest != final.CertificateDigest { + t.Fatalf("pre-sliced missing suffix was not retained: %v", err) + } +} + +func TestSelectFrostNativeSignerAnchorTrustTransitionChainCompletedRestart( + t *testing.T, +) { + bootstrap, authority := trustTestBootstrapCertificate(t) + second := trustTestRotationCertificate(t, bootstrap, authority, 0x83) + final := trustTestRotationCertificate(t, second, authority, 0x84) + configured := []FrostNativeSignerAnchorTrustCertificate{ + *bootstrap, + *second, + *final, + } + finalHead := trustTestCertificateHead(final) + finalReadback := frostNativeSignerAnchorNativeTrustHead(finalHead) + replay, err := selectFrostNativeSignerAnchorTrustTransitionChain( + configured, + &finalReadback, + ) + if err != nil { + t.Fatal(err) + } + if len(replay) != 1 || + replay[0].CertificateDigest != final.CertificateDigest { + t.Fatal("completed restart did not select the exact one-item replay") + } + options := trustTestChainOptions(final) + options.PriorHead = finalHead + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + replay, + options, + ); err != nil { + t.Fatalf("selected completed-restart replay is invalid: %v", err) + } + if !isFrostNativeSignerAnchorTrustExactHeadReplay( + finalHead, + finalHead, + ) { + t.Fatal("authenticated completed head did not select transition bypass") + } + different := *finalHead + different.CertificateDigest[0] ^= 1 + if isFrostNativeSignerAnchorTrustExactHeadReplay( + &different, + finalHead, + ) || isFrostNativeSignerAnchorTrustExactHeadReplay(nil, finalHead) { + t.Fatal("missing suffix selected completed-head transition bypass") + } +} + +func TestSelectFrostNativeSignerAnchorTrustTransitionChainRejectsAmbiguity( + t *testing.T, +) { + bootstrap, _ := trustTestBootstrapCertificate(t) + readback := frostNativeSignerAnchorNativeTrustHead( + trustTestCertificateHead(bootstrap), + ) + if _, err := selectFrostNativeSignerAnchorTrustTransitionChain( + []FrostNativeSignerAnchorTrustCertificate{ + *bootstrap, + *bootstrap, + }, + &readback, + ); err == nil { + t.Fatal("ambiguous authenticated head in configured chain was accepted") + } + if _, err := selectFrostNativeSignerAnchorTrustTransitionChain( + nil, + nil, + ); err == nil { + t.Fatal("empty configured trust chain was accepted") + } +} + +func TestValidateFrostNativeSignerAnchorTrustTransitionResult(t *testing.T) { + fixture := newFrostNativeSignerAnchorTrustStartupFixture() + protocolHead, nativeHead, err := + validateFrostNativeSignerAnchorTrustExpectedHead( + fixture.runtime, + &fixture.installed, + &fixture.certificate, + ) + if err != nil { + t.Fatal(err) + } + target := &FrostNativeSignerAnchorTrustTransitionTarget{ + ExactReadResponse: []byte(`{"fresh":"read"}`), + Reference: fixture.certificate.To.Reference, + } + result := frostNativeSignerAnchorTrustStartupResult( + *nativeHead, + target.Reference, + fixture.certificate.To.Reference.Checkpoint, + false, + 1, + ) + chain := []FrostNativeSignerAnchorTrustCertificate{ + fixture.certificate, + } + if err := validateFrostNativeSignerAnchorTrustTransitionResult( + &result, + target, + &fixture.certificate, + protocolHead, + nativeHead, + nil, + chain, + ); err != nil { + t.Fatalf("valid trust transition result was rejected: %v", err) + } + + tests := map[string]func( + *frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, + ){ + "not installed": func( + result *frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, + ) { + result.Installed = false + }, + "wrong head": func( + result *frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, + ) { + result.TrustHead.CertificateDigest[0] ^= 1 + }, + "idempotent": func( + result *frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, + ) { + result.Idempotent = true + }, + "applied count": func( + result *frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, + ) { + result.AppliedCertificateCount = 0 + }, + "current reference": func( + result *frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, + ) { + result.CurrentAnchorReference.EventRoot[0] ^= 1 + }, + "current checkpoint": func( + result *frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, + ) { + result.CurrentCheckpoint.Generation++ + }, + "witness base": func( + result *frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, + ) { + result.WitnessBaseCheckpoint.Generation++ + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + candidate := result + mutate(&candidate) + if err := validateFrostNativeSignerAnchorTrustTransitionResult( + &candidate, + target, + &fixture.certificate, + protocolHead, + nativeHead, + nil, + chain, + ); err == nil { + t.Fatal("invalid trust-transition result was accepted") + } + }) + } + + descendantTarget := *target + descendantTarget.Reference.Revision++ + descendantTarget.Reference.PreviousEventRoot = + target.Reference.EventRoot + descendantTarget.Reference.EventRoot = + frostNativeSignerAnchorTrustStartupBytes32(0xaf) + descendantTarget.Reference.AcknowledgementDigest = + frostNativeSignerAnchorTrustStartupBytes32(0xb0) + descendantResult := frostNativeSignerAnchorTrustStartupResult( + *nativeHead, + descendantTarget.Reference, + fixture.certificate.To.Reference.Checkpoint, + false, + 1, + ) + if err := validateFrostNativeSignerAnchorTrustTransitionResult( + &descendantResult, + &descendantTarget, + &fixture.certificate, + protocolHead, + nativeHead, + nil, + chain, + ); err == nil { + t.Fatal( + "new transition accepted a descendant instead of the exact certified target", + ) + } +} + +func TestValidateFrostNativeSignerAnchorReconciledTransitionTargetScopesExactHeadBypass( + t *testing.T, +) { + fixture := newFrostNativeSignerAnchorTrustStartupFixture() + reference := fixture.certificate.To.Reference + tip := &frostsigning.NativeTBTCSignerStateWitnessTip{ + Schema: frostsigning.NativeTBTCSignerStateWitnessTipSchema, + StoreFingerprint: reference.Checkpoint.StoreFingerprint, + Generation: reference.Checkpoint.Generation, + PreviousStateCommitment: reference.Checkpoint.PreviousStateCommitment, + StateImageDigest: reference.Checkpoint.StateImageDigest, + StateCommitment: reference.Checkpoint.StateCommitment, + WitnessBaseGeneration: reference.Checkpoint.Generation, + WitnessBaseCommitment: reference.Checkpoint.StateCommitment, + AnchorBindingHash: fixture.certificate.To.BindingHash, + AnchorServiceEpoch: reference.ServiceEpoch, + AnchorRevision: reference.Revision, + AnchorEventRoot: reference.EventRoot, + AnchorAcknowledgementDigest: reference.AcknowledgementDigest, + } + target := &FrostNativeSignerAnchorTrustTransitionTarget{ + ExactReadResponse: []byte(`{"fresh":"transition"}`), + Reference: reference, + } + if err := validateFrostNativeSignerAnchorReconciledTransitionTarget( + tip, + target, + ); err != nil { + t.Fatalf("exact missing-suffix target was rejected: %v", err) + } + + repaired := *tip + repaired.Generation++ + repaired.AnchorRevision++ + repaired.AnchorEventRoot[0] ^= 1 + repaired.AnchorAcknowledgementDigest[0] ^= 1 + if err := validateFrostNativeSignerAnchorReconciledTransitionTarget( + &repaired, + nil, + ); err != nil { + t.Fatalf( + "exact-head crash recovery was constrained by a stale target: %v", + err, + ) + } + if err := validateFrostNativeSignerAnchorReconciledTransitionTarget( + &repaired, + target, + ); err == nil { + t.Fatal("genuine missing-suffix transition accepted a different target") + } +} + +func TestValidateFrostNativeSignerAnchorTrustTransitionResultExactReplay( + t *testing.T, +) { + fixture := newFrostNativeSignerAnchorTrustStartupFixture() + protocolHead, nativeHead, err := + validateFrostNativeSignerAnchorTrustExpectedHead( + fixture.runtime, + &fixture.installed, + &fixture.certificate, + ) + if err != nil { + t.Fatal(err) + } + descendant := fixture.certificate.To.Reference + descendant.Revision = 4 + descendant.PreviousEventRoot = descendant.EventRoot + descendant.EventRoot = + frostNativeSignerAnchorTrustStartupBytes32(0xb1) + descendant.AcknowledgementDigest = + frostNativeSignerAnchorTrustStartupBytes32(0xb2) + descendant.Checkpoint.Generation += 3 + descendant.Checkpoint.PreviousStateCommitment = + frostNativeSignerAnchorTrustStartupBytes32(0xb3) + descendant.Checkpoint.StateImageDigest = + frostNativeSignerAnchorTrustStartupBytes32(0xb4) + descendant.Checkpoint.StateCommitment = + frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + descendant.Checkpoint.StoreFingerprint, + descendant.Checkpoint.Generation, + descendant.Checkpoint.PreviousStateCommitment, + descendant.Checkpoint.StateImageDigest, + ) + target := &FrostNativeSignerAnchorTrustTransitionTarget{ + ExactReadResponse: []byte(`{"fresh":"descendant"}`), + Reference: descendant, + } + result := frostNativeSignerAnchorTrustStartupResult( + *nativeHead, + descendant, + fixture.certificate.To.Reference.Checkpoint, + true, + 0, + ) + chain := []FrostNativeSignerAnchorTrustCertificate{ + fixture.certificate, + } + if err := validateFrostNativeSignerAnchorTrustTransitionResult( + &result, + target, + &fixture.certificate, + protocolHead, + nativeHead, + protocolHead, + chain, + ); err != nil { + t.Fatalf("valid completed-restart result was rejected: %v", err) + } + + result.WitnessBaseCheckpoint.Generation = + descendant.Checkpoint.Generation + if err := validateFrostNativeSignerAnchorTrustTransitionResult( + &result, + target, + &fixture.certificate, + protocolHead, + nativeHead, + protocolHead, + chain, + ); err == nil { + t.Fatal("equal-generation forked replay witness base was accepted") + } +} + +func frostNativeSignerAnchorTrustStartupResult( + head frostsigning.NativeTBTCSignerStateAnchorTrustHead, + current FrostNativeSignerAnchorTrustReference, + witnessBase FrostNativeSignerStateWitnessCheckpoint, + idempotent bool, + applied uint64, +) frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult { + return frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult{ + Schema: frostsigning. + NativeTBTCSignerStateAnchorTrustTransitionResultSchema, + Installed: true, + Idempotent: idempotent, + AppliedCertificateCount: applied, + TrustHead: head, + CurrentCheckpoint: frostNativeSignerAnchorNativeTrustCheckpoint( + current.Checkpoint, + ), + WitnessBaseCheckpoint: frostNativeSignerAnchorNativeTrustCheckpoint( + witnessBase, + ), + CurrentAnchorReference: frostNativeSignerAnchorNativeTrustReference( + current, + ), + } +} + +func newFrostNativeSignerAnchorTrustStartupFixture() ( + fixture frostNativeSignerAnchorTrustStartupFixture, +) { + protocolID := frostNativeSignerAnchorTrustStartupBytes32(0x11) + streamID := frostNativeSignerAnchorTrustStartupBytes32(0x12) + manifestHash := frostNativeSignerAnchorTrustStartupBytes32(0x13) + storeFingerprint := frostNativeSignerAnchorTrustStartupBytes32(0x14) + responsePublicKey := + frostNativeSignerAnchorTrustStartupPublicKey(0x21) + authorityPublicKey := + frostNativeSignerAnchorTrustStartupPublicKey(0x31) + responseSPKIHash := + ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + responsePublicKey, + ) + authoritySPKIHash := + ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + authorityPublicKey, + ) + identity := FrostNativeSignerAnchorIdentity{ + ProtocolID: protocolID, + StreamID: streamID, + ActivationManifestHash: manifestHash, + ActivationManifestSequence: 4, + TrustDomainID: "trust.example", + EndpointLeafSPKIHash: frostNativeSignerAnchorTrustStartupBytes32(0x41), + OnlineKeyHash: responseSPKIHash, + OperatorFingerprint: frostNativeSignerAnchorTrustStartupBytes32(0x42), + HistoryStoreID: "history-1", + HistoryStoreFingerprint: frostNativeSignerAnchorTrustStartupBytes32(0x43), + HistoryClusterFingerprint: frostNativeSignerAnchorTrustStartupBytes32(0x44), + OfflineAuthorityHash: authoritySPKIHash, + ClientSPKIHash: frostNativeSignerAnchorTrustStartupBytes32(0x45), + SignerStoreFingerprint: storeFingerprint, + TransportBinding: frostNativeSignerAnchorTrustStartupBytes32(0x46), + WitnessMaximumRecords: 64, + WitnessRotationThresholdRecords: 48, + } + identity.StreamID = ComputeFrostNativeSignerAnchorStreamID(identity) + streamID = identity.StreamID + bindingHash := ComputeFrostNativeSignerAnchorBindingHash(identity) + checkpoint := FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: storeFingerprint, + Generation: 7, + PreviousStateCommitment: frostNativeSignerAnchorTrustStartupBytes32(0x51), + StateImageDigest: frostNativeSignerAnchorTrustStartupBytes32(0x52), + } + checkpoint.StateCommitment = + frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + checkpoint.StoreFingerprint, + checkpoint.Generation, + checkpoint.PreviousStateCommitment, + checkpoint.StateImageDigest, + ) + reference := FrostNativeSignerAnchorTrustReference{ + ServiceEpoch: 3, + Revision: 1, + PreviousEventRoot: frostNativeSignerAnchorTrustStartupBytes32(0x53), + EventRoot: frostNativeSignerAnchorTrustStartupBytes32(0x54), + AcknowledgementDigest: frostNativeSignerAnchorTrustStartupBytes32(0x55), + Checkpoint: checkpoint, + } + endpoint := FrostNativeSignerAnchorTrustEndpoint{ + ActivationManifestHash: manifestHash, + ActivationManifestSequence: identity.ActivationManifestSequence, + BindingHash: bindingHash, + ResponsePublicKey: responsePublicKey, + ResponsePublicKeySPKISHA256: responseSPKIHash, + OfflineAuthorityPublicKey: authorityPublicKey, + OfflineAuthoritySPKISHA256: authoritySPKIHash, + WitnessMaximumRecords: identity.WitnessMaximumRecords, + WitnessRotationThresholdRecords: identity.WitnessRotationThresholdRecords, + Reference: reference, + } + certificateDigest := + frostNativeSignerAnchorTrustStartupBytes32(0x61) + fixture.runtime = FrostPreSignActivationRuntimeManifest{ + ManifestHash: manifestHash, + NativeSignerAnchor: FrostNativeSignerAnchorManifest{ + Identity: identity, + WitnessMaximumRecords: identity.WitnessMaximumRecords, + WitnessRotationThresholdRecords: identity.WitnessRotationThresholdRecords, + }, + ActivationAuthorityPublicKey: authorityPublicKey, + } + fixture.installed = + frostsigning.NativeTBTCSignerInstalledStateAnchorConfig{ + ProtocolID: protocolID, + StreamID: streamID, + ActivationManifestHash: manifestHash, + ActivationManifestSequence: identity.ActivationManifestSequence, + BindingHash: bindingHash, + ResponsePublicKey: responsePublicKey, + ResponsePublicKeySPKISHA256: responseSPKIHash, + OfflineAuthorityPublicKey: authorityPublicKey, + OfflineAuthoritySPKISHA256: authoritySPKIHash, + TrustCertificateSequence: 3, + TrustCertificateDigest: certificateDigest, + WitnessMaximumRecords: identity.WitnessMaximumRecords, + WitnessRotationThresholdRecords: identity.WitnessRotationThresholdRecords, + ConfigFingerprint: "installed-config", + } + fixture.certificate = FrostNativeSignerAnchorTrustCertificate{ + Kind: FrostNativeSignerAnchorTrustCertificateRotation, + CertificateSequence: fixture.installed.TrustCertificateSequence, + CertificateDigest: certificateDigest, + ProtocolID: protocolID, + StreamID: streamID, + SignerStoreFingerprint: storeFingerprint, + To: endpoint, + PreviousCertificateDigest: frostNativeSignerAnchorTrustStartupBytes32(0x62), + } + return fixture +} + +func newFrostNativeSignerAnchorTrustPriorStartupFixture() ( + frostNativeSignerAnchorTrustStartupFixture, + FrostNativeSignerAnchorTrustCertificateHead, + frostsigning.NativeTBTCSignerStateAnchorTrustHead, +) { + fixture := newFrostNativeSignerAnchorTrustStartupFixture() + priorResponsePublicKey := + frostNativeSignerAnchorTrustStartupPublicKey(0x71) + priorResponseSPKIHash := + ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + priorResponsePublicKey, + ) + priorEndpoint := fixture.certificate.To + priorEndpoint.ActivationManifestHash = + frostNativeSignerAnchorTrustStartupBytes32(0x72) + priorEndpoint.ActivationManifestSequence-- + priorEndpoint.BindingHash = + frostNativeSignerAnchorTrustStartupBytes32(0x73) + priorEndpoint.ResponsePublicKey = priorResponsePublicKey + priorEndpoint.ResponsePublicKeySPKISHA256 = priorResponseSPKIHash + priorEndpoint.Reference.ServiceEpoch-- + priorEndpoint.Reference.EventRoot = + frostNativeSignerAnchorTrustStartupBytes32(0x74) + priorEndpoint.Reference.AcknowledgementDigest = + frostNativeSignerAnchorTrustStartupBytes32(0x75) + fixture.certificate.From = &priorEndpoint + priorHead := FrostNativeSignerAnchorTrustCertificateHead{ + CertificateSequence: fixture.certificate.CertificateSequence - 1, + CertificateDigest: fixture.certificate.PreviousCertificateDigest, + ProtocolID: fixture.installed.ProtocolID, + StreamID: fixture.installed.StreamID, + SignerStoreFingerprint: fixture.certificate.SignerStoreFingerprint, + Endpoint: priorEndpoint, + } + readback := frostNativeSignerAnchorNativeTrustHead(&priorHead) + return fixture, priorHead, readback +} + +func frostNativeSignerAnchorTrustStartupBytes32(value byte) [32]byte { + result := [32]byte{} + for i := range result { + result[i] = value + } + return result +} + +func frostNativeSignerAnchorTrustStartupPublicKey(seed byte) [32]byte { + privateKey := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{seed}, ed25519.SeedSize), + ) + var result [32]byte + copy(result[:], privateKey.Public().(ed25519.PublicKey)) + return result +} diff --git a/pkg/tbtc/frost_native_signer_anchor_trust_test.go b/pkg/tbtc/frost_native_signer_anchor_trust_test.go new file mode 100644 index 0000000000..ac897fdb66 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_trust_test.go @@ -0,0 +1,1613 @@ +package tbtc + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/decred/dcrd/dcrec/edwards/v2" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +func TestFrostNativeSignerAnchorTrustCertificateFrozenVector(t *testing.T) { + certificate, _ := trustTestBootstrapCertificate(t) + finalDigest, err := + ComputeFrostNativeSignerAnchorTrustFinalDigest(certificate) + if err != nil { + t.Fatal(err) + } + encoded, err := EncodeFrostNativeSignerAnchorTrustCertificate(certificate) + if err != nil { + t.Fatal(err) + } + encodedDigest := sha256.Sum256(encoded) + vectors := []struct { + name string + actual []byte + expected string + }{ + { + "coreDigest", + certificate.CoreDigest[:], + "d3b5ea2a8c29dc4f4ef5250fc75efb3fb6bcd87167d523661c4a55e7898fb90d", + }, + { + "coreSignature", + certificate.CoreSignature[:], + "9a24471862899050b13aa08e83b994a7d247ad4661afca78bfd890d7d49c9c14c5c07cfe01d55dedddbae6eb06304c3cb772b9ce3073c5240d12bab9802b6102", + }, + { + "operationID", + certificate.OperationID[:], + "49351644f18779575f614ab4b50c14c6454d8bd93d0a287edf41c801826edea0", + }, + { + "transitionDigest", + certificate.TransitionDigest[:], + "50e2372f4008f0bd47c4e17c517fe5b5dc6062736492a1414ada049878447979", + }, + { + "targetAcknowledgementSHA256", + certificate.TargetAcknowledgementSHA256[:], + "9605e695983da86a907c2a4b32ae322aa0c242fa0884d9049150007bc463c91b", + }, + { + "finalDigest", + finalDigest[:], + "0ae35d27aa3b74817a5dd99dcb1355975a74df6d5bcdda14288382eb8067535e", + }, + { + "finalSignature", + certificate.FinalSignature[:], + "16bc2097a17956f2888a6a370c8ad31e76c0f30ca2fef66d7c2b6ccacccb0cf6f660a10e920d1a5d9fe4d06259ee87f9555f2be330aab19d13fbdceb8bdf6a02", + }, + { + "certificateDigest", + certificate.CertificateDigest[:], + "059967c2178a72c178e894fe54ac74fccd657aec73f3b2ce48b9e68bae098a0b", + }, + { + "canonicalCertificateJSONSHA256", + encodedDigest[:], + "ea80af7129eb2a52d3116007d9caab2f5bf9df2edbc7d29811ff275b7f6d0412", + }, + } + for _, vector := range vectors { + actual := hex.EncodeToString(vector.actual) + if actual != vector.expected { + t.Errorf( + "%s vector mismatch: got [%s], want [%s]", + vector.name, + actual, + vector.expected, + ) + } + } +} + +func TestFrostNativeSignerAnchorTrustCertificateRoundTripAndValidation( + t *testing.T, +) { + certificate, _ := trustTestBootstrapCertificate(t) + encoded, err := EncodeFrostNativeSignerAnchorTrustCertificate(certificate) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeFrostNativeSignerAnchorTrustCertificate(encoded) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(certificate, decoded) { + t.Fatal("trust certificate round trip changed its exact material") + } + expectedAfterCallback := trustTestCloneCertificate(decoded) + calls := 0 + err = ValidateFrostNativeSignerAnchorTrustCertificate( + decoded, + func( + validated *FrostNativeSignerAnchorTrustCertificate, + raw []byte, + ) error { + calls++ + if validated == decoded || + !bytes.Equal(raw, decoded.TargetAcknowledgement) { + t.Fatal("target acknowledgement validator received changed material") + } + validated.To.BindingHash[0] ^= 0xff + validated.TargetAcknowledgement[0] ^= 0xff + raw[0] ^= 0xff + return nil + }, + ) + if err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("target acknowledgement validator called [%d] times", calls) + } + if !reflect.DeepEqual(decoded, expectedAfterCallback) { + t.Fatal("target acknowledgement callback mutated the validated certificate") + } +} + +func TestFrostNativeSignerAnchorTrustVectorsCarryValidTargetAcknowledgements( + t *testing.T, +) { + bootstrap, authority := trustTestBootstrapCertificate(t) + rotation := trustTestRotationCertificate(t, bootstrap, authority, 0x33) + for name, certificate := range map[string]*FrostNativeSignerAnchorTrustCertificate{ + "bootstrap": bootstrap, + "rotation": rotation, + } { + t.Run(name, func(t *testing.T) { + if err := ValidateFrostNativeSignerAnchorTrustCertificate( + certificate, + ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement, + ); err != nil { + t.Fatalf("valid shared %s vector was rejected: %v", name, err) + } + }) + } +} + +func TestFrostNativeSignerAnchorTrustSharedValidVectors(t *testing.T) { + bootstrap, authority := trustTestBootstrapCertificate(t) + rotation := trustTestRotationCertificate(t, bootstrap, authority, 0x33) + adoption := trustTestRotationCertificate(t, bootstrap, authority, 0x44) + adoption.CertificateSequence = 1 + adoption.PreviousCertificateDigest = [32]byte{} + adoptionResponse := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x44}, ed25519.SeedSize), + ) + trustTestFinalizeCertificateWithAcknowledgement( + t, + adoption, + authority, + adoptionResponse, + 0x44, + ) + + vectors := map[string]struct { + certificate *FrostNativeSignerAnchorTrustCertificate + core string + operation string + transition string + final string + certificateDigest string + jsonSHA256 string + }{ + "bootstrap": { + bootstrap, + "d3b5ea2a8c29dc4f4ef5250fc75efb3fb6bcd87167d523661c4a55e7898fb90d", + "49351644f18779575f614ab4b50c14c6454d8bd93d0a287edf41c801826edea0", + "50e2372f4008f0bd47c4e17c517fe5b5dc6062736492a1414ada049878447979", + "0ae35d27aa3b74817a5dd99dcb1355975a74df6d5bcdda14288382eb8067535e", + "059967c2178a72c178e894fe54ac74fccd657aec73f3b2ce48b9e68bae098a0b", + "ea80af7129eb2a52d3116007d9caab2f5bf9df2edbc7d29811ff275b7f6d0412", + }, + "rotation": { + rotation, + "87ce928827c85744e9a01422c83e72ea1cf6f27c2694e4b1d93f519f8a905866", + "39f50c5787e4decb56b87979062aa7e75f773b1693e584e8872a860da55fec93", + "1a130d6da974f81cdbfdd923d9e53cf46cd9d81455b3c75e4cad1fe9c15b4385", + "19f98af5fb9e017470594782c632eeaabf352c681523c626d6f6bb59b43318a2", + "0d571f120304487645bad248d866f7455ea50bf9b2bc6521de47151b7d671f35", + "8f834f9b335854218f07fcd2af0fafb311ac046b026f5436c7fe89ff24c9ade8", + }, + "adoption": { + adoption, + "b8028e79579f400c80cd76101473e9a8e02be0644c5c4544a7a582103d5320a4", + "c4dc774b78b391f173756130abb5412e62958f174783302396df673cfb70c30d", + "bc3bc28337f870c07f41353d597ea38c96a2dd7f9f2f80536404e1e043296991", + "aa69c3894ad0118c71189352565122950d2dfa5a20337f48c53c750dddae5969", + "a8af21e3da145313f4f0357aa5de40a99e01af9faa8ff2cb680f9531ccb271dd", + "e8b0fe13efbb0c667576411836c2f6ac22df1eb70904cb0d796ae0b3dfc06cdb", + }, + } + for name, vector := range vectors { + t.Run(name, func(t *testing.T) { + certificate := vector.certificate + if err := ValidateFrostNativeSignerAnchorTrustCertificate( + certificate, + ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement, + ); err != nil { + t.Fatal(err) + } + encoded, err := + EncodeFrostNativeSignerAnchorTrustCertificate(certificate) + if err != nil { + t.Fatal(err) + } + finalDigest, err := + ComputeFrostNativeSignerAnchorTrustFinalDigest(certificate) + if err != nil { + t.Fatal(err) + } + jsonDigest := sha256.Sum256(encoded) + actual := []string{ + hex.EncodeToString(certificate.CoreDigest[:]), + hex.EncodeToString(certificate.OperationID[:]), + hex.EncodeToString(certificate.TransitionDigest[:]), + hex.EncodeToString(finalDigest[:]), + hex.EncodeToString(certificate.CertificateDigest[:]), + hex.EncodeToString(jsonDigest[:]), + } + expected := []string{ + vector.core, + vector.operation, + vector.transition, + vector.final, + vector.certificateDigest, + vector.jsonSHA256, + } + if !reflect.DeepEqual(actual, expected) { + t.Fatalf( + "shared %s vector mismatch:\nactual: %v\nexpected: %v", + name, + actual, + expected, + ) + } + t.Logf("canonicalJSON=%s", encoded) + }) + } + chainOptions := trustTestChainOptions(rotation) + chainOptions.ValidateTargetAcknowledgement = + ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + []FrostNativeSignerAnchorTrustCertificate{ + *bootstrap, + *rotation, + }, + chainOptions, + ); err != nil { + t.Fatalf("shared bootstrap/rotation chain is invalid: %v", err) + } + adoptionOptions := trustTestChainOptions(adoption) + adoptionOptions.AllowLegacyAdoption = true + adoptionOptions.ValidateTargetAcknowledgement = + ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + []FrostNativeSignerAnchorTrustCertificate{*adoption}, + adoptionOptions, + ); err != nil { + t.Fatalf("shared legacy-adoption chain is invalid: %v", err) + } +} + +func TestFrostNativeSignerAnchorTrustCertificateStrictDecode(t *testing.T) { + certificate, _ := trustTestBootstrapCertificate(t) + encoded, err := EncodeFrostNativeSignerAnchorTrustCertificate(certificate) + if err != nil { + t.Fatal(err) + } + canonical := string(encoded) + + withoutFrom := strings.Replace(canonical, `"from":null,`, "", 1) + duplicateReference := strings.Replace( + canonical, + `"reference":{`, + `"reference":{"revision":"1","revision":"1",`, + 1, + ) + caseAlias := strings.Replace(canonical, `"schema":`, `"Schema":`, 1) + unknown := strings.Replace(canonical, `"kind":`, `"unknown":"x","kind":`, 1) + nonCanonicalSequence := strings.Replace( + canonical, + `"certificateSequence":"1"`, + `"certificateSequence":"01"`, + 1, + ) + upperCoreDigest := trustTestUppercaseHexMember( + t, + canonical, + "coreDigest", + ) + rawBase64 := strings.Replace( + canonical, + base64.StdEncoding.EncodeToString(certificate.TargetAcknowledgement), + strings.TrimRight( + base64.StdEncoding.EncodeToString(certificate.TargetAcknowledgement), + "=", + ), + 1, + ) + coreSignatureBase64 := base64.StdEncoding.EncodeToString( + certificate.CoreSignature[:], + ) + rawCoreSignature := strings.Replace( + canonical, + coreSignatureBase64, + strings.TrimRight(coreSignatureBase64, "="), + 1, + ) + trailing := canonical + `{}` + nonASCIIKey := strings.Replace(canonical, `"kind":`, `"kınd":`, 1) + + cases := map[string]string{ + "missing from": withoutFrom, + "duplicate nested": duplicateReference, + "case alias": caseAlias, + "unknown": unknown, + "noncanonical sequence": nonCanonicalSequence, + "uppercase hex": upperCoreDigest, + "raw base64": rawBase64, + "raw signature base64": rawCoreSignature, + "trailing object": trailing, + "non-ASCII key": nonASCIIKey, + } + for name, payload := range cases { + t.Run(name, func(t *testing.T) { + if _, err := DecodeFrostNativeSignerAnchorTrustCertificate( + []byte(payload), + ); err == nil { + t.Fatal("malformed trust certificate was accepted") + } + }) + } +} + +func TestFrostNativeSignerAnchorTrustCertificateRejectsParserAndCryptoBypasses( + t *testing.T, +) { + valid, _ := trustTestBootstrapCertificate(t) + tests := map[string]func(*FrostNativeSignerAnchorTrustCertificate){ + "core digest": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.CoreDigest[0] ^= 1 + }, + "core signature": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.CoreSignature[0] ^= 1 + }, + "operation ID": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.OperationID[0] ^= 1 + }, + "transition digest": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.TransitionDigest[0] ^= 1 + }, + "acknowledgement hash": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.TargetAcknowledgementSHA256[0] ^= 1 + }, + "final signature": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.FinalSignature[0] ^= 1 + }, + "certificate digest": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.CertificateDigest[0] ^= 1 + }, + "response SPKI": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.To.ResponsePublicKeySPKISHA256[0] ^= 1 + }, + "authority SPKI": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.To.OfflineAuthoritySPKISHA256[0] ^= 1 + }, + "oversized acknowledgement": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.TargetAcknowledgement = bytes.Repeat( + []byte{'x'}, + frostNativeSignerAnchorTrustMaximumAcknowledgementBytes+1, + ) + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + certificate := trustTestCloneCertificate(valid) + mutate(certificate) + calls := 0 + err := ValidateFrostNativeSignerAnchorTrustCertificate( + certificate, + func( + _ *FrostNativeSignerAnchorTrustCertificate, + _ []byte, + ) error { + calls++ + return nil + }, + ) + if err == nil { + t.Fatal("tampered trust certificate was accepted") + } + if calls != 0 { + t.Fatal("semantic callback ran before structural/crypto validation") + } + }) + } + if err := ValidateFrostNativeSignerAnchorTrustCertificate( + valid, + func( + _ *FrostNativeSignerAnchorTrustCertificate, + _ []byte, + ) error { + return errors.New("rejected") + }, + ); err == nil { + t.Fatal("target acknowledgement callback rejection was ignored") + } + if err := ValidateFrostNativeSignerAnchorTrustCertificate(valid, nil); err == nil { + t.Fatal("missing target acknowledgement callback was accepted") + } +} + +func TestFrostNativeSignerAnchorTrustCertificateRejectsInvalidEd25519Points( + t *testing.T, +) { + valid, authority := trustTestBootstrapCertificate(t) + for _, field := range []string{"response", "authority"} { + t.Run(field, func(t *testing.T) { + certificate := trustTestCloneCertificate(valid) + invalid := [ed25519.PublicKeySize]byte{} + for index := range invalid { + invalid[index] = 0xff + } + switch field { + case "response": + certificate.To.ResponsePublicKey = invalid + certificate.To.ResponsePublicKeySPKISHA256 = + ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + invalid, + ) + case "authority": + certificate.To.OfflineAuthorityPublicKey = invalid + certificate.To.OfflineAuthoritySPKISHA256 = + ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + invalid, + ) + } + trustTestFinalizeCertificate(t, certificate, authority) + if err := ValidateFrostNativeSignerAnchorTrustCertificate( + certificate, + func( + _ *FrostNativeSignerAnchorTrustCertificate, + _ []byte, + ) error { + return nil + }, + ); err == nil || !strings.Contains(err.Error(), "point") { + t.Fatalf("invalid %s Ed25519 point was accepted: %v", field, err) + } + }) + } +} + +func TestFrostNativeSignerAnchorTrustEd25519PublicKeyRejectsCanonicalNonPrimeOrderPoints( + t *testing.T, +) { + validPrivate := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x5a}, ed25519.SeedSize), + ) + validPublic := validPrivate.Public().(ed25519.PublicKey) + if err := ValidateFrostNativeSignerAnchorTrustEd25519PublicKey( + validPublic, + ); err != nil { + t.Fatalf("valid prime-subgroup key was rejected: [%v]", err) + } + + identity := make([]byte, ed25519.PublicKeySize) + identity[0] = 1 + orderFourTorsion := make([]byte, ed25519.PublicKeySize) + mixedOrder, err := hex.DecodeString( + "9970c93c125fd998ebc1642abe30619e2fd971dbcbeaeb8ccfe919cbfd13b6cf", + ) + if err != nil { + t.Fatal(err) + } + for name, publicKey := range map[string][]byte{ + "identity": identity, + "order-four torsion": orderFourTorsion, + "prime-plus-torsion mixed order": mixedOrder, + } { + t.Run(name, func(t *testing.T) { + point, err := edwards.ParsePubKey(publicKey) + if err != nil || point == nil || + !bytes.Equal(point.Serialize(), publicKey) { + t.Fatalf( + "rejection vector is not a canonical Edwards25519 encoding: [%v]", + err, + ) + } + if err := ValidateFrostNativeSignerAnchorTrustEd25519PublicKey( + publicKey, + ); err == nil || + (!strings.Contains(err.Error(), "identity") && + !strings.Contains(err.Error(), "subgroup")) { + t.Fatalf("small-order key was accepted: [%v]", err) + } + }) + } + + certificate, _ := trustTestBootstrapCertificate(t) + copy(certificate.To.ResponsePublicKey[:], identity) + if _, err := verifyFrostNativeSignerAnchorTrustTargetAcknowledgement( + certificate, + []byte(`{}`), + ); err == nil || !strings.Contains(err.Error(), "response key") { + t.Fatalf( + "exported acknowledgement verification reached signature parsing "+ + "with an identity key: [%v]", + err, + ) + } +} + +func TestFrostNativeSignerAnchorTrustCertificateChain(t *testing.T) { + bootstrap, authority := trustTestBootstrapCertificate(t) + rotation := trustTestRotationCertificate(t, bootstrap, authority, 0x33) + chain := []FrostNativeSignerAnchorTrustCertificate{ + *bootstrap, + *rotation, + } + calls := 0 + options := FrostNativeSignerAnchorTrustChainValidationOptions{ + ExpectedProtocolID: bootstrap.ProtocolID, + ExpectedStreamID: bootstrap.StreamID, + ExpectedSignerStoreFingerprint: bootstrap.SignerStoreFingerprint, + ExpectedOfflineAuthorityPublicKey: bootstrap.To.OfflineAuthorityPublicKey, + ExpectedOfflineAuthoritySPKISHA256: bootstrap.To.OfflineAuthoritySPKISHA256, + ExpectedHead: trustTestCertificateHead(rotation), + ValidateTargetAcknowledgement: func( + _ *FrostNativeSignerAnchorTrustCertificate, + _ []byte, + ) error { + calls++ + return nil + }, + } + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + chain, + options, + ); err != nil { + t.Fatal(err) + } + if calls != len(chain) { + t.Fatalf("validated [%d] acknowledgements, expected [%d]", calls, len(chain)) + } + + tests := map[string]func( + []FrostNativeSignerAnchorTrustCertificate, + *FrostNativeSignerAnchorTrustChainValidationOptions, + ){ + "previous digest": func( + chain []FrostNativeSignerAnchorTrustCertificate, + _ *FrostNativeSignerAnchorTrustChainValidationOptions, + ) { + chain[1].PreviousCertificateDigest[0] ^= 1 + }, + "from endpoint": func( + chain []FrostNativeSignerAnchorTrustCertificate, + _ *FrostNativeSignerAnchorTrustChainValidationOptions, + ) { + chain[1].From.BindingHash[0] ^= 1 + }, + "protocol substitution": func( + chain []FrostNativeSignerAnchorTrustCertificate, + _ *FrostNativeSignerAnchorTrustChainValidationOptions, + ) { + chain[1].ProtocolID[0] ^= 1 + }, + "head digest": func( + _ []FrostNativeSignerAnchorTrustCertificate, + options *FrostNativeSignerAnchorTrustChainValidationOptions, + ) { + options.ExpectedHead.CertificateDigest[0] ^= 1 + }, + "authority pin": func( + _ []FrostNativeSignerAnchorTrustCertificate, + options *FrostNativeSignerAnchorTrustChainValidationOptions, + ) { + options.ExpectedOfflineAuthorityPublicKey[0] ^= 1 + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + candidate := trustTestCloneChain(chain) + candidateOptions := trustTestCloneChainOptions(options) + candidateOptions.ValidateTargetAcknowledgement = func( + _ *FrostNativeSignerAnchorTrustCertificate, + _ []byte, + ) error { + return nil + } + mutate(candidate, &candidateOptions) + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + candidate, + candidateOptions, + ); err == nil { + t.Fatal("forked or incorrectly pinned certificate chain was accepted") + } + }) + } +} + +func TestFrostNativeSignerAnchorTrustCertificateMissingSuffix(t *testing.T) { + bootstrap, authority := trustTestBootstrapCertificate(t) + second := trustTestRotationCertificate(t, bootstrap, authority, 0x34) + third := trustTestRotationCertificate(t, second, authority, 0x35) + validSuffix := []FrostNativeSignerAnchorTrustCertificate{*third} + options := trustTestChainOptions(third) + options.PriorHead = trustTestCertificateHead(second) + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + validSuffix, + options, + ); err != nil { + t.Fatalf("valid authenticated missing suffix was rejected: %v", err) + } + + tests := map[string]func( + []FrostNativeSignerAnchorTrustCertificate, + *FrostNativeSignerAnchorTrustChainValidationOptions, + ){ + "gap": func( + chain []FrostNativeSignerAnchorTrustCertificate, + options *FrostNativeSignerAnchorTrustChainValidationOptions, + ) { + chain[0].CertificateSequence++ + trustTestFinalizeCertificate(t, &chain[0], authority) + options.ExpectedHead = trustTestCertificateHead(&chain[0]) + }, + "fork": func( + chain []FrostNativeSignerAnchorTrustCertificate, + options *FrostNativeSignerAnchorTrustChainValidationOptions, + ) { + chain[0].PreviousCertificateDigest[0] ^= 1 + trustTestFinalizeCertificate(t, &chain[0], authority) + options.ExpectedHead = trustTestCertificateHead(&chain[0]) + }, + "from mismatch": func( + chain []FrostNativeSignerAnchorTrustCertificate, + options *FrostNativeSignerAnchorTrustChainValidationOptions, + ) { + chain[0].From.BindingHash[0] ^= 1 + trustTestFinalizeCertificate(t, &chain[0], authority) + options.ExpectedHead = trustTestCertificateHead(&chain[0]) + }, + "missing prior head": func( + _ []FrostNativeSignerAnchorTrustCertificate, + options *FrostNativeSignerAnchorTrustChainValidationOptions, + ) { + options.PriorHead = nil + }, + "wrong prior endpoint": func( + _ []FrostNativeSignerAnchorTrustCertificate, + options *FrostNativeSignerAnchorTrustChainValidationOptions, + ) { + options.PriorHead.Endpoint.BindingHash[0] ^= 1 + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + chain := trustTestCloneChain(validSuffix) + candidateOptions := trustTestCloneChainOptions(options) + calls := 0 + candidateOptions.ValidateTargetAcknowledgement = func( + _ *FrostNativeSignerAnchorTrustCertificate, + _ []byte, + ) error { + calls++ + return nil + } + mutate(chain, &candidateOptions) + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + chain, + candidateOptions, + ); err == nil { + t.Fatal("invalid missing suffix was accepted") + } + if calls != 0 { + t.Fatal("semantic callback ran before suffix anchoring") + } + }) + } +} + +func TestFrostNativeSignerAnchorTrustCertificateAllowsPostSigningRotation( + t *testing.T, +) { + bootstrap, authority := trustTestBootstrapCertificate(t) + rotation := trustTestRotationCertificate(t, bootstrap, authority, 0x39) + fromCheckpoint := trustTestCheckpoint( + bootstrap.SignerStoreFingerprint, + bootstrap.To.Reference.Checkpoint.Generation+3, + trustTestBytes32(0x91), + trustTestBytes32(0x92), + ) + trustTestSetRotationFromDescendant( + t, + rotation, + authority, + 7, + fromCheckpoint, + ) + + fullChain := []FrostNativeSignerAnchorTrustCertificate{ + *bootstrap, + *rotation, + } + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + fullChain, + trustTestChainOptions(rotation), + ); err != nil { + t.Fatalf("post-signing rotation in a full chain was rejected: %v", err) + } + + options := trustTestChainOptions(rotation) + options.PriorHead = trustTestCertificateHead(bootstrap) + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + []FrostNativeSignerAnchorTrustCertificate{*rotation}, + options, + ); err != nil { + t.Fatalf("post-signing rotation suffix was rejected: %v", err) + } +} + +func TestFrostNativeSignerAnchorTrustReferenceDescendantRules(t *testing.T) { + bootstrap, _ := trustTestBootstrapCertificate(t) + floor := bootstrap.To.Reference + valid := floor + valid.Revision = 2 + valid.PreviousEventRoot = floor.EventRoot + valid.EventRoot = trustTestBytes32(0xa1) + valid.AcknowledgementDigest = trustTestBytes32(0xa2) + if err := frostNativeSignerAnchorTrustValidateReferenceDescendant( + floor, + valid, + ); err != nil { + t.Fatalf("valid later reference was rejected: %v", err) + } + atBound := valid + atBound.Revision = + floor.Revision + FrostNativeSignerAnchorMaximumHistoryEvents + if err := frostNativeSignerAnchorTrustValidateReferenceDescendant( + floor, + atBound, + ); err != nil { + t.Fatalf("last restartable reference was rejected: %v", err) + } + beyondBound := atBound + beyondBound.Revision++ + if err := frostNativeSignerAnchorTrustValidateReferenceDescendant( + floor, + beyondBound, + ); err == nil { + t.Fatal("reference beyond the restartable history bound was accepted") + } + + tests := map[string]func(*FrostNativeSignerAnchorTrustReference){ + "equal revision fork": func(candidate *FrostNativeSignerAnchorTrustReference) { + *candidate = floor + candidate.EventRoot[0] ^= 1 + }, + "wrong epoch": func(candidate *FrostNativeSignerAnchorTrustReference) { + candidate.ServiceEpoch++ + }, + "missing parent": func(candidate *FrostNativeSignerAnchorTrustReference) { + candidate.PreviousEventRoot = [32]byte{} + }, + "generation rollback": func(candidate *FrostNativeSignerAnchorTrustReference) { + candidate.Checkpoint = trustTestCheckpoint( + floor.Checkpoint.StoreFingerprint, + floor.Checkpoint.Generation-1, + trustTestBytes32(0xa3), + trustTestBytes32(0xa4), + ) + }, + "equal generation checkpoint fork": func(candidate *FrostNativeSignerAnchorTrustReference) { + candidate.Checkpoint = trustTestCheckpoint( + floor.Checkpoint.StoreFingerprint, + floor.Checkpoint.Generation, + trustTestBytes32(0xa5), + trustTestBytes32(0xa6), + ) + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + candidate := valid + mutate(&candidate) + if err := frostNativeSignerAnchorTrustValidateReferenceDescendant( + floor, + candidate, + ); err == nil { + t.Fatal("invalid certified-floor descendant was accepted") + } + }) + } +} + +func TestFrostNativeSignerAnchorTrustCertificateExactReplay(t *testing.T) { + bootstrap, _ := trustTestBootstrapCertificate(t) + head := trustTestCertificateHead(bootstrap) + options := trustTestChainOptions(bootstrap) + options.PriorHead = head + chain := []FrostNativeSignerAnchorTrustCertificate{*bootstrap} + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + chain, + options, + ); err != nil { + t.Fatalf("exact idempotent certificate replay was rejected: %v", err) + } + + olderHead := *head + olderHead.CertificateDigest[0] ^= 1 + options.PriorHead = &olderHead + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + chain, + options, + ); err == nil { + t.Fatal("non-exact replay head was accepted") + } +} + +func TestAuthenticateFrostNativeSignerAnchorTrustChainMintsDefensiveCapability( + t *testing.T, +) { + bootstrap, _ := trustTestBootstrapCertificate(t) + chain := []FrostNativeSignerAnchorTrustCertificate{*bootstrap} + capability, err := + authenticateFrostNativeSignerAnchorTrustCertificateChain( + chain, + trustTestChainOptions(bootstrap), + ) + if err != nil { + t.Fatalf("valid trust chain did not mint a capability: %v", err) + } + if capability == nil || + capability.certificate.CertificateDigest != + bootstrap.CertificateDigest { + t.Fatal("verified trust-floor capability is incomplete") + } + expectedAcknowledgementByte := bootstrap.TargetAcknowledgement[0] + expectedDigest := bootstrap.CertificateDigest + chain[0].TargetAcknowledgement[0] ^= 1 + chain[0].CertificateDigest[0] ^= 1 + if capability.certificate.TargetAcknowledgement[0] != + expectedAcknowledgementByte || + capability.certificate.CertificateDigest != + expectedDigest { + t.Fatal("verified trust-floor capability aliases mutable input") + } + + invalid := *trustTestCloneCertificate(bootstrap) + invalid.FinalSignature[0] ^= 1 + if _, err := authenticateFrostNativeSignerAnchorTrustCertificateChain( + []FrostNativeSignerAnchorTrustCertificate{invalid}, + trustTestChainOptions(bootstrap), + ); err == nil { + t.Fatal("invalid authority signature minted a trust-floor capability") + } +} + +func TestFrostNativeSignerAnchorTrustChainRequiresEveryPin(t *testing.T) { + bootstrap, _ := trustTestBootstrapCertificate(t) + chain := []FrostNativeSignerAnchorTrustCertificate{*bootstrap} + validOptions := trustTestChainOptions(bootstrap) + tests := map[string]func(*FrostNativeSignerAnchorTrustChainValidationOptions){ + "protocol": func(options *FrostNativeSignerAnchorTrustChainValidationOptions) { + options.ExpectedProtocolID = [32]byte{} + }, + "stream": func(options *FrostNativeSignerAnchorTrustChainValidationOptions) { + options.ExpectedStreamID = [32]byte{} + }, + "store": func(options *FrostNativeSignerAnchorTrustChainValidationOptions) { + options.ExpectedSignerStoreFingerprint = [32]byte{} + }, + "authority key": func(options *FrostNativeSignerAnchorTrustChainValidationOptions) { + options.ExpectedOfflineAuthorityPublicKey = + [ed25519.PublicKeySize]byte{} + }, + "authority hash": func(options *FrostNativeSignerAnchorTrustChainValidationOptions) { + options.ExpectedOfflineAuthoritySPKISHA256 = [32]byte{} + }, + "head": func(options *FrostNativeSignerAnchorTrustChainValidationOptions) { + options.ExpectedHead = nil + }, + "head sequence": func(options *FrostNativeSignerAnchorTrustChainValidationOptions) { + options.ExpectedHead.CertificateSequence = 0 + }, + "head digest": func(options *FrostNativeSignerAnchorTrustChainValidationOptions) { + options.ExpectedHead.CertificateDigest = [32]byte{} + }, + "head certified revision": func(options *FrostNativeSignerAnchorTrustChainValidationOptions) { + options.ExpectedHead.Endpoint.Reference.Revision = 2 + }, + "validator": func(options *FrostNativeSignerAnchorTrustChainValidationOptions) { + options.ValidateTargetAcknowledgement = nil + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + options := trustTestCloneChainOptions(validOptions) + mutate(&options) + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + chain, + options, + ); err == nil { + t.Fatal("zero production trust pin was accepted") + } + }) + } +} + +func TestFrostNativeSignerAnchorTrustLegacyAdoptionIsExplicit(t *testing.T) { + bootstrap, authority := trustTestBootstrapCertificate(t) + legacy := trustTestRotationCertificate(t, bootstrap, authority, 0x44) + legacy.CertificateSequence = 1 + legacy.PreviousCertificateDigest = [32]byte{} + trustTestFinalizeCertificate(t, legacy, authority) + chain := []FrostNativeSignerAnchorTrustCertificate{*legacy} + options := FrostNativeSignerAnchorTrustChainValidationOptions{ + ExpectedProtocolID: legacy.ProtocolID, + ExpectedStreamID: legacy.StreamID, + ExpectedSignerStoreFingerprint: legacy.SignerStoreFingerprint, + ExpectedOfflineAuthorityPublicKey: legacy.To.OfflineAuthorityPublicKey, + ExpectedOfflineAuthoritySPKISHA256: legacy.To.OfflineAuthoritySPKISHA256, + ExpectedHead: trustTestCertificateHead(legacy), + ValidateTargetAcknowledgement: func( + _ *FrostNativeSignerAnchorTrustCertificate, + _ []byte, + ) error { + return nil + }, + } + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + chain, + options, + ); err == nil || !strings.Contains(err.Error(), "adoption") { + t.Fatalf("expected legacy adoption rejection, got [%v]", err) + } + options.AllowLegacyAdoption = true + if err := ValidateFrostNativeSignerAnchorTrustCertificateChain( + chain, + options, + ); err != nil { + t.Fatalf("explicitly authorized legacy adoption was rejected: %v", err) + } +} + +func TestFrostNativeSignerAnchorTrustRotationInvariants(t *testing.T) { + bootstrap, authority := trustTestBootstrapCertificate(t) + valid := trustTestRotationCertificate(t, bootstrap, authority, 0x55) + tests := map[string]func(*FrostNativeSignerAnchorTrustCertificate){ + "manifest sequence skip": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.To.ActivationManifestSequence++ + }, + "service epoch skip": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.To.Reference.ServiceEpoch++ + }, + "revision": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.To.Reference.Revision = 2 + }, + "previous event root": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.To.Reference.PreviousEventRoot[0] ^= 1 + }, + "checkpoint advance": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.To.Reference.Checkpoint.Generation++ + }, + "authority rotation": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.To.OfflineAuthorityPublicKey[0] ^= 1 + }, + "authority response key alias": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.To.ResponsePublicKey = + certificate.To.OfflineAuthorityPublicKey + certificate.To.ResponsePublicKeySPKISHA256 = + certificate.To.OfflineAuthoritySPKISHA256 + }, + "maximum records change": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.To.WitnessMaximumRecords++ + }, + "rotation threshold change": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.To.WitnessRotationThresholdRecords-- + }, + "unchanged binding": func(certificate *FrostNativeSignerAnchorTrustCertificate) { + certificate.To.BindingHash = certificate.From.BindingHash + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + certificate := trustTestCloneCertificate(valid) + mutate(certificate) + trustTestFinalizeCertificate(t, certificate, authority) + if err := ValidateFrostNativeSignerAnchorTrustCertificate( + certificate, + func( + _ *FrostNativeSignerAnchorTrustCertificate, + _ []byte, + ) error { + return nil + }, + ); err == nil { + t.Fatal("invalid rotation invariant was accepted") + } + }) + } +} + +func TestFrostNativeSignerAnchorTrustTransitionRequest(t *testing.T) { + bootstrap, authority := trustTestBootstrapCertificate(t) + rotation := trustTestRotationCertificate(t, bootstrap, authority, 0x66) + request := &FrostNativeSignerAnchorTrustTransitionRequest{ + CertificateChain: []FrostNativeSignerAnchorTrustCertificate{ + *bootstrap, + *rotation, + }, + TargetReadResponse: []byte(`{"schema":"fresh-read","nonce":"n"}`), + } + encoded, err := EncodeFrostNativeSignerAnchorTrustTransitionRequest(request) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeAndValidateFrostNativeSignerAnchorTrustTransitionRequest( + encoded, + FrostNativeSignerAnchorTrustChainValidationOptions{ + ExpectedProtocolID: rotation.ProtocolID, + ExpectedStreamID: rotation.StreamID, + ExpectedSignerStoreFingerprint: rotation.SignerStoreFingerprint, + ExpectedOfflineAuthorityPublicKey: rotation.To.OfflineAuthorityPublicKey, + ExpectedOfflineAuthoritySPKISHA256: rotation.To.OfflineAuthoritySPKISHA256, + ExpectedHead: trustTestCertificateHead(rotation), + ValidateTargetAcknowledgement: func( + _ *FrostNativeSignerAnchorTrustCertificate, + _ []byte, + ) error { + return nil + }, + }, + ) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(request, decoded) { + t.Fatal("trust transition request round trip changed exact bytes") + } + + emptyWire := frostNativeSignerAnchorTrustTransitionRequestWire{ + Schema: FrostNativeSignerAnchorTrustTransitionRequestSchema, + CertificateChain: &[]json.RawMessage{}, + TargetReadResponseBase64: base64.StdEncoding.EncodeToString(request.TargetReadResponse), + } + emptyJSON, _ := json.Marshal(emptyWire) + if _, err := DecodeFrostNativeSignerAnchorTrustTransitionRequest( + emptyJSON, + ); err == nil { + t.Fatal("empty certificate chain was accepted") + } + + certificateJSON, _ := + EncodeFrostNativeSignerAnchorTrustCertificate(bootstrap) + tooMany := make( + []json.RawMessage, + FrostNativeSignerAnchorTrustMaximumCertificateChainLength+1, + ) + for index := range tooMany { + tooMany[index] = certificateJSON + } + tooManyWire := frostNativeSignerAnchorTrustTransitionRequestWire{ + Schema: FrostNativeSignerAnchorTrustTransitionRequestSchema, + CertificateChain: &tooMany, + TargetReadResponseBase64: base64.StdEncoding.EncodeToString(request.TargetReadResponse), + } + tooManyJSON, _ := json.Marshal(tooManyWire) + if _, err := DecodeFrostNativeSignerAnchorTrustTransitionRequest( + tooManyJSON, + ); err == nil { + t.Fatal("oversized certificate chain was accepted") + } +} + +func TestFrostNativeSignerAnchorTrustWireBoundsAlignWithNativeFFI( + t *testing.T, +) { + if frostNativeSignerAnchorTrustMaximumTransitionRequestBytes != + frostsigning.NativeTBTCSignerStateAnchorTrustTransitionMaximumRequestBytes { + t.Fatal("trust-transition request and native FFI bounds differ") + } + + bootstrap, _ := trustTestBootstrapCertificate(t) + exactRead := trustTestJSONAtSize( + t, + frostNativeSignerAnchorTrustMaximumReadResponseBytes, + ) + request := &FrostNativeSignerAnchorTrustTransitionRequest{ + CertificateChain: []FrostNativeSignerAnchorTrustCertificate{ + *bootstrap, + }, + TargetReadResponse: exactRead, + } + encoded, err := EncodeFrostNativeSignerAnchorTrustTransitionRequest(request) + if err != nil { + t.Fatalf("exact-bound target Read was rejected: %v", err) + } + if len(encoded) > + frostsigning.NativeTBTCSignerStateAnchorTrustTransitionMaximumRequestBytes { + t.Fatal("accepted request exceeds the native FFI request bound") + } + + request.TargetReadResponse = trustTestJSONAtSize( + t, + frostNativeSignerAnchorTrustMaximumReadResponseBytes+1, + ) + if _, err := EncodeFrostNativeSignerAnchorTrustTransitionRequest( + request, + ); err == nil { + t.Fatal("over-bound target Read was accepted") + } + + oversizedCertificate := make( + []byte, + frostNativeSignerAnchorTrustMaximumCertificateBytes+1, + ) + copy(oversizedCertificate, []byte(`{"schema":`)) + if _, err := DecodeFrostNativeSignerAnchorTrustCertificate( + oversizedCertificate, + ); err == nil { + t.Fatal("certificate exceeding the durable-record bound was accepted") + } +} + +func TestDecodeFrostNativeSignerAnchorTrustCertificateChain(t *testing.T) { + bootstrap, authority := trustTestBootstrapCertificate(t) + rotation := trustTestRotationCertificate(t, bootstrap, authority, 0x67) + bootstrapJSON, err := + EncodeFrostNativeSignerAnchorTrustCertificate(bootstrap) + if err != nil { + t.Fatal(err) + } + rotationJSON, err := + EncodeFrostNativeSignerAnchorTrustCertificate(rotation) + if err != nil { + t.Fatal(err) + } + chainJSON, err := json.Marshal([]json.RawMessage{ + bootstrapJSON, + rotationJSON, + }) + if err != nil { + t.Fatal(err) + } + decoded, err := + DecodeFrostNativeSignerAnchorTrustCertificateChain(chainJSON) + if err != nil { + t.Fatal(err) + } + expected := []FrostNativeSignerAnchorTrustCertificate{ + *bootstrap, + *rotation, + } + if !reflect.DeepEqual(expected, decoded) { + t.Fatal("secure config certificate chain changed exact material") + } + for name, payload := range map[string][]byte{ + "object root": bootstrapJSON, + "empty": []byte(`[]`), + "trailing": append(append([]byte{}, chainJSON...), []byte(`[]`)...), + } { + t.Run(name, func(t *testing.T) { + if _, err := DecodeFrostNativeSignerAnchorTrustCertificateChain( + payload, + ); err == nil { + t.Fatal("malformed secure config certificate chain was accepted") + } + }) + } + + tooMany := make( + []json.RawMessage, + FrostNativeSignerAnchorTrustMaximumCertificateChainLength+1, + ) + for index := range tooMany { + tooMany[index] = bootstrapJSON + } + tooManyJSON, _ := json.Marshal(tooMany) + if _, err := DecodeFrostNativeSignerAnchorTrustCertificateChain( + tooManyJSON, + ); err == nil { + t.Fatal("oversized secure config certificate chain was accepted") + } +} + +func trustTestJSONAtSize(t *testing.T, size int) []byte { + t.Helper() + prefix := []byte(`{"padding":"`) + suffix := []byte(`"}`) + if size < len(prefix)+len(suffix) { + t.Fatal("requested JSON test size is too small") + } + result := make([]byte, 0, size) + result = append(result, prefix...) + result = append( + result, + bytes.Repeat([]byte{'x'}, size-len(prefix)-len(suffix))..., + ) + result = append(result, suffix...) + if len(result) != size || !json.Valid(result) { + t.Fatal("failed to construct exact-size JSON") + } + return result +} + +func trustTestBootstrapCertificate( + t *testing.T, +) (*FrostNativeSignerAnchorTrustCertificate, ed25519.PrivateKey) { + t.Helper() + authority := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x11}, ed25519.SeedSize), + ) + response := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x22}, ed25519.SeedSize), + ) + authorityPublic := trustTestRawPublicKey(authority) + responsePublic := trustTestRawPublicKey(response) + storeFingerprint := trustTestBytes32(0x03) + checkpoint := trustTestCheckpoint( + storeFingerprint, + 7, + trustTestBytes32(0x31), + trustTestBytes32(0x32), + ) + certificate := &FrostNativeSignerAnchorTrustCertificate{ + Kind: FrostNativeSignerAnchorTrustCertificateBootstrap, + CertificateSequence: 1, + ProtocolID: trustTestBytes32(0x01), + StreamID: trustTestBytes32(0x02), + SignerStoreFingerprint: storeFingerprint, + To: FrostNativeSignerAnchorTrustEndpoint{ + ActivationManifestHash: trustTestBytes32(0x04), + ActivationManifestSequence: 9, + BindingHash: trustTestBytes32(0x05), + ResponsePublicKey: responsePublic, + ResponsePublicKeySPKISHA256: ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256(responsePublic), + OfflineAuthorityPublicKey: authorityPublic, + OfflineAuthoritySPKISHA256: ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256(authorityPublic), + WitnessMaximumRecords: 1000, + WitnessRotationThresholdRecords: 900, + Reference: FrostNativeSignerAnchorTrustReference{ + ServiceEpoch: 1, + Revision: 1, + EventRoot: trustTestBytes32(0x41), + AcknowledgementDigest: trustTestBytes32(0x42), + Checkpoint: checkpoint, + }, + }, + TargetAcknowledgement: []byte( + `{"schema":"tbtc-signer-state-witness-checkpoint-ack/v1","vector":"bootstrap"}`, + ), + } + trustTestFinalizeCertificateWithAcknowledgement( + t, + certificate, + authority, + response, + 0x51, + ) + return certificate, authority +} + +func trustTestRotationCertificate( + t *testing.T, + previous *FrostNativeSignerAnchorTrustCertificate, + authority ed25519.PrivateKey, + responseSeed byte, +) *FrostNativeSignerAnchorTrustCertificate { + t.Helper() + response := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{responseSeed}, ed25519.SeedSize), + ) + responsePublic := trustTestRawPublicKey(response) + from := previous.To + certificate := &FrostNativeSignerAnchorTrustCertificate{ + Kind: FrostNativeSignerAnchorTrustCertificateRotation, + CertificateSequence: previous.CertificateSequence + 1, + PreviousCertificateDigest: previous.CertificateDigest, + ProtocolID: previous.ProtocolID, + StreamID: previous.StreamID, + SignerStoreFingerprint: previous.SignerStoreFingerprint, + From: &from, + To: FrostNativeSignerAnchorTrustEndpoint{ + ActivationManifestHash: trustTestBytes32(responseSeed + 1), + ActivationManifestSequence: from.ActivationManifestSequence + 1, + BindingHash: trustTestBytes32(responseSeed + 2), + ResponsePublicKey: responsePublic, + ResponsePublicKeySPKISHA256: ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256(responsePublic), + OfflineAuthorityPublicKey: from.OfflineAuthorityPublicKey, + OfflineAuthoritySPKISHA256: from.OfflineAuthoritySPKISHA256, + WitnessMaximumRecords: from.WitnessMaximumRecords, + WitnessRotationThresholdRecords: from.WitnessRotationThresholdRecords, + Reference: FrostNativeSignerAnchorTrustReference{ + ServiceEpoch: from.Reference.ServiceEpoch + 1, + Revision: 1, + PreviousEventRoot: from.Reference.EventRoot, + EventRoot: trustTestBytes32(responseSeed + 3), + AcknowledgementDigest: trustTestBytes32(responseSeed + 4), + Checkpoint: from.Reference.Checkpoint, + }, + }, + TargetAcknowledgement: []byte( + `{"schema":"tbtc-signer-state-witness-checkpoint-ack/v1","vector":"rotation"}`, + ), + } + trustTestFinalizeCertificateWithAcknowledgement( + t, + certificate, + authority, + response, + responseSeed, + ) + return certificate +} + +func trustTestFinalizeCertificate( + t *testing.T, + certificate *FrostNativeSignerAnchorTrustCertificate, + authority ed25519.PrivateKey, +) { + t.Helper() + var err error + certificate.CoreDigest, err = + ComputeFrostNativeSignerAnchorTrustCoreDigest(certificate) + if err != nil { + t.Fatal(err) + } + copy( + certificate.CoreSignature[:], + ed25519.Sign(authority, certificate.CoreDigest[:]), + ) + certificate.OperationID = + ComputeFrostNativeSignerAnchorTrustOperationID(certificate.CoreDigest) + certificate.TransitionDigest = + ComputeFrostNativeSignerAnchorTrustTransitionDigest( + certificate.CoreDigest, + certificate.OperationID, + ) + certificate.TargetAcknowledgementSHA256 = + sha256.Sum256(certificate.TargetAcknowledgement) + finalDigest, err := + ComputeFrostNativeSignerAnchorTrustFinalDigest(certificate) + if err != nil { + t.Fatal(err) + } + copy( + certificate.FinalSignature[:], + ed25519.Sign(authority, finalDigest[:]), + ) + certificate.CertificateDigest, err = + ComputeFrostNativeSignerAnchorTrustCertificateDigest(certificate) + if err != nil { + t.Fatal(err) + } +} + +func trustTestFinalizeCertificateWithAcknowledgement( + t *testing.T, + certificate *FrostNativeSignerAnchorTrustCertificate, + authority ed25519.PrivateKey, + response ed25519.PrivateKey, + seed byte, +) { + t.Helper() + // Establish the core-derived operation identities first. To event-root and + // acknowledgement fields are deliberately outside the core transcript. + trustTestFinalizeCertificate(t, certificate, authority) + acknowledgement := FrostNativeSignerCheckpointAcknowledgement{ + BindingHash: certificate.To.BindingHash, + RequestDigest: trustTestBytes32(seed + 1), + Nonce: trustTestBytes32(seed + 2), + Status: "applied", + ServiceEpoch: certificate.To.Reference.ServiceEpoch, + Revision: certificate.To.Reference.Revision, + PreviousEventRoot: certificate.To.Reference.PreviousEventRoot, + Checkpoint: certificate.To.Reference.Checkpoint, + OperationID: certificate.OperationID, + TransitionDigest: certificate.TransitionDigest, + CommittedAtUnixMs: 1_700_000_000_000 + + certificate.CertificateSequence*1_000, + ExpiresAtUnixMs: 1_700_000_030_000 + + certificate.CertificateSequence*1_000, + } + acknowledgement.EventRoot = + computeFrostNativeSignerAnchorEventRoot(acknowledgement) + wire := frostNativeSignerAnchorAcknowledgementWire{ + Schema: FrostNativeSignerCheckpointAcknowledgementSchema, + BindingHash: frostNativeSignerAnchorHex32(acknowledgement.BindingHash), + RequestDigest: frostNativeSignerAnchorHex32(acknowledgement.RequestDigest), + Nonce: frostNativeSignerAnchorHex32(acknowledgement.Nonce), + Status: acknowledgement.Status, + ServiceEpoch: fmt.Sprint(acknowledgement.ServiceEpoch), + Revision: fmt.Sprint(acknowledgement.Revision), + PreviousEventRoot: frostNativeSignerAnchorHex32(acknowledgement.PreviousEventRoot), + EventRoot: frostNativeSignerAnchorHex32(acknowledgement.EventRoot), + Checkpoint: frostNativeSignerAnchorCheckpointToWire( + acknowledgement.Checkpoint, + ), + OperationID: frostNativeSignerAnchorHex32(acknowledgement.OperationID), + TransitionDigest: frostNativeSignerAnchorHex32(acknowledgement.TransitionDigest), + CommittedAtUnixMs: fmt.Sprint(acknowledgement.CommittedAtUnixMs), + ExpiresAtUnixMs: fmt.Sprint(acknowledgement.ExpiresAtUnixMs), + } + signingDigest, err := frostNativeSignerAnchorAcknowledgementTranscript(wire) + if err != nil { + t.Fatal(err) + } + signature := ed25519.Sign(response, signingDigest) + wire.Signature = frostNativeSignerAnchorSignatureHex(signature) + var fixedSigningDigest [32]byte + copy(fixedSigningDigest[:], signingDigest) + var fixedSignature [ed25519.SignatureSize]byte + copy(fixedSignature[:], signature) + acknowledgement.AcknowledgementDigest = + computeFrostNativeSignerCheckpointAcknowledgementDigest( + fixedSigningDigest, + fixedSignature, + certificate.To.ResponsePublicKeySPKISHA256, + ) + raw, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + certificate.To.Reference.EventRoot = acknowledgement.EventRoot + certificate.To.Reference.AcknowledgementDigest = + acknowledgement.AcknowledgementDigest + certificate.TargetAcknowledgement = raw + trustTestFinalizeCertificate(t, certificate, authority) +} + +func trustTestSetRotationFromDescendant( + t *testing.T, + certificate *FrostNativeSignerAnchorTrustCertificate, + authority ed25519.PrivateKey, + revision uint64, + checkpoint FrostNativeSignerStateWitnessCheckpoint, +) { + t.Helper() + if certificate.From == nil { + t.Fatal("rotation certificate has no from endpoint") + } + from := *certificate.From + from.Reference.Revision = revision + from.Reference.PreviousEventRoot = trustTestBytes32(0x93) + from.Reference.EventRoot = trustTestBytes32(0x94) + from.Reference.AcknowledgementDigest = trustTestBytes32(0x95) + from.Reference.Checkpoint = checkpoint + certificate.From = &from + certificate.To.Reference.ServiceEpoch = from.Reference.ServiceEpoch + 1 + certificate.To.Reference.Revision = 1 + certificate.To.Reference.PreviousEventRoot = from.Reference.EventRoot + certificate.To.Reference.Checkpoint = checkpoint + trustTestFinalizeCertificate(t, certificate, authority) +} + +func trustTestCheckpoint( + storeFingerprint [32]byte, + generation uint64, + previous [32]byte, + image [32]byte, +) FrostNativeSignerStateWitnessCheckpoint { + return FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: storeFingerprint, + Generation: generation, + PreviousStateCommitment: previous, + StateImageDigest: image, + StateCommitment: frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + generation, + previous, + image, + ), + } +} + +func trustTestRawPublicKey( + privateKey ed25519.PrivateKey, +) [ed25519.PublicKeySize]byte { + var result [ed25519.PublicKeySize]byte + copy(result[:], privateKey.Public().(ed25519.PublicKey)) + return result +} + +func trustTestBytes32(value byte) [32]byte { + return [32]byte{ + value, value, value, value, value, value, value, value, + value, value, value, value, value, value, value, value, + value, value, value, value, value, value, value, value, + value, value, value, value, value, value, value, value, + } +} + +func trustTestCloneCertificate( + certificate *FrostNativeSignerAnchorTrustCertificate, +) *FrostNativeSignerAnchorTrustCertificate { + copy := *certificate + copy.TargetAcknowledgement = append( + []byte{}, + certificate.TargetAcknowledgement..., + ) + if certificate.From != nil { + from := *certificate.From + copy.From = &from + } + return © +} + +func trustTestCloneChain( + chain []FrostNativeSignerAnchorTrustCertificate, +) []FrostNativeSignerAnchorTrustCertificate { + result := make([]FrostNativeSignerAnchorTrustCertificate, len(chain)) + for index := range chain { + result[index] = *trustTestCloneCertificate(&chain[index]) + } + return result +} + +func trustTestCertificateHead( + certificate *FrostNativeSignerAnchorTrustCertificate, +) *FrostNativeSignerAnchorTrustCertificateHead { + return &FrostNativeSignerAnchorTrustCertificateHead{ + CertificateSequence: certificate.CertificateSequence, + CertificateDigest: certificate.CertificateDigest, + ProtocolID: certificate.ProtocolID, + StreamID: certificate.StreamID, + SignerStoreFingerprint: certificate.SignerStoreFingerprint, + Endpoint: certificate.To, + } +} + +func trustTestChainOptions( + head *FrostNativeSignerAnchorTrustCertificate, +) FrostNativeSignerAnchorTrustChainValidationOptions { + return FrostNativeSignerAnchorTrustChainValidationOptions{ + ExpectedProtocolID: head.ProtocolID, + ExpectedStreamID: head.StreamID, + ExpectedSignerStoreFingerprint: head.SignerStoreFingerprint, + ExpectedOfflineAuthorityPublicKey: head.To.OfflineAuthorityPublicKey, + ExpectedOfflineAuthoritySPKISHA256: head.To.OfflineAuthoritySPKISHA256, + ExpectedHead: trustTestCertificateHead(head), + ValidateTargetAcknowledgement: func( + _ *FrostNativeSignerAnchorTrustCertificate, + _ []byte, + ) error { + return nil + }, + } +} + +func trustTestCloneChainOptions( + options FrostNativeSignerAnchorTrustChainValidationOptions, +) FrostNativeSignerAnchorTrustChainValidationOptions { + result := options + if options.PriorHead != nil { + prior := *options.PriorHead + result.PriorHead = &prior + } + if options.ExpectedHead != nil { + expected := *options.ExpectedHead + result.ExpectedHead = &expected + } + return result +} + +func trustTestUppercaseHexMember( + t *testing.T, + payload string, + member string, +) string { + t.Helper() + prefix := `"` + member + `":"0x` + start := strings.Index(payload, prefix) + if start < 0 { + t.Fatalf("member [%s] not found", member) + } + start += len(prefix) + end := start + 64 + value := payload[start:end] + for index, character := range value { + if character >= 'a' && character <= 'f' { + upper := strings.ToUpper(string(character)) + return payload[:start+index] + upper + payload[start+index+1:] + } + } + t.Fatalf("member [%s] has no hexadecimal letter", member) + return "" +} diff --git a/pkg/tbtc/frost_native_signer_readiness.go b/pkg/tbtc/frost_native_signer_readiness.go index bfb3e8b30e..894e16f140 100644 --- a/pkg/tbtc/frost_native_signer_readiness.go +++ b/pkg/tbtc/frost_native_signer_readiness.go @@ -32,6 +32,11 @@ type frostNativeSignerStateWitnessProofReader func( *frostsigning.NativeTBTCSignerStateWitnessProofRequest, ) (*frostsigning.NativeTBTCSignerStateWitnessProof, error) +type frostNativeSignerStateAnchorTrustHeadReader func() ( + *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + error, +) + type frostNativeSignerInventoryExpectation struct { WalletID [32]byte KeyGroup string @@ -42,22 +47,33 @@ type frostNativeSignerInventoryExpectation struct { } type frostNativeSignerInventorySnapshot struct { - Schema string - StoreFingerprint [32]byte - StateGeneration uint64 - StateCommitment [32]byte - PreviousStateCommitment [32]byte - StateImageDigest [32]byte - InventoryCommitment [32]byte - WalletCount uint64 - KeyPackageCount uint64 - ExternalRollbackAnchorBound bool + Schema string + StoreFingerprint [32]byte + StateGeneration uint64 + StateCommitment [32]byte + PreviousStateCommitment [32]byte + StateImageDigest [32]byte + InventoryCommitment [32]byte + WalletCount uint64 + KeyPackageCount uint64 + ExternalRollbackAnchorBound bool + TrustCertificateSequence uint64 + TrustCertificateDigest [32]byte + AnchorServiceEpoch uint64 + CertifiedFloorRevision uint64 + CertifiedFloorGeneration uint64 + CurrentAnchorRevision uint64 + RestartableRevisionHeadroom uint64 + RestartableGenerationHeadroom uint64 + AnchorRotationWarning bool } type frostNativeSignerInventoryBinding struct { - storeBinding *frostDurableSessionStoreBinding - anchorBinding *frostNativeSignerAnchorBinding - readInventory frostNativeSignerInventoryReader + storeBinding *frostDurableSessionStoreBinding + anchorBinding *frostNativeSignerAnchorBinding + readInventory frostNativeSignerInventoryReader + readTrustHead frostNativeSignerStateAnchorTrustHeadReader + expectedTrustHead frostsigning.NativeTBTCSignerStateAnchorTrustHead mutex sync.Mutex } @@ -66,17 +82,24 @@ func newFrostNativeSignerInventoryBinding( storeBinding *frostDurableSessionStoreBinding, anchorBinding *frostNativeSignerAnchorBinding, readInventory frostNativeSignerInventoryReader, + readTrustHead frostNativeSignerStateAnchorTrustHeadReader, + expectedTrustHead *frostsigning.NativeTBTCSignerStateAnchorTrustHead, ) (*frostNativeSignerInventoryBinding, error) { - if storeBinding == nil || anchorBinding == nil || readInventory == nil { + if storeBinding == nil || anchorBinding == nil || readInventory == nil || + readTrustHead == nil || expectedTrustHead == nil || + expectedTrustHead.CertificateSequence == 0 || + expectedTrustHead.CertificateDigest == [32]byte{} { return nil, fmt.Errorf("FROST native signer inventory dependencies are incomplete") } if _, err := storeBinding.verify(); err != nil { return nil, fmt.Errorf("FROST native signer inventory store is not bound: [%w]", err) } return &frostNativeSignerInventoryBinding{ - storeBinding: storeBinding, - anchorBinding: anchorBinding, - readInventory: readInventory, + storeBinding: storeBinding, + anchorBinding: anchorBinding, + readInventory: readInventory, + readTrustHead: readTrustHead, + expectedTrustHead: *expectedTrustHead, }, nil } @@ -97,6 +120,18 @@ func (binding *frostNativeSignerInventoryBinding) verify( if err != nil { return nil, fmt.Errorf("FROST native signer store binding failed: [%w]", err) } + trustHead, err := binding.readTrustHead() + if err != nil { + return nil, fmt.Errorf( + "cannot read native signer state-anchor trust head: [%w]", + err, + ) + } + if trustHead == nil || *trustHead != binding.expectedTrustHead { + return nil, fmt.Errorf( + "native signer state-anchor trust head differs from the startup-certified head", + ) + } inventory, err := binding.readInventory() if err != nil { return nil, fmt.Errorf("cannot read native retained key-package inventory: [%w]", err) @@ -120,6 +155,20 @@ func (binding *frostNativeSignerInventoryBinding) verify( "native signer inventory and state-witness tip identify different checkpoints", ) } + if trustHead.BindingHash != localTip.AnchorBindingHash || + trustHead.ServiceEpoch != localTip.AnchorServiceEpoch || + trustHead.CertifiedFloor.ServiceEpoch != binding.anchorBinding.floor.ServiceEpoch || + trustHead.CertifiedFloor.Revision != binding.anchorBinding.floor.Revision || + trustHead.CertifiedFloor.EventRoot != binding.anchorBinding.floor.EventRoot || + trustHead.CertifiedFloor.AcknowledgementDigest != + binding.anchorBinding.floor.AcknowledgementDigest || + frostNativeSignerCheckpointFromTrustHead( + trustHead.CertifiedFloor.Checkpoint, + ) != binding.anchorBinding.floor.Checkpoint { + return nil, fmt.Errorf( + "native signer trust head, certified floor, and current anchor are inconsistent", + ) + } if err := binding.anchorBinding.VerifyNativeTBTCSignerStateTip( ctx, *localTip, @@ -129,6 +178,20 @@ func (binding *frostNativeSignerInventoryBinding) verify( err, ) } + revisionHeadroom, err := binding.anchorBinding.restartableRevisionHeadroom( + localTip.AnchorServiceEpoch, + localTip.AnchorRevision, + ) + if err != nil { + return nil, err + } + generationHeadroom, err := + binding.anchorBinding.restartableGenerationHeadroom( + localTip.Generation, + ) + if err != nil { + return nil, err + } if err := verifyFrostNativeSignerInventoryEntries(inventory.Entries, expected); err != nil { return nil, err } @@ -138,19 +201,59 @@ func (binding *frostNativeSignerInventoryBinding) verify( keyPackageCount += uint64(len(entry.KeyPackages)) } return &frostNativeSignerInventorySnapshot{ - Schema: inventory.Schema, - StoreFingerprint: inventory.StoreFingerprint, - StateGeneration: inventory.StateGeneration, - StateCommitment: inventory.StateCommitment, - PreviousStateCommitment: inventory.PreviousStateCommitment, - StateImageDigest: inventory.StateImageDigest, - InventoryCommitment: inventory.InventoryCommitment, - WalletCount: uint64(len(inventory.Entries)), - KeyPackageCount: keyPackageCount, - ExternalRollbackAnchorBound: true, + Schema: inventory.Schema, + StoreFingerprint: inventory.StoreFingerprint, + StateGeneration: inventory.StateGeneration, + StateCommitment: inventory.StateCommitment, + PreviousStateCommitment: inventory.PreviousStateCommitment, + StateImageDigest: inventory.StateImageDigest, + InventoryCommitment: inventory.InventoryCommitment, + WalletCount: uint64(len(inventory.Entries)), + KeyPackageCount: keyPackageCount, + ExternalRollbackAnchorBound: true, + TrustCertificateSequence: trustHead.CertificateSequence, + TrustCertificateDigest: trustHead.CertificateDigest, + AnchorServiceEpoch: localTip.AnchorServiceEpoch, + CertifiedFloorRevision: trustHead.CertifiedFloor.Revision, + CertifiedFloorGeneration: trustHead.CertifiedFloor.Checkpoint.Generation, + CurrentAnchorRevision: localTip.AnchorRevision, + RestartableRevisionHeadroom: revisionHeadroom, + RestartableGenerationHeadroom: generationHeadroom, + AnchorRotationWarning: frostNativeSignerAnchorRotationWarning( + minFrostNativeSignerAnchorHeadroom( + revisionHeadroom, + generationHeadroom, + ), + ), }, nil } +func frostNativeSignerAnchorRotationWarning(headroom uint64) bool { + return headroom <= FrostNativeSignerAnchorRotationWarningHeadroom +} + +func minFrostNativeSignerAnchorHeadroom( + revisionHeadroom uint64, + generationHeadroom uint64, +) uint64 { + if revisionHeadroom < generationHeadroom { + return revisionHeadroom + } + return generationHeadroom +} + +func frostNativeSignerCheckpointFromTrustHead( + checkpoint frostsigning.NativeTBTCSignerStateAnchorCheckpoint, +) FrostNativeSignerStateWitnessCheckpoint { + return FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: checkpoint.StoreFingerprint, + Generation: checkpoint.Generation, + PreviousStateCommitment: checkpoint.PreviousStateCommitment, + StateImageDigest: checkpoint.StateImageDigest, + StateCommitment: checkpoint.StateCommitment, + } +} + func verifyFrostNativeSignerInventoryEntries( actual []frostsigning.NativeTBTCSignerRetainedKeyGroup, expected []frostNativeSignerInventoryExpectation, @@ -265,6 +368,11 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead if *firstInventory != *secondInventory { return nil, fmt.Errorf("native signer state changed during readiness reconciliation") } + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + secondInventory, + ); err != nil { + return nil, err + } if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( registryRevision, ) { @@ -282,6 +390,40 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead }, nil } +func validateFrostNativeSignerAnchorReadinessHeadroom( + inventory *frostNativeSignerInventorySnapshot, +) error { + if inventory == nil || + inventory.CurrentAnchorRevision < inventory.CertifiedFloorRevision || + inventory.CurrentAnchorRevision-inventory.CertifiedFloorRevision+ + inventory.RestartableRevisionHeadroom != + FrostNativeSignerAnchorMaximumHistoryEvents || + inventory.StateGeneration < + inventory.CertifiedFloorGeneration || + inventory.StateGeneration- + inventory.CertifiedFloorGeneration+ + inventory.RestartableGenerationHeadroom != + FrostNativeSignerAnchorMaximumHistoryProofEntries || + inventory.AnchorRotationWarning != + frostNativeSignerAnchorRotationWarning( + minFrostNativeSignerAnchorHeadroom( + inventory.RestartableRevisionHeadroom, + inventory.RestartableGenerationHeadroom, + ), + ) { + return fmt.Errorf( + "native signer certified anchor revision or generation headroom is inconsistent", + ) + } + if inventory.RestartableRevisionHeadroom == 0 || + inventory.RestartableGenerationHeadroom == 0 { + return fmt.Errorf( + "native signer certified anchor revision or generation window is exhausted; offline anchor rotation is required", + ) + } + return nil +} + func (journal *frostRetainedGroupJournal) nativeSignerInventoryExpectations( ctx context.Context, point FrostPreSignFinality, diff --git a/pkg/tbtc/frost_native_signer_readiness_test.go b/pkg/tbtc/frost_native_signer_readiness_test.go index a329855e70..25e4f4f681 100644 --- a/pkg/tbtc/frost_native_signer_readiness_test.go +++ b/pkg/tbtc/frost_native_signer_readiness_test.go @@ -87,9 +87,10 @@ func testFrostNativeSignerInventoryAnchorBinding( store: store, identity: FrostNativeSignerAnchorIdentity{SignerStoreFingerprint: storeFingerprint}, bindingHash: bindingHash, - floor: FrostNativeSignerAnchorManifest{ - MinimumServiceEpoch: 1, - MinimumRevision: 1, + floor: FrostNativeSignerStateWitnessAnchorReference{ + ServiceEpoch: 1, + Revision: 1, + Checkpoint: checkpoint, }, readTip: func() (*frostsigning.NativeTBTCSignerStateWitnessTip, error) { result := *tip @@ -98,6 +99,38 @@ func testFrostNativeSignerInventoryAnchorBinding( }, store } +func testFrostNativeSignerInventoryTrustHead( + binding *frostNativeSignerAnchorBinding, +) *frostsigning.NativeTBTCSignerStateAnchorTrustHead { + floor := binding.floor + return &frostsigning.NativeTBTCSignerStateAnchorTrustHead{ + Schema: frostsigning.NativeTBTCSignerStateAnchorTrustHeadSchema, + CertificateSequence: 1, + CertificateDigest: [32]byte{0xb1}, + ActivationManifestSequence: 1, + ActivationManifestHash: [32]byte{0xb2}, + BindingHash: binding.bindingHash, + ResponsePublicKeySPKISHA256: [32]byte{0xb3}, + OfflineAuthoritySPKISHA256: [32]byte{0xb4}, + ServiceEpoch: floor.ServiceEpoch, + WitnessMaximumRecords: 4096, + WitnessRotationThresholdRecords: 1024, + CertifiedFloor: frostsigning.NativeTBTCSignerStateAnchorTrustReference{ + ServiceEpoch: floor.ServiceEpoch, + Revision: floor.Revision, + EventRoot: floor.EventRoot, + AcknowledgementDigest: floor.AcknowledgementDigest, + Checkpoint: frostsigning.NativeTBTCSignerStateAnchorCheckpoint{ + StoreFingerprint: floor.Checkpoint.StoreFingerprint, + Generation: floor.Checkpoint.Generation, + PreviousStateCommitment: floor.Checkpoint.PreviousStateCommitment, + StateImageDigest: floor.Checkpoint.StateImageDigest, + StateCommitment: floor.Checkpoint.StateCommitment, + }, + }, + } +} + func TestCompleteFrostInteractiveSigningReadiness_RequiresEveryRuntimeGate( t *testing.T, ) { @@ -132,6 +165,104 @@ func TestCompleteFrostInteractiveSigningReadiness_RequiresEveryRuntimeGate( } } +func TestValidateFrostNativeSignerAnchorReadinessHeadroomFreezesAtBound( + t *testing.T, +) { + inventory := &frostNativeSignerInventorySnapshot{ + CertifiedFloorRevision: 1, + CurrentAnchorRevision: 1 + + FrostNativeSignerAnchorMaximumHistoryEvents - 1, + CertifiedFloorGeneration: 1, + StateGeneration: 1, + RestartableRevisionHeadroom: 1, + RestartableGenerationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, + AnchorRotationWarning: true, + } + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + inventory, + ); err != nil { + t.Fatalf("last usable revision was rejected: %v", err) + } + inventory.CurrentAnchorRevision++ + inventory.RestartableRevisionHeadroom = 0 + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + inventory, + ); err == nil || !strings.Contains(err.Error(), "rotation is required") { + t.Fatalf("exhausted revision window remained readiness-valid: %v", err) + } + inventory.CurrentAnchorRevision++ + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + inventory, + ); err == nil || !strings.Contains(err.Error(), "inconsistent") { + t.Fatalf("beyond-bound readiness snapshot was accepted: %v", err) + } +} + +func TestValidateFrostNativeSignerAnchorReadinessHeadroomTracksGenerationBound( + t *testing.T, +) { + inventory := &frostNativeSignerInventorySnapshot{ + CertifiedFloorRevision: 1, + CurrentAnchorRevision: 1, + RestartableRevisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + CertifiedFloorGeneration: 1, + StateGeneration: FrostNativeSignerAnchorMaximumHistoryProofEntries, + RestartableGenerationHeadroom: 1, + AnchorRotationWarning: true, + } + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + inventory, + ); err != nil { + t.Fatalf("last usable generation was rejected: %v", err) + } + inventory.StateGeneration++ + inventory.RestartableGenerationHeadroom = 0 + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + inventory, + ); err == nil || !strings.Contains(err.Error(), "rotation is required") { + t.Fatalf("exhausted generation window remained readiness-valid: %v", err) + } + inventory.StateGeneration++ + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + inventory, + ); err == nil || !strings.Contains(err.Error(), "inconsistent") { + t.Fatalf("beyond-bound generation snapshot was accepted: %v", err) + } +} + +func TestValidateFrostNativeSignerAnchorReadinessHeadroomWarningUsesMinimum( + t *testing.T, +) { + inventory := &frostNativeSignerInventorySnapshot{ + CertifiedFloorRevision: 1, + CurrentAnchorRevision: 1, + RestartableRevisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + CertifiedFloorGeneration: 1, + } + for _, test := range []struct { + headroom uint64 + warning bool + }{ + {FrostNativeSignerAnchorRotationWarningHeadroom, true}, + {FrostNativeSignerAnchorRotationWarningHeadroom + 1, false}, + } { + inventory.RestartableGenerationHeadroom = test.headroom + inventory.StateGeneration = 1 + + FrostNativeSignerAnchorMaximumHistoryProofEntries - + test.headroom + inventory.AnchorRotationWarning = test.warning + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + inventory, + ); err != nil { + t.Fatalf( + "generation warning boundary [%d] was rejected: %v", + test.headroom, + err, + ) + } + } +} + func TestVerifyFrostNativeSignerInventoryEntriesRejectsMismatch(t *testing.T) { walletID := [32]byte{0x21} valid := []frostsigning.NativeTBTCSignerRetainedKeyGroup{ @@ -244,12 +375,18 @@ func TestFrostNativeSignerInventoryBindingRequiresExactAuthenticatedAnchor(t *te } anchorBinding, anchorStore := testFrostNativeSignerInventoryAnchorBinding(storeFingerprint, checkpoint) + trustHead := testFrostNativeSignerInventoryTrustHead(anchorBinding) binding, err := newFrostNativeSignerInventoryBinding( storeBinding, anchorBinding, func() (*frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error) { return inventory, nil }, + func() (*frostsigning.NativeTBTCSignerStateAnchorTrustHead, error) { + copy := *trustHead + return ©, nil + }, + trustHead, ) if err != nil { t.Fatal(err) @@ -263,9 +400,60 @@ func TestFrostNativeSignerInventoryBindingRequiresExactAuthenticatedAnchor(t *te ParticipantSeats: []uint16{2}, }, } - if _, err := binding.verify(context.Background(), expected); err != nil { + snapshot, err := binding.verify(context.Background(), expected) + if err != nil { t.Fatalf("anchored descendant inventory was rejected: [%v]", err) } + if snapshot.AnchorServiceEpoch != 1 || + snapshot.CertifiedFloorRevision != 1 || + snapshot.CertifiedFloorGeneration != 2 || + snapshot.CurrentAnchorRevision != 1 || + snapshot.RestartableRevisionHeadroom != + FrostNativeSignerAnchorMaximumHistoryEvents || + snapshot.RestartableGenerationHeadroom != + FrostNativeSignerAnchorMaximumHistoryProofEntries || + snapshot.AnchorRotationWarning { + t.Fatalf("unexpected native anchor readiness headroom: %+v", snapshot) + } + + warningTip, err := anchorBinding.readTip() + if err != nil { + t.Fatal(err) + } + baselineTip := *warningTip + baselineRecord := *anchorStore.record + warningTip.AnchorRevision = + anchorBinding.floor.Revision + + FrostNativeSignerAnchorMaximumHistoryEvents - 1 + warningTip.AnchorEventRoot = [32]byte{0xd1} + warningTip.AnchorAcknowledgementDigest = [32]byte{0xd2} + anchorBinding.readTip = func() ( + *frostsigning.NativeTBTCSignerStateWitnessTip, + error, + ) { + copy := *warningTip + return ©, nil + } + anchorStore.record.Revision = warningTip.AnchorRevision + anchorStore.record.PreviousEventRoot = [32]byte{0xd0} + anchorStore.record.EventRoot = warningTip.AnchorEventRoot + anchorStore.record.AcknowledgementDigest = + warningTip.AnchorAcknowledgementDigest + warningSnapshot, err := binding.verify(context.Background(), expected) + if err != nil { + t.Fatalf("warning-headroom inventory was rejected: %v", err) + } + if warningSnapshot.RestartableRevisionHeadroom != 1 || + !warningSnapshot.AnchorRotationWarning { + t.Fatalf( + "revision exhaustion warning was not surfaced: %+v", + warningSnapshot, + ) + } + + // Restore the exact baseline before the fork checks below. + *warningTip = baselineTip + *anchorStore.record = baselineRecord forkImage := [32]byte{0xff} anchorStore.record.Checkpoint = FrostNativeSignerStateWitnessCheckpoint{ @@ -417,6 +605,7 @@ func TestFrostProductionSignerReadinessRejectsConcurrentRegistryChange( } anchorBinding, _ := testFrostNativeSignerInventoryAnchorBinding(storeFingerprint, checkpoint) + trustHead := testFrostNativeSignerInventoryTrustHead(anchorBinding) inventoryReads := 0 inventoryBinding, err := newFrostNativeSignerInventoryBinding( storeBinding, @@ -430,6 +619,11 @@ func TestFrostProductionSignerReadinessRejectsConcurrentRegistryChange( } return inventory, nil }, + func() (*frostsigning.NativeTBTCSignerStateAnchorTrustHead, error) { + copy := *trustHead + return ©, nil + }, + trustHead, ) if err != nil { t.Fatal(err) diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index d3dda7fd5a..fc4a3bd3d4 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1468,19 +1468,19 @@ type FrostPreSignActivationRuntimeManifest struct { CanonicalJournal FrostRetainedGroupCanonicalJournalManifest QuarantineJournal FrostRetainedGroupQuarantineJournalManifest NativeSignerAnchor FrostNativeSignerAnchorManifest + // ActivationAuthorityPublicKey is the raw Ed25519 key from the already + // verified activation envelope. Its SPKI hash is pinned by + // NativeSignerAnchor.Identity.OfflineAuthorityHash. Runtime trust + // certificates are accepted only under this exact immutable v1 authority. + ActivationAuthorityPublicKey [32]byte } -// FrostNativeSignerAnchorManifest carries the authenticated service identity -// and the offline-reviewed monotonic floor. Online startup may accept this -// exact state or a signed/proven descendant, never an arbitrary same-store -// stream head. +// FrostNativeSignerAnchorManifest carries only authenticated static service +// identity and witness geometry. The exact monotonic floor deliberately lives +// in the separately signed trust-certificate chain so the activation manifest +// hash cannot participate in a circular floor/event-root commitment. type FrostNativeSignerAnchorManifest struct { Identity FrostNativeSignerAnchorIdentity - MinimumServiceEpoch uint64 - MinimumRevision uint64 - MinimumEventRoot [32]byte - MinimumAcknowledgementDigest [32]byte - MinimumCheckpoint FrostNativeSignerStateWitnessCheckpoint WitnessMaximumRecords uint64 WitnessRotationThresholdRecords uint64 } @@ -1501,6 +1501,14 @@ type frostPreSignAuthorization struct { Finality FrostPreSignFinality VariantSequence FrostPreSignVariantSequence proposal *FrostPreSignAuthorizationProposal + anchorReservation *frostNativeSignerAnchorRevisionReservation +} + +func (authorization *frostPreSignAuthorization) releaseAnchorReservation() { + if authorization == nil { + return + } + authorization.anchorReservation.Release() } type frostPreSignAuthorizationGate interface { @@ -1519,12 +1527,14 @@ type thresholdFrostPreSignAuthorizationGate struct { activationProfile FrostPreSignActivationProfile storeBinding *frostDurableSessionStoreBinding productionReadiness frostProductionSignerReadinessVerifier + anchorAdmission *frostNativeSignerAnchorAdmissionController signing chain.Signing broadcastChannel net.BroadcastChannel membershipValidator *group.MembershipValidator wallet wallet localMemberIndexes []group.MemberIndex threshold int + maximumAttempts uint64 } func newThresholdFrostPreSignAuthorizationGate( @@ -1532,6 +1542,7 @@ func newThresholdFrostPreSignAuthorizationGate( activationProfile FrostPreSignActivationProfile, storeBinding *frostDurableSessionStoreBinding, productionReadiness frostProductionSignerReadinessVerifier, + anchorAdmission *frostNativeSignerAnchorAdmissionController, signing chain.Signing, broadcastChannel net.BroadcastChannel, membershipValidator *group.MembershipValidator, @@ -1547,7 +1558,7 @@ func newThresholdFrostPreSignAuthorizationGate( if _, err := storeBinding.verify(); err != nil { return nil, fmt.Errorf("FROST durable session store is not activation-ready: [%w]", err) } - if productionReadiness == nil { + if productionReadiness == nil || anchorAdmission == nil { return nil, fmt.Errorf("FROST production signer readiness verifier is nil") } if signing == nil { @@ -1583,12 +1594,14 @@ func newThresholdFrostPreSignAuthorizationGate( activationProfile: activationProfile, storeBinding: storeBinding, productionReadiness: productionReadiness, + anchorAdmission: anchorAdmission, signing: signing, broadcastChannel: broadcastChannel, membershipValidator: membershipValidator, wallet: wallet, localMemberIndexes: indexes, threshold: frostPreSignAuthorizationThreshold, + maximumAttempts: signingAttemptsLimit, }, nil } @@ -1599,15 +1612,45 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) authorize( if ctx == nil { return nil, fmt.Errorf("FROST pre-sign authorization context is nil") } - if _, err := tfpsag.verifyCurrentProductionSignerReadiness(ctx); err != nil { + if err := transaction.validate(); err != nil { + return nil, err + } + _, readinessSnapshot, err := + tfpsag.verifyCurrentProductionSignerReadiness(ctx) + if err != nil { return nil, err } + if tfpsag.anchorAdmission == nil { + return nil, fmt.Errorf( + "FROST native signer anchor admission controller is nil", + ) + } + maximumAttempts := tfpsag.maximumAttempts + if maximumAttempts == 0 { + maximumAttempts = signingAttemptsLimit + } + anchorReservation, err := tfpsag.anchorAdmission.reservePreSign( + ctx, + readinessSnapshot, + uint64(len(transaction.SignatureHashes)), + uint64(len(tfpsag.localMemberIndexes)), + maximumAttempts, + ) + if err != nil { + return nil, fmt.Errorf( + "FROST pre-sign anchor admission failed: [%w]", + err, + ) + } + reservationTransferred := false + defer func() { + if !reservationTransferred { + anchorReservation.Release() + } + }() if _, err := tfpsag.storeBinding.verify(); err != nil { return nil, fmt.Errorf("FROST durable session store binding failed: [%w]", err) } - if err := transaction.validate(); err != nil { - return nil, err - } // Freeze the locally constructed signing batch before crossing the backend // boundary. Passing the caller's pointer would let an adapter mutate both the // proposal and the comparison target in place, making pointer-equal data pass @@ -1679,11 +1722,13 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) authorize( Finality: *finality, VariantSequence: frostPreSignVariantSequence(*finality), proposal: proposal, + anchorReservation: anchorReservation, } if err := tfpsag.revalidate(ctx, authorization); err != nil { return nil, err } + reservationTransferred = true return authorization, nil } @@ -1691,7 +1736,8 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( ctx context.Context, authorization *frostPreSignAuthorization, ) error { - currentFinality, err := tfpsag.verifyCurrentProductionSignerReadiness(ctx) + currentFinality, _, err := + tfpsag.verifyCurrentProductionSignerReadiness(ctx) if err != nil { return err } @@ -1755,28 +1801,51 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( func (tfpsag *thresholdFrostPreSignAuthorizationGate) verifyCurrentProductionSignerReadiness( ctx context.Context, -) (*FrostPreSignFinality, error) { +) ( + *FrostPreSignFinality, + *frostProductionSignerReadinessSnapshot, + error, +) { if tfpsag == nil || tfpsag.backend == nil || tfpsag.productionReadiness == nil { - return nil, fmt.Errorf("FROST production signer readiness dependencies are incomplete") + return nil, nil, fmt.Errorf( + "FROST production signer readiness dependencies are incomplete", + ) } if ctx == nil { - return nil, fmt.Errorf("FROST production signer readiness context is nil") + return nil, nil, fmt.Errorf( + "FROST production signer readiness context is nil", + ) } currentFinality, err := tfpsag.backend.CurrentFrostPreSignFinality(ctx) if err != nil { - return nil, fmt.Errorf("cannot obtain current FROST pre-sign finality: [%w]", err) + return nil, nil, fmt.Errorf( + "cannot obtain current FROST pre-sign finality: [%w]", + err, + ) } if currentFinality == nil || currentFinality.BlockNumber == 0 || currentFinality.BlockHash == [32]byte{} { - return nil, fmt.Errorf("invalid current FROST pre-sign finalized checkpoint") + return nil, nil, fmt.Errorf( + "invalid current FROST pre-sign finalized checkpoint", + ) } - if _, err := tfpsag.productionReadiness.verifyFrostProductionSignerReadiness( - ctx, - *currentFinality, - ); err != nil { - return nil, fmt.Errorf("FROST production signer is not authorization-ready: [%w]", err) + readinessSnapshot, err := + tfpsag.productionReadiness.verifyFrostProductionSignerReadiness( + ctx, + *currentFinality, + ) + if err != nil { + return nil, nil, fmt.Errorf( + "FROST production signer is not authorization-ready: [%w]", + err, + ) + } + if readinessSnapshot == nil { + return nil, nil, fmt.Errorf( + "FROST production signer readiness snapshot is nil", + ) } - return currentFinality, nil + return currentFinality, readinessSnapshot, nil } func (tfpsag *thresholdFrostPreSignAuthorizationGate) validatePinnedAuthorizationStateTwice( diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go index 445e575faf..cbb4c7428c 100644 --- a/pkg/tbtc/frost_pre_sign_authorization_test.go +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -15,9 +15,10 @@ import ( ) type testFrostProductionAuthorizationReadiness struct { - err error - calls uint64 - points []FrostPreSignFinality + err error + calls uint64 + points []FrostPreSignFinality + headrooms []uint64 } func (readiness *testFrostProductionAuthorizationReadiness) verifyFrostProductionSignerReadiness( @@ -29,9 +30,15 @@ func (readiness *testFrostProductionAuthorizationReadiness) verifyFrostProductio if readiness.err != nil { return nil, readiness.err } - return &frostProductionSignerReadinessSnapshot{ - InteractiveSigningReady: true, - }, nil + headroom := uint64(FrostNativeSignerAnchorMaximumHistoryEvents) + if len(readiness.headrooms) != 0 { + index := int(readiness.calls - 1) + if index >= len(readiness.headrooms) { + index = len(readiness.headrooms) - 1 + } + headroom = readiness.headrooms[index] + } + return testFrostAnchorAdmissionReadinessSnapshot(headroom, headroom), nil } func testFrostPreSignTransaction( @@ -580,6 +587,7 @@ func TestThresholdFrostPreSignAuthorizationGate_ProfileMismatchPrecedesSeatSigna activationProfile: profile, storeBinding: testFrostDurableSessionStoreBinding(t), productionReadiness: &testFrostProductionAuthorizationReadiness{}, + anchorAdmission: &frostNativeSignerAnchorAdmissionController{}, signing: countingSigner, wallet: wallet{ signingGroupOperators: make( diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index f5e1e04224..fc3993a8a5 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -4,6 +4,7 @@ import ( "context" "crypto/ecdsa" "encoding/hex" + "errors" "fmt" "math/big" "sync" @@ -69,6 +70,7 @@ type node struct { frostPreSignActivationProfile FrostPreSignActivationProfile frostDurableSessionStoreBinding *frostDurableSessionStoreBinding frostProductionSignerReadiness frostProductionSignerReadinessVerifier + frostNativeSignerAnchorAdmission *frostNativeSignerAnchorAdmissionController bitcoinBroadcastOutbox *bitcoinBroadcastOutbox frostRetainedGroupJournal *frostRetainedGroupJournal frostActivationHandshakeExporter *frostActivationHandshakeExporter @@ -336,17 +338,6 @@ func newNode( _ = outbox.close() return nil, fmt.Errorf("cannot read authenticated FROST runtime manifest: [%w]", err) } - storeBinding, err := newFrostDurableSessionStoreBinding( - runtimeManifest.DurableSessionStoreFingerprint, - signing.ReadNativeTBTCSignerDurableStoreIdentity, - ) - if err != nil { - _ = outbox.close() - return nil, fmt.Errorf( - "cannot bind the active FROST durable session store to the signed manifest: [%w]", - err, - ) - } anchorManifest := runtimeManifest.NativeSignerAnchor clientPrivateKey, err := loadFrostNativeSignerAnchorClientPrivateKey( config.FrostNativeSignerAnchorClientPrivateKeyPath, @@ -381,30 +372,149 @@ func newNode( } onlineRawKey := [32]byte{} copy(onlineRawKey[:], onlinePublicKey) - expectedAnchorBindingHash := - ComputeFrostNativeSignerAnchorBindingHash(anchorManifest.Identity) - if installedAnchorConfig.BindingHash != expectedAnchorBindingHash || - installedAnchorConfig.ResponsePublicKey != onlineRawKey || - installedAnchorConfig.ResponsePublicKeySPKISHA256 != - anchorManifest.Identity.OnlineKeyHash || - installedAnchorConfig.WitnessMaximumRecords != - anchorManifest.WitnessMaximumRecords || - installedAnchorConfig.WitnessRotationThresholdRecords != - anchorManifest.WitnessRotationThresholdRecords { + trustCertificateJSON, err := + loadFrostNativeSignerAnchorTrustCertificateChain( + config.FrostNativeSignerAnchorTrustCertificatePath, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot load FROST native signer anchor trust certificates: [%w]", + err, + ) + } + trustCertificateChain, err := + DecodeFrostNativeSignerAnchorTrustCertificateChain( + trustCertificateJSON, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot decode FROST native signer anchor trust certificates: [%w]", + err, + ) + } + finalTrustCertificate := + &trustCertificateChain[len(trustCertificateChain)-1] + expectedTrustCertificateHead, expectedTrustHead, err := + validateFrostNativeSignerAnchorTrustExpectedHead( + runtimeManifest, + installedAnchorConfig, + finalTrustCertificate, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot bind the expected FROST native signer anchor trust head: [%w]", + err, + ) + } + if installedAnchorConfig.ResponsePublicKey != onlineRawKey || + finalTrustCertificate.To.ResponsePublicKey != onlineRawKey { + _ = outbox.close() + return nil, fmt.Errorf( + "native signer anchor online key file differs from the installed and certified key", + ) + } + + var priorTrustCertificateHead *FrostNativeSignerAnchorTrustCertificateHead + priorTrustHead, priorTrustHeadErr := + signing.ReadNativeTBTCSignerStateAnchorTrustHead() + transitionCertificateChain := trustCertificateChain + if priorTrustHeadErr == nil { + transitionCertificateChain, err = + selectFrostNativeSignerAnchorTrustTransitionChain( + trustCertificateChain, + priorTrustHead, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot select the missing FROST native signer anchor trust-certificate suffix: [%w]", + err, + ) + } + priorTrustCertificateHead, err = + reconstructFrostNativeSignerAnchorTrustPriorHead( + priorTrustHead, + runtimeManifest, + installedAnchorConfig, + &transitionCertificateChain[0], + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot authenticate the installed FROST native signer anchor trust head: [%w]", + err, + ) + } + } else if !errors.Is( + priorTrustHeadErr, + signing.ErrNativeTBTCSignerStateAnchorTrustHeadAbsent, + ) { _ = outbox.close() return nil, fmt.Errorf( - "installed native signer anchor configuration differs from the signed manifest", + "cannot read the prior FROST native signer anchor trust head: [%w]", + priorTrustHeadErr, + ) + } else if transitionCertificateChain[0].CertificateSequence != 1 || + transitionCertificateChain[0].PreviousCertificateDigest != [32]byte{} { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot read the prior FROST native signer anchor trust head for a certificate suffix: [%w]", + priorTrustHeadErr, ) } - anchorClient, err := NewFrostNativeSignerAnchorClient( - FrostNativeSignerAnchorClientConfig{ - Endpoint: config.FrostNativeSignerAnchorURL, - RequestTimeout: config.FrostNativeSignerAnchorRequestTimeout, - ClientPrivateKey: clientPrivateKey, - OnlinePublicKeySPKI: onlineKeySPKI, - Identity: anchorManifest.Identity, + trustChainOptions := FrostNativeSignerAnchorTrustChainValidationOptions{ + // A sequence-one rotation is an explicit, offline-authorized + // adoption of a pre-journal anchor. The configured owner-only + // certificate artifact and exact installed final digest are the + // local authorization to perform it. + AllowLegacyAdoption: true, + ExpectedProtocolID: anchorManifest.Identity.ProtocolID, + ExpectedStreamID: anchorManifest.Identity.StreamID, + ExpectedSignerStoreFingerprint: anchorManifest.Identity. + SignerStoreFingerprint, + ExpectedOfflineAuthorityPublicKey: runtimeManifest. + ActivationAuthorityPublicKey, + ExpectedOfflineAuthoritySPKISHA256: anchorManifest.Identity. + OfflineAuthorityHash, + PriorHead: priorTrustCertificateHead, + ExpectedHead: expectedTrustCertificateHead, + ValidateTargetAcknowledgement: func( + certificate *FrostNativeSignerAnchorTrustCertificate, + rawAcknowledgement []byte, + ) error { + return ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement( + certificate, + rawAcknowledgement, + ) }, - ) + } + verifiedTrustFloor, err := + authenticateFrostNativeSignerAnchorTrustCertificateChain( + transitionCertificateChain, + trustChainOptions, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot authenticate FROST native signer anchor trust-certificate chain: [%w]", + err, + ) + } + anchorClient, err := + newFrostNativeSignerAnchorClientWithTrustFloor( + FrostNativeSignerAnchorClientConfig{ + Endpoint: config.FrostNativeSignerAnchorURL, + RequestTimeout: config. + FrostNativeSignerAnchorRequestTimeout, + ClientPrivateKey: clientPrivateKey, + OnlinePublicKeySPKI: onlineKeySPKI, + Identity: anchorManifest.Identity, + }, + verifiedTrustFloor, + ) if err != nil { _ = outbox.close() return nil, fmt.Errorf( @@ -412,9 +522,109 @@ func newNode( err, ) } + exactTrustHeadReplay := + isFrostNativeSignerAnchorTrustExactHeadReplay( + priorTrustCertificateHead, + expectedTrustCertificateHead, + ) + var trustTransitionTarget *FrostNativeSignerAnchorTrustTransitionTarget + if !exactTrustHeadReplay { + trustTransitionTarget, err = + anchorClient.readFrostNativeSignerAnchorTrustTransitionTarget( + context.Background(), + false, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot obtain the fresh FROST native signer anchor trust-transition target: [%w]", + err, + ) + } + trustTransitionRequest, err := + EncodeFrostNativeSignerAnchorTrustTransitionRequest( + &FrostNativeSignerAnchorTrustTransitionRequest{ + CertificateChain: transitionCertificateChain, + TargetReadResponse: trustTransitionTarget. + ExactReadResponse, + }, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot encode FROST native signer anchor trust transition: [%w]", + err, + ) + } + trustTransitionResult, err := + signing.TransitionNativeTBTCSignerStateWitnessAnchor( + trustTransitionRequest, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot install FROST native signer anchor trust transition: [%w]", + err, + ) + } + if err := validateFrostNativeSignerAnchorTrustTransitionResult( + trustTransitionResult, + trustTransitionTarget, + finalTrustCertificate, + expectedTrustCertificateHead, + expectedTrustHead, + priorTrustCertificateHead, + transitionCertificateChain, + ); err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot validate native signer anchor trust-transition result: [%w]", + err, + ) + } + } + installedTrustHead, err := + signing.ReadNativeTBTCSignerStateAnchorTrustHead() + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot read back the exact installed native signer anchor trust head: [%w]", + err, + ) + } + if installedTrustHead == nil || + *installedTrustHead != *expectedTrustHead { + _ = outbox.close() + return nil, fmt.Errorf( + "installed native signer anchor trust head differs from the independently pinned expected head", + ) + } + + // A missing suffix must transition before any durable signer-store + // access. An exact authenticated head deliberately skips the strict + // replay transition so ordinary reconciliation can repair either crash + // window where the durable tip or remote CAS is ahead of the persisted + // local acknowledgement. + storeBinding, err := newFrostDurableSessionStoreBinding( + runtimeManifest.DurableSessionStoreFingerprint, + signing.ReadNativeTBTCSignerDurableStoreIdentity, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot bind the active FROST durable session store to the signed manifest: [%w]", + err, + ) + } + certifiedFloor := frostNativeSignerAnchorReferenceFromTrust( + finalTrustCertificate.To.Reference, + ) + expectedAnchorBindingHash := finalTrustCertificate.To.BindingHash anchorBinding, err := newFrostNativeSignerAnchorBinding( anchorClient, anchorManifest, + certifiedFloor, + finalTrustCertificate.To.Reference.PreviousEventRoot, signing.ReadNativeTBTCSignerStateWitnessTip, signing.ReadNativeTBTCSignerStateWitnessProof, signing.AcknowledgeNativeTBTCSignerStateWitnessCheckpoint, @@ -427,6 +637,15 @@ func newNode( err, ) } + anchorAdmission, err := + newFrostNativeSignerAnchorAdmissionController(anchorBinding) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot initialize native signer anchor admission: [%w]", + err, + ) + } startupSignerTip, err := anchorBinding.reconcileStartup( context.Background(), ) @@ -437,14 +656,27 @@ func newNode( err, ) } + if err := validateFrostNativeSignerAnchorReconciledTransitionTarget( + startupSignerTip, + trustTransitionTarget, + ); err != nil { + _ = outbox.close() + return nil, err + } if err := signing.InstallNativeTBTCSignerStateAnchorBarrier( signing.NativeTBTCSignerStateAnchorBarrierConfig{ - InitialTip: startupSignerTip, - ExpectedAnchorBindingHash: expectedAnchorBindingHash, - MinimumAnchorServiceEpoch: anchorManifest.MinimumServiceEpoch, - ReadTip: signing.ReadNativeTBTCSignerStateWitnessTip, - Committer: anchorBinding, - Timeout: config.FrostNativeSignerAnchorRequestTimeout, + InitialTip: startupSignerTip, + ExpectedAnchorBindingHash: expectedAnchorBindingHash, + MinimumAnchorServiceEpoch: certifiedFloor.ServiceEpoch, + MaximumAnchorRevisionDistance: FrostNativeSignerAnchorMaximumHistoryEvents, + MaximumStateGenerationDistance: FrostNativeSignerAnchorMaximumHistoryProofEntries, + MaximumStateGenerationAdvancePerOperation: frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall, + ExpectedTrustHead: expectedTrustHead, + ReadTip: signing.ReadNativeTBTCSignerStateWitnessTip, + ReadTrustHead: signing. + ReadNativeTBTCSignerStateAnchorTrustHead, + Committer: anchorBinding, + Timeout: config.FrostNativeSignerAnchorRequestTimeout, }, ); err != nil { _ = outbox.close() @@ -457,6 +689,8 @@ func newNode( storeBinding, anchorBinding, signing.ReadNativeTBTCSignerRetainedKeyPackageInventory, + signing.ReadNativeTBTCSignerStateAnchorTrustHead, + expectedTrustHead, ) if err != nil { _ = outbox.close() @@ -541,6 +775,7 @@ func newNode( node.frostPreSignActivationProfile = verifiedActivationProfile node.frostDurableSessionStoreBinding = storeBinding node.frostProductionSignerReadiness = readiness + node.frostNativeSignerAnchorAdmission = anchorAdmission node.bitcoinBroadcastOutbox = outbox node.frostActivationHandshakeExporter = exporter } @@ -860,6 +1095,7 @@ func (n *node) getSigningExecutor( n.frostPreSignActivationProfile, n.frostDurableSessionStoreBinding, n.frostProductionSignerReadiness, + n.frostNativeSignerAnchorAdmission, n.chain.Signing(), broadcastChannel, membershipValidator, diff --git a/pkg/tbtc/signing.go b/pkg/tbtc/signing.go index 22b5bb2306..2a20b9b605 100644 --- a/pkg/tbtc/signing.go +++ b/pkg/tbtc/signing.go @@ -802,6 +802,21 @@ func (se *signingExecutor) signWithTaprootMerkleRootForSessionIntentAndAuthoriza authorizationID, ) } + aggregateMemoSession, err := + signing.BeginInteractiveAggregateMemoSession(roastSID) + if err != nil { + if se.metricsRecorder != nil { + se.metricsRecorder.IncrementCounter( + clientinfo.MetricSigningFailedTotal, + 1, + ) + } + return nil, nil, 0, fmt.Errorf( + "cannot bind interactive aggregate memo lifetime: [%w]", + err, + ) + } + defer aggregateMemoSession.Release() for _, currentSigner := range se.signers { go func(signer *signer) { @@ -1065,6 +1080,7 @@ func (se *signingExecutor) signWithTaprootMerkleRootForSessionIntentAndAuthoriza // Wait until all controlled signers complete their signing routines, // regardless of their result. wg.Wait() + aggregateMemoSession.Release() // Take the first outcome from the channel as the outcome of all members. // This assumption is totally valid because the signing loop produces a diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 361fd85a79..3e7e8c1664 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -176,6 +176,11 @@ type Config struct { // FrostNativeSignerAnchorOnlinePublicKeyPath contains the DER SubjectPublicKeyInfo // for the online Ed25519 service key pinned by the signed manifest. FrostNativeSignerAnchorOnlinePublicKeyPath string + // FrostNativeSignerAnchorTrustCertificatePath contains an owner-only, + // bounded JSON array of one to 64 offline-authority-signed bootstrap or + // rotation certificates. The final certificate must exactly match the + // verified activation manifest and installed native signer config. + FrostNativeSignerAnchorTrustCertificatePath string // FrostNativeSignerAnchorRequestTimeout bounds each authenticated Read/CAS. // Zero selects the protocol client's conservative production default. FrostNativeSignerAnchorRequestTimeout time.Duration diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index 5f4387094c..8c293da5e8 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -592,6 +592,9 @@ func (wte *walletTransactionExecutor) signTransaction( authorizationCtx, preSignTransaction, ) + if authorization != nil { + defer authorization.releaseAnchorReservation() + } if err == nil { err = wte.preSignAuthorizationGate.revalidate( authorizationCtx, From 0d50f0d304edddccc85da974eed89cdf2844af1e Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 20:39:31 -0400 Subject: [PATCH 12/93] Reject aggregate memo calls without active owner --- ...interactive_aggregate_memo_frost_native.go | 7 +++ ...active_aggregate_memo_frost_native_test.go | 57 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native.go b/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native.go index 4c82469031..caf19deff9 100644 --- a/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native.go +++ b/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native.go @@ -126,6 +126,13 @@ func aggregateInteractiveOnce( interactiveAggregateMemoMu.Lock() sessionID, _, hasAttemptSeparator := strings.Cut(key, "|") owner := interactiveAggregateMemoSessions[sessionID] + if hasAttemptSeparator && owner == nil { + interactiveAggregateMemoMu.Unlock() + return nil, fmt.Errorf( + "interactive aggregate memo key [%s] has no active outer session owner", + key, + ) + } entry, ok := interactiveAggregateMemo[key] if !ok { entry = &interactiveAggregateEntry{owner: owner} diff --git a/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native_test.go b/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native_test.go index 4536ca5b0e..3d60cf0360 100644 --- a/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native_test.go +++ b/pkg/frost/signing/roast_runner_interactive_aggregate_memo_frost_native_test.go @@ -261,3 +261,60 @@ func TestInteractiveAggregateMemoSessionCleanupIsExactAndIdentityGuarded( ) } } + +func TestAggregateInteractiveOnce_RejectsProductionKeyAfterSessionRelease( + t *testing.T, +) { + ResetInteractiveAggregateMemoForTest() + t.Cleanup(ResetInteractiveAggregateMemoForTest) + + session, err := BeginInteractiveAggregateMemoSession("released-session") + if err != nil { + t.Fatal(err) + } + var calls int + if _, err := aggregateInteractiveOnce( + "released-session|attempt-1", + func() ([]byte, error) { + calls++ + return []byte("first"), nil + }, + ); err != nil { + t.Fatal(err) + } + session.Release() + + if _, err := aggregateInteractiveOnce( + "released-session|attempt-1", + func() ([]byte, error) { + calls++ + return []byte("must-not-run"), nil + }, + ); err == nil { + t.Fatal("late aggregate after outer-session release was accepted") + } + if calls != 1 { + t.Fatalf("late aggregate callback ran [%d] times", calls) + } +} + +func TestAggregateInteractiveOnce_RejectsProductionKeyWithoutOuterSession( + t *testing.T, +) { + ResetInteractiveAggregateMemoForTest() + t.Cleanup(ResetInteractiveAggregateMemoForTest) + + called := false + if _, err := aggregateInteractiveOnce( + "never-begun|attempt-1", + func() ([]byte, error) { + called = true + return []byte("must-not-run"), nil + }, + ); err == nil { + t.Fatal("production-shaped aggregate key without an owner was accepted") + } + if called { + t.Fatal("ownerless aggregate callback ran") + } +} From d5a429c6f4efc017865d29fdf4c2f710b423594a Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 26 Jul 2026 19:26:04 -0400 Subject: [PATCH 13/93] Recover native signer anchor trust transitions at startup Authenticate a complete sequence-one recovery artifact, select the durable-intent suffix against it, execute the transition with at most one typed-recovery retry, and validate the result under an explicit recoveryReplay marker: a recovered transition must be an exact idempotent replay whose witness base equals the recovered certified floor, and can never double as an exact-head replay. Decode and classify the typed state-anchor trust-recovery error across the FFI boundary. Anchored interactive calls advance at most three Rust generations (prepared-witness reconciliation, one sweep/repair snapshot that also covers protected retirement, and the operation's own write), matching the engine's production barrier bound. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DpftF6aYHh56KgiHU6D2NQ --- ...c_signer_registration_frost_native_test.go | 44 ++ ...native_tbtc_signer_state_anchor_barrier.go | 3 +- ...e_tbtc_signer_state_anchor_barrier_test.go | 70 +++ ...tbtc_signer_state_anchor_trust_cgo_test.go | 85 +++ ...ive_tbtc_signer_state_anchor_trust_test.go | 131 +++++ .../frost_native_signer_anchor_admission.go | 18 +- ...rost_native_signer_anchor_trust_startup.go | 271 +++++++++ ...native_signer_anchor_trust_startup_test.go | 539 +++++++++++++++++- pkg/tbtc/node.go | 104 ++-- 9 files changed, 1198 insertions(+), 67 deletions(-) diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go index 10e1a0d73f..ba8a058c93 100644 --- a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go @@ -1221,6 +1221,50 @@ func TestBuildTaggedTBTCSignerErrorPayload_CandidateCulprits(t *testing.T) { } } +func TestBuildTaggedTBTCSignerErrorPayload_TrustRecovery(t *testing.T) { + recoveryWire := + testNativeTBTCSignerStateAnchorTrustRecoveryRequiredWire() + payload, err := json.Marshal(buildTaggedTBTCSignerErrorResponse{ + Code: nativeTBTCSignerStateAnchorTrustRecoveryRequiredCode, + Message: "recovery required", + StateAnchorTrustRecovery: &recoveryWire, + }) + if err != nil { + t.Fatal(err) + } + structured := buildTaggedTBTCSignerErrorPayload(payload) + if structured.Code != + nativeTBTCSignerStateAnchorTrustRecoveryRequiredCode || + structured.StateAnchorTrustRecovery == nil || + structured.StateAnchorTrustRecovery.CertificateCount != 2 || + structured.StateAnchorTrustRecovery.FinalCertificateSequence != 5 { + t.Fatalf("valid recovery context was not preserved: %+v", structured) + } + + recoveryWire.CertificateCount = "1" + payload, err = json.Marshal(buildTaggedTBTCSignerErrorResponse{ + Code: nativeTBTCSignerStateAnchorTrustRecoveryRequiredCode, + Message: "recovery required", + StateAnchorTrustRecovery: &recoveryWire, + }) + if err != nil { + t.Fatal(err) + } + structured = buildTaggedTBTCSignerErrorPayload(payload) + if structured.Code != + nativeTBTCSignerStateAnchorTrustRecoveryRequiredCode || + structured.StateAnchorTrustRecovery != nil || + !strings.Contains( + structured.Message, + "invalid state-anchor trust-recovery context", + ) { + t.Fatalf( + "malformed recovery context did not remain terminal: %+v", + structured, + ) + } +} + func TestBuildTaggedTBTCSignerDeriveInteractiveAttemptContextRequestPayload(t *testing.T) { payload, err := buildTaggedTBTCSignerDeriveInteractiveAttemptContextRequestPayload( "session-1", diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go index 17fdef02ed..4bc7ab8425 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go @@ -39,7 +39,8 @@ const ( // NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation is the // maximum durable Rust state writes one request-taking call may perform: - // two sweep/repair writes followed by the operation's own write. + // one prepared-witness reconciliation, a sweep/repair snapshot (which + // also covers protected retirement), and the operation's own write. NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation uint64 = 3 ) diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go index 71a082a2ce..89326ee8ff 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go @@ -852,6 +852,76 @@ func TestNativeTBTCSignerStateAnchorBarrierPoisonsOversizedGenerationAdvance( } } +func TestNativeTBTCSignerStateAnchorBarrierAcceptsMaximumGenerationAdvance( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{ + current: ¤t, + acknowledge: func( + candidate NativeTBTCSignerStateWitnessTip, + ) NativeTBTCSignerStateWitnessTip { + candidate.AnchorRevision++ + candidate.AnchorEventRoot = [32]byte{0xa1} + candidate.AnchorAcknowledgementDigest = [32]byte{0xa2} + return candidate + }, + } + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: NativeTBTCSignerStateAnchorMaximumRevisionDistance, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := current + return ©, nil + }, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, + }, + ); err != nil { + t.Fatal(err) + } + + payload, err := executeNativeTBTCSignerStateAnchoredOutput( + "InteractiveRound2", + func() { + current = testNativeTBTCSignerStateWitnessTip( + initial.Generation+ + NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + initial.StateCommitment, + ) + }, + func() ([]byte, error) { + return []byte("accepted"), nil + }, + func() { + t.Fatal("maximum valid generation advance was discarded") + }, + ) + if err != nil || string(payload) != "accepted" || + current.Generation != + initial.Generation+ + NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation || + committer.calls != 1 { + t.Fatalf( + "maximum generation advance was rejected [payload %q generation %d commits %d err %v]", + payload, + current.Generation, + committer.calls, + err, + ) + } +} + func TestNativeTBTCSignerStateAnchorBarrierGenerationCapacityAdvancesFasterThanRevision( t *testing.T, ) { diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_cgo_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_cgo_test.go index 72b2530e3f..4c11655360 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_cgo_test.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_cgo_test.go @@ -52,3 +52,88 @@ func TestClassifyNativeTBTCSignerStateAnchorTrustHeadError(t *testing.T) { } } } + +func TestClassifyNativeTBTCSignerStateAnchorTrustRecoveryRequiredError( + t *testing.T, +) { + recovery, err := decodeNativeTBTCSignerStateAnchorTrustRecoveryRequired( + testNativeTBTCSignerStateAnchorTrustRecoveryRequiredWirePointer(), + ) + if err != nil { + t.Fatal(err) + } + structured := &buildTaggedTBTCSignerStructuredError{ + Code: nativeTBTCSignerStateAnchorTrustRecoveryRequiredCode, + Message: "recovery required", + StateAnchorTrustRecovery: recovery, + } + failure := fmt.Errorf( + "%w: %w", + ErrNativeBridgeOperationFailed, + structured, + ) + + classifiers := map[string]func(error) error{ + "trust head": classifyNativeTBTCSignerStateAnchorTrustHeadError, + "transition": classifyNativeTBTCSignerStateAnchorTrustTransitionError, + } + for name, classify := range classifiers { + t.Run(name, func(t *testing.T) { + classified := classify(failure) + var recoveryError *NativeTBTCSignerStateAnchorTrustRecoveryRequiredError + if !errors.As(classified, &recoveryError) { + t.Fatalf("typed recovery error was not classified: %v", classified) + } + if !errors.Is(classified, ErrNativeBridgeOperationFailed) { + t.Fatal("typed recovery error lost the original bridge failure") + } + if recoveryError.Recovery.CertificateCount != 2 || + len(recoveryError.Recovery.OrderedCertificateDigests) != 2 { + t.Fatalf( + "unexpected typed recovery selector: %+v", + recoveryError.Recovery, + ) + } + + // The classified error owns its selector; later changes to the + // decoded envelope cannot alter which authenticated suffix is used. + originalDigest := + recoveryError.Recovery.OrderedCertificateDigests[0] + structured.StateAnchorTrustRecovery. + OrderedCertificateDigests[0] = [32]byte{0xff} + if recoveryError.Recovery.OrderedCertificateDigests[0] != + originalDigest { + t.Fatal("classified recovery selector aliases the FFI envelope") + } + structured.StateAnchorTrustRecovery. + OrderedCertificateDigests[0] = originalDigest + }) + } + + malformed := fmt.Errorf( + "%w: %w", + ErrNativeBridgeOperationFailed, + &buildTaggedTBTCSignerStructuredError{ + Code: nativeTBTCSignerStateAnchorTrustRecoveryRequiredCode, + Message: "recovery context was malformed", + }, + ) + for name, classify := range classifiers { + t.Run(name+" malformed", func(t *testing.T) { + classified := classify(malformed) + var recoveryError *NativeTBTCSignerStateAnchorTrustRecoveryRequiredError + if errors.As(classified, &recoveryError) { + t.Fatal("malformed recovery context became retryable") + } + if !errors.Is(classified, ErrNativeBridgeOperationFailed) { + t.Fatal("malformed recovery context lost its terminal cause") + } + }) + } +} + +func testNativeTBTCSignerStateAnchorTrustRecoveryRequiredWirePointer( +) *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire { + wire := testNativeTBTCSignerStateAnchorTrustRecoveryRequiredWire() + return &wire +} diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go index 37579e38ba..c76bdccfe0 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go @@ -120,6 +120,137 @@ func TestDecodeNativeTBTCSignerStateAnchorTrustRejectsUnknownAndInconsistent( } } +func TestDecodeNativeTBTCSignerStateAnchorTrustRecoveryRequired(t *testing.T) { + wire := testNativeTBTCSignerStateAnchorTrustRecoveryRequiredWire() + + recovery, err := + decodeNativeTBTCSignerStateAnchorTrustRecoveryRequired(&wire) + if err != nil { + t.Fatalf("valid trust-recovery selector was rejected: %v", err) + } + if recovery.Schema != + NativeTBTCSignerStateAnchorTrustRecoveryRequiredSchema || + recovery.StoreFingerprint != [32]byte{0x77} || + recovery.CertificateCount != 2 || + recovery.FirstCertificateSequence != 4 || + len(recovery.OrderedCertificateDigests) != 2 || + recovery.OrderedCertificateDigests[0] != [32]byte{0x41} || + recovery.OrderedCertificateDigests[1] != [32]byte{0x42} || + recovery.FinalCertificateSequence != 5 || + recovery.FinalCertificateDigest != [32]byte{0x42} || + recovery.TargetBindingHash != [32]byte{0x51} || + recovery.TargetServiceEpoch != 7 || + recovery.TargetRevision != 9 || + recovery.TargetCheckpoint.Generation != 12 { + t.Fatalf("unexpected decoded trust-recovery selector: %+v", recovery) + } +} + +func TestDecodeNativeTBTCSignerStateAnchorTrustRecoveryRequiredRejectsInvalid( + t *testing.T, +) { + tests := map[string]func(*nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire){ + "unsupported schema": func( + wire *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire, + ) { + wire.Schema = "unsupported" + }, + "non-canonical count": func( + wire *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire, + ) { + wire.CertificateCount = "02" + }, + "zero count": func( + wire *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire, + ) { + wire.CertificateCount = "0" + }, + "count differs from digests": func( + wire *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire, + ) { + wire.CertificateCount = "1" + }, + "sequence range overflows": func( + wire *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire, + ) { + wire.FirstCertificateSequence = "18446744073709551615" + }, + "final sequence differs from range": func( + wire *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire, + ) { + wire.FinalCertificateSequence = "6" + }, + "duplicate certificate digest": func( + wire *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire, + ) { + wire.OrderedCertificateDigests[1] = + wire.OrderedCertificateDigests[0] + wire.FinalCertificateDigest = + wire.OrderedCertificateDigests[0] + }, + "final digest differs from ordered suffix": func( + wire *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire, + ) { + wire.FinalCertificateDigest = + testNativeTBTCSignerTrustHex32(0x43) + }, + "checkpoint belongs to another store": func( + wire *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire, + ) { + wire.StoreFingerprint = + testNativeTBTCSignerTrustHex32(0x78) + }, + "checkpoint commitment is invalid": func( + wire *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire, + ) { + wire.TargetCheckpoint.StateCommitment = + testNativeTBTCSignerTrustHex32(0x61) + }, + } + + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + wire := testNativeTBTCSignerStateAnchorTrustRecoveryRequiredWire() + mutate(&wire) + if _, err := + decodeNativeTBTCSignerStateAnchorTrustRecoveryRequired( + &wire, + ); err == nil { + t.Fatal("invalid trust-recovery selector was accepted") + } + }) + } +} + +func testNativeTBTCSignerStateAnchorTrustRecoveryRequiredWire( +) nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire { + return nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire{ + Schema: NativeTBTCSignerStateAnchorTrustRecoveryRequiredSchema, + StoreFingerprint: testNativeTBTCSignerTrustHex32( + 0x77, + ), + CertificateCount: "2", + FirstCertificateSequence: "4", + OrderedCertificateDigests: []string{ + testNativeTBTCSignerTrustHex32(0x41), + testNativeTBTCSignerTrustHex32(0x42), + }, + FinalCertificateSequence: "5", + FinalCertificateDigest: testNativeTBTCSignerTrustHex32( + 0x42, + ), + TargetBindingHash: testNativeTBTCSignerTrustHex32( + 0x51, + ), + TargetServiceEpoch: "7", + TargetRevision: "9", + TargetCheckpoint: testNativeTBTCSignerStateAnchorCheckpointWire( + 12, + 0x61, + ), + } +} + func testNativeTBTCSignerStateAnchorCheckpointWire( generation uint64, seed byte, diff --git a/pkg/tbtc/frost_native_signer_anchor_admission.go b/pkg/tbtc/frost_native_signer_anchor_admission.go index 38571bc6a7..ff695745aa 100644 --- a/pkg/tbtc/frost_native_signer_anchor_admission.go +++ b/pkg/tbtc/frost_native_signer_anchor_admission.go @@ -8,10 +8,13 @@ import ( const ( // One request-taking interactive signer call can durably advance the Rust - // generation up to three times: repair/retire an InteractiveState pending - // snapshot (two writes in the sweep prologue), then persist the operation's - // own mutation. The process output barrier still advances the remote anchor - // revision only once for the call's final checkpoint. + // generation up to three times: reconcile one previously prepared witness + // snapshot, persist the sweep/repair snapshot (the expiry sweep and any + // protected retirement share it), then persist the endpoint's own + // mutation. Matching pending operations are covered by the sweep; + // nonmatching operations remain pending without another write. The + // process output barrier still advances the remote anchor revision only + // once for the call's final checkpoint. frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall uint64 = 3 ) @@ -304,9 +307,10 @@ func (reservation *frostNativeSignerAnchorRevisionReservation) Release() { // // Each of those calls can advance one service revision when its sweep prologue // mutates otherwise-unrelated state. Each interactive call can advance up to -// three Rust generations (two sweep/repair writes plus its own write), so the -// proof-window cost is three times the anchored-call count. Attempt derivation, -// package/evidence handling, and authorization guards do not persist Rust state. +// three Rust generations (prepared-witness reconciliation, one sweep/repair +// write, and its own write), so the proof-window cost is three times the +// anchored-call count. Attempt derivation, package/evidence handling, and +// authorization guards do not persist Rust state. func frostPreSignMaximumAnchorCapacityCost( inputCount uint64, localSeatCount uint64, diff --git a/pkg/tbtc/frost_native_signer_anchor_trust_startup.go b/pkg/tbtc/frost_native_signer_anchor_trust_startup.go index 57863e2915..072bafdb25 100644 --- a/pkg/tbtc/frost_native_signer_anchor_trust_startup.go +++ b/pkg/tbtc/frost_native_signer_anchor_trust_startup.go @@ -1,11 +1,262 @@ package tbtc import ( + "context" + "errors" "fmt" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" ) +type frostNativeSignerAnchorAuthenticatedRecoveryArtifact struct { + certificates []FrostNativeSignerAnchorTrustCertificate + trustFloor *frostNativeSignerAnchorVerifiedTrustFloor +} + +type frostNativeSignerAnchorTrustTransitionTargetReader func( + context.Context, + bool, +) (*FrostNativeSignerAnchorTrustTransitionTarget, error) + +type frostNativeSignerAnchorTrustTransitionInvoker func( + []byte, +) (*frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, error) + +// authenticateFrostNativeSignerAnchorTrustRecoveryArtifact establishes the +// authority recovery metadata deliberately lacks. Only a complete sequence-one +// artifact, independently authenticated through the pinned final head, can be +// used to select and replay a crash-recovery suffix. +func authenticateFrostNativeSignerAnchorTrustRecoveryArtifact( + configured []FrostNativeSignerAnchorTrustCertificate, + options FrostNativeSignerAnchorTrustChainValidationOptions, +) (*frostNativeSignerAnchorAuthenticatedRecoveryArtifact, error) { + if len(configured) == 0 || + configured[0].CertificateSequence != 1 || + configured[0].PreviousCertificateDigest != [32]byte{} { + return nil, fmt.Errorf( + "native signer anchor recovery requires a complete sequence-one certificate artifact", + ) + } + options.PriorHead = nil + trustFloor, err := + authenticateFrostNativeSignerAnchorTrustCertificateChain( + configured, + options, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot independently authenticate the complete native signer anchor recovery artifact: %w", + err, + ) + } + certificates := make( + []FrostNativeSignerAnchorTrustCertificate, + len(configured), + ) + for index := range configured { + certificates[index] = + frostNativeSignerAnchorTrustCloneCertificate(&configured[index]) + } + return &frostNativeSignerAnchorAuthenticatedRecoveryArtifact{ + certificates: certificates, + trustFloor: trustFloor, + }, nil +} + +// selectFrostNativeSignerAnchorTrustRecoveryChain treats Rust's durable intent +// metadata strictly as a selector. It must identify one exact contiguous suffix +// of the independently authenticated artifact, end at the independently pinned +// final certificate, and describe that final certificate's embedded target. +func selectFrostNativeSignerAnchorTrustRecoveryChain( + artifact *frostNativeSignerAnchorAuthenticatedRecoveryArtifact, + recovery *frostsigning.NativeTBTCSignerStateAnchorTrustRecoveryRequired, +) ([]FrostNativeSignerAnchorTrustCertificate, error) { + if artifact == nil || artifact.trustFloor == nil || + len(artifact.certificates) == 0 || recovery == nil || + recovery.CertificateCount == 0 || + uint64(len(recovery.OrderedCertificateDigests)) != + recovery.CertificateCount { + return nil, fmt.Errorf( + "native signer anchor trust-recovery selector inputs are incomplete", + ) + } + configured := artifact.certificates + final := &configured[len(configured)-1] + if recovery.StoreFingerprint != final.SignerStoreFingerprint || + recovery.FinalCertificateSequence != final.CertificateSequence || + recovery.FinalCertificateDigest != final.CertificateDigest || + recovery.TargetBindingHash != final.To.BindingHash || + recovery.TargetServiceEpoch != final.To.Reference.ServiceEpoch || + recovery.TargetRevision != final.To.Reference.Revision || + frostNativeSignerAnchorTrustCheckpointFromNative( + recovery.TargetCheckpoint, + ) != final.To.Reference.Checkpoint { + return nil, fmt.Errorf( + "native signer anchor trust-recovery selector differs from the independently authenticated final certificate", + ) + } + if recovery.CertificateCount > uint64(len(configured)) { + return nil, fmt.Errorf( + "native signer anchor trust-recovery selector exceeds the configured artifact", + ) + } + + match := -1 + for start := range configured { + if uint64(len(configured)-start) < recovery.CertificateCount { + break + } + matches := true + for offset := uint64(0); offset < recovery.CertificateCount; offset++ { + certificate := &configured[start+int(offset)] + if certificate.CertificateSequence != + recovery.FirstCertificateSequence+offset || + certificate.CertificateDigest != + recovery.OrderedCertificateDigests[offset] { + matches = false + break + } + } + if matches { + if match >= 0 { + return nil, fmt.Errorf( + "native signer anchor trust-recovery selector matches the configured artifact more than once", + ) + } + match = start + } + } + if match < 0 || + match+int(recovery.CertificateCount) != len(configured) { + return nil, fmt.Errorf( + "native signer anchor trust-recovery selector does not identify an exact configured suffix", + ) + } + + result := make( + []FrostNativeSignerAnchorTrustCertificate, + recovery.CertificateCount, + ) + for index := range result { + result[index] = frostNativeSignerAnchorTrustCloneCertificate( + &configured[match+index], + ) + } + return result, nil +} + +// executeFrostNativeSignerAnchorTrustTransition obtains a fresh signed Read for +// every transition attempt. A typed recovery failure is retried at most once, +// using only an exact suffix selected from the independently authenticated +// artifact; restored local intent bytes never supply authority or a target. +func executeFrostNativeSignerAnchorTrustTransition( + ctx context.Context, + artifact *frostNativeSignerAnchorAuthenticatedRecoveryArtifact, + initialChain []FrostNativeSignerAnchorTrustCertificate, + initialRecovery *frostsigning.NativeTBTCSignerStateAnchorTrustRecoveryRequired, + readTarget frostNativeSignerAnchorTrustTransitionTargetReader, + invoke frostNativeSignerAnchorTrustTransitionInvoker, +) ( + *frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, + *FrostNativeSignerAnchorTrustTransitionTarget, + []FrostNativeSignerAnchorTrustCertificate, + bool, + error, +) { + if ctx == nil || readTarget == nil || invoke == nil { + return nil, nil, nil, false, fmt.Errorf( + "native signer anchor trust-transition executor dependencies are incomplete", + ) + } + + chain := initialChain + recoveryReplay := initialRecovery != nil + if initialRecovery != nil { + var err error + chain, err = selectFrostNativeSignerAnchorTrustRecoveryChain( + artifact, + initialRecovery, + ) + if err != nil { + return nil, nil, nil, false, err + } + } + if len(chain) == 0 { + return nil, nil, nil, false, fmt.Errorf( + "native signer anchor trust-transition certificate chain is empty", + ) + } + + execute := func( + chain []FrostNativeSignerAnchorTrustCertificate, + ) ( + *frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, + *FrostNativeSignerAnchorTrustTransitionTarget, + error, + ) { + target, err := readTarget(ctx, false) + if err != nil { + return nil, nil, fmt.Errorf( + "cannot obtain a fresh native signer anchor trust-transition target: %w", + err, + ) + } + final := &chain[len(chain)-1] + if target == nil || + len(target.ExactReadResponse) == 0 || + target.Reference != final.To.Reference { + return nil, nil, fmt.Errorf( + "fresh native signer anchor trust-transition target differs from the selected certificate", + ) + } + request, err := EncodeFrostNativeSignerAnchorTrustTransitionRequest( + &FrostNativeSignerAnchorTrustTransitionRequest{ + CertificateChain: chain, + TargetReadResponse: target.ExactReadResponse, + }, + ) + if err != nil { + return nil, nil, fmt.Errorf( + "cannot encode native signer anchor trust transition: %w", + err, + ) + } + result, err := invoke(request) + return result, target, err + } + + result, target, err := execute(chain) + if err == nil { + return result, target, chain, recoveryReplay, nil + } + if initialRecovery != nil { + return nil, nil, nil, false, fmt.Errorf( + "native signer anchor trust recovery retry failed: %w", + err, + ) + } + var recoveryError *frostsigning.NativeTBTCSignerStateAnchorTrustRecoveryRequiredError + if !errors.As(err, &recoveryError) { + return nil, nil, nil, false, err + } + recoveryChain, selectErr := + selectFrostNativeSignerAnchorTrustRecoveryChain( + artifact, + &recoveryError.Recovery, + ) + if selectErr != nil { + return nil, nil, nil, false, selectErr + } + result, target, err = execute(recoveryChain) + if err != nil { + return nil, nil, nil, false, fmt.Errorf( + "native signer anchor trust recovery retry failed: %w", + err, + ) + } + return result, target, recoveryChain, true, nil +} + // validateFrostNativeSignerAnchorTrustExpectedHead derives the two exact head // representations consumed during startup. It does so only after proving that // the final certificate endpoint agrees with the independently authenticated @@ -336,6 +587,7 @@ func validateFrostNativeSignerAnchorTrustTransitionResult( expectedNativeHead *frostsigning.NativeTBTCSignerStateAnchorTrustHead, priorHead *FrostNativeSignerAnchorTrustCertificateHead, appliedChain []FrostNativeSignerAnchorTrustCertificate, + recoveryReplay bool, ) error { if result == nil || target == nil || finalCertificate == nil || expectedProtocolHead == nil || expectedNativeHead == nil || @@ -385,6 +637,11 @@ func validateFrostNativeSignerAnchorTrustTransitionResult( } if exactReplay { + if recoveryReplay { + return fmt.Errorf( + "native signer anchor trust-transition result is ambiguously both an exact-head and recovery replay", + ) + } head := &appliedChain[len(appliedChain)-1] if len(appliedChain) != 1 || head.CertificateSequence != @@ -412,6 +669,20 @@ func validateFrostNativeSignerAnchorTrustTransitionResult( } return nil } + if recoveryReplay { + if !result.Idempotent || + result.AppliedCertificateCount != 0 { + return fmt.Errorf( + "native signer anchor recovered transition is not an exact idempotent replay", + ) + } + if witnessBase != finalCertificate.To.Reference.Checkpoint { + return fmt.Errorf( + "native signer anchor recovered transition witness base differs from the recovered certified floor", + ) + } + return nil + } if result.Idempotent || result.AppliedCertificateCount != uint64(len(appliedChain)) { return fmt.Errorf( diff --git a/pkg/tbtc/frost_native_signer_anchor_trust_startup_test.go b/pkg/tbtc/frost_native_signer_anchor_trust_startup_test.go index e4731dd3b6..ce8b2f5a22 100644 --- a/pkg/tbtc/frost_native_signer_anchor_trust_startup_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_trust_startup_test.go @@ -2,6 +2,7 @@ package tbtc import ( "bytes" + "context" "crypto/ed25519" "strings" "testing" @@ -506,6 +507,345 @@ func TestSelectFrostNativeSignerAnchorTrustTransitionChainRejectsAmbiguity( } } +func TestSelectFrostNativeSignerAnchorTrustRecoveryChainRequiresExactFinalSuffix( + t *testing.T, +) { + bootstrap, authority := trustTestBootstrapCertificate(t) + second := trustTestRotationCertificate(t, bootstrap, authority, 0x91) + final := trustTestRotationCertificate(t, second, authority, 0x92) + configured := []FrostNativeSignerAnchorTrustCertificate{ + *bootstrap, + *second, + *final, + } + artifact, err := + authenticateFrostNativeSignerAnchorTrustRecoveryArtifact( + configured, + trustTestChainOptions(final), + ) + if err != nil { + t.Fatal(err) + } + recovery := testFrostNativeSignerAnchorTrustRecoverySelector( + configured, + 1, + ) + selected, err := selectFrostNativeSignerAnchorTrustRecoveryChain( + artifact, + &recovery, + ) + if err != nil { + t.Fatal(err) + } + if len(selected) != 2 || + selected[0].CertificateDigest != second.CertificateDigest || + selected[1].CertificateDigest != final.CertificateDigest { + t.Fatal("recovery selector did not select the exact final suffix") + } + + for name, mutate := range map[string]func( + *frostsigning.NativeTBTCSignerStateAnchorTrustRecoveryRequired, + ){ + "store": func( + value *frostsigning.NativeTBTCSignerStateAnchorTrustRecoveryRequired, + ) { + value.StoreFingerprint[0] ^= 1 + }, + "ordered digest": func( + value *frostsigning.NativeTBTCSignerStateAnchorTrustRecoveryRequired, + ) { + value.OrderedCertificateDigests[0][0] ^= 1 + }, + "final digest": func( + value *frostsigning.NativeTBTCSignerStateAnchorTrustRecoveryRequired, + ) { + value.FinalCertificateDigest[0] ^= 1 + }, + "target binding": func( + value *frostsigning.NativeTBTCSignerStateAnchorTrustRecoveryRequired, + ) { + value.TargetBindingHash[0] ^= 1 + }, + "target checkpoint": func( + value *frostsigning.NativeTBTCSignerStateAnchorTrustRecoveryRequired, + ) { + value.TargetCheckpoint.Generation++ + }, + "stale prior final": func( + value *frostsigning.NativeTBTCSignerStateAnchorTrustRecoveryRequired, + ) { + value.CertificateCount = 1 + value.FirstCertificateSequence = second.CertificateSequence + value.OrderedCertificateDigests = [][32]byte{ + second.CertificateDigest, + } + value.FinalCertificateSequence = second.CertificateSequence + value.FinalCertificateDigest = second.CertificateDigest + value.TargetBindingHash = second.To.BindingHash + value.TargetServiceEpoch = second.To.Reference.ServiceEpoch + value.TargetRevision = second.To.Reference.Revision + value.TargetCheckpoint = + frostNativeSignerAnchorNativeTrustCheckpoint( + second.To.Reference.Checkpoint, + ) + }, + } { + t.Run(name, func(t *testing.T) { + candidate := testFrostNativeSignerAnchorTrustRecoverySelector( + configured, + 1, + ) + mutate(&candidate) + if _, err := + selectFrostNativeSignerAnchorTrustRecoveryChain( + artifact, + &candidate, + ); err == nil { + t.Fatal("tampered or stale recovery selector was accepted") + } + }) + } +} + +func TestExecuteFrostNativeSignerAnchorTrustTransitionRecoversMultiCertificateIntent( + t *testing.T, +) { + bootstrap, authority := trustTestBootstrapCertificate(t) + second := trustTestRotationCertificate(t, bootstrap, authority, 0x93) + final := trustTestRotationCertificate(t, second, authority, 0x94) + configured := []FrostNativeSignerAnchorTrustCertificate{ + *bootstrap, + *second, + *final, + } + artifact, err := + authenticateFrostNativeSignerAnchorTrustRecoveryArtifact( + configured, + trustTestChainOptions(final), + ) + if err != nil { + t.Fatal(err) + } + recovery := testFrostNativeSignerAnchorTrustRecoverySelector( + configured, + 0, + ) + fresh := []byte(`{"fresh":"multi-certificate-recovery"}`) + readCalls := 0 + invokeCalls := 0 + expectedResult := + &frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult{ + Installed: true, + } + result, target, applied, recoveryReplay, err := + executeFrostNativeSignerAnchorTrustTransition( + context.Background(), + artifact, + nil, + &recovery, + func( + context.Context, + bool, + ) (*FrostNativeSignerAnchorTrustTransitionTarget, error) { + readCalls++ + return &FrostNativeSignerAnchorTrustTransitionTarget{ + Reference: final.To.Reference, + ExactReadResponse: fresh, + }, nil + }, + func( + request []byte, + ) (*frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, error) { + invokeCalls++ + decoded, err := + DecodeFrostNativeSignerAnchorTrustTransitionRequest( + request, + ) + if err != nil { + t.Fatal(err) + } + if len(decoded.CertificateChain) != 3 || + !bytes.Equal(decoded.TargetReadResponse, fresh) { + t.Fatal("recovery did not replay the exact intent chain with a fresh Read") + } + return expectedResult, nil + }, + ) + if err != nil { + t.Fatal(err) + } + if result != expectedResult || target == nil || + !bytes.Equal(target.ExactReadResponse, fresh) || + len(applied) != 3 || !recoveryReplay || + readCalls != 1 || invokeCalls != 1 { + t.Fatalf( + "unexpected multi-certificate recovery result [applied %d reads %d invokes %d]", + len(applied), + readCalls, + invokeCalls, + ) + } +} + +func TestExecuteFrostNativeSignerAnchorTrustTransitionRetriesWithFreshRead( + t *testing.T, +) { + bootstrap, authority := trustTestBootstrapCertificate(t) + second := trustTestRotationCertificate(t, bootstrap, authority, 0x95) + final := trustTestRotationCertificate(t, second, authority, 0x96) + configured := []FrostNativeSignerAnchorTrustCertificate{ + *bootstrap, + *second, + *final, + } + artifact, err := + authenticateFrostNativeSignerAnchorTrustRecoveryArtifact( + configured, + trustTestChainOptions(final), + ) + if err != nil { + t.Fatal(err) + } + recovery := testFrostNativeSignerAnchorTrustRecoverySelector( + configured, + 0, + ) + freshReads := [][]byte{ + []byte(`{"fresh":"before-recovery-signal"}`), + []byte(`{"fresh":"after-recovery-signal"}`), + } + readCalls := 0 + invokeCalls := 0 + expectedResult := + &frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult{ + Installed: true, + } + result, target, applied, recoveryReplay, err := + executeFrostNativeSignerAnchorTrustTransition( + context.Background(), + artifact, + []FrostNativeSignerAnchorTrustCertificate{*final}, + nil, + func( + context.Context, + bool, + ) (*FrostNativeSignerAnchorTrustTransitionTarget, error) { + read := freshReads[readCalls] + readCalls++ + return &FrostNativeSignerAnchorTrustTransitionTarget{ + Reference: final.To.Reference, + ExactReadResponse: read, + }, nil + }, + func( + request []byte, + ) (*frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, error) { + decoded, err := + DecodeFrostNativeSignerAnchorTrustTransitionRequest( + request, + ) + if err != nil { + t.Fatal(err) + } + invokeCalls++ + if invokeCalls == 1 { + if len(decoded.CertificateChain) != 1 || + !bytes.Equal( + decoded.TargetReadResponse, + freshReads[0], + ) { + t.Fatal("initial transition request is unexpected") + } + return nil, + &frostsigning.NativeTBTCSignerStateAnchorTrustRecoveryRequiredError{ + Recovery: recovery, + } + } + if len(decoded.CertificateChain) != 3 || + !bytes.Equal( + decoded.TargetReadResponse, + freshReads[1], + ) { + t.Fatal("recovery retry reused stale Read or wrong chain") + } + return expectedResult, nil + }, + ) + if err != nil { + t.Fatal(err) + } + if result != expectedResult || target == nil || + !bytes.Equal(target.ExactReadResponse, freshReads[1]) || + len(applied) != 3 || !recoveryReplay || + readCalls != 2 || invokeCalls != 2 { + t.Fatalf( + "unexpected recovery retry [applied %d reads %d invokes %d]", + len(applied), + readCalls, + invokeCalls, + ) + } +} + +func TestExecuteFrostNativeSignerAnchorTrustTransitionRejectsStaleRecoveryTarget( + t *testing.T, +) { + bootstrap, authority := trustTestBootstrapCertificate(t) + final := trustTestRotationCertificate(t, bootstrap, authority, 0x97) + configured := []FrostNativeSignerAnchorTrustCertificate{ + *bootstrap, + *final, + } + artifact, err := + authenticateFrostNativeSignerAnchorTrustRecoveryArtifact( + configured, + trustTestChainOptions(final), + ) + if err != nil { + t.Fatal(err) + } + recovery := testFrostNativeSignerAnchorTrustRecoverySelector( + configured, + 0, + ) + staleReference := final.To.Reference + staleReference.Revision++ + staleReference.PreviousEventRoot = staleReference.EventRoot + staleReference.EventRoot[0] ^= 1 + readCalls := 0 + invokeCalls := 0 + _, _, _, _, err = executeFrostNativeSignerAnchorTrustTransition( + context.Background(), + artifact, + nil, + &recovery, + func( + context.Context, + bool, + ) (*FrostNativeSignerAnchorTrustTransitionTarget, error) { + readCalls++ + return &FrostNativeSignerAnchorTrustTransitionTarget{ + Reference: staleReference, + ExactReadResponse: []byte(`{"fresh":"but-target-is-stale"}`), + }, nil + }, + func( + []byte, + ) (*frostsigning.NativeTBTCSignerStateAnchorTrustTransitionResult, error) { + invokeCalls++ + return nil, nil + }, + ) + if err == nil || readCalls != 1 || invokeCalls != 0 { + t.Fatalf( + "stale restored target was not rejected before mutation [err %v reads %d invokes %d]", + err, + readCalls, + invokeCalls, + ) + } +} + func TestValidateFrostNativeSignerAnchorTrustTransitionResult(t *testing.T) { fixture := newFrostNativeSignerAnchorTrustStartupFixture() protocolHead, nativeHead, err := @@ -539,6 +879,7 @@ func TestValidateFrostNativeSignerAnchorTrustTransitionResult(t *testing.T) { nativeHead, nil, chain, + false, ); err != nil { t.Fatalf("valid trust transition result was rejected: %v", err) } @@ -594,6 +935,7 @@ func TestValidateFrostNativeSignerAnchorTrustTransitionResult(t *testing.T) { nativeHead, nil, chain, + false, ); err == nil { t.Fatal("invalid trust-transition result was accepted") } @@ -623,6 +965,7 @@ func TestValidateFrostNativeSignerAnchorTrustTransitionResult(t *testing.T) { nativeHead, nil, chain, + false, ); err == nil { t.Fatal( "new transition accepted a descendant instead of the exact certified target", @@ -636,19 +979,19 @@ func TestValidateFrostNativeSignerAnchorReconciledTransitionTargetScopesExactHea fixture := newFrostNativeSignerAnchorTrustStartupFixture() reference := fixture.certificate.To.Reference tip := &frostsigning.NativeTBTCSignerStateWitnessTip{ - Schema: frostsigning.NativeTBTCSignerStateWitnessTipSchema, - StoreFingerprint: reference.Checkpoint.StoreFingerprint, - Generation: reference.Checkpoint.Generation, - PreviousStateCommitment: reference.Checkpoint.PreviousStateCommitment, - StateImageDigest: reference.Checkpoint.StateImageDigest, - StateCommitment: reference.Checkpoint.StateCommitment, - WitnessBaseGeneration: reference.Checkpoint.Generation, - WitnessBaseCommitment: reference.Checkpoint.StateCommitment, - AnchorBindingHash: fixture.certificate.To.BindingHash, - AnchorServiceEpoch: reference.ServiceEpoch, - AnchorRevision: reference.Revision, - AnchorEventRoot: reference.EventRoot, - AnchorAcknowledgementDigest: reference.AcknowledgementDigest, + Schema: frostsigning.NativeTBTCSignerStateWitnessTipSchema, + StoreFingerprint: reference.Checkpoint.StoreFingerprint, + Generation: reference.Checkpoint.Generation, + PreviousStateCommitment: reference.Checkpoint.PreviousStateCommitment, + StateImageDigest: reference.Checkpoint.StateImageDigest, + StateCommitment: reference.Checkpoint.StateCommitment, + WitnessBaseGeneration: reference.Checkpoint.Generation, + WitnessBaseCommitment: reference.Checkpoint.StateCommitment, + AnchorBindingHash: fixture.certificate.To.BindingHash, + AnchorServiceEpoch: reference.ServiceEpoch, + AnchorRevision: reference.Revision, + AnchorEventRoot: reference.EventRoot, + AnchorAcknowledgementDigest: reference.AcknowledgementDigest, } target := &FrostNativeSignerAnchorTrustTransitionTarget{ ExactReadResponse: []byte(`{"fresh":"transition"}`), @@ -737,6 +1080,7 @@ func TestValidateFrostNativeSignerAnchorTrustTransitionResultExactReplay( nativeHead, protocolHead, chain, + false, ); err != nil { t.Fatalf("valid completed-restart result was rejected: %v", err) } @@ -751,11 +1095,153 @@ func TestValidateFrostNativeSignerAnchorTrustTransitionResultExactReplay( nativeHead, protocolHead, chain, + false, ); err == nil { t.Fatal("equal-generation forked replay witness base was accepted") } } +func TestValidateFrostNativeSignerAnchorTrustTransitionResultRecoveryReplay( + t *testing.T, +) { + fixture := newFrostNativeSignerAnchorTrustStartupFixture() + protocolHead, nativeHead, err := + validateFrostNativeSignerAnchorTrustExpectedHead( + fixture.runtime, + &fixture.installed, + &fixture.certificate, + ) + if err != nil { + t.Fatal(err) + } + target := &FrostNativeSignerAnchorTrustTransitionTarget{ + ExactReadResponse: []byte(`{"fresh":"recovery"}`), + Reference: fixture.certificate.To.Reference, + } + chain := []FrostNativeSignerAnchorTrustCertificate{ + fixture.certificate, + } + floorCheckpoint := fixture.certificate.To.Reference.Checkpoint + + recovered := frostNativeSignerAnchorTrustStartupResult( + *nativeHead, + target.Reference, + floorCheckpoint, + true, + 0, + ) + if err := validateFrostNativeSignerAnchorTrustTransitionResult( + &recovered, + target, + &fixture.certificate, + protocolHead, + nativeHead, + nil, + chain, + true, + ); err != nil { + t.Fatalf("valid recovered replay at the certified floor was rejected: %v", err) + } + + // The identical engine result without the recovery marker must fail: a + // fresh transition may never report an idempotent zero-applied outcome. + if err := validateFrostNativeSignerAnchorTrustTransitionResult( + &recovered, + target, + &fixture.certificate, + protocolHead, + nativeHead, + nil, + chain, + false, + ); err == nil { + t.Fatal("idempotent zero-applied result was accepted as a fresh transition") + } + + partiallyApplied := frostNativeSignerAnchorTrustStartupResult( + *nativeHead, + target.Reference, + floorCheckpoint, + true, + 1, + ) + if err := validateFrostNativeSignerAnchorTrustTransitionResult( + &partiallyApplied, + target, + &fixture.certificate, + protocolHead, + nativeHead, + nil, + chain, + true, + ); err == nil { + t.Fatal("recovery replay reporting fresh application was accepted") + } + + nonIdempotent := frostNativeSignerAnchorTrustStartupResult( + *nativeHead, + target.Reference, + floorCheckpoint, + false, + 0, + ) + if err := validateFrostNativeSignerAnchorTrustTransitionResult( + &nonIdempotent, + target, + &fixture.certificate, + protocolHead, + nativeHead, + nil, + chain, + true, + ); err == nil { + t.Fatal("non-idempotent recovery replay was accepted") + } + + divergedBase := floorCheckpoint + divergedBase.Generation++ + divergedBase.StateCommitment = + frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + divergedBase.StoreFingerprint, + divergedBase.Generation, + divergedBase.PreviousStateCommitment, + divergedBase.StateImageDigest, + ) + diverged := frostNativeSignerAnchorTrustStartupResult( + *nativeHead, + target.Reference, + divergedBase, + true, + 0, + ) + if err := validateFrostNativeSignerAnchorTrustTransitionResult( + &diverged, + target, + &fixture.certificate, + protocolHead, + nativeHead, + nil, + chain, + true, + ); err == nil { + t.Fatal("recovery replay witness base off the certified floor was accepted") + } + + // A result cannot be both an exact-head replay and a recovery replay. + if err := validateFrostNativeSignerAnchorTrustTransitionResult( + &recovered, + target, + &fixture.certificate, + protocolHead, + nativeHead, + protocolHead, + chain, + true, + ); err == nil { + t.Fatal("ambiguous exact-head and recovery replay was accepted") + } +} + func frostNativeSignerAnchorTrustStartupResult( head frostsigning.NativeTBTCSignerStateAnchorTrustHead, current FrostNativeSignerAnchorTrustReference, @@ -782,6 +1268,33 @@ func frostNativeSignerAnchorTrustStartupResult( } } +func testFrostNativeSignerAnchorTrustRecoverySelector( + configured []FrostNativeSignerAnchorTrustCertificate, + start int, +) frostsigning.NativeTBTCSignerStateAnchorTrustRecoveryRequired { + final := configured[len(configured)-1] + digests := make([][32]byte, len(configured)-start) + for index := range digests { + digests[index] = configured[start+index].CertificateDigest + } + return frostsigning.NativeTBTCSignerStateAnchorTrustRecoveryRequired{ + Schema: frostsigning. + NativeTBTCSignerStateAnchorTrustRecoveryRequiredSchema, + StoreFingerprint: final.SignerStoreFingerprint, + CertificateCount: uint64(len(digests)), + FirstCertificateSequence: configured[start].CertificateSequence, + OrderedCertificateDigests: digests, + FinalCertificateSequence: final.CertificateSequence, + FinalCertificateDigest: final.CertificateDigest, + TargetBindingHash: final.To.BindingHash, + TargetServiceEpoch: final.To.Reference.ServiceEpoch, + TargetRevision: final.To.Reference.Revision, + TargetCheckpoint: frostNativeSignerAnchorNativeTrustCheckpoint( + final.To.Reference.Checkpoint, + ), + } +} + func newFrostNativeSignerAnchorTrustStartupFixture() ( fixture frostNativeSignerAnchorTrustStartupFixture, ) { diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index fc3993a8a5..4baf6350f2 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -418,6 +418,7 @@ func newNode( } var priorTrustCertificateHead *FrostNativeSignerAnchorTrustCertificateHead + var trustRecoverySelector *signing.NativeTBTCSignerStateAnchorTrustRecoveryRequired priorTrustHead, priorTrustHeadErr := signing.ReadNativeTBTCSignerStateAnchorTrustHead() transitionCertificateChain := trustCertificateChain @@ -448,22 +449,36 @@ func newNode( err, ) } - } else if !errors.Is( - priorTrustHeadErr, - signing.ErrNativeTBTCSignerStateAnchorTrustHeadAbsent, - ) { - _ = outbox.close() - return nil, fmt.Errorf( - "cannot read the prior FROST native signer anchor trust head: [%w]", - priorTrustHeadErr, - ) - } else if transitionCertificateChain[0].CertificateSequence != 1 || - transitionCertificateChain[0].PreviousCertificateDigest != [32]byte{} { - _ = outbox.close() - return nil, fmt.Errorf( - "cannot read the prior FROST native signer anchor trust head for a certificate suffix: [%w]", + } else { + var recoveryError *signing.NativeTBTCSignerStateAnchorTrustRecoveryRequiredError + switch { + case errors.As(priorTrustHeadErr, &recoveryError): + recovery := recoveryError.Recovery + recovery.OrderedCertificateDigests = append( + [][32]byte{}, + recovery.OrderedCertificateDigests..., + ) + trustRecoverySelector = &recovery + case errors.Is( priorTrustHeadErr, - ) + signing.ErrNativeTBTCSignerStateAnchorTrustHeadAbsent, + ): + if transitionCertificateChain[0].CertificateSequence != 1 || + transitionCertificateChain[0].PreviousCertificateDigest != + [32]byte{} { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot read the prior FROST native signer anchor trust head for a certificate suffix: [%w]", + priorTrustHeadErr, + ) + } + default: + _ = outbox.close() + return nil, fmt.Errorf( + "cannot read the prior FROST native signer anchor trust head: [%w]", + priorTrustHeadErr, + ) + } } trustChainOptions := FrostNativeSignerAnchorTrustChainValidationOptions{ // A sequence-one rotation is an explicit, offline-authorized @@ -503,6 +518,18 @@ func newNode( err, ) } + recoveryArtifact, err := + authenticateFrostNativeSignerAnchorTrustRecoveryArtifact( + trustCertificateChain, + trustChainOptions, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot authenticate the complete FROST native signer anchor recovery artifact: [%w]", + err, + ) + } anchorClient, err := newFrostNativeSignerAnchorClientWithTrustFloor( FrostNativeSignerAnchorClientConfig{ @@ -529,37 +556,21 @@ func newNode( ) var trustTransitionTarget *FrostNativeSignerAnchorTrustTransitionTarget if !exactTrustHeadReplay { - trustTransitionTarget, err = - anchorClient.readFrostNativeSignerAnchorTrustTransitionTarget( - context.Background(), - false, - ) - if err != nil { - _ = outbox.close() - return nil, fmt.Errorf( - "cannot obtain the fresh FROST native signer anchor trust-transition target: [%w]", - err, - ) - } - trustTransitionRequest, err := - EncodeFrostNativeSignerAnchorTrustTransitionRequest( - &FrostNativeSignerAnchorTrustTransitionRequest{ - CertificateChain: transitionCertificateChain, - TargetReadResponse: trustTransitionTarget. - ExactReadResponse, - }, - ) - if err != nil { - _ = outbox.close() - return nil, fmt.Errorf( - "cannot encode FROST native signer anchor trust transition: [%w]", - err, - ) - } - trustTransitionResult, err := - signing.TransitionNativeTBTCSignerStateWitnessAnchor( - trustTransitionRequest, - ) + var trustTransitionResult *signing.NativeTBTCSignerStateAnchorTrustTransitionResult + var trustTransitionRecoveryReplay bool + trustTransitionResult, + trustTransitionTarget, + transitionCertificateChain, + trustTransitionRecoveryReplay, + err = executeFrostNativeSignerAnchorTrustTransition( + context.Background(), + recoveryArtifact, + transitionCertificateChain, + trustRecoverySelector, + anchorClient. + readFrostNativeSignerAnchorTrustTransitionTarget, + signing.TransitionNativeTBTCSignerStateWitnessAnchor, + ) if err != nil { _ = outbox.close() return nil, fmt.Errorf( @@ -575,6 +586,7 @@ func newNode( expectedTrustHead, priorTrustCertificateHead, transitionCertificateChain, + trustTransitionRecoveryReplay, ); err != nil { _ = outbox.close() return nil, fmt.Errorf( From 7f5ff27c0f4aea5730dcb78757a1775f85e8d12c Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 26 Jul 2026 19:26:04 -0400 Subject: [PATCH 14/93] Keep legacy wallets off interactive aggregate memo ownership Interactive aggregate memoization exists only on the FROST/ROAST path. A legacy ECDSA wallet folds an empty key group into its stable ROAST session ID, so in frost-native builds two wallets signing the same message at the same start block would contend for one memo session and the second would fail signing outright. Gate memo ownership on the executor's Schnorr material; Release stays nil-safe on the legacy path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DpftF6aYHh56KgiHU6D2NQ --- pkg/tbtc/signing.go | 33 ++++++++---- .../signing_legacy_memo_frost_native_test.go | 52 +++++++++++++++++++ 2 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 pkg/tbtc/signing_legacy_memo_frost_native_test.go diff --git a/pkg/tbtc/signing.go b/pkg/tbtc/signing.go index 2a20b9b605..080d0b42f8 100644 --- a/pkg/tbtc/signing.go +++ b/pkg/tbtc/signing.go @@ -802,19 +802,30 @@ func (se *signingExecutor) signWithTaprootMerkleRootForSessionIntentAndAuthoriza authorizationID, ) } - aggregateMemoSession, err := - signing.BeginInteractiveAggregateMemoSession(roastSID) - if err != nil { - if se.metricsRecorder != nil { - se.metricsRecorder.IncrementCounter( - clientinfo.MetricSigningFailedTotal, - 1, + // Interactive aggregate memoization exists only on the FROST/ROAST path. + // A legacy ECDSA wallet never aggregates interactively, so it must not + // claim memo ownership it will never use: in frost-native builds the + // ownership registry rejects duplicate live session IDs, and a legacy + // wallet's roastSID folds an empty key-group, so two wallets signing the + // same message at the same start block would collide and fail signing. + // Release is nil-safe on the legacy path. + var aggregateMemoSession *signing.InteractiveAggregateMemoSession + if se.usesSchnorrSignatures() { + session, err := + signing.BeginInteractiveAggregateMemoSession(roastSID) + if err != nil { + if se.metricsRecorder != nil { + se.metricsRecorder.IncrementCounter( + clientinfo.MetricSigningFailedTotal, + 1, + ) + } + return nil, nil, 0, fmt.Errorf( + "cannot bind interactive aggregate memo lifetime: [%w]", + err, ) } - return nil, nil, 0, fmt.Errorf( - "cannot bind interactive aggregate memo lifetime: [%w]", - err, - ) + aggregateMemoSession = session } defer aggregateMemoSession.Release() diff --git a/pkg/tbtc/signing_legacy_memo_frost_native_test.go b/pkg/tbtc/signing_legacy_memo_frost_native_test.go new file mode 100644 index 0000000000..f3dbf2d547 --- /dev/null +++ b/pkg/tbtc/signing_legacy_memo_frost_native_test.go @@ -0,0 +1,52 @@ +//go:build frost_native + +package tbtc + +import ( + "math/big" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/signing" +) + +// Two legacy wallets heartbeat-signing the same proposal at the same start +// block derive IDENTICAL stable ROAST session IDs: the wallet-disambiguating +// key-group fold is empty for legacy material. The interactive aggregate memo +// registry rejects a duplicate live owner, so if legacy wallets claimed memo +// ownership, the second wallet's signing would fail outright in frost-native +// builds. The executor therefore gates memo ownership on +// usesSchnorrSignatures(); this test pins both halves of that reasoning. +func TestLegacyHeartbeatWallets_DoNotContendForAggregateMemoOwnership( + t *testing.T, +) { + message := new(big.Int).SetBytes([]byte{0xff, 0xff, 0x01}) + firstWalletSID := roastSessionIDWithAuthorization(message, nil, 0, "", nil) + secondWalletSID := roastSessionIDWithAuthorization(message, nil, 0, "", nil) + if firstWalletSID != secondWalletSID { + t.Fatal("legacy wallets signing the same message at the same start " + + "block must derive one roast session ID for this scenario") + } + + // The registry refuses a duplicate live owner: this is exactly the + // failure two concurrently heartbeating legacy wallets would hit if + // they began memo ownership. + first, err := signing.BeginInteractiveAggregateMemoSession(firstWalletSID) + if err != nil { + t.Fatal(err) + } + defer first.Release() + if _, err := signing.BeginInteractiveAggregateMemoSession( + secondWalletSID, + ); err == nil { + t.Fatal("duplicate live memo ownership was accepted") + } + + // The executor's gate keeps legacy wallets away from that contention: + // scaffold-era tECDSA signing material must never read as Schnorr, so + // the signing executor never begins interactive aggregate memo + // ownership for it. + executor := setupSigningExecutor(t) + if executor.usesSchnorrSignatures() { + t.Fatal("legacy tECDSA executor unexpectedly reports Schnorr signatures") + } +} From f9de3e357f474ad5959b33d90a35f328b948f4b0 Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 26 Jul 2026 19:26:04 -0400 Subject: [PATCH 15/93] ci: pin frost signer mirror to the ABI 4.3 trust-transition head Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DpftF6aYHh56KgiHU6D2NQ --- ci/frost-signer-pin.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/frost-signer-pin.env b/ci/frost-signer-pin.env index 8d95bd5d06..eed3b36042 100644 --- a/ci/frost-signer-pin.env +++ b/ci/frost-signer-pin.env @@ -15,4 +15,4 @@ # # After the scaffold and mirror branches merge into one, replace the cross-branch # checkout with an in-tree cargo build and retire this pin (keep the gate). -FROST_SIGNER_MIRROR_REF=a5363e947239eb5871e4a810150d2bc0836ea7a3 +FROST_SIGNER_MIRROR_REF=b35637ed01693014b6b7ac94586541538eaaa446 From 643b2ab5a6b321bb6cad07a72312a42a9f54fce2 Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 26 Jul 2026 19:32:36 -0400 Subject: [PATCH 16/93] Add native signer anchor bootstrap provisioning ceremony Introduce the offline-authorized four-phase initial anchor ceremony for the FROST native tBTC signer: - pkg/frost/signing: strict bootstrap-facts codec pinned to the exact generation-one store genesis, a strict-JSON preflight (duplicate, case-folded-alias, depth, and trailing-data rejection) now fronting every native-signer decoder, the dedicated four-field bootstrap provisioning config installer, and the StateAnchorBootstrapFacts bridge operation with a fail-closed default build. - pkg/tbtc: plan/core/detached-signature/final/output-bundle artifacts with strict codecs and validation. Prepare re-derives every stream, binding, SPKI, store, and checkpoint relationship; Initialize verifies the detached core authorization and requires an exactly reconciled create-if-absent record; Finalize validates the second detached signature and emits one atomic bundle whose decoder re-validates the embedded certificate chain and canonical signer config, with Finalize round-tripping its own output through that decoder. The identity wire decoder names its single zero-allowed pin (loopback endpoint leaf) instead of relying on positional indexing. - pkg/tbtc: openat-based no-replace artifact reader/writer enforcing owner-only 0700/0600 posture, O_NOFOLLOW opens, fsync-before-publish, and an explicit short-write guard. - cmd: "tbtc-signer anchor bootstrap {facts,core,initialize,finalize}" accepting detached signatures only; initialize fails closed until a separately reviewed transport supplies its narrow client factory. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DpftF6aYHh56KgiHU6D2NQ --- cmd/cmd.go | 1 + cmd/tbtc_signer.go | 375 +++++ .../signing/native_tbtc_signer_inventory.go | 91 ++ .../native_tbtc_signer_inventory_cgo.go | 42 + .../native_tbtc_signer_inventory_default.go | 10 + .../native_tbtc_signer_secure_config.go | 84 ++ ...tive_tbtc_signer_state_anchor_bootstrap.go | 109 ++ ...frost_native_signer_anchor_provisioning.go | 1324 +++++++++++++++++ ...native_signer_anchor_provisioning_files.go | 306 ++++ 9 files changed, 2342 insertions(+) create mode 100644 cmd/tbtc_signer.go create mode 100644 pkg/frost/signing/native_tbtc_signer_state_anchor_bootstrap.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_provisioning.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_provisioning_files.go diff --git a/cmd/cmd.go b/cmd/cmd.go index 0dbe4a24ee..e16a86e663 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -37,6 +37,7 @@ func init() { EthereumCommand, MaintainerCommand, MaintainerCliCommand, + TBTCSignerCommand, ) } diff --git a/cmd/tbtc_signer.go b/cmd/tbtc_signer.go new file mode 100644 index 0000000000..b54d1797ec --- /dev/null +++ b/cmd/tbtc_signer.go @@ -0,0 +1,375 @@ +package cmd + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +const tbtcSignerAnchorBootstrapArtifactReadLimit = 16 * 1024 * 1024 + +type tbtcSignerAnchorBootstrapClientFactory func( + context.Context, + string, +) (tbtc.FrostNativeSignerAnchorBootstrapClient, error) + +// TBTCSignerCommand contains fail-closed signer administration tools. The +// bootstrap subtree is safe in all builds: facts fails when the native ABI is +// unavailable, and initialize fails until the separately reviewed bootstrap +// transport supplies its narrow client factory. +var TBTCSignerCommand = newTBTCSignerCommand(nil) + +func newTBTCSignerCommand( + clientFactory tbtcSignerAnchorBootstrapClientFactory, +) *cobra.Command { + signer := &cobra.Command{ + Use: "tbtc-signer", + Short: "Administers the native tBTC signer", + SilenceUsage: true, + } + anchor := &cobra.Command{ + Use: "anchor", + Short: "Administers the native signer state anchor", + } + bootstrap := &cobra.Command{ + Use: "bootstrap", + Short: "Runs the offline-authorized initial anchor ceremony", + Long: "Runs the four-phase initial anchor ceremony. The online " + + "commands accept detached signatures only and never accept or load " + + "the offline authority private key.", + } + bootstrap.AddCommand( + newTBTCSignerAnchorBootstrapFactsCommand(), + newTBTCSignerAnchorBootstrapCoreCommand(), + newTBTCSignerAnchorBootstrapInitializeCommand(clientFactory), + newTBTCSignerAnchorBootstrapFinalizeCommand(), + ) + anchor.AddCommand(bootstrap) + signer.AddCommand(anchor) + return signer +} + +func newTBTCSignerAnchorBootstrapFactsCommand() *cobra.Command { + var provisioningConfig string + var output string + command := &cobra.Command{ + Use: "facts", + Short: "Exports the pristine native store genesis", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + if _, err := + frostsigning.InstallNativeTBTCSignerStateAnchorBootstrapProvisioningConfigFile( + provisioningConfig, + ); err != nil { + return err + } + facts, err := + frostsigning.ReadNativeTBTCSignerStateAnchorBootstrapFacts() + if err != nil { + return err + } + encoded, err := + frostsigning.EncodeNativeTBTCSignerStateAnchorBootstrapFacts( + facts, + ) + if err != nil { + return err + } + return tbtc.WriteFrostNativeSignerAnchorProvisioningArtifact( + output, + encoded, + ) + }, + } + command.Flags().StringVar( + &provisioningConfig, + "provisioning-config", + "", + "canonical absolute path to the exact owner-only provisioning init config", + ) + command.Flags().StringVar( + &output, + "output", + "", + "canonical absolute no-replace output artifact path", + ) + _ = command.MarkFlagRequired("provisioning-config") + _ = command.MarkFlagRequired("output") + return command +} + +func newTBTCSignerAnchorBootstrapCoreCommand() *cobra.Command { + var factsPath string + var planPath string + var output string + command := &cobra.Command{ + Use: "core", + Short: "Builds the first offline signing request", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + factsJSON, err := readTBTCSignerAnchorBootstrapArtifact(factsPath) + if err != nil { + return err + } + facts, err := + frostsigning.DecodeNativeTBTCSignerStateAnchorBootstrapFacts( + factsJSON, + ) + if err != nil { + return err + } + planJSON, err := readTBTCSignerAnchorBootstrapArtifact(planPath) + if err != nil { + return err + } + plan, err := tbtc.DecodeFrostNativeSignerAnchorBootstrapPlan( + planJSON, + ) + if err != nil { + return err + } + core, err := tbtc.PrepareFrostNativeSignerAnchorBootstrapCore( + facts, + plan, + ) + if err != nil { + return err + } + encoded, err := + tbtc.EncodeFrostNativeSignerAnchorBootstrapCoreArtifact(core) + if err != nil { + return err + } + return tbtc.WriteFrostNativeSignerAnchorProvisioningArtifact( + output, + encoded, + ) + }, + } + command.Flags().StringVar( + &factsPath, + "facts", + "", + "canonical absolute path to the bootstrap facts artifact", + ) + command.Flags().StringVar( + &planPath, + "plan", + "", + "canonical absolute path to the authenticated public bootstrap plan", + ) + command.Flags().StringVar( + &output, + "output", + "", + "canonical absolute no-replace core signing-request path", + ) + _ = command.MarkFlagRequired("facts") + _ = command.MarkFlagRequired("plan") + _ = command.MarkFlagRequired("output") + return command +} + +func newTBTCSignerAnchorBootstrapInitializeCommand( + clientFactory tbtcSignerAnchorBootstrapClientFactory, +) *cobra.Command { + var corePath string + var signaturePath string + var clientConfigPath string + var output string + command := &cobra.Command{ + Use: "initialize", + Short: "Initializes and reconciles the remote anchor stream", + Long: "Submits the detached core authorization, then requires a fresh " + + "authenticated Read of the exact created stream. This online phase " + + "never accepts an offline authority private key.", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if clientFactory == nil { + return fmt.Errorf( + "native signer anchor bootstrap transport is not available in this build", + ) + } + if !filepath.IsAbs(clientConfigPath) || + filepath.Clean(clientConfigPath) != clientConfigPath { + return fmt.Errorf( + "bootstrap client config path is not canonical absolute", + ) + } + coreJSON, err := readTBTCSignerAnchorBootstrapArtifact(corePath) + if err != nil { + return err + } + core, err := + tbtc.DecodeFrostNativeSignerAnchorBootstrapCoreArtifact( + coreJSON, + ) + if err != nil { + return err + } + signatureJSON, err := + readTBTCSignerAnchorBootstrapArtifact(signaturePath) + if err != nil { + return err + } + signature, err := + tbtc.DecodeFrostNativeSignerAnchorBootstrapDetachedSignature( + signatureJSON, + ) + if err != nil { + return err + } + client, err := clientFactory(command.Context(), clientConfigPath) + if err != nil { + return err + } + final, err := tbtc.InitializeFrostNativeSignerAnchorBootstrap( + command.Context(), + core, + signature, + client, + ) + if err != nil { + return err + } + encoded, err := + tbtc.EncodeFrostNativeSignerAnchorBootstrapFinalArtifact( + final, + ) + if err != nil { + return err + } + return tbtc.WriteFrostNativeSignerAnchorProvisioningArtifact( + output, + encoded, + ) + }, + } + command.Flags().StringVar( + &corePath, + "core", + "", + "canonical absolute path to the core signing request", + ) + command.Flags().StringVar( + &signaturePath, + "core-signature", + "", + "canonical absolute path to the detached offline core signature", + ) + command.Flags().StringVar( + &clientConfigPath, + "client-config", + "", + "canonical absolute owner-only online bootstrap client config path", + ) + command.Flags().StringVar( + &output, + "output", + "", + "canonical absolute no-replace final signing-request path", + ) + _ = command.MarkFlagRequired("core") + _ = command.MarkFlagRequired("core-signature") + _ = command.MarkFlagRequired("client-config") + _ = command.MarkFlagRequired("output") + return command +} + +func newTBTCSignerAnchorBootstrapFinalizeCommand() *cobra.Command { + var finalPath string + var signaturePath string + var baseConfigPath string + var output string + command := &cobra.Command{ + Use: "finalize", + Short: "Builds the certified bootstrap output bundle", + Long: "Validates the detached final signature and atomically emits a " + + "versioned bundle containing the canonical certificate chain and " + + "normal-signer init config.", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + finalJSON, err := readTBTCSignerAnchorBootstrapArtifact(finalPath) + if err != nil { + return err + } + final, err := + tbtc.DecodeFrostNativeSignerAnchorBootstrapFinalArtifact( + finalJSON, + ) + if err != nil { + return err + } + signatureJSON, err := + readTBTCSignerAnchorBootstrapArtifact(signaturePath) + if err != nil { + return err + } + signature, err := + tbtc.DecodeFrostNativeSignerAnchorBootstrapDetachedSignature( + signatureJSON, + ) + if err != nil { + return err + } + baseConfig, err := + readTBTCSignerAnchorBootstrapArtifact(baseConfigPath) + if err != nil { + return err + } + bundle, err := tbtc.FinalizeFrostNativeSignerAnchorBootstrap( + final, + signature, + baseConfig, + ) + if err != nil { + return err + } + return tbtc.WriteFrostNativeSignerAnchorProvisioningArtifact( + output, + bundle, + ) + }, + } + command.Flags().StringVar( + &finalPath, + "final", + "", + "canonical absolute path to the final signing request", + ) + command.Flags().StringVar( + &signaturePath, + "final-signature", + "", + "canonical absolute path to the detached offline final signature", + ) + command.Flags().StringVar( + &baseConfigPath, + "base-config", + "", + "canonical absolute path to the owner-only normal-signer base config", + ) + command.Flags().StringVar( + &output, + "output", + "", + "canonical absolute no-replace certified output-bundle path", + ) + _ = command.MarkFlagRequired("final") + _ = command.MarkFlagRequired("final-signature") + _ = command.MarkFlagRequired("base-config") + _ = command.MarkFlagRequired("output") + return command +} + +func readTBTCSignerAnchorBootstrapArtifact(path string) ([]byte, error) { + return tbtc.ReadFrostNativeSignerAnchorProvisioningArtifact( + path, + tbtcSignerAnchorBootstrapArtifactReadLimit, + ) +} diff --git a/pkg/frost/signing/native_tbtc_signer_inventory.go b/pkg/frost/signing/native_tbtc_signer_inventory.go index 8640d472fc..52acc8f2e5 100644 --- a/pkg/frost/signing/native_tbtc_signer_inventory.go +++ b/pkg/frost/signing/native_tbtc_signer_inventory.go @@ -469,6 +469,9 @@ func DecodeNativeTBTCSignerStateWitnessProof( } func decodeStrictNativeTBTCSignerJSON(payload []byte, target interface{}, subject string) error { + if err := preflightStrictNativeTBTCSignerJSON(payload, 0); err != nil { + return fmt.Errorf("cannot decode native signer %s: %w", subject, err) + } decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { @@ -484,6 +487,94 @@ func decodeStrictNativeTBTCSignerJSON(payload []byte, target interface{}, subjec return nil } +func preflightStrictNativeTBTCSignerJSON(payload []byte, depth int) error { + const maximumDepth = 32 + if depth != 0 { + return fmt.Errorf("native signer JSON preflight must start at the root") + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.UseNumber() + var scanValue func(int) error + scanValue = func(currentDepth int) error { + if currentDepth > maximumDepth { + return fmt.Errorf("JSON nesting exceeds the depth bound") + } + token, err := decoder.Token() + if err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + seen := make(map[string]struct{}) + seenFolded := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return fmt.Errorf("invalid JSON object member: %w", err) + } + key, ok := keyToken.(string) + if !ok || key == "" { + return fmt.Errorf("JSON object member name is invalid") + } + for _, character := range key { + if character < 0x21 || character > 0x7e { + return fmt.Errorf( + "JSON object member name [%s] is not canonical ASCII", + key, + ) + } + } + folded := strings.ToLower(key) + if _, exists := seen[key]; exists { + return fmt.Errorf("JSON object contains duplicate member [%s]", key) + } + if _, exists := seenFolded[folded]; exists { + return fmt.Errorf( + "JSON object contains case-folded duplicate member [%s]", + key, + ) + } + seen[key] = struct{}{} + seenFolded[folded] = struct{}{} + if err := scanValue(currentDepth + 1); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim('}') { + return fmt.Errorf("invalid JSON object termination") + } + case '[': + for decoder.More() { + if err := scanValue(currentDepth + 1); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim(']') { + return fmt.Errorf("invalid JSON array termination") + } + default: + return fmt.Errorf("unexpected JSON delimiter") + } + return nil + } + if err := scanValue(0); err != nil { + return err + } + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("JSON contains trailing data") + } + return fmt.Errorf("invalid JSON trailing data: %w", err) + } + return nil +} + func nativeTBTCSignerBytes32(value [32]byte) string { return "0x" + hex.EncodeToString(value[:]) } diff --git a/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go b/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go index 8f2eae84c7..4360ead5e9 100644 --- a/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go +++ b/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go @@ -23,6 +23,7 @@ typedef struct { typedef TbtcSignerInventoryResult (*tbtc_retained_key_package_inventory_fn)(void); typedef TbtcSignerInventoryResult (*tbtc_state_witness_tip_fn)(void); typedef TbtcSignerInventoryResult (*tbtc_state_anchor_trust_head_fn)(void); +typedef TbtcSignerInventoryResult (*tbtc_state_anchor_bootstrap_facts_fn)(void); typedef TbtcSignerInventoryResult (*tbtc_transition_state_witness_anchor_fn)( const uint8_t* request_ptr, size_t request_len @@ -85,6 +86,18 @@ static TbtcSignerInventoryResult tbtc_signer_state_anchor_trust_head(void) { return operation(); } +static TbtcSignerInventoryResult tbtc_signer_state_anchor_bootstrap_facts(void) { + tbtc_state_anchor_bootstrap_facts_fn operation = + (tbtc_state_anchor_bootstrap_facts_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_state_anchor_bootstrap_facts" + ); + if (operation == NULL) { + return unavailable_tbtc_signer_inventory_result(); + } + return operation(); +} + static TbtcSignerInventoryResult tbtc_signer_transition_state_witness_anchor( const uint8_t* request_ptr, size_t request_len @@ -243,6 +256,35 @@ func ReadNativeTBTCSignerStateAnchorTrustHead() ( return head, nil } +// ReadNativeTBTCSignerStateAnchorBootstrapFacts returns the stable store +// fingerprint and exact pristine genesis checkpoint used by the offline +// bootstrap ceremony. Rust permits this call only under the dedicated +// state_anchor_bootstrap_provisioning config purpose and before any +// state-touching signer operation. +func ReadNativeTBTCSignerStateAnchorBootstrapFacts() ( + *NativeTBTCSignerStateAnchorBootstrapFacts, + error, +) { + if err := ensureTBTCSignerABICompatible(); err != nil { + return nil, err + } + payload, err := parseNativeTBTCSignerInventoryResult( + "StateAnchorBootstrapFacts", + C.tbtc_signer_state_anchor_bootstrap_facts(), + ) + if err != nil { + return nil, err + } + facts, err := DecodeNativeTBTCSignerStateAnchorBootstrapFacts(payload) + if err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "StateAnchorBootstrapFacts", + err.Error(), + ) + } + return facts, nil +} + func classifyNativeTBTCSignerStateAnchorTrustHeadError(err error) error { var structured *buildTaggedTBTCSignerStructuredError if !errors.As(err, &structured) { diff --git a/pkg/frost/signing/native_tbtc_signer_inventory_default.go b/pkg/frost/signing/native_tbtc_signer_inventory_default.go index 1087a2a95c..8b3b4441e6 100644 --- a/pkg/frost/signing/native_tbtc_signer_inventory_default.go +++ b/pkg/frost/signing/native_tbtc_signer_inventory_default.go @@ -34,6 +34,16 @@ func ReadNativeTBTCSignerStateAnchorTrustHead() ( ) } +func ReadNativeTBTCSignerStateAnchorBootstrapFacts() ( + *NativeTBTCSignerStateAnchorBootstrapFacts, + error, +) { + return nil, fmt.Errorf( + "%w: tbtc-signer bridge operation [StateAnchorBootstrapFacts] is unavailable in this build", + ErrNativeCryptographyUnavailable, + ) +} + func TransitionNativeTBTCSignerStateWitnessAnchor( requestJSON []byte, ) (*NativeTBTCSignerStateAnchorTrustTransitionResult, error) { diff --git a/pkg/frost/signing/native_tbtc_signer_secure_config.go b/pkg/frost/signing/native_tbtc_signer_secure_config.go index 59419cace3..d410042d4e 100644 --- a/pkg/frost/signing/native_tbtc_signer_secure_config.go +++ b/pkg/frost/signing/native_tbtc_signer_secure_config.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "syscall" @@ -12,6 +13,15 @@ import ( const nativeTBTCSignerInitConfigMaximumBytes int64 = 1024 * 1024 +const nativeTBTCSignerStateAnchorBootstrapProvisioningWitnessMaximum uint64 = 4 + +type nativeTBTCSignerStateAnchorBootstrapProvisioningConfig struct { + Purpose *string `json:"purpose"` + Profile *string `json:"profile"` + StatePath *string `json:"state_path"` + StateWitnessMaxRecords *uint64 `json:"state_witness_max_records"` +} + // readSecureNativeTBTCSignerInitConfig opens the operator-selected config // without following the final path component and validates the opened // descriptor itself. The config can carry state_key_command, so treating it as @@ -82,6 +92,80 @@ func readSecureNativeTBTCSignerInitConfig(path string) ([]byte, error) { return result, nil } +// InstallNativeTBTCSignerStateAnchorBootstrapProvisioningConfigFile installs +// the deliberately minimal, production-only config accepted by the bootstrap +// facts FFI. It must run before any state-touching signer operation. Requiring +// an exact four-field object prevents an online ceremony process from +// accidentally acquiring runtime signing or anchor authority. +func InstallNativeTBTCSignerStateAnchorBootstrapProvisioningConfigFile( + path string, +) (*NativeTBTCSignerInitConfigResult, error) { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return nil, fmt.Errorf( + "native signer bootstrap provisioning config path is not canonical absolute", + ) + } + configJSON, err := readSecureNativeTBTCSignerInitConfig(path) + if err != nil { + return nil, fmt.Errorf( + "cannot read native signer bootstrap provisioning config: %w", + err, + ) + } + defer zeroNativeTBTCSignerConfigBytes(configJSON) + + wire := &nativeTBTCSignerStateAnchorBootstrapProvisioningConfig{} + if err := decodeStrictNativeTBTCSignerJSON( + configJSON, + wire, + "state-anchor bootstrap provisioning config", + ); err != nil { + return nil, err + } + if wire.Purpose == nil || + *wire.Purpose != + NativeTBTCSignerStateAnchorBootstrapProvisioningPurpose || + wire.Profile == nil || + *wire.Profile != "production" || + wire.StatePath == nil || + strings.TrimSpace(*wire.StatePath) == "" || + !filepath.IsAbs(*wire.StatePath) || + filepath.Clean(*wire.StatePath) != *wire.StatePath || + wire.StateWitnessMaxRecords == nil || + *wire.StateWitnessMaxRecords != + nativeTBTCSignerStateAnchorBootstrapProvisioningWitnessMaximum { + return nil, fmt.Errorf( + "native signer bootstrap provisioning config must contain exactly purpose=%q, profile=production, a canonical absolute state_path, and state_witness_max_records=%d", + NativeTBTCSignerStateAnchorBootstrapProvisioningPurpose, + nativeTBTCSignerStateAnchorBootstrapProvisioningWitnessMaximum, + ) + } + + result, err := InstallNativeTBTCSignerConfig(configJSON) + if err != nil { + return nil, fmt.Errorf( + "cannot install native signer bootstrap provisioning config: %w", + err, + ) + } + if result == nil || !result.Installed || + strings.TrimSpace(result.ConfigFingerprint) == "" { + return nil, fmt.Errorf( + "native signer bootstrap provisioning config installation returned an incomplete result", + ) + } + if err := recordNativeTBTCSignerInstalledStateAnchorConfig( + configJSON, + result.ConfigFingerprint, + ); err != nil { + return nil, fmt.Errorf( + "cannot bind native signer bootstrap provisioning config: %w", + err, + ) + } + return result, nil +} + func zeroNativeTBTCSignerConfigBytes(value []byte) { for index := range value { value[index] = 0 diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_bootstrap.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_bootstrap.go new file mode 100644 index 0000000000..27c9bff95a --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_bootstrap.go @@ -0,0 +1,109 @@ +package signing + +import ( + "encoding/json" + "fmt" +) + +const NativeTBTCSignerStateAnchorBootstrapFactsSchema = "tbtc-signer-state-anchor-bootstrap-facts/v1" + +// NativeTBTCSignerStateAnchorBootstrapFacts is the only native state exposed +// to the online half of the initial trust ceremony. The checkpoint is required +// to be the exact generation-one genesis of StoreFingerprint. +type NativeTBTCSignerStateAnchorBootstrapFacts struct { + Schema string + StoreFingerprint [32]byte + CurrentCheckpoint NativeTBTCSignerStateAnchorCheckpoint +} + +type nativeTBTCSignerStateAnchorBootstrapFactsWire struct { + Schema string `json:"schema"` + StoreFingerprint string `json:"storeFingerprint"` + CurrentCheckpoint nativeTBTCSignerStateAnchorCheckpointWire `json:"currentCheckpoint"` +} + +// DecodeNativeTBTCSignerStateAnchorBootstrapFacts strictly decodes and +// validates the versioned Rust response. In addition to the normal checkpoint +// transcript, bootstrap facts must prove the exact generation-one genesis +// predecessor for the same stable store fingerprint. +func DecodeNativeTBTCSignerStateAnchorBootstrapFacts( + payload []byte, +) (*NativeTBTCSignerStateAnchorBootstrapFacts, error) { + wire := &nativeTBTCSignerStateAnchorBootstrapFactsWire{} + if err := decodeStrictNativeTBTCSignerJSON( + payload, + wire, + "state-anchor bootstrap facts", + ); err != nil { + return nil, err + } + if wire.Schema != NativeTBTCSignerStateAnchorBootstrapFactsSchema { + return nil, fmt.Errorf( + "unsupported native signer state-anchor bootstrap-facts schema", + ) + } + storeFingerprint, err := decodeNativeTBTCSignerCanonicalBytes32( + wire.StoreFingerprint, + false, + ) + if err != nil { + return nil, fmt.Errorf("invalid bootstrap store fingerprint: %w", err) + } + checkpoint, err := decodeNativeTBTCSignerStateAnchorCheckpoint( + &wire.CurrentCheckpoint, + ) + if err != nil { + return nil, fmt.Errorf("invalid bootstrap checkpoint: %w", err) + } + if checkpoint.StoreFingerprint != storeFingerprint || + checkpoint.Generation != 1 || + checkpoint.PreviousStateCommitment != + ComputeNativeTBTCSignerStateWitnessGenesis(storeFingerprint) { + return nil, fmt.Errorf( + "native signer state-anchor bootstrap facts are not the exact store genesis", + ) + } + return &NativeTBTCSignerStateAnchorBootstrapFacts{ + Schema: wire.Schema, + StoreFingerprint: storeFingerprint, + CurrentCheckpoint: checkpoint, + }, nil +} + +// EncodeNativeTBTCSignerStateAnchorBootstrapFacts emits the canonical artifact +// bytes consumed by the offline ceremony. It reuses the strict decoder so a +// caller cannot serialize a non-genesis or cross-store checkpoint. +func EncodeNativeTBTCSignerStateAnchorBootstrapFacts( + facts *NativeTBTCSignerStateAnchorBootstrapFacts, +) ([]byte, error) { + if facts == nil { + return nil, fmt.Errorf("native signer state-anchor bootstrap facts are nil") + } + wire := nativeTBTCSignerStateAnchorBootstrapFactsWire{ + Schema: NativeTBTCSignerStateAnchorBootstrapFactsSchema, + StoreFingerprint: nativeTBTCSignerBytes32(facts.StoreFingerprint), + CurrentCheckpoint: nativeTBTCSignerStateAnchorCheckpointWire{ + StoreFingerprint: nativeTBTCSignerBytes32( + facts.CurrentCheckpoint.StoreFingerprint, + ), + Generation: fmt.Sprint(facts.CurrentCheckpoint.Generation), + PreviousStateCommitment: nativeTBTCSignerBytes32( + facts.CurrentCheckpoint.PreviousStateCommitment, + ), + StateImageDigest: nativeTBTCSignerBytes32( + facts.CurrentCheckpoint.StateImageDigest, + ), + StateCommitment: nativeTBTCSignerBytes32( + facts.CurrentCheckpoint.StateCommitment, + ), + }, + } + encoded, err := json.Marshal(wire) + if err != nil { + return nil, err + } + if _, err := DecodeNativeTBTCSignerStateAnchorBootstrapFacts(encoded); err != nil { + return nil, err + } + return encoded, nil +} diff --git a/pkg/tbtc/frost_native_signer_anchor_provisioning.go b/pkg/tbtc/frost_native_signer_anchor_provisioning.go new file mode 100644 index 0000000000..33c275067d --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_provisioning.go @@ -0,0 +1,1324 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "path/filepath" + "reflect" + "strconv" + "strings" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +const ( + FrostNativeSignerAnchorBootstrapPlanSchema = "tbtc-frost-native-signer-state-anchor-bootstrap-plan/v1" + FrostNativeSignerAnchorBootstrapCoreArtifactSchema = "tbtc-frost-native-signer-state-anchor-bootstrap-core-signing-request/v1" + FrostNativeSignerAnchorBootstrapDetachedSignatureSchema = "tbtc-frost-native-signer-state-anchor-bootstrap-detached-signature/v1" + FrostNativeSignerAnchorBootstrapFinalArtifactSchema = "tbtc-frost-native-signer-state-anchor-bootstrap-final-signing-request/v1" + FrostNativeSignerAnchorBootstrapOutputBundleSchema = "tbtc-frost-native-signer-state-anchor-bootstrap-output-bundle/v1" +) + +type FrostNativeSignerAnchorBootstrapSignatureStage string + +const ( + FrostNativeSignerAnchorBootstrapCoreSignatureStage FrostNativeSignerAnchorBootstrapSignatureStage = "core" + FrostNativeSignerAnchorBootstrapFinalSignatureStage FrostNativeSignerAnchorBootstrapSignatureStage = "final" +) + +// FrostNativeSignerAnchorBootstrapPlan is the public activation projection +// consumed by the offline ceremony. Its fields are not trusted merely because +// they appear here: PrepareFrostNativeSignerAnchorBootstrapCore re-derives +// every stream, transport, binding, SPKI, store, and checkpoint relationship, +// and the offline authority subsequently signs the resulting fixed transcript. +type FrostNativeSignerAnchorBootstrapPlan struct { + Schema string + Endpoint string + Identity FrostNativeSignerAnchorIdentity + ResponsePublicKey [ed25519.PublicKeySize]byte + OfflineAuthorityPublicKey [ed25519.PublicKeySize]byte +} + +// FrostNativeSignerAnchorBootstrapCoreArtifact is the immutable public input +// to the first offline signature. It contains no private key and no service +// result. +type FrostNativeSignerAnchorBootstrapCoreArtifact struct { + Schema string + Plan FrostNativeSignerAnchorBootstrapPlan + Checkpoint FrostNativeSignerStateWitnessCheckpoint + FactsSHA256 [32]byte + CoreDigest [32]byte + OperationID [32]byte + TransitionDigest [32]byte +} + +// FrostNativeSignerAnchorBootstrapDetachedSignature carries a signature +// created outside the online ceremony process. Stage and Digest prevent a +// valid core signature from being accidentally accepted as the final +// signature or vice versa. +type FrostNativeSignerAnchorBootstrapDetachedSignature struct { + Schema string + Stage FrostNativeSignerAnchorBootstrapSignatureStage + Digest [32]byte + Signature [ed25519.SignatureSize]byte +} + +// FrostNativeSignerAnchorBootstrapFinalArtifact contains the exact service +// acknowledgement ratified by the second offline signature. +type FrostNativeSignerAnchorBootstrapFinalArtifact struct { + Schema string + Core FrostNativeSignerAnchorBootstrapCoreArtifact + CoreSignature [ed25519.SignatureSize]byte + TargetReference FrostNativeSignerAnchorTrustReference + TargetAcknowledgement []byte + TargetAcknowledgementSHA256 [32]byte + FinalDigest [32]byte +} + +// FrostNativeSignerAnchorBootstrapOutputBundle is the parsed certified +// ceremony output. CertificateChainJSON and SignerConfigJSON retain the exact +// bundle bytes whose SHA-256 digests the bundle itself commits to. +type FrostNativeSignerAnchorBootstrapOutputBundle struct { + Schema string + CertificateDigest [32]byte + CertificateChain []FrostNativeSignerAnchorTrustCertificate + CertificateChainJSON []byte + SignerConfigJSON []byte +} + +// FrostNativeSignerAnchorBootstrapAuthorization is the only object passed to +// the online service client. It contains a detached offline signature, never +// the offline private key. +type FrostNativeSignerAnchorBootstrapAuthorization struct { + Certificate FrostNativeSignerAnchorTrustCertificate +} + +// FrostNativeSignerAnchorBootstrapClientResult must come from an authenticated +// create-if-absent operation followed by a fresh signed Read of the exact +// stored event. ReadRecoveryJSON makes that reconciliation explicit. +type FrostNativeSignerAnchorBootstrapClientResult struct { + Record *FrostNativeSignerStateWitnessAnchorRecord +} + +// FrostNativeSignerAnchorBootstrapClient is deliberately narrower than the +// runtime anchor store. A separate transport implementation supplies the +// create-if-absent endpoint without weakening ordinary Read/CAS semantics. +type FrostNativeSignerAnchorBootstrapClient interface { + InitializeFrostNativeSignerAnchor( + context.Context, + FrostNativeSignerAnchorBootstrapAuthorization, + ) (*FrostNativeSignerAnchorBootstrapClientResult, error) +} + +type frostNativeSignerAnchorBootstrapPlanWire struct { + Schema string `json:"schema"` + Endpoint string `json:"endpoint"` + Identity frostNativeSignerAnchorIdentityWire `json:"identity"` + ResponsePublicKey string `json:"responsePublicKey"` + OfflineAuthorityPublicKey string `json:"offlineAuthorityPublicKey"` +} + +type frostNativeSignerAnchorBootstrapCoreArtifactWire struct { + Schema string `json:"schema"` + Plan frostNativeSignerAnchorBootstrapPlanWire `json:"plan"` + Checkpoint frostNativeSignerAnchorCheckpointWire `json:"checkpoint"` + FactsSHA256 string `json:"factsSHA256"` + CoreDigest string `json:"coreDigest"` + OperationID string `json:"operationID"` + TransitionDigest string `json:"transitionDigest"` +} + +type frostNativeSignerAnchorBootstrapDetachedSignatureWire struct { + Schema string `json:"schema"` + Stage string `json:"stage"` + Digest string `json:"digest"` + Signature string `json:"signature"` +} + +type frostNativeSignerAnchorBootstrapFinalArtifactWire struct { + Schema string `json:"schema"` + Core frostNativeSignerAnchorBootstrapCoreArtifactWire `json:"core"` + CoreSignature string `json:"coreSignature"` + TargetReference frostNativeSignerAnchorTrustReferenceWire `json:"targetReference"` + TargetAcknowledgementBase64 string `json:"targetAcknowledgementBase64"` + TargetAcknowledgementSHA256 string `json:"targetAcknowledgementSHA256"` + FinalDigest string `json:"finalDigest"` +} + +type frostNativeSignerAnchorBootstrapOutputBundleWire struct { + Schema string `json:"schema"` + CertificateDigest string `json:"certificateDigest"` + CertificateChainSHA256 string `json:"certificateChainSHA256"` + SignerConfigSHA256 string `json:"signerConfigSHA256"` + CertificateChain json.RawMessage `json:"certificateChain"` + SignerConfig json.RawMessage `json:"signerConfig"` +} + +// PrepareFrostNativeSignerAnchorBootstrapCore constructs and validates the +// first fixed-width offline signing transcript. +func PrepareFrostNativeSignerAnchorBootstrapCore( + facts *frostsigning.NativeTBTCSignerStateAnchorBootstrapFacts, + plan *FrostNativeSignerAnchorBootstrapPlan, +) (*FrostNativeSignerAnchorBootstrapCoreArtifact, error) { + if facts == nil || plan == nil { + return nil, fmt.Errorf("native signer anchor bootstrap facts or plan are nil") + } + factsJSON, err := + frostsigning.EncodeNativeTBTCSignerStateAnchorBootstrapFacts(facts) + if err != nil { + return nil, err + } + if err := validateFrostNativeSignerAnchorBootstrapPlan(plan); err != nil { + return nil, err + } + checkpoint := frostNativeSignerAnchorBootstrapCheckpoint(facts.CurrentCheckpoint) + if checkpoint.StoreFingerprint != plan.Identity.SignerStoreFingerprint { + return nil, fmt.Errorf( + "native signer bootstrap facts differ from the activation plan store", + ) + } + certificate := frostNativeSignerAnchorBootstrapCoreCertificate( + plan, + checkpoint, + ) + coreDigest, err := ComputeFrostNativeSignerAnchorTrustCoreDigest(&certificate) + if err != nil { + return nil, err + } + operationID := ComputeFrostNativeSignerAnchorTrustOperationID(coreDigest) + transitionDigest := ComputeFrostNativeSignerAnchorTrustTransitionDigest( + coreDigest, + operationID, + ) + result := &FrostNativeSignerAnchorBootstrapCoreArtifact{ + Schema: FrostNativeSignerAnchorBootstrapCoreArtifactSchema, + Plan: *plan, + Checkpoint: checkpoint, + FactsSHA256: sha256.Sum256(factsJSON), + CoreDigest: coreDigest, + OperationID: operationID, + TransitionDigest: transitionDigest, + } + if err := validateFrostNativeSignerAnchorBootstrapCore(result); err != nil { + return nil, err + } + return result, nil +} + +// InitializeFrostNativeSignerAnchorBootstrap verifies the detached core +// authorization, invokes the separate online bootstrap transport, requires its +// fresh-Read reconciliation record, and prepares the second offline digest. +func InitializeFrostNativeSignerAnchorBootstrap( + ctx context.Context, + core *FrostNativeSignerAnchorBootstrapCoreArtifact, + signature *FrostNativeSignerAnchorBootstrapDetachedSignature, + client FrostNativeSignerAnchorBootstrapClient, +) (*FrostNativeSignerAnchorBootstrapFinalArtifact, error) { + if ctx == nil || client == nil { + return nil, fmt.Errorf( + "native signer anchor bootstrap context or client is nil", + ) + } + if err := validateFrostNativeSignerAnchorBootstrapCore(core); err != nil { + return nil, err + } + if err := validateFrostNativeSignerAnchorBootstrapSignature( + signature, + FrostNativeSignerAnchorBootstrapCoreSignatureStage, + core.CoreDigest, + core.Plan.OfflineAuthorityPublicKey, + ); err != nil { + return nil, err + } + certificate := frostNativeSignerAnchorBootstrapCoreCertificate( + &core.Plan, + core.Checkpoint, + ) + certificate.CoreDigest = core.CoreDigest + certificate.CoreSignature = signature.Signature + certificate.OperationID = core.OperationID + certificate.TransitionDigest = core.TransitionDigest + + result, err := client.InitializeFrostNativeSignerAnchor( + ctx, + FrostNativeSignerAnchorBootstrapAuthorization{ + Certificate: certificate, + }, + ) + if err != nil { + return nil, err + } + if result == nil || result.Record == nil || + len(result.Record.AcknowledgementJSON) == 0 || + len(result.Record.ReadRecoveryJSON) == 0 || + result.Record.ReadRecoveryExpires == 0 { + return nil, fmt.Errorf( + "native signer anchor bootstrap client did not return a fresh reconciled record", + ) + } + record := result.Record + reference := FrostNativeSignerAnchorTrustReference{ + ServiceEpoch: record.ServiceEpoch, + Revision: record.Revision, + PreviousEventRoot: record.PreviousEventRoot, + EventRoot: record.EventRoot, + AcknowledgementDigest: record.AcknowledgementDigest, + Checkpoint: record.Checkpoint, + } + certificate.To.Reference = reference + certificate.TargetAcknowledgement = append( + []byte{}, + record.AcknowledgementJSON..., + ) + certificate.TargetAcknowledgementSHA256 = sha256.Sum256( + certificate.TargetAcknowledgement, + ) + if record.BindingHash != certificate.To.BindingHash || + record.OperationID != certificate.OperationID || + record.TransitionDigest != certificate.TransitionDigest || + record.Checkpoint != core.Checkpoint || + record.ServiceEpoch != 1 || + record.Revision != 1 || + record.PreviousEventRoot != [32]byte{} { + return nil, fmt.Errorf( + "native signer anchor bootstrap reconciled record differs from its offline core", + ) + } + if err := ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement( + &certificate, + certificate.TargetAcknowledgement, + ); err != nil { + return nil, err + } + finalDigest, err := ComputeFrostNativeSignerAnchorTrustFinalDigest( + &certificate, + ) + if err != nil { + return nil, err + } + return &FrostNativeSignerAnchorBootstrapFinalArtifact{ + Schema: FrostNativeSignerAnchorBootstrapFinalArtifactSchema, + Core: *core, + CoreSignature: signature.Signature, + TargetReference: reference, + TargetAcknowledgement: certificate.TargetAcknowledgement, + TargetAcknowledgementSHA256: certificate.TargetAcknowledgementSHA256, + FinalDigest: finalDigest, + }, nil +} + +// FinalizeFrostNativeSignerAnchorBootstrap validates the second detached +// signature and emits one atomic bundle containing the canonical one-element +// certificate chain and complete normal-signer init config. The offline +// private key is never accepted by this API. +func FinalizeFrostNativeSignerAnchorBootstrap( + final *FrostNativeSignerAnchorBootstrapFinalArtifact, + signature *FrostNativeSignerAnchorBootstrapDetachedSignature, + baseSignerConfig []byte, +) ([]byte, error) { + certificate, err := + frostNativeSignerAnchorBootstrapCertificateFromFinal(final) + if err != nil { + return nil, err + } + if err := validateFrostNativeSignerAnchorBootstrapSignature( + signature, + FrostNativeSignerAnchorBootstrapFinalSignatureStage, + final.FinalDigest, + final.Core.Plan.OfflineAuthorityPublicKey, + ); err != nil { + return nil, err + } + certificate.FinalSignature = signature.Signature + certificate.CertificateDigest, err = + ComputeFrostNativeSignerAnchorTrustCertificateDigest(certificate) + if err != nil { + return nil, err + } + if err := ValidateFrostNativeSignerAnchorTrustCertificate( + certificate, + ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement, + ); err != nil { + return nil, err + } + certificateJSON, err := + EncodeFrostNativeSignerAnchorTrustCertificate(certificate) + if err != nil { + return nil, err + } + certificateChain, err := json.Marshal([]json.RawMessage{certificateJSON}) + if err != nil { + return nil, err + } + if _, err := DecodeFrostNativeSignerAnchorTrustCertificateChain( + certificateChain, + ); err != nil { + return nil, err + } + signerConfig, err := frostNativeSignerAnchorBootstrapSignerConfig( + baseSignerConfig, + certificate, + ) + if err != nil { + return nil, err + } + wire := frostNativeSignerAnchorBootstrapOutputBundleWire{ + Schema: FrostNativeSignerAnchorBootstrapOutputBundleSchema, + CertificateDigest: frostNativeSignerAnchorHex32(certificate.CertificateDigest), + CertificateChainSHA256: frostNativeSignerAnchorHex32(sha256.Sum256(certificateChain)), + SignerConfigSHA256: frostNativeSignerAnchorHex32(sha256.Sum256(signerConfig)), + CertificateChain: certificateChain, + SignerConfig: signerConfig, + } + encoded, err := json.Marshal(wire) + if err != nil { + return nil, err + } + if _, err := DecodeFrostNativeSignerAnchorBootstrapOutputBundle( + encoded, + ); err != nil { + return nil, fmt.Errorf( + "native signer anchor bootstrap output bundle failed its decode round-trip: %w", + err, + ) + } + return encoded, nil +} + +// DecodeFrostNativeSignerAnchorBootstrapOutputBundle strictly decodes the +// certified ceremony output. It re-validates the embedded one-certificate +// bootstrap chain cryptographically and requires the embedded signer config to +// be the exact canonical derivation of that certificate. +func DecodeFrostNativeSignerAnchorBootstrapOutputBundle( + data []byte, +) (*FrostNativeSignerAnchorBootstrapOutputBundle, error) { + wire := &frostNativeSignerAnchorBootstrapOutputBundleWire{} + if err := decodeStrictFrostNativeSignerAnchorProvisioningJSON( + data, + wire, + ); err != nil { + return nil, err + } + if wire.Schema != FrostNativeSignerAnchorBootstrapOutputBundleSchema { + return nil, fmt.Errorf( + "unsupported native signer anchor bootstrap output-bundle schema", + ) + } + certificateDigest, err := frostNativeSignerAnchorParseHex32( + wire.CertificateDigest, + ) + if err != nil || certificateDigest == [32]byte{} { + return nil, fmt.Errorf( + "invalid bootstrap output-bundle certificate digest", + ) + } + chainSHA, err := frostNativeSignerAnchorParseHex32( + wire.CertificateChainSHA256, + ) + if err != nil || chainSHA != sha256.Sum256(wire.CertificateChain) { + return nil, fmt.Errorf( + "bootstrap output-bundle certificate chain SHA-256 mismatch", + ) + } + configSHA, err := frostNativeSignerAnchorParseHex32(wire.SignerConfigSHA256) + if err != nil || configSHA != sha256.Sum256(wire.SignerConfig) { + return nil, fmt.Errorf( + "bootstrap output-bundle signer config SHA-256 mismatch", + ) + } + chain, err := DecodeFrostNativeSignerAnchorTrustCertificateChain( + wire.CertificateChain, + ) + if err != nil { + return nil, err + } + if len(chain) != 1 || + chain[0].Kind != FrostNativeSignerAnchorTrustCertificateBootstrap || + chain[0].CertificateDigest != certificateDigest { + return nil, fmt.Errorf( + "bootstrap output-bundle chain is not the exact single bootstrap certificate", + ) + } + certificate := &chain[0] + if err := ValidateFrostNativeSignerAnchorTrustCertificate( + certificate, + ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement, + ); err != nil { + return nil, err + } + signerConfig, err := frostNativeSignerAnchorBootstrapSignerConfig( + wire.SignerConfig, + certificate, + ) + if err != nil { + return nil, err + } + if !bytes.Equal(signerConfig, wire.SignerConfig) { + return nil, fmt.Errorf( + "bootstrap output-bundle signer config is not the canonical certified derivation", + ) + } + return &FrostNativeSignerAnchorBootstrapOutputBundle{ + Schema: wire.Schema, + CertificateDigest: certificateDigest, + CertificateChain: chain, + CertificateChainJSON: append([]byte{}, wire.CertificateChain...), + SignerConfigJSON: append([]byte{}, wire.SignerConfig...), + }, nil +} + +func EncodeFrostNativeSignerAnchorBootstrapPlan( + plan *FrostNativeSignerAnchorBootstrapPlan, +) ([]byte, error) { + if err := validateFrostNativeSignerAnchorBootstrapPlan(plan); err != nil { + return nil, err + } + return json.Marshal(frostNativeSignerAnchorBootstrapPlanToWire(plan)) +} + +func DecodeFrostNativeSignerAnchorBootstrapPlan( + data []byte, +) (*FrostNativeSignerAnchorBootstrapPlan, error) { + wire := &frostNativeSignerAnchorBootstrapPlanWire{} + if err := decodeStrictFrostNativeSignerAnchorProvisioningJSON( + data, + wire, + ); err != nil { + return nil, err + } + plan, err := frostNativeSignerAnchorBootstrapPlanFromWire(wire) + if err != nil { + return nil, err + } + if err := validateFrostNativeSignerAnchorBootstrapPlan(plan); err != nil { + return nil, err + } + return plan, nil +} + +func EncodeFrostNativeSignerAnchorBootstrapCoreArtifact( + core *FrostNativeSignerAnchorBootstrapCoreArtifact, +) ([]byte, error) { + if err := validateFrostNativeSignerAnchorBootstrapCore(core); err != nil { + return nil, err + } + return json.Marshal(frostNativeSignerAnchorBootstrapCoreToWire(core)) +} + +func DecodeFrostNativeSignerAnchorBootstrapCoreArtifact( + data []byte, +) (*FrostNativeSignerAnchorBootstrapCoreArtifact, error) { + wire := &frostNativeSignerAnchorBootstrapCoreArtifactWire{} + if err := decodeStrictFrostNativeSignerAnchorProvisioningJSON( + data, + wire, + ); err != nil { + return nil, err + } + core, err := frostNativeSignerAnchorBootstrapCoreFromWire(wire) + if err != nil { + return nil, err + } + if err := validateFrostNativeSignerAnchorBootstrapCore(core); err != nil { + return nil, err + } + return core, nil +} + +func EncodeFrostNativeSignerAnchorBootstrapDetachedSignature( + signature *FrostNativeSignerAnchorBootstrapDetachedSignature, +) ([]byte, error) { + if signature == nil || + signature.Schema != + FrostNativeSignerAnchorBootstrapDetachedSignatureSchema || + (signature.Stage != FrostNativeSignerAnchorBootstrapCoreSignatureStage && + signature.Stage != + FrostNativeSignerAnchorBootstrapFinalSignatureStage) || + signature.Digest == [32]byte{} { + return nil, fmt.Errorf( + "native signer anchor bootstrap detached signature is incomplete", + ) + } + return json.Marshal(frostNativeSignerAnchorBootstrapDetachedSignatureWire{ + Schema: signature.Schema, + Stage: string(signature.Stage), + Digest: frostNativeSignerAnchorHex32(signature.Digest), + Signature: base64.StdEncoding.EncodeToString(signature.Signature[:]), + }) +} + +func DecodeFrostNativeSignerAnchorBootstrapDetachedSignature( + data []byte, +) (*FrostNativeSignerAnchorBootstrapDetachedSignature, error) { + wire := &frostNativeSignerAnchorBootstrapDetachedSignatureWire{} + if err := decodeStrictFrostNativeSignerAnchorProvisioningJSON( + data, + wire, + ); err != nil { + return nil, err + } + if wire.Schema != + FrostNativeSignerAnchorBootstrapDetachedSignatureSchema { + return nil, fmt.Errorf( + "unsupported native signer anchor bootstrap detached-signature schema", + ) + } + digest, err := frostNativeSignerAnchorParseHex32(wire.Digest) + if err != nil || digest == [32]byte{} { + return nil, fmt.Errorf("invalid detached signature digest") + } + decoded, err := base64.StdEncoding.Strict().DecodeString(wire.Signature) + if err != nil || + base64.StdEncoding.EncodeToString(decoded) != wire.Signature || + len(decoded) != ed25519.SignatureSize { + return nil, fmt.Errorf("invalid detached Ed25519 signature") + } + result := &FrostNativeSignerAnchorBootstrapDetachedSignature{ + Schema: FrostNativeSignerAnchorBootstrapDetachedSignatureSchema, + Stage: FrostNativeSignerAnchorBootstrapSignatureStage(wire.Stage), + Digest: digest, + } + copy(result.Signature[:], decoded) + if result.Stage != FrostNativeSignerAnchorBootstrapCoreSignatureStage && + result.Stage != FrostNativeSignerAnchorBootstrapFinalSignatureStage { + return nil, fmt.Errorf("invalid detached signature stage") + } + return result, nil +} + +func EncodeFrostNativeSignerAnchorBootstrapFinalArtifact( + final *FrostNativeSignerAnchorBootstrapFinalArtifact, +) ([]byte, error) { + if _, err := frostNativeSignerAnchorBootstrapCertificateFromFinal( + final, + ); err != nil { + return nil, err + } + reference := frostNativeSignerAnchorTrustReferenceToWire( + final.TargetReference, + ) + return json.Marshal(frostNativeSignerAnchorBootstrapFinalArtifactWire{ + Schema: FrostNativeSignerAnchorBootstrapFinalArtifactSchema, + Core: frostNativeSignerAnchorBootstrapCoreToWire(&final.Core), + CoreSignature: base64.StdEncoding.EncodeToString(final.CoreSignature[:]), + TargetReference: reference, + TargetAcknowledgementBase64: base64.StdEncoding.EncodeToString(final.TargetAcknowledgement), + TargetAcknowledgementSHA256: frostNativeSignerAnchorHex32(final.TargetAcknowledgementSHA256), + FinalDigest: frostNativeSignerAnchorHex32(final.FinalDigest), + }) +} + +func DecodeFrostNativeSignerAnchorBootstrapFinalArtifact( + data []byte, +) (*FrostNativeSignerAnchorBootstrapFinalArtifact, error) { + wire := &frostNativeSignerAnchorBootstrapFinalArtifactWire{} + if err := decodeStrictFrostNativeSignerAnchorProvisioningJSON( + data, + wire, + ); err != nil { + return nil, err + } + if wire.Schema != FrostNativeSignerAnchorBootstrapFinalArtifactSchema { + return nil, fmt.Errorf( + "unsupported native signer anchor bootstrap final-artifact schema", + ) + } + core, err := frostNativeSignerAnchorBootstrapCoreFromWire(&wire.Core) + if err != nil { + return nil, err + } + coreSignature, err := base64.StdEncoding.Strict().DecodeString( + wire.CoreSignature, + ) + if err != nil || len(coreSignature) != ed25519.SignatureSize || + base64.StdEncoding.EncodeToString(coreSignature) != wire.CoreSignature { + return nil, fmt.Errorf("invalid bootstrap core signature") + } + reference, err := frostNativeSignerAnchorTrustReferenceFromWire( + wire.TargetReference, + ) + if err != nil { + return nil, err + } + acknowledgement, err := base64.StdEncoding.Strict().DecodeString( + wire.TargetAcknowledgementBase64, + ) + if err != nil || len(acknowledgement) == 0 || + base64.StdEncoding.EncodeToString(acknowledgement) != + wire.TargetAcknowledgementBase64 { + return nil, fmt.Errorf("invalid bootstrap target acknowledgement") + } + acknowledgementSHA, err := frostNativeSignerAnchorParseHex32( + wire.TargetAcknowledgementSHA256, + ) + if err != nil || acknowledgementSHA != sha256.Sum256(acknowledgement) { + return nil, fmt.Errorf( + "bootstrap target acknowledgement SHA-256 mismatch", + ) + } + finalDigest, err := frostNativeSignerAnchorParseHex32(wire.FinalDigest) + if err != nil || finalDigest == [32]byte{} { + return nil, fmt.Errorf("invalid bootstrap final digest") + } + result := &FrostNativeSignerAnchorBootstrapFinalArtifact{ + Schema: wire.Schema, + Core: *core, + TargetReference: reference, + TargetAcknowledgement: acknowledgement, + TargetAcknowledgementSHA256: acknowledgementSHA, + FinalDigest: finalDigest, + } + copy(result.CoreSignature[:], coreSignature) + if _, err := frostNativeSignerAnchorBootstrapCertificateFromFinal( + result, + ); err != nil { + return nil, err + } + return result, nil +} + +func frostNativeSignerAnchorBootstrapPlanToWire( + plan *FrostNativeSignerAnchorBootstrapPlan, +) frostNativeSignerAnchorBootstrapPlanWire { + return frostNativeSignerAnchorBootstrapPlanWire{ + Schema: FrostNativeSignerAnchorBootstrapPlanSchema, + Endpoint: plan.Endpoint, + Identity: frostNativeSignerAnchorIdentityToWire(plan.Identity), + ResponsePublicKey: frostNativeSignerAnchorHex32(plan.ResponsePublicKey), + OfflineAuthorityPublicKey: frostNativeSignerAnchorHex32(plan.OfflineAuthorityPublicKey), + } +} + +func frostNativeSignerAnchorBootstrapPlanFromWire( + wire *frostNativeSignerAnchorBootstrapPlanWire, +) (*FrostNativeSignerAnchorBootstrapPlan, error) { + if wire == nil || wire.Schema != FrostNativeSignerAnchorBootstrapPlanSchema { + return nil, fmt.Errorf( + "unsupported native signer anchor bootstrap plan schema", + ) + } + identity, err := frostNativeSignerAnchorIdentityFromWire(wire.Identity) + if err != nil { + return nil, err + } + responseKey, err := frostNativeSignerAnchorParseHex32( + wire.ResponsePublicKey, + ) + if err != nil { + return nil, err + } + offlineKey, err := frostNativeSignerAnchorParseHex32( + wire.OfflineAuthorityPublicKey, + ) + if err != nil { + return nil, err + } + return &FrostNativeSignerAnchorBootstrapPlan{ + Schema: wire.Schema, + Endpoint: wire.Endpoint, + Identity: identity, + ResponsePublicKey: responseKey, + OfflineAuthorityPublicKey: offlineKey, + }, nil +} + +func frostNativeSignerAnchorBootstrapCoreToWire( + core *FrostNativeSignerAnchorBootstrapCoreArtifact, +) frostNativeSignerAnchorBootstrapCoreArtifactWire { + return frostNativeSignerAnchorBootstrapCoreArtifactWire{ + Schema: FrostNativeSignerAnchorBootstrapCoreArtifactSchema, + Plan: frostNativeSignerAnchorBootstrapPlanToWire(&core.Plan), + Checkpoint: frostNativeSignerAnchorCheckpointToWire(core.Checkpoint), + FactsSHA256: frostNativeSignerAnchorHex32(core.FactsSHA256), + CoreDigest: frostNativeSignerAnchorHex32(core.CoreDigest), + OperationID: frostNativeSignerAnchorHex32(core.OperationID), + TransitionDigest: frostNativeSignerAnchorHex32(core.TransitionDigest), + } +} + +func frostNativeSignerAnchorBootstrapCoreFromWire( + wire *frostNativeSignerAnchorBootstrapCoreArtifactWire, +) (*FrostNativeSignerAnchorBootstrapCoreArtifact, error) { + if wire == nil || + wire.Schema != FrostNativeSignerAnchorBootstrapCoreArtifactSchema { + return nil, fmt.Errorf( + "unsupported native signer anchor bootstrap core-artifact schema", + ) + } + plan, err := frostNativeSignerAnchorBootstrapPlanFromWire(&wire.Plan) + if err != nil { + return nil, err + } + checkpoint, err := frostNativeSignerAnchorCheckpointFromWire( + wire.Checkpoint, + ) + if err != nil { + return nil, err + } + result := &FrostNativeSignerAnchorBootstrapCoreArtifact{ + Schema: wire.Schema, + Plan: *plan, + Checkpoint: checkpoint, + } + fields := []struct { + encoded string + destination *[32]byte + }{ + {wire.FactsSHA256, &result.FactsSHA256}, + {wire.CoreDigest, &result.CoreDigest}, + {wire.OperationID, &result.OperationID}, + {wire.TransitionDigest, &result.TransitionDigest}, + } + for _, field := range fields { + value, err := frostNativeSignerAnchorParseHex32(field.encoded) + if err != nil || value == [32]byte{} { + return nil, fmt.Errorf("bootstrap core artifact contains an invalid digest") + } + *field.destination = value + } + return result, nil +} + +func validateFrostNativeSignerAnchorBootstrapPlan( + plan *FrostNativeSignerAnchorBootstrapPlan, +) error { + if plan == nil || plan.Schema != FrostNativeSignerAnchorBootstrapPlanSchema { + return fmt.Errorf("native signer anchor bootstrap plan is incomplete") + } + _, https, err := validateFrostNativeSignerAnchorEndpoint(plan.Endpoint) + if err != nil { + return err + } + if err := validateFrostNativeSignerAnchorIdentity( + plan.Identity, + https, + ); err != nil { + return err + } + if ComputeFrostNativeSignerAnchorTransportBinding(plan.Endpoint) != + plan.Identity.TransportBinding { + return fmt.Errorf( + "native signer anchor bootstrap endpoint differs from its transport binding", + ) + } + if err := ValidateFrostNativeSignerAnchorTrustEd25519PublicKey( + plan.ResponsePublicKey[:], + ); err != nil { + return fmt.Errorf("invalid bootstrap response key: %w", err) + } + if err := ValidateFrostNativeSignerAnchorTrustEd25519PublicKey( + plan.OfflineAuthorityPublicKey[:], + ); err != nil { + return fmt.Errorf("invalid bootstrap offline authority key: %w", err) + } + if ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + plan.ResponsePublicKey, + ) != plan.Identity.OnlineKeyHash || + ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + plan.OfflineAuthorityPublicKey, + ) != plan.Identity.OfflineAuthorityHash || + plan.ResponsePublicKey == plan.OfflineAuthorityPublicKey { + return fmt.Errorf( + "native signer anchor bootstrap public keys differ from their activation pins", + ) + } + return nil +} + +func validateFrostNativeSignerAnchorBootstrapCore( + core *FrostNativeSignerAnchorBootstrapCoreArtifact, +) error { + if core == nil || + core.Schema != FrostNativeSignerAnchorBootstrapCoreArtifactSchema || + core.FactsSHA256 == [32]byte{} { + return fmt.Errorf("native signer anchor bootstrap core artifact is incomplete") + } + if err := validateFrostNativeSignerAnchorBootstrapPlan( + &core.Plan, + ); err != nil { + return err + } + if err := validateFrostNativeSignerAnchorCheckpoint( + core.Checkpoint, + core.Plan.Identity.SignerStoreFingerprint, + ); err != nil { + return err + } + if core.Checkpoint.Generation != 1 || + core.Checkpoint.PreviousStateCommitment != + frostsigning.ComputeNativeTBTCSignerStateWitnessGenesis( + core.Checkpoint.StoreFingerprint, + ) { + return fmt.Errorf( + "native signer anchor bootstrap checkpoint is not the exact genesis", + ) + } + certificate := frostNativeSignerAnchorBootstrapCoreCertificate( + &core.Plan, + core.Checkpoint, + ) + digest, err := ComputeFrostNativeSignerAnchorTrustCoreDigest(&certificate) + if err != nil || digest != core.CoreDigest { + return fmt.Errorf("native signer anchor bootstrap core digest mismatch") + } + operationID := ComputeFrostNativeSignerAnchorTrustOperationID(digest) + if operationID != core.OperationID || + ComputeFrostNativeSignerAnchorTrustTransitionDigest( + digest, + operationID, + ) != core.TransitionDigest { + return fmt.Errorf( + "native signer anchor bootstrap operation or transition digest mismatch", + ) + } + return nil +} + +func validateFrostNativeSignerAnchorBootstrapSignature( + signature *FrostNativeSignerAnchorBootstrapDetachedSignature, + stage FrostNativeSignerAnchorBootstrapSignatureStage, + digest [32]byte, + publicKey [ed25519.PublicKeySize]byte, +) error { + if signature == nil || + signature.Schema != + FrostNativeSignerAnchorBootstrapDetachedSignatureSchema || + signature.Stage != stage || + signature.Digest != digest || + !ed25519.Verify( + ed25519.PublicKey(publicKey[:]), + digest[:], + signature.Signature[:], + ) { + return fmt.Errorf( + "native signer anchor bootstrap %s signature is invalid", + stage, + ) + } + return nil +} + +func frostNativeSignerAnchorBootstrapCoreCertificate( + plan *FrostNativeSignerAnchorBootstrapPlan, + checkpoint FrostNativeSignerStateWitnessCheckpoint, +) FrostNativeSignerAnchorTrustCertificate { + bindingHash := ComputeFrostNativeSignerAnchorBindingHash(plan.Identity) + return FrostNativeSignerAnchorTrustCertificate{ + Kind: FrostNativeSignerAnchorTrustCertificateBootstrap, + CertificateSequence: 1, + ProtocolID: plan.Identity.ProtocolID, + StreamID: plan.Identity.StreamID, + SignerStoreFingerprint: plan.Identity.SignerStoreFingerprint, + To: FrostNativeSignerAnchorTrustEndpoint{ + ActivationManifestHash: plan.Identity.ActivationManifestHash, + ActivationManifestSequence: plan.Identity.ActivationManifestSequence, + BindingHash: bindingHash, + ResponsePublicKey: plan.ResponsePublicKey, + ResponsePublicKeySPKISHA256: plan.Identity.OnlineKeyHash, + OfflineAuthorityPublicKey: plan.OfflineAuthorityPublicKey, + OfflineAuthoritySPKISHA256: plan.Identity.OfflineAuthorityHash, + WitnessMaximumRecords: plan.Identity.WitnessMaximumRecords, + WitnessRotationThresholdRecords: plan.Identity.WitnessRotationThresholdRecords, + Reference: FrostNativeSignerAnchorTrustReference{ + ServiceEpoch: 1, + Checkpoint: checkpoint, + }, + }, + } +} + +func frostNativeSignerAnchorBootstrapCertificateFromFinal( + final *FrostNativeSignerAnchorBootstrapFinalArtifact, +) (*FrostNativeSignerAnchorTrustCertificate, error) { + if final == nil || + final.Schema != FrostNativeSignerAnchorBootstrapFinalArtifactSchema || + len(final.TargetAcknowledgement) == 0 || + final.TargetAcknowledgementSHA256 != + sha256.Sum256(final.TargetAcknowledgement) { + return nil, fmt.Errorf( + "native signer anchor bootstrap final artifact is incomplete", + ) + } + if err := validateFrostNativeSignerAnchorBootstrapCore(&final.Core); err != nil { + return nil, err + } + certificate := frostNativeSignerAnchorBootstrapCoreCertificate( + &final.Core.Plan, + final.Core.Checkpoint, + ) + certificate.CoreDigest = final.Core.CoreDigest + certificate.CoreSignature = final.CoreSignature + certificate.OperationID = final.Core.OperationID + certificate.TransitionDigest = final.Core.TransitionDigest + certificate.To.Reference = final.TargetReference + certificate.TargetAcknowledgement = append( + []byte{}, + final.TargetAcknowledgement..., + ) + certificate.TargetAcknowledgementSHA256 = + final.TargetAcknowledgementSHA256 + if !ed25519.Verify( + ed25519.PublicKey(final.Core.Plan.OfflineAuthorityPublicKey[:]), + certificate.CoreDigest[:], + certificate.CoreSignature[:], + ) { + return nil, fmt.Errorf("native signer anchor bootstrap core signature is invalid") + } + if err := ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement( + &certificate, + certificate.TargetAcknowledgement, + ); err != nil { + return nil, err + } + finalDigest, err := ComputeFrostNativeSignerAnchorTrustFinalDigest( + &certificate, + ) + if err != nil || finalDigest != final.FinalDigest { + return nil, fmt.Errorf("native signer anchor bootstrap final digest mismatch") + } + return &certificate, nil +} + +func frostNativeSignerAnchorBootstrapCheckpoint( + checkpoint frostsigning.NativeTBTCSignerStateAnchorCheckpoint, +) FrostNativeSignerStateWitnessCheckpoint { + return FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: checkpoint.StoreFingerprint, + Generation: checkpoint.Generation, + PreviousStateCommitment: checkpoint.PreviousStateCommitment, + StateImageDigest: checkpoint.StateImageDigest, + StateCommitment: checkpoint.StateCommitment, + } +} + +func frostNativeSignerAnchorIdentityFromWire( + wire frostNativeSignerAnchorIdentityWire, +) (FrostNativeSignerAnchorIdentity, error) { + result := FrostNativeSignerAnchorIdentity{ + TrustDomainID: wire.TrustDomainID, + HistoryStoreID: wire.HistoryStoreID, + } + var err error + if result.ActivationManifestSequence, err = + frostNativeSignerAnchorParseUint64( + wire.ActivationManifestSequence, + ); err != nil { + return result, err + } + if result.WitnessMaximumRecords, err = + frostNativeSignerAnchorParseUint64( + wire.WitnessMaximumRecords, + ); err != nil { + return result, err + } + if result.WitnessRotationThresholdRecords, err = + frostNativeSignerAnchorParseUint64( + wire.WitnessRotationThresholdRecords, + ); err != nil { + return result, err + } + fields := []struct { + encoded string + destination *[32]byte + // allowZero is set only for the endpoint leaf SPKI pin: numeric + // loopback HTTP plans legitimately carry a zero leaf hash, and + // validateFrostNativeSignerAnchorIdentity enforces that exact pairing. + allowZero bool + }{ + {wire.ProtocolID, &result.ProtocolID, false}, + {wire.StreamID, &result.StreamID, false}, + {wire.ActivationManifestHash, &result.ActivationManifestHash, false}, + {wire.EndpointLeafSPKIHash, &result.EndpointLeafSPKIHash, true}, + {wire.OnlineKeyHash, &result.OnlineKeyHash, false}, + {wire.OperatorFingerprint, &result.OperatorFingerprint, false}, + {wire.HistoryStoreFingerprint, &result.HistoryStoreFingerprint, false}, + {wire.HistoryClusterFingerprint, &result.HistoryClusterFingerprint, false}, + {wire.OfflineAuthorityHash, &result.OfflineAuthorityHash, false}, + {wire.ClientSPKIHash, &result.ClientSPKIHash, false}, + {wire.SignerStoreFingerprint, &result.SignerStoreFingerprint, false}, + {wire.TransportBinding, &result.TransportBinding, false}, + } + for _, field := range fields { + value, err := frostNativeSignerAnchorParseHex32(field.encoded) + if err != nil { + return result, err + } + if !field.allowZero && value == [32]byte{} { + return result, fmt.Errorf("bootstrap identity contains a zero pin") + } + *field.destination = value + } + return result, nil +} + +func frostNativeSignerAnchorBootstrapSignerConfig( + base []byte, + certificate *FrostNativeSignerAnchorTrustCertificate, +) ([]byte, error) { + value, err := decodeCanonicalFrostNativeSignerProvisioningObject(base) + if err != nil { + return nil, err + } + profile, ok := value["profile"].(string) + if !ok || profile != "production" { + return nil, fmt.Errorf( + "base native signer config must explicitly select profile production", + ) + } + statePath, ok := value["state_path"].(string) + if !ok || !filepath.IsAbs(statePath) || + filepath.Clean(statePath) != statePath { + return nil, fmt.Errorf( + "base native signer config must contain a canonical absolute state_path", + ) + } + if purpose, present := value["purpose"]; present && + purpose != "normal_signer" { + return nil, fmt.Errorf( + "base native signer config purpose is not normal_signer", + ) + } + derived := map[string]interface{}{ + "purpose": "normal_signer", + "state_anchor_protocol_id": frostNativeSignerAnchorHex32( + certificate.ProtocolID, + ), + "state_anchor_stream_id": frostNativeSignerAnchorHex32( + certificate.StreamID, + ), + "state_anchor_activation_manifest_hash": frostNativeSignerAnchorHex32( + certificate.To.ActivationManifestHash, + ), + "state_anchor_activation_manifest_sequence": json.Number( + strconv.FormatUint( + certificate.To.ActivationManifestSequence, + 10, + ), + ), + "state_anchor_binding_hash": frostNativeSignerAnchorHex32( + certificate.To.BindingHash, + ), + "state_anchor_response_public_key": frostNativeSignerAnchorHex32( + certificate.To.ResponsePublicKey, + ), + "state_anchor_response_public_key_spki_sha256": frostNativeSignerAnchorHex32( + certificate.To.ResponsePublicKeySPKISHA256, + ), + "state_anchor_offline_authority_public_key": frostNativeSignerAnchorHex32( + certificate.To.OfflineAuthorityPublicKey, + ), + "state_anchor_offline_authority_public_key_spki_sha256": frostNativeSignerAnchorHex32( + certificate.To.OfflineAuthoritySPKISHA256, + ), + "state_anchor_trust_certificate_sequence": json.Number( + strconv.FormatUint(certificate.CertificateSequence, 10), + ), + "state_anchor_trust_certificate_digest": frostNativeSignerAnchorHex32( + certificate.CertificateDigest, + ), + "state_witness_max_records": json.Number( + strconv.FormatUint(certificate.To.WitnessMaximumRecords, 10), + ), + "state_witness_rotation_threshold_records": json.Number( + strconv.FormatUint( + certificate.To.WitnessRotationThresholdRecords, + 10, + ), + ), + } + for key, expected := range derived { + if existing, present := value[key]; present && + !reflect.DeepEqual(existing, expected) { + return nil, fmt.Errorf( + "base native signer config field [%s] conflicts with the certified value", + key, + ) + } + value[key] = expected + } + return json.Marshal(value) +} + +func decodeCanonicalFrostNativeSignerProvisioningObject( + data []byte, +) (map[string]interface{}, error) { + if err := preflightFrostNativeSignerProvisioningJSON(data); err != nil { + return nil, err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + value := make(map[string]interface{}) + if err := decoder.Decode(&value); err != nil { + return nil, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("provisioning JSON contains trailing data") + } + if err := validateCanonicalFrostNativeSignerProvisioningValue( + value, + ); err != nil { + return nil, err + } + return value, nil +} + +func validateCanonicalFrostNativeSignerProvisioningValue( + value interface{}, +) error { + switch typed := value.(type) { + case nil, bool, string: + return nil + case json.Number: + encoded := string(typed) + if encoded == "" || + (len(encoded) > 1 && encoded[0] == '0') { + return fmt.Errorf("provisioning JSON number is not canonical uint64") + } + if _, err := strconv.ParseUint(encoded, 10, 64); err != nil { + return fmt.Errorf("provisioning JSON number is not canonical uint64") + } + return nil + case []interface{}: + for _, item := range typed { + if err := validateCanonicalFrostNativeSignerProvisioningValue( + item, + ); err != nil { + return err + } + } + return nil + case map[string]interface{}: + for _, item := range typed { + if err := validateCanonicalFrostNativeSignerProvisioningValue( + item, + ); err != nil { + return err + } + } + return nil + default: + return fmt.Errorf("provisioning JSON contains an unsupported value") + } +} + +func decodeStrictFrostNativeSignerAnchorProvisioningJSON( + data []byte, + target interface{}, +) error { + if len(data) == 0 || + int64(len(data)) > + FrostNativeSignerAnchorProvisioningArtifactMaximumBytes { + return fmt.Errorf("native signer anchor provisioning artifact size is invalid") + } + if err := preflightFrostNativeSignerProvisioningJSON(data); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("native signer anchor provisioning JSON contains trailing data") + } + return nil +} + +func preflightFrostNativeSignerProvisioningJSON(data []byte) error { + const maximumDepth = 32 + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var scan func(int) error + scan = func(depth int) error { + if depth > maximumDepth { + return fmt.Errorf("provisioning JSON exceeds the depth bound") + } + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + seen := make(map[string]struct{}) + folded := make(map[string]struct{}) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok || key == "" { + return fmt.Errorf( + "provisioning JSON member name is invalid", + ) + } + for _, character := range key { + if character < 0x21 || character > 0x7e { + return fmt.Errorf( + "provisioning JSON member name is not canonical ASCII", + ) + } + } + lower := strings.ToLower(key) + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf( + "provisioning JSON contains duplicate member [%s]", + key, + ) + } + if _, duplicate := folded[lower]; duplicate { + return fmt.Errorf( + "provisioning JSON contains case-folded duplicate member [%s]", + key, + ) + } + seen[key] = struct{}{} + folded[lower] = struct{}{} + if err := scan(depth + 1); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim('}') { + return fmt.Errorf( + "provisioning JSON object termination is invalid", + ) + } + case '[': + for decoder.More() { + if err := scan(depth + 1); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim(']') { + return fmt.Errorf( + "provisioning JSON array termination is invalid", + ) + } + default: + return fmt.Errorf("provisioning JSON delimiter is invalid") + } + return nil + } + if err := scan(0); err != nil { + return err + } + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("provisioning JSON contains trailing data") + } + return err + } + return nil +} diff --git a/pkg/tbtc/frost_native_signer_anchor_provisioning_files.go b/pkg/tbtc/frost_native_signer_anchor_provisioning_files.go new file mode 100644 index 0000000000..8ac13dbe72 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_provisioning_files.go @@ -0,0 +1,306 @@ +package tbtc + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "syscall" + + "golang.org/x/sys/unix" +) + +const FrostNativeSignerAnchorProvisioningArtifactMaximumBytes int64 = 16 * 1024 * 1024 + +// ReadFrostNativeSignerAnchorProvisioningArtifact opens an immutable ceremony +// artifact without following the final path component and validates the +// opened descriptor itself. Ceremony files are integrity-sensitive even when +// they contain only public material, so they use the same owner-only posture +// as signer configuration. +func ReadFrostNativeSignerAnchorProvisioningArtifact( + path string, + maximumBytes int64, +) ([]byte, error) { + directory, name, err := frostNativeSignerAnchorProvisioningPath(path) + if err != nil { + return nil, err + } + if maximumBytes <= 0 || + maximumBytes > FrostNativeSignerAnchorProvisioningArtifactMaximumBytes { + return nil, fmt.Errorf("provisioning artifact byte bound is invalid") + } + directoryFile, err := openFrostNativeSignerAnchorProvisioningDirectory( + directory, + ) + if err != nil { + return nil, err + } + defer directoryFile.Close() + fd, err := unix.Openat( + int(directoryFile.Fd()), + name, + unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0, + ) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), path) + if file == nil { + _ = unix.Close(fd) + return nil, fmt.Errorf("cannot wrap provisioning artifact descriptor") + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if err := validateFrostNativeSignerAnchorProvisioningFileInfo( + info, + ); err != nil { + return nil, err + } + if info.Size() <= 0 || info.Size() > maximumBytes { + return nil, fmt.Errorf("provisioning artifact size is invalid") + } + data, err := io.ReadAll(io.LimitReader(file, maximumBytes+1)) + if err != nil { + return nil, err + } + if len(data) == 0 || int64(len(data)) > maximumBytes { + return nil, fmt.Errorf("provisioning artifact size is invalid") + } + return data, nil +} + +// WriteFrostNativeSignerAnchorProvisioningArtifact publishes one immutable +// artifact with no-replace semantics. The file and containing directory are +// fsynced before success is returned, and a racing or pre-existing destination +// is never overwritten. +func WriteFrostNativeSignerAnchorProvisioningArtifact( + path string, + data []byte, +) error { + directory, name, err := frostNativeSignerAnchorProvisioningPath(path) + if err != nil { + return err + } + if len(data) == 0 || + int64(len(data)) > + FrostNativeSignerAnchorProvisioningArtifactMaximumBytes { + return fmt.Errorf("provisioning artifact size is invalid") + } + directoryFile, err := openFrostNativeSignerAnchorProvisioningDirectory( + directory, + ) + if err != nil { + return err + } + defer directoryFile.Close() + directoryFD := int(directoryFile.Fd()) + + var destination unix.Stat_t + err = unix.Fstatat( + directoryFD, + name, + &destination, + unix.AT_SYMLINK_NOFOLLOW, + ) + if err == nil { + return fmt.Errorf("provisioning artifact already exists: [%s]", path) + } + if !errors.Is(err, unix.ENOENT) { + return err + } + + temporary, temporaryName, err := + createFrostNativeSignerAnchorProvisioningTemporary( + directoryFD, + directory, + name, + ) + if err != nil { + return err + } + removeTemporary := true + defer func() { + if removeTemporary { + _ = unix.Unlinkat(directoryFD, temporaryName, 0) + } + }() + written, err := temporary.Write(data) + if err != nil { + _ = temporary.Close() + return err + } + if written != len(data) { + _ = temporary.Close() + return fmt.Errorf( + "provisioning artifact short write: [%d] of [%d] bytes", + written, + len(data), + ) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := unix.Linkat( + directoryFD, + temporaryName, + directoryFD, + name, + 0, + ); err != nil { + return fmt.Errorf("cannot publish provisioning artifact: %w", err) + } + if err := unix.Unlinkat(directoryFD, temporaryName, 0); err != nil { + return err + } + removeTemporary = false + return directoryFile.Sync() +} + +func frostNativeSignerAnchorProvisioningPath( + path string, +) (string, string, error) { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return "", "", fmt.Errorf( + "provisioning artifact path is not canonical absolute", + ) + } + directory := filepath.Dir(path) + name := filepath.Base(path) + if !filepath.IsLocal(name) || name == "." || name == string(filepath.Separator) { + return "", "", fmt.Errorf("provisioning artifact file name is invalid") + } + return directory, name, nil +} + +func openFrostNativeSignerAnchorProvisioningDirectory( + directory string, +) (*os.File, error) { + fd, err := unix.Open( + directory, + unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0, + ) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), directory) + if file == nil { + _ = unix.Close(fd) + return nil, fmt.Errorf("cannot wrap provisioning directory descriptor") + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || + info.Mode().Perm() != 0700 { + _ = file.Close() + return nil, fmt.Errorf( + "provisioning directory must be a non-symlink owner-only 0700 directory", + ) + } + if err := validateFrostNativeSignerAnchorProvisioningOwner(info); err != nil { + _ = file.Close() + return nil, err + } + return file, nil +} + +func createFrostNativeSignerAnchorProvisioningTemporary( + directoryFD int, + directory string, + finalName string, +) (*os.File, string, error) { + const maximumAttempts = 128 + var entropy [16]byte + for attempt := 0; attempt < maximumAttempts; attempt++ { + if _, err := io.ReadFull(rand.Reader, entropy[:]); err != nil { + return nil, "", err + } + name := finalName + "-" + hex.EncodeToString(entropy[:]) + ".tmp" + fd, err := unix.Openat( + directoryFD, + name, + unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0600, + ) + if errors.Is(err, unix.EEXIST) { + continue + } + if err != nil { + return nil, "", err + } + file := os.NewFile(uintptr(fd), filepath.Join(directory, name)) + if file == nil { + _ = unix.Close(fd) + _ = unix.Unlinkat(directoryFD, name, 0) + return nil, "", fmt.Errorf( + "cannot wrap provisioning temporary descriptor", + ) + } + if err := file.Chmod(0600); err != nil { + _ = file.Close() + _ = unix.Unlinkat(directoryFD, name, 0) + return nil, "", err + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + _ = unix.Unlinkat(directoryFD, name, 0) + return nil, "", err + } + if err := validateFrostNativeSignerAnchorProvisioningFileInfo( + info, + ); err != nil { + _ = file.Close() + _ = unix.Unlinkat(directoryFD, name, 0) + return nil, "", err + } + return file, name, nil + } + return nil, "", fmt.Errorf( + "cannot allocate a unique provisioning temporary file", + ) +} + +func validateFrostNativeSignerAnchorProvisioningFileInfo( + info os.FileInfo, +) error { + if info == nil || !info.Mode().IsRegular() || + info.Mode()&os.ModeSymlink != 0 || + info.Mode().Perm() != 0600 { + return fmt.Errorf( + "provisioning artifact must be a non-symlink owner-only 0600 regular file", + ) + } + return validateFrostNativeSignerAnchorProvisioningOwner(info) +} + +func validateFrostNativeSignerAnchorProvisioningOwner( + info os.FileInfo, +) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("cannot determine provisioning artifact owner") + } + if stat.Uid != uint32(os.Geteuid()) { + return fmt.Errorf( + "provisioning artifact is owned by uid [%d], expected [%d]", + stat.Uid, + os.Geteuid(), + ) + } + return nil +} From d3fdb9a7671dab5527322bbd303cdfe286daef24 Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 26 Jul 2026 19:32:51 -0400 Subject: [PATCH 17/93] test(tbtc): cover native signer anchor bootstrap provisioning ceremony - pkg/frost/signing: bootstrap-facts round trip plus a strict-decode table (schema, non-canonical/zero/missing fingerprints, cross-store checkpoint, non-genesis generation and predecessor, commitment mismatch, duplicate and case-folded members, trailing data, depth bomb, non-canonical numbers) and direct preflight tests exercising every rejection class with an exact depth-bound corpus. - pkg/tbtc: round trips and tamper tables for the plan, core artifact, detached signature, final artifact, and output bundle; Initialize divergence coverage against a simulated history service (binding hash, operation ID, transition digest, checkpoint, epoch, revision, previous event root, tampered/missing acknowledgement, missing read recovery) plus stage/digest/authority signature rejections; Finalize rejection of wrong-stage, tampered signatures, tampered certificates, and a non-canonical base-config table; output-bundle rejection of rehashed tampered chains, duplicated chains, and non-canonical configs. - pkg/tbtc: provisioning artifact writer/reader coverage - exact 0600 bytes, refused overwrite, no temporary residue, symlink and non-0700 directory rejection, byte-bound and mode enforcement. - cmd: end-to-end facts->core->initialize->finalize ceremony through the injected client factory with real Ed25519 authority keys and a local reimplementation of the acknowledgement transcripts, plus transport-unavailable, non-canonical-path, required-flag, and fail-closed facts assertions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DpftF6aYHh56KgiHU6D2NQ --- cmd/tbtc_signer_test.go | 601 ++++++++ ...tbtc_signer_state_anchor_bootstrap_test.go | 332 +++++ ...e_signer_anchor_provisioning_files_test.go | 271 ++++ ..._native_signer_anchor_provisioning_test.go | 1228 +++++++++++++++++ 4 files changed, 2432 insertions(+) create mode 100644 cmd/tbtc_signer_test.go create mode 100644 pkg/frost/signing/native_tbtc_signer_state_anchor_bootstrap_test.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_provisioning_files_test.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_provisioning_test.go diff --git a/cmd/tbtc_signer_test.go b/cmd/tbtc_signer_test.go new file mode 100644 index 0000000000..3029f9a669 --- /dev/null +++ b/cmd/tbtc_signer_test.go @@ -0,0 +1,601 @@ +package cmd + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +type tbtcSignerBootstrapTestFixture struct { + plan *tbtc.FrostNativeSignerAnchorBootstrapPlan + facts *frostsigning.NativeTBTCSignerStateAnchorBootstrapFacts + authority ed25519.PrivateKey + response ed25519.PrivateKey +} + +func tbtcSignerBootstrapTestBytes32(value byte) [32]byte { + result := [32]byte{} + for index := range result { + result[index] = value + } + return result +} + +func tbtcSignerBootstrapTestHex32(value [32]byte) string { + return "0x" + hex.EncodeToString(value[:]) +} + +func tbtcSignerBootstrapTestPublicKey( + privateKey ed25519.PrivateKey, +) [ed25519.PublicKeySize]byte { + result := [ed25519.PublicKeySize]byte{} + copy(result[:], privateKey.Public().(ed25519.PublicKey)) + return result +} + +func newTBTCSignerBootstrapTestFixture() *tbtcSignerBootstrapTestFixture { + authority := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x41}, ed25519.SeedSize), + ) + response := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x42}, ed25519.SeedSize), + ) + authorityPublic := tbtcSignerBootstrapTestPublicKey(authority) + responsePublic := tbtcSignerBootstrapTestPublicKey(response) + endpoint := "http://127.0.0.1:9788/anchor" + store := tbtcSignerBootstrapTestBytes32(0x13) + identity := tbtc.FrostNativeSignerAnchorIdentity{ + ProtocolID: tbtcSignerBootstrapTestBytes32(0x11), + ActivationManifestHash: tbtcSignerBootstrapTestBytes32(0x14), + ActivationManifestSequence: 7, + TrustDomainID: "cli-bootstrap-trust-domain", + OnlineKeyHash: tbtc.ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + responsePublic, + ), + OperatorFingerprint: tbtcSignerBootstrapTestBytes32(0x16), + HistoryStoreID: "cli-bootstrap-history-store", + HistoryStoreFingerprint: tbtcSignerBootstrapTestBytes32(0x17), + HistoryClusterFingerprint: tbtcSignerBootstrapTestBytes32(0x18), + OfflineAuthorityHash: tbtc.ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + authorityPublic, + ), + ClientSPKIHash: tbtcSignerBootstrapTestBytes32(0x19), + SignerStoreFingerprint: store, + TransportBinding: tbtc.ComputeFrostNativeSignerAnchorTransportBinding( + endpoint, + ), + WitnessMaximumRecords: 1000, + WitnessRotationThresholdRecords: 900, + } + identity.StreamID = tbtc.ComputeFrostNativeSignerAnchorStreamID(identity) + genesis := frostsigning.ComputeNativeTBTCSignerStateWitnessGenesis(store) + image := tbtcSignerBootstrapTestBytes32(0x1a) + return &tbtcSignerBootstrapTestFixture{ + plan: &tbtc.FrostNativeSignerAnchorBootstrapPlan{ + Schema: tbtc.FrostNativeSignerAnchorBootstrapPlanSchema, + Endpoint: endpoint, + Identity: identity, + ResponsePublicKey: responsePublic, + OfflineAuthorityPublicKey: authorityPublic, + }, + facts: &frostsigning.NativeTBTCSignerStateAnchorBootstrapFacts{ + Schema: frostsigning.NativeTBTCSignerStateAnchorBootstrapFactsSchema, + StoreFingerprint: store, + CurrentCheckpoint: frostsigning.NativeTBTCSignerStateAnchorCheckpoint{ + StoreFingerprint: store, + Generation: 1, + PreviousStateCommitment: genesis, + StateImageDigest: image, + StateCommitment: frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + store, + 1, + genesis, + image, + ), + }, + }, + authority: authority, + response: response, + } +} + +type tbtcSignerBootstrapTestCheckpointWire struct { + StoreFingerprint string `json:"storeFingerprint"` + Generation string `json:"generation"` + PreviousStateCommitment string `json:"previousStateCommitment"` + StateImageDigest string `json:"stateImageDigest"` + StateCommitment string `json:"stateCommitment"` +} + +type tbtcSignerBootstrapTestAcknowledgementWire struct { + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + RequestDigest string `json:"requestDigest"` + Nonce string `json:"nonce"` + Status string `json:"status"` + ServiceEpoch string `json:"serviceEpoch"` + Revision string `json:"revision"` + PreviousEventRoot string `json:"previousEventRoot"` + EventRoot string `json:"eventRoot"` + Checkpoint tbtcSignerBootstrapTestCheckpointWire `json:"checkpoint"` + OperationID string `json:"operationID"` + TransitionDigest string `json:"transitionDigest"` + CommittedAtUnixMs string `json:"committedAtUnixMs"` + ExpiresAtUnixMs string `json:"expiresAtUnixMs"` + Signature string `json:"signature"` +} + +// tbtcSignerBootstrapTestRecord is a minimal local reimplementation of the +// history-service acknowledgement transcripts. The tbtc package fixtures are +// package-private, so the CLI test reproduces the exact domain-separated +// hashes the anchor protocol pins. +func tbtcSignerBootstrapTestRecord( + certificate *tbtc.FrostNativeSignerAnchorTrustCertificate, + response ed25519.PrivateKey, +) (*tbtc.FrostNativeSignerStateWitnessAnchorRecord, error) { + const ( + serviceEpoch = uint64(1) + revision = uint64(1) + committedAt = uint64(1_700_000_000_000) + expiresAt = uint64(1_700_000_020_000) + statusByte = byte(0x01) + ) + bindingHash := certificate.To.BindingHash + requestDigest := tbtcSignerBootstrapTestBytes32(0x21) + nonce := tbtcSignerBootstrapTestBytes32(0x22) + checkpoint := certificate.To.Reference.Checkpoint + previousEventRoot := [32]byte{} + + writeCheckpoint := func(buffer *bytes.Buffer) { + buffer.Write(checkpoint.StoreFingerprint[:]) + _ = binary.Write(buffer, binary.BigEndian, checkpoint.Generation) + buffer.Write(checkpoint.PreviousStateCommitment[:]) + buffer.Write(checkpoint.StateImageDigest[:]) + buffer.Write(checkpoint.StateCommitment[:]) + } + writeTail := func(buffer *bytes.Buffer) { + buffer.Write(certificate.OperationID[:]) + buffer.Write(certificate.TransitionDigest[:]) + _ = binary.Write(buffer, binary.BigEndian, committedAt) + _ = binary.Write(buffer, binary.BigEndian, expiresAt) + } + + eventBuffer := bytes.NewBuffer(nil) + eventBuffer.WriteString("tbtc-native-signer-state-anchor-event/v1\x00") + eventBuffer.Write(bindingHash[:]) + _ = binary.Write(eventBuffer, binary.BigEndian, serviceEpoch) + _ = binary.Write(eventBuffer, binary.BigEndian, revision) + eventBuffer.Write(previousEventRoot[:]) + eventBuffer.Write(requestDigest[:]) + eventBuffer.Write(nonce[:]) + eventBuffer.WriteByte(statusByte) + writeCheckpoint(eventBuffer) + writeTail(eventBuffer) + eventRoot := sha256.Sum256(eventBuffer.Bytes()) + + signingBuffer := bytes.NewBuffer(nil) + signingBuffer.WriteString( + "tbtc-native-signer-state-anchor-service-response/v1\x00", + ) + signingBuffer.Write(bindingHash[:]) + signingBuffer.Write(requestDigest[:]) + signingBuffer.Write(nonce[:]) + signingBuffer.WriteByte(statusByte) + _ = binary.Write(signingBuffer, binary.BigEndian, serviceEpoch) + _ = binary.Write(signingBuffer, binary.BigEndian, revision) + signingBuffer.Write(previousEventRoot[:]) + signingBuffer.Write(eventRoot[:]) + writeCheckpoint(signingBuffer) + writeTail(signingBuffer) + signingDigest := sha256.Sum256(signingBuffer.Bytes()) + signature := ed25519.Sign(response, signingDigest[:]) + + acknowledgementHasher := sha256.New() + acknowledgementHasher.Write( + []byte("tbtc-signer-state-anchor-acknowledgement/v1\x00"), + ) + acknowledgementHasher.Write(signingDigest[:]) + acknowledgementHasher.Write(signature) + acknowledgementHasher.Write(certificate.To.ResponsePublicKeySPKISHA256[:]) + acknowledgementDigest := [32]byte{} + copy(acknowledgementDigest[:], acknowledgementHasher.Sum(nil)) + + raw, err := json.Marshal(tbtcSignerBootstrapTestAcknowledgementWire{ + Schema: tbtc.FrostNativeSignerCheckpointAcknowledgementSchema, + BindingHash: tbtcSignerBootstrapTestHex32(bindingHash), + RequestDigest: tbtcSignerBootstrapTestHex32(requestDigest), + Nonce: tbtcSignerBootstrapTestHex32(nonce), + Status: "applied", + ServiceEpoch: "1", + Revision: "1", + PreviousEventRoot: tbtcSignerBootstrapTestHex32(previousEventRoot), + EventRoot: tbtcSignerBootstrapTestHex32(eventRoot), + Checkpoint: tbtcSignerBootstrapTestCheckpointWire{ + StoreFingerprint: tbtcSignerBootstrapTestHex32( + checkpoint.StoreFingerprint, + ), + Generation: "1", + PreviousStateCommitment: tbtcSignerBootstrapTestHex32( + checkpoint.PreviousStateCommitment, + ), + StateImageDigest: tbtcSignerBootstrapTestHex32( + checkpoint.StateImageDigest, + ), + StateCommitment: tbtcSignerBootstrapTestHex32( + checkpoint.StateCommitment, + ), + }, + OperationID: tbtcSignerBootstrapTestHex32(certificate.OperationID), + TransitionDigest: tbtcSignerBootstrapTestHex32( + certificate.TransitionDigest, + ), + CommittedAtUnixMs: "1700000000000", + ExpiresAtUnixMs: "1700000020000", + Signature: "0x" + hex.EncodeToString(signature), + }) + if err != nil { + return nil, err + } + return &tbtc.FrostNativeSignerStateWitnessAnchorRecord{ + Checkpoint: checkpoint, + BindingHash: bindingHash, + AcknowledgementDigest: acknowledgementDigest, + OperationID: certificate.OperationID, + TransitionDigest: certificate.TransitionDigest, + ServiceEpoch: serviceEpoch, + Revision: revision, + PreviousEventRoot: previousEventRoot, + EventRoot: eventRoot, + AcknowledgementJSON: raw, + AcknowledgementExpires: expiresAt, + ReadRecoveryJSON: []byte(`{"readRecovery":"fresh"}`), + ReadRecoveryExpires: expiresAt, + }, nil +} + +type tbtcSignerBootstrapTestClient struct { + response ed25519.PrivateKey +} + +func (client *tbtcSignerBootstrapTestClient) InitializeFrostNativeSignerAnchor( + _ context.Context, + authorization tbtc.FrostNativeSignerAnchorBootstrapAuthorization, +) (*tbtc.FrostNativeSignerAnchorBootstrapClientResult, error) { + record, err := tbtcSignerBootstrapTestRecord( + &authorization.Certificate, + client.response, + ) + if err != nil { + return nil, err + } + return &tbtc.FrostNativeSignerAnchorBootstrapClientResult{ + Record: record, + }, nil +} + +func runTBTCSignerBootstrapCommand( + t *testing.T, + clientFactory tbtcSignerAnchorBootstrapClientFactory, + args ...string, +) error { + t.Helper() + command := newTBTCSignerCommand(clientFactory) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + command.SetArgs(args) + return command.Execute() +} + +func writeTBTCSignerBootstrapTestArtifact( + t *testing.T, + path string, + data []byte, +) { + t.Helper() + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } +} + +func TestFrostNativeSignerAnchorBootstrapCommandCeremony(t *testing.T) { + fixture := newTBTCSignerBootstrapTestFixture() + directory := t.TempDir() + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + + factsPath := filepath.Join(directory, "facts.json") + factsJSON, err := frostsigning.EncodeNativeTBTCSignerStateAnchorBootstrapFacts( + fixture.facts, + ) + if err != nil { + t.Fatal(err) + } + writeTBTCSignerBootstrapTestArtifact(t, factsPath, factsJSON) + + planPath := filepath.Join(directory, "plan.json") + planJSON, err := tbtc.EncodeFrostNativeSignerAnchorBootstrapPlan( + fixture.plan, + ) + if err != nil { + t.Fatal(err) + } + writeTBTCSignerBootstrapTestArtifact(t, planPath, planJSON) + + corePath := filepath.Join(directory, "core.json") + if err := runTBTCSignerBootstrapCommand( + t, + nil, + "anchor", "bootstrap", "core", + "--facts", factsPath, + "--plan", planPath, + "--output", corePath, + ); err != nil { + t.Fatalf("bootstrap core command failed: %v", err) + } + coreJSON, err := os.ReadFile(corePath) + if err != nil { + t.Fatal(err) + } + core, err := tbtc.DecodeFrostNativeSignerAnchorBootstrapCoreArtifact( + coreJSON, + ) + if err != nil { + t.Fatalf("bootstrap core command emitted an invalid artifact: %v", err) + } + + coreSignaturePath := filepath.Join(directory, "core-signature.json") + coreSignature := &tbtc.FrostNativeSignerAnchorBootstrapDetachedSignature{ + Schema: tbtc.FrostNativeSignerAnchorBootstrapDetachedSignatureSchema, + Stage: tbtc.FrostNativeSignerAnchorBootstrapCoreSignatureStage, + Digest: core.CoreDigest, + } + copy( + coreSignature.Signature[:], + ed25519.Sign(fixture.authority, core.CoreDigest[:]), + ) + coreSignatureJSON, err := + tbtc.EncodeFrostNativeSignerAnchorBootstrapDetachedSignature( + coreSignature, + ) + if err != nil { + t.Fatal(err) + } + writeTBTCSignerBootstrapTestArtifact( + t, + coreSignaturePath, + coreSignatureJSON, + ) + + clientConfigPath := filepath.Join(directory, "client-config.json") + factory := func( + _ context.Context, + configPath string, + ) (tbtc.FrostNativeSignerAnchorBootstrapClient, error) { + if configPath != clientConfigPath { + t.Fatalf( + "bootstrap client factory received path %q", + configPath, + ) + } + return &tbtcSignerBootstrapTestClient{ + response: fixture.response, + }, nil + } + finalPath := filepath.Join(directory, "final.json") + if err := runTBTCSignerBootstrapCommand( + t, + factory, + "anchor", "bootstrap", "initialize", + "--core", corePath, + "--core-signature", coreSignaturePath, + "--client-config", clientConfigPath, + "--output", finalPath, + ); err != nil { + t.Fatalf("bootstrap initialize command failed: %v", err) + } + finalJSON, err := os.ReadFile(finalPath) + if err != nil { + t.Fatal(err) + } + final, err := tbtc.DecodeFrostNativeSignerAnchorBootstrapFinalArtifact( + finalJSON, + ) + if err != nil { + t.Fatalf("bootstrap initialize emitted an invalid artifact: %v", err) + } + if final.Core.CoreDigest != core.CoreDigest || + final.TargetReference.ServiceEpoch != 1 || + final.TargetReference.Revision != 1 { + t.Fatalf("unexpected bootstrap final artifact: %+v", final) + } + + finalSignaturePath := filepath.Join(directory, "final-signature.json") + finalSignature := &tbtc.FrostNativeSignerAnchorBootstrapDetachedSignature{ + Schema: tbtc.FrostNativeSignerAnchorBootstrapDetachedSignatureSchema, + Stage: tbtc.FrostNativeSignerAnchorBootstrapFinalSignatureStage, + Digest: final.FinalDigest, + } + copy( + finalSignature.Signature[:], + ed25519.Sign(fixture.authority, final.FinalDigest[:]), + ) + finalSignatureJSON, err := + tbtc.EncodeFrostNativeSignerAnchorBootstrapDetachedSignature( + finalSignature, + ) + if err != nil { + t.Fatal(err) + } + writeTBTCSignerBootstrapTestArtifact( + t, + finalSignaturePath, + finalSignatureJSON, + ) + + baseConfigPath := filepath.Join(directory, "base-config.json") + writeTBTCSignerBootstrapTestArtifact( + t, + baseConfigPath, + []byte(`{"profile":"production","state_path":"/var/lib/keep/tbtc-signer"}`), + ) + + bundlePath := filepath.Join(directory, "bundle.json") + if err := runTBTCSignerBootstrapCommand( + t, + nil, + "anchor", "bootstrap", "finalize", + "--final", finalPath, + "--final-signature", finalSignaturePath, + "--base-config", baseConfigPath, + "--output", bundlePath, + ); err != nil { + t.Fatalf("bootstrap finalize command failed: %v", err) + } + bundleJSON, err := os.ReadFile(bundlePath) + if err != nil { + t.Fatal(err) + } + bundle, err := tbtc.DecodeFrostNativeSignerAnchorBootstrapOutputBundle( + bundleJSON, + ) + if err != nil { + t.Fatalf("bootstrap finalize emitted an invalid bundle: %v", err) + } + if len(bundle.CertificateChain) != 1 || + bundle.CertificateChain[0].Kind != + tbtc.FrostNativeSignerAnchorTrustCertificateBootstrap || + bundle.CertificateChain[0].To.Reference.Checkpoint != + core.Checkpoint { + t.Fatalf("unexpected bootstrap output bundle: %+v", bundle) + } +} + +func TestFrostNativeSignerAnchorBootstrapCommandInitializeWithoutTransport( + t *testing.T, +) { + directory := t.TempDir() + err := runTBTCSignerBootstrapCommand( + t, + nil, + "anchor", "bootstrap", "initialize", + "--core", filepath.Join(directory, "core.json"), + "--core-signature", filepath.Join(directory, "core-signature.json"), + "--client-config", filepath.Join(directory, "client-config.json"), + "--output", filepath.Join(directory, "final.json"), + ) + if err == nil || + !strings.Contains(err.Error(), "transport is not available") { + t.Fatalf("initialize without a transport factory returned: %v", err) + } +} + +func TestFrostNativeSignerAnchorBootstrapCommandRejectsNonCanonicalClientConfig( + t *testing.T, +) { + directory := t.TempDir() + factory := func( + _ context.Context, + _ string, + ) (tbtc.FrostNativeSignerAnchorBootstrapClient, error) { + t.Fatal("client factory was invoked for a non-canonical path") + return nil, nil + } + for _, path := range []string{ + "relative/client-config.json", + filepath.Join(directory, "sub", "..", "client-config.json") + "/", + } { + err := runTBTCSignerBootstrapCommand( + t, + factory, + "anchor", "bootstrap", "initialize", + "--core", filepath.Join(directory, "core.json"), + "--core-signature", filepath.Join(directory, "core-signature.json"), + "--client-config", path, + "--output", filepath.Join(directory, "final.json"), + ) + if err == nil || + !strings.Contains(err.Error(), "not canonical absolute") { + t.Fatalf( + "initialize with client config path %q returned: %v", + path, + err, + ) + } + } +} + +func TestFrostNativeSignerAnchorBootstrapCommandRequiresFlags(t *testing.T) { + for _, subcommand := range []string{ + "facts", + "core", + "initialize", + "finalize", + } { + err := runTBTCSignerBootstrapCommand( + t, + nil, + "anchor", "bootstrap", subcommand, + ) + if err == nil || + !strings.Contains(err.Error(), "required flag(s)") { + t.Fatalf( + "bootstrap %s without flags returned: %v", + subcommand, + err, + ) + } + } +} + +func TestFrostNativeSignerAnchorBootstrapCommandFactsFailsClosed(t *testing.T) { + directory := t.TempDir() + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + provisioningConfigPath := filepath.Join( + directory, + "provisioning-config.json", + ) + writeTBTCSignerBootstrapTestArtifact( + t, + provisioningConfigPath, + []byte(`{"purpose":"state_anchor_bootstrap_provisioning",`+ + `"profile":"production",`+ + `"state_path":"/var/lib/keep/tbtc-signer",`+ + `"state_witness_max_records":4}`), + ) + outputPath := filepath.Join(directory, "facts.json") + err := runTBTCSignerBootstrapCommand( + t, + nil, + "anchor", "bootstrap", "facts", + "--provisioning-config", provisioningConfigPath, + "--output", outputPath, + ) + if err == nil { + t.Fatal("facts command succeeded without the native tbtc-signer bridge") + } + if !errors.Is(err, frostsigning.ErrNativeCryptographyUnavailable) { + t.Skipf( + "facts command failed for a non-default-build reason: %v", + err, + ) + } + if _, statErr := os.Lstat(outputPath); !os.IsNotExist(statErr) { + t.Fatal("failed facts command still produced an output artifact") + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_bootstrap_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_bootstrap_test.go new file mode 100644 index 0000000000..df618d749c --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_bootstrap_test.go @@ -0,0 +1,332 @@ +package signing + +import ( + "encoding/json" + "strings" + "testing" +) + +func testNativeTBTCSignerBootstrapFacts() *NativeTBTCSignerStateAnchorBootstrapFacts { + store := [32]byte{0x5a} + genesis := ComputeNativeTBTCSignerStateWitnessGenesis(store) + image := [32]byte{0x5b} + commitment := ComputeNativeTBTCSignerStateWitnessCommitment( + store, + 1, + genesis, + image, + ) + return &NativeTBTCSignerStateAnchorBootstrapFacts{ + Schema: NativeTBTCSignerStateAnchorBootstrapFactsSchema, + StoreFingerprint: store, + CurrentCheckpoint: NativeTBTCSignerStateAnchorCheckpoint{ + StoreFingerprint: store, + Generation: 1, + PreviousStateCommitment: genesis, + StateImageDigest: image, + StateCommitment: commitment, + }, + } +} + +func testNativeTBTCSignerBootstrapFactsWire( + t *testing.T, + facts *NativeTBTCSignerStateAnchorBootstrapFacts, +) nativeTBTCSignerStateAnchorBootstrapFactsWire { + t.Helper() + return nativeTBTCSignerStateAnchorBootstrapFactsWire{ + Schema: facts.Schema, + StoreFingerprint: nativeTBTCSignerBytes32(facts.StoreFingerprint), + CurrentCheckpoint: nativeTBTCSignerStateAnchorCheckpointWire{ + StoreFingerprint: nativeTBTCSignerBytes32( + facts.CurrentCheckpoint.StoreFingerprint, + ), + Generation: uint64ToCanonicalString( + facts.CurrentCheckpoint.Generation, + ), + PreviousStateCommitment: nativeTBTCSignerBytes32( + facts.CurrentCheckpoint.PreviousStateCommitment, + ), + StateImageDigest: nativeTBTCSignerBytes32( + facts.CurrentCheckpoint.StateImageDigest, + ), + StateCommitment: nativeTBTCSignerBytes32( + facts.CurrentCheckpoint.StateCommitment, + ), + }, + } +} + +func TestNativeTBTCSignerStateAnchorBootstrapFactsRoundTrip(t *testing.T) { + facts := testNativeTBTCSignerBootstrapFacts() + encoded, err := EncodeNativeTBTCSignerStateAnchorBootstrapFacts(facts) + if err != nil { + t.Fatalf("valid bootstrap facts were rejected by the encoder: %v", err) + } + decoded, err := DecodeNativeTBTCSignerStateAnchorBootstrapFacts(encoded) + if err != nil { + t.Fatalf("canonical bootstrap facts were rejected: %v", err) + } + if *decoded != *facts { + t.Fatalf("bootstrap facts round trip diverged: %+v", decoded) + } +} + +func TestNativeTBTCSignerStateAnchorBootstrapFactsEncoderRejectsNonGenesis( + t *testing.T, +) { + if _, err := EncodeNativeTBTCSignerStateAnchorBootstrapFacts(nil); err == nil { + t.Fatal("nil bootstrap facts were encoded") + } + + nonGenesis := testNativeTBTCSignerBootstrapFacts() + nonGenesis.CurrentCheckpoint.Generation = 2 + nonGenesis.CurrentCheckpoint.StateCommitment = + ComputeNativeTBTCSignerStateWitnessCommitment( + nonGenesis.CurrentCheckpoint.StoreFingerprint, + 2, + nonGenesis.CurrentCheckpoint.PreviousStateCommitment, + nonGenesis.CurrentCheckpoint.StateImageDigest, + ) + if _, err := EncodeNativeTBTCSignerStateAnchorBootstrapFacts( + nonGenesis, + ); err == nil { + t.Fatal("generation-two bootstrap facts were encoded") + } + + crossStore := testNativeTBTCSignerBootstrapFacts() + crossStore.StoreFingerprint = [32]byte{0x5c} + if _, err := EncodeNativeTBTCSignerStateAnchorBootstrapFacts( + crossStore, + ); err == nil { + t.Fatal("cross-store bootstrap facts were encoded") + } +} + +func TestNativeTBTCSignerStateAnchorBootstrapFactsStrictDecode(t *testing.T) { + valid := testNativeTBTCSignerBootstrapFacts() + canonical, err := EncodeNativeTBTCSignerStateAnchorBootstrapFacts(valid) + if err != nil { + t.Fatal(err) + } + otherStore := [32]byte{0x6a} + otherGenesis := ComputeNativeTBTCSignerStateWitnessGenesis(otherStore) + tests := map[string]struct { + mutate func(*nativeTBTCSignerStateAnchorBootstrapFactsWire) + }{ + "wrong schema": { + mutate: func(wire *nativeTBTCSignerStateAnchorBootstrapFactsWire) { + wire.Schema = "tbtc-signer-state-anchor-bootstrap-facts/v2" + }, + }, + "non-canonical store fingerprint": { + mutate: func(wire *nativeTBTCSignerStateAnchorBootstrapFactsWire) { + wire.StoreFingerprint = strings.ToUpper(wire.StoreFingerprint) + }, + }, + "missing store fingerprint": { + mutate: func(wire *nativeTBTCSignerStateAnchorBootstrapFactsWire) { + wire.StoreFingerprint = "" + }, + }, + "zero store fingerprint": { + mutate: func(wire *nativeTBTCSignerStateAnchorBootstrapFactsWire) { + wire.StoreFingerprint = nativeTBTCSignerBytes32([32]byte{}) + }, + }, + "checkpoint store-fingerprint mismatch": { + mutate: func(wire *nativeTBTCSignerStateAnchorBootstrapFactsWire) { + checkpoint := testNativeTBTCSignerBootstrapFacts().CurrentCheckpoint + checkpoint.StoreFingerprint = otherStore + checkpoint.PreviousStateCommitment = otherGenesis + checkpoint.StateCommitment = + ComputeNativeTBTCSignerStateWitnessCommitment( + otherStore, + 1, + otherGenesis, + checkpoint.StateImageDigest, + ) + wire.CurrentCheckpoint.StoreFingerprint = + nativeTBTCSignerBytes32(checkpoint.StoreFingerprint) + wire.CurrentCheckpoint.PreviousStateCommitment = + nativeTBTCSignerBytes32(checkpoint.PreviousStateCommitment) + wire.CurrentCheckpoint.StateCommitment = + nativeTBTCSignerBytes32(checkpoint.StateCommitment) + }, + }, + "generation two": { + mutate: func(wire *nativeTBTCSignerStateAnchorBootstrapFactsWire) { + facts := testNativeTBTCSignerBootstrapFacts() + wire.CurrentCheckpoint.Generation = "2" + wire.CurrentCheckpoint.StateCommitment = + nativeTBTCSignerBytes32( + ComputeNativeTBTCSignerStateWitnessCommitment( + facts.StoreFingerprint, + 2, + facts.CurrentCheckpoint.PreviousStateCommitment, + facts.CurrentCheckpoint.StateImageDigest, + ), + ) + }, + }, + "non-genesis previous state commitment": { + mutate: func(wire *nativeTBTCSignerStateAnchorBootstrapFactsWire) { + facts := testNativeTBTCSignerBootstrapFacts() + previous := [32]byte{0x6b} + wire.CurrentCheckpoint.PreviousStateCommitment = + nativeTBTCSignerBytes32(previous) + wire.CurrentCheckpoint.StateCommitment = + nativeTBTCSignerBytes32( + ComputeNativeTBTCSignerStateWitnessCommitment( + facts.StoreFingerprint, + 1, + previous, + facts.CurrentCheckpoint.StateImageDigest, + ), + ) + }, + }, + "checkpoint commitment mismatch": { + mutate: func(wire *nativeTBTCSignerStateAnchorBootstrapFactsWire) { + wire.CurrentCheckpoint.StateCommitment = + nativeTBTCSignerBytes32([32]byte{0x6c}) + }, + }, + "non-canonical generation": { + mutate: func(wire *nativeTBTCSignerStateAnchorBootstrapFactsWire) { + wire.CurrentCheckpoint.Generation = "01" + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + wire := testNativeTBTCSignerBootstrapFactsWire(t, valid) + test.mutate(&wire) + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeNativeTBTCSignerStateAnchorBootstrapFacts( + payload, + ); err == nil { + t.Fatalf("bootstrap facts with %s were accepted", name) + } + }) + } + + payloadTests := map[string]string{ + "trailing data": string(canonical) + " {}", + "unknown member": strings.Replace( + string(canonical), + `"schema"`, + `"unknown":"x","schema"`, + 1, + ), + "duplicate member": strings.Replace( + string(canonical), + `"schema"`, + `"schema":"x","schema"`, + 1, + ), + "case-folded duplicate member": strings.Replace( + string(canonical), + `"schema"`, + `"Schema":"x","schema"`, + 1, + ), + "depth bomb": strings.Repeat("[", 40) + strings.Repeat("]", 40), + "empty payload": "", + } + for name, payload := range payloadTests { + t.Run(name, func(t *testing.T) { + if _, err := DecodeNativeTBTCSignerStateAnchorBootstrapFacts( + []byte(payload), + ); err == nil { + t.Fatalf("bootstrap facts payload with %s was accepted", name) + } + }) + } +} + +func TestPreflightStrictNativeTBTCSignerJSONAcceptsCanonicalPayloads( + t *testing.T, +) { + valid := []string{ + `{}`, + `[]`, + `"scalar"`, + `true`, + `null`, + `17`, + `{"a":1,"b":[true,null,1.5,"x"],"c":{"d":"e"}}`, + // Exactly at the depth bound: the innermost of 33 nested arrays is + // scanned at depth 32. + strings.Repeat("[", 33) + strings.Repeat("]", 33), + } + for _, payload := range valid { + if err := preflightStrictNativeTBTCSignerJSON( + []byte(payload), + 0, + ); err != nil { + t.Fatalf("canonical JSON %q was rejected: %v", payload, err) + } + } +} + +func TestPreflightStrictNativeTBTCSignerJSONRejections(t *testing.T) { + tests := map[string]string{ + "invalid JSON": `{`, + "unterminated array": `[1,`, + "object trailing data": `{} {}`, + "scalar trailing data": `1 2`, + "garbage trailing data": `{}x`, + "duplicate member": `{"a":1,"a":2}`, + "case-folded duplicate member": `{"a":1,"A":2}`, + "nested duplicate member": `{"a":{"b":1,"b":2}}`, + "empty member name": `{"":1}`, + "member name with space": `{"a b":1}`, + "member name outside ASCII": `{"ké":1}`, + "member name with control char": "{\"a\\u0001b\":1}", + "depth bomb": strings.Repeat("[", 34) + + strings.Repeat("]", 34), + "object depth bomb": strings.Repeat(`{"a":`, 34) + "1" + + strings.Repeat("}", 34), + } + for name, payload := range tests { + t.Run(name, func(t *testing.T) { + if err := preflightStrictNativeTBTCSignerJSON( + []byte(payload), + 0, + ); err == nil { + t.Fatalf("JSON with %s passed preflight", name) + } + }) + } + + if err := preflightStrictNativeTBTCSignerJSON([]byte(`{}`), 1); err == nil { + t.Fatal("preflight accepted a non-root starting depth") + } +} + +func TestNativeTBTCSignerStrictJSONDecodePreflightIsWired(t *testing.T) { + // decodeStrictNativeTBTCSignerJSON fronts every native-signer decoder; + // case-folded aliases must be rejected before Go's case-insensitive + // field matching can unify them. + target := &struct { + Schema string `json:"schema"` + }{} + if err := decodeStrictNativeTBTCSignerJSON( + []byte(`{"schema":"a","Schema":"b"}`), + target, + "test subject", + ); err == nil { + t.Fatal("case-folded duplicate members were accepted") + } + if err := decodeStrictNativeTBTCSignerJSON( + []byte(`{"schema":"a"}`), + target, + "test subject", + ); err != nil { + t.Fatalf("canonical strict JSON was rejected: %v", err) + } +} diff --git a/pkg/tbtc/frost_native_signer_anchor_provisioning_files_test.go b/pkg/tbtc/frost_native_signer_anchor_provisioning_files_test.go new file mode 100644 index 0000000000..cc065c0dcd --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_provisioning_files_test.go @@ -0,0 +1,271 @@ +package tbtc + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func bootstrapProvisioningTestDirectory(t *testing.T) string { + t.Helper() + directory := t.TempDir() + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + return directory +} + +func bootstrapProvisioningTestNoTemporaryResidue( + t *testing.T, + directory string, +) { + t.Helper() + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".tmp") { + t.Fatalf("provisioning temporary file was left behind: %s", entry.Name()) + } + } +} + +func TestFrostNativeSignerAnchorBootstrapProvisioningArtifactWriteRead( + t *testing.T, +) { + directory := bootstrapProvisioningTestDirectory(t) + path := filepath.Join(directory, "artifact.json") + data := []byte(`{"schema":"test-artifact/v1"}`) + if err := WriteFrostNativeSignerAnchorProvisioningArtifact( + path, + data, + ); err != nil { + t.Fatalf("valid provisioning artifact write failed: %v", err) + } + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { + t.Fatalf("provisioning artifact mode is %v, expected 0600 regular", info.Mode()) + } + stored, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(stored, data) { + t.Fatalf("provisioning artifact bytes diverged: %q", stored) + } + bootstrapProvisioningTestNoTemporaryResidue(t, directory) + + read, err := ReadFrostNativeSignerAnchorProvisioningArtifact( + path, + int64(len(data)), + ) + if err != nil { + t.Fatalf("valid provisioning artifact read failed: %v", err) + } + if !bytes.Equal(read, data) { + t.Fatalf("provisioning artifact read diverged: %q", read) + } + + if err := WriteFrostNativeSignerAnchorProvisioningArtifact( + path, + []byte("overwrite"), + ); err == nil || + !strings.Contains(err.Error(), "already exists") { + t.Fatalf("existing provisioning artifact was overwritten: %v", err) + } + stored, err = os.ReadFile(path) + if err != nil || !bytes.Equal(stored, data) { + t.Fatalf("refused overwrite still changed the artifact: %q, %v", stored, err) + } + bootstrapProvisioningTestNoTemporaryResidue(t, directory) +} + +func TestFrostNativeSignerAnchorBootstrapProvisioningArtifactWriteRejections( + t *testing.T, +) { + directory := bootstrapProvisioningTestDirectory(t) + data := []byte(`{"schema":"test-artifact/v1"}`) + + tests := map[string]struct { + path string + data []byte + }{ + "relative path": { + path: "artifact.json", + data: data, + }, + "non-canonical path": { + path: filepath.Join(directory, "sub", "..", "artifact.json") + string(filepath.Separator), + data: data, + }, + "empty data": { + path: filepath.Join(directory, "empty.json"), + data: nil, + }, + "oversize data": { + path: filepath.Join(directory, "oversize.json"), + data: make( + []byte, + FrostNativeSignerAnchorProvisioningArtifactMaximumBytes+1, + ), + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if err := WriteFrostNativeSignerAnchorProvisioningArtifact( + test.path, + test.data, + ); err == nil { + t.Fatalf("provisioning artifact write with %s succeeded", name) + } + }) + } + bootstrapProvisioningTestNoTemporaryResidue(t, directory) + + t.Run("symlinked target", func(t *testing.T) { + symlinked := filepath.Join(directory, "symlinked.json") + if err := os.Symlink("/nonexistent-target", symlinked); err != nil { + t.Fatal(err) + } + if err := WriteFrostNativeSignerAnchorProvisioningArtifact( + symlinked, + data, + ); err == nil { + t.Fatal("provisioning artifact write through a symlink succeeded") + } + bootstrapProvisioningTestNoTemporaryResidue(t, directory) + }) + + t.Run("group-accessible directory", func(t *testing.T) { + loose := bootstrapProvisioningTestDirectory(t) + if err := os.Chmod(loose, 0750); err != nil { + t.Fatal(err) + } + if err := WriteFrostNativeSignerAnchorProvisioningArtifact( + filepath.Join(loose, "artifact.json"), + data, + ); err == nil || + !strings.Contains(err.Error(), "0700") { + t.Fatalf("write into a non-0700 directory succeeded: %v", err) + } + }) + + t.Run("missing directory", func(t *testing.T) { + if err := WriteFrostNativeSignerAnchorProvisioningArtifact( + filepath.Join(directory, "missing", "artifact.json"), + data, + ); err == nil { + t.Fatal("write into a missing directory succeeded") + } + }) +} + +func TestFrostNativeSignerAnchorBootstrapProvisioningArtifactReadRejections( + t *testing.T, +) { + directory := bootstrapProvisioningTestDirectory(t) + path := filepath.Join(directory, "artifact.json") + data := []byte(`{"schema":"test-artifact/v1"}`) + if err := WriteFrostNativeSignerAnchorProvisioningArtifact( + path, + data, + ); err != nil { + t.Fatal(err) + } + + t.Run("relative path", func(t *testing.T) { + if _, err := ReadFrostNativeSignerAnchorProvisioningArtifact( + "artifact.json", + 1024, + ); err == nil { + t.Fatal("relative provisioning artifact path was read") + } + }) + t.Run("invalid byte bound", func(t *testing.T) { + for _, bound := range []int64{ + 0, + -1, + FrostNativeSignerAnchorProvisioningArtifactMaximumBytes + 1, + } { + if _, err := ReadFrostNativeSignerAnchorProvisioningArtifact( + path, + bound, + ); err == nil { + t.Fatalf("provisioning artifact byte bound %d was accepted", bound) + } + } + }) + t.Run("oversize artifact", func(t *testing.T) { + if _, err := ReadFrostNativeSignerAnchorProvisioningArtifact( + path, + int64(len(data))-1, + ); err == nil { + t.Fatal("provisioning artifact above the byte bound was read") + } + }) + t.Run("empty artifact", func(t *testing.T) { + empty := filepath.Join(directory, "empty.json") + if err := os.WriteFile(empty, nil, 0600); err != nil { + t.Fatal(err) + } + if _, err := ReadFrostNativeSignerAnchorProvisioningArtifact( + empty, + 1024, + ); err == nil { + t.Fatal("empty provisioning artifact was read") + } + }) + t.Run("wrong mode", func(t *testing.T) { + loose := filepath.Join(directory, "loose.json") + if err := os.WriteFile(loose, data, 0600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(loose, 0644); err != nil { + t.Fatal(err) + } + if _, err := ReadFrostNativeSignerAnchorProvisioningArtifact( + loose, + 1024, + ); err == nil || + !strings.Contains(err.Error(), "0600") { + t.Fatalf("group-readable provisioning artifact was read: %v", err) + } + }) + t.Run("symlinked artifact", func(t *testing.T) { + symlinked := filepath.Join(directory, "symlinked.json") + if err := os.Symlink(path, symlinked); err != nil { + t.Fatal(err) + } + if _, err := ReadFrostNativeSignerAnchorProvisioningArtifact( + symlinked, + 1024, + ); err == nil { + t.Fatal("symlinked provisioning artifact was read") + } + }) + t.Run("group-accessible directory", func(t *testing.T) { + loose := bootstrapProvisioningTestDirectory(t) + loosePath := filepath.Join(loose, "artifact.json") + if err := WriteFrostNativeSignerAnchorProvisioningArtifact( + loosePath, + data, + ); err != nil { + t.Fatal(err) + } + if err := os.Chmod(loose, 0750); err != nil { + t.Fatal(err) + } + if _, err := ReadFrostNativeSignerAnchorProvisioningArtifact( + loosePath, + 1024, + ); err == nil { + t.Fatal("provisioning artifact in a non-0700 directory was read") + } + }) +} diff --git a/pkg/tbtc/frost_native_signer_anchor_provisioning_test.go b/pkg/tbtc/frost_native_signer_anchor_provisioning_test.go new file mode 100644 index 0000000000..ef64e1cb89 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_provisioning_test.go @@ -0,0 +1,1228 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +type bootstrapProvisioningTestFixture struct { + endpoint string + plan *FrostNativeSignerAnchorBootstrapPlan + facts *frostsigning.NativeTBTCSignerStateAnchorBootstrapFacts + authority ed25519.PrivateKey + response ed25519.PrivateKey +} + +func newBootstrapProvisioningTestFixture() *bootstrapProvisioningTestFixture { + authority := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x61}, ed25519.SeedSize), + ) + response := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x62}, ed25519.SeedSize), + ) + authorityPublic := trustTestRawPublicKey(authority) + responsePublic := trustTestRawPublicKey(response) + endpoint := "http://127.0.0.1:9799/anchor" + store := trustTestBytes32(0x03) + identity := FrostNativeSignerAnchorIdentity{ + ProtocolID: trustTestBytes32(0x01), + ActivationManifestHash: trustTestBytes32(0x04), + ActivationManifestSequence: 9, + TrustDomainID: "bootstrap-trust-domain", + OnlineKeyHash: ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + responsePublic, + ), + OperatorFingerprint: trustTestBytes32(0x06), + HistoryStoreID: "bootstrap-history-store", + HistoryStoreFingerprint: trustTestBytes32(0x07), + HistoryClusterFingerprint: trustTestBytes32(0x08), + OfflineAuthorityHash: ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + authorityPublic, + ), + ClientSPKIHash: trustTestBytes32(0x09), + SignerStoreFingerprint: store, + TransportBinding: ComputeFrostNativeSignerAnchorTransportBinding(endpoint), + WitnessMaximumRecords: 1000, + WitnessRotationThresholdRecords: 900, + } + identity.StreamID = ComputeFrostNativeSignerAnchorStreamID(identity) + genesis := frostsigning.ComputeNativeTBTCSignerStateWitnessGenesis(store) + image := trustTestBytes32(0x0a) + return &bootstrapProvisioningTestFixture{ + endpoint: endpoint, + plan: &FrostNativeSignerAnchorBootstrapPlan{ + Schema: FrostNativeSignerAnchorBootstrapPlanSchema, + Endpoint: endpoint, + Identity: identity, + ResponsePublicKey: responsePublic, + OfflineAuthorityPublicKey: authorityPublic, + }, + facts: &frostsigning.NativeTBTCSignerStateAnchorBootstrapFacts{ + Schema: frostsigning.NativeTBTCSignerStateAnchorBootstrapFactsSchema, + StoreFingerprint: store, + CurrentCheckpoint: frostsigning.NativeTBTCSignerStateAnchorCheckpoint{ + StoreFingerprint: store, + Generation: 1, + PreviousStateCommitment: genesis, + StateImageDigest: image, + StateCommitment: frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + store, + 1, + genesis, + image, + ), + }, + }, + authority: authority, + response: response, + } +} + +// bootstrapProvisioningTestRecord simulates the history service: it constructs +// the exact response-key-signed acknowledgement and reconciled record that a +// correct create-if-absent endpoint would return for the given authorization +// certificate. +func bootstrapProvisioningTestRecord( + t *testing.T, + certificate *FrostNativeSignerAnchorTrustCertificate, + response ed25519.PrivateKey, +) *FrostNativeSignerStateWitnessAnchorRecord { + t.Helper() + acknowledgement := FrostNativeSignerCheckpointAcknowledgement{ + BindingHash: certificate.To.BindingHash, + RequestDigest: trustTestBytes32(0x71), + Nonce: trustTestBytes32(0x72), + Status: "applied", + ServiceEpoch: 1, + Revision: 1, + Checkpoint: certificate.To.Reference.Checkpoint, + OperationID: certificate.OperationID, + TransitionDigest: certificate.TransitionDigest, + CommittedAtUnixMs: 1_700_000_000_000, + ExpiresAtUnixMs: 1_700_000_020_000, + } + acknowledgement.EventRoot = + computeFrostNativeSignerAnchorEventRoot(acknowledgement) + wire := frostNativeSignerAnchorAcknowledgementWire{ + Schema: FrostNativeSignerCheckpointAcknowledgementSchema, + BindingHash: frostNativeSignerAnchorHex32(acknowledgement.BindingHash), + RequestDigest: frostNativeSignerAnchorHex32(acknowledgement.RequestDigest), + Nonce: frostNativeSignerAnchorHex32(acknowledgement.Nonce), + Status: acknowledgement.Status, + ServiceEpoch: fmt.Sprint(acknowledgement.ServiceEpoch), + Revision: fmt.Sprint(acknowledgement.Revision), + PreviousEventRoot: frostNativeSignerAnchorHex32(acknowledgement.PreviousEventRoot), + EventRoot: frostNativeSignerAnchorHex32(acknowledgement.EventRoot), + Checkpoint: frostNativeSignerAnchorCheckpointToWire( + acknowledgement.Checkpoint, + ), + OperationID: frostNativeSignerAnchorHex32(acknowledgement.OperationID), + TransitionDigest: frostNativeSignerAnchorHex32(acknowledgement.TransitionDigest), + CommittedAtUnixMs: fmt.Sprint(acknowledgement.CommittedAtUnixMs), + ExpiresAtUnixMs: fmt.Sprint(acknowledgement.ExpiresAtUnixMs), + } + signingDigest, err := frostNativeSignerAnchorAcknowledgementTranscript(wire) + if err != nil { + t.Fatal(err) + } + signature := ed25519.Sign(response, signingDigest) + wire.Signature = frostNativeSignerAnchorSignatureHex(signature) + var fixedSigningDigest [32]byte + copy(fixedSigningDigest[:], signingDigest) + var fixedSignature [ed25519.SignatureSize]byte + copy(fixedSignature[:], signature) + acknowledgementDigest := + computeFrostNativeSignerCheckpointAcknowledgementDigest( + fixedSigningDigest, + fixedSignature, + certificate.To.ResponsePublicKeySPKISHA256, + ) + raw, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + return &FrostNativeSignerStateWitnessAnchorRecord{ + Checkpoint: acknowledgement.Checkpoint, + BindingHash: acknowledgement.BindingHash, + AcknowledgementDigest: acknowledgementDigest, + OperationID: acknowledgement.OperationID, + TransitionDigest: acknowledgement.TransitionDigest, + ServiceEpoch: acknowledgement.ServiceEpoch, + Revision: acknowledgement.Revision, + PreviousEventRoot: acknowledgement.PreviousEventRoot, + EventRoot: acknowledgement.EventRoot, + AcknowledgementJSON: raw, + AcknowledgementExpires: acknowledgement.ExpiresAtUnixMs, + ReadRecoveryJSON: []byte(`{"readRecovery":"fresh"}`), + ReadRecoveryExpires: acknowledgement.ExpiresAtUnixMs, + } +} + +type bootstrapProvisioningTestClient struct { + t *testing.T + response ed25519.PrivateKey + err error + mutate func(*FrostNativeSignerStateWitnessAnchorRecord) + result *FrostNativeSignerAnchorBootstrapClientResult + seen *FrostNativeSignerAnchorBootstrapAuthorization +} + +func (client *bootstrapProvisioningTestClient) InitializeFrostNativeSignerAnchor( + _ context.Context, + authorization FrostNativeSignerAnchorBootstrapAuthorization, +) (*FrostNativeSignerAnchorBootstrapClientResult, error) { + client.seen = &authorization + if client.err != nil { + return nil, client.err + } + if client.result != nil { + return client.result, nil + } + record := bootstrapProvisioningTestRecord( + client.t, + &authorization.Certificate, + client.response, + ) + if client.mutate != nil { + client.mutate(record) + } + return &FrostNativeSignerAnchorBootstrapClientResult{Record: record}, nil +} + +func bootstrapProvisioningTestDetachedSignature( + key ed25519.PrivateKey, + stage FrostNativeSignerAnchorBootstrapSignatureStage, + digest [32]byte, +) *FrostNativeSignerAnchorBootstrapDetachedSignature { + result := &FrostNativeSignerAnchorBootstrapDetachedSignature{ + Schema: FrostNativeSignerAnchorBootstrapDetachedSignatureSchema, + Stage: stage, + Digest: digest, + } + copy(result.Signature[:], ed25519.Sign(key, digest[:])) + return result +} + +func bootstrapProvisioningTestBaseConfig() []byte { + return []byte( + `{"profile":"production","state_path":"/var/lib/keep/tbtc-signer"}`, + ) +} + +type bootstrapProvisioningTestCeremony struct { + fixture *bootstrapProvisioningTestFixture + core *FrostNativeSignerAnchorBootstrapCoreArtifact + coreSignature *FrostNativeSignerAnchorBootstrapDetachedSignature + final *FrostNativeSignerAnchorBootstrapFinalArtifact + finalSignature *FrostNativeSignerAnchorBootstrapDetachedSignature + baseConfig []byte + bundle []byte +} + +func runBootstrapProvisioningTestCeremony( + t *testing.T, +) *bootstrapProvisioningTestCeremony { + t.Helper() + fixture := newBootstrapProvisioningTestFixture() + core, err := PrepareFrostNativeSignerAnchorBootstrapCore( + fixture.facts, + fixture.plan, + ) + if err != nil { + t.Fatalf("valid bootstrap core preparation failed: %v", err) + } + coreSignature := bootstrapProvisioningTestDetachedSignature( + fixture.authority, + FrostNativeSignerAnchorBootstrapCoreSignatureStage, + core.CoreDigest, + ) + final, err := InitializeFrostNativeSignerAnchorBootstrap( + context.Background(), + core, + coreSignature, + &bootstrapProvisioningTestClient{t: t, response: fixture.response}, + ) + if err != nil { + t.Fatalf("valid bootstrap initialization failed: %v", err) + } + finalSignature := bootstrapProvisioningTestDetachedSignature( + fixture.authority, + FrostNativeSignerAnchorBootstrapFinalSignatureStage, + final.FinalDigest, + ) + baseConfig := bootstrapProvisioningTestBaseConfig() + bundle, err := FinalizeFrostNativeSignerAnchorBootstrap( + final, + finalSignature, + baseConfig, + ) + if err != nil { + t.Fatalf("valid bootstrap finalization failed: %v", err) + } + return &bootstrapProvisioningTestCeremony{ + fixture: fixture, + core: core, + coreSignature: coreSignature, + final: final, + finalSignature: finalSignature, + baseConfig: baseConfig, + bundle: bundle, + } +} + +func TestFrostNativeSignerAnchorBootstrapPlanCodec(t *testing.T) { + fixture := newBootstrapProvisioningTestFixture() + encoded, err := EncodeFrostNativeSignerAnchorBootstrapPlan(fixture.plan) + if err != nil { + t.Fatalf("valid bootstrap plan was rejected by the encoder: %v", err) + } + decoded, err := DecodeFrostNativeSignerAnchorBootstrapPlan(encoded) + if err != nil { + t.Fatalf("canonical bootstrap plan was rejected: %v", err) + } + if !reflect.DeepEqual(decoded, fixture.plan) { + t.Fatalf("bootstrap plan round trip diverged: %+v", decoded) + } + + tests := map[string]struct { + mutate func(*frostNativeSignerAnchorBootstrapPlanWire) + }{ + "wrong schema": { + mutate: func(wire *frostNativeSignerAnchorBootstrapPlanWire) { + wire.Schema = FrostNativeSignerAnchorBootstrapCoreArtifactSchema + }, + }, + "endpoint transport-binding mismatch": { + mutate: func(wire *frostNativeSignerAnchorBootstrapPlanWire) { + wire.Endpoint = "http://127.0.0.1:9800/anchor" + }, + }, + "non-canonical endpoint": { + mutate: func(wire *frostNativeSignerAnchorBootstrapPlanWire) { + wire.Endpoint = "http://127.0.0.1:9799/anchor/" + }, + }, + "non-loopback HTTP endpoint": { + mutate: func(wire *frostNativeSignerAnchorBootstrapPlanWire) { + wire.Endpoint = "http://10.0.0.7:9799/anchor" + }, + }, + "zero protocol pin": { + mutate: func(wire *frostNativeSignerAnchorBootstrapPlanWire) { + wire.Identity.ProtocolID = + frostNativeSignerAnchorHex32([32]byte{}) + }, + }, + "zero transport binding pin": { + mutate: func(wire *frostNativeSignerAnchorBootstrapPlanWire) { + wire.Identity.TransportBinding = + frostNativeSignerAnchorHex32([32]byte{}) + }, + }, + "loopback plan with a non-zero endpoint leaf pin": { + mutate: func(wire *frostNativeSignerAnchorBootstrapPlanWire) { + wire.Identity.EndpointLeafSPKIHash = + frostNativeSignerAnchorHex32(trustTestBytes32(0x0d)) + }, + }, + "non-canonical response key": { + mutate: func(wire *frostNativeSignerAnchorBootstrapPlanWire) { + wire.ResponsePublicKey = + strings.ToUpper(wire.ResponsePublicKey) + }, + }, + "response key differs from its activation pin": { + mutate: func(wire *frostNativeSignerAnchorBootstrapPlanWire) { + wire.ResponsePublicKey = wire.OfflineAuthorityPublicKey + }, + }, + "offline authority key differs from its activation pin": { + mutate: func(wire *frostNativeSignerAnchorBootstrapPlanWire) { + wire.OfflineAuthorityPublicKey = wire.ResponsePublicKey + }, + }, + "stream ID differs from its stable identity": { + mutate: func(wire *frostNativeSignerAnchorBootstrapPlanWire) { + wire.Identity.StreamID = + frostNativeSignerAnchorHex32(trustTestBytes32(0x0e)) + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + wire := frostNativeSignerAnchorBootstrapPlanToWire(fixture.plan) + test.mutate(&wire) + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeFrostNativeSignerAnchorBootstrapPlan( + payload, + ); err == nil { + t.Fatalf("bootstrap plan with %s was accepted", name) + } + }) + } + + // The identity role aliasing that collapses the response and authority + // keys must stay rejected even when both pins are updated consistently. + aliased := *fixture.plan + aliased.OfflineAuthorityPublicKey = aliased.ResponsePublicKey + aliased.Identity.OfflineAuthorityHash = aliased.Identity.OnlineKeyHash + aliasedWire := frostNativeSignerAnchorBootstrapPlanToWire(&aliased) + payload, err := json.Marshal(aliasedWire) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeFrostNativeSignerAnchorBootstrapPlan(payload); err == nil { + t.Fatal("bootstrap plan aliasing response and authority keys was accepted") + } + + strictTests := map[string]string{ + "trailing data": string(encoded) + " {}", + "duplicate member": strings.Replace( + string(encoded), + `"schema"`, + `"schema":"x","schema"`, + 1, + ), + "case-folded duplicate member": strings.Replace( + string(encoded), + `"schema"`, + `"SCHEMA":"x","schema"`, + 1, + ), + "unknown member": strings.Replace( + string(encoded), + `"schema"`, + `"unknown":"x","schema"`, + 1, + ), + "depth bomb": strings.Repeat("[", 40) + strings.Repeat("]", 40), + "empty payload": "", + } + for name, payload := range strictTests { + t.Run("strict "+name, func(t *testing.T) { + if _, err := DecodeFrostNativeSignerAnchorBootstrapPlan( + []byte(payload), + ); err == nil { + t.Fatalf("bootstrap plan payload with %s was accepted", name) + } + }) + } +} + +func TestFrostNativeSignerAnchorBootstrapPrepareCore(t *testing.T) { + fixture := newBootstrapProvisioningTestFixture() + core, err := PrepareFrostNativeSignerAnchorBootstrapCore( + fixture.facts, + fixture.plan, + ) + if err != nil { + t.Fatalf("valid bootstrap core preparation failed: %v", err) + } + factsJSON, err := frostsigning.EncodeNativeTBTCSignerStateAnchorBootstrapFacts( + fixture.facts, + ) + if err != nil { + t.Fatal(err) + } + if core.Schema != FrostNativeSignerAnchorBootstrapCoreArtifactSchema || + core.FactsSHA256 != sha256.Sum256(factsJSON) || + core.CoreDigest == [32]byte{} || + core.OperationID != + ComputeFrostNativeSignerAnchorTrustOperationID(core.CoreDigest) || + core.TransitionDigest != + ComputeFrostNativeSignerAnchorTrustTransitionDigest( + core.CoreDigest, + core.OperationID, + ) { + t.Fatalf("unexpected bootstrap core artifact: %+v", core) + } + + if _, err := PrepareFrostNativeSignerAnchorBootstrapCore( + nil, + fixture.plan, + ); err == nil { + t.Fatal("nil bootstrap facts were accepted") + } + if _, err := PrepareFrostNativeSignerAnchorBootstrapCore( + fixture.facts, + nil, + ); err == nil { + t.Fatal("nil bootstrap plan was accepted") + } + + crossStore := newBootstrapProvisioningTestFixture() + otherStore := trustTestBytes32(0x0b) + otherGenesis := + frostsigning.ComputeNativeTBTCSignerStateWitnessGenesis(otherStore) + crossStore.facts.StoreFingerprint = otherStore + crossStore.facts.CurrentCheckpoint.StoreFingerprint = otherStore + crossStore.facts.CurrentCheckpoint.PreviousStateCommitment = otherGenesis + crossStore.facts.CurrentCheckpoint.StateCommitment = + frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + otherStore, + 1, + otherGenesis, + crossStore.facts.CurrentCheckpoint.StateImageDigest, + ) + if _, err := PrepareFrostNativeSignerAnchorBootstrapCore( + crossStore.facts, + crossStore.plan, + ); err == nil { + t.Fatal("bootstrap facts from another store were accepted") + } +} + +func TestFrostNativeSignerAnchorBootstrapCoreArtifactCodec(t *testing.T) { + ceremony := runBootstrapProvisioningTestCeremony(t) + encoded, err := EncodeFrostNativeSignerAnchorBootstrapCoreArtifact( + ceremony.core, + ) + if err != nil { + t.Fatalf("valid bootstrap core artifact was rejected: %v", err) + } + decoded, err := DecodeFrostNativeSignerAnchorBootstrapCoreArtifact(encoded) + if err != nil { + t.Fatalf("canonical bootstrap core artifact was rejected: %v", err) + } + if !reflect.DeepEqual(decoded, ceremony.core) { + t.Fatalf("bootstrap core artifact round trip diverged: %+v", decoded) + } + + genesisCheckpoint := ceremony.core.Checkpoint + tests := map[string]struct { + mutate func(*frostNativeSignerAnchorBootstrapCoreArtifactWire) + }{ + "wrong schema": { + mutate: func(wire *frostNativeSignerAnchorBootstrapCoreArtifactWire) { + wire.Schema = FrostNativeSignerAnchorBootstrapPlanSchema + }, + }, + "core digest mismatch": { + mutate: func(wire *frostNativeSignerAnchorBootstrapCoreArtifactWire) { + wire.CoreDigest = + frostNativeSignerAnchorHex32(trustTestBytes32(0x0c)) + }, + }, + "operation ID mismatch": { + mutate: func(wire *frostNativeSignerAnchorBootstrapCoreArtifactWire) { + wire.OperationID = + frostNativeSignerAnchorHex32(trustTestBytes32(0x0c)) + }, + }, + "transition digest mismatch": { + mutate: func(wire *frostNativeSignerAnchorBootstrapCoreArtifactWire) { + wire.TransitionDigest = + frostNativeSignerAnchorHex32(trustTestBytes32(0x0c)) + }, + }, + "zero facts digest": { + mutate: func(wire *frostNativeSignerAnchorBootstrapCoreArtifactWire) { + wire.FactsSHA256 = frostNativeSignerAnchorHex32([32]byte{}) + }, + }, + "non-genesis checkpoint generation": { + mutate: func(wire *frostNativeSignerAnchorBootstrapCoreArtifactWire) { + checkpoint := genesisCheckpoint + checkpoint.Generation = 2 + checkpoint.PreviousStateCommitment = checkpoint.StateCommitment + checkpoint.StateCommitment = + frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + checkpoint.StoreFingerprint, + checkpoint.Generation, + checkpoint.PreviousStateCommitment, + checkpoint.StateImageDigest, + ) + wire.Checkpoint = + frostNativeSignerAnchorCheckpointToWire(checkpoint) + }, + }, + "checkpoint commitment mismatch": { + mutate: func(wire *frostNativeSignerAnchorBootstrapCoreArtifactWire) { + wire.Checkpoint.StateCommitment = + frostNativeSignerAnchorHex32(trustTestBytes32(0x0c)) + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + wire := frostNativeSignerAnchorBootstrapCoreToWire(ceremony.core) + test.mutate(&wire) + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeFrostNativeSignerAnchorBootstrapCoreArtifact( + payload, + ); err == nil { + t.Fatalf("bootstrap core artifact with %s was accepted", name) + } + }) + } +} + +func TestFrostNativeSignerAnchorBootstrapDetachedSignatureCodec(t *testing.T) { + ceremony := runBootstrapProvisioningTestCeremony(t) + for _, signature := range []*FrostNativeSignerAnchorBootstrapDetachedSignature{ + ceremony.coreSignature, + ceremony.finalSignature, + } { + encoded, err := EncodeFrostNativeSignerAnchorBootstrapDetachedSignature( + signature, + ) + if err != nil { + t.Fatalf("valid detached signature was rejected: %v", err) + } + decoded, err := DecodeFrostNativeSignerAnchorBootstrapDetachedSignature( + encoded, + ) + if err != nil { + t.Fatalf("canonical detached signature was rejected: %v", err) + } + if !reflect.DeepEqual(decoded, signature) { + t.Fatalf("detached signature round trip diverged: %+v", decoded) + } + } + + if _, err := EncodeFrostNativeSignerAnchorBootstrapDetachedSignature( + &FrostNativeSignerAnchorBootstrapDetachedSignature{ + Schema: FrostNativeSignerAnchorBootstrapDetachedSignatureSchema, + Stage: "attest", + Digest: trustTestBytes32(0x0c), + }, + ); err == nil { + t.Fatal("detached signature with an unknown stage was encoded") + } + + canonical, err := EncodeFrostNativeSignerAnchorBootstrapDetachedSignature( + ceremony.coreSignature, + ) + if err != nil { + t.Fatal(err) + } + tests := map[string]struct { + mutate func(*frostNativeSignerAnchorBootstrapDetachedSignatureWire) + }{ + "wrong schema": { + mutate: func(wire *frostNativeSignerAnchorBootstrapDetachedSignatureWire) { + wire.Schema = FrostNativeSignerAnchorBootstrapPlanSchema + }, + }, + "unknown stage": { + mutate: func(wire *frostNativeSignerAnchorBootstrapDetachedSignatureWire) { + wire.Stage = "attest" + }, + }, + "zero digest": { + mutate: func(wire *frostNativeSignerAnchorBootstrapDetachedSignatureWire) { + wire.Digest = frostNativeSignerAnchorHex32([32]byte{}) + }, + }, + "non-canonical digest": { + mutate: func(wire *frostNativeSignerAnchorBootstrapDetachedSignatureWire) { + wire.Digest = strings.ToUpper(wire.Digest) + }, + }, + "invalid signature base64": { + mutate: func(wire *frostNativeSignerAnchorBootstrapDetachedSignatureWire) { + wire.Signature = "!" + wire.Signature[1:] + }, + }, + "short signature": { + mutate: func(wire *frostNativeSignerAnchorBootstrapDetachedSignatureWire) { + wire.Signature = "c2hvcnQ=" + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + wire := frostNativeSignerAnchorBootstrapDetachedSignatureWire{} + if err := json.Unmarshal(canonical, &wire); err != nil { + t.Fatal(err) + } + test.mutate(&wire) + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeFrostNativeSignerAnchorBootstrapDetachedSignature( + payload, + ); err == nil { + t.Fatalf("detached signature with %s was accepted", name) + } + }) + } +} + +func TestFrostNativeSignerAnchorBootstrapInitialize(t *testing.T) { + fixture := newBootstrapProvisioningTestFixture() + core, err := PrepareFrostNativeSignerAnchorBootstrapCore( + fixture.facts, + fixture.plan, + ) + if err != nil { + t.Fatal(err) + } + coreSignature := bootstrapProvisioningTestDetachedSignature( + fixture.authority, + FrostNativeSignerAnchorBootstrapCoreSignatureStage, + core.CoreDigest, + ) + client := &bootstrapProvisioningTestClient{ + t: t, + response: fixture.response, + } + final, err := InitializeFrostNativeSignerAnchorBootstrap( + context.Background(), + core, + coreSignature, + client, + ) + if err != nil { + t.Fatalf("valid bootstrap initialization failed: %v", err) + } + if client.seen == nil || + client.seen.Certificate.Kind != + FrostNativeSignerAnchorTrustCertificateBootstrap || + client.seen.Certificate.CoreSignature != coreSignature.Signature { + t.Fatal("bootstrap client did not receive the authorized certificate") + } + if final.Schema != FrostNativeSignerAnchorBootstrapFinalArtifactSchema || + final.Core.CoreDigest != core.CoreDigest || + final.CoreSignature != coreSignature.Signature || + final.TargetReference.ServiceEpoch != 1 || + final.TargetReference.Revision != 1 || + final.TargetReference.PreviousEventRoot != [32]byte{} || + final.TargetReference.Checkpoint != core.Checkpoint || + final.TargetAcknowledgementSHA256 != + sha256.Sum256(final.TargetAcknowledgement) || + final.FinalDigest == [32]byte{} { + t.Fatalf("unexpected bootstrap final artifact: %+v", final) + } + + newClient := func( + mutate func(*FrostNativeSignerStateWitnessAnchorRecord), + ) *bootstrapProvisioningTestClient { + return &bootstrapProvisioningTestClient{ + t: t, + response: fixture.response, + mutate: mutate, + } + } + divergences := map[string]func(*FrostNativeSignerStateWitnessAnchorRecord){ + "binding hash": func(record *FrostNativeSignerStateWitnessAnchorRecord) { + record.BindingHash = trustTestBytes32(0x0c) + }, + "operation ID": func(record *FrostNativeSignerStateWitnessAnchorRecord) { + record.OperationID = trustTestBytes32(0x0c) + }, + "transition digest": func(record *FrostNativeSignerStateWitnessAnchorRecord) { + record.TransitionDigest = trustTestBytes32(0x0c) + }, + "checkpoint": func(record *FrostNativeSignerStateWitnessAnchorRecord) { + record.Checkpoint.Generation = 2 + }, + "service epoch": func(record *FrostNativeSignerStateWitnessAnchorRecord) { + record.ServiceEpoch = 2 + }, + "revision": func(record *FrostNativeSignerStateWitnessAnchorRecord) { + record.Revision = 2 + }, + "previous event root": func(record *FrostNativeSignerStateWitnessAnchorRecord) { + record.PreviousEventRoot = trustTestBytes32(0x0c) + }, + "tampered acknowledgement": func(record *FrostNativeSignerStateWitnessAnchorRecord) { + record.AcknowledgementJSON[len(record.AcknowledgementJSON)-2] ^= 0x01 + }, + "missing acknowledgement": func(record *FrostNativeSignerStateWitnessAnchorRecord) { + record.AcknowledgementJSON = nil + }, + "missing read recovery": func(record *FrostNativeSignerStateWitnessAnchorRecord) { + record.ReadRecoveryJSON = nil + }, + "expired read recovery": func(record *FrostNativeSignerStateWitnessAnchorRecord) { + record.ReadRecoveryExpires = 0 + }, + } + for name, mutate := range divergences { + t.Run("diverging "+name, func(t *testing.T) { + if _, err := InitializeFrostNativeSignerAnchorBootstrap( + context.Background(), + core, + coreSignature, + newClient(mutate), + ); err == nil { + t.Fatalf("client result with diverging %s was accepted", name) + } + }) + } + + t.Run("client error propagates", func(t *testing.T) { + if _, err := InitializeFrostNativeSignerAnchorBootstrap( + context.Background(), + core, + coreSignature, + &bootstrapProvisioningTestClient{ + t: t, + err: fmt.Errorf("transport failed"), + }, + ); err == nil || !strings.Contains(err.Error(), "transport failed") { + t.Fatalf("client error was not propagated: %v", err) + } + }) + t.Run("nil client result", func(t *testing.T) { + if _, err := InitializeFrostNativeSignerAnchorBootstrap( + context.Background(), + core, + coreSignature, + &bootstrapProvisioningTestClient{ + t: t, + result: &FrostNativeSignerAnchorBootstrapClientResult{ + Record: nil, + }, + }, + ); err == nil { + t.Fatal("client result without a record was accepted") + } + }) + t.Run("nil client", func(t *testing.T) { + if _, err := InitializeFrostNativeSignerAnchorBootstrap( + context.Background(), + core, + coreSignature, + nil, + ); err == nil { + t.Fatal("nil bootstrap client was accepted") + } + }) + t.Run("final-stage signature rejected", func(t *testing.T) { + wrongStage := bootstrapProvisioningTestDetachedSignature( + fixture.authority, + FrostNativeSignerAnchorBootstrapFinalSignatureStage, + core.CoreDigest, + ) + if _, err := InitializeFrostNativeSignerAnchorBootstrap( + context.Background(), + core, + wrongStage, + client, + ); err == nil { + t.Fatal("final-stage signature authorized the core stage") + } + }) + t.Run("signature digest mismatch rejected", func(t *testing.T) { + wrongDigest := bootstrapProvisioningTestDetachedSignature( + fixture.authority, + FrostNativeSignerAnchorBootstrapCoreSignatureStage, + trustTestBytes32(0x0c), + ) + if _, err := InitializeFrostNativeSignerAnchorBootstrap( + context.Background(), + core, + wrongDigest, + client, + ); err == nil { + t.Fatal("signature over another digest was accepted") + } + }) + t.Run("tampered signature rejected", func(t *testing.T) { + tampered := *coreSignature + tampered.Signature[0] ^= 0x01 + if _, err := InitializeFrostNativeSignerAnchorBootstrap( + context.Background(), + core, + &tampered, + client, + ); err == nil { + t.Fatal("tampered core signature was accepted") + } + }) + t.Run("non-authority signature rejected", func(t *testing.T) { + foreign := bootstrapProvisioningTestDetachedSignature( + fixture.response, + FrostNativeSignerAnchorBootstrapCoreSignatureStage, + core.CoreDigest, + ) + if _, err := InitializeFrostNativeSignerAnchorBootstrap( + context.Background(), + core, + foreign, + client, + ); err == nil { + t.Fatal("non-authority core signature was accepted") + } + }) +} + +func TestFrostNativeSignerAnchorBootstrapFinalArtifactCodec(t *testing.T) { + ceremony := runBootstrapProvisioningTestCeremony(t) + encoded, err := EncodeFrostNativeSignerAnchorBootstrapFinalArtifact( + ceremony.final, + ) + if err != nil { + t.Fatalf("valid bootstrap final artifact was rejected: %v", err) + } + decoded, err := DecodeFrostNativeSignerAnchorBootstrapFinalArtifact(encoded) + if err != nil { + t.Fatalf("canonical bootstrap final artifact was rejected: %v", err) + } + if !reflect.DeepEqual(decoded, ceremony.final) { + t.Fatalf("bootstrap final artifact round trip diverged: %+v", decoded) + } + + tests := map[string]struct { + mutate func(*frostNativeSignerAnchorBootstrapFinalArtifactWire) + }{ + "wrong schema": { + mutate: func(wire *frostNativeSignerAnchorBootstrapFinalArtifactWire) { + wire.Schema = FrostNativeSignerAnchorBootstrapCoreArtifactSchema + }, + }, + "tampered core signature": { + mutate: func(wire *frostNativeSignerAnchorBootstrapFinalArtifactWire) { + tampered := ceremony.final.CoreSignature + tampered[0] ^= 0x01 + wire.CoreSignature = + base64StdEncoding(tampered[:]) + }, + }, + "acknowledgement digest mismatch": { + mutate: func(wire *frostNativeSignerAnchorBootstrapFinalArtifactWire) { + wire.TargetAcknowledgementSHA256 = + frostNativeSignerAnchorHex32(trustTestBytes32(0x0c)) + }, + }, + "tampered acknowledgement with recomputed digest": { + mutate: func(wire *frostNativeSignerAnchorBootstrapFinalArtifactWire) { + tampered := append( + []byte{}, + ceremony.final.TargetAcknowledgement..., + ) + tampered[len(tampered)-2] ^= 0x01 + wire.TargetAcknowledgementBase64 = base64StdEncoding(tampered) + wire.TargetAcknowledgementSHA256 = + frostNativeSignerAnchorHex32(sha256.Sum256(tampered)) + }, + }, + "final digest mismatch": { + mutate: func(wire *frostNativeSignerAnchorBootstrapFinalArtifactWire) { + wire.FinalDigest = + frostNativeSignerAnchorHex32(trustTestBytes32(0x0c)) + }, + }, + "target reference revision divergence": { + mutate: func(wire *frostNativeSignerAnchorBootstrapFinalArtifactWire) { + wire.TargetReference.Revision = "2" + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + wire := frostNativeSignerAnchorBootstrapFinalArtifactWire{} + if err := json.Unmarshal(encoded, &wire); err != nil { + t.Fatal(err) + } + test.mutate(&wire) + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeFrostNativeSignerAnchorBootstrapFinalArtifact( + payload, + ); err == nil { + t.Fatalf("bootstrap final artifact with %s was accepted", name) + } + }) + } +} + +func TestFrostNativeSignerAnchorBootstrapFinalize(t *testing.T) { + ceremony := runBootstrapProvisioningTestCeremony(t) + bundle, err := DecodeFrostNativeSignerAnchorBootstrapOutputBundle( + ceremony.bundle, + ) + if err != nil { + t.Fatalf("canonical bootstrap output bundle was rejected: %v", err) + } + if bundle.Schema != FrostNativeSignerAnchorBootstrapOutputBundleSchema || + len(bundle.CertificateChain) != 1 || + bundle.CertificateChain[0].CertificateDigest != + bundle.CertificateDigest || + bundle.CertificateChain[0].Kind != + FrostNativeSignerAnchorTrustCertificateBootstrap { + t.Fatalf("unexpected bootstrap output bundle: %+v", bundle) + } + if err := ValidateFrostNativeSignerAnchorTrustCertificate( + &bundle.CertificateChain[0], + ValidateFrostNativeSignerAnchorTrustTargetAcknowledgement, + ); err != nil { + t.Fatalf("bundled certificate failed full validation: %v", err) + } + signerConfig := map[string]interface{}{} + if err := json.Unmarshal(bundle.SignerConfigJSON, &signerConfig); err != nil { + t.Fatal(err) + } + if signerConfig["purpose"] != "normal_signer" || + signerConfig["profile"] != "production" || + signerConfig["state_anchor_trust_certificate_digest"] != + frostNativeSignerAnchorHex32(bundle.CertificateDigest) { + t.Fatalf("unexpected bundled signer config: %v", signerConfig) + } + + t.Run("core-stage signature rejected", func(t *testing.T) { + wrongStage := bootstrapProvisioningTestDetachedSignature( + ceremony.fixture.authority, + FrostNativeSignerAnchorBootstrapCoreSignatureStage, + ceremony.final.FinalDigest, + ) + if _, err := FinalizeFrostNativeSignerAnchorBootstrap( + ceremony.final, + wrongStage, + ceremony.baseConfig, + ); err == nil { + t.Fatal("core-stage signature authorized the final stage") + } + }) + t.Run("tampered final signature rejected", func(t *testing.T) { + tampered := *ceremony.finalSignature + tampered.Signature[0] ^= 0x01 + if _, err := FinalizeFrostNativeSignerAnchorBootstrap( + ceremony.final, + &tampered, + ceremony.baseConfig, + ); err == nil { + t.Fatal("tampered final signature was accepted") + } + }) + t.Run("tampered certificate rejected", func(t *testing.T) { + tampered := *ceremony.final + tampered.TargetReference.EventRoot = trustTestBytes32(0x0c) + if _, err := FinalizeFrostNativeSignerAnchorBootstrap( + &tampered, + ceremony.finalSignature, + ceremony.baseConfig, + ); err == nil { + t.Fatal("final artifact with a tampered target reference was accepted") + } + }) + t.Run("nil final artifact rejected", func(t *testing.T) { + if _, err := FinalizeFrostNativeSignerAnchorBootstrap( + nil, + ceremony.finalSignature, + ceremony.baseConfig, + ); err == nil { + t.Fatal("nil final artifact was accepted") + } + }) + + baseConfigTests := map[string]string{ + "missing profile": `{"state_path":"/var/lib/keep/tbtc-signer"}`, + "non-production profile": `{"profile":"development",` + + `"state_path":"/var/lib/keep/tbtc-signer"}`, + "missing state path": `{"profile":"production"}`, + "relative state path": `{"profile":"production",` + + `"state_path":"var/lib/keep/tbtc-signer"}`, + "non-canonical state path": `{"profile":"production",` + + `"state_path":"/var/lib/keep/../keep/tbtc-signer"}`, + "conflicting purpose": `{"profile":"production",` + + `"state_path":"/var/lib/keep/tbtc-signer",` + + `"purpose":"state_anchor_bootstrap_provisioning"}`, + "conflicting certified field": `{"profile":"production",` + + `"state_path":"/var/lib/keep/tbtc-signer",` + + `"state_anchor_protocol_id":"0x00"}`, + "non-canonical number": `{"profile":"production",` + + `"state_path":"/var/lib/keep/tbtc-signer","retries":1.5}`, + "negative number": `{"profile":"production",` + + `"state_path":"/var/lib/keep/tbtc-signer","retries":-1}`, + "leading-zero number": `{"profile":"production",` + + `"state_path":"/var/lib/keep/tbtc-signer","retries":01}`, + "duplicate member": `{"profile":"production","profile":"production",` + + `"state_path":"/var/lib/keep/tbtc-signer"}`, + "trailing data": `{"profile":"production",` + + `"state_path":"/var/lib/keep/tbtc-signer"} {}`, + "non-object": `["profile"]`, + } + for name, baseConfig := range baseConfigTests { + t.Run("base config "+name, func(t *testing.T) { + if _, err := FinalizeFrostNativeSignerAnchorBootstrap( + ceremony.final, + ceremony.finalSignature, + []byte(baseConfig), + ); err == nil { + t.Fatalf("base config with %s was accepted", name) + } + }) + } +} + +func TestFrostNativeSignerAnchorBootstrapOutputBundleDecode(t *testing.T) { + ceremony := runBootstrapProvisioningTestCeremony(t) + rehash := func(wire *frostNativeSignerAnchorBootstrapOutputBundleWire) { + wire.CertificateChainSHA256 = frostNativeSignerAnchorHex32( + sha256.Sum256(wire.CertificateChain), + ) + wire.SignerConfigSHA256 = frostNativeSignerAnchorHex32( + sha256.Sum256(wire.SignerConfig), + ) + } + tests := map[string]struct { + mutate func(*testing.T, *frostNativeSignerAnchorBootstrapOutputBundleWire) + }{ + "wrong schema": { + mutate: func(t *testing.T, wire *frostNativeSignerAnchorBootstrapOutputBundleWire) { + wire.Schema = FrostNativeSignerAnchorBootstrapFinalArtifactSchema + }, + }, + "certificate chain digest mismatch": { + mutate: func(t *testing.T, wire *frostNativeSignerAnchorBootstrapOutputBundleWire) { + wire.CertificateChainSHA256 = + frostNativeSignerAnchorHex32(trustTestBytes32(0x0c)) + }, + }, + "signer config digest mismatch": { + mutate: func(t *testing.T, wire *frostNativeSignerAnchorBootstrapOutputBundleWire) { + wire.SignerConfigSHA256 = + frostNativeSignerAnchorHex32(trustTestBytes32(0x0c)) + }, + }, + "zero certificate digest": { + mutate: func(t *testing.T, wire *frostNativeSignerAnchorBootstrapOutputBundleWire) { + wire.CertificateDigest = + frostNativeSignerAnchorHex32([32]byte{}) + }, + }, + "certificate digest differs from chain head": { + mutate: func(t *testing.T, wire *frostNativeSignerAnchorBootstrapOutputBundleWire) { + wire.CertificateDigest = + frostNativeSignerAnchorHex32(trustTestBytes32(0x0c)) + }, + }, + "tampered certificate with recomputed digest": { + mutate: func(t *testing.T, wire *frostNativeSignerAnchorBootstrapOutputBundleWire) { + // Flip one nibble of the embedded core signature while + // keeping canonical hex, then recompute the chain hash so + // only certificate validation can reject the bundle. + chain := []json.RawMessage{} + if err := json.Unmarshal( + wire.CertificateChain, + &chain, + ); err != nil || len(chain) != 1 { + t.Fatal("cannot parse bundled certificate chain") + } + certificate := map[string]json.RawMessage{} + if err := json.Unmarshal(chain[0], &certificate); err != nil { + t.Fatal(err) + } + var coreSignature string + if err := json.Unmarshal( + certificate["coreSignature"], + &coreSignature, + ); err != nil { + t.Fatal(err) + } + flipped := []byte(coreSignature) + if flipped[2] == 'f' { + flipped[2] = 'e' + } else { + flipped[2] = 'f' + } + encoded, err := json.Marshal(string(flipped)) + if err != nil { + t.Fatal(err) + } + certificate["coreSignature"] = encoded + mutated, err := json.Marshal(certificate) + if err != nil { + t.Fatal(err) + } + wire.CertificateChain, err = json.Marshal( + []json.RawMessage{mutated}, + ) + if err != nil { + t.Fatal(err) + } + rehash(wire) + }, + }, + "duplicated chain with recomputed digest": { + mutate: func(t *testing.T, wire *frostNativeSignerAnchorBootstrapOutputBundleWire) { + chain := []json.RawMessage{} + if err := json.Unmarshal( + wire.CertificateChain, + &chain, + ); err != nil || len(chain) != 1 { + t.Fatal("cannot parse bundled certificate chain") + } + duplicated, err := json.Marshal( + []json.RawMessage{chain[0], chain[0]}, + ) + if err != nil { + t.Fatal(err) + } + wire.CertificateChain = duplicated + rehash(wire) + }, + }, + "non-canonical signer config with recomputed digest": { + mutate: func(t *testing.T, wire *frostNativeSignerAnchorBootstrapOutputBundleWire) { + wire.SignerConfig = append( + []byte(" "), + wire.SignerConfig..., + ) + rehash(wire) + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + wire := frostNativeSignerAnchorBootstrapOutputBundleWire{} + if err := json.Unmarshal(ceremony.bundle, &wire); err != nil { + t.Fatal(err) + } + test.mutate(t, &wire) + payload, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeFrostNativeSignerAnchorBootstrapOutputBundle( + payload, + ); err == nil { + t.Fatalf("bootstrap output bundle with %s was accepted", name) + } + }) + } + + strictTests := map[string]string{ + "trailing data": string(ceremony.bundle) + " {}", + "duplicate member": strings.Replace( + string(ceremony.bundle), + `"schema"`, + `"schema":"x","schema"`, + 1, + ), + "empty payload": "", + } + for name, payload := range strictTests { + t.Run("strict "+name, func(t *testing.T) { + if _, err := DecodeFrostNativeSignerAnchorBootstrapOutputBundle( + []byte(payload), + ); err == nil { + t.Fatalf( + "bootstrap output bundle payload with %s was accepted", + name, + ) + } + }) + } +} From 0c32250e7c2a89c313a0f6e4aa24855d52654e6b Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 26 Jul 2026 20:04:17 -0400 Subject: [PATCH 18/93] Add native signer anchor bootstrap initialize client Implement the online transport behind the bootstrap initialize ceremony phase as a hardened HTTP client beside the runtime anchor client: - New initialize endpoint (path suffix "initialize" beside read/advance/history) with request schema tbtc-frost-native-signer-state-anchor-initialize-request/v1 and its own signing domain. Both bootstrap request kinds ("initialize" and the reconciliation "read") are client-signed over a domain-separated fixed-width transcript binding schema, kind, binding hash, nonce, operation ID, transition digest, the genesis checkpoint fields, and the client SPKI; no JSON bytes are ever signed. - The client re-derives the offline core digests and verifies the offline authority signature before any network activity, then requires the existing checkpoint-acknowledgement schema in response (applied on first create, already-applied on idempotent replay) verified through the existing acknowledgement validator, and always reconciles through a fresh signed exact read whose retained JSON and expiry feed the client result consumed by InitializeFrostNativeSignerAnchorBootstrap. - An authenticated, request-bound read showing a different genesis record (checkpoint, binding hash, operation ID, or transition digest) poisons the client permanently; transport-shaped failures stay retryable, and every post-send verification failure is ambiguous rather than terminal. - Canonical strict-JSON client config tbtc-frost-native-signer-anchor-bootstrap-client-config/v1 (endpoint, raw response key plus SPKI pin, endpoint leaf SPKI hash zero only for numeric loopback HTTP, PKCS#8 PEM client key path, request timeout) validated with the provisioning canonicality machinery, plus the same transport hardening and byte caps as the runtime anchor client. - cmd/tbtc-signer anchor bootstrap initialize now constructs the real transport from --client-config; the transport-unavailable error remains only for nil-factory injection. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DpftF6aYHh56KgiHU6D2NQ --- cmd/tbtc_signer.go | 20 +- ...t_native_signer_anchor_bootstrap_client.go | 969 ++++++++++++++++++ .../frost_native_signer_anchor_protocol.go | 26 +- 3 files changed, 1000 insertions(+), 15 deletions(-) create mode 100644 pkg/tbtc/frost_native_signer_anchor_bootstrap_client.go diff --git a/cmd/tbtc_signer.go b/cmd/tbtc_signer.go index b54d1797ec..b72c5c39b4 100644 --- a/cmd/tbtc_signer.go +++ b/cmd/tbtc_signer.go @@ -18,11 +18,25 @@ type tbtcSignerAnchorBootstrapClientFactory func( string, ) (tbtc.FrostNativeSignerAnchorBootstrapClient, error) +// tbtcSignerAnchorBootstrapTransportFactory loads the hardened online +// bootstrap client from its canonical owner-only config artifact. Construction +// performs no network activity, and every trust decision stays inside the +// offline-signed authorization plus the client's pinned verification. +func tbtcSignerAnchorBootstrapTransportFactory( + _ context.Context, + configPath string, +) (tbtc.FrostNativeSignerAnchorBootstrapClient, error) { + return tbtc.LoadFrostNativeSignerAnchorBootstrapClient(configPath) +} + // TBTCSignerCommand contains fail-closed signer administration tools. The // bootstrap subtree is safe in all builds: facts fails when the native ABI is -// unavailable, and initialize fails until the separately reviewed bootstrap -// transport supplies its narrow client factory. -var TBTCSignerCommand = newTBTCSignerCommand(nil) +// unavailable, and initialize constructs the reviewed bootstrap transport from +// --client-config. The transport-unavailable error remains only for callers +// that inject a nil factory through newTBTCSignerCommand. +var TBTCSignerCommand = newTBTCSignerCommand( + tbtcSignerAnchorBootstrapTransportFactory, +) func newTBTCSignerCommand( clientFactory tbtcSignerAnchorBootstrapClientFactory, diff --git a/pkg/tbtc/frost_native_signer_anchor_bootstrap_client.go b/pkg/tbtc/frost_native_signer_anchor_bootstrap_client.go new file mode 100644 index 0000000000..38f479c554 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_bootstrap_client.go @@ -0,0 +1,969 @@ +package tbtc + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "path/filepath" + "sync" + "time" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +const ( + // FrostNativeSignerAnchorBootstrapClientConfigSchema is the canonical + // owner-only transport configuration consumed by the online initialize + // phase. It deliberately carries no anchor identity and no trust decision: + // every semantic pin travels inside the offline-signed authorization + // certificate, so a substituted transport config can only point the + // request at a peer that is unable to produce a verifiable + // acknowledgement. + FrostNativeSignerAnchorBootstrapClientConfigSchema = "tbtc-frost-native-signer-anchor-bootstrap-client-config/v1" + + frostNativeSignerAnchorBootstrapClientConfigMaximumBytes int64 = 64 * 1024 +) + +// FrostNativeSignerAnchorBootstrapClientConfig is the parsed canonical +// transport configuration plus runtime-only injection points. The fields below +// the divider never come from configuration JSON: ClientPrivateKey overrides +// ClientPrivateKeyPath when set, and TLSRootCAs, Random, and Now are the same +// deployment/test seams the runtime anchor client exposes. +type FrostNativeSignerAnchorBootstrapClientConfig struct { + Endpoint string + ResponsePublicKey [ed25519.PublicKeySize]byte + ResponsePublicKeySPKISHA256 [32]byte + EndpointLeafSPKIHash [32]byte + ClientPrivateKeyPath string + RequestTimeout time.Duration + + ClientPrivateKey ed25519.PrivateKey + TLSRootCAs *x509.CertPool + Random io.Reader + Now func() time.Time +} + +type frostNativeSignerAnchorBootstrapClientConfigWire struct { + Schema string `json:"schema"` + Endpoint string `json:"endpoint"` + ResponsePublicKey string `json:"responsePublicKey"` + ResponsePublicKeySPKISHA256 string `json:"responsePublicKeySpkiSha256"` + EndpointLeafSPKIHash string `json:"endpointLeafSpkiHash"` + ClientPrivateKeyPath string `json:"clientPrivateKeyPath"` + RequestTimeoutMilliseconds string `json:"requestTimeoutMilliseconds"` +} + +type frostNativeSignerAnchorInitializeRequestPayload struct { + Kind string `json:"kind"` + Nonce string `json:"nonce"` + BindingHash string `json:"bindingHash"` + OperationID string `json:"operationID"` + TransitionDigest string `json:"transitionDigest"` + Checkpoint frostNativeSignerAnchorCheckpointWire `json:"checkpoint"` +} + +type frostNativeSignerAnchorInitializeRequest struct { + Schema string `json:"schema"` + Payload frostNativeSignerAnchorInitializeRequestPayload `json:"payload"` + ClientPublicKeySPKI string `json:"clientPublicKeySpki"` + Signature string `json:"signature"` +} + +// FrostNativeSignerAnchorBootstrapHTTPClient is the serialized fail-closed +// transport for the one-time create-if-absent bootstrap endpoint. Both kinds +// of bootstrap request ("initialize" and its reconciliation "read") are +// client-signed over a domain-separated fixed-width transcript and POSTed to +// the single initialize endpoint beside read/advance/history. A verified +// service statement that the stream holds a different genesis record poisons +// this client permanently; transport-shaped failures never do. +type FrostNativeSignerAnchorBootstrapHTTPClient struct { + initializeEndpoint string + httpClient *http.Client + requestTimeout time.Duration + clockSkew time.Duration + maximumAckLife time.Duration + + responseKey ed25519.PublicKey + responseKeyRaw [ed25519.PublicKeySize]byte + responseKeyPin [32]byte + clientKey ed25519.PrivateKey + clientPublicKey [ed25519.PublicKeySize]byte + clientSPKIDER []byte + clientSPKIBase64 string + random io.Reader + now func() time.Time + + mutex sync.Mutex + poisoned error +} + +var _ FrostNativeSignerAnchorBootstrapClient = (*FrostNativeSignerAnchorBootstrapHTTPClient)(nil) + +// DecodeFrostNativeSignerAnchorBootstrapClientConfig strictly decodes the +// canonical transport configuration with the provisioning JSON machinery: +// duplicate and case-folded-duplicate members, non-ASCII member names, unknown +// members, trailing data, and non-canonical numbers are all rejected. The +// endpoint/leaf pairing enforces the provisioning identity rule: a zero +// endpoint leaf SPKI hash is legal only for a canonical numeric loopback HTTP +// endpoint, and mandatory for it. +func DecodeFrostNativeSignerAnchorBootstrapClientConfig( + data []byte, +) (*FrostNativeSignerAnchorBootstrapClientConfig, error) { + if len(data) == 0 || + int64(len(data)) > frostNativeSignerAnchorBootstrapClientConfigMaximumBytes { + return nil, fmt.Errorf( + "native signer anchor bootstrap client config size is invalid", + ) + } + wire := &frostNativeSignerAnchorBootstrapClientConfigWire{} + if err := decodeStrictFrostNativeSignerAnchorProvisioningJSON( + data, + wire, + ); err != nil { + return nil, err + } + if wire.Schema != FrostNativeSignerAnchorBootstrapClientConfigSchema { + return nil, fmt.Errorf( + "unsupported native signer anchor bootstrap client config schema", + ) + } + _, https, err := validateFrostNativeSignerAnchorEndpoint(wire.Endpoint) + if err != nil { + return nil, err + } + responseKey, err := frostNativeSignerAnchorParseHex32(wire.ResponsePublicKey) + if err != nil { + return nil, fmt.Errorf("invalid bootstrap client config response key: %w", err) + } + if err := ValidateFrostNativeSignerAnchorTrustEd25519PublicKey( + responseKey[:], + ); err != nil { + return nil, fmt.Errorf("invalid bootstrap client config response key: %w", err) + } + responsePin, err := frostNativeSignerAnchorParseHex32( + wire.ResponsePublicKeySPKISHA256, + ) + if err != nil || + ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256(responseKey) != + responsePin { + return nil, fmt.Errorf( + "bootstrap client config response key differs from its SPKI pin", + ) + } + leafHash, err := frostNativeSignerAnchorParseHex32(wire.EndpointLeafSPKIHash) + if err != nil { + return nil, fmt.Errorf( + "invalid bootstrap client config endpoint leaf SPKI hash: %w", + err, + ) + } + if https && leafHash == [32]byte{} { + return nil, fmt.Errorf( + "HTTPS bootstrap client config requires a nonzero endpoint leaf SPKI hash", + ) + } + if !https && leafHash != [32]byte{} { + return nil, fmt.Errorf( + "loopback HTTP bootstrap client config must use a zero endpoint leaf SPKI hash", + ) + } + if !filepath.IsAbs(wire.ClientPrivateKeyPath) || + filepath.Clean(wire.ClientPrivateKeyPath) != wire.ClientPrivateKeyPath { + return nil, fmt.Errorf( + "bootstrap client config key path is not canonical absolute", + ) + } + timeoutMilliseconds, err := frostNativeSignerAnchorParseUint64( + wire.RequestTimeoutMilliseconds, + ) + if err != nil || timeoutMilliseconds == 0 || + timeoutMilliseconds > + uint64(frostNativeSignerAnchorMaximumRequestTimeout/time.Millisecond) { + return nil, fmt.Errorf( + "bootstrap client config request timeout is invalid", + ) + } + return &FrostNativeSignerAnchorBootstrapClientConfig{ + Endpoint: wire.Endpoint, + ResponsePublicKey: responseKey, + ResponsePublicKeySPKISHA256: responsePin, + EndpointLeafSPKIHash: leafHash, + ClientPrivateKeyPath: wire.ClientPrivateKeyPath, + RequestTimeout: time.Duration(timeoutMilliseconds) * time.Millisecond, + }, nil +} + +// LoadFrostNativeSignerAnchorBootstrapClient reads the owner-only canonical +// config artifact, loads the referenced client key, and constructs the +// hardened bootstrap transport. Construction performs no network activity. +func LoadFrostNativeSignerAnchorBootstrapClient( + configPath string, +) (*FrostNativeSignerAnchorBootstrapHTTPClient, error) { + configJSON, err := ReadFrostNativeSignerAnchorProvisioningArtifact( + configPath, + frostNativeSignerAnchorBootstrapClientConfigMaximumBytes, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot read native signer anchor bootstrap client config: %w", + err, + ) + } + config, err := DecodeFrostNativeSignerAnchorBootstrapClientConfig(configJSON) + if err != nil { + return nil, err + } + return NewFrostNativeSignerAnchorBootstrapClient(*config) +} + +// NewFrostNativeSignerAnchorBootstrapClient validates every transport pin +// before constructing an HTTP client. HTTPS uses normal PKIX verification +// plus an exact leaf-SPKI pin; plaintext HTTP is restricted to a canonical +// numeric loopback endpoint with a zero leaf pin. Proxies and redirects are +// always disabled, mirroring the runtime anchor client exactly. +func NewFrostNativeSignerAnchorBootstrapClient( + config FrostNativeSignerAnchorBootstrapClientConfig, +) (*FrostNativeSignerAnchorBootstrapHTTPClient, error) { + endpoint, https, err := validateFrostNativeSignerAnchorEndpoint(config.Endpoint) + if err != nil { + return nil, err + } + if https { + if config.EndpointLeafSPKIHash == [32]byte{} { + return nil, fmt.Errorf( + "HTTPS native signer anchor bootstrap endpoint requires a leaf SPKI pin", + ) + } + } else { + if config.EndpointLeafSPKIHash != [32]byte{} { + return nil, fmt.Errorf( + "loopback HTTP native signer anchor bootstrap endpoint must use a zero leaf SPKI pin", + ) + } + if config.TLSRootCAs != nil { + return nil, fmt.Errorf( + "loopback HTTP native signer anchor bootstrap cannot configure TLS roots", + ) + } + } + if err := ValidateFrostNativeSignerAnchorTrustEd25519PublicKey( + config.ResponsePublicKey[:], + ); err != nil { + return nil, fmt.Errorf( + "invalid native signer anchor bootstrap response key: %w", + err, + ) + } + if ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + config.ResponsePublicKey, + ) != config.ResponsePublicKeySPKISHA256 { + return nil, fmt.Errorf( + "native signer anchor bootstrap response key differs from its SPKI pin", + ) + } + clientKey := config.ClientPrivateKey + if clientKey == nil { + clientKey, err = loadFrostNativeSignerAnchorClientPrivateKey( + config.ClientPrivateKeyPath, + ) + if err != nil { + return nil, err + } + } + if len(clientKey) != ed25519.PrivateKeySize { + return nil, fmt.Errorf( + "native signer anchor bootstrap client key is not Ed25519", + ) + } + clientPublic := [ed25519.PublicKeySize]byte{} + copy(clientPublic[:], clientKey.Public().(ed25519.PublicKey)) + if err := ValidateFrostNativeSignerAnchorTrustEd25519PublicKey( + clientPublic[:], + ); err != nil { + return nil, fmt.Errorf( + "native signer anchor bootstrap client key point is invalid: %w", + err, + ) + } + if clientPublic == config.ResponsePublicKey { + return nil, fmt.Errorf( + "native signer anchor bootstrap client and response keys must be distinct", + ) + } + clientSPKIDER, err := x509.MarshalPKIXPublicKey(clientKey.Public()) + if err != nil { + return nil, fmt.Errorf( + "cannot encode native signer anchor bootstrap client key: %w", + err, + ) + } + requestTimeout := config.RequestTimeout + if requestTimeout == 0 { + requestTimeout = frostNativeSignerAnchorDefaultRequestTimeout + } + if requestTimeout <= 0 || + requestTimeout > frostNativeSignerAnchorMaximumRequestTimeout { + return nil, fmt.Errorf( + "native signer anchor bootstrap request timeout is invalid", + ) + } + randomSource := config.Random + if randomSource == nil { + randomSource = rand.Reader + } + now := config.Now + if now == nil { + now = time.Now + } + + transport := &http.Transport{ + Proxy: nil, + DialContext: (&net.Dialer{Timeout: requestTimeout, KeepAlive: -1}).DialContext, + DisableKeepAlives: true, + DisableCompression: true, + ForceAttemptHTTP2: false, + MaxConnsPerHost: 1, + ResponseHeaderTimeout: requestTimeout, + TLSHandshakeTimeout: requestTimeout, + ExpectContinueTimeout: time.Second, + MaxResponseHeaderBytes: 16 * 1024, + } + if https { + expectedLeafSPKIHash := config.EndpointLeafSPKIHash + var rootCAs *x509.CertPool + if config.TLSRootCAs != nil { + rootCAs = config.TLSRootCAs.Clone() + } + transport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: rootCAs, + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.VerifiedChains) == 0 || len(state.PeerCertificates) == 0 { + return fmt.Errorf( + "native signer anchor bootstrap TLS peer is not PKIX-verified", + ) + } + if sha256.Sum256(state.PeerCertificates[0].RawSubjectPublicKeyInfo) != + expectedLeafSPKIHash { + return fmt.Errorf( + "native signer anchor bootstrap TLS leaf SPKI mismatch", + ) + } + return nil + }, + } + } + + // Copy secret material only after every fallible validation/construction + // step, mirroring the runtime anchor client constructor. + keyCopy := append(ed25519.PrivateKey{}, clientKey...) + return &FrostNativeSignerAnchorBootstrapHTTPClient{ + initializeEndpoint: frostNativeSignerAnchorOperationEndpoint( + endpoint, + "initialize", + ), + httpClient: &http.Client{ + Transport: transport, + Timeout: requestTimeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return fmt.Errorf( + "native signer anchor bootstrap redirects are disabled", + ) + }, + }, + requestTimeout: requestTimeout, + clockSkew: frostNativeSignerAnchorDefaultClockSkew, + maximumAckLife: frostNativeSignerAnchorDefaultAcknowledgementLifetime, + responseKey: append(ed25519.PublicKey{}, config.ResponsePublicKey[:]...), + responseKeyRaw: config.ResponsePublicKey, + responseKeyPin: config.ResponsePublicKeySPKISHA256, + clientKey: keyCopy, + clientPublicKey: clientPublic, + clientSPKIDER: append([]byte{}, clientSPKIDER...), + clientSPKIBase64: base64StdEncoding(clientSPKIDER), + random: randomSource, + now: now, + }, nil +} + +// InitializeFrostNativeSignerAnchor submits the offline-authorized +// create-if-absent request and then always reconciles through a fresh signed +// exact read before reporting success. The returned record therefore carries +// both the exact stored genesis acknowledgement and the exact read-recovery +// JSON required by InitializeFrostNativeSignerAnchorBootstrap. Outcome +// classes: pre-send failures are retryable; post-send verification failures +// are ambiguous and resolved only by the read; an authenticated read showing a +// different genesis record permanently poisons the client. +func (client *FrostNativeSignerAnchorBootstrapHTTPClient) InitializeFrostNativeSignerAnchor( + ctx context.Context, + authorization FrostNativeSignerAnchorBootstrapAuthorization, +) (*FrostNativeSignerAnchorBootstrapClientResult, error) { + if client == nil { + return nil, fmt.Errorf("native signer anchor bootstrap client is nil") + } + if ctx == nil { + return nil, fmt.Errorf("native signer anchor bootstrap context is nil") + } + client.mutex.Lock() + defer client.mutex.Unlock() + if client.poisoned != nil { + return nil, fmt.Errorf( + "native signer anchor bootstrap client is poisoned: %w", + client.poisoned, + ) + } + certificate := authorization.Certificate + if err := client.validateBootstrapAuthorization(&certificate); err != nil { + return nil, err + } + verifier := client.bootstrapAnchorVerifierLocked(&certificate) + sentinel, sent, initializeErr := client.initializeAttemptLocked( + ctx, + verifier, + &certificate, + ) + if initializeErr != nil && !sent { + // The request never left this process, so the stream state is + // untouched and the idempotent ceremony can simply be retried. + return nil, initializeErr + } + acknowledgement, err := client.reconcileBootstrapReadLocked( + ctx, + verifier, + &certificate, + sentinel, + initializeErr, + ) + if err != nil { + return nil, err + } + return &FrostNativeSignerAnchorBootstrapClientResult{ + Record: frostNativeSignerAnchorRecord(acknowledgement), + }, nil +} + +// validateBootstrapAuthorization re-derives every commitment of the offline +// core before any network activity. The client refuses to transmit an +// authorization whose digests, offline signature, genesis checkpoint, or +// response-key pins do not verify against its own transport configuration: +// pre-send validation failures are retryable and never poison. +func (client *FrostNativeSignerAnchorBootstrapHTTPClient) validateBootstrapAuthorization( + certificate *FrostNativeSignerAnchorTrustCertificate, +) error { + if certificate.Kind != FrostNativeSignerAnchorTrustCertificateBootstrap || + certificate.CertificateSequence != 1 || + certificate.PreviousCertificateDigest != [32]byte{} || + certificate.From != nil || + certificate.ProtocolID == [32]byte{} || + certificate.StreamID == [32]byte{} || + certificate.SignerStoreFingerprint == [32]byte{} { + return fmt.Errorf( + "bootstrap authorization is not a first bootstrap certificate", + ) + } + endpoint := certificate.To + if endpoint.ActivationManifestHash == [32]byte{} || + endpoint.ActivationManifestSequence == 0 || + endpoint.BindingHash == [32]byte{} || + endpoint.WitnessMaximumRecords < 2 || + endpoint.WitnessMaximumRecords > 1_000_000 || + endpoint.WitnessRotationThresholdRecords < 2 || + endpoint.WitnessRotationThresholdRecords > + endpoint.WitnessMaximumRecords-2 { + return fmt.Errorf("bootstrap authorization endpoint pins are invalid") + } + if endpoint.ResponsePublicKey != client.responseKeyRaw || + endpoint.ResponsePublicKeySPKISHA256 != client.responseKeyPin { + return fmt.Errorf( + "bootstrap authorization response key differs from the transport pin", + ) + } + if err := ValidateFrostNativeSignerAnchorTrustEd25519PublicKey( + endpoint.OfflineAuthorityPublicKey[:], + ); err != nil { + return fmt.Errorf( + "bootstrap authorization offline authority key is invalid: %w", + err, + ) + } + if ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + endpoint.OfflineAuthorityPublicKey, + ) != endpoint.OfflineAuthoritySPKISHA256 || + endpoint.OfflineAuthorityPublicKey == endpoint.ResponsePublicKey || + endpoint.OfflineAuthorityPublicKey == client.clientPublicKey || + endpoint.ResponsePublicKey == client.clientPublicKey { + return fmt.Errorf( + "bootstrap authorization cryptographic roles are not pairwise distinct", + ) + } + reference := endpoint.Reference + if reference.ServiceEpoch != 1 || reference.Revision != 0 || + reference.PreviousEventRoot != [32]byte{} || + reference.EventRoot != [32]byte{} || + reference.AcknowledgementDigest != [32]byte{} { + return fmt.Errorf( + "bootstrap authorization reference must be the unassigned first epoch", + ) + } + checkpoint := reference.Checkpoint + if err := validateFrostNativeSignerAnchorCheckpoint( + checkpoint, + certificate.SignerStoreFingerprint, + ); err != nil { + return err + } + if checkpoint.Generation != 1 || + checkpoint.PreviousStateCommitment != + frostsigning.ComputeNativeTBTCSignerStateWitnessGenesis( + checkpoint.StoreFingerprint, + ) { + return fmt.Errorf( + "bootstrap authorization checkpoint is not the exact genesis", + ) + } + if len(certificate.TargetAcknowledgement) != 0 || + certificate.TargetAcknowledgementSHA256 != [32]byte{} || + certificate.FinalSignature != [ed25519.SignatureSize]byte{} || + certificate.CertificateDigest != [32]byte{} { + return fmt.Errorf( + "bootstrap authorization already carries service or final material", + ) + } + coreDigest, err := ComputeFrostNativeSignerAnchorTrustCoreDigest(certificate) + if err != nil || coreDigest != certificate.CoreDigest { + return fmt.Errorf("bootstrap authorization core digest mismatch") + } + operationID := ComputeFrostNativeSignerAnchorTrustOperationID(coreDigest) + if operationID != certificate.OperationID || + ComputeFrostNativeSignerAnchorTrustTransitionDigest( + coreDigest, + operationID, + ) != certificate.TransitionDigest { + return fmt.Errorf( + "bootstrap authorization operation or transition digest mismatch", + ) + } + if !ed25519.Verify( + ed25519.PublicKey(endpoint.OfflineAuthorityPublicKey[:]), + coreDigest[:], + certificate.CoreSignature[:], + ) { + return fmt.Errorf( + "bootstrap authorization offline core signature is invalid", + ) + } + return nil +} + +// bootstrapAnchorVerifierLocked builds a deliberately partial runtime anchor +// client value in order to reuse its hardened POST, nonce, and acknowledgement +// verification methods against the certificate-scoped binding without +// re-implementing them. Exactly the fields those three methods read are +// populated; the value never escapes this client, and its stateful +// Read/CAS/History entry points are never invoked (their guards require state +// this value does not have). +func (client *FrostNativeSignerAnchorBootstrapHTTPClient) bootstrapAnchorVerifierLocked( + certificate *FrostNativeSignerAnchorTrustCertificate, +) *FrostNativeSignerAnchorClient { + return &FrostNativeSignerAnchorClient{ + httpClient: client.httpClient, + requestTimeout: client.requestTimeout, + clockSkew: client.clockSkew, + maximumAckLife: client.maximumAckLife, + identity: FrostNativeSignerAnchorIdentity{ + SignerStoreFingerprint: certificate.SignerStoreFingerprint, + OnlineKeyHash: certificate.To.ResponsePublicKeySPKISHA256, + }, + bindingHash: certificate.To.BindingHash, + onlineKey: client.responseKey, + random: client.random, + now: client.now, + } +} + +// initializeAttemptLocked posts the create-if-absent request. On success it +// returns the verified signed sentinel ("applied" on first create, +// "already-applied" on idempotent replay), each bound to this exact request +// digest and nonce. Once the POST may have reached the service every failure +// is reported with sent=true: it must be resolved by a fresh signed read, +// never by trusting local state. +func (client *FrostNativeSignerAnchorBootstrapHTTPClient) initializeAttemptLocked( + ctx context.Context, + verifier *FrostNativeSignerAnchorClient, + certificate *FrostNativeSignerAnchorTrustCertificate, +) (*FrostNativeSignerCheckpointAcknowledgement, bool, error) { + checkpoint := certificate.To.Reference.Checkpoint + response, requestDigest, nonce, sent, err := client.postBootstrapRequestLocked( + ctx, + verifier, + certificate, + "initialize", + ) + if err != nil { + return nil, sent, err + } + acknowledgement, err := verifier.verifyAcknowledgement( + response, + &requestDigest, + &nonce, + &checkpoint, + &certificate.OperationID, + true, + "applied", + "already-applied", + ) + if err != nil { + // The service may have committed before answering with a malformed, + // truncated, or stale body, so any post-send verification failure is + // ambiguous rather than terminal. + return nil, true, fmt.Errorf( + "invalid native signer anchor initialize acknowledgement: %w", + err, + ) + } + if acknowledgement.TransitionDigest != certificate.TransitionDigest || + acknowledgement.ServiceEpoch != 1 || + acknowledgement.Revision != 1 || + acknowledgement.PreviousEventRoot != [32]byte{} { + return nil, true, fmt.Errorf( + "native signer anchor initialize acknowledgement is outside the exact genesis identity", + ) + } + return acknowledgement, true, nil +} + +// postBootstrapRequestLocked signs and posts one bootstrap request of the +// given kind. Kind is bound first inside the signed transcript, so an +// "initialize" signature can never be replayed as a "read" or vice versa. +func (client *FrostNativeSignerAnchorBootstrapHTTPClient) postBootstrapRequestLocked( + ctx context.Context, + verifier *FrostNativeSignerAnchorClient, + certificate *FrostNativeSignerAnchorTrustCertificate, + kind string, +) ([]byte, [32]byte, [32]byte, bool, error) { + checkpoint := certificate.To.Reference.Checkpoint + nonce, err := verifier.randomBytes32() + if err != nil { + return nil, [32]byte{}, [32]byte{}, false, fmt.Errorf( + "cannot create native signer anchor bootstrap nonce: %w", + err, + ) + } + transcript := frostNativeSignerAnchorInitializeRequestTranscript( + kind, + certificate.To.BindingHash, + nonce, + certificate.OperationID, + certificate.TransitionDigest, + checkpoint, + client.clientSPKIDER, + ) + requestDigest := sha256.Sum256(transcript) + request := frostNativeSignerAnchorInitializeRequest{ + Schema: FrostNativeSignerAnchorInitializeRequestSchema, + Payload: frostNativeSignerAnchorInitializeRequestPayload{ + Kind: kind, + Nonce: frostNativeSignerAnchorHex32(nonce), + BindingHash: frostNativeSignerAnchorHex32(certificate.To.BindingHash), + OperationID: frostNativeSignerAnchorHex32(certificate.OperationID), + TransitionDigest: frostNativeSignerAnchorHex32(certificate.TransitionDigest), + Checkpoint: frostNativeSignerAnchorCheckpointToWire(checkpoint), + }, + ClientPublicKeySPKI: client.clientSPKIBase64, + Signature: frostNativeSignerAnchorSignatureHex( + ed25519.Sign(client.clientKey, transcript), + ), + } + payload, err := json.Marshal(request) + if err != nil { + return nil, [32]byte{}, [32]byte{}, false, fmt.Errorf( + "cannot encode native signer anchor bootstrap request: %w", + err, + ) + } + response, sent, err := verifier.post(ctx, client.initializeEndpoint, payload) + if err != nil { + return nil, [32]byte{}, [32]byte{}, sent, err + } + return response, requestDigest, nonce, true, nil +} + +// reconcileBootstrapReadLocked performs the mandatory fresh signed exact read +// after every sent initialize attempt and requires field-for-field agreement +// with the expected genesis record. Divergence policy: only a response that is +// authentic (service-signed) and bound to this exact request digest and nonce +// can poison the client; every transport-shaped or unverifiable outcome stays +// a retryable error. +func (client *FrostNativeSignerAnchorBootstrapHTTPClient) reconcileBootstrapReadLocked( + ctx context.Context, + verifier *FrostNativeSignerAnchorClient, + certificate *FrostNativeSignerAnchorTrustCertificate, + sentinel *FrostNativeSignerCheckpointAcknowledgement, + initializeErr error, +) (*FrostNativeSignerCheckpointAcknowledgement, error) { + checkpoint := certificate.To.Reference.Checkpoint + response, requestDigest, nonce, _, err := client.postBootstrapRequestLocked( + ctx, + verifier, + certificate, + "read", + ) + if err != nil { + return nil, fmt.Errorf( + "cannot reconcile native signer anchor bootstrap with a fresh signed read: %w", + err, + ) + } + readResponse := frostNativeSignerAnchorReadResponse{} + if err := decodeStrictFrostNativeSignerAnchorJSON( + response, + &readResponse, + ); err != nil { + return nil, fmt.Errorf( + "invalid native signer anchor bootstrap read response: %w", + err, + ) + } + if readResponse.Schema != FrostNativeSignerAnchorReadResponseSchema { + return nil, fmt.Errorf( + "unsupported native signer anchor bootstrap read response schema", + ) + } + responseDigest, err := frostNativeSignerAnchorReadResponseTranscript( + readResponse, + ) + if err != nil { + return nil, fmt.Errorf( + "invalid native signer anchor bootstrap read response: %w", + err, + ) + } + responseSignature, err := frostNativeSignerAnchorParseSignature( + readResponse.Signature, + ) + if err != nil || !ed25519.Verify( + client.responseKey, + responseDigest, + responseSignature[:], + ) { + return nil, fmt.Errorf( + "native signer anchor bootstrap read signature is invalid", + ) + } + responseRequestDigest, err := frostNativeSignerAnchorParseHex32( + readResponse.RequestDigest, + ) + if err != nil || responseRequestDigest != requestDigest { + return nil, fmt.Errorf( + "native signer anchor bootstrap read request digest mismatch", + ) + } + responseNonce, err := frostNativeSignerAnchorParseHex32(readResponse.Nonce) + if err != nil || responseNonce != nonce { + return nil, fmt.Errorf( + "native signer anchor bootstrap read nonce mismatch", + ) + } + // From this point the response is authentic and bound to this exact + // request; the statements below are the service's own, so contradictions + // are divergence, not transport noise. + responseBindingHash, err := frostNativeSignerAnchorParseHex32( + readResponse.BindingHash, + ) + if err != nil { + return nil, fmt.Errorf( + "invalid native signer anchor bootstrap read binding hash", + ) + } + if responseBindingHash != certificate.To.BindingHash { + return nil, client.poisonLocked(fmt.Errorf( + "authenticated read binding hash differs from the authorized stream", + )) + } + switch readResponse.Status { + case "absent": + if sentinel != nil { + return nil, client.poisonLocked(fmt.Errorf( + "service acknowledged the genesis event and then reported an absent stream", + )) + } + return nil, fmt.Errorf( + "native signer anchor bootstrap did not commit and can be retried: %w", + initializeErr, + ) + case "present": + default: + return nil, fmt.Errorf( + "unsupported native signer anchor bootstrap read status", + ) + } + if readResponse.Checkpoint == nil { + return nil, fmt.Errorf( + "native signer anchor bootstrap read checkpoint is absent", + ) + } + storedCheckpoint, err := frostNativeSignerAnchorCheckpointFromWire( + *readResponse.Checkpoint, + ) + if err != nil { + return nil, err + } + operationID, err := frostNativeSignerAnchorParseHex32( + readResponse.OperationID, + ) + if err != nil { + return nil, err + } + transitionDigest, err := frostNativeSignerAnchorParseHex32( + readResponse.TransitionDigest, + ) + if err != nil { + return nil, err + } + if storedCheckpoint != checkpoint || + operationID != certificate.OperationID || + transitionDigest != certificate.TransitionDigest { + return nil, client.poisonLocked(fmt.Errorf( + "stream already holds a different genesis record: checkpoint, operation, or transition differs", + )) + } + // The stored revision-one event of a bootstrap-created stream must be the + // "applied" acknowledgement: that exact JSON is what the offline final + // signature and the native recovery ABI ratify. + acknowledgement, err := verifier.verifyAcknowledgement( + readResponse.CheckpointAck, + nil, + nil, + &checkpoint, + &operationID, + false, + "applied", + ) + if err != nil { + return nil, fmt.Errorf( + "invalid stored native signer bootstrap acknowledgement: %w", + err, + ) + } + if acknowledgement.TransitionDigest != transitionDigest || + acknowledgement.ServiceEpoch != 1 || + acknowledgement.Revision != 1 || + acknowledgement.PreviousEventRoot != [32]byte{} { + return nil, fmt.Errorf( + "stored native signer bootstrap acknowledgement is outside the exact genesis identity", + ) + } + serviceEpoch, err := frostNativeSignerAnchorParseUint64( + readResponse.ServiceEpoch, + ) + if err != nil { + return nil, err + } + revision, err := frostNativeSignerAnchorParseUint64(readResponse.Revision) + if err != nil { + return nil, err + } + eventRoot, err := frostNativeSignerAnchorParseHex32(readResponse.EventRoot) + if err != nil { + return nil, err + } + acknowledgementDigest, err := frostNativeSignerAnchorParseHex32( + readResponse.CheckpointAckDigest, + ) + if err != nil || + serviceEpoch != acknowledgement.ServiceEpoch || + revision != acknowledgement.Revision || + eventRoot != acknowledgement.EventRoot || + acknowledgementDigest != acknowledgement.AcknowledgementDigest { + return nil, fmt.Errorf( + "native signer anchor bootstrap read summary differs from its stored acknowledgement", + ) + } + committedAt, err := frostNativeSignerAnchorParseUint64( + readResponse.CommittedAtUnixMs, + ) + if err != nil { + return nil, err + } + expiresAt, err := frostNativeSignerAnchorParseUint64( + readResponse.ExpiresAtUnixMs, + ) + if err != nil { + return nil, err + } + nowUnixMs := client.now().UnixMilli() + if nowUnixMs < 0 || committedAt == 0 || expiresAt <= committedAt || + expiresAt-committedAt > uint64(client.maximumAckLife/time.Millisecond) || + committedAt > uint64(nowUnixMs)+uint64(client.clockSkew/time.Millisecond) || + expiresAt <= uint64(nowUnixMs) { + return nil, fmt.Errorf( + "native signer anchor bootstrap read response is stale or has an invalid lifetime", + ) + } + // A first-create sentinel and the stored event are the same revision-one + // acknowledgement, so any difference is service equivocation at an equal + // revision. An "already-applied" sentinel is a fresh signature bound to + // its own request and legitimately differs from the stored event bytes. + if sentinel != nil && sentinel.Status == "applied" && + !equalFrostNativeSignerCheckpointAcknowledgements( + sentinel, + acknowledgement, + ) { + return nil, client.poisonLocked(fmt.Errorf( + "stored genesis acknowledgement differs from the freshly applied acknowledgement at an equal revision", + )) + } + acknowledgement.ExactReadRecovery = append([]byte{}, response...) + acknowledgement.ReadRecoveryExpiresAt = expiresAt + return acknowledgement, nil +} + +// poisonLocked records the terminal divergence cause and returns the exact +// error every subsequent call will repeat immediately without touching the +// network. +func (client *FrostNativeSignerAnchorBootstrapHTTPClient) poisonLocked( + cause error, +) error { + client.poisoned = cause + return fmt.Errorf( + "native signer anchor bootstrap client is poisoned: %w", + cause, + ) +} + +// frostNativeSignerAnchorInitializeRequestTranscript is the fixed-width +// client-signed bootstrap request commitment. The kind is bound immediately +// after the schema so the create ("initialize") and reconciliation ("read") +// signatures can never be replayed for one another, and every semantic pin of +// the offline-authorized operation is bound directly: no JSON bytes are ever +// signed. +func frostNativeSignerAnchorInitializeRequestTranscript( + kind string, + bindingHash [32]byte, + nonce [32]byte, + operationID [32]byte, + transitionDigest [32]byte, + checkpoint FrostNativeSignerStateWitnessCheckpoint, + clientSPKIDER []byte, +) []byte { + transcript := newFrostNativeSignerAnchorTranscript( + frostNativeSignerAnchorInitializeRequestDomain, + ) + transcript.string("schema", FrostNativeSignerAnchorInitializeRequestSchema) + transcript.string("kind", kind) + transcript.bytes32("bindingHash", bindingHash) + transcript.bytes32("nonce", nonce) + transcript.bytes32("operationID", operationID) + transcript.bytes32("transitionDigest", transitionDigest) + frostNativeSignerAnchorWriteCheckpoint(transcript, "checkpoint", checkpoint) + transcript.field("clientPublicKeySpki", clientSPKIDER) + return transcript.bytes() +} diff --git a/pkg/tbtc/frost_native_signer_anchor_protocol.go b/pkg/tbtc/frost_native_signer_anchor_protocol.go index 3f2ab12a7a..0e0fd925db 100644 --- a/pkg/tbtc/frost_native_signer_anchor_protocol.go +++ b/pkg/tbtc/frost_native_signer_anchor_protocol.go @@ -19,11 +19,12 @@ import ( ) const ( - FrostNativeSignerAnchorReadRequestSchema = "tbtc-frost-native-signer-state-anchor-read-request/v1" - FrostNativeSignerAnchorReadResponseSchema = "tbtc-frost-native-signer-state-anchor-read-response/v1" - FrostNativeSignerAnchorCASRequestSchema = "tbtc-frost-native-signer-state-anchor-advance-request/v1" - FrostNativeSignerAnchorHistoryRequestSchema = "tbtc-frost-native-signer-state-anchor-history-request/v1" - FrostNativeSignerAnchorHistoryResponseSchema = "tbtc-frost-native-signer-state-anchor-history-response/v1" + FrostNativeSignerAnchorReadRequestSchema = "tbtc-frost-native-signer-state-anchor-read-request/v1" + FrostNativeSignerAnchorReadResponseSchema = "tbtc-frost-native-signer-state-anchor-read-response/v1" + FrostNativeSignerAnchorCASRequestSchema = "tbtc-frost-native-signer-state-anchor-advance-request/v1" + FrostNativeSignerAnchorInitializeRequestSchema = "tbtc-frost-native-signer-state-anchor-initialize-request/v1" + FrostNativeSignerAnchorHistoryRequestSchema = "tbtc-frost-native-signer-state-anchor-history-request/v1" + FrostNativeSignerAnchorHistoryResponseSchema = "tbtc-frost-native-signer-state-anchor-history-response/v1" // FrostNativeSignerCheckpointAcknowledgementSchema is consumed verbatim by // the native signer after Go verifies the online authority signature and @@ -47,13 +48,14 @@ const ( frostNativeSignerAnchorMaximumResponseBytes = 256 * 1024 frostNativeSignerAnchorMaximumHistoryResponseBytes = 4 * 1024 * 1024 - frostNativeSignerAnchorStreamDomain = "tbtc-frost-native-signer-anchor-stream-v1\x00" - frostNativeSignerAnchorBindingDomain = "tbtc-frost-native-signer-anchor-binding-v1\x00" - frostNativeSignerAnchorTransportDomain = "tbtc-frost-native-signer-anchor-transport-v1\x00" - frostNativeSignerAnchorReadRequestDomain = "tbtc-frost-native-signer-anchor-read-request-v1\x00" - frostNativeSignerAnchorCASRequestDomain = "tbtc-frost-native-signer-anchor-cas-request-v1\x00" - frostNativeSignerAnchorHistoryRequestDomain = "tbtc-frost-native-signer-anchor-history-request-v1\x00" - frostNativeSignerAnchorTransitionDomain = "tbtc-frost-native-signer-anchor-transition-v1\x00" + frostNativeSignerAnchorStreamDomain = "tbtc-frost-native-signer-anchor-stream-v1\x00" + frostNativeSignerAnchorBindingDomain = "tbtc-frost-native-signer-anchor-binding-v1\x00" + frostNativeSignerAnchorTransportDomain = "tbtc-frost-native-signer-anchor-transport-v1\x00" + frostNativeSignerAnchorReadRequestDomain = "tbtc-frost-native-signer-anchor-read-request-v1\x00" + frostNativeSignerAnchorCASRequestDomain = "tbtc-frost-native-signer-anchor-cas-request-v1\x00" + frostNativeSignerAnchorInitializeRequestDomain = "tbtc-frost-native-signer-anchor-initialize-request-v1\x00" + frostNativeSignerAnchorHistoryRequestDomain = "tbtc-frost-native-signer-anchor-history-request-v1\x00" + frostNativeSignerAnchorTransitionDomain = "tbtc-frost-native-signer-anchor-transition-v1\x00" ) // FrostNativeSignerAnchorIdentity is the complete authenticated identity of From e0374501ff19f8f6130b199cb7f9d4cf665a806b Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 26 Jul 2026 20:04:17 -0400 Subject: [PATCH 19/93] test(tbtc): cover bootstrap initialize client protocol Exercise the bootstrap initialize HTTP client against an httptest fake history service with real Ed25519 keys: - happy path: applied acknowledgement plus reconciling read passes InitializeFrostNativeSignerAnchorBootstrap and finalize end-to-end. - idempotent retry: a commit followed by a malformed response reconciles through the fresh signed read; the second full call succeeds through the already-applied sentinel, and both results are identical except the nonce-fresh exact-read wrapper. - divergence: an authenticated read showing a foreign genesis record poisons the client permanently; repeated calls return the identical error with zero additional network requests, as does a signed absent read that contradicts a verified applied sentinel. - decode strictness table: wrong acknowledgement schema, wrong status, tampered signature, oversized response, wrong content type, non-200, and trailing data are all ambiguous and retryable, never accepted and never poisoning. - endpoint/config negative tables: non-canonical endpoint forms, HTTPS-without-pin, plaintext non-loopback, duplicate/unknown/case- folded members, non-canonical numbers, zero or aliased client keys. - transcript stability: frozen digests, length, and signature for the exact initialize and read request transcripts. - cmd: the default initialize factory reaches the client-config loader instead of the nil-factory transport-unavailable sentinel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DpftF6aYHh56KgiHU6D2NQ --- cmd/tbtc_signer_test.go | 68 + ...ive_signer_anchor_bootstrap_client_test.go | 1162 +++++++++++++++++ 2 files changed, 1230 insertions(+) create mode 100644 pkg/tbtc/frost_native_signer_anchor_bootstrap_client_test.go diff --git a/cmd/tbtc_signer_test.go b/cmd/tbtc_signer_test.go index 3029f9a669..ab4a53da99 100644 --- a/cmd/tbtc_signer_test.go +++ b/cmd/tbtc_signer_test.go @@ -484,6 +484,74 @@ func TestFrostNativeSignerAnchorBootstrapCommandCeremony(t *testing.T) { } } +// TestFrostNativeSignerAnchorBootstrapCommandDefaultFactoryLoadsClientConfig +// proves the production factory is wired: with valid core artifacts the +// initialize command must reach the bootstrap client config loader instead of +// failing with the transport-unavailable sentinel reserved for nil factories. +func TestFrostNativeSignerAnchorBootstrapCommandDefaultFactoryLoadsClientConfig( + t *testing.T, +) { + fixture := newTBTCSignerBootstrapTestFixture() + directory := t.TempDir() + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + core, err := tbtc.PrepareFrostNativeSignerAnchorBootstrapCore( + fixture.facts, + fixture.plan, + ) + if err != nil { + t.Fatal(err) + } + coreJSON, err := tbtc.EncodeFrostNativeSignerAnchorBootstrapCoreArtifact( + core, + ) + if err != nil { + t.Fatal(err) + } + corePath := filepath.Join(directory, "core.json") + writeTBTCSignerBootstrapTestArtifact(t, corePath, coreJSON) + coreSignature := &tbtc.FrostNativeSignerAnchorBootstrapDetachedSignature{ + Schema: tbtc.FrostNativeSignerAnchorBootstrapDetachedSignatureSchema, + Stage: tbtc.FrostNativeSignerAnchorBootstrapCoreSignatureStage, + Digest: core.CoreDigest, + } + copy( + coreSignature.Signature[:], + ed25519.Sign(fixture.authority, core.CoreDigest[:]), + ) + coreSignatureJSON, err := + tbtc.EncodeFrostNativeSignerAnchorBootstrapDetachedSignature( + coreSignature, + ) + if err != nil { + t.Fatal(err) + } + coreSignaturePath := filepath.Join(directory, "core-signature.json") + writeTBTCSignerBootstrapTestArtifact( + t, + coreSignaturePath, + coreSignatureJSON, + ) + err = runTBTCSignerBootstrapCommand( + t, + tbtcSignerAnchorBootstrapTransportFactory, + "anchor", "bootstrap", "initialize", + "--core", corePath, + "--core-signature", coreSignaturePath, + "--client-config", filepath.Join(directory, "client-config.json"), + "--output", filepath.Join(directory, "final.json"), + ) + if err == nil || + strings.Contains(err.Error(), "transport is not available") || + !strings.Contains(err.Error(), "bootstrap client config") { + t.Fatalf( + "default transport factory did not reach the config loader: %v", + err, + ) + } +} + func TestFrostNativeSignerAnchorBootstrapCommandInitializeWithoutTransport( t *testing.T, ) { diff --git a/pkg/tbtc/frost_native_signer_anchor_bootstrap_client_test.go b/pkg/tbtc/frost_native_signer_anchor_bootstrap_client_test.go new file mode 100644 index 0000000000..5a399c2a2c --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_bootstrap_client_test.go @@ -0,0 +1,1162 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +// bootstrapClientTestEnvironment runs a fake bootstrap history service that +// verifies client request signatures, keeps at most one stored genesis +// acknowledgement, and answers both bootstrap request kinds on the single +// initialize endpoint. +type bootstrapClientTestEnvironment struct { + t *testing.T + server *httptest.Server + endpoint string + nowFunc func() time.Time + + authority ed25519.PrivateKey + response ed25519.PrivateKey + clientKey ed25519.PrivateKey + clientSPKI []byte + + identity FrostNativeSignerAnchorIdentity + plan *FrostNativeSignerAnchorBootstrapPlan + facts *frostsigning.NativeTBTCSignerStateAnchorBootstrapFacts + core *FrostNativeSignerAnchorBootstrapCoreArtifact + coreSignature *FrostNativeSignerAnchorBootstrapDetachedSignature + client *FrostNativeSignerAnchorBootstrapHTTPClient + + mutex sync.Mutex + stored *FrostNativeSignerCheckpointAcknowledgement + storedJSON []byte + initializeHook func(http.ResponseWriter, [32]byte, [32]byte) bool + readHook func(http.ResponseWriter, [32]byte, [32]byte) bool + garbleFirstInitialize bool + initializeCalls int + readCalls int + totalRequests int +} + +func newBootstrapClientTestEnvironment( + t *testing.T, +) *bootstrapClientTestEnvironment { + fixedNow := time.UnixMilli(1_700_000_000_000) + return newBootstrapClientTestEnvironmentWithNow( + t, + func() time.Time { return fixedNow }, + ) +} + +func newBootstrapClientTestEnvironmentWithNow( + t *testing.T, + nowFunc func() time.Time, +) *bootstrapClientTestEnvironment { + t.Helper() + environment := &bootstrapClientTestEnvironment{ + t: t, + nowFunc: nowFunc, + authority: ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x61}, ed25519.SeedSize), + ), + response: ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x62}, ed25519.SeedSize), + ), + clientKey: ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x63}, ed25519.SeedSize), + ), + } + clientSPKI, err := x509.MarshalPKIXPublicKey(environment.clientKey.Public()) + if err != nil { + t.Fatal(err) + } + environment.clientSPKI = clientSPKI + environment.server = httptest.NewServer(http.HandlerFunc(environment.handle)) + t.Cleanup(environment.server.Close) + environment.endpoint = environment.server.URL + "/anchor" + + authorityPublic := trustTestRawPublicKey(environment.authority) + responsePublic := trustTestRawPublicKey(environment.response) + store := trustTestBytes32(0x03) + identity := FrostNativeSignerAnchorIdentity{ + ProtocolID: trustTestBytes32(0x01), + ActivationManifestHash: trustTestBytes32(0x04), + ActivationManifestSequence: 9, + TrustDomainID: "bootstrap-client-trust-domain", + OnlineKeyHash: ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + responsePublic, + ), + OperatorFingerprint: trustTestBytes32(0x06), + HistoryStoreID: "bootstrap-client-history-store", + HistoryStoreFingerprint: trustTestBytes32(0x07), + HistoryClusterFingerprint: trustTestBytes32(0x08), + OfflineAuthorityHash: ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + authorityPublic, + ), + ClientSPKIHash: sha256.Sum256(clientSPKI), + SignerStoreFingerprint: store, + TransportBinding: ComputeFrostNativeSignerAnchorTransportBinding( + environment.endpoint, + ), + WitnessMaximumRecords: 1000, + WitnessRotationThresholdRecords: 900, + } + identity.StreamID = ComputeFrostNativeSignerAnchorStreamID(identity) + environment.identity = identity + genesis := frostsigning.ComputeNativeTBTCSignerStateWitnessGenesis(store) + image := trustTestBytes32(0x0a) + environment.plan = &FrostNativeSignerAnchorBootstrapPlan{ + Schema: FrostNativeSignerAnchorBootstrapPlanSchema, + Endpoint: environment.endpoint, + Identity: identity, + ResponsePublicKey: responsePublic, + OfflineAuthorityPublicKey: authorityPublic, + } + environment.facts = &frostsigning.NativeTBTCSignerStateAnchorBootstrapFacts{ + Schema: frostsigning.NativeTBTCSignerStateAnchorBootstrapFactsSchema, + StoreFingerprint: store, + CurrentCheckpoint: frostsigning.NativeTBTCSignerStateAnchorCheckpoint{ + StoreFingerprint: store, + Generation: 1, + PreviousStateCommitment: genesis, + StateImageDigest: image, + StateCommitment: frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + store, + 1, + genesis, + image, + ), + }, + } + core, err := PrepareFrostNativeSignerAnchorBootstrapCore( + environment.facts, + environment.plan, + ) + if err != nil { + t.Fatalf("bootstrap client test core preparation failed: %v", err) + } + environment.core = core + environment.coreSignature = bootstrapProvisioningTestDetachedSignature( + environment.authority, + FrostNativeSignerAnchorBootstrapCoreSignatureStage, + core.CoreDigest, + ) + client, err := NewFrostNativeSignerAnchorBootstrapClient( + FrostNativeSignerAnchorBootstrapClientConfig{ + Endpoint: environment.endpoint, + ResponsePublicKey: responsePublic, + ResponsePublicKeySPKISHA256: ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + responsePublic, + ), + ClientPrivateKey: environment.clientKey, + Now: environment.nowFunc, + }, + ) + if err != nil { + t.Fatalf("bootstrap client construction failed: %v", err) + } + environment.client = client + return environment +} + +func (environment *bootstrapClientTestEnvironment) authorization() FrostNativeSignerAnchorBootstrapAuthorization { + certificate := frostNativeSignerAnchorBootstrapCoreCertificate( + &environment.core.Plan, + environment.core.Checkpoint, + ) + certificate.CoreDigest = environment.core.CoreDigest + certificate.CoreSignature = environment.coreSignature.Signature + certificate.OperationID = environment.core.OperationID + certificate.TransitionDigest = environment.core.TransitionDigest + return FrostNativeSignerAnchorBootstrapAuthorization{ + Certificate: certificate, + } +} + +func (environment *bootstrapClientTestEnvironment) handle( + writer http.ResponseWriter, + request *http.Request, +) { + environment.mutex.Lock() + defer environment.mutex.Unlock() + environment.totalRequests++ + if request.URL.Path != "/anchor/initialize" { + http.NotFound(writer, request) + return + } + payload, _ := io.ReadAll(request.Body) + decoded := frostNativeSignerAnchorInitializeRequest{} + if err := decodeStrictFrostNativeSignerAnchorJSON(payload, &decoded); err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + if decoded.Schema != FrostNativeSignerAnchorInitializeRequestSchema { + http.Error(writer, "unsupported schema", http.StatusBadRequest) + return + } + nonce, err := frostNativeSignerAnchorParseHex32(decoded.Payload.Nonce) + if err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + bindingHash, err := frostNativeSignerAnchorParseHex32( + decoded.Payload.BindingHash, + ) + if err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + operationID, err := frostNativeSignerAnchorParseHex32( + decoded.Payload.OperationID, + ) + if err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + transitionDigest, err := frostNativeSignerAnchorParseHex32( + decoded.Payload.TransitionDigest, + ) + if err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + checkpoint, err := frostNativeSignerAnchorCheckpointFromWire( + decoded.Payload.Checkpoint, + ) + if err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + transcript := frostNativeSignerAnchorInitializeRequestTranscript( + decoded.Payload.Kind, + bindingHash, + nonce, + operationID, + transitionDigest, + checkpoint, + environment.clientSPKI, + ) + signature, err := frostNativeSignerAnchorParseSignature(decoded.Signature) + if err != nil || !ed25519.Verify( + environment.clientKey.Public().(ed25519.PublicKey), + transcript, + signature[:], + ) { + http.Error(writer, "invalid request signature", http.StatusUnauthorized) + return + } + requestDigest := sha256.Sum256(transcript) + writer.Header().Set("Content-Type", "application/json") + switch decoded.Payload.Kind { + case "initialize": + environment.initializeCalls++ + if environment.initializeHook != nil && + environment.initializeHook(writer, requestDigest, nonce) { + return + } + if environment.stored == nil { + environment.stored, environment.storedJSON = + testFrostNativeSignerAnchorAcknowledgement( + environment.t, + environment.identity, + checkpoint, + operationID, + transitionDigest, + requestDigest, + nonce, + "applied", + 1, + 1, + [32]byte{}, + environment.nowFunc(), + environment.response, + ) + if environment.garbleFirstInitialize && + environment.initializeCalls == 1 { + _, _ = writer.Write([]byte(`{"garbage"`)) + return + } + _, _ = writer.Write(environment.storedJSON) + return + } + if environment.stored.OperationID != operationID { + http.Error( + writer, + "conflicting native signer anchor stream", + http.StatusConflict, + ) + return + } + _, sentinelJSON := testFrostNativeSignerAnchorAcknowledgement( + environment.t, + environment.identity, + environment.stored.Checkpoint, + environment.stored.OperationID, + environment.stored.TransitionDigest, + requestDigest, + nonce, + "already-applied", + 1, + 1, + [32]byte{}, + environment.nowFunc(), + environment.response, + ) + _, _ = writer.Write(sentinelJSON) + case "read": + environment.readCalls++ + if environment.readHook != nil && + environment.readHook(writer, requestDigest, nonce) { + return + } + if environment.stored == nil { + _, _ = writer.Write(testFrostNativeSignerAnchorAbsentReadResponse( + environment.t, + environment.identity, + requestDigest, + nonce, + environment.response, + )) + return + } + _, _ = writer.Write(bootstrapClientTestReadResponse( + environment.t, + environment.identity, + requestDigest, + nonce, + environment.stored, + environment.storedJSON, + environment.nowFunc(), + environment.response, + )) + default: + http.Error(writer, "unsupported bootstrap request kind", http.StatusBadRequest) + } +} + +func (environment *bootstrapClientTestEnvironment) setInitializeHook( + hook func(http.ResponseWriter, [32]byte, [32]byte) bool, +) { + environment.mutex.Lock() + defer environment.mutex.Unlock() + environment.initializeHook = hook +} + +func (environment *bootstrapClientTestEnvironment) setReadHook( + hook func(http.ResponseWriter, [32]byte, [32]byte) bool, +) { + environment.mutex.Lock() + defer environment.mutex.Unlock() + environment.readHook = hook +} + +func (environment *bootstrapClientTestEnvironment) setGarbleFirstInitialize() { + environment.mutex.Lock() + defer environment.mutex.Unlock() + environment.garbleFirstInitialize = true +} + +func (environment *bootstrapClientTestEnvironment) setStoredForeignRecord() { + environment.mutex.Lock() + defer environment.mutex.Unlock() + environment.stored, environment.storedJSON = + testFrostNativeSignerAnchorAcknowledgement( + environment.t, + environment.identity, + environment.core.Checkpoint, + trustTestBytes32(0xee), + trustTestBytes32(0xef), + trustTestBytes32(0xe1), + trustTestBytes32(0xe2), + "applied", + 1, + 1, + [32]byte{}, + environment.nowFunc(), + environment.response, + ) +} + +func (environment *bootstrapClientTestEnvironment) counters() (int, int, int) { + environment.mutex.Lock() + defer environment.mutex.Unlock() + return environment.initializeCalls, environment.readCalls, environment.totalRequests +} + +func (environment *bootstrapClientTestEnvironment) boundAcknowledgementJSON( + requestDigest [32]byte, + nonce [32]byte, + signingKey ed25519.PrivateKey, +) []byte { + _, acknowledgementJSON := testFrostNativeSignerAnchorAcknowledgement( + environment.t, + environment.identity, + environment.core.Checkpoint, + environment.core.OperationID, + environment.core.TransitionDigest, + requestDigest, + nonce, + "applied", + 1, + 1, + [32]byte{}, + environment.nowFunc(), + signingKey, + ) + return acknowledgementJSON +} + +// bootstrapClientTestReadResponse mirrors testFrostNativeSignerAnchorReadResponse +// with caller-controlled wrapper times so real-clock loader tests stay fresh. +func bootstrapClientTestReadResponse( + t *testing.T, + identity FrostNativeSignerAnchorIdentity, + requestDigest [32]byte, + nonce [32]byte, + acknowledgement *FrostNativeSignerCheckpointAcknowledgement, + acknowledgementJSON []byte, + now time.Time, + onlinePrivate ed25519.PrivateKey, +) []byte { + t.Helper() + checkpointWire := frostNativeSignerAnchorCheckpointToWire( + acknowledgement.Checkpoint, + ) + response := frostNativeSignerAnchorReadResponse{ + Schema: FrostNativeSignerAnchorReadResponseSchema, + BindingHash: frostNativeSignerAnchorHex32( + ComputeFrostNativeSignerAnchorBindingHash(identity), + ), + RequestDigest: frostNativeSignerAnchorHex32(requestDigest), + Nonce: frostNativeSignerAnchorHex32(nonce), + Status: "present", + ServiceEpoch: fmt.Sprint(acknowledgement.ServiceEpoch), + Revision: fmt.Sprint(acknowledgement.Revision), + EventRoot: frostNativeSignerAnchorHex32(acknowledgement.EventRoot), + Checkpoint: &checkpointWire, + OperationID: frostNativeSignerAnchorHex32(acknowledgement.OperationID), + TransitionDigest: frostNativeSignerAnchorHex32(acknowledgement.TransitionDigest), + CommittedAtUnixMs: fmt.Sprint(now.Add(-time.Second).UnixMilli()), + ExpiresAtUnixMs: fmt.Sprint(now.Add(29 * time.Second).UnixMilli()), + CheckpointAck: append([]byte{}, acknowledgementJSON...), + CheckpointAckDigest: frostNativeSignerAnchorHex32( + acknowledgement.AcknowledgementDigest, + ), + } + digest, err := frostNativeSignerAnchorReadResponseTranscript(response) + if err != nil { + t.Fatal(err) + } + response.Signature = frostNativeSignerAnchorSignatureHex( + ed25519.Sign(onlinePrivate, digest), + ) + payload, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + return payload +} + +func TestFrostNativeSignerAnchorBootstrapClientAppliedEndToEnd(t *testing.T) { + environment := newBootstrapClientTestEnvironment(t) + final, err := InitializeFrostNativeSignerAnchorBootstrap( + context.Background(), + environment.core, + environment.coreSignature, + environment.client, + ) + if err != nil { + t.Fatalf("bootstrap initialize over the HTTP client failed: %v", err) + } + initializeCalls, readCalls, _ := environment.counters() + if initializeCalls != 1 || readCalls != 1 { + t.Fatalf( + "expected one initialize and one reconciliation read, got [%d]/[%d]", + initializeCalls, + readCalls, + ) + } + if final.TargetReference.ServiceEpoch != 1 || + final.TargetReference.Revision != 1 || + final.TargetReference.PreviousEventRoot != [32]byte{} || + final.TargetReference.Checkpoint != environment.core.Checkpoint { + t.Fatalf("unexpected bootstrap final target reference: %+v", final) + } + finalSignature := bootstrapProvisioningTestDetachedSignature( + environment.authority, + FrostNativeSignerAnchorBootstrapFinalSignatureStage, + final.FinalDigest, + ) + bundle, err := FinalizeFrostNativeSignerAnchorBootstrap( + final, + finalSignature, + bootstrapProvisioningTestBaseConfig(), + ) + if err != nil { + t.Fatalf("bootstrap finalize after the HTTP client failed: %v", err) + } + if _, err := DecodeFrostNativeSignerAnchorBootstrapOutputBundle( + bundle, + ); err != nil { + t.Fatalf("certified bundle from the HTTP client run is invalid: %v", err) + } +} + +func TestFrostNativeSignerAnchorBootstrapClientRecoversCommittedAmbiguousInitialize( + t *testing.T, +) { + environment := newBootstrapClientTestEnvironment(t) + environment.setGarbleFirstInitialize() + + first, err := environment.client.InitializeFrostNativeSignerAnchor( + context.Background(), + environment.authorization(), + ) + if err != nil { + t.Fatalf("committed-but-ambiguous initialize did not reconcile: %v", err) + } + initializeCalls, readCalls, _ := environment.counters() + if initializeCalls != 1 || readCalls != 1 { + t.Fatalf( + "expected reconciliation through one read, got [%d]/[%d]", + initializeCalls, + readCalls, + ) + } + second, err := environment.client.InitializeFrostNativeSignerAnchor( + context.Background(), + environment.authorization(), + ) + if err != nil { + t.Fatalf("idempotent already-applied retry failed: %v", err) + } + // The exact read wrapper is nonce-fresh by design, so the results must be + // identical everywhere except the retained read-recovery JSON. + firstRecord := *first.Record + secondRecord := *second.Record + if len(firstRecord.ReadRecoveryJSON) == 0 || + len(secondRecord.ReadRecoveryJSON) == 0 || + firstRecord.ReadRecoveryExpires == 0 || + secondRecord.ReadRecoveryExpires == 0 { + t.Fatal("bootstrap results did not retain fresh exact-read recovery") + } + firstRecord.ReadRecoveryJSON = nil + secondRecord.ReadRecoveryJSON = nil + if !reflect.DeepEqual(firstRecord, secondRecord) { + t.Fatalf( + "recovered and already-applied results differ:\n%+v\n%+v", + firstRecord, + secondRecord, + ) + } + if !bytes.Equal(first.Record.AcknowledgementJSON, second.Record.AcknowledgementJSON) { + t.Fatal("stored genesis acknowledgement bytes changed between calls") + } + // The reconciled result must still satisfy the full offline validation. + if _, err := InitializeFrostNativeSignerAnchorBootstrap( + context.Background(), + environment.core, + environment.coreSignature, + environment.client, + ); err != nil { + t.Fatalf("reconciled record failed the offline core validation: %v", err) + } +} + +func TestFrostNativeSignerAnchorBootstrapClientPoisonsDivergentGenesisRecord( + t *testing.T, +) { + environment := newBootstrapClientTestEnvironment(t) + environment.setStoredForeignRecord() + + first, err := environment.client.InitializeFrostNativeSignerAnchor( + context.Background(), + environment.authorization(), + ) + if err == nil || first != nil || + !strings.Contains(err.Error(), "poisoned") || + !strings.Contains(err.Error(), "different genesis record") { + t.Fatalf("expected divergence poisoning, got [%v]", err) + } + _, _, requestsAfterPoison := environment.counters() + for attempt := 0; attempt < 2; attempt++ { + repeat, repeatErr := environment.client.InitializeFrostNativeSignerAnchor( + context.Background(), + environment.authorization(), + ) + if repeatErr == nil || repeat != nil || + repeatErr.Error() != err.Error() { + t.Fatalf( + "poisoned client returned a different result: [%v]", + repeatErr, + ) + } + } + if _, _, requests := environment.counters(); requests != requestsAfterPoison { + t.Fatalf( + "poisoned client touched the network: [%d] != [%d]", + requests, + requestsAfterPoison, + ) + } +} + +func TestFrostNativeSignerAnchorBootstrapClientPoisonsEquivocatingAbsentRead( + t *testing.T, +) { + environment := newBootstrapClientTestEnvironment(t) + environment.setReadHook(func( + writer http.ResponseWriter, + requestDigest [32]byte, + nonce [32]byte, + ) bool { + _, _ = writer.Write(testFrostNativeSignerAnchorAbsentReadResponse( + environment.t, + environment.identity, + requestDigest, + nonce, + environment.response, + )) + return true + }) + _, err := environment.client.InitializeFrostNativeSignerAnchor( + context.Background(), + environment.authorization(), + ) + if err == nil || !strings.Contains(err.Error(), "poisoned") || + !strings.Contains(err.Error(), "absent stream") { + t.Fatalf("expected equivocation poisoning, got [%v]", err) + } + _, _, requests := environment.counters() + if _, repeatErr := environment.client.InitializeFrostNativeSignerAnchor( + context.Background(), + environment.authorization(), + ); repeatErr == nil || repeatErr.Error() != err.Error() { + t.Fatalf("expected persistent poison, got [%v]", repeatErr) + } + if _, _, after := environment.counters(); after != requests { + t.Fatal("poisoned client touched the network") + } +} + +func TestFrostNativeSignerAnchorBootstrapClientDoesNotPoisonUnreachableService( + t *testing.T, +) { + environment := newBootstrapClientTestEnvironment(t) + environment.server.Close() + _, err := environment.client.InitializeFrostNativeSignerAnchor( + context.Background(), + environment.authorization(), + ) + if err == nil || strings.Contains(err.Error(), "poisoned") || + !strings.Contains(err.Error(), "cannot reconcile") { + t.Fatalf("expected retryable transport failure, got [%v]", err) + } + _, secondErr := environment.client.InitializeFrostNativeSignerAnchor( + context.Background(), + environment.authorization(), + ) + if secondErr == nil || strings.Contains(secondErr.Error(), "poisoned") { + t.Fatalf("transport failure poisoned the client: [%v]", secondErr) + } +} + +func TestFrostNativeSignerAnchorBootstrapClientInitializeResponseStrictness( + t *testing.T, +) { + oversized := bytes.Repeat( + []byte{'a'}, + frostNativeSignerAnchorMaximumResponseBytes+1, + ) + tests := map[string]func( + *bootstrapClientTestEnvironment, + http.ResponseWriter, + [32]byte, + [32]byte, + ){ + "wrong acknowledgement schema": func( + environment *bootstrapClientTestEnvironment, + writer http.ResponseWriter, + requestDigest [32]byte, + nonce [32]byte, + ) { + acknowledgement := environment.boundAcknowledgementJSON( + requestDigest, + nonce, + environment.response, + ) + _, _ = writer.Write(bytes.Replace( + acknowledgement, + []byte("tbtc-signer-state-witness-checkpoint-ack/v1"), + []byte("tbtc-signer-state-witness-checkpoint-ack/v2"), + 1, + )) + }, + "wrong acknowledgement status": func( + environment *bootstrapClientTestEnvironment, + writer http.ResponseWriter, + requestDigest [32]byte, + nonce [32]byte, + ) { + acknowledgement := environment.boundAcknowledgementJSON( + requestDigest, + nonce, + environment.response, + ) + _, _ = writer.Write(bytes.Replace( + acknowledgement, + []byte(`"status":"applied"`), + []byte(`"status":"rejected"`), + 1, + )) + }, + "tampered acknowledgement signature": func( + environment *bootstrapClientTestEnvironment, + writer http.ResponseWriter, + requestDigest [32]byte, + nonce [32]byte, + ) { + _, _ = writer.Write(environment.boundAcknowledgementJSON( + requestDigest, + nonce, + environment.authority, + )) + }, + "oversized response": func( + _ *bootstrapClientTestEnvironment, + writer http.ResponseWriter, + _ [32]byte, + _ [32]byte, + ) { + _, _ = writer.Write(oversized) + }, + "wrong content type": func( + environment *bootstrapClientTestEnvironment, + writer http.ResponseWriter, + requestDigest [32]byte, + nonce [32]byte, + ) { + writer.Header().Set("Content-Type", "text/plain") + _, _ = writer.Write(environment.boundAcknowledgementJSON( + requestDigest, + nonce, + environment.response, + )) + }, + "non-200 status": func( + _ *bootstrapClientTestEnvironment, + writer http.ResponseWriter, + _ [32]byte, + _ [32]byte, + ) { + http.Error(writer, "service failure", http.StatusInternalServerError) + }, + "trailing data": func( + environment *bootstrapClientTestEnvironment, + writer http.ResponseWriter, + requestDigest [32]byte, + nonce [32]byte, + ) { + acknowledgement := environment.boundAcknowledgementJSON( + requestDigest, + nonce, + environment.response, + ) + _, _ = writer.Write(append(acknowledgement, []byte("{}")...)) + }, + } + for name, respond := range tests { + t.Run(name, func(t *testing.T) { + environment := newBootstrapClientTestEnvironment(t) + environment.setInitializeHook(func( + writer http.ResponseWriter, + requestDigest [32]byte, + nonce [32]byte, + ) bool { + respond(environment, writer, requestDigest, nonce) + return true + }) + _, err := environment.client.InitializeFrostNativeSignerAnchor( + context.Background(), + environment.authorization(), + ) + if err == nil || strings.Contains(err.Error(), "poisoned") || + !strings.Contains(err.Error(), "did not commit") { + t.Fatalf( + "expected an ambiguous, retryable rejection, got [%v]", + err, + ) + } + _, readCalls, _ := environment.counters() + if readCalls != 1 { + t.Fatalf( + "expected one reconciliation read after the bad response, got [%d]", + readCalls, + ) + } + environment.setInitializeHook(nil) + if _, err := environment.client.InitializeFrostNativeSignerAnchor( + context.Background(), + environment.authorization(), + ); err != nil { + t.Fatalf("retry after a rejected response failed: %v", err) + } + }) + } +} + +func bootstrapClientTestConfigJSON( + endpoint string, + responseKeyHex string, + responsePinHex string, + leafHex string, + keyPath string, + timeout string, +) string { + return `{"schema":"` + FrostNativeSignerAnchorBootstrapClientConfigSchema + + `","endpoint":"` + endpoint + + `","responsePublicKey":"` + responseKeyHex + + `","responsePublicKeySpkiSha256":"` + responsePinHex + + `","endpointLeafSpkiHash":"` + leafHex + + `","clientPrivateKeyPath":"` + keyPath + + `","requestTimeoutMilliseconds":"` + timeout + `"}` +} + +func TestFrostNativeSignerAnchorBootstrapClientConfigDecodeStrictness( + t *testing.T, +) { + response := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x62}, ed25519.SeedSize), + ) + responsePublic := trustTestRawPublicKey(response) + responseHex := frostNativeSignerAnchorHex32(responsePublic) + pinHex := frostNativeSignerAnchorHex32( + ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256(responsePublic), + ) + zeroHex := frostNativeSignerAnchorHex32([32]byte{}) + leafHex := frostNativeSignerAnchorHex32(trustTestBytes32(0x0c)) + keyPath := "/var/lib/keep/bootstrap-client-key.pem" + valid := bootstrapClientTestConfigJSON( + "http://127.0.0.1:9799/anchor", + responseHex, + pinHex, + zeroHex, + keyPath, + "3000", + ) + config, err := DecodeFrostNativeSignerAnchorBootstrapClientConfig( + []byte(valid), + ) + if err != nil { + t.Fatalf("canonical bootstrap client config was rejected: %v", err) + } + if config.Endpoint != "http://127.0.0.1:9799/anchor" || + config.ResponsePublicKey != responsePublic || + config.ClientPrivateKeyPath != keyPath || + config.RequestTimeout != 3*time.Second { + t.Fatalf("canonical bootstrap client config decoded incorrectly: %+v", config) + } + httpsValid := bootstrapClientTestConfigJSON( + "https://anchor.example/anchor", + responseHex, + pinHex, + leafHex, + keyPath, + "3000", + ) + if _, err := DecodeFrostNativeSignerAnchorBootstrapClientConfig( + []byte(httpsValid), + ); err != nil { + t.Fatalf("canonical HTTPS bootstrap client config was rejected: %v", err) + } + + invalid := map[string]string{ + "wrong schema": strings.Replace( + valid, + FrostNativeSignerAnchorBootstrapClientConfigSchema, + "tbtc-frost-native-signer-anchor-bootstrap-client-config/v2", + 1, + ), + "non-numeric loopback endpoint": bootstrapClientTestConfigJSON( + "http://localhost:9799/anchor", responseHex, pinHex, zeroHex, keyPath, "3000", + ), + "plaintext non-loopback endpoint": bootstrapClientTestConfigJSON( + "http://10.0.0.1:9799/anchor", responseHex, pinHex, zeroHex, keyPath, "3000", + ), + "loopback endpoint without a fixed port": bootstrapClientTestConfigJSON( + "http://127.0.0.1/anchor", responseHex, pinHex, zeroHex, keyPath, "3000", + ), + "uppercase endpoint host": bootstrapClientTestConfigJSON( + "https://ANCHOR.example/anchor", responseHex, pinHex, leafHex, keyPath, "3000", + ), + "endpoint trailing slash": bootstrapClientTestConfigJSON( + "https://anchor.example/anchor/", responseHex, pinHex, leafHex, keyPath, "3000", + ), + "endpoint query": bootstrapClientTestConfigJSON( + "https://anchor.example/anchor?x=1", responseHex, pinHex, leafHex, keyPath, "3000", + ), + "HTTPS without a leaf SPKI pin": bootstrapClientTestConfigJSON( + "https://anchor.example/anchor", responseHex, pinHex, zeroHex, keyPath, "3000", + ), + "loopback HTTP with a leaf SPKI pin": bootstrapClientTestConfigJSON( + "http://127.0.0.1:9799/anchor", responseHex, pinHex, leafHex, keyPath, "3000", + ), + "zero response key": bootstrapClientTestConfigJSON( + "http://127.0.0.1:9799/anchor", zeroHex, pinHex, zeroHex, keyPath, "3000", + ), + "response key SPKI pin mismatch": bootstrapClientTestConfigJSON( + "http://127.0.0.1:9799/anchor", responseHex, + frostNativeSignerAnchorHex32(trustTestBytes32(0x0d)), + zeroHex, keyPath, "3000", + ), + "relative client key path": bootstrapClientTestConfigJSON( + "http://127.0.0.1:9799/anchor", responseHex, pinHex, zeroHex, + "relative/key.pem", "3000", + ), + "empty client key path": bootstrapClientTestConfigJSON( + "http://127.0.0.1:9799/anchor", responseHex, pinHex, zeroHex, "", "3000", + ), + "zero request timeout": bootstrapClientTestConfigJSON( + "http://127.0.0.1:9799/anchor", responseHex, pinHex, zeroHex, keyPath, "0", + ), + "non-canonical request timeout": bootstrapClientTestConfigJSON( + "http://127.0.0.1:9799/anchor", responseHex, pinHex, zeroHex, keyPath, "0300", + ), + "request timeout above the bound": bootstrapClientTestConfigJSON( + "http://127.0.0.1:9799/anchor", responseHex, pinHex, zeroHex, keyPath, "30001", + ), + "bare JSON number timeout": strings.Replace( + valid, `"requestTimeoutMilliseconds":"3000"`, + `"requestTimeoutMilliseconds":3000`, 1, + ), + "duplicate member": strings.Replace( + valid, `"endpoint":`, + `"endpoint":"http://127.0.0.1:1/anchor","endpoint":`, 1, + ), + "case-folded duplicate member": strings.Replace( + valid, `"endpoint":`, `"Endpoint":"x","endpoint":`, 1, + ), + "unknown member": strings.Replace( + valid, `{"schema"`, `{"extra":"x","schema"`, 1, + ), + "trailing data": valid + "{}", + "empty config": "", + } + for name, payload := range invalid { + t.Run(name, func(t *testing.T) { + if _, err := DecodeFrostNativeSignerAnchorBootstrapClientConfig( + []byte(payload), + ); err == nil { + t.Fatal("expected canonical bootstrap client config rejection") + } + }) + } +} + +func TestFrostNativeSignerAnchorBootstrapClientConstructorRejectsInvalidMaterial( + t *testing.T, +) { + response := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x62}, ed25519.SeedSize), + ) + client := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x63}, ed25519.SeedSize), + ) + responsePublic := trustTestRawPublicKey(response) + responsePin := ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256( + responsePublic, + ) + valid := FrostNativeSignerAnchorBootstrapClientConfig{ + Endpoint: "http://127.0.0.1:9799/anchor", + ResponsePublicKey: responsePublic, + ResponsePublicKeySPKISHA256: responsePin, + ClientPrivateKey: client, + } + if _, err := NewFrostNativeSignerAnchorBootstrapClient(valid); err != nil { + t.Fatalf("valid bootstrap client config was rejected: %v", err) + } + + tests := map[string]func(*FrostNativeSignerAnchorBootstrapClientConfig){ + "zero client key": func(config *FrostNativeSignerAnchorBootstrapClientConfig) { + config.ClientPrivateKey = make(ed25519.PrivateKey, ed25519.PrivateKeySize) + }, + "client key aliases the response key": func( + config *FrostNativeSignerAnchorBootstrapClientConfig, + ) { + config.ClientPrivateKey = response + }, + "loopback HTTP with TLS roots": func( + config *FrostNativeSignerAnchorBootstrapClientConfig, + ) { + config.TLSRootCAs = x509.NewCertPool() + }, + "loopback HTTP with a leaf pin": func( + config *FrostNativeSignerAnchorBootstrapClientConfig, + ) { + config.EndpointLeafSPKIHash = trustTestBytes32(0x0c) + }, + "HTTPS without a leaf pin": func( + config *FrostNativeSignerAnchorBootstrapClientConfig, + ) { + config.Endpoint = "https://anchor.example/anchor" + }, + "request timeout above the bound": func( + config *FrostNativeSignerAnchorBootstrapClientConfig, + ) { + config.RequestTimeout = 31 * time.Second + }, + "response key SPKI pin mismatch": func( + config *FrostNativeSignerAnchorBootstrapClientConfig, + ) { + config.ResponsePublicKeySPKISHA256 = trustTestBytes32(0x0d) + }, + "missing client key and path": func( + config *FrostNativeSignerAnchorBootstrapClientConfig, + ) { + config.ClientPrivateKey = nil + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + candidate := valid + mutate(&candidate) + if _, err := NewFrostNativeSignerAnchorBootstrapClient( + candidate, + ); err == nil { + t.Fatal("expected bootstrap client constructor rejection") + } + }) + } +} + +// TestFrostNativeSignerAnchorBootstrapInitializeTranscriptFrozenVector pins +// the exact signed request bytes for fixed inputs so the wire transcript +// cannot drift silently, and proves the create and reconciliation kinds are +// signature-disjoint. +func TestFrostNativeSignerAnchorBootstrapInitializeTranscriptFrozenVector( + t *testing.T, +) { + privateKey := ed25519.NewKeyFromSeed( + bytes.Repeat([]byte{0x01}, ed25519.SeedSize), + ) + clientSPKI, err := x509.MarshalPKIXPublicKey(privateKey.Public()) + if err != nil { + t.Fatal(err) + } + checkpoint := FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: testFrostNativeSignerAnchorBytes32(0x55), + Generation: 7, + PreviousStateCommitment: testFrostNativeSignerAnchorBytes32(0x66), + StateImageDigest: testFrostNativeSignerAnchorBytes32(0x77), + StateCommitment: testFrostNativeSignerAnchorBytes32(0x88), + } + transcript := frostNativeSignerAnchorInitializeRequestTranscript( + "initialize", + testFrostNativeSignerAnchorBytes32(0x11), + testFrostNativeSignerAnchorBytes32(0x22), + testFrostNativeSignerAnchorBytes32(0x33), + testFrostNativeSignerAnchorBytes32(0x44), + checkpoint, + clientSPKI, + ) + if len(transcript) != 741 { + t.Fatalf("unexpected frozen transcript length [%d]", len(transcript)) + } + transcriptDigest := sha256.Sum256(transcript) + if hex.EncodeToString(transcriptDigest[:]) != + "753e5bad0920848776af636ea4a53d3a221621c7ae59b17430ed53602f42a694" { + t.Fatalf("unexpected frozen initialize transcript digest [%x]", transcriptDigest) + } + signature := ed25519.Sign(privateKey, transcript) + if hex.EncodeToString(signature) != + "1e0a673a2bd5871d6817b47a977d5e6d73fc00d0ec856d997c98171240310e7b"+ + "7c9c485e0df38083173d6ac69d23d07cee5dca582cdc8bd211d16223ec751a08" { + t.Fatalf("unexpected frozen initialize signature [%x]", signature) + } + readTranscript := frostNativeSignerAnchorInitializeRequestTranscript( + "read", + testFrostNativeSignerAnchorBytes32(0x11), + testFrostNativeSignerAnchorBytes32(0x22), + testFrostNativeSignerAnchorBytes32(0x33), + testFrostNativeSignerAnchorBytes32(0x44), + checkpoint, + clientSPKI, + ) + readDigest := sha256.Sum256(readTranscript) + if len(readTranscript) != 735 || + hex.EncodeToString(readDigest[:]) != + "e2107032e2e397a615f2a1889f2c46c66585e965e11fa42345baed3c2dd36e2b" { + t.Fatalf("unexpected frozen read transcript digest [%x]", readDigest) + } + if bytes.Equal(transcript, readTranscript) { + t.Fatal("initialize and read transcripts must be signature-disjoint") + } +} + +func TestFrostNativeSignerAnchorBootstrapClientLoadsCanonicalConfig( + t *testing.T, +) { + environment := newBootstrapClientTestEnvironmentWithNow(t, time.Now) + directory := t.TempDir() + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(environment.clientKey) + if err != nil { + t.Fatal(err) + } + keyPath := filepath.Join(directory, "client-key.pem") + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: keyDER, + }) + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + t.Fatal(err) + } + responsePublic := trustTestRawPublicKey(environment.response) + configPath := filepath.Join(directory, "client-config.json") + configJSON := bootstrapClientTestConfigJSON( + environment.endpoint, + frostNativeSignerAnchorHex32(responsePublic), + frostNativeSignerAnchorHex32( + ComputeFrostNativeSignerAnchorTrustEd25519SPKISHA256(responsePublic), + ), + frostNativeSignerAnchorHex32([32]byte{}), + keyPath, + "3000", + ) + if err := os.WriteFile(configPath, []byte(configJSON), 0600); err != nil { + t.Fatal(err) + } + client, err := LoadFrostNativeSignerAnchorBootstrapClient(configPath) + if err != nil { + t.Fatalf("canonical bootstrap client config failed to load: %v", err) + } + if _, err := InitializeFrostNativeSignerAnchorBootstrap( + context.Background(), + environment.core, + environment.coreSignature, + client, + ); err != nil { + t.Fatalf("loaded bootstrap client failed end-to-end: %v", err) + } +} From 6cc6f5a70d4d75b666d1722544818d1b9d1cb206 Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 26 Jul 2026 20:41:41 -0400 Subject: [PATCH 20/93] Keep scaffold-era signer material off retained FROST key-group binding frostWalletIDFromSigner already treats FrostTBTCSignerV1 material whose key group is folded from the legacy wallet public key as a legacy wallet; the retained key-group binding resolver did not, so a stored scaffold-era wallet failed startup by validating a taproot-derived key group against its legacy wallet ID. Mirror the KeyGroupSource discrimination so both resolvers agree on what is a FROST wallet. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DpftF6aYHh56KgiHU6D2NQ --- pkg/tbtc/frost_retained_key_group_binding.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pkg/tbtc/frost_retained_key_group_binding.go b/pkg/tbtc/frost_retained_key_group_binding.go index 0ffc8d3cba..271d62304f 100644 --- a/pkg/tbtc/frost_retained_key_group_binding.go +++ b/pkg/tbtc/frost_retained_key_group_binding.go @@ -119,6 +119,21 @@ func frostKeyGroupFromWalletCacheValue( frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1 { continue } + var payload frostsigning.NativeTBTCSignerMaterialPayload + if err := json.Unmarshal(material.Payload, &payload); err != nil { + return "", false, fmt.Errorf( + "cannot decode FrostTBTCSignerV1 signer material: [%w]", + err, + ) + } + // Scaffold-era material folds the legacy wallet public key in as its + // key group: the wallet keeps its legacy identity (mirroring + // frostWalletIDFromSigner) and must not bind a retained FROST key + // group derived from that fold. + if payload.KeyGroupSource == + frostsigning.NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey { + continue + } nativeCount++ resolved, err := frostKeyGroupFromSignerMaterial( material, From 1749bfe8dde6146ac210ba5d385021147ae50f79 Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 26 Jul 2026 20:41:41 -0400 Subject: [PATCH 21/93] test: align tagged FROST suites with anchor barrier and memo ownership The rebuilt lineage's first tagged CI run surfaced harness gaps rather than product bugs: - Multiproc e2e children (shape-B bootstrap/workers, distributed-DKG workers) never installed the process-local test state anchor, so every request-taking native operation was blocked; they now run the same anchor setup as the in-process suites, and the setup itself gained the ABI 4.3 trust head and window bounds the barrier now requires. - Harnesses that drive the ROAST runner directly now begin the outer interactive aggregate memo session the production executor owns. - Shape-B asserts the finalized signing subset aggregates one signature while a committed-but-omitted seat fails closed with the engine's aggregate-authorization rejection: separate processes share no aggregate memo, so an omitted observer cannot obtain the signature locally. - The native-anchor store-mismatch manifest case recomputes the stream ID over the mutated identity so the store check is actually reachable. - gofmt on the two state-anchor trust test files. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DpftF6aYHh56KgiHU6D2NQ --- ...tbtc_frost_retained_group_manifest_test.go | 10 ++++ ...igner_anchor_real_cgo_frost_native_test.go | 58 +++++++++++++++++-- ...tbtc_signer_state_anchor_trust_cgo_test.go | 3 +- ...ive_tbtc_signer_state_anchor_trust_test.go | 3 +- ..._libp2p_multiproc_e2e_frost_native_test.go | 4 ++ ...ve_signing_drive_frost_roast_retry_test.go | 8 +++ ...st_runner_bus_net_e2e_frost_native_test.go | 9 +++ ...eal_cgo_dropout_retry_frost_native_test.go | 7 +++ ...valid_share_exclusion_frost_native_test.go | 7 +++ ...eal_cgo_multinode_e2e_frost_native_test.go | 14 +++++ ..._libp2p_multiproc_e2e_frost_native_test.go | 53 +++++++++++++++-- 11 files changed, 162 insertions(+), 14 deletions(-) diff --git a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go index 44603e7447..74f97726b8 100644 --- a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go +++ b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go @@ -146,6 +146,16 @@ func TestValidateFrostPreSignActivationManifest_CanonicalJournal(t *testing.T) { manifest := testFrostJournalActivationManifest() manifest.FrostSigner.NativeSignerAnchor.SignerStoreFingerprint = testManifestHex32(0xee) + // The stream ID commits to the store fingerprint, so recompute it over + // the mutated identity: otherwise the stream-ID pin fires first and + // the store check under test is unreachable. + anchorIdentity, err := frostPreSignNativeSignerAnchorIdentity(manifest) + if err != nil { + t.Fatalf("mutated anchor identity: %v", err) + } + streamID := tbtc.ComputeFrostNativeSignerAnchorStreamID(anchorIdentity) + manifest.FrostSigner.NativeSignerAnchor.StreamID = + fmt.Sprintf("0x%x", streamID[:]) if err := validateFrostPreSignActivationManifest(manifest); err == nil || !strings.Contains(err.Error(), "differs from the durable signer store") { t.Fatalf("expected native anchor store failure, got [%v]", err) diff --git a/pkg/frost/signing/native_tbtc_signer_anchor_real_cgo_frost_native_test.go b/pkg/frost/signing/native_tbtc_signer_anchor_real_cgo_frost_native_test.go index 347e131a7d..5c8dc26106 100644 --- a/pkg/frost/signing/native_tbtc_signer_anchor_real_cgo_frost_native_test.go +++ b/pkg/frost/signing/native_tbtc_signer_anchor_real_cgo_frost_native_test.go @@ -25,6 +25,43 @@ var realCgoTestSignerAnchor struct { latest NativeTBTCSignerStateWitnessTip } +// realCgoTestSignerAnchorTrustHead derives the barrier's expected trust head +// from the persisted test anchor: the certified floor IS the initial tip, so a +// fresh process (in-process suite or a re-exec'd multiproc child) certifies +// exactly the state it loaded and the full revision/generation windows remain +// available to the run. +func realCgoTestSignerAnchorTrustHead( + tip *NativeTBTCSignerStateWitnessTip, + responsePublicKeySPKISHA256 [32]byte, +) *NativeTBTCSignerStateAnchorTrustHead { + return &NativeTBTCSignerStateAnchorTrustHead{ + Schema: NativeTBTCSignerStateAnchorTrustHeadSchema, + CertificateSequence: 1, + CertificateDigest: sha256.Sum256([]byte("real-cgo-test-anchor-trust-certificate/v1")), + ActivationManifestSequence: 1, + ActivationManifestHash: sha256.Sum256([]byte("real-cgo-test-anchor-activation-manifest/v1")), + BindingHash: realCgoTestSignerAnchor.binding, + ResponsePublicKeySPKISHA256: responsePublicKeySPKISHA256, + OfflineAuthoritySPKISHA256: sha256.Sum256([]byte("real-cgo-test-anchor-offline-authority/v1")), + ServiceEpoch: tip.AnchorServiceEpoch, + CertifiedFloor: NativeTBTCSignerStateAnchorTrustReference{ + ServiceEpoch: tip.AnchorServiceEpoch, + Revision: tip.AnchorRevision, + EventRoot: tip.AnchorEventRoot, + AcknowledgementDigest: tip.AnchorAcknowledgementDigest, + Checkpoint: NativeTBTCSignerStateAnchorCheckpoint{ + StoreFingerprint: tip.StoreFingerprint, + Generation: tip.Generation, + PreviousStateCommitment: tip.PreviousStateCommitment, + StateImageDigest: tip.StateImageDigest, + StateCommitment: tip.StateCommitment, + }, + }, + WitnessMaximumRecords: 4096, + WitnessRotationThresholdRecords: 1024, + } +} + type realCgoTestSignerAnchorCommitter struct{} func (committer *realCgoTestSignerAnchorCommitter) VerifyNativeTBTCSignerStateTip( @@ -158,14 +195,23 @@ func setupRealCgoSignerStateAnchor(t *testing.T) { if installed { return } + trustHead := realCgoTestSignerAnchorTrustHead(tip, publicKeySPKIHash) if err := InstallNativeTBTCSignerStateAnchorBarrier( NativeTBTCSignerStateAnchorBarrierConfig{ - InitialTip: tip, - ExpectedAnchorBindingHash: realCgoTestSignerAnchor.binding, - MinimumAnchorServiceEpoch: 1, - ReadTip: ReadNativeTBTCSignerStateWitnessTip, - Committer: &realCgoTestSignerAnchorCommitter{}, - Timeout: 15 * time.Second, + InitialTip: tip, + ExpectedAnchorBindingHash: realCgoTestSignerAnchor.binding, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: NativeTBTCSignerStateAnchorMaximumRevisionDistance, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: trustHead, + ReadTip: ReadNativeTBTCSignerStateWitnessTip, + ReadTrustHead: func() (*NativeTBTCSignerStateAnchorTrustHead, error) { + head := *trustHead + return &head, nil + }, + Committer: &realCgoTestSignerAnchorCommitter{}, + Timeout: 15 * time.Second, }, ); err != nil { t.Fatalf("cannot install real-cgo test signer anchor barrier: %v", err) diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_cgo_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_cgo_test.go index 4c11655360..3ae9bee005 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_cgo_test.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_cgo_test.go @@ -132,8 +132,7 @@ func TestClassifyNativeTBTCSignerStateAnchorTrustRecoveryRequiredError( } } -func testNativeTBTCSignerStateAnchorTrustRecoveryRequiredWirePointer( -) *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire { +func testNativeTBTCSignerStateAnchorTrustRecoveryRequiredWirePointer() *nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire { wire := testNativeTBTCSignerStateAnchorTrustRecoveryRequiredWire() return &wire } diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go index c76bdccfe0..a9f624378b 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go @@ -222,8 +222,7 @@ func TestDecodeNativeTBTCSignerStateAnchorTrustRecoveryRequiredRejectsInvalid( } } -func testNativeTBTCSignerStateAnchorTrustRecoveryRequiredWire( -) nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire { +func testNativeTBTCSignerStateAnchorTrustRecoveryRequiredWire() nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire { return nativeTBTCSignerStateAnchorTrustRecoveryRequiredWire{ Schema: NativeTBTCSignerStateAnchorTrustRecoveryRequiredSchema, StoreFingerprint: testNativeTBTCSignerTrustHex32( diff --git a/pkg/frost/signing/roast_distributed_dkg_libp2p_multiproc_e2e_frost_native_test.go b/pkg/frost/signing/roast_distributed_dkg_libp2p_multiproc_e2e_frost_native_test.go index 23c3133183..42a7bdabbb 100644 --- a/pkg/frost/signing/roast_distributed_dkg_libp2p_multiproc_e2e_frost_native_test.go +++ b/pkg/frost/signing/roast_distributed_dkg_libp2p_multiproc_e2e_frost_native_test.go @@ -224,6 +224,10 @@ func runDdkgWorker(t *testing.T, idxStr string) { fmt.Printf("%sbad worker index %q: %v\n", ddkgErrPrefix, idxStr, err) return } + // Each worker is its own process with no parent-provided signer state: + // give it the per-process development state and install the test anchor + // barrier before the request-taking DKG parts run. + setupRealCgoSignerState(t) cfgBytes, err := os.ReadFile(os.Getenv(ddkgConfigEnv)) if err != nil { fmt.Printf("%sread config: %v\n", ddkgErrPrefix, err) diff --git a/pkg/frost/signing/roast_interactive_signing_drive_frost_roast_retry_test.go b/pkg/frost/signing/roast_interactive_signing_drive_frost_roast_retry_test.go index fd7227fdc0..eba7882038 100644 --- a/pkg/frost/signing/roast_interactive_signing_drive_frost_roast_retry_test.go +++ b/pkg/frost/signing/roast_interactive_signing_drive_frost_roast_retry_test.go @@ -109,6 +109,14 @@ func newDriveFixture(t *testing.T) driveFixture { // mint a unique session id per attempt, so this collision is test-only. t.Cleanup(ResetInteractiveAggregateMemoForTest) + // The production executor owns the aggregate memo session for the outer + // signing operation; the drive fixture stands in for it here. + memoSession, err := BeginInteractiveAggregateMemoSession(roastSessionID) + if err != nil { + t.Fatalf("begin aggregate memo session: %v", err) + } + t.Cleanup(memoSession.Release) + // The handle is minted by the registered coordinator - exactly the handle // the executor entry threads into the drive for this Execute. handle, err := coord.BeginAttempt(attemptCtx) diff --git a/pkg/frost/signing/roast_runner_bus_net_e2e_frost_native_test.go b/pkg/frost/signing/roast_runner_bus_net_e2e_frost_native_test.go index cd21bdeb24..3fd3d9d6e8 100644 --- a/pkg/frost/signing/roast_runner_bus_net_e2e_frost_native_test.go +++ b/pkg/frost/signing/roast_runner_bus_net_e2e_frost_native_test.go @@ -45,6 +45,15 @@ func buildInteractiveSigningNetHarness( ) netSigningHarness { t.Helper() + // The runner's aggregate path releases results only under an active outer + // memo session owner (the production executor holds one across all local + // seats); the harness owns it for the round. + memoSession, err := BeginInteractiveAggregateMemoSession("session-net-1") + if err != nil { + t.Fatalf("begin aggregate memo session: %v", err) + } + t.Cleanup(memoSession.Release) + included := make([]group.MemberIndex, 0, n) for i := 1; i <= n; i++ { included = append(included, group.MemberIndex(i)) diff --git a/pkg/frost/signing/roast_runner_real_cgo_dropout_retry_frost_native_test.go b/pkg/frost/signing/roast_runner_real_cgo_dropout_retry_frost_native_test.go index 78e9d14873..694049656f 100644 --- a/pkg/frost/signing/roast_runner_real_cgo_dropout_retry_frost_native_test.go +++ b/pkg/frost/signing/roast_runner_real_cgo_dropout_retry_frost_native_test.go @@ -79,6 +79,13 @@ func TestRealCgoInteractiveSigning_DropoutForcesNextAttemptAndReshuffledSubsetFi engine := &buildTaggedTBTCSignerEngine{} sessionID := fmt.Sprintf("real-cgo-dropout-retry-%d", realCgoSessionSeq.Add(1)) + // The production executor owns the aggregate memo session for the outer + // signing operation; the harness stands in for it here. + memoSession, err := BeginInteractiveAggregateMemoSession(sessionID) + if err != nil { + t.Fatalf("begin aggregate memo session: %v", err) + } + t.Cleanup(memoSession.Release) const n = 3 const threshold uint16 = 2 diff --git a/pkg/frost/signing/roast_runner_real_cgo_invalid_share_exclusion_frost_native_test.go b/pkg/frost/signing/roast_runner_real_cgo_invalid_share_exclusion_frost_native_test.go index 5145c80d85..20b63140c4 100644 --- a/pkg/frost/signing/roast_runner_real_cgo_invalid_share_exclusion_frost_native_test.go +++ b/pkg/frost/signing/roast_runner_real_cgo_invalid_share_exclusion_frost_native_test.go @@ -70,6 +70,13 @@ func TestRealCgoInteractiveSigning_InvalidShareBlameForcesPermanentExclusion(t * engine := &buildTaggedTBTCSignerEngine{} sessionID := fmt.Sprintf("real-cgo-invalid-share-%d", realCgoSessionSeq.Add(1)) + // The production executor owns the aggregate memo session for the outer + // signing operation; the harness stands in for it here. + memoSession, err := BeginInteractiveAggregateMemoSession(sessionID) + if err != nil { + t.Fatalf("begin aggregate memo session: %v", err) + } + t.Cleanup(memoSession.Release) const n = 3 const threshold uint16 = 2 diff --git a/pkg/frost/signing/roast_runner_real_cgo_multinode_e2e_frost_native_test.go b/pkg/frost/signing/roast_runner_real_cgo_multinode_e2e_frost_native_test.go index da9d76e652..0384d62dd8 100644 --- a/pkg/frost/signing/roast_runner_real_cgo_multinode_e2e_frost_native_test.go +++ b/pkg/frost/signing/roast_runner_real_cgo_multinode_e2e_frost_native_test.go @@ -244,6 +244,13 @@ func TestRealCgoInteractiveSigning_NetTransport_FullIncludedRound(t *testing.T) engine := &buildTaggedTBTCSignerEngine{} sessionID := fmt.Sprintf("real-cgo-multinode-full-%d", realCgoSessionSeq.Add(1)) + // The production executor owns the aggregate memo session for the outer + // signing operation; the harness stands in for it here. + memoSession, err := BeginInteractiveAggregateMemoSession(sessionID) + if err != nil { + t.Fatalf("begin aggregate memo session: %v", err) + } + t.Cleanup(memoSession.Release) buildRealCgoNetHarness(t, ctx, engine, sessionID, 2, 2). runAllAndAssertRealSignature(t, ctx) } @@ -262,6 +269,13 @@ func TestRealCgoInteractiveSigning_NetTransport_ThresholdSubsetRound(t *testing. engine := &buildTaggedTBTCSignerEngine{} sessionID := fmt.Sprintf("real-cgo-multinode-subset-%d", realCgoSessionSeq.Add(1)) + // The production executor owns the aggregate memo session for the outer + // signing operation; the harness stands in for it here. + memoSession, err := BeginInteractiveAggregateMemoSession(sessionID) + if err != nil { + t.Fatalf("begin aggregate memo session: %v", err) + } + t.Cleanup(memoSession.Release) buildRealCgoNetHarness(t, ctx, engine, sessionID, 3, 2). runAllAndAssertRealSignature(t, ctx) } diff --git a/pkg/frost/signing/roast_shapeb_libp2p_multiproc_e2e_frost_native_test.go b/pkg/frost/signing/roast_shapeb_libp2p_multiproc_e2e_frost_native_test.go index 3522935390..66978fb6ca 100644 --- a/pkg/frost/signing/roast_shapeb_libp2p_multiproc_e2e_frost_native_test.go +++ b/pkg/frost/signing/roast_shapeb_libp2p_multiproc_e2e_frost_native_test.go @@ -96,7 +96,11 @@ type shapeBConfig struct { // TestRealCgoInteractiveSigning_Libp2pMultiProc_ShapeB is BOTH the orchestrator and (when // re-exec'd with FROST_SHAPEB_WORKER set) the per-node worker. The worker branch runs one // seat to a real signature over real libp2p; the orchestrator branch wires the group, -// launches the workers, and asserts every one independently aggregates the same signature. +// launches the workers, and asserts every seat in the finalized signing subset (plus the +// coordinator) independently aggregates the same signature, while a committed seat the +// coordinator omitted fails closed with the engine's aggregate-authorization rejection +// (separate processes share no aggregate memo, so an omitted observer cannot obtain the +// signature locally). func TestRealCgoInteractiveSigning_Libp2pMultiProc_ShapeB(t *testing.T) { if os.Getenv(shapeBBootstrapEnv) != "" { runShapeBBootstrap(t) @@ -130,6 +134,10 @@ func runShapeBBootstrap(t *testing.T) { for i := 1; i <= n; i++ { participantIDs = append(participantIDs, byte(i)) } + // The parent provides the signer state env; the anchor barrier is + // process-local Go state, so this child must install the test anchor + // itself before any request-taking native operation. + setupRealCgoSignerStateAnchor(t) keyGroup := runRealCgoDKGKeyGroup(t, &buildTaggedTBTCSignerEngine{}, sessionID, participantIDs, uint16(threshold)) fmt.Printf("%s%s\n", shapeBKeyGroupPrefix, keyGroup) } @@ -279,12 +287,25 @@ func runShapeBOrchestrator(t *testing.T, n int, threshold uint16) { // 4. Every worker must independently produce the same valid 64-byte signature. var winning string winners := 0 + observers := 0 for _, r := range results { if skip := extractPrefixed(r.output, shapeBSkipPrefix); skip != "" { t.Skipf("member %d skipped: %s", r.index, skip) } sig := extractPrefixed(r.output, shapeBSigPrefix) if sig == "" { + // A committed seat the coordinator omitted from the finalized + // t-subset cannot aggregate in its OWN process: the engine + // authorizes aggregation only for seats that Round2-signed the + // exact package (plus the elected coordinator), and the + // cross-seat aggregate memo that hands observers the signature + // in-process does not span processes. Such a seat must fail + // closed with exactly the authorization rejection - anything + // else is a real failure. + if strings.Contains(r.output, "package is not authorized for attempt_id") { + observers++ + continue + } t.Fatalf("member %d did not emit a signature (err=%v):\n%s", r.index, r.err, indentTail(r.output, 40)) } raw, err := hex.DecodeString(sig) @@ -298,10 +319,19 @@ func runShapeBOrchestrator(t *testing.T, n int, threshold uint16) { } winners++ } - if winners != n { - t.Fatalf("expected all %d separate-process nodes to aggregate the signature, got %d", n, winners) + if winners < int(threshold) { + t.Fatalf( + "expected at least the %d-seat signing subset to aggregate the signature, got %d (observers %d)", + threshold, winners, observers, + ) } - t.Logf("shape-B: %d separate-process nodes over real libp2p each aggregated the same BIP-340 signature %s…", n, winning[:16]) + if winners+observers != n { + t.Fatalf( + "expected every node to aggregate or fail closed as an omitted observer: %d winners + %d observers != %d", + winners, observers, n, + ) + } + t.Logf("shape-B: %d/%d separate-process nodes over real libp2p aggregated the same BIP-340 signature %s… (%d omitted observers failed closed)", winners, n, winning[:16], observers) } func runShapeBWorker(t *testing.T, idxStr string) { @@ -335,6 +365,21 @@ func runShapeBWorker(t *testing.T, idxStr string) { return } + // The member state copied from the bootstrap process already carries the + // installed test anchor; this worker process still has to pin the anchor + // env and install its own process-local barrier. + setupRealCgoSignerStateAnchor(t) + + // The production executor owns the aggregate memo session for the outer + // signing operation; each worker process owns its own registry, so the + // worker stands in for it here. + memoSession, err := BeginInteractiveAggregateMemoSession(cfg.SessionID) + if err != nil { + fmt.Printf("%sbegin aggregate memo session: %v\n", shapeBErrPrefix, err) + return + } + defer memoSession.Release() + ctx, cancel := context.WithTimeout(context.Background(), 130*time.Second) defer cancel() From 782f9a3e84d73fdec812af5506e012c4d8cc6f94 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 09:23:48 -0400 Subject: [PATCH 22/93] fix(tbtc): harden FROST activation durability paths --- ci/frost-signer-pin.env | 2 +- pkg/bitcoin/transaction_policy.go | 19 ++ pkg/bitcoin/transaction_policy_test.go | 48 +++ pkg/chain/ethereum/ethereum.go | 15 +- .../tbtc_frost_historical_deployment_test.go | 51 +++ .../tbtc_frost_pre_sign_authorization.go | 36 +- .../native_tbtc_signer_secure_config.go | 2 +- .../native_tbtc_signer_secure_config_test.go | 12 + pkg/tbtc/bitcoin_broadcast_outbox.go | 95 +++++- pkg/tbtc/bitcoin_broadcast_outbox_test.go | 110 ++++++ ...ive_signer_anchor_bootstrap_client_test.go | 2 +- pkg/tbtc/frost_native_signer_anchor_client.go | 16 +- .../frost_native_signer_anchor_client_test.go | 57 ++++ ...native_signer_anchor_provisioning_files.go | 2 +- ...e_signer_anchor_provisioning_files_test.go | 14 + pkg/tbtc/frost_retained_group_journal.go | 10 + pkg/tbtc/frost_retained_group_journal_test.go | 23 ++ pkg/tbtc/redemption.go | 77 ++++- pkg/tbtc/redemption_test.go | 312 ++++++++++++++++++ 19 files changed, 872 insertions(+), 31 deletions(-) create mode 100644 pkg/bitcoin/transaction_policy.go create mode 100644 pkg/bitcoin/transaction_policy_test.go diff --git a/ci/frost-signer-pin.env b/ci/frost-signer-pin.env index eed3b36042..8171a9a68b 100644 --- a/ci/frost-signer-pin.env +++ b/ci/frost-signer-pin.env @@ -15,4 +15,4 @@ # # After the scaffold and mirror branches merge into one, replace the cross-branch # checkout with an in-tree cargo build and retire this pin (keep the gate). -FROST_SIGNER_MIRROR_REF=b35637ed01693014b6b7ac94586541538eaaa446 +FROST_SIGNER_MIRROR_REF=f3480af3162f38725fea15e09c765af028bef349 diff --git a/pkg/bitcoin/transaction_policy.go b/pkg/bitcoin/transaction_policy.go new file mode 100644 index 0000000000..8f837f47d5 --- /dev/null +++ b/pkg/bitcoin/transaction_policy.go @@ -0,0 +1,19 @@ +package bitcoin + +import ( + "github.com/btcsuite/btcd/mempool" + "github.com/btcsuite/btcd/wire" +) + +// IsDustOutput reports whether the output is non-standard under Bitcoin +// Core's default minimum relay fee. Nil outputs are treated as invalid dust. +func IsDustOutput(output *TransactionOutput) bool { + if output == nil { + return true + } + + return mempool.IsDust( + wire.NewTxOut(output.Value, output.PublicKeyScript), + mempool.DefaultMinRelayTxFee, + ) +} diff --git a/pkg/bitcoin/transaction_policy_test.go b/pkg/bitcoin/transaction_policy_test.go new file mode 100644 index 0000000000..bcacae5ec0 --- /dev/null +++ b/pkg/bitcoin/transaction_policy_test.go @@ -0,0 +1,48 @@ +package bitcoin + +import "testing" + +func TestIsDustOutput(t *testing.T) { + p2wpkh, err := PayToWitnessPublicKeyHash([20]byte{0x01}) + if err != nil { + t.Fatal(err) + } + p2tr, err := PayToTaproot([32]byte{0x02}) + if err != nil { + t.Fatal(err) + } + + tests := map[string]struct { + output *TransactionOutput + dust bool + }{ + "nil": { + output: nil, + dust: true, + }, + "P2WPKH below threshold": { + output: &TransactionOutput{Value: 293, PublicKeyScript: p2wpkh}, + dust: true, + }, + "P2WPKH at threshold": { + output: &TransactionOutput{Value: 294, PublicKeyScript: p2wpkh}, + dust: false, + }, + "P2TR below threshold": { + output: &TransactionOutput{Value: 329, PublicKeyScript: p2tr}, + dust: true, + }, + "P2TR at threshold": { + output: &TransactionOutput{Value: 330, PublicKeyScript: p2tr}, + dust: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if actual := IsDustOutput(test.output); actual != test.dust { + t.Fatalf("unexpected dust result [%v]", actual) + } + }) + } +} diff --git a/pkg/chain/ethereum/ethereum.go b/pkg/chain/ethereum/ethereum.go index b0cd64f8fa..a55054c70d 100644 --- a/pkg/chain/ethereum/ethereum.go +++ b/pkg/chain/ethereum/ethereum.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/keystore" "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" "github.com/ipfs/go-log" "github.com/keep-network/keep-common/pkg/chain/ethereum" @@ -34,9 +35,11 @@ var logger = log.Logger("keep-ethereum") // provides the implementation of generic features like balance monitor, // block counter and similar. type baseChain struct { - key *keystore.Key - client ethutil.EthereumClient - chainID *big.Int + key *keystore.Key + client ethutil.EthereumClient + rpcClient *rpc.Client + rpcLimiter *rate.Limiter + chainID *big.Int blockCounter *ethereum.BlockCounter nonceManager *ethereum.NonceManager @@ -247,6 +250,10 @@ func newBaseChain( } clientWithAddons := wrapClientAddons(config, client) + rpcLimiter := rate.NewLimiter(&rate.LimiterConfig{ + RequestsPerSecondLimit: config.RequestsPerSecondLimit, + ConcurrencyLimit: config.ConcurrencyLimit, + }) blockCounter, err := ethutil.NewBlockCounter(clientWithAddons) if err != nil { @@ -297,6 +304,8 @@ func newBaseChain( return &baseChain{ key: key, client: clientWithAddons, + rpcClient: client.Client(), + rpcLimiter: rpcLimiter, chainID: chainID, blockCounter: blockCounter, nonceManager: nonceManager, diff --git a/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go b/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go index 1c571d1af4..b0cc7829e8 100644 --- a/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go +++ b/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go @@ -10,7 +10,10 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" + commonethereum "github.com/keep-network/keep-common/pkg/chain/ethereum" + "github.com/keep-network/keep-common/pkg/rate" "github.com/keep-network/keep-core/pkg/tbtc" ) @@ -244,3 +247,51 @@ func TestFrostPreSignCanonicalHashReader_RequiresCanonicalEIP1898State( } } } + +func TestFrostPreSignExactHashReader_WrappedProductionClient(t *testing.T) { + server := rpc.NewServer() + api := &testFrostCanonicalRPCAPI{} + if err := server.RegisterName("eth", api); err != nil { + t.Fatal(err) + } + rpcClient := rpc.DialInProc(server) + defer rpcClient.Close() + client := ethclient.NewClient(rpcClient) + + config := commonethereum.Config{ + RequestsPerSecondLimit: 10, + ConcurrencyLimit: 2, + } + chain := &baseChain{ + client: wrapClientAddons(config, client), + rpcClient: client.Client(), + rpcLimiter: rate.NewLimiter(&rate.LimiterConfig{ + RequestsPerSecondLimit: config.RequestsPerSecondLimit, + ConcurrencyLimit: config.ConcurrencyLimit, + }), + } + adapter := &frostPreSignEthereumAdapter{ + chain: &TbtcChain{baseChain: chain}, + } + + reader, err := adapter.exactHashReader() + if err != nil { + t.Fatalf("wrapped production client rejected: %v", err) + } + blockHash := common.HexToHash("0x1234") + address := common.HexToAddress( + "0x1111111111111111111111111111111111111111", + ) + if _, err := reader.CodeAtHash( + context.Background(), + address, + blockHash, + ); err != nil { + t.Fatal(err) + } + if len(api.points) != 1 || api.points[0].BlockHash == nil || + *api.points[0].BlockHash != blockHash || + !api.points[0].RequireCanonical { + t.Fatalf("unexpected exact-hash RPC point [%+v]", api.points) + } +} diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index 5ac502eab7..f953b73c87 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -546,7 +546,11 @@ type frostPreSignCanonicalHashReader struct { headerReader interface { HeaderByHash(context.Context, common.Hash) (*types.Header, error) } - rpcClient *rpc.Client + rpcClient *rpc.Client + rpcLimiter interface { + AcquirePermit() error + ReleasePermit() + } } func (reader *frostPreSignCanonicalHashReader) HeaderByHash( @@ -562,7 +566,7 @@ func (reader *frostPreSignCanonicalHashReader) CodeAtHash( blockHash common.Hash, ) ([]byte, error) { var result hexutil.Bytes - err := reader.rpcClient.CallContext( + err := reader.callContext( ctx, &result, "eth_getCode", @@ -579,7 +583,7 @@ func (reader *frostPreSignCanonicalHashReader) StorageAtHash( blockHash common.Hash, ) ([]byte, error) { var result hexutil.Bytes - err := reader.rpcClient.CallContext( + err := reader.callContext( ctx, &result, "eth_getStorageAt", @@ -596,7 +600,7 @@ func (reader *frostPreSignCanonicalHashReader) CallContractAtHash( blockHash common.Hash, ) ([]byte, error) { var result hexutil.Bytes - err := reader.rpcClient.CallContext( + err := reader.callContext( ctx, &result, "eth_call", @@ -606,6 +610,22 @@ func (reader *frostPreSignCanonicalHashReader) CallContractAtHash( return result, err } +func (reader *frostPreSignCanonicalHashReader) callContext( + ctx context.Context, + result interface{}, + method string, + args ...interface{}, +) error { + if reader.rpcLimiter != nil { + if err := reader.rpcLimiter.AcquirePermit(); err != nil { + return fmt.Errorf("cannot acquire Ethereum RPC rate limiter permit: [%w]", err) + } + defer reader.rpcLimiter.ReleasePermit() + } + + return reader.rpcClient.CallContext(ctx, result, method, args...) +} + func frostPreSignCallArgument(message geth.CallMsg) map[string]interface{} { result := map[string]interface{}{ "from": message.From, @@ -651,15 +671,13 @@ func (adapter *frostPreSignEthereumAdapter) exactHashReader() ( if !ok { return nil, fmt.Errorf("Ethereum client does not expose exact-block-hash headers") } - rpcProvider, ok := adapter.chain.client.(interface { - Client() *rpc.Client - }) - if !ok || rpcProvider.Client() == nil { + if adapter.chain.rpcClient == nil { return nil, fmt.Errorf("Ethereum client does not expose canonical EIP-1898 reads") } return &frostPreSignCanonicalHashReader{ headerReader: headerReader, - rpcClient: rpcProvider.Client(), + rpcClient: adapter.chain.rpcClient, + rpcLimiter: adapter.chain.rpcLimiter, }, nil } diff --git a/pkg/frost/signing/native_tbtc_signer_secure_config.go b/pkg/frost/signing/native_tbtc_signer_secure_config.go index d410042d4e..553857306e 100644 --- a/pkg/frost/signing/native_tbtc_signer_secure_config.go +++ b/pkg/frost/signing/native_tbtc_signer_secure_config.go @@ -33,7 +33,7 @@ func readSecureNativeTBTCSignerInitConfig(path string) ([]byte, error) { } fd, err := unix.Open( path, - unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, + unix.O_RDONLY|unix.O_NONBLOCK|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0, ) if err != nil { diff --git a/pkg/frost/signing/native_tbtc_signer_secure_config_test.go b/pkg/frost/signing/native_tbtc_signer_secure_config_test.go index a29d332c6f..c134109a4f 100644 --- a/pkg/frost/signing/native_tbtc_signer_secure_config_test.go +++ b/pkg/frost/signing/native_tbtc_signer_secure_config_test.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "testing" + + "golang.org/x/sys/unix" ) func TestReadSecureNativeTBTCSignerInitConfig(t *testing.T) { @@ -58,6 +60,16 @@ func TestReadSecureNativeTBTCSignerInitConfigRejectsUnsafeFiles(t *testing.T) { } }) + t.Run("fifo", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "signer-config.json") + if err := unix.Mkfifo(path, 0600); err != nil { + t.Fatal(err) + } + if _, err := readSecureNativeTBTCSignerInitConfig(path); err == nil { + t.Fatal("FIFO native signer init config was accepted") + } + }) + t.Run("oversized", func(t *testing.T) { path := filepath.Join(t.TempDir(), "signer-config.json") payload := bytes.Repeat( diff --git a/pkg/tbtc/bitcoin_broadcast_outbox.go b/pkg/tbtc/bitcoin_broadcast_outbox.go index db7ad0c171..2bc10ca745 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox.go @@ -385,7 +385,7 @@ func openSecureBitcoinBroadcastFile( ) (*os.File, error) { fd, err := unix.Open( path, - flags|unix.O_CLOEXEC|unix.O_NOFOLLOW, + flags|unix.O_NONBLOCK|unix.O_CLOEXEC|unix.O_NOFOLLOW, mode, ) if err != nil { @@ -599,10 +599,14 @@ func (bbo *bitcoinBroadcastOutbox) canonicalAuthorizationStatus( status, err := bbo.authorizationStatusSource. GetCanonicalFrostBitcoinBroadcastAuthorizationStatus(ctx, request) if err != nil { - return nil, fmt.Errorf( + requestError := fmt.Errorf( "cannot revalidate canonical Bitcoin broadcast authorization: [%w]", err, ) + if ctx.Err() != nil { + return nil, requestError + } + return nil, &bitcoinBroadcastTransientReplayError{requestError} } if status == nil || status.RequestHash != request.ComputeHash() || @@ -617,7 +621,14 @@ func (bbo *bitcoinBroadcastOutbox) start(ctx context.Context) error { return fmt.Errorf("Bitcoin broadcast outbox context is nil") } if err := bbo.replayOnceWithContext(ctx); err != nil { - return err + var replayErrors *bitcoinBroadcastReplayErrors + if !errors.As(err, &replayErrors) || replayErrors.hasFatalFailure() { + return err + } + logger.Warnf( + "Bitcoin broadcast outbox initial replay has retryable failures: [%v]", + err, + ) } bbo.mutex.Lock() if bbo.closed { @@ -718,6 +729,57 @@ type bitcoinBroadcastReplayCandidate struct { record *bitcoinBroadcastOutboxRecord } +type bitcoinBroadcastTransientReplayError struct { + err error +} + +func (errorValue *bitcoinBroadcastTransientReplayError) Error() string { + return errorValue.err.Error() +} + +func (errorValue *bitcoinBroadcastTransientReplayError) Unwrap() error { + return errorValue.err +} + +type bitcoinBroadcastReplayFailure struct { + reservationID [32]byte + err error +} + +type bitcoinBroadcastReplayErrors struct { + failures []bitcoinBroadcastReplayFailure +} + +func (errorValue *bitcoinBroadcastReplayErrors) Error() string { + parts := make([]string, 0, len(errorValue.failures)) + for _, failure := range errorValue.failures { + parts = append(parts, fmt.Sprintf( + "reservation [%x]: %v", + failure.reservationID, + failure.err, + )) + } + return strings.Join(parts, "; ") +} + +func (errorValue *bitcoinBroadcastReplayErrors) Unwrap() []error { + result := make([]error, 0, len(errorValue.failures)) + for _, failure := range errorValue.failures { + result = append(result, failure.err) + } + return result +} + +func (errorValue *bitcoinBroadcastReplayErrors) hasFatalFailure() bool { + for _, failure := range errorValue.failures { + var transient *bitcoinBroadcastTransientReplayError + if !errors.As(failure.err, &transient) { + return true + } + } + return false +} + // replayOnceWithContext checks only the latest/confirmed record of each active // reservation. Deeply confirmed history is reconciled in a fixed-size rotating // batch, so canonical RPC work is bounded independently of archived history. @@ -729,32 +791,51 @@ func (bbo *bitcoinBroadcastOutbox) replayOnceWithContext(ctx context.Context) er if err != nil { return err } + replayErrors := &bitcoinBroadcastReplayErrors{} for _, candidate := range append(active, archived...) { refreshed, err := bbo.refreshConfirmation(ctx, candidate.record) if err != nil { - return err + replayErrors.failures = append( + replayErrors.failures, + bitcoinBroadcastReplayFailure{candidate.reservationID, err}, + ) + continue } if refreshed.Confirmation != nil || refreshed.Quarantine != nil { continue } latest, err := bbo.latestBroadcastRecord(candidate.reservationID) if err != nil { - return err + replayErrors.failures = append( + replayErrors.failures, + bitcoinBroadcastReplayFailure{candidate.reservationID, err}, + ) + continue } broadcastErr, broadcastRecord, err := bbo.broadcastAuthorizedRecord(ctx, latest) if err != nil { if errors.Is(err, errBitcoinBroadcastQuarantined) { continue } - return err + replayErrors.failures = append( + replayErrors.failures, + bitcoinBroadcastReplayFailure{candidate.reservationID, err}, + ) + continue } if broadcastErr != nil { continue } if _, err := bbo.refreshConfirmation(ctx, broadcastRecord); err != nil { - return err + replayErrors.failures = append( + replayErrors.failures, + bitcoinBroadcastReplayFailure{candidate.reservationID, err}, + ) } } + if len(replayErrors.failures) > 0 { + return replayErrors + } return nil } diff --git a/pkg/tbtc/bitcoin_broadcast_outbox_test.go b/pkg/tbtc/bitcoin_broadcast_outbox_test.go index 76a57f42a5..5fdf2c5f72 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox_test.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox_test.go @@ -8,11 +8,13 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "testing" "time" "github.com/keep-network/keep-core/pkg/bitcoin" + "golang.org/x/sys/unix" ) var ( @@ -67,6 +69,20 @@ func TestBitcoinBroadcastOutbox_PersistsAndRestoresFullRecord(t *testing.T) { } } +func TestBitcoinBroadcastOutbox_RejectsFIFORecordWithoutBlocking(t *testing.T) { + directory := t.TempDir() + name := strings.Repeat("01", 32) + bitcoinBroadcastOutboxFileSuffix + if err := unix.Mkfifo(filepath.Join(directory, name), 0600); err != nil { + t.Fatal(err) + } + if _, err := newTestBitcoinBroadcastOutbox( + directory, + newOutboxTestBitcoinChain(), + ); err == nil { + t.Fatal("FIFO Bitcoin outbox record was accepted") + } +} + func TestBitcoinBroadcastOutbox_StartupFailsOnCorruption(t *testing.T) { directory := t.TempDir() chain := newOutboxTestBitcoinChain() @@ -603,6 +619,86 @@ func TestBitcoinBroadcastOutbox_ReplaysLatestVariantAndRecoversFromReorg(t *test } } +func TestBitcoinBroadcastOutbox_ReplayIsolatesCandidateFailures(t *testing.T) { + chain := newOutboxTestBitcoinChain() + statusSource := newOutboxTestAuthorizationStatusSource() + outbox := openTestBitcoinBroadcastOutboxWithStatusSource( + t, + t.TempDir(), + chain, + statusSource, + ) + defer outbox.close() + + failing := testOutboxTransaction(31, 7000) + healthy := testOutboxTransaction(32, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + failing, + testBitcoinBroadcastAuthorization(31, 1, 1), + ) + enqueueTestBitcoinTransaction( + t, + outbox, + healthy, + testBitcoinBroadcastAuthorization(32, 2, 1), + ) + statusSource.setTransactionError( + failing.Hash(), + errors.New("historical state unavailable"), + ) + + if err := outbox.replayOnce(); err == nil || + !strings.Contains(err.Error(), "historical state unavailable") { + t.Fatalf("unexpected replay result: [%v]", err) + } + if chain.broadcastCount(failing.Hash()) != 0 { + t.Fatal("failing candidate reached broadcast") + } + if chain.broadcastCount(healthy.Hash()) != 1 { + t.Fatal("candidate after a failing record was starved") + } +} + +func TestBitcoinBroadcastOutbox_StartRetriesTransientCandidateFailure( + t *testing.T, +) { + chain := newOutboxTestBitcoinChain() + statusSource := newOutboxTestAuthorizationStatusSource() + outbox := openTestBitcoinBroadcastOutboxWithStatusSource( + t, + t.TempDir(), + chain, + statusSource, + ) + defer outbox.close() + transaction := testOutboxTransaction(33, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + transaction, + testBitcoinBroadcastAuthorization(33, 3, 1), + ) + statusSource.setTransactionError( + transaction.Hash(), + errors.New("provider temporarily unavailable"), + ) + contextValue, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := outbox.start(contextValue); err != nil { + t.Fatalf("transient first-pass failure stopped outbox: [%v]", err) + } + snapshot, err := outbox.activationSnapshot() + if err != nil { + t.Fatal(err) + } + if !snapshot.Recovered || snapshot.PendingReservationCount != 1 { + t.Fatalf("unexpected recovery snapshot: [%+v]", snapshot) + } +} + func TestBitcoinBroadcastOutbox_PersistenceFailureDoesNotPublishConfirmationMutation( t *testing.T, ) { @@ -1252,6 +1348,7 @@ type outboxTestBitcoinChain struct { type outboxTestAuthorizationStatusSource struct { mutex sync.Mutex err error + transactionErrs map[bitcoin.Hash]error canonical bool broadcastAllowed bool calls int @@ -1262,6 +1359,7 @@ func newOutboxTestAuthorizationStatusSource() *outboxTestAuthorizationStatusSour return &outboxTestAuthorizationStatusSource{ canonical: true, broadcastAllowed: true, + transactionErrs: make(map[bitcoin.Hash]error), } } @@ -1281,6 +1379,9 @@ func (otass *outboxTestAuthorizationStatusSource) GetCanonicalFrostBitcoinBroadc if otass.err != nil { return nil, otass.err } + if err := otass.transactionErrs[request.TransactionHash]; err != nil { + return nil, err + } return &FrostBitcoinBroadcastAuthorizationStatus{ RequestHash: request.ComputeHash(), Canonical: otass.canonical, @@ -1288,6 +1389,15 @@ func (otass *outboxTestAuthorizationStatusSource) GetCanonicalFrostBitcoinBroadc }, nil } +func (otass *outboxTestAuthorizationStatusSource) setTransactionError( + hash bitcoin.Hash, + err error, +) { + otass.mutex.Lock() + defer otass.mutex.Unlock() + otass.transactionErrs[hash] = err +} + func (otass *outboxTestAuthorizationStatusSource) setBroadcastAllowed( allowed bool, ) { diff --git a/pkg/tbtc/frost_native_signer_anchor_bootstrap_client_test.go b/pkg/tbtc/frost_native_signer_anchor_bootstrap_client_test.go index 5a399c2a2c..1cfa0baf7d 100644 --- a/pkg/tbtc/frost_native_signer_anchor_bootstrap_client_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_bootstrap_client_test.go @@ -668,7 +668,7 @@ func TestFrostNativeSignerAnchorBootstrapClientDoesNotPoisonUnreachableService( environment.authorization(), ) if err == nil || strings.Contains(err.Error(), "poisoned") || - !strings.Contains(err.Error(), "cannot reconcile") { + !strings.Contains(err.Error(), "request failed") { t.Fatalf("expected retryable transport failure, got [%v]", err) } _, secondErr := environment.client.InitializeFrostNativeSignerAnchor( diff --git a/pkg/tbtc/frost_native_signer_anchor_client.go b/pkg/tbtc/frost_native_signer_anchor_client.go index 4f8a36248d..3f05f79f03 100644 --- a/pkg/tbtc/frost_native_signer_anchor_client.go +++ b/pkg/tbtc/frost_native_signer_anchor_client.go @@ -16,11 +16,13 @@ import ( "mime" "net" "net/http" + "net/http/httptrace" "net/url" "path" "strconv" "strings" "sync" + "sync/atomic" "time" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" @@ -1447,9 +1449,21 @@ func (client *FrostNativeSignerAnchorClient) postWithResponseLimit( request.Header.Set("Content-Type", "application/json") request.Header.Set("Accept", "application/json") request.Header.Set("Cache-Control", "no-store") + var wroteRequest atomic.Bool + request = request.WithContext(httptrace.WithClientTrace( + request.Context(), + &httptrace.ClientTrace{ + WroteRequest: func(httptrace.WroteRequestInfo) { + wroteRequest.Store(true) + }, + }, + )) response, err := client.httpClient.Do(request) if err != nil { - return nil, true, fmt.Errorf("native signer anchor request failed: %w", err) + return nil, wroteRequest.Load(), fmt.Errorf( + "native signer anchor request failed: %w", + err, + ) } defer response.Body.Close() limited := io.LimitReader(response.Body, responseLimit+1) diff --git a/pkg/tbtc/frost_native_signer_anchor_client_test.go b/pkg/tbtc/frost_native_signer_anchor_client_test.go index 2468e67b85..6e4cc94095 100644 --- a/pkg/tbtc/frost_native_signer_anchor_client_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_client_test.go @@ -101,6 +101,63 @@ func TestFrostNativeSignerAnchorStreamIDFrozenAndStable(t *testing.T) { } } +func TestFrostNativeSignerAnchorPost_ClassifiesTransportDelivery(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + closedEndpoint := "http://" + listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatal(err) + } + client := &FrostNativeSignerAnchorClient{ + httpClient: &http.Client{Transport: &http.Transport{}}, + requestTimeout: time.Second, + } + if _, sent, err := client.post( + context.Background(), + closedEndpoint, + []byte(`{"request":"test"}`), + ); err == nil || sent { + t.Fatalf( + "connection-refused request was not definitively unsent: sent [%v], error [%v]", + sent, + err, + ) + } + + server := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + if _, err := io.ReadAll(request.Body); err != nil { + t.Errorf("failed to read request body: %v", err) + } + hijacker, ok := writer.(http.Hijacker) + if !ok { + t.Error("response writer cannot be hijacked") + return + } + connection, _, err := hijacker.Hijack() + if err != nil { + t.Errorf("failed to hijack response: %v", err) + return + } + _ = connection.Close() + }, + )) + defer server.Close() + if _, sent, err := client.post( + context.Background(), + server.URL, + []byte(`{"request":"test"}`), + ); err == nil || !sent { + t.Fatalf( + "written request was not classified ambiguous: sent [%v], error [%v]", + sent, + err, + ) + } +} + func TestFrostNativeSignerAnchorRestartBoundsAlignAcrossGoLayers( t *testing.T, ) { diff --git a/pkg/tbtc/frost_native_signer_anchor_provisioning_files.go b/pkg/tbtc/frost_native_signer_anchor_provisioning_files.go index 8ac13dbe72..2e5c3f6f85 100644 --- a/pkg/tbtc/frost_native_signer_anchor_provisioning_files.go +++ b/pkg/tbtc/frost_native_signer_anchor_provisioning_files.go @@ -42,7 +42,7 @@ func ReadFrostNativeSignerAnchorProvisioningArtifact( fd, err := unix.Openat( int(directoryFile.Fd()), name, - unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, + unix.O_RDONLY|unix.O_NONBLOCK|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0, ) if err != nil { diff --git a/pkg/tbtc/frost_native_signer_anchor_provisioning_files_test.go b/pkg/tbtc/frost_native_signer_anchor_provisioning_files_test.go index cc065c0dcd..eb005a8348 100644 --- a/pkg/tbtc/frost_native_signer_anchor_provisioning_files_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_provisioning_files_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "golang.org/x/sys/unix" ) func bootstrapProvisioningTestDirectory(t *testing.T) string { @@ -249,6 +251,18 @@ func TestFrostNativeSignerAnchorBootstrapProvisioningArtifactReadRejections( t.Fatal("symlinked provisioning artifact was read") } }) + t.Run("fifo artifact", func(t *testing.T) { + fifo := filepath.Join(directory, "fifo.json") + if err := unix.Mkfifo(fifo, 0600); err != nil { + t.Fatal(err) + } + if _, err := ReadFrostNativeSignerAnchorProvisioningArtifact( + fifo, + 1024, + ); err == nil { + t.Fatal("FIFO provisioning artifact was read") + } + }) t.Run("group-accessible directory", func(t *testing.T) { loose := bootstrapProvisioningTestDirectory(t) loosePath := filepath.Join(loose, "artifact.json") diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index f91e66abd4..98b1025321 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -1970,6 +1970,16 @@ func (wr *walletRegistry) frostLocalSessionSnapshotLocked() ( if value == nil || len(value.signers) == 0 { return nil, fmt.Errorf("wallet registry contains an empty session") } + _, isFrostWallet, err := frostKeyGroupFromWalletCacheValue(value) + if err != nil { + return nil, fmt.Errorf( + "wallet registry cannot classify local session material: [%w]", + err, + ) + } + if !isFrostWallet { + continue + } nativeSigners := make([]*signer, 0) for _, signer := range value.signers { if signer == nil { diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index 18062cdaa2..bedab364a4 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -252,6 +252,29 @@ func TestFrostLocalSessionSnapshotBindsExactSignerMaterial(t *testing.T) { !strings.Contains(err.Error(), "does not identify its wallet") { t.Fatalf("mismatched local key-group material was accepted: [%v]", err) } + + scaffoldPayload, err := json.Marshal( + frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: strings.Repeat("33", 32), + KeyGroupSource: frostsigning. + NativeTBTCSignerKeyGroupSourceLegacyWalletPubKey, + }, + ) + if err != nil { + t.Fatal(err) + } + fixture.registry.walletCache["wallet"].signers[0].signerMaterial = + &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: scaffoldPayload, + } + sessions, err = fixture.registry.frostLocalSessionSnapshot() + if err != nil { + t.Fatalf("scaffold session failed readiness classification: [%v]", err) + } + if len(sessions) != 0 { + t.Fatalf("scaffold session classified as retained FROST: [%+v]", sessions) + } } func (fixture *journalTestFixture) openJournal( diff --git a/pkg/tbtc/redemption.go b/pkg/tbtc/redemption.go index 78b9832ea3..e8f4074895 100644 --- a/pkg/tbtc/redemption.go +++ b/pkg/tbtc/redemption.go @@ -3,6 +3,7 @@ package tbtc import ( "crypto/ecdsa" "fmt" + "math" "math/big" "time" @@ -251,6 +252,27 @@ func (ra *redemptionAction) execute() error { ) } + _, _, _, txMaxTotalFee, _, _, _, err := + ra.chain.GetRedemptionParameters() + if err != nil { + if ra.metricsRecorder != nil { + ra.metricsRecorder.IncrementCounter(clientinfo.MetricRedemptionExecutionsFailedTotal, 1) + } + return fmt.Errorf( + "error while obtaining redemption parameters: [%v]", + err, + ) + } + if err := validateRedemptionTransactionTotalFee( + unsignedRedemptionTx, + txMaxTotalFee, + ); err != nil { + if ra.metricsRecorder != nil { + ra.metricsRecorder.IncrementCounter(clientinfo.MetricRedemptionExecutionsFailedTotal, 1) + } + return fmt.Errorf("invalid redemption transaction fee: [%v]", err) + } + signTxLogger := ra.logger.With( zap.String("step", "signTransaction"), ) @@ -547,13 +569,17 @@ func assembleRedemptionTransaction( PublicKeyScript: changeOutputScript, } - switch resolvedShape { - case RedemptionChangeFirst: - outputs = append([]*bitcoin.TransactionOutput{changeOutput}, outputs...) - case RedemptionChangeLast: - outputs = append(outputs, changeOutput) - default: - panic("unknown redemption transaction shape") + // A sub-dust change output is not relayable under Bitcoin Core's + // standard policy. Omit it and let its value become additional fee. + if !bitcoin.IsDustOutput(changeOutput) { + switch resolvedShape { + case RedemptionChangeFirst: + outputs = append([]*bitcoin.TransactionOutput{changeOutput}, outputs...) + case RedemptionChangeLast: + outputs = append(outputs, changeOutput) + default: + panic("unknown redemption transaction shape") + } } } @@ -564,3 +590,40 @@ func assembleRedemptionTransaction( return builder, nil } + +func validateRedemptionTransactionTotalFee( + builder *bitcoin.TransactionBuilder, + maxTotalFee uint64, +) error { + if builder == nil { + return fmt.Errorf("redemption transaction builder is nil") + } + + totalOutputsValue := int64(0) + for index, output := range builder.UnsignedTransaction().Outputs { + if output == nil || output.Value < 0 { + return fmt.Errorf( + "redemption transaction output [%d] has invalid value", + index, + ) + } + if output.Value > math.MaxInt64-totalOutputsValue { + return fmt.Errorf("redemption transaction output value overflows") + } + totalOutputsValue += output.Value + } + + actualFee := builder.TotalInputsValue() - totalOutputsValue + if actualFee < 0 { + return fmt.Errorf("redemption transaction fee is negative") + } + if uint64(actualFee) > maxTotalFee { + return fmt.Errorf( + "redemption transaction total fee [%d] exceeds maximum [%d]", + actualFee, + maxTotalFee, + ) + } + + return nil +} diff --git a/pkg/tbtc/redemption_test.go b/pkg/tbtc/redemption_test.go index b2c35b8cb9..cbc8f61dae 100644 --- a/pkg/tbtc/redemption_test.go +++ b/pkg/tbtc/redemption_test.go @@ -2,7 +2,9 @@ package tbtc import ( "context" + "fmt" "math/big" + "strings" "testing" "time" @@ -177,6 +179,116 @@ func TestRedemptionAction_Execute(t *testing.T) { } } +type failIfCalledRedemptionSigningExecutor struct { + called bool +} + +func (executor *failIfCalledRedemptionSigningExecutor) signBatch( + context.Context, + []*big.Int, + uint64, +) ([]*frost.Signature, error) { + executor.called = true + return nil, fmt.Errorf("unexpected signing call") +} + +func TestRedemptionAction_RejectsActualFeeBeforeSigning(t *testing.T) { + hostChain := Connect() + bitcoinChain := newLocalBitcoinChain() + redeemingWallet := generateWallet(big.NewInt(1)) + walletPublicKey := redeemingWallet.publicKey + walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) + walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + }, + Sequence: 0xffffffff, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 9293, + PublicKeyScript: walletScript, + }}, + } + if err := bitcoinChain.BroadcastTransaction( + fundingTransaction, + ); err != nil { + t.Fatal(err) + } + walletMainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + }, + Value: 9293, + } + redeemerScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{0x01}) + if err != nil { + t.Fatal(err) + } + request := &RedemptionRequest{ + RedeemerOutputScript: redeemerScript, + RequestedAmount: 10000, + TreasuryFee: 1000, + TxMaxFee: 1000, + RequestedAt: time.Now(), + } + hostChain.setPendingRedemptionRequest( + walletPublicKeyHash, + request, + ) + proposal := &RedemptionProposal{ + RedeemersOutputScripts: []bitcoin.Script{redeemerScript}, + RedemptionTxFee: big.NewInt(1000), + } + if err := hostChain.setRedemptionProposalValidationResult( + walletPublicKeyHash, + proposal, + true, + ); err != nil { + t.Fatal(err) + } + hostChain.setWallet(walletPublicKeyHash, &WalletChainData{ + MainUtxoHash: hostChain.ComputeMainUtxoHash(walletMainUtxo), + }) + hostChain.setRedemptionParameters( + 0, + 0, + 0, + 1292, + 0, + big.NewInt(0), + 0, + ) + signingExecutor := &failIfCalledRedemptionSigningExecutor{} + action := newRedemptionAction( + logger.With(), + hostChain, + bitcoinChain, + redeemingWallet, + signingExecutor, + proposal, + 100, + 100+redemptionProposalValidityBlocks, + func(context.Context, uint64) error { return nil }, + ) + + err = action.execute() + if err == nil || !strings.Contains( + err.Error(), + "redemption transaction total fee [1293] exceeds maximum [1292]", + ) { + t.Fatalf("unexpected action result: [%v]", err) + } + if signingExecutor.called { + t.Fatal("signing executor called for an over-limit redemption fee") + } +} + func TestAssembleRedemptionTransaction(t *testing.T) { scenarios, err := test.LoadRedemptionTestScenarios() if err != nil { @@ -267,6 +379,206 @@ func TestAssembleRedemptionTransaction(t *testing.T) { } } +func TestAssembleRedemptionTransaction_DustChangePolicy(t *testing.T) { + scenarios, err := test.LoadRedemptionTestScenarios() + if err != nil { + t.Fatal(err) + } + scenario := scenarios[0] + + tests := map[string]struct { + changeValue int64 + expectedOutputCount int + }{ + "zero change omitted": { + changeValue: 0, + expectedOutputCount: 1, + }, + "P2WPKH below dust omitted": { + changeValue: 293, + expectedOutputCount: 1, + }, + "P2WPKH at dust threshold retained": { + changeValue: 294, + expectedOutputCount: 2, + }, + } + + for name, testCase := range tests { + t.Run(name, func(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + if err := bitcoinChain.BroadcastTransaction( + scenario.InputTransaction, + ); err != nil { + t.Fatal(err) + } + + request := scenario.RedemptionRequests[0] + feeShare := scenario.FeeShares[0] + redemptionOutputValue := + int64(request.RequestedAmount-request.TreasuryFee) - feeShare + mainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: scenario.WalletMainUtxo.Outpoint, + Value: redemptionOutputValue + + feeShare + + testCase.changeValue, + } + + builder, err := assembleRedemptionTransaction( + bitcoinChain, + scenario.WalletPublicKey, + mainUtxo, + []*RedemptionRequest{{ + Redeemer: request.Redeemer, + RedeemerOutputScript: request.RedeemerOutputScript, + RequestedAmount: request.RequestedAmount, + TreasuryFee: request.TreasuryFee, + TxMaxFee: request.TxMaxFee, + RequestedAt: request.RequestedAt, + }}, + func([]*RedemptionRequest) []int64 { + return []int64{feeShare} + }, + RedemptionChangeLast, + ) + if err != nil { + t.Fatal(err) + } + + outputs := builder.UnsignedTransaction().Outputs + if len(outputs) != testCase.expectedOutputCount { + t.Fatalf( + "unexpected output count [%d]", + len(outputs), + ) + } + if len(outputs) == 2 && + outputs[1].Value != testCase.changeValue { + t.Fatalf( + "unexpected change value [%d]", + outputs[1].Value, + ) + } + }) + } +} + +func TestAssembleRedemptionTransaction_TaprootDustChangePolicy(t *testing.T) { + walletPublicKey := testWalletPublicKeyFromXOnly( + t, + "2336f65004d8f122f1fe947ebd009a8b4add3a0d937356d568e30f7fcc2e4008", + ) + redeemerScript, err := bitcoin.PayToWitnessPublicKeyHash([20]byte{0x01}) + if err != nil { + t.Fatal(err) + } + request := &RedemptionRequest{ + RedeemerOutputScript: redeemerScript, + RequestedAmount: 10000, + TreasuryFee: 1000, + TxMaxFee: 1000, + } + + for _, testCase := range []struct { + name string + changeValue int64 + expectedOutputCount int + }{ + {"P2TR below dust omitted", 329, 1}, + {"P2TR at dust threshold retained", 330, 2}, + } { + t.Run(testCase.name, func(t *testing.T) { + bitcoinChain := newLocalBitcoinChain() + mainUtxo := testTaprootWalletMainUtxoWithValue( + t, + bitcoinChain, + walletPublicKey, + 9000+testCase.changeValue, + ) + builder, err := assembleRedemptionTransaction( + bitcoinChain, + walletPublicKey, + mainUtxo, + []*RedemptionRequest{request}, + func([]*RedemptionRequest) []int64 { + return []int64{1000} + }, + RedemptionChangeLast, + ) + if err != nil { + t.Fatal(err) + } + + outputs := builder.UnsignedTransaction().Outputs + if len(outputs) != testCase.expectedOutputCount { + t.Fatalf("unexpected output count [%d]", len(outputs)) + } + if len(outputs) == 2 && + outputs[1].Value != testCase.changeValue { + t.Fatalf("unexpected change value [%d]", outputs[1].Value) + } + }) + } +} + +func TestValidateRedemptionTransactionTotalFee_IncludesOmittedDustChange( + t *testing.T, +) { + scenarios, err := test.LoadRedemptionTestScenarios() + if err != nil { + t.Fatal(err) + } + scenario := scenarios[0] + bitcoinChain := newLocalBitcoinChain() + if err := bitcoinChain.BroadcastTransaction( + scenario.InputTransaction, + ); err != nil { + t.Fatal(err) + } + request := scenario.RedemptionRequests[0] + feeShare := scenario.FeeShares[0] + changeValue := int64(293) + redemptionOutputValue := + int64(request.RequestedAmount-request.TreasuryFee) - feeShare + mainUtxo := &bitcoin.UnspentTransactionOutput{ + Outpoint: scenario.WalletMainUtxo.Outpoint, + Value: redemptionOutputValue + feeShare + changeValue, + } + builder, err := assembleRedemptionTransaction( + bitcoinChain, + scenario.WalletPublicKey, + mainUtxo, + []*RedemptionRequest{{ + Redeemer: request.Redeemer, + RedeemerOutputScript: request.RedeemerOutputScript, + RequestedAmount: request.RequestedAmount, + TreasuryFee: request.TreasuryFee, + TxMaxFee: request.TxMaxFee, + RequestedAt: request.RequestedAt, + }}, + func([]*RedemptionRequest) []int64 { + return []int64{feeShare} + }, + ) + if err != nil { + t.Fatal(err) + } + + actualFee := uint64(feeShare + changeValue) + if err := validateRedemptionTransactionTotalFee( + builder, + actualFee, + ); err != nil { + t.Fatalf("fee at maximum rejected: [%v]", err) + } + if err := validateRedemptionTransactionTotalFee( + builder, + actualFee-1, + ); err == nil { + t.Fatal("fee above maximum accepted") + } +} + func TestWithRedemptionTotalFee(t *testing.T) { var tests = map[string]struct { totalFee int64 From d73af44a2b949f3e7a1cf0e57fb9418fa2d6b407 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 21 Jul 2026 18:51:23 -0400 Subject: [PATCH 23/93] Add manifest-bound retained FROST history source --- cmd/start.go | 19 + pkg/tbtc/frost_activation_handshake_test.go | 11 + .../frost_retained_group_history_evidence.go | 502 ++++++++ .../frost_retained_group_history_source.go | 1147 +++++++++++++++++ ...rost_retained_group_history_source_test.go | 961 ++++++++++++++ pkg/tbtc/frost_retained_group_journal.go | 21 +- pkg/tbtc/frost_retained_group_journal_test.go | 25 +- pkg/tbtc/node.go | 18 + pkg/tbtc/tbtc.go | 7 +- 9 files changed, 2706 insertions(+), 5 deletions(-) create mode 100644 pkg/tbtc/frost_retained_group_history_evidence.go create mode 100644 pkg/tbtc/frost_retained_group_history_source.go create mode 100644 pkg/tbtc/frost_retained_group_history_source_test.go diff --git a/cmd/start.go b/cmd/start.go index c5bc8902f2..541cd533f6 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -162,6 +162,25 @@ func start(cmd *cobra.Command) error { btcChain, ) + var retainedGroupHistorySource interface{ Close() } + if clientConfig.Tbtc.EnableFrostPreSignAuthorization { + source, err := tbtc.NewFrostRetainedGroupHistorySource( + ctx, + clientConfig.Tbtc.FrostRetainedGroupHistory, + clientConfig.Ethereum.URL, + uint64(clientConfig.Ethereum.Network.ChainID()), + ) + if err != nil { + return fmt.Errorf( + "cannot initialize independent FROST retained-group history source: [%w]", + err, + ) + } + retainedGroupHistorySource = source + defer retainedGroupHistorySource.Close() + clientConfig.Tbtc.FrostRetainedGroupHistorySource = source + } + err = tbtc.Initialize( ctx, tbtcChain, diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index a2f16fdf6d..2533762dbc 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -93,6 +93,17 @@ func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSigner }, nil } +func (source *testFrostRetainedGroupHistorySource) BindFrostRetainedGroupActivationEvidence( + _ FrostPreSignActivationProfile, + _ [32]byte, + descriptorSetHash [32]byte, +) error { + if descriptorSetHash != source.manifest.DescriptorSetHash { + return fmt.Errorf("descriptor set mismatch") + } + return nil +} + func (source *testFrostRetainedGroupHistorySource) Identity( context.Context, ) (FrostRetainedGroupHistoryIdentity, error) { diff --git a/pkg/tbtc/frost_retained_group_history_evidence.go b/pkg/tbtc/frost_retained_group_history_evidence.go new file mode 100644 index 0000000000..8aa8f46615 --- /dev/null +++ b/pkg/tbtc/frost_retained_group_history_evidence.go @@ -0,0 +1,502 @@ +package tbtc + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + ethabi "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" + bridgeabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" +) + +// FrostRetainedGroupActivationEvidenceBinder binds a retained-group history +// source to the exact deployment and descriptor set authenticated by the +// signed activation manifest. Production activation requires this binding +// before the source can authenticate semantic history or operator IDs. +type FrostRetainedGroupActivationEvidenceBinder interface { + BindFrostRetainedGroupActivationEvidence( + FrostPreSignActivationProfile, + [32]byte, + [32]byte, + ) error +} + +type frostRetainedGroupContractPin struct { + address common.Address + codeHash common.Hash +} + +type frostRetainedGroupEvidenceProfile struct { + manifestHash [32]byte + descriptorSetHash [32]byte + bridge frostRetainedGroupContractPin + registry frostRetainedGroupContractPin + sortitionPool frostRetainedGroupContractPin + bridgeABI ethabi.ABI + registryABI ethabi.ABI + membersArguments ethabi.Arguments +} + +type frostRetainedGroupReceiptCache map[common.Hash]*types.Receipt + +type frostRetainedGroupCodeCacheKey struct { + address common.Address + blockNumber uint64 +} + +type frostRetainedGroupCodeCache map[frostRetainedGroupCodeCacheKey]struct{} + +var _ FrostRetainedGroupActivationEvidenceBinder = (*signedFrostRetainedGroupHistorySource)(nil) + +// BindFrostRetainedGroupActivationEvidence is deliberately one-shot. The +// profile and descriptor are supplied only after the activation envelope has +// been signature-checked and converted to its immutable runtime manifest. +func (source *signedFrostRetainedGroupHistorySource) BindFrostRetainedGroupActivationEvidence( + profile FrostPreSignActivationProfile, + manifestHash [32]byte, + descriptorSetHash [32]byte, +) error { + if source == nil { + return fmt.Errorf("retained-group history source is nil") + } + if err := profile.ValidateForProduction(); err != nil { + return fmt.Errorf("retained-group activation profile is invalid: [%w]", err) + } + if manifestHash == [32]byte{} || descriptorSetHash == [32]byte{} || + profile.ActivationManifestHash != manifestHash { + return fmt.Errorf("retained-group evidence does not match the signed activation manifest") + } + parsedBridgeABI, err := ethabi.JSON(strings.NewReader(bridgeabi.BridgeMetaData.ABI)) + if err != nil { + return fmt.Errorf("cannot parse pinned Bridge ABI: [%w]", err) + } + parsedRegistryABI, err := ethabi.JSON(strings.NewReader(frostabi.FrostWalletRegistryMetaData.ABI)) + if err != nil { + return fmt.Errorf("cannot parse pinned FROST registry ABI: [%w]", err) + } + membersType, err := ethabi.NewType("uint32[]", "", nil) + if err != nil { + return fmt.Errorf("cannot construct pinned DKG members descriptor: [%w]", err) + } + evidence := &frostRetainedGroupEvidenceProfile{ + manifestHash: manifestHash, + descriptorSetHash: descriptorSetHash, + bridge: frostRetainedGroupContractPin{ + address: common.Address(profile.BridgeAddress), + codeHash: common.Hash(profile.BridgeCodeHash), + }, + registry: frostRetainedGroupContractPin{ + address: common.Address(profile.FrostRegistry), + codeHash: common.Hash(profile.FrostRegistryCodeHash), + }, + sortitionPool: frostRetainedGroupContractPin{ + address: common.Address(profile.SortitionPool), + codeHash: common.Hash(profile.SortitionPoolCodeHash), + }, + bridgeABI: parsedBridgeABI, + registryABI: parsedRegistryABI, + membersArguments: ethabi.Arguments{{Type: membersType}}, + } + for name, event := range map[string]struct { + contract *ethabi.ABI + topic common.Hash + }{ + "DkgResultSubmitted": {&evidence.registryABI, common.HexToHash("0xbfc6cd6291b6741d3ac1631ba81a0288d08265bea4d59d452e8c953e11ec11c6")}, + "DkgResultApproved": {&evidence.registryABI, common.HexToHash("0xe6e9d5eba171e82025efb3f3d44fd35905e7283d104284cb9f3bbc5bf1e4276f")}, + "WalletCreated": {&evidence.registryABI, common.HexToHash("0xbe8f27cef1f3d94120c9c547c3614f5b992fdb0c0a497cc920fde06546291ab4")}, + "WalletClosed": {&evidence.registryABI, common.HexToHash("0xa6ae4af610b8ada39d3675190ead27a5552631a8e33f53e4e37dbb082f11a73e")}, + "NewWalletRegisteredV2": {&evidence.bridgeABI, common.HexToHash("0x6a501a1d441e1c8b5490e52589d0d27d35504cf1063a8c848fef40f326710d4b")}, + "WalletMovingFunds": {&evidence.bridgeABI, common.HexToHash("0xbdc9ce990a067e5fd3a5d8dfc68e27e9f221aaa3fe55265e0b7e93c460b3efe2")}, + "WalletClosing": {&evidence.bridgeABI, common.HexToHash("0x68cb496f5e64383745876664ef119840f154a729c03ba866b8aecb5c9f53d516")}, + "BridgeWalletClosed": {&evidence.bridgeABI, common.HexToHash("0x47b159947c3066cb253f60e8f046cfd747411788a545cb189679e3fa1467b28d")}, + "WalletTerminated": {&evidence.bridgeABI, common.HexToHash("0x9272a280b0f32f70b00ad0b546499c68e3ecc6f7bb7ef43491ec5d7b99bf69ef")}, + } { + eventName := name + if name == "BridgeWalletClosed" { + eventName = "WalletClosed" + } + parsedEvent, ok := event.contract.Events[eventName] + if !ok || parsedEvent.ID != event.topic { + return fmt.Errorf("pinned retained-group event descriptor [%s] is unavailable or changed", name) + } + } + + source.evidenceMutex.Lock() + defer source.evidenceMutex.Unlock() + if source.evidence != nil { + return fmt.Errorf("retained-group activation evidence is already bound") + } + source.evidence = evidence + return nil +} + +func (source *signedFrostRetainedGroupHistorySource) activationEvidence() ( + *frostRetainedGroupEvidenceProfile, + error, +) { + if source == nil { + return nil, fmt.Errorf("retained-group history source is nil") + } + source.evidenceMutex.RLock() + defer source.evidenceMutex.RUnlock() + if source.evidence == nil { + return nil, fmt.Errorf("retained-group history source is not bound to the signed activation manifest") + } + return source.evidence, nil +} + +func (source *signedFrostRetainedGroupHistorySource) verifyHistoryEvidence( + ctx context.Context, + mutations []FrostRetainedGroupMutation, + evidence *frostRetainedGroupEvidenceProfile, +) error { + if evidence == nil { + return fmt.Errorf("retained-group activation evidence is nil") + } + receipts := make(frostRetainedGroupReceiptCache) + code := make(frostRetainedGroupCodeCache) + for index, mutation := range mutations { + if err := source.verifyMutationEvidence(ctx, mutation, evidence, receipts, code); err != nil { + return fmt.Errorf("mutation [%d] [%s]: [%w]", index, mutation.Kind, err) + } + } + return nil +} + +func (source *signedFrostRetainedGroupHistorySource) verifyMutationEvidence( + ctx context.Context, + mutation FrostRetainedGroupMutation, + evidence *frostRetainedGroupEvidenceProfile, + receipts frostRetainedGroupReceiptCache, + code frostRetainedGroupCodeCache, +) error { + // Quarantine, recovery-required, and lift records are signed operational + // records, not Ethereum events. Their Point is only a canonical finalized + // ordering anchor and is deliberately never presented as receipt evidence. + if isFrostRetainedGroupQuarantineMutation(mutation.Kind) { + return nil + } + + switch mutation.Kind { + case FrostRetainedGroupAdmissionMutation: + return source.verifyAdmissionEvidence(ctx, mutation, evidence, receipts, code) + case FrostRetainedGroupMovingFundsMutation, + FrostRetainedGroupClosingMutation, + FrostRetainedGroupClosedMutation, + FrostRetainedGroupTerminatedMutation: + eventName := map[FrostRetainedGroupMutationKind]string{ + FrostRetainedGroupMovingFundsMutation: "WalletMovingFunds", + FrostRetainedGroupClosingMutation: "WalletClosing", + FrostRetainedGroupClosedMutation: "WalletClosed", + FrostRetainedGroupTerminatedMutation: "WalletTerminated", + }[mutation.Kind] + log, err := source.authenticatedEventLog( + ctx, + mutation.Point, + evidence.bridge, + evidence.bridgeABI.Events[eventName].ID, + receipts, + code, + ) + if err != nil { + return err + } + if len(log.Topics) != 3 || len(log.Data) != 0 || log.Topics[1] != (common.Hash{}) || + log.Topics[2] != frostRetainedGroupBytes20Topic(mutation.WalletPublicKeyHash) { + return fmt.Errorf("Bridge lifecycle log does not encode the exported FROST wallet") + } + return nil + case FrostRetainedGroupRegistryClosureMutation: + log, err := source.authenticatedEventLog( + ctx, + mutation.Point, + evidence.registry, + evidence.registryABI.Events["WalletClosed"].ID, + receipts, + code, + ) + if err != nil { + return err + } + if len(log.Topics) != 2 || len(log.Data) != 0 || log.Topics[1] != common.Hash(mutation.WalletID) { + return fmt.Errorf("FROST registry closure log does not encode the exported wallet") + } + return nil + default: + return fmt.Errorf("unsupported retained-group mutation kind [%s]", mutation.Kind) + } +} + +func (source *signedFrostRetainedGroupHistorySource) verifyAdmissionEvidence( + ctx context.Context, + mutation FrostRetainedGroupMutation, + evidence *frostRetainedGroupEvidenceProfile, + receipts frostRetainedGroupReceiptCache, + code frostRetainedGroupCodeCache, +) error { + if mutation.Point != mutation.BridgeRegistrationPoint || + compareFrostRetainedGroupEventPoints(mutation.DkgSubmissionPoint, mutation.DkgApprovalPoint) >= 0 || + !sameFrostRetainedGroupTransaction(mutation.DkgApprovalPoint, mutation.CreationPoint) || + !sameFrostRetainedGroupTransaction(mutation.CreationPoint, mutation.BridgeRegistrationPoint) || + mutation.DkgApprovalPoint.LogIndex >= mutation.CreationPoint.LogIndex || + mutation.CreationPoint.LogIndex >= mutation.BridgeRegistrationPoint.LogIndex { + return fmt.Errorf("admission evidence points are not the required DKG/registration sequence") + } + + submissionLog, err := source.authenticatedEventLog( + ctx, + mutation.DkgSubmissionPoint, + evidence.registry, + evidence.registryABI.Events["DkgResultSubmitted"].ID, + receipts, + code, + ) + if err != nil { + return err + } + result, resultHash, err := frostRetainedGroupDecodeDkgSubmission( + submissionLog, + evidence, + ) + if err != nil { + return err + } + if resultHash != mutation.DkgResultHash || result.XOnlyOutputKey != mutation.WalletID || + result.MembersHash != mutation.RetainedGroupHash || + !frostRetainedGroupEqualOperatorIDs(result.Members, mutation.OperatorIDs) { + return fmt.Errorf("DKG submission does not encode the exported admission") + } + encodedMembers, err := evidence.membersArguments.Pack(result.Members) + if err != nil || crypto.Keccak256Hash(encodedMembers) != common.Hash(result.MembersHash) { + return fmt.Errorf("DKG members hash does not commit to the exact ordered member IDs") + } + + approvalLog, err := source.authenticatedEventLog( + ctx, + mutation.DkgApprovalPoint, + evidence.registry, + evidence.registryABI.Events["DkgResultApproved"].ID, + receipts, + code, + ) + if err != nil { + return err + } + if len(approvalLog.Topics) != 3 || len(approvalLog.Data) != 0 || + approvalLog.Topics[1] != common.Hash(mutation.DkgResultHash) || + approvalLog.Topics[2] == (common.Hash{}) || + !frostRetainedGroupCanonicalAddressTopic(approvalLog.Topics[2]) { + return fmt.Errorf("DKG approval log does not approve the exported result") + } + + creationLog, err := source.authenticatedEventLog( + ctx, + mutation.CreationPoint, + evidence.registry, + evidence.registryABI.Events["WalletCreated"].ID, + receipts, + code, + ) + if err != nil { + return err + } + if len(creationLog.Topics) != 3 || len(creationLog.Data) != 0 || + creationLog.Topics[1] != common.Hash(mutation.WalletID) || + creationLog.Topics[2] != common.Hash(mutation.DkgResultHash) { + return fmt.Errorf("FROST wallet-creation log does not encode the exported admission") + } + + registrationLog, err := source.authenticatedEventLog( + ctx, + mutation.BridgeRegistrationPoint, + evidence.bridge, + evidence.bridgeABI.Events["NewWalletRegisteredV2"].ID, + receipts, + code, + ) + if err != nil { + return err + } + if len(registrationLog.Topics) != 4 || len(registrationLog.Data) != 0 || + registrationLog.Topics[1] != common.Hash(mutation.WalletID) || + registrationLog.Topics[2] != (common.Hash{}) || + registrationLog.Topics[3] != frostRetainedGroupBytes20Topic(mutation.WalletPublicKeyHash) { + return fmt.Errorf("Bridge registration log does not encode the exported FROST wallet") + } + return nil +} + +func frostRetainedGroupDecodeDkgSubmission( + log *types.Log, + evidence *frostRetainedGroupEvidenceProfile, +) (result frostabi.FrostDkgResult, resultHash [32]byte, err error) { + defer func() { + if recovered := recover(); recovered != nil { + result = frostabi.FrostDkgResult{} + resultHash = [32]byte{} + err = fmt.Errorf("cannot decode DKG submission result") + } + }() + if log == nil || evidence == nil || len(log.Topics) != 3 || len(log.Data) == 0 { + return result, resultHash, fmt.Errorf("DKG submission log is malformed") + } + resultHash = [32]byte(log.Topics[1]) + if resultHash == [32]byte{} || crypto.Keccak256Hash(log.Data) != common.Hash(resultHash) { + return result, resultHash, fmt.Errorf("DKG result hash does not commit to the submitted result") + } + values, unpackErr := evidence.registryABI.Events["DkgResultSubmitted"].Inputs.NonIndexed().Unpack(log.Data) + if unpackErr != nil || len(values) != 1 { + return result, resultHash, fmt.Errorf("cannot decode DKG submitted result: [%w]", unpackErr) + } + converted := ethabi.ConvertType(values[0], new(frostabi.FrostDkgResult)) + decoded, ok := converted.(*frostabi.FrostDkgResult) + if !ok || decoded == nil { + return result, resultHash, fmt.Errorf("cannot convert DKG submitted result") + } + return *decoded, resultHash, nil +} + +func (source *signedFrostRetainedGroupHistorySource) authenticatedEventLog( + ctx context.Context, + point FrostRetainedGroupEventPoint, + pin frostRetainedGroupContractPin, + topic common.Hash, + receipts frostRetainedGroupReceiptCache, + code frostRetainedGroupCodeCache, +) (*types.Log, error) { + if !point.valid() || pin.address == (common.Address{}) || pin.codeHash == (common.Hash{}) || + topic == (common.Hash{}) { + return nil, fmt.Errorf("event evidence descriptor is incomplete") + } + if err := source.authenticateContractCode(ctx, pin, point.BlockNumber, code); err != nil { + return nil, err + } + transactionHash := common.Hash(point.TransactionHash) + receipt, ok := receipts[transactionHash] + if !ok { + var err error + receipt, err = source.verifier.TransactionReceipt(ctx, transactionHash) + if err != nil { + return nil, fmt.Errorf("cannot read transaction receipt [%s]: [%w]", transactionHash.Hex(), err) + } + if receipt == nil { + return nil, fmt.Errorf("transaction receipt [%s] is missing", transactionHash.Hex()) + } + receipts[transactionHash] = receipt + } + if receipt.Status != types.ReceiptStatusSuccessful || receipt.BlockNumber == nil || + !receipt.BlockNumber.IsUint64() || receipt.BlockNumber.Uint64() != point.BlockNumber || + receipt.BlockHash != common.Hash(point.BlockHash) || receipt.TxHash != transactionHash || + receipt.TransactionIndex != uint(point.TransactionIndex) { + return nil, fmt.Errorf("transaction receipt does not match the exported event point") + } + var matched *types.Log + for _, candidate := range receipt.Logs { + if candidate == nil || candidate.Index != uint(point.LogIndex) { + continue + } + if matched != nil { + return nil, fmt.Errorf("transaction receipt contains duplicate global log index") + } + matched = candidate + } + if matched == nil || matched.Removed || matched.Address != pin.address || + matched.BlockNumber != point.BlockNumber || matched.BlockHash != common.Hash(point.BlockHash) || + matched.TxHash != transactionHash || matched.TxIndex != uint(point.TransactionIndex) || + len(matched.Topics) == 0 || matched.Topics[0] != topic { + return nil, fmt.Errorf("receipt log does not match the exact contract/event point") + } + return matched, nil +} + +func (source *signedFrostRetainedGroupHistorySource) authenticateContractCode( + ctx context.Context, + pin frostRetainedGroupContractPin, + blockNumber uint64, + cache frostRetainedGroupCodeCache, +) error { + key := frostRetainedGroupCodeCacheKey{address: pin.address, blockNumber: blockNumber} + if _, ok := cache[key]; ok { + return nil + } + code, err := source.verifier.CodeAt(ctx, pin.address, new(big.Int).SetUint64(blockNumber)) + if err != nil { + return fmt.Errorf("cannot read pinned contract code at block [%d]: [%w]", blockNumber, err) + } + if len(code) == 0 || crypto.Keccak256Hash(code) != pin.codeHash { + return fmt.Errorf("contract code at block [%d] differs from the signed activation manifest", blockNumber) + } + cache[key] = struct{}{} + return nil +} + +func (source *signedFrostRetainedGroupHistorySource) resolveOperatorIDAt( + ctx context.Context, + operator common.Address, + at FrostPreSignFinality, + evidence *frostRetainedGroupEvidenceProfile, +) (uint32, error) { + if evidence == nil || operator == (common.Address{}) { + return 0, fmt.Errorf("operator-resolution evidence is incomplete") + } + if err := source.authenticateContractCode( + ctx, + evidence.sortitionPool, + at.BlockNumber, + make(frostRetainedGroupCodeCache), + ); err != nil { + return 0, err + } + // getOperatorID(address), pinned explicitly rather than learned from an + // exporter or a mutable ABI service. + callData := make([]byte, 4+32) + copy(callData[:4], []byte{0x5a, 0x48, 0xb4, 0x6b}) + copy(callData[4+12:], operator[:]) + to := evidence.sortitionPool.address + output, err := source.verifier.CallContract( + ctx, + ethereum.CallMsg{To: &to, Data: callData}, + new(big.Int).SetUint64(at.BlockNumber), + ) + if err != nil { + return 0, err + } + if len(output) != 32 || !bytes.Equal(output[:28], make([]byte, 28)) { + return 0, fmt.Errorf("sortition-pool getOperatorID returned noncanonical data") + } + operatorID := binary.BigEndian.Uint32(output[28:]) + if operatorID == 0 { + return 0, fmt.Errorf("operator is not registered in the pinned sortition pool at the requested block") + } + return operatorID, nil +} + +func frostRetainedGroupEqualOperatorIDs(left []uint32, right []uint32) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func frostRetainedGroupBytes20Topic(value [20]byte) common.Hash { + var result common.Hash + copy(result[:20], value[:]) + return result +} + +func frostRetainedGroupCanonicalAddressTopic(topic common.Hash) bool { + return bytes.Equal(topic[:12], make([]byte, 12)) +} diff --git a/pkg/tbtc/frost_retained_group_history_source.go b/pkg/tbtc/frost_retained_group_history_source.go new file mode 100644 index 0000000000..d710793aba --- /dev/null +++ b/pkg/tbtc/frost_retained_group_history_source.go @@ -0,0 +1,1147 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "mime" + "net" + "net/http" + "net/url" + "path" + "strings" + "sync" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/keep-network/keep-core/pkg/chain" +) + +const ( + frostRetainedGroupHistoryPageSchema = "tbtc-frost-retained-group-history-page/v1" + frostRetainedGroupOperatorReceiptSchema = "tbtc-frost-retained-group-operator-receipt/v1" + frostRetainedGroupHistoryRequestSchema = "tbtc-frost-retained-group-history-request/v1" + frostRetainedGroupOperatorRequestSchema = "tbtc-frost-retained-group-operator-request/v1" + frostRetainedGroupHistorySignatureDomain = "tbtc-frost-retained-group-export-signature-v1\x00" + frostRetainedGroupHistoryEndpointDomain = "tbtc-frost-retained-group-endpoints-v1\x00" + frostRetainedGroupHistoryQueryDomain = "tbtc-frost-retained-group-history-query-v1\x00" + frostRetainedGroupOperatorQueryDomain = "tbtc-frost-retained-group-operator-query-v1\x00" + frostRetainedGroupHistoryRootDomain = "tbtc-frost-retained-group-export-history-root-v1\x00" + + frostRetainedGroupMaximumResponseBytes = 1024 * 1024 + frostRetainedGroupMaximumPages = 4096 + frostRetainedGroupMaximumMutations = 250000 + frostRetainedGroupMaximumCursorBytes = 256 + frostRetainedGroupMaximumReasonBytes = 1024 + frostRetainedGroupDefaultTimeout = 20 * time.Second +) + +// FrostRetainedGroupHistorySourceConfig configures the independent, +// receipt-complete retained-group history service. ExportURL and EthereumURL +// must be distinct from each other and from the primary Ethereum endpoint. The +// export authority's Ed25519 SPKI hash is pinned rather than learned from the +// service. +type FrostRetainedGroupHistorySourceConfig struct { + ExportURL string + EthereumURL string + TrustDomainID string + TrustedSignerKeyHash string + RequestTimeout time.Duration +} + +type frostRetainedGroupEthereumVerifier interface { + ChainID(context.Context) (*big.Int, error) + HeaderByNumber(context.Context, *big.Int) (*types.Header, error) + TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) + CodeAt(context.Context, common.Address, *big.Int) ([]byte, error) + CallContract(context.Context, ethereum.CallMsg, *big.Int) ([]byte, error) + Close() +} + +type frostRetainedGroupHTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +// signedFrostRetainedGroupHistorySource consumes independently signed, +// paginated history receipts and checks their block commitments against a +// separately configured finalized Ethereum endpoint. It intentionally does +// not use eth_getLogs: generic RPC providers cannot prove a capped response is +// complete. +type signedFrostRetainedGroupHistorySource struct { + exportEndpoint *url.URL + verifier frostRetainedGroupEthereumVerifier + httpClient frostRetainedGroupHTTPClient + chainID uint64 + identity FrostRetainedGroupHistoryIdentity + trustedSignerKeyHash [32]byte + evidenceMutex sync.RWMutex + evidence *frostRetainedGroupEvidenceProfile +} + +var _ FrostRetainedGroupHistorySource = (*signedFrostRetainedGroupHistorySource)(nil) + +type frostRetainedGroupSignedEnvelope struct { + Schema string `json:"schema"` + Payload json.RawMessage `json:"payload"` + PayloadSHA256 string `json:"payloadSha256"` + SignerPublicKeySPKI string `json:"signerPublicKeySpki"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + Signature string `json:"signature"` +} + +type frostRetainedGroupWireIdentity struct { + TrustDomainID string `json:"trustDomainID"` + EndpointFingerprint string `json:"endpointFingerprint"` + OperatorFingerprint string `json:"operatorFingerprint"` +} + +type frostRetainedGroupWireFinality struct { + RelayTransactionHash string `json:"relayTransactionHash"` + BlockNumber uint64 `json:"blockNumber"` + BlockHash string `json:"blockHash"` + TransactionIndex uint32 `json:"transactionIndex"` + LogIndex uint32 `json:"logIndex"` + AuthorizationSequence string `json:"authorizationSequence"` +} + +type frostRetainedGroupWireEventPoint struct { + BlockNumber uint64 `json:"blockNumber"` + BlockHash string `json:"blockHash"` + TransactionHash string `json:"transactionHash"` + TransactionIndex uint32 `json:"transactionIndex"` + LogIndex uint32 `json:"logIndex"` +} + +type frostRetainedGroupWireMutation struct { + Point frostRetainedGroupWireEventPoint `json:"point"` + Kind string `json:"kind"` + WalletID string `json:"walletID"` + WalletPublicKeyHash string `json:"walletPublicKeyHash"` + OperatorIDs []uint32 `json:"operatorIDs"` + RetainedGroupHash string `json:"retainedGroupHash"` + DkgResultHash string `json:"dkgResultHash"` + DkgSubmissionPoint frostRetainedGroupWireEventPoint `json:"dkgSubmissionPoint"` + DkgApprovalPoint frostRetainedGroupWireEventPoint `json:"dkgApprovalPoint"` + CreationPoint frostRetainedGroupWireEventPoint `json:"creationPoint"` + BridgeRegistrationPoint frostRetainedGroupWireEventPoint `json:"bridgeRegistrationPoint"` + QuarantineID string `json:"quarantineID"` + EvidenceHash string `json:"evidenceHash"` + AuthenticationHash string `json:"authenticationHash"` + Reason string `json:"reason"` +} + +type frostRetainedGroupHistoryQuery struct { + Schema string `json:"schema"` + From frostRetainedGroupWireFinality `json:"from"` + To frostRetainedGroupWireFinality `json:"to"` +} + +type frostRetainedGroupHistoryPageRequest struct { + Query frostRetainedGroupHistoryQuery `json:"query"` + Cursor string `json:"cursor"` +} + +type frostRetainedGroupHistoryReceipt struct { + PageCount uint64 `json:"pageCount"` + MutationCount uint64 `json:"mutationCount"` + HistoryRoot string `json:"historyRoot"` +} + +type frostRetainedGroupHistoryPagePayload struct { + Schema string `json:"schema"` + Identity frostRetainedGroupWireIdentity `json:"identity"` + ChainID uint64 `json:"chainID"` + QueryHash string `json:"queryHash"` + SnapshotID string `json:"snapshotID"` + PageIndex uint64 `json:"pageIndex"` + Cursor string `json:"cursor"` + PreviousPageHash string `json:"previousPageHash"` + From frostRetainedGroupWireFinality `json:"from"` + To frostRetainedGroupWireFinality `json:"to"` + EmptyAtFrom bool `json:"emptyAtFrom"` + DescriptorSetHash string `json:"descriptorSetHash"` + Mutations []frostRetainedGroupWireMutation `json:"mutations"` + NextCursor string `json:"nextCursor"` + Complete bool `json:"complete"` + Receipt *frostRetainedGroupHistoryReceipt `json:"receipt"` +} + +type frostRetainedGroupOperatorQuery struct { + Schema string `json:"schema"` + OperatorAddress string `json:"operatorAddress"` + At frostRetainedGroupWireFinality `json:"at"` +} + +type frostRetainedGroupOperatorReceiptPayload struct { + Schema string `json:"schema"` + Identity frostRetainedGroupWireIdentity `json:"identity"` + ChainID uint64 `json:"chainID"` + QueryHash string `json:"queryHash"` + OperatorAddress string `json:"operatorAddress"` + At frostRetainedGroupWireFinality `json:"at"` + OperatorID uint32 `json:"operatorID"` + Found bool `json:"found"` +} + +// FrostRetainedGroupHistoryEndpointFingerprint returns the identity committed +// by the activation manifest for this exact export/RPC endpoint pair. +func FrostRetainedGroupHistoryEndpointFingerprint( + exportURL string, + ethereumURL string, +) ([32]byte, error) { + _, canonicalExport, err := validateFrostRetainedGroupEndpoint(exportURL, true) + if err != nil { + return [32]byte{}, fmt.Errorf("invalid retained-group export URL: [%w]", err) + } + _, canonicalEthereum, err := validateFrostRetainedGroupEndpoint(ethereumURL, false) + if err != nil { + return [32]byte{}, fmt.Errorf("invalid retained-group Ethereum URL: [%w]", err) + } + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupHistoryEndpointDomain)) + hasher.Write([]byte(canonicalExport)) + hasher.Write([]byte{0}) + hasher.Write([]byte(canonicalEthereum)) + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result, nil +} + +// NewFrostRetainedGroupHistorySource creates the production source. It fails +// closed if the verifier is not a distinct endpoint, does not expose finalized +// blocks, or serves a different chain. +func NewFrostRetainedGroupHistorySource( + ctx context.Context, + config FrostRetainedGroupHistorySourceConfig, + primaryEthereumURL string, + expectedChainID uint64, +) (*signedFrostRetainedGroupHistorySource, error) { + if ctx == nil { + return nil, fmt.Errorf("retained-group history context is nil") + } + endpoint, canonicalEthereum, identity, signerHash, timeout, err := + validateFrostRetainedGroupSourceConfig(config, primaryEthereumURL) + if err != nil { + return nil, err + } + if expectedChainID == 0 { + return nil, fmt.Errorf("retained-group history expected chain ID is zero") + } + verifier, err := ethclient.DialContext(ctx, canonicalEthereum) + if err != nil { + return nil, fmt.Errorf("cannot connect independent retained-group Ethereum verifier: [%w]", err) + } + source, err := newSignedFrostRetainedGroupHistorySource( + ctx, + endpoint, + verifier, + &http.Client{ + Timeout: timeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return fmt.Errorf("retained-group export redirects are forbidden") + }, + }, + expectedChainID, + identity, + signerHash, + ) + if err != nil { + verifier.Close() + return nil, err + } + return source, nil +} + +func newSignedFrostRetainedGroupHistorySource( + ctx context.Context, + exportEndpoint *url.URL, + verifier frostRetainedGroupEthereumVerifier, + httpClient frostRetainedGroupHTTPClient, + chainID uint64, + identity FrostRetainedGroupHistoryIdentity, + signerHash [32]byte, +) (*signedFrostRetainedGroupHistorySource, error) { + if exportEndpoint == nil || verifier == nil || httpClient == nil || chainID == 0 || + strings.TrimSpace(identity.TrustDomainID) == "" || + identity.EndpointFingerprint == [32]byte{} || signerHash == [32]byte{} || + identity.OperatorFingerprint != signerHash { + return nil, fmt.Errorf("retained-group history source configuration is incomplete") + } + actualChainID, err := verifier.ChainID(ctx) + if err != nil { + return nil, fmt.Errorf("cannot identify independent retained-group Ethereum verifier: [%w]", err) + } + if actualChainID == nil || !actualChainID.IsUint64() || actualChainID.Uint64() != chainID { + return nil, fmt.Errorf("independent retained-group Ethereum verifier chain ID mismatch") + } + source := &signedFrostRetainedGroupHistorySource{ + exportEndpoint: exportEndpoint, + verifier: verifier, + httpClient: httpClient, + chainID: chainID, + identity: identity, + trustedSignerKeyHash: signerHash, + } + if _, err := source.FinalizedHead(ctx); err != nil { + return nil, fmt.Errorf("independent retained-group Ethereum verifier has no usable finalized head: [%w]", err) + } + return source, nil +} + +func validateFrostRetainedGroupSourceConfig( + config FrostRetainedGroupHistorySourceConfig, + primaryEthereumURL string, +) (*url.URL, string, FrostRetainedGroupHistoryIdentity, [32]byte, time.Duration, error) { + exportEndpoint, _, err := validateFrostRetainedGroupEndpoint(config.ExportURL, true) + if err != nil { + return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, + fmt.Errorf("invalid retained-group export URL: [%w]", err) + } + ethereumEndpoint, canonicalEthereum, err := + validateFrostRetainedGroupEndpoint(config.EthereumURL, false) + if err != nil { + return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, + fmt.Errorf("invalid retained-group Ethereum URL: [%w]", err) + } + primaryEndpoint, _, err := validateFrostRetainedGroupEndpoint(primaryEthereumURL, false) + if err != nil { + return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, + fmt.Errorf("invalid primary Ethereum URL: [%w]", err) + } + if frostRetainedGroupEndpointsShareAuthority(primaryEndpoint, ethereumEndpoint) || + frostRetainedGroupEndpointsShareAuthority(primaryEndpoint, exportEndpoint) { + return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, + fmt.Errorf("retained-group source is not independent of the primary endpoint") + } + if frostRetainedGroupEndpointsShareAuthority(exportEndpoint, ethereumEndpoint) { + return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, + fmt.Errorf("retained-group exporter and Ethereum verifier are not independent") + } + trustDomainID := strings.TrimSpace(config.TrustDomainID) + if trustDomainID == "" || len(trustDomainID) > 128 || strings.ContainsAny(trustDomainID, "\x00\r\n\t") { + return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, + fmt.Errorf("retained-group trust domain ID is invalid") + } + signerHash, err := parseFrostActivationHex32(strings.TrimSpace(config.TrustedSignerKeyHash)) + if err != nil || signerHash == [32]byte{} { + return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, + fmt.Errorf("retained-group trusted signer key hash is invalid") + } + endpointFingerprint, err := FrostRetainedGroupHistoryEndpointFingerprint( + config.ExportURL, + config.EthereumURL, + ) + if err != nil { + return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, err + } + timeout := config.RequestTimeout + if timeout == 0 { + timeout = frostRetainedGroupDefaultTimeout + } + if timeout < time.Second || timeout > time.Minute { + return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, + fmt.Errorf("retained-group request timeout is outside supported bounds") + } + return exportEndpoint, canonicalEthereum, FrostRetainedGroupHistoryIdentity{ + TrustDomainID: trustDomainID, + EndpointFingerprint: endpointFingerprint, + OperatorFingerprint: signerHash, + }, signerHash, timeout, nil +} + +func frostRetainedGroupEndpointsShareAuthority(left *url.URL, right *url.URL) bool { + if left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) { + return false + } + leftIP := net.ParseIP(left.Hostname()) + rightIP := net.ParseIP(right.Hostname()) + if leftIP != nil && rightIP != nil && leftIP.IsLoopback() && rightIP.IsLoopback() { + return strings.EqualFold(left.Host, right.Host) + } + return true +} + +func validateFrostRetainedGroupEndpoint( + raw string, + export bool, +) (*url.URL, string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || trimmed != raw { + return nil, "", fmt.Errorf("URL is empty or has surrounding whitespace") + } + endpoint, err := url.Parse(trimmed) + if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.Fragment != "" { + return nil, "", fmt.Errorf("URL is not an absolute credential-free endpoint") + } + if export && endpoint.RawQuery != "" { + return nil, "", fmt.Errorf("export URL must not contain a query") + } + scheme := strings.ToLower(endpoint.Scheme) + secure := scheme == "https" || (!export && scheme == "wss") + insecure := scheme == "http" || (!export && scheme == "ws") + if !secure && !insecure { + return nil, "", fmt.Errorf("URL scheme is unsupported") + } + if insecure { + ip := net.ParseIP(endpoint.Hostname()) + if ip == nil || !ip.IsLoopback() { + return nil, "", fmt.Errorf("non-TLS endpoint must use a numeric loopback address") + } + } + endpoint.Scheme = scheme + endpoint.Host = strings.ToLower(endpoint.Host) + if endpoint.Path == "" { + endpoint.Path = "/" + } + return endpoint, endpoint.String(), nil +} + +func (source *signedFrostRetainedGroupHistorySource) Close() { + if source != nil && source.verifier != nil { + source.verifier.Close() + } +} + +func (source *signedFrostRetainedGroupHistorySource) Identity( + ctx context.Context, +) (FrostRetainedGroupHistoryIdentity, error) { + if ctx == nil { + return FrostRetainedGroupHistoryIdentity{}, fmt.Errorf("retained-group identity context is nil") + } + select { + case <-ctx.Done(): + return FrostRetainedGroupHistoryIdentity{}, ctx.Err() + default: + return source.identity, nil + } +} + +func (source *signedFrostRetainedGroupHistorySource) FinalizedHead( + ctx context.Context, +) (FrostPreSignFinality, error) { + if ctx == nil { + return FrostPreSignFinality{}, fmt.Errorf("retained-group finalized-head context is nil") + } + header, err := source.verifier.HeaderByNumber( + ctx, + big.NewInt(int64(rpc.FinalizedBlockNumber)), + ) + if err != nil { + return FrostPreSignFinality{}, err + } + if header == nil || header.Number == nil || !header.Number.IsUint64() || + header.Number.Sign() <= 0 || header.Hash() == (common.Hash{}) { + return FrostPreSignFinality{}, fmt.Errorf("retained-group finalized header is invalid") + } + return FrostPreSignFinality{ + BlockNumber: header.Number.Uint64(), + BlockHash: header.Hash(), + }, nil +} + +func (source *signedFrostRetainedGroupHistorySource) VerifyPoint( + ctx context.Context, + point FrostPreSignFinality, +) error { + head, err := source.FinalizedHead(ctx) + if err != nil { + return err + } + return source.verifyPointAtFinalizedHead(ctx, point, head) +} + +func (source *signedFrostRetainedGroupHistorySource) verifyPointAtFinalizedHead( + ctx context.Context, + point FrostPreSignFinality, + head FrostPreSignFinality, +) error { + if point.BlockNumber == 0 || point.BlockHash == [32]byte{} || + point.BlockNumber > head.BlockNumber { + return fmt.Errorf("retained-group point is invalid or not finalized") + } + header, err := source.verifier.HeaderByNumber( + ctx, + new(big.Int).SetUint64(point.BlockNumber), + ) + if err != nil { + return err + } + if header == nil || header.Number == nil || !header.Number.IsUint64() || + header.Number.Uint64() != point.BlockNumber || header.Hash() != common.Hash(point.BlockHash) { + return fmt.Errorf("retained-group point does not match the independent canonical chain") + } + if point.BlockNumber == head.BlockNumber && point.BlockHash != head.BlockHash { + return fmt.Errorf("retained-group point conflicts with the finalized head") + } + return nil +} + +func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( + ctx context.Context, + from FrostPreSignFinality, + to FrostPreSignFinality, +) (*FrostRetainedGroupHistory, error) { + if ctx == nil { + return nil, fmt.Errorf("retained-group history context is nil") + } + if from.BlockNumber == 0 || from.BlockHash == [32]byte{} || + to.BlockNumber < from.BlockNumber || to.BlockHash == [32]byte{} { + return nil, fmt.Errorf("retained-group history bounds are invalid") + } + evidence, err := source.activationEvidence() + if err != nil { + return nil, err + } + headBefore, err := source.FinalizedHead(ctx) + if err != nil { + return nil, err + } + if err := source.verifyPointAtFinalizedHead(ctx, from, headBefore); err != nil { + return nil, fmt.Errorf("retained-group checkpoint is not canonical: [%w]", err) + } + if err := source.verifyPointAtFinalizedHead(ctx, to, headBefore); err != nil { + return nil, fmt.Errorf("retained-group target is not canonical: [%w]", err) + } + + query := frostRetainedGroupHistoryQuery{ + Schema: frostRetainedGroupHistoryRequestSchema, + From: frostRetainedGroupFinalityToWire(from), + To: frostRetainedGroupFinalityToWire(to), + } + queryHash, err := frostRetainedGroupDomainHash( + frostRetainedGroupHistoryQueryDomain, + query, + ) + if err != nil { + return nil, err + } + + mutations := make([]FrostRetainedGroupMutation, 0) + wireMutations := make([]frostRetainedGroupWireMutation, 0) + seenCursors := make(map[string]bool) + blockHashes := map[uint64][32]byte{ + from.BlockNumber: from.BlockHash, + to.BlockNumber: to.BlockHash, + } + cursor := "" + var snapshotID [32]byte + var descriptorSetHash [32]byte + var previousPageHash [32]byte + for pageIndex := uint64(0); pageIndex < frostRetainedGroupMaximumPages; pageIndex++ { + if seenCursors[cursor] { + return nil, fmt.Errorf("retained-group history cursor repeated") + } + seenCursors[cursor] = true + request := frostRetainedGroupHistoryPageRequest{Query: query, Cursor: cursor} + payload := &frostRetainedGroupHistoryPagePayload{} + if err := source.postSigned(ctx, "history", request, payload); err != nil { + return nil, fmt.Errorf("cannot read retained-group history page [%d]: [%w]", pageIndex, err) + } + pageHash, err := source.validateHistoryPage( + payload, + queryHash, + from, + to, + cursor, + pageIndex, + previousPageHash, + snapshotID, + descriptorSetHash, + ) + if err != nil { + return nil, err + } + parsedSnapshotID, _ := parseFrostActivationHex32(payload.SnapshotID) + parsedDescriptorSetHash, _ := parseFrostActivationHex32(payload.DescriptorSetHash) + if pageIndex == 0 { + snapshotID = parsedSnapshotID + descriptorSetHash = parsedDescriptorSetHash + } + for _, wireMutation := range payload.Mutations { + mutation, err := frostRetainedGroupMutationFromWire(wireMutation) + if err != nil { + return nil, fmt.Errorf("retained-group history contains malformed mutation: [%w]", err) + } + if err := addFrostRetainedGroupMutationBlockHashes(blockHashes, mutation); err != nil { + return nil, err + } + mutations = append(mutations, mutation) + wireMutations = append(wireMutations, wireMutation) + if len(mutations) > frostRetainedGroupMaximumMutations { + return nil, fmt.Errorf("retained-group history exceeds the mutation limit") + } + } + if payload.Complete { + if payload.Receipt == nil || payload.NextCursor != "" || + payload.Receipt.PageCount != pageIndex+1 || + payload.Receipt.MutationCount != uint64(len(mutations)) { + return nil, fmt.Errorf("retained-group final history receipt is inconsistent") + } + receiptRoot, err := parseFrostActivationHex32(payload.Receipt.HistoryRoot) + if err != nil { + return nil, fmt.Errorf("retained-group history receipt root is invalid") + } + computedRoot, err := frostRetainedGroupHistoryRoot(queryHash, wireMutations) + if err != nil || receiptRoot != computedRoot { + return nil, fmt.Errorf("retained-group history receipt does not cover the exact mutation sequence") + } + history := &FrostRetainedGroupHistory{ + From: from, + To: to, + Mutations: mutations, + Complete: true, + EmptyAtFrom: true, + DescriptorSetHash: descriptorSetHash, + } + if err := validateCompleteFrostRetainedGroupHistory(history); err != nil { + return nil, err + } + for blockNumber, blockHash := range blockHashes { + if err := source.verifyPointAtFinalizedHead(ctx, FrostPreSignFinality{ + BlockNumber: blockNumber, + BlockHash: blockHash, + }, headBefore); err != nil { + return nil, fmt.Errorf("retained-group history references a noncanonical block: [%w]", err) + } + } + if err := source.verifyHistoryEvidence(ctx, mutations, evidence); err != nil { + return nil, fmt.Errorf("retained-group history has unauthenticated semantic evidence: [%w]", err) + } + headAfter, err := source.FinalizedHead(ctx) + if err != nil { + return nil, err + } + if headAfter.BlockNumber < headBefore.BlockNumber || + (headAfter.BlockNumber == headBefore.BlockNumber && headAfter.BlockHash != headBefore.BlockHash) { + return nil, fmt.Errorf("retained-group finalized head changed inconsistently during export") + } + if err := source.verifyPointAtFinalizedHead(ctx, to, headAfter); err != nil { + return nil, fmt.Errorf("retained-group target changed during export: [%w]", err) + } + return history, nil + } + if payload.Receipt != nil || len(payload.Mutations) == 0 || + !validFrostRetainedGroupCursor(payload.NextCursor) || payload.NextCursor == cursor { + return nil, fmt.Errorf("retained-group nonfinal history page is malformed") + } + cursor = payload.NextCursor + previousPageHash = pageHash + } + return nil, fmt.Errorf("retained-group history exceeded the page limit without a final receipt") +} + +func (source *signedFrostRetainedGroupHistorySource) validateHistoryPage( + payload *frostRetainedGroupHistoryPagePayload, + queryHash [32]byte, + from FrostPreSignFinality, + to FrostPreSignFinality, + cursor string, + pageIndex uint64, + previousPageHash [32]byte, + snapshotID [32]byte, + descriptorSetHash [32]byte, +) ([32]byte, error) { + if payload == nil || payload.Schema != frostRetainedGroupHistoryPageSchema || + payload.ChainID != source.chainID || payload.PageIndex != pageIndex || + payload.Cursor != cursor || !payload.EmptyAtFrom || + !source.validWireIdentity(payload.Identity) { + return [32]byte{}, fmt.Errorf("retained-group history page has the wrong identity or position") + } + declaredQueryHash, err := parseFrostActivationHex32(payload.QueryHash) + if err != nil || declaredQueryHash != queryHash { + return [32]byte{}, fmt.Errorf("retained-group history page is bound to a different query") + } + pageSnapshotID, err := parseFrostActivationHex32(payload.SnapshotID) + if err != nil || pageSnapshotID == [32]byte{} || (pageIndex > 0 && pageSnapshotID != snapshotID) { + return [32]byte{}, fmt.Errorf("retained-group history snapshot changed between pages") + } + pageDescriptorSetHash, err := parseFrostActivationHex32(payload.DescriptorSetHash) + evidence, evidenceErr := source.activationEvidence() + if err != nil || evidenceErr != nil || pageDescriptorSetHash != evidence.descriptorSetHash || + (pageIndex > 0 && pageDescriptorSetHash != descriptorSetHash) { + return [32]byte{}, fmt.Errorf("retained-group descriptor set differs from the signed activation manifest") + } + pageFrom, err := frostRetainedGroupFinalityFromWire(payload.From) + if err != nil || pageFrom != from { + return [32]byte{}, fmt.Errorf("retained-group history page checkpoint mismatch") + } + pageTo, err := frostRetainedGroupFinalityFromWire(payload.To) + if err != nil || pageTo != to { + return [32]byte{}, fmt.Errorf("retained-group history page target mismatch") + } + declaredPreviousPageHash, err := parseFrostActivationHex32(payload.PreviousPageHash) + if err != nil || declaredPreviousPageHash != previousPageHash { + return [32]byte{}, fmt.Errorf("retained-group history page hash chain is broken") + } + if len(payload.Mutations) > frostRetainedGroupMaximumMutations { + return [32]byte{}, fmt.Errorf("retained-group history page exceeds the mutation limit") + } + canonical, err := canonicalFrostActivationValue(payload) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(canonical), nil +} + +func (source *signedFrostRetainedGroupHistorySource) ResolveOperatorID( + ctx context.Context, + operator chain.Address, + at FrostPreSignFinality, +) (chain.OperatorID, error) { + if ctx == nil { + return 0, fmt.Errorf("retained-group operator-resolution context is nil") + } + evidence, err := source.activationEvidence() + if err != nil { + return 0, err + } + canonicalAddress, err := canonicalFrostRetainedGroupOperatorAddress(operator) + if err != nil { + return 0, err + } + headBefore, err := source.FinalizedHead(ctx) + if err != nil { + return 0, err + } + if err := source.verifyPointAtFinalizedHead(ctx, at, headBefore); err != nil { + return 0, err + } + query := frostRetainedGroupOperatorQuery{ + Schema: frostRetainedGroupOperatorRequestSchema, + OperatorAddress: canonicalAddress, + At: frostRetainedGroupFinalityToWire(at), + } + queryHash, err := frostRetainedGroupDomainHash(frostRetainedGroupOperatorQueryDomain, query) + if err != nil { + return 0, err + } + payload := &frostRetainedGroupOperatorReceiptPayload{} + if err := source.postSigned(ctx, "operator-id", query, payload); err != nil { + return 0, fmt.Errorf("cannot resolve retained-group operator ID: [%w]", err) + } + declaredQueryHash, err := parseFrostActivationHex32(payload.QueryHash) + receiptAt, pointErr := frostRetainedGroupFinalityFromWire(payload.At) + if payload.Schema != frostRetainedGroupOperatorReceiptSchema || + payload.ChainID != source.chainID || !source.validWireIdentity(payload.Identity) || + err != nil || declaredQueryHash != queryHash || pointErr != nil || receiptAt != at || + payload.OperatorAddress != canonicalAddress || !payload.Found || payload.OperatorID == 0 { + return 0, fmt.Errorf("retained-group operator receipt is incomplete or differently bound") + } + onChainOperatorID, err := source.resolveOperatorIDAt( + ctx, + common.HexToAddress(canonicalAddress), + at, + evidence, + ) + if err != nil { + return 0, fmt.Errorf("cannot independently authenticate retained-group operator ID: [%w]", err) + } + if onChainOperatorID != payload.OperatorID { + return 0, fmt.Errorf("retained-group operator receipt disagrees with exact finalized sortition-pool state") + } + headAfter, err := source.FinalizedHead(ctx) + if err != nil { + return 0, err + } + if headAfter.BlockNumber < headBefore.BlockNumber || + (headAfter.BlockNumber == headBefore.BlockNumber && headAfter.BlockHash != headBefore.BlockHash) { + return 0, fmt.Errorf("retained-group finalized head changed during operator resolution") + } + if err := source.verifyPointAtFinalizedHead(ctx, at, headAfter); err != nil { + return 0, fmt.Errorf("retained-group operator-resolution point changed: [%w]", err) + } + return chain.OperatorID(payload.OperatorID), nil +} + +func (source *signedFrostRetainedGroupHistorySource) postSigned( + ctx context.Context, + operation string, + requestPayload interface{}, + responsePayload interface{}, +) error { + requestBody, err := json.Marshal(requestPayload) + if err != nil { + return err + } + endpoint := *source.exportEndpoint + endpoint.Path = path.Join(endpoint.Path, operation) + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + endpoint.String(), + bytes.NewReader(requestBody), + ) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + response, err := source.httpClient.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("retained-group export returned HTTP status [%d]", response.StatusCode) + } + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + return fmt.Errorf("retained-group export response is not application/json") + } + data, err := io.ReadAll(io.LimitReader(response.Body, frostRetainedGroupMaximumResponseBytes+1)) + if err != nil { + return err + } + if len(data) == 0 || len(data) > frostRetainedGroupMaximumResponseBytes { + return fmt.Errorf("retained-group export response size is invalid") + } + envelope := &frostRetainedGroupSignedEnvelope{} + if err := decodeStrictFrostActivationJSON(data, envelope); err != nil { + return fmt.Errorf("cannot decode retained-group signed envelope: [%w]", err) + } + return source.verifySignedEnvelope(envelope, responsePayload) +} + +func (source *signedFrostRetainedGroupHistorySource) verifySignedEnvelope( + envelope *frostRetainedGroupSignedEnvelope, + target interface{}, +) error { + if envelope == nil || envelope.Schema != "tbtc-frost-retained-group-signed-envelope/v1" || + envelope.SignatureAlgorithm != "ed25519" || len(envelope.Payload) == 0 { + return fmt.Errorf("retained-group signed envelope is malformed") + } + canonical, err := canonicalFrostActivationValue(envelope.Payload) + if err != nil { + return err + } + payloadHash := sha256.Sum256(canonical) + declaredHash, err := parseFrostActivationHex32(envelope.PayloadSHA256) + if err != nil || declaredHash != payloadHash { + return fmt.Errorf("retained-group signed envelope payload hash mismatch") + } + publicKeyDER, err := base64.StdEncoding.Strict().DecodeString(envelope.SignerPublicKeySPKI) + if err != nil || len(publicKeyDER) == 0 || len(publicKeyDER) > 1024 || + sha256.Sum256(publicKeyDER) != source.trustedSignerKeyHash { + return fmt.Errorf("retained-group export signer is not trusted") + } + parsedKey, err := x509.ParsePKIXPublicKey(publicKeyDER) + if err != nil { + return fmt.Errorf("cannot parse retained-group export signer: [%w]", err) + } + publicKey, ok := parsedKey.(ed25519.PublicKey) + if !ok { + return fmt.Errorf("retained-group export signer is not Ed25519") + } + signature, err := base64.StdEncoding.Strict().DecodeString(envelope.Signature) + signed := append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...) + if err != nil || len(signature) != ed25519.SignatureSize || + !ed25519.Verify(publicKey, signed, signature) { + return fmt.Errorf("retained-group export signature is invalid") + } + if err := decodeStrictFrostActivationJSON(envelope.Payload, target); err != nil { + return fmt.Errorf("cannot decode retained-group signed payload: [%w]", err) + } + return nil +} + +func (source *signedFrostRetainedGroupHistorySource) validWireIdentity( + identity frostRetainedGroupWireIdentity, +) bool { + endpoint, endpointErr := parseFrostActivationHex32(identity.EndpointFingerprint) + operator, operatorErr := parseFrostActivationHex32(identity.OperatorFingerprint) + return identity.TrustDomainID == source.identity.TrustDomainID && + endpointErr == nil && endpoint == source.identity.EndpointFingerprint && + operatorErr == nil && operator == source.identity.OperatorFingerprint +} + +func frostRetainedGroupDomainHash(domain string, value interface{}) ([32]byte, error) { + canonical, err := canonicalFrostActivationValue(value) + if err != nil { + return [32]byte{}, err + } + hasher := sha256.New() + hasher.Write([]byte(domain)) + hasher.Write(canonical) + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result, nil +} + +func frostRetainedGroupHistoryRoot( + queryHash [32]byte, + mutations []frostRetainedGroupWireMutation, +) ([32]byte, error) { + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupHistoryRootDomain)) + hasher.Write(queryHash[:]) + count := make([]byte, 8) + for i := uint(0); i < 8; i++ { + count[7-i] = byte(uint64(len(mutations)) >> (i * 8)) + } + hasher.Write(count) + for _, mutation := range mutations { + canonical, err := canonicalFrostActivationValue(mutation) + if err != nil { + return [32]byte{}, err + } + itemHash := sha256.Sum256(canonical) + hasher.Write(itemHash[:]) + } + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result, nil +} + +func frostRetainedGroupFinalityToWire( + point FrostPreSignFinality, +) frostRetainedGroupWireFinality { + return frostRetainedGroupWireFinality{ + RelayTransactionHash: frostActivationHex32(point.RelayTransactionHash), + BlockNumber: point.BlockNumber, + BlockHash: frostActivationHex32(point.BlockHash), + TransactionIndex: point.TransactionIndex, + LogIndex: point.LogIndex, + AuthorizationSequence: frostActivationHex32(point.AuthorizationSequence), + } +} + +func frostRetainedGroupFinalityFromWire( + point frostRetainedGroupWireFinality, +) (FrostPreSignFinality, error) { + relayHash, err := parseFrostActivationHex32(point.RelayTransactionHash) + if err != nil { + return FrostPreSignFinality{}, err + } + blockHash, err := parseFrostActivationHex32(point.BlockHash) + if err != nil || point.BlockNumber == 0 || blockHash == [32]byte{} { + return FrostPreSignFinality{}, fmt.Errorf("retained-group finality block is invalid") + } + sequence, err := parseFrostActivationHex32(point.AuthorizationSequence) + if err != nil { + return FrostPreSignFinality{}, err + } + return FrostPreSignFinality{ + RelayTransactionHash: relayHash, + BlockNumber: point.BlockNumber, + BlockHash: blockHash, + TransactionIndex: point.TransactionIndex, + LogIndex: point.LogIndex, + AuthorizationSequence: sequence, + }, nil +} + +func frostRetainedGroupEventPointToWire( + point FrostRetainedGroupEventPoint, +) frostRetainedGroupWireEventPoint { + return frostRetainedGroupWireEventPoint{ + BlockNumber: point.BlockNumber, + BlockHash: frostActivationHex32(point.BlockHash), + TransactionHash: frostActivationHex32(point.TransactionHash), + TransactionIndex: point.TransactionIndex, + LogIndex: point.LogIndex, + } +} + +func frostRetainedGroupEventPointFromWire( + point frostRetainedGroupWireEventPoint, +) (FrostRetainedGroupEventPoint, error) { + blockHash, err := parseFrostActivationHex32(point.BlockHash) + if err != nil { + return FrostRetainedGroupEventPoint{}, err + } + transactionHash, err := parseFrostActivationHex32(point.TransactionHash) + if err != nil { + return FrostRetainedGroupEventPoint{}, err + } + result := FrostRetainedGroupEventPoint{ + BlockNumber: point.BlockNumber, + BlockHash: blockHash, + TransactionHash: transactionHash, + TransactionIndex: point.TransactionIndex, + LogIndex: point.LogIndex, + } + if result.BlockNumber == 0 { + if result != (FrostRetainedGroupEventPoint{}) { + return FrostRetainedGroupEventPoint{}, fmt.Errorf("zero retained-group event point is noncanonical") + } + return result, nil + } + if !result.valid() { + return FrostRetainedGroupEventPoint{}, fmt.Errorf("retained-group event point is invalid") + } + return result, nil +} + +func frostRetainedGroupMutationFromWire( + mutation frostRetainedGroupWireMutation, +) (FrostRetainedGroupMutation, error) { + point, err := frostRetainedGroupEventPointFromWire(mutation.Point) + if err != nil || !point.valid() { + return FrostRetainedGroupMutation{}, fmt.Errorf("mutation point is invalid") + } + walletID, err := parseFrostActivationHex32(mutation.WalletID) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + walletPublicKeyHash, err := parseFrostRetainedGroupHex20(mutation.WalletPublicKeyHash) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + retainedGroupHash, err := parseFrostActivationHex32(mutation.RetainedGroupHash) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + dkgResultHash, err := parseFrostActivationHex32(mutation.DkgResultHash) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + dkgSubmissionPoint, err := frostRetainedGroupEventPointFromWire(mutation.DkgSubmissionPoint) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + dkgApprovalPoint, err := frostRetainedGroupEventPointFromWire(mutation.DkgApprovalPoint) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + creationPoint, err := frostRetainedGroupEventPointFromWire(mutation.CreationPoint) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + registrationPoint, err := frostRetainedGroupEventPointFromWire(mutation.BridgeRegistrationPoint) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + quarantineID, err := parseFrostActivationHex32(mutation.QuarantineID) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + evidenceHash, err := parseFrostActivationHex32(mutation.EvidenceHash) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + authenticationHash, err := parseFrostActivationHex32(mutation.AuthenticationHash) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + if len(mutation.OperatorIDs) > 100 || len(mutation.Reason) > frostRetainedGroupMaximumReasonBytes { + return FrostRetainedGroupMutation{}, fmt.Errorf("retained-group mutation exceeds field bounds") + } + operatorIDs := append([]uint32{}, mutation.OperatorIDs...) + seenOperatorIDs := make(map[uint32]bool, len(operatorIDs)) + for _, operatorID := range operatorIDs { + if operatorID == 0 || seenOperatorIDs[operatorID] { + return FrostRetainedGroupMutation{}, fmt.Errorf("retained-group mutation has invalid or duplicate operator IDs") + } + seenOperatorIDs[operatorID] = true + } + return FrostRetainedGroupMutation{ + Point: point, + Kind: FrostRetainedGroupMutationKind(mutation.Kind), + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + OperatorIDs: operatorIDs, + RetainedGroupHash: retainedGroupHash, + DkgResultHash: dkgResultHash, + DkgSubmissionPoint: dkgSubmissionPoint, + DkgApprovalPoint: dkgApprovalPoint, + CreationPoint: creationPoint, + BridgeRegistrationPoint: registrationPoint, + QuarantineID: quarantineID, + EvidenceHash: evidenceHash, + AuthenticationHash: authenticationHash, + Reason: mutation.Reason, + }, nil +} + +func frostRetainedGroupMutationToWire( + mutation FrostRetainedGroupMutation, +) frostRetainedGroupWireMutation { + return frostRetainedGroupWireMutation{ + Point: frostRetainedGroupEventPointToWire(mutation.Point), + Kind: string(mutation.Kind), + WalletID: frostActivationHex32(mutation.WalletID), + WalletPublicKeyHash: frostActivationHex20(mutation.WalletPublicKeyHash), + OperatorIDs: append([]uint32{}, mutation.OperatorIDs...), + RetainedGroupHash: frostActivationHex32(mutation.RetainedGroupHash), + DkgResultHash: frostActivationHex32(mutation.DkgResultHash), + DkgSubmissionPoint: frostRetainedGroupEventPointToWire(mutation.DkgSubmissionPoint), + DkgApprovalPoint: frostRetainedGroupEventPointToWire(mutation.DkgApprovalPoint), + CreationPoint: frostRetainedGroupEventPointToWire(mutation.CreationPoint), + BridgeRegistrationPoint: frostRetainedGroupEventPointToWire(mutation.BridgeRegistrationPoint), + QuarantineID: frostActivationHex32(mutation.QuarantineID), + EvidenceHash: frostActivationHex32(mutation.EvidenceHash), + AuthenticationHash: frostActivationHex32(mutation.AuthenticationHash), + Reason: mutation.Reason, + } +} + +func parseFrostRetainedGroupHex20(value string) ([20]byte, error) { + if len(value) != 42 || !strings.HasPrefix(value, "0x") || value != strings.ToLower(value) { + return [20]byte{}, fmt.Errorf("value is not canonical bytes20") + } + decoded, err := hex.DecodeString(value[2:]) + if err != nil || len(decoded) != 20 { + return [20]byte{}, fmt.Errorf("value is not bytes20") + } + var result [20]byte + copy(result[:], decoded) + return result, nil +} + +func addFrostRetainedGroupMutationBlockHashes( + blocks map[uint64][32]byte, + mutation FrostRetainedGroupMutation, +) error { + points := []FrostRetainedGroupEventPoint{ + mutation.Point, + mutation.DkgSubmissionPoint, + mutation.DkgApprovalPoint, + mutation.CreationPoint, + mutation.BridgeRegistrationPoint, + } + for _, point := range points { + if point.BlockNumber == 0 { + continue + } + if existing, ok := blocks[point.BlockNumber]; ok && existing != point.BlockHash { + return fmt.Errorf("retained-group history contains conflicting hashes for block [%d]", point.BlockNumber) + } + blocks[point.BlockNumber] = point.BlockHash + } + return nil +} + +func canonicalFrostRetainedGroupOperatorAddress(address chain.Address) (string, error) { + raw := strings.TrimSpace(address.String()) + if !common.IsHexAddress(raw) { + return "", fmt.Errorf("retained-group operator address is invalid") + } + return strings.ToLower(common.HexToAddress(raw).Hex()), nil +} + +func validFrostRetainedGroupCursor(cursor string) bool { + if cursor == "" || len(cursor) > frostRetainedGroupMaximumCursorBytes { + return false + } + for _, character := range cursor { + if !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || + character == '-' || character == '_') { + return false + } + } + return true +} diff --git a/pkg/tbtc/frost_retained_group_history_source_test.go b/pkg/tbtc/frost_retained_group_history_source_test.go new file mode 100644 index 0000000000..a751500e9f --- /dev/null +++ b/pkg/tbtc/frost_retained_group_history_source_test.go @@ -0,0 +1,961 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/keep-network/keep-core/pkg/chain" + frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" +) + +type frostRetainedGroupHistoryTestVerifier struct { + mutex sync.Mutex + chainID *big.Int + finalized *types.Header + headers map[uint64]*types.Header + reads map[uint64]int + reorgBlock uint64 + reorgAfterRead int + reorgHeader *types.Header + receipts map[common.Hash]*types.Receipt + code map[common.Address][]byte + sortitionPool common.Address + operator common.Address + operatorAt uint64 + operatorID uint32 + closed bool +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) ChainID( + context.Context, +) (*big.Int, error) { + return new(big.Int).Set(verifier.chainID), nil +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) HeaderByNumber( + _ context.Context, + number *big.Int, +) (*types.Header, error) { + verifier.mutex.Lock() + defer verifier.mutex.Unlock() + if number.Sign() < 0 { + return verifier.finalized, nil + } + blockNumber := number.Uint64() + verifier.reads[blockNumber]++ + if blockNumber == verifier.reorgBlock && verifier.reorgHeader != nil && + verifier.reads[blockNumber] > verifier.reorgAfterRead { + return verifier.reorgHeader, nil + } + return verifier.headers[blockNumber], nil +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) Close() { + verifier.mutex.Lock() + defer verifier.mutex.Unlock() + verifier.closed = true +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) TransactionReceipt( + _ context.Context, + hash common.Hash, +) (*types.Receipt, error) { + receipt := verifier.receipts[hash] + if receipt == nil { + return nil, fmt.Errorf("missing receipt") + } + return receipt, nil +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) CodeAt( + _ context.Context, + address common.Address, + _ *big.Int, +) ([]byte, error) { + code := verifier.code[address] + if len(code) == 0 { + return nil, fmt.Errorf("missing code") + } + return append([]byte{}, code...), nil +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) CallContract( + _ context.Context, + call ethereum.CallMsg, + block *big.Int, +) ([]byte, error) { + if call.To == nil || *call.To != verifier.sortitionPool || block == nil || + !block.IsUint64() || block.Uint64() != verifier.operatorAt || len(call.Data) != 36 || + !bytes.Equal(call.Data[:4], []byte{0x5a, 0x48, 0xb4, 0x6b}) || + !bytes.Equal(call.Data[4:16], make([]byte, 12)) || + !bytes.Equal(call.Data[16:], verifier.operator[:]) { + return nil, fmt.Errorf("unexpected operator call") + } + result := make([]byte, 32) + binary.BigEndian.PutUint32(result[28:], verifier.operatorID) + return result, nil +} + +type frostRetainedGroupHistoryTestExport struct { + t *testing.T + privateKey ed25519.PrivateKey + publicKeyDER []byte + historyResponder func(frostRetainedGroupHistoryPageRequest) interface{} + operatorResponder func(frostRetainedGroupOperatorQuery) interface{} + corruptSignature bool +} + +func (export *frostRetainedGroupHistoryTestExport) ServeHTTP( + responseWriter http.ResponseWriter, + request *http.Request, +) { + if request.Method != http.MethodPost { + http.Error(responseWriter, "method", http.StatusMethodNotAllowed) + return + } + var payload interface{} + switch request.URL.Path { + case "/history": + historyRequest := frostRetainedGroupHistoryPageRequest{} + if err := json.NewDecoder(request.Body).Decode(&historyRequest); err != nil { + http.Error(responseWriter, "request", http.StatusBadRequest) + return + } + payload = export.historyResponder(historyRequest) + case "/operator-id": + operatorRequest := frostRetainedGroupOperatorQuery{} + if err := json.NewDecoder(request.Body).Decode(&operatorRequest); err != nil { + http.Error(responseWriter, "request", http.StatusBadRequest) + return + } + payload = export.operatorResponder(operatorRequest) + default: + http.NotFound(responseWriter, request) + return + } + if payload == nil { + http.Error(responseWriter, "missing", http.StatusServiceUnavailable) + return + } + canonical, err := canonicalFrostActivationValue(payload) + if err != nil { + export.t.Fatal(err) + } + payloadHash := sha256.Sum256(canonical) + signed := append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...) + signature := ed25519.Sign(export.privateKey, signed) + if export.corruptSignature { + signature[0] ^= 0xff + } + envelope := frostRetainedGroupSignedEnvelope{ + Schema: "tbtc-frost-retained-group-signed-envelope/v1", + Payload: json.RawMessage(canonical), + PayloadSHA256: frostActivationHex32(payloadHash), + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(export.publicKeyDER), + SignatureAlgorithm: "ed25519", + Signature: base64.StdEncoding.EncodeToString(signature), + } + responseWriter.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(responseWriter).Encode(envelope); err != nil { + export.t.Fatal(err) + } +} + +type frostRetainedGroupHistorySourceFixture struct { + t *testing.T + verifier *frostRetainedGroupHistoryTestVerifier + export *frostRetainedGroupHistoryTestExport + server *httptest.Server + source *signedFrostRetainedGroupHistorySource + identity FrostRetainedGroupHistoryIdentity + profile FrostPreSignActivationProfile + from FrostPreSignFinality + to FrostPreSignFinality + descriptorSetHash [32]byte + snapshotID [32]byte + mutations []FrostRetainedGroupMutation + pageMutator func(*frostRetainedGroupHistoryPagePayload) + operatorMutator func(*frostRetainedGroupOperatorReceiptPayload) +} + +func newFrostRetainedGroupHistorySourceFixture( + t *testing.T, +) *frostRetainedGroupHistorySourceFixture { + t.Helper() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + operatorFingerprint := sha256.Sum256(publicKeyDER) + headers := make(map[uint64]*types.Header) + for blockNumber := uint64(1); blockNumber <= 10; blockNumber++ { + headers[blockNumber] = &types.Header{ + Number: new(big.Int).SetUint64(blockNumber), + Time: blockNumber, + Extra: []byte{byte(blockNumber), 0x9a}, + } + } + bridgeCode := []byte{0x60, 0x01, 0x60, 0x02} + registryCode := []byte{0x60, 0x03, 0x60, 0x04} + sortitionPoolCode := []byte{0x60, 0x05, 0x60, 0x06} + profile := frostRetainedGroupHistoryTestProfile( + bridgeCode, + registryCode, + sortitionPoolCode, + ) + operatorAddress := common.HexToAddress("0x1111111111111111111111111111111111111111") + verifier := &frostRetainedGroupHistoryTestVerifier{ + chainID: big.NewInt(1), + finalized: headers[10], + headers: headers, + reads: make(map[uint64]int), + receipts: make(map[common.Hash]*types.Receipt), + code: make(map[common.Address][]byte), + sortitionPool: common.Address(profile.SortitionPool), + operator: operatorAddress, + operatorAt: 6, + operatorID: 17, + } + verifier.code[common.Address(profile.BridgeAddress)] = bridgeCode + verifier.code[common.Address(profile.FrostRegistry)] = registryCode + verifier.code[common.Address(profile.SortitionPool)] = sortitionPoolCode + export := &frostRetainedGroupHistoryTestExport{ + t: t, + privateKey: privateKey, + publicKeyDER: publicKeyDER, + } + server := httptest.NewServer(export) + t.Cleanup(server.Close) + endpoint, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + identity := FrostRetainedGroupHistoryIdentity{ + TrustDomainID: "independent-retained-history-test", + EndpointFingerprint: [32]byte{0x31}, + OperatorFingerprint: operatorFingerprint, + } + source, err := newSignedFrostRetainedGroupHistorySource( + context.Background(), + endpoint, + verifier, + server.Client(), + 1, + identity, + operatorFingerprint, + ) + if err != nil { + t.Fatal(err) + } + descriptorSetHash := [32]byte{0x42} + if err := source.BindFrostRetainedGroupActivationEvidence( + profile, + profile.ActivationManifestHash, + descriptorSetHash, + ); err != nil { + t.Fatal(err) + } + t.Cleanup(source.Close) + fixture := &frostRetainedGroupHistorySourceFixture{ + t: t, + verifier: verifier, + export: export, + server: server, + source: source, + identity: identity, + profile: profile, + from: FrostPreSignFinality{BlockNumber: 1, BlockHash: headers[1].Hash()}, + to: FrostPreSignFinality{BlockNumber: 6, BlockHash: headers[6].Hash()}, + descriptorSetHash: descriptorSetHash, + snapshotID: [32]byte{0x53}, + } + fixture.mutations = frostRetainedGroupHistoryTestMutations(t, headers, source.evidence) + fixture.installReceipts() + export.historyResponder = fixture.historyResponse + export.operatorResponder = fixture.operatorResponse + return fixture +} + +func frostRetainedGroupHistoryTestProfile( + bridgeCode []byte, + registryCode []byte, + sortitionPoolCode []byte, +) FrostPreSignActivationProfile { + profile := FrostPreSignActivationProfile{ + DomainChainID: [32]byte{0x01}, + ActivationManifestHash: [32]byte{0x02}, + ImplementationSetHash: [32]byte{0x03}, + BridgeAddress: [20]byte{0x11}, + RegistryAddress: [20]byte{0x12}, + CompleteRouter: [20]byte{0x13}, + FrostRegistry: [20]byte{0x14}, + ProposalValidator: [20]byte{0x15}, + SortitionPool: [20]byte{0x16}, + BridgeCodeHash: [32]byte(crypto.Keccak256Hash(bridgeCode)), + RegistryCodeHash: [32]byte{0x22}, + CompleteRouterCodeHash: [32]byte{0x23}, + FrostRegistryCodeHash: [32]byte(crypto.Keccak256Hash(registryCode)), + ProposalValidatorCodeHash: [32]byte{0x25}, + SortitionPoolCodeHash: [32]byte(crypto.Keccak256Hash(sortitionPoolCode)), + ReservationProtocolID: frostPreSignReservationProtocolID(), + EvidenceProtocolID: frostCompleteEvidenceProtocolID(), + SigningPolicyHash: frostPreSignSigningPolicyHash(), + } + profile.ProfileHash = profile.ComputeHash() + return profile +} + +func frostRetainedGroupHistoryTestMutations( + t *testing.T, + headers map[uint64]*types.Header, + evidence *frostRetainedGroupEvidenceProfile, +) []FrostRetainedGroupMutation { + t.Helper() + walletID := [32]byte{0x71} + walletPublicKeyHash := [20]byte{0x72} + operatorIDs := make([]uint32, 51) + for index := range operatorIDs { + operatorIDs[index] = uint32(index + 1) + } + dkgSubmission := FrostRetainedGroupEventPoint{ + BlockNumber: 2, + BlockHash: headers[2].Hash(), + TransactionHash: [32]byte{0xa2}, + TransactionIndex: 0, + LogIndex: 2, + } + admissionTransaction := [32]byte{0xa3} + dkgApproval := FrostRetainedGroupEventPoint{ + BlockNumber: 3, + BlockHash: headers[3].Hash(), + TransactionHash: admissionTransaction, + TransactionIndex: 3, + LogIndex: 10, + } + creation := FrostRetainedGroupEventPoint{ + BlockNumber: 3, + BlockHash: headers[3].Hash(), + TransactionHash: admissionTransaction, + TransactionIndex: 3, + LogIndex: 11, + } + registration := creation + registration.LogIndex = 12 + closing := FrostRetainedGroupEventPoint{ + BlockNumber: 4, + BlockHash: headers[4].Hash(), + TransactionHash: [32]byte{0xa4}, + TransactionIndex: 1, + LogIndex: 20, + } + closureTransaction := [32]byte{0xa5} + closed := FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: headers[5].Hash(), + TransactionHash: closureTransaction, + TransactionIndex: 2, + LogIndex: 30, + } + registryClosure := closed + registryClosure.LogIndex = 31 + dkgResult, _, dkgResultHash := frostRetainedGroupHistoryTestDkgResult( + t, + evidence, + walletID, + operatorIDs, + ) + return []FrostRetainedGroupMutation{ + { + Point: registration, + Kind: FrostRetainedGroupAdmissionMutation, + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + OperatorIDs: operatorIDs, + RetainedGroupHash: dkgResult.MembersHash, + DkgResultHash: dkgResultHash, + DkgSubmissionPoint: dkgSubmission, + DkgApprovalPoint: dkgApproval, + CreationPoint: creation, + BridgeRegistrationPoint: registration, + }, + { + Point: closing, + Kind: FrostRetainedGroupClosingMutation, + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + }, + { + Point: closed, + Kind: FrostRetainedGroupClosedMutation, + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + }, + { + Point: registryClosure, + Kind: FrostRetainedGroupRegistryClosureMutation, + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + }, + } +} + +func frostRetainedGroupHistoryTestDkgResult( + t *testing.T, + evidence *frostRetainedGroupEvidenceProfile, + walletID [32]byte, + operatorIDs []uint32, +) (frostabi.FrostDkgResult, []byte, [32]byte) { + t.Helper() + encodedMembers, err := evidence.membersArguments.Pack(operatorIDs) + if err != nil { + t.Fatal(err) + } + result := frostabi.FrostDkgResult{ + SubmitterMemberIndex: big.NewInt(1), + XOnlyOutputKey: walletID, + MisbehavedMembersIndices: []uint8{}, + Signatures: []byte{0x01, 0x02}, + SigningMembersIndices: []*big.Int{big.NewInt(1)}, + Members: append([]uint32{}, operatorIDs...), + MembersHash: [32]byte(crypto.Keccak256Hash(encodedMembers)), + } + data, err := evidence.registryABI.Events["DkgResultSubmitted"].Inputs.NonIndexed().Pack(result) + if err != nil { + t.Fatal(err) + } + return result, data, [32]byte(crypto.Keccak256Hash(data)) +} + +func (fixture *frostRetainedGroupHistorySourceFixture) installReceipts() { + fixture.t.Helper() + evidence, err := fixture.source.activationEvidence() + if err != nil { + fixture.t.Fatal(err) + } + admission := fixture.mutations[0] + _, dkgData, _ := frostRetainedGroupHistoryTestDkgResult( + fixture.t, + evidence, + admission.WalletID, + admission.OperatorIDs, + ) + submissionLog := frostRetainedGroupHistoryTestLog( + admission.DkgSubmissionPoint, + evidence.registry.address, + []common.Hash{ + evidence.registryABI.Events["DkgResultSubmitted"].ID, + common.Hash(admission.DkgResultHash), + common.BigToHash(big.NewInt(123)), + }, + dkgData, + ) + fixture.verifier.receipts[common.Hash(admission.DkgSubmissionPoint.TransactionHash)] = + frostRetainedGroupHistoryTestReceipt(admission.DkgSubmissionPoint, submissionLog) + + approvalLog := frostRetainedGroupHistoryTestLog( + admission.DkgApprovalPoint, + evidence.registry.address, + []common.Hash{ + evidence.registryABI.Events["DkgResultApproved"].ID, + common.Hash(admission.DkgResultHash), + common.BytesToHash(common.HexToAddress("0x2222222222222222222222222222222222222222").Bytes()), + }, + nil, + ) + creationLog := frostRetainedGroupHistoryTestLog( + admission.CreationPoint, + evidence.registry.address, + []common.Hash{ + evidence.registryABI.Events["WalletCreated"].ID, + common.Hash(admission.WalletID), + common.Hash(admission.DkgResultHash), + }, + nil, + ) + registrationLog := frostRetainedGroupHistoryTestLog( + admission.BridgeRegistrationPoint, + evidence.bridge.address, + []common.Hash{ + evidence.bridgeABI.Events["NewWalletRegisteredV2"].ID, + common.Hash(admission.WalletID), + {}, + frostRetainedGroupBytes20Topic(admission.WalletPublicKeyHash), + }, + nil, + ) + fixture.verifier.receipts[common.Hash(admission.DkgApprovalPoint.TransactionHash)] = + frostRetainedGroupHistoryTestReceipt( + admission.DkgApprovalPoint, + approvalLog, + creationLog, + registrationLog, + ) + + closing := fixture.mutations[1] + closingLog := frostRetainedGroupHistoryTestLog( + closing.Point, + evidence.bridge.address, + []common.Hash{ + evidence.bridgeABI.Events["WalletClosing"].ID, + {}, + frostRetainedGroupBytes20Topic(closing.WalletPublicKeyHash), + }, + nil, + ) + fixture.verifier.receipts[common.Hash(closing.Point.TransactionHash)] = + frostRetainedGroupHistoryTestReceipt(closing.Point, closingLog) + + closed := fixture.mutations[2] + registryClosure := fixture.mutations[3] + closedLog := frostRetainedGroupHistoryTestLog( + closed.Point, + evidence.bridge.address, + []common.Hash{ + evidence.bridgeABI.Events["WalletClosed"].ID, + {}, + frostRetainedGroupBytes20Topic(closed.WalletPublicKeyHash), + }, + nil, + ) + registryClosureLog := frostRetainedGroupHistoryTestLog( + registryClosure.Point, + evidence.registry.address, + []common.Hash{ + evidence.registryABI.Events["WalletClosed"].ID, + common.Hash(registryClosure.WalletID), + }, + nil, + ) + fixture.verifier.receipts[common.Hash(closed.Point.TransactionHash)] = + frostRetainedGroupHistoryTestReceipt(closed.Point, closedLog, registryClosureLog) +} + +func frostRetainedGroupHistoryTestLog( + point FrostRetainedGroupEventPoint, + address common.Address, + topics []common.Hash, + data []byte, +) *types.Log { + return &types.Log{ + Address: address, + Topics: append([]common.Hash{}, topics...), + Data: append([]byte{}, data...), + BlockNumber: point.BlockNumber, + TxHash: common.Hash(point.TransactionHash), + TxIndex: uint(point.TransactionIndex), + BlockHash: common.Hash(point.BlockHash), + Index: uint(point.LogIndex), + } +} + +func frostRetainedGroupHistoryTestReceipt( + point FrostRetainedGroupEventPoint, + logs ...*types.Log, +) *types.Receipt { + return &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: common.Hash(point.TransactionHash), + BlockHash: common.Hash(point.BlockHash), + BlockNumber: new(big.Int).SetUint64(point.BlockNumber), + TransactionIndex: uint(point.TransactionIndex), + Logs: logs, + } +} + +func (fixture *frostRetainedGroupHistorySourceFixture) historyPages( + query frostRetainedGroupHistoryQuery, +) []*frostRetainedGroupHistoryPagePayload { + fixture.t.Helper() + queryHash, err := frostRetainedGroupDomainHash(frostRetainedGroupHistoryQueryDomain, query) + if err != nil { + fixture.t.Fatal(err) + } + wireMutations := make([]frostRetainedGroupWireMutation, len(fixture.mutations)) + for index, mutation := range fixture.mutations { + wireMutations[index] = frostRetainedGroupMutationToWire(mutation) + } + pageCount := (len(wireMutations) + 1) / 2 + if pageCount == 0 { + pageCount = 1 + } + pages := make([]*frostRetainedGroupHistoryPagePayload, pageCount) + previousPageHash := [32]byte{} + identity := frostRetainedGroupWireIdentity{ + TrustDomainID: fixture.identity.TrustDomainID, + EndpointFingerprint: frostActivationHex32(fixture.identity.EndpointFingerprint), + OperatorFingerprint: frostActivationHex32(fixture.identity.OperatorFingerprint), + } + for index := 0; index < pageCount; index++ { + start := index * 2 + end := start + 2 + if end > len(wireMutations) { + end = len(wireMutations) + } + cursor := "" + if index > 0 { + cursor = "page_" + string(rune('0'+index)) + } + nextCursor := "" + if index+1 < pageCount { + nextCursor = "page_" + string(rune('0'+index+1)) + } + page := &frostRetainedGroupHistoryPagePayload{ + Schema: frostRetainedGroupHistoryPageSchema, + Identity: identity, + ChainID: 1, + QueryHash: frostActivationHex32(queryHash), + SnapshotID: frostActivationHex32(fixture.snapshotID), + PageIndex: uint64(index), + Cursor: cursor, + PreviousPageHash: frostActivationHex32(previousPageHash), + From: query.From, + To: query.To, + EmptyAtFrom: true, + DescriptorSetHash: frostActivationHex32(fixture.descriptorSetHash), + Mutations: append([]frostRetainedGroupWireMutation{}, wireMutations[start:end]...), + NextCursor: nextCursor, + Complete: index+1 == pageCount, + } + pages[index] = page + canonical, err := canonicalFrostActivationValue(page) + if err != nil { + fixture.t.Fatal(err) + } + previousPageHash = sha256.Sum256(canonical) + } + historyRoot, err := frostRetainedGroupHistoryRoot(queryHash, wireMutations) + if err != nil { + fixture.t.Fatal(err) + } + pages[len(pages)-1].Receipt = &frostRetainedGroupHistoryReceipt{ + PageCount: uint64(len(pages)), + MutationCount: uint64(len(wireMutations)), + HistoryRoot: frostActivationHex32(historyRoot), + } + return pages +} + +func (fixture *frostRetainedGroupHistorySourceFixture) historyResponse( + request frostRetainedGroupHistoryPageRequest, +) interface{} { + pages := fixture.historyPages(request.Query) + for _, page := range pages { + if page.Cursor == request.Cursor { + copy := *page + copy.Mutations = append([]frostRetainedGroupWireMutation{}, page.Mutations...) + if page.Receipt != nil { + receipt := *page.Receipt + copy.Receipt = &receipt + } + if fixture.pageMutator != nil { + fixture.pageMutator(©) + } + return © + } + } + return nil +} + +func (fixture *frostRetainedGroupHistorySourceFixture) operatorResponse( + query frostRetainedGroupOperatorQuery, +) interface{} { + queryHash, err := frostRetainedGroupDomainHash(frostRetainedGroupOperatorQueryDomain, query) + if err != nil { + fixture.t.Fatal(err) + } + payload := &frostRetainedGroupOperatorReceiptPayload{ + Schema: frostRetainedGroupOperatorReceiptSchema, + Identity: frostRetainedGroupWireIdentity{ + TrustDomainID: fixture.identity.TrustDomainID, + EndpointFingerprint: frostActivationHex32(fixture.identity.EndpointFingerprint), + OperatorFingerprint: frostActivationHex32(fixture.identity.OperatorFingerprint), + }, + ChainID: 1, + QueryHash: frostActivationHex32(queryHash), + OperatorAddress: query.OperatorAddress, + At: query.At, + OperatorID: 17, + Found: true, + } + if fixture.operatorMutator != nil { + fixture.operatorMutator(payload) + } + return payload +} + +func TestSignedFrostRetainedGroupHistorySource_ReadsCompletePaginatedHistory( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + identity, err := fixture.source.Identity(context.Background()) + if err != nil || identity != fixture.identity { + t.Fatalf("unexpected identity: [%v] [%v]", identity, err) + } + history, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + ) + if err != nil { + t.Fatal(err) + } + if !history.Complete || !history.EmptyAtFrom || len(history.Mutations) != 4 || + history.DescriptorSetHash != fixture.descriptorSetHash { + t.Fatalf("unexpected complete history: [%+v]", history) + } + operatorID, err := fixture.source.ResolveOperatorID( + context.Background(), + chain.Address("0x1111111111111111111111111111111111111111"), + fixture.to, + ) + if err != nil || operatorID != 17 { + t.Fatalf("unexpected operator ID: [%d] [%v]", operatorID, err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsOmittedPage(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.export.historyResponder = func(request frostRetainedGroupHistoryPageRequest) interface{} { + pages := fixture.historyPages(request.Query) + return pages[len(pages)-1] + } + _, err := fixture.source.ReadCompleteHistory(context.Background(), fixture.from, fixture.to) + if err == nil || !strings.Contains(err.Error(), "wrong identity or position") { + t.Fatalf("expected omitted-page rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsTruncationAndForgery( + t *testing.T, +) { + t.Run("truncated pagination", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + defaultResponder := fixture.export.historyResponder + fixture.export.historyResponder = func( + request frostRetainedGroupHistoryPageRequest, + ) interface{} { + if request.Cursor != "" { + return nil + } + return defaultResponder(request) + } + _, err := fixture.source.ReadCompleteHistory(context.Background(), fixture.from, fixture.to) + if err == nil || !strings.Contains(err.Error(), "HTTP status [503]") { + t.Fatalf("expected truncated pagination rejection, got [%v]", err) + } + }) + + t.Run("forged envelope", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.export.corruptSignature = true + _, err := fixture.source.ReadCompleteHistory(context.Background(), fixture.from, fixture.to) + if err == nil || !strings.Contains(err.Error(), "signature is invalid") { + t.Fatalf("expected forged envelope rejection, got [%v]", err) + } + }) +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsDuplicateAndReorderedHistory( + t *testing.T, +) { + tests := map[string]func(*frostRetainedGroupHistorySourceFixture){ + "duplicate event": func(fixture *frostRetainedGroupHistorySourceFixture) { + fixture.mutations = append(fixture.mutations, fixture.mutations[3]) + }, + "reordered event": func(fixture *frostRetainedGroupHistorySourceFixture) { + fixture.mutations[0], fixture.mutations[1] = fixture.mutations[1], fixture.mutations[0] + }, + "duplicate DKG member": func(fixture *frostRetainedGroupHistorySourceFixture) { + fixture.mutations[0].OperatorIDs[1] = fixture.mutations[0].OperatorIDs[0] + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + mutate(fixture) + _, err := fixture.source.ReadCompleteHistory(context.Background(), fixture.from, fixture.to) + if err == nil { + t.Fatal("expected malformed exact history to be rejected") + } + }) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsIdentityCheckpointAndReceiptDrift( + t *testing.T, +) { + tests := map[string]func(*frostRetainedGroupHistoryPagePayload){ + "identity": func(page *frostRetainedGroupHistoryPagePayload) { + page.Identity.TrustDomainID = "wrong-domain" + }, + "checkpoint": func(page *frostRetainedGroupHistoryPagePayload) { + page.From.BlockHash = frostActivationHex32([32]byte{0xff}) + }, + "manifest descriptor": func(page *frostRetainedGroupHistoryPagePayload) { + page.DescriptorSetHash = frostActivationHex32([32]byte{0xdd}) + }, + "receipt": func(page *frostRetainedGroupHistoryPagePayload) { + if page.Complete { + page.Receipt.HistoryRoot = frostActivationHex32([32]byte{0xee}) + } + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.pageMutator = mutate + _, err := fixture.source.ReadCompleteHistory(context.Background(), fixture.from, fixture.to) + if err == nil { + t.Fatalf("expected %s drift to be rejected", name) + } + }) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsSemanticForgeryInCanonicalBlock( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + for index := range fixture.mutations { + fixture.mutations[index].WalletPublicKeyHash[0] ^= 0xff + } + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + ) + if err == nil || !strings.Contains(err.Error(), "Bridge registration log") { + t.Fatalf("expected canonical-block semantic forgery rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsWrongReceiptLog( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + admission := fixture.mutations[0] + receipt := fixture.verifier.receipts[common.Hash(admission.BridgeRegistrationPoint.TransactionHash)] + receipt.Logs[2].Topics[3] = common.Hash{0xff} + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + ) + if err == nil || !strings.Contains(err.Error(), "Bridge registration log") { + t.Fatalf("expected wrong receipt-log rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsManifestCodeDrift( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.verifier.code[common.Address(fixture.profile.BridgeAddress)] = []byte{0xff} + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + ) + if err == nil || !strings.Contains(err.Error(), "signed activation manifest") { + t.Fatalf("expected manifest code-drift rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsWrongEndpointAndReorg( + t *testing.T, +) { + _, _, _, _, _, err := validateFrostRetainedGroupSourceConfig( + FrostRetainedGroupHistorySourceConfig{ + ExportURL: "https://export.example/history", + EthereumURL: "https://primary.example/independent", + TrustDomainID: "independent", + TrustedSignerKeyHash: frostActivationHex32([32]byte{0x01}), + }, + "wss://primary.example/rpc", + ) + if err == nil || !strings.Contains(err.Error(), "not independent") { + t.Fatalf("expected shared endpoint rejection, got [%v]", err) + } + _, _, _, _, _, err = validateFrostRetainedGroupSourceConfig( + FrostRetainedGroupHistorySourceConfig{ + ExportURL: "https://history.example/export", + EthereumURL: "https://history.example/rpc", + TrustDomainID: "independent", + TrustedSignerKeyHash: frostActivationHex32([32]byte{0x01}), + }, + "wss://primary.example/rpc", + ) + if err == nil || !strings.Contains(err.Error(), "exporter and Ethereum verifier") { + t.Fatalf("expected exporter/verifier separation rejection, got [%v]", err) + } + + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.verifier.reorgBlock = fixture.to.BlockNumber + fixture.verifier.reorgAfterRead = 2 + fixture.verifier.reorgHeader = &types.Header{ + Number: new(big.Int).SetUint64(fixture.to.BlockNumber), + Time: 999, + Extra: []byte{0xff}, + } + _, err = fixture.source.ReadCompleteHistory(context.Background(), fixture.from, fixture.to) + if err == nil || !strings.Contains(err.Error(), "canonical chain") { + t.Fatalf("expected finalized-chain reorg rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsWrongOperatorReceipt( + t *testing.T, +) { + t.Run("wrong point", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.operatorMutator = func(payload *frostRetainedGroupOperatorReceiptPayload) { + payload.At.BlockHash = frostActivationHex32([32]byte{0x99}) + } + _, err := fixture.source.ResolveOperatorID( + context.Background(), + chain.Address("0x1111111111111111111111111111111111111111"), + fixture.to, + ) + if err == nil || !strings.Contains(err.Error(), "differently bound") { + t.Fatalf("expected operator receipt rejection, got [%v]", err) + } + }) + + t.Run("wrong ID", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.operatorMutator = func(payload *frostRetainedGroupOperatorReceiptPayload) { + payload.OperatorID++ + } + _, err := fixture.source.ResolveOperatorID( + context.Background(), + chain.Address("0x1111111111111111111111111111111111111111"), + fixture.to, + ) + if err == nil || !strings.Contains(err.Error(), "disagrees with exact finalized") { + t.Fatalf("expected independent operator-ID rejection, got [%v]", err) + } + }) +} diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index 98b1025321..2c02dae18c 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -130,6 +130,9 @@ type FrostRetainedGroupMutation struct { WalletPublicKeyHash [20]byte `json:"walletPublicKeyHash"` OperatorIDs []uint32 `json:"operatorIDs,omitempty"` RetainedGroupHash [32]byte `json:"retainedGroupHash,omitempty"` + DkgResultHash [32]byte `json:"dkgResultHash,omitempty"` + DkgSubmissionPoint FrostRetainedGroupEventPoint `json:"dkgSubmissionPoint,omitempty"` + DkgApprovalPoint FrostRetainedGroupEventPoint `json:"dkgApprovalPoint,omitempty"` CreationPoint FrostRetainedGroupEventPoint `json:"creationPoint,omitempty"` BridgeRegistrationPoint FrostRetainedGroupEventPoint `json:"bridgeRegistrationPoint,omitempty"` QuarantineID [32]byte `json:"quarantineID,omitempty"` @@ -1248,18 +1251,30 @@ func applyFrostRetainedGroupMutations( case FrostRetainedGroupAdmissionMutation: if mutation.WalletID == [32]byte{} || mutation.WalletPublicKeyHash == [20]byte{} || len(mutation.OperatorIDs) < 51 || len(mutation.OperatorIDs) > 100 || - mutation.RetainedGroupHash == [32]byte{} || + mutation.RetainedGroupHash == [32]byte{} || mutation.DkgResultHash == [32]byte{} || + !mutation.DkgSubmissionPoint.valid() || !mutation.DkgApprovalPoint.valid() || !mutation.CreationPoint.valid() || !mutation.BridgeRegistrationPoint.valid() || mutation.Point != mutation.BridgeRegistrationPoint || + compareFrostRetainedGroupEventPoints(mutation.DkgSubmissionPoint, mutation.DkgApprovalPoint) >= 0 || + !sameFrostRetainedGroupTransaction(mutation.DkgApprovalPoint, mutation.CreationPoint) || + compareFrostRetainedGroupEventPoints(mutation.DkgApprovalPoint, mutation.CreationPoint) >= 0 || !sameFrostRetainedGroupTransaction(mutation.CreationPoint, mutation.BridgeRegistrationPoint) || compareFrostRetainedGroupEventPoints(mutation.CreationPoint, mutation.BridgeRegistrationPoint) >= 0 || wallet.WalletID != [32]byte{} || mutation.QuarantineID != [32]byte{} { return fmt.Errorf("invalid or duplicate FROST retained-group admission") } + for _, existingWallet := range wallets { + if existingWallet.WalletID != [32]byte{} && + existingWallet.WalletPublicKeyHash == mutation.WalletPublicKeyHash { + return fmt.Errorf("FROST retained-group admission reuses a wallet public-key hash") + } + } + seenOperatorIDs := make(map[uint32]bool, len(mutation.OperatorIDs)) for _, operatorID := range mutation.OperatorIDs { - if operatorID == 0 { - return fmt.Errorf("FROST retained-group admission contains zero operator ID") + if operatorID == 0 || seenOperatorIDs[operatorID] { + return fmt.Errorf("FROST retained-group admission contains zero or duplicate operator ID") } + seenOperatorIDs[operatorID] = true } wallets[mutation.WalletID] = frostRetainedGroupWalletState{ WalletID: mutation.WalletID, diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index bedab364a4..49b42afc79 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -200,6 +200,9 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { WalletPublicKeyHash: walletPKH, OperatorIDs: append([]uint32{}, operatorIDs...), RetainedGroupHash: [32]byte{0x93}, + DkgResultHash: [32]byte{0x94}, + DkgSubmissionPoint: FrostRetainedGroupEventPoint{BlockNumber: 2, BlockHash: [32]byte{0x02}, TransactionHash: [32]byte{0xa1}, TransactionIndex: 0, LogIndex: 1}, + DkgApprovalPoint: FrostRetainedGroupEventPoint{BlockNumber: 2, BlockHash: [32]byte{0x02}, TransactionHash: [32]byte{0xa2}, TransactionIndex: 1, LogIndex: 3}, CreationPoint: FrostRetainedGroupEventPoint{BlockNumber: 2, BlockHash: [32]byte{0x02}, TransactionHash: [32]byte{0xa2}, TransactionIndex: 1, LogIndex: 4}, BridgeRegistrationPoint: FrostRetainedGroupEventPoint{BlockNumber: 2, BlockHash: [32]byte{0x02}, TransactionHash: [32]byte{0xa2}, TransactionIndex: 1, LogIndex: 5}, } @@ -314,7 +317,7 @@ func TestFrostRetainedGroupJournal_ReconcilesAndRejectsRewrittenHistory( t.Fatalf("unexpected journal snapshot: %+v", snapshot) } - fixture.source.mutations[0].OperatorIDs[0] = 51 + fixture.source.mutations[0].OperatorIDs[0] = 52 if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || !strings.Contains(err.Error(), "rewrote, omitted, or reordered") { t.Fatalf("expected canonical prefix rewrite failure, got [%v]", err) @@ -528,6 +531,26 @@ func TestApplyFrostRetainedGroupMutations_EnforcesLifecycleAndRegistryClosure( } } +func TestApplyFrostRetainedGroupMutations_RejectsDuplicateAdmissionMember( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + duplicate := fixture.admission + duplicate.OperatorIDs = append([]uint32{}, fixture.admission.OperatorIDs...) + duplicate.OperatorIDs[1] = duplicate.OperatorIDs[0] + state := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + CurrentPoint: fixture.manifest.Checkpoint, + Wallets: []frostRetainedGroupWalletState{}, + } + if err := applyFrostRetainedGroupMutations( + &state, + []FrostRetainedGroupMutation{duplicate}, + ); err == nil || !strings.Contains(err.Error(), "duplicate operator ID") { + t.Fatalf("expected duplicate admission member rejection, got [%v]", err) + } +} + func lifecycleMutation( fixture *journalTestFixture, block uint64, diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index 4baf6350f2..d407989703 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -717,6 +717,24 @@ func newNode( "cannot enable FROST activation handshake without an independent retained-group history source", ) } + evidenceBinder, ok := config.FrostRetainedGroupHistorySource.(FrostRetainedGroupActivationEvidenceBinder) + if !ok { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot enable FROST activation handshake without a manifest-bound retained-group evidence source", + ) + } + if err := evidenceBinder.BindFrostRetainedGroupActivationEvidence( + verifiedActivationProfile, + runtimeManifest.ManifestHash, + runtimeManifest.CanonicalJournal.DescriptorSetHash, + ); err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot bind retained-group evidence to the authenticated activation manifest: [%w]", + err, + ) + } journal, err := newFrostRetainedGroupJournal( config.FrostRetainedGroupJournalDirectory, runtimeManifest.ManifestHash, diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 3e7e8c1664..96e8334ebd 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -165,7 +165,12 @@ type Config struct { // FrostRetainedGroupHistorySource is an independently authenticated, // receipt-complete source. It must not share the primary Ethereum adapter's // trust domain, endpoint, operator, or history store. - FrostRetainedGroupHistorySource FrostRetainedGroupHistorySource + FrostRetainedGroupHistorySource FrostRetainedGroupHistorySource `mapstructure:"-"` + // FrostRetainedGroupHistory configures the production signed, paginated + // retained-group export and its independent finalized Ethereum verifier. + // The start command constructs FrostRetainedGroupHistorySource from this + // configuration before TBTC initialization whenever FROST activation is on. + FrostRetainedGroupHistory FrostRetainedGroupHistorySourceConfig // FrostNativeSignerAnchorURL is the exact authenticated checkpoint-service // endpoint. HTTPS uses normal PKIX validation plus the manifest-pinned leaf // SPKI. Plain HTTP is accepted only for a canonical numeric loopback host. From 64deb8e98695d77f087aaa74e6bfe69bb4edf275 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 17:10:50 -0400 Subject: [PATCH 24/93] Harden retained FROST history semantics --- pkg/tbtc/frost_activation_handshake.go | 207 +++++++++++ pkg/tbtc/frost_activation_handshake_test.go | 54 +++ .../frost_retained_group_history_evidence.go | 59 +++- .../frost_retained_group_history_source.go | 134 +++++--- ...rost_retained_group_history_source_test.go | 320 +++++++++++++++++- pkg/tbtc/frost_retained_group_journal.go | 105 ++++-- pkg/tbtc/frost_retained_group_journal_test.go | 312 ++++++++++++++++- 7 files changed, 1105 insertions(+), 86 deletions(-) diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 5bfcad682c..1bd2375e47 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -18,6 +18,7 @@ import ( "net/http" "net/url" "path" + "reflect" "sort" "strings" "sync" @@ -618,6 +619,21 @@ func frostActivationHandshakeSignatureTranscript( } func decodeStrictFrostActivationJSON(data []byte, target interface{}) error { + if err := validateUniqueFrostActivationJSONKeys(data); err != nil { + return err + } + var decoded interface{} + shapeDecoder := json.NewDecoder(bytes.NewReader(data)) + shapeDecoder.UseNumber() + if err := shapeDecoder.Decode(&decoded); err != nil { + return err + } + if err := validateExactFrostActivationJSONShape( + decoded, + reflect.TypeOf(target), + ); err != nil { + return err + } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() decoder.UseNumber() @@ -630,17 +646,208 @@ func decodeStrictFrostActivationJSON(data []byte, target interface{}) error { return nil } +func validateUniqueFrostActivationJSONKeys(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var readValue func() error + readValue = func() error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, isDelimiter := token.(json.Delim) + if !isDelimiter { + return nil + } + switch delimiter { + case '{': + keys := make(map[string]struct{}) + foldedKeys := make(map[string]string) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("JSON object key is not a string") + } + for _, character := range key { + if character < 0x20 || character > 0x7e { + return fmt.Errorf( + "JSON object key [%s] is not printable ASCII", + key, + ) + } + } + if _, exists := keys[key]; exists { + return fmt.Errorf("JSON object contains duplicate key [%s]", key) + } + folded := strings.ToLower(key) + if existing, exists := foldedKeys[folded]; exists { + return fmt.Errorf( + "JSON object contains case-fold-equivalent keys [%s] and [%s]", + existing, + key, + ) + } + keys[key] = struct{}{} + foldedKeys[folded] = key + if err := readValue(); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim('}') { + return fmt.Errorf("JSON object is not closed") + } + case '[': + for decoder.More() { + if err := readValue(); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim(']') { + return fmt.Errorf("JSON array is not closed") + } + default: + return fmt.Errorf("unexpected JSON delimiter [%s]", delimiter) + } + return nil + } + if err := readValue(); err != nil { + return err + } + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return fmt.Errorf("JSON contains trailing data") + } + return err + } + return nil +} + +func validateExactFrostActivationJSONShape( + value interface{}, + targetType reflect.Type, +) error { + if targetType == nil { + return fmt.Errorf("JSON target type is nil") + } + for targetType.Kind() == reflect.Pointer { + targetType = targetType.Elem() + } + rawMessageType := reflect.TypeOf(json.RawMessage{}) + jsonUnmarshalerType := reflect.TypeOf((*json.Unmarshaler)(nil)).Elem() + if targetType == rawMessageType || targetType.Kind() == reflect.Interface { + return nil + } + if targetType.Implements(jsonUnmarshalerType) || + reflect.PointerTo(targetType).Implements(jsonUnmarshalerType) { + return fmt.Errorf("custom JSON unmarshal targets are not supported") + } + if value == nil { + return nil + } + if targetType.Kind() == reflect.Struct { + object, ok := value.(map[string]interface{}) + if !ok { + return nil + } + fields := make(map[string]reflect.Type) + for index := 0; index < targetType.NumField(); index++ { + field := targetType.Field(index) + if field.PkgPath != "" { + continue + } + tag := field.Tag.Get("json") + name := strings.Split(tag, ",")[0] + if name == "-" { + continue + } + if name == "" { + name = field.Name + } + fields[name] = field.Type + } + for key, item := range object { + fieldType, ok := fields[key] + if !ok { + return fmt.Errorf("JSON object contains non-exact or unknown key [%s]", key) + } + if err := validateExactFrostActivationJSONShape(item, fieldType); err != nil { + return fmt.Errorf("JSON key [%s]: [%w]", key, err) + } + } + return nil + } + if targetType.Kind() == reflect.Slice || targetType.Kind() == reflect.Array { + if targetType.Kind() == reflect.Slice && + targetType.Elem().Kind() == reflect.Uint8 { + return nil + } + items, ok := value.([]interface{}) + if !ok { + return nil + } + for index, item := range items { + if err := validateExactFrostActivationJSONShape( + item, + targetType.Elem(), + ); err != nil { + return fmt.Errorf("JSON array item [%d]: [%w]", index, err) + } + } + return nil + } + if targetType.Kind() == reflect.Map { + object, ok := value.(map[string]interface{}) + if !ok { + return nil + } + for key, item := range object { + if err := validateExactFrostActivationJSONShape( + item, + targetType.Elem(), + ); err != nil { + return fmt.Errorf("JSON map key [%s]: [%w]", key, err) + } + } + } + return nil +} + func canonicalFrostActivationValue(value interface{}) ([]byte, error) { + if raw, ok := value.(json.RawMessage); ok { + return canonicalFrostActivationJSON(raw) + } + if raw, ok := value.(*json.RawMessage); ok { + if raw == nil { + return nil, fmt.Errorf("canonical JSON raw message is nil") + } + return canonicalFrostActivationJSON(*raw) + } encoded, err := json.Marshal(value) if err != nil { return nil, err } + return canonicalFrostActivationJSON(encoded) +} + +func canonicalFrostActivationJSON(encoded []byte) ([]byte, error) { + if err := validateUniqueFrostActivationJSONKeys(encoded); err != nil { + return nil, err + } decoder := json.NewDecoder(bytes.NewReader(encoded)) decoder.UseNumber() var decoded interface{} if err := decoder.Decode(&decoded); err != nil { return nil, err } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("JSON contains trailing data") + } buffer := bytes.NewBuffer(nil) if err := writeCanonicalFrostActivationJSON(buffer, decoded); err != nil { return nil, err diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index 2533762dbc..ee61a570b4 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -18,6 +18,7 @@ import ( "path/filepath" "reflect" "sort" + "strings" "testing" "time" @@ -407,6 +408,59 @@ func TestCanonicalFrostActivationValue_MatchesRuntimeOrdering(t *testing.T) { } } +func TestDecodeStrictFrostActivationJSON_RejectsAmbiguousObjectKeys( + t *testing.T, +) { + type nested struct { + Count uint64 `json:"count"` + } + type payload struct { + Schema string `json:"schema"` + Nested nested `json:"nested"` + } + testCases := map[string]string{ + "duplicate exact key": `{"schema":"v1","schema":"v2","nested":{"count":1}}`, + "case-insensitive field alias": `{"Schema":"v1","nested":{"count":1}}`, + "case-fold-equivalent keys": `{"schema":"v1","SCHEMA":"v2","nested":{"count":1}}`, + "nested duplicate key": `{"schema":"v1","nested":{"count":1,"count":2}}`, + "nested field alias": `{"schema":"v1","nested":{"Count":1}}`, + "non-ASCII field alias": `{"Å¿chema":"v1","nested":{"count":1}}`, + } + for name, encoded := range testCases { + t.Run(name, func(t *testing.T) { + target := payload{} + if err := decodeStrictFrostActivationJSON( + []byte(encoded), + &target, + ); err == nil { + t.Fatal("expected ambiguous JSON to be rejected") + } + }) + } + + target := payload{} + if err := decodeStrictFrostActivationJSON( + []byte(`{"schema":"v1","nested":{"count":1}}`), + &target, + ); err != nil { + t.Fatalf("expected exact JSON keys to remain valid: [%v]", err) + } + if target.Schema != "v1" || target.Nested.Count != 1 { + t.Fatalf("unexpected exact JSON decode: [%+v]", target) + } +} + +func TestCanonicalFrostActivationValue_RejectsDuplicateRawMessageKeys( + t *testing.T, +) { + _, err := canonicalFrostActivationValue( + json.RawMessage(`{"schema":"v1","schema":"v2"}`), + ) + if err == nil || !strings.Contains(err.Error(), "duplicate key") { + t.Fatalf("expected duplicate raw-message key rejection, got [%v]", err) + } +} + func testFrostActivationRuntimeManifest( keyHash [32]byte, ) FrostPreSignActivationRuntimeManifest { diff --git a/pkg/tbtc/frost_retained_group_history_evidence.go b/pkg/tbtc/frost_retained_group_history_evidence.go index 8aa8f46615..cf5b0a96d5 100644 --- a/pkg/tbtc/frost_retained_group_history_evidence.go +++ b/pkg/tbtc/frost_retained_group_history_evidence.go @@ -15,6 +15,7 @@ import ( "github.com/ethereum/go-ethereum/crypto" frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" bridgeabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" + frostregistry "github.com/keep-network/keep-core/pkg/frost/registry" ) // FrostRetainedGroupActivationEvidenceBinder binds a retained-group history @@ -42,7 +43,6 @@ type frostRetainedGroupEvidenceProfile struct { sortitionPool frostRetainedGroupContractPin bridgeABI ethabi.ABI registryABI ethabi.ABI - membersArguments ethabi.Arguments } type frostRetainedGroupReceiptCache map[common.Hash]*types.Receipt @@ -82,10 +82,6 @@ func (source *signedFrostRetainedGroupHistorySource) BindFrostRetainedGroupActiv if err != nil { return fmt.Errorf("cannot parse pinned FROST registry ABI: [%w]", err) } - membersType, err := ethabi.NewType("uint32[]", "", nil) - if err != nil { - return fmt.Errorf("cannot construct pinned DKG members descriptor: [%w]", err) - } evidence := &frostRetainedGroupEvidenceProfile{ manifestHash: manifestHash, descriptorSetHash: descriptorSetHash, @@ -101,9 +97,8 @@ func (source *signedFrostRetainedGroupHistorySource) BindFrostRetainedGroupActiv address: common.Address(profile.SortitionPool), codeHash: common.Hash(profile.SortitionPoolCodeHash), }, - bridgeABI: parsedBridgeABI, - registryABI: parsedRegistryABI, - membersArguments: ethabi.Arguments{{Type: membersType}}, + bridgeABI: parsedBridgeABI, + registryABI: parsedRegistryABI, } for name, event := range map[string]struct { contract *ethabi.ABI @@ -269,15 +264,39 @@ func (source *signedFrostRetainedGroupHistorySource) verifyAdmissionEvidence( if err != nil { return err } - if resultHash != mutation.DkgResultHash || result.XOnlyOutputKey != mutation.WalletID || + fullMembers := frostregistry.FullMembers(result.Members) + misbehaved := frostregistry.MisbehavedMemberIndices( + result.MisbehavedMembersIndices, + ) + activeMembers, err := frostregistry.ActiveMembersFromMisbehaved( + fullMembers, + misbehaved, + ) + if err != nil { + return fmt.Errorf("DKG submission has invalid misbehaved member indices: [%w]", err) + } + if len(fullMembers) > 100 { + return fmt.Errorf("DKG submission exceeds the supported group size") + } + for _, operatorID := range fullMembers { + if operatorID == 0 { + return fmt.Errorf("DKG submission contains a zero operator ID") + } + } + activeMembersHash, err := frostregistry.ActiveMembersHash(activeMembers) + if err != nil { + return fmt.Errorf("cannot hash active DKG members: [%w]", err) + } + if resultHash != mutation.DkgResultHash || + result.XOnlyOutputKey != mutation.WalletID || result.MembersHash != mutation.RetainedGroupHash || - !frostRetainedGroupEqualOperatorIDs(result.Members, mutation.OperatorIDs) { + result.MembersHash != activeMembersHash || + !frostRetainedGroupEqualOperatorIDs( + []uint32(activeMembers), + mutation.OperatorIDs, + ) { return fmt.Errorf("DKG submission does not encode the exported admission") } - encodedMembers, err := evidence.membersArguments.Pack(result.Members) - if err != nil || crypto.Keccak256Hash(encodedMembers) != common.Hash(result.MembersHash) { - return fmt.Errorf("DKG members hash does not commit to the exact ordered member IDs") - } approvalLog, err := source.authenticatedEventLog( ctx, @@ -382,6 +401,9 @@ func (source *signedFrostRetainedGroupHistorySource) authenticatedEventLog( transactionHash := common.Hash(point.TransactionHash) receipt, ok := receipts[transactionHash] if !ok { + if len(receipts) >= frostRetainedGroupMaximumEvidenceReceipts { + return nil, fmt.Errorf("retained-group evidence exceeds the receipt limit") + } var err error receipt, err = source.verifier.TransactionReceipt(ctx, transactionHash) if err != nil { @@ -392,6 +414,9 @@ func (source *signedFrostRetainedGroupHistorySource) authenticatedEventLog( } receipts[transactionHash] = receipt } + if len(receipt.Logs) > frostRetainedGroupMaximumReceiptLogs { + return nil, fmt.Errorf("transaction receipt exceeds the retained-group log limit") + } if receipt.Status != types.ReceiptStatusSuccessful || receipt.BlockNumber == nil || !receipt.BlockNumber.IsUint64() || receipt.BlockNumber.Uint64() != point.BlockNumber || receipt.BlockHash != common.Hash(point.BlockHash) || receipt.TxHash != transactionHash || @@ -427,11 +452,15 @@ func (source *signedFrostRetainedGroupHistorySource) authenticateContractCode( if _, ok := cache[key]; ok { return nil } + if len(cache) >= frostRetainedGroupMaximumEvidenceCodePoints { + return fmt.Errorf("retained-group evidence exceeds the contract-code point limit") + } code, err := source.verifier.CodeAt(ctx, pin.address, new(big.Int).SetUint64(blockNumber)) if err != nil { return fmt.Errorf("cannot read pinned contract code at block [%d]: [%w]", blockNumber, err) } - if len(code) == 0 || crypto.Keccak256Hash(code) != pin.codeHash { + if len(code) == 0 || len(code) > frostRetainedGroupMaximumContractCodeBytes || + crypto.Keccak256Hash(code) != pin.codeHash { return fmt.Errorf("contract code at block [%d] differs from the signed activation manifest", blockNumber) } cache[key] = struct{}{} diff --git a/pkg/tbtc/frost_retained_group_history_source.go b/pkg/tbtc/frost_retained_group_history_source.go index d710793aba..b24fd31798 100644 --- a/pkg/tbtc/frost_retained_group_history_source.go +++ b/pkg/tbtc/frost_retained_group_history_source.go @@ -40,12 +40,20 @@ const ( frostRetainedGroupOperatorQueryDomain = "tbtc-frost-retained-group-operator-query-v1\x00" frostRetainedGroupHistoryRootDomain = "tbtc-frost-retained-group-export-history-root-v1\x00" - frostRetainedGroupMaximumResponseBytes = 1024 * 1024 - frostRetainedGroupMaximumPages = 4096 - frostRetainedGroupMaximumMutations = 250000 - frostRetainedGroupMaximumCursorBytes = 256 - frostRetainedGroupMaximumReasonBytes = 1024 - frostRetainedGroupDefaultTimeout = 20 * time.Second + frostRetainedGroupMaximumResponseBytes = 1024 * 1024 + frostRetainedGroupMaximumAggregateResponseBytes = 16 * 1024 * 1024 + frostRetainedGroupMaximumPages = 256 + frostRetainedGroupMaximumMutations = 4096 + frostRetainedGroupMaximumWallets = 2048 + frostRetainedGroupMaximumUniqueBlocks = 8192 + frostRetainedGroupMaximumEvidenceReceipts = 8192 + frostRetainedGroupMaximumEvidenceCodePoints = 16384 + frostRetainedGroupMaximumReceiptLogs = 4096 + frostRetainedGroupMaximumContractCodeBytes = 64 * 1024 + frostRetainedGroupMaximumCursorBytes = 256 + frostRetainedGroupMaximumReasonBytes = 1024 + frostRetainedGroupDefaultTimeout = 20 * time.Second + frostRetainedGroupMaximumReconciliationDuration = 5 * time.Minute ) // FrostRetainedGroupHistorySourceConfig configures the independent, @@ -88,6 +96,11 @@ type signedFrostRetainedGroupHistorySource struct { trustedSignerKeyHash [32]byte evidenceMutex sync.RWMutex evidence *frostRetainedGroupEvidenceProfile + maximumPages uint64 + maximumMutations uint64 + maximumResponseBytes uint64 + maximumUniqueBlocks uint64 + maximumReadDuration time.Duration } var _ FrostRetainedGroupHistorySource = (*signedFrostRetainedGroupHistorySource)(nil) @@ -293,6 +306,11 @@ func newSignedFrostRetainedGroupHistorySource( chainID: chainID, identity: identity, trustedSignerKeyHash: signerHash, + maximumPages: frostRetainedGroupMaximumPages, + maximumMutations: frostRetainedGroupMaximumMutations, + maximumResponseBytes: frostRetainedGroupMaximumAggregateResponseBytes, + maximumUniqueBlocks: frostRetainedGroupMaximumUniqueBlocks, + maximumReadDuration: frostRetainedGroupMaximumReconciliationDuration, } if _, err := source.FinalizedHead(ctx); err != nil { return nil, fmt.Errorf("independent retained-group Ethereum verifier has no usable finalized head: [%w]", err) @@ -500,6 +518,14 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( to.BlockNumber < from.BlockNumber || to.BlockHash == [32]byte{} { return nil, fmt.Errorf("retained-group history bounds are invalid") } + if source.maximumPages == 0 || source.maximumMutations == 0 || + source.maximumResponseBytes == 0 || source.maximumUniqueBlocks == 0 || + source.maximumReadDuration <= 0 { + return nil, fmt.Errorf("retained-group history resource limits are invalid") + } + readContext, cancel := context.WithTimeout(ctx, source.maximumReadDuration) + defer cancel() + ctx = readContext evidence, err := source.activationEvidence() if err != nil { return nil, err @@ -529,7 +555,7 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( } mutations := make([]FrostRetainedGroupMutation, 0) - wireMutations := make([]frostRetainedGroupWireMutation, 0) + mutationHashes := make([][32]byte, 0) seenCursors := make(map[string]bool) blockHashes := map[uint64][32]byte{ from.BlockNumber: from.BlockHash, @@ -539,16 +565,22 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( var snapshotID [32]byte var descriptorSetHash [32]byte var previousPageHash [32]byte - for pageIndex := uint64(0); pageIndex < frostRetainedGroupMaximumPages; pageIndex++ { + var aggregateResponseBytes uint64 + for pageIndex := uint64(0); pageIndex < source.maximumPages; pageIndex++ { if seenCursors[cursor] { return nil, fmt.Errorf("retained-group history cursor repeated") } seenCursors[cursor] = true request := frostRetainedGroupHistoryPageRequest{Query: query, Cursor: cursor} payload := &frostRetainedGroupHistoryPagePayload{} - if err := source.postSigned(ctx, "history", request, payload); err != nil { + responseBytes, err := source.postSigned(ctx, "history", request, payload) + if err != nil { return nil, fmt.Errorf("cannot read retained-group history page [%d]: [%w]", pageIndex, err) } + if responseBytes > source.maximumResponseBytes-aggregateResponseBytes { + return nil, fmt.Errorf("retained-group history exceeds the aggregate response-byte limit") + } + aggregateResponseBytes += responseBytes pageHash, err := source.validateHistoryPage( payload, queryHash, @@ -570,6 +602,10 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( descriptorSetHash = parsedDescriptorSetHash } for _, wireMutation := range payload.Mutations { + canonicalMutation, err := canonicalFrostActivationValue(wireMutation) + if err != nil { + return nil, fmt.Errorf("cannot hash retained-group history mutation: [%w]", err) + } mutation, err := frostRetainedGroupMutationFromWire(wireMutation) if err != nil { return nil, fmt.Errorf("retained-group history contains malformed mutation: [%w]", err) @@ -577,9 +613,15 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( if err := addFrostRetainedGroupMutationBlockHashes(blockHashes, mutation); err != nil { return nil, err } + if uint64(len(blockHashes)) > source.maximumUniqueBlocks { + return nil, fmt.Errorf("retained-group history exceeds the unique-block limit") + } mutations = append(mutations, mutation) - wireMutations = append(wireMutations, wireMutation) - if len(mutations) > frostRetainedGroupMaximumMutations { + mutationHashes = append( + mutationHashes, + sha256.Sum256(canonicalMutation), + ) + if uint64(len(mutations)) > source.maximumMutations { return nil, fmt.Errorf("retained-group history exceeds the mutation limit") } } @@ -593,8 +635,11 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( if err != nil { return nil, fmt.Errorf("retained-group history receipt root is invalid") } - computedRoot, err := frostRetainedGroupHistoryRoot(queryHash, wireMutations) - if err != nil || receiptRoot != computedRoot { + computedRoot := frostRetainedGroupHistoryRootFromHashes( + queryHash, + mutationHashes, + ) + if receiptRoot != computedRoot { return nil, fmt.Errorf("retained-group history receipt does not cover the exact mutation sequence") } history := &FrostRetainedGroupHistory{ @@ -685,7 +730,7 @@ func (source *signedFrostRetainedGroupHistorySource) validateHistoryPage( if err != nil || declaredPreviousPageHash != previousPageHash { return [32]byte{}, fmt.Errorf("retained-group history page hash chain is broken") } - if len(payload.Mutations) > frostRetainedGroupMaximumMutations { + if uint64(len(payload.Mutations)) > source.maximumMutations { return [32]byte{}, fmt.Errorf("retained-group history page exceeds the mutation limit") } canonical, err := canonicalFrostActivationValue(payload) @@ -728,7 +773,7 @@ func (source *signedFrostRetainedGroupHistorySource) ResolveOperatorID( return 0, err } payload := &frostRetainedGroupOperatorReceiptPayload{} - if err := source.postSigned(ctx, "operator-id", query, payload); err != nil { + if _, err := source.postSigned(ctx, "operator-id", query, payload); err != nil { return 0, fmt.Errorf("cannot resolve retained-group operator ID: [%w]", err) } declaredQueryHash, err := parseFrostActivationHex32(payload.QueryHash) @@ -770,10 +815,10 @@ func (source *signedFrostRetainedGroupHistorySource) postSigned( operation string, requestPayload interface{}, responsePayload interface{}, -) error { +) (uint64, error) { requestBody, err := json.Marshal(requestPayload) if err != nil { - return err + return 0, err } endpoint := *source.exportEndpoint endpoint.Path = path.Join(endpoint.Path, operation) @@ -784,34 +829,37 @@ func (source *signedFrostRetainedGroupHistorySource) postSigned( bytes.NewReader(requestBody), ) if err != nil { - return err + return 0, err } request.Header.Set("Content-Type", "application/json") request.Header.Set("Accept", "application/json") response, err := source.httpClient.Do(request) if err != nil { - return err + return 0, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { - return fmt.Errorf("retained-group export returned HTTP status [%d]", response.StatusCode) + return 0, fmt.Errorf("retained-group export returned HTTP status [%d]", response.StatusCode) } mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) if err != nil || mediaType != "application/json" { - return fmt.Errorf("retained-group export response is not application/json") + return 0, fmt.Errorf("retained-group export response is not application/json") } data, err := io.ReadAll(io.LimitReader(response.Body, frostRetainedGroupMaximumResponseBytes+1)) if err != nil { - return err + return 0, err } if len(data) == 0 || len(data) > frostRetainedGroupMaximumResponseBytes { - return fmt.Errorf("retained-group export response size is invalid") + return 0, fmt.Errorf("retained-group export response size is invalid") } envelope := &frostRetainedGroupSignedEnvelope{} if err := decodeStrictFrostActivationJSON(data, envelope); err != nil { - return fmt.Errorf("cannot decode retained-group signed envelope: [%w]", err) + return 0, fmt.Errorf("cannot decode retained-group signed envelope: [%w]", err) + } + if err := source.verifySignedEnvelope(envelope, responsePayload); err != nil { + return 0, err } - return source.verifySignedEnvelope(envelope, responsePayload) + return uint64(len(data)), nil } func (source *signedFrostRetainedGroupHistorySource) verifySignedEnvelope( @@ -850,7 +898,7 @@ func (source *signedFrostRetainedGroupHistorySource) verifySignedEnvelope( !ed25519.Verify(publicKey, signed, signature) { return fmt.Errorf("retained-group export signature is invalid") } - if err := decodeStrictFrostActivationJSON(envelope.Payload, target); err != nil { + if err := decodeStrictFrostActivationJSON(canonical, target); err != nil { return fmt.Errorf("cannot decode retained-group signed payload: [%w]", err) } return nil @@ -883,25 +931,35 @@ func frostRetainedGroupHistoryRoot( queryHash [32]byte, mutations []frostRetainedGroupWireMutation, ) ([32]byte, error) { + hashes := make([][32]byte, 0, len(mutations)) + for _, mutation := range mutations { + canonical, err := canonicalFrostActivationValue(mutation) + if err != nil { + return [32]byte{}, err + } + hashes = append(hashes, sha256.Sum256(canonical)) + } + return frostRetainedGroupHistoryRootFromHashes(queryHash, hashes), nil +} + +func frostRetainedGroupHistoryRootFromHashes( + queryHash [32]byte, + mutationHashes [][32]byte, +) [32]byte { hasher := sha256.New() hasher.Write([]byte(frostRetainedGroupHistoryRootDomain)) hasher.Write(queryHash[:]) count := make([]byte, 8) for i := uint(0); i < 8; i++ { - count[7-i] = byte(uint64(len(mutations)) >> (i * 8)) + count[7-i] = byte(uint64(len(mutationHashes)) >> (i * 8)) } hasher.Write(count) - for _, mutation := range mutations { - canonical, err := canonicalFrostActivationValue(mutation) - if err != nil { - return [32]byte{}, err - } - itemHash := sha256.Sum256(canonical) - hasher.Write(itemHash[:]) + for _, mutationHash := range mutationHashes { + hasher.Write(mutationHash[:]) } var result [32]byte copy(result[:], hasher.Sum(nil)) - return result, nil + return result } func frostRetainedGroupFinalityToWire( @@ -1039,12 +1097,10 @@ func frostRetainedGroupMutationFromWire( return FrostRetainedGroupMutation{}, fmt.Errorf("retained-group mutation exceeds field bounds") } operatorIDs := append([]uint32{}, mutation.OperatorIDs...) - seenOperatorIDs := make(map[uint32]bool, len(operatorIDs)) for _, operatorID := range operatorIDs { - if operatorID == 0 || seenOperatorIDs[operatorID] { - return FrostRetainedGroupMutation{}, fmt.Errorf("retained-group mutation has invalid or duplicate operator IDs") + if operatorID == 0 { + return FrostRetainedGroupMutation{}, fmt.Errorf("retained-group mutation has a zero operator ID") } - seenOperatorIDs[operatorID] = true } return FrostRetainedGroupMutation{ Point: point, diff --git a/pkg/tbtc/frost_retained_group_history_source_test.go b/pkg/tbtc/frost_retained_group_history_source_test.go index a751500e9f..ca1e0dbd7f 100644 --- a/pkg/tbtc/frost_retained_group_history_source_test.go +++ b/pkg/tbtc/frost_retained_group_history_source_test.go @@ -18,6 +18,7 @@ import ( "strings" "sync" "testing" + "time" ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" @@ -25,6 +26,7 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/keep-network/keep-core/pkg/chain" frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" + frostregistry "github.com/keep-network/keep-core/pkg/frost/registry" ) type frostRetainedGroupHistoryTestVerifier struct { @@ -193,6 +195,8 @@ type frostRetainedGroupHistorySourceFixture struct { descriptorSetHash [32]byte snapshotID [32]byte mutations []FrostRetainedGroupMutation + dkgFullMembers []uint32 + dkgMisbehaved []uint8 pageMutator func(*frostRetainedGroupHistoryPagePayload) operatorMutator func(*frostRetainedGroupOperatorReceiptPayload) } @@ -293,6 +297,10 @@ func newFrostRetainedGroupHistorySourceFixture( snapshotID: [32]byte{0x53}, } fixture.mutations = frostRetainedGroupHistoryTestMutations(t, headers, source.evidence) + fixture.dkgFullMembers = append( + []uint32{}, + fixture.mutations[0].OperatorIDs..., + ) fixture.installReceipts() export.historyResponder = fixture.historyResponse export.operatorResponder = fixture.operatorResponse @@ -427,20 +435,43 @@ func frostRetainedGroupHistoryTestDkgResult( evidence *frostRetainedGroupEvidenceProfile, walletID [32]byte, operatorIDs []uint32, +) (frostabi.FrostDkgResult, []byte, [32]byte) { + return frostRetainedGroupHistoryTestDkgResultWithMisbehaved( + t, + evidence, + walletID, + operatorIDs, + nil, + ) +} + +func frostRetainedGroupHistoryTestDkgResultWithMisbehaved( + t *testing.T, + evidence *frostRetainedGroupEvidenceProfile, + walletID [32]byte, + fullMembers []uint32, + misbehaved []uint8, ) (frostabi.FrostDkgResult, []byte, [32]byte) { t.Helper() - encodedMembers, err := evidence.membersArguments.Pack(operatorIDs) + activeMembers, err := frostregistry.ActiveMembersFromMisbehaved( + frostregistry.FullMembers(fullMembers), + frostregistry.MisbehavedMemberIndices(misbehaved), + ) + if err != nil { + t.Fatal(err) + } + activeMembersHash, err := frostregistry.ActiveMembersHash(activeMembers) if err != nil { t.Fatal(err) } result := frostabi.FrostDkgResult{ SubmitterMemberIndex: big.NewInt(1), XOnlyOutputKey: walletID, - MisbehavedMembersIndices: []uint8{}, + MisbehavedMembersIndices: append([]uint8{}, misbehaved...), Signatures: []byte{0x01, 0x02}, SigningMembersIndices: []*big.Int{big.NewInt(1)}, - Members: append([]uint32{}, operatorIDs...), - MembersHash: [32]byte(crypto.Keccak256Hash(encodedMembers)), + Members: append([]uint32{}, fullMembers...), + MembersHash: activeMembersHash, } data, err := evidence.registryABI.Events["DkgResultSubmitted"].Inputs.NonIndexed().Pack(result) if err != nil { @@ -456,12 +487,24 @@ func (fixture *frostRetainedGroupHistorySourceFixture) installReceipts() { fixture.t.Fatal(err) } admission := fixture.mutations[0] - _, dkgData, _ := frostRetainedGroupHistoryTestDkgResult( + dkgResult, dkgData, dkgResultHash := frostRetainedGroupHistoryTestDkgResultWithMisbehaved( fixture.t, evidence, admission.WalletID, - admission.OperatorIDs, + fixture.dkgFullMembers, + fixture.dkgMisbehaved, ) + admission.OperatorIDs = append( + []uint32{}, + mustFrostRetainedGroupActiveMembers( + fixture.t, + fixture.dkgFullMembers, + fixture.dkgMisbehaved, + )..., + ) + admission.RetainedGroupHash = dkgResult.MembersHash + admission.DkgResultHash = dkgResultHash + fixture.mutations[0] = admission submissionLog := frostRetainedGroupHistoryTestLog( admission.DkgSubmissionPoint, evidence.registry.address, @@ -553,6 +596,22 @@ func (fixture *frostRetainedGroupHistorySourceFixture) installReceipts() { frostRetainedGroupHistoryTestReceipt(closed.Point, closedLog, registryClosureLog) } +func mustFrostRetainedGroupActiveMembers( + t *testing.T, + fullMembers []uint32, + misbehaved []uint8, +) []uint32 { + t.Helper() + activeMembers, err := frostregistry.ActiveMembersFromMisbehaved( + frostregistry.FullMembers(fullMembers), + frostregistry.MisbehavedMemberIndices(misbehaved), + ) + if err != nil { + t.Fatal(err) + } + return append([]uint32{}, activeMembers...) +} + func frostRetainedGroupHistoryTestLog( point FrostRetainedGroupEventPoint, address common.Address, @@ -736,6 +795,89 @@ func TestSignedFrostRetainedGroupHistorySource_ReadsCompletePaginatedHistory( } } +func TestSignedFrostRetainedGroupHistorySource_DecodesCanonicalVerifiedBytes( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + raw := json.RawMessage("{\n \"z\": 1,\n \"a\": 2\n}") + canonical, err := canonicalFrostActivationValue(raw) + if err != nil { + t.Fatal(err) + } + payloadHash := sha256.Sum256(canonical) + signature := ed25519.Sign( + fixture.export.privateKey, + append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...), + ) + envelope := &frostRetainedGroupSignedEnvelope{ + Schema: "tbtc-frost-retained-group-signed-envelope/v1", + Payload: raw, + PayloadSHA256: frostActivationHex32(payloadHash), + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(fixture.export.publicKeyDER), + SignatureAlgorithm: "ed25519", + Signature: base64.StdEncoding.EncodeToString(signature), + } + capture := json.RawMessage{} + if err := fixture.source.verifySignedEnvelope(envelope, &capture); err != nil { + t.Fatal(err) + } + if !bytes.Equal(capture, canonical) { + t.Fatalf( + "signed payload decoder did not consume exact verified bytes\nexpected: %s\nactual: %s", + canonical, + capture, + ) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsDuplicateSignedPayloadKey( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + envelope := &frostRetainedGroupSignedEnvelope{ + Schema: "tbtc-frost-retained-group-signed-envelope/v1", + Payload: json.RawMessage(`{"schema":"v1","schema":"v2"}`), + PayloadSHA256: frostActivationHex32([32]byte{0x01}), + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(fixture.export.publicKeyDER), + SignatureAlgorithm: "ed25519", + Signature: base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)), + } + target := frostRetainedGroupHistoryPagePayload{} + err := fixture.source.verifySignedEnvelope(envelope, &target) + if err == nil || !strings.Contains(err.Error(), "duplicate key") { + t.Fatalf("expected duplicate signed payload key rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsNonExactSignedPayloadKey( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + payload := json.RawMessage(`{"Schema":"tbtc-frost-retained-group-history-page/v1"}`) + canonical, err := canonicalFrostActivationValue(payload) + if err != nil { + t.Fatal(err) + } + payloadHash := sha256.Sum256(canonical) + signature := ed25519.Sign( + fixture.export.privateKey, + append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...), + ) + envelope := &frostRetainedGroupSignedEnvelope{ + Schema: "tbtc-frost-retained-group-signed-envelope/v1", + Payload: payload, + PayloadSHA256: frostActivationHex32(payloadHash), + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(fixture.export.publicKeyDER), + SignatureAlgorithm: "ed25519", + Signature: base64.StdEncoding.EncodeToString(signature), + } + target := frostRetainedGroupHistoryPagePayload{} + err = fixture.source.verifySignedEnvelope(envelope, &target) + if err == nil || !strings.Contains(err.Error(), "non-exact or unknown key") { + t.Fatalf("expected non-exact signed payload key rejection, got [%v]", err) + } +} + func TestSignedFrostRetainedGroupHistorySource_RejectsOmittedPage(t *testing.T) { fixture := newFrostRetainedGroupHistorySourceFixture(t) fixture.export.historyResponder = func(request frostRetainedGroupHistoryPageRequest) interface{} { @@ -778,6 +920,79 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsTruncationAndForgery( }) } +func TestSignedFrostRetainedGroupHistorySource_EnforcesAggregateResourceLimits( + t *testing.T, +) { + testCases := map[string]struct { + configure func(*signedFrostRetainedGroupHistorySource) + expected string + }{ + "aggregate response bytes": { + configure: func(source *signedFrostRetainedGroupHistorySource) { + source.maximumResponseBytes = 1 + }, + expected: "aggregate response-byte limit", + }, + "page count": { + configure: func(source *signedFrostRetainedGroupHistorySource) { + source.maximumPages = 1 + }, + expected: "exceeded the page limit", + }, + "mutation count": { + configure: func(source *signedFrostRetainedGroupHistorySource) { + source.maximumMutations = 3 + }, + expected: "exceeds the mutation limit", + }, + "unique block count": { + configure: func(source *signedFrostRetainedGroupHistorySource) { + source.maximumUniqueBlocks = 2 + }, + expected: "exceeds the unique-block limit", + }, + "end-to-end duration": { + configure: func(source *signedFrostRetainedGroupHistorySource) { + source.maximumReadDuration = time.Nanosecond + }, + expected: "context deadline exceeded", + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + testCase.configure(fixture.source) + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + ) + if err == nil || !strings.Contains(err.Error(), testCase.expected) { + t.Fatalf("expected %s rejection, got [%v]", name, err) + } + }) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsOversizedReceiptLogSet( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + submissionHash := common.Hash( + fixture.mutations[0].DkgSubmissionPoint.TransactionHash, + ) + receipt := fixture.verifier.receipts[submissionHash] + receipt.Logs = make([]*types.Log, frostRetainedGroupMaximumReceiptLogs+1) + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + ) + if err == nil || !strings.Contains(err.Error(), "log limit") { + t.Fatalf("expected oversized receipt-log rejection, got [%v]", err) + } +} + func TestSignedFrostRetainedGroupHistorySource_RejectsDuplicateAndReorderedHistory( t *testing.T, ) { @@ -788,9 +1003,6 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsDuplicateAndReorderedHisto "reordered event": func(fixture *frostRetainedGroupHistorySourceFixture) { fixture.mutations[0], fixture.mutations[1] = fixture.mutations[1], fixture.mutations[0] }, - "duplicate DKG member": func(fixture *frostRetainedGroupHistorySourceFixture) { - fixture.mutations[0].OperatorIDs[1] = fixture.mutations[0].OperatorIDs[0] - }, } for name, mutate := range tests { t.Run(name, func(t *testing.T) { @@ -804,6 +1016,96 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsDuplicateAndReorderedHisto } } +func TestSignedFrostRetainedGroupHistorySource_AcceptsFilteredStakeWeightedSeats( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fullMembers := make([]uint32, 52) + for index := range fullMembers { + fullMembers[index] = uint32(index + 1) + } + // Repeated nonzero IDs are distinct stake-weighted sortition seats. The + // second seat is excluded using the contract's strict 1-based index. + fullMembers[51] = fullMembers[0] + fixture.dkgFullMembers = fullMembers + fixture.dkgMisbehaved = []uint8{2} + fixture.installReceipts() + + history, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + ) + if err != nil { + t.Fatal(err) + } + active := history.Mutations[0].OperatorIDs + if len(active) != 51 || active[0] != 1 || active[50] != 1 { + t.Fatalf("unexpected ordered active DKG members: [%v]", active) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsInvalidMisbehavedIndices( + t *testing.T, +) { + testCases := map[string][]uint8{ + "zero": {0}, + "duplicate": {2, 2}, + "not sorted": {3, 2}, + "out of range": { + 52, + }, + } + for name, misbehaved := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + evidence, err := fixture.source.activationEvidence() + if err != nil { + t.Fatal(err) + } + admission := &fixture.mutations[0] + result, _, _ := frostRetainedGroupHistoryTestDkgResult( + t, + evidence, + admission.WalletID, + fixture.dkgFullMembers, + ) + result.MisbehavedMembersIndices = append([]uint8{}, misbehaved...) + data, err := evidence.registryABI.Events["DkgResultSubmitted"].Inputs.NonIndexed().Pack(result) + if err != nil { + t.Fatal(err) + } + resultHash := [32]byte(crypto.Keccak256Hash(data)) + admission.DkgResultHash = resultHash + + submissionTransactionHash := common.Hash( + admission.DkgSubmissionPoint.TransactionHash, + ) + submissionReceipt := fixture.verifier.receipts[submissionTransactionHash] + submissionReceipt.Logs[0].Topics[1] = common.Hash(resultHash) + submissionReceipt.Logs[0].Data = data + approvalTransactionHash := common.Hash( + admission.DkgApprovalPoint.TransactionHash, + ) + approvalReceipt := fixture.verifier.receipts[approvalTransactionHash] + approvalReceipt.Logs[0].Topics[1] = common.Hash(resultHash) + approvalReceipt.Logs[1].Topics[2] = common.Hash(resultHash) + + _, err = fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + ) + if err == nil || !strings.Contains( + err.Error(), + "invalid misbehaved member indices", + ) { + t.Fatalf("expected strict 1-based index rejection, got [%v]", err) + } + }) + } +} + func TestSignedFrostRetainedGroupHistorySource_RejectsIdentityCheckpointAndReceiptDrift( t *testing.T, ) { diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index 2c02dae18c..776a847b4d 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -24,10 +24,10 @@ import ( ) const ( - frostRetainedGroupJournalMetadataSchema = "tbtc-frost-retained-group-journal-metadata/v1" - frostRetainedGroupJournalBatchSchema = "tbtc-frost-retained-group-journal-batch/v1" - frostRetainedGroupJournalStateSchema = "tbtc-frost-retained-group-journal-state/v1" - frostRetainedGroupJournalSnapshotSchema = "tbtc-frost-retained-group-journal-snapshot/v1" + frostRetainedGroupJournalMetadataSchema = "tbtc-frost-retained-group-journal-metadata/v2" + frostRetainedGroupJournalBatchSchema = "tbtc-frost-retained-group-journal-batch/v2" + frostRetainedGroupJournalStateSchema = "tbtc-frost-retained-group-journal-state/v2" + frostRetainedGroupJournalSnapshotSchema = "tbtc-frost-retained-group-journal-snapshot/v2" frostRetainedGroupJournalLockFile = ".lock" frostRetainedGroupJournalMetadataFile = "metadata.json" frostRetainedGroupJournalStateFile = "state.json" @@ -38,17 +38,24 @@ const ( frostRetainedGroupCanonicalDirectory = "canonical" frostRetainedGroupQuarantineDirectory = "quarantine" - frostRetainedGroupQuarantineMetadataSchema = "tbtc-frost-retained-group-quarantine-metadata/v1" - frostRetainedGroupQuarantineBatchSchema = "tbtc-frost-retained-group-quarantine-batch/v1" - frostRetainedGroupQuarantineStateSchema = "tbtc-frost-retained-group-quarantine-state/v1" + frostRetainedGroupQuarantineMetadataSchema = "tbtc-frost-retained-group-quarantine-metadata/v2" + frostRetainedGroupQuarantineBatchSchema = "tbtc-frost-retained-group-quarantine-batch/v2" + frostRetainedGroupQuarantineStateSchema = "tbtc-frost-retained-group-quarantine-state/v2" + + frostRetainedGroupJournalMetadataSchemaV1 = "tbtc-frost-retained-group-journal-metadata/v1" + frostRetainedGroupJournalBatchSchemaV1 = "tbtc-frost-retained-group-journal-batch/v1" + frostRetainedGroupJournalStateSchemaV1 = "tbtc-frost-retained-group-journal-state/v1" + frostRetainedGroupQuarantineMetadataV1 = "tbtc-frost-retained-group-quarantine-metadata/v1" + frostRetainedGroupQuarantineBatchV1 = "tbtc-frost-retained-group-quarantine-batch/v1" + frostRetainedGroupQuarantineStateV1 = "tbtc-frost-retained-group-quarantine-state/v1" frostRetainedGroupInventoryEntriesDomain = "tbtc-p2tr-frost-wallet-group-inventory-entries-v1\x00" frostRetainedGroupInventoryLeafDomain = "tbtc-p2tr-frost-wallet-group-inventory-leaf-v1\x00" frostRetainedGroupInventoryNodeDomain = "tbtc-p2tr-frost-wallet-group-inventory-node-v1\x00" frostRetainedGroupInventoryRootDomain = "tbtc-p2tr-frost-wallet-group-inventory-root-v1\x00" - frostRetainedGroupBatchDomain = "tbtc-frost-retained-group-journal-batch-v1\x00" - frostRetainedGroupQuarantineBatchDomain = "tbtc-frost-retained-group-quarantine-batch-v1\x00" - frostRetainedGroupQuarantineDomain = "tbtc-frost-retained-group-quarantine-v1\x00" + frostRetainedGroupBatchDomain = "tbtc-frost-retained-group-journal-batch-v2\x00" + frostRetainedGroupQuarantineBatchDomain = "tbtc-frost-retained-group-quarantine-batch-v2\x00" + frostRetainedGroupQuarantineDomain = "tbtc-frost-retained-group-quarantine-v2\x00" ) // FrostRetainedGroupEventPoint identifies one canonical Ethereum log. The @@ -553,6 +560,9 @@ func (frgj *frostRetainedGroupJournal) initialize() error { if err := frgj.readEnvelope(frostRetainedGroupJournalMetadataFile, &stored); err != nil { return fmt.Errorf("cannot read FROST retained-group journal metadata: [%w]", err) } + if stored.Schema == frostRetainedGroupJournalMetadataSchemaV1 { + return frostRetainedGroupLegacySchemaError("canonical metadata") + } if stored != frgj.metadata { return fmt.Errorf("FROST retained-group journal metadata differs from signed manifest") } @@ -580,6 +590,9 @@ func (frgj *frostRetainedGroupJournal) initialize() error { if err := frgj.readEnvelope(frostRetainedGroupJournalStateFile, &stored); err != nil { return fmt.Errorf("cannot read FROST retained-group journal state: [%w]", err) } + if stored.Schema == frostRetainedGroupJournalStateSchemaV1 { + return frostRetainedGroupLegacySchemaError("canonical state") + } if stored.Schema != frostRetainedGroupJournalStateSchema { return fmt.Errorf("unsupported FROST retained-group journal state schema") } @@ -712,6 +725,9 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { ); err != nil { return fmt.Errorf("cannot read FROST retained-group quarantine metadata: [%w]", err) } + if stored.Schema == frostRetainedGroupQuarantineMetadataV1 { + return frostRetainedGroupLegacySchemaError("quarantine metadata") + } if stored != frgj.quarantineMetadata { return fmt.Errorf("FROST retained-group quarantine metadata differs from signed manifest") } @@ -744,6 +760,9 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { ); err != nil { return fmt.Errorf("cannot read FROST retained-group quarantine state: [%w]", err) } + if stored.Schema == frostRetainedGroupQuarantineStateV1 { + return frostRetainedGroupLegacySchemaError("quarantine state") + } if stored.Schema != frostRetainedGroupQuarantineStateSchema { return fmt.Errorf("unsupported FROST retained-group quarantine state schema") } @@ -829,10 +848,21 @@ func frostRetainedGroupBatchFileName(sequence uint64) string { return fmt.Sprintf("%s%020d%s", frostRetainedGroupJournalBatchPrefix, sequence, frostRetainedGroupJournalFileSuffix) } +func frostRetainedGroupLegacySchemaError(component string) error { + return fmt.Errorf( + "legacy FROST retained-group %s schema v1 is not safely migratable; "+ + "the signed activation manifest must provision a new empty v2 store identity", + component, + ) +} + func validateFrostRetainedGroupBatch( batch frostRetainedGroupJournalBatch, prior frostRetainedGroupJournalState, ) error { + if batch.Schema == frostRetainedGroupJournalBatchSchemaV1 { + return frostRetainedGroupLegacySchemaError("canonical batch") + } if batch.Schema != frostRetainedGroupJournalBatchSchema || batch.Sequence != prior.BatchSequence+1 || batch.From != prior.CurrentPoint || batch.PriorBatchRoot != prior.BatchRoot || batch.To.BlockNumber < batch.From.BlockNumber || @@ -863,6 +893,9 @@ func validateFrostRetainedGroupQuarantineBatch( batch frostRetainedGroupQuarantineJournalBatch, prior frostRetainedGroupQuarantineJournalState, ) error { + if batch.Schema == frostRetainedGroupQuarantineBatchV1 { + return frostRetainedGroupLegacySchemaError("quarantine batch") + } if batch.Schema != frostRetainedGroupQuarantineBatchSchema || batch.Sequence != prior.BatchSequence+1 || batch.From != prior.CurrentPoint || batch.PriorBatchRoot != prior.BatchRoot || batch.To.BlockNumber < batch.From.BlockNumber || @@ -1227,13 +1260,26 @@ func applyFrostRetainedGroupMutations( if state == nil { return fmt.Errorf("FROST retained-group state is nil") } + if len(state.Wallets) > frostRetainedGroupMaximumWallets { + return fmt.Errorf("FROST retained-group wallet state exceeds the wallet limit") + } wallets := make(map[[32]byte]frostRetainedGroupWalletState, len(state.Wallets)) + publicKeyHashes := make( + map[[20]byte][32]byte, + len(state.Wallets), + ) for _, wallet := range state.Wallets { - if wallet.WalletID == [32]byte{} || wallets[wallet.WalletID].WalletID != [32]byte{} { + if wallet.WalletID == [32]byte{} || + wallet.WalletPublicKeyHash == [20]byte{} || + wallets[wallet.WalletID].WalletID != [32]byte{} { return fmt.Errorf("FROST retained-group wallet state is duplicate or invalid") } + if _, exists := publicKeyHashes[wallet.WalletPublicKeyHash]; exists { + return fmt.Errorf("FROST retained-group wallet state repeats a public-key hash") + } wallet.OperatorIDs = append([]uint32{}, wallet.OperatorIDs...) wallets[wallet.WalletID] = wallet + publicKeyHashes[wallet.WalletPublicKeyHash] = wallet.WalletID } var previous FrostRetainedGroupEventPoint for index, mutation := range mutations { @@ -1263,18 +1309,16 @@ func applyFrostRetainedGroupMutations( wallet.WalletID != [32]byte{} || mutation.QuarantineID != [32]byte{} { return fmt.Errorf("invalid or duplicate FROST retained-group admission") } - for _, existingWallet := range wallets { - if existingWallet.WalletID != [32]byte{} && - existingWallet.WalletPublicKeyHash == mutation.WalletPublicKeyHash { - return fmt.Errorf("FROST retained-group admission reuses a wallet public-key hash") - } + if len(wallets) >= frostRetainedGroupMaximumWallets { + return fmt.Errorf("FROST retained-group admission exceeds the wallet limit") + } + if _, exists := publicKeyHashes[mutation.WalletPublicKeyHash]; exists { + return fmt.Errorf("FROST retained-group admission reuses a wallet public-key hash") } - seenOperatorIDs := make(map[uint32]bool, len(mutation.OperatorIDs)) for _, operatorID := range mutation.OperatorIDs { - if operatorID == 0 || seenOperatorIDs[operatorID] { - return fmt.Errorf("FROST retained-group admission contains zero or duplicate operator ID") + if operatorID == 0 { + return fmt.Errorf("FROST retained-group admission contains zero operator ID") } - seenOperatorIDs[operatorID] = true } wallets[mutation.WalletID] = frostRetainedGroupWalletState{ WalletID: mutation.WalletID, @@ -1287,6 +1331,7 @@ func applyFrostRetainedGroupMutations( LifecyclePoint: mutation.BridgeRegistrationPoint, LastBridgePoint: mutation.Point, } + publicKeyHashes[mutation.WalletPublicKeyHash] = mutation.WalletID state.SnapshotGeneration++ case FrostRetainedGroupMovingFundsMutation, FrostRetainedGroupClosingMutation, @@ -1491,6 +1536,11 @@ func appendFrostRetainedGroupQuarantineRoot( func frostRetainedGroupInventoryRoot( state frostRetainedGroupJournalState, ) ([32]byte, uint64, uint64, uint64, error) { + if len(state.Wallets) > frostRetainedGroupMaximumWallets { + return [32]byte{}, 0, 0, 0, fmt.Errorf( + "FROST retained-group inventory exceeds the wallet limit", + ) + } type inventoryEventPoint struct { BlockNumber uint64 `json:"blockNumber"` BlockHash string `json:"blockHash"` @@ -1645,6 +1695,12 @@ func (frgj *frostRetainedGroupJournal) reconcile( if ctx == nil { return nil, fmt.Errorf("FROST retained-group reconciliation context is nil") } + reconciliationContext, cancel := context.WithTimeout( + ctx, + frostRetainedGroupMaximumReconciliationDuration, + ) + defer cancel() + ctx = reconciliationContext frgj.mutex.Lock() defer frgj.mutex.Unlock() if frgj.closed { @@ -1691,6 +1747,9 @@ func (frgj *frostRetainedGroupJournal) reconcile( history.To != target || history.DescriptorSetHash != frgj.metadata.DescriptorSetHash { return nil, fmt.Errorf("FROST retained-group history receipt is incomplete or differently bound") } + if len(history.Mutations) > frostRetainedGroupMaximumMutations { + return nil, fmt.Errorf("FROST retained-group history exceeds the mutation limit") + } if err := validateCompleteFrostRetainedGroupHistory(history); err != nil { return nil, err } @@ -1895,6 +1954,12 @@ func (frgj *frostRetainedGroupJournal) reconcile( func validateCompleteFrostRetainedGroupHistory( history *FrostRetainedGroupHistory, ) error { + if history == nil { + return fmt.Errorf("complete FROST retained-group history is nil") + } + if len(history.Mutations) > frostRetainedGroupMaximumMutations { + return fmt.Errorf("complete FROST retained-group history exceeds the mutation limit") + } var previous FrostRetainedGroupEventPoint for index, mutation := range history.Mutations { if !mutation.Point.valid() || mutation.Point.BlockNumber <= history.From.BlockNumber || diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index 49b42afc79..ca4d4107f6 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -5,6 +5,8 @@ import ( "context" "crypto/ecdsa" "crypto/elliptic" + "crypto/sha256" + "encoding/binary" "encoding/json" "fmt" "os" @@ -300,6 +302,24 @@ func (fixture *journalTestFixture) openJournal( return journal } +func (fixture *journalTestFixture) openJournalError( + directory string, +) error { + journal, err := newFrostRetainedGroupJournal( + directory, + fixture.manifestHash, + fixture.manifest, + fixture.quarantine, + fixture.source, + fixture.registry, + fixture.localOperator, + ) + if journal != nil { + _ = journal.close() + } + return err +} + func TestFrostRetainedGroupJournal_ReconcilesAndRejectsRewrittenHistory( t *testing.T, ) { @@ -368,6 +388,171 @@ func TestFrostRetainedGroupJournal_IntegratesCommittedOrphanBatchExactlyOnce( } } +func TestFrostRetainedGroupJournal_RejectsAuthenticatedV1Fixtures( + t *testing.T, +) { + testCases := map[string]func(*testing.T, string){ + "canonical metadata": func(t *testing.T, directory string) { + path := filepath.Join(directory, frostRetainedGroupCanonicalDirectory) + metadata := frostRetainedGroupJournalMetadata{} + if err := readFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalMetadataFile, + &metadata, + ); err != nil { + t.Fatal(err) + } + metadata.Schema = frostRetainedGroupJournalMetadataSchemaV1 + if err := persistFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalMetadataFile, + &metadata, + true, + ); err != nil { + t.Fatal(err) + } + }, + "canonical state": func(t *testing.T, directory string) { + path := filepath.Join(directory, frostRetainedGroupCanonicalDirectory) + state := frostRetainedGroupJournalState{} + if err := readFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalStateFile, + &state, + ); err != nil { + t.Fatal(err) + } + state.Schema = frostRetainedGroupJournalStateSchemaV1 + if err := persistFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalStateFile, + &state, + true, + ); err != nil { + t.Fatal(err) + } + }, + "canonical batch with pre-evidence admission": func(t *testing.T, directory string) { + path := filepath.Join(directory, frostRetainedGroupCanonicalDirectory) + name := frostRetainedGroupBatchFileName(1) + batch := frostRetainedGroupJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt(path, name, &batch); err != nil { + t.Fatal(err) + } + batch.Schema = frostRetainedGroupJournalBatchSchemaV1 + for index := range batch.Mutations { + batch.Mutations[index].DkgResultHash = [32]byte{} + batch.Mutations[index].DkgSubmissionPoint = FrostRetainedGroupEventPoint{} + batch.Mutations[index].DkgApprovalPoint = FrostRetainedGroupEventPoint{} + } + batch.Checksum = [32]byte{} + payload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + t.Fatal(err) + } + batch.Checksum = sha256.Sum256(payload) + if err := persistFrostRetainedGroupEnvelopeAt(path, name, &batch, true); err != nil { + t.Fatal(err) + } + }, + "quarantine metadata": func(t *testing.T, directory string) { + path := filepath.Join(directory, frostRetainedGroupQuarantineDirectory) + metadata := frostRetainedGroupQuarantineMetadata{} + if err := readFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalMetadataFile, + &metadata, + ); err != nil { + t.Fatal(err) + } + metadata.Schema = frostRetainedGroupQuarantineMetadataV1 + if err := persistFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalMetadataFile, + &metadata, + true, + ); err != nil { + t.Fatal(err) + } + }, + "quarantine state": func(t *testing.T, directory string) { + path := filepath.Join(directory, frostRetainedGroupQuarantineDirectory) + state := frostRetainedGroupQuarantineJournalState{} + if err := readFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalStateFile, + &state, + ); err != nil { + t.Fatal(err) + } + state.Schema = frostRetainedGroupQuarantineStateV1 + if err := persistFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalStateFile, + &state, + true, + ); err != nil { + t.Fatal(err) + } + }, + "quarantine batch": func(t *testing.T, directory string) { + path := filepath.Join(directory, frostRetainedGroupQuarantineDirectory) + name := frostRetainedGroupBatchFileName(1) + batch := frostRetainedGroupQuarantineJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt(path, name, &batch); err != nil { + t.Fatal(err) + } + batch.Schema = frostRetainedGroupQuarantineBatchV1 + batch.Checksum = [32]byte{} + payload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + t.Fatal(err) + } + batch.Checksum = sha256.Sum256(payload) + if err := persistFrostRetainedGroupEnvelopeAt(path, name, &batch, true); err != nil { + t.Fatal(err) + } + }, + } + + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newJournalTestFixture(t) + quarantine := FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, + TransactionHash: [32]byte{0xa5}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupQuarantineMutation, + WalletID: fixture.walletID, + QuarantineID: [32]byte{0x61}, + EvidenceHash: [32]byte{0x62}, + Reason: "authenticated v1 fixture", + } + fixture.source.mutations = append(fixture.source.mutations, quarantine) + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + if _, err := journal.reconcile(context.Background(), fixture.target); err != nil { + t.Fatal(err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + mutate(t, directory) + err := fixture.openJournalError(directory) + if err == nil || + !strings.Contains(err.Error(), "schema v1 is not safely migratable") || + !strings.Contains(err.Error(), "signed activation manifest") { + t.Fatalf("expected explicit manifest-pinned v1 rejection, got [%v]", err) + } + }) + } +} + func TestFrostRetainedGroupJournal_QuarantineAndAuthenticatedLiftAreIndependent( t *testing.T, ) { @@ -531,7 +716,7 @@ func TestApplyFrostRetainedGroupMutations_EnforcesLifecycleAndRegistryClosure( } } -func TestApplyFrostRetainedGroupMutations_RejectsDuplicateAdmissionMember( +func TestApplyFrostRetainedGroupMutations_AllowsRepeatedStakeWeightedSeats( t *testing.T, ) { fixture := newJournalTestFixture(t) @@ -546,11 +731,132 @@ func TestApplyFrostRetainedGroupMutations_RejectsDuplicateAdmissionMember( if err := applyFrostRetainedGroupMutations( &state, []FrostRetainedGroupMutation{duplicate}, - ); err == nil || !strings.Contains(err.Error(), "duplicate operator ID") { - t.Fatalf("expected duplicate admission member rejection, got [%v]", err) + ); err != nil { + t.Fatalf("expected repeated operator seats to be retained, got [%v]", err) + } + if len(state.Wallets) != 1 || + state.Wallets[0].OperatorIDs[0] != state.Wallets[0].OperatorIDs[1] { + t.Fatalf("repeated stake-weighted seats were not preserved: [%+v]", state.Wallets) + } +} + +func TestApplyFrostRetainedGroupMutations_EnforcesWalletLimit(t *testing.T) { + state := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + CurrentPoint: FrostPreSignFinality{BlockNumber: 1, BlockHash: [32]byte{1}}, + Wallets: []frostRetainedGroupWalletState{}, + } + err := applyFrostRetainedGroupMutations( + &state, + journalTestBoundedAdmissions(frostRetainedGroupMaximumWallets+1), + ) + if err == nil || !strings.Contains(err.Error(), "wallet limit") { + t.Fatalf("expected retained-wallet limit rejection, got [%v]", err) + } +} + +func TestValidateCompleteFrostRetainedGroupHistory_EnforcesMutationLimit( + t *testing.T, +) { + history := &FrostRetainedGroupHistory{ + From: FrostPreSignFinality{BlockNumber: 1, BlockHash: [32]byte{1}}, + To: FrostPreSignFinality{BlockNumber: 2, BlockHash: [32]byte{2}}, + Mutations: make( + []FrostRetainedGroupMutation, + frostRetainedGroupMaximumMutations+1, + ), + } + err := validateCompleteFrostRetainedGroupHistory(history) + if err == nil || !strings.Contains(err.Error(), "mutation limit") { + t.Fatalf("expected aggregate mutation limit rejection, got [%v]", err) } } +func BenchmarkApplyFrostRetainedGroupMutations_MaximumWalletSet( + b *testing.B, +) { + mutations := journalTestBoundedAdmissions( + frostRetainedGroupMaximumWallets, + ) + b.ResetTimer() + for iteration := 0; iteration < b.N; iteration++ { + state := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + CurrentPoint: FrostPreSignFinality{ + BlockNumber: 1, + BlockHash: [32]byte{1}, + }, + Wallets: []frostRetainedGroupWalletState{}, + } + if err := applyFrostRetainedGroupMutations( + &state, + mutations, + ); err != nil { + b.Fatal(err) + } + } +} + +func journalTestBoundedAdmissions( + count int, +) []FrostRetainedGroupMutation { + result := make([]FrostRetainedGroupMutation, count) + operatorIDs := make([]uint32, 51) + for index := range operatorIDs { + operatorIDs[index] = uint32((index % 17) + 1) + } + for index := range result { + identifier := uint64(index + 1) + walletID := [32]byte{0xa1} + binary.BigEndian.PutUint64(walletID[24:], identifier) + walletPublicKeyHash := [20]byte{0xa2} + binary.BigEndian.PutUint64(walletPublicKeyHash[12:], identifier) + blockNumber := uint64(index + 2) + blockHash := [32]byte{0xa3} + binary.BigEndian.PutUint64(blockHash[24:], blockNumber) + submissionTransaction := [32]byte{0xa4} + binary.BigEndian.PutUint64(submissionTransaction[24:], identifier) + admissionTransaction := [32]byte{0xa5} + binary.BigEndian.PutUint64(admissionTransaction[24:], identifier) + submission := FrostRetainedGroupEventPoint{ + BlockNumber: blockNumber, + BlockHash: blockHash, + TransactionHash: submissionTransaction, + TransactionIndex: 0, + LogIndex: 1, + } + approval := FrostRetainedGroupEventPoint{ + BlockNumber: blockNumber, + BlockHash: blockHash, + TransactionHash: admissionTransaction, + TransactionIndex: 1, + LogIndex: 1, + } + creation := approval + creation.LogIndex = 2 + registration := approval + registration.LogIndex = 3 + retainedGroupHash := [32]byte{0xa6} + binary.BigEndian.PutUint64(retainedGroupHash[24:], identifier) + dkgResultHash := [32]byte{0xa7} + binary.BigEndian.PutUint64(dkgResultHash[24:], identifier) + result[index] = FrostRetainedGroupMutation{ + Point: registration, + Kind: FrostRetainedGroupAdmissionMutation, + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + OperatorIDs: append([]uint32{}, operatorIDs...), + RetainedGroupHash: retainedGroupHash, + DkgResultHash: dkgResultHash, + DkgSubmissionPoint: submission, + DkgApprovalPoint: approval, + CreationPoint: creation, + BridgeRegistrationPoint: registration, + } + } + return result +} + func lifecycleMutation( fixture *journalTestFixture, block uint64, From a3cf5772f133db97bab882310f98b990bfe50450 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 18:13:23 -0400 Subject: [PATCH 25/93] Bind retained FROST activation transcripts --- .../tbtc_frost_pre_sign_authorization.go | 102 ++ pkg/tbtc/frost_activation_handshake.go | 32 +- pkg/tbtc/frost_activation_handshake_test.go | 31 +- pkg/tbtc/frost_pre_sign_authorization.go | 1 + .../frost_retained_group_history_evidence.go | 951 ++++++++++++++++-- .../frost_retained_group_history_source.go | 283 +++++- ...rost_retained_group_history_source_test.go | 780 +++++++++++++- pkg/tbtc/frost_retained_group_journal.go | 18 + pkg/tbtc/frost_retained_group_journal_test.go | 117 +++ pkg/tbtc/node.go | 22 +- 10 files changed, 2207 insertions(+), 130 deletions(-) diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index f953b73c87..abfd03f05f 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -2221,6 +2221,12 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( if err != nil { return tbtc.FrostPreSignActivationRuntimeManifest{}, err } + endpointIdentitySetHash, err := frostPreSignEndpointIdentitySetHash( + adapter.manifest, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } quarantine := frost.QuarantineJournal quarantineJournalProtocolID, err := parse(quarantine.ProtocolID) if err != nil { @@ -2272,6 +2278,7 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( ProfileHash: adapter.profile.ProfileHash, ImplementationSetHash: adapter.profile.ImplementationSetHash, LinkedLibraryDescriptorSetHash: linkedLibraryDescriptorSetHash, + EndpointIdentitySetHash: endpointIdentitySetHash, Deployments: frostPreSignRuntimeDeploymentEvidence(adapter.deployments), SignerProtocolID: signerProtocolID, ReservationProtocolID: adapter.profile.ReservationProtocolID, @@ -2310,6 +2317,101 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( }, nil } +func frostPreSignEndpointIdentitySetHash( + manifest frostPreSignActivationManifest, +) ([32]byte, error) { + ethereum := manifest.Ethereum + frost := manifest.FrostSigner + type identity struct { + role string + trustDomainID string + endpointFingerprint string + operatorFingerprint string + storeID string + storeFingerprint string + clusterFingerprint string + } + identities := []identity{ + { + role: "ethereum-source", + trustDomainID: ethereum.SourceTrustDomainID, + endpointFingerprint: ethereum.SourceEndpointFingerprint, + operatorFingerprint: ethereum.SourceOperatorFingerprint, + storeID: ethereum.SourceHistoryStoreID, + storeFingerprint: ethereum.SourceHistoryStoreFingerprint, + }, + { + role: "ethereum-verifier", + trustDomainID: ethereum.VerifierTrustDomainID, + endpointFingerprint: ethereum.VerifierEndpointFingerprint, + operatorFingerprint: ethereum.VerifierOperatorFingerprint, + storeID: ethereum.VerifierHistoryStoreID, + storeFingerprint: ethereum.VerifierHistoryStoreFingerprint, + }, + { + role: "retained-group-export", + trustDomainID: frost.CanonicalJournal.SourceTrustDomainID, + endpointFingerprint: frost.CanonicalJournal.SourceEndpointFingerprint, + operatorFingerprint: frost.CanonicalJournal.SourceOperatorFingerprint, + storeID: frost.CanonicalJournal.StoreID, + storeFingerprint: frost.CanonicalJournal.StoreFingerprint, + clusterFingerprint: frost.CanonicalJournal.ClusterFingerprint, + }, + { + role: "runtime-handshake", + trustDomainID: frost.TrustDomainID, + endpointFingerprint: frost.HandshakeEndpointFingerprint, + operatorFingerprint: frost.HandshakeOperatorFingerprint, + }, + } + hasher := sha256.New() + hasher.Write([]byte("tbtc-frost-endpoint-identity-set-v1\x00")) + for _, entry := range identities { + endpointFingerprint, err := frostPreSignParseBytes32( + entry.endpointFingerprint, + ) + if err != nil { + return [32]byte{}, err + } + operatorFingerprint, err := frostPreSignParseBytes32( + entry.operatorFingerprint, + ) + if err != nil { + return [32]byte{}, err + } + frostPreSignWriteHashString(hasher, entry.role) + frostPreSignWriteHashString(hasher, entry.trustDomainID) + hasher.Write(endpointFingerprint[:]) + hasher.Write(operatorFingerprint[:]) + frostPreSignWriteHashString(hasher, entry.storeID) + if entry.storeFingerprint == "" { + hasher.Write(make([]byte, 32)) + } else { + storeFingerprint, err := frostPreSignParseBytes32( + entry.storeFingerprint, + ) + if err != nil { + return [32]byte{}, err + } + hasher.Write(storeFingerprint[:]) + } + if entry.clusterFingerprint == "" { + hasher.Write(make([]byte, 32)) + } else { + clusterFingerprint, err := frostPreSignParseBytes32( + entry.clusterFingerprint, + ) + if err != nil { + return [32]byte{}, err + } + hasher.Write(clusterFingerprint[:]) + } + } + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result, nil +} + func frostPreSignRuntimeDeploymentEvidence( deployments []frostPreSignDeploymentPin, ) []tbtc.FrostPreSignDeploymentEvidence { diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 1bd2375e47..4c630785e7 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -41,6 +41,7 @@ type frostActivationEthereumPoint struct { type frostActivationChallenge struct { Nonce string `json:"nonce"` ManifestHash string `json:"manifestHash"` + BindingHash string `json:"bindingHash"` EthereumPoint frostActivationEthereumPoint `json:"ethereumPoint"` } @@ -53,6 +54,7 @@ type frostActivationCanonicalJournalState struct { StoreID string `json:"storeID"` StoreFingerprint string `json:"storeFingerprint"` ClusterFingerprint string `json:"clusterFingerprint"` + BindingHash string `json:"bindingHash"` Checkpoint frostActivationEthereumPoint `json:"checkpoint"` Current frostActivationEthereumPoint `json:"current"` DescriptorSetHash string `json:"descriptorSetHash"` @@ -139,6 +141,7 @@ type frostActivationHandshakePayload struct { Kind string `json:"kind"` Nonce string `json:"nonce"` ManifestHash string `json:"manifestHash"` + BindingHash string `json:"bindingHash"` EthereumPoint frostActivationEthereumPoint `json:"ethereumPoint"` State frostActivationHandshakeState `json:"state"` } @@ -154,6 +157,7 @@ type frostActivationHandshakeExporter struct { privateKey ed25519.PrivateKey publicKeySPKI string manifest FrostPreSignActivationRuntimeManifest + bindingHash [32]byte pointVerifier FrostPreSignActivationPointVerifier storeBinding *frostDurableSessionStoreBinding outbox *bitcoinBroadcastOutbox @@ -172,6 +176,7 @@ func newFrostActivationHandshakeExporter( pointVerifier FrostPreSignActivationPointVerifier, storeBinding *frostDurableSessionStoreBinding, outbox *bitcoinBroadcastOutbox, + journal *frostRetainedGroupJournal, readiness frostProductionSignerReadinessVerifier, ) (*frostActivationHandshakeExporter, error) { parsedEndpoint, err := validateFrostActivationHandshakeEndpoint(endpoint) @@ -191,7 +196,8 @@ func newFrostActivationHandshakeExporter( durableSessionStoreFingerprintErr != nil || durableSessionStoreFingerprint == [32]byte{} || durableSessionStoreFingerprint == manifest.CanonicalJournal.StoreFingerprint || durableSessionStoreFingerprint == manifest.QuarantineJournal.StoreFingerprint || - pointVerifier == nil || storeBinding == nil || outbox == nil || readiness == nil { + pointVerifier == nil || storeBinding == nil || outbox == nil || + journal == nil || readiness == nil { return nil, fmt.Errorf("FROST activation handshake dependencies are invalid") } boundStoreFingerprint, err := storeBinding.verify() @@ -207,11 +213,26 @@ func newFrostActivationHandshakeExporter( if sha256.Sum256(publicKeyDER) != manifest.AttestationSignerKeyHash { return nil, fmt.Errorf("FROST activation attestation key differs from signed manifest") } + journal.mutex.Lock() + bindingHash := journal.metadata.BindingHash + journalBindingValid := bindingHash != [32]byte{} && + journal.metadata.ManifestHash == manifest.ManifestHash && + journal.quarantineMetadata.ManifestHash == manifest.ManifestHash && + journal.quarantineMetadata.BindingHash == bindingHash && + journal.state.BindingHash == bindingHash && + journal.quarantineState.BindingHash == bindingHash + journal.mutex.Unlock() + if !journalBindingValid { + return nil, fmt.Errorf( + "FROST activation handshake journal binding differs from signed runtime state", + ) + } exporter := &frostActivationHandshakeExporter{ endpoint: parsedEndpoint, privateKey: privateKey, publicKeySPKI: base64.StdEncoding.EncodeToString(publicKeyDER), manifest: manifest, + bindingHash: bindingHash, pointVerifier: pointVerifier, storeBinding: storeBinding, outbox: outbox, @@ -397,6 +418,10 @@ func (fahe *frostActivationHandshakeExporter) attest( if err != nil || manifestHash != fahe.manifest.ManifestHash { return nil, fmt.Errorf("FROST activation challenge manifest hash mismatch") } + bindingHash, err := parseFrostActivationHex32(request.Challenge.BindingHash) + if err != nil || bindingHash != fahe.bindingHash { + return nil, fmt.Errorf("FROST activation challenge binding hash mismatch") + } blockHash, err := parseFrostActivationHex32(request.Challenge.EthereumPoint.BlockHash) if err != nil || request.Challenge.EthereumPoint.BlockNumber == 0 { return nil, fmt.Errorf("FROST activation challenge Ethereum point is invalid") @@ -450,7 +475,8 @@ func (fahe *frostActivationHandshakeExporter) attest( } journalManifest := fahe.manifest.CanonicalJournal quarantineManifest := fahe.manifest.QuarantineJournal - if !journalSnapshot.Complete || journalSnapshot.CurrentPoint != finality || + if journalSnapshot.BindingHash != fahe.bindingHash || + !journalSnapshot.Complete || journalSnapshot.CurrentPoint != finality || journalSnapshot.StoreID != journalManifest.StoreID || journalSnapshot.StoreFingerprint != journalManifest.StoreFingerprint || journalSnapshot.ClusterFingerprint != journalManifest.ClusterFingerprint || @@ -508,6 +534,7 @@ func (fahe *frostActivationHandshakeExporter) attest( StoreID: journalSnapshot.StoreID, StoreFingerprint: frostActivationHex32(journalSnapshot.StoreFingerprint), ClusterFingerprint: frostActivationHex32(journalSnapshot.ClusterFingerprint), + BindingHash: frostActivationHex32(journalSnapshot.BindingHash), Checkpoint: frostActivationEthereumPoint{ BlockNumber: journalManifest.Checkpoint.BlockNumber, BlockHash: frostActivationHex32(journalManifest.Checkpoint.BlockHash), @@ -567,6 +594,7 @@ func (fahe *frostActivationHandshakeExporter) attest( Kind: "frost-signer", Nonce: request.Challenge.Nonce, ManifestHash: request.Challenge.ManifestHash, + BindingHash: request.Challenge.BindingHash, EthereumPoint: request.Challenge.EthereumPoint, State: state, } diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index ee61a570b4..46366aaa5f 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -96,15 +96,22 @@ func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSigner func (source *testFrostRetainedGroupHistorySource) BindFrostRetainedGroupActivationEvidence( _ FrostPreSignActivationProfile, - _ [32]byte, - descriptorSetHash [32]byte, + runtimeManifest FrostPreSignActivationRuntimeManifest, ) error { - if descriptorSetHash != source.manifest.DescriptorSetHash { + if runtimeManifest.CanonicalJournal.DescriptorSetHash != + source.manifest.DescriptorSetHash { return fmt.Errorf("descriptor set mismatch") } return nil } +func (source *testFrostRetainedGroupHistorySource) FrostRetainedGroupProtocolBindingHash() ( + [32]byte, + error, +) { + return source.manifest.DescriptorSetHash, nil +} + func (source *testFrostRetainedGroupHistorySource) Identity( context.Context, ) (FrostRetainedGroupHistoryIdentity, error) { @@ -194,6 +201,7 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { verifier, testFrostDurableSessionStoreBinding(t), outbox, + journal, readiness, ) if err != nil { @@ -212,6 +220,7 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { Challenge: frostActivationChallenge{ Nonce: frostActivationHex32(nonce), ManifestHash: frostActivationHex32(manifest.ManifestHash), + BindingHash: frostActivationHex32(journal.metadata.BindingHash), EthereumPoint: point, }, } @@ -228,6 +237,9 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { if handshake.Payload.Kind != "frost-signer" || handshake.Payload.Nonce != request.Challenge.Nonce || handshake.Payload.ManifestHash != request.Challenge.ManifestHash || + handshake.Payload.BindingHash != request.Challenge.BindingHash || + handshake.Payload.State.CanonicalJournal.BindingHash != + request.Challenge.BindingHash || !handshake.Payload.State.Healthy || !handshake.Payload.State.InteractiveSigningReady || !handshake.Payload.State.NonceShareGateEnforced || @@ -264,8 +276,8 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { "snapshotGeneration", "walletCount", }) assertFrostActivationObjectKeys(t, handshake.Payload.State.CanonicalJournal, []string{ - "checkpoint", "clusterFingerprint", "complete", "current", "descriptorSetHash", - "generation", "sourceEndpointFingerprint", "sourceOperatorFingerprint", + "bindingHash", "checkpoint", "clusterFingerprint", "complete", "current", + "descriptorSetHash", "generation", "sourceEndpointFingerprint", "sourceOperatorFingerprint", "sourceTrustDomainID", "storeFingerprint", "storeID", }) assertFrostActivationObjectKeys(t, handshake.Payload.State.QuarantineJournal, []string{ @@ -354,6 +366,7 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { &testFrostActivationPointVerifier{}, testFrostDurableSessionStoreBinding(t), outbox, + journal, readiness, ) if err != nil { @@ -371,6 +384,7 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { Challenge: frostActivationChallenge{ Nonce: frostActivationHex32([32]byte{0x22}), ManifestHash: frostActivationHex32(manifest.ManifestHash), + BindingHash: frostActivationHex32(journal.metadata.BindingHash), EthereumPoint: point, }, } @@ -512,8 +526,10 @@ func testFrostRetainedGroupJournal( t.Fatal(err) } target := FrostPreSignFinality{BlockNumber: point.BlockNumber, BlockHash: blockHash} + bindingHash := [32]byte{0x28} state := frostRetainedGroupJournalState{ Schema: frostRetainedGroupJournalStateSchema, + BindingHash: bindingHash, CurrentPoint: target, SnapshotGeneration: 9, Wallets: []frostRetainedGroupWalletState{}, @@ -529,6 +545,9 @@ func testFrostRetainedGroupJournal( } return &frostRetainedGroupJournal{ metadata: frostRetainedGroupJournalMetadata{ + Schema: frostRetainedGroupJournalMetadataSchema, + ManifestHash: manifest.ManifestHash, + BindingHash: bindingHash, StoreID: manifest.CanonicalJournal.StoreID, StoreFingerprint: manifest.CanonicalJournal.StoreFingerprint, ClusterFingerprint: manifest.CanonicalJournal.ClusterFingerprint, @@ -541,6 +560,7 @@ func testFrostRetainedGroupJournal( quarantineMetadata: frostRetainedGroupQuarantineMetadata{ Schema: frostRetainedGroupQuarantineMetadataSchema, ManifestHash: manifest.ManifestHash, + BindingHash: bindingHash, ProtocolID: manifest.QuarantineJournal.ProtocolID, StoreID: manifest.QuarantineJournal.StoreID, StoreFingerprint: manifest.QuarantineJournal.StoreFingerprint, @@ -555,6 +575,7 @@ func testFrostRetainedGroupJournal( state: state, quarantineState: frostRetainedGroupQuarantineJournalState{ Schema: frostRetainedGroupQuarantineStateSchema, + BindingHash: bindingHash, CurrentPoint: target, Root: quarantineRoot, Quarantines: []frostRetainedGroupQuarantineState{}, diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index fc4a3bd3d4..fa868482be 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1453,6 +1453,7 @@ type FrostPreSignActivationRuntimeManifest struct { ProfileHash [32]byte ImplementationSetHash [32]byte LinkedLibraryDescriptorSetHash [32]byte + EndpointIdentitySetHash [32]byte Deployments []FrostPreSignDeploymentEvidence SignerProtocolID [32]byte ReservationProtocolID [32]byte diff --git a/pkg/tbtc/frost_retained_group_history_evidence.go b/pkg/tbtc/frost_retained_group_history_evidence.go index cf5b0a96d5..a54fb097ac 100644 --- a/pkg/tbtc/frost_retained_group_history_evidence.go +++ b/pkg/tbtc/frost_retained_group_history_evidence.go @@ -3,9 +3,11 @@ package tbtc import ( "bytes" "context" + "crypto/sha256" "encoding/binary" "fmt" "math/big" + "sort" "strings" ethereum "github.com/ethereum/go-ethereum" @@ -25,44 +27,51 @@ import ( type FrostRetainedGroupActivationEvidenceBinder interface { BindFrostRetainedGroupActivationEvidence( FrostPreSignActivationProfile, - [32]byte, - [32]byte, + FrostPreSignActivationRuntimeManifest, ) error } -type frostRetainedGroupContractPin struct { - address common.Address - codeHash common.Hash +type FrostRetainedGroupProtocolBindingSource interface { + FrostRetainedGroupProtocolBindingHash() ([32]byte, error) } type frostRetainedGroupEvidenceProfile struct { - manifestHash [32]byte - descriptorSetHash [32]byte - bridge frostRetainedGroupContractPin - registry frostRetainedGroupContractPin - sortitionPool frostRetainedGroupContractPin - bridgeABI ethabi.ABI - registryABI ethabi.ABI + manifestHash [32]byte + profileHash [32]byte + implementationSetHash [32]byte + descriptorSetHash [32]byte + linkedLibraryDescriptorSetHash [32]byte + inventoryProtocolID [32]byte + quarantineProtocolID [32]byte + domainChainID [32]byte + genesisBlockHash [32]byte + bindingHash [32]byte + deployments map[string]FrostPreSignDeploymentEvidence + bridgeABI ethabi.ABI + registryABI ethabi.ABI } type frostRetainedGroupReceiptCache map[common.Hash]*types.Receipt type frostRetainedGroupCodeCacheKey struct { - address common.Address - blockNumber uint64 + address common.Address + blockHash common.Hash + codeHash common.Hash + descriptorHash common.Hash + verified bool } -type frostRetainedGroupCodeCache map[frostRetainedGroupCodeCacheKey]struct{} +type frostRetainedGroupCodeCache map[frostRetainedGroupCodeCacheKey][]byte var _ FrostRetainedGroupActivationEvidenceBinder = (*signedFrostRetainedGroupHistorySource)(nil) +var _ FrostRetainedGroupProtocolBindingSource = (*signedFrostRetainedGroupHistorySource)(nil) // BindFrostRetainedGroupActivationEvidence is deliberately one-shot. The // profile and descriptor are supplied only after the activation envelope has // been signature-checked and converted to its immutable runtime manifest. func (source *signedFrostRetainedGroupHistorySource) BindFrostRetainedGroupActivationEvidence( profile FrostPreSignActivationProfile, - manifestHash [32]byte, - descriptorSetHash [32]byte, + runtimeManifest FrostPreSignActivationRuntimeManifest, ) error { if source == nil { return fmt.Errorf("retained-group history source is nil") @@ -70,10 +79,106 @@ func (source *signedFrostRetainedGroupHistorySource) BindFrostRetainedGroupActiv if err := profile.ValidateForProduction(); err != nil { return fmt.Errorf("retained-group activation profile is invalid: [%w]", err) } - if manifestHash == [32]byte{} || descriptorSetHash == [32]byte{} || - profile.ActivationManifestHash != manifestHash { + if runtimeManifest.ManifestHash == [32]byte{} || + runtimeManifest.ProfileHash == [32]byte{} || + runtimeManifest.GenesisBlockHash == [32]byte{} || + runtimeManifest.ImplementationSetHash == [32]byte{} || + runtimeManifest.LinkedLibraryDescriptorSetHash == [32]byte{} || + runtimeManifest.EndpointIdentitySetHash == [32]byte{} || + runtimeManifest.CanonicalJournal.DescriptorSetHash == [32]byte{} || + runtimeManifest.RetainedGroupInventoryProtocolID == [32]byte{} || + runtimeManifest.QuarantineJournal.ProtocolID == [32]byte{} || + profile.ActivationManifestHash != runtimeManifest.ManifestHash || + profile.ProfileHash != runtimeManifest.ProfileHash || + profile.ImplementationSetHash != runtimeManifest.ImplementationSetHash || + profile.DomainChainID != runtimeManifest.DomainChainID || + profile.ReservationProtocolID != runtimeManifest.ReservationProtocolID || + profile.SigningPolicyHash != runtimeManifest.SigningPolicyHash || + runtimeManifest.SignerProtocolID == [32]byte{} || + runtimeManifest.BitcoinOutboxProtocolID == [32]byte{} || + runtimeManifest.CanonicalJournal.Checkpoint.BlockNumber == 0 || + runtimeManifest.CanonicalJournal.Checkpoint.BlockHash == [32]byte{} || + strings.TrimSpace(runtimeManifest.CanonicalJournal.StoreID) == "" || + runtimeManifest.CanonicalJournal.StoreFingerprint == [32]byte{} || + runtimeManifest.CanonicalJournal.ClusterFingerprint == [32]byte{} || + strings.TrimSpace(runtimeManifest.QuarantineJournal.StoreID) == "" || + runtimeManifest.QuarantineJournal.StoreFingerprint == [32]byte{} || + runtimeManifest.QuarantineJournal.ClusterFingerprint == [32]byte{} || + runtimeManifest.CanonicalJournal.SourceTrustDomainID != + source.identity.TrustDomainID || + runtimeManifest.CanonicalJournal.SourceEndpointFingerprint != + source.identity.EndpointFingerprint || + runtimeManifest.CanonicalJournal.SourceOperatorFingerprint != + source.identity.OperatorFingerprint || + new(big.Int).SetBytes(runtimeManifest.DomainChainID[:]).BitLen() > 64 || + new(big.Int).SetBytes(runtimeManifest.DomainChainID[:]).Uint64() != + source.chainID || + ComputeFrostPreSignDeploymentEvidenceHash(runtimeManifest.Deployments) != + profile.ImplementationSetHash { return fmt.Errorf("retained-group evidence does not match the signed activation manifest") } + deployments, err := validateFrostRetainedGroupDeploymentEvidence( + runtimeManifest.Deployments, + ) + if err != nil { + return err + } + for role, expected := range map[string]struct { + address [20]byte + codeHash [32]byte + }{ + "bridge": { + address: profile.BridgeAddress, + codeHash: profile.BridgeCodeHash, + }, + "completeRouter": { + address: profile.CompleteRouter, + codeHash: profile.CompleteRouterCodeHash, + }, + "authorizationRegistry": { + address: profile.RegistryAddress, + codeHash: profile.RegistryCodeHash, + }, + "frostWalletRegistry": { + address: profile.FrostRegistry, + codeHash: profile.FrostRegistryCodeHash, + }, + "frostProposalValidator": { + address: profile.ProposalValidator, + codeHash: profile.ProposalValidatorCodeHash, + }, + "frostSortitionPool": { + address: profile.SortitionPool, + codeHash: profile.SortitionPoolCodeHash, + }, + } { + deployment := deployments[role] + if deployment.Current.Address != expected.address || + deployment.Current.RuntimeCodeHash != expected.codeHash { + return fmt.Errorf( + "retained-group deployment [%s] differs from the activation profile", + role, + ) + } + } + linkedLibraryDescriptorSetHash, err := + frostRetainedGroupLinkedLibraryDescriptorSetHash( + runtimeManifest.Deployments, + ) + if err != nil || + linkedLibraryDescriptorSetHash != + runtimeManifest.LinkedLibraryDescriptorSetHash { + return fmt.Errorf( + "retained-group linked-library descriptor set differs from the signed activation manifest", + ) + } + bindingHash, err := source.computeProtocolBinding( + profile, + runtimeManifest, + ) + if err != nil { + return err + } parsedBridgeABI, err := ethabi.JSON(strings.NewReader(bridgeabi.BridgeMetaData.ABI)) if err != nil { return fmt.Errorf("cannot parse pinned Bridge ABI: [%w]", err) @@ -83,22 +188,19 @@ func (source *signedFrostRetainedGroupHistorySource) BindFrostRetainedGroupActiv return fmt.Errorf("cannot parse pinned FROST registry ABI: [%w]", err) } evidence := &frostRetainedGroupEvidenceProfile{ - manifestHash: manifestHash, - descriptorSetHash: descriptorSetHash, - bridge: frostRetainedGroupContractPin{ - address: common.Address(profile.BridgeAddress), - codeHash: common.Hash(profile.BridgeCodeHash), - }, - registry: frostRetainedGroupContractPin{ - address: common.Address(profile.FrostRegistry), - codeHash: common.Hash(profile.FrostRegistryCodeHash), - }, - sortitionPool: frostRetainedGroupContractPin{ - address: common.Address(profile.SortitionPool), - codeHash: common.Hash(profile.SortitionPoolCodeHash), - }, - bridgeABI: parsedBridgeABI, - registryABI: parsedRegistryABI, + manifestHash: runtimeManifest.ManifestHash, + profileHash: runtimeManifest.ProfileHash, + implementationSetHash: runtimeManifest.ImplementationSetHash, + descriptorSetHash: runtimeManifest.CanonicalJournal.DescriptorSetHash, + linkedLibraryDescriptorSetHash: runtimeManifest.LinkedLibraryDescriptorSetHash, + inventoryProtocolID: runtimeManifest.RetainedGroupInventoryProtocolID, + quarantineProtocolID: runtimeManifest.QuarantineJournal.ProtocolID, + domainChainID: runtimeManifest.DomainChainID, + genesisBlockHash: runtimeManifest.GenesisBlockHash, + bindingHash: bindingHash, + deployments: deployments, + bridgeABI: parsedBridgeABI, + registryABI: parsedRegistryABI, } for name, event := range map[string]struct { contract *ethabi.ABI @@ -133,6 +235,465 @@ func (source *signedFrostRetainedGroupHistorySource) BindFrostRetainedGroupActiv return nil } +func (source *signedFrostRetainedGroupHistorySource) computeProtocolBinding( + profile FrostPreSignActivationProfile, + runtimeManifest FrostPreSignActivationRuntimeManifest, +) ([32]byte, error) { + binding := frostRetainedGroupProtocolBinding{ + Schema: "tbtc-frost-retained-group-protocol-binding/v2", + ChainID: source.chainID, + DomainChainID: frostActivationHex32(runtimeManifest.DomainChainID), + GenesisBlockHash: frostActivationHex32(runtimeManifest.GenesisBlockHash), + Checkpoint: frostRetainedGroupWireBlockPoint{ + BlockNumber: runtimeManifest.CanonicalJournal.Checkpoint.BlockNumber, + BlockHash: frostActivationHex32( + runtimeManifest.CanonicalJournal.Checkpoint.BlockHash, + ), + }, + ManifestHash: frostActivationHex32(runtimeManifest.ManifestHash), + ProfileHash: frostActivationHex32(runtimeManifest.ProfileHash), + ImplementationSetHash: frostActivationHex32(runtimeManifest.ImplementationSetHash), + DescriptorSetHash: frostActivationHex32(runtimeManifest.CanonicalJournal.DescriptorSetHash), + LinkedLibraryDescriptorSetHash: frostActivationHex32(runtimeManifest.LinkedLibraryDescriptorSetHash), + EndpointIdentitySetHash: frostActivationHex32(runtimeManifest.EndpointIdentitySetHash), + SignerProtocolID: frostActivationHex32(runtimeManifest.SignerProtocolID), + ReservationProtocolID: frostActivationHex32(runtimeManifest.ReservationProtocolID), + EvidenceProtocolID: frostActivationHex32(profile.EvidenceProtocolID), + BitcoinOutboxProtocolID: frostActivationHex32(runtimeManifest.BitcoinOutboxProtocolID), + InventoryProtocolID: frostActivationHex32(runtimeManifest.RetainedGroupInventoryProtocolID), + QuarantineProtocolID: frostActivationHex32(runtimeManifest.QuarantineJournal.ProtocolID), + SigningPolicyHash: frostActivationHex32(runtimeManifest.SigningPolicyHash), + CanonicalStoreID: runtimeManifest.CanonicalJournal.StoreID, + CanonicalStoreFingerprint: frostActivationHex32( + runtimeManifest.CanonicalJournal.StoreFingerprint, + ), + CanonicalClusterFingerprint: frostActivationHex32( + runtimeManifest.CanonicalJournal.ClusterFingerprint, + ), + QuarantineStoreID: runtimeManifest.QuarantineJournal.StoreID, + QuarantineStoreFingerprint: frostActivationHex32( + runtimeManifest.QuarantineJournal.StoreFingerprint, + ), + QuarantineClusterFingerprint: frostActivationHex32( + runtimeManifest.QuarantineJournal.ClusterFingerprint, + ), + SourceIdentity: frostRetainedGroupWireIdentity{ + TrustDomainID: source.identity.TrustDomainID, + EndpointFingerprint: frostActivationHex32( + source.identity.EndpointFingerprint, + ), + OperatorFingerprint: frostActivationHex32( + source.identity.OperatorFingerprint, + ), + }, + } + return frostRetainedGroupDomainHash( + frostRetainedGroupProtocolBindingDomain, + binding, + ) +} + +func validateFrostRetainedGroupDeploymentEvidence( + deployments []FrostPreSignDeploymentEvidence, +) (map[string]FrostPreSignDeploymentEvidence, error) { + requiredRoles := map[string]bool{ + "bridge": false, + "completeRouter": false, + "authorizationRegistry": false, + "frostWalletRegistry": false, + "frostProposalValidator": false, + "frostSortitionPool": false, + "ecdsaFraudRouter": false, + "ecdsaCutoverCoordinator": false, + } + if len(deployments) != len(requiredRoles) { + return nil, fmt.Errorf("retained-group deployment evidence is incomplete") + } + result := make(map[string]FrostPreSignDeploymentEvidence, len(deployments)) + for _, deployment := range deployments { + if _, required := requiredRoles[deployment.Role]; !required || + requiredRoles[deployment.Role] || + strings.TrimSpace(deployment.Name) == "" || + deployment.DeploymentBlock == 0 || + deployment.RelevantEventStartBlock < deployment.DeploymentBlock || + len(deployment.HistoricalEpochs) == 0 || + len(deployment.HistoricalEpochs) > 64 { + return nil, fmt.Errorf("retained-group deployment [%s] is invalid", deployment.Role) + } + requiredRoles[deployment.Role] = true + if err := validateFrostRetainedGroupDeploymentDescriptor( + deployment.Current, + ); err != nil { + return nil, fmt.Errorf("invalid current %s deployment descriptor: [%w]", deployment.Role, err) + } + for index, epoch := range deployment.HistoricalEpochs { + if epoch.Start.BlockNumber == 0 || epoch.Start.BlockHash == [32]byte{} || + (index == 0 && + epoch.Start.BlockNumber != deployment.DeploymentBlock) || + (index+1 < len(deployment.HistoricalEpochs) && epoch.End == nil) || + (index+1 == len(deployment.HistoricalEpochs) && epoch.End != nil) { + return nil, fmt.Errorf("retained-group %s epoch [%d] range is invalid", deployment.Role, index) + } + if epoch.End != nil && + (epoch.End.BlockNumber < epoch.Start.BlockNumber || + epoch.End.BlockHash == [32]byte{}) { + return nil, fmt.Errorf("retained-group %s epoch [%d] end is invalid", deployment.Role, index) + } + if index > 0 { + previous := deployment.HistoricalEpochs[index-1] + if previous.End == nil || + previous.End.BlockNumber == ^uint64(0) || + previous.End.BlockNumber+1 != epoch.Start.BlockNumber { + return nil, fmt.Errorf("retained-group %s epochs have a gap or overlap", deployment.Role) + } + } + if err := validateFrostRetainedGroupDeploymentDescriptor( + epoch.Descriptor, + ); err != nil { + return nil, fmt.Errorf("invalid %s epoch [%d] descriptor: [%w]", deployment.Role, index, err) + } + } + if deployment.RelevantEventStartBlock < + deployment.HistoricalEpochs[0].Start.BlockNumber || + deployment.Current.DescriptorHash != + deployment.HistoricalEpochs[len(deployment.HistoricalEpochs)-1].Descriptor.DescriptorHash { + return nil, fmt.Errorf("retained-group %s epochs do not cover the event range", deployment.Role) + } + result[deployment.Role] = cloneFrostRetainedGroupDeploymentEvidence( + deployment, + ) + } + return result, nil +} + +func validateFrostRetainedGroupDeploymentDescriptor( + descriptor FrostPreSignDeploymentDescriptorEvidence, +) error { + if descriptor.Address == [20]byte{} || + descriptor.RuntimeCodeHash == [32]byte{} || + descriptor.LinkedLibraryDescriptorHash == [32]byte{} || + descriptor.DescriptorHash == [32]byte{} || + descriptor.ComputeHash() != descriptor.DescriptorHash { + return fmt.Errorf("deployment descriptor identity or commitment is invalid") + } + switch descriptor.Upgradeability { + case "immutable": + if descriptor.ImplementationAddress != [20]byte{} || + descriptor.ImplementationCodeHash != [32]byte{} || + descriptor.AdminAddress != [20]byte{} || + descriptor.AdminCodeHash != [32]byte{} || + descriptor.ImplementationSlotValue != [32]byte{} || + descriptor.AdminSlotValue != [32]byte{} { + return fmt.Errorf("immutable deployment descriptor contains proxy fields") + } + case "eip1967": + if descriptor.ImplementationAddress == [20]byte{} || + descriptor.ImplementationCodeHash == [32]byte{} || + descriptor.AdminAddress == [20]byte{} || + descriptor.AdminCodeHash == [32]byte{} || + descriptor.ImplementationAddress == descriptor.AdminAddress || + descriptor.ImplementationAddress == descriptor.Address || + descriptor.AdminAddress == descriptor.Address || + !frostRetainedGroupSlotValueBindsAddress( + descriptor.ImplementationSlotValue, + descriptor.ImplementationAddress, + ) || + !frostRetainedGroupSlotValueBindsAddress( + descriptor.AdminSlotValue, + descriptor.AdminAddress, + ) { + return fmt.Errorf("EIP-1967 deployment descriptor is incomplete") + } + default: + return fmt.Errorf("unsupported upgradeability [%s]", descriptor.Upgradeability) + } + count := 0 + if err := validateFrostRetainedGroupLinkedLibraries( + descriptor.LinkedLibraries, + 0, + &count, + ); err != nil { + return err + } + computedDescriptorHash, err := + frostRetainedGroupLinkedLibraryInventoryHash( + descriptor.LinkedLibraries, + ) + if err != nil || + computedDescriptorHash != descriptor.LinkedLibraryDescriptorHash { + return fmt.Errorf("linked-library descriptor hash mismatch") + } + return nil +} + +func validateFrostRetainedGroupLinkedLibraries( + libraries []FrostPreSignLinkedLibraryEvidence, + depth int, + count *int, +) error { + if count == nil || depth > 16 { + return fmt.Errorf("linked-library evidence is too deep") + } + roles := make(map[string]bool) + addresses := make(map[[20]byte]bool) + var previousRole string + for index, library := range libraries { + (*count)++ + if *count > 256 || + !frostRetainedGroupValidProtocolRole(library.ProtocolRole) || + (index > 0 && library.ProtocolRole <= previousRole) || + roles[library.ProtocolRole] || addresses[library.Address] || + library.Address == [20]byte{} || + library.RuntimeCodeHash == [32]byte{} || + library.LinkedLibraryDescriptorHash == [32]byte{} || + len(library.References) == 0 { + return fmt.Errorf("linked-library evidence is noncanonical") + } + roles[library.ProtocolRole] = true + addresses[library.Address] = true + previousRole = library.ProtocolRole + for referenceIndex, reference := range library.References { + if reference.Length != 20 || + reference.Start > ^uint64(0)-reference.Length || + (referenceIndex > 0 && + library.References[referenceIndex-1].Start+ + library.References[referenceIndex-1].Length > + reference.Start) { + return fmt.Errorf("linked-library references are noncanonical") + } + } + if err := validateFrostRetainedGroupLinkedLibraries( + library.LinkedLibraries, + depth+1, + count, + ); err != nil { + return err + } + computedDescriptorHash, err := + frostRetainedGroupLinkedLibraryInventoryHash( + library.LinkedLibraries, + ) + if err != nil || + computedDescriptorHash != library.LinkedLibraryDescriptorHash { + return fmt.Errorf( + "linked-library [%s] descriptor hash mismatch", + library.ProtocolRole, + ) + } + } + return nil +} + +func frostRetainedGroupValidProtocolRole(value string) bool { + if len(value) == 0 || len(value) > 255 { + return false + } + for _, character := range []byte(value) { + if (character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') || + strings.ContainsRune("._:/-", rune(character)) { + continue + } + return false + } + return true +} + +type frostRetainedGroupLinkedLibraryReferenceCommitment struct { + Start uint64 `json:"start"` + Length uint64 `json:"length"` +} + +type frostRetainedGroupLinkedLibraryDescriptorCommitment struct { + ProtocolRole string `json:"protocolRole"` + References []frostRetainedGroupLinkedLibraryReferenceCommitment `json:"references"` + LinkedLibraries []frostRetainedGroupLinkedLibraryDescriptorCommitment `json:"linkedLibraries"` +} + +func frostRetainedGroupLinkedLibraryDescriptors( + libraries []FrostPreSignLinkedLibraryEvidence, +) []frostRetainedGroupLinkedLibraryDescriptorCommitment { + result := make( + []frostRetainedGroupLinkedLibraryDescriptorCommitment, + 0, + len(libraries), + ) + for _, library := range libraries { + references := make( + []frostRetainedGroupLinkedLibraryReferenceCommitment, + 0, + len(library.References), + ) + for _, reference := range library.References { + references = append( + references, + frostRetainedGroupLinkedLibraryReferenceCommitment{ + Start: reference.Start, + Length: reference.Length, + }, + ) + } + result = append( + result, + frostRetainedGroupLinkedLibraryDescriptorCommitment{ + ProtocolRole: library.ProtocolRole, + References: references, + LinkedLibraries: frostRetainedGroupLinkedLibraryDescriptors( + library.LinkedLibraries, + ), + }, + ) + } + return result +} + +func frostRetainedGroupLinkedLibraryInventoryHash( + libraries []FrostPreSignLinkedLibraryEvidence, +) ([32]byte, error) { + canonical, err := canonicalFrostActivationValue(map[string]interface{}{ + "schema": "tbtc-p2tr-linked-library-inventory/v1", + "linkedLibraries": frostRetainedGroupLinkedLibraryDescriptors( + libraries, + ), + }) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(canonical), nil +} + +func frostRetainedGroupLinkedLibraryDescriptorSetHash( + deployments []FrostPreSignDeploymentEvidence, +) ([32]byte, error) { + type epochDescriptor struct { + StartBlock uint64 `json:"startBlock"` + EndBlock *uint64 `json:"endBlock"` + CodeKind string `json:"codeKind"` + LinkedLibraries []frostRetainedGroupLinkedLibraryDescriptorCommitment `json:"linkedLibraries"` + } + type contractDescriptor struct { + ContractRole string `json:"contractRole"` + CodeKind string `json:"codeKind"` + LinkedLibraries []frostRetainedGroupLinkedLibraryDescriptorCommitment `json:"linkedLibraries"` + HistoricalEpochs []epochDescriptor `json:"historicalEpochs"` + } + contracts := make([]contractDescriptor, 0, len(deployments)) + for _, deployment := range deployments { + codeKind := "runtime" + if deployment.Current.Upgradeability == "eip1967" { + codeKind = "implementation-runtime" + } + historicalEpochs := make( + []epochDescriptor, + 0, + len(deployment.HistoricalEpochs), + ) + for _, epoch := range deployment.HistoricalEpochs { + epochCodeKind := "runtime" + if epoch.Descriptor.Upgradeability == "eip1967" { + epochCodeKind = "implementation-runtime" + } + var endBlock *uint64 + if epoch.End != nil { + value := epoch.End.BlockNumber + endBlock = &value + } + historicalEpochs = append( + historicalEpochs, + epochDescriptor{ + StartBlock: epoch.Start.BlockNumber, + EndBlock: endBlock, + CodeKind: epochCodeKind, + LinkedLibraries: frostRetainedGroupLinkedLibraryDescriptors( + epoch.Descriptor.LinkedLibraries, + ), + }, + ) + } + contracts = append( + contracts, + contractDescriptor{ + ContractRole: deployment.Role, + CodeKind: codeKind, + LinkedLibraries: frostRetainedGroupLinkedLibraryDescriptors( + deployment.Current.LinkedLibraries, + ), + HistoricalEpochs: historicalEpochs, + }, + ) + } + sort.Slice(contracts, func(i, j int) bool { + return contracts[i].ContractRole < contracts[j].ContractRole + }) + canonical, err := canonicalFrostActivationValue(map[string]interface{}{ + "schema": "tbtc-p2tr-linked-library-descriptor-set/v2", + "contracts": contracts, + }) + if err != nil { + return [32]byte{}, err + } + return sha256.Sum256(canonical), nil +} + +func frostRetainedGroupSlotValueBindsAddress( + value [32]byte, + address [20]byte, +) bool { + return bytes.Equal(value[:12], make([]byte, 12)) && + bytes.Equal(value[12:], address[:]) +} + +func cloneFrostRetainedGroupDeploymentEvidence( + deployment FrostPreSignDeploymentEvidence, +) FrostPreSignDeploymentEvidence { + result := deployment + result.Current = cloneFrostRetainedGroupDeploymentDescriptor( + deployment.Current, + ) + result.HistoricalEpochs = make( + []FrostPreSignDeploymentEpochEvidence, + len(deployment.HistoricalEpochs), + ) + for index, epoch := range deployment.HistoricalEpochs { + result.HistoricalEpochs[index] = epoch + if epoch.End != nil { + end := *epoch.End + result.HistoricalEpochs[index].End = &end + } + result.HistoricalEpochs[index].Descriptor = + cloneFrostRetainedGroupDeploymentDescriptor(epoch.Descriptor) + } + return result +} + +func cloneFrostRetainedGroupDeploymentDescriptor( + descriptor FrostPreSignDeploymentDescriptorEvidence, +) FrostPreSignDeploymentDescriptorEvidence { + result := descriptor + result.LinkedLibraries = cloneFrostRetainedGroupLinkedLibraries( + descriptor.LinkedLibraries, + ) + return result +} + +func cloneFrostRetainedGroupLinkedLibraries( + libraries []FrostPreSignLinkedLibraryEvidence, +) []FrostPreSignLinkedLibraryEvidence { + result := make([]FrostPreSignLinkedLibraryEvidence, len(libraries)) + for index, library := range libraries { + result[index] = library + result[index].References = append( + []FrostPreSignLinkedLibraryReference{}, + library.References..., + ) + result[index].LinkedLibraries = cloneFrostRetainedGroupLinkedLibraries( + library.LinkedLibraries, + ) + } + return result +} + func (source *signedFrostRetainedGroupHistorySource) activationEvidence() ( *frostRetainedGroupEvidenceProfile, error, @@ -148,6 +709,17 @@ func (source *signedFrostRetainedGroupHistorySource) activationEvidence() ( return source.evidence, nil } +func (source *signedFrostRetainedGroupHistorySource) FrostRetainedGroupProtocolBindingHash() ( + [32]byte, + error, +) { + evidence, err := source.activationEvidence() + if err != nil { + return [32]byte{}, err + } + return evidence.bindingHash, nil +} + func (source *signedFrostRetainedGroupHistorySource) verifyHistoryEvidence( ctx context.Context, mutations []FrostRetainedGroupMutation, @@ -196,7 +768,7 @@ func (source *signedFrostRetainedGroupHistorySource) verifyMutationEvidence( log, err := source.authenticatedEventLog( ctx, mutation.Point, - evidence.bridge, + evidence.deployments["bridge"], evidence.bridgeABI.Events[eventName].ID, receipts, code, @@ -213,7 +785,7 @@ func (source *signedFrostRetainedGroupHistorySource) verifyMutationEvidence( log, err := source.authenticatedEventLog( ctx, mutation.Point, - evidence.registry, + evidence.deployments["frostWalletRegistry"], evidence.registryABI.Events["WalletClosed"].ID, receipts, code, @@ -249,7 +821,7 @@ func (source *signedFrostRetainedGroupHistorySource) verifyAdmissionEvidence( submissionLog, err := source.authenticatedEventLog( ctx, mutation.DkgSubmissionPoint, - evidence.registry, + evidence.deployments["frostWalletRegistry"], evidence.registryABI.Events["DkgResultSubmitted"].ID, receipts, code, @@ -301,7 +873,7 @@ func (source *signedFrostRetainedGroupHistorySource) verifyAdmissionEvidence( approvalLog, err := source.authenticatedEventLog( ctx, mutation.DkgApprovalPoint, - evidence.registry, + evidence.deployments["frostWalletRegistry"], evidence.registryABI.Events["DkgResultApproved"].ID, receipts, code, @@ -319,7 +891,7 @@ func (source *signedFrostRetainedGroupHistorySource) verifyAdmissionEvidence( creationLog, err := source.authenticatedEventLog( ctx, mutation.CreationPoint, - evidence.registry, + evidence.deployments["frostWalletRegistry"], evidence.registryABI.Events["WalletCreated"].ID, receipts, code, @@ -336,7 +908,7 @@ func (source *signedFrostRetainedGroupHistorySource) verifyAdmissionEvidence( registrationLog, err := source.authenticatedEventLog( ctx, mutation.BridgeRegistrationPoint, - evidence.bridge, + evidence.deployments["bridge"], evidence.bridgeABI.Events["NewWalletRegisteredV2"].ID, receipts, code, @@ -386,16 +958,30 @@ func frostRetainedGroupDecodeDkgSubmission( func (source *signedFrostRetainedGroupHistorySource) authenticatedEventLog( ctx context.Context, point FrostRetainedGroupEventPoint, - pin frostRetainedGroupContractPin, + deployment FrostPreSignDeploymentEvidence, topic common.Hash, receipts frostRetainedGroupReceiptCache, code frostRetainedGroupCodeCache, ) (*types.Log, error) { - if !point.valid() || pin.address == (common.Address{}) || pin.codeHash == (common.Hash{}) || - topic == (common.Hash{}) { + if !point.valid() || topic == (common.Hash{}) { return nil, fmt.Errorf("event evidence descriptor is incomplete") } - if err := source.authenticateContractCode(ctx, pin, point.BlockNumber, code); err != nil { + descriptor, err := frostRetainedGroupDeploymentDescriptorAt( + deployment, + point.BlockNumber, + point.BlockHash, + true, + ) + if err != nil { + return nil, err + } + if err := source.authenticateContractDeployment( + ctx, + descriptor, + point.BlockNumber, + point.BlockHash, + code, + ); err != nil { return nil, err } transactionHash := common.Hash(point.TransactionHash) @@ -405,7 +991,9 @@ func (source *signedFrostRetainedGroupHistorySource) authenticatedEventLog( return nil, fmt.Errorf("retained-group evidence exceeds the receipt limit") } var err error - receipt, err = source.verifier.TransactionReceipt(ctx, transactionHash) + requestContext, cancel := source.requestContext(ctx) + receipt, err = source.verifier.TransactionReceipt(requestContext, transactionHash) + cancel() if err != nil { return nil, fmt.Errorf("cannot read transaction receipt [%s]: [%w]", transactionHash.Hex(), err) } @@ -433,7 +1021,8 @@ func (source *signedFrostRetainedGroupHistorySource) authenticatedEventLog( } matched = candidate } - if matched == nil || matched.Removed || matched.Address != pin.address || + if matched == nil || matched.Removed || + matched.Address != common.Address(descriptor.Address) || matched.BlockNumber != point.BlockNumber || matched.BlockHash != common.Hash(point.BlockHash) || matched.TxHash != transactionHash || matched.TxIndex != uint(point.TransactionIndex) || len(matched.Topics) == 0 || matched.Topics[0] != topic { @@ -442,31 +1031,256 @@ func (source *signedFrostRetainedGroupHistorySource) authenticatedEventLog( return matched, nil } -func (source *signedFrostRetainedGroupHistorySource) authenticateContractCode( +func frostRetainedGroupDeploymentDescriptorAt( + deployment FrostPreSignDeploymentEvidence, + blockNumber uint64, + blockHash [32]byte, + rejectTransitionBlock bool, +) (FrostPreSignDeploymentDescriptorEvidence, error) { + if blockNumber < deployment.RelevantEventStartBlock || blockHash == [32]byte{} { + return FrostPreSignDeploymentDescriptorEvidence{}, + fmt.Errorf("retained-group point is outside the authenticated deployment range") + } + matchIndex := -1 + for index, epoch := range deployment.HistoricalEpochs { + if blockNumber < epoch.Start.BlockNumber || + (epoch.End != nil && blockNumber > epoch.End.BlockNumber) { + continue + } + if matchIndex >= 0 { + return FrostPreSignDeploymentDescriptorEvidence{}, + fmt.Errorf("retained-group point matches multiple deployment epochs") + } + matchIndex = index + } + if matchIndex < 0 { + return FrostPreSignDeploymentDescriptorEvidence{}, + fmt.Errorf("retained-group point has no authenticated deployment epoch") + } + epoch := deployment.HistoricalEpochs[matchIndex] + if (blockNumber == epoch.Start.BlockNumber && + blockHash != epoch.Start.BlockHash) || + (epoch.End != nil && blockNumber == epoch.End.BlockNumber && + blockHash != epoch.End.BlockHash) { + return FrostPreSignDeploymentDescriptorEvidence{}, + fmt.Errorf("retained-group point conflicts with a signed deployment boundary") + } + if rejectTransitionBlock && matchIndex > 0 && + blockNumber == epoch.Start.BlockNumber { + return FrostPreSignDeploymentDescriptorEvidence{}, + fmt.Errorf("retained-group event occurs in an implementation-transition block") + } + return epoch.Descriptor, nil +} + +func (source *signedFrostRetainedGroupHistorySource) authenticateContractDeployment( ctx context.Context, - pin frostRetainedGroupContractPin, + descriptor FrostPreSignDeploymentDescriptorEvidence, blockNumber uint64, + blockHash [32]byte, cache frostRetainedGroupCodeCache, ) error { - key := frostRetainedGroupCodeCacheKey{address: pin.address, blockNumber: blockNumber} - if _, ok := cache[key]; ok { + if blockNumber == 0 || blockHash == [32]byte{} { + return fmt.Errorf("retained-group contract deployment point is invalid") + } + verifiedKey := frostRetainedGroupCodeCacheKey{ + address: common.Address(descriptor.Address), + blockHash: common.Hash(blockHash), + codeHash: common.Hash(descriptor.RuntimeCodeHash), + descriptorHash: common.Hash(descriptor.DescriptorHash), + verified: true, + } + if _, ok := cache[verifiedKey]; ok { return nil } + proxyCode, err := source.readAuthenticatedCode( + ctx, + common.Address(descriptor.Address), + common.Hash(descriptor.RuntimeCodeHash), + common.Hash(descriptor.DescriptorHash), + blockNumber, + common.Hash(blockHash), + cache, + ) + if err != nil { + return err + } + implementationSlot := frostRetainedGroupEIP1967Slot( + "eip1967.proxy.implementation", + ) + adminSlot := frostRetainedGroupEIP1967Slot("eip1967.proxy.admin") + requestContext, cancel := source.requestContext(ctx) + implementationValue, err := source.verifier.StorageAtHash( + requestContext, + common.Address(descriptor.Address), + implementationSlot, + common.Hash(blockHash), + ) + cancel() + if err != nil || len(implementationValue) != 32 { + return fmt.Errorf("cannot read retained-group EIP-1967 implementation slot: [%w]", err) + } + requestContext, cancel = source.requestContext(ctx) + adminValue, err := source.verifier.StorageAtHash( + requestContext, + common.Address(descriptor.Address), + adminSlot, + common.Hash(blockHash), + ) + cancel() + if err != nil || len(adminValue) != 32 { + return fmt.Errorf("cannot read retained-group EIP-1967 admin slot: [%w]", err) + } + ownerCode := proxyCode + switch descriptor.Upgradeability { + case "immutable": + if !bytes.Equal(implementationValue, make([]byte, 32)) || + !bytes.Equal(adminValue, make([]byte, 32)) { + return fmt.Errorf("immutable retained-group deployment has populated EIP-1967 slots") + } + case "eip1967": + if !bytes.Equal(implementationValue, descriptor.ImplementationSlotValue[:]) || + !bytes.Equal(adminValue, descriptor.AdminSlotValue[:]) { + return fmt.Errorf("retained-group EIP-1967 slot value mismatch") + } + ownerCode, err = source.readAuthenticatedCode( + ctx, + common.Address(descriptor.ImplementationAddress), + common.Hash(descriptor.ImplementationCodeHash), + common.Hash(descriptor.DescriptorHash), + blockNumber, + common.Hash(blockHash), + cache, + ) + if err != nil { + return fmt.Errorf("retained-group implementation authentication failed: [%w]", err) + } + if _, err := source.readAuthenticatedCode( + ctx, + common.Address(descriptor.AdminAddress), + common.Hash(descriptor.AdminCodeHash), + common.Hash(descriptor.DescriptorHash), + blockNumber, + common.Hash(blockHash), + cache, + ); err != nil { + return fmt.Errorf("retained-group admin authentication failed: [%w]", err) + } + default: + return fmt.Errorf("retained-group deployment upgradeability is unsupported") + } + if err := source.authenticateLinkedLibraries( + ctx, + ownerCode, + descriptor.LinkedLibraries, + common.Hash(descriptor.DescriptorHash), + blockNumber, + common.Hash(blockHash), + cache, + ); err != nil { + return err + } if len(cache) >= frostRetainedGroupMaximumEvidenceCodePoints { return fmt.Errorf("retained-group evidence exceeds the contract-code point limit") } - code, err := source.verifier.CodeAt(ctx, pin.address, new(big.Int).SetUint64(blockNumber)) + cache[verifiedKey] = []byte{1} + return nil +} + +func (source *signedFrostRetainedGroupHistorySource) readAuthenticatedCode( + ctx context.Context, + address common.Address, + expectedHash common.Hash, + descriptorHash common.Hash, + blockNumber uint64, + blockHash common.Hash, + cache frostRetainedGroupCodeCache, +) ([]byte, error) { + key := frostRetainedGroupCodeCacheKey{ + address: address, + blockHash: blockHash, + codeHash: expectedHash, + descriptorHash: descriptorHash, + } + if cached, ok := cache[key]; ok { + return cached, nil + } + if len(cache) >= frostRetainedGroupMaximumEvidenceCodePoints { + return nil, fmt.Errorf("retained-group evidence exceeds the contract-code point limit") + } + requestContext, cancel := source.requestContext(ctx) + code, err := source.verifier.CodeAtHash( + requestContext, + address, + blockHash, + ) + cancel() if err != nil { - return fmt.Errorf("cannot read pinned contract code at block [%d]: [%w]", blockNumber, err) + return nil, fmt.Errorf("cannot read pinned contract code at block [%d]: [%w]", blockNumber, err) } if len(code) == 0 || len(code) > frostRetainedGroupMaximumContractCodeBytes || - crypto.Keccak256Hash(code) != pin.codeHash { - return fmt.Errorf("contract code at block [%d] differs from the signed activation manifest", blockNumber) + crypto.Keccak256Hash(code) != expectedHash { + return nil, fmt.Errorf("contract code at block [%d] differs from the signed activation manifest", blockNumber) + } + copied := append([]byte{}, code...) + cache[key] = copied + return copied, nil +} + +func (source *signedFrostRetainedGroupHistorySource) authenticateLinkedLibraries( + ctx context.Context, + ownerCode []byte, + libraries []FrostPreSignLinkedLibraryEvidence, + descriptorHash common.Hash, + blockNumber uint64, + blockHash common.Hash, + cache frostRetainedGroupCodeCache, +) error { + for _, library := range libraries { + for _, reference := range library.References { + if reference.Start > uint64(len(ownerCode)) || + reference.Start+reference.Length < reference.Start || + reference.Start+reference.Length > uint64(len(ownerCode)) || + !bytes.Equal( + ownerCode[int(reference.Start):int(reference.Start+reference.Length)], + library.Address[:], + ) { + return fmt.Errorf("retained-group linked-library reference [%s:%d] mismatch", library.ProtocolRole, reference.Start) + } + } + libraryCode, err := source.readAuthenticatedCode( + ctx, + common.Address(library.Address), + common.Hash(library.RuntimeCodeHash), + descriptorHash, + blockNumber, + blockHash, + cache, + ) + if err != nil { + return fmt.Errorf("retained-group linked library [%s] authentication failed: [%w]", library.ProtocolRole, err) + } + if err := source.authenticateLinkedLibraries( + ctx, + libraryCode, + library.LinkedLibraries, + descriptorHash, + blockNumber, + blockHash, + cache, + ); err != nil { + return err + } } - cache[key] = struct{}{} return nil } +func frostRetainedGroupEIP1967Slot(label string) common.Hash { + value := crypto.Keccak256Hash([]byte(label)).Big() + value.Sub(value, big.NewInt(1)) + return common.BigToHash(value) +} + func (source *signedFrostRetainedGroupHistorySource) resolveOperatorIDAt( ctx context.Context, operator common.Address, @@ -476,10 +1290,21 @@ func (source *signedFrostRetainedGroupHistorySource) resolveOperatorIDAt( if evidence == nil || operator == (common.Address{}) { return 0, fmt.Errorf("operator-resolution evidence is incomplete") } - if err := source.authenticateContractCode( + deployment := evidence.deployments["frostSortitionPool"] + descriptor, err := frostRetainedGroupDeploymentDescriptorAt( + deployment, + at.BlockNumber, + at.BlockHash, + false, + ) + if err != nil { + return 0, err + } + if err := source.authenticateContractDeployment( ctx, - evidence.sortitionPool, + descriptor, at.BlockNumber, + at.BlockHash, make(frostRetainedGroupCodeCache), ); err != nil { return 0, err @@ -489,12 +1314,14 @@ func (source *signedFrostRetainedGroupHistorySource) resolveOperatorIDAt( callData := make([]byte, 4+32) copy(callData[:4], []byte{0x5a, 0x48, 0xb4, 0x6b}) copy(callData[4+12:], operator[:]) - to := evidence.sortitionPool.address - output, err := source.verifier.CallContract( - ctx, + to := common.Address(descriptor.Address) + requestContext, cancel := source.requestContext(ctx) + output, err := source.verifier.CallContractAtHash( + requestContext, ethereum.CallMsg{To: &to, Data: callData}, - new(big.Int).SetUint64(at.BlockNumber), + common.Hash(at.BlockHash), ) + cancel() if err != nil { return 0, err } diff --git a/pkg/tbtc/frost_retained_group_history_source.go b/pkg/tbtc/frost_retained_group_history_source.go index b24fd31798..00ed0a557f 100644 --- a/pkg/tbtc/frost_retained_group_history_source.go +++ b/pkg/tbtc/frost_retained_group_history_source.go @@ -23,22 +23,25 @@ import ( ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" + "github.com/gorilla/websocket" "github.com/keep-network/keep-core/pkg/chain" ) const ( - frostRetainedGroupHistoryPageSchema = "tbtc-frost-retained-group-history-page/v1" - frostRetainedGroupOperatorReceiptSchema = "tbtc-frost-retained-group-operator-receipt/v1" - frostRetainedGroupHistoryRequestSchema = "tbtc-frost-retained-group-history-request/v1" - frostRetainedGroupOperatorRequestSchema = "tbtc-frost-retained-group-operator-request/v1" - frostRetainedGroupHistorySignatureDomain = "tbtc-frost-retained-group-export-signature-v1\x00" + frostRetainedGroupHistoryPageSchema = "tbtc-frost-retained-group-history-page/v2" + frostRetainedGroupOperatorReceiptSchema = "tbtc-frost-retained-group-operator-receipt/v2" + frostRetainedGroupHistoryRequestSchema = "tbtc-frost-retained-group-history-request/v2" + frostRetainedGroupOperatorRequestSchema = "tbtc-frost-retained-group-operator-request/v2" + frostRetainedGroupHistorySignatureDomain = "tbtc-frost-retained-group-export-signature-v2\x00" frostRetainedGroupHistoryEndpointDomain = "tbtc-frost-retained-group-endpoints-v1\x00" - frostRetainedGroupHistoryQueryDomain = "tbtc-frost-retained-group-history-query-v1\x00" - frostRetainedGroupOperatorQueryDomain = "tbtc-frost-retained-group-operator-query-v1\x00" - frostRetainedGroupHistoryRootDomain = "tbtc-frost-retained-group-export-history-root-v1\x00" + frostRetainedGroupHistoryQueryDomain = "tbtc-frost-retained-group-history-query-v2\x00" + frostRetainedGroupOperatorQueryDomain = "tbtc-frost-retained-group-operator-query-v2\x00" + frostRetainedGroupHistoryRootDomain = "tbtc-frost-retained-group-export-history-root-v2\x00" + frostRetainedGroupProtocolBindingDomain = "tbtc-frost-retained-group-protocol-binding-v2\x00" frostRetainedGroupMaximumResponseBytes = 1024 * 1024 frostRetainedGroupMaximumAggregateResponseBytes = 16 * 1024 * 1024 @@ -72,9 +75,11 @@ type FrostRetainedGroupHistorySourceConfig struct { type frostRetainedGroupEthereumVerifier interface { ChainID(context.Context) (*big.Int, error) HeaderByNumber(context.Context, *big.Int) (*types.Header, error) + HeaderByHash(context.Context, common.Hash) (*types.Header, error) TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) - CodeAt(context.Context, common.Address, *big.Int) ([]byte, error) - CallContract(context.Context, ethereum.CallMsg, *big.Int) ([]byte, error) + CodeAtHash(context.Context, common.Address, common.Hash) ([]byte, error) + StorageAtHash(context.Context, common.Address, common.Hash, common.Hash) ([]byte, error) + CallContractAtHash(context.Context, ethereum.CallMsg, common.Hash) ([]byte, error) Close() } @@ -82,6 +87,96 @@ type frostRetainedGroupHTTPClient interface { Do(*http.Request) (*http.Response, error) } +type canonicalFrostRetainedGroupEthereumVerifier struct { + *ethclient.Client + rpcClient *rpc.Client +} + +func (verifier *canonicalFrostRetainedGroupEthereumVerifier) CodeAtHash( + ctx context.Context, + account common.Address, + blockHash common.Hash, +) ([]byte, error) { + var result hexutil.Bytes + err := verifier.rpcClient.CallContext( + ctx, + &result, + "eth_getCode", + account, + rpc.BlockNumberOrHashWithHash(blockHash, true), + ) + return result, err +} + +func (verifier *canonicalFrostRetainedGroupEthereumVerifier) StorageAtHash( + ctx context.Context, + account common.Address, + key common.Hash, + blockHash common.Hash, +) ([]byte, error) { + var result hexutil.Bytes + err := verifier.rpcClient.CallContext( + ctx, + &result, + "eth_getStorageAt", + account, + key, + rpc.BlockNumberOrHashWithHash(blockHash, true), + ) + return result, err +} + +func (verifier *canonicalFrostRetainedGroupEthereumVerifier) CallContractAtHash( + ctx context.Context, + message ethereum.CallMsg, + blockHash common.Hash, +) ([]byte, error) { + var result hexutil.Bytes + err := verifier.rpcClient.CallContext( + ctx, + &result, + "eth_call", + frostRetainedGroupCallArgument(message), + rpc.BlockNumberOrHashWithHash(blockHash, true), + ) + return result, err +} + +func frostRetainedGroupCallArgument(message ethereum.CallMsg) map[string]interface{} { + result := map[string]interface{}{ + "from": message.From, + "to": message.To, + } + if len(message.Data) > 0 { + result["input"] = hexutil.Bytes(message.Data) + } + if message.Value != nil { + result["value"] = (*hexutil.Big)(message.Value) + } + if message.Gas != 0 { + result["gas"] = hexutil.Uint64(message.Gas) + } + if message.GasPrice != nil { + result["gasPrice"] = (*hexutil.Big)(message.GasPrice) + } + if message.GasFeeCap != nil { + result["maxFeePerGas"] = (*hexutil.Big)(message.GasFeeCap) + } + if message.GasTipCap != nil { + result["maxPriorityFeePerGas"] = (*hexutil.Big)(message.GasTipCap) + } + if message.AccessList != nil { + result["accessList"] = message.AccessList + } + if message.BlobGasFeeCap != nil { + result["maxFeePerBlobGas"] = (*hexutil.Big)(message.BlobGasFeeCap) + } + if message.BlobHashes != nil { + result["blobVersionedHashes"] = message.BlobHashes + } + return result +} + // signedFrostRetainedGroupHistorySource consumes independently signed, // paginated history receipts and checks their block commitments against a // separately configured finalized Ethereum endpoint. It intentionally does @@ -101,12 +196,14 @@ type signedFrostRetainedGroupHistorySource struct { maximumResponseBytes uint64 maximumUniqueBlocks uint64 maximumReadDuration time.Duration + requestTimeout time.Duration } var _ FrostRetainedGroupHistorySource = (*signedFrostRetainedGroupHistorySource)(nil) type frostRetainedGroupSignedEnvelope struct { Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` Payload json.RawMessage `json:"payload"` PayloadSHA256 string `json:"payloadSha256"` SignerPublicKeySPKI string `json:"signerPublicKeySpki"` @@ -129,6 +226,39 @@ type frostRetainedGroupWireFinality struct { AuthorizationSequence string `json:"authorizationSequence"` } +type frostRetainedGroupWireBlockPoint struct { + BlockNumber uint64 `json:"blockNumber"` + BlockHash string `json:"blockHash"` +} + +type frostRetainedGroupProtocolBinding struct { + Schema string `json:"schema"` + ChainID uint64 `json:"chainID"` + DomainChainID string `json:"domainChainID"` + GenesisBlockHash string `json:"genesisBlockHash"` + Checkpoint frostRetainedGroupWireBlockPoint `json:"checkpoint"` + ManifestHash string `json:"manifestHash"` + ProfileHash string `json:"profileHash"` + ImplementationSetHash string `json:"implementationSetHash"` + DescriptorSetHash string `json:"descriptorSetHash"` + LinkedLibraryDescriptorSetHash string `json:"linkedLibraryDescriptorSetHash"` + EndpointIdentitySetHash string `json:"endpointIdentitySetHash"` + SignerProtocolID string `json:"signerProtocolID"` + ReservationProtocolID string `json:"reservationProtocolID"` + EvidenceProtocolID string `json:"evidenceProtocolID"` + BitcoinOutboxProtocolID string `json:"bitcoinOutboxProtocolID"` + InventoryProtocolID string `json:"inventoryProtocolID"` + QuarantineProtocolID string `json:"quarantineProtocolID"` + SigningPolicyHash string `json:"signingPolicyHash"` + CanonicalStoreID string `json:"canonicalStoreID"` + CanonicalStoreFingerprint string `json:"canonicalStoreFingerprint"` + CanonicalClusterFingerprint string `json:"canonicalClusterFingerprint"` + QuarantineStoreID string `json:"quarantineStoreID"` + QuarantineStoreFingerprint string `json:"quarantineStoreFingerprint"` + QuarantineClusterFingerprint string `json:"quarantineClusterFingerprint"` + SourceIdentity frostRetainedGroupWireIdentity `json:"sourceIdentity"` +} + type frostRetainedGroupWireEventPoint struct { BlockNumber uint64 `json:"blockNumber"` BlockHash string `json:"blockHash"` @@ -156,24 +286,28 @@ type frostRetainedGroupWireMutation struct { } type frostRetainedGroupHistoryQuery struct { - Schema string `json:"schema"` - From frostRetainedGroupWireFinality `json:"from"` - To frostRetainedGroupWireFinality `json:"to"` + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + From frostRetainedGroupWireFinality `json:"from"` + To frostRetainedGroupWireFinality `json:"to"` } type frostRetainedGroupHistoryPageRequest struct { - Query frostRetainedGroupHistoryQuery `json:"query"` - Cursor string `json:"cursor"` + BindingHash string `json:"bindingHash"` + Query frostRetainedGroupHistoryQuery `json:"query"` + Cursor string `json:"cursor"` } type frostRetainedGroupHistoryReceipt struct { PageCount uint64 `json:"pageCount"` MutationCount uint64 `json:"mutationCount"` + BindingHash string `json:"bindingHash"` HistoryRoot string `json:"historyRoot"` } type frostRetainedGroupHistoryPagePayload struct { Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` Identity frostRetainedGroupWireIdentity `json:"identity"` ChainID uint64 `json:"chainID"` QueryHash string `json:"queryHash"` @@ -193,12 +327,14 @@ type frostRetainedGroupHistoryPagePayload struct { type frostRetainedGroupOperatorQuery struct { Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` OperatorAddress string `json:"operatorAddress"` At frostRetainedGroupWireFinality `json:"at"` } type frostRetainedGroupOperatorReceiptPayload struct { Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` Identity frostRetainedGroupWireIdentity `json:"identity"` ChainID uint64 `json:"chainID"` QueryHash string `json:"queryHash"` @@ -252,16 +388,37 @@ func NewFrostRetainedGroupHistorySource( if expectedChainID == 0 { return nil, fmt.Errorf("retained-group history expected chain ID is zero") } - verifier, err := ethclient.DialContext(ctx, canonicalEthereum) + httpTransport := http.DefaultTransport.(*http.Transport).Clone() + httpTransport.Proxy = nil + rpcHTTPClient := &http.Client{ + Transport: httpTransport, + Timeout: timeout, + } + rpcClient, err := rpc.DialOptions( + ctx, + canonicalEthereum, + rpc.WithHTTPClient(rpcHTTPClient), + rpc.WithWebsocketDialer(websocket.Dialer{ + HandshakeTimeout: timeout, + Proxy: nil, + }), + ) if err != nil { return nil, fmt.Errorf("cannot connect independent retained-group Ethereum verifier: [%w]", err) } + verifier := &canonicalFrostRetainedGroupEthereumVerifier{ + Client: ethclient.NewClient(rpcClient), + rpcClient: rpcClient, + } + exportTransport := http.DefaultTransport.(*http.Transport).Clone() + exportTransport.Proxy = nil source, err := newSignedFrostRetainedGroupHistorySource( ctx, endpoint, verifier, &http.Client{ - Timeout: timeout, + Transport: exportTransport, + Timeout: timeout, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return fmt.Errorf("retained-group export redirects are forbidden") }, @@ -269,6 +426,7 @@ func NewFrostRetainedGroupHistorySource( expectedChainID, identity, signerHash, + timeout, ) if err != nil { verifier.Close() @@ -285,14 +443,18 @@ func newSignedFrostRetainedGroupHistorySource( chainID uint64, identity FrostRetainedGroupHistoryIdentity, signerHash [32]byte, + requestTimeout time.Duration, ) (*signedFrostRetainedGroupHistorySource, error) { if exportEndpoint == nil || verifier == nil || httpClient == nil || chainID == 0 || strings.TrimSpace(identity.TrustDomainID) == "" || identity.EndpointFingerprint == [32]byte{} || signerHash == [32]byte{} || - identity.OperatorFingerprint != signerHash { + identity.OperatorFingerprint != signerHash || + requestTimeout < time.Second || requestTimeout > time.Minute { return nil, fmt.Errorf("retained-group history source configuration is incomplete") } - actualChainID, err := verifier.ChainID(ctx) + requestContext, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + actualChainID, err := verifier.ChainID(requestContext) if err != nil { return nil, fmt.Errorf("cannot identify independent retained-group Ethereum verifier: [%w]", err) } @@ -311,6 +473,7 @@ func newSignedFrostRetainedGroupHistorySource( maximumResponseBytes: frostRetainedGroupMaximumAggregateResponseBytes, maximumUniqueBlocks: frostRetainedGroupMaximumUniqueBlocks, maximumReadDuration: frostRetainedGroupMaximumReconciliationDuration, + requestTimeout: requestTimeout, } if _, err := source.FinalizedHead(ctx); err != nil { return nil, fmt.Errorf("independent retained-group Ethereum verifier has no usable finalized head: [%w]", err) @@ -432,6 +595,12 @@ func (source *signedFrostRetainedGroupHistorySource) Close() { } } +func (source *signedFrostRetainedGroupHistorySource) requestContext( + ctx context.Context, +) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, source.requestTimeout) +} + func (source *signedFrostRetainedGroupHistorySource) Identity( ctx context.Context, ) (FrostRetainedGroupHistoryIdentity, error) { @@ -452,8 +621,10 @@ func (source *signedFrostRetainedGroupHistorySource) FinalizedHead( if ctx == nil { return FrostPreSignFinality{}, fmt.Errorf("retained-group finalized-head context is nil") } + requestContext, cancel := source.requestContext(ctx) + defer cancel() header, err := source.verifier.HeaderByNumber( - ctx, + requestContext, big.NewInt(int64(rpc.FinalizedBlockNumber)), ) if err != nil { @@ -489,9 +660,11 @@ func (source *signedFrostRetainedGroupHistorySource) verifyPointAtFinalizedHead( point.BlockNumber > head.BlockNumber { return fmt.Errorf("retained-group point is invalid or not finalized") } - header, err := source.verifier.HeaderByNumber( - ctx, - new(big.Int).SetUint64(point.BlockNumber), + requestContext, cancel := source.requestContext(ctx) + defer cancel() + header, err := source.verifier.HeaderByHash( + requestContext, + common.Hash(point.BlockHash), ) if err != nil { return err @@ -500,6 +673,21 @@ func (source *signedFrostRetainedGroupHistorySource) verifyPointAtFinalizedHead( header.Number.Uint64() != point.BlockNumber || header.Hash() != common.Hash(point.BlockHash) { return fmt.Errorf("retained-group point does not match the independent canonical chain") } + canonicalContext, canonicalCancel := source.requestContext(ctx) + defer canonicalCancel() + canonicalHeader, err := source.verifier.HeaderByNumber( + canonicalContext, + new(big.Int).SetUint64(point.BlockNumber), + ) + if err != nil { + return err + } + if canonicalHeader == nil || canonicalHeader.Number == nil || + !canonicalHeader.Number.IsUint64() || + canonicalHeader.Number.Uint64() != point.BlockNumber || + canonicalHeader.Hash() != common.Hash(point.BlockHash) { + return fmt.Errorf("retained-group point is not canonical by height") + } if point.BlockNumber == head.BlockNumber && point.BlockHash != head.BlockHash { return fmt.Errorf("retained-group point conflicts with the finalized head") } @@ -542,9 +730,10 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( } query := frostRetainedGroupHistoryQuery{ - Schema: frostRetainedGroupHistoryRequestSchema, - From: frostRetainedGroupFinalityToWire(from), - To: frostRetainedGroupFinalityToWire(to), + Schema: frostRetainedGroupHistoryRequestSchema, + BindingHash: frostActivationHex32(evidence.bindingHash), + From: frostRetainedGroupFinalityToWire(from), + To: frostRetainedGroupFinalityToWire(to), } queryHash, err := frostRetainedGroupDomainHash( frostRetainedGroupHistoryQueryDomain, @@ -571,7 +760,11 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( return nil, fmt.Errorf("retained-group history cursor repeated") } seenCursors[cursor] = true - request := frostRetainedGroupHistoryPageRequest{Query: query, Cursor: cursor} + request := frostRetainedGroupHistoryPageRequest{ + BindingHash: frostActivationHex32(evidence.bindingHash), + Query: query, + Cursor: cursor, + } payload := &frostRetainedGroupHistoryPagePayload{} responseBytes, err := source.postSigned(ctx, "history", request, payload) if err != nil { @@ -628,7 +821,9 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( if payload.Complete { if payload.Receipt == nil || payload.NextCursor != "" || payload.Receipt.PageCount != pageIndex+1 || - payload.Receipt.MutationCount != uint64(len(mutations)) { + payload.Receipt.MutationCount != uint64(len(mutations)) || + payload.Receipt.BindingHash != + frostActivationHex32(evidence.bindingHash) { return nil, fmt.Errorf("retained-group final history receipt is inconsistent") } receiptRoot, err := parseFrostActivationHex32(payload.Receipt.HistoryRoot) @@ -636,6 +831,7 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( return nil, fmt.Errorf("retained-group history receipt root is invalid") } computedRoot := frostRetainedGroupHistoryRootFromHashes( + evidence.bindingHash, queryHash, mutationHashes, ) @@ -698,7 +894,12 @@ func (source *signedFrostRetainedGroupHistorySource) validateHistoryPage( snapshotID [32]byte, descriptorSetHash [32]byte, ) ([32]byte, error) { + evidence, evidenceErr := source.activationEvidence() + if evidenceErr != nil { + return [32]byte{}, evidenceErr + } if payload == nil || payload.Schema != frostRetainedGroupHistoryPageSchema || + payload.BindingHash != frostActivationHex32(evidence.bindingHash) || payload.ChainID != source.chainID || payload.PageIndex != pageIndex || payload.Cursor != cursor || !payload.EmptyAtFrom || !source.validWireIdentity(payload.Identity) { @@ -713,8 +914,7 @@ func (source *signedFrostRetainedGroupHistorySource) validateHistoryPage( return [32]byte{}, fmt.Errorf("retained-group history snapshot changed between pages") } pageDescriptorSetHash, err := parseFrostActivationHex32(payload.DescriptorSetHash) - evidence, evidenceErr := source.activationEvidence() - if err != nil || evidenceErr != nil || pageDescriptorSetHash != evidence.descriptorSetHash || + if err != nil || pageDescriptorSetHash != evidence.descriptorSetHash || (pageIndex > 0 && pageDescriptorSetHash != descriptorSetHash) { return [32]byte{}, fmt.Errorf("retained-group descriptor set differs from the signed activation manifest") } @@ -748,6 +948,9 @@ func (source *signedFrostRetainedGroupHistorySource) ResolveOperatorID( if ctx == nil { return 0, fmt.Errorf("retained-group operator-resolution context is nil") } + resolveContext, cancel := context.WithTimeout(ctx, source.maximumReadDuration) + defer cancel() + ctx = resolveContext evidence, err := source.activationEvidence() if err != nil { return 0, err @@ -765,6 +968,7 @@ func (source *signedFrostRetainedGroupHistorySource) ResolveOperatorID( } query := frostRetainedGroupOperatorQuery{ Schema: frostRetainedGroupOperatorRequestSchema, + BindingHash: frostActivationHex32(evidence.bindingHash), OperatorAddress: canonicalAddress, At: frostRetainedGroupFinalityToWire(at), } @@ -779,6 +983,7 @@ func (source *signedFrostRetainedGroupHistorySource) ResolveOperatorID( declaredQueryHash, err := parseFrostActivationHex32(payload.QueryHash) receiptAt, pointErr := frostRetainedGroupFinalityFromWire(payload.At) if payload.Schema != frostRetainedGroupOperatorReceiptSchema || + payload.BindingHash != frostActivationHex32(evidence.bindingHash) || payload.ChainID != source.chainID || !source.validWireIdentity(payload.Identity) || err != nil || declaredQueryHash != queryHash || pointErr != nil || receiptAt != at || payload.OperatorAddress != canonicalAddress || !payload.Found || payload.OperatorID == 0 { @@ -816,6 +1021,8 @@ func (source *signedFrostRetainedGroupHistorySource) postSigned( requestPayload interface{}, responsePayload interface{}, ) (uint64, error) { + requestContext, cancel := source.requestContext(ctx) + defer cancel() requestBody, err := json.Marshal(requestPayload) if err != nil { return 0, err @@ -823,7 +1030,7 @@ func (source *signedFrostRetainedGroupHistorySource) postSigned( endpoint := *source.exportEndpoint endpoint.Path = path.Join(endpoint.Path, operation) request, err := http.NewRequestWithContext( - ctx, + requestContext, http.MethodPost, endpoint.String(), bytes.NewReader(requestBody), @@ -866,7 +1073,10 @@ func (source *signedFrostRetainedGroupHistorySource) verifySignedEnvelope( envelope *frostRetainedGroupSignedEnvelope, target interface{}, ) error { - if envelope == nil || envelope.Schema != "tbtc-frost-retained-group-signed-envelope/v1" || + evidence, evidenceErr := source.activationEvidence() + if envelope == nil || evidenceErr != nil || + envelope.Schema != "tbtc-frost-retained-group-signed-envelope/v2" || + envelope.BindingHash != frostActivationHex32(evidence.bindingHash) || envelope.SignatureAlgorithm != "ed25519" || len(envelope.Payload) == 0 { return fmt.Errorf("retained-group signed envelope is malformed") } @@ -928,6 +1138,7 @@ func frostRetainedGroupDomainHash(domain string, value interface{}) ([32]byte, e } func frostRetainedGroupHistoryRoot( + bindingHash [32]byte, queryHash [32]byte, mutations []frostRetainedGroupWireMutation, ) ([32]byte, error) { @@ -939,15 +1150,21 @@ func frostRetainedGroupHistoryRoot( } hashes = append(hashes, sha256.Sum256(canonical)) } - return frostRetainedGroupHistoryRootFromHashes(queryHash, hashes), nil + return frostRetainedGroupHistoryRootFromHashes( + bindingHash, + queryHash, + hashes, + ), nil } func frostRetainedGroupHistoryRootFromHashes( + bindingHash [32]byte, queryHash [32]byte, mutationHashes [][32]byte, ) [32]byte { hasher := sha256.New() hasher.Write([]byte(frostRetainedGroupHistoryRootDomain)) + hasher.Write(bindingHash[:]) hasher.Write(queryHash[:]) count := make([]byte, 8) for i := uint(0); i < 8; i++ { diff --git a/pkg/tbtc/frost_retained_group_history_source_test.go b/pkg/tbtc/frost_retained_group_history_source_test.go index ca1e0dbd7f..5d3f7d006c 100644 --- a/pkg/tbtc/frost_retained_group_history_source_test.go +++ b/pkg/tbtc/frost_retained_group_history_source_test.go @@ -22,8 +22,10 @@ import ( ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" "github.com/keep-network/keep-core/pkg/chain" frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" frostregistry "github.com/keep-network/keep-core/pkg/frost/registry" @@ -40,6 +42,7 @@ type frostRetainedGroupHistoryTestVerifier struct { reorgHeader *types.Header receipts map[common.Hash]*types.Receipt code map[common.Address][]byte + storage map[common.Address]map[common.Hash][]byte sortitionPool common.Address operator common.Address operatorAt uint64 @@ -47,6 +50,88 @@ type frostRetainedGroupHistoryTestVerifier struct { closed bool } +type frostRetainedGroupCanonicalRPCTestAPI struct { + points []rpc.BlockNumberOrHash +} + +func (api *frostRetainedGroupCanonicalRPCTestAPI) GetCode( + _ context.Context, + _ common.Address, + point rpc.BlockNumberOrHash, +) (hexutil.Bytes, error) { + api.points = append(api.points, point) + return hexutil.Bytes{0x01}, nil +} + +func (api *frostRetainedGroupCanonicalRPCTestAPI) GetStorageAt( + _ context.Context, + _ common.Address, + _ common.Hash, + point rpc.BlockNumberOrHash, +) (hexutil.Bytes, error) { + api.points = append(api.points, point) + return make(hexutil.Bytes, 32), nil +} + +func (api *frostRetainedGroupCanonicalRPCTestAPI) Call( + _ context.Context, + _ map[string]interface{}, + point rpc.BlockNumberOrHash, +) (hexutil.Bytes, error) { + api.points = append(api.points, point) + return make(hexutil.Bytes, 32), nil +} + +func TestCanonicalFrostRetainedGroupEthereumVerifier_RequiresCanonicalHashState( + t *testing.T, +) { + server := rpc.NewServer() + api := &frostRetainedGroupCanonicalRPCTestAPI{} + if err := server.RegisterName("eth", api); err != nil { + t.Fatal(err) + } + client := rpc.DialInProc(server) + defer client.Close() + verifier := &canonicalFrostRetainedGroupEthereumVerifier{ + rpcClient: client, + } + blockHash := common.HexToHash("0x1234") + address := common.HexToAddress( + "0x1111111111111111111111111111111111111111", + ) + if _, err := verifier.CodeAtHash( + context.Background(), + address, + blockHash, + ); err != nil { + t.Fatal(err) + } + if _, err := verifier.StorageAtHash( + context.Background(), + address, + common.Hash{}, + blockHash, + ); err != nil { + t.Fatal(err) + } + if _, err := verifier.CallContractAtHash( + context.Background(), + ethereum.CallMsg{To: &address, Data: []byte{0x01}}, + blockHash, + ); err != nil { + t.Fatal(err) + } + if len(api.points) != 3 { + t.Fatalf("unexpected exact-hash call count [%d]", len(api.points)) + } + for _, point := range api.points { + if point.BlockHash == nil || *point.BlockHash != blockHash || + !point.RequireCanonical || point.BlockNumber != nil { + t.Fatalf("state read did not require canonical hash [%+v]", point) + } + } +} + func (verifier *frostRetainedGroupHistoryTestVerifier) ChainID( context.Context, ) (*big.Int, error) { @@ -71,6 +156,29 @@ func (verifier *frostRetainedGroupHistoryTestVerifier) HeaderByNumber( return verifier.headers[blockNumber], nil } +func (verifier *frostRetainedGroupHistoryTestVerifier) HeaderByHash( + _ context.Context, + hash common.Hash, +) (*types.Header, error) { + verifier.mutex.Lock() + defer verifier.mutex.Unlock() + for blockNumber, header := range verifier.headers { + if header.Hash() != hash { + continue + } + verifier.reads[blockNumber]++ + if blockNumber == verifier.reorgBlock && verifier.reorgHeader != nil && + verifier.reads[blockNumber] > verifier.reorgAfterRead { + if verifier.reorgHeader.Hash() == hash { + return verifier.reorgHeader, nil + } + return nil, nil + } + return header, nil + } + return nil, nil +} + func (verifier *frostRetainedGroupHistoryTestVerifier) Close() { verifier.mutex.Lock() defer verifier.mutex.Unlock() @@ -88,11 +196,14 @@ func (verifier *frostRetainedGroupHistoryTestVerifier) TransactionReceipt( return receipt, nil } -func (verifier *frostRetainedGroupHistoryTestVerifier) CodeAt( +func (verifier *frostRetainedGroupHistoryTestVerifier) CodeAtHash( _ context.Context, address common.Address, - _ *big.Int, + hash common.Hash, ) ([]byte, error) { + if _, err := verifier.HeaderByHash(context.Background(), hash); err != nil { + return nil, err + } code := verifier.code[address] if len(code) == 0 { return nil, fmt.Errorf("missing code") @@ -100,13 +211,30 @@ func (verifier *frostRetainedGroupHistoryTestVerifier) CodeAt( return append([]byte{}, code...), nil } -func (verifier *frostRetainedGroupHistoryTestVerifier) CallContract( +func (verifier *frostRetainedGroupHistoryTestVerifier) StorageAtHash( + _ context.Context, + address common.Address, + slot common.Hash, + blockHash common.Hash, +) ([]byte, error) { + if _, err := verifier.HeaderByHash(context.Background(), blockHash); err != nil { + return nil, err + } + if slots := verifier.storage[address]; slots != nil { + if value := slots[slot]; value != nil { + return append([]byte{}, value...), nil + } + } + return make([]byte, 32), nil +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) CallContractAtHash( _ context.Context, call ethereum.CallMsg, - block *big.Int, + blockHash common.Hash, ) ([]byte, error) { - if call.To == nil || *call.To != verifier.sortitionPool || block == nil || - !block.IsUint64() || block.Uint64() != verifier.operatorAt || len(call.Data) != 36 || + if call.To == nil || *call.To != verifier.sortitionPool || + blockHash != verifier.headers[verifier.operatorAt].Hash() || len(call.Data) != 36 || !bytes.Equal(call.Data[:4], []byte{0x5a, 0x48, 0xb4, 0x6b}) || !bytes.Equal(call.Data[4:16], make([]byte, 12)) || !bytes.Equal(call.Data[16:], verifier.operator[:]) { @@ -124,6 +252,7 @@ type frostRetainedGroupHistoryTestExport struct { historyResponder func(frostRetainedGroupHistoryPageRequest) interface{} operatorResponder func(frostRetainedGroupOperatorQuery) interface{} corruptSignature bool + bindingHash [32]byte } func (export *frostRetainedGroupHistoryTestExport) ServeHTTP( @@ -169,7 +298,8 @@ func (export *frostRetainedGroupHistoryTestExport) ServeHTTP( signature[0] ^= 0xff } envelope := frostRetainedGroupSignedEnvelope{ - Schema: "tbtc-frost-retained-group-signed-envelope/v1", + Schema: "tbtc-frost-retained-group-signed-envelope/v2", + BindingHash: frostActivationHex32(export.bindingHash), Payload: json.RawMessage(canonical), PayloadSHA256: frostActivationHex32(payloadHash), SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(export.publicKeyDER), @@ -190,6 +320,7 @@ type frostRetainedGroupHistorySourceFixture struct { source *signedFrostRetainedGroupHistorySource identity FrostRetainedGroupHistoryIdentity profile FrostPreSignActivationProfile + runtimeManifest FrostPreSignActivationRuntimeManifest from FrostPreSignFinality to FrostPreSignFinality descriptorSetHash [32]byte @@ -225,10 +356,12 @@ func newFrostRetainedGroupHistorySourceFixture( bridgeCode := []byte{0x60, 0x01, 0x60, 0x02} registryCode := []byte{0x60, 0x03, 0x60, 0x04} sortitionPoolCode := []byte{0x60, 0x05, 0x60, 0x06} - profile := frostRetainedGroupHistoryTestProfile( + profile, runtimeManifest := frostRetainedGroupHistoryTestProfile( + t, bridgeCode, registryCode, sortitionPoolCode, + headers[1].Hash(), ) operatorAddress := common.HexToAddress("0x1111111111111111111111111111111111111111") verifier := &frostRetainedGroupHistoryTestVerifier{ @@ -238,6 +371,7 @@ func newFrostRetainedGroupHistorySourceFixture( reads: make(map[uint64]int), receipts: make(map[common.Hash]*types.Receipt), code: make(map[common.Address][]byte), + storage: make(map[common.Address]map[common.Hash][]byte), sortitionPool: common.Address(profile.SortitionPool), operator: operatorAddress, operatorAt: 6, @@ -262,6 +396,8 @@ func newFrostRetainedGroupHistorySourceFixture( EndpointFingerprint: [32]byte{0x31}, OperatorFingerprint: operatorFingerprint, } + runtimeManifest.CanonicalJournal.SourceOperatorFingerprint = + identity.OperatorFingerprint source, err := newSignedFrostRetainedGroupHistorySource( context.Background(), endpoint, @@ -270,6 +406,7 @@ func newFrostRetainedGroupHistorySourceFixture( 1, identity, operatorFingerprint, + frostRetainedGroupDefaultTimeout, ) if err != nil { t.Fatal(err) @@ -277,11 +414,11 @@ func newFrostRetainedGroupHistorySourceFixture( descriptorSetHash := [32]byte{0x42} if err := source.BindFrostRetainedGroupActivationEvidence( profile, - profile.ActivationManifestHash, - descriptorSetHash, + runtimeManifest, ); err != nil { t.Fatal(err) } + export.bindingHash = source.evidence.bindingHash t.Cleanup(source.Close) fixture := &frostRetainedGroupHistorySourceFixture{ t: t, @@ -291,6 +428,7 @@ func newFrostRetainedGroupHistorySourceFixture( source: source, identity: identity, profile: profile, + runtimeManifest: runtimeManifest, from: FrostPreSignFinality{BlockNumber: 1, BlockHash: headers[1].Hash()}, to: FrostPreSignFinality{BlockNumber: 6, BlockHash: headers[6].Hash()}, descriptorSetHash: descriptorSetHash, @@ -308,14 +446,23 @@ func newFrostRetainedGroupHistorySourceFixture( } func frostRetainedGroupHistoryTestProfile( + t *testing.T, bridgeCode []byte, registryCode []byte, sortitionPoolCode []byte, -) FrostPreSignActivationProfile { + deploymentBlockHash common.Hash, +) (FrostPreSignActivationProfile, FrostPreSignActivationRuntimeManifest) { + t.Helper() + emptyLinkedLibraryDescriptorHash, err := + frostRetainedGroupLinkedLibraryInventoryHash( + []FrostPreSignLinkedLibraryEvidence{}, + ) + if err != nil { + t.Fatal(err) + } profile := FrostPreSignActivationProfile{ - DomainChainID: [32]byte{0x01}, + DomainChainID: [32]byte{31: 0x01}, ActivationManifestHash: [32]byte{0x02}, - ImplementationSetHash: [32]byte{0x03}, BridgeAddress: [20]byte{0x11}, RegistryAddress: [20]byte{0x12}, CompleteRouter: [20]byte{0x13}, @@ -332,8 +479,83 @@ func frostRetainedGroupHistoryTestProfile( EvidenceProtocolID: frostCompleteEvidenceProtocolID(), SigningPolicyHash: frostPreSignSigningPolicyHash(), } + inputs := []struct { + role string + name string + address [20]byte + codeHash [32]byte + }{ + {"bridge", "Bridge", profile.BridgeAddress, profile.BridgeCodeHash}, + {"completeRouter", "COMPLETE router", profile.CompleteRouter, profile.CompleteRouterCodeHash}, + {"authorizationRegistry", "authorization registry", profile.RegistryAddress, profile.RegistryCodeHash}, + {"frostWalletRegistry", "FROST wallet registry", profile.FrostRegistry, profile.FrostRegistryCodeHash}, + {"frostProposalValidator", "proposal validator", profile.ProposalValidator, profile.ProposalValidatorCodeHash}, + {"frostSortitionPool", "sortition pool", profile.SortitionPool, profile.SortitionPoolCodeHash}, + {"ecdsaFraudRouter", "ECDSA fraud router", [20]byte{0x17}, [32]byte{0x27}}, + {"ecdsaCutoverCoordinator", "ECDSA cutover coordinator", [20]byte{0x18}, [32]byte{0x28}}, + } + deployments := make([]FrostPreSignDeploymentEvidence, 0, len(inputs)) + for _, input := range inputs { + descriptor := FrostPreSignDeploymentDescriptorEvidence{ + Address: input.address, + RuntimeCodeHash: input.codeHash, + Upgradeability: "immutable", + LinkedLibraryDescriptorHash: emptyLinkedLibraryDescriptorHash, + } + descriptor.DescriptorHash = descriptor.ComputeHash() + deployments = append(deployments, FrostPreSignDeploymentEvidence{ + Role: input.role, + Name: input.name, + DeploymentBlock: 1, + RelevantEventStartBlock: 1, + Current: descriptor, + HistoricalEpochs: []FrostPreSignDeploymentEpochEvidence{{ + Start: FrostPreSignFinality{ + BlockNumber: 1, + BlockHash: [32]byte(deploymentBlockHash), + }, + Descriptor: descriptor, + }}, + }) + } + profile.ImplementationSetHash = + ComputeFrostPreSignDeploymentEvidenceHash(deployments) profile.ProfileHash = profile.ComputeHash() - return profile + linkedLibraryDescriptorSetHash, err := + frostRetainedGroupLinkedLibraryDescriptorSetHash(deployments) + if err != nil { + t.Fatal(err) + } + return profile, FrostPreSignActivationRuntimeManifest{ + ManifestHash: profile.ActivationManifestHash, + DomainChainID: profile.DomainChainID, + GenesisBlockHash: [32]byte{0x39}, + ProfileHash: profile.ProfileHash, + ImplementationSetHash: profile.ImplementationSetHash, + LinkedLibraryDescriptorSetHash: linkedLibraryDescriptorSetHash, + EndpointIdentitySetHash: [32]byte{0x3a}, + Deployments: deployments, + SignerProtocolID: [32]byte{0x45}, + ReservationProtocolID: profile.ReservationProtocolID, + BitcoinOutboxProtocolID: [32]byte{0x46}, + SigningPolicyHash: profile.SigningPolicyHash, + RetainedGroupInventoryProtocolID: [32]byte{0x43}, + CanonicalJournal: FrostRetainedGroupCanonicalJournalManifest{ + StoreID: "canonical-test-store", + StoreFingerprint: [32]byte{0x47}, + ClusterFingerprint: [32]byte{0x48}, + Checkpoint: FrostPreSignFinality{BlockNumber: 1, BlockHash: [32]byte(deploymentBlockHash)}, + DescriptorSetHash: [32]byte{0x42}, + SourceTrustDomainID: "independent-retained-history-test", + SourceEndpointFingerprint: [32]byte{0x31}, + }, + QuarantineJournal: FrostRetainedGroupQuarantineJournalManifest{ + ProtocolID: [32]byte{0x44}, + StoreID: "quarantine-test-store", + StoreFingerprint: [32]byte{0x49}, + ClusterFingerprint: [32]byte{0x4a}, + }, + } } func frostRetainedGroupHistoryTestMutations( @@ -486,6 +708,12 @@ func (fixture *frostRetainedGroupHistorySourceFixture) installReceipts() { if err != nil { fixture.t.Fatal(err) } + registryAddress := common.Address( + evidence.deployments["frostWalletRegistry"].Current.Address, + ) + bridgeAddress := common.Address( + evidence.deployments["bridge"].Current.Address, + ) admission := fixture.mutations[0] dkgResult, dkgData, dkgResultHash := frostRetainedGroupHistoryTestDkgResultWithMisbehaved( fixture.t, @@ -507,7 +735,7 @@ func (fixture *frostRetainedGroupHistorySourceFixture) installReceipts() { fixture.mutations[0] = admission submissionLog := frostRetainedGroupHistoryTestLog( admission.DkgSubmissionPoint, - evidence.registry.address, + registryAddress, []common.Hash{ evidence.registryABI.Events["DkgResultSubmitted"].ID, common.Hash(admission.DkgResultHash), @@ -520,7 +748,7 @@ func (fixture *frostRetainedGroupHistorySourceFixture) installReceipts() { approvalLog := frostRetainedGroupHistoryTestLog( admission.DkgApprovalPoint, - evidence.registry.address, + registryAddress, []common.Hash{ evidence.registryABI.Events["DkgResultApproved"].ID, common.Hash(admission.DkgResultHash), @@ -530,7 +758,7 @@ func (fixture *frostRetainedGroupHistorySourceFixture) installReceipts() { ) creationLog := frostRetainedGroupHistoryTestLog( admission.CreationPoint, - evidence.registry.address, + registryAddress, []common.Hash{ evidence.registryABI.Events["WalletCreated"].ID, common.Hash(admission.WalletID), @@ -540,7 +768,7 @@ func (fixture *frostRetainedGroupHistorySourceFixture) installReceipts() { ) registrationLog := frostRetainedGroupHistoryTestLog( admission.BridgeRegistrationPoint, - evidence.bridge.address, + bridgeAddress, []common.Hash{ evidence.bridgeABI.Events["NewWalletRegisteredV2"].ID, common.Hash(admission.WalletID), @@ -560,7 +788,7 @@ func (fixture *frostRetainedGroupHistorySourceFixture) installReceipts() { closing := fixture.mutations[1] closingLog := frostRetainedGroupHistoryTestLog( closing.Point, - evidence.bridge.address, + bridgeAddress, []common.Hash{ evidence.bridgeABI.Events["WalletClosing"].ID, {}, @@ -575,7 +803,7 @@ func (fixture *frostRetainedGroupHistorySourceFixture) installReceipts() { registryClosure := fixture.mutations[3] closedLog := frostRetainedGroupHistoryTestLog( closed.Point, - evidence.bridge.address, + bridgeAddress, []common.Hash{ evidence.bridgeABI.Events["WalletClosed"].ID, {}, @@ -585,7 +813,7 @@ func (fixture *frostRetainedGroupHistorySourceFixture) installReceipts() { ) registryClosureLog := frostRetainedGroupHistoryTestLog( registryClosure.Point, - evidence.registry.address, + registryAddress, []common.Hash{ evidence.registryABI.Events["WalletClosed"].ID, common.Hash(registryClosure.WalletID), @@ -683,6 +911,7 @@ func (fixture *frostRetainedGroupHistorySourceFixture) historyPages( } page := &frostRetainedGroupHistoryPagePayload{ Schema: frostRetainedGroupHistoryPageSchema, + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), Identity: identity, ChainID: 1, QueryHash: frostActivationHex32(queryHash), @@ -705,13 +934,18 @@ func (fixture *frostRetainedGroupHistorySourceFixture) historyPages( } previousPageHash = sha256.Sum256(canonical) } - historyRoot, err := frostRetainedGroupHistoryRoot(queryHash, wireMutations) + historyRoot, err := frostRetainedGroupHistoryRoot( + fixture.source.evidence.bindingHash, + queryHash, + wireMutations, + ) if err != nil { fixture.t.Fatal(err) } pages[len(pages)-1].Receipt = &frostRetainedGroupHistoryReceipt{ PageCount: uint64(len(pages)), MutationCount: uint64(len(wireMutations)), + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), HistoryRoot: frostActivationHex32(historyRoot), } return pages @@ -720,6 +954,10 @@ func (fixture *frostRetainedGroupHistorySourceFixture) historyPages( func (fixture *frostRetainedGroupHistorySourceFixture) historyResponse( request frostRetainedGroupHistoryPageRequest, ) interface{} { + if request.BindingHash != + frostActivationHex32(fixture.source.evidence.bindingHash) { + return nil + } pages := fixture.historyPages(request.Query) for _, page := range pages { if page.Cursor == request.Cursor { @@ -747,6 +985,9 @@ func (fixture *frostRetainedGroupHistorySourceFixture) operatorResponse( } payload := &frostRetainedGroupOperatorReceiptPayload{ Schema: frostRetainedGroupOperatorReceiptSchema, + BindingHash: frostActivationHex32( + fixture.source.evidence.bindingHash, + ), Identity: frostRetainedGroupWireIdentity{ TrustDomainID: fixture.identity.TrustDomainID, EndpointFingerprint: frostActivationHex32(fixture.identity.EndpointFingerprint), @@ -795,6 +1036,226 @@ func TestSignedFrostRetainedGroupHistorySource_ReadsCompletePaginatedHistory( } } +func TestSignedFrostRetainedGroupHistorySource_ProtocolBindingCommitsRuntime( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + baseline := fixture.source.evidence.bindingHash + testCases := map[string]func( + *FrostPreSignActivationProfile, + *FrostPreSignActivationRuntimeManifest, + ){ + "domain chain": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.DomainChainID[31] ^= 0xff + }, + "genesis": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.GenesisBlockHash[0] ^= 0xff + }, + "checkpoint": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.CanonicalJournal.Checkpoint.BlockHash[0] ^= 0xff + }, + "manifest": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.ManifestHash[0] ^= 0xff + }, + "profile": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.ProfileHash[0] ^= 0xff + }, + "implementation": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.ImplementationSetHash[0] ^= 0xff + }, + "descriptor": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.CanonicalJournal.DescriptorSetHash[0] ^= 0xff + }, + "linked libraries": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.LinkedLibraryDescriptorSetHash[0] ^= 0xff + }, + "endpoint identities": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.EndpointIdentitySetHash[0] ^= 0xff + }, + "protocol": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.SignerProtocolID[0] ^= 0xff + }, + "evidence protocol": func( + profile *FrostPreSignActivationProfile, + _ *FrostPreSignActivationRuntimeManifest, + ) { + profile.EvidenceProtocolID[0] ^= 0xff + }, + "canonical store": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.CanonicalJournal.StoreID += "-other" + }, + "canonical cluster": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.CanonicalJournal.ClusterFingerprint[0] ^= 0xff + }, + "quarantine store": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.StoreFingerprint[0] ^= 0xff + }, + } + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + profile := fixture.profile + runtime := fixture.runtimeManifest + mutate(&profile, &runtime) + binding, err := fixture.source.computeProtocolBinding(profile, runtime) + if err != nil { + t.Fatal(err) + } + if binding == baseline { + t.Fatalf("%s was omitted from the protocol binding", name) + } + }) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsInconsistentRuntimeEvidence( + t *testing.T, +) { + testCases := map[string]struct { + mutate func(*FrostPreSignActivationProfile, *FrostPreSignActivationRuntimeManifest) + expected string + }{ + "profile role": { + mutate: func( + profile *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + for index := range runtime.Deployments { + deployment := &runtime.Deployments[index] + if deployment.Role != "bridge" { + continue + } + deployment.Current.Address[0] ^= 0xff + deployment.Current.DescriptorHash = + deployment.Current.ComputeHash() + last := len(deployment.HistoricalEpochs) - 1 + deployment.HistoricalEpochs[last].Descriptor = + cloneFrostRetainedGroupDeploymentDescriptor( + deployment.Current, + ) + } + profile.ImplementationSetHash = + ComputeFrostPreSignDeploymentEvidenceHash( + runtime.Deployments, + ) + profile.ProfileHash = profile.ComputeHash() + runtime.ImplementationSetHash = + profile.ImplementationSetHash + runtime.ProfileHash = profile.ProfileHash + }, + expected: "differs from the activation profile", + }, + "recursive descriptor": { + mutate: func( + profile *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + for index := range runtime.Deployments { + deployment := &runtime.Deployments[index] + if deployment.Role != "bridge" { + continue + } + deployment.Current.LinkedLibraryDescriptorHash[0] ^= 0xff + deployment.Current.DescriptorHash = + deployment.Current.ComputeHash() + last := len(deployment.HistoricalEpochs) - 1 + deployment.HistoricalEpochs[last].Descriptor = + cloneFrostRetainedGroupDeploymentDescriptor( + deployment.Current, + ) + } + profile.ImplementationSetHash = + ComputeFrostPreSignDeploymentEvidenceHash( + runtime.Deployments, + ) + profile.ProfileHash = profile.ComputeHash() + runtime.ImplementationSetHash = + profile.ImplementationSetHash + runtime.ProfileHash = profile.ProfileHash + }, + expected: "linked-library descriptor hash mismatch", + }, + "global descriptor set": { + mutate: func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.LinkedLibraryDescriptorSetHash[0] ^= 0xff + }, + expected: "descriptor set differs", + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + profile := fixture.profile + runtime := fixture.runtimeManifest + runtime.Deployments = make( + []FrostPreSignDeploymentEvidence, + len(fixture.runtimeManifest.Deployments), + ) + for index, deployment := range fixture.runtimeManifest.Deployments { + runtime.Deployments[index] = + cloneFrostRetainedGroupDeploymentEvidence(deployment) + } + testCase.mutate(&profile, &runtime) + unbound := &signedFrostRetainedGroupHistorySource{ + chainID: fixture.source.chainID, + identity: fixture.source.identity, + } + err := unbound.BindFrostRetainedGroupActivationEvidence( + profile, + runtime, + ) + if err == nil || !strings.Contains(err.Error(), testCase.expected) { + t.Fatalf( + "expected %s inconsistency rejection, got [%v]", + name, + err, + ) + } + }) + } +} + func TestSignedFrostRetainedGroupHistorySource_DecodesCanonicalVerifiedBytes( t *testing.T, ) { @@ -810,7 +1271,8 @@ func TestSignedFrostRetainedGroupHistorySource_DecodesCanonicalVerifiedBytes( append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...), ) envelope := &frostRetainedGroupSignedEnvelope{ - Schema: "tbtc-frost-retained-group-signed-envelope/v1", + Schema: "tbtc-frost-retained-group-signed-envelope/v2", + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), Payload: raw, PayloadSHA256: frostActivationHex32(payloadHash), SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(fixture.export.publicKeyDER), @@ -830,12 +1292,84 @@ func TestSignedFrostRetainedGroupHistorySource_DecodesCanonicalVerifiedBytes( } } +func TestSignedFrostRetainedGroupHistorySource_RejectsCrossBindingEnvelopeAndRoot( + t *testing.T, +) { + t.Run("signed envelope", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + raw := json.RawMessage(`{"bindingHash":"0x01"}`) + canonical, err := canonicalFrostActivationValue(raw) + if err != nil { + t.Fatal(err) + } + payloadHash := sha256.Sum256(canonical) + signature := ed25519.Sign( + fixture.export.privateKey, + append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...), + ) + envelope := &frostRetainedGroupSignedEnvelope{ + Schema: "tbtc-frost-retained-group-signed-envelope/v2", + BindingHash: frostActivationHex32([32]byte{0xff}), + Payload: raw, + PayloadSHA256: frostActivationHex32(payloadHash), + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(fixture.export.publicKeyDER), + SignatureAlgorithm: "ed25519", + Signature: base64.StdEncoding.EncodeToString(signature), + } + capture := json.RawMessage{} + err = fixture.source.verifySignedEnvelope(envelope, &capture) + if err == nil || !strings.Contains(err.Error(), "malformed") { + t.Fatalf("expected cross-binding envelope rejection, got [%v]", err) + } + }) + + t.Run("history root", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.pageMutator = func(page *frostRetainedGroupHistoryPagePayload) { + if !page.Complete { + return + } + queryHash, err := parseFrostActivationHex32(page.QueryHash) + if err != nil { + t.Fatal(err) + } + mutationHashes := make([][32]byte, 0, len(fixture.mutations)) + for _, mutation := range fixture.mutations { + canonical, err := canonicalFrostActivationValue( + frostRetainedGroupMutationToWire(mutation), + ) + if err != nil { + t.Fatal(err) + } + mutationHashes = append(mutationHashes, sha256.Sum256(canonical)) + } + page.Receipt.HistoryRoot = frostActivationHex32( + frostRetainedGroupHistoryRootFromHashes( + [32]byte{0xfe}, + queryHash, + mutationHashes, + ), + ) + } + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + ) + if err == nil || + !strings.Contains(err.Error(), "does not cover the exact mutation sequence") { + t.Fatalf("expected cross-binding history-root rejection, got [%v]", err) + } + }) +} + func TestSignedFrostRetainedGroupHistorySource_RejectsDuplicateSignedPayloadKey( t *testing.T, ) { fixture := newFrostRetainedGroupHistorySourceFixture(t) envelope := &frostRetainedGroupSignedEnvelope{ - Schema: "tbtc-frost-retained-group-signed-envelope/v1", + Schema: "tbtc-frost-retained-group-signed-envelope/v2", + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), Payload: json.RawMessage(`{"schema":"v1","schema":"v2"}`), PayloadSHA256: frostActivationHex32([32]byte{0x01}), SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(fixture.export.publicKeyDER), @@ -853,7 +1387,7 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsNonExactSignedPayloadKey( t *testing.T, ) { fixture := newFrostRetainedGroupHistorySourceFixture(t) - payload := json.RawMessage(`{"Schema":"tbtc-frost-retained-group-history-page/v1"}`) + payload := json.RawMessage(`{"Schema":"tbtc-frost-retained-group-history-page/v2"}`) canonical, err := canonicalFrostActivationValue(payload) if err != nil { t.Fatal(err) @@ -864,7 +1398,8 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsNonExactSignedPayloadKey( append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...), ) envelope := &frostRetainedGroupSignedEnvelope{ - Schema: "tbtc-frost-retained-group-signed-envelope/v1", + Schema: "tbtc-frost-retained-group-signed-envelope/v2", + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), Payload: payload, PayloadSHA256: frostActivationHex32(payloadHash), SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(fixture.export.publicKeyDER), @@ -1119,6 +1654,14 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsIdentityCheckpointAndRecei "manifest descriptor": func(page *frostRetainedGroupHistoryPagePayload) { page.DescriptorSetHash = frostActivationHex32([32]byte{0xdd}) }, + "protocol binding": func(page *frostRetainedGroupHistoryPagePayload) { + page.BindingHash = frostActivationHex32([32]byte{0xdc}) + }, + "receipt binding": func(page *frostRetainedGroupHistoryPagePayload) { + if page.Complete { + page.Receipt.BindingHash = frostActivationHex32([32]byte{0xdb}) + } + }, "receipt": func(page *frostRetainedGroupHistoryPagePayload) { if page.Complete { page.Receipt.HistoryRoot = frostActivationHex32([32]byte{0xee}) @@ -1186,6 +1729,176 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsManifestCodeDrift( } } +func TestSignedFrostRetainedGroupHistorySource_RejectsDeploymentTransitionEvent( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + deployment := cloneFrostRetainedGroupDeploymentEvidence( + fixture.source.evidence.deployments["bridge"], + ) + firstEnd := FrostPreSignFinality{ + BlockNumber: 2, + BlockHash: fixture.verifier.headers[2].Hash(), + } + deployment.HistoricalEpochs = []FrostPreSignDeploymentEpochEvidence{ + { + Start: deployment.HistoricalEpochs[0].Start, + End: &firstEnd, + Descriptor: deployment.HistoricalEpochs[0].Descriptor, + }, + { + Start: FrostPreSignFinality{ + BlockNumber: 3, + BlockHash: fixture.verifier.headers[3].Hash(), + }, + Descriptor: deployment.Current, + }, + } + + if _, err := frostRetainedGroupDeploymentDescriptorAt( + deployment, + 3, + fixture.verifier.headers[3].Hash(), + true, + ); err == nil || !strings.Contains(err.Error(), "implementation-transition block") { + t.Fatalf("expected implementation-transition event rejection, got [%v]", err) + } + if _, err := frostRetainedGroupDeploymentDescriptorAt( + deployment, + 3, + fixture.verifier.headers[3].Hash(), + false, + ); err != nil { + t.Fatalf("expected exact transition state read to select the new epoch: [%v]", err) + } + if _, err := frostRetainedGroupDeploymentDescriptorAt( + deployment, + 2, + [32]byte{0xff}, + false, + ); err == nil || !strings.Contains(err.Error(), "signed deployment boundary") { + t.Fatalf("expected exact epoch-boundary hash rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsEIP1967SlotDrift( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + descriptor := cloneFrostRetainedGroupDeploymentDescriptor( + fixture.source.evidence.deployments["bridge"].Current, + ) + proxyAddress := common.Address(descriptor.Address) + implementationAddress := common.HexToAddress( + "0x2222222222222222222222222222222222222222", + ) + adminAddress := common.HexToAddress( + "0x3333333333333333333333333333333333333333", + ) + implementationCode := []byte{0x60, 0x51} + adminCode := []byte{0x60, 0x52} + implementationSlotValue := [32]byte{} + copy(implementationSlotValue[12:], implementationAddress[:]) + adminSlotValue := [32]byte{} + copy(adminSlotValue[12:], adminAddress[:]) + + descriptor.Upgradeability = "eip1967" + descriptor.ImplementationAddress = [20]byte(implementationAddress) + descriptor.ImplementationCodeHash = [32]byte( + crypto.Keccak256Hash(implementationCode), + ) + descriptor.AdminAddress = [20]byte(adminAddress) + descriptor.AdminCodeHash = [32]byte(crypto.Keccak256Hash(adminCode)) + descriptor.ImplementationSlotValue = implementationSlotValue + descriptor.AdminSlotValue = adminSlotValue + descriptor.DescriptorHash = descriptor.ComputeHash() + fixture.verifier.code[implementationAddress] = implementationCode + fixture.verifier.code[adminAddress] = adminCode + fixture.verifier.storage[proxyAddress] = map[common.Hash][]byte{ + frostRetainedGroupEIP1967Slot("eip1967.proxy.implementation"): make( + []byte, + 32, + ), + frostRetainedGroupEIP1967Slot("eip1967.proxy.admin"): append( + []byte{}, + adminSlotValue[:]..., + ), + } + + err := fixture.source.authenticateContractDeployment( + context.Background(), + descriptor, + 2, + fixture.verifier.headers[2].Hash(), + make(frostRetainedGroupCodeCache), + ) + if err == nil || !strings.Contains(err.Error(), "slot value mismatch") { + t.Fatalf("expected EIP-1967 slot-drift rejection, got [%v]", err) + } +} + +func TestSignedFrostRetainedGroupHistorySource_RejectsLinkedLibraryDrift( + t *testing.T, +) { + testCases := map[string]struct { + copyReference bool + actualCode []byte + expected string + }{ + "reference": { + actualCode: []byte{0x60, 0x61}, + expected: "reference", + }, + "runtime code": { + copyReference: true, + actualCode: []byte{0xff}, + expected: "signed activation manifest", + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + descriptor := cloneFrostRetainedGroupDeploymentDescriptor( + fixture.source.evidence.deployments["bridge"].Current, + ) + ownerAddress := common.Address(descriptor.Address) + libraryAddress := common.HexToAddress( + "0x4444444444444444444444444444444444444444", + ) + expectedLibraryCode := []byte{0x60, 0x61} + ownerCode := make([]byte, 24) + if testCase.copyReference { + copy(ownerCode[2:], libraryAddress[:]) + } + descriptor.RuntimeCodeHash = [32]byte(crypto.Keccak256Hash(ownerCode)) + descriptor.LinkedLibraries = []FrostPreSignLinkedLibraryEvidence{{ + ProtocolRole: "bridge-library", + Address: [20]byte(libraryAddress), + RuntimeCodeHash: [32]byte(crypto.Keccak256Hash(expectedLibraryCode)), + LinkedLibraryDescriptorHash: [32]byte{0x67}, + References: []FrostPreSignLinkedLibraryReference{{ + Start: 2, + Length: 20, + }}, + }} + descriptor.DescriptorHash = descriptor.ComputeHash() + fixture.verifier.code[ownerAddress] = ownerCode + fixture.verifier.code[libraryAddress] = testCase.actualCode + + err := fixture.source.authenticateContractDeployment( + context.Background(), + descriptor, + 2, + fixture.verifier.headers[2].Hash(), + make(frostRetainedGroupCodeCache), + ) + if err == nil || !strings.Contains(err.Error(), testCase.expected) { + t.Fatalf("expected linked-library %s drift rejection, got [%v]", name, err) + } + }) + } +} + func TestSignedFrostRetainedGroupHistorySource_RejectsWrongEndpointAndReorg( t *testing.T, ) { @@ -1231,6 +1944,21 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsWrongEndpointAndReorg( func TestSignedFrostRetainedGroupHistorySource_RejectsWrongOperatorReceipt( t *testing.T, ) { + t.Run("wrong binding", func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.operatorMutator = func(payload *frostRetainedGroupOperatorReceiptPayload) { + payload.BindingHash = frostActivationHex32([32]byte{0x98}) + } + _, err := fixture.source.ResolveOperatorID( + context.Background(), + chain.Address("0x1111111111111111111111111111111111111111"), + fixture.to, + ) + if err == nil || !strings.Contains(err.Error(), "differently bound") { + t.Fatalf("expected operator binding rejection, got [%v]", err) + } + }) + t.Run("wrong point", func(t *testing.T) { fixture := newFrostRetainedGroupHistorySourceFixture(t) fixture.operatorMutator = func(payload *frostRetainedGroupOperatorReceiptPayload) { diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index 776a847b4d..f4f95db2a0 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -207,6 +207,7 @@ type FrostRetainedGroupQuarantineJournalManifest struct { type frostRetainedGroupJournalMetadata struct { Schema string `json:"schema"` ManifestHash [32]byte `json:"manifestHash"` + BindingHash [32]byte `json:"bindingHash"` StoreID string `json:"storeID"` StoreFingerprint [32]byte `json:"storeFingerprint"` ClusterFingerprint [32]byte `json:"clusterFingerprint"` @@ -242,6 +243,7 @@ type frostRetainedGroupQuarantineState struct { type frostRetainedGroupJournalState struct { Schema string `json:"schema"` + BindingHash [32]byte `json:"bindingHash"` BatchSequence uint64 `json:"batchSequence"` CurrentPoint FrostPreSignFinality `json:"currentPoint"` SnapshotGeneration uint64 `json:"snapshotGeneration"` @@ -252,6 +254,7 @@ type frostRetainedGroupJournalState struct { type frostRetainedGroupJournalBatch struct { Schema string `json:"schema"` + BindingHash [32]byte `json:"bindingHash"` Sequence uint64 `json:"sequence"` From FrostPreSignFinality `json:"from"` To FrostPreSignFinality `json:"to"` @@ -263,6 +266,7 @@ type frostRetainedGroupJournalBatch struct { type frostRetainedGroupQuarantineMetadata struct { Schema string `json:"schema"` ManifestHash [32]byte `json:"manifestHash"` + BindingHash [32]byte `json:"bindingHash"` ProtocolID [32]byte `json:"protocolID"` StoreID string `json:"storeID"` StoreFingerprint [32]byte `json:"storeFingerprint"` @@ -272,6 +276,7 @@ type frostRetainedGroupQuarantineMetadata struct { type frostRetainedGroupQuarantineJournalState struct { Schema string `json:"schema"` + BindingHash [32]byte `json:"bindingHash"` BatchSequence uint64 `json:"batchSequence"` CurrentPoint FrostPreSignFinality `json:"currentPoint"` Generation uint64 `json:"generation"` @@ -282,6 +287,7 @@ type frostRetainedGroupQuarantineJournalState struct { type frostRetainedGroupQuarantineJournalBatch struct { Schema string `json:"schema"` + BindingHash [32]byte `json:"bindingHash"` Sequence uint64 `json:"sequence"` From FrostPreSignFinality `json:"from"` To FrostPreSignFinality `json:"to"` @@ -297,6 +303,7 @@ type frostRetainedGroupEnvelope struct { type frostRetainedGroupJournalSnapshot struct { Schema string + BindingHash [32]byte StoreID string StoreFingerprint [32]byte ClusterFingerprint [32]byte @@ -344,6 +351,7 @@ type frostRetainedGroupJournal struct { func newFrostRetainedGroupJournal( directory string, manifestHash [32]byte, + bindingHash [32]byte, manifest FrostRetainedGroupCanonicalJournalManifest, quarantineManifest FrostRetainedGroupQuarantineJournalManifest, source FrostRetainedGroupHistorySource, @@ -351,6 +359,7 @@ func newFrostRetainedGroupJournal( operatorAddress chain.Address, ) (*frostRetainedGroupJournal, error) { if strings.TrimSpace(directory) == "" || manifestHash == [32]byte{} || + bindingHash == [32]byte{} || strings.TrimSpace(manifest.StoreID) == "" || manifest.StoreFingerprint == [32]byte{} || manifest.ClusterFingerprint == [32]byte{} || @@ -413,6 +422,7 @@ func newFrostRetainedGroupJournal( metadata: frostRetainedGroupJournalMetadata{ Schema: frostRetainedGroupJournalMetadataSchema, ManifestHash: manifestHash, + BindingHash: bindingHash, StoreID: manifest.StoreID, StoreFingerprint: manifest.StoreFingerprint, ClusterFingerprint: manifest.ClusterFingerprint, @@ -425,6 +435,7 @@ func newFrostRetainedGroupJournal( quarantineMetadata: frostRetainedGroupQuarantineMetadata{ Schema: frostRetainedGroupQuarantineMetadataSchema, ManifestHash: manifestHash, + BindingHash: bindingHash, ProtocolID: quarantineManifest.ProtocolID, StoreID: quarantineManifest.StoreID, StoreFingerprint: quarantineManifest.StoreFingerprint, @@ -577,6 +588,7 @@ func (frgj *frostRetainedGroupJournal) initialize() error { initial := frostRetainedGroupJournalState{ Schema: frostRetainedGroupJournalStateSchema, + BindingHash: frgj.metadata.BindingHash, CurrentPoint: frgj.metadata.Checkpoint, Wallets: []frostRetainedGroupWalletState{}, } @@ -747,6 +759,7 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { initial := frostRetainedGroupQuarantineJournalState{ Schema: frostRetainedGroupQuarantineStateSchema, + BindingHash: frgj.quarantineMetadata.BindingHash, CurrentPoint: frgj.quarantineMetadata.Checkpoint, Root: sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)), Quarantines: []frostRetainedGroupQuarantineState{}, @@ -864,6 +877,7 @@ func validateFrostRetainedGroupBatch( return frostRetainedGroupLegacySchemaError("canonical batch") } if batch.Schema != frostRetainedGroupJournalBatchSchema || + batch.BindingHash == [32]byte{} || batch.BindingHash != prior.BindingHash || batch.Sequence != prior.BatchSequence+1 || batch.From != prior.CurrentPoint || batch.PriorBatchRoot != prior.BatchRoot || batch.To.BlockNumber < batch.From.BlockNumber || batch.To.BlockHash == [32]byte{} || batch.Checksum == [32]byte{} { @@ -897,6 +911,7 @@ func validateFrostRetainedGroupQuarantineBatch( return frostRetainedGroupLegacySchemaError("quarantine batch") } if batch.Schema != frostRetainedGroupQuarantineBatchSchema || + batch.BindingHash == [32]byte{} || batch.BindingHash != prior.BindingHash || batch.Sequence != prior.BatchSequence+1 || batch.From != prior.CurrentPoint || batch.PriorBatchRoot != prior.BatchRoot || batch.To.BlockNumber < batch.From.BlockNumber || batch.To.BlockHash == [32]byte{} || batch.Checksum == [32]byte{} { @@ -1803,6 +1818,7 @@ func (frgj *frostRetainedGroupJournal) reconcile( } batch := frostRetainedGroupJournalBatch{ Schema: frostRetainedGroupJournalBatchSchema, + BindingHash: frgj.metadata.BindingHash, Sequence: frgj.state.BatchSequence + 1, From: frgj.state.CurrentPoint, To: target, @@ -1851,6 +1867,7 @@ func (frgj *frostRetainedGroupJournal) reconcile( } batch := frostRetainedGroupQuarantineJournalBatch{ Schema: frostRetainedGroupQuarantineBatchSchema, + BindingHash: frgj.quarantineMetadata.BindingHash, Sequence: frgj.quarantineState.BatchSequence + 1, From: frgj.quarantineState.CurrentPoint, To: target, @@ -1928,6 +1945,7 @@ func (frgj *frostRetainedGroupJournal) reconcile( } return &frostRetainedGroupJournalSnapshot{ Schema: frostRetainedGroupJournalSnapshotSchema, + BindingHash: frgj.metadata.BindingHash, StoreID: frgj.metadata.StoreID, StoreFingerprint: frgj.metadata.StoreFingerprint, ClusterFingerprint: frgj.metadata.ClusterFingerprint, diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index ca4d4107f6..4f1702898a 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -94,6 +94,7 @@ type journalTestFixture struct { manifest FrostRetainedGroupCanonicalJournalManifest quarantine FrostRetainedGroupQuarantineJournalManifest manifestHash [32]byte + bindingHash [32]byte checkpoint FrostPreSignFinality target FrostPreSignFinality later FrostPreSignFinality @@ -213,6 +214,7 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { manifest: manifest, quarantine: quarantine, manifestHash: [32]byte{0x42}, + bindingHash: [32]byte{0x44}, checkpoint: checkpoint, target: target, later: later, @@ -290,6 +292,7 @@ func (fixture *journalTestFixture) openJournal( journal, err := newFrostRetainedGroupJournal( directory, fixture.manifestHash, + fixture.bindingHash, fixture.manifest, fixture.quarantine, fixture.source, @@ -308,6 +311,7 @@ func (fixture *journalTestFixture) openJournalError( journal, err := newFrostRetainedGroupJournal( directory, fixture.manifestHash, + fixture.bindingHash, fixture.manifest, fixture.quarantine, fixture.source, @@ -553,6 +557,114 @@ func TestFrostRetainedGroupJournal_RejectsAuthenticatedV1Fixtures( } } +func TestFrostRetainedGroupJournal_RejectsCrossBindingBatchReplay( + t *testing.T, +) { + testCases := map[string]struct { + addQuarantine bool + directory string + }{ + "canonical": { + directory: frostRetainedGroupCanonicalDirectory, + }, + "quarantine": { + addQuarantine: true, + directory: frostRetainedGroupQuarantineDirectory, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newJournalTestFixture(t) + if testCase.addQuarantine { + fixture.source.mutations = append( + fixture.source.mutations, + FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, + TransactionHash: [32]byte{0xa5}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupQuarantineMutation, + WalletID: fixture.walletID, + QuarantineID: [32]byte{0x61}, + EvidenceHash: [32]byte{0x62}, + Reason: "binding replay test", + }, + ) + } + + rootDirectory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, rootDirectory) + if _, err := journal.reconcile(context.Background(), fixture.target); err != nil { + t.Fatal(err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + directory := filepath.Join(rootDirectory, testCase.directory) + batchName := frostRetainedGroupBatchFileName(1) + if testCase.addQuarantine { + batch := frostRetainedGroupQuarantineJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt( + directory, + batchName, + &batch, + ); err != nil { + t.Fatal(err) + } + batch.BindingHash = [32]byte{0xff} + batch.Checksum = [32]byte{} + payload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + t.Fatal(err) + } + batch.Checksum = sha256.Sum256(payload) + if err := persistFrostRetainedGroupEnvelopeAt( + directory, + batchName, + &batch, + true, + ); err != nil { + t.Fatal(err) + } + } else { + batch := frostRetainedGroupJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt( + directory, + batchName, + &batch, + ); err != nil { + t.Fatal(err) + } + batch.BindingHash = [32]byte{0xff} + batch.Checksum = [32]byte{} + payload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + t.Fatal(err) + } + batch.Checksum = sha256.Sum256(payload) + if err := persistFrostRetainedGroupEnvelopeAt( + directory, + batchName, + &batch, + true, + ); err != nil { + t.Fatal(err) + } + } + + err := fixture.openJournalError(rootDirectory) + if err == nil || !strings.Contains(err.Error(), "batch header is invalid") { + t.Fatalf("expected cross-binding batch replay rejection, got [%v]", err) + } + }) + } +} + func TestFrostRetainedGroupJournal_QuarantineAndAuthenticatedLiftAreIndependent( t *testing.T, ) { @@ -888,6 +1000,7 @@ func TestFrostRetainedGroupJournal_RejectsIdentityMismatchAndConcurrentOwner( if _, err := newFrostRetainedGroupJournal( directory, fixture.manifestHash, + fixture.bindingHash, fixture.manifest, fixture.quarantine, fixture.source, @@ -906,6 +1019,7 @@ func TestFrostRetainedGroupJournal_RejectsIdentityMismatchAndConcurrentOwner( if _, err := newFrostRetainedGroupJournal( otherDirectory, fixture.manifestHash, + fixture.bindingHash, fixture.manifest, fixture.quarantine, fixture.source, @@ -928,6 +1042,7 @@ func TestFrostRetainedGroupJournal_RejectsSymlinkEntry(t *testing.T) { if _, err := newFrostRetainedGroupJournal( directory, fixture.manifestHash, + fixture.bindingHash, fixture.manifest, fixture.quarantine, fixture.source, @@ -1140,6 +1255,7 @@ func TestFrostRetainedGroupJournal_RejectsCorruptOrPublicStoreFiles(t *testing.T if _, err := newFrostRetainedGroupJournal( directory, fixture.manifestHash, + fixture.bindingHash, fixture.manifest, fixture.quarantine, fixture.source, @@ -1164,6 +1280,7 @@ func TestFrostRetainedGroupJournal_RejectsCorruptOrPublicStoreFiles(t *testing.T if _, err := newFrostRetainedGroupJournal( directory, fixture.manifestHash, + fixture.bindingHash, fixture.manifest, fixture.quarantine, fixture.source, diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index d407989703..2f4a3b8b37 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -726,8 +726,7 @@ func newNode( } if err := evidenceBinder.BindFrostRetainedGroupActivationEvidence( verifiedActivationProfile, - runtimeManifest.ManifestHash, - runtimeManifest.CanonicalJournal.DescriptorSetHash, + runtimeManifest, ); err != nil { _ = outbox.close() return nil, fmt.Errorf( @@ -735,9 +734,27 @@ func newNode( err, ) } + bindingSource, ok := + config.FrostRetainedGroupHistorySource.(FrostRetainedGroupProtocolBindingSource) + if !ok { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot enable FROST activation handshake without a protocol-bound retained-group source", + ) + } + retainedGroupBindingHash, err := + bindingSource.FrostRetainedGroupProtocolBindingHash() + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot read retained-group protocol binding: [%w]", + err, + ) + } journal, err := newFrostRetainedGroupJournal( config.FrostRetainedGroupJournalDirectory, runtimeManifest.ManifestHash, + retainedGroupBindingHash, runtimeManifest.CanonicalJournal, runtimeManifest.QuarantineJournal, config.FrostRetainedGroupHistorySource, @@ -794,6 +811,7 @@ func newNode( pointVerifier, storeBinding, outbox, + journal, readiness, ) if err != nil { From 126654bb6895befc3892cac62ca08514174eeba0 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 18:14:03 -0400 Subject: [PATCH 26/93] Cache FROST activation reconciliation asynchronously --- pkg/tbtc/frost_activation_handshake.go | 474 ++++++++++++++++-- pkg/tbtc/frost_activation_handshake_test.go | 525 +++++++++++++++++++- 2 files changed, 943 insertions(+), 56 deletions(-) diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 4c630785e7..afb94f9baa 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -10,6 +10,7 @@ import ( "encoding/hex" "encoding/json" "encoding/pem" + "errors" "fmt" "io" "math/big" @@ -28,9 +29,21 @@ import ( ) const ( - frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v3" - frostActivationInventorySchema = "tbtc-p2tr-frost-wallet-group-inventory/v1" - frostActivationHandshakeSignatureDomain = "tbtc-p2tr-production-activation-handshake-signature/v3\x00" + frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v3" + frostActivationInventorySchema = "tbtc-p2tr-frost-wallet-group-inventory/v1" + frostActivationHandshakeSignatureDomain = "tbtc-p2tr-production-activation-handshake-signature/v3\x00" + frostActivationHandshakeReconciliationTimeout = frostRetainedGroupMaximumReconciliationDuration + frostActivationHandshakeRequestTimeout = 5 * time.Second + frostActivationHandshakeQuickCheckTimeout = 2 * time.Second + frostActivationHandshakeRetryAfter = "1" +) + +var errFrostActivationReconciliationPending = errors.New( + "FROST activation reconciliation is pending", +) + +var errFrostActivationJournalBusy = errors.New( + "FROST activation journal live-state check is busy", ) type frostActivationEthereumPoint struct { @@ -161,12 +174,47 @@ type frostActivationHandshakeExporter struct { pointVerifier FrostPreSignActivationPointVerifier storeBinding *frostDurableSessionStoreBinding outbox *bitcoinBroadcastOutbox + journal *frostRetainedGroupJournal readiness frostProductionSignerReadinessVerifier - mutex sync.Mutex - listener net.Listener - server *http.Server - closed bool + mutex sync.Mutex + listener net.Listener + server *http.Server + reconciliationCancel context.CancelFunc + closed bool + + reconciliationMutex sync.Mutex + reconciliationWake chan struct{} + reconciliationSequence uint64 + reconciliationDesired *frostActivationReconciliationJob + reconciliationActive *frostActivationReconciliationJob + reconciliationActiveCancel context.CancelFunc + reconciliationCompleted *frostActivationReconciliationCache +} + +type frostActivationReconciliationJob struct { + sequence uint64 + point FrostPreSignFinality +} + +type frostActivationJournalStamp struct { + bindingHash [32]byte + canonicalPoint FrostPreSignFinality + canonicalGeneration uint64 + canonicalBatchRoot [32]byte + canonicalInventory [32]byte + quarantinePoint FrostPreSignFinality + quarantineGeneration uint64 + quarantineBatchRoot [32]byte + quarantineRoot [32]byte +} + +type frostActivationReconciliationCache struct { + point FrostPreSignFinality + journal frostRetainedGroupJournalSnapshot + inventory frostNativeSignerInventorySnapshot + interactiveSigningReady bool + stamp frostActivationJournalStamp } func newFrostActivationHandshakeExporter( @@ -228,15 +276,17 @@ func newFrostActivationHandshakeExporter( ) } exporter := &frostActivationHandshakeExporter{ - endpoint: parsedEndpoint, - privateKey: privateKey, - publicKeySPKI: base64.StdEncoding.EncodeToString(publicKeyDER), - manifest: manifest, - bindingHash: bindingHash, - pointVerifier: pointVerifier, - storeBinding: storeBinding, - outbox: outbox, - readiness: readiness, + endpoint: parsedEndpoint, + privateKey: privateKey, + publicKeySPKI: base64.StdEncoding.EncodeToString(publicKeyDER), + manifest: manifest, + bindingHash: bindingHash, + pointVerifier: pointVerifier, + storeBinding: storeBinding, + outbox: outbox, + journal: journal, + readiness: readiness, + reconciliationWake: make(chan struct{}, 1), } return exporter, nil } @@ -320,12 +370,15 @@ func (fahe *frostActivationHandshakeExporter) start(ctx context.Context) error { Handler: http.HandlerFunc(fahe.serveHTTP), ReadHeaderTimeout: 2 * time.Second, ReadTimeout: 3 * time.Second, - WriteTimeout: 3 * time.Second, + WriteTimeout: frostActivationHandshakeRequestTimeout + time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 4096, } + reconciliationContext, reconciliationCancel := context.WithCancel(ctx) fahe.listener = listener fahe.server = server + fahe.reconciliationCancel = reconciliationCancel + go fahe.reconciliationWorker(reconciliationContext) go func() { if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { logger.Errorf("FROST activation handshake exporter failed: [%v]", err) @@ -349,6 +402,9 @@ func (fahe *frostActivationHandshakeExporter) close() error { return nil } fahe.closed = true + if fahe.reconciliationCancel != nil { + fahe.reconciliationCancel() + } if fahe.server == nil { return nil } @@ -357,6 +413,298 @@ func (fahe *frostActivationHandshakeExporter) close() error { return fahe.server.Shutdown(ctx) } +func (fahe *frostActivationHandshakeExporter) reconciliationWorker( + ctx context.Context, +) { + for { + select { + case <-ctx.Done(): + return + case <-fahe.reconciliationWake: + } + for { + job, reconciliationContext, cancel := fahe.takeReconciliationJob(ctx) + if job == nil { + break + } + cache, err := fahe.reconcileActivationState( + reconciliationContext, + job.point, + ) + cancel() + if !fahe.completeReconciliationJob(job, cache, err) { + break + } + } + } +} + +func (fahe *frostActivationHandshakeExporter) takeReconciliationJob( + ctx context.Context, +) ( + *frostActivationReconciliationJob, + context.Context, + context.CancelFunc, +) { + fahe.reconciliationMutex.Lock() + defer fahe.reconciliationMutex.Unlock() + if fahe.reconciliationDesired == nil { + return nil, nil, nil + } + job := fahe.reconciliationDesired + fahe.reconciliationDesired = nil + reconciliationContext, cancel := context.WithTimeout( + ctx, + frostActivationHandshakeReconciliationTimeout, + ) + fahe.reconciliationActive = job + fahe.reconciliationActiveCancel = cancel + return job, reconciliationContext, cancel +} + +func (fahe *frostActivationHandshakeExporter) completeReconciliationJob( + job *frostActivationReconciliationJob, + cache *frostActivationReconciliationCache, + reconciliationErr error, +) bool { + fahe.reconciliationMutex.Lock() + if fahe.reconciliationActive != nil && + fahe.reconciliationActive.sequence == job.sequence { + fahe.reconciliationActive = nil + fahe.reconciliationActiveCancel = nil + } + if reconciliationErr == nil && cache != nil && + fahe.reconciliationSequence == job.sequence && + fahe.reconciliationDesired == nil { + fahe.reconciliationCompleted = cache + } + hasDesired := fahe.reconciliationDesired != nil + fahe.reconciliationMutex.Unlock() + if reconciliationErr != nil { + logger.Warnf( + "background FROST activation reconciliation failed for block [%d]: [%v]", + job.point.BlockNumber, + reconciliationErr, + ) + } + return hasDesired +} + +func (fahe *frostActivationHandshakeExporter) queueReconciliation( + point FrostPreSignFinality, + force bool, +) { + fahe.reconciliationMutex.Lock() + if force || (fahe.reconciliationCompleted != nil && + fahe.reconciliationCompleted.point != point) { + fahe.reconciliationCompleted = nil + } + if fahe.reconciliationDesired != nil && + fahe.reconciliationDesired.point == point { + fahe.reconciliationMutex.Unlock() + return + } + if !force && fahe.reconciliationDesired == nil && + fahe.reconciliationActive != nil && + fahe.reconciliationActive.point == point { + fahe.reconciliationMutex.Unlock() + return + } + fahe.reconciliationSequence++ + job := &frostActivationReconciliationJob{ + sequence: fahe.reconciliationSequence, + point: point, + } + fahe.reconciliationDesired = job + if fahe.reconciliationActiveCancel != nil { + fahe.reconciliationActiveCancel() + } + fahe.reconciliationMutex.Unlock() + select { + case fahe.reconciliationWake <- struct{}{}: + default: + } +} + +func (fahe *frostActivationHandshakeExporter) cachedReconciliation( + point FrostPreSignFinality, +) *frostActivationReconciliationCache { + fahe.reconciliationMutex.Lock() + defer fahe.reconciliationMutex.Unlock() + if fahe.reconciliationCompleted == nil || + fahe.reconciliationCompleted.point != point { + return nil + } + cache := *fahe.reconciliationCompleted + return &cache +} + +func (fahe *frostActivationHandshakeExporter) reconcileActivationState( + ctx context.Context, + finality FrostPreSignFinality, +) (*frostActivationReconciliationCache, error) { + if err := fahe.pointVerifier.VerifyFrostPreSignActivationPoint( + ctx, + finality, + ); err != nil { + return nil, fmt.Errorf("cannot verify FROST activation point: [%w]", err) + } + readinessSnapshot, err := fahe.readiness.verifyFrostProductionSignerReadiness( + ctx, + finality, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot verify production FROST signer readiness: [%w]", + err, + ) + } + journalSnapshot := readinessSnapshot.Journal + inventorySnapshot := readinessSnapshot.Inventory + if journalSnapshot == nil || inventorySnapshot == nil { + return nil, fmt.Errorf( + "production FROST signer readiness snapshot is incomplete", + ) + } + if err := fahe.validateActivationJournalSnapshot( + journalSnapshot, + finality, + ); err != nil { + return nil, err + } + if err := fahe.pointVerifier.VerifyFrostPreSignActivationPoint( + ctx, + finality, + ); err != nil { + return nil, fmt.Errorf( + "FROST activation point changed during readiness reconciliation: [%w]", + err, + ) + } + stamp, err := fahe.tryJournalStamp() + if err != nil { + return nil, fmt.Errorf( + "cannot read canonical FROST retained-group journal after reconciliation: [%w]", + err, + ) + } + if !frostActivationStampMatchesSnapshot( + stamp, + journalSnapshot, + finality, + ) { + return nil, fmt.Errorf( + "canonical FROST retained-group journal changed after reconciliation", + ) + } + return &frostActivationReconciliationCache{ + point: finality, + journal: *journalSnapshot, + inventory: *inventorySnapshot, + interactiveSigningReady: readinessSnapshot.InteractiveSigningReady, + stamp: stamp, + }, nil +} + +func (fahe *frostActivationHandshakeExporter) validateActivationJournalSnapshot( + journalSnapshot *frostRetainedGroupJournalSnapshot, + finality FrostPreSignFinality, +) error { + if journalSnapshot == nil { + return fmt.Errorf("canonical FROST retained-group journal snapshot is nil") + } + journalManifest := fahe.manifest.CanonicalJournal + quarantineManifest := fahe.manifest.QuarantineJournal + if journalSnapshot.Schema != frostRetainedGroupJournalSnapshotSchema || + journalSnapshot.BindingHash != fahe.bindingHash || + !journalSnapshot.Complete || journalSnapshot.CurrentPoint != finality || + journalSnapshot.StoreID != journalManifest.StoreID || + journalSnapshot.StoreFingerprint != journalManifest.StoreFingerprint || + journalSnapshot.ClusterFingerprint != journalManifest.ClusterFingerprint || + journalSnapshot.SnapshotGeneration < journalManifest.MinimumGeneration || + journalSnapshot.QuarantineProtocolID != quarantineManifest.ProtocolID || + journalSnapshot.QuarantineStoreID != quarantineManifest.StoreID || + journalSnapshot.QuarantineStoreFingerprint != quarantineManifest.StoreFingerprint || + journalSnapshot.QuarantineClusterFingerprint != quarantineManifest.ClusterFingerprint || + journalSnapshot.QuarantineGeneration < quarantineManifest.MinimumGeneration || + journalSnapshot.QuarantineRoot == [32]byte{} || + journalSnapshot.QuarantineCount != 0 { + return fmt.Errorf( + "canonical FROST retained-group journal is not activation-ready", + ) + } + return nil +} + +func (fahe *frostActivationHandshakeExporter) tryJournalStamp() ( + frostActivationJournalStamp, + error, +) { + if !fahe.journal.mutex.TryLock() { + return frostActivationJournalStamp{}, errFrostActivationJournalBusy + } + defer fahe.journal.mutex.Unlock() + return fahe.journalStampLocked() +} + +func (fahe *frostActivationHandshakeExporter) journalStampLocked() ( + frostActivationJournalStamp, + error, +) { + journal := fahe.journal + if journal.closed || + journal.metadata.BindingHash != fahe.bindingHash || + journal.quarantineMetadata.BindingHash != fahe.bindingHash || + journal.state.BindingHash != fahe.bindingHash || + journal.quarantineState.BindingHash != fahe.bindingHash { + return frostActivationJournalStamp{}, fmt.Errorf( + "canonical FROST retained-group journal binding is not live", + ) + } + return frostActivationJournalStamp{ + bindingHash: fahe.bindingHash, + canonicalPoint: journal.state.CurrentPoint, + canonicalGeneration: journal.state.SnapshotGeneration, + canonicalBatchRoot: journal.state.BatchRoot, + canonicalInventory: journal.state.InventoryRoot, + quarantinePoint: journal.quarantineState.CurrentPoint, + quarantineGeneration: journal.quarantineState.Generation, + quarantineBatchRoot: journal.quarantineState.BatchRoot, + quarantineRoot: journal.quarantineState.Root, + }, nil +} + +func frostActivationStampMatchesSnapshot( + stamp frostActivationJournalStamp, + snapshot *frostRetainedGroupJournalSnapshot, + point FrostPreSignFinality, +) bool { + return snapshot != nil && + stamp.bindingHash == snapshot.BindingHash && + stamp.canonicalPoint == point && + stamp.quarantinePoint == point && + stamp.canonicalGeneration == snapshot.SnapshotGeneration && + stamp.canonicalBatchRoot == snapshot.BatchRoot && + stamp.canonicalInventory == snapshot.InventoryRoot && + stamp.quarantineGeneration == snapshot.QuarantineGeneration && + stamp.quarantineRoot == snapshot.QuarantineRoot +} + +func (fahe *frostActivationHandshakeExporter) verifyActivationPointQuick( + ctx context.Context, + point FrostPreSignFinality, +) error { + quickContext, cancel := context.WithTimeout( + ctx, + frostActivationHandshakeQuickCheckTimeout, + ) + defer cancel() + return fahe.pointVerifier.VerifyFrostPreSignActivationPoint( + quickContext, + point, + ) +} + func (fahe *frostActivationHandshakeExporter) serveHTTP( responseWriter http.ResponseWriter, request *http.Request, @@ -389,9 +737,20 @@ func (fahe *frostActivationHandshakeExporter) serveHTTP( http.Error(responseWriter, "invalid request", http.StatusBadRequest) return } - handshake, err := fahe.attest(request.Context(), handshakeRequest) + attestationContext, cancel := context.WithTimeout( + request.Context(), + frostActivationHandshakeRequestTimeout, + ) + defer cancel() + handshake, err := fahe.attest(attestationContext, handshakeRequest) if err != nil { logger.Warnf("refusing FROST activation handshake: [%v]", err) + if errors.Is(err, errFrostActivationReconciliationPending) { + responseWriter.Header().Set( + "Retry-After", + frostActivationHandshakeRetryAfter, + ) + } http.Error(responseWriter, "activation state is not ready", http.StatusServiceUnavailable) return } @@ -430,18 +789,32 @@ func (fahe *frostActivationHandshakeExporter) attest( BlockNumber: request.Challenge.EthereumPoint.BlockNumber, BlockHash: blockHash, } - if err := fahe.pointVerifier.VerifyFrostPreSignActivationPoint(ctx, finality); err != nil { - return nil, fmt.Errorf("cannot verify FROST activation point: [%w]", err) + reconciliation := fahe.cachedReconciliation(finality) + if reconciliation == nil { + fahe.queueReconciliation(finality, false) + return nil, errFrostActivationReconciliationPending } - readinessSnapshot, err := fahe.readiness.verifyFrostProductionSignerReadiness(ctx, finality) - if err != nil { - return nil, fmt.Errorf("cannot verify production FROST signer readiness: [%w]", err) + liveStamp, stampErr := fahe.tryJournalStamp() + if errors.Is(stampErr, errFrostActivationJournalBusy) { + return nil, errFrostActivationReconciliationPending } - journalSnapshot := readinessSnapshot.Journal - nativeSignerSnapshot := readinessSnapshot.Inventory - if journalSnapshot == nil || nativeSignerSnapshot == nil { - return nil, fmt.Errorf("production FROST signer readiness snapshot is incomplete") + if stampErr != nil || liveStamp != reconciliation.stamp { + fahe.queueReconciliation(finality, true) + return nil, fmt.Errorf( + "%w: canonical or quarantine journal generation changed", + errFrostActivationReconciliationPending, + ) + } + if err := fahe.verifyActivationPointQuick(ctx, finality); err != nil { + fahe.queueReconciliation(finality, true) + return nil, fmt.Errorf( + "%w: cannot verify cached FROST activation point: [%v]", + errFrostActivationReconciliationPending, + err, + ) } + journalSnapshot := &reconciliation.journal + nativeSignerSnapshot := &reconciliation.inventory if !nativeSignerSnapshot.ExternalRollbackAnchorBound || nativeSignerSnapshot.TrustCertificateSequence == 0 || nativeSignerSnapshot.TrustCertificateDigest == [32]byte{} || @@ -474,21 +847,6 @@ func (fahe *frostActivationHandshakeExporter) attest( ) } journalManifest := fahe.manifest.CanonicalJournal - quarantineManifest := fahe.manifest.QuarantineJournal - if journalSnapshot.BindingHash != fahe.bindingHash || - !journalSnapshot.Complete || journalSnapshot.CurrentPoint != finality || - journalSnapshot.StoreID != journalManifest.StoreID || - journalSnapshot.StoreFingerprint != journalManifest.StoreFingerprint || - journalSnapshot.ClusterFingerprint != journalManifest.ClusterFingerprint || - journalSnapshot.SnapshotGeneration < journalManifest.MinimumGeneration || - journalSnapshot.QuarantineProtocolID != quarantineManifest.ProtocolID || - journalSnapshot.QuarantineStoreID != quarantineManifest.StoreID || - journalSnapshot.QuarantineStoreFingerprint != quarantineManifest.StoreFingerprint || - journalSnapshot.QuarantineClusterFingerprint != quarantineManifest.ClusterFingerprint || - journalSnapshot.QuarantineGeneration < quarantineManifest.MinimumGeneration || - journalSnapshot.QuarantineRoot == [32]byte{} || journalSnapshot.QuarantineCount != 0 { - return nil, fmt.Errorf("canonical FROST retained-group journal is not activation-ready") - } outboxSnapshot, err := fahe.outbox.activationSnapshot() if err != nil { return nil, err @@ -497,9 +855,6 @@ func (fahe *frostActivationHandshakeExporter) attest( outboxSnapshot.QuarantineCount != 0 { return nil, fmt.Errorf("durable Bitcoin outbox is not activation-ready") } - if err := fahe.pointVerifier.VerifyFrostPreSignActivationPoint(ctx, finality); err != nil { - return nil, fmt.Errorf("FROST activation point changed during readiness reconciliation: [%w]", err) - } durableSessionStoreFingerprint, err := fahe.storeBinding.verify() if err != nil { return nil, fmt.Errorf( @@ -579,10 +934,10 @@ func (fahe *frostActivationHandshakeExporter) attest( AnchorRotationWarning: nativeSignerSnapshot.AnchorRotationWarning, Complete: true, }, - InteractiveSigningReady: readinessSnapshot.InteractiveSigningReady, + InteractiveSigningReady: reconciliation.interactiveSigningReady, FinalizedReservationReadbackEnforced: true, ExactTransactionAuthorizationRootEnforced: true, - NonceShareGateEnforced: readinessSnapshot.InteractiveSigningReady && + NonceShareGateEnforced: reconciliation.interactiveSigningReady && nativeSignerSnapshot.StateGeneration > 0 && nativeSignerSnapshot.StateCommitment != [32]byte{}, DurableBitcoinOutboxRecovered: outboxSnapshot.Recovered, @@ -604,7 +959,32 @@ func (fahe *frostActivationHandshakeExporter) attest( if err != nil { return nil, err } + if err := fahe.verifyActivationPointQuick(ctx, finality); err != nil { + fahe.queueReconciliation(finality, true) + return nil, fmt.Errorf( + "%w: cached FROST activation point changed before signing: [%v]", + errFrostActivationReconciliationPending, + err, + ) + } + if !fahe.journal.mutex.TryLock() { + return nil, fmt.Errorf( + "%w: %v", + errFrostActivationReconciliationPending, + errFrostActivationJournalBusy, + ) + } + signingStamp, stampErr := fahe.journalStampLocked() + if stampErr != nil || signingStamp != reconciliation.stamp { + fahe.journal.mutex.Unlock() + fahe.queueReconciliation(finality, true) + return nil, fmt.Errorf( + "%w: canonical or quarantine journal generation changed before signing", + errFrostActivationReconciliationPending, + ) + } signature := ed25519.Sign(fahe.privateKey, signatureTranscript) + fahe.journal.mutex.Unlock() return &frostActivationSignedHandshake{ Payload: payload, SignerPublicKeySPKI: fahe.publicKeySPKI, diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index 46366aaa5f..4879d76fb6 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -19,6 +19,7 @@ import ( "reflect" "sort" "strings" + "sync" "testing" "time" @@ -27,6 +28,7 @@ import ( ) type testFrostActivationPointVerifier struct { + mutex sync.Mutex err error point FrostPreSignFinality calls uint64 @@ -36,14 +38,38 @@ func (tfapv *testFrostActivationPointVerifier) VerifyFrostPreSignActivationPoint ctx context.Context, point FrostPreSignFinality, ) error { + tfapv.mutex.Lock() + defer tfapv.mutex.Unlock() tfapv.point = point tfapv.calls++ return tfapv.err } +func (tfapv *testFrostActivationPointVerifier) snapshot() ( + FrostPreSignFinality, + uint64, +) { + tfapv.mutex.Lock() + defer tfapv.mutex.Unlock() + return tfapv.point, tfapv.calls +} + +func (tfapv *testFrostActivationPointVerifier) setError(err error) { + tfapv.mutex.Lock() + defer tfapv.mutex.Unlock() + tfapv.err = err +} + type testFrostRetainedGroupHistorySource struct { - manifest FrostRetainedGroupCanonicalJournalManifest - target FrostPreSignFinality + mutex sync.Mutex + manifest FrostRetainedGroupCanonicalJournalManifest + target FrostPreSignFinality + readCalls uint64 + readStarted chan struct{} + readRelease <-chan struct{} + readDeadline time.Time + hasDeadline bool + readOnce sync.Once } type testFrostProductionSignerReadiness struct { @@ -125,6 +151,8 @@ func (source *testFrostRetainedGroupHistorySource) Identity( func (source *testFrostRetainedGroupHistorySource) FinalizedHead( context.Context, ) (FrostPreSignFinality, error) { + source.mutex.Lock() + defer source.mutex.Unlock() return source.target, nil } @@ -136,10 +164,28 @@ func (source *testFrostRetainedGroupHistorySource) VerifyPoint( } func (source *testFrostRetainedGroupHistorySource) ReadCompleteHistory( - _ context.Context, + ctx context.Context, from FrostPreSignFinality, to FrostPreSignFinality, ) (*FrostRetainedGroupHistory, error) { + source.mutex.Lock() + source.readCalls++ + readStarted := source.readStarted + readRelease := source.readRelease + source.readDeadline, source.hasDeadline = ctx.Deadline() + source.mutex.Unlock() + if readStarted != nil { + source.readOnce.Do(func() { + close(readStarted) + }) + } + if readRelease != nil { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-readRelease: + } + } return &FrostRetainedGroupHistory{ From: from, To: to, @@ -149,6 +195,29 @@ func (source *testFrostRetainedGroupHistorySource) ReadCompleteHistory( }, nil } +func (source *testFrostRetainedGroupHistorySource) readCallCount() uint64 { + source.mutex.Lock() + defer source.mutex.Unlock() + return source.readCalls +} + +func (source *testFrostRetainedGroupHistorySource) reconciliationDeadline() ( + time.Time, + bool, +) { + source.mutex.Lock() + defer source.mutex.Unlock() + return source.readDeadline, source.hasDeadline +} + +func (source *testFrostRetainedGroupHistorySource) setTarget( + target FrostPreSignFinality, +) { + source.mutex.Lock() + defer source.mutex.Unlock() + source.target = target +} + func (source *testFrostRetainedGroupHistorySource) ResolveOperatorID( context.Context, chain.Address, @@ -225,15 +294,30 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { }, } response := postTestFrostActivationHandshake(t, endpoint, request) - defer response.Body.Close() - if response.StatusCode != http.StatusOK { + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { body, _ := io.ReadAll(response.Body) - t.Fatalf("unexpected status [%d]: %s", response.StatusCode, body) + response.Body.Close() + t.Fatalf( + "initial asynchronous response was [%d] Retry-After [%s]: %s", + response.StatusCode, + response.Header.Get("Retry-After"), + body, + ) } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + defer response.Body.Close() handshake := &frostActivationSignedHandshake{} if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { t.Fatal(err) } + verifiedPoint, verifierCalls := verifier.snapshot() if handshake.Payload.Kind != "frost-signer" || handshake.Payload.Nonce != request.Challenge.Nonce || handshake.Payload.ManifestHash != request.Challenge.ManifestHash || @@ -244,9 +328,9 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { !handshake.Payload.State.InteractiveSigningReady || !handshake.Payload.State.NonceShareGateEnforced || !handshake.Payload.State.DurableBitcoinOutboxRecovered || - verifier.point.BlockNumber != point.BlockNumber || - frostActivationHex32(verifier.point.BlockHash) != point.BlockHash || - verifier.calls != 2 { + verifiedPoint.BlockNumber != point.BlockNumber || + frostActivationHex32(verifiedPoint.BlockHash) != point.BlockHash || + verifierCalls != 4 { t.Fatalf("unexpected handshake: %+v", handshake) } canonicalPayload, err := canonicalFrostActivationValue(handshake.Payload) @@ -389,16 +473,65 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { }, } response := postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf( + "initial reconciliation did not return retryable service unavailable", + ) + } + response.Body.Close() + awaitTestFrostActivationReconciliation( + t, + exporter, + FrostPreSignFinality{ + BlockNumber: point.BlockNumber, + BlockHash: [32]byte{0x11}, + }, + ) + response = postTestFrostActivationHandshake(t, endpoint, request) defer response.Body.Close() if response.StatusCode != http.StatusServiceUnavailable { t.Fatalf("quarantined outbox returned status [%d]", response.StatusCode) } + if response.Header.Get("Retry-After") != "" { + t.Fatal("non-reconciliation readiness failure advertised a retry interval") + } response.Body.Close() outbox.mutex.Lock() outbox.records = make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord) outbox.mutex.Unlock() readiness.interactive = false + journal.mutex.Lock() + journal.state.SnapshotGeneration++ + var rootErr error + journal.state.InventoryRoot, _, _, _, rootErr = + frostRetainedGroupInventoryRoot(journal.state) + journal.mutex.Unlock() + if rootErr != nil { + t.Fatal(rootErr) + } + response = postTestFrostActivationHandshake(t, endpoint, request) + response.Body.Close() + settleDeadline := time.Now().Add(2 * time.Second) + for { + exporter.reconciliationMutex.Lock() + idle := exporter.reconciliationDesired == nil && + exporter.reconciliationActive == nil + cached := exporter.reconciliationCompleted != nil + exporter.reconciliationMutex.Unlock() + if idle { + if cached { + t.Fatal("unready interactive signer cached reconciliation state") + } + break + } + if time.Now().After(settleDeadline) { + t.Fatal("background reconciliation did not settle") + } + time.Sleep(5 * time.Millisecond) + } response = postTestFrostActivationHandshake(t, endpoint, request) defer response.Body.Close() if response.StatusCode != http.StatusServiceUnavailable { @@ -406,6 +539,256 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { } } +func TestFrostActivationHandshakeExporter_RejectsUnboundOrLegacyChallenge( + t *testing.T, +) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + _, _, source, _, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + + testCases := map[string]func(*frostActivationHandshakeRequest){ + "legacy schema": func(request *frostActivationHandshakeRequest) { + request.Schema = "tbtc-p2tr-production-activation-handshake/v1" + }, + "different binding": func(request *frostActivationHandshakeRequest) { + request.Challenge.BindingHash = frostActivationHex32([32]byte{0xff}) + }, + } + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + candidate := request + mutate(&candidate) + response := postTestFrostActivationHandshake(t, endpoint, candidate) + defer response.Body.Close() + if response.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("unbound challenge returned [%d]", response.StatusCode) + } + if response.Header.Get("Retry-After") != "" { + t.Fatal("invalid transcript was treated as pending reconciliation") + } + }) + } + if source.readCallCount() != 0 { + t.Fatal("invalid transcript started retained-history reconciliation") + } +} + +func TestFrostActivationHandshakeExporter_ReconciliationIsAsynchronousAndGenerationBound( + t *testing.T, +) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + _, journal, source, verifier, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + release := make(chan struct{}) + defer func() { + select { + case <-release: + default: + close(release) + } + }() + source.mutex.Lock() + source.readStarted = make(chan struct{}) + readStarted := source.readStarted + source.readRelease = release + source.mutex.Unlock() + + startedAt := time.Now() + response := postTestFrostActivationHandshake(t, endpoint, request) + elapsed := time.Since(startedAt) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("initial reconciliation response was not retryable: [%d]", response.StatusCode) + } + response.Body.Close() + if elapsed >= time.Second { + t.Fatalf("reconciliation held the HTTP request for [%v]", elapsed) + } + select { + case <-readStarted: + case <-time.After(time.Second): + t.Fatal("background reconciliation did not start") + } + reconciliationDeadline, hasDeadline := source.reconciliationDeadline() + remaining := time.Until(reconciliationDeadline) + if !hasDeadline || remaining <= 0 || + remaining > frostActivationHandshakeReconciliationTimeout { + t.Fatalf( + "background reconciliation deadline is not bounded: [%v] [%v]", + hasDeadline, + remaining, + ) + } + + response = postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("in-flight reconciliation response was not retryable: [%d]", response.StatusCode) + } + response.Body.Close() + if source.readCallCount() != 1 { + t.Fatalf( + "same-point requests started [%d] reconciliations", + source.readCallCount(), + ) + } + + close(release) + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + response.Body.Close() + if source.readCallCount() != 1 { + t.Fatalf("completed exact-point cache was not reused") + } + + journal.mutex.Lock() + response = postTestFrostActivationHandshake(t, endpoint, request) + journal.mutex.Unlock() + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("busy live-state check was not retryable: [%d]", response.StatusCode) + } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + response.Body.Close() + if source.readCallCount() != 1 { + t.Fatal("a transient live-state lock invalidated the completed cache") + } + + journal.mutex.Lock() + journal.state.SnapshotGeneration++ + canonicalGeneration := journal.state.SnapshotGeneration + var rootErr error + journal.state.InventoryRoot, _, _, _, rootErr = + frostRetainedGroupInventoryRoot(journal.state) + journal.mutex.Unlock() + if rootErr != nil { + t.Fatal(rootErr) + } + response = postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("canonical generation drift reused stale cache: [%d]", response.StatusCode) + } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + handshake := &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { + response.Body.Close() + t.Fatal(err) + } + response.Body.Close() + if handshake.Payload.State.CanonicalJournal.Generation != canonicalGeneration { + t.Fatalf( + "reconciled generation is [%d], expected [%d]", + handshake.Payload.State.CanonicalJournal.Generation, + canonicalGeneration, + ) + } + + journal.mutex.Lock() + journal.quarantineState.Generation++ + quarantineGeneration := journal.quarantineState.Generation + journal.mutex.Unlock() + response = postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("quarantine generation drift reused stale cache: [%d]", response.StatusCode) + } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + handshake = &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { + response.Body.Close() + t.Fatal(err) + } + response.Body.Close() + if handshake.Payload.State.QuarantineJournal.Generation != quarantineGeneration { + t.Fatalf( + "reconciled quarantine generation is [%d], expected [%d]", + handshake.Payload.State.QuarantineJournal.Generation, + quarantineGeneration, + ) + } + + nextPoint := FrostPreSignFinality{ + BlockNumber: point.BlockNumber + 1, + BlockHash: [32]byte{0x45}, + } + source.setTarget(nextPoint) + journal.mutex.Lock() + journal.state.CurrentPoint = nextPoint + journal.state.InventoryRoot, _, _, _, rootErr = + frostRetainedGroupInventoryRoot(journal.state) + journal.quarantineState.CurrentPoint = nextPoint + journal.mutex.Unlock() + if rootErr != nil { + t.Fatal(rootErr) + } + request.Challenge.EthereumPoint = frostActivationEthereumPoint{ + BlockNumber: nextPoint.BlockNumber, + BlockHash: frostActivationHex32(nextPoint.BlockHash), + } + response = postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("different finality point reused stale cache: [%d]", response.StatusCode) + } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + response.Body.Close() + if source.readCallCount() != 4 { + t.Fatalf( + "expected one reconciliation per cache invalidation, got [%d]", + source.readCallCount(), + ) + } + + verifier.setError(fmt.Errorf("exact point no longer canonical")) + response = postTestFrostActivationHandshake(t, endpoint, request) + defer response.Body.Close() + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + t.Fatalf("failed quick point check signed cached state: [%d]", response.StatusCode) + } +} + func TestCanonicalFrostActivationValue_MatchesRuntimeOrdering(t *testing.T) { value := map[string]interface{}{ "state": map[string]interface{}{"healthy": true, "count": 3}, @@ -515,6 +898,87 @@ func testFrostActivationRuntimeManifest( } } +func startTestFrostActivationHandshakeExporter( + t *testing.T, + point frostActivationEthereumPoint, +) ( + *frostActivationHandshakeExporter, + *frostRetainedGroupJournal, + *testFrostRetainedGroupHistorySource, + *testFrostActivationPointVerifier, + string, + frostActivationHandshakeRequest, +) { + t.Helper() + directory := t.TempDir() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatal(err) + } + privateKeyPath := filepath.Join(directory, "attestation-key.pem") + if err := os.WriteFile(privateKeyPath, pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: privateKeyDER, + }), 0600); err != nil { + t.Fatal(err) + } + publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + manifest := testFrostActivationRuntimeManifest(sha256.Sum256(publicKeyDER)) + journal := testFrostRetainedGroupJournal(t, manifest, point) + source, ok := journal.source.(*testFrostRetainedGroupHistorySource) + if !ok { + t.Fatal("unexpected retained-group history source") + } + endpoint := testLoopbackEndpoint(t) + verifier := &testFrostActivationPointVerifier{} + outbox := &bitcoinBroadcastOutbox{ + records: make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord), + recovered: true, + } + readiness := &testFrostProductionSignerReadiness{ + journal: journal, + interactive: true, + } + exporter, err := newFrostActivationHandshakeExporter( + endpoint, + privateKeyPath, + manifest, + verifier, + testFrostDurableSessionStoreBinding(t), + outbox, + journal, + readiness, + ) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if err := exporter.start(ctx); err != nil { + cancel() + t.Fatal(err) + } + t.Cleanup(func() { + cancel() + _ = exporter.close() + }) + return exporter, journal, source, verifier, endpoint, frostActivationHandshakeRequest{ + Schema: frostActivationHandshakeSchema, + Challenge: frostActivationChallenge{ + Nonce: frostActivationHex32([32]byte{0x77}), + ManifestHash: frostActivationHex32(manifest.ManifestHash), + BindingHash: frostActivationHex32(journal.metadata.BindingHash), + EthereumPoint: point, + }, + } +} + func testFrostRetainedGroupJournal( t *testing.T, manifest FrostPreSignActivationRuntimeManifest, @@ -618,3 +1082,46 @@ func postTestFrostActivationHandshake( } return response } + +func awaitTestFrostActivationHandshake( + t *testing.T, + endpoint string, + request frostActivationHandshakeRequest, + status int, +) *http.Response { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for { + response := postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode == status { + return response + } + body, _ := io.ReadAll(response.Body) + response.Body.Close() + if response.StatusCode != http.StatusServiceUnavailable || + time.Now().After(deadline) { + t.Fatalf( + "handshake did not reach status [%d]; last status [%d]: %s", + status, + response.StatusCode, + body, + ) + } + time.Sleep(10 * time.Millisecond) + } +} + +func awaitTestFrostActivationReconciliation( + t *testing.T, + exporter *frostActivationHandshakeExporter, + point FrostPreSignFinality, +) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for exporter.cachedReconciliation(point) == nil { + if time.Now().After(deadline) { + t.Fatal("activation reconciliation did not complete") + } + time.Sleep(10 * time.Millisecond) + } +} From 1c2727d6d54905a3449a528d6adec165da1b0868 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 19:06:11 -0400 Subject: [PATCH 27/93] Require quorum certificates for FROST quarantine lifts --- .../tbtc_frost_pre_sign_authorization.go | 221 ++- ...tbtc_frost_retained_group_manifest_test.go | 113 +- pkg/tbtc/frost_activation_handshake.go | 52 +- pkg/tbtc/frost_activation_handshake_test.go | 188 +- pkg/tbtc/frost_pre_sign_authorization.go | 3 + .../frost_retained_group_history_evidence.go | 50 +- .../frost_retained_group_history_source.go | 402 +++- ...rost_retained_group_history_source_test.go | 69 +- pkg/tbtc/frost_retained_group_journal.go | 1611 +++++++++++++++-- pkg/tbtc/frost_retained_group_journal_test.go | 1394 +++++++++++--- pkg/tbtc/node.go | 4 +- 11 files changed, 3690 insertions(+), 417 deletions(-) diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index abfd03f05f..61521da32a 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -195,11 +195,22 @@ type frostPreSignManifestCanonicalJournal struct { } type frostPreSignManifestQuarantineJournal struct { - ProtocolID string `json:"protocolID"` - StoreID string `json:"storeID"` - StoreFingerprint string `json:"storeFingerprint"` - ClusterFingerprint string `json:"clusterFingerprint"` - MinimumGeneration uint64 `json:"minimumGeneration"` + ProtocolID string `json:"protocolID"` + LiftProtocolID string `json:"liftProtocolID"` + TombstoneProtocolID string `json:"tombstoneProtocolID"` + CheckpointAuthorityThreshold uint64 `json:"checkpointAuthorityThreshold"` + CheckpointAuthorities []frostPreSignManifestLiftAuthority `json:"checkpointAuthorities"` + LiftAuthorityThreshold uint64 `json:"liftAuthorityThreshold"` + LiftAuthorities []frostPreSignManifestLiftAuthority `json:"liftAuthorities"` + StoreID string `json:"storeID"` + StoreFingerprint string `json:"storeFingerprint"` + ClusterFingerprint string `json:"clusterFingerprint"` + MinimumGeneration uint64 `json:"minimumGeneration"` +} + +type frostPreSignManifestLiftAuthority struct { + AuthorityID string `json:"authorityID"` + PublicKeySPKIHash string `json:"publicKeySpkiHash"` } type frostPreSignManifestNativeSignerAnchor struct { @@ -260,6 +271,7 @@ type frostPreSignActivationManifest struct { FrostSigner frostPreSignManifestFrostSigner `json:"frostSigner"` manifestHash [32]byte activationAuthorityPublicKey [32]byte + activationAuthorityKeyHash [32]byte } type frostPreSignDeploymentPin struct { @@ -520,6 +532,7 @@ func loadFrostPreSignActivationManifest( } manifest.manifestHash = payloadHash copy(manifest.activationAuthorityPublicKey[:], publicKey) + manifest.activationAuthorityKeyHash = trustedKeyHash if err := validateFrostPreSignActivationManifest(manifest); err != nil { return nil, err } @@ -809,9 +822,12 @@ func validateFrostPreSignActivationManifest( "attestation signer key": frost.AttestationSignerKeyHash, "retained group inventory protocol": frost.RetainedGroupInventoryProtocolID, "quarantine journal protocol": frost.QuarantineJournal.ProtocolID, + "quarantine lift protocol": frost.QuarantineJournal.LiftProtocolID, + "quarantine tombstone protocol": frost.QuarantineJournal.TombstoneProtocolID, } { - if _, err := frostPreSignParseBytes32(value); err != nil { - return fmt.Errorf("invalid FROST activation %s: [%w]", name, err) + parsed, err := frostPreSignParseBytes32(value) + if err != nil || parsed == [32]byte{} { + return fmt.Errorf("invalid FROST activation %s", name) } } durableSessionStoreFingerprint, err := frostPreSignParseBytes32( @@ -926,6 +942,11 @@ func validateFrostPreSignActivationManifest( if strings.TrimSpace(quarantine.StoreID) == "" || len(quarantine.StoreID) > 255 { return fmt.Errorf("FROST quarantine journal manifest is incomplete") } + if err := validateFrostPreSignQuarantineLiftAuthorities( + manifest, + ); err != nil { + return err + } quarantineStoreFingerprint, err := frostPreSignParseBytes32(quarantine.StoreFingerprint) if err != nil || quarantineStoreFingerprint == [32]byte{} { return fmt.Errorf("invalid FROST quarantine journal store fingerprint") @@ -1072,6 +1093,125 @@ func frostPreSignNativeSignerAnchorManifest( return result, nil } +func validateFrostPreSignQuarantineLiftAuthorities( + manifest *frostPreSignActivationManifest, +) error { + if manifest == nil { + return fmt.Errorf("FROST quarantine lift manifest is nil") + } + frost := manifest.FrostSigner + quarantine := frost.QuarantineJournal + quarantineProtocolID, _ := frostPreSignParseBytes32(quarantine.ProtocolID) + liftProtocolID, _ := frostPreSignParseBytes32(quarantine.LiftProtocolID) + tombstoneProtocolID, _ := frostPreSignParseBytes32(quarantine.TombstoneProtocolID) + if quarantineProtocolID == liftProtocolID || + quarantineProtocolID == tombstoneProtocolID || + liftProtocolID == tombstoneProtocolID { + return fmt.Errorf("FROST quarantine protocol identities are not distinct") + } + + forbidden := make(map[[32]byte]string) + for name, value := range map[string]string{ + "runtime attestation": frost.AttestationSignerKeyHash, + "runtime exporter": frost.HandshakeOperatorFingerprint, + "retained history source": frost.CanonicalJournal.SourceOperatorFingerprint, + "primary history source": manifest.Ethereum.SourceOperatorFingerprint, + "primary history verifier": manifest.Ethereum.VerifierOperatorFingerprint, + } { + hash, err := frostPreSignParseBytes32(value) + if err != nil || hash == [32]byte{} { + return fmt.Errorf("FROST %s role key is invalid", name) + } + forbidden[hash] = name + } + if manifest.activationAuthorityKeyHash == [32]byte{} { + return fmt.Errorf("FROST activation authority key is unavailable") + } + forbidden[manifest.activationAuthorityKeyHash] = "activation" + + checkpointHashes, err := validateFrostPreSignManifestAuthoritySet( + "checkpoint", + quarantine.CheckpointAuthorityThreshold, + quarantine.CheckpointAuthorities, + forbidden, + ) + if err != nil { + return err + } + for hash := range checkpointHashes { + forbidden[hash] = "checkpoint authority" + } + if _, err := validateFrostPreSignManifestAuthoritySet( + "quarantine lift", + quarantine.LiftAuthorityThreshold, + quarantine.LiftAuthorities, + forbidden, + ); err != nil { + return err + } + return nil +} + +func validateFrostPreSignManifestAuthoritySet( + name string, + threshold uint64, + authorities []frostPreSignManifestLiftAuthority, + forbidden map[[32]byte]string, +) (map[[32]byte]bool, error) { + if threshold < 2 || len(authorities) < 3 || + threshold > uint64(len(authorities)) || + threshold <= uint64(len(authorities))/2 { + return nil, fmt.Errorf( + "FROST %s authority set must be a production strict majority of at least 2-of-3", + name, + ) + } + seenHashes := make(map[[32]byte]bool, len(authorities)) + previousID := "" + for index, authority := range authorities { + if !validFrostPreSignLiftAuthorityID(authority.AuthorityID) || + (index > 0 && authority.AuthorityID <= previousID) { + return nil, fmt.Errorf( + "FROST %s authority IDs are not canonical and strictly sorted", + name, + ) + } + previousID = authority.AuthorityID + keyHash, err := frostPreSignParseBytes32(authority.PublicKeySPKIHash) + if err != nil || keyHash == [32]byte{} || seenHashes[keyHash] { + return nil, fmt.Errorf( + "FROST %s authority SPKI hashes are invalid or duplicate", + name, + ) + } + if role, exists := forbidden[keyHash]; exists { + return nil, fmt.Errorf( + "FROST %s authority [%s] aliases the %s role", + name, + authority.AuthorityID, + role, + ) + } + seenHashes[keyHash] = true + } + return seenHashes, nil +} + +func validFrostPreSignLiftAuthorityID(value string) bool { + if value == "" || len(value) > 64 { + return false + } + for index := range value { + character := value[index] + if !((character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') || + (index > 0 && (character == '-' || character == '_'))) { + return false + } + } + return true +} + func newFrostPreSignEthereumAdapter( ctx context.Context, tc *TbtcChain, @@ -2232,6 +2372,54 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( if err != nil { return tbtc.FrostPreSignActivationRuntimeManifest{}, err } + quarantineLiftProtocolID, err := parse(quarantine.LiftProtocolID) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + quarantineTombstoneProtocolID, err := parse(quarantine.TombstoneProtocolID) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + liftAuthorities := make( + []tbtc.FrostRetainedGroupAuthority, + len(quarantine.LiftAuthorities), + ) + for index, authority := range quarantine.LiftAuthorities { + publicKeySPKIHash, err := parse(authority.PublicKeySPKIHash) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + liftAuthorities[index] = tbtc.FrostRetainedGroupAuthority{ + AuthorityID: authority.AuthorityID, + PublicKeySPKIHash: publicKeySPKIHash, + } + } + checkpointAuthorities := make( + []tbtc.FrostRetainedGroupAuthority, + len(quarantine.CheckpointAuthorities), + ) + for index, authority := range quarantine.CheckpointAuthorities { + publicKeySPKIHash, err := parse(authority.PublicKeySPKIHash) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + checkpointAuthorities[index] = tbtc.FrostRetainedGroupAuthority{ + AuthorityID: authority.AuthorityID, + PublicKeySPKIHash: publicKeySPKIHash, + } + } + verifierOperatorFingerprint, err := parse( + adapter.manifest.Ethereum.VerifierOperatorFingerprint, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + handshakeOperatorFingerprint, err := parse( + adapter.manifest.FrostSigner.HandshakeOperatorFingerprint, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } journal := frost.CanonicalJournal storeFingerprint, err := parse(journal.StoreFingerprint) if err != nil { @@ -2273,6 +2461,9 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( } return tbtc.FrostPreSignActivationRuntimeManifest{ ManifestHash: adapter.profile.ActivationManifestHash, + ActivationAuthorityKeyHash: adapter.manifest.activationAuthorityKeyHash, + VerifierOperatorFingerprint: verifierOperatorFingerprint, + HandshakeOperatorFingerprint: handshakeOperatorFingerprint, DomainChainID: adapter.profile.DomainChainID, GenesisBlockHash: genesisBlockHash, ProfileHash: adapter.profile.ProfileHash, @@ -2308,11 +2499,17 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( MinimumGeneration: journal.MinimumGeneration, }, QuarantineJournal: tbtc.FrostRetainedGroupQuarantineJournalManifest{ - ProtocolID: quarantineJournalProtocolID, - StoreID: quarantine.StoreID, - StoreFingerprint: quarantineStoreFingerprint, - ClusterFingerprint: quarantineClusterFingerprint, - MinimumGeneration: quarantine.MinimumGeneration, + ProtocolID: quarantineJournalProtocolID, + LiftProtocolID: quarantineLiftProtocolID, + TombstoneProtocolID: quarantineTombstoneProtocolID, + CheckpointAuthorityThreshold: quarantine.CheckpointAuthorityThreshold, + CheckpointAuthorities: checkpointAuthorities, + LiftAuthorityThreshold: quarantine.LiftAuthorityThreshold, + LiftAuthorities: liftAuthorities, + StoreID: quarantine.StoreID, + StoreFingerprint: quarantineStoreFingerprint, + ClusterFingerprint: quarantineClusterFingerprint, + MinimumGeneration: quarantine.MinimumGeneration, }, }, nil } diff --git a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go index 74f97726b8..6905691004 100644 --- a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go +++ b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go @@ -15,11 +15,12 @@ func testManifestHex32(value byte) string { func testFrostJournalActivationManifest() *frostPreSignActivationManifest { checkpointHash := testManifestHex32(0x02) manifest := &frostPreSignActivationManifest{ - Schema: frostPreSignManifestVersion, - ActivationSequence: 1, - ActivationID: testManifestHex32(0x01), - Environment: "test", - manifestHash: [32]byte{0x99}, + Schema: frostPreSignManifestVersion, + ActivationSequence: 1, + ActivationID: testManifestHex32(0x01), + Environment: "test", + manifestHash: [32]byte{0x99}, + activationAuthorityKeyHash: [32]byte{0x40}, Ethereum: frostPreSignManifestEthereum{ ChainID: 1, GenesisBlockHash: testManifestHex32(0x30), @@ -68,7 +69,21 @@ func testFrostJournalActivationManifest() *frostPreSignActivationManifest { MinimumGeneration: 7, }, QuarantineJournal: frostPreSignManifestQuarantineJournal{ - ProtocolID: testManifestHex32(0x25), + ProtocolID: testManifestHex32(0x25), + LiftProtocolID: testManifestHex32(0x29), + TombstoneProtocolID: testManifestHex32(0x2a), + CheckpointAuthorityThreshold: 2, + CheckpointAuthorities: []frostPreSignManifestLiftAuthority{ + {AuthorityID: "checkpoint-1", PublicKeySPKIHash: testManifestHex32(0x2b)}, + {AuthorityID: "checkpoint-2", PublicKeySPKIHash: testManifestHex32(0x2c)}, + {AuthorityID: "checkpoint-3", PublicKeySPKIHash: testManifestHex32(0x2d)}, + }, + LiftAuthorityThreshold: 2, + LiftAuthorities: []frostPreSignManifestLiftAuthority{ + {AuthorityID: "authority-1", PublicKeySPKIHash: testManifestHex32(0x36)}, + {AuthorityID: "authority-2", PublicKeySPKIHash: testManifestHex32(0x37)}, + {AuthorityID: "authority-3", PublicKeySPKIHash: testManifestHex32(0x38)}, + }, StoreID: "quarantine-journal-store", StoreFingerprint: testManifestHex32(0x26), ClusterFingerprint: testManifestHex32(0x27), @@ -192,6 +207,92 @@ func TestValidateFrostPreSignActivationManifest_CanonicalJournal(t *testing.T) { }) } +func TestValidateFrostPreSignActivationManifest_QuarantineAuthoritySets( + t *testing.T, +) { + t.Run("2-of-3 lift authority set", func(t *testing.T) { + if err := validateFrostPreSignActivationManifest( + testFrostJournalActivationManifest(), + ); err != nil { + t.Fatal(err) + } + }) + t.Run("3-of-4 lift authority set", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.LiftAuthorityThreshold = 3 + manifest.FrostSigner.QuarantineJournal.LiftAuthorities = append( + manifest.FrostSigner.QuarantineJournal.LiftAuthorities, + frostPreSignManifestLiftAuthority{ + AuthorityID: "authority-4", + PublicKeySPKIHash: testManifestHex32(0x39), + }, + ) + if err := validateFrostPreSignActivationManifest(manifest); err != nil { + t.Fatal(err) + } + }) + t.Run("2-of-4 is not a strict majority", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.LiftAuthorities = append( + manifest.FrostSigner.QuarantineJournal.LiftAuthorities, + frostPreSignManifestLiftAuthority{ + AuthorityID: "authority-4", + PublicKeySPKIHash: testManifestHex32(0x39), + }, + ) + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "strict majority") { + t.Fatalf("expected 2-of-4 rejection, got [%v]", err) + } + }) + t.Run("unsorted authority IDs", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + authorities := manifest.FrostSigner.QuarantineJournal.LiftAuthorities + authorities[0], authorities[1] = authorities[1], authorities[0] + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "strictly sorted") { + t.Fatalf("expected unsorted authority rejection, got [%v]", err) + } + }) + t.Run("duplicate authority key", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + authorities := manifest.FrostSigner.QuarantineJournal.LiftAuthorities + authorities[1].PublicKeySPKIHash = authorities[0].PublicKeySPKIHash + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("expected duplicate authority-key rejection, got [%v]", err) + } + }) + t.Run("activation role alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.LiftAuthorities[0]. + PublicKeySPKIHash = testManifestHex32(0x40) + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "aliases the activation role") { + t.Fatalf("expected activation-role alias rejection, got [%v]", err) + } + }) + t.Run("checkpoint role alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.LiftAuthorities[0]. + PublicKeySPKIHash = manifest.FrostSigner.QuarantineJournal. + CheckpointAuthorities[0].PublicKeySPKIHash + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "checkpoint authority") { + t.Fatalf("expected checkpoint-role alias rejection, got [%v]", err) + } + }) + t.Run("protocol identity alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.LiftProtocolID = + manifest.FrostSigner.QuarantineJournal.ProtocolID + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "not distinct") { + t.Fatalf("expected protocol-identity alias rejection, got [%v]", err) + } + }) +} + func TestFrostPreSignDecodeStrictJSON_RejectsUnknownCanonicalJournalField(t *testing.T) { data := []byte(`{"storeID":"id","unknown":true}`) if err := frostPreSignDecodeStrictJSON( diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index afb94f9baa..0aaf7915b2 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -97,8 +97,11 @@ type frostActivationQuarantineJournalState struct { StoreFingerprint string `json:"storeFingerprint"` ClusterFingerprint string `json:"clusterFingerprint"` Root string `json:"root"` + ActiveRoot string `json:"activeRoot"` + TombstoneRoot string `json:"tombstoneRoot"` Generation uint64 `json:"generation"` CurrentQuarantineCount uint64 `json:"currentQuarantineCount"` + TombstoneCount uint64 `json:"tombstoneCount"` Complete bool `json:"complete"` } @@ -198,15 +201,17 @@ type frostActivationReconciliationJob struct { } type frostActivationJournalStamp struct { - bindingHash [32]byte - canonicalPoint FrostPreSignFinality - canonicalGeneration uint64 - canonicalBatchRoot [32]byte - canonicalInventory [32]byte - quarantinePoint FrostPreSignFinality - quarantineGeneration uint64 - quarantineBatchRoot [32]byte - quarantineRoot [32]byte + bindingHash [32]byte + canonicalPoint FrostPreSignFinality + canonicalGeneration uint64 + canonicalBatchRoot [32]byte + canonicalInventory [32]byte + quarantinePoint FrostPreSignFinality + quarantineGeneration uint64 + quarantineBatchRoot [32]byte + quarantineRoot [32]byte + quarantineActiveRoot [32]byte + quarantineTombstoneRoot [32]byte } type frostActivationReconciliationCache struct { @@ -628,6 +633,8 @@ func (fahe *frostActivationHandshakeExporter) validateActivationJournalSnapshot( journalSnapshot.QuarantineClusterFingerprint != quarantineManifest.ClusterFingerprint || journalSnapshot.QuarantineGeneration < quarantineManifest.MinimumGeneration || journalSnapshot.QuarantineRoot == [32]byte{} || + journalSnapshot.QuarantineActiveRoot == [32]byte{} || + journalSnapshot.QuarantineTombstoneRoot == [32]byte{} || journalSnapshot.QuarantineCount != 0 { return fmt.Errorf( "canonical FROST retained-group journal is not activation-ready", @@ -662,15 +669,17 @@ func (fahe *frostActivationHandshakeExporter) journalStampLocked() ( ) } return frostActivationJournalStamp{ - bindingHash: fahe.bindingHash, - canonicalPoint: journal.state.CurrentPoint, - canonicalGeneration: journal.state.SnapshotGeneration, - canonicalBatchRoot: journal.state.BatchRoot, - canonicalInventory: journal.state.InventoryRoot, - quarantinePoint: journal.quarantineState.CurrentPoint, - quarantineGeneration: journal.quarantineState.Generation, - quarantineBatchRoot: journal.quarantineState.BatchRoot, - quarantineRoot: journal.quarantineState.Root, + bindingHash: fahe.bindingHash, + canonicalPoint: journal.state.CurrentPoint, + canonicalGeneration: journal.state.SnapshotGeneration, + canonicalBatchRoot: journal.state.BatchRoot, + canonicalInventory: journal.state.InventoryRoot, + quarantinePoint: journal.quarantineState.CurrentPoint, + quarantineGeneration: journal.quarantineState.Generation, + quarantineBatchRoot: journal.quarantineState.BatchRoot, + quarantineRoot: journal.quarantineState.Root, + quarantineActiveRoot: journal.quarantineState.ActiveRoot, + quarantineTombstoneRoot: journal.quarantineState.TombstoneRoot, }, nil } @@ -687,7 +696,9 @@ func frostActivationStampMatchesSnapshot( stamp.canonicalBatchRoot == snapshot.BatchRoot && stamp.canonicalInventory == snapshot.InventoryRoot && stamp.quarantineGeneration == snapshot.QuarantineGeneration && - stamp.quarantineRoot == snapshot.QuarantineRoot + stamp.quarantineRoot == snapshot.QuarantineRoot && + stamp.quarantineActiveRoot == snapshot.QuarantineActiveRoot && + stamp.quarantineTombstoneRoot == snapshot.QuarantineTombstoneRoot } func (fahe *frostActivationHandshakeExporter) verifyActivationPointQuick( @@ -908,8 +919,11 @@ func (fahe *frostActivationHandshakeExporter) attest( StoreFingerprint: frostActivationHex32(journalSnapshot.QuarantineStoreFingerprint), ClusterFingerprint: frostActivationHex32(journalSnapshot.QuarantineClusterFingerprint), Root: frostActivationHex32(journalSnapshot.QuarantineRoot), + ActiveRoot: frostActivationHex32(journalSnapshot.QuarantineActiveRoot), + TombstoneRoot: frostActivationHex32(journalSnapshot.QuarantineTombstoneRoot), Generation: journalSnapshot.QuarantineGeneration, CurrentQuarantineCount: journalSnapshot.QuarantineCount, + TombstoneCount: journalSnapshot.QuarantineTombstoneCount, Complete: true, }, NativeSignerState: frostActivationNativeSignerState{ diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index 4879d76fb6..4d39ae30a4 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -365,8 +365,9 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { "sourceTrustDomainID", "storeFingerprint", "storeID", }) assertFrostActivationObjectKeys(t, handshake.Payload.State.QuarantineJournal, []string{ - "clusterFingerprint", "complete", "currentQuarantineCount", "generation", - "protocolID", "root", "storeFingerprint", "storeID", + "activeRoot", "clusterFingerprint", "complete", "currentQuarantineCount", + "generation", "protocolID", "root", "storeFingerprint", "storeID", + "tombstoneCount", "tombstoneRoot", }) assertFrostActivationObjectKeys(t, handshake.Payload.State.NativeSignerState, []string{ "anchorRotationWarning", "anchorServiceEpoch", "certifiedFloorGeneration", @@ -380,6 +381,109 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { }) } +func TestFrostActivationHandshakeExporter_PermitsAndAttestsTombstones( + t *testing.T, +) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + _, journal, _, _, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + raisedRecord := FrostRetainedGroupQuarantineRaisedRecord{ + QuarantineID: [32]byte{0x51}, + WalletID: [32]byte{0x52}, + EvidenceHash: [32]byte{0x53}, + Reason: "resolved quarantine", + RecoveryRequired: true, + RaisedAt: FrostRetainedGroupEventPoint{ + BlockNumber: 100, + BlockHash: [32]byte{0x64}, + TransactionHash: [32]byte{0xa1}, + TransactionIndex: 1, + LogIndex: 1, + }, + } + liftedAt := FrostRetainedGroupEventPoint{ + BlockNumber: 120, + BlockHash: [32]byte{0x78}, + TransactionHash: [32]byte{0xa2}, + TransactionIndex: 1, + LogIndex: 1, + } + certificateHash := [32]byte{0x54} + quarantine := frostRetainedGroupQuarantineState{ + RaisedRecord: raisedRecord, + Status: frostRetainedGroupQuarantineLifted, + LiftCertificateHash: certificateHash, + LiftedAt: liftedAt, + } + tombstone := frostRetainedGroupQuarantineTombstone{ + QuarantineID: raisedRecord.QuarantineID, + WalletID: raisedRecord.WalletID, + LiftCertificateHash: certificateHash, + LiftedAt: liftedAt, + ResolutionEvidenceHash: [32]byte{0x55}, + ResolutionFinality: FrostPreSignFinality{ + BlockNumber: 110, + BlockHash: [32]byte{0x6e}, + }, + } + journal.quarantineState.Generation = 2 + journal.quarantineState.Quarantines = + []frostRetainedGroupQuarantineState{quarantine} + journal.quarantineState.Tombstones = + []frostRetainedGroupQuarantineTombstone{tombstone} + var err error + journal.quarantineState.ActiveRoot, err = + frostRetainedGroupQuarantineActiveRoot( + journal.metadata.BindingHash, + map[[32]byte]frostRetainedGroupQuarantineState{ + raisedRecord.QuarantineID: quarantine, + }, + ) + if err != nil { + t.Fatal(err) + } + journal.quarantineState.TombstoneRoot, err = + frostRetainedGroupQuarantineTombstoneRoot( + journal.metadata.BindingHash, + map[[32]byte]frostRetainedGroupQuarantineTombstone{ + raisedRecord.QuarantineID: tombstone, + }, + ) + if err != nil { + t.Fatal(err) + } + + response := postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != + frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf("initial tombstone reconciliation returned [%d]", response.StatusCode) + } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + defer response.Body.Close() + handshake := &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { + t.Fatal(err) + } + attestation := handshake.Payload.State.QuarantineJournal + if attestation.CurrentQuarantineCount != 0 || + attestation.TombstoneCount != 1 || + attestation.TombstoneRoot != + frostActivationHex32(journal.quarantineState.TombstoneRoot) { + t.Fatalf("tombstoned ready state was not attested: %+v", attestation) + } +} + func assertFrostActivationObjectKeys( t *testing.T, value interface{}, @@ -550,9 +654,12 @@ func TestFrostActivationHandshakeExporter_RejectsUnboundOrLegacyChallenge( startTestFrostActivationHandshakeExporter(t, point) testCases := map[string]func(*frostActivationHandshakeRequest){ - "legacy schema": func(request *frostActivationHandshakeRequest) { + "legacy v1 schema": func(request *frostActivationHandshakeRequest) { request.Schema = "tbtc-p2tr-production-activation-handshake/v1" }, + "legacy v2 schema": func(request *frostActivationHandshakeRequest) { + request.Schema = "tbtc-p2tr-production-activation-handshake/v2" + }, "different binding": func(request *frostActivationHandshakeRequest) { request.Challenge.BindingHash = frostActivationHex32([32]byte{0xff}) }, @@ -863,6 +970,13 @@ func testFrostActivationRuntimeManifest( ) FrostPreSignActivationRuntimeManifest { return FrostPreSignActivationRuntimeManifest{ ManifestHash: [32]byte{0x10}, + ActivationAuthorityKeyHash: [32]byte{0x30}, + VerifierOperatorFingerprint: [32]byte{0x31}, + HandshakeOperatorFingerprint: [32]byte{0x37}, + DomainChainID: [32]byte{31: 0x01}, + GenesisBlockHash: [32]byte{0x32}, + ProfileHash: [32]byte{0x33}, + ImplementationSetHash: [32]byte{0x34}, SignerProtocolID: [32]byte{0x11}, ReservationProtocolID: [32]byte{0x12}, BitcoinOutboxProtocolID: [32]byte{0x13}, @@ -889,7 +1003,21 @@ func testFrostActivationRuntimeManifest( MinimumGeneration: 1, }, QuarantineJournal: FrostRetainedGroupQuarantineJournalManifest{ - ProtocolID: [32]byte{0x25}, + ProtocolID: [32]byte{0x25}, + LiftProtocolID: [32]byte{0x35}, + TombstoneProtocolID: [32]byte{0x36}, + CheckpointAuthorityThreshold: 2, + CheckpointAuthorities: []FrostRetainedGroupAuthority{ + {AuthorityID: "checkpoint-1", PublicKeySPKIHash: [32]byte{0x40}}, + {AuthorityID: "checkpoint-2", PublicKeySPKIHash: [32]byte{0x41}}, + {AuthorityID: "checkpoint-3", PublicKeySPKIHash: [32]byte{0x42}}, + }, + LiftAuthorityThreshold: 2, + LiftAuthorities: []FrostRetainedGroupAuthority{ + {AuthorityID: "lift-1", PublicKeySPKIHash: [32]byte{0x43}}, + {AuthorityID: "lift-2", PublicKeySPKIHash: [32]byte{0x44}}, + {AuthorityID: "lift-3", PublicKeySPKIHash: [32]byte{0x45}}, + }, StoreID: "quarantine-store-id", StoreFingerprint: [32]byte{0x26}, ClusterFingerprint: [32]byte{0x27}, @@ -999,6 +1127,27 @@ func testFrostRetainedGroupJournal( Wallets: []frostRetainedGroupWalletState{}, } quarantineRoot := sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)) + liftPolicy, err := frostRetainedGroupLiftPolicyFromRuntimeManifest( + bindingHash, + manifest, + ) + if err != nil { + t.Fatal(err) + } + activeRoot, err := frostRetainedGroupQuarantineActiveRoot( + bindingHash, + map[[32]byte]frostRetainedGroupQuarantineState{}, + ) + if err != nil { + t.Fatal(err) + } + tombstoneRoot, err := frostRetainedGroupQuarantineTombstoneRoot( + bindingHash, + map[[32]byte]frostRetainedGroupQuarantineTombstone{}, + ) + if err != nil { + t.Fatal(err) + } state.InventoryRoot, _, _, _, err = frostRetainedGroupInventoryRoot(state) if err != nil { t.Fatal(err) @@ -1022,10 +1171,18 @@ func testFrostRetainedGroupJournal( SourceOperatorFingerprint: manifest.CanonicalJournal.SourceOperatorFingerprint, }, quarantineMetadata: frostRetainedGroupQuarantineMetadata{ - Schema: frostRetainedGroupQuarantineMetadataSchema, - ManifestHash: manifest.ManifestHash, - BindingHash: bindingHash, - ProtocolID: manifest.QuarantineJournal.ProtocolID, + Schema: frostRetainedGroupQuarantineMetadataSchema, + ManifestHash: manifest.ManifestHash, + BindingHash: bindingHash, + ProtocolID: manifest.QuarantineJournal.ProtocolID, + LiftProtocolID: manifest.QuarantineJournal.LiftProtocolID, + TombstoneProtocolID: manifest.QuarantineJournal.TombstoneProtocolID, + LiftAuthoritySetHash: liftPolicy.AuthoritySetHash, + LiftAuthorityThreshold: liftPolicy.AuthorityThreshold, + LiftAuthorities: append( + []FrostRetainedGroupAuthority{}, + liftPolicy.Authorities..., + ), StoreID: manifest.QuarantineJournal.StoreID, StoreFingerprint: manifest.QuarantineJournal.StoreFingerprint, ClusterFingerprint: manifest.QuarantineJournal.ClusterFingerprint, @@ -1037,12 +1194,17 @@ func testFrostRetainedGroupJournal( walletRegistry: &walletRegistry{walletCache: make(map[string]*walletCacheValue)}, operatorAddress: chain.Address("0x01"), state: state, + liftPolicy: liftPolicy, + liftCertificates: make(map[[32]byte]FrostRetainedGroupQuarantineLiftCertificate), quarantineState: frostRetainedGroupQuarantineJournalState{ - Schema: frostRetainedGroupQuarantineStateSchema, - BindingHash: bindingHash, - CurrentPoint: target, - Root: quarantineRoot, - Quarantines: []frostRetainedGroupQuarantineState{}, + Schema: frostRetainedGroupQuarantineStateSchema, + BindingHash: bindingHash, + CurrentPoint: target, + Root: quarantineRoot, + ActiveRoot: activeRoot, + TombstoneRoot: tombstoneRoot, + Quarantines: []frostRetainedGroupQuarantineState{}, + Tombstones: []frostRetainedGroupQuarantineTombstone{}, }, } } diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index fa868482be..0643d3c877 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1448,6 +1448,9 @@ func frostPreSignWriteCommitmentUint64( type FrostPreSignActivationRuntimeManifest struct { ManifestHash [32]byte + ActivationAuthorityKeyHash [32]byte + VerifierOperatorFingerprint [32]byte + HandshakeOperatorFingerprint [32]byte DomainChainID [32]byte GenesisBlockHash [32]byte ProfileHash [32]byte diff --git a/pkg/tbtc/frost_retained_group_history_evidence.go b/pkg/tbtc/frost_retained_group_history_evidence.go index a54fb097ac..3999dfebfd 100644 --- a/pkg/tbtc/frost_retained_group_history_evidence.go +++ b/pkg/tbtc/frost_retained_group_history_evidence.go @@ -46,6 +46,7 @@ type frostRetainedGroupEvidenceProfile struct { domainChainID [32]byte genesisBlockHash [32]byte bindingHash [32]byte + liftPolicy frostRetainedGroupQuarantineLiftPolicy deployments map[string]FrostPreSignDeploymentEvidence bridgeABI ethabi.ABI registryABI ethabi.ABI @@ -179,6 +180,16 @@ func (source *signedFrostRetainedGroupHistorySource) BindFrostRetainedGroupActiv if err != nil { return err } + liftPolicy, err := frostRetainedGroupLiftPolicyFromRuntimeManifest( + bindingHash, + runtimeManifest, + ) + if err != nil { + return fmt.Errorf( + "retained-group quarantine lift policy is invalid: [%w]", + err, + ) + } parsedBridgeABI, err := ethabi.JSON(strings.NewReader(bridgeabi.BridgeMetaData.ABI)) if err != nil { return fmt.Errorf("cannot parse pinned Bridge ABI: [%w]", err) @@ -198,6 +209,7 @@ func (source *signedFrostRetainedGroupHistorySource) BindFrostRetainedGroupActiv domainChainID: runtimeManifest.DomainChainID, genesisBlockHash: runtimeManifest.GenesisBlockHash, bindingHash: bindingHash, + liftPolicy: liftPolicy, deployments: deployments, bridgeABI: parsedBridgeABI, registryABI: parsedRegistryABI, @@ -239,8 +251,23 @@ func (source *signedFrostRetainedGroupHistorySource) computeProtocolBinding( profile FrostPreSignActivationProfile, runtimeManifest FrostPreSignActivationRuntimeManifest, ) ([32]byte, error) { + liftAuthoritySetHash, err := frostRetainedGroupLiftAuthoritySetHash( + runtimeManifest.QuarantineJournal.LiftAuthorityThreshold, + runtimeManifest.QuarantineJournal.LiftAuthorities, + ) + if err != nil { + return [32]byte{}, err + } + checkpointAuthoritySetHash, err := frostRetainedGroupAuthoritySetHash( + "tbtc-frost-retained-group-checkpoint-authority-set/v1", + runtimeManifest.QuarantineJournal.CheckpointAuthorityThreshold, + runtimeManifest.QuarantineJournal.CheckpointAuthorities, + ) + if err != nil { + return [32]byte{}, err + } binding := frostRetainedGroupProtocolBinding{ - Schema: "tbtc-frost-retained-group-protocol-binding/v2", + Schema: "tbtc-frost-retained-group-protocol-binding/v3", ChainID: source.chainID, DomainChainID: frostActivationHex32(runtimeManifest.DomainChainID), GenesisBlockHash: frostActivationHex32(runtimeManifest.GenesisBlockHash), @@ -262,6 +289,10 @@ func (source *signedFrostRetainedGroupHistorySource) computeProtocolBinding( BitcoinOutboxProtocolID: frostActivationHex32(runtimeManifest.BitcoinOutboxProtocolID), InventoryProtocolID: frostActivationHex32(runtimeManifest.RetainedGroupInventoryProtocolID), QuarantineProtocolID: frostActivationHex32(runtimeManifest.QuarantineJournal.ProtocolID), + LiftProtocolID: frostActivationHex32(runtimeManifest.QuarantineJournal.LiftProtocolID), + TombstoneProtocolID: frostActivationHex32(runtimeManifest.QuarantineJournal.TombstoneProtocolID), + LiftAuthoritySetHash: frostActivationHex32(liftAuthoritySetHash), + CheckpointAuthoritySetHash: frostActivationHex32(checkpointAuthoritySetHash), SigningPolicyHash: frostActivationHex32(runtimeManifest.SigningPolicyHash), CanonicalStoreID: runtimeManifest.CanonicalJournal.StoreID, CanonicalStoreFingerprint: frostActivationHex32( @@ -749,6 +780,23 @@ func (source *signedFrostRetainedGroupHistorySource) verifyMutationEvidence( // records, not Ethereum events. Their Point is only a canonical finalized // ordering anchor and is deliberately never presented as receipt evidence. if isFrostRetainedGroupQuarantineMutation(mutation.Kind) { + if mutation.Kind == FrostRetainedGroupQuarantineLiftMutation { + if _, err := validateFrostRetainedGroupLiftCertificateShape( + evidence.liftPolicy, + mutation.LiftCertificate, + ); err != nil { + return err + } + if err := source.VerifyPoint( + ctx, + mutation.LiftCertificate.Body.ResolutionFinality, + ); err != nil { + return fmt.Errorf( + "quarantine lift resolution finality is not canonical: [%w]", + err, + ) + } + } return nil } diff --git a/pkg/tbtc/frost_retained_group_history_source.go b/pkg/tbtc/frost_retained_group_history_source.go index 00ed0a557f..44fbcb2a9c 100644 --- a/pkg/tbtc/frost_retained_group_history_source.go +++ b/pkg/tbtc/frost_retained_group_history_source.go @@ -32,16 +32,16 @@ import ( ) const ( - frostRetainedGroupHistoryPageSchema = "tbtc-frost-retained-group-history-page/v2" - frostRetainedGroupOperatorReceiptSchema = "tbtc-frost-retained-group-operator-receipt/v2" - frostRetainedGroupHistoryRequestSchema = "tbtc-frost-retained-group-history-request/v2" - frostRetainedGroupOperatorRequestSchema = "tbtc-frost-retained-group-operator-request/v2" - frostRetainedGroupHistorySignatureDomain = "tbtc-frost-retained-group-export-signature-v2\x00" + frostRetainedGroupHistoryPageSchema = "tbtc-frost-retained-group-history-page/v3" + frostRetainedGroupOperatorReceiptSchema = "tbtc-frost-retained-group-operator-receipt/v3" + frostRetainedGroupHistoryRequestSchema = "tbtc-frost-retained-group-history-request/v3" + frostRetainedGroupOperatorRequestSchema = "tbtc-frost-retained-group-operator-request/v3" + frostRetainedGroupHistorySignatureDomain = "tbtc-frost-retained-group-export-signature-v3\x00" frostRetainedGroupHistoryEndpointDomain = "tbtc-frost-retained-group-endpoints-v1\x00" - frostRetainedGroupHistoryQueryDomain = "tbtc-frost-retained-group-history-query-v2\x00" - frostRetainedGroupOperatorQueryDomain = "tbtc-frost-retained-group-operator-query-v2\x00" - frostRetainedGroupHistoryRootDomain = "tbtc-frost-retained-group-export-history-root-v2\x00" - frostRetainedGroupProtocolBindingDomain = "tbtc-frost-retained-group-protocol-binding-v2\x00" + frostRetainedGroupHistoryQueryDomain = "tbtc-frost-retained-group-history-query-v3\x00" + frostRetainedGroupOperatorQueryDomain = "tbtc-frost-retained-group-operator-query-v3\x00" + frostRetainedGroupHistoryRootDomain = "tbtc-frost-retained-group-export-history-root-v3\x00" + frostRetainedGroupProtocolBindingDomain = "tbtc-frost-retained-group-protocol-binding-v3\x00" frostRetainedGroupMaximumResponseBytes = 1024 * 1024 frostRetainedGroupMaximumAggregateResponseBytes = 16 * 1024 * 1024 @@ -249,6 +249,10 @@ type frostRetainedGroupProtocolBinding struct { BitcoinOutboxProtocolID string `json:"bitcoinOutboxProtocolID"` InventoryProtocolID string `json:"inventoryProtocolID"` QuarantineProtocolID string `json:"quarantineProtocolID"` + LiftProtocolID string `json:"liftProtocolID"` + TombstoneProtocolID string `json:"tombstoneProtocolID"` + LiftAuthoritySetHash string `json:"liftAuthoritySetHash"` + CheckpointAuthoritySetHash string `json:"checkpointAuthoritySetHash"` SigningPolicyHash string `json:"signingPolicyHash"` CanonicalStoreID string `json:"canonicalStoreID"` CanonicalStoreFingerprint string `json:"canonicalStoreFingerprint"` @@ -267,22 +271,72 @@ type frostRetainedGroupWireEventPoint struct { LogIndex uint32 `json:"logIndex"` } +type frostRetainedGroupWireQuarantineRaisedRecord struct { + QuarantineID string `json:"quarantineID"` + WalletID string `json:"walletID"` + EvidenceHash string `json:"evidenceHash"` + Reason string `json:"reason"` + RecoveryRequired bool `json:"recoveryRequired"` + RaisedAt frostRetainedGroupWireEventPoint `json:"raisedAt"` +} + +type frostRetainedGroupWireQuarantineLiftBody struct { + Schema string `json:"schema"` + ProtocolBindingHash string `json:"protocolBindingHash"` + ManifestHash string `json:"manifestHash"` + ProfileHash string `json:"profileHash"` + ImplementationSetHash string `json:"implementationSetHash"` + ChainID uint64 `json:"chainID"` + DomainChainID string `json:"domainChainID"` + GenesisBlockHash string `json:"genesisBlockHash"` + QuarantineProtocolID string `json:"quarantineProtocolID"` + LiftProtocolID string `json:"liftProtocolID"` + TombstoneProtocolID string `json:"tombstoneProtocolID"` + AuthoritySetHash string `json:"authoritySetHash"` + QuarantineID string `json:"quarantineID"` + WalletID string `json:"walletID"` + OriginalRaisedRecord frostRetainedGroupWireQuarantineRaisedRecord `json:"originalRaisedRecord"` + PriorGeneration uint64 `json:"priorGeneration"` + PriorEventRoot string `json:"priorEventRoot"` + PriorActiveRoot string `json:"priorActiveRoot"` + PriorTombstoneRoot string `json:"priorTombstoneRoot"` + LiftPoint frostRetainedGroupWireEventPoint `json:"liftPoint"` + ResolutionEvidenceHash string `json:"resolutionEvidenceHash"` + ResolutionFinality frostRetainedGroupWireFinality `json:"resolutionFinality"` + NotBeforeBlock uint64 `json:"notBeforeBlock"` + ExpiresAtBlock uint64 `json:"expiresAtBlock"` +} + +type frostRetainedGroupWireQuarantineLiftSignature struct { + AuthorityID string `json:"authorityID"` + SignerPublicKeySPKI string `json:"signerPublicKeySpki"` + Signature string `json:"signature"` +} + +type frostRetainedGroupWireQuarantineLiftCertificate struct { + Schema string `json:"schema"` + Body frostRetainedGroupWireQuarantineLiftBody `json:"body"` + BodyHash string `json:"bodyHash"` + Signatures []frostRetainedGroupWireQuarantineLiftSignature `json:"signatures"` +} + type frostRetainedGroupWireMutation struct { - Point frostRetainedGroupWireEventPoint `json:"point"` - Kind string `json:"kind"` - WalletID string `json:"walletID"` - WalletPublicKeyHash string `json:"walletPublicKeyHash"` - OperatorIDs []uint32 `json:"operatorIDs"` - RetainedGroupHash string `json:"retainedGroupHash"` - DkgResultHash string `json:"dkgResultHash"` - DkgSubmissionPoint frostRetainedGroupWireEventPoint `json:"dkgSubmissionPoint"` - DkgApprovalPoint frostRetainedGroupWireEventPoint `json:"dkgApprovalPoint"` - CreationPoint frostRetainedGroupWireEventPoint `json:"creationPoint"` - BridgeRegistrationPoint frostRetainedGroupWireEventPoint `json:"bridgeRegistrationPoint"` - QuarantineID string `json:"quarantineID"` - EvidenceHash string `json:"evidenceHash"` - AuthenticationHash string `json:"authenticationHash"` - Reason string `json:"reason"` + Point frostRetainedGroupWireEventPoint `json:"point"` + Kind string `json:"kind"` + WalletID string `json:"walletID"` + WalletPublicKeyHash string `json:"walletPublicKeyHash"` + OperatorIDs []uint32 `json:"operatorIDs"` + RetainedGroupHash string `json:"retainedGroupHash"` + DkgResultHash string `json:"dkgResultHash"` + DkgSubmissionPoint frostRetainedGroupWireEventPoint `json:"dkgSubmissionPoint"` + DkgApprovalPoint frostRetainedGroupWireEventPoint `json:"dkgApprovalPoint"` + CreationPoint frostRetainedGroupWireEventPoint `json:"creationPoint"` + BridgeRegistrationPoint frostRetainedGroupWireEventPoint `json:"bridgeRegistrationPoint"` + QuarantineID string `json:"quarantineID"` + EvidenceHash string `json:"evidenceHash"` + LiftCertificateHash string `json:"liftCertificateHash"` + LiftCertificate *frostRetainedGroupWireQuarantineLiftCertificate `json:"liftCertificate"` + Reason string `json:"reason"` } type frostRetainedGroupHistoryQuery struct { @@ -846,7 +900,10 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( EmptyAtFrom: true, DescriptorSetHash: descriptorSetHash, } - if err := validateCompleteFrostRetainedGroupHistory(history); err != nil { + if err := validateCompleteFrostRetainedGroupHistory( + history, + evidence.liftPolicy, + ); err != nil { return nil, err } for blockNumber, blockHash := range blockHashes { @@ -1306,7 +1363,15 @@ func frostRetainedGroupMutationFromWire( if err != nil { return FrostRetainedGroupMutation{}, err } - authenticationHash, err := parseFrostActivationHex32(mutation.AuthenticationHash) + liftCertificateHash, err := parseFrostActivationHex32( + mutation.LiftCertificateHash, + ) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + liftCertificate, err := frostRetainedGroupLiftCertificateFromWire( + mutation.LiftCertificate, + ) if err != nil { return FrostRetainedGroupMutation{}, err } @@ -1333,7 +1398,8 @@ func frostRetainedGroupMutationFromWire( BridgeRegistrationPoint: registrationPoint, QuarantineID: quarantineID, EvidenceHash: evidenceHash, - AuthenticationHash: authenticationHash, + LiftCertificateHash: liftCertificateHash, + LiftCertificate: liftCertificate, Reason: mutation.Reason, }, nil } @@ -1355,11 +1421,275 @@ func frostRetainedGroupMutationToWire( BridgeRegistrationPoint: frostRetainedGroupEventPointToWire(mutation.BridgeRegistrationPoint), QuarantineID: frostActivationHex32(mutation.QuarantineID), EvidenceHash: frostActivationHex32(mutation.EvidenceHash), - AuthenticationHash: frostActivationHex32(mutation.AuthenticationHash), + LiftCertificateHash: frostActivationHex32(mutation.LiftCertificateHash), + LiftCertificate: frostRetainedGroupLiftCertificateToWire(mutation.LiftCertificate), Reason: mutation.Reason, } } +func frostRetainedGroupLiftCertificateToWire( + certificate *FrostRetainedGroupQuarantineLiftCertificate, +) *frostRetainedGroupWireQuarantineLiftCertificate { + if certificate == nil { + return nil + } + body := certificate.Body + signatures := make( + []frostRetainedGroupWireQuarantineLiftSignature, + len(certificate.Signatures), + ) + for index, signature := range certificate.Signatures { + signatures[index] = frostRetainedGroupWireQuarantineLiftSignature{ + AuthorityID: signature.AuthorityID, + SignerPublicKeySPKI: signature.SignerPublicKeySPKI, + Signature: signature.Signature, + } + } + return &frostRetainedGroupWireQuarantineLiftCertificate{ + Schema: certificate.Schema, + Body: frostRetainedGroupWireQuarantineLiftBody{ + Schema: body.Schema, + ProtocolBindingHash: frostActivationHex32(body.ProtocolBindingHash), + ManifestHash: frostActivationHex32(body.ManifestHash), + ProfileHash: frostActivationHex32(body.ProfileHash), + ImplementationSetHash: frostActivationHex32(body.ImplementationSetHash), + ChainID: body.ChainID, + DomainChainID: frostActivationHex32(body.DomainChainID), + GenesisBlockHash: frostActivationHex32(body.GenesisBlockHash), + QuarantineProtocolID: frostActivationHex32(body.QuarantineProtocolID), + LiftProtocolID: frostActivationHex32(body.LiftProtocolID), + TombstoneProtocolID: frostActivationHex32(body.TombstoneProtocolID), + AuthoritySetHash: frostActivationHex32(body.AuthoritySetHash), + QuarantineID: frostActivationHex32(body.QuarantineID), + WalletID: frostActivationHex32(body.WalletID), + OriginalRaisedRecord: frostRetainedGroupWireQuarantineRaisedRecord{ + QuarantineID: frostActivationHex32( + body.OriginalRaisedRecord.QuarantineID, + ), + WalletID: frostActivationHex32( + body.OriginalRaisedRecord.WalletID, + ), + EvidenceHash: frostActivationHex32( + body.OriginalRaisedRecord.EvidenceHash, + ), + Reason: body.OriginalRaisedRecord.Reason, + RecoveryRequired: body.OriginalRaisedRecord.RecoveryRequired, + RaisedAt: frostRetainedGroupEventPointToWire( + body.OriginalRaisedRecord.RaisedAt, + ), + }, + PriorGeneration: body.PriorGeneration, + PriorEventRoot: frostActivationHex32(body.PriorEventRoot), + PriorActiveRoot: frostActivationHex32(body.PriorActiveRoot), + PriorTombstoneRoot: frostActivationHex32(body.PriorTombstoneRoot), + LiftPoint: frostRetainedGroupEventPointToWire(body.LiftPoint), + ResolutionEvidenceHash: frostActivationHex32(body.ResolutionEvidenceHash), + ResolutionFinality: frostRetainedGroupFinalityToWire( + body.ResolutionFinality, + ), + NotBeforeBlock: body.NotBeforeBlock, + ExpiresAtBlock: body.ExpiresAtBlock, + }, + BodyHash: frostActivationHex32(certificate.BodyHash), + Signatures: signatures, + } +} + +func frostRetainedGroupLiftCertificateFromWire( + certificate *frostRetainedGroupWireQuarantineLiftCertificate, +) (*FrostRetainedGroupQuarantineLiftCertificate, error) { + if certificate == nil { + return nil, nil + } + body := certificate.Body + parse := func(name string, value string) ([32]byte, error) { + parsed, err := parseFrostActivationHex32(value) + if err != nil { + return [32]byte{}, fmt.Errorf( + "invalid FROST quarantine lift %s: [%w]", + name, + err, + ) + } + return parsed, nil + } + protocolBindingHash, err := parse( + "protocol binding hash", + body.ProtocolBindingHash, + ) + if err != nil { + return nil, err + } + manifestHash, err := parse("manifest hash", body.ManifestHash) + if err != nil { + return nil, err + } + profileHash, err := parse("profile hash", body.ProfileHash) + if err != nil { + return nil, err + } + implementationSetHash, err := parse( + "implementation set hash", + body.ImplementationSetHash, + ) + if err != nil { + return nil, err + } + domainChainID, err := parse("domain chain ID", body.DomainChainID) + if err != nil { + return nil, err + } + genesisBlockHash, err := parse("genesis block hash", body.GenesisBlockHash) + if err != nil { + return nil, err + } + quarantineProtocolID, err := parse( + "quarantine protocol ID", + body.QuarantineProtocolID, + ) + if err != nil { + return nil, err + } + liftProtocolID, err := parse("lift protocol ID", body.LiftProtocolID) + if err != nil { + return nil, err + } + tombstoneProtocolID, err := parse( + "tombstone protocol ID", + body.TombstoneProtocolID, + ) + if err != nil { + return nil, err + } + authoritySetHash, err := parse("authority set hash", body.AuthoritySetHash) + if err != nil { + return nil, err + } + quarantineID, err := parse("quarantine ID", body.QuarantineID) + if err != nil { + return nil, err + } + walletID, err := parse("wallet ID", body.WalletID) + if err != nil { + return nil, err + } + raisedQuarantineID, err := parse( + "raised quarantine ID", + body.OriginalRaisedRecord.QuarantineID, + ) + if err != nil { + return nil, err + } + raisedWalletID, err := parse( + "raised wallet ID", + body.OriginalRaisedRecord.WalletID, + ) + if err != nil { + return nil, err + } + raisedEvidenceHash, err := parse( + "raised evidence hash", + body.OriginalRaisedRecord.EvidenceHash, + ) + if err != nil { + return nil, err + } + raisedAt, err := frostRetainedGroupEventPointFromWire( + body.OriginalRaisedRecord.RaisedAt, + ) + if err != nil { + return nil, fmt.Errorf("invalid FROST quarantine raised point: [%w]", err) + } + priorEventRoot, err := parse("prior event root", body.PriorEventRoot) + if err != nil { + return nil, err + } + priorActiveRoot, err := parse("prior active root", body.PriorActiveRoot) + if err != nil { + return nil, err + } + priorTombstoneRoot, err := parse( + "prior tombstone root", + body.PriorTombstoneRoot, + ) + if err != nil { + return nil, err + } + liftPoint, err := frostRetainedGroupEventPointFromWire(body.LiftPoint) + if err != nil { + return nil, fmt.Errorf("invalid FROST quarantine lift point: [%w]", err) + } + resolutionEvidenceHash, err := parse( + "resolution evidence hash", + body.ResolutionEvidenceHash, + ) + if err != nil { + return nil, err + } + resolutionFinality, err := frostRetainedGroupFinalityFromWire( + body.ResolutionFinality, + ) + if err != nil { + return nil, fmt.Errorf( + "invalid FROST quarantine resolution finality: [%w]", + err, + ) + } + bodyHash, err := parse("body hash", certificate.BodyHash) + if err != nil { + return nil, err + } + signatures := make( + []FrostRetainedGroupQuarantineLiftSignature, + len(certificate.Signatures), + ) + for index, signature := range certificate.Signatures { + signatures[index] = FrostRetainedGroupQuarantineLiftSignature{ + AuthorityID: signature.AuthorityID, + SignerPublicKeySPKI: signature.SignerPublicKeySPKI, + Signature: signature.Signature, + } + } + return &FrostRetainedGroupQuarantineLiftCertificate{ + Schema: certificate.Schema, + Body: FrostRetainedGroupQuarantineLiftBody{ + Schema: body.Schema, + ProtocolBindingHash: protocolBindingHash, + ManifestHash: manifestHash, + ProfileHash: profileHash, + ImplementationSetHash: implementationSetHash, + ChainID: body.ChainID, + DomainChainID: domainChainID, + GenesisBlockHash: genesisBlockHash, + QuarantineProtocolID: quarantineProtocolID, + LiftProtocolID: liftProtocolID, + TombstoneProtocolID: tombstoneProtocolID, + AuthoritySetHash: authoritySetHash, + QuarantineID: quarantineID, + WalletID: walletID, + OriginalRaisedRecord: FrostRetainedGroupQuarantineRaisedRecord{ + QuarantineID: raisedQuarantineID, + WalletID: raisedWalletID, + EvidenceHash: raisedEvidenceHash, + Reason: body.OriginalRaisedRecord.Reason, + RecoveryRequired: body.OriginalRaisedRecord.RecoveryRequired, + RaisedAt: raisedAt, + }, + PriorGeneration: body.PriorGeneration, + PriorEventRoot: priorEventRoot, + PriorActiveRoot: priorActiveRoot, + PriorTombstoneRoot: priorTombstoneRoot, + LiftPoint: liftPoint, + ResolutionEvidenceHash: resolutionEvidenceHash, + ResolutionFinality: resolutionFinality, + NotBeforeBlock: body.NotBeforeBlock, + ExpiresAtBlock: body.ExpiresAtBlock, + }, + BodyHash: bodyHash, + Signatures: signatures, + }, nil +} + func parseFrostRetainedGroupHex20(value string) ([20]byte, error) { if len(value) != 42 || !strings.HasPrefix(value, "0x") || value != strings.ToLower(value) { return [20]byte{}, fmt.Errorf("value is not canonical bytes20") @@ -1393,6 +1723,22 @@ func addFrostRetainedGroupMutationBlockHashes( } blocks[point.BlockNumber] = point.BlockHash } + if mutation.LiftCertificate != nil { + finality := mutation.LiftCertificate.Body.ResolutionFinality + if finality.BlockNumber == 0 || finality.BlockHash == [32]byte{} { + return fmt.Errorf( + "retained-group quarantine lift resolution finality is invalid", + ) + } + if existing, ok := blocks[finality.BlockNumber]; ok && + existing != finality.BlockHash { + return fmt.Errorf( + "retained-group history contains conflicting hashes for block [%d]", + finality.BlockNumber, + ) + } + blocks[finality.BlockNumber] = finality.BlockHash + } return nil } diff --git a/pkg/tbtc/frost_retained_group_history_source_test.go b/pkg/tbtc/frost_retained_group_history_source_test.go index 5d3f7d006c..ced69de5e6 100644 --- a/pkg/tbtc/frost_retained_group_history_source_test.go +++ b/pkg/tbtc/frost_retained_group_history_source_test.go @@ -526,8 +526,21 @@ func frostRetainedGroupHistoryTestProfile( if err != nil { t.Fatal(err) } + checkpointAuthorities := []FrostRetainedGroupAuthority{ + {AuthorityID: "checkpoint-1", PublicKeySPKIHash: [32]byte{0x51}}, + {AuthorityID: "checkpoint-2", PublicKeySPKIHash: [32]byte{0x52}}, + {AuthorityID: "checkpoint-3", PublicKeySPKIHash: [32]byte{0x53}}, + } + liftAuthorities := []FrostRetainedGroupAuthority{ + {AuthorityID: "lift-1", PublicKeySPKIHash: [32]byte{0x54}}, + {AuthorityID: "lift-2", PublicKeySPKIHash: [32]byte{0x55}}, + {AuthorityID: "lift-3", PublicKeySPKIHash: [32]byte{0x56}}, + } return profile, FrostPreSignActivationRuntimeManifest{ ManifestHash: profile.ActivationManifestHash, + ActivationAuthorityKeyHash: [32]byte{0x35}, + VerifierOperatorFingerprint: [32]byte{0x36}, + HandshakeOperatorFingerprint: [32]byte{0x38}, DomainChainID: profile.DomainChainID, GenesisBlockHash: [32]byte{0x39}, ProfileHash: profile.ProfileHash, @@ -539,6 +552,7 @@ func frostRetainedGroupHistoryTestProfile( ReservationProtocolID: profile.ReservationProtocolID, BitcoinOutboxProtocolID: [32]byte{0x46}, SigningPolicyHash: profile.SigningPolicyHash, + AttestationSignerKeyHash: [32]byte{0x37}, RetainedGroupInventoryProtocolID: [32]byte{0x43}, CanonicalJournal: FrostRetainedGroupCanonicalJournalManifest{ StoreID: "canonical-test-store", @@ -550,10 +564,16 @@ func frostRetainedGroupHistoryTestProfile( SourceEndpointFingerprint: [32]byte{0x31}, }, QuarantineJournal: FrostRetainedGroupQuarantineJournalManifest{ - ProtocolID: [32]byte{0x44}, - StoreID: "quarantine-test-store", - StoreFingerprint: [32]byte{0x49}, - ClusterFingerprint: [32]byte{0x4a}, + ProtocolID: [32]byte{0x44}, + LiftProtocolID: [32]byte{0x4b}, + TombstoneProtocolID: [32]byte{0x4c}, + CheckpointAuthorityThreshold: 2, + CheckpointAuthorities: checkpointAuthorities, + LiftAuthorityThreshold: 2, + LiftAuthorities: liftAuthorities, + StoreID: "quarantine-test-store", + StoreFingerprint: [32]byte{0x49}, + ClusterFingerprint: [32]byte{0x4a}, }, } } @@ -1129,11 +1149,52 @@ func TestSignedFrostRetainedGroupHistorySource_ProtocolBindingCommitsRuntime( ) { runtime.QuarantineJournal.StoreFingerprint[0] ^= 0xff }, + "quarantine protocol": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.ProtocolID[0] ^= 0xff + }, + "quarantine lift protocol": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.LiftProtocolID[0] ^= 0xff + }, + "quarantine tombstone protocol": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.TombstoneProtocolID[0] ^= 0xff + }, + "checkpoint authority set": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.CheckpointAuthorities[0]. + PublicKeySPKIHash[0] ^= 0xff + }, + "lift authority set": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.LiftAuthorities[0]. + PublicKeySPKIHash[0] ^= 0xff + }, } for name, mutate := range testCases { t.Run(name, func(t *testing.T) { profile := fixture.profile runtime := fixture.runtimeManifest + runtime.QuarantineJournal.CheckpointAuthorities = append( + []FrostRetainedGroupAuthority{}, + fixture.runtimeManifest.QuarantineJournal. + CheckpointAuthorities..., + ) + runtime.QuarantineJournal.LiftAuthorities = append( + []FrostRetainedGroupAuthority{}, + fixture.runtimeManifest.QuarantineJournal.LiftAuthorities..., + ) mutate(&profile, &runtime) binding, err := fixture.source.computeProtocolBinding(profile, runtime) if err != nil { diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index f4f95db2a0..b3e775bd53 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -3,8 +3,12 @@ package tbtc import ( "bytes" "context" + "crypto/ed25519" "crypto/rand" "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" "encoding/hex" "encoding/json" "errors" @@ -24,23 +28,24 @@ import ( ) const ( - frostRetainedGroupJournalMetadataSchema = "tbtc-frost-retained-group-journal-metadata/v2" - frostRetainedGroupJournalBatchSchema = "tbtc-frost-retained-group-journal-batch/v2" - frostRetainedGroupJournalStateSchema = "tbtc-frost-retained-group-journal-state/v2" - frostRetainedGroupJournalSnapshotSchema = "tbtc-frost-retained-group-journal-snapshot/v2" + frostRetainedGroupJournalMetadataSchema = "tbtc-frost-retained-group-journal-metadata/v3" + frostRetainedGroupJournalBatchSchema = "tbtc-frost-retained-group-journal-batch/v3" + frostRetainedGroupJournalStateSchema = "tbtc-frost-retained-group-journal-state/v3" + frostRetainedGroupJournalSnapshotSchema = "tbtc-frost-retained-group-journal-snapshot/v3" frostRetainedGroupJournalLockFile = ".lock" frostRetainedGroupJournalMetadataFile = "metadata.json" frostRetainedGroupJournalStateFile = "state.json" frostRetainedGroupJournalBatchPrefix = "batch-" + frostRetainedGroupLiftCertificatePrefix = "lift-certificate-" frostRetainedGroupJournalFileSuffix = ".json" frostRetainedGroupJournalTempSuffix = ".tmp" frostRetainedGroupJournalMaximumFile = 8 * 1024 * 1024 frostRetainedGroupCanonicalDirectory = "canonical" frostRetainedGroupQuarantineDirectory = "quarantine" - frostRetainedGroupQuarantineMetadataSchema = "tbtc-frost-retained-group-quarantine-metadata/v2" - frostRetainedGroupQuarantineBatchSchema = "tbtc-frost-retained-group-quarantine-batch/v2" - frostRetainedGroupQuarantineStateSchema = "tbtc-frost-retained-group-quarantine-state/v2" + frostRetainedGroupQuarantineMetadataSchema = "tbtc-frost-retained-group-quarantine-metadata/v3" + frostRetainedGroupQuarantineBatchSchema = "tbtc-frost-retained-group-quarantine-batch/v3" + frostRetainedGroupQuarantineStateSchema = "tbtc-frost-retained-group-quarantine-state/v3" frostRetainedGroupJournalMetadataSchemaV1 = "tbtc-frost-retained-group-journal-metadata/v1" frostRetainedGroupJournalBatchSchemaV1 = "tbtc-frost-retained-group-journal-batch/v1" @@ -48,14 +53,31 @@ const ( frostRetainedGroupQuarantineMetadataV1 = "tbtc-frost-retained-group-quarantine-metadata/v1" frostRetainedGroupQuarantineBatchV1 = "tbtc-frost-retained-group-quarantine-batch/v1" frostRetainedGroupQuarantineStateV1 = "tbtc-frost-retained-group-quarantine-state/v1" + frostRetainedGroupJournalMetadataSchemaV2 = "tbtc-frost-retained-group-journal-metadata/v2" + frostRetainedGroupJournalBatchSchemaV2 = "tbtc-frost-retained-group-journal-batch/v2" + frostRetainedGroupJournalStateSchemaV2 = "tbtc-frost-retained-group-journal-state/v2" + frostRetainedGroupQuarantineMetadataV2 = "tbtc-frost-retained-group-quarantine-metadata/v2" + frostRetainedGroupQuarantineBatchV2 = "tbtc-frost-retained-group-quarantine-batch/v2" + frostRetainedGroupQuarantineStateV2 = "tbtc-frost-retained-group-quarantine-state/v2" frostRetainedGroupInventoryEntriesDomain = "tbtc-p2tr-frost-wallet-group-inventory-entries-v1\x00" frostRetainedGroupInventoryLeafDomain = "tbtc-p2tr-frost-wallet-group-inventory-leaf-v1\x00" frostRetainedGroupInventoryNodeDomain = "tbtc-p2tr-frost-wallet-group-inventory-node-v1\x00" frostRetainedGroupInventoryRootDomain = "tbtc-p2tr-frost-wallet-group-inventory-root-v1\x00" - frostRetainedGroupBatchDomain = "tbtc-frost-retained-group-journal-batch-v2\x00" - frostRetainedGroupQuarantineBatchDomain = "tbtc-frost-retained-group-quarantine-batch-v2\x00" - frostRetainedGroupQuarantineDomain = "tbtc-frost-retained-group-quarantine-v2\x00" + frostRetainedGroupBatchDomain = "tbtc-frost-retained-group-journal-batch-v3\x00" + frostRetainedGroupQuarantineBatchDomain = "tbtc-frost-retained-group-quarantine-batch-v3\x00" + frostRetainedGroupQuarantineDomain = "tbtc-frost-retained-group-quarantine-event-v3\x00" + frostRetainedGroupQuarantineActiveDomain = "tbtc-frost-retained-group-quarantine-active-root-v1\x00" + frostRetainedGroupTombstoneRootDomain = "tbtc-frost-retained-group-quarantine-tombstone-root-v1\x00" + frostRetainedGroupLiftAuthorityDomain = "tbtc-frost-retained-group-quarantine-lift-authority-set-v1\x00" + frostRetainedGroupLiftBodyDomain = "tbtc-frost-retained-group-quarantine-lift-body-v1\x00" + frostRetainedGroupLiftSignatureDomain = "tbtc-frost-retained-group-quarantine-lift-signature-v1\x00" + frostRetainedGroupLiftCertificateDomain = "tbtc-frost-retained-group-quarantine-lift-certificate-v1\x00" + + frostRetainedGroupLiftAuthoritySetSchema = "tbtc-frost-retained-group-quarantine-lift-authority-set/v1" + frostRetainedGroupLiftBodySchema = "tbtc-frost-retained-group-quarantine-lift-body/v1" + frostRetainedGroupLiftCertificateSchema = "tbtc-frost-retained-group-quarantine-lift-certificate/v1" + frostRetainedGroupMaximumCanonicalJSONInteger uint64 = 9007199254740991 ) // FrostRetainedGroupEventPoint identifies one canonical Ethereum log. The @@ -123,29 +145,98 @@ const ( FrostRetainedGroupTerminated FrostRetainedGroupLifecycle = "Terminated" ) +const ( + frostRetainedGroupQuarantineActive = "active" + frostRetainedGroupQuarantineLifted = "lifted" +) + func (frgl FrostRetainedGroupLifecycle) terminal() bool { return frgl == FrostRetainedGroupClosed || frgl == FrostRetainedGroupTerminated } // FrostRetainedGroupMutation is one complete source-authenticated semantic // history item. Admission carries exact ordered DKG operator IDs. A lift is -// accepted only with a nonzero authentication commitment for its exact ID. +// accepted only with a manifest-pinned quorum certificate for the exact +// durable quarantine state it resolves. type FrostRetainedGroupMutation struct { - Point FrostRetainedGroupEventPoint `json:"point"` - Kind FrostRetainedGroupMutationKind `json:"kind"` - WalletID [32]byte `json:"walletID"` - WalletPublicKeyHash [20]byte `json:"walletPublicKeyHash"` - OperatorIDs []uint32 `json:"operatorIDs,omitempty"` - RetainedGroupHash [32]byte `json:"retainedGroupHash,omitempty"` - DkgResultHash [32]byte `json:"dkgResultHash,omitempty"` - DkgSubmissionPoint FrostRetainedGroupEventPoint `json:"dkgSubmissionPoint,omitempty"` - DkgApprovalPoint FrostRetainedGroupEventPoint `json:"dkgApprovalPoint,omitempty"` - CreationPoint FrostRetainedGroupEventPoint `json:"creationPoint,omitempty"` - BridgeRegistrationPoint FrostRetainedGroupEventPoint `json:"bridgeRegistrationPoint,omitempty"` - QuarantineID [32]byte `json:"quarantineID,omitempty"` - EvidenceHash [32]byte `json:"evidenceHash,omitempty"` - AuthenticationHash [32]byte `json:"authenticationHash,omitempty"` - Reason string `json:"reason,omitempty"` + Point FrostRetainedGroupEventPoint `json:"point"` + Kind FrostRetainedGroupMutationKind `json:"kind"` + WalletID [32]byte `json:"walletID"` + WalletPublicKeyHash [20]byte `json:"walletPublicKeyHash"` + OperatorIDs []uint32 `json:"operatorIDs,omitempty"` + RetainedGroupHash [32]byte `json:"retainedGroupHash,omitempty"` + DkgResultHash [32]byte `json:"dkgResultHash,omitempty"` + DkgSubmissionPoint FrostRetainedGroupEventPoint `json:"dkgSubmissionPoint,omitempty"` + DkgApprovalPoint FrostRetainedGroupEventPoint `json:"dkgApprovalPoint,omitempty"` + CreationPoint FrostRetainedGroupEventPoint `json:"creationPoint,omitempty"` + BridgeRegistrationPoint FrostRetainedGroupEventPoint `json:"bridgeRegistrationPoint,omitempty"` + QuarantineID [32]byte `json:"quarantineID,omitempty"` + EvidenceHash [32]byte `json:"evidenceHash,omitempty"` + LiftCertificateHash [32]byte `json:"liftCertificateHash,omitempty"` + LiftCertificate *FrostRetainedGroupQuarantineLiftCertificate `json:"liftCertificate,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// FrostRetainedGroupAuthority is one manifest-pinned Ed25519 authority. +// AuthorityID is the stable, human-auditable identity used to order both the +// manifest set and certificate signatures. PublicKeySPKIHash pins the exact +// DER SubjectPublicKeyInfo supplied by a lift certificate. +type FrostRetainedGroupAuthority struct { + AuthorityID string `json:"authorityID"` + PublicKeySPKIHash [32]byte `json:"publicKeySpkiHash"` +} + +type FrostRetainedGroupQuarantineRaisedRecord struct { + QuarantineID [32]byte `json:"quarantineID"` + WalletID [32]byte `json:"walletID"` + EvidenceHash [32]byte `json:"evidenceHash"` + Reason string `json:"reason"` + RecoveryRequired bool `json:"recoveryRequired"` + RaisedAt FrostRetainedGroupEventPoint `json:"raisedAt"` +} + +// FrostRetainedGroupQuarantineLiftBody binds a lift to the signed production +// deployment and to the exact durable state immediately preceding the lift. +// NotBeforeBlock and ExpiresAtBlock are inclusive canonical Ethereum block +// bounds; wall-clock time is deliberately excluded. +type FrostRetainedGroupQuarantineLiftBody struct { + Schema string `json:"schema"` + ProtocolBindingHash [32]byte `json:"protocolBindingHash"` + ManifestHash [32]byte `json:"manifestHash"` + ProfileHash [32]byte `json:"profileHash"` + ImplementationSetHash [32]byte `json:"implementationSetHash"` + ChainID uint64 `json:"chainID"` + DomainChainID [32]byte `json:"domainChainID"` + GenesisBlockHash [32]byte `json:"genesisBlockHash"` + QuarantineProtocolID [32]byte `json:"quarantineProtocolID"` + LiftProtocolID [32]byte `json:"liftProtocolID"` + TombstoneProtocolID [32]byte `json:"tombstoneProtocolID"` + AuthoritySetHash [32]byte `json:"authoritySetHash"` + QuarantineID [32]byte `json:"quarantineID"` + WalletID [32]byte `json:"walletID"` + OriginalRaisedRecord FrostRetainedGroupQuarantineRaisedRecord `json:"originalRaisedRecord"` + PriorGeneration uint64 `json:"priorGeneration"` + PriorEventRoot [32]byte `json:"priorEventRoot"` + PriorActiveRoot [32]byte `json:"priorActiveRoot"` + PriorTombstoneRoot [32]byte `json:"priorTombstoneRoot"` + LiftPoint FrostRetainedGroupEventPoint `json:"liftPoint"` + ResolutionEvidenceHash [32]byte `json:"resolutionEvidenceHash"` + ResolutionFinality FrostPreSignFinality `json:"resolutionFinality"` + NotBeforeBlock uint64 `json:"notBeforeBlock"` + ExpiresAtBlock uint64 `json:"expiresAtBlock"` +} + +type FrostRetainedGroupQuarantineLiftSignature struct { + AuthorityID string `json:"authorityID"` + SignerPublicKeySPKI string `json:"signerPublicKeySpki"` + Signature string `json:"signature"` +} + +type FrostRetainedGroupQuarantineLiftCertificate struct { + Schema string `json:"schema"` + Body FrostRetainedGroupQuarantineLiftBody `json:"body"` + BodyHash [32]byte `json:"bodyHash"` + Signatures []FrostRetainedGroupQuarantineLiftSignature `json:"signatures"` } type FrostRetainedGroupHistoryIdentity struct { @@ -197,11 +288,472 @@ type FrostRetainedGroupCanonicalJournalManifest struct { } type FrostRetainedGroupQuarantineJournalManifest struct { - ProtocolID [32]byte - StoreID string - StoreFingerprint [32]byte - ClusterFingerprint [32]byte - MinimumGeneration uint64 + ProtocolID [32]byte + LiftProtocolID [32]byte + TombstoneProtocolID [32]byte + CheckpointAuthorityThreshold uint64 + CheckpointAuthorities []FrostRetainedGroupAuthority + LiftAuthorityThreshold uint64 + LiftAuthorities []FrostRetainedGroupAuthority + StoreID string + StoreFingerprint [32]byte + ClusterFingerprint [32]byte + MinimumGeneration uint64 +} + +type frostRetainedGroupQuarantineLiftPolicy struct { + ProtocolBindingHash [32]byte + ManifestHash [32]byte + ProfileHash [32]byte + ImplementationSetHash [32]byte + ChainID uint64 + DomainChainID [32]byte + GenesisBlockHash [32]byte + QuarantineProtocolID [32]byte + LiftProtocolID [32]byte + TombstoneProtocolID [32]byte + AuthoritySetHash [32]byte + AuthorityThreshold uint64 + Authorities []FrostRetainedGroupAuthority +} + +func frostRetainedGroupLiftAuthoritySetHash( + threshold uint64, + authorities []FrostRetainedGroupAuthority, +) ([32]byte, error) { + return frostRetainedGroupAuthoritySetHash( + frostRetainedGroupLiftAuthoritySetSchema, + threshold, + authorities, + ) +} + +func frostRetainedGroupAuthoritySetHash( + schema string, + threshold uint64, + authorities []FrostRetainedGroupAuthority, +) ([32]byte, error) { + if strings.TrimSpace(schema) == "" || threshold < 2 || len(authorities) < 3 || + threshold > uint64(len(authorities)) || + threshold <= uint64(len(authorities))/2 { + return [32]byte{}, fmt.Errorf( + "FROST retained-group authority set is not a production strict majority", + ) + } + seenHashes := make(map[[32]byte]bool, len(authorities)) + previousID := "" + for index, authority := range authorities { + if !validFrostRetainedGroupAuthorityID(authority.AuthorityID) || + (index > 0 && authority.AuthorityID <= previousID) || + authority.PublicKeySPKIHash == [32]byte{} || + seenHashes[authority.PublicKeySPKIHash] { + return [32]byte{}, fmt.Errorf( + "FROST retained-group authority set is not strictly sorted and unique", + ) + } + previousID = authority.AuthorityID + seenHashes[authority.PublicKeySPKIHash] = true + } + type wireAuthority struct { + AuthorityID string `json:"authorityID"` + PublicKeySPKIHash string `json:"publicKeySpkiHash"` + } + wireAuthorities := make([]wireAuthority, len(authorities)) + for index, authority := range authorities { + wireAuthorities[index] = wireAuthority{ + AuthorityID: authority.AuthorityID, + PublicKeySPKIHash: frostActivationHex32(authority.PublicKeySPKIHash), + } + } + commitment := struct { + Schema string `json:"schema"` + Threshold uint64 `json:"threshold"` + Authorities []wireAuthority `json:"authorities"` + }{ + Schema: schema, + Threshold: threshold, + Authorities: wireAuthorities, + } + payload, err := frostRetainedGroupCanonicalValue(commitment) + if err != nil { + return [32]byte{}, err + } + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupLiftAuthorityDomain)) + hasher.Write(payload) + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result, nil +} + +func validFrostRetainedGroupAuthorityID(value string) bool { + if value == "" || len(value) > 64 { + return false + } + for index := range value { + character := value[index] + if !((character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') || + (index > 0 && (character == '-' || character == '_'))) { + return false + } + } + return true +} + +func frostRetainedGroupLiftPolicyFromRuntimeManifest( + bindingHash [32]byte, + runtimeManifest FrostPreSignActivationRuntimeManifest, +) (frostRetainedGroupQuarantineLiftPolicy, error) { + quarantine := runtimeManifest.QuarantineJournal + liftAuthoritySetHash, err := frostRetainedGroupLiftAuthoritySetHash( + quarantine.LiftAuthorityThreshold, + quarantine.LiftAuthorities, + ) + if err != nil { + return frostRetainedGroupQuarantineLiftPolicy{}, err + } + if _, err := frostRetainedGroupAuthoritySetHash( + "tbtc-frost-retained-group-checkpoint-authority-set/v1", + quarantine.CheckpointAuthorityThreshold, + quarantine.CheckpointAuthorities, + ); err != nil { + return frostRetainedGroupQuarantineLiftPolicy{}, err + } + if bindingHash == [32]byte{} || + runtimeManifest.ManifestHash == [32]byte{} || + runtimeManifest.ProfileHash == [32]byte{} || + runtimeManifest.ImplementationSetHash == [32]byte{} || + runtimeManifest.DomainChainID == [32]byte{} || + runtimeManifest.GenesisBlockHash == [32]byte{} || + quarantine.ProtocolID == [32]byte{} || + quarantine.LiftProtocolID == [32]byte{} || + quarantine.TombstoneProtocolID == [32]byte{} || + quarantine.ProtocolID == quarantine.LiftProtocolID || + quarantine.ProtocolID == quarantine.TombstoneProtocolID || + quarantine.LiftProtocolID == quarantine.TombstoneProtocolID { + return frostRetainedGroupQuarantineLiftPolicy{}, fmt.Errorf( + "FROST retained-group lift policy is incomplete", + ) + } + for _, value := range runtimeManifest.DomainChainID[:24] { + if value != 0 { + return frostRetainedGroupQuarantineLiftPolicy{}, fmt.Errorf( + "FROST retained-group lift chain ID exceeds uint64", + ) + } + } + chainID := binary.BigEndian.Uint64(runtimeManifest.DomainChainID[24:]) + if chainID == 0 { + return frostRetainedGroupQuarantineLiftPolicy{}, fmt.Errorf( + "FROST retained-group lift chain ID is zero", + ) + } + forbidden := map[[32]byte]string{ + runtimeManifest.ActivationAuthorityKeyHash: "activation", + runtimeManifest.AttestationSignerKeyHash: "runtime attestation", + runtimeManifest.HandshakeOperatorFingerprint: "runtime exporter", + runtimeManifest.CanonicalJournal.SourceOperatorFingerprint: "retained history source", + runtimeManifest.VerifierOperatorFingerprint: "history verifier", + } + for hash, role := range forbidden { + if hash == [32]byte{} { + return frostRetainedGroupQuarantineLiftPolicy{}, fmt.Errorf( + "FROST retained-group %s role identity is unavailable", + role, + ) + } + } + for _, authority := range quarantine.CheckpointAuthorities { + if role, exists := forbidden[authority.PublicKeySPKIHash]; exists { + return frostRetainedGroupQuarantineLiftPolicy{}, fmt.Errorf( + "FROST checkpoint authority aliases the %s role", + role, + ) + } + forbidden[authority.PublicKeySPKIHash] = "checkpoint authority" + } + for _, authority := range quarantine.LiftAuthorities { + if role, exists := forbidden[authority.PublicKeySPKIHash]; exists { + return frostRetainedGroupQuarantineLiftPolicy{}, fmt.Errorf( + "FROST quarantine lift authority aliases the %s role", + role, + ) + } + forbidden[authority.PublicKeySPKIHash] = "quarantine lift authority" + } + return frostRetainedGroupQuarantineLiftPolicy{ + ProtocolBindingHash: bindingHash, + ManifestHash: runtimeManifest.ManifestHash, + ProfileHash: runtimeManifest.ProfileHash, + ImplementationSetHash: runtimeManifest.ImplementationSetHash, + ChainID: chainID, + DomainChainID: runtimeManifest.DomainChainID, + GenesisBlockHash: runtimeManifest.GenesisBlockHash, + QuarantineProtocolID: quarantine.ProtocolID, + LiftProtocolID: quarantine.LiftProtocolID, + TombstoneProtocolID: quarantine.TombstoneProtocolID, + AuthoritySetHash: liftAuthoritySetHash, + AuthorityThreshold: quarantine.LiftAuthorityThreshold, + Authorities: append( + []FrostRetainedGroupAuthority{}, + quarantine.LiftAuthorities..., + ), + }, nil +} + +func frostRetainedGroupLiftBodyHash( + body FrostRetainedGroupQuarantineLiftBody, +) ([32]byte, error) { + if body.Schema != frostRetainedGroupLiftBodySchema { + return [32]byte{}, fmt.Errorf( + "unsupported FROST quarantine lift body schema", + ) + } + wireCertificate := frostRetainedGroupLiftCertificateToWire( + &FrostRetainedGroupQuarantineLiftCertificate{Body: body}, + ) + if wireCertificate == nil { + return [32]byte{}, fmt.Errorf( + "cannot project FROST quarantine lift body to its wire representation", + ) + } + payload, err := frostRetainedGroupCanonicalValue(wireCertificate.Body) + if err != nil { + return [32]byte{}, err + } + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupLiftBodyDomain)) + hasher.Write(payload) + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result, nil +} + +func frostRetainedGroupLiftSignatureHash(bodyHash [32]byte) [32]byte { + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupLiftSignatureDomain)) + hasher.Write(bodyHash[:]) + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result +} + +func frostRetainedGroupLiftCertificateHash( + certificate FrostRetainedGroupQuarantineLiftCertificate, +) ([32]byte, error) { + wireCertificate := frostRetainedGroupLiftCertificateToWire(&certificate) + if wireCertificate == nil { + return [32]byte{}, fmt.Errorf( + "cannot project FROST quarantine lift certificate to its wire representation", + ) + } + payload, err := frostRetainedGroupCanonicalValue(wireCertificate) + if err != nil { + return [32]byte{}, err + } + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupLiftCertificateDomain)) + hasher.Write(payload) + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result, nil +} + +func validateFrostRetainedGroupLiftCertificateShape( + policy frostRetainedGroupQuarantineLiftPolicy, + certificate *FrostRetainedGroupQuarantineLiftCertificate, +) ([32]byte, error) { + if certificate == nil || + certificate.Schema != frostRetainedGroupLiftCertificateSchema || + certificate.BodyHash == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate is absent or has an unsupported schema", + ) + } + body := certificate.Body + bodyHash, err := frostRetainedGroupLiftBodyHash(body) + if err != nil || bodyHash != certificate.BodyHash { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate body hash mismatch", + ) + } + if body.ProtocolBindingHash != policy.ProtocolBindingHash || + body.ManifestHash != policy.ManifestHash || + body.ProfileHash != policy.ProfileHash || + body.ImplementationSetHash != policy.ImplementationSetHash || + body.ChainID != policy.ChainID || + body.DomainChainID != policy.DomainChainID || + body.GenesisBlockHash != policy.GenesisBlockHash || + body.QuarantineProtocolID != policy.QuarantineProtocolID || + body.LiftProtocolID != policy.LiftProtocolID || + body.TombstoneProtocolID != policy.TombstoneProtocolID || + body.AuthoritySetHash != policy.AuthoritySetHash { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate differs from the signed production policy", + ) + } + raised := body.OriginalRaisedRecord + if body.QuarantineID == [32]byte{} || + body.WalletID == [32]byte{} || + raised.QuarantineID != body.QuarantineID || + raised.WalletID != body.WalletID || + raised.EvidenceHash == [32]byte{} || + strings.TrimSpace(raised.Reason) == "" || + len(raised.Reason) > frostRetainedGroupMaximumReasonBytes || + !raised.RaisedAt.valid() || + body.PriorGeneration == 0 || + body.PriorEventRoot == [32]byte{} || + body.PriorActiveRoot == [32]byte{} || + body.PriorTombstoneRoot == [32]byte{} || + !body.LiftPoint.valid() || + body.ResolutionEvidenceHash == [32]byte{} || + body.ResolutionFinality.BlockNumber == 0 || + body.ResolutionFinality.BlockHash == [32]byte{} || + body.ResolutionFinality.BlockNumber < raised.RaisedAt.BlockNumber || + (body.ResolutionFinality.BlockNumber == raised.RaisedAt.BlockNumber && + body.ResolutionFinality.BlockHash != raised.RaisedAt.BlockHash) || + body.ResolutionFinality.BlockNumber > body.LiftPoint.BlockNumber || + (body.ResolutionFinality.BlockNumber == body.LiftPoint.BlockNumber && + body.ResolutionFinality.BlockHash != body.LiftPoint.BlockHash) || + body.NotBeforeBlock == 0 || + body.ExpiresAtBlock < body.NotBeforeBlock || + body.LiftPoint.BlockNumber < body.NotBeforeBlock || + body.LiftPoint.BlockNumber > body.ExpiresAtBlock || + body.ResolutionFinality.BlockNumber > body.ExpiresAtBlock || + body.ChainID > frostRetainedGroupMaximumCanonicalJSONInteger || + raised.RaisedAt.BlockNumber > frostRetainedGroupMaximumCanonicalJSONInteger || + body.PriorGeneration > frostRetainedGroupMaximumCanonicalJSONInteger || + body.LiftPoint.BlockNumber > frostRetainedGroupMaximumCanonicalJSONInteger || + body.ResolutionFinality.BlockNumber > frostRetainedGroupMaximumCanonicalJSONInteger || + body.NotBeforeBlock > frostRetainedGroupMaximumCanonicalJSONInteger || + body.ExpiresAtBlock > frostRetainedGroupMaximumCanonicalJSONInteger { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate body is incomplete or outside its canonical block window", + ) + } + if uint64(len(certificate.Signatures)) < policy.AuthorityThreshold || + len(certificate.Signatures) > len(policy.Authorities) { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate does not carry the required quorum", + ) + } + authorityByID := make( + map[string]FrostRetainedGroupAuthority, + len(policy.Authorities), + ) + for _, authority := range policy.Authorities { + authorityByID[authority.AuthorityID] = authority + } + signatureHash := frostRetainedGroupLiftSignatureHash(bodyHash) + previousID := "" + for index, signature := range certificate.Signatures { + if !validFrostRetainedGroupAuthorityID(signature.AuthorityID) || + (index > 0 && signature.AuthorityID <= previousID) { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift signatures are not strictly sorted and unique", + ) + } + previousID = signature.AuthorityID + authority, known := authorityByID[signature.AuthorityID] + if !known { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate contains an unknown authority [%s]", + signature.AuthorityID, + ) + } + if len(signature.SignerPublicKeySPKI) > 2048 || + len(signature.Signature) > 128 { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift authority [%s] credential exceeds its bound", + signature.AuthorityID, + ) + } + publicKeyDER, err := base64.StdEncoding.Strict().DecodeString( + signature.SignerPublicKeySPKI, + ) + if err != nil || len(publicKeyDER) == 0 || len(publicKeyDER) > 1024 || + base64.StdEncoding.EncodeToString(publicKeyDER) != + signature.SignerPublicKeySPKI || + sha256.Sum256(publicKeyDER) != authority.PublicKeySPKIHash { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift authority [%s] supplied an unpinned key", + signature.AuthorityID, + ) + } + parsedPublicKey, err := x509.ParsePKIXPublicKey(publicKeyDER) + if err != nil { + return [32]byte{}, fmt.Errorf( + "cannot parse FROST quarantine lift authority [%s] key: [%w]", + signature.AuthorityID, + err, + ) + } + publicKey, ok := parsedPublicKey.(ed25519.PublicKey) + if !ok || len(publicKey) != ed25519.PublicKeySize { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift authority [%s] key is not Ed25519", + signature.AuthorityID, + ) + } + signatureBytes, err := base64.StdEncoding.Strict().DecodeString( + signature.Signature, + ) + if err != nil || len(signatureBytes) != ed25519.SignatureSize || + base64.StdEncoding.EncodeToString(signatureBytes) != + signature.Signature || + !ed25519.Verify(publicKey, signatureHash[:], signatureBytes) { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift authority [%s] signature is invalid", + signature.AuthorityID, + ) + } + } + certificateHash, err := frostRetainedGroupLiftCertificateHash(*certificate) + if err != nil { + return [32]byte{}, fmt.Errorf( + "cannot hash FROST quarantine lift certificate: [%w]", + err, + ) + } + if certificateHash == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate hash is zero", + ) + } + return certificateHash, nil +} + +func validateFrostRetainedGroupLiftCertificate( + policy frostRetainedGroupQuarantineLiftPolicy, + state frostRetainedGroupQuarantineJournalState, + mutation FrostRetainedGroupMutation, + quarantine frostRetainedGroupQuarantineState, +) ([32]byte, error) { + certificateHash, err := validateFrostRetainedGroupLiftCertificateShape( + policy, + mutation.LiftCertificate, + ) + if err != nil { + return [32]byte{}, err + } + body := mutation.LiftCertificate.Body + if mutation.Kind != FrostRetainedGroupQuarantineLiftMutation || + mutation.QuarantineID != body.QuarantineID || + mutation.WalletID != body.WalletID || + mutation.Point != body.LiftPoint || + mutation.LiftCertificateHash != certificateHash || + quarantine.Status != frostRetainedGroupQuarantineActive || + quarantine.RaisedRecord != body.OriginalRaisedRecord || + state.Generation != body.PriorGeneration || + state.Root != body.PriorEventRoot || + state.ActiveRoot != body.PriorActiveRoot || + state.TombstoneRoot != body.PriorTombstoneRoot { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift certificate does not bind the exact active durable state", + ) + } + return certificateHash, nil } type frostRetainedGroupJournalMetadata struct { @@ -233,12 +785,19 @@ type frostRetainedGroupWalletState struct { } type frostRetainedGroupQuarantineState struct { - QuarantineID [32]byte `json:"quarantineID"` - WalletID [32]byte `json:"walletID"` - EvidenceHash [32]byte `json:"evidenceHash"` - Reason string `json:"reason"` - RecoveryRequired bool `json:"recoveryRequired"` - RaisedAt FrostRetainedGroupEventPoint `json:"raisedAt"` + RaisedRecord FrostRetainedGroupQuarantineRaisedRecord `json:"raisedRecord"` + Status string `json:"status"` + LiftCertificateHash [32]byte `json:"liftCertificateHash,omitempty"` + LiftedAt FrostRetainedGroupEventPoint `json:"liftedAt,omitempty"` +} + +type frostRetainedGroupQuarantineTombstone struct { + QuarantineID [32]byte `json:"quarantineID"` + WalletID [32]byte `json:"walletID"` + LiftCertificateHash [32]byte `json:"liftCertificateHash"` + LiftedAt FrostRetainedGroupEventPoint `json:"liftedAt"` + ResolutionEvidenceHash [32]byte `json:"resolutionEvidenceHash"` + ResolutionFinality FrostPreSignFinality `json:"resolutionFinality"` } type frostRetainedGroupJournalState struct { @@ -264,25 +823,33 @@ type frostRetainedGroupJournalBatch struct { } type frostRetainedGroupQuarantineMetadata struct { - Schema string `json:"schema"` - ManifestHash [32]byte `json:"manifestHash"` - BindingHash [32]byte `json:"bindingHash"` - ProtocolID [32]byte `json:"protocolID"` - StoreID string `json:"storeID"` - StoreFingerprint [32]byte `json:"storeFingerprint"` - ClusterFingerprint [32]byte `json:"clusterFingerprint"` - Checkpoint FrostPreSignFinality `json:"checkpoint"` + Schema string `json:"schema"` + ManifestHash [32]byte `json:"manifestHash"` + BindingHash [32]byte `json:"bindingHash"` + ProtocolID [32]byte `json:"protocolID"` + LiftProtocolID [32]byte `json:"liftProtocolID"` + TombstoneProtocolID [32]byte `json:"tombstoneProtocolID"` + LiftAuthoritySetHash [32]byte `json:"liftAuthoritySetHash"` + LiftAuthorityThreshold uint64 `json:"liftAuthorityThreshold"` + LiftAuthorities []FrostRetainedGroupAuthority `json:"liftAuthorities"` + StoreID string `json:"storeID"` + StoreFingerprint [32]byte `json:"storeFingerprint"` + ClusterFingerprint [32]byte `json:"clusterFingerprint"` + Checkpoint FrostPreSignFinality `json:"checkpoint"` } type frostRetainedGroupQuarantineJournalState struct { - Schema string `json:"schema"` - BindingHash [32]byte `json:"bindingHash"` - BatchSequence uint64 `json:"batchSequence"` - CurrentPoint FrostPreSignFinality `json:"currentPoint"` - Generation uint64 `json:"generation"` - BatchRoot [32]byte `json:"batchRoot"` - Root [32]byte `json:"root"` - Quarantines []frostRetainedGroupQuarantineState `json:"quarantines"` + Schema string `json:"schema"` + BindingHash [32]byte `json:"bindingHash"` + BatchSequence uint64 `json:"batchSequence"` + CurrentPoint FrostPreSignFinality `json:"currentPoint"` + Generation uint64 `json:"generation"` + BatchRoot [32]byte `json:"batchRoot"` + Root [32]byte `json:"root"` + ActiveRoot [32]byte `json:"activeRoot"` + TombstoneRoot [32]byte `json:"tombstoneRoot"` + Quarantines []frostRetainedGroupQuarantineState `json:"quarantines"` + Tombstones []frostRetainedGroupQuarantineTombstone `json:"tombstones"` } type frostRetainedGroupQuarantineJournalBatch struct { @@ -296,6 +863,27 @@ type frostRetainedGroupQuarantineJournalBatch struct { Checksum [32]byte `json:"checksum"` } +type frostRetainedGroupWireQuarantineJournalMutation struct { + Point frostRetainedGroupWireEventPoint `json:"point"` + Kind string `json:"kind"` + WalletID string `json:"walletID"` + QuarantineID string `json:"quarantineID"` + EvidenceHash string `json:"evidenceHash"` + LiftCertificateHash string `json:"liftCertificateHash"` + Reason string `json:"reason"` +} + +type frostRetainedGroupWireQuarantineJournalBatch struct { + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + Sequence uint64 `json:"sequence"` + From frostRetainedGroupWireFinality `json:"from"` + To frostRetainedGroupWireFinality `json:"to"` + PriorBatchRoot string `json:"priorBatchRoot"` + Mutations []frostRetainedGroupWireQuarantineJournalMutation `json:"mutations"` + Checksum string `json:"checksum"` +} + type frostRetainedGroupEnvelope struct { Payload json.RawMessage `json:"payload"` Checksum [32]byte `json:"checksum"` @@ -321,7 +909,10 @@ type frostRetainedGroupJournalSnapshot struct { QuarantineMinimumGeneration uint64 QuarantineGeneration uint64 QuarantineRoot [32]byte + QuarantineActiveRoot [32]byte + QuarantineTombstoneRoot [32]byte QuarantineCount uint64 + QuarantineTombstoneCount uint64 LocalSessionCount uint64 Complete bool } @@ -344,21 +935,34 @@ type frostRetainedGroupJournal struct { quarantineState frostRetainedGroupQuarantineJournalState mutations []FrostRetainedGroupMutation quarantineMutations []FrostRetainedGroupMutation + liftPolicy frostRetainedGroupQuarantineLiftPolicy + liftCertificates map[[32]byte]FrostRetainedGroupQuarantineLiftCertificate persistFailureHook func(string) error closed bool } func newFrostRetainedGroupJournal( directory string, - manifestHash [32]byte, bindingHash [32]byte, - manifest FrostRetainedGroupCanonicalJournalManifest, - quarantineManifest FrostRetainedGroupQuarantineJournalManifest, + runtimeManifest FrostPreSignActivationRuntimeManifest, source FrostRetainedGroupHistorySource, walletRegistry *walletRegistry, operatorAddress chain.Address, ) (*frostRetainedGroupJournal, error) { - if strings.TrimSpace(directory) == "" || manifestHash == [32]byte{} || + manifest := runtimeManifest.CanonicalJournal + quarantineManifest := runtimeManifest.QuarantineJournal + liftPolicy, liftPolicyErr := frostRetainedGroupLiftPolicyFromRuntimeManifest( + bindingHash, + runtimeManifest, + ) + if liftPolicyErr != nil { + return nil, fmt.Errorf( + "invalid FROST retained-group quarantine lift policy: [%w]", + liftPolicyErr, + ) + } + if strings.TrimSpace(directory) == "" || + runtimeManifest.ManifestHash == [32]byte{} || bindingHash == [32]byte{} || strings.TrimSpace(manifest.StoreID) == "" || manifest.StoreFingerprint == [32]byte{} || @@ -369,6 +973,8 @@ func newFrostRetainedGroupJournal( manifest.SourceEndpointFingerprint == [32]byte{} || manifest.SourceOperatorFingerprint == [32]byte{} || quarantineManifest.ProtocolID == [32]byte{} || + quarantineManifest.LiftProtocolID == [32]byte{} || + quarantineManifest.TombstoneProtocolID == [32]byte{} || strings.TrimSpace(quarantineManifest.StoreID) == "" || quarantineManifest.StoreFingerprint == [32]byte{} || quarantineManifest.ClusterFingerprint == [32]byte{} || @@ -421,7 +1027,7 @@ func newFrostRetainedGroupJournal( quarantineDirectory: quarantineDirectory, metadata: frostRetainedGroupJournalMetadata{ Schema: frostRetainedGroupJournalMetadataSchema, - ManifestHash: manifestHash, + ManifestHash: runtimeManifest.ManifestHash, BindingHash: bindingHash, StoreID: manifest.StoreID, StoreFingerprint: manifest.StoreFingerprint, @@ -433,10 +1039,18 @@ func newFrostRetainedGroupJournal( SourceOperatorFingerprint: manifest.SourceOperatorFingerprint, }, quarantineMetadata: frostRetainedGroupQuarantineMetadata{ - Schema: frostRetainedGroupQuarantineMetadataSchema, - ManifestHash: manifestHash, - BindingHash: bindingHash, - ProtocolID: quarantineManifest.ProtocolID, + Schema: frostRetainedGroupQuarantineMetadataSchema, + ManifestHash: runtimeManifest.ManifestHash, + BindingHash: bindingHash, + ProtocolID: quarantineManifest.ProtocolID, + LiftProtocolID: quarantineManifest.LiftProtocolID, + TombstoneProtocolID: quarantineManifest.TombstoneProtocolID, + LiftAuthoritySetHash: liftPolicy.AuthoritySetHash, + LiftAuthorityThreshold: liftPolicy.AuthorityThreshold, + LiftAuthorities: append( + []FrostRetainedGroupAuthority{}, + liftPolicy.Authorities..., + ), StoreID: quarantineManifest.StoreID, StoreFingerprint: quarantineManifest.StoreFingerprint, ClusterFingerprint: quarantineManifest.ClusterFingerprint, @@ -449,6 +1063,8 @@ func newFrostRetainedGroupJournal( operatorAddress: operatorAddress, lockFile: lockFile, quarantineLockFile: quarantineLockFile, + liftPolicy: liftPolicy, + liftCertificates: make(map[[32]byte]FrostRetainedGroupQuarantineLiftCertificate), } if err := journal.initialize(); err != nil { _ = journal.close() @@ -571,7 +1187,8 @@ func (frgj *frostRetainedGroupJournal) initialize() error { if err := frgj.readEnvelope(frostRetainedGroupJournalMetadataFile, &stored); err != nil { return fmt.Errorf("cannot read FROST retained-group journal metadata: [%w]", err) } - if stored.Schema == frostRetainedGroupJournalMetadataSchemaV1 { + if stored.Schema == frostRetainedGroupJournalMetadataSchemaV1 || + stored.Schema == frostRetainedGroupJournalMetadataSchemaV2 { return frostRetainedGroupLegacySchemaError("canonical metadata") } if stored != frgj.metadata { @@ -602,7 +1219,8 @@ func (frgj *frostRetainedGroupJournal) initialize() error { if err := frgj.readEnvelope(frostRetainedGroupJournalStateFile, &stored); err != nil { return fmt.Errorf("cannot read FROST retained-group journal state: [%w]", err) } - if stored.Schema == frostRetainedGroupJournalStateSchemaV1 { + if stored.Schema == frostRetainedGroupJournalStateSchemaV1 || + stored.Schema == frostRetainedGroupJournalStateSchemaV2 { return frostRetainedGroupLegacySchemaError("canonical state") } if stored.Schema != frostRetainedGroupJournalStateSchema { @@ -705,6 +1323,7 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { metadataExists := false stateExists := false batchNames := make([]string, 0) + certificateNames := make([]string, 0) for _, entry := range entries { name := entry.Name() if name == frostRetainedGroupJournalLockFile { @@ -721,6 +1340,9 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { case strings.HasPrefix(name, frostRetainedGroupJournalBatchPrefix) && strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix): batchNames = append(batchNames, name) + case strings.HasPrefix(name, frostRetainedGroupLiftCertificatePrefix) && + strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix): + certificateNames = append(certificateNames, name) case strings.HasSuffix(name, frostRetainedGroupJournalTempSuffix): return fmt.Errorf("interrupted FROST retained-group quarantine temp is present: [%s]", name) default: @@ -737,14 +1359,20 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { ); err != nil { return fmt.Errorf("cannot read FROST retained-group quarantine metadata: [%w]", err) } - if stored.Schema == frostRetainedGroupQuarantineMetadataV1 { + if stored.Schema == frostRetainedGroupQuarantineMetadataV1 || + stored.Schema == frostRetainedGroupQuarantineMetadataV2 { return frostRetainedGroupLegacySchemaError("quarantine metadata") } - if stored != frgj.quarantineMetadata { + storedBytes, storedErr := frostRetainedGroupCanonicalValue(stored) + expectedBytes, expectedErr := frostRetainedGroupCanonicalValue( + frgj.quarantineMetadata, + ) + if storedErr != nil || expectedErr != nil || + !bytes.Equal(storedBytes, expectedBytes) { return fmt.Errorf("FROST retained-group quarantine metadata differs from signed manifest") } } else { - if stateExists || len(batchNames) != 0 { + if stateExists || len(batchNames) != 0 || len(certificateNames) != 0 { return fmt.Errorf("FROST retained-group quarantine journal has state without immutable metadata") } if err := persistFrostRetainedGroupEnvelopeAt( @@ -756,13 +1384,78 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { return fmt.Errorf("cannot persist FROST retained-group quarantine metadata: [%w]", err) } } + for _, name := range certificateNames { + wireCertificate := frostRetainedGroupWireQuarantineLiftCertificate{} + if err := readFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + name, + &wireCertificate, + ); err != nil { + return fmt.Errorf( + "cannot read immutable FROST quarantine lift certificate [%s]: [%w]", + name, + err, + ) + } + certificate, err := frostRetainedGroupLiftCertificateFromWire( + &wireCertificate, + ) + if err != nil || certificate == nil { + return fmt.Errorf( + "cannot decode immutable FROST quarantine lift certificate [%s]: [%v]", + name, + err, + ) + } + certificateHash, err := validateFrostRetainedGroupLiftCertificateShape( + frgj.liftPolicy, + certificate, + ) + if err != nil { + return fmt.Errorf( + "invalid immutable FROST quarantine lift certificate [%s]: [%w]", + name, + err, + ) + } + if name != frostRetainedGroupLiftCertificateFileName(certificateHash) { + return fmt.Errorf( + "immutable FROST quarantine lift certificate filename [%s] does not match its digest", + name, + ) + } + if _, exists := frgj.liftCertificates[certificateHash]; exists { + return fmt.Errorf( + "duplicate immutable FROST quarantine lift certificate [%s]", + name, + ) + } + frgj.liftCertificates[certificateHash] = *certificate + } + emptyActiveRoot, err := frostRetainedGroupQuarantineActiveRoot( + frgj.quarantineMetadata.BindingHash, + map[[32]byte]frostRetainedGroupQuarantineState{}, + ) + if err != nil { + return err + } + emptyTombstoneRoot, err := frostRetainedGroupQuarantineTombstoneRoot( + frgj.quarantineMetadata.BindingHash, + map[[32]byte]frostRetainedGroupQuarantineTombstone{}, + ) + if err != nil { + return err + } initial := frostRetainedGroupQuarantineJournalState{ - Schema: frostRetainedGroupQuarantineStateSchema, - BindingHash: frgj.quarantineMetadata.BindingHash, - CurrentPoint: frgj.quarantineMetadata.Checkpoint, - Root: sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)), - Quarantines: []frostRetainedGroupQuarantineState{}, + Schema: frostRetainedGroupQuarantineStateSchema, + BindingHash: frgj.quarantineMetadata.BindingHash, + CurrentPoint: frgj.quarantineMetadata.Checkpoint, + Root: sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)), + ActiveRoot: emptyActiveRoot, + TombstoneRoot: emptyTombstoneRoot, + Quarantines: []frostRetainedGroupQuarantineState{}, + Tombstones: []frostRetainedGroupQuarantineTombstone{}, } stored := initial if stateExists { @@ -773,7 +1466,8 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { ); err != nil { return fmt.Errorf("cannot read FROST retained-group quarantine state: [%w]", err) } - if stored.Schema == frostRetainedGroupQuarantineStateV1 { + if stored.Schema == frostRetainedGroupQuarantineStateV1 || + stored.Schema == frostRetainedGroupQuarantineStateV2 { return frostRetainedGroupLegacySchemaError("quarantine state") } if stored.Schema != frostRetainedGroupQuarantineStateSchema { @@ -796,18 +1490,40 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { if name != frostRetainedGroupBatchFileName(expectedSequence) { return fmt.Errorf("FROST retained-group quarantine batch sequence has a gap at [%d]", expectedSequence) } - batch := frostRetainedGroupQuarantineJournalBatch{} + wireBatch := frostRetainedGroupWireQuarantineJournalBatch{} if err := readFrostRetainedGroupEnvelopeAt( frgj.quarantineDirectory, name, - &batch, + &wireBatch, ); err != nil { return fmt.Errorf("cannot read FROST retained-group quarantine batch [%d]: [%w]", expectedSequence, err) } + batch, err := frostRetainedGroupQuarantineBatchFromWire( + wireBatch, + frgj.liftCertificates, + ) + if err != nil { + return fmt.Errorf( + "cannot decode FROST retained-group quarantine batch [%d]: [%w]", + expectedSequence, + err, + ) + } if err := validateFrostRetainedGroupQuarantineBatch(batch, rebuilt); err != nil { return fmt.Errorf("invalid FROST retained-group quarantine batch [%d]: [%w]", expectedSequence, err) } - if err := applyFrostRetainedGroupQuarantineMutations(&rebuilt, batch.Mutations); err != nil { + if err := frgj.validatePersistedLiftCertificates(batch.Mutations); err != nil { + return fmt.Errorf( + "invalid persisted FROST quarantine lift certificate in batch [%d]: [%w]", + expectedSequence, + err, + ) + } + if err := applyFrostRetainedGroupQuarantineMutations( + &rebuilt, + batch.Mutations, + frgj.liftPolicy, + ); err != nil { return fmt.Errorf("cannot replay FROST retained-group quarantine batch [%d]: [%w]", expectedSequence, err) } rebuilt.BatchSequence = batch.Sequence @@ -861,10 +1577,301 @@ func frostRetainedGroupBatchFileName(sequence uint64) string { return fmt.Sprintf("%s%020d%s", frostRetainedGroupJournalBatchPrefix, sequence, frostRetainedGroupJournalFileSuffix) } +func frostRetainedGroupLiftCertificateFileName( + certificateHash [32]byte, +) string { + return fmt.Sprintf( + "%s%s%s", + frostRetainedGroupLiftCertificatePrefix, + hex.EncodeToString(certificateHash[:]), + frostRetainedGroupJournalFileSuffix, + ) +} + +func (frgj *frostRetainedGroupJournal) validatePersistedLiftCertificates( + mutations []FrostRetainedGroupMutation, +) error { + for _, mutation := range mutations { + if mutation.Kind != FrostRetainedGroupQuarantineLiftMutation { + continue + } + certificateHash, err := validateFrostRetainedGroupLiftCertificateShape( + frgj.liftPolicy, + mutation.LiftCertificate, + ) + if err != nil { + return err + } + if mutation.LiftCertificateHash != certificateHash { + return fmt.Errorf( + "lift mutation certificate reference does not match its immutable certificate", + ) + } + stored, exists := frgj.liftCertificates[certificateHash] + if !exists { + return fmt.Errorf( + "immutable lift certificate [%x] is absent", + certificateHash, + ) + } + equal, err := equalFrostRetainedGroupLiftCertificates( + stored, + *mutation.LiftCertificate, + ) + if err != nil { + return fmt.Errorf( + "immutable lift certificate [%x] is not byte-identical: [%w]", + certificateHash, + err, + ) + } + if !equal { + return fmt.Errorf( + "immutable lift certificate [%x] is not byte-identical", + certificateHash, + ) + } + } + return nil +} + +func (frgj *frostRetainedGroupJournal) ensureLiftCertificatesPersisted( + mutations []FrostRetainedGroupMutation, +) error { + for _, mutation := range mutations { + if mutation.Kind != FrostRetainedGroupQuarantineLiftMutation { + continue + } + certificateHash, err := validateFrostRetainedGroupLiftCertificateShape( + frgj.liftPolicy, + mutation.LiftCertificate, + ) + if err != nil { + return err + } + if mutation.LiftCertificateHash != certificateHash { + return fmt.Errorf( + "lift mutation certificate reference does not match its immutable certificate", + ) + } + if stored, exists := frgj.liftCertificates[certificateHash]; exists { + equal, err := equalFrostRetainedGroupLiftCertificates( + stored, + *mutation.LiftCertificate, + ) + if err != nil { + return fmt.Errorf( + "immutable lift certificate [%x] conflicts with a persisted certificate: [%w]", + certificateHash, + err, + ) + } + if !equal { + return fmt.Errorf( + "immutable lift certificate [%x] conflicts with a persisted certificate", + certificateHash, + ) + } + continue + } + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + frostRetainedGroupLiftCertificateFileName(certificateHash), + frostRetainedGroupLiftCertificateToWire( + mutation.LiftCertificate, + ), + false, + ); err != nil { + return fmt.Errorf( + "cannot persist immutable FROST quarantine lift certificate [%x]: [%w]", + certificateHash, + err, + ) + } + certificate := *mutation.LiftCertificate + certificate.Signatures = append( + []FrostRetainedGroupQuarantineLiftSignature{}, + mutation.LiftCertificate.Signatures..., + ) + frgj.liftCertificates[certificateHash] = certificate + } + return nil +} + +func equalFrostRetainedGroupLiftCertificates( + left FrostRetainedGroupQuarantineLiftCertificate, + right FrostRetainedGroupQuarantineLiftCertificate, +) (bool, error) { + leftWire := frostRetainedGroupLiftCertificateToWire(&left) + rightWire := frostRetainedGroupLiftCertificateToWire(&right) + leftBytes, err := frostRetainedGroupCanonicalValue(leftWire) + if err != nil { + return false, err + } + rightBytes, err := frostRetainedGroupCanonicalValue(rightWire) + if err != nil { + return false, err + } + return bytes.Equal(leftBytes, rightBytes), nil +} + +func frostRetainedGroupQuarantineBatchToWire( + batch frostRetainedGroupQuarantineJournalBatch, +) (frostRetainedGroupWireQuarantineJournalBatch, error) { + mutations := make( + []frostRetainedGroupWireQuarantineJournalMutation, + len(batch.Mutations), + ) + for index, mutation := range batch.Mutations { + if !isFrostRetainedGroupQuarantineMutation(mutation.Kind) { + return frostRetainedGroupWireQuarantineJournalBatch{}, fmt.Errorf( + "canonical inventory mutation cannot enter a quarantine batch", + ) + } + if mutation.Kind == FrostRetainedGroupQuarantineLiftMutation { + if mutation.LiftCertificateHash == [32]byte{} || + mutation.LiftCertificate == nil { + return frostRetainedGroupWireQuarantineJournalBatch{}, fmt.Errorf( + "quarantine lift batch mutation has no certificate reference", + ) + } + } else if mutation.LiftCertificateHash != [32]byte{} || + mutation.LiftCertificate != nil { + return frostRetainedGroupWireQuarantineJournalBatch{}, fmt.Errorf( + "non-lift quarantine batch mutation carries a certificate reference", + ) + } + mutations[index] = frostRetainedGroupWireQuarantineJournalMutation{ + Point: frostRetainedGroupEventPointToWire(mutation.Point), + Kind: string(mutation.Kind), + WalletID: frostActivationHex32(mutation.WalletID), + QuarantineID: frostActivationHex32(mutation.QuarantineID), + EvidenceHash: frostActivationHex32(mutation.EvidenceHash), + LiftCertificateHash: frostActivationHex32(mutation.LiftCertificateHash), + Reason: mutation.Reason, + } + } + return frostRetainedGroupWireQuarantineJournalBatch{ + Schema: batch.Schema, + BindingHash: frostActivationHex32(batch.BindingHash), + Sequence: batch.Sequence, + From: frostRetainedGroupFinalityToWire(batch.From), + To: frostRetainedGroupFinalityToWire(batch.To), + PriorBatchRoot: frostActivationHex32(batch.PriorBatchRoot), + Mutations: mutations, + Checksum: frostActivationHex32(batch.Checksum), + }, nil +} + +func frostRetainedGroupQuarantineBatchFromWire( + wire frostRetainedGroupWireQuarantineJournalBatch, + certificates map[[32]byte]FrostRetainedGroupQuarantineLiftCertificate, +) (frostRetainedGroupQuarantineJournalBatch, error) { + bindingHash, err := parseFrostActivationHex32(wire.BindingHash) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + from, err := frostRetainedGroupFinalityFromWire(wire.From) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + to, err := frostRetainedGroupFinalityFromWire(wire.To) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + priorBatchRoot, err := parseFrostActivationHex32(wire.PriorBatchRoot) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + checksum, err := parseFrostActivationHex32(wire.Checksum) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + mutations := make([]FrostRetainedGroupMutation, len(wire.Mutations)) + for index, wireMutation := range wire.Mutations { + point, err := frostRetainedGroupEventPointFromWire(wireMutation.Point) + if err != nil || !point.valid() { + return frostRetainedGroupQuarantineJournalBatch{}, fmt.Errorf( + "quarantine batch mutation [%d] point is invalid", + index, + ) + } + walletID, err := parseFrostActivationHex32(wireMutation.WalletID) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + quarantineID, err := parseFrostActivationHex32( + wireMutation.QuarantineID, + ) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + evidenceHash, err := parseFrostActivationHex32(wireMutation.EvidenceHash) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + certificateHash, err := parseFrostActivationHex32( + wireMutation.LiftCertificateHash, + ) + if err != nil { + return frostRetainedGroupQuarantineJournalBatch{}, err + } + mutation := FrostRetainedGroupMutation{ + Point: point, + Kind: FrostRetainedGroupMutationKind(wireMutation.Kind), + WalletID: walletID, + QuarantineID: quarantineID, + EvidenceHash: evidenceHash, + LiftCertificateHash: certificateHash, + Reason: wireMutation.Reason, + } + if mutation.Kind == FrostRetainedGroupQuarantineLiftMutation { + certificate, exists := certificates[certificateHash] + if certificateHash == [32]byte{} || !exists { + return frostRetainedGroupQuarantineJournalBatch{}, fmt.Errorf( + "quarantine lift batch mutation [%d] references an absent certificate", + index, + ) + } + certificate.Signatures = append( + []FrostRetainedGroupQuarantineLiftSignature{}, + certificate.Signatures..., + ) + mutation.LiftCertificate = &certificate + } else if certificateHash != [32]byte{} { + return frostRetainedGroupQuarantineJournalBatch{}, fmt.Errorf( + "non-lift quarantine batch mutation [%d] references a certificate", + index, + ) + } + mutations[index] = mutation + } + return frostRetainedGroupQuarantineJournalBatch{ + Schema: wire.Schema, + BindingHash: bindingHash, + Sequence: wire.Sequence, + From: from, + To: to, + PriorBatchRoot: priorBatchRoot, + Mutations: mutations, + Checksum: checksum, + }, nil +} + +func frostRetainedGroupQuarantineBatchCanonicalValue( + batch frostRetainedGroupQuarantineJournalBatch, +) ([]byte, error) { + wire, err := frostRetainedGroupQuarantineBatchToWire(batch) + if err != nil { + return nil, err + } + return frostRetainedGroupCanonicalValue(wire) +} + func frostRetainedGroupLegacySchemaError(component string) error { return fmt.Errorf( - "legacy FROST retained-group %s schema v1 is not safely migratable; "+ - "the signed activation manifest must provision a new empty v2 store identity", + "prior FROST retained-group %s store schema is not safely migratable; "+ + "the signed activation manifest must provision a new empty v3 store identity", component, ) } @@ -873,7 +1880,8 @@ func validateFrostRetainedGroupBatch( batch frostRetainedGroupJournalBatch, prior frostRetainedGroupJournalState, ) error { - if batch.Schema == frostRetainedGroupJournalBatchSchemaV1 { + if batch.Schema == frostRetainedGroupJournalBatchSchemaV1 || + batch.Schema == frostRetainedGroupJournalBatchSchemaV2 { return frostRetainedGroupLegacySchemaError("canonical batch") } if batch.Schema != frostRetainedGroupJournalBatchSchema || @@ -907,7 +1915,8 @@ func validateFrostRetainedGroupQuarantineBatch( batch frostRetainedGroupQuarantineJournalBatch, prior frostRetainedGroupQuarantineJournalState, ) error { - if batch.Schema == frostRetainedGroupQuarantineBatchV1 { + if batch.Schema == frostRetainedGroupQuarantineBatchV1 || + batch.Schema == frostRetainedGroupQuarantineBatchV2 { return frostRetainedGroupLegacySchemaError("quarantine batch") } if batch.Schema != frostRetainedGroupQuarantineBatchSchema || @@ -919,7 +1928,7 @@ func validateFrostRetainedGroupQuarantineBatch( } declared := batch.Checksum batch.Checksum = [32]byte{} - payload, err := frostRetainedGroupCanonicalValue(batch) + payload, err := frostRetainedGroupQuarantineBatchCanonicalValue(batch) if err != nil { return err } @@ -1126,6 +2135,28 @@ func validateFrostRetainedGroupJournalFileName(name string) error { name == frostRetainedGroupJournalStateFile { return nil } + if strings.HasPrefix(name, frostRetainedGroupLiftCertificatePrefix) { + digestText := strings.TrimSuffix( + strings.TrimPrefix(name, frostRetainedGroupLiftCertificatePrefix), + frostRetainedGroupJournalFileSuffix, + ) + digestBytes, err := hex.DecodeString(digestText) + if err != nil || len(digestBytes) != 32 { + return fmt.Errorf( + "noncanonical FROST retained-group lift certificate name: [%s]", + name, + ) + } + digest := [32]byte{} + copy(digest[:], digestBytes) + if frostRetainedGroupLiftCertificateFileName(digest) != name { + return fmt.Errorf( + "noncanonical FROST retained-group lift certificate name: [%s]", + name, + ) + } + return nil + } if !strings.HasPrefix(name, frostRetainedGroupJournalBatchPrefix) || !strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix) { return fmt.Errorf("unsupported FROST retained-group journal file name: [%s]", name) @@ -1443,6 +2474,7 @@ func frostRetainedGroupCanonicalMutations( func applyFrostRetainedGroupQuarantineMutations( state *frostRetainedGroupQuarantineJournalState, mutations []FrostRetainedGroupMutation, + policy frostRetainedGroupQuarantineLiftPolicy, ) error { if state == nil { return fmt.Errorf("FROST retained-group quarantine state is nil") @@ -1452,11 +2484,66 @@ func applyFrostRetainedGroupQuarantineMutations( len(state.Quarantines), ) for _, quarantine := range state.Quarantines { - if quarantine.QuarantineID == [32]byte{} || - quarantines[quarantine.QuarantineID].QuarantineID != [32]byte{} { + quarantineID := quarantine.RaisedRecord.QuarantineID + if quarantineID == [32]byte{} || + quarantine.RaisedRecord.WalletID == [32]byte{} || + quarantine.RaisedRecord.EvidenceHash == [32]byte{} || + strings.TrimSpace(quarantine.RaisedRecord.Reason) == "" || + !quarantine.RaisedRecord.RaisedAt.valid() || + (quarantine.Status != frostRetainedGroupQuarantineActive && + quarantine.Status != frostRetainedGroupQuarantineLifted) { + return fmt.Errorf("FROST retained-group quarantine state is duplicate or invalid") + } + if _, exists := quarantines[quarantineID]; exists { return fmt.Errorf("FROST retained-group quarantine state is duplicate or invalid") } - quarantines[quarantine.QuarantineID] = quarantine + quarantines[quarantineID] = quarantine + } + tombstones := make( + map[[32]byte]frostRetainedGroupQuarantineTombstone, + len(state.Tombstones), + ) + for _, tombstone := range state.Tombstones { + if tombstone.QuarantineID == [32]byte{} || + tombstone.WalletID == [32]byte{} || + tombstone.LiftCertificateHash == [32]byte{} || + !tombstone.LiftedAt.valid() || + tombstone.ResolutionEvidenceHash == [32]byte{} || + tombstone.ResolutionFinality.BlockNumber == 0 || + tombstone.ResolutionFinality.BlockHash == [32]byte{} { + return fmt.Errorf("FROST retained-group tombstone state is invalid") + } + if _, exists := tombstones[tombstone.QuarantineID]; exists { + return fmt.Errorf("FROST retained-group tombstone state is duplicate") + } + quarantine, exists := quarantines[tombstone.QuarantineID] + if !exists || quarantine.Status != frostRetainedGroupQuarantineLifted || + quarantine.RaisedRecord.WalletID != tombstone.WalletID || + quarantine.LiftCertificateHash != tombstone.LiftCertificateHash || + quarantine.LiftedAt != tombstone.LiftedAt { + return fmt.Errorf("FROST retained-group tombstone does not match its lifted record") + } + tombstones[tombstone.QuarantineID] = tombstone + } + for quarantineID, quarantine := range quarantines { + _, hasTombstone := tombstones[quarantineID] + if (quarantine.Status == frostRetainedGroupQuarantineLifted) != hasTombstone { + return fmt.Errorf("FROST retained-group lifted state and tombstones disagree") + } + } + activeRoot, err := frostRetainedGroupQuarantineActiveRoot( + state.BindingHash, + quarantines, + ) + if err != nil || activeRoot != state.ActiveRoot { + return fmt.Errorf("FROST retained-group active quarantine root mismatch") + } + tombstoneRoot, err := frostRetainedGroupQuarantineTombstoneRoot( + state.BindingHash, + tombstones, + ) + if err != nil || tombstoneRoot != state.TombstoneRoot { + return fmt.Errorf("FROST retained-group quarantine tombstone root mismatch") } var previous FrostRetainedGroupEventPoint for index, mutation := range mutations { @@ -1474,39 +2561,257 @@ func applyFrostRetainedGroupQuarantineMutations( previous = mutation.Point switch mutation.Kind { case FrostRetainedGroupQuarantineMutation, FrostRetainedGroupRecoveryRequiredMutation: - if mutation.QuarantineID == [32]byte{} || mutation.EvidenceHash == [32]byte{} || + if mutation.QuarantineID == [32]byte{} || + mutation.WalletID == [32]byte{} || + mutation.EvidenceHash == [32]byte{} || strings.TrimSpace(mutation.Reason) == "" || - quarantines[mutation.QuarantineID].QuarantineID != [32]byte{} { + mutation.LiftCertificateHash != [32]byte{} || + mutation.LiftCertificate != nil || + !frostRetainedGroupQuarantineMutationInventoryFieldsEmpty(mutation) { return fmt.Errorf("invalid or duplicate FROST retained-group quarantine") } + if _, exists := quarantines[mutation.QuarantineID]; exists { + return fmt.Errorf("invalid or duplicate FROST retained-group quarantine") + } + if _, exists := tombstones[mutation.QuarantineID]; exists { + return fmt.Errorf("FROST retained-group quarantine ID has a permanent tombstone") + } quarantines[mutation.QuarantineID] = frostRetainedGroupQuarantineState{ - QuarantineID: mutation.QuarantineID, - WalletID: mutation.WalletID, - EvidenceHash: mutation.EvidenceHash, - Reason: mutation.Reason, - RecoveryRequired: mutation.Kind == FrostRetainedGroupRecoveryRequiredMutation, - RaisedAt: mutation.Point, + RaisedRecord: FrostRetainedGroupQuarantineRaisedRecord{ + QuarantineID: mutation.QuarantineID, + WalletID: mutation.WalletID, + EvidenceHash: mutation.EvidenceHash, + Reason: mutation.Reason, + RecoveryRequired: mutation.Kind == FrostRetainedGroupRecoveryRequiredMutation, + RaisedAt: mutation.Point, + }, + Status: frostRetainedGroupQuarantineActive, } case FrostRetainedGroupQuarantineLiftMutation: - quarantine := quarantines[mutation.QuarantineID] - if quarantine.QuarantineID == [32]byte{} || mutation.AuthenticationHash == [32]byte{} || - mutation.WalletID != quarantine.WalletID { - return fmt.Errorf("unauthenticated or unknown FROST retained-group quarantine lift") + quarantine, exists := quarantines[mutation.QuarantineID] + if !exists || mutation.EvidenceHash != [32]byte{} || + mutation.Reason != "" || + mutation.LiftCertificateHash == [32]byte{} || + !frostRetainedGroupQuarantineMutationInventoryFieldsEmpty(mutation) { + return fmt.Errorf("unknown or malformed FROST retained-group quarantine lift") + } + if _, exists := tombstones[mutation.QuarantineID]; exists { + return fmt.Errorf("FROST retained-group quarantine lift was already tombstoned") } - delete(quarantines, mutation.QuarantineID) + certificateHash, err := validateFrostRetainedGroupLiftCertificate( + policy, + *state, + mutation, + quarantine, + ) + if err != nil { + return err + } + body := mutation.LiftCertificate.Body + quarantine.Status = frostRetainedGroupQuarantineLifted + quarantine.LiftCertificateHash = certificateHash + quarantine.LiftedAt = mutation.Point + quarantines[mutation.QuarantineID] = quarantine + tombstones[mutation.QuarantineID] = + frostRetainedGroupQuarantineTombstone{ + QuarantineID: mutation.QuarantineID, + WalletID: mutation.WalletID, + LiftCertificateHash: certificateHash, + LiftedAt: mutation.Point, + ResolutionEvidenceHash: body.ResolutionEvidenceHash, + ResolutionFinality: body.ResolutionFinality, + } + } + if err := appendFrostRetainedGroupQuarantineRoot(state, mutation); err != nil { + return err + } + if err := setFrostRetainedGroupQuarantineCollections( + state, + quarantines, + tombstones, + ); err != nil { + return err } - appendFrostRetainedGroupQuarantineRoot(state, mutation) } - state.Quarantines = state.Quarantines[:0] + return setFrostRetainedGroupQuarantineCollections( + state, + quarantines, + tombstones, + ) +} + +func frostRetainedGroupQuarantineMutationInventoryFieldsEmpty( + mutation FrostRetainedGroupMutation, +) bool { + return mutation.WalletPublicKeyHash == [20]byte{} && + len(mutation.OperatorIDs) == 0 && + mutation.RetainedGroupHash == [32]byte{} && + mutation.DkgResultHash == [32]byte{} && + mutation.DkgSubmissionPoint == (FrostRetainedGroupEventPoint{}) && + mutation.DkgApprovalPoint == (FrostRetainedGroupEventPoint{}) && + mutation.CreationPoint == (FrostRetainedGroupEventPoint{}) && + mutation.BridgeRegistrationPoint == (FrostRetainedGroupEventPoint{}) +} + +func setFrostRetainedGroupQuarantineCollections( + state *frostRetainedGroupQuarantineJournalState, + quarantines map[[32]byte]frostRetainedGroupQuarantineState, + tombstones map[[32]byte]frostRetainedGroupQuarantineTombstone, +) error { + state.Quarantines = make( + []frostRetainedGroupQuarantineState, + 0, + len(quarantines), + ) for _, quarantine := range quarantines { state.Quarantines = append(state.Quarantines, quarantine) } sort.Slice(state.Quarantines, func(i, j int) bool { - return bytes.Compare(state.Quarantines[i].QuarantineID[:], state.Quarantines[j].QuarantineID[:]) < 0 + return bytes.Compare( + state.Quarantines[i].RaisedRecord.QuarantineID[:], + state.Quarantines[j].RaisedRecord.QuarantineID[:], + ) < 0 + }) + state.Tombstones = make( + []frostRetainedGroupQuarantineTombstone, + 0, + len(tombstones), + ) + for _, tombstone := range tombstones { + state.Tombstones = append(state.Tombstones, tombstone) + } + sort.Slice(state.Tombstones, func(i, j int) bool { + return bytes.Compare( + state.Tombstones[i].QuarantineID[:], + state.Tombstones[j].QuarantineID[:], + ) < 0 }) + activeRoot, err := frostRetainedGroupQuarantineActiveRoot( + state.BindingHash, + quarantines, + ) + if err != nil { + return err + } + tombstoneRoot, err := frostRetainedGroupQuarantineTombstoneRoot( + state.BindingHash, + tombstones, + ) + if err != nil { + return err + } + state.ActiveRoot = activeRoot + state.TombstoneRoot = tombstoneRoot return nil } +func frostRetainedGroupQuarantineActiveRoot( + bindingHash [32]byte, + quarantines map[[32]byte]frostRetainedGroupQuarantineState, +) ([32]byte, error) { + active := make([]frostRetainedGroupWireQuarantineRaisedRecord, 0) + for _, quarantine := range quarantines { + if quarantine.Status == frostRetainedGroupQuarantineActive { + record := quarantine.RaisedRecord + active = append( + active, + frostRetainedGroupWireQuarantineRaisedRecord{ + QuarantineID: frostActivationHex32(record.QuarantineID), + WalletID: frostActivationHex32(record.WalletID), + EvidenceHash: frostActivationHex32(record.EvidenceHash), + Reason: record.Reason, + RecoveryRequired: record.RecoveryRequired, + RaisedAt: frostRetainedGroupEventPointToWire( + record.RaisedAt, + ), + }, + ) + } + } + sort.Slice(active, func(i, j int) bool { + return active[i].QuarantineID < active[j].QuarantineID + }) + return frostRetainedGroupCollectionRoot( + frostRetainedGroupQuarantineActiveDomain, + bindingHash, + active, + ) +} + +func frostRetainedGroupQuarantineTombstoneRoot( + bindingHash [32]byte, + tombstones map[[32]byte]frostRetainedGroupQuarantineTombstone, +) ([32]byte, error) { + type wireTombstone struct { + QuarantineID string `json:"quarantineID"` + WalletID string `json:"walletID"` + LiftCertificateHash string `json:"liftCertificateHash"` + LiftedAt frostRetainedGroupWireEventPoint `json:"liftedAt"` + ResolutionEvidenceHash string `json:"resolutionEvidenceHash"` + ResolutionFinality frostRetainedGroupWireFinality `json:"resolutionFinality"` + } + ordered := make( + []wireTombstone, + 0, + len(tombstones), + ) + for _, tombstone := range tombstones { + ordered = append(ordered, wireTombstone{ + QuarantineID: frostActivationHex32(tombstone.QuarantineID), + WalletID: frostActivationHex32(tombstone.WalletID), + LiftCertificateHash: frostActivationHex32(tombstone.LiftCertificateHash), + LiftedAt: frostRetainedGroupEventPointToWire(tombstone.LiftedAt), + ResolutionEvidenceHash: frostActivationHex32(tombstone.ResolutionEvidenceHash), + ResolutionFinality: frostRetainedGroupFinalityToWire( + tombstone.ResolutionFinality, + ), + }) + } + sort.Slice(ordered, func(i, j int) bool { + return ordered[i].QuarantineID < ordered[j].QuarantineID + }) + return frostRetainedGroupCollectionRoot( + frostRetainedGroupTombstoneRootDomain, + bindingHash, + ordered, + ) +} + +func frostRetainedGroupCollectionRoot( + domain string, + bindingHash [32]byte, + collection interface{}, +) ([32]byte, error) { + if bindingHash == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "FROST retained-group collection root has an empty protocol binding", + ) + } + payload, err := frostRetainedGroupCanonicalValue(collection) + if err != nil { + return [32]byte{}, err + } + hasher := sha256.New() + hasher.Write([]byte(domain)) + hasher.Write(bindingHash[:]) + hasher.Write(payload) + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result, nil +} + +func frostRetainedGroupActiveQuarantineCount( + state frostRetainedGroupQuarantineJournalState, +) uint64 { + count := uint64(0) + for _, quarantine := range state.Quarantines { + if quarantine.Status == frostRetainedGroupQuarantineActive { + count++ + } + } + return count +} + func validFrostRetainedGroupTransition( current FrostRetainedGroupLifecycle, next FrostRetainedGroupLifecycle, @@ -1537,8 +2842,20 @@ func sameFrostRetainedGroupTransaction( func appendFrostRetainedGroupQuarantineRoot( state *frostRetainedGroupQuarantineJournalState, mutation FrostRetainedGroupMutation, -) { - payload, _ := frostRetainedGroupCanonicalValue(mutation) +) error { + wireMutation := frostRetainedGroupWireQuarantineJournalMutation{ + Point: frostRetainedGroupEventPointToWire(mutation.Point), + Kind: string(mutation.Kind), + WalletID: frostActivationHex32(mutation.WalletID), + QuarantineID: frostActivationHex32(mutation.QuarantineID), + EvidenceHash: frostActivationHex32(mutation.EvidenceHash), + LiftCertificateHash: frostActivationHex32(mutation.LiftCertificateHash), + Reason: mutation.Reason, + } + payload, err := frostRetainedGroupCanonicalValue(wireMutation) + if err != nil { + return err + } leaf := sha256.Sum256(payload) hasher := sha256.New() hasher.Write([]byte(frostRetainedGroupQuarantineDomain)) @@ -1546,6 +2863,7 @@ func appendFrostRetainedGroupQuarantineRoot( hasher.Write(leaf[:]) copy(state.Root[:], hasher.Sum(nil)) state.Generation++ + return nil } func frostRetainedGroupInventoryRoot( @@ -1699,6 +3017,14 @@ func cloneFrostRetainedGroupMutations( copy(result, mutations) for index := range result { result[index].OperatorIDs = append([]uint32{}, mutations[index].OperatorIDs...) + if mutations[index].LiftCertificate != nil { + certificate := *mutations[index].LiftCertificate + certificate.Signatures = append( + []FrostRetainedGroupQuarantineLiftSignature{}, + mutations[index].LiftCertificate.Signatures..., + ) + result[index].LiftCertificate = &certificate + } } return result } @@ -1765,7 +3091,10 @@ func (frgj *frostRetainedGroupJournal) reconcile( if len(history.Mutations) > frostRetainedGroupMaximumMutations { return nil, fmt.Errorf("FROST retained-group history exceeds the mutation limit") } - if err := validateCompleteFrostRetainedGroupHistory(history); err != nil { + if err := validateCompleteFrostRetainedGroupHistory( + history, + frgj.liftPolicy, + ); err != nil { return nil, err } canonicalInventoryMutations := frostRetainedGroupCanonicalMutations(history.Mutations) @@ -1862,9 +3191,32 @@ func (frgj *frostRetainedGroupJournal) reconcile( } if target != frgj.quarantineState.CurrentPoint || len(quarantineSuffix) != 0 { candidate := cloneFrostRetainedGroupQuarantineState(frgj.quarantineState) - if err := applyFrostRetainedGroupQuarantineMutations(&candidate, quarantineSuffix); err != nil { + if err := applyFrostRetainedGroupQuarantineMutations( + &candidate, + quarantineSuffix, + frgj.liftPolicy, + ); err != nil { return nil, err } + hasLift := false + for _, mutation := range quarantineSuffix { + if mutation.Kind == FrostRetainedGroupQuarantineLiftMutation { + hasLift = true + break + } + } + if hasLift { + if err := frgj.ensureLiftCertificatesPersisted(quarantineSuffix); err != nil { + return nil, err + } + if frgj.persistFailureHook != nil { + if err := frgj.persistFailureHook( + "after-quarantine-lift-certificate-before-batch", + ); err != nil { + return nil, err + } + } + } batch := frostRetainedGroupQuarantineJournalBatch{ Schema: frostRetainedGroupQuarantineBatchSchema, BindingHash: frgj.quarantineMetadata.BindingHash, @@ -1874,7 +3226,9 @@ func (frgj *frostRetainedGroupJournal) reconcile( PriorBatchRoot: frgj.quarantineState.BatchRoot, Mutations: quarantineSuffix, } - checksumPayload, err := frostRetainedGroupCanonicalValue(batch) + checksumPayload, err := frostRetainedGroupQuarantineBatchCanonicalValue( + batch, + ) if err != nil { return nil, err } @@ -1885,10 +3239,14 @@ func (frgj *frostRetainedGroupJournal) reconcile( batch.PriorBatchRoot, batch.Checksum, ) + wireBatch, err := frostRetainedGroupQuarantineBatchToWire(batch) + if err != nil { + return nil, err + } if err := persistFrostRetainedGroupEnvelopeAt( frgj.quarantineDirectory, frostRetainedGroupBatchFileName(batch.Sequence), - &batch, + &wireBatch, false, ); err != nil { return nil, fmt.Errorf("cannot append FROST retained-group quarantine batch: [%w]", err) @@ -1940,7 +3298,9 @@ func (frgj *frostRetainedGroupJournal) reconcile( } if frgj.quarantineState.CurrentPoint != target || frgj.quarantineState.Generation < frgj.quarantineMinimumGeneration || - frgj.quarantineState.Root == [32]byte{} { + frgj.quarantineState.Root == [32]byte{} || + frgj.quarantineState.ActiveRoot == [32]byte{} || + frgj.quarantineState.TombstoneRoot == [32]byte{} { return nil, fmt.Errorf("independent FROST quarantine journal is not activation-ready") } return &frostRetainedGroupJournalSnapshot{ @@ -1963,7 +3323,10 @@ func (frgj *frostRetainedGroupJournal) reconcile( QuarantineMinimumGeneration: frgj.quarantineMinimumGeneration, QuarantineGeneration: frgj.quarantineState.Generation, QuarantineRoot: frgj.quarantineState.Root, - QuarantineCount: uint64(len(frgj.quarantineState.Quarantines)), + QuarantineActiveRoot: frgj.quarantineState.ActiveRoot, + QuarantineTombstoneRoot: frgj.quarantineState.TombstoneRoot, + QuarantineCount: frostRetainedGroupActiveQuarantineCount(frgj.quarantineState), + QuarantineTombstoneCount: uint64(len(frgj.quarantineState.Tombstones)), LocalSessionCount: localSessionCount, Complete: true, }, nil @@ -1971,6 +3334,7 @@ func (frgj *frostRetainedGroupJournal) reconcile( func validateCompleteFrostRetainedGroupHistory( history *FrostRetainedGroupHistory, + liftPolicy frostRetainedGroupQuarantineLiftPolicy, ) error { if history == nil { return fmt.Errorf("complete FROST retained-group history is nil") @@ -2002,15 +3366,34 @@ func validateCompleteFrostRetainedGroupHistory( ); err != nil { return fmt.Errorf("complete FROST retained-group history is semantically invalid: [%w]", err) } + emptyActiveRoot, err := frostRetainedGroupQuarantineActiveRoot( + liftPolicy.ProtocolBindingHash, + map[[32]byte]frostRetainedGroupQuarantineState{}, + ) + if err != nil { + return fmt.Errorf("cannot initialize complete FROST quarantine history: [%w]", err) + } + emptyTombstoneRoot, err := frostRetainedGroupQuarantineTombstoneRoot( + liftPolicy.ProtocolBindingHash, + map[[32]byte]frostRetainedGroupQuarantineTombstone{}, + ) + if err != nil { + return fmt.Errorf("cannot initialize complete FROST quarantine history: [%w]", err) + } quarantineProbe := frostRetainedGroupQuarantineJournalState{ - Schema: frostRetainedGroupQuarantineStateSchema, - CurrentPoint: history.From, - Root: sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)), - Quarantines: []frostRetainedGroupQuarantineState{}, + Schema: frostRetainedGroupQuarantineStateSchema, + BindingHash: liftPolicy.ProtocolBindingHash, + CurrentPoint: history.From, + Root: sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)), + ActiveRoot: emptyActiveRoot, + TombstoneRoot: emptyTombstoneRoot, + Quarantines: []frostRetainedGroupQuarantineState{}, + Tombstones: []frostRetainedGroupQuarantineTombstone{}, } if err := applyFrostRetainedGroupQuarantineMutations( &quarantineProbe, frostRetainedGroupQuarantineMutations(history.Mutations), + liftPolicy, ); err != nil { return fmt.Errorf("complete FROST quarantine history is semantically invalid: [%w]", err) } @@ -2036,6 +3419,10 @@ func cloneFrostRetainedGroupQuarantineState( []frostRetainedGroupQuarantineState{}, state.Quarantines..., ) + result.Tombstones = append( + []frostRetainedGroupQuarantineTombstone{}, + state.Tombstones..., + ) return result } diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index 4f1702898a..3f83d17d18 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -4,8 +4,11 @@ import ( "bytes" "context" "crypto/ecdsa" + "crypto/ed25519" "crypto/elliptic" "crypto/sha256" + "crypto/x509" + "encoding/base64" "encoding/binary" "encoding/json" "fmt" @@ -91,21 +94,24 @@ func (jhs *journalHistorySource) ResolveOperatorID( } type journalTestFixture struct { - manifest FrostRetainedGroupCanonicalJournalManifest - quarantine FrostRetainedGroupQuarantineJournalManifest - manifestHash [32]byte - bindingHash [32]byte - checkpoint FrostPreSignFinality - target FrostPreSignFinality - later FrostPreSignFinality - walletID [32]byte - walletPKH [20]byte - operatorIDs []uint32 - operatorAddrs []chain.Address - localOperator chain.Address - registry *walletRegistry - source *journalHistorySource - admission FrostRetainedGroupMutation + manifest FrostRetainedGroupCanonicalJournalManifest + quarantine FrostRetainedGroupQuarantineJournalManifest + runtime FrostPreSignActivationRuntimeManifest + manifestHash [32]byte + bindingHash [32]byte + liftPrivateKeys []ed25519.PrivateKey + liftPublicKeySPKIs []string + checkpoint FrostPreSignFinality + target FrostPreSignFinality + later FrostPreSignFinality + walletID [32]byte + walletPKH [20]byte + operatorIDs []uint32 + operatorAddrs []chain.Address + localOperator chain.Address + registry *walletRegistry + source *journalHistorySource + admission FrostRetainedGroupMutation } func newJournalTestFixture(t *testing.T) *journalTestFixture { @@ -165,11 +171,56 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { SourceOperatorFingerprint: [32]byte{0x35}, MinimumGeneration: 1, } + checkpointAuthorities := make([]FrostRetainedGroupAuthority, 3) + for index := range checkpointAuthorities { + authority, _, _ := journalTestAuthority( + t, + fmt.Sprintf("checkpoint-%d", index+1), + byte(0x60+index), + ) + checkpointAuthorities[index] = authority + } + liftAuthorities := make([]FrostRetainedGroupAuthority, 3) + liftPrivateKeys := make([]ed25519.PrivateKey, 3) + liftPublicKeySPKIs := make([]string, 3) + for index := range liftAuthorities { + authority, privateKey, publicKeySPKI := journalTestAuthority( + t, + fmt.Sprintf("lift-%d", index+1), + byte(0x70+index), + ) + liftAuthorities[index] = authority + liftPrivateKeys[index] = privateKey + liftPublicKeySPKIs[index] = publicKeySPKI + } quarantine := FrostRetainedGroupQuarantineJournalManifest{ - ProtocolID: [32]byte{0x41}, - StoreID: "quarantine-store-uuid", - StoreFingerprint: [32]byte{0x42}, - ClusterFingerprint: [32]byte{0x43}, + ProtocolID: [32]byte{0x41}, + LiftProtocolID: [32]byte{0x45}, + TombstoneProtocolID: [32]byte{0x46}, + CheckpointAuthorityThreshold: 2, + CheckpointAuthorities: checkpointAuthorities, + LiftAuthorityThreshold: 2, + LiftAuthorities: liftAuthorities, + StoreID: "quarantine-store-uuid", + StoreFingerprint: [32]byte{0x42}, + ClusterFingerprint: [32]byte{0x43}, + } + manifestHash := [32]byte{0x42} + bindingHash := [32]byte{0x44} + domainChainID := [32]byte{} + domainChainID[31] = 1 + runtime := FrostPreSignActivationRuntimeManifest{ + ManifestHash: manifestHash, + ActivationAuthorityKeyHash: [32]byte{0x47}, + VerifierOperatorFingerprint: [32]byte{0x48}, + HandshakeOperatorFingerprint: [32]byte{0x4d}, + DomainChainID: domainChainID, + GenesisBlockHash: [32]byte{0x49}, + ProfileHash: [32]byte{0x4a}, + ImplementationSetHash: [32]byte{0x4b}, + AttestationSignerKeyHash: [32]byte{0x4c}, + CanonicalJournal: manifest, + QuarantineJournal: quarantine, } source := &journalHistorySource{ identity: FrostRetainedGroupHistoryIdentity{ @@ -211,21 +262,24 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { } source.mutations = []FrostRetainedGroupMutation{admission} return &journalTestFixture{ - manifest: manifest, - quarantine: quarantine, - manifestHash: [32]byte{0x42}, - bindingHash: [32]byte{0x44}, - checkpoint: checkpoint, - target: target, - later: later, - walletID: walletID, - walletPKH: walletPKH, - operatorIDs: operatorIDs, - operatorAddrs: operatorAddrs, - localOperator: localOperator, - registry: registry, - source: source, - admission: admission, + manifest: manifest, + quarantine: quarantine, + runtime: runtime, + manifestHash: manifestHash, + bindingHash: bindingHash, + liftPrivateKeys: liftPrivateKeys, + liftPublicKeySPKIs: liftPublicKeySPKIs, + checkpoint: checkpoint, + target: target, + later: later, + walletID: walletID, + walletPKH: walletPKH, + operatorIDs: operatorIDs, + operatorAddrs: operatorAddrs, + localOperator: localOperator, + registry: registry, + source: source, + admission: admission, } } @@ -284,6 +338,157 @@ func TestFrostLocalSessionSnapshotBindsExactSignerMaterial(t *testing.T) { } } +func journalTestAuthority( + t *testing.T, + authorityID string, + seedByte byte, +) (FrostRetainedGroupAuthority, ed25519.PrivateKey, string) { + t.Helper() + seed := make([]byte, ed25519.SeedSize) + seed[0] = seedByte + privateKey := ed25519.NewKeyFromSeed(seed) + publicKeyDER, err := x509.MarshalPKIXPublicKey(privateKey.Public()) + if err != nil { + t.Fatal(err) + } + return FrostRetainedGroupAuthority{ + AuthorityID: authorityID, + PublicKeySPKIHash: sha256.Sum256(publicKeyDER), + }, + privateKey, + base64.StdEncoding.EncodeToString(publicKeyDER) +} + +func (fixture *journalTestFixture) liftMutation( + t *testing.T, + journal *frostRetainedGroupJournal, + quarantine FrostRetainedGroupMutation, + point FrostRetainedGroupEventPoint, +) FrostRetainedGroupMutation { + t.Helper() + var raisedRecord FrostRetainedGroupQuarantineRaisedRecord + for _, existing := range journal.quarantineState.Quarantines { + if existing.RaisedRecord.QuarantineID == quarantine.QuarantineID { + raisedRecord = existing.RaisedRecord + break + } + } + if raisedRecord.QuarantineID == [32]byte{} { + t.Fatal("active quarantine is absent from the durable journal") + } + resolutionFinality := FrostPreSignFinality{ + BlockNumber: 14, + BlockHash: [32]byte{0x0e}, + } + fixture.source.points[resolutionFinality.BlockNumber] = + resolutionFinality.BlockHash + body := FrostRetainedGroupQuarantineLiftBody{ + Schema: frostRetainedGroupLiftBodySchema, + ProtocolBindingHash: journal.liftPolicy.ProtocolBindingHash, + ManifestHash: journal.liftPolicy.ManifestHash, + ProfileHash: journal.liftPolicy.ProfileHash, + ImplementationSetHash: journal.liftPolicy.ImplementationSetHash, + ChainID: journal.liftPolicy.ChainID, + DomainChainID: journal.liftPolicy.DomainChainID, + GenesisBlockHash: journal.liftPolicy.GenesisBlockHash, + QuarantineProtocolID: journal.liftPolicy.QuarantineProtocolID, + LiftProtocolID: journal.liftPolicy.LiftProtocolID, + TombstoneProtocolID: journal.liftPolicy.TombstoneProtocolID, + AuthoritySetHash: journal.liftPolicy.AuthoritySetHash, + QuarantineID: quarantine.QuarantineID, + WalletID: quarantine.WalletID, + OriginalRaisedRecord: raisedRecord, + PriorGeneration: journal.quarantineState.Generation, + PriorEventRoot: journal.quarantineState.Root, + PriorActiveRoot: journal.quarantineState.ActiveRoot, + PriorTombstoneRoot: journal.quarantineState.TombstoneRoot, + LiftPoint: point, + ResolutionEvidenceHash: [32]byte{0x53}, + ResolutionFinality: resolutionFinality, + NotBeforeBlock: 14, + ExpiresAtBlock: 19, + } + lift := FrostRetainedGroupMutation{ + Point: point, + Kind: FrostRetainedGroupQuarantineLiftMutation, + WalletID: quarantine.WalletID, + QuarantineID: quarantine.QuarantineID, + LiftCertificate: &FrostRetainedGroupQuarantineLiftCertificate{ + Schema: frostRetainedGroupLiftCertificateSchema, + Body: body, + }, + } + authorityIndexes := make([]int, journal.liftPolicy.AuthorityThreshold) + for index := range authorityIndexes { + authorityIndexes[index] = index + } + fixture.resignLiftMutation(t, &lift, authorityIndexes) + return lift +} + +func (fixture *journalTestFixture) resignLiftMutation( + t *testing.T, + mutation *FrostRetainedGroupMutation, + authorityIndexes []int, +) { + t.Helper() + if mutation == nil || mutation.LiftCertificate == nil { + t.Fatal("lift mutation certificate is nil") + } + bodyHash, err := frostRetainedGroupLiftBodyHash( + mutation.LiftCertificate.Body, + ) + if err != nil { + t.Fatal(err) + } + signatureHash := frostRetainedGroupLiftSignatureHash(bodyHash) + signatures := make( + []FrostRetainedGroupQuarantineLiftSignature, + len(authorityIndexes), + ) + for index, authorityIndex := range authorityIndexes { + if authorityIndex < 0 || + authorityIndex >= len(fixture.runtime.QuarantineJournal.LiftAuthorities) || + authorityIndex >= len(fixture.liftPrivateKeys) || + authorityIndex >= len(fixture.liftPublicKeySPKIs) { + t.Fatalf("invalid lift authority index [%d]", authorityIndex) + } + authority := fixture.runtime.QuarantineJournal. + LiftAuthorities[authorityIndex] + signatures[index] = FrostRetainedGroupQuarantineLiftSignature{ + AuthorityID: authority.AuthorityID, + SignerPublicKeySPKI: fixture.liftPublicKeySPKIs[authorityIndex], + Signature: base64.StdEncoding.EncodeToString( + ed25519.Sign( + fixture.liftPrivateKeys[authorityIndex], + signatureHash[:], + ), + ), + } + } + mutation.LiftCertificate.Schema = frostRetainedGroupLiftCertificateSchema + mutation.LiftCertificate.BodyHash = bodyHash + mutation.LiftCertificate.Signatures = signatures + refreshJournalTestLiftCertificateHash(t, mutation) +} + +func refreshJournalTestLiftCertificateHash( + t *testing.T, + mutation *FrostRetainedGroupMutation, +) { + t.Helper() + if mutation == nil || mutation.LiftCertificate == nil { + t.Fatal("lift mutation certificate is nil") + } + certificateHash, err := frostRetainedGroupLiftCertificateHash( + *mutation.LiftCertificate, + ) + if err != nil { + t.Fatal(err) + } + mutation.LiftCertificateHash = certificateHash +} + func (fixture *journalTestFixture) openJournal( t *testing.T, directory string, @@ -291,10 +496,8 @@ func (fixture *journalTestFixture) openJournal( t.Helper() journal, err := newFrostRetainedGroupJournal( directory, - fixture.manifestHash, fixture.bindingHash, - fixture.manifest, - fixture.quarantine, + fixture.runtime, fixture.source, fixture.registry, fixture.localOperator, @@ -310,10 +513,8 @@ func (fixture *journalTestFixture) openJournalError( ) error { journal, err := newFrostRetainedGroupJournal( directory, - fixture.manifestHash, fixture.bindingHash, - fixture.manifest, - fixture.quarantine, + fixture.runtime, fixture.source, fixture.registry, fixture.localOperator, @@ -324,6 +525,56 @@ func (fixture *journalTestFixture) openJournalError( return err } +func (fixture *journalTestFixture) openActiveQuarantine( + t *testing.T, + directory string, +) (*frostRetainedGroupJournal, FrostRetainedGroupMutation) { + t.Helper() + quarantine := FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, + TransactionHash: [32]byte{0xa5}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupRecoveryRequiredMutation, + WalletID: fixture.walletID, + QuarantineID: [32]byte{0x51}, + EvidenceHash: [32]byte{0x52}, + Reason: "manual recovery is required", + } + fixture.source.mutations = append(fixture.source.mutations, quarantine) + journal := fixture.openJournal(t, directory) + snapshot, err := journal.reconcile(context.Background(), fixture.target) + if err != nil { + _ = journal.close() + t.Fatal(err) + } + if snapshot.QuarantineCount != 1 || + snapshot.QuarantineTombstoneCount != 0 { + _ = journal.close() + t.Fatalf("unexpected active quarantine snapshot: %+v", snapshot) + } + return journal, quarantine +} + +func validateJournalTestLift( + journal *frostRetainedGroupJournal, + lift FrostRetainedGroupMutation, +) error { + if journal == nil || len(journal.quarantineState.Quarantines) != 1 { + return fmt.Errorf("journal does not contain exactly one quarantine") + } + _, err := validateFrostRetainedGroupLiftCertificate( + journal.liftPolicy, + journal.quarantineState, + lift, + journal.quarantineState.Quarantines[0], + ) + return err +} + func TestFrostRetainedGroupJournal_ReconcilesAndRejectsRewrittenHistory( t *testing.T, ) { @@ -392,168 +643,216 @@ func TestFrostRetainedGroupJournal_IntegratesCommittedOrphanBatchExactlyOnce( } } -func TestFrostRetainedGroupJournal_RejectsAuthenticatedV1Fixtures( +func TestFrostRetainedGroupJournal_RejectsAuthenticatedPriorSchemaFixtures( t *testing.T, ) { - testCases := map[string]func(*testing.T, string){ - "canonical metadata": func(t *testing.T, directory string) { - path := filepath.Join(directory, frostRetainedGroupCanonicalDirectory) - metadata := frostRetainedGroupJournalMetadata{} - if err := readFrostRetainedGroupEnvelopeAt( - path, - frostRetainedGroupJournalMetadataFile, - &metadata, - ); err != nil { - t.Fatal(err) - } - metadata.Schema = frostRetainedGroupJournalMetadataSchemaV1 - if err := persistFrostRetainedGroupEnvelopeAt( - path, - frostRetainedGroupJournalMetadataFile, - &metadata, - true, - ); err != nil { - t.Fatal(err) - } + type legacyFixture struct { + schemas [2]string + mutate func(*testing.T, string, string) + } + testCases := map[string]legacyFixture{ + "canonical metadata": { + schemas: [2]string{ + frostRetainedGroupJournalMetadataSchemaV1, + frostRetainedGroupJournalMetadataSchemaV2, + }, + mutate: func(t *testing.T, directory string, schema string) { + path := filepath.Join(directory, frostRetainedGroupCanonicalDirectory) + metadata := frostRetainedGroupJournalMetadata{} + if err := readFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalMetadataFile, + &metadata, + ); err != nil { + t.Fatal(err) + } + metadata.Schema = schema + if err := persistFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalMetadataFile, + &metadata, + true, + ); err != nil { + t.Fatal(err) + } + }, }, - "canonical state": func(t *testing.T, directory string) { - path := filepath.Join(directory, frostRetainedGroupCanonicalDirectory) - state := frostRetainedGroupJournalState{} - if err := readFrostRetainedGroupEnvelopeAt( - path, - frostRetainedGroupJournalStateFile, - &state, - ); err != nil { - t.Fatal(err) - } - state.Schema = frostRetainedGroupJournalStateSchemaV1 - if err := persistFrostRetainedGroupEnvelopeAt( - path, - frostRetainedGroupJournalStateFile, - &state, - true, - ); err != nil { - t.Fatal(err) - } + "canonical state": { + schemas: [2]string{ + frostRetainedGroupJournalStateSchemaV1, + frostRetainedGroupJournalStateSchemaV2, + }, + mutate: func(t *testing.T, directory string, schema string) { + path := filepath.Join(directory, frostRetainedGroupCanonicalDirectory) + state := frostRetainedGroupJournalState{} + if err := readFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalStateFile, + &state, + ); err != nil { + t.Fatal(err) + } + state.Schema = schema + if err := persistFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalStateFile, + &state, + true, + ); err != nil { + t.Fatal(err) + } + }, }, - "canonical batch with pre-evidence admission": func(t *testing.T, directory string) { - path := filepath.Join(directory, frostRetainedGroupCanonicalDirectory) - name := frostRetainedGroupBatchFileName(1) - batch := frostRetainedGroupJournalBatch{} - if err := readFrostRetainedGroupEnvelopeAt(path, name, &batch); err != nil { - t.Fatal(err) - } - batch.Schema = frostRetainedGroupJournalBatchSchemaV1 - for index := range batch.Mutations { - batch.Mutations[index].DkgResultHash = [32]byte{} - batch.Mutations[index].DkgSubmissionPoint = FrostRetainedGroupEventPoint{} - batch.Mutations[index].DkgApprovalPoint = FrostRetainedGroupEventPoint{} - } - batch.Checksum = [32]byte{} - payload, err := frostRetainedGroupCanonicalValue(batch) - if err != nil { - t.Fatal(err) - } - batch.Checksum = sha256.Sum256(payload) - if err := persistFrostRetainedGroupEnvelopeAt(path, name, &batch, true); err != nil { - t.Fatal(err) - } + "canonical batch": { + schemas: [2]string{ + frostRetainedGroupJournalBatchSchemaV1, + frostRetainedGroupJournalBatchSchemaV2, + }, + mutate: func(t *testing.T, directory string, schema string) { + path := filepath.Join(directory, frostRetainedGroupCanonicalDirectory) + name := frostRetainedGroupBatchFileName(1) + batch := frostRetainedGroupJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt(path, name, &batch); err != nil { + t.Fatal(err) + } + batch.Schema = schema + if schema == frostRetainedGroupJournalBatchSchemaV1 { + for index := range batch.Mutations { + batch.Mutations[index].DkgResultHash = [32]byte{} + batch.Mutations[index].DkgSubmissionPoint = FrostRetainedGroupEventPoint{} + batch.Mutations[index].DkgApprovalPoint = FrostRetainedGroupEventPoint{} + } + } + batch.Checksum = [32]byte{} + payload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + t.Fatal(err) + } + batch.Checksum = sha256.Sum256(payload) + if err := persistFrostRetainedGroupEnvelopeAt(path, name, &batch, true); err != nil { + t.Fatal(err) + } + }, }, - "quarantine metadata": func(t *testing.T, directory string) { - path := filepath.Join(directory, frostRetainedGroupQuarantineDirectory) - metadata := frostRetainedGroupQuarantineMetadata{} - if err := readFrostRetainedGroupEnvelopeAt( - path, - frostRetainedGroupJournalMetadataFile, - &metadata, - ); err != nil { - t.Fatal(err) - } - metadata.Schema = frostRetainedGroupQuarantineMetadataV1 - if err := persistFrostRetainedGroupEnvelopeAt( - path, - frostRetainedGroupJournalMetadataFile, - &metadata, - true, - ); err != nil { - t.Fatal(err) - } + "quarantine metadata": { + schemas: [2]string{ + frostRetainedGroupQuarantineMetadataV1, + frostRetainedGroupQuarantineMetadataV2, + }, + mutate: func(t *testing.T, directory string, schema string) { + path := filepath.Join(directory, frostRetainedGroupQuarantineDirectory) + metadata := frostRetainedGroupQuarantineMetadata{} + if err := readFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalMetadataFile, + &metadata, + ); err != nil { + t.Fatal(err) + } + metadata.Schema = schema + if err := persistFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalMetadataFile, + &metadata, + true, + ); err != nil { + t.Fatal(err) + } + }, }, - "quarantine state": func(t *testing.T, directory string) { - path := filepath.Join(directory, frostRetainedGroupQuarantineDirectory) - state := frostRetainedGroupQuarantineJournalState{} - if err := readFrostRetainedGroupEnvelopeAt( - path, - frostRetainedGroupJournalStateFile, - &state, - ); err != nil { - t.Fatal(err) - } - state.Schema = frostRetainedGroupQuarantineStateV1 - if err := persistFrostRetainedGroupEnvelopeAt( - path, - frostRetainedGroupJournalStateFile, - &state, - true, - ); err != nil { - t.Fatal(err) - } + "quarantine state": { + schemas: [2]string{ + frostRetainedGroupQuarantineStateV1, + frostRetainedGroupQuarantineStateV2, + }, + mutate: func(t *testing.T, directory string, schema string) { + path := filepath.Join(directory, frostRetainedGroupQuarantineDirectory) + state := frostRetainedGroupQuarantineJournalState{} + if err := readFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalStateFile, + &state, + ); err != nil { + t.Fatal(err) + } + state.Schema = schema + if err := persistFrostRetainedGroupEnvelopeAt( + path, + frostRetainedGroupJournalStateFile, + &state, + true, + ); err != nil { + t.Fatal(err) + } + }, }, - "quarantine batch": func(t *testing.T, directory string) { - path := filepath.Join(directory, frostRetainedGroupQuarantineDirectory) - name := frostRetainedGroupBatchFileName(1) - batch := frostRetainedGroupQuarantineJournalBatch{} - if err := readFrostRetainedGroupEnvelopeAt(path, name, &batch); err != nil { - t.Fatal(err) - } - batch.Schema = frostRetainedGroupQuarantineBatchV1 - batch.Checksum = [32]byte{} - payload, err := frostRetainedGroupCanonicalValue(batch) - if err != nil { - t.Fatal(err) - } - batch.Checksum = sha256.Sum256(payload) - if err := persistFrostRetainedGroupEnvelopeAt(path, name, &batch, true); err != nil { - t.Fatal(err) - } + "quarantine batch": { + schemas: [2]string{ + frostRetainedGroupQuarantineBatchV1, + frostRetainedGroupQuarantineBatchV2, + }, + mutate: func(t *testing.T, directory string, schema string) { + path := filepath.Join(directory, frostRetainedGroupQuarantineDirectory) + name := frostRetainedGroupBatchFileName(1) + batch := frostRetainedGroupWireQuarantineJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt(path, name, &batch); err != nil { + t.Fatal(err) + } + batch.Schema = schema + batch.Checksum = frostActivationHex32([32]byte{}) + payload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + t.Fatal(err) + } + batch.Checksum = frostActivationHex32(sha256.Sum256(payload)) + if err := persistFrostRetainedGroupEnvelopeAt(path, name, &batch, true); err != nil { + t.Fatal(err) + } + }, }, } - for name, mutate := range testCases { - t.Run(name, func(t *testing.T) { - fixture := newJournalTestFixture(t) - quarantine := FrostRetainedGroupMutation{ - Point: FrostRetainedGroupEventPoint{ - BlockNumber: 5, - BlockHash: [32]byte{0x05}, - TransactionHash: [32]byte{0xa5}, - TransactionIndex: 1, - LogIndex: 1, - }, - Kind: FrostRetainedGroupQuarantineMutation, - WalletID: fixture.walletID, - QuarantineID: [32]byte{0x61}, - EvidenceHash: [32]byte{0x62}, - Reason: "authenticated v1 fixture", - } - fixture.source.mutations = append(fixture.source.mutations, quarantine) - directory := filepath.Join(t.TempDir(), "journal") - journal := fixture.openJournal(t, directory) - if _, err := journal.reconcile(context.Background(), fixture.target); err != nil { - t.Fatal(err) - } - if err := journal.close(); err != nil { - t.Fatal(err) - } + for componentName, testCase := range testCases { + for versionIndex, schema := range testCase.schemas { + t.Run(fmt.Sprintf("%s/v%d", componentName, versionIndex+1), func(t *testing.T) { + fixture := newJournalTestFixture(t) + quarantine := FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, + TransactionHash: [32]byte{0xa5}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupQuarantineMutation, + WalletID: fixture.walletID, + QuarantineID: [32]byte{0x61}, + EvidenceHash: [32]byte{0x62}, + Reason: "authenticated prior-schema fixture", + } + fixture.source.mutations = append(fixture.source.mutations, quarantine) + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + if _, err := journal.reconcile(context.Background(), fixture.target); err != nil { + t.Fatal(err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } - mutate(t, directory) - err := fixture.openJournalError(directory) - if err == nil || - !strings.Contains(err.Error(), "schema v1 is not safely migratable") || - !strings.Contains(err.Error(), "signed activation manifest") { - t.Fatalf("expected explicit manifest-pinned v1 rejection, got [%v]", err) - } - }) + testCase.mutate(t, directory, schema) + err := fixture.openJournalError(directory) + if err == nil || + !strings.Contains(err.Error(), "prior FROST retained-group") || + !strings.Contains(err.Error(), "not safely migratable") || + !strings.Contains(err.Error(), "new empty v3") { + t.Fatalf( + "expected explicit manifest-pinned prior-schema rejection, got [%v]", + err, + ) + } + }) + } } } @@ -608,7 +907,7 @@ func TestFrostRetainedGroupJournal_RejectsCrossBindingBatchReplay( directory := filepath.Join(rootDirectory, testCase.directory) batchName := frostRetainedGroupBatchFileName(1) if testCase.addQuarantine { - batch := frostRetainedGroupQuarantineJournalBatch{} + batch := frostRetainedGroupWireQuarantineJournalBatch{} if err := readFrostRetainedGroupEnvelopeAt( directory, batchName, @@ -616,13 +915,13 @@ func TestFrostRetainedGroupJournal_RejectsCrossBindingBatchReplay( ); err != nil { t.Fatal(err) } - batch.BindingHash = [32]byte{0xff} - batch.Checksum = [32]byte{} + batch.BindingHash = frostActivationHex32([32]byte{0xff}) + batch.Checksum = frostActivationHex32([32]byte{}) payload, err := frostRetainedGroupCanonicalValue(batch) if err != nil { t.Fatal(err) } - batch.Checksum = sha256.Sum256(payload) + batch.Checksum = frostActivationHex32(sha256.Sum256(payload)) if err := persistFrostRetainedGroupEnvelopeAt( directory, batchName, @@ -712,28 +1011,443 @@ func TestFrostRetainedGroupJournal_QuarantineAndAuthenticatedLiftAreIndependent( t.Fatalf("independent journal metadata is not durable and private: [%s] [%v]", metadataPath, err) } } - lift := FrostRetainedGroupMutation{ - Point: FrostRetainedGroupEventPoint{ + lift := fixture.liftMutation( + t, + journal, + quarantine, + FrostRetainedGroupEventPoint{ BlockNumber: 15, BlockHash: [32]byte{0x0f}, TransactionHash: [32]byte{0xaf}, TransactionIndex: 2, LogIndex: 3, }, - Kind: FrostRetainedGroupQuarantineLiftMutation, - WalletID: fixture.walletID, - QuarantineID: quarantine.QuarantineID, - AuthenticationHash: [32]byte{0x53}, - } + ) fixture.source.mutations = append(fixture.source.mutations, lift) second, err := journal.reconcile(context.Background(), fixture.later) if err != nil { t.Fatal(err) } if second.SnapshotGeneration != 1 || second.QuarantineGeneration != 2 || - second.QuarantineCount != 0 || second.QuarantineRoot == first.QuarantineRoot { + second.QuarantineCount != 0 || second.QuarantineTombstoneCount != 1 || + second.QuarantineRoot == first.QuarantineRoot || + second.QuarantineActiveRoot == first.QuarantineActiveRoot || + second.QuarantineTombstoneRoot == first.QuarantineTombstoneRoot { t.Fatalf("unexpected lifted snapshot: %+v", second) } + if len(journal.quarantineState.Quarantines) != 1 || + journal.quarantineState.Quarantines[0].Status != frostRetainedGroupQuarantineLifted || + len(journal.quarantineState.Tombstones) != 1 { + t.Fatal("lift did not retain its immutable record and permanent tombstone") + } +} + +func TestFrostRetainedGroupJournal_LiftCertificateRejectsBypasses( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + journal, quarantine := fixture.openActiveQuarantine( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + lift := fixture.liftMutation( + t, + journal, + quarantine, + FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + }, + ) + if err := validateJournalTestLift(journal, lift); err != nil { + t.Fatalf("valid lift certificate was rejected: [%v]", err) + } + + substitutions := map[string]struct { + mutate func(*FrostRetainedGroupMutation) + resign bool + }{ + "unsigned body substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.ManifestHash[0] ^= 0xff + }, + }, + "signed manifest substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.ManifestHash[0] ^= 0xff + }, + resign: true, + }, + "signed quarantine ID substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.QuarantineID[0] ^= 0xff + }, + resign: true, + }, + "signed wallet substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.WalletID[0] ^= 0xff + }, + resign: true, + }, + "signed raised evidence substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.OriginalRaisedRecord. + EvidenceHash[0] ^= 0xff + }, + resign: true, + }, + "signed raised reason substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.OriginalRaisedRecord.Reason += + " altered" + }, + resign: true, + }, + "signed recovery flag substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.OriginalRaisedRecord. + RecoveryRequired = false + }, + resign: true, + }, + "signed prior generation substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.PriorGeneration++ + }, + resign: true, + }, + "signed prior event root substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.PriorEventRoot[0] ^= 0xff + }, + resign: true, + }, + "signed prior active root substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.PriorActiveRoot[0] ^= 0xff + }, + resign: true, + }, + "signed prior tombstone root substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.PriorTombstoneRoot[0] ^= 0xff + }, + resign: true, + }, + "signed lift point substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.LiftPoint. + TransactionHash[0] ^= 0xff + }, + resign: true, + }, + "future resolution finality": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.ResolutionFinality = + FrostPreSignFinality{ + BlockNumber: 16, + BlockHash: [32]byte{0x10}, + } + }, + resign: true, + }, + "resolution finality before quarantine": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.ResolutionFinality = + FrostPreSignFinality{ + BlockNumber: 4, + BlockHash: [32]byte{0x04}, + } + }, + resign: true, + }, + "same-height conflicting resolution hash": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.ResolutionFinality = + FrostPreSignFinality{ + BlockNumber: 15, + BlockHash: [32]byte{0xee}, + } + }, + resign: true, + }, + "unsafe canonical JSON integer": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificate.Body.ExpiresAtBlock = + frostRetainedGroupMaximumCanonicalJSONInteger + 1 + }, + }, + "certificate reference substitution": { + mutate: func(candidate *FrostRetainedGroupMutation) { + candidate.LiftCertificateHash[0] ^= 0xff + }, + }, + } + for name, testCase := range substitutions { + t.Run(name, func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + testCase.mutate(&candidate) + if testCase.resign { + fixture.resignLiftMutation(t, &candidate, []int{0, 1}) + } + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("substituted lift certificate was accepted") + } + }) + } + + irrelevantFields := map[string]func(*FrostRetainedGroupMutation){ + "wallet public key hash": func(candidate *FrostRetainedGroupMutation) { + candidate.WalletPublicKeyHash = [20]byte{0x99} + }, + "whitespace reason": func(candidate *FrostRetainedGroupMutation) { + candidate.Reason = " " + }, + } + for name, mutate := range irrelevantFields { + t.Run("irrelevant "+name, func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + mutate(&candidate) + state := cloneFrostRetainedGroupQuarantineState( + journal.quarantineState, + ) + if err := applyFrostRetainedGroupQuarantineMutations( + &state, + []FrostRetainedGroupMutation{candidate}, + journal.liftPolicy, + ); err == nil { + t.Fatal("lift with an unsigned irrelevant field was accepted") + } + }) + } + + t.Run("insufficient quorum", func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + fixture.resignLiftMutation(t, &candidate, []int{0}) + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("one-of-three lift quorum was accepted") + } + }) + t.Run("unsorted signatures", func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + fixture.resignLiftMutation(t, &candidate, []int{1, 0}) + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("unsorted lift signatures were accepted") + } + }) + t.Run("duplicate signatures", func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + fixture.resignLiftMutation(t, &candidate, []int{0, 0}) + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("duplicate lift signatures were accepted") + } + }) + t.Run("unknown extra signature", func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + fixture.resignLiftMutation(t, &candidate, []int{0, 1}) + _, unknownPrivateKey, unknownSPKI := journalTestAuthority( + t, + "zz-unknown", + 0x7f, + ) + signatureHash := frostRetainedGroupLiftSignatureHash( + candidate.LiftCertificate.BodyHash, + ) + candidate.LiftCertificate.Signatures = append( + candidate.LiftCertificate.Signatures, + FrostRetainedGroupQuarantineLiftSignature{ + AuthorityID: "zz-unknown", + SignerPublicKeySPKI: unknownSPKI, + Signature: base64.StdEncoding.EncodeToString( + ed25519.Sign(unknownPrivateKey, signatureHash[:]), + ), + }, + ) + refreshJournalTestLiftCertificateHash(t, &candidate) + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("unknown extra lift signature was accepted") + } + }) + t.Run("invalid extra signature", func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + fixture.resignLiftMutation(t, &candidate, []int{0, 1, 2}) + signature, err := base64.StdEncoding.Strict().DecodeString( + candidate.LiftCertificate.Signatures[2].Signature, + ) + if err != nil { + t.Fatal(err) + } + signature[0] ^= 0xff + candidate.LiftCertificate.Signatures[2].Signature = + base64.StdEncoding.EncodeToString(signature) + refreshJournalTestLiftCertificateHash(t, &candidate) + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("invalid extra lift signature was accepted") + } + }) + t.Run("noncanonical base64", func(t *testing.T) { + candidate := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{lift}, + )[0] + candidate.LiftCertificate.Signatures[0].SignerPublicKeySPKI = + "\n" + candidate.LiftCertificate.Signatures[0].SignerPublicKeySPKI + refreshJournalTestLiftCertificateHash(t, &candidate) + if err := validateJournalTestLift(journal, candidate); err == nil { + t.Fatal("noncanonical base64 lift credential was accepted") + } + }) +} + +func TestFrostRetainedGroupJournal_LiftAuthorityStrictMajority( + t *testing.T, +) { + addFourthAuthority := func( + t *testing.T, + fixture *journalTestFixture, + threshold uint64, + ) { + authority, privateKey, publicKeySPKI := journalTestAuthority( + t, + "lift-4", + 0x73, + ) + authorities := append( + []FrostRetainedGroupAuthority{}, + fixture.runtime.QuarantineJournal.LiftAuthorities..., + ) + authorities = append(authorities, authority) + fixture.runtime.QuarantineJournal.LiftAuthorityThreshold = threshold + fixture.runtime.QuarantineJournal.LiftAuthorities = authorities + fixture.quarantine = fixture.runtime.QuarantineJournal + fixture.liftPrivateKeys = append( + fixture.liftPrivateKeys, + privateKey, + ) + fixture.liftPublicKeySPKIs = append( + fixture.liftPublicKeySPKIs, + publicKeySPKI, + ) + } + t.Run("2-of-4 rejected", func(t *testing.T) { + fixture := newJournalTestFixture(t) + addFourthAuthority(t, fixture, 2) + err := fixture.openJournalError( + filepath.Join(t.TempDir(), "journal"), + ) + if err == nil || !strings.Contains(err.Error(), "strict majority") { + t.Fatalf("expected 2-of-4 policy rejection, got [%v]", err) + } + }) + t.Run("3-of-4 accepted", func(t *testing.T) { + fixture := newJournalTestFixture(t) + addFourthAuthority(t, fixture, 3) + journal, quarantine := fixture.openActiveQuarantine( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + lift := fixture.liftMutation( + t, + journal, + quarantine, + FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + }, + ) + if len(lift.LiftCertificate.Signatures) != 3 { + t.Fatal("3-of-4 policy did not produce three signatures") + } + if err := validateJournalTestLift(journal, lift); err != nil { + t.Fatalf("valid 3-of-4 lift was rejected: [%v]", err) + } + }) +} + +func TestFrostRetainedGroupJournal_LiftCertificateFrozenWireVector( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + journal, quarantine := fixture.openActiveQuarantine( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + lift := fixture.liftMutation( + t, + journal, + quarantine, + FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + }, + ) + wire := frostRetainedGroupLiftCertificateToWire(lift.LiftCertificate) + canonical, err := frostRetainedGroupCanonicalValue(wire) + if err != nil { + t.Fatal(err) + } + if strings.Contains( + string(canonical), + `"protocolBindingHash":[`, + ) || !strings.Contains( + string(canonical), + `"protocolBindingHash":"0x`, + ) { + t.Fatal("lift certificate did not use the explicit hex32 wire contract") + } + vectors := map[string]struct { + actual [32]byte + expected string + }{ + "authority set": { + actual: journal.liftPolicy.AuthoritySetHash, + expected: "b08dcb52b095337400460f2df2a4b490c1d59e9995a0980489eb0d5540a2ee57", + }, + "body": { + actual: lift.LiftCertificate.BodyHash, + expected: "a39727457786e7ad6bf67f3ea3cd7777c766e54d761684324bfdfa19c8030686", + }, + "certificate": { + actual: lift.LiftCertificateHash, + expected: "7000fbb0730db155daa390ba9f8a0641ebc20ce7ee1ccee34b9ff987d7501e0d", + }, + } + for name, vector := range vectors { + if fmt.Sprintf("%x", vector.actual) != vector.expected { + t.Fatalf( + "%s wire vector changed: got [%x], expected [%s]", + name, + vector.actual, + vector.expected, + ) + } + } } func TestFrostRetainedGroupJournal_IntegratesQuarantineOrphanBatchExactlyOnce( @@ -791,6 +1505,250 @@ func TestFrostRetainedGroupJournal_IntegratesQuarantineOrphanBatchExactlyOnce( } } +func TestFrostRetainedGroupJournal_LiftCrashRecoveryAndContentAddressing( + t *testing.T, +) { + liftPoint := FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + } + t.Run("certificate-only orphan is inert", func(t *testing.T) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + journal, quarantine := fixture.openActiveQuarantine( + t, + directory, + ) + lift := fixture.liftMutation(t, journal, quarantine, liftPoint) + fixture.source.mutations = append(fixture.source.mutations, lift) + journal.persistFailureHook = func(stage string) error { + switch stage { + case "after-batch-before-state": + return nil + case "after-quarantine-lift-certificate-before-batch": + return fmt.Errorf("simulated certificate-only crash") + default: + t.Fatalf("unexpected failure stage [%s]", stage) + return nil + } + } + if _, err := journal.reconcile( + context.Background(), + fixture.later, + ); err == nil || !strings.Contains(err.Error(), "certificate-only") { + t.Fatalf("expected certificate-only crash, got [%v]", err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + if len(restarted.liftCertificates) != 1 || + frostRetainedGroupActiveQuarantineCount( + restarted.quarantineState, + ) != 1 || + len(restarted.quarantineState.Tombstones) != 0 { + t.Fatal("certificate-only orphan changed quarantine state") + } + snapshot, err := restarted.reconcile( + context.Background(), + fixture.later, + ) + if err != nil { + t.Fatal(err) + } + if snapshot.QuarantineCount != 0 || + snapshot.QuarantineTombstoneCount != 1 { + t.Fatalf("certificate-only recovery did not lift exactly once: %+v", snapshot) + } + }) + + t.Run("certificate and batch orphan integrate exactly once", func(t *testing.T) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + journal, quarantine := fixture.openActiveQuarantine( + t, + directory, + ) + lift := fixture.liftMutation(t, journal, quarantine, liftPoint) + fixture.source.mutations = append(fixture.source.mutations, lift) + journal.persistFailureHook = func(stage string) error { + switch stage { + case "after-batch-before-state", + "after-quarantine-lift-certificate-before-batch": + return nil + case "after-quarantine-batch-before-state": + return fmt.Errorf("simulated certificate-and-batch crash") + default: + t.Fatalf("unexpected failure stage [%s]", stage) + return nil + } + } + if _, err := journal.reconcile( + context.Background(), + fixture.later, + ); err == nil || !strings.Contains(err.Error(), "certificate-and-batch") { + t.Fatalf("expected certificate-and-batch crash, got [%v]", err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + if frostRetainedGroupActiveQuarantineCount( + restarted.quarantineState, + ) != 0 || + len(restarted.quarantineState.Tombstones) != 1 || + restarted.quarantineState.BatchSequence != 2 { + t.Fatalf( + "certificate-and-batch orphan was not integrated: %+v", + restarted.quarantineState, + ) + } + snapshot, err := restarted.reconcile( + context.Background(), + fixture.later, + ) + if err != nil { + t.Fatal(err) + } + if snapshot.QuarantineCount != 0 || + snapshot.QuarantineTombstoneCount != 1 || + restarted.quarantineState.BatchSequence != 2 { + t.Fatalf("orphan lift was not integrated exactly once: %+v", snapshot) + } + }) + + t.Run("conflicting valid certificate orphan has a distinct digest", func(t *testing.T) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + journal, quarantine := fixture.openActiveQuarantine( + t, + directory, + ) + firstLift := fixture.liftMutation(t, journal, quarantine, liftPoint) + fixture.source.mutations = append( + fixture.source.mutations, + firstLift, + ) + journal.persistFailureHook = func(stage string) error { + switch stage { + case "after-batch-before-state": + return nil + case "after-quarantine-lift-certificate-before-batch": + return fmt.Errorf("simulated first certificate crash") + default: + t.Fatalf("unexpected failure stage [%s]", stage) + return nil + } + } + if _, err := journal.reconcile( + context.Background(), + fixture.later, + ); err == nil { + t.Fatal("expected first certificate crash") + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + secondLift := cloneFrostRetainedGroupMutations( + []FrostRetainedGroupMutation{firstLift}, + )[0] + fixture.resignLiftMutation(t, &secondLift, []int{0, 2}) + if secondLift.LiftCertificateHash == firstLift.LiftCertificateHash { + t.Fatal("different quorum certificates have the same full digest") + } + fixture.source.mutations[len(fixture.source.mutations)-1] = + secondLift + snapshot, err := restarted.reconcile( + context.Background(), + fixture.later, + ) + if err != nil { + t.Fatal(err) + } + if len(restarted.liftCertificates) != 2 || + snapshot.QuarantineTombstoneCount != 1 || + restarted.quarantineState.Tombstones[0].LiftCertificateHash != + secondLift.LiftCertificateHash { + t.Fatal("content-addressed conflicting certificate handling failed") + } + }) +} + +func TestFrostRetainedGroupJournal_TombstoneRejectsReplayAndReraise( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + directory := filepath.Join(t.TempDir(), "journal") + journal, quarantine := fixture.openActiveQuarantine(t, directory) + lift := fixture.liftMutation( + t, + journal, + quarantine, + FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + }, + ) + fixture.source.mutations = append(fixture.source.mutations, lift) + if _, err := journal.reconcile( + context.Background(), + fixture.later, + ); err != nil { + t.Fatal(err) + } + + replayState := cloneFrostRetainedGroupQuarantineState( + journal.quarantineState, + ) + if err := applyFrostRetainedGroupQuarantineMutations( + &replayState, + []FrostRetainedGroupMutation{lift}, + journal.liftPolicy, + ); err == nil { + t.Fatal("tombstoned lift replay was accepted") + } + reraise := quarantine + reraise.Point = FrostRetainedGroupEventPoint{ + BlockNumber: 18, + BlockHash: [32]byte{0x12}, + TransactionHash: [32]byte{0xb2}, + TransactionIndex: 1, + LogIndex: 1, + } + reraise.EvidenceHash = [32]byte{0x54} + if err := applyFrostRetainedGroupQuarantineMutations( + &replayState, + []FrostRetainedGroupMutation{reraise}, + journal.liftPolicy, + ); err == nil { + t.Fatal("tombstoned quarantine ID was raised again") + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + restarted := fixture.openJournal(t, directory) + defer restarted.close() + if len(restarted.quarantineState.Quarantines) != 1 || + restarted.quarantineState.Quarantines[0].Status != + frostRetainedGroupQuarantineLifted || + len(restarted.quarantineState.Tombstones) != 1 { + t.Fatal("lifted record or permanent tombstone was lost on restart") + } +} + func TestApplyFrostRetainedGroupMutations_EnforcesLifecycleAndRegistryClosure( t *testing.T, ) { @@ -870,6 +1828,14 @@ func TestApplyFrostRetainedGroupMutations_EnforcesWalletLimit(t *testing.T) { func TestValidateCompleteFrostRetainedGroupHistory_EnforcesMutationLimit( t *testing.T, ) { + fixture := newJournalTestFixture(t) + policy, err := frostRetainedGroupLiftPolicyFromRuntimeManifest( + fixture.bindingHash, + fixture.runtime, + ) + if err != nil { + t.Fatal(err) + } history := &FrostRetainedGroupHistory{ From: FrostPreSignFinality{BlockNumber: 1, BlockHash: [32]byte{1}}, To: FrostPreSignFinality{BlockNumber: 2, BlockHash: [32]byte{2}}, @@ -878,7 +1844,7 @@ func TestValidateCompleteFrostRetainedGroupHistory_EnforcesMutationLimit( frostRetainedGroupMaximumMutations+1, ), } - err := validateCompleteFrostRetainedGroupHistory(history) + err = validateCompleteFrostRetainedGroupHistory(history, policy) if err == nil || !strings.Contains(err.Error(), "mutation limit") { t.Fatalf("expected aggregate mutation limit rejection, got [%v]", err) } @@ -999,10 +1965,8 @@ func TestFrostRetainedGroupJournal_RejectsIdentityMismatchAndConcurrentOwner( defer journal.close() if _, err := newFrostRetainedGroupJournal( directory, - fixture.manifestHash, fixture.bindingHash, - fixture.manifest, - fixture.quarantine, + fixture.runtime, fixture.source, fixture.registry, fixture.localOperator, @@ -1018,10 +1982,8 @@ func TestFrostRetainedGroupJournal_RejectsIdentityMismatchAndConcurrentOwner( } if _, err := newFrostRetainedGroupJournal( otherDirectory, - fixture.manifestHash, fixture.bindingHash, - fixture.manifest, - fixture.quarantine, + fixture.runtime, fixture.source, fixture.registry, fixture.localOperator, @@ -1041,10 +2003,8 @@ func TestFrostRetainedGroupJournal_RejectsSymlinkEntry(t *testing.T) { } if _, err := newFrostRetainedGroupJournal( directory, - fixture.manifestHash, fixture.bindingHash, - fixture.manifest, - fixture.quarantine, + fixture.runtime, fixture.source, fixture.registry, fixture.localOperator, @@ -1254,10 +2214,8 @@ func TestFrostRetainedGroupJournal_RejectsCorruptOrPublicStoreFiles(t *testing.T } if _, err := newFrostRetainedGroupJournal( directory, - fixture.manifestHash, fixture.bindingHash, - fixture.manifest, - fixture.quarantine, + fixture.runtime, fixture.source, fixture.registry, fixture.localOperator, @@ -1279,10 +2237,8 @@ func TestFrostRetainedGroupJournal_RejectsCorruptOrPublicStoreFiles(t *testing.T } if _, err := newFrostRetainedGroupJournal( directory, - fixture.manifestHash, fixture.bindingHash, - fixture.manifest, - fixture.quarantine, + fixture.runtime, fixture.source, fixture.registry, fixture.localOperator, diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index 2f4a3b8b37..06724a1f67 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -753,10 +753,8 @@ func newNode( } journal, err := newFrostRetainedGroupJournal( config.FrostRetainedGroupJournalDirectory, - runtimeManifest.ManifestHash, retainedGroupBindingHash, - runtimeManifest.CanonicalJournal, - runtimeManifest.QuarantineJournal, + runtimeManifest, config.FrostRetainedGroupHistorySource, walletRegistry, operatorAddress, From 448a36d05da03a89a6d69e4762bc399ccd38b7cf Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 19:55:08 -0400 Subject: [PATCH 28/93] Require quorum checkpoints for FROST history --- .../tbtc_frost_pre_sign_authorization.go | 26 +- ...tbtc_frost_retained_group_manifest_test.go | 29 +- pkg/tbtc/frost_activation_handshake.go | 132 +- pkg/tbtc/frost_activation_handshake_test.go | 292 +++- pkg/tbtc/frost_retained_group_checkpoint.go | 1445 +++++++++++++++++ .../frost_retained_group_history_evidence.go | 16 +- .../frost_retained_group_history_source.go | 215 ++- ...rost_retained_group_history_source_test.go | 309 +++- pkg/tbtc/frost_retained_group_journal.go | 443 ++++- pkg/tbtc/frost_retained_group_journal_test.go | 987 ++++++++++- 10 files changed, 3694 insertions(+), 200 deletions(-) create mode 100644 pkg/tbtc/frost_retained_group_checkpoint.go diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index 61521da32a..4a66c9add8 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -33,7 +33,7 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) -const frostPreSignManifestVersion = "tbtc-p2tr-fraud-production-activation/v4" +const frostPreSignManifestVersion = "tbtc-p2tr-fraud-production-activation/v5" const frostPreSignBridgeABIJSON = `[ {"type":"function","name":"previewP2TRTransactionAuthorization","stateMutability":"view","inputs":[{"name":"payload","type":"bytes"}],"outputs":[{"name":"","type":"bytes"}]}, @@ -200,6 +200,8 @@ type frostPreSignManifestQuarantineJournal struct { TombstoneProtocolID string `json:"tombstoneProtocolID"` CheckpointAuthorityThreshold uint64 `json:"checkpointAuthorityThreshold"` CheckpointAuthorities []frostPreSignManifestLiftAuthority `json:"checkpointAuthorities"` + CheckpointMinimumSequence uint64 `json:"checkpointMinimumSequence"` + CheckpointPredecessorHash string `json:"checkpointPredecessorHash"` LiftAuthorityThreshold uint64 `json:"liftAuthorityThreshold"` LiftAuthorities []frostPreSignManifestLiftAuthority `json:"liftAuthorities"` StoreID string `json:"storeID"` @@ -1141,6 +1143,20 @@ func validateFrostPreSignQuarantineLiftAuthorities( for hash := range checkpointHashes { forbidden[hash] = "checkpoint authority" } + checkpointPredecessorHash, err := frostPreSignParseBytes32( + quarantine.CheckpointPredecessorHash, + ) + if err != nil || + quarantine.CheckpointMinimumSequence == 0 || + quarantine.CheckpointMinimumSequence > 9007199254740991 || + (quarantine.CheckpointMinimumSequence == 1 && + checkpointPredecessorHash != [32]byte{}) || + (quarantine.CheckpointMinimumSequence > 1 && + checkpointPredecessorHash == [32]byte{}) { + return fmt.Errorf( + "FROST checkpoint transparency floor is invalid", + ) + } if _, err := validateFrostPreSignManifestAuthoritySet( "quarantine lift", quarantine.LiftAuthorityThreshold, @@ -2408,6 +2424,12 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( PublicKeySPKIHash: publicKeySPKIHash, } } + checkpointPredecessorHash, err := parse( + quarantine.CheckpointPredecessorHash, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } verifierOperatorFingerprint, err := parse( adapter.manifest.Ethereum.VerifierOperatorFingerprint, ) @@ -2504,6 +2526,8 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( TombstoneProtocolID: quarantineTombstoneProtocolID, CheckpointAuthorityThreshold: quarantine.CheckpointAuthorityThreshold, CheckpointAuthorities: checkpointAuthorities, + CheckpointMinimumSequence: quarantine.CheckpointMinimumSequence, + CheckpointPredecessorHash: checkpointPredecessorHash, LiftAuthorityThreshold: quarantine.LiftAuthorityThreshold, LiftAuthorities: liftAuthorities, StoreID: quarantine.StoreID, diff --git a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go index 6905691004..65c93477a0 100644 --- a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go +++ b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go @@ -78,7 +78,9 @@ func testFrostJournalActivationManifest() *frostPreSignActivationManifest { {AuthorityID: "checkpoint-2", PublicKeySPKIHash: testManifestHex32(0x2c)}, {AuthorityID: "checkpoint-3", PublicKeySPKIHash: testManifestHex32(0x2d)}, }, - LiftAuthorityThreshold: 2, + CheckpointMinimumSequence: 1, + CheckpointPredecessorHash: testManifestHex32(0x00), + LiftAuthorityThreshold: 2, LiftAuthorities: []frostPreSignManifestLiftAuthority{ {AuthorityID: "authority-1", PublicKeySPKIHash: testManifestHex32(0x36)}, {AuthorityID: "authority-2", PublicKeySPKIHash: testManifestHex32(0x37)}, @@ -291,6 +293,31 @@ func TestValidateFrostPreSignActivationManifest_QuarantineAuthoritySets( t.Fatalf("expected protocol-identity alias rejection, got [%v]", err) } }) + t.Run("zero checkpoint floor", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.CheckpointMinimumSequence = 0 + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "transparency floor") { + t.Fatalf("expected zero checkpoint-floor rejection, got [%v]", err) + } + }) + t.Run("missing non-genesis predecessor", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.CheckpointMinimumSequence = 2 + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "transparency floor") { + t.Fatalf("expected missing predecessor rejection, got [%v]", err) + } + }) + t.Run("nonzero genesis predecessor", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.CheckpointPredecessorHash = + testManifestHex32(0x7f) + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "transparency floor") { + t.Fatalf("expected genesis predecessor rejection, got [%v]", err) + } + }) } func TestFrostPreSignDecodeStrictJSON_RejectsUnknownCanonicalJournalField(t *testing.T) { diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 0aaf7915b2..39f52bf06d 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -29,7 +29,7 @@ import ( ) const ( - frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v3" + frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v4" frostActivationInventorySchema = "tbtc-p2tr-frost-wallet-group-inventory/v1" frostActivationHandshakeSignatureDomain = "tbtc-p2tr-production-activation-handshake-signature/v3\x00" frostActivationHandshakeReconciliationTimeout = frostRetainedGroupMaximumReconciliationDuration @@ -52,10 +52,11 @@ type frostActivationEthereumPoint struct { } type frostActivationChallenge struct { - Nonce string `json:"nonce"` - ManifestHash string `json:"manifestHash"` - BindingHash string `json:"bindingHash"` - EthereumPoint frostActivationEthereumPoint `json:"ethereumPoint"` + Nonce string `json:"nonce"` + ManifestHash string `json:"manifestHash"` + BindingHash string `json:"bindingHash"` + EthereumPoint frostActivationEthereumPoint `json:"ethereumPoint"` + CheckpointFloor frostRetainedGroupWireCheckpointCursor `json:"checkpointFloor"` } type frostActivationHandshakeRequest struct { @@ -128,6 +129,23 @@ type frostActivationNativeSignerState struct { Complete bool `json:"complete"` } +type frostActivationCheckpointJournalState struct { + ManifestMinimumSequence uint64 `json:"manifestMinimumSequence"` + ManifestPredecessorHash string `json:"manifestPredecessorHash"` + ChallengeFloor frostRetainedGroupWireCheckpointCursor `json:"challengeFloor"` + DurableHead frostRetainedGroupWireCheckpointCursor `json:"durableHead"` + Point frostActivationEthereumPoint `json:"point"` + HistoryRoot string `json:"historyRoot"` + CanonicalGeneration uint64 `json:"canonicalGeneration"` + CanonicalInventoryRoot string `json:"canonicalInventoryRoot"` + QuarantineGeneration uint64 `json:"quarantineGeneration"` + QuarantineEventRoot string `json:"quarantineEventRoot"` + QuarantineActiveRoot string `json:"quarantineActiveRoot"` + QuarantineTombstoneRoot string `json:"quarantineTombstoneRoot"` + Ancestry []frostRetainedGroupWireCheckpointCertificate `json:"ancestry"` + Complete bool `json:"complete"` +} + type frostActivationHandshakeState struct { ProtocolID string `json:"protocolID"` ReservationProtocolID string `json:"reservationProtocolID"` @@ -142,6 +160,7 @@ type frostActivationHandshakeState struct { FrostWalletGroupInventory frostActivationWalletGroupInventory `json:"frostWalletGroupInventory"` CanonicalJournal frostActivationCanonicalJournalState `json:"canonicalJournal"` QuarantineJournal frostActivationQuarantineJournalState `json:"quarantineJournal"` + CheckpointJournal frostActivationCheckpointJournalState `json:"checkpointJournal"` NativeSignerState frostActivationNativeSignerState `json:"nativeSignerState"` InteractiveSigningReady bool `json:"interactiveSigningReady"` FinalizedReservationReadbackEnforced bool `json:"finalizedReservationReadbackEnforced"` @@ -212,6 +231,9 @@ type frostActivationJournalStamp struct { quarantineRoot [32]byte quarantineActiveRoot [32]byte quarantineTombstoneRoot [32]byte + checkpointSequence uint64 + checkpointHash [32]byte + checkpointHistoryRoot [32]byte } type frostActivationReconciliationCache struct { @@ -273,7 +295,8 @@ func newFrostActivationHandshakeExporter( journal.quarantineMetadata.ManifestHash == manifest.ManifestHash && journal.quarantineMetadata.BindingHash == bindingHash && journal.state.BindingHash == bindingHash && - journal.quarantineState.BindingHash == bindingHash + journal.quarantineState.BindingHash == bindingHash && + journal.checkpointState.BindingHash == bindingHash journal.mutex.Unlock() if !journalBindingValid { return nil, fmt.Errorf( @@ -635,6 +658,14 @@ func (fahe *frostActivationHandshakeExporter) validateActivationJournalSnapshot( journalSnapshot.QuarantineRoot == [32]byte{} || journalSnapshot.QuarantineActiveRoot == [32]byte{} || journalSnapshot.QuarantineTombstoneRoot == [32]byte{} || + journalSnapshot.CheckpointMinimumSequence != + quarantineManifest.CheckpointMinimumSequence || + journalSnapshot.CheckpointPredecessorHash != + quarantineManifest.CheckpointPredecessorHash || + journalSnapshot.CheckpointSequence < + quarantineManifest.CheckpointMinimumSequence || + journalSnapshot.CheckpointCertificateHash == [32]byte{} || + journalSnapshot.CheckpointHistoryRoot == [32]byte{} || journalSnapshot.QuarantineCount != 0 { return fmt.Errorf( "canonical FROST retained-group journal is not activation-ready", @@ -663,7 +694,8 @@ func (fahe *frostActivationHandshakeExporter) journalStampLocked() ( journal.metadata.BindingHash != fahe.bindingHash || journal.quarantineMetadata.BindingHash != fahe.bindingHash || journal.state.BindingHash != fahe.bindingHash || - journal.quarantineState.BindingHash != fahe.bindingHash { + journal.quarantineState.BindingHash != fahe.bindingHash || + journal.checkpointState.BindingHash != fahe.bindingHash { return frostActivationJournalStamp{}, fmt.Errorf( "canonical FROST retained-group journal binding is not live", ) @@ -680,6 +712,9 @@ func (fahe *frostActivationHandshakeExporter) journalStampLocked() ( quarantineRoot: journal.quarantineState.Root, quarantineActiveRoot: journal.quarantineState.ActiveRoot, quarantineTombstoneRoot: journal.quarantineState.TombstoneRoot, + checkpointSequence: journal.checkpointState.Sequence, + checkpointHash: journal.checkpointState.CertificateHash, + checkpointHistoryRoot: journal.checkpointState.HistoryRoot, }, nil } @@ -698,7 +733,10 @@ func frostActivationStampMatchesSnapshot( stamp.quarantineGeneration == snapshot.QuarantineGeneration && stamp.quarantineRoot == snapshot.QuarantineRoot && stamp.quarantineActiveRoot == snapshot.QuarantineActiveRoot && - stamp.quarantineTombstoneRoot == snapshot.QuarantineTombstoneRoot + stamp.quarantineTombstoneRoot == snapshot.QuarantineTombstoneRoot && + stamp.checkpointSequence == snapshot.CheckpointSequence && + stamp.checkpointHash == snapshot.CheckpointCertificateHash && + stamp.checkpointHistoryRoot == snapshot.CheckpointHistoryRoot } func (fahe *frostActivationHandshakeExporter) verifyActivationPointQuick( @@ -792,6 +830,25 @@ func (fahe *frostActivationHandshakeExporter) attest( if err != nil || bindingHash != fahe.bindingHash { return nil, fmt.Errorf("FROST activation challenge binding hash mismatch") } + checkpointFloorHash, err := parseFrostActivationHex32( + request.Challenge.CheckpointFloor.CertificateHash, + ) + checkpointFloor := FrostRetainedGroupCheckpointCursor{ + Sequence: request.Challenge.CheckpointFloor.Sequence, + CertificateHash: checkpointFloorHash, + } + if err != nil || + request.Challenge.CheckpointFloor.CertificateHash != + frostActivationHex32(checkpointFloorHash) || + checkpointFloor.Sequence < + fahe.manifest.QuarantineJournal.CheckpointMinimumSequence || + checkpointFloor.Sequence > + frostRetainedGroupMaximumCanonicalJSONInteger || + checkpointFloorHash == [32]byte{} { + return nil, fmt.Errorf( + "FROST activation challenge checkpoint floor is invalid", + ) + } blockHash, err := parseFrostActivationHex32(request.Challenge.EthereumPoint.BlockHash) if err != nil || request.Challenge.EthereumPoint.BlockNumber == 0 { return nil, fmt.Errorf("FROST activation challenge Ethereum point is invalid") @@ -858,6 +915,39 @@ func (fahe *frostActivationHandshakeExporter) attest( ) } journalManifest := fahe.manifest.CanonicalJournal + if !fahe.journal.mutex.TryLock() { + return nil, fmt.Errorf( + "%w: %v", + errFrostActivationReconciliationPending, + errFrostActivationJournalBusy, + ) + } + ancestryStamp, ancestryStampErr := fahe.journalStampLocked() + if ancestryStampErr != nil || ancestryStamp != reconciliation.stamp { + fahe.journal.mutex.Unlock() + fahe.queueReconciliation(finality, true) + return nil, fmt.Errorf( + "%w: canonical or quarantine journal generation changed before checkpoint ancestry", + errFrostActivationReconciliationPending, + ) + } + checkpointAncestry, ancestryErr := + fahe.journal.checkpointAncestryAfter(checkpointFloor) + fahe.journal.mutex.Unlock() + if ancestryErr != nil { + return nil, fmt.Errorf( + "checkpoint ancestry rejected the external transparency floor: [%w]", + ancestryErr, + ) + } + wireCheckpointAncestry := make( + []frostRetainedGroupWireCheckpointCertificate, + len(checkpointAncestry), + ) + for index, certificate := range checkpointAncestry { + wireCheckpointAncestry[index] = + frostRetainedGroupCheckpointCertificateToWire(certificate) + } outboxSnapshot, err := fahe.outbox.activationSnapshot() if err != nil { return nil, err @@ -926,6 +1016,29 @@ func (fahe *frostActivationHandshakeExporter) attest( TombstoneCount: journalSnapshot.QuarantineTombstoneCount, Complete: true, }, + CheckpointJournal: frostActivationCheckpointJournalState{ + ManifestMinimumSequence: journalSnapshot.CheckpointMinimumSequence, + ManifestPredecessorHash: frostActivationHex32( + journalSnapshot.CheckpointPredecessorHash, + ), + ChallengeFloor: request.Challenge.CheckpointFloor, + DurableHead: frostRetainedGroupWireCheckpointCursor{ + Sequence: journalSnapshot.CheckpointSequence, + CertificateHash: frostActivationHex32( + journalSnapshot.CheckpointCertificateHash, + ), + }, + Point: request.Challenge.EthereumPoint, + HistoryRoot: frostActivationHex32(journalSnapshot.CheckpointHistoryRoot), + CanonicalGeneration: journalSnapshot.SnapshotGeneration, + CanonicalInventoryRoot: frostActivationHex32(journalSnapshot.InventoryRoot), + QuarantineGeneration: journalSnapshot.QuarantineGeneration, + QuarantineEventRoot: frostActivationHex32(journalSnapshot.QuarantineRoot), + QuarantineActiveRoot: frostActivationHex32(journalSnapshot.QuarantineActiveRoot), + QuarantineTombstoneRoot: frostActivationHex32(journalSnapshot.QuarantineTombstoneRoot), + Ancestry: wireCheckpointAncestry, + Complete: true, + }, NativeSignerState: frostActivationNativeSignerState{ Schema: nativeSignerSnapshot.Schema, StoreFingerprint: frostActivationHex32(nativeSignerSnapshot.StoreFingerprint), @@ -989,7 +1102,8 @@ func (fahe *frostActivationHandshakeExporter) attest( ) } signingStamp, stampErr := fahe.journalStampLocked() - if stampErr != nil || signingStamp != reconciliation.stamp { + if stampErr != nil || signingStamp != reconciliation.stamp || + !fahe.journal.checkpointDescendsFrom(checkpointFloor) { fahe.journal.mutex.Unlock() fahe.queueReconciliation(finality, true) return nil, fmt.Errorf( diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index 4d39ae30a4..c43bc05212 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -61,15 +61,18 @@ func (tfapv *testFrostActivationPointVerifier) setError(err error) { } type testFrostRetainedGroupHistorySource struct { - mutex sync.Mutex - manifest FrostRetainedGroupCanonicalJournalManifest - target FrostPreSignFinality - readCalls uint64 - readStarted chan struct{} - readRelease <-chan struct{} - readDeadline time.Time - hasDeadline bool - readOnce sync.Once + mutex sync.Mutex + manifest FrostRetainedGroupCanonicalJournalManifest + bindingHash [32]byte + checkpointHead FrostRetainedGroupCheckpointCursor + historyRoot [32]byte + target FrostPreSignFinality + readCalls uint64 + readStarted chan struct{} + readRelease <-chan struct{} + readDeadline time.Time + hasDeadline bool + readOnce sync.Once } type testFrostProductionSignerReadiness struct { @@ -167,11 +170,14 @@ func (source *testFrostRetainedGroupHistorySource) ReadCompleteHistory( ctx context.Context, from FrostPreSignFinality, to FrostPreSignFinality, + checkpointAfter FrostRetainedGroupCheckpointCursor, ) (*FrostRetainedGroupHistory, error) { source.mutex.Lock() source.readCalls++ readStarted := source.readStarted readRelease := source.readRelease + historyRoot := source.historyRoot + checkpointHead := source.checkpointHead source.readDeadline, source.hasDeadline = ctx.Deadline() source.mutex.Unlock() if readStarted != nil { @@ -187,11 +193,21 @@ func (source *testFrostRetainedGroupHistorySource) ReadCompleteHistory( } } return &FrostRetainedGroupHistory{ - From: from, - To: to, - Complete: true, - EmptyAtFrom: true, - DescriptorSetHash: source.manifest.DescriptorSetHash, + From: from, + To: to, + HistoryRoot: historyRoot, + CheckpointAfter: checkpointAfter, + Checkpoints: []FrostRetainedGroupCheckpointCertificate{}, + CheckpointChainRoot: frostRetainedGroupCheckpointChainRoot( + source.bindingHash, + checkpointAfter, + nil, + ), + CheckpointTipHash: checkpointHead.CertificateHash, + CheckpointComplete: true, + Complete: true, + EmptyAtFrom: true, + DescriptorSetHash: source.manifest.DescriptorSetHash, }, nil } @@ -291,6 +307,12 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { ManifestHash: frostActivationHex32(manifest.ManifestHash), BindingHash: frostActivationHex32(journal.metadata.BindingHash), EthereumPoint: point, + CheckpointFloor: frostRetainedGroupWireCheckpointCursor{ + Sequence: journal.checkpointState.Sequence, + CertificateHash: frostActivationHex32( + journal.checkpointState.CertificateHash, + ), + }, }, } response := postTestFrostActivationHandshake(t, endpoint, request) @@ -347,7 +369,7 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { } assertFrostActivationObjectKeys(t, handshake.Payload.State, []string{ "authorizationRegistryAddress", "bitcoinOutboxProtocolID", "canonicalJournal", - "completeRouterAddress", "durableBitcoinOutboxRecovered", "durableSessionStoreFingerprint", + "checkpointJournal", "completeRouterAddress", "durableBitcoinOutboxRecovered", "durableSessionStoreFingerprint", "exactTransactionAuthorizationRootEnforced", "finalizedReservationReadbackEnforced", "frostWalletGroupInventory", "healthy", "maximumGroupSize", "nonceShareGateEnforced", "interactiveSigningReady", "nativeSignerState", @@ -379,6 +401,29 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { "stateImageDigest", "storeFingerprint", "trustCertificateDigest", "trustCertificateSequence", }) + assertFrostActivationObjectKeys(t, handshake.Payload.State.CheckpointJournal, []string{ + "ancestry", "canonicalGeneration", "canonicalInventoryRoot", "challengeFloor", + "complete", "durableHead", "historyRoot", "manifestMinimumSequence", + "manifestPredecessorHash", "point", "quarantineActiveRoot", + "quarantineEventRoot", "quarantineGeneration", "quarantineTombstoneRoot", + }) + unknownFloor := request + unknownFloor.Challenge.CheckpointFloor.CertificateHash = + frostActivationHex32([32]byte{0xff}) + unknownResponse := postTestFrostActivationHandshake( + t, + endpoint, + unknownFloor, + ) + defer unknownResponse.Body.Close() + if unknownResponse.StatusCode != http.StatusServiceUnavailable || + unknownResponse.Header.Get("Retry-After") != "" { + t.Fatalf( + "unknown external checkpoint floor returned [%d] with retry [%s]", + unknownResponse.StatusCode, + unknownResponse.Header.Get("Retry-After"), + ) + } } func TestFrostActivationHandshakeExporter_PermitsAndAttestsTombstones( @@ -388,7 +433,7 @@ func TestFrostActivationHandshakeExporter_PermitsAndAttestsTombstones( BlockNumber: 123, BlockHash: frostActivationHex32([32]byte{0x44}), } - _, journal, _, _, endpoint, request := + _, journal, source, _, endpoint, request := startTestFrostActivationHandshakeExporter(t, point) raisedRecord := FrostRetainedGroupQuarantineRaisedRecord{ QuarantineID: [32]byte{0x51}, @@ -455,6 +500,13 @@ func TestFrostActivationHandshakeExporter_PermitsAndAttestsTombstones( if err != nil { t.Fatal(err) } + recertifyTestFrostActivationJournal( + t, + journal, + source, + &request, + journal.checkpointPolicy.MinimumSequence, + ) response := postTestFrostActivationHandshake(t, endpoint, request) if response.StatusCode != http.StatusServiceUnavailable || @@ -574,6 +626,12 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { ManifestHash: frostActivationHex32(manifest.ManifestHash), BindingHash: frostActivationHex32(journal.metadata.BindingHash), EthereumPoint: point, + CheckpointFloor: frostRetainedGroupWireCheckpointCursor{ + Sequence: journal.checkpointState.Sequence, + CertificateHash: frostActivationHex32( + journal.checkpointState.CertificateHash, + ), + }, }, } response := postTestFrostActivationHandshake(t, endpoint, request) @@ -663,6 +721,19 @@ func TestFrostActivationHandshakeExporter_RejectsUnboundOrLegacyChallenge( "different binding": func(request *frostActivationHandshakeRequest) { request.Challenge.BindingHash = frostActivationHex32([32]byte{0xff}) }, + "missing checkpoint floor": func(request *frostActivationHandshakeRequest) { + request.Challenge.CheckpointFloor = + frostRetainedGroupWireCheckpointCursor{} + }, + "uncertified manifest predecessor": func( + request *frostActivationHandshakeRequest, + ) { + request.Challenge.CheckpointFloor = + frostRetainedGroupWireCheckpointCursor{ + Sequence: 0, + CertificateHash: frostActivationHex32([32]byte{}), + } + }, } for name, mutate := range testCases { t.Run(name, func(t *testing.T) { @@ -783,6 +854,12 @@ func TestFrostActivationHandshakeExporter_ReconciliationIsAsynchronousAndGenerat journal.mutex.Lock() journal.state.SnapshotGeneration++ canonicalGeneration := journal.state.SnapshotGeneration + canonicalPoint := FrostPreSignFinality{ + BlockNumber: point.BlockNumber + 1, + BlockHash: [32]byte{0x45}, + } + journal.state.CurrentPoint = canonicalPoint + journal.quarantineState.CurrentPoint = canonicalPoint var rootErr error journal.state.InventoryRoot, _, _, _, rootErr = frostRetainedGroupInventoryRoot(journal.state) @@ -790,6 +867,18 @@ func TestFrostActivationHandshakeExporter_ReconciliationIsAsynchronousAndGenerat if rootErr != nil { t.Fatal(rootErr) } + source.setTarget(canonicalPoint) + request.Challenge.EthereumPoint = frostActivationEthereumPoint{ + BlockNumber: canonicalPoint.BlockNumber, + BlockHash: frostActivationHex32(canonicalPoint.BlockHash), + } + recertifyTestFrostActivationJournal( + t, + journal, + source, + &request, + journal.checkpointState.Sequence+1, + ) response = postTestFrostActivationHandshake(t, endpoint, request) if response.StatusCode != http.StatusServiceUnavailable || response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { @@ -820,7 +909,30 @@ func TestFrostActivationHandshakeExporter_ReconciliationIsAsynchronousAndGenerat journal.mutex.Lock() journal.quarantineState.Generation++ quarantineGeneration := journal.quarantineState.Generation + quarantinePoint := FrostPreSignFinality{ + BlockNumber: canonicalPoint.BlockNumber + 1, + BlockHash: [32]byte{0x46}, + } + journal.state.CurrentPoint = quarantinePoint + journal.quarantineState.CurrentPoint = quarantinePoint + journal.state.InventoryRoot, _, _, _, rootErr = + frostRetainedGroupInventoryRoot(journal.state) journal.mutex.Unlock() + if rootErr != nil { + t.Fatal(rootErr) + } + source.setTarget(quarantinePoint) + request.Challenge.EthereumPoint = frostActivationEthereumPoint{ + BlockNumber: quarantinePoint.BlockNumber, + BlockHash: frostActivationHex32(quarantinePoint.BlockHash), + } + recertifyTestFrostActivationJournal( + t, + journal, + source, + &request, + journal.checkpointState.Sequence+1, + ) response = postTestFrostActivationHandshake(t, endpoint, request) if response.StatusCode != http.StatusServiceUnavailable || response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { @@ -849,8 +961,8 @@ func TestFrostActivationHandshakeExporter_ReconciliationIsAsynchronousAndGenerat } nextPoint := FrostPreSignFinality{ - BlockNumber: point.BlockNumber + 1, - BlockHash: [32]byte{0x45}, + BlockNumber: quarantinePoint.BlockNumber + 1, + BlockHash: [32]byte{0x47}, } source.setTarget(nextPoint) journal.mutex.Lock() @@ -866,6 +978,13 @@ func TestFrostActivationHandshakeExporter_ReconciliationIsAsynchronousAndGenerat BlockNumber: nextPoint.BlockNumber, BlockHash: frostActivationHex32(nextPoint.BlockHash), } + recertifyTestFrostActivationJournal( + t, + journal, + source, + &request, + journal.checkpointState.Sequence+1, + ) response = postTestFrostActivationHandshake(t, endpoint, request) if response.StatusCode != http.StatusServiceUnavailable || response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { @@ -1012,7 +1131,9 @@ func testFrostActivationRuntimeManifest( {AuthorityID: "checkpoint-2", PublicKeySPKIHash: [32]byte{0x41}}, {AuthorityID: "checkpoint-3", PublicKeySPKIHash: [32]byte{0x42}}, }, - LiftAuthorityThreshold: 2, + CheckpointMinimumSequence: 1, + CheckpointPredecessorHash: [32]byte{}, + LiftAuthorityThreshold: 2, LiftAuthorities: []FrostRetainedGroupAuthority{ {AuthorityID: "lift-1", PublicKeySPKIHash: [32]byte{0x43}}, {AuthorityID: "lift-2", PublicKeySPKIHash: [32]byte{0x44}}, @@ -1103,6 +1224,12 @@ func startTestFrostActivationHandshakeExporter( ManifestHash: frostActivationHex32(manifest.ManifestHash), BindingHash: frostActivationHex32(journal.metadata.BindingHash), EthereumPoint: point, + CheckpointFloor: frostRetainedGroupWireCheckpointCursor{ + Sequence: journal.checkpointState.Sequence, + CertificateHash: frostActivationHex32( + journal.checkpointState.CertificateHash, + ), + }, }, } } @@ -1152,9 +1279,34 @@ func testFrostRetainedGroupJournal( if err != nil { t.Fatal(err) } + checkpointPolicy, err := + frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + bindingHash, + manifest, + ) + if err != nil { + t.Fatal(err) + } + historyRoot, err := frostRetainedGroupTestHistoryRoot( + bindingHash, + manifest.CanonicalJournal.Checkpoint, + target, + nil, + ) + if err != nil { + t.Fatal(err) + } + checkpointHash := [32]byte{0x55} + checkpointHead := FrostRetainedGroupCheckpointCursor{ + Sequence: manifest.QuarantineJournal.CheckpointMinimumSequence, + CertificateHash: checkpointHash, + } source := &testFrostRetainedGroupHistorySource{ - manifest: manifest.CanonicalJournal, - target: target, + manifest: manifest.CanonicalJournal, + bindingHash: bindingHash, + checkpointHead: checkpointHead, + historyRoot: historyRoot, + target: target, } return &frostRetainedGroupJournal{ metadata: frostRetainedGroupJournalMetadata{ @@ -1196,6 +1348,25 @@ func testFrostRetainedGroupJournal( state: state, liftPolicy: liftPolicy, liftCertificates: make(map[[32]byte]FrostRetainedGroupQuarantineLiftCertificate), + checkpointPolicy: checkpointPolicy, + checkpointState: frostRetainedGroupCheckpointJournalState{ + Schema: frostRetainedGroupCheckpointStateSchema, + BindingHash: bindingHash, + Sequence: checkpointHead.Sequence, + CertificateHash: checkpointHead.CertificateHash, + Point: target, + HistoryRoot: historyRoot, + CanonicalGeneration: state.SnapshotGeneration, + CanonicalInventoryRoot: state.InventoryRoot, + QuarantineGeneration: 0, + QuarantineEventRoot: quarantineRoot, + QuarantineActiveRoot: activeRoot, + QuarantineTombstoneRoot: tombstoneRoot, + }, + checkpointCertificates: make(map[uint64]FrostRetainedGroupCheckpointCertificate), + checkpointHashes: map[uint64][32]byte{ + checkpointHead.Sequence: checkpointHead.CertificateHash, + }, quarantineState: frostRetainedGroupQuarantineJournalState{ Schema: frostRetainedGroupQuarantineStateSchema, BindingHash: bindingHash, @@ -1209,6 +1380,85 @@ func testFrostRetainedGroupJournal( } } +func recertifyTestFrostActivationJournal( + t *testing.T, + journal *frostRetainedGroupJournal, + source *testFrostRetainedGroupHistorySource, + request *frostActivationHandshakeRequest, + sequence uint64, +) { + t.Helper() + journal.mutex.Lock() + historyRoot, err := frostRetainedGroupTestHistoryRoot( + journal.metadata.BindingHash, + journal.metadata.Checkpoint, + journal.state.CurrentPoint, + nil, + ) + if err != nil { + journal.mutex.Unlock() + t.Fatal(err) + } + commitment := struct { + Sequence uint64 + Point FrostPreSignFinality + HistoryRoot [32]byte + CanonicalGeneration uint64 + CanonicalInventoryRoot [32]byte + QuarantineGeneration uint64 + QuarantineEventRoot [32]byte + QuarantineActiveRoot [32]byte + QuarantineTombstoneRoot [32]byte + }{ + Sequence: sequence, + Point: journal.state.CurrentPoint, + HistoryRoot: historyRoot, + CanonicalGeneration: journal.state.SnapshotGeneration, + CanonicalInventoryRoot: journal.state.InventoryRoot, + QuarantineGeneration: journal.quarantineState.Generation, + QuarantineEventRoot: journal.quarantineState.Root, + QuarantineActiveRoot: journal.quarantineState.ActiveRoot, + QuarantineTombstoneRoot: journal.quarantineState.TombstoneRoot, + } + certificateHash, err := frostRetainedGroupDomainHash( + "test-frost-checkpoint-certificate-v1\x00", + commitment, + ) + if err != nil { + journal.mutex.Unlock() + t.Fatal(err) + } + journal.checkpointState = frostRetainedGroupCheckpointJournalState{ + Schema: frostRetainedGroupCheckpointStateSchema, + BindingHash: journal.metadata.BindingHash, + Sequence: sequence, + CertificateHash: certificateHash, + Point: journal.state.CurrentPoint, + HistoryRoot: historyRoot, + CanonicalGeneration: journal.state.SnapshotGeneration, + CanonicalInventoryRoot: journal.state.InventoryRoot, + QuarantineGeneration: journal.quarantineState.Generation, + QuarantineEventRoot: journal.quarantineState.Root, + QuarantineActiveRoot: journal.quarantineState.ActiveRoot, + QuarantineTombstoneRoot: journal.quarantineState.TombstoneRoot, + } + journal.checkpointHashes[sequence] = certificateHash + journal.mutex.Unlock() + + source.mutex.Lock() + source.historyRoot = historyRoot + source.checkpointHead = FrostRetainedGroupCheckpointCursor{ + Sequence: sequence, + CertificateHash: certificateHash, + } + source.mutex.Unlock() + request.Challenge.CheckpointFloor = + frostRetainedGroupWireCheckpointCursor{ + Sequence: sequence, + CertificateHash: frostActivationHex32(certificateHash), + } +} + func testLoopbackEndpoint(t *testing.T) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/pkg/tbtc/frost_retained_group_checkpoint.go b/pkg/tbtc/frost_retained_group_checkpoint.go new file mode 100644 index 0000000000..8f96b1d933 --- /dev/null +++ b/pkg/tbtc/frost_retained_group_checkpoint.go @@ -0,0 +1,1445 @@ +package tbtc + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "fmt" + "os" + "sort" + "strings" +) + +const ( + frostRetainedGroupCheckpointBodySchema = "tbtc-frost-retained-group-checkpoint-body/v1" + frostRetainedGroupCheckpointCertificateSchema = "tbtc-frost-retained-group-checkpoint-certificate/v1" + frostRetainedGroupCheckpointMetadataSchema = "tbtc-frost-retained-group-checkpoint-metadata/v1" + frostRetainedGroupCheckpointStateSchema = "tbtc-frost-retained-group-checkpoint-state/v1" + + frostRetainedGroupCheckpointBodyDomain = "tbtc-frost-retained-group-checkpoint-body-v1\x00" + frostRetainedGroupCheckpointSignatureDomain = "tbtc-frost-retained-group-checkpoint-signature-v1\x00" + frostRetainedGroupCheckpointCertificateDomain = "tbtc-frost-retained-group-checkpoint-certificate-v1\x00" + frostRetainedGroupCheckpointChainDomain = "tbtc-frost-retained-group-checkpoint-chain-v1\x00" + + frostRetainedGroupCheckpointMetadataFile = "metadata.json" + frostRetainedGroupCheckpointStateFile = "state.json" + frostRetainedGroupCheckpointFilePrefix = "certificate-" + // A recovery page is deliberately bounded, while a complete certificate + // chain is not. The journal can durably advance through multiple pages and + // resume from the last authenticated certificate after a crash or timeout. + frostRetainedGroupMaximumCheckpointsPerPage = 256 + frostRetainedGroupMaximumCheckpointPages = 16 + // Activation verifiers must obtain a fresh rollback-independent floor from + // the transparency channel. An arbitrarily stale caller-supplied floor + // cannot force the signer to allocate and serialize its entire lifetime + // history. This remains above the old 256-certificate recovery limit. + frostRetainedGroupMaximumHandshakeAncestry = 512 + frostRetainedGroupCheckpointDirectory = "checkpoints" +) + +// FrostRetainedGroupCheckpointCursor is the rollback-independent head after +// which a history source must return a cryptographically contiguous suffix. +// Sequence zero and a zero digest denote the manifest's sequence-one genesis +// predecessor. +type FrostRetainedGroupCheckpointCursor struct { + Sequence uint64 + CertificateHash [32]byte +} + +// FrostRetainedGroupCheckpointBody commits a quorum to one deterministic +// semantic retained-group state. Per-node batch roots are deliberately +// excluded: nodes can reconcile at different intervals while deriving the +// same inventory and quarantine roots from the same complete history. +type FrostRetainedGroupCheckpointBody struct { + Schema string + ProtocolBindingHash [32]byte + ManifestHash [32]byte + ProfileHash [32]byte + ImplementationSetHash [32]byte + ChainID uint64 + DomainChainID [32]byte + GenesisBlockHash [32]byte + AuthoritySetHash [32]byte + Sequence uint64 + PreviousCertificateHash [32]byte + Point FrostPreSignFinality + HistoryRoot [32]byte + CanonicalGeneration uint64 + CanonicalInventoryRoot [32]byte + QuarantineGeneration uint64 + QuarantineEventRoot [32]byte + QuarantineActiveRoot [32]byte + QuarantineTombstoneRoot [32]byte +} + +type FrostRetainedGroupCheckpointSignature struct { + AuthorityID string + SignerPublicKeySPKI string + Signature string +} + +type FrostRetainedGroupCheckpointCertificate struct { + Schema string + Body FrostRetainedGroupCheckpointBody + BodyHash [32]byte + Signatures []FrostRetainedGroupCheckpointSignature +} + +type frostRetainedGroupWireCheckpointBody struct { + Schema string `json:"schema"` + ProtocolBindingHash string `json:"protocolBindingHash"` + ManifestHash string `json:"manifestHash"` + ProfileHash string `json:"profileHash"` + ImplementationSetHash string `json:"implementationSetHash"` + ChainID uint64 `json:"chainID"` + DomainChainID string `json:"domainChainID"` + GenesisBlockHash string `json:"genesisBlockHash"` + AuthoritySetHash string `json:"authoritySetHash"` + Sequence uint64 `json:"sequence"` + PreviousCertificateHash string `json:"previousCertificateHash"` + Point frostRetainedGroupWireFinality `json:"point"` + HistoryRoot string `json:"historyRoot"` + CanonicalGeneration uint64 `json:"canonicalGeneration"` + CanonicalInventoryRoot string `json:"canonicalInventoryRoot"` + QuarantineGeneration uint64 `json:"quarantineGeneration"` + QuarantineEventRoot string `json:"quarantineEventRoot"` + QuarantineActiveRoot string `json:"quarantineActiveRoot"` + QuarantineTombstoneRoot string `json:"quarantineTombstoneRoot"` +} + +type frostRetainedGroupWireCheckpointSignature struct { + AuthorityID string `json:"authorityID"` + SignerPublicKeySPKI string `json:"signerPublicKeySpki"` + Signature string `json:"signature"` +} + +type frostRetainedGroupWireCheckpointCertificate struct { + Schema string `json:"schema"` + Body frostRetainedGroupWireCheckpointBody `json:"body"` + BodyHash string `json:"bodyHash"` + Signatures []frostRetainedGroupWireCheckpointSignature `json:"signatures"` +} + +type frostRetainedGroupCheckpointPolicy struct { + ProtocolBindingHash [32]byte + ManifestHash [32]byte + ProfileHash [32]byte + ImplementationSetHash [32]byte + ChainID uint64 + DomainChainID [32]byte + GenesisBlockHash [32]byte + AuthoritySetHash [32]byte + AuthorityThreshold uint64 + Authorities []FrostRetainedGroupAuthority + MinimumSequence uint64 + PredecessorHash [32]byte + CanonicalMinimum uint64 + QuarantineMinimum uint64 + LiftPolicy frostRetainedGroupQuarantineLiftPolicy +} + +type frostRetainedGroupCheckpointMetadata struct { + Schema string `json:"schema"` + ManifestHash [32]byte `json:"manifestHash"` + BindingHash [32]byte `json:"bindingHash"` + AuthoritySetHash [32]byte `json:"authoritySetHash"` + AuthorityThreshold uint64 `json:"authorityThreshold"` + Authorities []FrostRetainedGroupAuthority `json:"authorities"` + MinimumSequence uint64 `json:"minimumSequence"` + PredecessorHash [32]byte `json:"predecessorHash"` +} + +type frostRetainedGroupCheckpointJournalState struct { + Schema string `json:"schema"` + BindingHash [32]byte `json:"bindingHash"` + Sequence uint64 `json:"sequence"` + CertificateHash [32]byte `json:"certificateHash"` + Point FrostPreSignFinality `json:"point"` + HistoryRoot [32]byte `json:"historyRoot"` + CanonicalGeneration uint64 `json:"canonicalGeneration"` + CanonicalInventoryRoot [32]byte `json:"canonicalInventoryRoot"` + QuarantineGeneration uint64 `json:"quarantineGeneration"` + QuarantineEventRoot [32]byte `json:"quarantineEventRoot"` + QuarantineActiveRoot [32]byte `json:"quarantineActiveRoot"` + QuarantineTombstoneRoot [32]byte `json:"quarantineTombstoneRoot"` +} + +func frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + bindingHash [32]byte, + runtimeManifest FrostPreSignActivationRuntimeManifest, +) (frostRetainedGroupCheckpointPolicy, error) { + quarantine := runtimeManifest.QuarantineJournal + authoritySetHash, err := frostRetainedGroupAuthoritySetHash( + "tbtc-frost-retained-group-checkpoint-authority-set/v1", + quarantine.CheckpointAuthorityThreshold, + quarantine.CheckpointAuthorities, + ) + if err != nil { + return frostRetainedGroupCheckpointPolicy{}, err + } + if bindingHash == [32]byte{} || + runtimeManifest.ManifestHash == [32]byte{} || + runtimeManifest.ProfileHash == [32]byte{} || + runtimeManifest.ImplementationSetHash == [32]byte{} || + runtimeManifest.DomainChainID == [32]byte{} || + runtimeManifest.GenesisBlockHash == [32]byte{} || + quarantine.CheckpointMinimumSequence == 0 || + quarantine.CheckpointMinimumSequence > + frostRetainedGroupMaximumCanonicalJSONInteger || + (quarantine.CheckpointMinimumSequence == 1 && + quarantine.CheckpointPredecessorHash != [32]byte{}) || + (quarantine.CheckpointMinimumSequence > 1 && + quarantine.CheckpointPredecessorHash == [32]byte{}) { + return frostRetainedGroupCheckpointPolicy{}, fmt.Errorf( + "FROST retained-group checkpoint policy is incomplete", + ) + } + for _, value := range runtimeManifest.DomainChainID[:24] { + if value != 0 { + return frostRetainedGroupCheckpointPolicy{}, fmt.Errorf( + "FROST checkpoint chain ID exceeds uint64", + ) + } + } + chainID := uint64(0) + for _, value := range runtimeManifest.DomainChainID[24:] { + chainID = (chainID << 8) | uint64(value) + } + if chainID == 0 { + return frostRetainedGroupCheckpointPolicy{}, fmt.Errorf( + "FROST checkpoint chain ID is zero", + ) + } + liftPolicy, err := frostRetainedGroupLiftPolicyFromRuntimeManifest( + bindingHash, + runtimeManifest, + ) + if err != nil { + return frostRetainedGroupCheckpointPolicy{}, err + } + return frostRetainedGroupCheckpointPolicy{ + ProtocolBindingHash: bindingHash, + ManifestHash: runtimeManifest.ManifestHash, + ProfileHash: runtimeManifest.ProfileHash, + ImplementationSetHash: runtimeManifest.ImplementationSetHash, + ChainID: chainID, + DomainChainID: runtimeManifest.DomainChainID, + GenesisBlockHash: runtimeManifest.GenesisBlockHash, + AuthoritySetHash: authoritySetHash, + AuthorityThreshold: quarantine.CheckpointAuthorityThreshold, + Authorities: append( + []FrostRetainedGroupAuthority{}, + quarantine.CheckpointAuthorities..., + ), + MinimumSequence: quarantine.CheckpointMinimumSequence, + PredecessorHash: quarantine.CheckpointPredecessorHash, + CanonicalMinimum: runtimeManifest.CanonicalJournal.MinimumGeneration, + QuarantineMinimum: quarantine.MinimumGeneration, + LiftPolicy: liftPolicy, + }, nil +} + +func frostRetainedGroupCheckpointCertificateToWire( + certificate FrostRetainedGroupCheckpointCertificate, +) frostRetainedGroupWireCheckpointCertificate { + body := certificate.Body + signatures := make( + []frostRetainedGroupWireCheckpointSignature, + len(certificate.Signatures), + ) + for index, signature := range certificate.Signatures { + signatures[index] = frostRetainedGroupWireCheckpointSignature{ + AuthorityID: signature.AuthorityID, + SignerPublicKeySPKI: signature.SignerPublicKeySPKI, + Signature: signature.Signature, + } + } + return frostRetainedGroupWireCheckpointCertificate{ + Schema: certificate.Schema, + Body: frostRetainedGroupWireCheckpointBody{ + Schema: body.Schema, + ProtocolBindingHash: frostActivationHex32(body.ProtocolBindingHash), + ManifestHash: frostActivationHex32(body.ManifestHash), + ProfileHash: frostActivationHex32(body.ProfileHash), + ImplementationSetHash: frostActivationHex32(body.ImplementationSetHash), + ChainID: body.ChainID, + DomainChainID: frostActivationHex32(body.DomainChainID), + GenesisBlockHash: frostActivationHex32(body.GenesisBlockHash), + AuthoritySetHash: frostActivationHex32(body.AuthoritySetHash), + Sequence: body.Sequence, + PreviousCertificateHash: frostActivationHex32(body.PreviousCertificateHash), + Point: frostRetainedGroupFinalityToWire(body.Point), + HistoryRoot: frostActivationHex32(body.HistoryRoot), + CanonicalGeneration: body.CanonicalGeneration, + CanonicalInventoryRoot: frostActivationHex32(body.CanonicalInventoryRoot), + QuarantineGeneration: body.QuarantineGeneration, + QuarantineEventRoot: frostActivationHex32(body.QuarantineEventRoot), + QuarantineActiveRoot: frostActivationHex32(body.QuarantineActiveRoot), + QuarantineTombstoneRoot: frostActivationHex32(body.QuarantineTombstoneRoot), + }, + BodyHash: frostActivationHex32(certificate.BodyHash), + Signatures: signatures, + } +} + +func frostRetainedGroupCheckpointCertificateFromWire( + wire frostRetainedGroupWireCheckpointCertificate, +) (FrostRetainedGroupCheckpointCertificate, error) { + parse := func(name string, value string) ([32]byte, error) { + result, err := parseFrostActivationHex32(value) + if err != nil { + return [32]byte{}, fmt.Errorf( + "invalid FROST checkpoint %s: [%w]", + name, + err, + ) + } + return result, nil + } + protocolBindingHash, err := parse( + "protocol binding hash", + wire.Body.ProtocolBindingHash, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + manifestHash, err := parse("manifest hash", wire.Body.ManifestHash) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + profileHash, err := parse("profile hash", wire.Body.ProfileHash) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + implementationSetHash, err := parse( + "implementation set hash", + wire.Body.ImplementationSetHash, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + domainChainID, err := parse("domain chain ID", wire.Body.DomainChainID) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + genesisBlockHash, err := parse( + "genesis block hash", + wire.Body.GenesisBlockHash, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + authoritySetHash, err := parse( + "authority set hash", + wire.Body.AuthoritySetHash, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + previousCertificateHash, err := parse( + "previous certificate hash", + wire.Body.PreviousCertificateHash, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + point, err := frostRetainedGroupFinalityFromWire(wire.Body.Point) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "invalid FROST checkpoint point: [%w]", + err, + ) + } + historyRoot, err := parse("history root", wire.Body.HistoryRoot) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + canonicalInventoryRoot, err := parse( + "canonical inventory root", + wire.Body.CanonicalInventoryRoot, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + quarantineEventRoot, err := parse( + "quarantine event root", + wire.Body.QuarantineEventRoot, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + quarantineActiveRoot, err := parse( + "quarantine active root", + wire.Body.QuarantineActiveRoot, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + quarantineTombstoneRoot, err := parse( + "quarantine tombstone root", + wire.Body.QuarantineTombstoneRoot, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + bodyHash, err := parse("body hash", wire.BodyHash) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + signatures := make( + []FrostRetainedGroupCheckpointSignature, + len(wire.Signatures), + ) + for index, signature := range wire.Signatures { + signatures[index] = FrostRetainedGroupCheckpointSignature{ + AuthorityID: signature.AuthorityID, + SignerPublicKeySPKI: signature.SignerPublicKeySPKI, + Signature: signature.Signature, + } + } + return FrostRetainedGroupCheckpointCertificate{ + Schema: wire.Schema, + Body: FrostRetainedGroupCheckpointBody{ + Schema: wire.Body.Schema, + ProtocolBindingHash: protocolBindingHash, + ManifestHash: manifestHash, + ProfileHash: profileHash, + ImplementationSetHash: implementationSetHash, + ChainID: wire.Body.ChainID, + DomainChainID: domainChainID, + GenesisBlockHash: genesisBlockHash, + AuthoritySetHash: authoritySetHash, + Sequence: wire.Body.Sequence, + PreviousCertificateHash: previousCertificateHash, + Point: point, + HistoryRoot: historyRoot, + CanonicalGeneration: wire.Body.CanonicalGeneration, + CanonicalInventoryRoot: canonicalInventoryRoot, + QuarantineGeneration: wire.Body.QuarantineGeneration, + QuarantineEventRoot: quarantineEventRoot, + QuarantineActiveRoot: quarantineActiveRoot, + QuarantineTombstoneRoot: quarantineTombstoneRoot, + }, + BodyHash: bodyHash, + Signatures: signatures, + }, nil +} + +func frostRetainedGroupCheckpointBodyHash( + body FrostRetainedGroupCheckpointBody, +) ([32]byte, error) { + if body.Schema != frostRetainedGroupCheckpointBodySchema { + return [32]byte{}, fmt.Errorf( + "unsupported FROST checkpoint body schema", + ) + } + wire := frostRetainedGroupCheckpointCertificateToWire( + FrostRetainedGroupCheckpointCertificate{Body: body}, + ) + return frostRetainedGroupDomainHash( + frostRetainedGroupCheckpointBodyDomain, + wire.Body, + ) +} + +func frostRetainedGroupCheckpointSignatureHash( + bodyHash [32]byte, +) [32]byte { + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupCheckpointSignatureDomain)) + hasher.Write(bodyHash[:]) + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result +} + +func frostRetainedGroupCheckpointCertificateHash( + certificate FrostRetainedGroupCheckpointCertificate, +) ([32]byte, error) { + return frostRetainedGroupDomainHash( + frostRetainedGroupCheckpointCertificateDomain, + frostRetainedGroupCheckpointCertificateToWire(certificate), + ) +} + +func validateFrostRetainedGroupCheckpointCertificateShape( + policy frostRetainedGroupCheckpointPolicy, + certificate FrostRetainedGroupCheckpointCertificate, +) ([32]byte, error) { + if certificate.Schema != frostRetainedGroupCheckpointCertificateSchema || + certificate.BodyHash == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint certificate has an unsupported schema", + ) + } + bodyHash, err := frostRetainedGroupCheckpointBodyHash(certificate.Body) + if err != nil || bodyHash != certificate.BodyHash { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint certificate body hash mismatch", + ) + } + body := certificate.Body + if body.ProtocolBindingHash != policy.ProtocolBindingHash || + body.ManifestHash != policy.ManifestHash || + body.ProfileHash != policy.ProfileHash || + body.ImplementationSetHash != policy.ImplementationSetHash || + body.ChainID != policy.ChainID || + body.DomainChainID != policy.DomainChainID || + body.GenesisBlockHash != policy.GenesisBlockHash || + body.AuthoritySetHash != policy.AuthoritySetHash { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint certificate differs from the signed production policy", + ) + } + if body.Sequence == 0 || + body.Sequence > frostRetainedGroupMaximumCanonicalJSONInteger || + body.Point.BlockNumber == 0 || + body.Point.BlockNumber > frostRetainedGroupMaximumCanonicalJSONInteger || + body.Point.BlockHash == [32]byte{} || + body.HistoryRoot == [32]byte{} || + body.CanonicalGeneration < policy.CanonicalMinimum || + body.CanonicalGeneration > frostRetainedGroupMaximumCanonicalJSONInteger || + body.CanonicalInventoryRoot == [32]byte{} || + body.QuarantineGeneration < policy.QuarantineMinimum || + body.QuarantineGeneration > frostRetainedGroupMaximumCanonicalJSONInteger || + body.QuarantineEventRoot == [32]byte{} || + body.QuarantineActiveRoot == [32]byte{} || + body.QuarantineTombstoneRoot == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint body is incomplete or outside canonical bounds", + ) + } + if uint64(len(certificate.Signatures)) < policy.AuthorityThreshold || + len(certificate.Signatures) > len(policy.Authorities) { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint certificate does not carry the required quorum", + ) + } + authorityByID := make( + map[string]FrostRetainedGroupAuthority, + len(policy.Authorities), + ) + for _, authority := range policy.Authorities { + authorityByID[authority.AuthorityID] = authority + } + signatureHash := frostRetainedGroupCheckpointSignatureHash(bodyHash) + previousID := "" + for index, signature := range certificate.Signatures { + if !validFrostRetainedGroupAuthorityID(signature.AuthorityID) || + (index > 0 && signature.AuthorityID <= previousID) { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint signatures are not strictly sorted and unique", + ) + } + previousID = signature.AuthorityID + authority, known := authorityByID[signature.AuthorityID] + if !known { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint certificate contains unknown authority [%s]", + signature.AuthorityID, + ) + } + if len(signature.SignerPublicKeySPKI) > 2048 || + len(signature.Signature) > 128 { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint authority [%s] credential exceeds its bound", + signature.AuthorityID, + ) + } + publicKeyDER, err := base64.StdEncoding.Strict().DecodeString( + signature.SignerPublicKeySPKI, + ) + if err != nil || len(publicKeyDER) == 0 || + len(publicKeyDER) > 1024 || + base64.StdEncoding.EncodeToString(publicKeyDER) != + signature.SignerPublicKeySPKI || + sha256.Sum256(publicKeyDER) != authority.PublicKeySPKIHash { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint authority [%s] supplied an unpinned key", + signature.AuthorityID, + ) + } + parsedPublicKey, err := x509.ParsePKIXPublicKey(publicKeyDER) + if err != nil { + return [32]byte{}, fmt.Errorf( + "cannot parse FROST checkpoint authority [%s] key: [%w]", + signature.AuthorityID, + err, + ) + } + publicKey, ok := parsedPublicKey.(ed25519.PublicKey) + if !ok || len(publicKey) != ed25519.PublicKeySize { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint authority [%s] key is not Ed25519", + signature.AuthorityID, + ) + } + signatureBytes, err := base64.StdEncoding.Strict().DecodeString( + signature.Signature, + ) + if err != nil || len(signatureBytes) != ed25519.SignatureSize || + base64.StdEncoding.EncodeToString(signatureBytes) != + signature.Signature || + !ed25519.Verify(publicKey, signatureHash[:], signatureBytes) { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint authority [%s] signature is invalid", + signature.AuthorityID, + ) + } + } + certificateHash, err := frostRetainedGroupCheckpointCertificateHash( + certificate, + ) + if err != nil || certificateHash == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "cannot hash FROST checkpoint certificate: [%v]", + err, + ) + } + return certificateHash, nil +} + +func frostRetainedGroupCheckpointChainRoot( + bindingHash [32]byte, + after FrostRetainedGroupCheckpointCursor, + certificateHashes [][32]byte, +) [32]byte { + hasher := sha256.New() + hasher.Write([]byte(frostRetainedGroupCheckpointChainDomain)) + hasher.Write(bindingHash[:]) + sequence := [8]byte{} + for index := uint(0); index < 8; index++ { + sequence[7-index] = byte(after.Sequence >> (8 * index)) + } + hasher.Write(sequence[:]) + hasher.Write(after.CertificateHash[:]) + count := [8]byte{} + for index := uint(0); index < 8; index++ { + count[7-index] = byte(uint64(len(certificateHashes)) >> (8 * index)) + } + hasher.Write(count[:]) + for _, certificateHash := range certificateHashes { + hasher.Write(certificateHash[:]) + } + result := [32]byte{} + copy(result[:], hasher.Sum(nil)) + return result +} + +func validateFrostRetainedGroupCheckpointSuffix( + policy frostRetainedGroupCheckpointPolicy, + after FrostRetainedGroupCheckpointCursor, + certificates []FrostRetainedGroupCheckpointCertificate, +) ([][32]byte, error) { + if after.Sequence+1 < after.Sequence || + after.Sequence+1 > frostRetainedGroupMaximumCanonicalJSONInteger { + return nil, fmt.Errorf("FROST checkpoint cursor overflows") + } + if after.Sequence == policy.MinimumSequence-1 { + if after.CertificateHash != policy.PredecessorHash { + return nil, fmt.Errorf( + "FROST checkpoint suffix does not start at the manifest transparency floor", + ) + } + } else if after.Sequence < policy.MinimumSequence || + after.CertificateHash == [32]byte{} { + return nil, fmt.Errorf( + "FROST checkpoint cursor is below the manifest transparency floor", + ) + } + if len(certificates) == 0 { + if after.Sequence < policy.MinimumSequence || + after.CertificateHash == [32]byte{} { + return nil, fmt.Errorf( + "fresh FROST checkpoint state requires the manifest-minimum certificate", + ) + } + return [][32]byte{}, nil + } + hashes := make([][32]byte, len(certificates)) + previousSequence := after.Sequence + previousHash := after.CertificateHash + var previousPoint FrostPreSignFinality + var previousCanonicalGeneration uint64 + var previousQuarantineGeneration uint64 + for index, certificate := range certificates { + certificateHash, err := + validateFrostRetainedGroupCheckpointCertificateShape( + policy, + certificate, + ) + if err != nil { + return nil, fmt.Errorf( + "invalid FROST checkpoint certificate [%d]: [%w]", + index, + err, + ) + } + body := certificate.Body + if body.Sequence != previousSequence+1 || + body.PreviousCertificateHash != previousHash { + return nil, fmt.Errorf( + "FROST checkpoint sequence has a gap, fork, or rollback at [%d]", + body.Sequence, + ) + } + if index > 0 && + (body.Point.BlockNumber <= previousPoint.BlockNumber || + body.CanonicalGeneration < previousCanonicalGeneration || + body.QuarantineGeneration < previousQuarantineGeneration) { + return nil, fmt.Errorf( + "FROST checkpoint point or generation is not strictly monotonic", + ) + } + hashes[index] = certificateHash + previousSequence = body.Sequence + previousHash = certificateHash + previousPoint = body.Point + previousCanonicalGeneration = body.CanonicalGeneration + previousQuarantineGeneration = body.QuarantineGeneration + } + return hashes, nil +} + +func frostRetainedGroupCertifiedStateFromHistory( + policy frostRetainedGroupCheckpointPolicy, + from FrostPreSignFinality, + point FrostPreSignFinality, + mutations []FrostRetainedGroupMutation, +) (FrostRetainedGroupCheckpointBody, error) { + if point.BlockNumber <= from.BlockNumber || + point.BlockHash == [32]byte{} { + return FrostRetainedGroupCheckpointBody{}, fmt.Errorf( + "FROST checkpoint point is not above the empty history baseline", + ) + } + prefix := make([]FrostRetainedGroupMutation, 0, len(mutations)) + for _, mutation := range mutations { + if mutation.Point.BlockNumber > point.BlockNumber { + break + } + if mutation.Point.BlockNumber == point.BlockNumber && + mutation.Point.BlockHash != point.BlockHash { + return FrostRetainedGroupCheckpointBody{}, fmt.Errorf( + "FROST checkpoint point conflicts with mutation block hash", + ) + } + prefix = append(prefix, mutation) + } + canonical := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + BindingHash: policy.ProtocolBindingHash, + CurrentPoint: from, + Wallets: []frostRetainedGroupWalletState{}, + } + if err := applyFrostRetainedGroupMutations( + &canonical, + frostRetainedGroupCanonicalMutations(prefix), + ); err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + canonical.CurrentPoint = point + inventoryRoot, _, _, _, err := frostRetainedGroupInventoryRoot(canonical) + if err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + emptyActiveRoot, err := frostRetainedGroupQuarantineActiveRoot( + policy.ProtocolBindingHash, + map[[32]byte]frostRetainedGroupQuarantineState{}, + ) + if err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + emptyTombstoneRoot, err := frostRetainedGroupQuarantineTombstoneRoot( + policy.ProtocolBindingHash, + map[[32]byte]frostRetainedGroupQuarantineTombstone{}, + ) + if err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + quarantine := frostRetainedGroupQuarantineJournalState{ + Schema: frostRetainedGroupQuarantineStateSchema, + BindingHash: policy.ProtocolBindingHash, + CurrentPoint: from, + Root: sha256.Sum256([]byte(frostRetainedGroupQuarantineDomain)), + ActiveRoot: emptyActiveRoot, + TombstoneRoot: emptyTombstoneRoot, + Quarantines: []frostRetainedGroupQuarantineState{}, + Tombstones: []frostRetainedGroupQuarantineTombstone{}, + } + if err := applyFrostRetainedGroupQuarantineMutations( + &quarantine, + frostRetainedGroupQuarantineMutations(prefix), + policy.LiftPolicy, + ); err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + quarantine.CurrentPoint = point + wireMutations := make( + []frostRetainedGroupWireMutation, + len(prefix), + ) + for index, mutation := range prefix { + wireMutations[index] = frostRetainedGroupMutationToWire(mutation) + } + query := frostRetainedGroupHistoryQuery{ + Schema: frostRetainedGroupHistoryRequestSchema, + BindingHash: frostActivationHex32(policy.ProtocolBindingHash), + From: frostRetainedGroupFinalityToWire(from), + To: frostRetainedGroupFinalityToWire(point), + } + queryHash, err := frostRetainedGroupDomainHash( + frostRetainedGroupHistoryQueryDomain, + query, + ) + if err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + historyRoot, err := frostRetainedGroupHistoryRoot( + policy.ProtocolBindingHash, + queryHash, + wireMutations, + ) + if err != nil { + return FrostRetainedGroupCheckpointBody{}, err + } + return FrostRetainedGroupCheckpointBody{ + Point: point, + HistoryRoot: historyRoot, + CanonicalGeneration: canonical.SnapshotGeneration, + CanonicalInventoryRoot: inventoryRoot, + QuarantineGeneration: quarantine.Generation, + QuarantineEventRoot: quarantine.Root, + QuarantineActiveRoot: quarantine.ActiveRoot, + QuarantineTombstoneRoot: quarantine.TombstoneRoot, + }, nil +} + +func validateFrostRetainedGroupCheckpointSemantics( + policy frostRetainedGroupCheckpointPolicy, + history *FrostRetainedGroupHistory, + hashes [][32]byte, +) error { + if history == nil || len(history.Checkpoints) != len(hashes) || + len(history.Checkpoints) == 0 { + return fmt.Errorf( + "FROST checkpoint semantic history is incomplete", + ) + } + if history.CheckpointChainRoot != + frostRetainedGroupCheckpointChainRoot( + policy.ProtocolBindingHash, + history.CheckpointAfter, + hashes, + ) { + return fmt.Errorf( + "FROST history receipt does not bind the exact checkpoint suffix", + ) + } + for index, certificate := range history.Checkpoints { + expected, err := frostRetainedGroupCertifiedStateFromHistory( + policy, + history.From, + certificate.Body.Point, + history.Mutations, + ) + if err != nil { + return fmt.Errorf( + "cannot derive FROST checkpoint semantic state [%d]: [%w]", + index, + err, + ) + } + body := certificate.Body + if body.HistoryRoot != expected.HistoryRoot || + body.CanonicalGeneration != expected.CanonicalGeneration || + body.CanonicalInventoryRoot != expected.CanonicalInventoryRoot || + body.QuarantineGeneration != expected.QuarantineGeneration || + body.QuarantineEventRoot != expected.QuarantineEventRoot || + body.QuarantineActiveRoot != expected.QuarantineActiveRoot || + body.QuarantineTombstoneRoot != expected.QuarantineTombstoneRoot { + return fmt.Errorf( + "FROST checkpoint certificate [%d] does not commit the independently derived semantic state", + index, + ) + } + } + tail := history.Checkpoints[len(history.Checkpoints)-1] + if hashes[len(hashes)-1] != history.CheckpointTipHash { + return fmt.Errorf( + "FROST checkpoint tail digest differs from the receipt", + ) + } + if history.CheckpointComplete && + (tail.Body.Point != history.To || + tail.Body.HistoryRoot != history.HistoryRoot) { + return fmt.Errorf( + "FROST checkpoint tail does not bind the exact finalized target and receipt root", + ) + } + if !history.CheckpointComplete && + tail.Body.Point.BlockNumber >= history.To.BlockNumber { + return fmt.Errorf( + "nonfinal FROST checkpoint page does not precede the exact finalized target", + ) + } + return nil +} + +func frostRetainedGroupCheckpointFileName( + sequence uint64, + certificateHash [32]byte, +) string { + return fmt.Sprintf( + "%s%020d-%s%s", + frostRetainedGroupCheckpointFilePrefix, + sequence, + hex.EncodeToString(certificateHash[:]), + frostRetainedGroupJournalFileSuffix, + ) +} + +func frostRetainedGroupCheckpointStateFromCertificate( + bindingHash [32]byte, + certificate FrostRetainedGroupCheckpointCertificate, + certificateHash [32]byte, +) frostRetainedGroupCheckpointJournalState { + body := certificate.Body + return frostRetainedGroupCheckpointJournalState{ + Schema: frostRetainedGroupCheckpointStateSchema, + BindingHash: bindingHash, + Sequence: body.Sequence, + CertificateHash: certificateHash, + Point: body.Point, + HistoryRoot: body.HistoryRoot, + CanonicalGeneration: body.CanonicalGeneration, + CanonicalInventoryRoot: body.CanonicalInventoryRoot, + QuarantineGeneration: body.QuarantineGeneration, + QuarantineEventRoot: body.QuarantineEventRoot, + QuarantineActiveRoot: body.QuarantineActiveRoot, + QuarantineTombstoneRoot: body.QuarantineTombstoneRoot, + } +} + +func equalFrostRetainedGroupCheckpointStates( + left frostRetainedGroupCheckpointJournalState, + right frostRetainedGroupCheckpointJournalState, +) bool { + return left == right +} + +func (frgj *frostRetainedGroupJournal) initializeCheckpointJournal() error { + entries, err := os.ReadDir(frgj.checkpointDirectory) + if err != nil { + return fmt.Errorf("cannot read FROST checkpoint journal: [%w]", err) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + metadataExists := false + stateExists := false + certificateNames := make([]string, 0) + for _, entry := range entries { + name := entry.Name() + if name == frostRetainedGroupJournalLockFile { + continue + } + if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() { + return fmt.Errorf("unsafe entry in FROST checkpoint journal: [%s]", name) + } + switch { + case name == frostRetainedGroupCheckpointMetadataFile: + metadataExists = true + case name == frostRetainedGroupCheckpointStateFile: + stateExists = true + case strings.HasPrefix(name, frostRetainedGroupCheckpointFilePrefix) && + strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix): + certificateNames = append(certificateNames, name) + case strings.HasSuffix(name, frostRetainedGroupJournalTempSuffix): + return fmt.Errorf( + "interrupted FROST checkpoint journal temp is present: [%s]", + name, + ) + default: + return fmt.Errorf( + "unexpected file in FROST checkpoint journal: [%s]", + name, + ) + } + } + expectedMetadata := frostRetainedGroupCheckpointMetadata{ + Schema: frostRetainedGroupCheckpointMetadataSchema, + ManifestHash: frgj.checkpointPolicy.ManifestHash, + BindingHash: frgj.checkpointPolicy.ProtocolBindingHash, + AuthoritySetHash: frgj.checkpointPolicy.AuthoritySetHash, + AuthorityThreshold: frgj.checkpointPolicy.AuthorityThreshold, + Authorities: append( + []FrostRetainedGroupAuthority{}, + frgj.checkpointPolicy.Authorities..., + ), + MinimumSequence: frgj.checkpointPolicy.MinimumSequence, + PredecessorHash: frgj.checkpointPolicy.PredecessorHash, + } + if metadataExists { + storedMetadata := frostRetainedGroupCheckpointMetadata{} + if err := readFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + frostRetainedGroupCheckpointMetadataFile, + &storedMetadata, + ); err != nil { + return fmt.Errorf( + "cannot read FROST checkpoint metadata: [%w]", + err, + ) + } + stored, storedErr := frostRetainedGroupCanonicalValue(storedMetadata) + expected, expectedErr := frostRetainedGroupCanonicalValue(expectedMetadata) + if storedErr != nil || expectedErr != nil || + !bytes.Equal(stored, expected) { + return fmt.Errorf( + "FROST checkpoint metadata differs from the signed manifest", + ) + } + } else { + if stateExists || len(certificateNames) != 0 { + return fmt.Errorf( + "FROST checkpoint journal has state without immutable metadata", + ) + } + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + frostRetainedGroupCheckpointMetadataFile, + expectedMetadata, + false, + ); err != nil { + return fmt.Errorf( + "cannot persist FROST checkpoint metadata: [%w]", + err, + ) + } + } + + initial := frostRetainedGroupCheckpointJournalState{ + Schema: frostRetainedGroupCheckpointStateSchema, + BindingHash: frgj.checkpointPolicy.ProtocolBindingHash, + Sequence: frgj.checkpointPolicy.MinimumSequence - 1, + CertificateHash: frgj.checkpointPolicy.PredecessorHash, + } + storedState := initial + if stateExists { + if err := readFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + frostRetainedGroupCheckpointStateFile, + &storedState, + ); err != nil { + return fmt.Errorf( + "cannot read FROST checkpoint journal state: [%w]", + err, + ) + } + if storedState.Schema != frostRetainedGroupCheckpointStateSchema || + storedState.BindingHash != + frgj.checkpointPolicy.ProtocolBindingHash { + return fmt.Errorf( + "unsupported or differently bound FROST checkpoint state", + ) + } + } + + rebuilt := initial + matchedStored := equalFrostRetainedGroupCheckpointStates( + storedState, + initial, + ) + for index, name := range certificateNames { + wire := frostRetainedGroupWireCheckpointCertificate{} + if err := readFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + name, + &wire, + ); err != nil { + return fmt.Errorf( + "cannot read immutable FROST checkpoint certificate [%s]: [%w]", + name, + err, + ) + } + certificate, err := + frostRetainedGroupCheckpointCertificateFromWire(wire) + if err != nil { + return fmt.Errorf( + "cannot decode immutable FROST checkpoint certificate [%s]: [%w]", + name, + err, + ) + } + if rebuilt.Sequence >= frgj.checkpointPolicy.MinimumSequence && + (certificate.Body.Point.BlockNumber <= + rebuilt.Point.BlockNumber || + certificate.Body.CanonicalGeneration < + rebuilt.CanonicalGeneration || + certificate.Body.QuarantineGeneration < + rebuilt.QuarantineGeneration) { + return fmt.Errorf( + "immutable FROST checkpoint certificate [%s] is not monotonic", + name, + ) + } + hashes, err := validateFrostRetainedGroupCheckpointSuffix( + frgj.checkpointPolicy, + FrostRetainedGroupCheckpointCursor{ + Sequence: rebuilt.Sequence, + CertificateHash: rebuilt.CertificateHash, + }, + []FrostRetainedGroupCheckpointCertificate{certificate}, + ) + if err != nil { + return fmt.Errorf( + "invalid immutable FROST checkpoint certificate [%s]: [%w]", + name, + err, + ) + } + certificateHash := hashes[0] + expectedName := frostRetainedGroupCheckpointFileName( + certificate.Body.Sequence, + certificateHash, + ) + if name != expectedName { + return fmt.Errorf( + "immutable FROST checkpoint filename [%s] does not match its sequence and digest", + name, + ) + } + if index > 0 && + certificate.Body.Sequence != + frgj.checkpointPolicy.MinimumSequence+uint64(index) { + return fmt.Errorf( + "FROST checkpoint certificate sequence has a filesystem gap", + ) + } + frgj.checkpointCertificates[certificate.Body.Sequence] = certificate + frgj.checkpointHashes[certificate.Body.Sequence] = certificateHash + rebuilt = frostRetainedGroupCheckpointStateFromCertificate( + frgj.checkpointPolicy.ProtocolBindingHash, + certificate, + certificateHash, + ) + if rebuilt.Sequence == storedState.Sequence { + if !equalFrostRetainedGroupCheckpointStates( + rebuilt, + storedState, + ) { + return fmt.Errorf( + "FROST checkpoint state differs from its exact certificate prefix", + ) + } + matchedStored = true + } + } + if storedState.Sequence > rebuilt.Sequence || !matchedStored { + return fmt.Errorf( + "FROST checkpoint state has no exact immutable certificate prefix", + ) + } + if rebuilt.Sequence >= frgj.checkpointPolicy.MinimumSequence { + if err := frgj.validateCheckpointAgainstDurablePrefix(rebuilt); err != nil { + return fmt.Errorf( + "durable FROST checkpoint is not an exact prefix of the canonical and quarantine journals: [%w]", + err, + ) + } + } + frgj.checkpointState = rebuilt + if !stateExists || storedState.Sequence != rebuilt.Sequence { + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + frostRetainedGroupCheckpointStateFile, + rebuilt, + true, + ); err != nil { + return fmt.Errorf( + "cannot integrate orphan FROST checkpoint certificate: [%w]", + err, + ) + } + } + return nil +} + +func (frgj *frostRetainedGroupJournal) validateCheckpointAgainstDurableState( + checkpoint frostRetainedGroupCheckpointJournalState, +) error { + if checkpoint.Point != frgj.state.CurrentPoint || + checkpoint.Point != frgj.quarantineState.CurrentPoint || + checkpoint.CanonicalGeneration != frgj.state.SnapshotGeneration || + checkpoint.CanonicalInventoryRoot != frgj.state.InventoryRoot || + checkpoint.QuarantineGeneration != frgj.quarantineState.Generation || + checkpoint.QuarantineEventRoot != frgj.quarantineState.Root || + checkpoint.QuarantineActiveRoot != frgj.quarantineState.ActiveRoot || + checkpoint.QuarantineTombstoneRoot != + frgj.quarantineState.TombstoneRoot { + return fmt.Errorf( + "checkpoint roots or generations differ from durable semantic state", + ) + } + return nil +} + +func (frgj *frostRetainedGroupJournal) validateCheckpointAgainstDurablePrefix( + checkpoint frostRetainedGroupCheckpointJournalState, +) error { + if checkpoint.Point.BlockNumber > frgj.state.CurrentPoint.BlockNumber || + checkpoint.Point.BlockNumber > + frgj.quarantineState.CurrentPoint.BlockNumber { + return fmt.Errorf( + "checkpoint is ahead of a durable semantic journal", + ) + } + mutations := append( + cloneFrostRetainedGroupMutations(frgj.mutations), + cloneFrostRetainedGroupMutations(frgj.quarantineMutations)..., + ) + sort.Slice(mutations, func(i, j int) bool { + return compareFrostRetainedGroupEventPoints( + mutations[i].Point, + mutations[j].Point, + ) < 0 + }) + for index := 1; index < len(mutations); index++ { + if compareFrostRetainedGroupEventPoints( + mutations[index-1].Point, + mutations[index].Point, + ) >= 0 { + return fmt.Errorf( + "durable semantic journals overlap or disagree in event order", + ) + } + } + expected, err := frostRetainedGroupCertifiedStateFromHistory( + frgj.checkpointPolicy, + frgj.metadata.Checkpoint, + checkpoint.Point, + mutations, + ) + if err != nil { + return err + } + if checkpoint.HistoryRoot != expected.HistoryRoot || + checkpoint.CanonicalGeneration != expected.CanonicalGeneration || + checkpoint.CanonicalInventoryRoot != expected.CanonicalInventoryRoot || + checkpoint.QuarantineGeneration != expected.QuarantineGeneration || + checkpoint.QuarantineEventRoot != expected.QuarantineEventRoot || + checkpoint.QuarantineActiveRoot != expected.QuarantineActiveRoot || + checkpoint.QuarantineTombstoneRoot != + expected.QuarantineTombstoneRoot { + return fmt.Errorf( + "checkpoint roots or generations differ from the durable semantic prefix", + ) + } + return nil +} + +func (frgj *frostRetainedGroupJournal) persistCheckpointSuffix( + certificates []FrostRetainedGroupCheckpointCertificate, + hashes [][32]byte, +) error { + if len(certificates) == 0 || len(certificates) != len(hashes) { + return fmt.Errorf("FROST checkpoint certificate/hash count mismatch") + } + tail := len(certificates) - 1 + next := frostRetainedGroupCheckpointStateFromCertificate( + frgj.checkpointPolicy.ProtocolBindingHash, + certificates[tail], + hashes[tail], + ) + if next.Point == frgj.state.CurrentPoint && + next.Point == frgj.quarantineState.CurrentPoint { + if err := frgj.validateCheckpointAgainstDurableState(next); err != nil { + return err + } + } else if err := frgj.validateCheckpointAgainstDurablePrefix(next); err != nil { + return err + } + existingEntries, err := os.ReadDir(frgj.checkpointDirectory) + if err != nil { + return fmt.Errorf( + "cannot inspect immutable FROST checkpoint certificates: [%w]", + err, + ) + } + for index, certificate := range certificates { + hash := hashes[index] + sequence := certificate.Body.Sequence + if _, exists := frgj.checkpointCertificates[sequence]; exists { + return fmt.Errorf( + "FROST checkpoint suffix contains an already persisted sequence [%d]", + sequence, + ) + } + if err := frgj.persistOrAdoptCheckpointCertificate( + certificate, + hash, + existingEntries, + ); err != nil { + return fmt.Errorf( + "cannot persist immutable FROST checkpoint certificate [%d]: [%w]", + sequence, + err, + ) + } + if frgj.checkpointPersistFailureHook != nil { + if err := frgj.checkpointPersistFailureHook( + "after-checkpoint-certificate-before-next", + ); err != nil { + return err + } + } + } + if frgj.checkpointPersistFailureHook != nil { + if err := frgj.checkpointPersistFailureHook( + "after-checkpoint-certificates-before-state", + ); err != nil { + return err + } + } + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + frostRetainedGroupCheckpointStateFile, + next, + true, + ); err != nil { + return fmt.Errorf( + "cannot advance durable FROST checkpoint head: [%w]", + err, + ) + } + if frgj.checkpointPersistFailureHook != nil { + if err := frgj.checkpointPersistFailureHook( + "after-checkpoint-state-before-memory", + ); err != nil { + return err + } + } + for index, certificate := range certificates { + sequence := certificate.Body.Sequence + frgj.checkpointCertificates[sequence] = certificate + frgj.checkpointHashes[sequence] = hashes[index] + } + frgj.checkpointState = next + return nil +} + +func (frgj *frostRetainedGroupJournal) persistOrAdoptCheckpointCertificate( + certificate FrostRetainedGroupCheckpointCertificate, + certificateHash [32]byte, + existingEntries []os.DirEntry, +) error { + sequence := certificate.Body.Sequence + expectedName := frostRetainedGroupCheckpointFileName( + sequence, + certificateHash, + ) + sequencePrefix := fmt.Sprintf( + "%s%020d-", + frostRetainedGroupCheckpointFilePrefix, + sequence, + ) + existingName := "" + for _, entry := range existingEntries { + name := entry.Name() + if !strings.HasPrefix(name, sequencePrefix) || + !strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix) { + continue + } + if existingName != "" || name != expectedName { + return fmt.Errorf( + "conflicting immutable checkpoint certificate exists for sequence [%d]", + sequence, + ) + } + existingName = name + } + expectedWire := + frostRetainedGroupCheckpointCertificateToWire(certificate) + if existingName == "" { + return persistFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + expectedName, + expectedWire, + false, + ) + } + storedWire := frostRetainedGroupWireCheckpointCertificate{} + if err := readFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + existingName, + &storedWire, + ); err != nil { + return fmt.Errorf( + "cannot read orphan checkpoint certificate: [%w]", + err, + ) + } + storedCanonical, err := frostRetainedGroupCanonicalValue(storedWire) + if err != nil { + return err + } + expectedCanonical, err := frostRetainedGroupCanonicalValue(expectedWire) + if err != nil { + return err + } + if !bytes.Equal(storedCanonical, expectedCanonical) { + return fmt.Errorf( + "orphan checkpoint certificate differs from the requested exact suffix", + ) + } + return nil +} + +func (frgj *frostRetainedGroupJournal) checkpointDescendsFrom( + floor FrostRetainedGroupCheckpointCursor, +) bool { + if floor.Sequence < frgj.checkpointPolicy.MinimumSequence || + floor.CertificateHash == [32]byte{} || + floor.Sequence > frgj.checkpointState.Sequence { + return false + } + hash, exists := frgj.checkpointHashes[floor.Sequence] + return exists && hash == floor.CertificateHash +} + +func (frgj *frostRetainedGroupJournal) checkpointAncestryAfter( + floor FrostRetainedGroupCheckpointCursor, +) ([]FrostRetainedGroupCheckpointCertificate, error) { + if !frgj.checkpointDescendsFrom(floor) { + return nil, fmt.Errorf( + "FROST checkpoint head does not descend from the external transparency floor", + ) + } + distance := frgj.checkpointState.Sequence - floor.Sequence + if distance > frostRetainedGroupMaximumHandshakeAncestry { + return nil, fmt.Errorf( + "external FROST checkpoint floor is too stale; obtain a fresh rollback-independent transparency floor", + ) + } + result := make( + []FrostRetainedGroupCheckpointCertificate, + 0, + int(distance), + ) + for sequence := floor.Sequence + 1; sequence <= frgj.checkpointState.Sequence; sequence++ { + certificate, exists := frgj.checkpointCertificates[sequence] + if !exists { + return nil, fmt.Errorf( + "FROST checkpoint ancestry is missing certificate [%d]", + sequence, + ) + } + result = append(result, certificate) + } + return result, nil +} diff --git a/pkg/tbtc/frost_retained_group_history_evidence.go b/pkg/tbtc/frost_retained_group_history_evidence.go index 3999dfebfd..4a5973d4c6 100644 --- a/pkg/tbtc/frost_retained_group_history_evidence.go +++ b/pkg/tbtc/frost_retained_group_history_evidence.go @@ -47,6 +47,7 @@ type frostRetainedGroupEvidenceProfile struct { genesisBlockHash [32]byte bindingHash [32]byte liftPolicy frostRetainedGroupQuarantineLiftPolicy + checkpointPolicy frostRetainedGroupCheckpointPolicy deployments map[string]FrostPreSignDeploymentEvidence bridgeABI ethabi.ABI registryABI ethabi.ABI @@ -190,6 +191,16 @@ func (source *signedFrostRetainedGroupHistorySource) BindFrostRetainedGroupActiv err, ) } + checkpointPolicy, err := frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + bindingHash, + runtimeManifest, + ) + if err != nil { + return fmt.Errorf( + "retained-group checkpoint policy is invalid: [%w]", + err, + ) + } parsedBridgeABI, err := ethabi.JSON(strings.NewReader(bridgeabi.BridgeMetaData.ABI)) if err != nil { return fmt.Errorf("cannot parse pinned Bridge ABI: [%w]", err) @@ -210,6 +221,7 @@ func (source *signedFrostRetainedGroupHistorySource) BindFrostRetainedGroupActiv genesisBlockHash: runtimeManifest.GenesisBlockHash, bindingHash: bindingHash, liftPolicy: liftPolicy, + checkpointPolicy: checkpointPolicy, deployments: deployments, bridgeABI: parsedBridgeABI, registryABI: parsedRegistryABI, @@ -267,7 +279,7 @@ func (source *signedFrostRetainedGroupHistorySource) computeProtocolBinding( return [32]byte{}, err } binding := frostRetainedGroupProtocolBinding{ - Schema: "tbtc-frost-retained-group-protocol-binding/v3", + Schema: "tbtc-frost-retained-group-protocol-binding/v4", ChainID: source.chainID, DomainChainID: frostActivationHex32(runtimeManifest.DomainChainID), GenesisBlockHash: frostActivationHex32(runtimeManifest.GenesisBlockHash), @@ -293,6 +305,8 @@ func (source *signedFrostRetainedGroupHistorySource) computeProtocolBinding( TombstoneProtocolID: frostActivationHex32(runtimeManifest.QuarantineJournal.TombstoneProtocolID), LiftAuthoritySetHash: frostActivationHex32(liftAuthoritySetHash), CheckpointAuthoritySetHash: frostActivationHex32(checkpointAuthoritySetHash), + CheckpointMinimumSequence: runtimeManifest.QuarantineJournal.CheckpointMinimumSequence, + CheckpointPredecessorHash: frostActivationHex32(runtimeManifest.QuarantineJournal.CheckpointPredecessorHash), SigningPolicyHash: frostActivationHex32(runtimeManifest.SigningPolicyHash), CanonicalStoreID: runtimeManifest.CanonicalJournal.StoreID, CanonicalStoreFingerprint: frostActivationHex32( diff --git a/pkg/tbtc/frost_retained_group_history_source.go b/pkg/tbtc/frost_retained_group_history_source.go index 44fbcb2a9c..ab758d8568 100644 --- a/pkg/tbtc/frost_retained_group_history_source.go +++ b/pkg/tbtc/frost_retained_group_history_source.go @@ -32,16 +32,16 @@ import ( ) const ( - frostRetainedGroupHistoryPageSchema = "tbtc-frost-retained-group-history-page/v3" + frostRetainedGroupHistoryPageSchema = "tbtc-frost-retained-group-history-page/v5" frostRetainedGroupOperatorReceiptSchema = "tbtc-frost-retained-group-operator-receipt/v3" - frostRetainedGroupHistoryRequestSchema = "tbtc-frost-retained-group-history-request/v3" + frostRetainedGroupHistoryRequestSchema = "tbtc-frost-retained-group-history-request/v4" frostRetainedGroupOperatorRequestSchema = "tbtc-frost-retained-group-operator-request/v3" - frostRetainedGroupHistorySignatureDomain = "tbtc-frost-retained-group-export-signature-v3\x00" + frostRetainedGroupHistorySignatureDomain = "tbtc-frost-retained-group-export-signature-v5\x00" frostRetainedGroupHistoryEndpointDomain = "tbtc-frost-retained-group-endpoints-v1\x00" - frostRetainedGroupHistoryQueryDomain = "tbtc-frost-retained-group-history-query-v3\x00" + frostRetainedGroupHistoryQueryDomain = "tbtc-frost-retained-group-history-query-v4\x00" frostRetainedGroupOperatorQueryDomain = "tbtc-frost-retained-group-operator-query-v3\x00" - frostRetainedGroupHistoryRootDomain = "tbtc-frost-retained-group-export-history-root-v3\x00" - frostRetainedGroupProtocolBindingDomain = "tbtc-frost-retained-group-protocol-binding-v3\x00" + frostRetainedGroupHistoryRootDomain = "tbtc-frost-retained-group-export-history-root-v4\x00" + frostRetainedGroupProtocolBindingDomain = "tbtc-frost-retained-group-protocol-binding-v4\x00" frostRetainedGroupMaximumResponseBytes = 1024 * 1024 frostRetainedGroupMaximumAggregateResponseBytes = 16 * 1024 * 1024 @@ -253,6 +253,8 @@ type frostRetainedGroupProtocolBinding struct { TombstoneProtocolID string `json:"tombstoneProtocolID"` LiftAuthoritySetHash string `json:"liftAuthoritySetHash"` CheckpointAuthoritySetHash string `json:"checkpointAuthoritySetHash"` + CheckpointMinimumSequence uint64 `json:"checkpointMinimumSequence"` + CheckpointPredecessorHash string `json:"checkpointPredecessorHash"` SigningPolicyHash string `json:"signingPolicyHash"` CanonicalStoreID string `json:"canonicalStoreID"` CanonicalStoreFingerprint string `json:"canonicalStoreFingerprint"` @@ -347,36 +349,48 @@ type frostRetainedGroupHistoryQuery struct { } type frostRetainedGroupHistoryPageRequest struct { - BindingHash string `json:"bindingHash"` - Query frostRetainedGroupHistoryQuery `json:"query"` - Cursor string `json:"cursor"` + BindingHash string `json:"bindingHash"` + Query frostRetainedGroupHistoryQuery `json:"query"` + CheckpointAfter frostRetainedGroupWireCheckpointCursor `json:"checkpointAfter"` + Cursor string `json:"cursor"` +} + +type frostRetainedGroupWireCheckpointCursor struct { + Sequence uint64 `json:"sequence"` + CertificateHash string `json:"certificateHash"` } type frostRetainedGroupHistoryReceipt struct { - PageCount uint64 `json:"pageCount"` - MutationCount uint64 `json:"mutationCount"` - BindingHash string `json:"bindingHash"` - HistoryRoot string `json:"historyRoot"` + PageCount uint64 `json:"pageCount"` + MutationCount uint64 `json:"mutationCount"` + BindingHash string `json:"bindingHash"` + HistoryRoot string `json:"historyRoot"` + CheckpointAfter frostRetainedGroupWireCheckpointCursor `json:"checkpointAfter"` + CheckpointCertificates []frostRetainedGroupWireCheckpointCertificate `json:"checkpointCertificates"` + CheckpointChainRoot string `json:"checkpointChainRoot"` + CheckpointTipHash string `json:"checkpointTipHash"` + CheckpointComplete bool `json:"checkpointComplete"` } type frostRetainedGroupHistoryPagePayload struct { - Schema string `json:"schema"` - BindingHash string `json:"bindingHash"` - Identity frostRetainedGroupWireIdentity `json:"identity"` - ChainID uint64 `json:"chainID"` - QueryHash string `json:"queryHash"` - SnapshotID string `json:"snapshotID"` - PageIndex uint64 `json:"pageIndex"` - Cursor string `json:"cursor"` - PreviousPageHash string `json:"previousPageHash"` - From frostRetainedGroupWireFinality `json:"from"` - To frostRetainedGroupWireFinality `json:"to"` - EmptyAtFrom bool `json:"emptyAtFrom"` - DescriptorSetHash string `json:"descriptorSetHash"` - Mutations []frostRetainedGroupWireMutation `json:"mutations"` - NextCursor string `json:"nextCursor"` - Complete bool `json:"complete"` - Receipt *frostRetainedGroupHistoryReceipt `json:"receipt"` + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + Identity frostRetainedGroupWireIdentity `json:"identity"` + ChainID uint64 `json:"chainID"` + QueryHash string `json:"queryHash"` + SnapshotID string `json:"snapshotID"` + PageIndex uint64 `json:"pageIndex"` + Cursor string `json:"cursor"` + PreviousPageHash string `json:"previousPageHash"` + From frostRetainedGroupWireFinality `json:"from"` + To frostRetainedGroupWireFinality `json:"to"` + EmptyAtFrom bool `json:"emptyAtFrom"` + DescriptorSetHash string `json:"descriptorSetHash"` + CheckpointAfter frostRetainedGroupWireCheckpointCursor `json:"checkpointAfter"` + Mutations []frostRetainedGroupWireMutation `json:"mutations"` + NextCursor string `json:"nextCursor"` + Complete bool `json:"complete"` + Receipt *frostRetainedGroupHistoryReceipt `json:"receipt"` } type frostRetainedGroupOperatorQuery struct { @@ -752,6 +766,7 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( ctx context.Context, from FrostPreSignFinality, to FrostPreSignFinality, + checkpointAfter FrostRetainedGroupCheckpointCursor, ) (*FrostRetainedGroupHistory, error) { if ctx == nil { return nil, fmt.Errorf("retained-group history context is nil") @@ -772,6 +787,20 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( if err != nil { return nil, err } + if checkpointAfter.Sequence == evidence.checkpointPolicy.MinimumSequence-1 { + if checkpointAfter.CertificateHash != + evidence.checkpointPolicy.PredecessorHash { + return nil, fmt.Errorf( + "retained-group checkpoint cursor differs from the manifest floor", + ) + } + } else if checkpointAfter.Sequence < + evidence.checkpointPolicy.MinimumSequence || + checkpointAfter.CertificateHash == [32]byte{} { + return nil, fmt.Errorf( + "retained-group checkpoint cursor is below the manifest floor", + ) + } headBefore, err := source.FinalizedHead(ctx) if err != nil { return nil, err @@ -817,7 +846,13 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( request := frostRetainedGroupHistoryPageRequest{ BindingHash: frostActivationHex32(evidence.bindingHash), Query: query, - Cursor: cursor, + CheckpointAfter: frostRetainedGroupWireCheckpointCursor{ + Sequence: checkpointAfter.Sequence, + CertificateHash: frostActivationHex32( + checkpointAfter.CertificateHash, + ), + }, + Cursor: cursor, } payload := &frostRetainedGroupHistoryPagePayload{} responseBytes, err := source.postSigned(ctx, "history", request, payload) @@ -838,6 +873,7 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( previousPageHash, snapshotID, descriptorSetHash, + checkpointAfter, ) if err != nil { return nil, err @@ -877,7 +913,9 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( payload.Receipt.PageCount != pageIndex+1 || payload.Receipt.MutationCount != uint64(len(mutations)) || payload.Receipt.BindingHash != - frostActivationHex32(evidence.bindingHash) { + frostActivationHex32(evidence.bindingHash) || + payload.Receipt.CheckpointAfter != + request.CheckpointAfter { return nil, fmt.Errorf("retained-group final history receipt is inconsistent") } receiptRoot, err := parseFrostActivationHex32(payload.Receipt.HistoryRoot) @@ -892,13 +930,79 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( if receiptRoot != computedRoot { return nil, fmt.Errorf("retained-group history receipt does not cover the exact mutation sequence") } + if len(payload.Receipt.CheckpointCertificates) > + frostRetainedGroupMaximumCheckpointsPerPage { + return nil, fmt.Errorf( + "retained-group checkpoint page exceeds its bound", + ) + } + checkpoints := make( + []FrostRetainedGroupCheckpointCertificate, + len(payload.Receipt.CheckpointCertificates), + ) + for index, wireCertificate := range payload.Receipt.CheckpointCertificates { + checkpoint, err := + frostRetainedGroupCheckpointCertificateFromWire( + wireCertificate, + ) + if err != nil { + return nil, fmt.Errorf( + "retained-group checkpoint certificate [%d] is malformed: [%w]", + index, + err, + ) + } + checkpoints[index] = checkpoint + } + checkpointHashes, err := + validateFrostRetainedGroupCheckpointSuffix( + evidence.checkpointPolicy, + checkpointAfter, + checkpoints, + ) + if err != nil { + return nil, err + } + checkpointChainRoot, err := parseFrostActivationHex32( + payload.Receipt.CheckpointChainRoot, + ) + if err != nil || + checkpointChainRoot != + frostRetainedGroupCheckpointChainRoot( + evidence.bindingHash, + checkpointAfter, + checkpointHashes, + ) { + return nil, fmt.Errorf( + "retained-group final receipt checkpoint-chain root mismatch", + ) + } + checkpointTipHash, err := parseFrostActivationHex32( + payload.Receipt.CheckpointTipHash, + ) + if err != nil || + (len(checkpointHashes) == 0 && + checkpointTipHash != checkpointAfter.CertificateHash) || + (len(checkpointHashes) > 0 && + checkpointTipHash != + checkpointHashes[len(checkpointHashes)-1]) { + return nil, fmt.Errorf( + "retained-group final receipt checkpoint-tip mismatch", + ) + } history := &FrostRetainedGroupHistory{ - From: from, - To: to, - Mutations: mutations, - Complete: true, - EmptyAtFrom: true, - DescriptorSetHash: descriptorSetHash, + From: from, + To: to, + Mutations: mutations, + HistoryRoot: receiptRoot, + CheckpointAfter: checkpointAfter, + Checkpoints: checkpoints, + CheckpointChainRoot: checkpointChainRoot, + CheckpointTipHash: checkpointTipHash, + CheckpointComplete: payload.Receipt.CheckpointComplete, + Complete: true, + EmptyAtFrom: true, + DescriptorSetHash: descriptorSetHash, } if err := validateCompleteFrostRetainedGroupHistory( history, @@ -906,6 +1010,31 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( ); err != nil { return nil, err } + if !history.CheckpointComplete && len(checkpoints) == 0 { + return nil, fmt.Errorf( + "retained-group nonfinal checkpoint page made no progress", + ) + } + if len(checkpoints) > 0 { + if err := validateFrostRetainedGroupCheckpointSemantics( + evidence.checkpointPolicy, + history, + checkpointHashes, + ); err != nil { + return nil, err + } + } + for _, checkpoint := range checkpoints { + blockNumber := checkpoint.Body.Point.BlockNumber + blockHash := checkpoint.Body.Point.BlockHash + if existing, ok := blockHashes[blockNumber]; ok && + existing != blockHash { + return nil, fmt.Errorf( + "retained-group checkpoint chain contains conflicting block hashes", + ) + } + blockHashes[blockNumber] = blockHash + } for blockNumber, blockHash := range blockHashes { if err := source.verifyPointAtFinalizedHead(ctx, FrostPreSignFinality{ BlockNumber: blockNumber, @@ -950,6 +1079,7 @@ func (source *signedFrostRetainedGroupHistorySource) validateHistoryPage( previousPageHash [32]byte, snapshotID [32]byte, descriptorSetHash [32]byte, + checkpointAfter FrostRetainedGroupCheckpointCursor, ) ([32]byte, error) { evidence, evidenceErr := source.activationEvidence() if evidenceErr != nil { @@ -962,6 +1092,13 @@ func (source *signedFrostRetainedGroupHistorySource) validateHistoryPage( !source.validWireIdentity(payload.Identity) { return [32]byte{}, fmt.Errorf("retained-group history page has the wrong identity or position") } + if payload.CheckpointAfter.Sequence != checkpointAfter.Sequence || + payload.CheckpointAfter.CertificateHash != + frostActivationHex32(checkpointAfter.CertificateHash) { + return [32]byte{}, fmt.Errorf( + "retained-group history page checkpoint cursor mismatch", + ) + } declaredQueryHash, err := parseFrostActivationHex32(payload.QueryHash) if err != nil || declaredQueryHash != queryHash { return [32]byte{}, fmt.Errorf("retained-group history page is bound to a different query") @@ -1132,7 +1269,7 @@ func (source *signedFrostRetainedGroupHistorySource) verifySignedEnvelope( ) error { evidence, evidenceErr := source.activationEvidence() if envelope == nil || evidenceErr != nil || - envelope.Schema != "tbtc-frost-retained-group-signed-envelope/v2" || + envelope.Schema != "tbtc-frost-retained-group-signed-envelope/v3" || envelope.BindingHash != frostActivationHex32(evidence.bindingHash) || envelope.SignatureAlgorithm != "ed25519" || len(envelope.Payload) == 0 { return fmt.Errorf("retained-group signed envelope is malformed") diff --git a/pkg/tbtc/frost_retained_group_history_source_test.go b/pkg/tbtc/frost_retained_group_history_source_test.go index ced69de5e6..3ff213c81c 100644 --- a/pkg/tbtc/frost_retained_group_history_source_test.go +++ b/pkg/tbtc/frost_retained_group_history_source_test.go @@ -298,7 +298,7 @@ func (export *frostRetainedGroupHistoryTestExport) ServeHTTP( signature[0] ^= 0xff } envelope := frostRetainedGroupSignedEnvelope{ - Schema: "tbtc-frost-retained-group-signed-envelope/v2", + Schema: "tbtc-frost-retained-group-signed-envelope/v3", BindingHash: frostActivationHex32(export.bindingHash), Payload: json.RawMessage(canonical), PayloadSHA256: frostActivationHex32(payloadHash), @@ -328,8 +328,22 @@ type frostRetainedGroupHistorySourceFixture struct { mutations []FrostRetainedGroupMutation dkgFullMembers []uint32 dkgMisbehaved []uint8 - pageMutator func(*frostRetainedGroupHistoryPagePayload) - operatorMutator func(*frostRetainedGroupOperatorReceiptPayload) + checkpointIssuer func( + FrostRetainedGroupCheckpointCursor, + FrostPreSignFinality, + []FrostRetainedGroupMutation, + ) ([]FrostRetainedGroupCheckpointCertificate, error) + pageMutator func(*frostRetainedGroupHistoryPagePayload) + operatorMutator func(*frostRetainedGroupOperatorReceiptPayload) +} + +func (fixture *frostRetainedGroupHistorySourceFixture) checkpointAfter() FrostRetainedGroupCheckpointCursor { + return FrostRetainedGroupCheckpointCursor{ + Sequence: fixture.runtimeManifest.QuarantineJournal. + CheckpointMinimumSequence - 1, + CertificateHash: fixture.runtimeManifest.QuarantineJournal. + CheckpointPredecessorHash, + } } func newFrostRetainedGroupHistorySourceFixture( @@ -363,6 +377,21 @@ func newFrostRetainedGroupHistorySourceFixture( sortitionPoolCode, headers[1].Hash(), ) + checkpointPrivateKeys := make([]ed25519.PrivateKey, 3) + checkpointPublicKeySPKIs := make([]string, 3) + checkpointAuthorities := make([]FrostRetainedGroupAuthority, 3) + for index := range checkpointAuthorities { + authority, privateKey, publicKeySPKI := journalTestAuthority( + t, + fmt.Sprintf("checkpoint-%d", index+1), + byte(0x51+index), + ) + checkpointAuthorities[index] = authority + checkpointPrivateKeys[index] = privateKey + checkpointPublicKeySPKIs[index] = publicKeySPKI + } + runtimeManifest.QuarantineJournal.CheckpointAuthorities = + checkpointAuthorities operatorAddress := common.HexToAddress("0x1111111111111111111111111111111111111111") verifier := &frostRetainedGroupHistoryTestVerifier{ chainID: big.NewInt(1), @@ -440,6 +469,13 @@ func newFrostRetainedGroupHistorySourceFixture( fixture.mutations[0].OperatorIDs..., ) fixture.installReceipts() + fixture.checkpointIssuer = newFrostRetainedGroupTestCheckpointIssuer( + t, + source.evidence.checkpointPolicy, + fixture.from, + checkpointPrivateKeys, + checkpointPublicKeySPKIs, + ) export.historyResponder = fixture.historyResponse export.operatorResponder = fixture.operatorResponse return fixture @@ -569,6 +605,8 @@ func frostRetainedGroupHistoryTestProfile( TombstoneProtocolID: [32]byte{0x4c}, CheckpointAuthorityThreshold: 2, CheckpointAuthorities: checkpointAuthorities, + CheckpointMinimumSequence: 1, + CheckpointPredecessorHash: [32]byte{}, LiftAuthorityThreshold: 2, LiftAuthorities: liftAuthorities, StoreID: "quarantine-test-store", @@ -894,6 +932,7 @@ func frostRetainedGroupHistoryTestReceipt( func (fixture *frostRetainedGroupHistorySourceFixture) historyPages( query frostRetainedGroupHistoryQuery, + checkpointAfterWire frostRetainedGroupWireCheckpointCursor, ) []*frostRetainedGroupHistoryPagePayload { fixture.t.Helper() queryHash, err := frostRetainedGroupDomainHash(frostRetainedGroupHistoryQueryDomain, query) @@ -904,6 +943,51 @@ func (fixture *frostRetainedGroupHistorySourceFixture) historyPages( for index, mutation := range fixture.mutations { wireMutations[index] = frostRetainedGroupMutationToWire(mutation) } + checkpointAfterHash, err := parseFrostActivationHex32( + checkpointAfterWire.CertificateHash, + ) + if err != nil { + fixture.t.Fatal(err) + } + checkpointAfter := FrostRetainedGroupCheckpointCursor{ + Sequence: checkpointAfterWire.Sequence, + CertificateHash: checkpointAfterHash, + } + checkpointTarget, err := frostRetainedGroupFinalityFromWire(query.To) + if err != nil { + fixture.t.Fatal(err) + } + checkpoints, err := fixture.checkpointIssuer( + checkpointAfter, + checkpointTarget, + fixture.mutations, + ) + if err != nil { + fixture.t.Fatal(err) + } + checkpointComplete := true + if len(checkpoints) > frostRetainedGroupMaximumCheckpointsPerPage { + checkpoints = checkpoints[:frostRetainedGroupMaximumCheckpointsPerPage] + checkpointComplete = false + } + checkpointHashes := make([][32]byte, len(checkpoints)) + wireCheckpoints := make( + []frostRetainedGroupWireCheckpointCertificate, + len(checkpoints), + ) + for index, checkpoint := range checkpoints { + checkpointHashes[index], err = + frostRetainedGroupCheckpointCertificateHash(checkpoint) + if err != nil { + fixture.t.Fatal(err) + } + wireCheckpoints[index] = + frostRetainedGroupCheckpointCertificateToWire(checkpoint) + } + checkpointTipHash := checkpointAfter.CertificateHash + if len(checkpointHashes) > 0 { + checkpointTipHash = checkpointHashes[len(checkpointHashes)-1] + } pageCount := (len(wireMutations) + 1) / 2 if pageCount == 0 { pageCount = 1 @@ -943,6 +1027,7 @@ func (fixture *frostRetainedGroupHistorySourceFixture) historyPages( To: query.To, EmptyAtFrom: true, DescriptorSetHash: frostActivationHex32(fixture.descriptorSetHash), + CheckpointAfter: checkpointAfterWire, Mutations: append([]frostRetainedGroupWireMutation{}, wireMutations[start:end]...), NextCursor: nextCursor, Complete: index+1 == pageCount, @@ -963,10 +1048,21 @@ func (fixture *frostRetainedGroupHistorySourceFixture) historyPages( fixture.t.Fatal(err) } pages[len(pages)-1].Receipt = &frostRetainedGroupHistoryReceipt{ - PageCount: uint64(len(pages)), - MutationCount: uint64(len(wireMutations)), - BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), - HistoryRoot: frostActivationHex32(historyRoot), + PageCount: uint64(len(pages)), + MutationCount: uint64(len(wireMutations)), + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), + HistoryRoot: frostActivationHex32(historyRoot), + CheckpointAfter: checkpointAfterWire, + CheckpointCertificates: wireCheckpoints, + CheckpointChainRoot: frostActivationHex32( + frostRetainedGroupCheckpointChainRoot( + fixture.source.evidence.bindingHash, + checkpointAfter, + checkpointHashes, + ), + ), + CheckpointTipHash: frostActivationHex32(checkpointTipHash), + CheckpointComplete: checkpointComplete, } return pages } @@ -978,7 +1074,7 @@ func (fixture *frostRetainedGroupHistorySourceFixture) historyResponse( frostActivationHex32(fixture.source.evidence.bindingHash) { return nil } - pages := fixture.historyPages(request.Query) + pages := fixture.historyPages(request.Query, request.CheckpointAfter) for _, page := range pages { if page.Cursor == request.Cursor { copy := *page @@ -1038,6 +1134,7 @@ func TestSignedFrostRetainedGroupHistorySource_ReadsCompletePaginatedHistory( context.Background(), fixture.from, fixture.to, + fixture.checkpointAfter(), ) if err != nil { t.Fatal(err) @@ -1056,6 +1153,106 @@ func TestSignedFrostRetainedGroupHistorySource_ReadsCompletePaginatedHistory( } } +func TestSignedFrostRetainedGroupHistorySource_PaginatesLongCheckpointChain( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + checkpointCount := frostRetainedGroupMaximumCheckpointsPerPage + 2 + targetBlock := uint64(checkpointCount + 2) + finalizedBlock := targetBlock + 10 + for blockNumber := uint64(11); blockNumber <= finalizedBlock; blockNumber++ { + fixture.verifier.headers[blockNumber] = &types.Header{ + Number: new(big.Int).SetUint64(blockNumber), + Time: blockNumber, + Extra: []byte{byte(blockNumber), 0x9a}, + } + } + fixture.to = FrostPreSignFinality{ + BlockNumber: targetBlock, + BlockHash: fixture.verifier.headers[targetBlock].Hash(), + } + fixture.verifier.finalized = fixture.verifier.headers[finalizedBlock] + + cursor := fixture.checkpointAfter() + for blockNumber := uint64(3); blockNumber <= targetBlock; blockNumber++ { + point := FrostPreSignFinality{ + BlockNumber: blockNumber, + BlockHash: fixture.verifier.headers[blockNumber].Hash(), + } + prefix := make([]FrostRetainedGroupMutation, 0, len(fixture.mutations)) + for _, mutation := range fixture.mutations { + if mutation.Point.BlockNumber <= blockNumber { + prefix = append(prefix, mutation) + } + } + certificates, err := fixture.checkpointIssuer( + cursor, + point, + prefix, + ) + if err != nil { + t.Fatal(err) + } + if len(certificates) != 1 { + t.Fatalf( + "expected one newly issued checkpoint, got [%d]", + len(certificates), + ) + } + certificateHash, err := + frostRetainedGroupCheckpointCertificateHash(certificates[0]) + if err != nil { + t.Fatal(err) + } + cursor = FrostRetainedGroupCheckpointCursor{ + Sequence: certificates[0].Body.Sequence, + CertificateHash: certificateHash, + } + } + + first, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if err != nil { + t.Fatal(err) + } + if first.CheckpointComplete || + len(first.Checkpoints) != + frostRetainedGroupMaximumCheckpointsPerPage { + t.Fatalf( + "unexpected first checkpoint page: complete [%t], count [%d]", + first.CheckpointComplete, + len(first.Checkpoints), + ) + } + firstTail := FrostRetainedGroupCheckpointCursor{ + Sequence: first.Checkpoints[len(first.Checkpoints)-1].Body.Sequence, + CertificateHash: first.CheckpointTipHash, + } + second, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + firstTail, + ) + if err != nil { + t.Fatal(err) + } + if !second.CheckpointComplete || + len(second.Checkpoints) != 2 || + second.CheckpointTipHash != cursor.CertificateHash || + second.HistoryRoot != first.HistoryRoot { + t.Fatalf( + "unexpected final checkpoint page: complete [%t], count [%d]", + second.CheckpointComplete, + len(second.Checkpoints), + ) + } +} + func TestSignedFrostRetainedGroupHistorySource_ProtocolBindingCommitsRuntime( t *testing.T, ) { @@ -1174,6 +1371,22 @@ func TestSignedFrostRetainedGroupHistorySource_ProtocolBindingCommitsRuntime( runtime.QuarantineJournal.CheckpointAuthorities[0]. PublicKeySPKIHash[0] ^= 0xff }, + "checkpoint minimum sequence": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.CheckpointMinimumSequence++ + runtime.QuarantineJournal.CheckpointPredecessorHash = + [32]byte{0x7f} + }, + "checkpoint predecessor": func( + _ *FrostPreSignActivationProfile, + runtime *FrostPreSignActivationRuntimeManifest, + ) { + runtime.QuarantineJournal.CheckpointMinimumSequence = 2 + runtime.QuarantineJournal.CheckpointPredecessorHash = + [32]byte{0x7e} + }, "lift authority set": func( _ *FrostPreSignActivationProfile, runtime *FrostPreSignActivationRuntimeManifest, @@ -1332,7 +1545,7 @@ func TestSignedFrostRetainedGroupHistorySource_DecodesCanonicalVerifiedBytes( append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...), ) envelope := &frostRetainedGroupSignedEnvelope{ - Schema: "tbtc-frost-retained-group-signed-envelope/v2", + Schema: "tbtc-frost-retained-group-signed-envelope/v3", BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), Payload: raw, PayloadSHA256: frostActivationHex32(payloadHash), @@ -1369,7 +1582,7 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsCrossBindingEnvelopeAndRoo append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...), ) envelope := &frostRetainedGroupSignedEnvelope{ - Schema: "tbtc-frost-retained-group-signed-envelope/v2", + Schema: "tbtc-frost-retained-group-signed-envelope/v3", BindingHash: frostActivationHex32([32]byte{0xff}), Payload: raw, PayloadSHA256: frostActivationHex32(payloadHash), @@ -1416,6 +1629,7 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsCrossBindingEnvelopeAndRoo context.Background(), fixture.from, fixture.to, + fixture.checkpointAfter(), ) if err == nil || !strings.Contains(err.Error(), "does not cover the exact mutation sequence") { @@ -1429,7 +1643,7 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsDuplicateSignedPayloadKey( ) { fixture := newFrostRetainedGroupHistorySourceFixture(t) envelope := &frostRetainedGroupSignedEnvelope{ - Schema: "tbtc-frost-retained-group-signed-envelope/v2", + Schema: "tbtc-frost-retained-group-signed-envelope/v3", BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), Payload: json.RawMessage(`{"schema":"v1","schema":"v2"}`), PayloadSHA256: frostActivationHex32([32]byte{0x01}), @@ -1459,7 +1673,7 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsNonExactSignedPayloadKey( append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...), ) envelope := &frostRetainedGroupSignedEnvelope{ - Schema: "tbtc-frost-retained-group-signed-envelope/v2", + Schema: "tbtc-frost-retained-group-signed-envelope/v3", BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), Payload: payload, PayloadSHA256: frostActivationHex32(payloadHash), @@ -1477,10 +1691,18 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsNonExactSignedPayloadKey( func TestSignedFrostRetainedGroupHistorySource_RejectsOmittedPage(t *testing.T) { fixture := newFrostRetainedGroupHistorySourceFixture(t) fixture.export.historyResponder = func(request frostRetainedGroupHistoryPageRequest) interface{} { - pages := fixture.historyPages(request.Query) + pages := fixture.historyPages( + request.Query, + request.CheckpointAfter, + ) return pages[len(pages)-1] } - _, err := fixture.source.ReadCompleteHistory(context.Background(), fixture.from, fixture.to) + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) if err == nil || !strings.Contains(err.Error(), "wrong identity or position") { t.Fatalf("expected omitted-page rejection, got [%v]", err) } @@ -1500,7 +1722,12 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsTruncationAndForgery( } return defaultResponder(request) } - _, err := fixture.source.ReadCompleteHistory(context.Background(), fixture.from, fixture.to) + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) if err == nil || !strings.Contains(err.Error(), "HTTP status [503]") { t.Fatalf("expected truncated pagination rejection, got [%v]", err) } @@ -1509,7 +1736,12 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsTruncationAndForgery( t.Run("forged envelope", func(t *testing.T) { fixture := newFrostRetainedGroupHistorySourceFixture(t) fixture.export.corruptSignature = true - _, err := fixture.source.ReadCompleteHistory(context.Background(), fixture.from, fixture.to) + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) if err == nil || !strings.Contains(err.Error(), "signature is invalid") { t.Fatalf("expected forged envelope rejection, got [%v]", err) } @@ -1562,6 +1794,7 @@ func TestSignedFrostRetainedGroupHistorySource_EnforcesAggregateResourceLimits( context.Background(), fixture.from, fixture.to, + fixture.checkpointAfter(), ) if err == nil || !strings.Contains(err.Error(), testCase.expected) { t.Fatalf("expected %s rejection, got [%v]", name, err) @@ -1583,6 +1816,7 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsOversizedReceiptLogSet( context.Background(), fixture.from, fixture.to, + fixture.checkpointAfter(), ) if err == nil || !strings.Contains(err.Error(), "log limit") { t.Fatalf("expected oversized receipt-log rejection, got [%v]", err) @@ -1604,7 +1838,12 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsDuplicateAndReorderedHisto t.Run(name, func(t *testing.T) { fixture := newFrostRetainedGroupHistorySourceFixture(t) mutate(fixture) - _, err := fixture.source.ReadCompleteHistory(context.Background(), fixture.from, fixture.to) + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) if err == nil { t.Fatal("expected malformed exact history to be rejected") } @@ -1631,6 +1870,7 @@ func TestSignedFrostRetainedGroupHistorySource_AcceptsFilteredStakeWeightedSeats context.Background(), fixture.from, fixture.to, + fixture.checkpointAfter(), ) if err != nil { t.Fatal(err) @@ -1691,6 +1931,7 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsInvalidMisbehavedIndices( context.Background(), fixture.from, fixture.to, + fixture.checkpointAfter(), ) if err == nil || !strings.Contains( err.Error(), @@ -1728,12 +1969,34 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsIdentityCheckpointAndRecei page.Receipt.HistoryRoot = frostActivationHex32([32]byte{0xee}) } }, + "checkpoint chain root": func(page *frostRetainedGroupHistoryPagePayload) { + if page.Complete { + page.Receipt.CheckpointChainRoot = + frostActivationHex32([32]byte{0xed}) + } + }, + "checkpoint tip": func(page *frostRetainedGroupHistoryPagePayload) { + if page.Complete { + page.Receipt.CheckpointTipHash = + frostActivationHex32([32]byte{0xec}) + } + }, + "checkpoint completion": func(page *frostRetainedGroupHistoryPagePayload) { + if page.Complete { + page.Receipt.CheckpointComplete = false + } + }, } for name, mutate := range tests { t.Run(name, func(t *testing.T) { fixture := newFrostRetainedGroupHistorySourceFixture(t) fixture.pageMutator = mutate - _, err := fixture.source.ReadCompleteHistory(context.Background(), fixture.from, fixture.to) + _, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) if err == nil { t.Fatalf("expected %s drift to be rejected", name) } @@ -1752,6 +2015,7 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsSemanticForgeryInCanonical context.Background(), fixture.from, fixture.to, + fixture.checkpointAfter(), ) if err == nil || !strings.Contains(err.Error(), "Bridge registration log") { t.Fatalf("expected canonical-block semantic forgery rejection, got [%v]", err) @@ -1769,6 +2033,7 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsWrongReceiptLog( context.Background(), fixture.from, fixture.to, + fixture.checkpointAfter(), ) if err == nil || !strings.Contains(err.Error(), "Bridge registration log") { t.Fatalf("expected wrong receipt-log rejection, got [%v]", err) @@ -1784,6 +2049,7 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsManifestCodeDrift( context.Background(), fixture.from, fixture.to, + fixture.checkpointAfter(), ) if err == nil || !strings.Contains(err.Error(), "signed activation manifest") { t.Fatalf("expected manifest code-drift rejection, got [%v]", err) @@ -1996,7 +2262,12 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsWrongEndpointAndReorg( Time: 999, Extra: []byte{0xff}, } - _, err = fixture.source.ReadCompleteHistory(context.Background(), fixture.from, fixture.to) + _, err = fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) if err == nil || !strings.Contains(err.Error(), "canonical chain") { t.Fatalf("expected finalized-chain reorg rejection, got [%v]", err) } diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index b3e775bd53..0cf91c382d 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -31,7 +31,7 @@ const ( frostRetainedGroupJournalMetadataSchema = "tbtc-frost-retained-group-journal-metadata/v3" frostRetainedGroupJournalBatchSchema = "tbtc-frost-retained-group-journal-batch/v3" frostRetainedGroupJournalStateSchema = "tbtc-frost-retained-group-journal-state/v3" - frostRetainedGroupJournalSnapshotSchema = "tbtc-frost-retained-group-journal-snapshot/v3" + frostRetainedGroupJournalSnapshotSchema = "tbtc-frost-retained-group-journal-snapshot/v4" frostRetainedGroupJournalLockFile = ".lock" frostRetainedGroupJournalMetadataFile = "metadata.json" frostRetainedGroupJournalStateFile = "state.json" @@ -246,12 +246,18 @@ type FrostRetainedGroupHistoryIdentity struct { } type FrostRetainedGroupHistory struct { - From FrostPreSignFinality - To FrostPreSignFinality - Mutations []FrostRetainedGroupMutation - Complete bool - EmptyAtFrom bool - DescriptorSetHash [32]byte + From FrostPreSignFinality + To FrostPreSignFinality + Mutations []FrostRetainedGroupMutation + HistoryRoot [32]byte + CheckpointAfter FrostRetainedGroupCheckpointCursor + Checkpoints []FrostRetainedGroupCheckpointCertificate + CheckpointChainRoot [32]byte + CheckpointTipHash [32]byte + CheckpointComplete bool + Complete bool + EmptyAtFrom bool + DescriptorSetHash [32]byte } // FrostRetainedGroupHistorySource is independent of the primary deployment @@ -265,6 +271,7 @@ type FrostRetainedGroupHistorySource interface { context.Context, FrostPreSignFinality, FrostPreSignFinality, + FrostRetainedGroupCheckpointCursor, ) (*FrostRetainedGroupHistory, error) ResolveOperatorID( context.Context, @@ -293,6 +300,8 @@ type FrostRetainedGroupQuarantineJournalManifest struct { TombstoneProtocolID [32]byte CheckpointAuthorityThreshold uint64 CheckpointAuthorities []FrostRetainedGroupAuthority + CheckpointMinimumSequence uint64 + CheckpointPredecessorHash [32]byte LiftAuthorityThreshold uint64 LiftAuthorities []FrostRetainedGroupAuthority StoreID string @@ -913,32 +922,44 @@ type frostRetainedGroupJournalSnapshot struct { QuarantineTombstoneRoot [32]byte QuarantineCount uint64 QuarantineTombstoneCount uint64 + CheckpointMinimumSequence uint64 + CheckpointPredecessorHash [32]byte + CheckpointSequence uint64 + CheckpointCertificateHash [32]byte + CheckpointHistoryRoot [32]byte LocalSessionCount uint64 Complete bool } type frostRetainedGroupJournal struct { - mutex sync.Mutex - rootDirectory string - directory string - quarantineDirectory string - metadata frostRetainedGroupJournalMetadata - quarantineMetadata frostRetainedGroupQuarantineMetadata - minimumGeneration uint64 - quarantineMinimumGeneration uint64 - source FrostRetainedGroupHistorySource - walletRegistry *walletRegistry - operatorAddress chain.Address - lockFile *os.File - quarantineLockFile *os.File - state frostRetainedGroupJournalState - quarantineState frostRetainedGroupQuarantineJournalState - mutations []FrostRetainedGroupMutation - quarantineMutations []FrostRetainedGroupMutation - liftPolicy frostRetainedGroupQuarantineLiftPolicy - liftCertificates map[[32]byte]FrostRetainedGroupQuarantineLiftCertificate - persistFailureHook func(string) error - closed bool + mutex sync.Mutex + rootDirectory string + directory string + quarantineDirectory string + checkpointDirectory string + metadata frostRetainedGroupJournalMetadata + quarantineMetadata frostRetainedGroupQuarantineMetadata + minimumGeneration uint64 + quarantineMinimumGeneration uint64 + source FrostRetainedGroupHistorySource + walletRegistry *walletRegistry + operatorAddress chain.Address + lockFile *os.File + quarantineLockFile *os.File + checkpointLockFile *os.File + state frostRetainedGroupJournalState + quarantineState frostRetainedGroupQuarantineJournalState + mutations []FrostRetainedGroupMutation + quarantineMutations []FrostRetainedGroupMutation + liftPolicy frostRetainedGroupQuarantineLiftPolicy + liftCertificates map[[32]byte]FrostRetainedGroupQuarantineLiftCertificate + checkpointPolicy frostRetainedGroupCheckpointPolicy + checkpointState frostRetainedGroupCheckpointJournalState + checkpointCertificates map[uint64]FrostRetainedGroupCheckpointCertificate + checkpointHashes map[uint64][32]byte + persistFailureHook func(string) error + checkpointPersistFailureHook func(string) error + closed bool } func newFrostRetainedGroupJournal( @@ -961,6 +982,17 @@ func newFrostRetainedGroupJournal( liftPolicyErr, ) } + checkpointPolicy, checkpointPolicyErr := + frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + bindingHash, + runtimeManifest, + ) + if checkpointPolicyErr != nil { + return nil, fmt.Errorf( + "invalid FROST retained-group checkpoint policy: [%w]", + checkpointPolicyErr, + ) + } if strings.TrimSpace(directory) == "" || runtimeManifest.ManifestHash == [32]byte{} || bindingHash == [32]byte{} || @@ -1000,7 +1032,12 @@ func newFrostRetainedGroupJournal( } canonicalDirectory := filepath.Join(cleanRootDirectory, frostRetainedGroupCanonicalDirectory) quarantineDirectory := filepath.Join(cleanRootDirectory, frostRetainedGroupQuarantineDirectory) - for _, child := range []string{canonicalDirectory, quarantineDirectory} { + checkpointDirectory := filepath.Join(cleanRootDirectory, frostRetainedGroupCheckpointDirectory) + for _, child := range []string{ + canonicalDirectory, + quarantineDirectory, + checkpointDirectory, + } { if err := os.MkdirAll(child, 0700); err != nil { return nil, fmt.Errorf("cannot create FROST retained-group journal store: [%w]", err) } @@ -1021,10 +1058,21 @@ func newFrostRetainedGroupJournal( _ = lockFile.Close() return nil, err } + checkpointLockFile, err := acquireFrostRetainedGroupJournalLock( + checkpointDirectory, + ) + if err != nil { + _ = unix.Flock(int(quarantineLockFile.Fd()), unix.LOCK_UN) + _ = quarantineLockFile.Close() + _ = unix.Flock(int(lockFile.Fd()), unix.LOCK_UN) + _ = lockFile.Close() + return nil, err + } journal := &frostRetainedGroupJournal{ rootDirectory: cleanRootDirectory, directory: canonicalDirectory, quarantineDirectory: quarantineDirectory, + checkpointDirectory: checkpointDirectory, metadata: frostRetainedGroupJournalMetadata{ Schema: frostRetainedGroupJournalMetadataSchema, ManifestHash: runtimeManifest.ManifestHash, @@ -1063,8 +1111,12 @@ func newFrostRetainedGroupJournal( operatorAddress: operatorAddress, lockFile: lockFile, quarantineLockFile: quarantineLockFile, + checkpointLockFile: checkpointLockFile, liftPolicy: liftPolicy, liftCertificates: make(map[[32]byte]FrostRetainedGroupQuarantineLiftCertificate), + checkpointPolicy: checkpointPolicy, + checkpointCertificates: make(map[uint64]FrostRetainedGroupCheckpointCertificate), + checkpointHashes: make(map[uint64][32]byte), } if err := journal.initialize(); err != nil { _ = journal.close() @@ -1081,7 +1133,8 @@ func validateFrostRetainedGroupJournalRoot(directory string) error { for _, entry := range entries { if entry.Type()&os.ModeSymlink != 0 || (entry.Name() != frostRetainedGroupCanonicalDirectory && - entry.Name() != frostRetainedGroupQuarantineDirectory) || + entry.Name() != frostRetainedGroupQuarantineDirectory && + entry.Name() != frostRetainedGroupCheckpointDirectory) || !entry.IsDir() { return fmt.Errorf("unsafe entry in FROST retained-group journal root: [%s]", entry.Name()) } @@ -1130,7 +1183,11 @@ func (frgj *frostRetainedGroupJournal) close() error { } frgj.closed = true var result error - for _, lock := range []*os.File{frgj.quarantineLockFile, frgj.lockFile} { + for _, lock := range []*os.File{ + frgj.checkpointLockFile, + frgj.quarantineLockFile, + frgj.lockFile, + } { if lock == nil { continue } @@ -1143,6 +1200,7 @@ func (frgj *frostRetainedGroupJournal) close() error { } frgj.lockFile = nil frgj.quarantineLockFile = nil + frgj.checkpointLockFile = nil return result } @@ -1278,7 +1336,10 @@ func (frgj *frostRetainedGroupJournal) initialize() error { return fmt.Errorf("cannot integrate orphan FROST retained-group batch: [%w]", err) } } - return frgj.initializeQuarantine() + if err := frgj.initializeQuarantine(); err != nil { + return err + } + return frgj.initializeCheckpointJournal() } func (frgj *frostRetainedGroupJournal) verifyHistorySourceIdentity( @@ -2135,6 +2196,31 @@ func validateFrostRetainedGroupJournalFileName(name string) error { name == frostRetainedGroupJournalStateFile { return nil } + if strings.HasPrefix(name, frostRetainedGroupCheckpointFilePrefix) { + checkpointText := strings.TrimSuffix( + strings.TrimPrefix(name, frostRetainedGroupCheckpointFilePrefix), + frostRetainedGroupJournalFileSuffix, + ) + sequenceText, digestText, separated := strings.Cut(checkpointText, "-") + digestBytes, digestErr := hex.DecodeString(digestText) + sequence, sequenceErr := strconv.ParseUint(sequenceText, 10, 64) + if !separated || len(sequenceText) != 20 || sequenceErr != nil || + sequence == 0 || digestErr != nil || len(digestBytes) != 32 { + return fmt.Errorf( + "noncanonical FROST retained-group checkpoint certificate name: [%s]", + name, + ) + } + digest := [32]byte{} + copy(digest[:], digestBytes) + if frostRetainedGroupCheckpointFileName(sequence, digest) != name { + return fmt.Errorf( + "noncanonical FROST retained-group checkpoint certificate name: [%s]", + name, + ) + } + return nil + } if strings.HasPrefix(name, frostRetainedGroupLiftCertificatePrefix) { digestText := strings.TrimSuffix( strings.TrimPrefix(name, frostRetainedGroupLiftCertificatePrefix), @@ -3029,6 +3115,40 @@ func cloneFrostRetainedGroupMutations( return result } +func equalFrostRetainedGroupSemanticHistories( + first *FrostRetainedGroupHistory, + second *FrostRetainedGroupHistory, +) (bool, error) { + if first == nil || second == nil || + first.From != second.From || + first.To != second.To || + first.HistoryRoot != second.HistoryRoot || + first.Complete != second.Complete || + first.EmptyAtFrom != second.EmptyAtFrom || + first.DescriptorSetHash != second.DescriptorSetHash || + len(first.Mutations) != len(second.Mutations) { + return false, nil + } + for index := range first.Mutations { + firstMutation, err := frostRetainedGroupCanonicalValue( + first.Mutations[index], + ) + if err != nil { + return false, err + } + secondMutation, err := frostRetainedGroupCanonicalValue( + second.Mutations[index], + ) + if err != nil { + return false, err + } + if !bytes.Equal(firstMutation, secondMutation) { + return false, nil + } + } + return true, nil +} + func (frgj *frostRetainedGroupJournal) reconcile( ctx context.Context, target FrostPreSignFinality, @@ -3050,7 +3170,11 @@ func (frgj *frostRetainedGroupJournal) reconcile( if target.BlockNumber == 0 || target.BlockHash == [32]byte{} || target.BlockNumber < frgj.metadata.Checkpoint.BlockNumber || target.BlockNumber < frgj.state.CurrentPoint.BlockNumber || - target.BlockNumber < frgj.quarantineState.CurrentPoint.BlockNumber { + target.BlockNumber < frgj.quarantineState.CurrentPoint.BlockNumber || + (frgj.checkpointState.Sequence >= + frgj.checkpointPolicy.MinimumSequence && + target.BlockNumber < + frgj.checkpointState.Point.BlockNumber) { return nil, fmt.Errorf("FROST retained-group target is invalid or retrograde") } if err := frgj.verifyHistorySourceIdentity(ctx); err != nil { @@ -3079,23 +3203,201 @@ func (frgj *frostRetainedGroupJournal) reconcile( return nil, fmt.Errorf("cannot verify FROST retained-group %s: [%w]", name, err) } } - history, err := frgj.source.ReadCompleteHistory(ctx, frgj.metadata.Checkpoint, target) - if err != nil { - return nil, fmt.Errorf("cannot reconstruct complete FROST retained-group history: [%w]", err) + checkpointAfter := FrostRetainedGroupCheckpointCursor{ + Sequence: frgj.checkpointState.Sequence, + CertificateHash: frgj.checkpointState.CertificateHash, } - if history == nil || !history.Complete || !history.EmptyAtFrom || - history.From != frgj.metadata.Checkpoint || - history.To != target || history.DescriptorSetHash != frgj.metadata.DescriptorSetHash { - return nil, fmt.Errorf("FROST retained-group history receipt is incomplete or differently bound") + checkpointCursor := checkpointAfter + var history *FrostRetainedGroupHistory + checkpointHashes := make([][32]byte, 0) + checkpointCertificates := make( + []FrostRetainedGroupCheckpointCertificate, + 0, + ) + checkpointRecoveryComplete := false + for checkpointPage := 0; checkpointPage < frostRetainedGroupMaximumCheckpointPages; checkpointPage++ { + page, err := frgj.source.ReadCompleteHistory( + ctx, + frgj.metadata.Checkpoint, + target, + checkpointCursor, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot reconstruct complete FROST retained-group history: [%w]", + err, + ) + } + if page == nil || !page.Complete || !page.EmptyAtFrom || + page.From != frgj.metadata.Checkpoint || + page.To != target || + page.DescriptorSetHash != frgj.metadata.DescriptorSetHash { + return nil, fmt.Errorf( + "FROST retained-group history receipt is incomplete or differently bound", + ) + } + if len(page.Mutations) > frostRetainedGroupMaximumMutations { + return nil, fmt.Errorf( + "FROST retained-group history exceeds the mutation limit", + ) + } + if len(page.Checkpoints) > + frostRetainedGroupMaximumCheckpointsPerPage { + return nil, fmt.Errorf( + "FROST retained-group checkpoint page exceeds its bound", + ) + } + if err := validateCompleteFrostRetainedGroupHistory( + page, + frgj.liftPolicy, + ); err != nil { + return nil, err + } + if page.CheckpointAfter != checkpointCursor { + return nil, fmt.Errorf( + "FROST retained-group history checkpoint cursor differs from the requested certified head", + ) + } + if history == nil { + history = page + } else { + equal, err := equalFrostRetainedGroupSemanticHistories( + history, + page, + ) + if err != nil { + return nil, err + } + if !equal { + return nil, fmt.Errorf( + "FROST retained-group history changed between checkpoint pages", + ) + } + } + pageCheckpointHashes, err := + validateFrostRetainedGroupCheckpointSuffix( + frgj.checkpointPolicy, + checkpointCursor, + page.Checkpoints, + ) + if err != nil { + return nil, err + } + if len(page.Checkpoints) == 0 && !page.CheckpointComplete { + return nil, fmt.Errorf( + "nonfinal FROST checkpoint page made no progress", + ) + } + if len(page.Checkpoints) > 0 { + if err := validateFrostRetainedGroupCheckpointSemantics( + frgj.checkpointPolicy, + page, + pageCheckpointHashes, + ); err != nil { + return nil, err + } + for index, certificate := range page.Checkpoints { + if err := frgj.source.VerifyPoint( + ctx, + certificate.Body.Point, + ); err != nil { + return nil, fmt.Errorf( + "cannot verify FROST checkpoint certificate point [%d:%d]: [%w]", + checkpointPage, + index, + err, + ) + } + } + checkpointCertificates = append( + checkpointCertificates, + page.Checkpoints..., + ) + checkpointHashes = append( + checkpointHashes, + pageCheckpointHashes..., + ) + tail := len(page.Checkpoints) - 1 + checkpointCursor = FrostRetainedGroupCheckpointCursor{ + Sequence: page.Checkpoints[tail].Body.Sequence, + CertificateHash: pageCheckpointHashes[tail], + } + } + if page.CheckpointComplete { + checkpointRecoveryComplete = true + break + } } - if len(history.Mutations) > frostRetainedGroupMaximumMutations { - return nil, fmt.Errorf("FROST retained-group history exceeds the mutation limit") + if history == nil { + return nil, fmt.Errorf( + "FROST retained-group history source returned no checkpoint page", + ) } - if err := validateCompleteFrostRetainedGroupHistory( - history, - frgj.liftPolicy, - ); err != nil { - return nil, err + history.CheckpointAfter = checkpointAfter + history.Checkpoints = checkpointCertificates + history.CheckpointChainRoot = + frostRetainedGroupCheckpointChainRoot( + frgj.checkpointPolicy.ProtocolBindingHash, + checkpointAfter, + checkpointHashes, + ) + history.CheckpointTipHash = checkpointAfter.CertificateHash + if len(checkpointHashes) > 0 { + history.CheckpointTipHash = + checkpointHashes[len(checkpointHashes)-1] + } + history.CheckpointComplete = checkpointRecoveryComplete + if len(checkpointCertificates) > 0 { + // Revalidate the cross-page predecessor chain and the exact aggregate + // semantic binding. Per-page bounds remain enforced above. + checkpointHashes, err = + validateFrostRetainedGroupCheckpointSuffix( + frgj.checkpointPolicy, + checkpointAfter, + checkpointCertificates, + ) + if err != nil { + return nil, err + } + if err := validateFrostRetainedGroupCheckpointSemantics( + frgj.checkpointPolicy, + history, + checkpointHashes, + ); err != nil { + return nil, err + } + } + if len(history.Checkpoints) == 0 { + if !history.CheckpointComplete || + frgj.checkpointState.Sequence < + frgj.checkpointPolicy.MinimumSequence || + frgj.checkpointState.Point != target || + frgj.checkpointState.HistoryRoot != history.HistoryRoot || + history.CheckpointTipHash != + frgj.checkpointState.CertificateHash || + history.CheckpointChainRoot != + frostRetainedGroupCheckpointChainRoot( + frgj.checkpointPolicy.ProtocolBindingHash, + checkpointAfter, + nil, + ) { + return nil, fmt.Errorf( + "canonical FROST retained-group history rewrote, omitted, or reordered the durable certified head", + ) + } + } else { + if frgj.checkpointState.Sequence >= + frgj.checkpointPolicy.MinimumSequence && + (history.Checkpoints[0].Body.Point.BlockNumber <= + frgj.checkpointState.Point.BlockNumber || + history.Checkpoints[0].Body.CanonicalGeneration < + frgj.checkpointState.CanonicalGeneration || + history.Checkpoints[0].Body.QuarantineGeneration < + frgj.checkpointState.QuarantineGeneration) { + return nil, fmt.Errorf( + "FROST checkpoint suffix does not monotonically advance the durable head", + ) + } } canonicalInventoryMutations := frostRetainedGroupCanonicalMutations(history.Mutations) if len(frgj.mutations) > len(canonicalInventoryMutations) { @@ -3180,6 +3482,13 @@ func (frgj *frostRetainedGroupJournal) reconcile( frgj.state = candidate frgj.mutations = append(frgj.mutations, suffix...) } + if frgj.checkpointPersistFailureHook != nil { + if err := frgj.checkpointPersistFailureHook( + "after-canonical-before-quarantine", + ); err != nil { + return nil, err + } + } quarantineSuffix := cloneFrostRetainedGroupMutations( canonicalQuarantineMutations[len(frgj.quarantineMutations):], ) @@ -3267,6 +3576,25 @@ func (frgj *frostRetainedGroupJournal) reconcile( frgj.quarantineState = candidate frgj.quarantineMutations = append(frgj.quarantineMutations, quarantineSuffix...) } + if frgj.checkpointPersistFailureHook != nil { + if err := frgj.checkpointPersistFailureHook( + "after-semantic-journals-before-checkpoints", + ); err != nil { + return nil, err + } + } + if len(history.Checkpoints) > 0 { + if err := frgj.persistCheckpointSuffix( + history.Checkpoints, + checkpointHashes, + ); err != nil { + return nil, err + } + } else if err := frgj.validateCheckpointAgainstDurableState( + frgj.checkpointState, + ); err != nil { + return nil, err + } afterHead, err := frgj.source.FinalizedHead(ctx) if err != nil { return nil, fmt.Errorf("cannot read independent finalized head after journal replay: [%w]", err) @@ -3285,6 +3613,13 @@ func (frgj *frostRetainedGroupJournal) reconcile( if err := frgj.source.VerifyPoint(ctx, target); err != nil { return nil, fmt.Errorf("challenged FROST retained-group point changed during replay: [%w]", err) } + if !history.CheckpointComplete { + return nil, fmt.Errorf( + "FROST checkpoint recovery remains incomplete after [%d] authenticated pages; retry from durable sequence [%d]", + frostRetainedGroupMaximumCheckpointPages, + frgj.checkpointState.Sequence, + ) + } localSessionCount, err := frgj.reconcileLocalSessions(ctx, target) if err != nil { return nil, err @@ -3303,6 +3638,15 @@ func (frgj *frostRetainedGroupJournal) reconcile( frgj.quarantineState.TombstoneRoot == [32]byte{} { return nil, fmt.Errorf("independent FROST quarantine journal is not activation-ready") } + if frgj.checkpointState.Point != target || + frgj.checkpointState.Sequence < + frgj.checkpointPolicy.MinimumSequence || + frgj.checkpointState.CertificateHash == [32]byte{} || + frgj.checkpointState.HistoryRoot != history.HistoryRoot { + return nil, fmt.Errorf( + "quorum-certified FROST checkpoint journal is not activation-ready", + ) + } return &frostRetainedGroupJournalSnapshot{ Schema: frostRetainedGroupJournalSnapshotSchema, BindingHash: frgj.metadata.BindingHash, @@ -3327,6 +3671,11 @@ func (frgj *frostRetainedGroupJournal) reconcile( QuarantineTombstoneRoot: frgj.quarantineState.TombstoneRoot, QuarantineCount: frostRetainedGroupActiveQuarantineCount(frgj.quarantineState), QuarantineTombstoneCount: uint64(len(frgj.quarantineState.Tombstones)), + CheckpointMinimumSequence: frgj.checkpointPolicy.MinimumSequence, + CheckpointPredecessorHash: frgj.checkpointPolicy.PredecessorHash, + CheckpointSequence: frgj.checkpointState.Sequence, + CheckpointCertificateHash: frgj.checkpointState.CertificateHash, + CheckpointHistoryRoot: frgj.checkpointState.HistoryRoot, LocalSessionCount: localSessionCount, Complete: true, }, nil diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index 3f83d17d18..b643ab00d6 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -23,16 +23,21 @@ import ( ) type journalHistorySource struct { - identity FrostRetainedGroupHistoryIdentity - checkpoint FrostPreSignFinality - head FrostPreSignFinality - descriptor [32]byte - mutations []FrostRetainedGroupMutation - complete bool - emptyAtFrom bool - points map[uint64][32]byte - operators map[chain.Address]chain.OperatorID - verifyErr error + identity FrostRetainedGroupHistoryIdentity + checkpoint FrostPreSignFinality + head FrostPreSignFinality + descriptor [32]byte + mutations []FrostRetainedGroupMutation + complete bool + emptyAtFrom bool + points map[uint64][32]byte + operators map[chain.Address]chain.OperatorID + verifyErr error + checkpointIssuer func( + FrostRetainedGroupCheckpointCursor, + FrostPreSignFinality, + []FrostRetainedGroupMutation, + ) ([]FrostRetainedGroupCheckpointCertificate, error) } func (jhs *journalHistorySource) Identity( @@ -64,6 +69,7 @@ func (jhs *journalHistorySource) ReadCompleteHistory( _ context.Context, from FrostPreSignFinality, to FrostPreSignFinality, + checkpointAfter FrostRetainedGroupCheckpointCursor, ) (*FrostRetainedGroupHistory, error) { mutations := make([]FrostRetainedGroupMutation, 0) for _, mutation := range jhs.mutations { @@ -71,13 +77,57 @@ func (jhs *journalHistorySource) ReadCompleteHistory( mutations = append(mutations, mutation) } } + historyRoot, err := frostRetainedGroupTestHistoryRoot( + [32]byte{0x44}, + from, + to, + mutations, + ) + if err != nil { + return nil, err + } + checkpoints, err := jhs.checkpointIssuer( + checkpointAfter, + to, + mutations, + ) + if err != nil { + return nil, err + } + checkpointComplete := true + if len(checkpoints) > frostRetainedGroupMaximumCheckpointsPerPage { + checkpoints = checkpoints[:frostRetainedGroupMaximumCheckpointsPerPage] + checkpointComplete = false + } + hashes := make([][32]byte, len(checkpoints)) + for index, checkpoint := range checkpoints { + hashes[index], err = + frostRetainedGroupCheckpointCertificateHash(checkpoint) + if err != nil { + return nil, err + } + } + tipHash := checkpointAfter.CertificateHash + if len(hashes) > 0 { + tipHash = hashes[len(hashes)-1] + } return &FrostRetainedGroupHistory{ - From: from, - To: to, - Mutations: cloneFrostRetainedGroupMutations(mutations), - Complete: jhs.complete, - EmptyAtFrom: jhs.emptyAtFrom, - DescriptorSetHash: jhs.descriptor, + From: from, + To: to, + Mutations: cloneFrostRetainedGroupMutations(mutations), + HistoryRoot: historyRoot, + CheckpointAfter: checkpointAfter, + Checkpoints: checkpoints, + CheckpointChainRoot: frostRetainedGroupCheckpointChainRoot( + [32]byte{0x44}, + checkpointAfter, + hashes, + ), + CheckpointTipHash: tipHash, + CheckpointComplete: checkpointComplete, + Complete: jhs.complete, + EmptyAtFrom: jhs.emptyAtFrom, + DescriptorSetHash: jhs.descriptor, }, nil } @@ -94,24 +144,26 @@ func (jhs *journalHistorySource) ResolveOperatorID( } type journalTestFixture struct { - manifest FrostRetainedGroupCanonicalJournalManifest - quarantine FrostRetainedGroupQuarantineJournalManifest - runtime FrostPreSignActivationRuntimeManifest - manifestHash [32]byte - bindingHash [32]byte - liftPrivateKeys []ed25519.PrivateKey - liftPublicKeySPKIs []string - checkpoint FrostPreSignFinality - target FrostPreSignFinality - later FrostPreSignFinality - walletID [32]byte - walletPKH [20]byte - operatorIDs []uint32 - operatorAddrs []chain.Address - localOperator chain.Address - registry *walletRegistry - source *journalHistorySource - admission FrostRetainedGroupMutation + manifest FrostRetainedGroupCanonicalJournalManifest + quarantine FrostRetainedGroupQuarantineJournalManifest + runtime FrostPreSignActivationRuntimeManifest + manifestHash [32]byte + bindingHash [32]byte + liftPrivateKeys []ed25519.PrivateKey + liftPublicKeySPKIs []string + checkpoint FrostPreSignFinality + target FrostPreSignFinality + later FrostPreSignFinality + walletID [32]byte + walletPKH [20]byte + operatorIDs []uint32 + operatorAddrs []chain.Address + localOperator chain.Address + registry *walletRegistry + source *journalHistorySource + admission FrostRetainedGroupMutation + checkpointPrivateKeys []ed25519.PrivateKey + checkpointPublicKeySPKIs []string } func newJournalTestFixture(t *testing.T) *journalTestFixture { @@ -172,13 +224,17 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { MinimumGeneration: 1, } checkpointAuthorities := make([]FrostRetainedGroupAuthority, 3) + checkpointPrivateKeys := make([]ed25519.PrivateKey, 3) + checkpointPublicKeySPKIs := make([]string, 3) for index := range checkpointAuthorities { - authority, _, _ := journalTestAuthority( + authority, privateKey, publicKeySPKI := journalTestAuthority( t, fmt.Sprintf("checkpoint-%d", index+1), byte(0x60+index), ) checkpointAuthorities[index] = authority + checkpointPrivateKeys[index] = privateKey + checkpointPublicKeySPKIs[index] = publicKeySPKI } liftAuthorities := make([]FrostRetainedGroupAuthority, 3) liftPrivateKeys := make([]ed25519.PrivateKey, 3) @@ -199,6 +255,8 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { TombstoneProtocolID: [32]byte{0x46}, CheckpointAuthorityThreshold: 2, CheckpointAuthorities: checkpointAuthorities, + CheckpointMinimumSequence: 1, + CheckpointPredecessorHash: [32]byte{}, LiftAuthorityThreshold: 2, LiftAuthorities: liftAuthorities, StoreID: "quarantine-store-uuid", @@ -261,26 +319,265 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { BridgeRegistrationPoint: FrostRetainedGroupEventPoint{BlockNumber: 2, BlockHash: [32]byte{0x02}, TransactionHash: [32]byte{0xa2}, TransactionIndex: 1, LogIndex: 5}, } source.mutations = []FrostRetainedGroupMutation{admission} + checkpointPolicy, err := + frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + bindingHash, + runtime, + ) + if err != nil { + t.Fatal(err) + } + source.checkpointIssuer = newFrostRetainedGroupTestCheckpointIssuer( + t, + checkpointPolicy, + checkpoint, + checkpointPrivateKeys, + checkpointPublicKeySPKIs, + ) return &journalTestFixture{ - manifest: manifest, - quarantine: quarantine, - runtime: runtime, - manifestHash: manifestHash, - bindingHash: bindingHash, - liftPrivateKeys: liftPrivateKeys, - liftPublicKeySPKIs: liftPublicKeySPKIs, - checkpoint: checkpoint, - target: target, - later: later, - walletID: walletID, - walletPKH: walletPKH, - operatorIDs: operatorIDs, - operatorAddrs: operatorAddrs, - localOperator: localOperator, - registry: registry, - source: source, - admission: admission, + manifest: manifest, + quarantine: quarantine, + runtime: runtime, + manifestHash: manifestHash, + bindingHash: bindingHash, + liftPrivateKeys: liftPrivateKeys, + liftPublicKeySPKIs: liftPublicKeySPKIs, + checkpoint: checkpoint, + target: target, + later: later, + walletID: walletID, + walletPKH: walletPKH, + operatorIDs: operatorIDs, + operatorAddrs: operatorAddrs, + localOperator: localOperator, + registry: registry, + source: source, + admission: admission, + checkpointPrivateKeys: checkpointPrivateKeys, + checkpointPublicKeySPKIs: checkpointPublicKeySPKIs, + } +} + +func frostRetainedGroupTestHistoryRoot( + bindingHash [32]byte, + from FrostPreSignFinality, + to FrostPreSignFinality, + mutations []FrostRetainedGroupMutation, +) ([32]byte, error) { + query := frostRetainedGroupHistoryQuery{ + Schema: frostRetainedGroupHistoryRequestSchema, + BindingHash: frostActivationHex32(bindingHash), + From: frostRetainedGroupFinalityToWire(from), + To: frostRetainedGroupFinalityToWire(to), + } + queryHash, err := frostRetainedGroupDomainHash( + frostRetainedGroupHistoryQueryDomain, + query, + ) + if err != nil { + return [32]byte{}, err + } + wireMutations := make( + []frostRetainedGroupWireMutation, + len(mutations), + ) + for index, mutation := range mutations { + wireMutations[index] = frostRetainedGroupMutationToWire(mutation) + } + return frostRetainedGroupHistoryRoot( + bindingHash, + queryHash, + wireMutations, + ) +} + +func newFrostRetainedGroupTestCheckpointIssuer( + t *testing.T, + policy frostRetainedGroupCheckpointPolicy, + from FrostPreSignFinality, + privateKeys []ed25519.PrivateKey, + publicKeySPKIs []string, +) func( + FrostRetainedGroupCheckpointCursor, + FrostPreSignFinality, + []FrostRetainedGroupMutation, +) ([]FrostRetainedGroupCheckpointCertificate, error) { + t.Helper() + certificates := make( + map[uint64]FrostRetainedGroupCheckpointCertificate, + ) + hashes := make(map[uint64][32]byte) + latest := FrostRetainedGroupCheckpointCursor{ + Sequence: policy.MinimumSequence - 1, + CertificateHash: policy.PredecessorHash, + } + var latestPoint FrostPreSignFinality + return func( + after FrostRetainedGroupCheckpointCursor, + to FrostPreSignFinality, + mutations []FrostRetainedGroupMutation, + ) ([]FrostRetainedGroupCheckpointCertificate, error) { + if after.Sequence < policy.MinimumSequence-1 || + (after.Sequence == policy.MinimumSequence-1 && + after.CertificateHash != policy.PredecessorHash) { + return nil, fmt.Errorf("test checkpoint cursor is invalid") + } + if after.Sequence >= policy.MinimumSequence { + hash, exists := hashes[after.Sequence] + if !exists || hash != after.CertificateHash { + return nil, fmt.Errorf("test checkpoint cursor is not an ancestor") + } + } + if latest.Sequence >= policy.MinimumSequence && + latestPoint == to { + suffix := make( + []FrostRetainedGroupCheckpointCertificate, + 0, + latest.Sequence-after.Sequence, + ) + for sequence := after.Sequence + 1; sequence <= latest.Sequence; sequence++ { + suffix = append(suffix, certificates[sequence]) + } + return suffix, nil + } + if latest.Sequence >= policy.MinimumSequence && + to.BlockNumber <= latestPoint.BlockNumber { + return nil, fmt.Errorf( + "test checkpoint point does not strictly advance", + ) + } + semantic, err := frostRetainedGroupCertifiedStateFromHistory( + policy, + from, + to, + mutations, + ) + if err != nil { + historyRoot, historyRootErr := + frostRetainedGroupTestHistoryRoot( + policy.ProtocolBindingHash, + from, + to, + mutations, + ) + if historyRootErr != nil { + return nil, historyRootErr + } + // Malformed-history tests still need a strictly valid wire + // certificate so the source reaches its earlier semantic-history + // rejection. These sentinel roots can never pass checkpoint + // semantic validation. + semantic = FrostRetainedGroupCheckpointBody{ + Point: to, + HistoryRoot: historyRoot, + CanonicalGeneration: policy.CanonicalMinimum, + CanonicalInventoryRoot: [32]byte{0xf1}, + QuarantineGeneration: policy.QuarantineMinimum, + QuarantineEventRoot: [32]byte{0xf2}, + QuarantineActiveRoot: [32]byte{0xf3}, + QuarantineTombstoneRoot: [32]byte{0xf4}, + } + } + body := FrostRetainedGroupCheckpointBody{ + Schema: frostRetainedGroupCheckpointBodySchema, + ProtocolBindingHash: policy.ProtocolBindingHash, + ManifestHash: policy.ManifestHash, + ProfileHash: policy.ProfileHash, + ImplementationSetHash: policy.ImplementationSetHash, + ChainID: policy.ChainID, + DomainChainID: policy.DomainChainID, + GenesisBlockHash: policy.GenesisBlockHash, + AuthoritySetHash: policy.AuthoritySetHash, + Sequence: latest.Sequence + 1, + PreviousCertificateHash: latest.CertificateHash, + Point: semantic.Point, + HistoryRoot: semantic.HistoryRoot, + CanonicalGeneration: semantic.CanonicalGeneration, + CanonicalInventoryRoot: semantic.CanonicalInventoryRoot, + QuarantineGeneration: semantic.QuarantineGeneration, + QuarantineEventRoot: semantic.QuarantineEventRoot, + QuarantineActiveRoot: semantic.QuarantineActiveRoot, + QuarantineTombstoneRoot: semantic.QuarantineTombstoneRoot, + } + bodyHash, err := frostRetainedGroupCheckpointBodyHash(body) + if err != nil { + return nil, err + } + signatureHash := frostRetainedGroupCheckpointSignatureHash(bodyHash) + signatures := make( + []FrostRetainedGroupCheckpointSignature, + policy.AuthorityThreshold, + ) + for index := range signatures { + signatures[index] = FrostRetainedGroupCheckpointSignature{ + AuthorityID: policy.Authorities[index].AuthorityID, + SignerPublicKeySPKI: publicKeySPKIs[index], + Signature: base64.StdEncoding.EncodeToString( + ed25519.Sign(privateKeys[index], signatureHash[:]), + ), + } + } + certificate := FrostRetainedGroupCheckpointCertificate{ + Schema: frostRetainedGroupCheckpointCertificateSchema, + Body: body, + BodyHash: bodyHash, + Signatures: signatures, + } + certificateHash, err := + frostRetainedGroupCheckpointCertificateHash(certificate) + if err != nil { + return nil, err + } + certificates[body.Sequence] = certificate + hashes[body.Sequence] = certificateHash + latest = FrostRetainedGroupCheckpointCursor{ + Sequence: body.Sequence, + CertificateHash: certificateHash, + } + latestPoint = to + suffix := make( + []FrostRetainedGroupCheckpointCertificate, + 0, + latest.Sequence-after.Sequence, + ) + for sequence := after.Sequence + 1; sequence <= latest.Sequence; sequence++ { + suffix = append(suffix, certificates[sequence]) + } + return suffix, nil + } +} + +func (fixture *journalTestFixture) resignCheckpointCertificate( + t *testing.T, + certificate *FrostRetainedGroupCheckpointCertificate, + signerIndices []int, +) { + t.Helper() + bodyHash, err := frostRetainedGroupCheckpointBodyHash(certificate.Body) + if err != nil { + t.Fatal(err) + } + signatureHash := frostRetainedGroupCheckpointSignatureHash(bodyHash) + signatures := make( + []FrostRetainedGroupCheckpointSignature, + len(signerIndices), + ) + for index, signerIndex := range signerIndices { + signatures[index] = FrostRetainedGroupCheckpointSignature{ + AuthorityID: fixture.quarantine. + CheckpointAuthorities[signerIndex].AuthorityID, + SignerPublicKeySPKI: fixture. + checkpointPublicKeySPKIs[signerIndex], + Signature: base64.StdEncoding.EncodeToString( + ed25519.Sign( + fixture.checkpointPrivateKeys[signerIndex], + signatureHash[:], + ), + ), + } } + certificate.BodyHash = bodyHash + certificate.Signatures = signatures } func TestFrostLocalSessionSnapshotBindsExactSignerMaterial(t *testing.T) { @@ -611,6 +908,569 @@ func TestFrostRetainedGroupJournal_ReconcilesAndRejectsRewrittenHistory( } } +func TestFrostRetainedGroupJournal_RejectsResignedGenerationRollback( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + movingFunds := FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 3, + BlockHash: [32]byte{0x03}, + TransactionHash: [32]byte{0xb3}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupMovingFundsMutation, + WalletID: fixture.walletID, + WalletPublicKeyHash: fixture.walletPKH, + } + fixture.source.mutations = append( + fixture.source.mutations, + movingFunds, + ) + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + defer journal.close() + first, err := journal.reconcile(context.Background(), fixture.target) + if err != nil { + t.Fatal(err) + } + if first.SnapshotGeneration != 2 || + journal.checkpointState.CanonicalGeneration != 2 { + t.Fatalf("unexpected certified generation: %+v", first) + } + + // The exporter and checkpoint quorum now re-sign a history that omits a + // previously certified canonical transition. The new certificate is + // internally valid and exactly matches the rewritten history, but its + // generation rolls back relative to the durable certified predecessor. + fixture.source.mutations = []FrostRetainedGroupMutation{ + fixture.admission, + } + _, err = journal.reconcile(context.Background(), fixture.later) + if err == nil || !strings.Contains( + err.Error(), + "does not monotonically advance the durable head", + ) { + t.Fatalf("expected re-signed generation rollback rejection, got [%v]", err) + } + if journal.checkpointState.Sequence != 1 || + journal.checkpointState.CanonicalGeneration != 2 { + t.Fatalf( + "re-signed generation rollback changed durable checkpoint: %+v", + journal.checkpointState, + ) + } +} + +func TestFrostRetainedGroupJournal_CheckpointCertificateRejectsBypasses( + t *testing.T, +) { + newCertificate := func( + t *testing.T, + ) ( + *journalTestFixture, + frostRetainedGroupCheckpointPolicy, + FrostRetainedGroupCheckpointCursor, + FrostRetainedGroupCheckpointCertificate, + ) { + t.Helper() + fixture := newJournalTestFixture(t) + policy, err := + frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + fixture.bindingHash, + fixture.runtime, + ) + if err != nil { + t.Fatal(err) + } + after := FrostRetainedGroupCheckpointCursor{ + Sequence: policy.MinimumSequence - 1, + CertificateHash: policy.PredecessorHash, + } + certificates, err := fixture.source.checkpointIssuer( + after, + fixture.target, + fixture.source.mutations, + ) + if err != nil || len(certificates) != 1 { + t.Fatalf("cannot issue test checkpoint: [%v]", err) + } + return fixture, policy, after, certificates[0] + } + + testCases := map[string]func( + *testing.T, + *journalTestFixture, + *FrostRetainedGroupCheckpointCertificate, + ){ + "insufficient quorum": func( + _ *testing.T, + _ *journalTestFixture, + certificate *FrostRetainedGroupCheckpointCertificate, + ) { + certificate.Signatures = certificate.Signatures[:1] + }, + "wrong pinned SPKI": func( + _ *testing.T, + fixture *journalTestFixture, + certificate *FrostRetainedGroupCheckpointCertificate, + ) { + certificate.Signatures[0].SignerPublicKeySPKI = + fixture.checkpointPublicKeySPKIs[2] + }, + "duplicate authority": func( + _ *testing.T, + _ *journalTestFixture, + certificate *FrostRetainedGroupCheckpointCertificate, + ) { + certificate.Signatures[1] = certificate.Signatures[0] + }, + "unsorted authorities": func( + _ *testing.T, + _ *journalTestFixture, + certificate *FrostRetainedGroupCheckpointCertificate, + ) { + certificate.Signatures[0], certificate.Signatures[1] = + certificate.Signatures[1], certificate.Signatures[0] + }, + "re-signed sequence gap": func( + t *testing.T, + fixture *journalTestFixture, + certificate *FrostRetainedGroupCheckpointCertificate, + ) { + certificate.Body.Sequence++ + fixture.resignCheckpointCertificate( + t, + certificate, + []int{0, 1}, + ) + }, + "re-signed predecessor fork": func( + t *testing.T, + fixture *journalTestFixture, + certificate *FrostRetainedGroupCheckpointCertificate, + ) { + certificate.Body.PreviousCertificateHash[0] ^= 0xff + fixture.resignCheckpointCertificate( + t, + certificate, + []int{0, 1}, + ) + }, + } + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + fixture, policy, after, certificate := newCertificate(t) + mutate(t, fixture, &certificate) + if _, err := validateFrostRetainedGroupCheckpointSuffix( + policy, + after, + []FrostRetainedGroupCheckpointCertificate{certificate}, + ); err == nil { + t.Fatalf("checkpoint bypass [%s] was accepted", name) + } + }) + } + + t.Run("same-point successor", func(t *testing.T) { + fixture, policy, after, first := newCertificate(t) + firstHash, err := + frostRetainedGroupCheckpointCertificateHash(first) + if err != nil { + t.Fatal(err) + } + second := first + second.Body.Sequence++ + second.Body.PreviousCertificateHash = firstHash + fixture.resignCheckpointCertificate(t, &second, []int{0, 1}) + if _, err := validateFrostRetainedGroupCheckpointSuffix( + policy, + after, + []FrostRetainedGroupCheckpointCertificate{first, second}, + ); err == nil || !strings.Contains(err.Error(), "strictly monotonic") { + t.Fatalf("same-point checkpoint successor was accepted: [%v]", err) + } + }) +} + +func TestFrostRetainedGroupJournal_CheckpointFrozenHashVector( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + policy, err := frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + fixture.bindingHash, + fixture.runtime, + ) + if err != nil { + t.Fatal(err) + } + after := FrostRetainedGroupCheckpointCursor{ + Sequence: policy.MinimumSequence - 1, + CertificateHash: policy.PredecessorHash, + } + certificates, err := fixture.source.checkpointIssuer( + after, + fixture.target, + fixture.source.mutations, + ) + if err != nil || len(certificates) != 1 { + t.Fatalf("cannot issue frozen checkpoint vector: [%v]", err) + } + bodyHash, err := + frostRetainedGroupCheckpointBodyHash(certificates[0].Body) + if err != nil { + t.Fatal(err) + } + certificateHash, err := + frostRetainedGroupCheckpointCertificateHash(certificates[0]) + if err != nil { + t.Fatal(err) + } + chainRoot := frostRetainedGroupCheckpointChainRoot( + fixture.bindingHash, + after, + [][32]byte{certificateHash}, + ) + const expectedBodyHash = "ba0fdaecfc27fac1de867bd56f88fe66198952b3a99ef21f639bc62f331ce1fd" + const expectedCertificateHash = "cd396aa5ddc6bc7b53209ba2dc335fb8ed95113efdc256871df87f91a7d619e2" + const expectedChainRoot = "85a412822497e9029f7c437cf5c2087f20b2610a76ab4b952823aa3f89b27544" + if fmt.Sprintf("%x", bodyHash) != expectedBodyHash || + fmt.Sprintf("%x", certificateHash) != expectedCertificateHash || + fmt.Sprintf("%x", chainRoot) != expectedChainRoot { + t.Fatalf( + "checkpoint hash vector changed\nbody: %x\ncertificate: %x\nchain: %x", + bodyHash, + certificateHash, + chainRoot, + ) + } +} + +func TestFrostRetainedGroupJournal_RecoversCheckpointChainBeyondOnePage( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + count := frostRetainedGroupMaximumCheckpointsPerPage + 2 + cursor := FrostRetainedGroupCheckpointCursor{ + Sequence: fixture.quarantine.CheckpointMinimumSequence - 1, + CertificateHash: fixture.quarantine.CheckpointPredecessorHash, + } + var oldestCertified FrostRetainedGroupCheckpointCursor + var target FrostPreSignFinality + for index := 0; index < count; index++ { + blockNumber := fixture.checkpoint.BlockNumber + uint64(index) + 1 + blockHash := [32]byte{} + binary.BigEndian.PutUint64(blockHash[24:], blockNumber) + target = FrostPreSignFinality{ + BlockNumber: blockNumber, + BlockHash: blockHash, + } + if blockNumber == fixture.admission.Point.BlockNumber { + target.BlockHash = fixture.admission.Point.BlockHash + } + fixture.source.points[target.BlockNumber] = target.BlockHash + certificates, err := fixture.source.checkpointIssuer( + cursor, + target, + []FrostRetainedGroupMutation{fixture.admission}, + ) + if err != nil { + t.Fatal(err) + } + if len(certificates) != 1 { + t.Fatalf( + "expected one newly issued certificate, got [%d]", + len(certificates), + ) + } + certificateHash, err := + frostRetainedGroupCheckpointCertificateHash(certificates[0]) + if err != nil { + t.Fatal(err) + } + cursor = FrostRetainedGroupCheckpointCursor{ + Sequence: certificates[0].Body.Sequence, + CertificateHash: certificateHash, + } + if index == 0 { + oldestCertified = cursor + } + } + fixture.source.head = FrostPreSignFinality{ + BlockNumber: 1000, + BlockHash: [32]byte{0xfa}, + } + fixture.source.points[fixture.source.head.BlockNumber] = + fixture.source.head.BlockHash + + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + defer journal.close() + snapshot, err := journal.reconcile(context.Background(), target) + if err != nil { + t.Fatal(err) + } + if snapshot.CheckpointSequence != uint64(count) || + journal.checkpointState.Sequence != uint64(count) || + snapshot.CheckpointCertificateHash != cursor.CertificateHash { + t.Fatalf( + "multi-page recovery stopped at the wrong checkpoint: %+v", + snapshot, + ) + } + ancestry, err := journal.checkpointAncestryAfter(oldestCertified) + if err != nil { + t.Fatal(err) + } + if len(ancestry) != count-1 || + len(ancestry) <= frostRetainedGroupMaximumCheckpointsPerPage { + t.Fatalf( + "long-lived ancestry proof was truncated at [%d] certificates", + len(ancestry), + ) + } + if _, err := validateFrostRetainedGroupCheckpointSuffix( + journal.checkpointPolicy, + oldestCertified, + ancestry, + ); err != nil { + t.Fatalf("long-lived ancestry proof is invalid: [%v]", err) + } +} + +func TestFrostRetainedGroupJournal_CheckpointPersistenceRetriesInProcess( + t *testing.T, +) { + t.Run("adopts a certificate orphan before the next certificate", func(t *testing.T) { + fixture := newJournalTestFixture(t) + cursor := FrostRetainedGroupCheckpointCursor{ + Sequence: fixture.quarantine.CheckpointMinimumSequence - 1, + CertificateHash: fixture.quarantine. + CheckpointPredecessorHash, + } + first, err := fixture.source.checkpointIssuer( + cursor, + fixture.target, + fixture.source.mutations, + ) + if err != nil || len(first) != 1 { + t.Fatalf("cannot issue first test checkpoint: [%v]", err) + } + firstHash, err := + frostRetainedGroupCheckpointCertificateHash(first[0]) + if err != nil { + t.Fatal(err) + } + cursor = FrostRetainedGroupCheckpointCursor{ + Sequence: first[0].Body.Sequence, + CertificateHash: firstHash, + } + second, err := fixture.source.checkpointIssuer( + cursor, + fixture.later, + fixture.source.mutations, + ) + if err != nil || len(second) != 1 { + t.Fatalf("cannot issue second test checkpoint: [%v]", err) + } + + journal := fixture.openJournal( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + certificateBoundaries := 0 + journal.checkpointPersistFailureHook = func(stage string) error { + if stage == "after-checkpoint-certificate-before-next" { + certificateBoundaries++ + if certificateBoundaries == 1 { + return fmt.Errorf("simulated certificate-boundary crash") + } + } + return nil + } + if _, err := journal.reconcile( + context.Background(), + fixture.later, + ); err == nil || !strings.Contains( + err.Error(), + "certificate-boundary", + ) { + t.Fatalf("expected certificate-boundary failure, got [%v]", err) + } + if journal.checkpointState.Sequence != + fixture.quarantine.CheckpointMinimumSequence-1 || + len(journal.checkpointCertificates) != 0 { + t.Fatal("certificate orphan was published to in-memory state") + } + journal.checkpointPersistFailureHook = nil + snapshot, err := journal.reconcile( + context.Background(), + fixture.later, + ) + if err != nil { + t.Fatal(err) + } + if snapshot.CheckpointSequence != 2 || + journal.checkpointState.Sequence != 2 || + len(journal.checkpointCertificates) != 2 { + t.Fatalf( + "same-process certificate-orphan retry did not converge: %+v", + snapshot, + ) + } + }) + + for _, stage := range []string{ + "after-checkpoint-certificates-before-state", + "after-checkpoint-state-before-memory", + } { + t.Run(stage, func(t *testing.T) { + fixture := newJournalTestFixture(t) + journal := fixture.openJournal( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + failed := false + journal.checkpointPersistFailureHook = func(actual string) error { + if actual == stage && !failed { + failed = true + return fmt.Errorf("simulated checkpoint publication crash") + } + return nil + } + if _, err := journal.reconcile( + context.Background(), + fixture.target, + ); err == nil || !strings.Contains( + err.Error(), + "publication crash", + ) { + t.Fatalf("expected checkpoint publication failure, got [%v]", err) + } + if journal.checkpointState.Sequence != + fixture.quarantine.CheckpointMinimumSequence-1 || + len(journal.checkpointCertificates) != 0 { + t.Fatal("failed checkpoint publication changed in-memory head") + } + journal.checkpointPersistFailureHook = nil + snapshot, err := journal.reconcile( + context.Background(), + fixture.target, + ) + if err != nil { + t.Fatal(err) + } + if snapshot.CheckpointSequence != 1 || + journal.checkpointState.Sequence != 1 || + len(journal.checkpointCertificates) != 1 { + t.Fatalf( + "same-process state-boundary retry did not converge: %+v", + snapshot, + ) + } + }) + } +} + +func TestFrostRetainedGroupJournal_CheckpointCrashBoundariesRecover( + t *testing.T, +) { + for _, stage := range []string{ + "after-canonical-before-quarantine", + "after-semantic-journals-before-checkpoints", + "after-checkpoint-certificates-before-state", + } { + t.Run(stage, func(t *testing.T) { + fixture := newJournalTestFixture(t) + fixture.source.mutations = append( + fixture.source.mutations, + FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, + TransactionHash: [32]byte{0xa5}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupRecoveryRequiredMutation, + WalletID: fixture.walletID, + QuarantineID: [32]byte{0x51}, + EvidenceHash: [32]byte{0x52}, + Reason: "manual recovery is required", + }, + ) + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + failed := false + journal.checkpointPersistFailureHook = func(actual string) error { + if actual == stage && !failed { + failed = true + return fmt.Errorf("simulated cross-journal crash") + } + return nil + } + if _, err := journal.reconcile( + context.Background(), + fixture.target, + ); err == nil || !strings.Contains( + err.Error(), + "cross-journal crash", + ) { + t.Fatalf("expected cross-journal crash, got [%v]", err) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + snapshot, err := restarted.reconcile( + context.Background(), + fixture.target, + ) + if err != nil { + t.Fatal(err) + } + if snapshot.SnapshotGeneration != 1 || + snapshot.QuarantineGeneration != 1 || + snapshot.CheckpointSequence != 1 || + len(restarted.mutations) != 1 || + len(restarted.quarantineMutations) != 1 || + len(restarted.checkpointCertificates) != 1 { + t.Fatalf( + "cross-journal crash recovery did not converge exactly once: %+v", + snapshot, + ) + } + }) + } +} + +func TestFrostRetainedGroupJournal_RejectsStaleHandshakeFloorBeforeAllocation( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + journal := fixture.openJournal( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + floor := FrostRetainedGroupCheckpointCursor{ + Sequence: fixture.quarantine.CheckpointMinimumSequence, + CertificateHash: [32]byte{0x7a}, + } + journal.checkpointHashes[floor.Sequence] = floor.CertificateHash + journal.checkpointState.Sequence = + floor.Sequence + frostRetainedGroupMaximumHandshakeAncestry + 1 + _, err := journal.checkpointAncestryAfter(floor) + if err == nil || !strings.Contains(err.Error(), "floor is too stale") { + t.Fatalf("expected stale external floor rejection, got [%v]", err) + } +} + func TestFrostRetainedGroupJournal_IntegratesCommittedOrphanBatchExactlyOnce( t *testing.T, ) { @@ -1624,7 +2484,7 @@ func TestFrostRetainedGroupJournal_LiftCrashRecoveryAndContentAddressing( } }) - t.Run("conflicting valid certificate orphan has a distinct digest", func(t *testing.T) { + t.Run("checkpoint rejects a conflicting valid certificate rewrite", func(t *testing.T) { fixture := newJournalTestFixture(t) directory := filepath.Join(t.TempDir(), "journal") journal, quarantine := fixture.openActiveQuarantine( @@ -1668,18 +2528,21 @@ func TestFrostRetainedGroupJournal_LiftCrashRecoveryAndContentAddressing( } fixture.source.mutations[len(fixture.source.mutations)-1] = secondLift - snapshot, err := restarted.reconcile( + _, err := restarted.reconcile( context.Background(), fixture.later, ) - if err != nil { - t.Fatal(err) + if err == nil || !strings.Contains( + err.Error(), + "independently derived semantic state", + ) { + t.Fatalf( + "expected checkpoint-bound certificate rewrite rejection, got [%v]", + err, + ) } - if len(restarted.liftCertificates) != 2 || - snapshot.QuarantineTombstoneCount != 1 || - restarted.quarantineState.Tombstones[0].LiftCertificateHash != - secondLift.LiftCertificateHash { - t.Fatal("content-addressed conflicting certificate handling failed") + if len(restarted.quarantineState.Tombstones) != 0 { + t.Fatal("conflicting certificate rewrite changed durable state") } }) } From e5fffa558fb5ad354ed19af7d4c226034e4dafa1 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 20:39:15 -0400 Subject: [PATCH 29/93] Harden FROST checkpoint trust and recovery --- pkg/tbtc/frost_activation_handshake.go | 196 ++++++- pkg/tbtc/frost_activation_handshake_test.go | 502 +++++++++++++++++- pkg/tbtc/frost_retained_group_checkpoint.go | 329 +++++++++++- pkg/tbtc/frost_retained_group_journal.go | 74 ++- pkg/tbtc/frost_retained_group_journal_test.go | 482 ++++++++++++++++- 5 files changed, 1498 insertions(+), 85 deletions(-) diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 39f52bf06d..2f502e841b 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -443,6 +443,16 @@ func (fahe *frostActivationHandshakeExporter) close() error { func (fahe *frostActivationHandshakeExporter) reconciliationWorker( ctx context.Context, +) { + fahe.runReconciliationWorker(ctx, fahe.reconcileActivationState) +} + +func (fahe *frostActivationHandshakeExporter) runReconciliationWorker( + ctx context.Context, + reconcile func( + context.Context, + FrostPreSignFinality, + ) (*frostActivationReconciliationCache, error), ) { for { select { @@ -455,7 +465,7 @@ func (fahe *frostActivationHandshakeExporter) reconciliationWorker( if job == nil { break } - cache, err := fahe.reconcileActivationState( + cache, err := reconcile( reconciliationContext, job.point, ) @@ -506,9 +516,27 @@ func (fahe *frostActivationHandshakeExporter) completeReconciliationJob( fahe.reconciliationDesired == nil { fahe.reconciliationCompleted = cache } + checkpointRecoveryProgress := errors.Is( + reconciliationErr, + errFrostRetainedGroupCheckpointRecoveryProgress, + ) + if checkpointRecoveryProgress && + fahe.reconciliationSequence == job.sequence && + fahe.reconciliationDesired == nil { + fahe.reconciliationSequence++ + fahe.reconciliationDesired = &frostActivationReconciliationJob{ + sequence: fahe.reconciliationSequence, + point: job.point, + } + } hasDesired := fahe.reconciliationDesired != nil fahe.reconciliationMutex.Unlock() - if reconciliationErr != nil { + if checkpointRecoveryProgress { + logger.Infof( + "background FROST activation reconciliation advanced the checkpoint recovery cursor for block [%d]", + job.point.BlockNumber, + ) + } else if reconciliationErr != nil { logger.Warnf( "background FROST activation reconciliation failed for block [%d]: [%v]", job.point.BlockNumber, @@ -932,7 +960,7 @@ func (fahe *frostActivationHandshakeExporter) attest( ) } checkpointAncestry, ancestryErr := - fahe.journal.checkpointAncestryAfter(checkpointFloor) + fahe.journal.checkpointAncestryFrom(checkpointFloor) fahe.journal.mutex.Unlock() if ancestryErr != nil { return nil, fmt.Errorf( @@ -1071,6 +1099,17 @@ func (fahe *frostActivationHandshakeExporter) attest( QuarantineFailClosed: journalSnapshot.QuarantineCount == 0, } state.Healthy = frostActivationHandshakeHealthy(state) + if err := verifyFrostActivationCheckpointHandshakeState( + fahe.manifest, + fahe.bindingHash, + request.Challenge, + state, + ); err != nil { + return nil, fmt.Errorf( + "cannot self-verify FROST activation checkpoint proof: [%w]", + err, + ) + } payload := frostActivationHandshakePayload{ Schema: frostActivationHandshakeSchema, Kind: "frost-signer", @@ -1154,6 +1193,157 @@ func frostActivationHandshakeSignatureTranscript( return result, nil } +func verifyFrostActivationCheckpointHandshakeState( + manifest FrostPreSignActivationRuntimeManifest, + bindingHash [32]byte, + challenge frostActivationChallenge, + state frostActivationHandshakeState, +) error { + checkpoint := state.CheckpointJournal + if !checkpoint.Complete || + checkpoint.ManifestMinimumSequence != + manifest.QuarantineJournal.CheckpointMinimumSequence || + checkpoint.ManifestPredecessorHash != + frostActivationHex32( + manifest.QuarantineJournal.CheckpointPredecessorHash, + ) || + checkpoint.ChallengeFloor != challenge.CheckpointFloor || + checkpoint.Point != challenge.EthereumPoint || + checkpoint.Point != state.CanonicalJournal.Current || + checkpoint.Point != state.FrostWalletGroupInventory.Point || + checkpoint.CanonicalGeneration != + state.CanonicalJournal.Generation || + checkpoint.CanonicalGeneration != + state.FrostWalletGroupInventory.SnapshotGeneration || + checkpoint.CanonicalInventoryRoot != + state.FrostWalletGroupInventory.InventoryRoot || + checkpoint.QuarantineGeneration != + state.QuarantineJournal.Generation || + checkpoint.QuarantineEventRoot != + state.QuarantineJournal.Root || + checkpoint.QuarantineActiveRoot != + state.QuarantineJournal.ActiveRoot || + checkpoint.QuarantineTombstoneRoot != + state.QuarantineJournal.TombstoneRoot { + return fmt.Errorf( + "FROST activation checkpoint proof differs from the surrounding handshake state", + ) + } + parseCursor := func( + name string, + wire frostRetainedGroupWireCheckpointCursor, + ) (FrostRetainedGroupCheckpointCursor, error) { + certificateHash, err := parseFrostActivationHex32( + wire.CertificateHash, + ) + if err != nil || + wire.CertificateHash != + frostActivationHex32(certificateHash) { + return FrostRetainedGroupCheckpointCursor{}, fmt.Errorf( + "invalid %s checkpoint cursor", + name, + ) + } + return FrostRetainedGroupCheckpointCursor{ + Sequence: wire.Sequence, + CertificateHash: certificateHash, + }, nil + } + floor, err := parseCursor("floor", checkpoint.ChallengeFloor) + if err != nil { + return err + } + durableHead, err := parseCursor("durable head", checkpoint.DurableHead) + if err != nil { + return err + } + pointHash, err := parseFrostActivationHex32(checkpoint.Point.BlockHash) + if err != nil || + checkpoint.Point.BlockHash != frostActivationHex32(pointHash) { + return fmt.Errorf("invalid FROST checkpoint proof point") + } + parseRoot := func(name string, value string) ([32]byte, error) { + root, err := parseFrostActivationHex32(value) + if err != nil || value != frostActivationHex32(root) { + return [32]byte{}, fmt.Errorf( + "invalid FROST checkpoint proof %s", + name, + ) + } + return root, nil + } + historyRoot, err := parseRoot("history root", checkpoint.HistoryRoot) + if err != nil { + return err + } + canonicalInventoryRoot, err := parseRoot( + "canonical inventory root", + checkpoint.CanonicalInventoryRoot, + ) + if err != nil { + return err + } + quarantineEventRoot, err := parseRoot( + "quarantine event root", + checkpoint.QuarantineEventRoot, + ) + if err != nil { + return err + } + quarantineActiveRoot, err := parseRoot( + "quarantine active root", + checkpoint.QuarantineActiveRoot, + ) + if err != nil { + return err + } + quarantineTombstoneRoot, err := parseRoot( + "quarantine tombstone root", + checkpoint.QuarantineTombstoneRoot, + ) + if err != nil { + return err + } + certificates := make( + []FrostRetainedGroupCheckpointCertificate, + len(checkpoint.Ancestry), + ) + for index, wireCertificate := range checkpoint.Ancestry { + certificate, err := + frostRetainedGroupCheckpointCertificateFromWire( + wireCertificate, + ) + if err != nil { + return fmt.Errorf( + "invalid FROST checkpoint proof certificate [%d]: [%w]", + index, + err, + ) + } + certificates[index] = certificate + } + return VerifyFrostRetainedGroupCheckpointProof( + bindingHash, + manifest, + floor, + FrostRetainedGroupCheckpointCommitment{ + DurableHead: durableHead, + Point: FrostPreSignFinality{ + BlockNumber: checkpoint.Point.BlockNumber, + BlockHash: pointHash, + }, + HistoryRoot: historyRoot, + CanonicalGeneration: checkpoint.CanonicalGeneration, + CanonicalInventoryRoot: canonicalInventoryRoot, + QuarantineGeneration: checkpoint.QuarantineGeneration, + QuarantineEventRoot: quarantineEventRoot, + QuarantineActiveRoot: quarantineActiveRoot, + QuarantineTombstoneRoot: quarantineTombstoneRoot, + }, + certificates, + ) +} + func decodeStrictFrostActivationJSON(data []byte, target interface{}) error { if err := validateUniqueFrostActivationJSONKeys(data); err != nil { return err diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index c43bc05212..dbea942ac8 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -10,6 +10,7 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" + "errors" "fmt" "io" "net" @@ -27,6 +28,201 @@ import ( "github.com/keep-network/keep-core/pkg/chain" ) +func TestFrostActivationHandshakeExporter_CheckpointRecoveryReentry( + t *testing.T, +) { + point := FrostPreSignFinality{ + BlockNumber: 11, + BlockHash: [32]byte{0x11}, + } + + t.Run("requeues authenticated progress", func(t *testing.T) { + job := &frostActivationReconciliationJob{ + sequence: 7, + point: point, + } + exporter := &frostActivationHandshakeExporter{ + reconciliationSequence: 7, + reconciliationActive: job, + } + hasDesired := exporter.completeReconciliationJob( + job, + nil, + fmt.Errorf( + "wrapped recovery result: %w", + errFrostRetainedGroupCheckpointRecoveryProgress, + ), + ) + if !hasDesired || + exporter.reconciliationActive != nil || + exporter.reconciliationCompleted != nil || + exporter.reconciliationSequence != 8 || + exporter.reconciliationDesired == nil || + exporter.reconciliationDesired.sequence != 8 || + exporter.reconciliationDesired.point != point { + t.Fatalf( + "authenticated checkpoint progress was not deterministically requeued: %+v", + exporter, + ) + } + }) + + t.Run("does not retry an ordinary failure", func(t *testing.T) { + job := &frostActivationReconciliationJob{ + sequence: 7, + point: point, + } + exporter := &frostActivationHandshakeExporter{ + reconciliationSequence: 7, + reconciliationActive: job, + } + if exporter.completeReconciliationJob( + job, + nil, + errors.New("ordinary failure"), + ) || + exporter.reconciliationDesired != nil || + exporter.reconciliationSequence != 7 { + t.Fatalf( + "ordinary reconciliation failure was automatically requeued: %+v", + exporter, + ) + } + }) + + t.Run("preserves a newer request", func(t *testing.T) { + job := &frostActivationReconciliationJob{ + sequence: 7, + point: point, + } + newerPoint := FrostPreSignFinality{ + BlockNumber: 12, + BlockHash: [32]byte{0x12}, + } + newerJob := &frostActivationReconciliationJob{ + sequence: 8, + point: newerPoint, + } + exporter := &frostActivationHandshakeExporter{ + reconciliationSequence: 8, + reconciliationActive: job, + reconciliationDesired: newerJob, + } + if !exporter.completeReconciliationJob( + job, + nil, + errFrostRetainedGroupCheckpointRecoveryProgress, + ) || + exporter.reconciliationSequence != 8 || + exporter.reconciliationDesired != newerJob { + t.Fatalf( + "checkpoint progress overwrote the newer reconciliation request: %+v", + exporter, + ) + } + }) +} + +func TestFrostActivationHandshakeExporter_CheckpointRecoveryWorkerLoopsUntilComplete( + t *testing.T, +) { + point := FrostPreSignFinality{ + BlockNumber: 11, + BlockHash: [32]byte{0x11}, + } + job := &frostActivationReconciliationJob{ + sequence: 1, + point: point, + } + exporter := &frostActivationHandshakeExporter{ + reconciliationWake: make(chan struct{}, 1), + reconciliationSequence: 1, + reconciliationDesired: job, + } + thirdAttemptStarted := make(chan struct{}) + releaseThirdAttempt := make(chan struct{}) + attempts := 0 + reconcile := func( + ctx context.Context, + actualPoint FrostPreSignFinality, + ) (*frostActivationReconciliationCache, error) { + attempts++ + if actualPoint != point { + return nil, fmt.Errorf("worker reconciled an unexpected point") + } + if attempts <= 2 { + return nil, fmt.Errorf( + "bounded page [%d]: %w", + attempts, + errFrostRetainedGroupCheckpointRecoveryProgress, + ) + } + close(thirdAttemptStarted) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-releaseThirdAttempt: + } + return &frostActivationReconciliationCache{ + point: point, + }, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + workerDone := make(chan struct{}) + go func() { + exporter.runReconciliationWorker(ctx, reconcile) + close(workerDone) + }() + exporter.reconciliationWake <- struct{}{} + + select { + case <-thirdAttemptStarted: + case <-time.After(3 * time.Second): + cancel() + <-workerDone + t.Fatal("worker did not re-enter checkpoint recovery") + } + exporter.reconciliationMutex.Lock() + if exporter.reconciliationCompleted != nil || + exporter.reconciliationActive == nil || + exporter.reconciliationActive.sequence != 3 || + exporter.reconciliationSequence != 3 { + exporter.reconciliationMutex.Unlock() + cancel() + close(releaseThirdAttempt) + <-workerDone + t.Fatalf( + "worker published health before checkpoint recovery completed: %+v", + exporter, + ) + } + exporter.reconciliationMutex.Unlock() + + close(releaseThirdAttempt) + deadline := time.Now().Add(3 * time.Second) + for exporter.cachedReconciliation(point) == nil { + if time.Now().After(deadline) { + cancel() + <-workerDone + t.Fatal("worker did not publish completed reconciliation") + } + time.Sleep(time.Millisecond) + } + cancel() + select { + case <-workerDone: + case <-time.After(3 * time.Second): + t.Fatal("checkpoint recovery worker did not stop") + } + if attempts != 3 { + t.Fatalf( + "worker made [%d] attempts, expected two progress pages and completion", + attempts, + ) + } +} + type testFrostActivationPointVerifier struct { mutex sync.Mutex err error @@ -367,6 +563,23 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { if err != nil || !ed25519.Verify(publicKey, signatureTranscript, signature) { t.Fatal("handshake signature did not verify over canonical payload") } + if len(handshake.Payload.State.CheckpointJournal.Ancestry) != 1 { + t.Fatalf( + "exact-head checkpoint proof contains [%d] certificates", + len(handshake.Payload.State.CheckpointJournal.Ancestry), + ) + } + if err := verifyFrostActivationCheckpointHandshakeState( + manifest, + journal.metadata.BindingHash, + request.Challenge, + handshake.Payload.State, + ); err != nil { + t.Fatalf( + "independent exact-head checkpoint proof verification failed: [%v]", + err, + ) + } assertFrostActivationObjectKeys(t, handshake.Payload.State, []string{ "authorizationRegistryAddress", "bitcoinOutboxProtocolID", "canonicalJournal", "checkpointJournal", "completeRouterAddress", "durableBitcoinOutboxRecovered", "durableSessionStoreFingerprint", @@ -426,6 +639,111 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { } } +func TestFrostActivationHandshakeExporter_AttestsInclusiveCheckpointAncestry( + t *testing.T, +) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + exporter, journal, source, _, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + externalFloor := request.Challenge.CheckpointFloor + nextPoint := FrostPreSignFinality{ + BlockNumber: point.BlockNumber + 1, + BlockHash: [32]byte{0x45}, + } + journal.mutex.Lock() + journal.state.CurrentPoint = nextPoint + journal.quarantineState.CurrentPoint = nextPoint + var err error + journal.state.InventoryRoot, _, _, _, err = + frostRetainedGroupInventoryRoot(journal.state) + journal.mutex.Unlock() + if err != nil { + t.Fatal(err) + } + source.setTarget(nextPoint) + request.Challenge.EthereumPoint = frostActivationEthereumPoint{ + BlockNumber: nextPoint.BlockNumber, + BlockHash: frostActivationHex32(nextPoint.BlockHash), + } + recertifyTestFrostActivationJournal( + t, + journal, + source, + &request, + journal.checkpointState.Sequence+1, + ) + request.Challenge.CheckpointFloor = externalFloor + + response := postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != + frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf( + "initial ancestry reconciliation returned [%d]", + response.StatusCode, + ) + } + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + defer response.Body.Close() + handshake := &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { + t.Fatal(err) + } + if len(handshake.Payload.State.CheckpointJournal.Ancestry) != 2 { + t.Fatalf( + "checkpoint proof contains [%d] certificates, expected floor and head", + len(handshake.Payload.State.CheckpointJournal.Ancestry), + ) + } + if err := verifyFrostActivationCheckpointHandshakeState( + exporter.manifest, + journal.metadata.BindingHash, + request.Challenge, + handshake.Payload.State, + ); err != nil { + t.Fatalf( + "independent descendant checkpoint proof verification failed: [%v]", + err, + ) + } + + missingFloor := handshake.Payload.State + missingFloor.CheckpointJournal.Ancestry = + append( + []frostRetainedGroupWireCheckpointCertificate{}, + missingFloor.CheckpointJournal.Ancestry[1:]..., + ) + if err := verifyFrostActivationCheckpointHandshakeState( + exporter.manifest, + journal.metadata.BindingHash, + request.Challenge, + missingFloor, + ); err == nil { + t.Fatal("checkpoint proof without its external floor was accepted") + } + wrongRoot := handshake.Payload.State + wrongRoot.CheckpointJournal.HistoryRoot = + frostActivationHex32([32]byte{0xff}) + if err := verifyFrostActivationCheckpointHandshakeState( + exporter.manifest, + journal.metadata.BindingHash, + request.Challenge, + wrongRoot, + ); err == nil { + t.Fatal("checkpoint proof with a different tail root was accepted") + } +} + func TestFrostActivationHandshakeExporter_PermitsAndAttestsTombstones( t *testing.T, ) { @@ -1087,6 +1405,8 @@ func TestCanonicalFrostActivationValue_RejectsDuplicateRawMessageKeys( func testFrostActivationRuntimeManifest( keyHash [32]byte, ) FrostPreSignActivationRuntimeManifest { + checkpointAuthorities, _, _ := + testFrostActivationCheckpointCredentials() return FrostPreSignActivationRuntimeManifest{ ManifestHash: [32]byte{0x10}, ActivationAuthorityKeyHash: [32]byte{0x30}, @@ -1126,14 +1446,10 @@ func testFrostActivationRuntimeManifest( LiftProtocolID: [32]byte{0x35}, TombstoneProtocolID: [32]byte{0x36}, CheckpointAuthorityThreshold: 2, - CheckpointAuthorities: []FrostRetainedGroupAuthority{ - {AuthorityID: "checkpoint-1", PublicKeySPKIHash: [32]byte{0x40}}, - {AuthorityID: "checkpoint-2", PublicKeySPKIHash: [32]byte{0x41}}, - {AuthorityID: "checkpoint-3", PublicKeySPKIHash: [32]byte{0x42}}, - }, - CheckpointMinimumSequence: 1, - CheckpointPredecessorHash: [32]byte{}, - LiftAuthorityThreshold: 2, + CheckpointAuthorities: checkpointAuthorities, + CheckpointMinimumSequence: 1, + CheckpointPredecessorHash: [32]byte{}, + LiftAuthorityThreshold: 2, LiftAuthorities: []FrostRetainedGroupAuthority{ {AuthorityID: "lift-1", PublicKeySPKIHash: [32]byte{0x43}}, {AuthorityID: "lift-2", PublicKeySPKIHash: [32]byte{0x44}}, @@ -1147,6 +1463,113 @@ func testFrostActivationRuntimeManifest( } } +func testFrostActivationCheckpointCredentials() ( + []FrostRetainedGroupAuthority, + []ed25519.PrivateKey, + []string, +) { + authorities := make([]FrostRetainedGroupAuthority, 3) + privateKeys := make([]ed25519.PrivateKey, 3) + publicKeySPKIs := make([]string, 3) + for index := range authorities { + seed := make([]byte, ed25519.SeedSize) + seed[0] = byte(0x70 + index) + privateKey := ed25519.NewKeyFromSeed(seed) + publicKeyDER, err := x509.MarshalPKIXPublicKey( + privateKey.Public(), + ) + if err != nil { + panic(err) + } + authorities[index] = FrostRetainedGroupAuthority{ + AuthorityID: fmt.Sprintf( + "checkpoint-%d", + index+1, + ), + PublicKeySPKIHash: sha256.Sum256(publicKeyDER), + } + privateKeys[index] = privateKey + publicKeySPKIs[index] = + base64.StdEncoding.EncodeToString(publicKeyDER) + } + return authorities, privateKeys, publicKeySPKIs +} + +func testFrostActivationCheckpointCertificate( + t *testing.T, + policy frostRetainedGroupCheckpointPolicy, + sequence uint64, + previousHash [32]byte, + commitment FrostRetainedGroupCheckpointCommitment, +) (FrostRetainedGroupCheckpointCertificate, [32]byte) { + t.Helper() + body := FrostRetainedGroupCheckpointBody{ + Schema: frostRetainedGroupCheckpointBodySchema, + ProtocolBindingHash: policy.ProtocolBindingHash, + ManifestHash: policy.ManifestHash, + ProfileHash: policy.ProfileHash, + ImplementationSetHash: policy.ImplementationSetHash, + ChainID: policy.ChainID, + DomainChainID: policy.DomainChainID, + GenesisBlockHash: policy.GenesisBlockHash, + AuthoritySetHash: policy.AuthoritySetHash, + Sequence: sequence, + PreviousCertificateHash: previousHash, + Point: commitment.Point, + HistoryRoot: commitment.HistoryRoot, + CanonicalGeneration: commitment.CanonicalGeneration, + CanonicalInventoryRoot: commitment.CanonicalInventoryRoot, + QuarantineGeneration: commitment.QuarantineGeneration, + QuarantineEventRoot: commitment.QuarantineEventRoot, + QuarantineActiveRoot: commitment.QuarantineActiveRoot, + QuarantineTombstoneRoot: commitment.QuarantineTombstoneRoot, + } + bodyHash, err := frostRetainedGroupCheckpointBodyHash(body) + if err != nil { + t.Fatal(err) + } + authorities, privateKeys, publicKeySPKIs := + testFrostActivationCheckpointCredentials() + if len(authorities) != len(policy.Authorities) { + t.Fatal("test checkpoint authority count differs from policy") + } + signatureHash := frostRetainedGroupCheckpointSignatureHash(bodyHash) + signatures := make( + []FrostRetainedGroupCheckpointSignature, + policy.AuthorityThreshold, + ) + for index := range signatures { + if authorities[index] != policy.Authorities[index] { + t.Fatal("test checkpoint authority differs from policy") + } + signatures[index] = FrostRetainedGroupCheckpointSignature{ + AuthorityID: authorities[index].AuthorityID, + SignerPublicKeySPKI: publicKeySPKIs[index], + Signature: base64.StdEncoding.EncodeToString( + ed25519.Sign( + privateKeys[index], + signatureHash[:], + ), + ), + } + } + certificate := FrostRetainedGroupCheckpointCertificate{ + Schema: frostRetainedGroupCheckpointCertificateSchema, + Body: body, + BodyHash: bodyHash, + Signatures: signatures, + } + certificateHash, err := + validateFrostRetainedGroupCheckpointCertificateShape( + policy, + certificate, + ) + if err != nil { + t.Fatal(err) + } + return certificate, certificateHash +} + func startTestFrostActivationHandshakeExporter( t *testing.T, point frostActivationEthereumPoint, @@ -1296,7 +1719,23 @@ func testFrostRetainedGroupJournal( if err != nil { t.Fatal(err) } - checkpointHash := [32]byte{0x55} + checkpointCertificate, checkpointHash := + testFrostActivationCheckpointCertificate( + t, + checkpointPolicy, + manifest.QuarantineJournal.CheckpointMinimumSequence, + manifest.QuarantineJournal.CheckpointPredecessorHash, + FrostRetainedGroupCheckpointCommitment{ + Point: target, + HistoryRoot: historyRoot, + CanonicalGeneration: state.SnapshotGeneration, + CanonicalInventoryRoot: state.InventoryRoot, + QuarantineGeneration: 0, + QuarantineEventRoot: quarantineRoot, + QuarantineActiveRoot: activeRoot, + QuarantineTombstoneRoot: tombstoneRoot, + }, + ) checkpointHead := FrostRetainedGroupCheckpointCursor{ Sequence: manifest.QuarantineJournal.CheckpointMinimumSequence, CertificateHash: checkpointHash, @@ -1363,7 +1802,9 @@ func testFrostRetainedGroupJournal( QuarantineActiveRoot: activeRoot, QuarantineTombstoneRoot: tombstoneRoot, }, - checkpointCertificates: make(map[uint64]FrostRetainedGroupCheckpointCertificate), + checkpointCertificates: map[uint64]FrostRetainedGroupCheckpointCertificate{ + checkpointHead.Sequence: checkpointCertificate, + }, checkpointHashes: map[uint64][32]byte{ checkpointHead.Sequence: checkpointHead.CertificateHash, }, @@ -1399,18 +1840,7 @@ func recertifyTestFrostActivationJournal( journal.mutex.Unlock() t.Fatal(err) } - commitment := struct { - Sequence uint64 - Point FrostPreSignFinality - HistoryRoot [32]byte - CanonicalGeneration uint64 - CanonicalInventoryRoot [32]byte - QuarantineGeneration uint64 - QuarantineEventRoot [32]byte - QuarantineActiveRoot [32]byte - QuarantineTombstoneRoot [32]byte - }{ - Sequence: sequence, + commitment := FrostRetainedGroupCheckpointCommitment{ Point: journal.state.CurrentPoint, HistoryRoot: historyRoot, CanonicalGeneration: journal.state.SnapshotGeneration, @@ -1420,14 +1850,27 @@ func recertifyTestFrostActivationJournal( QuarantineActiveRoot: journal.quarantineState.ActiveRoot, QuarantineTombstoneRoot: journal.quarantineState.TombstoneRoot, } - certificateHash, err := frostRetainedGroupDomainHash( - "test-frost-checkpoint-certificate-v1\x00", - commitment, - ) - if err != nil { - journal.mutex.Unlock() - t.Fatal(err) + previousHash := journal.checkpointPolicy.PredecessorHash + if sequence > journal.checkpointPolicy.MinimumSequence { + var exists bool + previousHash, exists = + journal.checkpointHashes[sequence-1] + if !exists { + journal.mutex.Unlock() + t.Fatalf( + "test checkpoint predecessor [%d] is missing", + sequence-1, + ) + } } + certificate, certificateHash := + testFrostActivationCheckpointCertificate( + t, + journal.checkpointPolicy, + sequence, + previousHash, + commitment, + ) journal.checkpointState = frostRetainedGroupCheckpointJournalState{ Schema: frostRetainedGroupCheckpointStateSchema, BindingHash: journal.metadata.BindingHash, @@ -1442,6 +1885,7 @@ func recertifyTestFrostActivationJournal( QuarantineActiveRoot: journal.quarantineState.ActiveRoot, QuarantineTombstoneRoot: journal.quarantineState.TombstoneRoot, } + journal.checkpointCertificates[sequence] = certificate journal.checkpointHashes[sequence] = certificateHash journal.mutex.Unlock() diff --git a/pkg/tbtc/frost_retained_group_checkpoint.go b/pkg/tbtc/frost_retained_group_checkpoint.go index 8f96b1d933..0ffceebbd9 100644 --- a/pkg/tbtc/frost_retained_group_checkpoint.go +++ b/pkg/tbtc/frost_retained_group_checkpoint.go @@ -8,9 +8,12 @@ import ( "encoding/base64" "encoding/hex" "fmt" + "math/big" "os" "sort" "strings" + + "github.com/decred/dcrd/dcrec/edwards/v2" ) const ( @@ -31,13 +34,22 @@ const ( // chain is not. The journal can durably advance through multiple pages and // resume from the last authenticated certificate after a crash or timeout. frostRetainedGroupMaximumCheckpointsPerPage = 256 - frostRetainedGroupMaximumCheckpointPages = 16 + // Reconciliation durably publishes exactly one authenticated page before + // yielding an explicit progress result. The controller then re-enters with + // a fresh timeout from the new durable cursor, so total history is unbounded + // without making one reconciliation attempt unbounded. + frostRetainedGroupCheckpointPagesPerReconciliation = 1 // Activation verifiers must obtain a fresh rollback-independent floor from // the transparency channel. An arbitrarily stale caller-supplied floor // cannot force the signer to allocate and serialize its entire lifetime // history. This remains above the old 256-certificate recovery limit. frostRetainedGroupMaximumHandshakeAncestry = 512 - frostRetainedGroupCheckpointDirectory = "checkpoints" + // Canonical checkpoint proof bytes are accounted certificate-by-certificate + // before the aggregate handshake payload is materialized. This protects the + // signer from a small loopback request expanding into hundreds of megabytes + // of authority credentials and canonical-JSON working buffers. + frostRetainedGroupMaximumHandshakeProofBytes = 4 * 1024 * 1024 + frostRetainedGroupCheckpointDirectory = "checkpoints" ) // FrostRetainedGroupCheckpointCursor is the rollback-independent head after @@ -88,6 +100,20 @@ type FrostRetainedGroupCheckpointCertificate struct { Signatures []FrostRetainedGroupCheckpointSignature } +// FrostRetainedGroupCheckpointCommitment is the exact durable semantic state +// that the tail of an externally verified checkpoint proof must certify. +type FrostRetainedGroupCheckpointCommitment struct { + DurableHead FrostRetainedGroupCheckpointCursor + Point FrostPreSignFinality + HistoryRoot [32]byte + CanonicalGeneration uint64 + CanonicalInventoryRoot [32]byte + QuarantineGeneration uint64 + QuarantineEventRoot [32]byte + QuarantineActiveRoot [32]byte + QuarantineTombstoneRoot [32]byte +} + type frostRetainedGroupWireCheckpointBody struct { Schema string `json:"schema"` ProtocolBindingHash string `json:"protocolBindingHash"` @@ -459,9 +485,32 @@ func frostRetainedGroupCheckpointSignatureHash( func frostRetainedGroupCheckpointCertificateHash( certificate FrostRetainedGroupCheckpointCertificate, ) ([32]byte, error) { + if certificate.Schema != + frostRetainedGroupCheckpointCertificateSchema { + return [32]byte{}, fmt.Errorf( + "unsupported FROST checkpoint certificate schema", + ) + } + bodyHash, err := frostRetainedGroupCheckpointBodyHash(certificate.Body) + if err != nil || bodyHash != certificate.BodyHash { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint certificate body hash mismatch", + ) + } + // A checkpoint's durable identity must not depend on which valid quorum + // subset an aggregator happened to include. Otherwise, the same signed + // body can acquire multiple predecessor hashes and permanently fork + // journals that received different 2-of-3 or 3-of-3 encodings. + identity := struct { + Schema string `json:"schema"` + BodyHash string `json:"bodyHash"` + }{ + Schema: certificate.Schema, + BodyHash: frostActivationHex32(certificate.BodyHash), + } return frostRetainedGroupDomainHash( frostRetainedGroupCheckpointCertificateDomain, - frostRetainedGroupCheckpointCertificateToWire(certificate), + identity, ) } @@ -577,6 +626,15 @@ func validateFrostRetainedGroupCheckpointCertificateShape( signature.AuthorityID, ) } + if err := validateFrostRetainedGroupPrimeOrderEd25519PublicKey( + publicKey, + ); err != nil { + return [32]byte{}, fmt.Errorf( + "FROST checkpoint authority [%s] key is not a nonidentity prime-order Ed25519 point: [%w]", + signature.AuthorityID, + err, + ) + } signatureBytes, err := base64.StdEncoding.Strict().DecodeString( signature.Signature, ) @@ -602,6 +660,179 @@ func validateFrostRetainedGroupCheckpointCertificateShape( return certificateHash, nil } +func validateFrostRetainedGroupPrimeOrderEd25519PublicKey( + publicKey ed25519.PublicKey, +) error { + if len(publicKey) != ed25519.PublicKeySize { + return fmt.Errorf("invalid Ed25519 public-key length") + } + parsed, err := edwards.ParsePubKey(publicKey) + if err != nil || + !bytes.Equal(parsed.Serialize(), publicKey) { + return fmt.Errorf("invalid or noncanonical Ed25519 point encoding") + } + if parsed.GetX().Sign() == 0 && + parsed.GetY().Cmp(big.NewInt(1)) == 0 { + return fmt.Errorf("Ed25519 identity point is forbidden") + } + curve := edwards.Edwards() + x, y := curve.ScalarMult( + parsed.GetX(), + parsed.GetY(), + curve.Params().N.Bytes(), + ) + if x == nil || y == nil || + x.Sign() != 0 || + y.Cmp(big.NewInt(1)) != 0 { + return fmt.Errorf("Ed25519 point is outside the prime-order subgroup") + } + return nil +} + +// VerifyFrostRetainedGroupCheckpointProof validates an inclusive certificate +// proof from a rollback-independent floor through the exact durable head. The +// floor cursor is supplied by an external transparency channel; the proof must +// include the corresponding full floor certificate even when floor and head +// are equal. +func VerifyFrostRetainedGroupCheckpointProof( + bindingHash [32]byte, + runtimeManifest FrostPreSignActivationRuntimeManifest, + floor FrostRetainedGroupCheckpointCursor, + commitment FrostRetainedGroupCheckpointCommitment, + certificates []FrostRetainedGroupCheckpointCertificate, +) error { + policy, err := frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + bindingHash, + runtimeManifest, + ) + if err != nil { + return fmt.Errorf("invalid FROST checkpoint proof policy: [%w]", err) + } + if floor.Sequence < policy.MinimumSequence || + floor.Sequence > frostRetainedGroupMaximumCanonicalJSONInteger || + floor.CertificateHash == [32]byte{} || + commitment.DurableHead.Sequence < floor.Sequence || + commitment.DurableHead.Sequence > + frostRetainedGroupMaximumCanonicalJSONInteger || + commitment.DurableHead.CertificateHash == [32]byte{} || + len(certificates) == 0 || + uint64(len(certificates)) != + commitment.DurableHead.Sequence-floor.Sequence+1 || + uint64(len(certificates)) > + frostRetainedGroupMaximumHandshakeAncestry+1 { + return fmt.Errorf( + "FROST checkpoint proof bounds or cursors are invalid", + ) + } + var previousHash [32]byte + var previousBody FrostRetainedGroupCheckpointBody + for index, certificate := range certificates { + certificateHash, err := + validateFrostRetainedGroupCheckpointCertificateShape( + policy, + certificate, + ) + if err != nil { + return fmt.Errorf( + "invalid FROST checkpoint proof certificate [%d]: [%w]", + index, + err, + ) + } + body := certificate.Body + if index == 0 { + if body.Sequence != floor.Sequence || + certificateHash != floor.CertificateHash { + return fmt.Errorf( + "FROST checkpoint proof does not contain the exact external floor", + ) + } + if body.Sequence == policy.MinimumSequence && + body.PreviousCertificateHash != policy.PredecessorHash { + return fmt.Errorf( + "FROST checkpoint proof floor does not extend the manifest predecessor", + ) + } + if body.Sequence > policy.MinimumSequence && + body.PreviousCertificateHash == [32]byte{} { + return fmt.Errorf( + "FROST checkpoint proof floor has no predecessor", + ) + } + } else if body.Sequence != previousBody.Sequence+1 || + body.PreviousCertificateHash != previousHash || + body.Point.BlockNumber <= previousBody.Point.BlockNumber || + body.CanonicalGeneration < + previousBody.CanonicalGeneration || + body.QuarantineGeneration < + previousBody.QuarantineGeneration { + return fmt.Errorf( + "FROST checkpoint proof has a gap, fork, rollback, or nonmonotonic successor", + ) + } + previousHash = certificateHash + previousBody = body + } + if _, err := frostRetainedGroupCheckpointProofCanonicalSize( + certificates, + frostRetainedGroupMaximumHandshakeProofBytes, + ); err != nil { + return err + } + + tail := certificates[len(certificates)-1].Body + if previousBody.Sequence != commitment.DurableHead.Sequence || + previousHash != commitment.DurableHead.CertificateHash || + tail.Point != commitment.Point || + tail.HistoryRoot != commitment.HistoryRoot || + tail.CanonicalGeneration != commitment.CanonicalGeneration || + tail.CanonicalInventoryRoot != + commitment.CanonicalInventoryRoot || + tail.QuarantineGeneration != commitment.QuarantineGeneration || + tail.QuarantineEventRoot != commitment.QuarantineEventRoot || + tail.QuarantineActiveRoot != commitment.QuarantineActiveRoot || + tail.QuarantineTombstoneRoot != + commitment.QuarantineTombstoneRoot { + return fmt.Errorf( + "FROST checkpoint proof tail differs from the exact durable commitment", + ) + } + return nil +} + +func frostRetainedGroupCheckpointProofCanonicalSize( + certificates []FrostRetainedGroupCheckpointCertificate, + maximum int, +) (int, error) { + if maximum <= 0 { + return 0, fmt.Errorf("FROST checkpoint proof byte limit is invalid") + } + size := 2 // JSON array brackets. + for index, certificate := range certificates { + encoded, err := frostRetainedGroupCanonicalValue( + frostRetainedGroupCheckpointCertificateToWire(certificate), + ) + if err != nil { + return 0, fmt.Errorf( + "cannot encode FROST checkpoint proof certificate [%d]: [%w]", + index, + err, + ) + } + delimiter := 0 + if index > 0 { + delimiter = 1 + } + if len(encoded) > maximum-size-delimiter { + return 0, fmt.Errorf( + "FROST checkpoint proof exceeds the canonical byte limit", + ) + } + size += delimiter + len(encoded) + } + return size, nil +} + func frostRetainedGroupCheckpointChainRoot( bindingHash [32]byte, after FrostRetainedGroupCheckpointCursor, @@ -1271,6 +1502,10 @@ func (frgj *frostRetainedGroupJournal) persistCheckpointSuffix( err, ) } + persistedCertificates := make( + []FrostRetainedGroupCheckpointCertificate, + len(certificates), + ) for index, certificate := range certificates { hash := hashes[index] sequence := certificate.Body.Sequence @@ -1280,17 +1515,20 @@ func (frgj *frostRetainedGroupJournal) persistCheckpointSuffix( sequence, ) } - if err := frgj.persistOrAdoptCheckpointCertificate( - certificate, - hash, - existingEntries, - ); err != nil { + persistedCertificate, err := + frgj.persistOrAdoptCheckpointCertificate( + certificate, + hash, + existingEntries, + ) + if err != nil { return fmt.Errorf( "cannot persist immutable FROST checkpoint certificate [%d]: [%w]", sequence, err, ) } + persistedCertificates[index] = persistedCertificate if frgj.checkpointPersistFailureHook != nil { if err := frgj.checkpointPersistFailureHook( "after-checkpoint-certificate-before-next", @@ -1324,7 +1562,7 @@ func (frgj *frostRetainedGroupJournal) persistCheckpointSuffix( return err } } - for index, certificate := range certificates { + for index, certificate := range persistedCertificates { sequence := certificate.Body.Sequence frgj.checkpointCertificates[sequence] = certificate frgj.checkpointHashes[sequence] = hashes[index] @@ -1337,7 +1575,7 @@ func (frgj *frostRetainedGroupJournal) persistOrAdoptCheckpointCertificate( certificate FrostRetainedGroupCheckpointCertificate, certificateHash [32]byte, existingEntries []os.DirEntry, -) error { +) (FrostRetainedGroupCheckpointCertificate, error) { sequence := certificate.Body.Sequence expectedName := frostRetainedGroupCheckpointFileName( sequence, @@ -1356,7 +1594,7 @@ func (frgj *frostRetainedGroupJournal) persistOrAdoptCheckpointCertificate( continue } if existingName != "" || name != expectedName { - return fmt.Errorf( + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( "conflicting immutable checkpoint certificate exists for sequence [%d]", sequence, ) @@ -1366,12 +1604,15 @@ func (frgj *frostRetainedGroupJournal) persistOrAdoptCheckpointCertificate( expectedWire := frostRetainedGroupCheckpointCertificateToWire(certificate) if existingName == "" { - return persistFrostRetainedGroupEnvelopeAt( + if err := persistFrostRetainedGroupEnvelopeAt( frgj.checkpointDirectory, expectedName, expectedWire, false, - ) + ); err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + return certificate, nil } storedWire := frostRetainedGroupWireCheckpointCertificate{} if err := readFrostRetainedGroupEnvelopeAt( @@ -1379,25 +1620,40 @@ func (frgj *frostRetainedGroupJournal) persistOrAdoptCheckpointCertificate( existingName, &storedWire, ); err != nil { - return fmt.Errorf( + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( "cannot read orphan checkpoint certificate: [%w]", err, ) } - storedCanonical, err := frostRetainedGroupCanonicalValue(storedWire) + storedCertificate, err := + frostRetainedGroupCheckpointCertificateFromWire(storedWire) if err != nil { - return err + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "cannot decode orphan checkpoint certificate: [%w]", + err, + ) } - expectedCanonical, err := frostRetainedGroupCanonicalValue(expectedWire) + storedHash, err := validateFrostRetainedGroupCheckpointCertificateShape( + frgj.checkpointPolicy, + storedCertificate, + ) if err != nil { - return err + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "cannot validate orphan checkpoint certificate: [%w]", + err, + ) } - if !bytes.Equal(storedCanonical, expectedCanonical) { - return fmt.Errorf( - "orphan checkpoint certificate differs from the requested exact suffix", + if storedHash != certificateHash || + storedCertificate.Schema != certificate.Schema || + storedCertificate.BodyHash != certificate.BodyHash || + storedCertificate.Body != certificate.Body { + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "orphan checkpoint certificate differs from the requested checkpoint body", ) } - return nil + // The durable encoding wins when the incoming certificate carries a + // different, but equally valid, quorum subset for the same stable body. + return storedCertificate, nil } func (frgj *frostRetainedGroupJournal) checkpointDescendsFrom( @@ -1412,7 +1668,7 @@ func (frgj *frostRetainedGroupJournal) checkpointDescendsFrom( return exists && hash == floor.CertificateHash } -func (frgj *frostRetainedGroupJournal) checkpointAncestryAfter( +func (frgj *frostRetainedGroupJournal) checkpointAncestryFrom( floor FrostRetainedGroupCheckpointCursor, ) ([]FrostRetainedGroupCheckpointCertificate, error) { if !frgj.checkpointDescendsFrom(floor) { @@ -1429,9 +1685,10 @@ func (frgj *frostRetainedGroupJournal) checkpointAncestryAfter( result := make( []FrostRetainedGroupCheckpointCertificate, 0, - int(distance), + int(distance+1), ) - for sequence := floor.Sequence + 1; sequence <= frgj.checkpointState.Sequence; sequence++ { + proofBytes := 2 // JSON array brackets. + for sequence := floor.Sequence; sequence <= frgj.checkpointState.Sequence; sequence++ { certificate, exists := frgj.checkpointCertificates[sequence] if !exists { return nil, fmt.Errorf( @@ -1439,6 +1696,28 @@ func (frgj *frostRetainedGroupJournal) checkpointAncestryAfter( sequence, ) } + encoded, err := frostRetainedGroupCanonicalValue( + frostRetainedGroupCheckpointCertificateToWire(certificate), + ) + if err != nil { + return nil, fmt.Errorf( + "cannot encode FROST checkpoint ancestry certificate [%d]: [%w]", + sequence, + err, + ) + } + delimiter := 0 + if len(result) > 0 { + delimiter = 1 + } + if len(encoded) > + frostRetainedGroupMaximumHandshakeProofBytes- + proofBytes-delimiter { + return nil, fmt.Errorf( + "FROST checkpoint ancestry exceeds the canonical byte limit", + ) + } + proofBytes += delimiter + len(encoded) result = append(result, certificate) } return result, nil diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index 0cf91c382d..99cc9fb72f 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -80,6 +80,31 @@ const ( frostRetainedGroupMaximumCanonicalJSONInteger uint64 = 9007199254740991 ) +var errFrostRetainedGroupCheckpointRecoveryProgress = errors.New( + "FROST checkpoint recovery advanced the durable head", +) + +func frostRetainedGroupCheckpointRecoveryProgressError( + sequence uint64, + cause error, +) error { + if cause == nil { + return fmt.Errorf( + "%w after [%d] authenticated page; retry from durable sequence [%d]", + errFrostRetainedGroupCheckpointRecoveryProgress, + frostRetainedGroupCheckpointPagesPerReconciliation, + sequence, + ) + } + return fmt.Errorf( + "%w after [%d] authenticated page; retry from durable sequence [%d]; post-publication verification failed: %w", + errFrostRetainedGroupCheckpointRecoveryProgress, + frostRetainedGroupCheckpointPagesPerReconciliation, + sequence, + cause, + ) +} + // FrostRetainedGroupEventPoint identifies one canonical Ethereum log. The // transaction identity and ordering indexes prove Registry closure follows // the matching Bridge terminal transition in the same transaction. @@ -3215,7 +3240,8 @@ func (frgj *frostRetainedGroupJournal) reconcile( 0, ) checkpointRecoveryComplete := false - for checkpointPage := 0; checkpointPage < frostRetainedGroupMaximumCheckpointPages; checkpointPage++ { + for checkpointPage := 0; checkpointPage < + frostRetainedGroupCheckpointPagesPerReconciliation; checkpointPage++ { page, err := frgj.source.ReadCompleteHistory( ctx, frgj.metadata.Checkpoint, @@ -3595,30 +3621,56 @@ func (frgj *frostRetainedGroupJournal) reconcile( ); err != nil { return nil, err } + checkpointRecoveryAdvanced := + !history.CheckpointComplete && + frgj.checkpointState.Sequence > checkpointAfter.Sequence + withCheckpointRecoveryProgress := func(cause error) error { + if !checkpointRecoveryAdvanced { + return cause + } + return frostRetainedGroupCheckpointRecoveryProgressError( + frgj.checkpointState.Sequence, + cause, + ) + } afterHead, err := frgj.source.FinalizedHead(ctx) if err != nil { - return nil, fmt.Errorf("cannot read independent finalized head after journal replay: [%w]", err) + return nil, withCheckpointRecoveryProgress(fmt.Errorf( + "cannot read independent finalized head after journal replay: [%w]", + err, + )) } if afterHead.BlockNumber < beforeHead.BlockNumber || afterHead.BlockNumber < target.BlockNumber || afterHead.BlockHash == [32]byte{} || (beforeHead.BlockNumber == afterHead.BlockNumber && beforeHead.BlockHash != afterHead.BlockHash) { - return nil, fmt.Errorf("independent finalized head changed inconsistently during journal replay") + return nil, withCheckpointRecoveryProgress(fmt.Errorf( + "independent finalized head changed inconsistently during journal replay", + )) } if afterHead.BlockNumber == target.BlockNumber && afterHead.BlockHash != target.BlockHash { - return nil, fmt.Errorf("independent finalized head disagrees with challenged target after replay") + return nil, withCheckpointRecoveryProgress(fmt.Errorf( + "independent finalized head disagrees with challenged target after replay", + )) } if err := frgj.source.VerifyPoint(ctx, afterHead); err != nil { - return nil, fmt.Errorf("cannot verify independent finalized head after journal replay: [%w]", err) + return nil, withCheckpointRecoveryProgress(fmt.Errorf( + "cannot verify independent finalized head after journal replay: [%w]", + err, + )) } if err := frgj.source.VerifyPoint(ctx, target); err != nil { - return nil, fmt.Errorf("challenged FROST retained-group point changed during replay: [%w]", err) + return nil, withCheckpointRecoveryProgress(fmt.Errorf( + "challenged FROST retained-group point changed during replay: [%w]", + err, + )) } if !history.CheckpointComplete { - return nil, fmt.Errorf( - "FROST checkpoint recovery remains incomplete after [%d] authenticated pages; retry from durable sequence [%d]", - frostRetainedGroupMaximumCheckpointPages, - frgj.checkpointState.Sequence, - ) + if !checkpointRecoveryAdvanced { + return nil, fmt.Errorf( + "incomplete FROST checkpoint recovery did not advance the durable head", + ) + } + return nil, withCheckpointRecoveryProgress(nil) } localSessionCount, err := frgj.reconcileLocalSessions(ctx, target) if err != nil { diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index b643ab00d6..7cbedc06f6 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -11,6 +11,7 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -26,6 +27,7 @@ type journalHistorySource struct { identity FrostRetainedGroupHistoryIdentity checkpoint FrostPreSignFinality head FrostPreSignFinality + finalizedHeadErr error descriptor [32]byte mutations []FrostRetainedGroupMutation complete bool @@ -49,6 +51,9 @@ func (jhs *journalHistorySource) Identity( func (jhs *journalHistorySource) FinalizedHead( context.Context, ) (FrostPreSignFinality, error) { + if jhs.finalizedHeadErr != nil { + return FrostPreSignFinality{}, jhs.finalizedHeadErr + } return jhs.head, nil } @@ -1094,6 +1099,195 @@ func TestFrostRetainedGroupJournal_CheckpointCertificateRejectsBypasses( }) } +func TestFrostRetainedGroupJournal_CheckpointIdentityIgnoresQuorumEncoding( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + policy, err := frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + fixture.bindingHash, + fixture.runtime, + ) + if err != nil { + t.Fatal(err) + } + after := FrostRetainedGroupCheckpointCursor{ + Sequence: policy.MinimumSequence - 1, + CertificateHash: policy.PredecessorHash, + } + certificates, err := fixture.source.checkpointIssuer( + after, + fixture.target, + fixture.source.mutations, + ) + if err != nil || len(certificates) != 1 { + t.Fatalf("cannot issue test checkpoint: [%v]", err) + } + + twoOfThreeAB := certificates[0] + twoOfThreeAC := certificates[0] + fixture.resignCheckpointCertificate( + t, + &twoOfThreeAC, + []int{0, 2}, + ) + threeOfThree := certificates[0] + fixture.resignCheckpointCertificate( + t, + &threeOfThree, + []int{0, 1, 2}, + ) + + var expectedHash [32]byte + for index, certificate := range []FrostRetainedGroupCheckpointCertificate{ + twoOfThreeAB, + twoOfThreeAC, + threeOfThree, + } { + certificateHash, err := + validateFrostRetainedGroupCheckpointCertificateShape( + policy, + certificate, + ) + if err != nil { + t.Fatalf( + "valid quorum encoding [%d] was rejected: [%v]", + index, + err, + ) + } + if index == 0 { + expectedHash = certificateHash + } else if certificateHash != expectedHash { + t.Fatalf( + "checkpoint identity depends on quorum encoding [%d]: [%x != %x]", + index, + certificateHash, + expectedHash, + ) + } + } + twoOfThreeABWire, err := frostRetainedGroupCanonicalValue( + frostRetainedGroupCheckpointCertificateToWire(twoOfThreeAB), + ) + if err != nil { + t.Fatal(err) + } + twoOfThreeACWire, err := frostRetainedGroupCanonicalValue( + frostRetainedGroupCheckpointCertificateToWire(twoOfThreeAC), + ) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(twoOfThreeABWire, twoOfThreeACWire) { + t.Fatal("alternate quorum encodings unexpectedly have identical wire form") + } +} + +func TestFrostRetainedGroupJournal_CheckpointRejectsNonPrimeOrderAuthorityKeys( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + policy, err := frostRetainedGroupCheckpointPolicyFromRuntimeManifest( + fixture.bindingHash, + fixture.runtime, + ) + if err != nil { + t.Fatal(err) + } + after := FrostRetainedGroupCheckpointCursor{ + Sequence: policy.MinimumSequence - 1, + CertificateHash: policy.PredecessorHash, + } + certificates, err := fixture.source.checkpointIssuer( + after, + fixture.target, + fixture.source.mutations, + ) + if err != nil || len(certificates) != 1 { + t.Fatalf("cannot issue test checkpoint: [%v]", err) + } + + identityKey := make(ed25519.PublicKey, ed25519.PublicKeySize) + identityKey[0] = 1 + identityKeyDER, err := x509.MarshalPKIXPublicKey(identityKey) + if err != nil { + t.Fatal(err) + } + policy.Authorities = append( + []FrostRetainedGroupAuthority{}, + policy.Authorities..., + ) + policy.Authorities[0].PublicKeySPKIHash = + sha256.Sum256(identityKeyDER) + policy.AuthoritySetHash, err = + frostRetainedGroupAuthoritySetHash( + "tbtc-frost-retained-group-checkpoint-authority-set/v1", + policy.AuthorityThreshold, + policy.Authorities, + ) + if err != nil { + t.Fatal(err) + } + certificate := certificates[0] + certificate.Body.AuthoritySetHash = policy.AuthoritySetHash + fixture.resignCheckpointCertificate( + t, + &certificate, + []int{0, 1}, + ) + signatureHash := frostRetainedGroupCheckpointSignatureHash( + certificate.BodyHash, + ) + trivialSignature := make([]byte, ed25519.SignatureSize) + trivialSignature[0] = 1 // R is the identity; S is zero. + if !ed25519.Verify( + identityKey, + signatureHash[:], + trivialSignature, + ) { + t.Fatal( + "test runtime no longer accepts the identity-key Ed25519 forgery", + ) + } + certificate.Signatures[0] = + FrostRetainedGroupCheckpointSignature{ + AuthorityID: policy.Authorities[0].AuthorityID, + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + identityKeyDER, + ), + Signature: base64.StdEncoding.EncodeToString( + trivialSignature, + ), + } + if _, err := validateFrostRetainedGroupCheckpointCertificateShape( + policy, + certificate, + ); err == nil || !strings.Contains( + err.Error(), + "nonidentity prime-order", + ) { + t.Fatalf( + "identity checkpoint authority key was not rejected: [%v]", + err, + ) + } + + orderTwoKey := make(ed25519.PublicKey, ed25519.PublicKeySize) + orderTwoKey[0] = 0xec + for index := 1; index < len(orderTwoKey)-1; index++ { + orderTwoKey[index] = 0xff + } + orderTwoKey[len(orderTwoKey)-1] = 0x7f + if err := validateFrostRetainedGroupPrimeOrderEd25519PublicKey( + orderTwoKey, + ); err == nil || !strings.Contains(err.Error(), "subgroup") { + t.Fatalf( + "nonidentity torsion Ed25519 key was not rejected: [%v]", + err, + ) + } +} + func TestFrostRetainedGroupJournal_CheckpointFrozenHashVector( t *testing.T, ) { @@ -1133,8 +1327,8 @@ func TestFrostRetainedGroupJournal_CheckpointFrozenHashVector( [][32]byte{certificateHash}, ) const expectedBodyHash = "ba0fdaecfc27fac1de867bd56f88fe66198952b3a99ef21f639bc62f331ce1fd" - const expectedCertificateHash = "cd396aa5ddc6bc7b53209ba2dc335fb8ed95113efdc256871df87f91a7d619e2" - const expectedChainRoot = "85a412822497e9029f7c437cf5c2087f20b2610a76ab4b952823aa3f89b27544" + const expectedCertificateHash = "0f7a902e61f4d2e47ab936440b865f693c403c263d17aeb9298a515830557d4a" + const expectedChainRoot = "a4bb6bd09d47673ef2476afcf6318c7508430623a20011feae3338f3c2302ec6" if fmt.Sprintf("%x", bodyHash) != expectedBodyHash || fmt.Sprintf("%x", certificateHash) != expectedCertificateHash || fmt.Sprintf("%x", chainRoot) != expectedChainRoot { @@ -1151,12 +1345,13 @@ func TestFrostRetainedGroupJournal_RecoversCheckpointChainBeyondOnePage( t *testing.T, ) { fixture := newJournalTestFixture(t) - count := frostRetainedGroupMaximumCheckpointsPerPage + 2 + const previousAggregateRecoveryLimit = 4096 + count := previousAggregateRecoveryLimit + 2 cursor := FrostRetainedGroupCheckpointCursor{ Sequence: fixture.quarantine.CheckpointMinimumSequence - 1, CertificateHash: fixture.quarantine.CheckpointPredecessorHash, } - var oldestCertified FrostRetainedGroupCheckpointCursor + var proofFloor FrostRetainedGroupCheckpointCursor var target FrostPreSignFinality for index := 0; index < count; index++ { blockNumber := fixture.checkpoint.BlockNumber + uint64(index) + 1 @@ -1193,12 +1388,12 @@ func TestFrostRetainedGroupJournal_RecoversCheckpointChainBeyondOnePage( Sequence: certificates[0].Body.Sequence, CertificateHash: certificateHash, } - if index == 0 { - oldestCertified = cursor + if index == count-1-frostRetainedGroupMaximumHandshakeAncestry { + proofFloor = cursor } } fixture.source.head = FrostPreSignFinality{ - BlockNumber: 1000, + BlockNumber: target.BlockNumber + 1, BlockHash: [32]byte{0xfa}, } fixture.source.points[fixture.source.head.BlockNumber] = @@ -1206,33 +1401,134 @@ func TestFrostRetainedGroupJournal_RecoversCheckpointChainBeyondOnePage( directory := filepath.Join(t.TempDir(), "journal") journal := fixture.openJournal(t, directory) - defer journal.close() snapshot, err := journal.reconcile(context.Background(), target) - if err != nil { + if !errors.Is(err, errFrostRetainedGroupCheckpointRecoveryProgress) || + snapshot != nil { + _ = journal.close() + t.Fatalf( + "first bounded recovery page did not report durable progress: [%v]", + err, + ) + } + if journal.checkpointState.Sequence != + uint64(frostRetainedGroupMaximumCheckpointsPerPage) { + _ = journal.close() + t.Fatalf( + "first bounded recovery page stopped at sequence [%d]", + journal.checkpointState.Sequence, + ) + } + + postPublicationTimeoutInjected := false + journal.checkpointPersistFailureHook = func(stage string) error { + if stage == "after-checkpoint-state-before-memory" && + !postPublicationTimeoutInjected { + postPublicationTimeoutInjected = true + fixture.source.finalizedHeadErr = context.DeadlineExceeded + } + return nil + } + snapshot, err = journal.reconcile(context.Background(), target) + if !errors.Is(err, errFrostRetainedGroupCheckpointRecoveryProgress) || + !errors.Is(err, context.DeadlineExceeded) || + snapshot != nil { + _ = journal.close() + t.Fatalf( + "post-publication timeout did not preserve durable progress: [%v]", + err, + ) + } + if journal.checkpointState.Sequence != + 2*uint64(frostRetainedGroupMaximumCheckpointsPerPage) { + _ = journal.close() + t.Fatal("post-publication timeout lost the durable checkpoint cursor") + } + journal.checkpointPersistFailureHook = nil + fixture.source.finalizedHeadErr = nil + if err := journal.close(); err != nil { t.Fatal(err) } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + if restarted.checkpointState.Sequence != + 2*uint64(frostRetainedGroupMaximumCheckpointsPerPage) { + t.Fatalf( + "restart lost the durable checkpoint cursor: [%d]", + restarted.checkpointState.Sequence, + ) + } + previousSequence := restarted.checkpointState.Sequence + progressCount := 0 + for { + snapshot, err = restarted.reconcile(context.Background(), target) + if err == nil { + break + } + if !errors.Is( + err, + errFrostRetainedGroupCheckpointRecoveryProgress, + ) || snapshot != nil { + t.Fatalf( + "bounded recovery returned a non-progress result: [%v]", + err, + ) + } + if restarted.checkpointState.Sequence != + previousSequence+ + uint64(frostRetainedGroupMaximumCheckpointsPerPage) { + t.Fatalf( + "bounded recovery did not advance exactly one page: [%d] -> [%d]", + previousSequence, + restarted.checkpointState.Sequence, + ) + } + previousSequence = restarted.checkpointState.Sequence + progressCount++ + } + expectedProgressCount := + previousAggregateRecoveryLimit/ + frostRetainedGroupMaximumCheckpointsPerPage - + 2 + if progressCount != expectedProgressCount { + t.Fatalf( + "unexpected bounded recovery progress count: [%d]", + progressCount, + ) + } if snapshot.CheckpointSequence != uint64(count) || - journal.checkpointState.Sequence != uint64(count) || + restarted.checkpointState.Sequence != uint64(count) || snapshot.CheckpointCertificateHash != cursor.CertificateHash { t.Fatalf( "multi-page recovery stopped at the wrong checkpoint: %+v", snapshot, ) } - ancestry, err := journal.checkpointAncestryAfter(oldestCertified) + ancestry, err := restarted.checkpointAncestryFrom(proofFloor) if err != nil { t.Fatal(err) } - if len(ancestry) != count-1 || - len(ancestry) <= frostRetainedGroupMaximumCheckpointsPerPage { + if len(ancestry) != frostRetainedGroupMaximumHandshakeAncestry+1 { t.Fatalf( "long-lived ancestry proof was truncated at [%d] certificates", len(ancestry), ) } - if _, err := validateFrostRetainedGroupCheckpointSuffix( - journal.checkpointPolicy, - oldestCertified, + if err := VerifyFrostRetainedGroupCheckpointProof( + fixture.bindingHash, + fixture.runtime, + proofFloor, + FrostRetainedGroupCheckpointCommitment{ + DurableHead: cursor, + Point: restarted.checkpointState.Point, + HistoryRoot: restarted.checkpointState.HistoryRoot, + CanonicalGeneration: restarted.checkpointState.CanonicalGeneration, + CanonicalInventoryRoot: restarted.checkpointState.CanonicalInventoryRoot, + QuarantineGeneration: restarted.checkpointState.QuarantineGeneration, + QuarantineEventRoot: restarted.checkpointState.QuarantineEventRoot, + QuarantineActiveRoot: restarted.checkpointState.QuarantineActiveRoot, + QuarantineTombstoneRoot: restarted.checkpointState.QuarantineTombstoneRoot, + }, ancestry, ); err != nil { t.Fatalf("long-lived ancestry proof is invalid: [%v]", err) @@ -1242,6 +1538,100 @@ func TestFrostRetainedGroupJournal_RecoversCheckpointChainBeyondOnePage( func TestFrostRetainedGroupJournal_CheckpointPersistenceRetriesInProcess( t *testing.T, ) { + t.Run("adopts an alternate valid quorum encoding", func(t *testing.T) { + fixture := newJournalTestFixture(t) + cursor := FrostRetainedGroupCheckpointCursor{ + Sequence: fixture.quarantine.CheckpointMinimumSequence - 1, + CertificateHash: fixture.quarantine. + CheckpointPredecessorHash, + } + certificates, err := fixture.source.checkpointIssuer( + cursor, + fixture.target, + fixture.source.mutations, + ) + if err != nil || len(certificates) != 1 { + t.Fatalf("cannot issue test checkpoint: [%v]", err) + } + sourceCertificate := certificates[0] + storedCertificate := sourceCertificate + fixture.resignCheckpointCertificate( + t, + &storedCertificate, + []int{0, 2}, + ) + sourceHash, err := + frostRetainedGroupCheckpointCertificateHash(sourceCertificate) + if err != nil { + t.Fatal(err) + } + storedHash, err := + frostRetainedGroupCheckpointCertificateHash(storedCertificate) + if err != nil { + t.Fatal(err) + } + if sourceHash != storedHash { + t.Fatal("alternate quorum encodings have different checkpoint identities") + } + + directory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, directory) + if err := persistFrostRetainedGroupEnvelopeAt( + journal.checkpointDirectory, + frostRetainedGroupCheckpointFileName( + storedCertificate.Body.Sequence, + storedHash, + ), + frostRetainedGroupCheckpointCertificateToWire( + storedCertificate, + ), + false, + ); err != nil { + _ = journal.close() + t.Fatal(err) + } + snapshot, err := journal.reconcile( + context.Background(), + fixture.target, + ) + if err != nil { + _ = journal.close() + t.Fatal(err) + } + adopted := journal.checkpointCertificates[storedCertificate.Body.Sequence] + if snapshot.CheckpointCertificateHash != storedHash || + len(adopted.Signatures) != len(storedCertificate.Signatures) || + adopted.Signatures[1].AuthorityID != + storedCertificate.Signatures[1].AuthorityID { + _ = journal.close() + t.Fatalf( + "journal did not adopt the durable quorum encoding: %+v", + adopted, + ) + } + if err := journal.close(); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, directory) + defer restarted.close() + restartedSnapshot, err := restarted.reconcile( + context.Background(), + fixture.target, + ) + if err != nil { + t.Fatal(err) + } + if restartedSnapshot.CheckpointCertificateHash != storedHash || + restarted.checkpointState.Sequence != + storedCertificate.Body.Sequence { + t.Fatalf( + "alternate quorum encoding did not converge after restart: %+v", + restartedSnapshot, + ) + } + }) + t.Run("adopts a certificate orphan before the next certificate", func(t *testing.T) { fixture := newJournalTestFixture(t) cursor := FrostRetainedGroupCheckpointCursor{ @@ -1465,12 +1855,70 @@ func TestFrostRetainedGroupJournal_RejectsStaleHandshakeFloorBeforeAllocation( journal.checkpointHashes[floor.Sequence] = floor.CertificateHash journal.checkpointState.Sequence = floor.Sequence + frostRetainedGroupMaximumHandshakeAncestry + 1 - _, err := journal.checkpointAncestryAfter(floor) + _, err := journal.checkpointAncestryFrom(floor) if err == nil || !strings.Contains(err.Error(), "floor is too stale") { t.Fatalf("expected stale external floor rejection, got [%v]", err) } } +func TestFrostRetainedGroupJournal_RejectsOversizedHandshakeProofBeforeMaterialization( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + journal := fixture.openJournal( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + if _, err := journal.reconcile( + context.Background(), + fixture.target, + ); err != nil { + t.Fatal(err) + } + floor := FrostRetainedGroupCheckpointCursor{ + Sequence: journal.checkpointState.Sequence, + CertificateHash: journal.checkpointState.CertificateHash, + } + certificate := journal.checkpointCertificates[floor.Sequence] + baseSignatures := append( + []FrostRetainedGroupCheckpointSignature{}, + certificate.Signatures..., + ) + certificate.Signatures = make( + []FrostRetainedGroupCheckpointSignature, + 0, + len(baseSignatures)*64, + ) + for index := 0; index < 64; index++ { + certificate.Signatures = append( + certificate.Signatures, + baseSignatures..., + ) + } + for offset := uint64(0); offset <= + frostRetainedGroupMaximumHandshakeAncestry; offset++ { + sequence := floor.Sequence + offset + journal.checkpointCertificates[sequence] = certificate + journal.checkpointHashes[sequence] = [32]byte{0x7a} + } + journal.checkpointHashes[floor.Sequence] = floor.CertificateHash + journal.checkpointState.Sequence = + floor.Sequence + frostRetainedGroupMaximumHandshakeAncestry + + if _, err := journal.checkpointAncestryFrom( + floor, + ); err == nil || !strings.Contains( + err.Error(), + "canonical byte limit", + ) { + t.Fatalf( + "oversized checkpoint proof was not rejected before aggregate materialization: [%v]", + err, + ) + } +} + func TestFrostRetainedGroupJournal_IntegratesCommittedOrphanBatchExactlyOnce( t *testing.T, ) { From 49b1fbd3be49d11c2ec0bb497fd779c868d4d3e9 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 25 Jul 2026 20:08:17 -0400 Subject: [PATCH 30/93] Harden retained history endpoint identity --- ...-retained-group-transport-attestation.adoc | 243 ++ .../tbtc_frost_pre_sign_authorization.go | 330 ++- ...tbtc_frost_retained_group_manifest_test.go | 175 +- pkg/tbtc/frost_activation_handshake.go | 26 +- pkg/tbtc/frost_activation_handshake_test.go | 17 +- .../frost_retained_group_endpoint_identity.go | 2070 +++++++++++++++++ ...t_retained_group_endpoint_identity_test.go | 1153 +++++++++ .../frost_retained_group_history_evidence.go | 11 +- .../frost_retained_group_history_source.go | 302 +-- ...rost_retained_group_history_source_test.go | 450 +++- pkg/tbtc/frost_retained_group_journal.go | 61 +- pkg/tbtc/frost_retained_group_journal_test.go | 14 +- 12 files changed, 4515 insertions(+), 337 deletions(-) create mode 100644 docs/development/frost-retained-group-transport-attestation.adoc create mode 100644 pkg/tbtc/frost_retained_group_endpoint_identity.go create mode 100644 pkg/tbtc/frost_retained_group_endpoint_identity_test.go diff --git a/docs/development/frost-retained-group-transport-attestation.adoc b/docs/development/frost-retained-group-transport-attestation.adoc new file mode 100644 index 0000000000..0a109d0de9 --- /dev/null +++ b/docs/development/frost-retained-group-transport-attestation.adoc @@ -0,0 +1,243 @@ += FROST retained-group endpoint and transport attestation v1 + +This document specifies the wire contract required by the FROST retained-group +history export and independent Ethereum verifier endpoints. It is a fail-closed +protocol: a normal HTTPS or JSON-RPC response without all of these proofs is not +conforming. + +== Endpoint roles + +The activation manifest commits a source identity and two endpoint identities: + +* `retained-history-export` +* `retained-history-verifier` + +Each endpoint identity commits all of the following: + +* canonical HTTPS URL, canonical DNS name, resolved CNAME, and the hash of the + frozen resolved IP set; +* TLS 1.3 leaf SPKI hash and exactly one SPIFFE URI SAN; +* backend Ed25519 SPKI hash; +* operator Ed25519 SPKI hash; +* transport-attestation Ed25519 SPKI hash; and +* TLS exporter protocol ID. + +The source identity additionally commits the retained-history envelope-signing +Ed25519 SPKI hash. The TLS leaf, backend, operator, transport-attestation, and +history-envelope keys are distinct roles. The export and verifier instances +must not reuse any role key, SPIFFE ID, trust domain, DNS/CNAME identity, or +resolved backend address. + +Each endpoint `TrustDomainID` is exactly the authority component of its SPIFFE +service identity. The export and verifier therefore use different SPIFFE trust +domains, not merely different paths under one authority. + +`BackendServiceFingerprint` and `OperatorFingerprint` are SHA-256 hashes of +DER-encoded PKIX Ed25519 SubjectPublicKeyInfo values. They are not arbitrary +labels. The backend and operator key holders sign every response, as described +below. The backend key must be held by the backend being identified; the +operator key must be held by the manifest-authorized operator. Co-locating +either key only at an untrusted TLS edge defeats the role separation. + +== Canonical transcript encoding + +Every transcript starts with its ASCII domain string, including the terminal +NUL byte. Each field is then appended in the specified order as: + +.... +uint64_be(length(field_name)) +field_name bytes +uint64_be(length(field_value)) +field_value bytes +.... + +Text values use their exact UTF-8 bytes. A `bytes32` value is the raw 32 bytes, +not its hexadecimal representation. A `uint64` value is eight-byte +big-endian. + +Endpoint and source fingerprints use the field order defined by +`computeFrostRetainedGroupEndpointFingerprint` and +`computeFrostRetainedGroupSourceEndpointFingerprint`. Frozen vectors are in +`TestFrostRetainedGroupIdentityFingerprintsFrozen`. + +== TLS profile + +The connection must: + +* negotiate exactly TLS 1.3 and HTTP/1.1; +* pass normal PKIX validation for the canonical endpoint host; +* present the manifest-pinned leaf SPKI; +* present a non-CA X.509-SVID leaf with `digitalSignature`, without + `keyCertSign` or `cRLSign`; +* include both `serverAuth` and `clientAuth` when an EKU extension is present; + and +* contain exactly one URI SAN, equal to the manifest SPIFFE service identity. + +The client connects only to the manifest-frozen IP set. Proxies, redirects, +connection reuse, compression, content transformation, query strings, encoded +paths, path normalization, and Host overrides are forbidden. + +== Request challenge + +For every POST, the client generates 32 fresh random bytes and sends their +lowercase, unprefixed hexadecimal encoding in: + +.... +Tbtc-Retained-Transport-Challenge +.... + +The request method, absolute canonical request target, and SHA-256 of the exact +request-body bytes are bound into the proof. + +== TLS exporter + +The exporter context is the canonical transcript with domain: + +.... +tbtc-frost-retained-group-tls-exporter-context/v1\0 +.... + +and fields, in order: + +.... +endpointFingerprint bytes32 +challenge bytes32 +requestMethod text +requestTarget text +requestBodySha256 bytes32 +responseStatus uint64 +responseBodySha256 bytes32 +.... + +The TLS exporter call is: + +.... +label = "EXPORTER-tbtc-frost-retained-group-v1" +context = exporter_context_sha256 +length = 32 +.... + +The resulting 32 bytes are hashed with the canonical transcript domain +`tbtc-frost-retained-group-tls-exporter-value/v1\0` and one byte-string field +named `exporterValue`. + +== Response attestation + +Every response, including non-200 responses, carries exactly one: + +.... +Tbtc-Retained-Transport-Attestation +.... + +The header is canonical padded standard Base64 of a strict JSON object with +schema `tbtc-frost-retained-group-transport-attestation/v1`. Duplicate, +unknown, missing, non-canonically encoded, or oversized values are rejected. +The JSON fields are defined by `frostRetainedGroupTransportAttestation`. + +The signed transport transcript uses domain: + +.... +tbtc-frost-retained-group-transport-attestation/v1\0 +.... + +and these fields, in order: + +.... +schema text +role text +endpointFingerprint bytes32 +canonicalEndpoint text +canonicalDNSName text +resolvedDNSName text +resolvedPeerIP text +tlsLeafSpkiHash bytes32 +serviceIdentity text +backendServiceFingerprint bytes32 +operatorFingerprint bytes32 +attestationKeyHash bytes32 +tlsExporterProtocolID bytes32 +challenge bytes32 +requestMethod text +requestTarget text +requestBodySha256 bytes32 +responseStatus uint64 +responseBodySha256 bytes32 +issuedAtUnixMs uint64 +expiresAtUnixMs uint64 +tlsExporterContextSha256 bytes32 +tlsExporterValueSha256 bytes32 +.... + +The backend signs a domain-separated transcript containing the transport +transcript digest: + +.... +domain = "tbtc-frost-retained-group-backend-attestation/v1\0" +field = transportAttestationDigest bytes32 +.... + +The operator signs the equivalent transcript under domain: + +.... +tbtc-frost-retained-group-operator-attestation/v1\0 +.... + +The transport-attestation key signs the transport transcript digest directly. +All three algorithms are exactly `ed25519`. Every public key is canonical +padded Base64 of DER PKIX SubjectPublicKeyInfo and must hash to its distinct +manifest role. Every signature is canonical padded Base64. + +Attestations have a maximum 30-second lifetime. Clients allow at most five +seconds of clock skew and reject non-canonical or overflowing decimal +timestamps. + +== Frozen conformance vector + +`TestFrostRetainedGroupTransportAttestationFrozenVectors` is the normative +machine-readable vector. Its fixed outputs are: + +.... +TLS exporter context: +50587c477f56e9d2597c4cf4d9cf69d69a8ded13b9a074d1c18042eb4aea2e30 + +TLS exporter value hash: +62e2fd2ccb30b5e2ca49a99f04cbb01a947d8228060ee377dc6896c6c96a08b0 + +Transport attestation digest: +53eb0f08ca2c1761592ec90387c5aba9913668ef68c1e4cf6f20dbbd531e267b + +Backend digest: +7fb92657871cf2dd864eeffaac5d699f1131f81b04507b2092f90987aa4a722c + +Operator digest: +02495d610fde3ad437a22de7e93dae5394d34a0ef4e590f4d613e3fa6197cbc4 + +Transport signature: +AV8bIwrHUE6ACkJ9vQWtQTVXGJDR8Nm42nQqCka8NISgXIIPbW2abLhOrGlX/bnYVUUUMbhRfcyYCf+asQ9KBg== + +Backend signature: +0ACdPMwZaraKgWpVRMNnwc6qgLtB90rGDoj18kYCcbd2jJG36VCVf0j5OhHlqP5KoQbzKwJ9BRelXF1pYd/yDA== + +Operator signature: +lx22S9wJxUSOsgZZlWqP7S5bTdoULktZQ9Ao7KDwdpI8zJDkq3AD76rj737DSthJUt0+6IEdElMyiCnXwdgFBg== + +SHA-256 of the exact JSON response-attestation object: +174da49defe9c6b6c669a8a33177ccf6257df24e1316c650734c8ff35099dcdb +.... + +An independently implemented endpoint must reproduce these values before it is +eligible for activation. + +== Primary-endpoint independence limitation + +The retained export and verifier transports are channel-bound as specified +above. The current keep-core primary Ethereum client does not expose its actual +connected peer or TLS identity to this component. The retained-history client +therefore re-resolves the primary URL at every verification boundary and +rejects DNS/CNAME/IP overlap, but cannot cryptographically bind that observation +to the primary client's existing connection. + +Production activation remains no-go until the primary client exposes a +manifest-bound connected-peer identity (or is created through a shared pinned +transport) so split-horizon DNS, proxies, and persistent-connection drift can +also be excluded. diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index 4a66c9add8..3dadf60483 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -183,15 +183,43 @@ type frostPreSignManifestEthereum struct { } type frostPreSignManifestCanonicalJournal struct { - StoreID string `json:"storeID"` - StoreFingerprint string `json:"storeFingerprint"` - ClusterFingerprint string `json:"clusterFingerprint"` - Checkpoint frostPreSignManifestPoint `json:"checkpoint"` - DescriptorSetHash string `json:"descriptorSetHash"` - SourceTrustDomainID string `json:"sourceTrustDomainID"` - SourceEndpointFingerprint string `json:"sourceEndpointFingerprint"` - SourceOperatorFingerprint string `json:"sourceOperatorFingerprint"` - MinimumGeneration uint64 `json:"minimumGeneration"` + StoreID string `json:"storeID"` + StoreFingerprint string `json:"storeFingerprint"` + ClusterFingerprint string `json:"clusterFingerprint"` + Checkpoint frostPreSignManifestPoint `json:"checkpoint"` + DescriptorSetHash string `json:"descriptorSetHash"` + SourceTrustDomainID string `json:"sourceTrustDomainID"` + SourceEndpointFingerprint string `json:"sourceEndpointFingerprint"` + SourceOperatorFingerprint string `json:"sourceOperatorFingerprint"` + SourceIdentity frostPreSignManifestRetainedSourceIdentity `json:"sourceIdentity"` + MinimumGeneration uint64 `json:"minimumGeneration"` +} + +type frostPreSignManifestRetainedEndpointIdentity struct { + Schema string `json:"schema"` + Role string `json:"role"` + TrustDomainID string `json:"trustDomainID"` + CanonicalEndpoint string `json:"canonicalEndpoint"` + CanonicalDNSName string `json:"canonicalDNSName"` + ResolvedDNSName string `json:"resolvedDNSName"` + ResolvedAddressSetHash string `json:"resolvedAddressSetHash"` + TLSLeafSPKIHash string `json:"tlsLeafSpkiHash"` + ServiceIdentity string `json:"serviceIdentity"` + BackendServiceFingerprint string `json:"backendServiceFingerprint"` + OperatorFingerprint string `json:"operatorFingerprint"` + AttestationKeyHash string `json:"attestationKeyHash"` + TLSExporterProtocolID string `json:"tlsExporterProtocolID"` + EndpointFingerprint string `json:"endpointFingerprint"` +} + +type frostPreSignManifestRetainedSourceIdentity struct { + Schema string `json:"schema"` + TrustDomainID string `json:"trustDomainID"` + EndpointFingerprint string `json:"endpointFingerprint"` + OperatorFingerprint string `json:"operatorFingerprint"` + HistorySignerKeyHash string `json:"historySignerKeyHash"` + Export frostPreSignManifestRetainedEndpointIdentity `json:"export"` + Verifier frostPreSignManifestRetainedEndpointIdentity `json:"verifier"` } type frostPreSignManifestQuarantineJournal struct { @@ -911,6 +939,75 @@ func validateFrostPreSignActivationManifest( } parsedJournalValues[name] = parsed } + sourceIdentity, err := frostPreSignRetainedSourceIdentity( + journal.SourceIdentity, + ) + if err != nil { + return fmt.Errorf( + "FROST canonical journal complete endpoint identity is invalid: [%w]", + err, + ) + } + if sourceIdentity.TrustDomainID != journal.SourceTrustDomainID || + sourceIdentity.EndpointFingerprint != + parsedJournalValues["source endpoint fingerprint"] || + sourceIdentity.OperatorFingerprint != + parsedJournalValues["source operator fingerprint"] { + return fmt.Errorf( + "FROST canonical journal complete endpoint identity differs from its aggregate fields", + ) + } + otherRoleHashes := make(map[[32]byte]string) + for name, value := range map[string]string{ + "Ethereum source endpoint": manifest.Ethereum.SourceEndpointFingerprint, + "Ethereum verifier endpoint": manifest.Ethereum.VerifierEndpointFingerprint, + "runtime handshake endpoint": frost.HandshakeEndpointFingerprint, + "Ethereum source operator": manifest.Ethereum.SourceOperatorFingerprint, + "Ethereum verifier operator": manifest.Ethereum.VerifierOperatorFingerprint, + "runtime handshake operator": frost.HandshakeOperatorFingerprint, + "runtime attestation signer": frost.AttestationSignerKeyHash, + } { + parsed, parseErr := frostPreSignParseBytes32(value) + if parseErr != nil || parsed == [32]byte{} { + return fmt.Errorf("FROST %s identity is invalid", name) + } + if previous, exists := otherRoleHashes[parsed]; exists { + return fmt.Errorf( + "FROST %s identity aliases %s", + name, + previous, + ) + } + otherRoleHashes[parsed] = name + } + if previous, exists := otherRoleHashes[manifest.activationAuthorityKeyHash]; exists { + return fmt.Errorf( + "FROST activation authority identity aliases %s", + previous, + ) + } + otherRoleHashes[manifest.activationAuthorityKeyHash] = "activation authority" + for name, value := range map[string][32]byte{ + "retained export endpoint": sourceIdentity.Export.EndpointFingerprint, + "retained verifier endpoint": sourceIdentity.Verifier.EndpointFingerprint, + "retained export TLS leaf": sourceIdentity.Export.TLSLeafSPKIHash, + "retained verifier TLS leaf": sourceIdentity.Verifier.TLSLeafSPKIHash, + "retained export backend": sourceIdentity.Export.BackendServiceFingerprint, + "retained verifier backend": sourceIdentity.Verifier.BackendServiceFingerprint, + "retained export operator": sourceIdentity.Export.OperatorFingerprint, + "retained verifier operator": sourceIdentity.Verifier.OperatorFingerprint, + "retained history signer": sourceIdentity.HistorySignerKeyHash, + "retained export attestation": sourceIdentity.Export.AttestationKeyHash, + "retained verifier attestation": sourceIdentity.Verifier.AttestationKeyHash, + } { + if other, exists := otherRoleHashes[value]; exists { + return fmt.Errorf( + "FROST %s identity aliases %s", + name, + other, + ) + } + } for name, value := range map[string]string{ "Ethereum source endpoint": manifest.Ethereum.SourceEndpointFingerprint, "Ethereum verifier endpoint": manifest.Ethereum.VerifierEndpointFingerprint, @@ -931,14 +1028,26 @@ func validateFrostPreSignActivationManifest( return fmt.Errorf("FROST canonical journal source operator is not independent of %s", name) } } + trustDomains := make(map[string]string) for name, value := range map[string]string{ "Ethereum source": manifest.Ethereum.SourceTrustDomainID, "Ethereum verifier": manifest.Ethereum.VerifierTrustDomainID, "runtime signer": frost.TrustDomainID, + "retained source": journal.SourceTrustDomainID, + "retained export": sourceIdentity.Export.TrustDomainID, + "retained verifier": sourceIdentity.Verifier.TrustDomainID, } { - if strings.TrimSpace(value) == "" || value == journal.SourceTrustDomainID { - return fmt.Errorf("FROST canonical journal source trust domain is not independent of %s", name) + if strings.TrimSpace(value) == "" || value != strings.TrimSpace(value) { + return fmt.Errorf("FROST %s trust domain is invalid", name) + } + if previous, exists := trustDomains[value]; exists { + return fmt.Errorf( + "FROST %s trust domain aliases %s", + name, + previous, + ) } + trustDomains[value] = name } quarantine := frost.QuarantineJournal if strings.TrimSpace(quarantine.StoreID) == "" || len(quarantine.StoreID) > 255 { @@ -1095,6 +1204,109 @@ func frostPreSignNativeSignerAnchorManifest( return result, nil } +func frostPreSignRetainedEndpointIdentity( + wire frostPreSignManifestRetainedEndpointIdentity, +) (tbtc.FrostRetainedGroupEndpointIdentity, error) { + parse := func(name string, value string) ([32]byte, error) { + parsed, err := frostPreSignParseBytes32(value) + if err != nil { + return [32]byte{}, fmt.Errorf("invalid retained %s: [%w]", name, err) + } + return parsed, nil + } + addressSet, err := parse("resolved address-set hash", wire.ResolvedAddressSetHash) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + leaf, err := parse("TLS leaf SPKI hash", wire.TLSLeafSPKIHash) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + backend, err := parse( + "backend service fingerprint", + wire.BackendServiceFingerprint, + ) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + operator, err := parse("operator fingerprint", wire.OperatorFingerprint) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + attestation, err := parse("attestation key hash", wire.AttestationKeyHash) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + exporter, err := parse("TLS exporter protocol ID", wire.TLSExporterProtocolID) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + fingerprint, err := parse("endpoint fingerprint", wire.EndpointFingerprint) + if err != nil { + return tbtc.FrostRetainedGroupEndpointIdentity{}, err + } + return tbtc.FrostRetainedGroupEndpointIdentity{ + Schema: wire.Schema, + Role: wire.Role, + TrustDomainID: wire.TrustDomainID, + CanonicalEndpoint: wire.CanonicalEndpoint, + CanonicalDNSName: wire.CanonicalDNSName, + ResolvedDNSName: wire.ResolvedDNSName, + ResolvedAddressSetHash: addressSet, + TLSLeafSPKIHash: leaf, + ServiceIdentity: wire.ServiceIdentity, + BackendServiceFingerprint: backend, + OperatorFingerprint: operator, + AttestationKeyHash: attestation, + TLSExporterProtocolID: exporter, + EndpointFingerprint: fingerprint, + }, nil +} + +func frostPreSignRetainedSourceIdentity( + wire frostPreSignManifestRetainedSourceIdentity, +) (tbtc.FrostRetainedGroupHistoryIdentity, error) { + endpointFingerprint, err := frostPreSignParseBytes32( + wire.EndpointFingerprint, + ) + if err != nil { + return tbtc.FrostRetainedGroupHistoryIdentity{}, err + } + operatorFingerprint, err := frostPreSignParseBytes32( + wire.OperatorFingerprint, + ) + if err != nil { + return tbtc.FrostRetainedGroupHistoryIdentity{}, err + } + historySignerKeyHash, err := frostPreSignParseBytes32( + wire.HistorySignerKeyHash, + ) + if err != nil { + return tbtc.FrostRetainedGroupHistoryIdentity{}, err + } + exportIdentity, err := frostPreSignRetainedEndpointIdentity(wire.Export) + if err != nil { + return tbtc.FrostRetainedGroupHistoryIdentity{}, err + } + verifierIdentity, err := frostPreSignRetainedEndpointIdentity(wire.Verifier) + if err != nil { + return tbtc.FrostRetainedGroupHistoryIdentity{}, err + } + result := tbtc.FrostRetainedGroupHistoryIdentity{ + Schema: wire.Schema, + TrustDomainID: wire.TrustDomainID, + EndpointFingerprint: endpointFingerprint, + OperatorFingerprint: operatorFingerprint, + HistorySignerKeyHash: historySignerKeyHash, + Export: exportIdentity, + Verifier: verifierIdentity, + } + if err := tbtc.ValidateFrostRetainedGroupHistoryIdentity(result); err != nil { + return tbtc.FrostRetainedGroupHistoryIdentity{}, err + } + return result, nil +} + func validateFrostPreSignQuarantineLiftAuthorities( manifest *frostPreSignActivationManifest, ) error { @@ -1114,11 +1326,19 @@ func validateFrostPreSignQuarantineLiftAuthorities( forbidden := make(map[[32]byte]string) for name, value := range map[string]string{ - "runtime attestation": frost.AttestationSignerKeyHash, - "runtime exporter": frost.HandshakeOperatorFingerprint, - "retained history source": frost.CanonicalJournal.SourceOperatorFingerprint, - "primary history source": manifest.Ethereum.SourceOperatorFingerprint, - "primary history verifier": manifest.Ethereum.VerifierOperatorFingerprint, + "runtime attestation": frost.AttestationSignerKeyHash, + "runtime exporter": frost.HandshakeOperatorFingerprint, + "retained history source": frost.CanonicalJournal.SourceOperatorFingerprint, + "retained history verifier": frost.CanonicalJournal.SourceIdentity.Verifier.OperatorFingerprint, + "retained history signer": frost.CanonicalJournal.SourceIdentity.HistorySignerKeyHash, + "retained export TLS leaf": frost.CanonicalJournal.SourceIdentity.Export.TLSLeafSPKIHash, + "retained verifier TLS leaf": frost.CanonicalJournal.SourceIdentity.Verifier.TLSLeafSPKIHash, + "retained export backend": frost.CanonicalJournal.SourceIdentity.Export.BackendServiceFingerprint, + "retained verifier backend": frost.CanonicalJournal.SourceIdentity.Verifier.BackendServiceFingerprint, + "retained export attestation": frost.CanonicalJournal.SourceIdentity.Export.AttestationKeyHash, + "retained verifier attestation": frost.CanonicalJournal.SourceIdentity.Verifier.AttestationKeyHash, + "primary history source": manifest.Ethereum.SourceOperatorFingerprint, + "primary history verifier": manifest.Ethereum.VerifierOperatorFingerprint, } { hash, err := frostPreSignParseBytes32(value) if err != nil || hash == [32]byte{} { @@ -2463,6 +2683,12 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( if err != nil { return tbtc.FrostPreSignActivationRuntimeManifest{}, err } + sourceIdentity, err := frostPreSignRetainedSourceIdentity( + journal.SourceIdentity, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } quarantineStoreFingerprint, err := parse(quarantine.StoreFingerprint) if err != nil { return tbtc.FrostPreSignActivationRuntimeManifest{}, err @@ -2518,6 +2744,7 @@ func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( SourceTrustDomainID: journal.SourceTrustDomainID, SourceEndpointFingerprint: sourceEndpointFingerprint, SourceOperatorFingerprint: sourceOperatorFingerprint, + SourceIdentity: sourceIdentity, MinimumGeneration: journal.MinimumGeneration, }, QuarantineJournal: tbtc.FrostRetainedGroupQuarantineJournalManifest{ @@ -2544,13 +2771,17 @@ func frostPreSignEndpointIdentitySetHash( ethereum := manifest.Ethereum frost := manifest.FrostSigner type identity struct { - role string - trustDomainID string - endpointFingerprint string - operatorFingerprint string - storeID string - storeFingerprint string - clusterFingerprint string + role string + trustDomainID string + endpointFingerprint string + tlsLeafSPKIHash string + operatorFingerprint string + backendFingerprint string + attestationKeyHash string + historySignerKeyHash string + storeID string + storeFingerprint string + clusterFingerprint string } identities := []identity{ { @@ -2570,13 +2801,32 @@ func frostPreSignEndpointIdentitySetHash( storeFingerprint: ethereum.VerifierHistoryStoreFingerprint, }, { - role: "retained-group-export", - trustDomainID: frost.CanonicalJournal.SourceTrustDomainID, - endpointFingerprint: frost.CanonicalJournal.SourceEndpointFingerprint, - operatorFingerprint: frost.CanonicalJournal.SourceOperatorFingerprint, - storeID: frost.CanonicalJournal.StoreID, - storeFingerprint: frost.CanonicalJournal.StoreFingerprint, - clusterFingerprint: frost.CanonicalJournal.ClusterFingerprint, + role: "retained-group-source", + trustDomainID: frost.CanonicalJournal.SourceTrustDomainID, + endpointFingerprint: frost.CanonicalJournal.SourceEndpointFingerprint, + operatorFingerprint: frost.CanonicalJournal.SourceOperatorFingerprint, + historySignerKeyHash: frost.CanonicalJournal.SourceIdentity.HistorySignerKeyHash, + storeID: frost.CanonicalJournal.StoreID, + storeFingerprint: frost.CanonicalJournal.StoreFingerprint, + clusterFingerprint: frost.CanonicalJournal.ClusterFingerprint, + }, + { + role: "retained-history-export", + trustDomainID: frost.CanonicalJournal.SourceIdentity.Export.TrustDomainID, + endpointFingerprint: frost.CanonicalJournal.SourceIdentity.Export.EndpointFingerprint, + tlsLeafSPKIHash: frost.CanonicalJournal.SourceIdentity.Export.TLSLeafSPKIHash, + operatorFingerprint: frost.CanonicalJournal.SourceIdentity.Export.OperatorFingerprint, + backendFingerprint: frost.CanonicalJournal.SourceIdentity.Export.BackendServiceFingerprint, + attestationKeyHash: frost.CanonicalJournal.SourceIdentity.Export.AttestationKeyHash, + }, + { + role: "retained-history-verifier", + trustDomainID: frost.CanonicalJournal.SourceIdentity.Verifier.TrustDomainID, + endpointFingerprint: frost.CanonicalJournal.SourceIdentity.Verifier.EndpointFingerprint, + tlsLeafSPKIHash: frost.CanonicalJournal.SourceIdentity.Verifier.TLSLeafSPKIHash, + operatorFingerprint: frost.CanonicalJournal.SourceIdentity.Verifier.OperatorFingerprint, + backendFingerprint: frost.CanonicalJournal.SourceIdentity.Verifier.BackendServiceFingerprint, + attestationKeyHash: frost.CanonicalJournal.SourceIdentity.Verifier.AttestationKeyHash, }, { role: "runtime-handshake", @@ -2586,7 +2836,7 @@ func frostPreSignEndpointIdentitySetHash( }, } hasher := sha256.New() - hasher.Write([]byte("tbtc-frost-endpoint-identity-set-v1\x00")) + hasher.Write([]byte("tbtc-frost-endpoint-identity-set-v3\x00")) for _, entry := range identities { endpointFingerprint, err := frostPreSignParseBytes32( entry.endpointFingerprint, @@ -2604,6 +2854,24 @@ func frostPreSignEndpointIdentitySetHash( frostPreSignWriteHashString(hasher, entry.trustDomainID) hasher.Write(endpointFingerprint[:]) hasher.Write(operatorFingerprint[:]) + for _, value := range []string{ + entry.tlsLeafSPKIHash, + entry.backendFingerprint, + entry.attestationKeyHash, + entry.historySignerKeyHash, + } { + if value == "" { + hasher.Write(make([]byte, 32)) + continue + } + parsed, err := frostPreSignParseBytes32(value) + if err != nil || parsed == [32]byte{} { + return [32]byte{}, fmt.Errorf( + "invalid endpoint identity role hash", + ) + } + hasher.Write(parsed[:]) + } frostPreSignWriteHashString(hasher, entry.storeID) if entry.storeFingerprint == "" { hasher.Write(make([]byte, 32)) diff --git a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go index 65c93477a0..2eedb2bc4a 100644 --- a/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go +++ b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go @@ -12,8 +12,89 @@ func testManifestHex32(value byte) string { return fmt.Sprintf("0x%02x%s", value, strings.Repeat("00", 31)) } +func testFrostRetainedSourceIdentity() ( + tbtc.FrostRetainedGroupHistoryIdentity, + frostPreSignManifestRetainedSourceIdentity, +) { + protocolID := tbtc.FrostRetainedGroupTLSExporterProtocolID() + exportIdentity := tbtc.FrostRetainedGroupEndpointIdentity{ + Schema: "tbtc-frost-retained-group-endpoint-identity/v1", + Role: "retained-history-export", + TrustDomainID: "export.retained.example", + CanonicalEndpoint: "https://export.example:443/history", + CanonicalDNSName: "export.example", + ResolvedDNSName: "export-origin.example", + ResolvedAddressSetHash: [32]byte{0x60}, + TLSLeafSPKIHash: [32]byte{0x61}, + ServiceIdentity: "spiffe://export.retained.example/export", + BackendServiceFingerprint: [32]byte{0x62}, + OperatorFingerprint: [32]byte{0x63}, + AttestationKeyHash: [32]byte{0x64}, + TLSExporterProtocolID: protocolID, + } + exportIdentity.EndpointFingerprint = + tbtc.ComputeFrostRetainedGroupEndpointIdentityFingerprint(exportIdentity) + verifierIdentity := tbtc.FrostRetainedGroupEndpointIdentity{ + Schema: "tbtc-frost-retained-group-endpoint-identity/v1", + Role: "retained-history-verifier", + TrustDomainID: "verifier.retained.example", + CanonicalEndpoint: "https://verifier.example:443/rpc", + CanonicalDNSName: "verifier.example", + ResolvedDNSName: "verifier-origin.example", + ResolvedAddressSetHash: [32]byte{0x65}, + TLSLeafSPKIHash: [32]byte{0x66}, + ServiceIdentity: "spiffe://verifier.retained.example/verifier", + BackendServiceFingerprint: [32]byte{0x67}, + OperatorFingerprint: [32]byte{0x68}, + AttestationKeyHash: [32]byte{0x69}, + TLSExporterProtocolID: protocolID, + } + verifierIdentity.EndpointFingerprint = + tbtc.ComputeFrostRetainedGroupEndpointIdentityFingerprint(verifierIdentity) + identity := tbtc.FrostRetainedGroupHistoryIdentity{ + Schema: "tbtc-frost-retained-group-source-identity/v1", + TrustDomainID: "independent-journal-source", + OperatorFingerprint: exportIdentity.OperatorFingerprint, + HistorySignerKeyHash: [32]byte{0x6a}, + Export: exportIdentity, + Verifier: verifierIdentity, + } + identity.EndpointFingerprint = + tbtc.ComputeFrostRetainedGroupSourceEndpointFingerprint(identity) + toEndpoint := func( + value tbtc.FrostRetainedGroupEndpointIdentity, + ) frostPreSignManifestRetainedEndpointIdentity { + return frostPreSignManifestRetainedEndpointIdentity{ + Schema: value.Schema, + Role: value.Role, + TrustDomainID: value.TrustDomainID, + CanonicalEndpoint: value.CanonicalEndpoint, + CanonicalDNSName: value.CanonicalDNSName, + ResolvedDNSName: value.ResolvedDNSName, + ResolvedAddressSetHash: fmt.Sprintf("0x%x", value.ResolvedAddressSetHash), + TLSLeafSPKIHash: fmt.Sprintf("0x%x", value.TLSLeafSPKIHash), + ServiceIdentity: value.ServiceIdentity, + BackendServiceFingerprint: fmt.Sprintf("0x%x", value.BackendServiceFingerprint), + OperatorFingerprint: fmt.Sprintf("0x%x", value.OperatorFingerprint), + AttestationKeyHash: fmt.Sprintf("0x%x", value.AttestationKeyHash), + TLSExporterProtocolID: fmt.Sprintf("0x%x", value.TLSExporterProtocolID), + EndpointFingerprint: fmt.Sprintf("0x%x", value.EndpointFingerprint), + } + } + return identity, frostPreSignManifestRetainedSourceIdentity{ + Schema: identity.Schema, + TrustDomainID: identity.TrustDomainID, + EndpointFingerprint: fmt.Sprintf("0x%x", identity.EndpointFingerprint), + OperatorFingerprint: fmt.Sprintf("0x%x", identity.OperatorFingerprint), + HistorySignerKeyHash: fmt.Sprintf("0x%x", identity.HistorySignerKeyHash), + Export: toEndpoint(identity.Export), + Verifier: toEndpoint(identity.Verifier), + } +} + func testFrostJournalActivationManifest() *frostPreSignActivationManifest { checkpointHash := testManifestHex32(0x02) + sourceIdentity, wireSourceIdentity := testFrostRetainedSourceIdentity() manifest := &frostPreSignActivationManifest{ Schema: frostPreSignManifestVersion, ActivationSequence: 1, @@ -64,8 +145,9 @@ func testFrostJournalActivationManifest() *frostPreSignActivationManifest { Checkpoint: frostPreSignManifestPoint{BlockNumber: 1, BlockHash: testManifestHex32(0x28)}, DescriptorSetHash: testManifestHex32(0x22), SourceTrustDomainID: "independent-journal-source", - SourceEndpointFingerprint: testManifestHex32(0x23), - SourceOperatorFingerprint: testManifestHex32(0x24), + SourceEndpointFingerprint: fmt.Sprintf("0x%x", sourceIdentity.EndpointFingerprint), + SourceOperatorFingerprint: fmt.Sprintf("0x%x", sourceIdentity.OperatorFingerprint), + SourceIdentity: wireSourceIdentity, MinimumGeneration: 7, }, QuarantineJournal: frostPreSignManifestQuarantineJournal{ @@ -130,7 +212,7 @@ func TestValidateFrostPreSignActivationManifest_CanonicalJournal(t *testing.T) { manifest.FrostSigner.CanonicalJournal.SourceEndpointFingerprint = manifest.Ethereum.SourceEndpointFingerprint if err := validateFrostPreSignActivationManifest(manifest); err == nil || - !strings.Contains(err.Error(), "not independent") { + !strings.Contains(err.Error(), "differs from its aggregate") { t.Fatalf("expected independent-source validation failure, got [%v]", err) } }) @@ -207,6 +289,83 @@ func TestValidateFrostPreSignActivationManifest_CanonicalJournal(t *testing.T) { t.Fatalf("expected native anchor authority failure, got [%v]", err) } }) + t.Run("history signer aliases runtime attestation", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.AttestationSignerKeyHash = + manifest.FrostSigner.CanonicalJournal.SourceIdentity. + HistorySignerKeyHash + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "retained history signer") { + t.Fatalf("expected history-signer role alias rejection, got [%v]", err) + } + }) + t.Run("retained TLS leaf aliases activation authority", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + leaf, err := frostPreSignParseBytes32( + manifest.FrostSigner.CanonicalJournal.SourceIdentity.Export. + TLSLeafSPKIHash, + ) + if err != nil { + t.Fatal(err) + } + manifest.activationAuthorityKeyHash = leaf + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "retained export TLS leaf") { + t.Fatalf("expected retained-leaf role alias rejection, got [%v]", err) + } + }) + t.Run("retained backend aliases primary verifier", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.Ethereum.VerifierOperatorFingerprint = + manifest.FrostSigner.CanonicalJournal.SourceIdentity.Export. + BackendServiceFingerprint + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "retained export backend") { + t.Fatalf("expected retained-backend role alias rejection, got [%v]", err) + } + }) + t.Run("outer operator roles alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.Ethereum.VerifierOperatorFingerprint = + manifest.Ethereum.SourceOperatorFingerprint + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "aliases") { + t.Fatalf("expected outer-role alias rejection, got [%v]", err) + } + }) + t.Run("activation authority aliases outer role", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + operator, err := frostPreSignParseBytes32( + manifest.Ethereum.SourceOperatorFingerprint, + ) + if err != nil { + t.Fatal(err) + } + manifest.activationAuthorityKeyHash = operator + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "activation authority") { + t.Fatalf("expected activation/outer alias rejection, got [%v]", err) + } + }) + t.Run("outer trust domains alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.Ethereum.VerifierTrustDomainID = + manifest.Ethereum.SourceTrustDomainID + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "trust domain aliases") { + t.Fatalf("expected outer trust-domain alias rejection, got [%v]", err) + } + }) + t.Run("retained nested trust domain aliases outer role", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.Ethereum.SourceTrustDomainID = + manifest.FrostSigner.CanonicalJournal.SourceIdentity.Export. + TrustDomainID + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "trust domain aliases") { + t.Fatalf("expected nested/outer trust-domain alias rejection, got [%v]", err) + } + }) } func TestValidateFrostPreSignActivationManifest_QuarantineAuthoritySets( @@ -284,6 +443,16 @@ func TestValidateFrostPreSignActivationManifest_QuarantineAuthoritySets( t.Fatalf("expected checkpoint-role alias rejection, got [%v]", err) } }) + t.Run("retained backend role alias", func(t *testing.T) { + manifest := testFrostJournalActivationManifest() + manifest.FrostSigner.QuarantineJournal.LiftAuthorities[0]. + PublicKeySPKIHash = manifest.FrostSigner.CanonicalJournal. + SourceIdentity.Verifier.BackendServiceFingerprint + if err := validateFrostPreSignActivationManifest(manifest); err == nil || + !strings.Contains(err.Error(), "retained verifier backend") { + t.Fatalf("expected retained-backend authority alias rejection, got [%v]", err) + } + }) t.Run("protocol identity alias", func(t *testing.T) { manifest := testFrostJournalActivationManifest() manifest.FrostSigner.QuarantineJournal.LiftProtocolID = diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 2f502e841b..4239f24e1c 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -65,18 +65,19 @@ type frostActivationHandshakeRequest struct { } type frostActivationCanonicalJournalState struct { - StoreID string `json:"storeID"` - StoreFingerprint string `json:"storeFingerprint"` - ClusterFingerprint string `json:"clusterFingerprint"` - BindingHash string `json:"bindingHash"` - Checkpoint frostActivationEthereumPoint `json:"checkpoint"` - Current frostActivationEthereumPoint `json:"current"` - DescriptorSetHash string `json:"descriptorSetHash"` - SourceTrustDomainID string `json:"sourceTrustDomainID"` - SourceEndpointFingerprint string `json:"sourceEndpointFingerprint"` - SourceOperatorFingerprint string `json:"sourceOperatorFingerprint"` - Generation uint64 `json:"generation"` - Complete bool `json:"complete"` + StoreID string `json:"storeID"` + StoreFingerprint string `json:"storeFingerprint"` + ClusterFingerprint string `json:"clusterFingerprint"` + BindingHash string `json:"bindingHash"` + Checkpoint frostActivationEthereumPoint `json:"checkpoint"` + Current frostActivationEthereumPoint `json:"current"` + DescriptorSetHash string `json:"descriptorSetHash"` + SourceTrustDomainID string `json:"sourceTrustDomainID"` + SourceEndpointFingerprint string `json:"sourceEndpointFingerprint"` + SourceOperatorFingerprint string `json:"sourceOperatorFingerprint"` + SourceIdentity frostRetainedGroupWireIdentity `json:"sourceIdentity"` + Generation uint64 `json:"generation"` + Complete bool `json:"complete"` } type frostActivationWalletGroupInventory struct { @@ -1028,6 +1029,7 @@ func (fahe *frostActivationHandshakeExporter) attest( SourceTrustDomainID: journalManifest.SourceTrustDomainID, SourceEndpointFingerprint: frostActivationHex32(journalManifest.SourceEndpointFingerprint), SourceOperatorFingerprint: frostActivationHex32(journalManifest.SourceOperatorFingerprint), + SourceIdentity: frostRetainedGroupIdentityToWire(journalManifest.SourceIdentity), Generation: journalSnapshot.SnapshotGeneration, Complete: true, }, diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index dbea942ac8..d2f3df1edd 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -340,11 +340,7 @@ func (source *testFrostRetainedGroupHistorySource) FrostRetainedGroupProtocolBin func (source *testFrostRetainedGroupHistorySource) Identity( context.Context, ) (FrostRetainedGroupHistoryIdentity, error) { - return FrostRetainedGroupHistoryIdentity{ - TrustDomainID: source.manifest.SourceTrustDomainID, - EndpointFingerprint: source.manifest.SourceEndpointFingerprint, - OperatorFingerprint: source.manifest.SourceOperatorFingerprint, - }, nil + return source.manifest.SourceIdentity, nil } func (source *testFrostRetainedGroupHistorySource) FinalizedHead( @@ -597,7 +593,7 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { assertFrostActivationObjectKeys(t, handshake.Payload.State.CanonicalJournal, []string{ "bindingHash", "checkpoint", "clusterFingerprint", "complete", "current", "descriptorSetHash", "generation", "sourceEndpointFingerprint", "sourceOperatorFingerprint", - "sourceTrustDomainID", "storeFingerprint", "storeID", + "sourceIdentity", "sourceTrustDomainID", "storeFingerprint", "storeID", }) assertFrostActivationObjectKeys(t, handshake.Payload.State.QuarantineJournal, []string{ "activeRoot", "clusterFingerprint", "complete", "currentQuarantineCount", @@ -1407,6 +1403,7 @@ func testFrostActivationRuntimeManifest( ) FrostPreSignActivationRuntimeManifest { checkpointAuthorities, _, _ := testFrostActivationCheckpointCredentials() + sourceIdentity := testFrostRetainedGroupCompleteIdentity() return FrostPreSignActivationRuntimeManifest{ ManifestHash: [32]byte{0x10}, ActivationAuthorityKeyHash: [32]byte{0x30}, @@ -1436,9 +1433,10 @@ func testFrostActivationRuntimeManifest( BlockHash: [32]byte{0x20}, }, DescriptorSetHash: [32]byte{0x21}, - SourceTrustDomainID: "independent-journal-source", - SourceEndpointFingerprint: [32]byte{0x22}, - SourceOperatorFingerprint: [32]byte{0x23}, + SourceTrustDomainID: sourceIdentity.TrustDomainID, + SourceEndpointFingerprint: sourceIdentity.EndpointFingerprint, + SourceOperatorFingerprint: sourceIdentity.OperatorFingerprint, + SourceIdentity: sourceIdentity, MinimumGeneration: 1, }, QuarantineJournal: FrostRetainedGroupQuarantineJournalManifest{ @@ -1760,6 +1758,7 @@ func testFrostRetainedGroupJournal( SourceTrustDomainID: manifest.CanonicalJournal.SourceTrustDomainID, SourceEndpointFingerprint: manifest.CanonicalJournal.SourceEndpointFingerprint, SourceOperatorFingerprint: manifest.CanonicalJournal.SourceOperatorFingerprint, + SourceIdentity: manifest.CanonicalJournal.SourceIdentity, }, quarantineMetadata: frostRetainedGroupQuarantineMetadata{ Schema: frostRetainedGroupQuarantineMetadataSchema, diff --git a/pkg/tbtc/frost_retained_group_endpoint_identity.go b/pkg/tbtc/frost_retained_group_endpoint_identity.go new file mode 100644 index 0000000000..49d470dd60 --- /dev/null +++ b/pkg/tbtc/frost_retained_group_endpoint_identity.go @@ -0,0 +1,2070 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "hash" + "io" + "net" + "net/http" + "net/http/httptrace" + "net/netip" + "net/url" + "path" + "sort" + "strconv" + "strings" + "time" +) + +const ( + frostRetainedGroupEndpointIdentitySchema = "tbtc-frost-retained-group-endpoint-identity/v1" + frostRetainedGroupSourceIdentitySchema = "tbtc-frost-retained-group-source-identity/v1" + frostRetainedGroupEndpointIdentityDomain = "tbtc-frost-retained-group-endpoint-identity/v1\x00" + frostRetainedGroupSourceIdentityDomain = "tbtc-frost-retained-group-source-identity/v1\x00" + frostRetainedGroupResolvedAddressSetDomain = "tbtc-frost-retained-group-resolved-address-set/v1\x00" + frostRetainedGroupTransportAttestationSchema = "tbtc-frost-retained-group-transport-attestation/v1" + frostRetainedGroupTransportAttestationDomain = "tbtc-frost-retained-group-transport-attestation/v1\x00" + frostRetainedGroupBackendAttestationDomain = "tbtc-frost-retained-group-backend-attestation/v1\x00" + frostRetainedGroupOperatorAttestationDomain = "tbtc-frost-retained-group-operator-attestation/v1\x00" + frostRetainedGroupTLSExporterProtocolDomain = "tbtc-frost-retained-group-tls-exporter-protocol/v1\x00" + frostRetainedGroupTLSExporterContextDomain = "tbtc-frost-retained-group-tls-exporter-context/v1\x00" + frostRetainedGroupTLSExporterValueDomain = "tbtc-frost-retained-group-tls-exporter-value/v1\x00" + frostRetainedGroupTLSExporterLabel = "EXPORTER-tbtc-frost-retained-group-v1" + frostRetainedGroupTransportAttestationHeader = "Tbtc-Retained-Transport-Attestation" + frostRetainedGroupTransportChallengeHeader = "Tbtc-Retained-Transport-Challenge" + frostRetainedGroupMaximumTransportAttestationBytes = 16 * 1024 + frostRetainedGroupMaximumTransportBodyBytes = 16 * 1024 * 1024 + frostRetainedGroupMaximumResolvedAddresses = 16 + frostRetainedGroupTransportAttestationLifetime = 30 * time.Second + frostRetainedGroupTransportClockSkew = 5 * time.Second +) + +// FrostRetainedGroupEndpointIdentity is one manifest-authenticated endpoint +// role. EndpointFingerprint is derived from every other field by the frozen +// v1 transcript; it is never an operator-authored opaque label. +type FrostRetainedGroupEndpointIdentity struct { + Schema string `json:"schema"` + Role string `json:"role"` + TrustDomainID string `json:"trustDomainID"` + CanonicalEndpoint string `json:"canonicalEndpoint"` + CanonicalDNSName string `json:"canonicalDNSName"` + ResolvedDNSName string `json:"resolvedDNSName"` + ResolvedAddressSetHash [32]byte `json:"resolvedAddressSetHash"` + TLSLeafSPKIHash [32]byte `json:"tlsLeafSpkiHash"` + ServiceIdentity string `json:"serviceIdentity"` + BackendServiceFingerprint [32]byte `json:"backendServiceFingerprint"` + OperatorFingerprint [32]byte `json:"operatorFingerprint"` + AttestationKeyHash [32]byte `json:"attestationKeyHash"` + TLSExporterProtocolID [32]byte `json:"tlsExporterProtocolID"` + EndpointFingerprint [32]byte `json:"endpointFingerprint"` +} + +// FrostRetainedGroupHistoryIdentity is the complete export/verifier trust +// boundary committed by the signed activation manifest. +type FrostRetainedGroupHistoryIdentity struct { + Schema string `json:"schema"` + TrustDomainID string `json:"trustDomainID"` + EndpointFingerprint [32]byte `json:"endpointFingerprint"` + OperatorFingerprint [32]byte `json:"operatorFingerprint"` + HistorySignerKeyHash [32]byte `json:"historySignerKeyHash"` + Export FrostRetainedGroupEndpointIdentity `json:"export"` + Verifier FrostRetainedGroupEndpointIdentity `json:"verifier"` +} + +type frostRetainedGroupWireEndpointIdentity struct { + Schema string `json:"schema"` + Role string `json:"role"` + TrustDomainID string `json:"trustDomainID"` + CanonicalEndpoint string `json:"canonicalEndpoint"` + CanonicalDNSName string `json:"canonicalDNSName"` + ResolvedDNSName string `json:"resolvedDNSName"` + ResolvedAddressSetHash string `json:"resolvedAddressSetHash"` + TLSLeafSPKIHash string `json:"tlsLeafSpkiHash"` + ServiceIdentity string `json:"serviceIdentity"` + BackendServiceFingerprint string `json:"backendServiceFingerprint"` + OperatorFingerprint string `json:"operatorFingerprint"` + AttestationKeyHash string `json:"attestationKeyHash"` + TLSExporterProtocolID string `json:"tlsExporterProtocolID"` + EndpointFingerprint string `json:"endpointFingerprint"` +} + +type frostRetainedGroupWireIdentity struct { + Schema string `json:"schema"` + TrustDomainID string `json:"trustDomainID"` + EndpointFingerprint string `json:"endpointFingerprint"` + OperatorFingerprint string `json:"operatorFingerprint"` + HistorySignerKeyHash string `json:"historySignerKeyHash"` + Export frostRetainedGroupWireEndpointIdentity `json:"export"` + Verifier frostRetainedGroupWireEndpointIdentity `json:"verifier"` +} + +type frostRetainedGroupResolvedEndpoint struct { + endpoint *url.URL + canonical string + canonicalDNSName string + resolvedDNSName string + addresses []netip.Addr + addressSetHash [32]byte +} + +type frostRetainedGroupTransportAttestation struct { + Schema string `json:"schema"` + Role string `json:"role"` + EndpointFingerprint string `json:"endpointFingerprint"` + CanonicalEndpoint string `json:"canonicalEndpoint"` + CanonicalDNSName string `json:"canonicalDNSName"` + ResolvedDNSName string `json:"resolvedDNSName"` + ResolvedPeerIP string `json:"resolvedPeerIP"` + TLSLeafSPKIHash string `json:"tlsLeafSpkiHash"` + ServiceIdentity string `json:"serviceIdentity"` + BackendServiceFingerprint string `json:"backendServiceFingerprint"` + OperatorFingerprint string `json:"operatorFingerprint"` + AttestationKeyHash string `json:"attestationKeyHash"` + TLSExporterProtocolID string `json:"tlsExporterProtocolID"` + Challenge string `json:"challenge"` + RequestMethod string `json:"requestMethod"` + RequestTarget string `json:"requestTarget"` + RequestBodySHA256 string `json:"requestBodySha256"` + ResponseStatus uint64 `json:"responseStatus"` + ResponseBodySHA256 string `json:"responseBodySha256"` + IssuedAtUnixMs string `json:"issuedAtUnixMs"` + ExpiresAtUnixMs string `json:"expiresAtUnixMs"` + TLSExporterContextSHA256 string `json:"tlsExporterContextSha256"` + TLSExporterValueSHA256 string `json:"tlsExporterValueSha256"` + BackendSignerPublicKeySPKI string `json:"backendSignerPublicKeySpki"` + BackendSignatureAlgorithm string `json:"backendSignatureAlgorithm"` + BackendSignature string `json:"backendSignature"` + OperatorSignerPublicKeySPKI string `json:"operatorSignerPublicKeySpki"` + OperatorSignatureAlgorithm string `json:"operatorSignatureAlgorithm"` + OperatorSignature string `json:"operatorSignature"` + SignerPublicKeySPKI string `json:"signerPublicKeySpki"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + Signature string `json:"signature"` +} + +type frostRetainedGroupTransportProof struct { + role string + requestDigest [32]byte + responseDigest [32]byte + challenge [32]byte +} + +type frostRetainedGroupTransportProofKey struct{} + +type frostRetainedGroupAttestedRoundTripper struct { + base http.RoundTripper + endpoint frostRetainedGroupResolvedEndpoint + identity FrostRetainedGroupEndpointIdentity + random io.Reader + now func() time.Time + maximumBodyBytes int64 + maximumClockSkew time.Duration + maximumLifetime time.Duration +} + +type frostRetainedGroupValidatedSourceConfig struct { + exportEndpoint frostRetainedGroupResolvedEndpoint + verifierEndpoint frostRetainedGroupResolvedEndpoint + primaryEndpoint frostRetainedGroupResolvedEndpoint + identity FrostRetainedGroupHistoryIdentity + requestTimeout time.Duration + rootCAs *x509.CertPool +} + +type frostRetainedGroupResolver interface { + LookupCNAME(context.Context, string) (string, error) + LookupNetIP(context.Context, string, string) ([]netip.Addr, error) +} + +type frostRetainedGroupIndependenceMonitor struct { + exportEndpoint frostRetainedGroupResolvedEndpoint + verifierEndpoint frostRetainedGroupResolvedEndpoint + primaryEndpoint frostRetainedGroupResolvedEndpoint + resolver frostRetainedGroupResolver + timeout time.Duration +} + +func frostRetainedGroupTLSExporterProtocolID() [32]byte { + return sha256.Sum256([]byte(frostRetainedGroupTLSExporterProtocolDomain)) +} + +// FrostRetainedGroupTLSExporterProtocolID is the compile-time protocol +// identity that every signed manifest endpoint descriptor must commit. +func FrostRetainedGroupTLSExporterProtocolID() [32]byte { + return frostRetainedGroupTLSExporterProtocolID() +} + +// ComputeFrostRetainedGroupEndpointIdentityFingerprint computes the frozen v1 +// endpoint-descriptor transcript. +func ComputeFrostRetainedGroupEndpointIdentityFingerprint( + identity FrostRetainedGroupEndpointIdentity, +) [32]byte { + return computeFrostRetainedGroupEndpointFingerprint(identity) +} + +// ComputeFrostRetainedGroupSourceEndpointFingerprint computes the frozen v1 +// aggregate export/verifier transcript. +func ComputeFrostRetainedGroupSourceEndpointFingerprint( + identity FrostRetainedGroupHistoryIdentity, +) [32]byte { + return computeFrostRetainedGroupSourceEndpointFingerprint(identity) +} + +// ValidateFrostRetainedGroupHistoryIdentity checks the complete endpoint-role +// separation and every derived transcript. +func ValidateFrostRetainedGroupHistoryIdentity( + identity FrostRetainedGroupHistoryIdentity, +) error { + return validateFrostRetainedGroupHistoryIdentity(identity) +} + +func frostRetainedGroupIdentityTranscript(domain string) *frostRetainedGroupTranscript { + transcript := &frostRetainedGroupTranscript{hasher: sha256.New()} + _, _ = transcript.hasher.Write([]byte(domain)) + return transcript +} + +type frostRetainedGroupTranscript struct { + hasher hash.Hash +} + +func (transcript *frostRetainedGroupTranscript) field( + name string, + value []byte, +) { + buffer := [8]byte{} + binary.BigEndian.PutUint64(buffer[:], uint64(len(name))) + _, _ = transcript.hasher.Write(buffer[:]) + _, _ = transcript.hasher.Write([]byte(name)) + binary.BigEndian.PutUint64(buffer[:], uint64(len(value))) + _, _ = transcript.hasher.Write(buffer[:]) + _, _ = transcript.hasher.Write(value) +} + +func (transcript *frostRetainedGroupTranscript) text(name string, value string) { + transcript.field(name, []byte(value)) +} + +func (transcript *frostRetainedGroupTranscript) bytes32( + name string, + value [32]byte, +) { + transcript.field(name, value[:]) +} + +func (transcript *frostRetainedGroupTranscript) uint64( + name string, + value uint64, +) { + buffer := [8]byte{} + binary.BigEndian.PutUint64(buffer[:], value) + transcript.field(name, buffer[:]) +} + +func (transcript *frostRetainedGroupTranscript) sum() [32]byte { + var result [32]byte + copy(result[:], transcript.hasher.Sum(nil)) + return result +} + +func computeFrostRetainedGroupEndpointFingerprint( + identity FrostRetainedGroupEndpointIdentity, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupEndpointIdentityDomain, + ) + transcript.text("schema", identity.Schema) + transcript.text("role", identity.Role) + transcript.text("trustDomainID", identity.TrustDomainID) + transcript.text("canonicalEndpoint", identity.CanonicalEndpoint) + transcript.text("canonicalDNSName", identity.CanonicalDNSName) + transcript.text("resolvedDNSName", identity.ResolvedDNSName) + transcript.bytes32("resolvedAddressSetHash", identity.ResolvedAddressSetHash) + transcript.bytes32("tlsLeafSpkiHash", identity.TLSLeafSPKIHash) + transcript.text("serviceIdentity", identity.ServiceIdentity) + transcript.bytes32( + "backendServiceFingerprint", + identity.BackendServiceFingerprint, + ) + transcript.bytes32("operatorFingerprint", identity.OperatorFingerprint) + transcript.bytes32("attestationKeyHash", identity.AttestationKeyHash) + transcript.bytes32( + "tlsExporterProtocolID", + identity.TLSExporterProtocolID, + ) + return transcript.sum() +} + +func computeFrostRetainedGroupSourceEndpointFingerprint( + identity FrostRetainedGroupHistoryIdentity, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupSourceIdentityDomain, + ) + transcript.text("schema", identity.Schema) + transcript.text("trustDomainID", identity.TrustDomainID) + transcript.bytes32( + "operatorFingerprint", + identity.OperatorFingerprint, + ) + transcript.bytes32( + "historySignerKeyHash", + identity.HistorySignerKeyHash, + ) + transcript.bytes32( + "exportEndpointFingerprint", + identity.Export.EndpointFingerprint, + ) + transcript.bytes32( + "verifierEndpointFingerprint", + identity.Verifier.EndpointFingerprint, + ) + return transcript.sum() +} + +func frostRetainedGroupEndpointIdentityToWire( + identity FrostRetainedGroupEndpointIdentity, +) frostRetainedGroupWireEndpointIdentity { + return frostRetainedGroupWireEndpointIdentity{ + Schema: identity.Schema, + Role: identity.Role, + TrustDomainID: identity.TrustDomainID, + CanonicalEndpoint: identity.CanonicalEndpoint, + CanonicalDNSName: identity.CanonicalDNSName, + ResolvedDNSName: identity.ResolvedDNSName, + ResolvedAddressSetHash: frostActivationHex32(identity.ResolvedAddressSetHash), + TLSLeafSPKIHash: frostActivationHex32(identity.TLSLeafSPKIHash), + ServiceIdentity: identity.ServiceIdentity, + BackendServiceFingerprint: frostActivationHex32(identity.BackendServiceFingerprint), + OperatorFingerprint: frostActivationHex32(identity.OperatorFingerprint), + AttestationKeyHash: frostActivationHex32(identity.AttestationKeyHash), + TLSExporterProtocolID: frostActivationHex32(identity.TLSExporterProtocolID), + EndpointFingerprint: frostActivationHex32(identity.EndpointFingerprint), + } +} + +func frostRetainedGroupIdentityToWire( + identity FrostRetainedGroupHistoryIdentity, +) frostRetainedGroupWireIdentity { + return frostRetainedGroupWireIdentity{ + Schema: identity.Schema, + TrustDomainID: identity.TrustDomainID, + EndpointFingerprint: frostActivationHex32(identity.EndpointFingerprint), + OperatorFingerprint: frostActivationHex32(identity.OperatorFingerprint), + HistorySignerKeyHash: frostActivationHex32(identity.HistorySignerKeyHash), + Export: frostRetainedGroupEndpointIdentityToWire( + identity.Export, + ), + Verifier: frostRetainedGroupEndpointIdentityToWire( + identity.Verifier, + ), + } +} + +func frostRetainedGroupEndpointIdentityFromWire( + wire frostRetainedGroupWireEndpointIdentity, +) (FrostRetainedGroupEndpointIdentity, error) { + parse := func(name string, value string) ([32]byte, error) { + parsed, err := parseFrostActivationHex32(value) + if err != nil { + return [32]byte{}, fmt.Errorf("invalid %s: [%w]", name, err) + } + return parsed, nil + } + addressSetHash, err := parse( + "resolved address-set hash", + wire.ResolvedAddressSetHash, + ) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + leafHash, err := parse("TLS leaf SPKI hash", wire.TLSLeafSPKIHash) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + backend, err := parse( + "backend service fingerprint", + wire.BackendServiceFingerprint, + ) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + operator, err := parse("operator fingerprint", wire.OperatorFingerprint) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + attestation, err := parse("attestation key hash", wire.AttestationKeyHash) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + exporter, err := parse( + "TLS exporter protocol ID", + wire.TLSExporterProtocolID, + ) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + fingerprint, err := parse( + "endpoint fingerprint", + wire.EndpointFingerprint, + ) + if err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + result := FrostRetainedGroupEndpointIdentity{ + Schema: wire.Schema, + Role: wire.Role, + TrustDomainID: wire.TrustDomainID, + CanonicalEndpoint: wire.CanonicalEndpoint, + CanonicalDNSName: wire.CanonicalDNSName, + ResolvedDNSName: wire.ResolvedDNSName, + ResolvedAddressSetHash: addressSetHash, + TLSLeafSPKIHash: leafHash, + ServiceIdentity: wire.ServiceIdentity, + BackendServiceFingerprint: backend, + OperatorFingerprint: operator, + AttestationKeyHash: attestation, + TLSExporterProtocolID: exporter, + EndpointFingerprint: fingerprint, + } + if err := validateFrostRetainedGroupEndpointIdentity(result); err != nil { + return FrostRetainedGroupEndpointIdentity{}, err + } + return result, nil +} + +func frostRetainedGroupIdentityFromWire( + wire frostRetainedGroupWireIdentity, +) (FrostRetainedGroupHistoryIdentity, error) { + endpointFingerprint, err := parseFrostActivationHex32( + wire.EndpointFingerprint, + ) + if err != nil { + return FrostRetainedGroupHistoryIdentity{}, err + } + operatorFingerprint, err := parseFrostActivationHex32( + wire.OperatorFingerprint, + ) + if err != nil { + return FrostRetainedGroupHistoryIdentity{}, err + } + historySignerKeyHash, err := parseFrostActivationHex32( + wire.HistorySignerKeyHash, + ) + if err != nil { + return FrostRetainedGroupHistoryIdentity{}, err + } + exportIdentity, err := frostRetainedGroupEndpointIdentityFromWire(wire.Export) + if err != nil { + return FrostRetainedGroupHistoryIdentity{}, err + } + verifierIdentity, err := frostRetainedGroupEndpointIdentityFromWire( + wire.Verifier, + ) + if err != nil { + return FrostRetainedGroupHistoryIdentity{}, err + } + result := FrostRetainedGroupHistoryIdentity{ + Schema: wire.Schema, + TrustDomainID: wire.TrustDomainID, + EndpointFingerprint: endpointFingerprint, + OperatorFingerprint: operatorFingerprint, + HistorySignerKeyHash: historySignerKeyHash, + Export: exportIdentity, + Verifier: verifierIdentity, + } + if err := validateFrostRetainedGroupHistoryIdentity(result); err != nil { + return FrostRetainedGroupHistoryIdentity{}, err + } + return result, nil +} + +func validateFrostRetainedGroupEndpointIdentity( + identity FrostRetainedGroupEndpointIdentity, +) error { + if identity.Schema != frostRetainedGroupEndpointIdentitySchema || + (identity.Role != "retained-history-export" && + identity.Role != "retained-history-verifier") || + !validFrostRetainedGroupIdentityLabel(identity.TrustDomainID) || + identity.CanonicalEndpoint == "" || + identity.CanonicalDNSName == "" || + identity.ResolvedDNSName == "" || + identity.ResolvedAddressSetHash == [32]byte{} || + identity.TLSLeafSPKIHash == [32]byte{} || + identity.BackendServiceFingerprint == [32]byte{} || + identity.OperatorFingerprint == [32]byte{} || + identity.AttestationKeyHash == [32]byte{} || + identity.TLSExporterProtocolID != + frostRetainedGroupTLSExporterProtocolID() || + identity.EndpointFingerprint == [32]byte{} { + return fmt.Errorf("retained-group endpoint identity is incomplete") + } + endpoint, canonical, err := validateFrostRetainedGroupTLSEndpoint( + identity.CanonicalEndpoint, + ) + if err != nil || canonical != identity.CanonicalEndpoint || + endpoint.Hostname() != identity.CanonicalDNSName || + !validFrostRetainedGroupEndpointHostname(identity.ResolvedDNSName) { + return fmt.Errorf("retained-group endpoint identity is not canonical") + } + if err := validateFrostRetainedGroupServiceIdentity( + identity.ServiceIdentity, + ); err != nil { + return err + } + serviceIdentity, err := url.Parse(identity.ServiceIdentity) + if err != nil || serviceIdentity.Hostname() != identity.TrustDomainID { + return fmt.Errorf( + "retained-group endpoint trust domain differs from its SPIFFE authority", + ) + } + if computeFrostRetainedGroupEndpointFingerprint(identity) != + identity.EndpointFingerprint { + return fmt.Errorf("retained-group endpoint fingerprint mismatch") + } + roleHashes := map[[32]byte]string{} + for name, value := range map[string][32]byte{ + "TLS leaf": identity.TLSLeafSPKIHash, + "backend service": identity.BackendServiceFingerprint, + "operator": identity.OperatorFingerprint, + "attestation signer": identity.AttestationKeyHash, + } { + if previous, ok := roleHashes[value]; ok { + return fmt.Errorf( + "retained-group endpoint reuses %s identity as %s", + name, + previous, + ) + } + roleHashes[value] = name + } + return nil +} + +func validateFrostRetainedGroupHistoryIdentity( + identity FrostRetainedGroupHistoryIdentity, +) error { + if identity.Schema != frostRetainedGroupSourceIdentitySchema || + !validFrostRetainedGroupIdentityLabel(identity.TrustDomainID) || + identity.EndpointFingerprint == [32]byte{} || + identity.OperatorFingerprint == [32]byte{} || + identity.HistorySignerKeyHash == [32]byte{} || + identity.Export.Role != "retained-history-export" || + identity.Verifier.Role != "retained-history-verifier" || + identity.OperatorFingerprint != identity.Export.OperatorFingerprint { + return fmt.Errorf("retained-group source identity is incomplete") + } + if err := validateFrostRetainedGroupEndpointIdentity(identity.Export); err != nil { + return fmt.Errorf("invalid retained-group export identity: [%w]", err) + } + if err := validateFrostRetainedGroupEndpointIdentity(identity.Verifier); err != nil { + return fmt.Errorf("invalid retained-group verifier identity: [%w]", err) + } + if identity.Export.TrustDomainID == identity.Verifier.TrustDomainID || + identity.TrustDomainID == identity.Export.TrustDomainID || + identity.TrustDomainID == identity.Verifier.TrustDomainID || + identity.Export.CanonicalEndpoint == identity.Verifier.CanonicalEndpoint || + identity.Export.CanonicalDNSName == identity.Verifier.CanonicalDNSName || + identity.Export.ResolvedDNSName == identity.Verifier.ResolvedDNSName || + identity.Export.ResolvedAddressSetHash == + identity.Verifier.ResolvedAddressSetHash || + identity.Export.ServiceIdentity == identity.Verifier.ServiceIdentity { + return fmt.Errorf("retained-group export and verifier identities are aliased") + } + roleHashes := map[[32]byte]string{} + for name, value := range map[string][32]byte{ + "export endpoint": identity.Export.EndpointFingerprint, + "export TLS leaf": identity.Export.TLSLeafSPKIHash, + "export backend": identity.Export.BackendServiceFingerprint, + "export operator": identity.Export.OperatorFingerprint, + "export attestation": identity.Export.AttestationKeyHash, + "history signer": identity.HistorySignerKeyHash, + "verifier endpoint": identity.Verifier.EndpointFingerprint, + "verifier TLS leaf": identity.Verifier.TLSLeafSPKIHash, + "verifier backend": identity.Verifier.BackendServiceFingerprint, + "verifier operator": identity.Verifier.OperatorFingerprint, + "verifier attestation": identity.Verifier.AttestationKeyHash, + } { + if previous, ok := roleHashes[value]; ok { + return fmt.Errorf( + "retained-group source reuses %s identity as %s", + name, + previous, + ) + } + roleHashes[value] = name + } + if computeFrostRetainedGroupSourceEndpointFingerprint(identity) != + identity.EndpointFingerprint { + return fmt.Errorf("retained-group source endpoint fingerprint mismatch") + } + return nil +} + +func validFrostRetainedGroupIdentityLabel(value string) bool { + return value != "" && + value == strings.TrimSpace(value) && + len(value) <= 128 && + !strings.ContainsAny(value, "\x00\r\n\t") +} + +func validateFrostRetainedGroupServiceIdentity(value string) error { + if value == "" || value != strings.TrimSpace(value) || len(value) > 2048 { + return fmt.Errorf("retained-group service identity is invalid") + } + identity, err := url.Parse(value) + if err != nil || identity.Scheme != "spiffe" || + identity.Host == "" || identity.User != nil || + identity.Port() != "" || identity.Host != identity.Hostname() || + !validFrostRetainedGroupSPIFFETrustDomain(identity.Hostname()) || + identity.RawQuery != "" || identity.Fragment != "" || + identity.RawPath != "" || identity.Host != strings.ToLower(identity.Host) || + identity.Path == "" || identity.Path == "/" || + path.Clean(identity.Path) != identity.Path || + strings.HasSuffix(identity.Path, "/") || + strings.Contains(identity.Path, "//") || + identity.String() != value { + return fmt.Errorf("retained-group service identity is not a canonical SPIFFE URI") + } + for _, segment := range strings.Split(strings.TrimPrefix(identity.Path, "/"), "/") { + if segment == "" || segment == "." || segment == ".." { + return fmt.Errorf("retained-group service identity is not a canonical SPIFFE URI") + } + for _, character := range segment { + if (character < 'a' || character > 'z') && + (character < 'A' || character > 'Z') && + (character < '0' || character > '9') && + !strings.ContainsRune("-._", character) { + return fmt.Errorf("retained-group service identity is not a canonical SPIFFE URI") + } + } + } + return nil +} + +func validFrostRetainedGroupSPIFFETrustDomain(value string) bool { + if value == "" || len(value) > 255 || value != strings.ToLower(value) { + return false + } + for _, character := range value { + if (character < 'a' || character > 'z') && + (character < '0' || character > '9') && + !strings.ContainsRune(".-_", character) { + return false + } + } + return true +} + +func validateFrostRetainedGroupTLSEndpoint( + raw string, +) (*url.URL, string, error) { + if raw == "" || raw != strings.TrimSpace(raw) { + return nil, "", fmt.Errorf("URL is empty or has surrounding whitespace") + } + endpoint, err := url.Parse(raw) + if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || + endpoint.User != nil || endpoint.Fragment != "" || + endpoint.RawQuery != "" || endpoint.Opaque != "" || + endpoint.RawPath != "" || strings.Contains(raw, "\\") || + strings.Contains(endpoint.EscapedPath(), "%") { + return nil, "", fmt.Errorf("URL is not an unambiguous HTTPS endpoint") + } + hostname := endpoint.Hostname() + if hostname == "" || hostname != strings.ToLower(hostname) || + strings.HasSuffix(hostname, ".") || + !validFrostRetainedGroupEndpointHostname(hostname) { + return nil, "", fmt.Errorf("URL hostname is not canonical") + } + port := endpoint.Port() + if port == "" { + return nil, "", fmt.Errorf("HTTPS endpoint must use an explicit port") + } + parsedPort, err := strconv.ParseUint(port, 10, 16) + if err != nil || parsedPort == 0 || strconv.FormatUint(parsedPort, 10) != port { + return nil, "", fmt.Errorf("URL port is not canonical") + } + expectedHost := net.JoinHostPort(hostname, port) + if endpoint.Host != expectedHost { + return nil, "", fmt.Errorf("URL authority is not canonical") + } + if endpoint.Path == "" { + endpoint.Path = "/" + } + if !strings.HasPrefix(endpoint.Path, "/") || + path.Clean(endpoint.Path) != endpoint.Path || + (endpoint.Path != "/" && strings.HasSuffix(endpoint.Path, "/")) || + strings.Contains(endpoint.Path, "//") { + return nil, "", fmt.Errorf("URL path is not canonical") + } + canonical := endpoint.String() + if canonical != raw { + return nil, "", fmt.Errorf("URL is not in canonical form") + } + return endpoint, canonical, nil +} + +func validFrostRetainedGroupEndpointHostname(hostname string) bool { + if parsed, err := netip.ParseAddr(hostname); err == nil { + return parsed.Zone() == "" && parsed.String() == hostname + } + if len(hostname) > 253 { + return false + } + labels := strings.Split(hostname, ".") + if len(labels) < 2 { + return false + } + for _, label := range labels { + if len(label) == 0 || len(label) > 63 || + label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, character := range label { + if (character < 'a' || character > 'z') && + (character < '0' || character > '9') && + character != '-' { + return false + } + } + } + return true +} + +func resolveFrostRetainedGroupEndpoint( + ctx context.Context, + endpoint *url.URL, + resolver frostRetainedGroupResolver, +) (frostRetainedGroupResolvedEndpoint, error) { + if ctx == nil || endpoint == nil { + return frostRetainedGroupResolvedEndpoint{}, + fmt.Errorf("retained-group endpoint resolution is incomplete") + } + if resolver == nil { + resolver = net.DefaultResolver + } + hostname := endpoint.Hostname() + addresses := make([]netip.Addr, 0) + canonicalDNSName := hostname + if parsed, err := netip.ParseAddr(hostname); err == nil { + addresses = append(addresses, parsed.Unmap()) + } else { + canonicalName, err := resolver.LookupCNAME(ctx, hostname) + if err != nil { + return frostRetainedGroupResolvedEndpoint{}, + fmt.Errorf("cannot resolve retained-group endpoint CNAME: [%w]", err) + } + canonicalDNSName = strings.TrimSuffix( + strings.ToLower(canonicalName), + ".", + ) + if !validFrostRetainedGroupEndpointHostname(canonicalDNSName) { + return frostRetainedGroupResolvedEndpoint{}, + fmt.Errorf("retained-group endpoint CNAME is not canonical") + } + resolved, err := resolver.LookupNetIP(ctx, "ip", hostname) + if err != nil { + return frostRetainedGroupResolvedEndpoint{}, + fmt.Errorf("cannot resolve retained-group endpoint addresses: [%w]", err) + } + for _, address := range resolved { + if address.IsValid() && address.Zone() == "" { + addresses = append(addresses, address.Unmap()) + } + } + } + addresses = canonicalFrostRetainedGroupAddresses(addresses) + if len(addresses) == 0 || + len(addresses) > frostRetainedGroupMaximumResolvedAddresses { + return frostRetainedGroupResolvedEndpoint{}, + fmt.Errorf("retained-group endpoint address set is invalid") + } + addressSetHash := frostRetainedGroupResolvedAddressSetHash(addresses) + return frostRetainedGroupResolvedEndpoint{ + endpoint: endpoint, + canonical: endpoint.String(), + canonicalDNSName: endpoint.Hostname(), + resolvedDNSName: canonicalDNSName, + addresses: addresses, + addressSetHash: addressSetHash, + }, nil +} + +func validateAndResolveFrostRetainedGroupSourceConfig( + ctx context.Context, + config FrostRetainedGroupHistorySourceConfig, + primaryEthereumURL string, +) (*frostRetainedGroupValidatedSourceConfig, error) { + if ctx == nil { + return nil, fmt.Errorf("retained-group source validation context is nil") + } + requestTimeout := config.RequestTimeout + if requestTimeout == 0 { + requestTimeout = frostRetainedGroupDefaultTimeout + } + if requestTimeout < time.Second || requestTimeout > time.Minute { + return nil, fmt.Errorf( + "retained-group request timeout is outside supported bounds", + ) + } + exportURL, canonicalExport, err := validateFrostRetainedGroupTLSEndpoint( + config.ExportURL, + ) + if err != nil { + return nil, fmt.Errorf("invalid retained-group export URL: [%w]", err) + } + verifierURL, canonicalVerifier, err := validateFrostRetainedGroupTLSEndpoint( + config.EthereumURL, + ) + if err != nil { + return nil, fmt.Errorf("invalid retained-group Ethereum URL: [%w]", err) + } + primaryURL, err := validateFrostRetainedGroupPrimaryEndpoint( + primaryEthereumURL, + ) + if err != nil { + return nil, fmt.Errorf("invalid primary Ethereum URL: [%w]", err) + } + resolver := config.Resolver + if resolver == nil { + resolver = net.DefaultResolver + } + resolveContext, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + exportEndpoint, err := resolveFrostRetainedGroupEndpoint( + resolveContext, + exportURL, + resolver, + ) + if err != nil { + return nil, fmt.Errorf("cannot resolve retained-group export URL: [%w]", err) + } + verifierEndpoint, err := resolveFrostRetainedGroupEndpoint( + resolveContext, + verifierURL, + resolver, + ) + if err != nil { + return nil, fmt.Errorf("cannot resolve retained-group Ethereum URL: [%w]", err) + } + primaryEndpoint, err := resolveFrostRetainedGroupEndpoint( + resolveContext, + primaryURL, + resolver, + ) + if err != nil { + return nil, fmt.Errorf("cannot resolve primary Ethereum URL: [%w]", err) + } + if frostRetainedGroupEndpointSetsOverlap(exportEndpoint, verifierEndpoint) { + return nil, fmt.Errorf( + "retained-group exporter and Ethereum verifier resolve to an alias or shared backend", + ) + } + if frostRetainedGroupEndpointSetsOverlap(exportEndpoint, primaryEndpoint) || + frostRetainedGroupEndpointSetsOverlap(verifierEndpoint, primaryEndpoint) { + return nil, fmt.Errorf( + "retained-group source is not independent of the primary endpoint", + ) + } + for name, value := range map[string]string{ + "source trust domain": config.TrustDomainID, + "export trust domain": config.ExportTrustDomainID, + "verifier trust domain": config.EthereumTrustDomainID, + } { + if !validFrostRetainedGroupIdentityLabel(value) { + return nil, fmt.Errorf("retained-group %s is invalid", name) + } + } + if config.TrustDomainID == config.ExportTrustDomainID || + config.TrustDomainID == config.EthereumTrustDomainID || + config.ExportTrustDomainID == config.EthereumTrustDomainID { + return nil, fmt.Errorf("retained-group trust-domain identities are aliased") + } + if err := validateFrostRetainedGroupServiceIdentity( + config.ExportServiceIdentity, + ); err != nil { + return nil, fmt.Errorf("invalid retained-group export service identity: [%w]", err) + } + if err := validateFrostRetainedGroupServiceIdentity( + config.EthereumServiceIdentity, + ); err != nil { + return nil, fmt.Errorf("invalid retained-group verifier service identity: [%w]", err) + } + if config.ExportServiceIdentity == config.EthereumServiceIdentity { + return nil, fmt.Errorf("retained-group service identities are aliased") + } + parseRequired := func(name string, value string) ([32]byte, error) { + parsed, err := parseFrostActivationHex32(strings.TrimSpace(value)) + if err != nil || parsed == [32]byte{} { + return [32]byte{}, fmt.Errorf("retained-group %s is invalid", name) + } + return parsed, nil + } + exportBackend, err := parseRequired( + "export backend service fingerprint", + config.ExportBackendServiceFingerprint, + ) + if err != nil { + return nil, err + } + verifierBackend, err := parseRequired( + "verifier backend service fingerprint", + config.EthereumBackendServiceFingerprint, + ) + if err != nil { + return nil, err + } + exportOperator, err := parseRequired( + "export operator fingerprint", + config.ExportOperatorFingerprint, + ) + if err != nil { + return nil, err + } + verifierOperator, err := parseRequired( + "verifier operator fingerprint", + config.EthereumOperatorFingerprint, + ) + if err != nil { + return nil, err + } + exportHistorySigner, err := parseRequired( + "export history signer key hash", + config.TrustedSignerKeyHash, + ) + if err != nil { + return nil, err + } + exportAttestation, err := parseRequired( + "export attestation key hash", + config.ExportAttestationKeyHash, + ) + if err != nil { + return nil, err + } + verifierAttestation, err := parseRequired( + "verifier attestation key hash", + config.EthereumAttestationKeyHash, + ) + if err != nil { + return nil, err + } + exportLeaf, err := parseRequired( + "export TLS leaf SPKI hash", + config.ExportTLSLeafSPKIHash, + ) + if err != nil { + return nil, err + } + verifierLeaf, err := parseRequired( + "verifier TLS leaf SPKI hash", + config.EthereumTLSLeafSPKIHash, + ) + if err != nil { + return nil, err + } + allRoleHashes := map[[32]byte]string{} + for name, value := range map[string][32]byte{ + "export TLS leaf": exportLeaf, + "export backend": exportBackend, + "export operator": exportOperator, + "export attestation": exportAttestation, + "export history signer": exportHistorySigner, + "verifier TLS leaf": verifierLeaf, + "verifier backend": verifierBackend, + "verifier operator": verifierOperator, + "verifier attestation": verifierAttestation, + } { + if previous, exists := allRoleHashes[value]; exists { + return nil, fmt.Errorf( + "retained-group %s identity aliases %s", + name, + previous, + ) + } + allRoleHashes[value] = name + } + protocolID := frostRetainedGroupTLSExporterProtocolID() + exportIdentity := FrostRetainedGroupEndpointIdentity{ + Schema: frostRetainedGroupEndpointIdentitySchema, + Role: "retained-history-export", + TrustDomainID: config.ExportTrustDomainID, + CanonicalEndpoint: canonicalExport, + CanonicalDNSName: exportEndpoint.canonicalDNSName, + ResolvedDNSName: exportEndpoint.resolvedDNSName, + ResolvedAddressSetHash: exportEndpoint.addressSetHash, + TLSLeafSPKIHash: exportLeaf, + ServiceIdentity: config.ExportServiceIdentity, + BackendServiceFingerprint: exportBackend, + OperatorFingerprint: exportOperator, + AttestationKeyHash: exportAttestation, + TLSExporterProtocolID: protocolID, + } + exportIdentity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(exportIdentity) + verifierIdentity := FrostRetainedGroupEndpointIdentity{ + Schema: frostRetainedGroupEndpointIdentitySchema, + Role: "retained-history-verifier", + TrustDomainID: config.EthereumTrustDomainID, + CanonicalEndpoint: canonicalVerifier, + CanonicalDNSName: verifierEndpoint.canonicalDNSName, + ResolvedDNSName: verifierEndpoint.resolvedDNSName, + ResolvedAddressSetHash: verifierEndpoint.addressSetHash, + TLSLeafSPKIHash: verifierLeaf, + ServiceIdentity: config.EthereumServiceIdentity, + BackendServiceFingerprint: verifierBackend, + OperatorFingerprint: verifierOperator, + AttestationKeyHash: verifierAttestation, + TLSExporterProtocolID: protocolID, + } + verifierIdentity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(verifierIdentity) + identity := FrostRetainedGroupHistoryIdentity{ + Schema: frostRetainedGroupSourceIdentitySchema, + TrustDomainID: config.TrustDomainID, + OperatorFingerprint: exportOperator, + HistorySignerKeyHash: exportHistorySigner, + Export: exportIdentity, + Verifier: verifierIdentity, + } + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(identity) + if err := validateFrostRetainedGroupHistoryIdentity(identity); err != nil { + return nil, err + } + var roots *x509.CertPool + if config.TLSRootCAs != nil { + roots = config.TLSRootCAs.Clone() + } + return &frostRetainedGroupValidatedSourceConfig{ + exportEndpoint: exportEndpoint, + verifierEndpoint: verifierEndpoint, + primaryEndpoint: primaryEndpoint, + identity: identity, + requestTimeout: requestTimeout, + rootCAs: roots, + }, nil +} + +func validateFrostRetainedGroupPrimaryEndpoint(raw string) (*url.URL, error) { + if raw == "" || raw != strings.TrimSpace(raw) { + return nil, fmt.Errorf("URL is empty or has surrounding whitespace") + } + endpoint, err := url.Parse(raw) + if err != nil || endpoint.Host == "" || endpoint.User != nil || + endpoint.Fragment != "" || endpoint.Opaque != "" { + return nil, fmt.Errorf("URL is not an absolute credential-free endpoint") + } + switch endpoint.Scheme { + case "https", "wss": + if endpoint.Port() == "" { + endpoint.Host = net.JoinHostPort(endpoint.Hostname(), "443") + } + case "http", "ws": + if endpoint.Port() == "" { + endpoint.Host = net.JoinHostPort(endpoint.Hostname(), "80") + } + default: + return nil, fmt.Errorf("URL scheme is unsupported") + } + hostname := endpoint.Hostname() + if hostname == "" || hostname != strings.ToLower(hostname) || + strings.HasSuffix(hostname, ".") || + !validFrostRetainedGroupEndpointHostname(hostname) { + return nil, fmt.Errorf("URL hostname is not canonical") + } + port := endpoint.Port() + parsedPort, err := strconv.ParseUint(port, 10, 16) + if err != nil || parsedPort == 0 { + return nil, fmt.Errorf("URL port is invalid") + } + endpoint.Host = net.JoinHostPort(hostname, strconv.FormatUint(parsedPort, 10)) + if endpoint.Path == "" { + endpoint.Path = "/" + } + return endpoint, nil +} + +func (monitor *frostRetainedGroupIndependenceMonitor) verify( + ctx context.Context, +) error { + if monitor == nil || ctx == nil || + monitor.exportEndpoint.endpoint == nil || + monitor.verifierEndpoint.endpoint == nil || + monitor.primaryEndpoint.endpoint == nil || + monitor.timeout < time.Second || monitor.timeout > time.Minute { + return fmt.Errorf("retained-group endpoint independence monitor is incomplete") + } + resolver := monitor.resolver + if resolver == nil { + resolver = net.DefaultResolver + } + resolveContext, cancel := context.WithTimeout(ctx, monitor.timeout) + defer cancel() + currentPrimary, err := resolveFrostRetainedGroupEndpoint( + resolveContext, + monitor.primaryEndpoint.endpoint, + resolver, + ) + if err != nil { + return fmt.Errorf( + "cannot re-resolve primary Ethereum endpoint: [%w]", + err, + ) + } + if frostRetainedGroupEndpointSetsOverlap( + currentPrimary, + monitor.exportEndpoint, + ) || frostRetainedGroupEndpointSetsOverlap( + currentPrimary, + monitor.verifierEndpoint, + ) { + return fmt.Errorf( + "primary Ethereum endpoint now aliases a retained-group endpoint", + ) + } + return nil +} + +func canonicalFrostRetainedGroupAddresses( + addresses []netip.Addr, +) []netip.Addr { + unique := make(map[netip.Addr]bool) + for _, address := range addresses { + if address.IsValid() && address.Zone() == "" { + unique[address.Unmap()] = true + } + } + result := make([]netip.Addr, 0, len(unique)) + for address := range unique { + result = append(result, address) + } + sort.Slice(result, func(left int, right int) bool { + return result[left].Compare(result[right]) < 0 + }) + return result +} + +func frostRetainedGroupResolvedAddressSetHash( + addresses []netip.Addr, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupResolvedAddressSetDomain, + ) + canonical := canonicalFrostRetainedGroupAddresses(addresses) + transcript.uint64("addressCount", uint64(len(canonical))) + for index, address := range canonical { + transcript.text( + fmt.Sprintf("address[%d]", index), + address.String(), + ) + } + return transcript.sum() +} + +func frostRetainedGroupEndpointSetsOverlap( + left frostRetainedGroupResolvedEndpoint, + right frostRetainedGroupResolvedEndpoint, +) bool { + if left.canonicalDNSName == right.canonicalDNSName || + left.resolvedDNSName == right.resolvedDNSName || + left.canonicalDNSName == right.resolvedDNSName || + left.resolvedDNSName == right.canonicalDNSName { + return true + } + addresses := make(map[netip.Addr]bool, len(left.addresses)) + for _, address := range left.addresses { + addresses[address] = true + } + for _, address := range right.addresses { + if addresses[address] { + return true + } + } + return false +} + +func frostRetainedGroupPinnedDialContext( + endpoint frostRetainedGroupResolvedEndpoint, + timeout time.Duration, +) func(context.Context, string, string) (net.Conn, error) { + dialer := &net.Dialer{ + Timeout: timeout, + KeepAlive: -1, + } + return func( + ctx context.Context, + network string, + address string, + ) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil || + host != endpoint.endpoint.Hostname() || + port != endpoint.endpoint.Port() { + return nil, fmt.Errorf( + "retained-group transport attempted an unpinned endpoint", + ) + } + var lastErr error + for _, pinned := range endpoint.addresses { + connection, err := dialer.DialContext( + ctx, + network, + net.JoinHostPort(pinned.String(), port), + ) + if err == nil { + return connection, nil + } + lastErr = err + } + return nil, fmt.Errorf( + "cannot connect any pinned retained-group endpoint address: [%w]", + lastErr, + ) + } +} + +func newFrostRetainedGroupPinnedTLSConfig( + identity FrostRetainedGroupEndpointIdentity, + rootCAs *x509.CertPool, +) (*tls.Config, error) { + if err := validateFrostRetainedGroupEndpointIdentity(identity); err != nil { + return nil, err + } + var roots *x509.CertPool + if rootCAs != nil { + roots = rootCAs.Clone() + } + return &tls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + ServerName: identity.CanonicalDNSName, + RootCAs: roots, + NextProtos: []string{"http/1.1"}, + VerifyConnection: func(state tls.ConnectionState) error { + return verifyFrostRetainedGroupTLSConnection(state, identity) + }, + }, nil +} + +func newFrostRetainedGroupAttestedHTTPClient( + endpoint frostRetainedGroupResolvedEndpoint, + identity FrostRetainedGroupEndpointIdentity, + rootCAs *x509.CertPool, + timeout time.Duration, +) (*http.Client, *http.Transport, error) { + if endpoint.endpoint == nil || endpoint.canonical != identity.CanonicalEndpoint || + endpoint.canonicalDNSName != identity.CanonicalDNSName || + endpoint.resolvedDNSName != identity.ResolvedDNSName || + endpoint.addressSetHash != identity.ResolvedAddressSetHash { + return nil, nil, fmt.Errorf( + "retained-group transport endpoint differs from its identity", + ) + } + tlsConfig, err := newFrostRetainedGroupPinnedTLSConfig(identity, rootCAs) + if err != nil { + return nil, nil, err + } + transport := &http.Transport{ + Proxy: nil, + DialContext: frostRetainedGroupPinnedDialContext(endpoint, timeout), + DisableKeepAlives: true, + DisableCompression: true, + ForceAttemptHTTP2: false, + MaxConnsPerHost: 1, + ResponseHeaderTimeout: timeout, + TLSHandshakeTimeout: timeout, + ExpectContinueTimeout: time.Second, + MaxResponseHeaderBytes: 32 * 1024, + TLSClientConfig: tlsConfig, + } + attested := &frostRetainedGroupAttestedRoundTripper{ + base: transport, + endpoint: endpoint, + identity: identity, + maximumBodyBytes: frostRetainedGroupMaximumTransportBodyBytes, + maximumClockSkew: frostRetainedGroupTransportClockSkew, + maximumLifetime: frostRetainedGroupTransportAttestationLifetime, + } + client := &http.Client{ + Transport: attested, + Timeout: timeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return fmt.Errorf("retained-group redirects are forbidden") + }, + } + return client, transport, nil +} + +func verifyFrostRetainedGroupTLSConnection( + state tls.ConnectionState, + identity FrostRetainedGroupEndpointIdentity, +) error { + if len(state.VerifiedChains) == 0 || len(state.PeerCertificates) == 0 { + return fmt.Errorf("retained-group TLS peer is not PKIX-verified") + } + if state.Version != tls.VersionTLS13 || + state.NegotiatedProtocol != "http/1.1" { + return fmt.Errorf("retained-group TLS protocol profile mismatch") + } + leaf := state.PeerCertificates[0] + if !leaf.BasicConstraintsValid || leaf.IsCA || + leaf.KeyUsage&x509.KeyUsageDigitalSignature == 0 || + leaf.KeyUsage&(x509.KeyUsageCertSign|x509.KeyUsageCRLSign) != 0 { + return fmt.Errorf("retained-group TLS leaf is not an X.509-SVID leaf") + } + if len(leaf.ExtKeyUsage) > 0 { + hasServerAuth := false + hasClientAuth := false + for _, usage := range leaf.ExtKeyUsage { + hasServerAuth = hasServerAuth || usage == x509.ExtKeyUsageServerAuth + hasClientAuth = hasClientAuth || usage == x509.ExtKeyUsageClientAuth + } + if !hasServerAuth || !hasClientAuth { + return fmt.Errorf("retained-group TLS leaf has incomplete X.509-SVID EKU") + } + } + if sha256.Sum256(leaf.RawSubjectPublicKeyInfo) != + identity.TLSLeafSPKIHash { + return fmt.Errorf("retained-group TLS leaf SPKI mismatch") + } + if len(leaf.URIs) != 1 || leaf.URIs[0].String() != identity.ServiceIdentity || + validateFrostRetainedGroupServiceIdentity( + leaf.URIs[0].String(), + ) != nil { + return fmt.Errorf("retained-group TLS service identity mismatch") + } + return nil +} + +func frostRetainedGroupAttestationTranscript( + attestation frostRetainedGroupTransportAttestation, +) ([32]byte, error) { + parse := func(name string, value string) ([32]byte, error) { + parsed, err := parseFrostActivationHex32(value) + if err != nil { + return [32]byte{}, fmt.Errorf("invalid %s: [%w]", name, err) + } + return parsed, nil + } + endpoint, err := parse("endpoint fingerprint", attestation.EndpointFingerprint) + if err != nil { + return [32]byte{}, err + } + addressSet := map[string]string{ + "tlsLeafSpkiHash": attestation.TLSLeafSPKIHash, + "backendServiceFingerprint": attestation.BackendServiceFingerprint, + "operatorFingerprint": attestation.OperatorFingerprint, + "attestationKeyHash": attestation.AttestationKeyHash, + "tlsExporterProtocolID": attestation.TLSExporterProtocolID, + "challenge": attestation.Challenge, + "requestBodySha256": attestation.RequestBodySHA256, + "responseBodySha256": attestation.ResponseBodySHA256, + "tlsExporterContextSha256": attestation.TLSExporterContextSHA256, + "tlsExporterValueSha256": attestation.TLSExporterValueSHA256, + } + parsed := make(map[string][32]byte, len(addressSet)) + for name, value := range addressSet { + parsed[name], err = parse(name, value) + if err != nil { + return [32]byte{}, err + } + } + issued, err := parseFrostRetainedGroupUint64(attestation.IssuedAtUnixMs) + if err != nil { + return [32]byte{}, err + } + expires, err := parseFrostRetainedGroupUint64(attestation.ExpiresAtUnixMs) + if err != nil { + return [32]byte{}, err + } + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupTransportAttestationDomain, + ) + transcript.text("schema", attestation.Schema) + transcript.text("role", attestation.Role) + transcript.bytes32("endpointFingerprint", endpoint) + transcript.text("canonicalEndpoint", attestation.CanonicalEndpoint) + transcript.text("canonicalDNSName", attestation.CanonicalDNSName) + transcript.text("resolvedDNSName", attestation.ResolvedDNSName) + transcript.text("resolvedPeerIP", attestation.ResolvedPeerIP) + transcript.bytes32("tlsLeafSpkiHash", parsed["tlsLeafSpkiHash"]) + transcript.text("serviceIdentity", attestation.ServiceIdentity) + transcript.bytes32( + "backendServiceFingerprint", + parsed["backendServiceFingerprint"], + ) + transcript.bytes32( + "operatorFingerprint", + parsed["operatorFingerprint"], + ) + transcript.bytes32("attestationKeyHash", parsed["attestationKeyHash"]) + transcript.bytes32( + "tlsExporterProtocolID", + parsed["tlsExporterProtocolID"], + ) + transcript.bytes32("challenge", parsed["challenge"]) + transcript.text("requestMethod", attestation.RequestMethod) + transcript.text("requestTarget", attestation.RequestTarget) + transcript.bytes32("requestBodySha256", parsed["requestBodySha256"]) + transcript.uint64("responseStatus", attestation.ResponseStatus) + transcript.bytes32("responseBodySha256", parsed["responseBodySha256"]) + transcript.uint64("issuedAtUnixMs", issued) + transcript.uint64("expiresAtUnixMs", expires) + transcript.bytes32( + "tlsExporterContextSha256", + parsed["tlsExporterContextSha256"], + ) + transcript.bytes32( + "tlsExporterValueSha256", + parsed["tlsExporterValueSha256"], + ) + return transcript.sum(), nil +} + +func frostRetainedGroupTLSExporterContext( + identity FrostRetainedGroupEndpointIdentity, + challenge [32]byte, + method string, + target string, + requestDigest [32]byte, + responseStatus uint64, + responseDigest [32]byte, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupTLSExporterContextDomain, + ) + transcript.bytes32("endpointFingerprint", identity.EndpointFingerprint) + transcript.bytes32("challenge", challenge) + transcript.text("requestMethod", method) + transcript.text("requestTarget", target) + transcript.bytes32("requestBodySha256", requestDigest) + transcript.uint64("responseStatus", responseStatus) + transcript.bytes32("responseBodySha256", responseDigest) + return transcript.sum() +} + +func frostRetainedGroupTLSExporterValueHash(material []byte) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupTLSExporterValueDomain, + ) + transcript.field("exporterValue", material) + return transcript.sum() +} + +func frostRetainedGroupBackendAttestationDigest( + transportAttestationDigest [32]byte, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupBackendAttestationDomain, + ) + transcript.bytes32( + "transportAttestationDigest", + transportAttestationDigest, + ) + return transcript.sum() +} + +func frostRetainedGroupOperatorAttestationDigest( + transportAttestationDigest [32]byte, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostRetainedGroupOperatorAttestationDomain, + ) + transcript.bytes32( + "transportAttestationDigest", + transportAttestationDigest, + ) + return transcript.sum() +} + +func frostRetainedGroupExportKeyingMaterial( + state *tls.ConnectionState, + contextHash [32]byte, +) ([32]byte, error) { + if state == nil || !state.HandshakeComplete { + return [32]byte{}, fmt.Errorf("retained-group response has no completed TLS state") + } + material, err := state.ExportKeyingMaterial( + frostRetainedGroupTLSExporterLabel, + contextHash[:], + 32, + ) + if err != nil { + return [32]byte{}, fmt.Errorf( + "cannot derive retained-group TLS exporter: [%w]", + err, + ) + } + return frostRetainedGroupTLSExporterValueHash(material), nil +} + +func parseFrostRetainedGroupUint64(value string) (uint64, error) { + if value == "" || (len(value) > 1 && value[0] == '0') { + return 0, fmt.Errorf("retained-group uint64 is not canonical") + } + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil || strconv.FormatUint(parsed, 10) != value { + return 0, fmt.Errorf("retained-group uint64 is invalid") + } + return parsed, nil +} + +func (roundTripper *frostRetainedGroupAttestedRoundTripper) RoundTrip( + request *http.Request, +) (*http.Response, error) { + if roundTripper == nil || roundTripper.base == nil || request == nil || + request.URL == nil || request.Method != http.MethodPost { + return nil, fmt.Errorf("retained-group attested transport request is invalid") + } + base := roundTripper.endpoint.endpoint + basePath := strings.TrimSuffix(base.Path, "/") + if request.URL.Scheme != base.Scheme || + request.URL.Host != base.Host || + request.URL.User != nil || + request.URL.Fragment != "" || + request.URL.RawQuery != "" || + request.URL.Opaque != "" || + request.URL.RawPath != "" || + request.URL.Path == "" || + path.Clean(request.URL.Path) != request.URL.Path || + strings.Contains(request.URL.Path, "//") || + (request.URL.Path != base.Path && + !strings.HasPrefix(request.URL.Path, basePath+"/")) || + (request.Host != "" && request.Host != base.Host) { + return nil, fmt.Errorf("retained-group transport target escaped its pinned endpoint") + } + target := request.URL.String() + if request.Header.Get(frostRetainedGroupTransportChallengeHeader) != "" || + request.Header.Get("Accept-Encoding") != "" { + return nil, fmt.Errorf("retained-group transport headers are ambiguous") + } + var requestBodyReader io.Reader = http.NoBody + if request.Body != nil { + requestBodyReader = request.Body + } + body, err := io.ReadAll( + io.LimitReader( + requestBodyReader, + roundTripper.maximumBodyBytes+1, + ), + ) + if err != nil { + return nil, err + } + if int64(len(body)) > roundTripper.maximumBodyBytes { + return nil, fmt.Errorf("retained-group request body is too large") + } + if request.Body != nil { + _ = request.Body.Close() + } + request.Body = io.NopCloser(bytes.NewReader(body)) + request.ContentLength = int64(len(body)) + requestDigest := sha256.Sum256(body) + var challenge [32]byte + randomSource := roundTripper.random + if randomSource == nil { + randomSource = rand.Reader + } + if _, err := io.ReadFull(randomSource, challenge[:]); err != nil { + return nil, fmt.Errorf("cannot create retained-group transport challenge: [%w]", err) + } + request.Header.Set( + frostRetainedGroupTransportChallengeHeader, + hex.EncodeToString(challenge[:]), + ) + request.Header.Set("Accept-Encoding", "identity") + + var remote net.Addr + trace := &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + if info.Conn != nil { + remote = info.Conn.RemoteAddr() + } + }, + } + request = request.WithContext( + httptrace.WithClientTrace(request.Context(), trace), + ) + response, err := roundTripper.base.RoundTrip(request) + if err != nil { + return nil, err + } + bodyLimit := roundTripper.maximumBodyBytes + responseBody, readErr := io.ReadAll( + io.LimitReader(response.Body, bodyLimit+1), + ) + _ = response.Body.Close() + if readErr != nil { + return nil, readErr + } + if int64(len(responseBody)) > bodyLimit { + return nil, fmt.Errorf("retained-group response body is too large") + } + response.Body = io.NopCloser(bytes.NewReader(responseBody)) + response.ContentLength = int64(len(responseBody)) + if response.Uncompressed || + (response.Header.Get("Content-Encoding") != "" && + response.Header.Get("Content-Encoding") != "identity") { + return nil, fmt.Errorf("retained-group response transformation is forbidden") + } + responseDigest := sha256.Sum256(responseBody) + if err := verifyFrostRetainedGroupTransportAttestation( + response, + remote, + roundTripper.endpoint, + roundTripper.identity, + challenge, + request.Method, + target, + requestDigest, + responseDigest, + roundTripper.now, + roundTripper.maximumClockSkew, + roundTripper.maximumLifetime, + ); err != nil { + return nil, err + } + proof := frostRetainedGroupTransportProof{ + role: roundTripper.identity.Role, + requestDigest: requestDigest, + responseDigest: responseDigest, + challenge: challenge, + } + if response.Request == nil { + response.Request = request + } + response.Request = response.Request.Clone( + context.WithValue( + response.Request.Context(), + frostRetainedGroupTransportProofKey{}, + proof, + ), + ) + return response, nil +} + +func verifyFrostRetainedGroupTransportAttestation( + response *http.Response, + remote net.Addr, + endpoint frostRetainedGroupResolvedEndpoint, + identity FrostRetainedGroupEndpointIdentity, + challenge [32]byte, + requestMethod string, + requestTarget string, + requestDigest [32]byte, + responseDigest [32]byte, + now func() time.Time, + maximumClockSkew time.Duration, + maximumLifetime time.Duration, +) error { + if response == nil || response.TLS == nil || !response.TLS.HandshakeComplete || + remote == nil || response.Request == nil { + return fmt.Errorf("retained-group response transport is unauthenticated") + } + if err := verifyFrostRetainedGroupTLSConnection(*response.TLS, identity); err != nil { + return err + } + remoteIP, err := frostRetainedGroupRemoteIP(remote) + if err != nil || !frostRetainedGroupAddressPinned(endpoint.addresses, remoteIP) { + return fmt.Errorf("retained-group response peer is outside the pinned address set") + } + values := response.Header.Values( + frostRetainedGroupTransportAttestationHeader, + ) + if len(values) != 1 || len(values[0]) == 0 || + len(values[0]) > frostRetainedGroupMaximumTransportAttestationBytes { + return fmt.Errorf("retained-group transport attestation header is missing or ambiguous") + } + raw, err := decodeCanonicalFrostRetainedGroupBase64(values[0]) + if err != nil || len(raw) == 0 || + len(raw) > frostRetainedGroupMaximumTransportAttestationBytes { + return fmt.Errorf("retained-group transport attestation encoding is invalid") + } + attestation := frostRetainedGroupTransportAttestation{} + if err := decodeStrictFrostActivationJSON(raw, &attestation); err != nil { + return fmt.Errorf("cannot decode retained-group transport attestation: [%w]", err) + } + issued, issuedErr := parseFrostRetainedGroupUint64( + attestation.IssuedAtUnixMs, + ) + expires, expiresErr := parseFrostRetainedGroupUint64( + attestation.ExpiresAtUnixMs, + ) + if now == nil { + now = time.Now + } + if maximumClockSkew <= 0 { + maximumClockSkew = frostRetainedGroupTransportClockSkew + } + if maximumLifetime <= 0 { + maximumLifetime = frostRetainedGroupTransportAttestationLifetime + } + nowMilliseconds := now().UnixMilli() + maximumInt64 := uint64(^uint64(0) >> 1) + maximumLifetimeMilliseconds := uint64(maximumLifetime / time.Millisecond) + maximumClockSkewMilliseconds := maximumClockSkew.Milliseconds() + if issuedErr != nil || expiresErr != nil || nowMilliseconds < 0 || + issued > maximumInt64 || expires > maximumInt64 || + expires <= issued || + expires-issued > maximumLifetimeMilliseconds { + return fmt.Errorf("retained-group transport attestation is stale") + } + issuedMilliseconds := int64(issued) + expiresMilliseconds := int64(expires) + if (issuedMilliseconds > nowMilliseconds && + issuedMilliseconds-nowMilliseconds > maximumClockSkewMilliseconds) || + (expiresMilliseconds <= nowMilliseconds && + nowMilliseconds-expiresMilliseconds >= maximumClockSkewMilliseconds) { + return fmt.Errorf("retained-group transport attestation is stale") + } + contextHash := frostRetainedGroupTLSExporterContext( + identity, + challenge, + requestMethod, + requestTarget, + requestDigest, + uint64(response.StatusCode), + responseDigest, + ) + exporterValueHash, err := frostRetainedGroupExportKeyingMaterial( + response.TLS, + contextHash, + ) + if err != nil { + return err + } + if attestation.Schema != frostRetainedGroupTransportAttestationSchema || + attestation.Role != identity.Role || + attestation.EndpointFingerprint != + frostActivationHex32(identity.EndpointFingerprint) || + attestation.CanonicalEndpoint != identity.CanonicalEndpoint || + attestation.CanonicalDNSName != identity.CanonicalDNSName || + attestation.ResolvedDNSName != identity.ResolvedDNSName || + attestation.ResolvedPeerIP != remoteIP.String() || + attestation.TLSLeafSPKIHash != + frostActivationHex32(identity.TLSLeafSPKIHash) || + attestation.ServiceIdentity != identity.ServiceIdentity || + attestation.BackendServiceFingerprint != + frostActivationHex32(identity.BackendServiceFingerprint) || + attestation.OperatorFingerprint != + frostActivationHex32(identity.OperatorFingerprint) || + attestation.AttestationKeyHash != + frostActivationHex32(identity.AttestationKeyHash) || + attestation.TLSExporterProtocolID != + frostActivationHex32(identity.TLSExporterProtocolID) || + attestation.Challenge != frostActivationHex32(challenge) || + attestation.RequestMethod != requestMethod || + attestation.RequestTarget != requestTarget || + attestation.RequestBodySHA256 != frostActivationHex32(requestDigest) || + attestation.ResponseStatus != uint64(response.StatusCode) || + attestation.ResponseBodySHA256 != frostActivationHex32(responseDigest) || + attestation.TLSExporterContextSHA256 != + frostActivationHex32(contextHash) || + attestation.TLSExporterValueSHA256 != + frostActivationHex32(exporterValueHash) { + return fmt.Errorf("retained-group transport attestation is differently bound") + } + digest, err := frostRetainedGroupAttestationTranscript(attestation) + if err != nil { + return fmt.Errorf("retained-group transport attestation transcript is invalid: [%w]", err) + } + backendDigest := frostRetainedGroupBackendAttestationDigest(digest) + if err := verifyFrostRetainedGroupEd25519RoleSignature( + "backend", + identity.BackendServiceFingerprint, + attestation.BackendSignerPublicKeySPKI, + attestation.BackendSignatureAlgorithm, + attestation.BackendSignature, + backendDigest, + ); err != nil { + return err + } + operatorDigest := frostRetainedGroupOperatorAttestationDigest(digest) + if err := verifyFrostRetainedGroupEd25519RoleSignature( + "operator", + identity.OperatorFingerprint, + attestation.OperatorSignerPublicKeySPKI, + attestation.OperatorSignatureAlgorithm, + attestation.OperatorSignature, + operatorDigest, + ); err != nil { + return err + } + if err := verifyFrostRetainedGroupEd25519RoleSignature( + "transport attestation", + identity.AttestationKeyHash, + attestation.SignerPublicKeySPKI, + attestation.SignatureAlgorithm, + attestation.Signature, + digest, + ); err != nil { + return err + } + return nil +} + +func verifyFrostRetainedGroupEd25519RoleSignature( + role string, + expectedKeyHash [32]byte, + publicKeySPKI string, + algorithm string, + signatureValue string, + digest [32]byte, +) error { + publicKeyDER, err := decodeCanonicalFrostRetainedGroupBase64( + publicKeySPKI, + ) + if err != nil || len(publicKeyDER) == 0 || len(publicKeyDER) > 1024 || + sha256.Sum256(publicKeyDER) != expectedKeyHash { + return fmt.Errorf("retained-group %s signer is not trusted", role) + } + parsedPublicKey, err := x509.ParsePKIXPublicKey(publicKeyDER) + if err != nil { + return fmt.Errorf( + "cannot parse retained-group %s signer: [%w]", + role, + err, + ) + } + publicKey, ok := parsedPublicKey.(ed25519.PublicKey) + if !ok || algorithm != "ed25519" { + return fmt.Errorf("retained-group %s signer is not Ed25519", role) + } + signature, err := decodeCanonicalFrostRetainedGroupBase64( + signatureValue, + ) + if err != nil || len(signature) != ed25519.SignatureSize || + !ed25519.Verify(publicKey, digest[:], signature) { + return fmt.Errorf("retained-group %s signature is invalid", role) + } + return nil +} + +func decodeCanonicalFrostRetainedGroupBase64(value string) ([]byte, error) { + decoded, err := base64.StdEncoding.Strict().DecodeString(value) + if err != nil || base64.StdEncoding.EncodeToString(decoded) != value { + return nil, fmt.Errorf("retained-group base64 is not canonical") + } + return decoded, nil +} + +func frostRetainedGroupRemoteIP(address net.Addr) (netip.Addr, error) { + host, _, err := net.SplitHostPort(address.String()) + if err != nil { + return netip.Addr{}, err + } + parsed, err := netip.ParseAddr(host) + if err != nil || parsed.Zone() != "" { + return netip.Addr{}, fmt.Errorf("retained-group peer address is invalid") + } + return parsed.Unmap(), nil +} + +func frostRetainedGroupAddressPinned( + addresses []netip.Addr, + candidate netip.Addr, +) bool { + for _, address := range addresses { + if address == candidate { + return true + } + } + return false +} + +func requireFrostRetainedGroupTransportProof( + response *http.Response, + role string, +) error { + if response == nil || response.Request == nil { + return fmt.Errorf("retained-group response has no transport proof") + } + proof, ok := response.Request.Context().Value( + frostRetainedGroupTransportProofKey{}, + ).(frostRetainedGroupTransportProof) + if !ok || proof.role != role || + proof.requestDigest == [32]byte{} || + proof.responseDigest == [32]byte{} || + proof.challenge == [32]byte{} { + return fmt.Errorf("retained-group response has no valid transport proof") + } + return nil +} + +// marshalFrostRetainedGroupTransportAttestation constructs the exact header +// an independently implemented service must emit. It is intentionally kept +// package-private; production servers should implement the frozen transcript, +// not import signer-client internals. +func marshalFrostRetainedGroupTransportAttestation( + request *http.Request, + responseStatus int, + responseBody []byte, + identity FrostRetainedGroupEndpointIdentity, + attestationPrivateKey ed25519.PrivateKey, + attestationPublicKeyDER []byte, + backendPrivateKey ed25519.PrivateKey, + backendPublicKeyDER []byte, + operatorPrivateKey ed25519.PrivateKey, + operatorPublicKeyDER []byte, + now time.Time, + localIP netip.Addr, +) (string, error) { + if request == nil || request.TLS == nil || + len(attestationPrivateKey) != ed25519.PrivateKeySize || + sha256.Sum256(attestationPublicKeyDER) != identity.AttestationKeyHash || + len(backendPrivateKey) != ed25519.PrivateKeySize || + sha256.Sum256(backendPublicKeyDER) != + identity.BackendServiceFingerprint || + len(operatorPrivateKey) != ed25519.PrivateKeySize || + sha256.Sum256(operatorPublicKeyDER) != identity.OperatorFingerprint || + now.UnixMilli() < 0 || !localIP.IsValid() { + return "", fmt.Errorf("retained-group transport attestation inputs are invalid") + } + challengeBytes, err := hex.DecodeString( + request.Header.Get(frostRetainedGroupTransportChallengeHeader), + ) + if err != nil || len(challengeBytes) != 32 { + return "", fmt.Errorf("retained-group transport challenge is invalid") + } + var challenge [32]byte + copy(challenge[:], challengeBytes) + var requestBodyReader io.Reader = http.NoBody + if request.Body != nil { + requestBodyReader = request.Body + } + requestBody, err := io.ReadAll( + io.LimitReader( + requestBodyReader, + frostRetainedGroupMaximumTransportBodyBytes+1, + ), + ) + if err != nil || len(requestBody) > frostRetainedGroupMaximumTransportBodyBytes { + return "", fmt.Errorf("retained-group transport request body is invalid") + } + request.Body = io.NopCloser(bytes.NewReader(requestBody)) + requestDigest := sha256.Sum256(requestBody) + responseDigest := sha256.Sum256(responseBody) + requestTarget, err := frostRetainedGroupServerRequestTarget(identity, request) + if err != nil { + return "", err + } + contextHash := frostRetainedGroupTLSExporterContext( + identity, + challenge, + request.Method, + requestTarget, + requestDigest, + uint64(responseStatus), + responseDigest, + ) + exporterValueHash, err := frostRetainedGroupExportKeyingMaterial( + request.TLS, + contextHash, + ) + if err != nil { + return "", err + } + issued := uint64(now.UnixMilli()) + expires := issued + uint64( + frostRetainedGroupTransportAttestationLifetime.Milliseconds(), + ) + attestation := frostRetainedGroupTransportAttestation{ + Schema: frostRetainedGroupTransportAttestationSchema, + Role: identity.Role, + EndpointFingerprint: frostActivationHex32(identity.EndpointFingerprint), + CanonicalEndpoint: identity.CanonicalEndpoint, + CanonicalDNSName: identity.CanonicalDNSName, + ResolvedDNSName: identity.ResolvedDNSName, + ResolvedPeerIP: localIP.Unmap().String(), + TLSLeafSPKIHash: frostActivationHex32(identity.TLSLeafSPKIHash), + ServiceIdentity: identity.ServiceIdentity, + BackendServiceFingerprint: frostActivationHex32(identity.BackendServiceFingerprint), + OperatorFingerprint: frostActivationHex32(identity.OperatorFingerprint), + AttestationKeyHash: frostActivationHex32(identity.AttestationKeyHash), + TLSExporterProtocolID: frostActivationHex32(identity.TLSExporterProtocolID), + Challenge: frostActivationHex32(challenge), + RequestMethod: request.Method, + RequestTarget: requestTarget, + RequestBodySHA256: frostActivationHex32(requestDigest), + ResponseStatus: uint64(responseStatus), + ResponseBodySHA256: frostActivationHex32(responseDigest), + IssuedAtUnixMs: strconv.FormatUint(issued, 10), + ExpiresAtUnixMs: strconv.FormatUint(expires, 10), + TLSExporterContextSHA256: frostActivationHex32(contextHash), + TLSExporterValueSHA256: frostActivationHex32(exporterValueHash), + BackendSignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + backendPublicKeyDER, + ), + BackendSignatureAlgorithm: "ed25519", + OperatorSignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + operatorPublicKeyDER, + ), + OperatorSignatureAlgorithm: "ed25519", + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + attestationPublicKeyDER, + ), + SignatureAlgorithm: "ed25519", + } + digest, err := frostRetainedGroupAttestationTranscript(attestation) + if err != nil { + return "", err + } + backendDigest := frostRetainedGroupBackendAttestationDigest(digest) + attestation.BackendSignature = base64.StdEncoding.EncodeToString( + ed25519.Sign(backendPrivateKey, backendDigest[:]), + ) + operatorDigest := frostRetainedGroupOperatorAttestationDigest(digest) + attestation.OperatorSignature = base64.StdEncoding.EncodeToString( + ed25519.Sign(operatorPrivateKey, operatorDigest[:]), + ) + attestation.Signature = base64.StdEncoding.EncodeToString( + ed25519.Sign(attestationPrivateKey, digest[:]), + ) + encoded, err := json.Marshal(attestation) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(encoded), nil +} + +func frostRetainedGroupServerRequestTarget( + identity FrostRetainedGroupEndpointIdentity, + request *http.Request, +) (string, error) { + if request == nil || request.URL == nil { + return "", fmt.Errorf("retained-group server request target is missing") + } + base, _, err := validateFrostRetainedGroupTLSEndpoint( + identity.CanonicalEndpoint, + ) + if err != nil { + return "", err + } + basePath := strings.TrimSuffix(base.Path, "/") + if request.Host != base.Host || + request.URL.RawQuery != "" || request.URL.RawPath != "" || + request.URL.Fragment != "" || request.URL.Opaque != "" || + request.URL.Path == "" || + path.Clean(request.URL.Path) != request.URL.Path || + strings.Contains(request.URL.Path, "//") || + (request.URL.Path != base.Path && + !strings.HasPrefix(request.URL.Path, basePath+"/")) { + return "", fmt.Errorf("retained-group server request escaped its endpoint") + } + base.Path = request.URL.Path + base.RawPath = "" + base.RawQuery = "" + return base.String(), nil +} diff --git a/pkg/tbtc/frost_retained_group_endpoint_identity_test.go b/pkg/tbtc/frost_retained_group_endpoint_identity_test.go new file mode 100644 index 0000000000..6b177e1b45 --- /dev/null +++ b/pkg/tbtc/frost_retained_group_endpoint_identity_test.go @@ -0,0 +1,1153 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/rpc" +) + +func testFrostRetainedGroupCompleteIdentity() FrostRetainedGroupHistoryIdentity { + protocolID := frostRetainedGroupTLSExporterProtocolID() + exportIdentity := FrostRetainedGroupEndpointIdentity{ + Schema: frostRetainedGroupEndpointIdentitySchema, + Role: "retained-history-export", + TrustDomainID: "retained-export.example", + CanonicalEndpoint: "https://retained-export.example:443/history", + CanonicalDNSName: "retained-export.example", + ResolvedDNSName: "retained-export-origin.example", + ResolvedAddressSetHash: [32]byte{0xa1}, + TLSLeafSPKIHash: [32]byte{0xa2}, + ServiceIdentity: "spiffe://retained-export.example/export", + BackendServiceFingerprint: [32]byte{0xa3}, + OperatorFingerprint: [32]byte{0xa4}, + AttestationKeyHash: [32]byte{0xa5}, + TLSExporterProtocolID: protocolID, + } + exportIdentity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(exportIdentity) + verifierIdentity := FrostRetainedGroupEndpointIdentity{ + Schema: frostRetainedGroupEndpointIdentitySchema, + Role: "retained-history-verifier", + TrustDomainID: "retained-verifier.example", + CanonicalEndpoint: "https://retained-verifier.example:443/rpc", + CanonicalDNSName: "retained-verifier.example", + ResolvedDNSName: "retained-verifier-origin.example", + ResolvedAddressSetHash: [32]byte{0xa6}, + TLSLeafSPKIHash: [32]byte{0xa7}, + ServiceIdentity: "spiffe://retained-verifier.example/verifier", + BackendServiceFingerprint: [32]byte{0xa8}, + OperatorFingerprint: [32]byte{0xa9}, + AttestationKeyHash: [32]byte{0xaa}, + TLSExporterProtocolID: protocolID, + } + verifierIdentity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(verifierIdentity) + identity := FrostRetainedGroupHistoryIdentity{ + Schema: frostRetainedGroupSourceIdentitySchema, + TrustDomainID: "independent-journal-source", + OperatorFingerprint: exportIdentity.OperatorFingerprint, + HistorySignerKeyHash: [32]byte{0xab}, + Export: exportIdentity, + Verifier: verifierIdentity, + } + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(identity) + return identity +} + +func TestFrostRetainedGroupIdentityFingerprintsFrozen(t *testing.T) { + identity := testFrostRetainedGroupCompleteIdentity() + addressHash := frostRetainedGroupResolvedAddressSetHash([]netip.Addr{ + netip.MustParseAddr("2001:db8::1"), + netip.MustParseAddr("192.0.2.1"), + netip.MustParseAddr("::ffff:192.0.2.1"), + netip.MustParseAddr("2001:db8::1"), + }) + const expectedExport = "416dd89aa039243ca4b03e8b5c15e54e4dd9b5553ceaf230ad5e978bd3e073d3" + const expectedSource = "d4100a02930b9fd62a78fae2c40316ea8ddd85c2dcffc344a502d5ebdd27d971" + const expectedAddresses = "9bafc074d0b8ca6b0458ed2d25a48a140f5599854952386087530f1217a90d45" + if hex.EncodeToString(identity.Export.EndpointFingerprint[:]) != expectedExport || + hex.EncodeToString(identity.EndpointFingerprint[:]) != expectedSource || + hex.EncodeToString(addressHash[:]) != expectedAddresses { + t.Fatalf( + "frozen identity vectors changed: [%x] [%x] [%x]", + identity.Export.EndpointFingerprint, + identity.EndpointFingerprint, + addressHash, + ) + } +} + +func TestFrostRetainedGroupTransportAttestationFrozenVectors(t *testing.T) { + attestationPrivateKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x01}, 32)) + backendPrivateKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x02}, 32)) + operatorPrivateKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x03}, 32)) + attestationPublicKeyDER, err := x509.MarshalPKIXPublicKey( + attestationPrivateKey.Public(), + ) + if err != nil { + t.Fatal(err) + } + backendPublicKeyDER, err := x509.MarshalPKIXPublicKey( + backendPrivateKey.Public(), + ) + if err != nil { + t.Fatal(err) + } + operatorPublicKeyDER, err := x509.MarshalPKIXPublicKey( + operatorPrivateKey.Public(), + ) + if err != nil { + t.Fatal(err) + } + identity := testFrostRetainedGroupCompleteIdentity().Export + identity.AttestationKeyHash = sha256.Sum256(attestationPublicKeyDER) + identity.BackendServiceFingerprint = sha256.Sum256(backendPublicKeyDER) + identity.OperatorFingerprint = sha256.Sum256(operatorPublicKeyDER) + identity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity) + challenge := [32]byte{0x11} + requestDigest := [32]byte{0x12} + responseDigest := [32]byte{0x13} + contextHash := frostRetainedGroupTLSExporterContext( + identity, + challenge, + http.MethodPost, + identity.CanonicalEndpoint, + requestDigest, + http.StatusOK, + responseDigest, + ) + exporterValueHash := frostRetainedGroupTLSExporterValueHash( + []byte("fixed-exported-keying-material"), + ) + attestation := frostRetainedGroupTransportAttestation{ + Schema: frostRetainedGroupTransportAttestationSchema, + Role: identity.Role, + EndpointFingerprint: frostActivationHex32(identity.EndpointFingerprint), + CanonicalEndpoint: identity.CanonicalEndpoint, + CanonicalDNSName: identity.CanonicalDNSName, + ResolvedDNSName: identity.ResolvedDNSName, + ResolvedPeerIP: "192.0.2.1", + TLSLeafSPKIHash: frostActivationHex32(identity.TLSLeafSPKIHash), + ServiceIdentity: identity.ServiceIdentity, + BackendServiceFingerprint: frostActivationHex32(identity.BackendServiceFingerprint), + OperatorFingerprint: frostActivationHex32(identity.OperatorFingerprint), + AttestationKeyHash: frostActivationHex32(identity.AttestationKeyHash), + TLSExporterProtocolID: frostActivationHex32(identity.TLSExporterProtocolID), + Challenge: frostActivationHex32(challenge), + RequestMethod: http.MethodPost, + RequestTarget: identity.CanonicalEndpoint, + RequestBodySHA256: frostActivationHex32(requestDigest), + ResponseStatus: http.StatusOK, + ResponseBodySHA256: frostActivationHex32(responseDigest), + IssuedAtUnixMs: "1700000000000", + ExpiresAtUnixMs: "1700000030000", + TLSExporterContextSHA256: frostActivationHex32(contextHash), + TLSExporterValueSHA256: frostActivationHex32(exporterValueHash), + BackendSignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + backendPublicKeyDER, + ), + BackendSignatureAlgorithm: "ed25519", + OperatorSignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + operatorPublicKeyDER, + ), + OperatorSignatureAlgorithm: "ed25519", + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + attestationPublicKeyDER, + ), + SignatureAlgorithm: "ed25519", + } + attestationDigest, err := frostRetainedGroupAttestationTranscript(attestation) + if err != nil { + t.Fatal(err) + } + backendDigest := frostRetainedGroupBackendAttestationDigest(attestationDigest) + operatorDigest := frostRetainedGroupOperatorAttestationDigest(attestationDigest) + attestation.BackendSignature = base64.StdEncoding.EncodeToString( + ed25519.Sign(backendPrivateKey, backendDigest[:]), + ) + attestation.OperatorSignature = base64.StdEncoding.EncodeToString( + ed25519.Sign(operatorPrivateKey, operatorDigest[:]), + ) + attestation.Signature = base64.StdEncoding.EncodeToString( + ed25519.Sign(attestationPrivateKey, attestationDigest[:]), + ) + wire, err := json.Marshal(attestation) + if err != nil { + t.Fatal(err) + } + wireHash := sha256.Sum256(wire) + const expectedContext = "50587c477f56e9d2597c4cf4d9cf69d69a8ded13b9a074d1c18042eb4aea2e30" + const expectedExporter = "62e2fd2ccb30b5e2ca49a99f04cbb01a947d8228060ee377dc6896c6c96a08b0" + const expectedAttestation = "53eb0f08ca2c1761592ec90387c5aba9913668ef68c1e4cf6f20dbbd531e267b" + const expectedBackend = "7fb92657871cf2dd864eeffaac5d699f1131f81b04507b2092f90987aa4a722c" + const expectedOperator = "02495d610fde3ad437a22de7e93dae5394d34a0ef4e590f4d613e3fa6197cbc4" + const expectedTransportSignature = "AV8bIwrHUE6ACkJ9vQWtQTVXGJDR8Nm42nQqCka8NISgXIIPbW2abLhOrGlX/bnYVUUUMbhRfcyYCf+asQ9KBg==" + const expectedBackendSignature = "0ACdPMwZaraKgWpVRMNnwc6qgLtB90rGDoj18kYCcbd2jJG36VCVf0j5OhHlqP5KoQbzKwJ9BRelXF1pYd/yDA==" + const expectedOperatorSignature = "lx22S9wJxUSOsgZZlWqP7S5bTdoULktZQ9Ao7KDwdpI8zJDkq3AD76rj737DSthJUt0+6IEdElMyiCnXwdgFBg==" + const expectedWire = "174da49defe9c6b6c669a8a33177ccf6257df24e1316c650734c8ff35099dcdb" + if hex.EncodeToString(contextHash[:]) != expectedContext || + hex.EncodeToString(exporterValueHash[:]) != expectedExporter || + hex.EncodeToString(attestationDigest[:]) != expectedAttestation || + hex.EncodeToString(backendDigest[:]) != expectedBackend || + hex.EncodeToString(operatorDigest[:]) != expectedOperator || + attestation.Signature != expectedTransportSignature || + attestation.BackendSignature != expectedBackendSignature || + attestation.OperatorSignature != expectedOperatorSignature || + hex.EncodeToString(wireHash[:]) != expectedWire { + t.Fatalf( + "frozen transport-attestation vectors changed: context=%x exporter=%x attestation=%x backend=%x operator=%x transport=%s backendSignature=%s operatorSignature=%s wire=%x", + contextHash, + exporterValueHash, + attestationDigest, + backendDigest, + operatorDigest, + attestation.Signature, + attestation.BackendSignature, + attestation.OperatorSignature, + wireHash, + ) + } +} + +func TestValidateFrostRetainedGroupHistoryIdentity_CommitsEveryField( + t *testing.T, +) { + endpointMutations := map[string]func(*FrostRetainedGroupEndpointIdentity){ + "schema": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.Schema += "-other" + }, + "role": func(identity *FrostRetainedGroupEndpointIdentity) { + if identity.Role == "retained-history-export" { + identity.Role = "retained-history-verifier" + } else { + identity.Role = "retained-history-export" + } + }, + "trust domain": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.TrustDomainID += "-other" + }, + "canonical endpoint": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.CanonicalEndpoint = "https://other.example:443/history" + }, + "canonical DNS name": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.CanonicalDNSName = "other.example" + }, + "resolved DNS name": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.ResolvedDNSName = "other-origin.example" + }, + "resolved addresses": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.ResolvedAddressSetHash[31] ^= 0x01 + }, + "TLS leaf": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.TLSLeafSPKIHash[31] ^= 0x01 + }, + "service identity": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.ServiceIdentity = "spiffe://retained.example/other" + }, + "backend identity": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.BackendServiceFingerprint[31] ^= 0x01 + }, + "operator identity": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.OperatorFingerprint[31] ^= 0x01 + }, + "attestation key": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.AttestationKeyHash[31] ^= 0x01 + }, + "TLS exporter protocol": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.TLSExporterProtocolID[31] ^= 0x01 + }, + "endpoint fingerprint": func(identity *FrostRetainedGroupEndpointIdentity) { + identity.EndpointFingerprint[31] ^= 0x01 + }, + } + for name, mutate := range endpointMutations { + t.Run("export "+name, func(t *testing.T) { + identity := testFrostRetainedGroupCompleteIdentity() + mutate(&identity.Export) + if err := validateFrostRetainedGroupHistoryIdentity(identity); err == nil { + t.Fatal("mutated export identity was accepted") + } + }) + t.Run("verifier "+name, func(t *testing.T) { + identity := testFrostRetainedGroupCompleteIdentity() + mutate(&identity.Verifier) + if err := validateFrostRetainedGroupHistoryIdentity(identity); err == nil { + t.Fatal("mutated verifier identity was accepted") + } + }) + } + + sourceMutations := map[string]func(*FrostRetainedGroupHistoryIdentity){ + "schema": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.Schema += "-other" + }, + "trust domain": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.TrustDomainID += "-other" + }, + "operator": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.OperatorFingerprint[31] ^= 0x01 + }, + "history signer": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.HistorySignerKeyHash[31] ^= 0x01 + }, + "fingerprint": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.EndpointFingerprint[31] ^= 0x01 + }, + } + for name, mutate := range sourceMutations { + t.Run("source "+name, func(t *testing.T) { + identity := testFrostRetainedGroupCompleteIdentity() + mutate(&identity) + if err := validateFrostRetainedGroupHistoryIdentity(identity); err == nil { + t.Fatal("mutated source identity was accepted") + } + }) + } +} + +func TestValidateFrostRetainedGroupHistoryIdentity_RejectsRoleReuse( + t *testing.T, +) { + testCases := map[string]func(*FrostRetainedGroupHistoryIdentity){ + "within endpoint": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.Export.BackendServiceFingerprint = + identity.Export.TLSLeafSPKIHash + identity.Export.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity.Export) + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + "cross endpoint hash": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.Verifier.AttestationKeyHash = + identity.Export.OperatorFingerprint + identity.Verifier.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity.Verifier) + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + "history signer reuse": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.HistorySignerKeyHash = + identity.Export.AttestationKeyHash + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + "cross endpoint service": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.Verifier.ServiceIdentity = identity.Export.ServiceIdentity + identity.Verifier.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity.Verifier) + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + "same SPIFFE authority": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.Verifier.ServiceIdentity = + "spiffe://retained-export.example/verifier" + identity.Verifier.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity.Verifier) + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + "cross endpoint DNS": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.Verifier.ResolvedDNSName = identity.Export.ResolvedDNSName + identity.Verifier.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity.Verifier) + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + "aggregate trust domain": func(identity *FrostRetainedGroupHistoryIdentity) { + identity.TrustDomainID = identity.Export.TrustDomainID + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(*identity) + }, + } + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + identity := testFrostRetainedGroupCompleteIdentity() + mutate(&identity) + if err := validateFrostRetainedGroupHistoryIdentity(identity); err == nil { + t.Fatal("reused endpoint role identity was accepted") + } + }) + } +} + +func TestValidateFrostRetainedGroupServiceIdentity_StrictSPIFFECanonicalization( + t *testing.T, +) { + for _, valid := range []string{ + "spiffe://retained.example/export", + "spiffe://retained.example/services/verifier-v1", + "spiffe://single_label/export", + } { + if err := validateFrostRetainedGroupServiceIdentity(valid); err != nil { + t.Fatalf("canonical SPIFFE identity rejected [%s]: [%v]", valid, err) + } + } + for _, invalid := range []string{ + "spiffe://retained.example", + "spiffe://retained.example/", + "spiffe://retained.example:443/export", + "spiffe://Retained.example/export", + "spiffe://retained.example/export/", + "spiffe://retained.example/a//b", + "spiffe://retained.example/a/../b", + "spiffe://retained.example/%65xport", + "spiffe://retained.example/a:b", + "spiffe://retained.example/a~b", + "spiffe://user@retained.example/export", + "spiffe://retained.example/export?query=1", + "spiffe://retained.example/export#fragment", + } { + if err := validateFrostRetainedGroupServiceIdentity(invalid); err == nil { + t.Fatalf("ambiguous SPIFFE identity accepted [%s]", invalid) + } + } +} + +func TestValidateFrostRetainedGroupTLSEndpoint_StrictCanonicalization( + t *testing.T, +) { + for _, valid := range []string{ + "https://history.example:443/", + "https://history.example:8443/export", + "https://127.0.0.1:443/rpc", + "https://[2001:db8::1]:443/rpc", + } { + t.Run("valid "+valid, func(t *testing.T) { + _, canonical, err := validateFrostRetainedGroupTLSEndpoint(valid) + if err != nil || canonical != valid { + t.Fatalf("canonical endpoint rejected: [%s] [%v]", canonical, err) + } + }) + } + for _, invalid := range []string{ + "http://history.example:443/", + "wss://history.example:443/", + "https://history.example/", + "https://history.example:0443/", + "https://History.example:443/", + "https://history.example.:443/", + "https://user@history.example:443/", + "https://history.example:443/?query=1", + "https://history.example:443/#fragment", + "https://history.example:443/%68istory", + "https://history.example:443/a/../history", + "https://history.example:443//history", + "https://history.example:443/history/", + "https://hé.example:443/", + "https:\\\\history.example:443\\history", + "https://history.example:443", + } { + t.Run("invalid "+invalid, func(t *testing.T) { + if _, _, err := validateFrostRetainedGroupTLSEndpoint(invalid); err == nil { + t.Fatal("ambiguous endpoint was accepted") + } + }) + } +} + +func TestFrostRetainedGroupResolvedAddressSet_IsCanonicalAndDetectsAliases( + t *testing.T, +) { + leftAddresses := []netip.Addr{ + netip.MustParseAddr("2001:db8::1"), + netip.MustParseAddr("192.0.2.1"), + netip.MustParseAddr("::ffff:192.0.2.1"), + } + rightAddresses := []netip.Addr{ + netip.MustParseAddr("192.0.2.1"), + netip.MustParseAddr("2001:db8::1"), + } + if frostRetainedGroupResolvedAddressSetHash(leftAddresses) != + frostRetainedGroupResolvedAddressSetHash(rightAddresses) { + t.Fatal("address-set hash depends on order, duplicates, or mapped form") + } + left := frostRetainedGroupResolvedEndpoint{ + canonicalDNSName: "left.example", + resolvedDNSName: "left-origin.example", + addresses: []netip.Addr{netip.MustParseAddr("192.0.2.1")}, + } + right := frostRetainedGroupResolvedEndpoint{ + canonicalDNSName: "right.example", + resolvedDNSName: "right-origin.example", + addresses: []netip.Addr{netip.MustParseAddr("192.0.2.1")}, + } + if !frostRetainedGroupEndpointSetsOverlap(left, right) { + t.Fatal("shared backend IP was not detected") + } + right.addresses = []netip.Addr{netip.MustParseAddr("192.0.2.2")} + right.resolvedDNSName = left.canonicalDNSName + if !frostRetainedGroupEndpointSetsOverlap(left, right) { + t.Fatal("CNAME/canonical DNS alias was not detected") + } +} + +type frostRetainedGroupRebindingResolver struct { + cname string + addresses []netip.Addr +} + +func (resolver *frostRetainedGroupRebindingResolver) LookupCNAME( + context.Context, + string, +) (string, error) { + return resolver.cname, nil +} + +func (resolver *frostRetainedGroupRebindingResolver) LookupNetIP( + context.Context, + string, + string, +) ([]netip.Addr, error) { + return append([]netip.Addr{}, resolver.addresses...), nil +} + +func TestFrostRetainedGroupIndependenceMonitor_RejectsPrimaryDNSRebind( + t *testing.T, +) { + primaryURL, err := url.Parse("https://primary.example:443/rpc") + if err != nil { + t.Fatal(err) + } + exportAddress := netip.MustParseAddr("192.0.2.1") + verifierAddress := netip.MustParseAddr("192.0.2.2") + resolver := &frostRetainedGroupRebindingResolver{ + cname: "primary-origin.example.", + addresses: []netip.Addr{netip.MustParseAddr("192.0.2.3")}, + } + monitor := &frostRetainedGroupIndependenceMonitor{ + exportEndpoint: frostRetainedGroupResolvedEndpoint{ + canonicalDNSName: "export.example", + resolvedDNSName: "export-origin.example", + addresses: []netip.Addr{exportAddress}, + endpoint: &url.URL{Scheme: "https", Host: "export.example:443", Path: "/"}, + }, + verifierEndpoint: frostRetainedGroupResolvedEndpoint{ + canonicalDNSName: "verifier.example", + resolvedDNSName: "verifier-origin.example", + addresses: []netip.Addr{verifierAddress}, + endpoint: &url.URL{Scheme: "https", Host: "verifier.example:443", Path: "/"}, + }, + primaryEndpoint: frostRetainedGroupResolvedEndpoint{ + canonicalDNSName: "primary.example", + resolvedDNSName: "primary-origin.example", + addresses: append([]netip.Addr{}, resolver.addresses...), + endpoint: primaryURL, + }, + resolver: resolver, + timeout: time.Second, + } + if err := monitor.verify(context.Background()); err != nil { + t.Fatalf("independent primary endpoint rejected: [%v]", err) + } + resolver.addresses = []netip.Addr{exportAddress} + if err := monitor.verify(context.Background()); err == nil || + !strings.Contains(err.Error(), "now aliases") { + t.Fatalf("primary DNS rebind was accepted: [%v]", err) + } +} + +func performFrostRetainedGroupAttestedTestRequest( + fixture *frostRetainedGroupHistorySourceFixture, +) error { + client, ok := fixture.source.httpClient.(*http.Client) + if !ok { + return io.ErrUnexpectedEOF + } + request, err := http.NewRequest( + http.MethodPost, + fixture.server.URL+"/operator-id", + strings.NewReader("{}"), + ) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.Do(request) + if response != nil { + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + } + return err +} + +func TestFrostRetainedGroupAttestedTransport_RejectsMissingAndMutatedEvidence( + t *testing.T, +) { + testCases := map[string]func(*frostRetainedGroupHistoryTestExport){ + "missing": func(export *frostRetainedGroupHistoryTestExport) { + export.omitTransportAttestation = true + }, + "duplicate": func(export *frostRetainedGroupHistoryTestExport) { + export.duplicateTransportAttestation = true + }, + "role": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.Role = "retained-history-verifier" + } + }, + "endpoint fingerprint": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.EndpointFingerprint = + frostActivationHex32([32]byte{0xb0}) + } + }, + "canonical endpoint": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.CanonicalEndpoint = + "https://other.example:443/" + } + }, + "canonical DNS": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.CanonicalDNSName = "other.example" + } + }, + "resolved DNS": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.ResolvedDNSName = "other-origin.example" + } + }, + "resolved peer": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.ResolvedPeerIP = "192.0.2.99" + } + }, + "TLS leaf": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.TLSLeafSPKIHash = + frostActivationHex32([32]byte{0xb8}) + } + }, + "service identity": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.ServiceIdentity = + "spiffe://retained.test/other" + } + }, + "backend": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.BackendServiceFingerprint = + frostActivationHex32([32]byte{0xb1}) + } + }, + "backend signer SPKI": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.BackendSignerPublicKeySPKI = + base64.StdEncoding.EncodeToString([]byte("other")) + } + }, + "backend signature algorithm": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.BackendSignatureAlgorithm = "other" + } + }, + "operator": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.OperatorFingerprint = + frostActivationHex32([32]byte{0xb2}) + } + }, + "operator signer SPKI": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.OperatorSignerPublicKeySPKI = + base64.StdEncoding.EncodeToString([]byte("other")) + } + }, + "operator signature algorithm": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.OperatorSignatureAlgorithm = "other" + } + }, + "attestation key": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.AttestationKeyHash = + frostActivationHex32([32]byte{0xb9}) + } + }, + "TLS exporter protocol": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.TLSExporterProtocolID = + frostActivationHex32([32]byte{0xba}) + } + }, + "request digest": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.RequestBodySHA256 = + frostActivationHex32([32]byte{0xb3}) + } + }, + "response digest": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.ResponseBodySHA256 = + frostActivationHex32([32]byte{0xb4}) + } + }, + "status": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.ResponseStatus++ + } + }, + "challenge": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.Challenge = frostActivationHex32([32]byte{0xb5}) + } + }, + "request target": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.RequestTarget += "/other" + } + }, + "exporter context": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.TLSExporterContextSHA256 = + frostActivationHex32([32]byte{0xb6}) + } + }, + "exporter value": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.TLSExporterValueSHA256 = + frostActivationHex32([32]byte{0xb7}) + } + }, + "stale": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.IssuedAtUnixMs = "1" + attestation.ExpiresAtUnixMs = "2" + } + }, + "overflow timestamp": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.IssuedAtUnixMs = "18446744073709551614" + attestation.ExpiresAtUnixMs = "18446744073709551615" + } + }, + "signer SPKI": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.SignerPublicKeySPKI = + base64.StdEncoding.EncodeToString([]byte("other")) + } + }, + "noncanonical signer SPKI": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.SignerPublicKeySPKI += "\n" + } + }, + "signature algorithm": func(export *frostRetainedGroupHistoryTestExport) { + export.transportAttestationMutator = + func(attestation *frostRetainedGroupTransportAttestation) { + attestation.SignatureAlgorithm = "other" + } + }, + } + for name, configure := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + configure(fixture.export) + if err := performFrostRetainedGroupAttestedTestRequest(fixture); err == nil { + t.Fatal("unbound transport evidence was accepted") + } + }) + } +} + +func TestFrostRetainedGroupAttestedTransport_RejectsReplayAcrossTLSConnections( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.export.replayTransportAttestation = true + client := fixture.source.httpClient.(*http.Client) + attested := client.Transport.(*frostRetainedGroupAttestedRoundTripper) + challenge := bytes.Repeat([]byte{0x5a}, 32) + attested.random = bytes.NewReader(append(challenge, challenge...)) + if err := performFrostRetainedGroupAttestedTestRequest(fixture); err != nil { + t.Fatalf("initial attested request failed: [%v]", err) + } + if err := performFrostRetainedGroupAttestedTestRequest(fixture); err == nil || + !strings.Contains(err.Error(), "differently bound") { + t.Fatalf("cross-connection replay was accepted: [%v]", err) + } +} + +func TestFrostRetainedGroupAttestedTransport_RejectsHostAndPathAmbiguity( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + client := fixture.source.httpClient.(*http.Client) + for name, mutate := range map[string]func(*http.Request){ + "host override": func(request *http.Request) { + request.Host = "proxy.example:443" + }, + "encoded path": func(request *http.Request) { + request.URL.Path = "/operator-id" + request.URL.RawPath = "/%6fperator-id" + }, + "query": func(request *http.Request) { + request.URL.RawQuery = "proxy=1" + }, + } { + t.Run(name, func(t *testing.T) { + request, err := http.NewRequest( + http.MethodPost, + fixture.server.URL+"/operator-id", + strings.NewReader("{}"), + ) + if err != nil { + t.Fatal(err) + } + mutate(request) + response, err := client.Do(request) + if response != nil { + _ = response.Body.Close() + } + if err == nil { + t.Fatal("ambiguous transport target was accepted") + } + }) + } + + serverRequest := &http.Request{ + Host: "proxy.example:443", + URL: &url.URL{Path: "/history"}, + } + if _, err := frostRetainedGroupServerRequestTarget( + fixture.identity.Export, + serverRequest, + ); err == nil { + t.Fatal("server accepted a Host header outside the manifest endpoint") + } +} + +func TestVerifyFrostRetainedGroupTLSConnection_RequiresLeafAndServicePins( + t *testing.T, +) { + const serviceIdentity = "spiffe://retained-export.example/export" + server, leaf, _ := newFrostRetainedGroupHistoryTLSTestServer( + t, + http.NotFoundHandler(), + serviceIdentity, + ) + _ = server + identity := testFrostRetainedGroupCompleteIdentity().Export + identity.TLSLeafSPKIHash = sha256.Sum256(leaf.RawSubjectPublicKeyInfo) + identity.ServiceIdentity = serviceIdentity + identity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity) + state := tls.ConnectionState{ + Version: tls.VersionTLS13, + HandshakeComplete: true, + NegotiatedProtocol: "http/1.1", + PeerCertificates: []*x509.Certificate{leaf}, + VerifiedChains: [][]*x509.Certificate{{leaf}}, + } + if err := verifyFrostRetainedGroupTLSConnection(state, identity); err != nil { + t.Fatalf("correct TLS identity rejected: [%v]", err) + } + missingALPN := state + missingALPN.NegotiatedProtocol = "" + if err := verifyFrostRetainedGroupTLSConnection( + missingALPN, + identity, + ); err == nil { + t.Fatal("missing ALPN was accepted") + } + wrongALPN := state + wrongALPN.NegotiatedProtocol = "h2" + if err := verifyFrostRetainedGroupTLSConnection( + wrongALPN, + identity, + ); err == nil { + t.Fatal("wrong ALPN was accepted") + } + wrongTLSVersion := state + wrongTLSVersion.Version = tls.VersionTLS12 + if err := verifyFrostRetainedGroupTLSConnection( + wrongTLSVersion, + identity, + ); err == nil { + t.Fatal("wrong TLS version was accepted") + } + unverified := state + unverified.VerifiedChains = nil + if err := verifyFrostRetainedGroupTLSConnection( + unverified, + identity, + ); err == nil { + t.Fatal("unverified TLS chain was accepted") + } + wrongLeaf := identity + wrongLeaf.TLSLeafSPKIHash[31] ^= 0x01 + if err := verifyFrostRetainedGroupTLSConnection( + state, + wrongLeaf, + ); err == nil { + t.Fatal("wrong TLS leaf was accepted") + } + wrongService := identity + wrongService.ServiceIdentity = "spiffe://retained.test/other" + if err := verifyFrostRetainedGroupTLSConnection( + state, + wrongService, + ); err == nil { + t.Fatal("wrong SPIFFE service identity was accepted") + } + secondURI, err := url.Parse("spiffe://retained.test/second") + if err != nil { + t.Fatal(err) + } + ambiguousLeaf := *leaf + ambiguousLeaf.URIs = append( + append([]*url.URL{}, leaf.URIs...), + secondURI, + ) + ambiguous := state + ambiguous.PeerCertificates = []*x509.Certificate{&ambiguousLeaf} + ambiguous.VerifiedChains = [][]*x509.Certificate{{&ambiguousLeaf}} + if err := verifyFrostRetainedGroupTLSConnection( + ambiguous, + identity, + ); err == nil { + t.Fatal("ambiguous SPIFFE URI SAN set was accepted") + } +} + +type frostRetainedGroupVerifierAttestationHandler struct { + t *testing.T + identity FrostRetainedGroupEndpointIdentity + privateKey ed25519.PrivateKey + publicKeyDER []byte + backendPrivateKey ed25519.PrivateKey + backendPublicKeyDER []byte + operatorPrivateKey ed25519.PrivateKey + operatorPublicKeyDER []byte + omit atomic.Bool +} + +func (handler *frostRetainedGroupVerifierAttestationHandler) ServeHTTP( + responseWriter http.ResponseWriter, + request *http.Request, +) { + requestBody, err := io.ReadAll(request.Body) + if err != nil { + handler.t.Fatal(err) + } + request.Body = io.NopCloser(bytes.NewReader(requestBody)) + rpcRequest := struct { + ID json.RawMessage `json:"id"` + }{} + if err := json.Unmarshal(requestBody, &rpcRequest); err != nil { + handler.t.Fatal(err) + } + responseBody, err := json.Marshal(struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result string `json:"result"` + }{ + JSONRPC: "2.0", + ID: rpcRequest.ID, + Result: "0x1", + }) + if err != nil { + handler.t.Fatal(err) + } + if !handler.omit.Load() { + localAddress, ok := request.Context().Value( + http.LocalAddrContextKey, + ).(net.Addr) + if !ok { + handler.t.Fatal("missing verifier local address") + } + localIP, err := frostRetainedGroupRemoteIP(localAddress) + if err != nil { + handler.t.Fatal(err) + } + attestation, err := marshalFrostRetainedGroupTransportAttestation( + request, + http.StatusOK, + responseBody, + handler.identity, + handler.privateKey, + handler.publicKeyDER, + handler.backendPrivateKey, + handler.backendPublicKeyDER, + handler.operatorPrivateKey, + handler.operatorPublicKeyDER, + time.Now(), + localIP, + ) + if err != nil { + handler.t.Fatal(err) + } + responseWriter.Header().Set( + frostRetainedGroupTransportAttestationHeader, + attestation, + ) + } + responseWriter.Header().Set("Content-Type", "application/json") + responseWriter.WriteHeader(http.StatusOK) + if _, err := responseWriter.Write(responseBody); err != nil { + handler.t.Fatal(err) + } +} + +func TestFrostRetainedGroupVerifierRPC_RequiresPerResponseConformanceAttestation( + t *testing.T, +) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + backendPublicKey, backendPrivateKey, err := + ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + backendPublicKeyDER, err := x509.MarshalPKIXPublicKey(backendPublicKey) + if err != nil { + t.Fatal(err) + } + operatorPublicKey, operatorPrivateKey, err := + ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + operatorPublicKeyDER, err := x509.MarshalPKIXPublicKey(operatorPublicKey) + if err != nil { + t.Fatal(err) + } + handler := &frostRetainedGroupVerifierAttestationHandler{ + t: t, + privateKey: privateKey, + publicKeyDER: publicKeyDER, + backendPrivateKey: backendPrivateKey, + backendPublicKeyDER: backendPublicKeyDER, + operatorPrivateKey: operatorPrivateKey, + operatorPublicKeyDER: operatorPublicKeyDER, + } + const serviceIdentity = "spiffe://retained-verifier.example/verifier" + server, leaf, roots := newFrostRetainedGroupHistoryTLSTestServer( + t, + handler, + serviceIdentity, + ) + endpointURL, canonicalEndpoint, err := + validateFrostRetainedGroupTLSEndpoint(server.URL + "/") + if err != nil { + t.Fatal(err) + } + resolvedEndpoint, err := resolveFrostRetainedGroupEndpoint( + context.Background(), + endpointURL, + nil, + ) + if err != nil { + t.Fatal(err) + } + identity := testFrostRetainedGroupCompleteIdentity().Verifier + identity.CanonicalEndpoint = canonicalEndpoint + identity.CanonicalDNSName = resolvedEndpoint.canonicalDNSName + identity.ResolvedDNSName = resolvedEndpoint.resolvedDNSName + identity.ResolvedAddressSetHash = resolvedEndpoint.addressSetHash + identity.TLSLeafSPKIHash = sha256.Sum256(leaf.RawSubjectPublicKeyInfo) + identity.ServiceIdentity = serviceIdentity + identity.BackendServiceFingerprint = sha256.Sum256(backendPublicKeyDER) + identity.OperatorFingerprint = sha256.Sum256(operatorPublicKeyDER) + identity.AttestationKeyHash = sha256.Sum256(publicKeyDER) + identity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity) + handler.identity = identity + client, transport, err := newFrostRetainedGroupAttestedHTTPClient( + resolvedEndpoint, + identity, + roots, + time.Second, + ) + if err != nil { + t.Fatal(err) + } + defer transport.CloseIdleConnections() + rpcClient, err := rpc.DialOptions( + context.Background(), + canonicalEndpoint, + rpc.WithHTTPClient(client), + ) + if err != nil { + t.Fatal(err) + } + defer rpcClient.Close() + var result string + if err := rpcClient.CallContext( + context.Background(), + &result, + "eth_chainId", + ); err != nil || result != "0x1" { + t.Fatalf("attested verifier RPC failed: [%s] [%v]", result, err) + } + handler.omit.Store(true) + if err := rpcClient.CallContext( + context.Background(), + &result, + "eth_chainId", + ); err == nil || !strings.Contains(err.Error(), "attestation") { + t.Fatalf("generic unattested RPC response was accepted: [%v]", err) + } +} + +func TestFrostRetainedGroupTLSExporterProtocolIDFrozen(t *testing.T) { + const expected = "42e4447e7981f7691f5d7a0f93fa5500bbc3dda7e58438a9db475ae6c4e39b5c" + protocolID := frostRetainedGroupTLSExporterProtocolID() + actual := hex.EncodeToString(protocolID[:]) + if actual != expected { + t.Fatalf("frozen TLS exporter protocol ID changed: [%s]", actual) + } +} diff --git a/pkg/tbtc/frost_retained_group_history_evidence.go b/pkg/tbtc/frost_retained_group_history_evidence.go index 4a5973d4c6..b47f8fc847 100644 --- a/pkg/tbtc/frost_retained_group_history_evidence.go +++ b/pkg/tbtc/frost_retained_group_history_evidence.go @@ -112,6 +112,7 @@ func (source *signedFrostRetainedGroupHistorySource) BindFrostRetainedGroupActiv source.identity.EndpointFingerprint || runtimeManifest.CanonicalJournal.SourceOperatorFingerprint != source.identity.OperatorFingerprint || + runtimeManifest.CanonicalJournal.SourceIdentity != source.identity || new(big.Int).SetBytes(runtimeManifest.DomainChainID[:]).BitLen() > 64 || new(big.Int).SetBytes(runtimeManifest.DomainChainID[:]).Uint64() != source.chainID || @@ -322,15 +323,7 @@ func (source *signedFrostRetainedGroupHistorySource) computeProtocolBinding( QuarantineClusterFingerprint: frostActivationHex32( runtimeManifest.QuarantineJournal.ClusterFingerprint, ), - SourceIdentity: frostRetainedGroupWireIdentity{ - TrustDomainID: source.identity.TrustDomainID, - EndpointFingerprint: frostActivationHex32( - source.identity.EndpointFingerprint, - ), - OperatorFingerprint: frostActivationHex32( - source.identity.OperatorFingerprint, - ), - }, + SourceIdentity: frostRetainedGroupIdentityToWire(source.identity), } return frostRetainedGroupDomainHash( frostRetainedGroupProtocolBindingDomain, diff --git a/pkg/tbtc/frost_retained_group_history_source.go b/pkg/tbtc/frost_retained_group_history_source.go index ab758d8568..dd0409119d 100644 --- a/pkg/tbtc/frost_retained_group_history_source.go +++ b/pkg/tbtc/frost_retained_group_history_source.go @@ -6,7 +6,6 @@ import ( "crypto/ed25519" "crypto/sha256" "crypto/x509" - "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -27,19 +26,17 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" - "github.com/gorilla/websocket" "github.com/keep-network/keep-core/pkg/chain" ) const ( frostRetainedGroupHistoryPageSchema = "tbtc-frost-retained-group-history-page/v5" - frostRetainedGroupOperatorReceiptSchema = "tbtc-frost-retained-group-operator-receipt/v3" + frostRetainedGroupOperatorReceiptSchema = "tbtc-frost-retained-group-operator-receipt/v4" frostRetainedGroupHistoryRequestSchema = "tbtc-frost-retained-group-history-request/v4" - frostRetainedGroupOperatorRequestSchema = "tbtc-frost-retained-group-operator-request/v3" + frostRetainedGroupOperatorRequestSchema = "tbtc-frost-retained-group-operator-request/v4" frostRetainedGroupHistorySignatureDomain = "tbtc-frost-retained-group-export-signature-v5\x00" - frostRetainedGroupHistoryEndpointDomain = "tbtc-frost-retained-group-endpoints-v1\x00" frostRetainedGroupHistoryQueryDomain = "tbtc-frost-retained-group-history-query-v4\x00" - frostRetainedGroupOperatorQueryDomain = "tbtc-frost-retained-group-operator-query-v3\x00" + frostRetainedGroupOperatorQueryDomain = "tbtc-frost-retained-group-operator-query-v4\x00" frostRetainedGroupHistoryRootDomain = "tbtc-frost-retained-group-export-history-root-v4\x00" frostRetainedGroupProtocolBindingDomain = "tbtc-frost-retained-group-protocol-binding-v4\x00" @@ -62,14 +59,29 @@ const ( // FrostRetainedGroupHistorySourceConfig configures the independent, // receipt-complete retained-group history service. ExportURL and EthereumURL // must be distinct from each other and from the primary Ethereum endpoint. The -// export authority's Ed25519 SPKI hash is pinned rather than learned from the -// service. +// history-envelope, backend, operator, and transport-attestation Ed25519 SPKI +// hashes are separate manifest roles and are pinned rather than learned from +// either service. type FrostRetainedGroupHistorySourceConfig struct { - ExportURL string - EthereumURL string - TrustDomainID string - TrustedSignerKeyHash string - RequestTimeout time.Duration + ExportURL string + EthereumURL string + TrustDomainID string + ExportTrustDomainID string + EthereumTrustDomainID string + ExportServiceIdentity string + EthereumServiceIdentity string + ExportBackendServiceFingerprint string + EthereumBackendServiceFingerprint string + ExportOperatorFingerprint string + EthereumOperatorFingerprint string + TrustedSignerKeyHash string + ExportAttestationKeyHash string + EthereumAttestationKeyHash string + ExportTLSLeafSPKIHash string + EthereumTLSLeafSPKIHash string + RequestTimeout time.Duration + TLSRootCAs *x509.CertPool `mapstructure:"-"` + Resolver *net.Resolver `mapstructure:"-"` } type frostRetainedGroupEthereumVerifier interface { @@ -186,6 +198,8 @@ type signedFrostRetainedGroupHistorySource struct { exportEndpoint *url.URL verifier frostRetainedGroupEthereumVerifier httpClient frostRetainedGroupHTTPClient + httpTransports []*http.Transport + independenceMonitor *frostRetainedGroupIndependenceMonitor chainID uint64 identity FrostRetainedGroupHistoryIdentity trustedSignerKeyHash [32]byte @@ -211,12 +225,6 @@ type frostRetainedGroupSignedEnvelope struct { Signature string `json:"signature"` } -type frostRetainedGroupWireIdentity struct { - TrustDomainID string `json:"trustDomainID"` - EndpointFingerprint string `json:"endpointFingerprint"` - OperatorFingerprint string `json:"operatorFingerprint"` -} - type frostRetainedGroupWireFinality struct { RelayTransactionHash string `json:"relayTransactionHash"` BlockNumber uint64 `json:"blockNumber"` @@ -412,28 +420,17 @@ type frostRetainedGroupOperatorReceiptPayload struct { Found bool `json:"found"` } -// FrostRetainedGroupHistoryEndpointFingerprint returns the identity committed -// by the activation manifest for this exact export/RPC endpoint pair. +// FrostRetainedGroupHistoryEndpointFingerprint returns the complete identity +// committed by the activation manifest. The caller must supply the explicit +// endpoint descriptors; URL strings alone are not an authenticated endpoint +// identity. func FrostRetainedGroupHistoryEndpointFingerprint( - exportURL string, - ethereumURL string, + identity FrostRetainedGroupHistoryIdentity, ) ([32]byte, error) { - _, canonicalExport, err := validateFrostRetainedGroupEndpoint(exportURL, true) - if err != nil { - return [32]byte{}, fmt.Errorf("invalid retained-group export URL: [%w]", err) - } - _, canonicalEthereum, err := validateFrostRetainedGroupEndpoint(ethereumURL, false) - if err != nil { - return [32]byte{}, fmt.Errorf("invalid retained-group Ethereum URL: [%w]", err) + if err := validateFrostRetainedGroupHistoryIdentity(identity); err != nil { + return [32]byte{}, err } - hasher := sha256.New() - hasher.Write([]byte(frostRetainedGroupHistoryEndpointDomain)) - hasher.Write([]byte(canonicalExport)) - hasher.Write([]byte{0}) - hasher.Write([]byte(canonicalEthereum)) - var result [32]byte - copy(result[:], hasher.Sum(nil)) - return result, nil + return computeFrostRetainedGroupSourceEndpointFingerprint(identity), nil } // NewFrostRetainedGroupHistorySource creates the production source. It fails @@ -448,58 +445,82 @@ func NewFrostRetainedGroupHistorySource( if ctx == nil { return nil, fmt.Errorf("retained-group history context is nil") } - endpoint, canonicalEthereum, identity, signerHash, timeout, err := - validateFrostRetainedGroupSourceConfig(config, primaryEthereumURL) + validated, err := validateAndResolveFrostRetainedGroupSourceConfig( + ctx, + config, + primaryEthereumURL, + ) if err != nil { return nil, err } if expectedChainID == 0 { return nil, fmt.Errorf("retained-group history expected chain ID is zero") } - httpTransport := http.DefaultTransport.(*http.Transport).Clone() - httpTransport.Proxy = nil - rpcHTTPClient := &http.Client{ - Transport: httpTransport, - Timeout: timeout, + rpcHTTPClient, rpcTransport, err := + newFrostRetainedGroupAttestedHTTPClient( + validated.verifierEndpoint, + validated.identity.Verifier, + validated.rootCAs, + validated.requestTimeout, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot configure independent retained-group Ethereum verifier transport: [%w]", + err, + ) } rpcClient, err := rpc.DialOptions( ctx, - canonicalEthereum, + validated.verifierEndpoint.canonical, rpc.WithHTTPClient(rpcHTTPClient), - rpc.WithWebsocketDialer(websocket.Dialer{ - HandshakeTimeout: timeout, - Proxy: nil, - }), ) if err != nil { + rpcTransport.CloseIdleConnections() return nil, fmt.Errorf("cannot connect independent retained-group Ethereum verifier: [%w]", err) } verifier := &canonicalFrostRetainedGroupEthereumVerifier{ Client: ethclient.NewClient(rpcClient), rpcClient: rpcClient, } - exportTransport := http.DefaultTransport.(*http.Transport).Clone() - exportTransport.Proxy = nil + exportHTTPClient, exportTransport, err := + newFrostRetainedGroupAttestedHTTPClient( + validated.exportEndpoint, + validated.identity.Export, + validated.rootCAs, + validated.requestTimeout, + ) + if err != nil { + verifier.Close() + rpcTransport.CloseIdleConnections() + return nil, fmt.Errorf( + "cannot configure retained-group export transport: [%w]", + err, + ) + } source, err := newSignedFrostRetainedGroupHistorySource( ctx, - endpoint, + validated.exportEndpoint.endpoint, verifier, - &http.Client{ - Transport: exportTransport, - Timeout: timeout, - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return fmt.Errorf("retained-group export redirects are forbidden") - }, - }, + exportHTTPClient, expectedChainID, - identity, - signerHash, - timeout, + validated.identity, + validated.identity.HistorySignerKeyHash, + validated.requestTimeout, + &frostRetainedGroupIndependenceMonitor{ + exportEndpoint: validated.exportEndpoint, + verifierEndpoint: validated.verifierEndpoint, + primaryEndpoint: validated.primaryEndpoint, + resolver: config.Resolver, + timeout: validated.requestTimeout, + }, ) if err != nil { verifier.Close() + rpcTransport.CloseIdleConnections() + exportTransport.CloseIdleConnections() return nil, err } + source.httpTransports = []*http.Transport{exportTransport, rpcTransport} return source, nil } @@ -512,14 +533,19 @@ func newSignedFrostRetainedGroupHistorySource( identity FrostRetainedGroupHistoryIdentity, signerHash [32]byte, requestTimeout time.Duration, + independenceMonitor *frostRetainedGroupIndependenceMonitor, ) (*signedFrostRetainedGroupHistorySource, error) { if exportEndpoint == nil || verifier == nil || httpClient == nil || chainID == 0 || - strings.TrimSpace(identity.TrustDomainID) == "" || - identity.EndpointFingerprint == [32]byte{} || signerHash == [32]byte{} || - identity.OperatorFingerprint != signerHash || + validateFrostRetainedGroupHistoryIdentity(identity) != nil || + signerHash == [32]byte{} || + identity.HistorySignerKeyHash != signerHash || + independenceMonitor == nil || requestTimeout < time.Second || requestTimeout > time.Minute { return nil, fmt.Errorf("retained-group history source configuration is incomplete") } + if err := independenceMonitor.verify(ctx); err != nil { + return nil, err + } requestContext, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() actualChainID, err := verifier.ChainID(requestContext) @@ -533,6 +559,7 @@ func newSignedFrostRetainedGroupHistorySource( exportEndpoint: exportEndpoint, verifier: verifier, httpClient: httpClient, + independenceMonitor: independenceMonitor, chainID: chainID, identity: identity, trustedSignerKeyHash: signerHash, @@ -549,118 +576,18 @@ func newSignedFrostRetainedGroupHistorySource( return source, nil } -func validateFrostRetainedGroupSourceConfig( - config FrostRetainedGroupHistorySourceConfig, - primaryEthereumURL string, -) (*url.URL, string, FrostRetainedGroupHistoryIdentity, [32]byte, time.Duration, error) { - exportEndpoint, _, err := validateFrostRetainedGroupEndpoint(config.ExportURL, true) - if err != nil { - return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, - fmt.Errorf("invalid retained-group export URL: [%w]", err) - } - ethereumEndpoint, canonicalEthereum, err := - validateFrostRetainedGroupEndpoint(config.EthereumURL, false) - if err != nil { - return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, - fmt.Errorf("invalid retained-group Ethereum URL: [%w]", err) - } - primaryEndpoint, _, err := validateFrostRetainedGroupEndpoint(primaryEthereumURL, false) - if err != nil { - return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, - fmt.Errorf("invalid primary Ethereum URL: [%w]", err) - } - if frostRetainedGroupEndpointsShareAuthority(primaryEndpoint, ethereumEndpoint) || - frostRetainedGroupEndpointsShareAuthority(primaryEndpoint, exportEndpoint) { - return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, - fmt.Errorf("retained-group source is not independent of the primary endpoint") - } - if frostRetainedGroupEndpointsShareAuthority(exportEndpoint, ethereumEndpoint) { - return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, - fmt.Errorf("retained-group exporter and Ethereum verifier are not independent") - } - trustDomainID := strings.TrimSpace(config.TrustDomainID) - if trustDomainID == "" || len(trustDomainID) > 128 || strings.ContainsAny(trustDomainID, "\x00\r\n\t") { - return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, - fmt.Errorf("retained-group trust domain ID is invalid") - } - signerHash, err := parseFrostActivationHex32(strings.TrimSpace(config.TrustedSignerKeyHash)) - if err != nil || signerHash == [32]byte{} { - return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, - fmt.Errorf("retained-group trusted signer key hash is invalid") - } - endpointFingerprint, err := FrostRetainedGroupHistoryEndpointFingerprint( - config.ExportURL, - config.EthereumURL, - ) - if err != nil { - return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, err - } - timeout := config.RequestTimeout - if timeout == 0 { - timeout = frostRetainedGroupDefaultTimeout - } - if timeout < time.Second || timeout > time.Minute { - return nil, "", FrostRetainedGroupHistoryIdentity{}, [32]byte{}, 0, - fmt.Errorf("retained-group request timeout is outside supported bounds") - } - return exportEndpoint, canonicalEthereum, FrostRetainedGroupHistoryIdentity{ - TrustDomainID: trustDomainID, - EndpointFingerprint: endpointFingerprint, - OperatorFingerprint: signerHash, - }, signerHash, timeout, nil -} - -func frostRetainedGroupEndpointsShareAuthority(left *url.URL, right *url.URL) bool { - if left == nil || right == nil || !strings.EqualFold(left.Hostname(), right.Hostname()) { - return false +func (source *signedFrostRetainedGroupHistorySource) Close() { + if source == nil { + return } - leftIP := net.ParseIP(left.Hostname()) - rightIP := net.ParseIP(right.Hostname()) - if leftIP != nil && rightIP != nil && leftIP.IsLoopback() && rightIP.IsLoopback() { - return strings.EqualFold(left.Host, right.Host) + if source.verifier != nil { + source.verifier.Close() } - return true -} - -func validateFrostRetainedGroupEndpoint( - raw string, - export bool, -) (*url.URL, string, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" || trimmed != raw { - return nil, "", fmt.Errorf("URL is empty or has surrounding whitespace") - } - endpoint, err := url.Parse(trimmed) - if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.Fragment != "" { - return nil, "", fmt.Errorf("URL is not an absolute credential-free endpoint") - } - if export && endpoint.RawQuery != "" { - return nil, "", fmt.Errorf("export URL must not contain a query") - } - scheme := strings.ToLower(endpoint.Scheme) - secure := scheme == "https" || (!export && scheme == "wss") - insecure := scheme == "http" || (!export && scheme == "ws") - if !secure && !insecure { - return nil, "", fmt.Errorf("URL scheme is unsupported") - } - if insecure { - ip := net.ParseIP(endpoint.Hostname()) - if ip == nil || !ip.IsLoopback() { - return nil, "", fmt.Errorf("non-TLS endpoint must use a numeric loopback address") + for _, transport := range source.httpTransports { + if transport != nil { + transport.CloseIdleConnections() } } - endpoint.Scheme = scheme - endpoint.Host = strings.ToLower(endpoint.Host) - if endpoint.Path == "" { - endpoint.Path = "/" - } - return endpoint, endpoint.String(), nil -} - -func (source *signedFrostRetainedGroupHistorySource) Close() { - if source != nil && source.verifier != nil { - source.verifier.Close() - } } func (source *signedFrostRetainedGroupHistorySource) requestContext( @@ -689,6 +616,9 @@ func (source *signedFrostRetainedGroupHistorySource) FinalizedHead( if ctx == nil { return FrostPreSignFinality{}, fmt.Errorf("retained-group finalized-head context is nil") } + if err := source.independenceMonitor.verify(ctx); err != nil { + return FrostPreSignFinality{}, err + } requestContext, cancel := source.requestContext(ctx) defer cancel() header, err := source.verifier.HeaderByNumber( @@ -1215,6 +1145,9 @@ func (source *signedFrostRetainedGroupHistorySource) postSigned( requestPayload interface{}, responsePayload interface{}, ) (uint64, error) { + if err := source.independenceMonitor.verify(ctx); err != nil { + return 0, err + } requestContext, cancel := source.requestContext(ctx) defer cancel() requestBody, err := json.Marshal(requestPayload) @@ -1239,6 +1172,12 @@ func (source *signedFrostRetainedGroupHistorySource) postSigned( return 0, err } defer response.Body.Close() + if err := requireFrostRetainedGroupTransportProof( + response, + source.identity.Export.Role, + ); err != nil { + return 0, err + } if response.StatusCode != http.StatusOK { return 0, fmt.Errorf("retained-group export returned HTTP status [%d]", response.StatusCode) } @@ -1283,7 +1222,9 @@ func (source *signedFrostRetainedGroupHistorySource) verifySignedEnvelope( if err != nil || declaredHash != payloadHash { return fmt.Errorf("retained-group signed envelope payload hash mismatch") } - publicKeyDER, err := base64.StdEncoding.Strict().DecodeString(envelope.SignerPublicKeySPKI) + publicKeyDER, err := decodeCanonicalFrostRetainedGroupBase64( + envelope.SignerPublicKeySPKI, + ) if err != nil || len(publicKeyDER) == 0 || len(publicKeyDER) > 1024 || sha256.Sum256(publicKeyDER) != source.trustedSignerKeyHash { return fmt.Errorf("retained-group export signer is not trusted") @@ -1296,7 +1237,9 @@ func (source *signedFrostRetainedGroupHistorySource) verifySignedEnvelope( if !ok { return fmt.Errorf("retained-group export signer is not Ed25519") } - signature, err := base64.StdEncoding.Strict().DecodeString(envelope.Signature) + signature, err := decodeCanonicalFrostRetainedGroupBase64( + envelope.Signature, + ) signed := append([]byte(frostRetainedGroupHistorySignatureDomain), canonical...) if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, signed, signature) { @@ -1311,11 +1254,8 @@ func (source *signedFrostRetainedGroupHistorySource) verifySignedEnvelope( func (source *signedFrostRetainedGroupHistorySource) validWireIdentity( identity frostRetainedGroupWireIdentity, ) bool { - endpoint, endpointErr := parseFrostActivationHex32(identity.EndpointFingerprint) - operator, operatorErr := parseFrostActivationHex32(identity.OperatorFingerprint) - return identity.TrustDomainID == source.identity.TrustDomainID && - endpointErr == nil && endpoint == source.identity.EndpointFingerprint && - operatorErr == nil && operator == source.identity.OperatorFingerprint + parsed, err := frostRetainedGroupIdentityFromWire(identity) + return err == nil && parsed == source.identity } func frostRetainedGroupDomainHash(domain string, value interface{}) ([32]byte, error) { diff --git a/pkg/tbtc/frost_retained_group_history_source_test.go b/pkg/tbtc/frost_retained_group_history_source_test.go index 3ff213c81c..cd8715e2ad 100644 --- a/pkg/tbtc/frost_retained_group_history_source_test.go +++ b/pkg/tbtc/frost_retained_group_history_source_test.go @@ -3,17 +3,24 @@ package tbtc import ( "bytes" "context" + "crypto/ecdsa" "crypto/ed25519" + "crypto/elliptic" "crypto/rand" "crypto/sha256" + "crypto/tls" "crypto/x509" + "crypto/x509/pkix" "encoding/base64" "encoding/binary" "encoding/json" "fmt" + "io" "math/big" + "net" "net/http" "net/http/httptest" + "net/netip" "net/url" "strings" "sync" @@ -246,13 +253,27 @@ func (verifier *frostRetainedGroupHistoryTestVerifier) CallContractAtHash( } type frostRetainedGroupHistoryTestExport struct { - t *testing.T - privateKey ed25519.PrivateKey - publicKeyDER []byte - historyResponder func(frostRetainedGroupHistoryPageRequest) interface{} - operatorResponder func(frostRetainedGroupOperatorQuery) interface{} - corruptSignature bool - bindingHash [32]byte + t *testing.T + privateKey ed25519.PrivateKey + publicKeyDER []byte + transportPrivateKey ed25519.PrivateKey + transportPublicKeyDER []byte + backendPrivateKey ed25519.PrivateKey + backendPublicKeyDER []byte + operatorPrivateKey ed25519.PrivateKey + operatorPublicKeyDER []byte + historyResponder func(frostRetainedGroupHistoryPageRequest) interface{} + operatorResponder func(frostRetainedGroupOperatorQuery) interface{} + corruptSignature bool + bindingHash [32]byte + identity FrostRetainedGroupEndpointIdentity + now func() time.Time + transportAttestationMutator func(*frostRetainedGroupTransportAttestation) + omitTransportAttestation bool + duplicateTransportAttestation bool + replayTransportAttestation bool + transportAttestationMutex sync.Mutex + savedTransportAttestation string } func (export *frostRetainedGroupHistoryTestExport) ServeHTTP( @@ -263,18 +284,24 @@ func (export *frostRetainedGroupHistoryTestExport) ServeHTTP( http.Error(responseWriter, "method", http.StatusMethodNotAllowed) return } + requestBody, err := io.ReadAll(request.Body) + if err != nil { + http.Error(responseWriter, "request", http.StatusBadRequest) + return + } + request.Body = io.NopCloser(bytes.NewReader(requestBody)) var payload interface{} switch request.URL.Path { case "/history": historyRequest := frostRetainedGroupHistoryPageRequest{} - if err := json.NewDecoder(request.Body).Decode(&historyRequest); err != nil { + if err := json.NewDecoder(bytes.NewReader(requestBody)).Decode(&historyRequest); err != nil { http.Error(responseWriter, "request", http.StatusBadRequest) return } payload = export.historyResponder(historyRequest) case "/operator-id": operatorRequest := frostRetainedGroupOperatorQuery{} - if err := json.NewDecoder(request.Body).Decode(&operatorRequest); err != nil { + if err := json.NewDecoder(bytes.NewReader(requestBody)).Decode(&operatorRequest); err != nil { http.Error(responseWriter, "request", http.StatusBadRequest) return } @@ -284,7 +311,14 @@ func (export *frostRetainedGroupHistoryTestExport) ServeHTTP( return } if payload == nil { - http.Error(responseWriter, "missing", http.StatusServiceUnavailable) + request.Body = io.NopCloser(bytes.NewReader(requestBody)) + export.writeAttestedResponse( + responseWriter, + request, + http.StatusServiceUnavailable, + "text/plain", + []byte("missing\n"), + ) return } canonical, err := canonicalFrostActivationValue(payload) @@ -306,8 +340,112 @@ func (export *frostRetainedGroupHistoryTestExport) ServeHTTP( SignatureAlgorithm: "ed25519", Signature: base64.StdEncoding.EncodeToString(signature), } - responseWriter.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(responseWriter).Encode(envelope); err != nil { + responseBody, err := json.Marshal(envelope) + if err != nil { + export.t.Fatal(err) + } + request.Body = io.NopCloser(bytes.NewReader(requestBody)) + export.writeAttestedResponse( + responseWriter, + request, + http.StatusOK, + "application/json", + responseBody, + ) +} + +func (export *frostRetainedGroupHistoryTestExport) writeAttestedResponse( + responseWriter http.ResponseWriter, + request *http.Request, + status int, + contentType string, + responseBody []byte, +) { + export.t.Helper() + localAddress, ok := request.Context().Value(http.LocalAddrContextKey).(net.Addr) + if !ok { + export.t.Fatal("missing test server local address") + } + localIP, err := frostRetainedGroupRemoteIP(localAddress) + if err != nil { + export.t.Fatal(err) + } + now := time.Now() + if export.now != nil { + now = export.now() + } + attestation, err := marshalFrostRetainedGroupTransportAttestation( + request, + status, + responseBody, + export.identity, + export.transportPrivateKey, + export.transportPublicKeyDER, + export.backendPrivateKey, + export.backendPublicKeyDER, + export.operatorPrivateKey, + export.operatorPublicKeyDER, + now, + localIP, + ) + if err != nil { + export.t.Fatal(err) + } + if export.transportAttestationMutator != nil { + raw, err := base64.StdEncoding.Strict().DecodeString(attestation) + if err != nil { + export.t.Fatal(err) + } + mutated := frostRetainedGroupTransportAttestation{} + if err := decodeStrictFrostActivationJSON(raw, &mutated); err != nil { + export.t.Fatal(err) + } + export.transportAttestationMutator(&mutated) + digest, err := frostRetainedGroupAttestationTranscript(mutated) + if err != nil { + export.t.Fatal(err) + } + backendDigest := frostRetainedGroupBackendAttestationDigest(digest) + mutated.BackendSignature = base64.StdEncoding.EncodeToString( + ed25519.Sign(export.backendPrivateKey, backendDigest[:]), + ) + operatorDigest := frostRetainedGroupOperatorAttestationDigest(digest) + mutated.OperatorSignature = base64.StdEncoding.EncodeToString( + ed25519.Sign(export.operatorPrivateKey, operatorDigest[:]), + ) + mutated.Signature = base64.StdEncoding.EncodeToString( + ed25519.Sign(export.transportPrivateKey, digest[:]), + ) + encoded, err := json.Marshal(mutated) + if err != nil { + export.t.Fatal(err) + } + attestation = base64.StdEncoding.EncodeToString(encoded) + } + if export.replayTransportAttestation { + export.transportAttestationMutex.Lock() + if export.savedTransportAttestation == "" { + export.savedTransportAttestation = attestation + } else { + attestation = export.savedTransportAttestation + } + export.transportAttestationMutex.Unlock() + } + responseWriter.Header().Set("Content-Type", contentType) + if !export.omitTransportAttestation { + responseWriter.Header().Add( + frostRetainedGroupTransportAttestationHeader, + attestation, + ) + if export.duplicateTransportAttestation { + responseWriter.Header().Add( + frostRetainedGroupTransportAttestationHeader, + attestation, + ) + } + } + responseWriter.WriteHeader(status) + if _, err := responseWriter.Write(responseBody); err != nil { export.t.Fatal(err) } } @@ -346,6 +484,66 @@ func (fixture *frostRetainedGroupHistorySourceFixture) checkpointAfter() FrostRe } } +func newFrostRetainedGroupHistoryTLSTestServer( + t *testing.T, + handler http.Handler, + serviceIdentity string, +) (*httptest.Server, *x509.Certificate, *x509.CertPool) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + serviceURI, err := url.Parse(serviceIdentity) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "retained-history-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, + BasicConstraintsValid: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + URIs: []*url.URL{serviceURI}, + } + certificateDER, err := x509.CreateCertificate( + rand.Reader, + template, + template, + &privateKey.PublicKey, + privateKey, + ) + if err != nil { + t.Fatal(err) + } + leaf, err := x509.ParseCertificate(certificateDER) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(handler) + server.TLS = &tls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + NextProtos: []string{"http/1.1"}, + Certificates: []tls.Certificate{{ + Certificate: [][]byte{certificateDER}, + PrivateKey: privateKey, + Leaf: leaf, + }}, + } + server.StartTLS() + t.Cleanup(server.Close) + roots := x509.NewCertPool() + roots.AddCert(leaf) + return server, leaf, roots +} + func newFrostRetainedGroupHistorySourceFixture( t *testing.T, ) *frostRetainedGroupHistorySourceFixture { @@ -358,7 +556,35 @@ func newFrostRetainedGroupHistorySourceFixture( if err != nil { t.Fatal(err) } - operatorFingerprint := sha256.Sum256(publicKeyDER) + transportPublicKey, transportPrivateKey, err := + ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + transportPublicKeyDER, err := x509.MarshalPKIXPublicKey( + transportPublicKey, + ) + if err != nil { + t.Fatal(err) + } + backendPublicKey, backendPrivateKey, err := + ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + backendPublicKeyDER, err := x509.MarshalPKIXPublicKey(backendPublicKey) + if err != nil { + t.Fatal(err) + } + operatorPublicKey, operatorPrivateKey, err := + ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + operatorPublicKeyDER, err := x509.MarshalPKIXPublicKey(operatorPublicKey) + if err != nil { + t.Fatal(err) + } headers := make(map[uint64]*types.Header) for blockNumber := uint64(1); blockNumber <= 10; blockNumber++ { headers[blockNumber] = &types.Header{ @@ -410,36 +636,147 @@ func newFrostRetainedGroupHistorySourceFixture( verifier.code[common.Address(profile.FrostRegistry)] = registryCode verifier.code[common.Address(profile.SortitionPool)] = sortitionPoolCode export := &frostRetainedGroupHistoryTestExport{ - t: t, - privateKey: privateKey, - publicKeyDER: publicKeyDER, + t: t, + privateKey: privateKey, + publicKeyDER: publicKeyDER, + transportPrivateKey: transportPrivateKey, + transportPublicKeyDER: transportPublicKeyDER, + backendPrivateKey: backendPrivateKey, + backendPublicKeyDER: backendPublicKeyDER, + operatorPrivateKey: operatorPrivateKey, + operatorPublicKeyDER: operatorPublicKeyDER, + } + exportServiceIdentity := "spiffe://export.retained.test/export" + server, leaf, roots := newFrostRetainedGroupHistoryTLSTestServer( + t, + export, + exportServiceIdentity, + ) + endpoint, canonicalEndpoint, err := validateFrostRetainedGroupTLSEndpoint( + server.URL + "/", + ) + if err != nil { + t.Fatal(err) } - server := httptest.NewServer(export) - t.Cleanup(server.Close) - endpoint, err := url.Parse(server.URL) + resolvedEndpoint, err := resolveFrostRetainedGroupEndpoint( + context.Background(), + endpoint, + nil, + ) if err != nil { t.Fatal(err) } + exportIdentity := FrostRetainedGroupEndpointIdentity{ + Schema: frostRetainedGroupEndpointIdentitySchema, + Role: "retained-history-export", + TrustDomainID: "export.retained.test", + CanonicalEndpoint: canonicalEndpoint, + CanonicalDNSName: resolvedEndpoint.canonicalDNSName, + ResolvedDNSName: resolvedEndpoint.resolvedDNSName, + ResolvedAddressSetHash: resolvedEndpoint.addressSetHash, + TLSLeafSPKIHash: sha256.Sum256(leaf.RawSubjectPublicKeyInfo), + ServiceIdentity: exportServiceIdentity, + BackendServiceFingerprint: sha256.Sum256(backendPublicKeyDER), + OperatorFingerprint: sha256.Sum256(operatorPublicKeyDER), + AttestationKeyHash: sha256.Sum256(transportPublicKeyDER), + TLSExporterProtocolID: frostRetainedGroupTLSExporterProtocolID(), + } + exportIdentity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(exportIdentity) + verifierAddress := netip.MustParseAddr("127.0.0.2") + verifierIdentity := FrostRetainedGroupEndpointIdentity{ + Schema: frostRetainedGroupEndpointIdentitySchema, + Role: "retained-history-verifier", + TrustDomainID: "verifier.retained.test", + CanonicalEndpoint: "https://127.0.0.2:9443/rpc", + CanonicalDNSName: "127.0.0.2", + ResolvedDNSName: "127.0.0.2", + ResolvedAddressSetHash: frostRetainedGroupResolvedAddressSetHash([]netip.Addr{verifierAddress}), + TLSLeafSPKIHash: [32]byte{0x63}, + ServiceIdentity: "spiffe://verifier.retained.test/verifier", + BackendServiceFingerprint: [32]byte{0x64}, + OperatorFingerprint: [32]byte{0x65}, + AttestationKeyHash: [32]byte{0x66}, + TLSExporterProtocolID: frostRetainedGroupTLSExporterProtocolID(), + } + verifierIdentity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(verifierIdentity) identity := FrostRetainedGroupHistoryIdentity{ - TrustDomainID: "independent-retained-history-test", - EndpointFingerprint: [32]byte{0x31}, - OperatorFingerprint: operatorFingerprint, - } + Schema: frostRetainedGroupSourceIdentitySchema, + TrustDomainID: "independent-retained-history-test", + OperatorFingerprint: exportIdentity.OperatorFingerprint, + HistorySignerKeyHash: sha256.Sum256(publicKeyDER), + Export: exportIdentity, + Verifier: verifierIdentity, + } + identity.EndpointFingerprint = + computeFrostRetainedGroupSourceEndpointFingerprint(identity) + export.identity = exportIdentity + runtimeManifest.CanonicalJournal.SourceTrustDomainID = + identity.TrustDomainID + runtimeManifest.CanonicalJournal.SourceEndpointFingerprint = + identity.EndpointFingerprint runtimeManifest.CanonicalJournal.SourceOperatorFingerprint = identity.OperatorFingerprint + runtimeManifest.CanonicalJournal.SourceIdentity = identity + exportHTTPClient, exportTransport, err := + newFrostRetainedGroupAttestedHTTPClient( + resolvedEndpoint, + exportIdentity, + roots, + frostRetainedGroupDefaultTimeout, + ) + if err != nil { + t.Fatal(err) + } + verifierURL, _, err := validateFrostRetainedGroupTLSEndpoint( + verifierIdentity.CanonicalEndpoint, + ) + if err != nil { + t.Fatal(err) + } + resolvedVerifier, err := resolveFrostRetainedGroupEndpoint( + context.Background(), + verifierURL, + nil, + ) + if err != nil { + t.Fatal(err) + } + primaryURL, _, err := validateFrostRetainedGroupTLSEndpoint( + "https://127.0.0.3:9444/rpc", + ) + if err != nil { + t.Fatal(err) + } + resolvedPrimary, err := resolveFrostRetainedGroupEndpoint( + context.Background(), + primaryURL, + nil, + ) + if err != nil { + t.Fatal(err) + } source, err := newSignedFrostRetainedGroupHistorySource( context.Background(), endpoint, verifier, - server.Client(), + exportHTTPClient, 1, identity, - operatorFingerprint, + identity.HistorySignerKeyHash, frostRetainedGroupDefaultTimeout, + &frostRetainedGroupIndependenceMonitor{ + exportEndpoint: resolvedEndpoint, + verifierEndpoint: resolvedVerifier, + primaryEndpoint: resolvedPrimary, + timeout: frostRetainedGroupDefaultTimeout, + }, ) if err != nil { t.Fatal(err) } + source.httpTransports = []*http.Transport{exportTransport} descriptorSetHash := [32]byte{0x42} if err := source.BindFrostRetainedGroupActivationEvidence( profile, @@ -994,11 +1331,7 @@ func (fixture *frostRetainedGroupHistorySourceFixture) historyPages( } pages := make([]*frostRetainedGroupHistoryPagePayload, pageCount) previousPageHash := [32]byte{} - identity := frostRetainedGroupWireIdentity{ - TrustDomainID: fixture.identity.TrustDomainID, - EndpointFingerprint: frostActivationHex32(fixture.identity.EndpointFingerprint), - OperatorFingerprint: frostActivationHex32(fixture.identity.OperatorFingerprint), - } + identity := frostRetainedGroupIdentityToWire(fixture.identity) for index := 0; index < pageCount; index++ { start := index * 2 end := start + 2 @@ -1104,11 +1437,7 @@ func (fixture *frostRetainedGroupHistorySourceFixture) operatorResponse( BindingHash: frostActivationHex32( fixture.source.evidence.bindingHash, ), - Identity: frostRetainedGroupWireIdentity{ - TrustDomainID: fixture.identity.TrustDomainID, - EndpointFingerprint: frostActivationHex32(fixture.identity.EndpointFingerprint), - OperatorFingerprint: frostActivationHex32(fixture.identity.OperatorFingerprint), - }, + Identity: frostRetainedGroupIdentityToWire(fixture.identity), ChainID: 1, QueryHash: frostActivationHex32(queryHash), OperatorAddress: query.OperatorAddress, @@ -2229,29 +2558,32 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsLinkedLibraryDrift( func TestSignedFrostRetainedGroupHistorySource_RejectsWrongEndpointAndReorg( t *testing.T, ) { - _, _, _, _, _, err := validateFrostRetainedGroupSourceConfig( - FrostRetainedGroupHistorySourceConfig{ - ExportURL: "https://export.example/history", - EthereumURL: "https://primary.example/independent", - TrustDomainID: "independent", - TrustedSignerKeyHash: frostActivationHex32([32]byte{0x01}), - }, - "wss://primary.example/rpc", - ) - if err == nil || !strings.Contains(err.Error(), "not independent") { - t.Fatalf("expected shared endpoint rejection, got [%v]", err) - } - _, _, _, _, _, err = validateFrostRetainedGroupSourceConfig( - FrostRetainedGroupHistorySourceConfig{ - ExportURL: "https://history.example/export", - EthereumURL: "https://history.example/rpc", - TrustDomainID: "independent", - TrustedSignerKeyHash: frostActivationHex32([32]byte{0x01}), - }, - "wss://primary.example/rpc", - ) - if err == nil || !strings.Contains(err.Error(), "exporter and Ethereum verifier") { - t.Fatalf("expected exporter/verifier separation rejection, got [%v]", err) + resolve := func(raw string) frostRetainedGroupResolvedEndpoint { + endpoint, _, err := validateFrostRetainedGroupTLSEndpoint(raw) + if err != nil { + t.Fatal(err) + } + resolved, err := resolveFrostRetainedGroupEndpoint( + context.Background(), + endpoint, + nil, + ) + if err != nil { + t.Fatal(err) + } + return resolved + } + exportEndpoint := resolve("https://127.0.0.1:9443/export") + verifierAlias := resolve("https://127.0.0.1:9444/rpc") + primaryAlias := resolve("https://127.0.0.1:9445/rpc") + if !frostRetainedGroupEndpointSetsOverlap( + exportEndpoint, + verifierAlias, + ) || !frostRetainedGroupEndpointSetsOverlap( + exportEndpoint, + primaryAlias, + ) { + t.Fatal("expected resolved shared-backend aliases to be rejected") } fixture := newFrostRetainedGroupHistorySourceFixture(t) @@ -2262,7 +2594,7 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsWrongEndpointAndReorg( Time: 999, Extra: []byte{0xff}, } - _, err = fixture.source.ReadCompleteHistory( + _, err := fixture.source.ReadCompleteHistory( context.Background(), fixture.from, fixture.to, diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index 99cc9fb72f..586690ab52 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -28,7 +28,7 @@ import ( ) const ( - frostRetainedGroupJournalMetadataSchema = "tbtc-frost-retained-group-journal-metadata/v3" + frostRetainedGroupJournalMetadataSchema = "tbtc-frost-retained-group-journal-metadata/v4" frostRetainedGroupJournalBatchSchema = "tbtc-frost-retained-group-journal-batch/v3" frostRetainedGroupJournalStateSchema = "tbtc-frost-retained-group-journal-state/v3" frostRetainedGroupJournalSnapshotSchema = "tbtc-frost-retained-group-journal-snapshot/v4" @@ -54,6 +54,7 @@ const ( frostRetainedGroupQuarantineBatchV1 = "tbtc-frost-retained-group-quarantine-batch/v1" frostRetainedGroupQuarantineStateV1 = "tbtc-frost-retained-group-quarantine-state/v1" frostRetainedGroupJournalMetadataSchemaV2 = "tbtc-frost-retained-group-journal-metadata/v2" + frostRetainedGroupJournalMetadataSchemaV3 = "tbtc-frost-retained-group-journal-metadata/v3" frostRetainedGroupJournalBatchSchemaV2 = "tbtc-frost-retained-group-journal-batch/v2" frostRetainedGroupJournalStateSchemaV2 = "tbtc-frost-retained-group-journal-state/v2" frostRetainedGroupQuarantineMetadataV2 = "tbtc-frost-retained-group-quarantine-metadata/v2" @@ -264,12 +265,6 @@ type FrostRetainedGroupQuarantineLiftCertificate struct { Signatures []FrostRetainedGroupQuarantineLiftSignature `json:"signatures"` } -type FrostRetainedGroupHistoryIdentity struct { - TrustDomainID string - EndpointFingerprint [32]byte - OperatorFingerprint [32]byte -} - type FrostRetainedGroupHistory struct { From FrostPreSignFinality To FrostPreSignFinality @@ -316,6 +311,7 @@ type FrostRetainedGroupCanonicalJournalManifest struct { SourceTrustDomainID string SourceEndpointFingerprint [32]byte SourceOperatorFingerprint [32]byte + SourceIdentity FrostRetainedGroupHistoryIdentity MinimumGeneration uint64 } @@ -484,11 +480,18 @@ func frostRetainedGroupLiftPolicyFromRuntimeManifest( ) } forbidden := map[[32]byte]string{ - runtimeManifest.ActivationAuthorityKeyHash: "activation", - runtimeManifest.AttestationSignerKeyHash: "runtime attestation", - runtimeManifest.HandshakeOperatorFingerprint: "runtime exporter", - runtimeManifest.CanonicalJournal.SourceOperatorFingerprint: "retained history source", - runtimeManifest.VerifierOperatorFingerprint: "history verifier", + runtimeManifest.ActivationAuthorityKeyHash: "activation", + runtimeManifest.AttestationSignerKeyHash: "runtime attestation", + runtimeManifest.HandshakeOperatorFingerprint: "runtime exporter", + runtimeManifest.CanonicalJournal.SourceOperatorFingerprint: "retained history source", + runtimeManifest.VerifierOperatorFingerprint: "history verifier", + runtimeManifest.CanonicalJournal.SourceIdentity.HistorySignerKeyHash: "retained history signer", + runtimeManifest.CanonicalJournal.SourceIdentity.Export.TLSLeafSPKIHash: "retained export TLS leaf", + runtimeManifest.CanonicalJournal.SourceIdentity.Verifier.TLSLeafSPKIHash: "retained verifier TLS leaf", + runtimeManifest.CanonicalJournal.SourceIdentity.Export.BackendServiceFingerprint: "retained export backend", + runtimeManifest.CanonicalJournal.SourceIdentity.Verifier.BackendServiceFingerprint: "retained verifier backend", + runtimeManifest.CanonicalJournal.SourceIdentity.Export.AttestationKeyHash: "retained export attestation", + runtimeManifest.CanonicalJournal.SourceIdentity.Verifier.AttestationKeyHash: "retained verifier attestation", } for hash, role := range forbidden { if hash == [32]byte{} { @@ -791,17 +794,18 @@ func validateFrostRetainedGroupLiftCertificate( } type frostRetainedGroupJournalMetadata struct { - Schema string `json:"schema"` - ManifestHash [32]byte `json:"manifestHash"` - BindingHash [32]byte `json:"bindingHash"` - StoreID string `json:"storeID"` - StoreFingerprint [32]byte `json:"storeFingerprint"` - ClusterFingerprint [32]byte `json:"clusterFingerprint"` - Checkpoint FrostPreSignFinality `json:"checkpoint"` - DescriptorSetHash [32]byte `json:"descriptorSetHash"` - SourceTrustDomainID string `json:"sourceTrustDomainID"` - SourceEndpointFingerprint [32]byte `json:"sourceEndpointFingerprint"` - SourceOperatorFingerprint [32]byte `json:"sourceOperatorFingerprint"` + Schema string `json:"schema"` + ManifestHash [32]byte `json:"manifestHash"` + BindingHash [32]byte `json:"bindingHash"` + StoreID string `json:"storeID"` + StoreFingerprint [32]byte `json:"storeFingerprint"` + ClusterFingerprint [32]byte `json:"clusterFingerprint"` + Checkpoint FrostPreSignFinality `json:"checkpoint"` + DescriptorSetHash [32]byte `json:"descriptorSetHash"` + SourceTrustDomainID string `json:"sourceTrustDomainID"` + SourceEndpointFingerprint [32]byte `json:"sourceEndpointFingerprint"` + SourceOperatorFingerprint [32]byte `json:"sourceOperatorFingerprint"` + SourceIdentity FrostRetainedGroupHistoryIdentity `json:"sourceIdentity"` } type frostRetainedGroupWalletState struct { @@ -1029,6 +1033,10 @@ func newFrostRetainedGroupJournal( strings.TrimSpace(manifest.SourceTrustDomainID) == "" || manifest.SourceEndpointFingerprint == [32]byte{} || manifest.SourceOperatorFingerprint == [32]byte{} || + validateFrostRetainedGroupHistoryIdentity(manifest.SourceIdentity) != nil || + manifest.SourceTrustDomainID != manifest.SourceIdentity.TrustDomainID || + manifest.SourceEndpointFingerprint != manifest.SourceIdentity.EndpointFingerprint || + manifest.SourceOperatorFingerprint != manifest.SourceIdentity.OperatorFingerprint || quarantineManifest.ProtocolID == [32]byte{} || quarantineManifest.LiftProtocolID == [32]byte{} || quarantineManifest.TombstoneProtocolID == [32]byte{} || @@ -1110,6 +1118,7 @@ func newFrostRetainedGroupJournal( SourceTrustDomainID: manifest.SourceTrustDomainID, SourceEndpointFingerprint: manifest.SourceEndpointFingerprint, SourceOperatorFingerprint: manifest.SourceOperatorFingerprint, + SourceIdentity: manifest.SourceIdentity, }, quarantineMetadata: frostRetainedGroupQuarantineMetadata{ Schema: frostRetainedGroupQuarantineMetadataSchema, @@ -1271,7 +1280,8 @@ func (frgj *frostRetainedGroupJournal) initialize() error { return fmt.Errorf("cannot read FROST retained-group journal metadata: [%w]", err) } if stored.Schema == frostRetainedGroupJournalMetadataSchemaV1 || - stored.Schema == frostRetainedGroupJournalMetadataSchemaV2 { + stored.Schema == frostRetainedGroupJournalMetadataSchemaV2 || + stored.Schema == frostRetainedGroupJournalMetadataSchemaV3 { return frostRetainedGroupLegacySchemaError("canonical metadata") } if stored != frgj.metadata { @@ -1376,7 +1386,8 @@ func (frgj *frostRetainedGroupJournal) verifyHistorySourceIdentity( } if identity.TrustDomainID != frgj.metadata.SourceTrustDomainID || identity.EndpointFingerprint != frgj.metadata.SourceEndpointFingerprint || - identity.OperatorFingerprint != frgj.metadata.SourceOperatorFingerprint { + identity.OperatorFingerprint != frgj.metadata.SourceOperatorFingerprint || + identity != frgj.metadata.SourceIdentity { return fmt.Errorf("FROST retained-group history source identity differs from signed manifest") } return nil diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index 7cbedc06f6..5b4bb9edc4 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -217,15 +217,17 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { }, }, } + sourceIdentity := testFrostRetainedGroupCompleteIdentity() manifest := FrostRetainedGroupCanonicalJournalManifest{ StoreID: "journal-store-uuid", StoreFingerprint: [32]byte{0x31}, ClusterFingerprint: [32]byte{0x32}, Checkpoint: checkpoint, DescriptorSetHash: [32]byte{0x33}, - SourceTrustDomainID: "independent-journal-source", - SourceEndpointFingerprint: [32]byte{0x34}, - SourceOperatorFingerprint: [32]byte{0x35}, + SourceTrustDomainID: sourceIdentity.TrustDomainID, + SourceEndpointFingerprint: sourceIdentity.EndpointFingerprint, + SourceOperatorFingerprint: sourceIdentity.OperatorFingerprint, + SourceIdentity: sourceIdentity, MinimumGeneration: 1, } checkpointAuthorities := make([]FrostRetainedGroupAuthority, 3) @@ -286,11 +288,7 @@ func newJournalTestFixture(t *testing.T) *journalTestFixture { QuarantineJournal: quarantine, } source := &journalHistorySource{ - identity: FrostRetainedGroupHistoryIdentity{ - TrustDomainID: manifest.SourceTrustDomainID, - EndpointFingerprint: manifest.SourceEndpointFingerprint, - OperatorFingerprint: manifest.SourceOperatorFingerprint, - }, + identity: sourceIdentity, checkpoint: checkpoint, head: FrostPreSignFinality{BlockNumber: 100, BlockHash: [32]byte{0x64}}, descriptor: manifest.DescriptorSetHash, From f856d535adfd5070652e563bf539e1b1fad4c046 Mon Sep 17 00:00:00 2001 From: maclane Date: Sun, 26 Jul 2026 19:37:45 -0400 Subject: [PATCH 31/93] Route the primary Ethereum chain through an identity-frozen transport Own the exact RPC client used by the primary Ethereum handle: direct TLS 1.3 HTTP/1.1 only, first complete DNS answer frozen, every connection and WebSocket reconnect verified and recorded as a peer identity before it can reach go-ethereum, and the retained-group history source held to a separation policy against the primary transport's trust domain. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DpftF6aYHh56KgiHU6D2NQ --- cmd/start.go | 59 +- pkg/chain/ethereum/ethereum.go | 37 + pkg/tbtc/frost_primary_ethereum_transport.go | 1100 +++++++++++++++++ .../frost_retained_group_endpoint_identity.go | 145 +-- ...t_retained_group_endpoint_identity_test.go | 67 +- .../frost_retained_group_history_source.go | 27 +- ...rost_retained_group_history_source_test.go | 17 +- 7 files changed, 1333 insertions(+), 119 deletions(-) create mode 100644 pkg/tbtc/frost_primary_ethereum_transport.go diff --git a/cmd/start.go b/cmd/start.go index 541cd533f6..7cd916d166 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -65,11 +65,64 @@ Environment variables: func start(cmd *cobra.Command) error { ctx := context.Background() - beaconChain, tbtcChain, blockCounter, signing, operatorPrivateKey, err := - ethereum.Connect(ctx, clientConfig.Ethereum) + var primaryEthereumTransport *tbtc.FrostPrimaryEthereumTransport + var err error + if clientConfig.Tbtc.EnableFrostPreSignAuthorization && + !clientConfig.LibP2P.Bootstrap { + historyConfig := clientConfig.Tbtc.FrostRetainedGroupHistory + primaryEthereumTransport, err = + tbtc.NewFrostPrimaryEthereumTransport( + ctx, + tbtc.FrostPrimaryEthereumTransportConfig{ + URL: clientConfig.Ethereum.URL, + RequestTimeout: historyConfig.RequestTimeout, + TLSRootCAs: historyConfig.PrimaryTLSRootCAs, + Resolver: historyConfig.Resolver, + }, + ) + if err != nil { + return fmt.Errorf( + "cannot initialize guarded primary Ethereum transport: [%w]", + err, + ) + } + } + + var ( + beaconChain *ethereum.BeaconChain + tbtcChain *ethereum.TbtcChain + blockCounter chain.BlockCounter + signing chain.Signing + operatorPrivateKey *operator.PrivateKey + ) + if primaryEthereumTransport != nil { + beaconChain, + tbtcChain, + blockCounter, + signing, + operatorPrivateKey, + err = ethereum.ConnectWithClient( + ctx, + clientConfig.Ethereum, + primaryEthereumTransport.Client(), + ) + } else { + beaconChain, + tbtcChain, + blockCounter, + signing, + operatorPrivateKey, + err = ethereum.Connect(ctx, clientConfig.Ethereum) + } if err != nil { + if primaryEthereumTransport != nil { + primaryEthereumTransport.Close() + } return fmt.Errorf("error connecting to Ethereum node: [%v]", err) } + if primaryEthereumTransport != nil { + defer primaryEthereumTransport.Close() + } netProvider, err := initializeNetwork( ctx, @@ -167,7 +220,7 @@ func start(cmd *cobra.Command) error { source, err := tbtc.NewFrostRetainedGroupHistorySource( ctx, clientConfig.Tbtc.FrostRetainedGroupHistory, - clientConfig.Ethereum.URL, + primaryEthereumTransport, uint64(clientConfig.Ethereum.Network.ChainID()), ) if err != nil { diff --git a/pkg/chain/ethereum/ethereum.go b/pkg/chain/ethereum/ethereum.go index a55054c70d..cedc9ad3f6 100644 --- a/pkg/chain/ethereum/ethereum.go +++ b/pkg/chain/ethereum/ethereum.go @@ -82,7 +82,44 @@ func Connect( err, ) } + return connectWithClient(ctx, config, client) +} + +// ConnectWithClient creates Random Beacon and TBTC Ethereum chain handles +// using the exact supplied client. It is used by the FROST start path so the +// chain handle and retained-history independence monitor share one guarded +// primary transport. +func ConnectWithClient( + ctx context.Context, + config ethereum.Config, + client *ethclient.Client, +) ( + *BeaconChain, + *TbtcChain, + chain.BlockCounter, + chain.Signing, + *operator.PrivateKey, + error, +) { + if client == nil { + return nil, nil, nil, nil, nil, + fmt.Errorf("Ethereum client is nil") + } + return connectWithClient(ctx, config, client) +} +func connectWithClient( + ctx context.Context, + config ethereum.Config, + client *ethclient.Client, +) ( + *BeaconChain, + *TbtcChain, + chain.BlockCounter, + chain.Signing, + *operator.PrivateKey, + error, +) { baseChain, err := newBaseChain(ctx, config, client) if err != nil { return nil, nil, nil, nil, nil, fmt.Errorf( diff --git a/pkg/tbtc/frost_primary_ethereum_transport.go b/pkg/tbtc/frost_primary_ethereum_transport.go new file mode 100644 index 0000000000..330ca54e32 --- /dev/null +++ b/pkg/tbtc/frost_primary_ethereum_transport.go @@ -0,0 +1,1100 @@ +package tbtc + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "net/http" + "net/http/httptrace" + "net/netip" + "net/url" + "path" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/gorilla/websocket" +) + +const ( + frostPrimaryEthereumTLSExporterLabel = "EXPORTER-tbtc-frost-primary-ethereum-v1" + frostPrimaryEthereumTLSExporterContextDomain = "tbtc-frost-primary-ethereum-tls-exporter-context/v1\x00" + frostPrimaryEthereumPeerIdentityDomain = "tbtc-frost-primary-ethereum-peer-identity/v1\x00" + frostPrimaryEthereumMaximumSeenPeers = 4096 +) + +// FrostPrimaryEthereumTransportConfig configures the primary Ethereum +// transport used by a FROST-enabled node. The transport accepts only direct +// TLS 1.3 HTTP/1.1 connections and freezes the first complete DNS answer. +type FrostPrimaryEthereumTransportConfig struct { + URL string + RequestTimeout time.Duration + TLSRootCAs *x509.CertPool + Resolver *net.Resolver +} + +// FrostPrimaryEthereumTransport owns the exact RPC client used by the primary +// Ethereum chain handle. Every HTTP connection and every WebSocket reconnect +// is TLS-verified and recorded before it can reach go-ethereum. +type FrostPrimaryEthereumTransport struct { + mutex sync.RWMutex + endpoint frostRetainedGroupResolvedEndpoint + resolver frostRetainedGroupResolver + timeout time.Duration + rootCAs *x509.CertPool + rpcClient *rpc.Client + client *ethclient.Client + httpTransport *http.Transport + seenPeers map[[32]byte]frostTransportPeerIdentity + livePeers map[[32]byte]uint64 + policy *frostPrimaryRetainedSeparationPolicy + closed bool +} + +type frostTransportPeerIdentity struct { + remoteIP netip.Addr + leafCertificateHash [32]byte + leafSPKIHash [32]byte + spiffeAuthorities []string + tlsExporterValueHash [32]byte +} + +type frostPrimaryTrackedRawConnection struct { + net.Conn + transport *FrostPrimaryEthereumTransport + mutex sync.Mutex + peerKey [32]byte + active bool + closeOnce sync.Once +} + +type frostPrimaryEthereumHTTPRoundTripper struct { + base *http.Transport + transport *FrostPrimaryEthereumTransport + endpoint frostRetainedGroupResolvedEndpoint +} + +type frostPrimaryRetainedEndpointPolicy struct { + endpoint frostRetainedGroupResolvedEndpoint + identity FrostRetainedGroupEndpointIdentity +} + +type frostPrimaryRetainedSeparationPolicy struct { + mutex sync.RWMutex + primary frostRetainedGroupResolvedEndpoint + retained map[string]frostPrimaryRetainedEndpointPolicy + primaryPeers map[[32]byte]frostTransportPeerIdentity + retainedPeers map[string]map[[32]byte]frostTransportPeerIdentity + failure error +} + +// NewFrostPrimaryEthereumTransport creates and probes a primary Ethereum +// client whose actual network channels can be bound to the retained endpoint +// independence policy. +func NewFrostPrimaryEthereumTransport( + ctx context.Context, + config FrostPrimaryEthereumTransportConfig, +) (*FrostPrimaryEthereumTransport, error) { + resolver := frostRetainedGroupResolver(config.Resolver) + if resolver == nil { + resolver = net.DefaultResolver + } + return newFrostPrimaryEthereumTransport(ctx, config, resolver) +} + +func newFrostPrimaryEthereumTransport( + ctx context.Context, + config FrostPrimaryEthereumTransportConfig, + resolver frostRetainedGroupResolver, +) (*FrostPrimaryEthereumTransport, error) { + if ctx == nil { + return nil, fmt.Errorf("primary Ethereum transport context is nil") + } + timeout := config.RequestTimeout + if timeout == 0 { + timeout = frostRetainedGroupDefaultTimeout + } + if timeout < time.Second || timeout > time.Minute { + return nil, fmt.Errorf( + "primary Ethereum request timeout is outside supported bounds", + ) + } + endpointURL, err := validateFrostPrimaryEthereumTLSEndpoint(config.URL) + if err != nil { + return nil, fmt.Errorf("invalid primary Ethereum endpoint: [%w]", err) + } + if resolver == nil { + return nil, fmt.Errorf("primary Ethereum resolver is nil") + } + resolveContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + endpoint, err := resolveFrostRetainedGroupEndpoint( + resolveContext, + endpointURL, + resolver, + ) + if err != nil { + return nil, fmt.Errorf("cannot resolve primary Ethereum endpoint: [%w]", err) + } + var roots *x509.CertPool + if config.TLSRootCAs != nil { + roots = config.TLSRootCAs.Clone() + } + transport := &FrostPrimaryEthereumTransport{ + endpoint: endpoint, + resolver: resolver, + timeout: timeout, + rootCAs: roots, + seenPeers: make(map[[32]byte]frostTransportPeerIdentity), + livePeers: make(map[[32]byte]uint64), + } + + var rpcClient *rpc.Client + switch endpoint.endpoint.Scheme { + case "https": + base := &http.Transport{ + Proxy: nil, + DialTLSContext: transport.dialTLSContext, + DisableCompression: true, + ForceAttemptHTTP2: false, + MaxIdleConns: 16, + MaxIdleConnsPerHost: 16, + MaxConnsPerHost: 16, + IdleConnTimeout: 90 * time.Second, + ResponseHeaderTimeout: timeout, + ExpectContinueTimeout: time.Second, + MaxResponseHeaderBytes: 32 * 1024, + } + transport.httpTransport = base + roundTripper := &frostPrimaryEthereumHTTPRoundTripper{ + base: base, + transport: transport, + endpoint: endpoint, + } + httpClient := &http.Client{ + Transport: roundTripper, + Timeout: timeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return fmt.Errorf("primary Ethereum redirects are forbidden") + }, + } + rpcClient, err = rpc.DialOptions( + ctx, + endpoint.canonical, + rpc.WithHTTPClient(httpClient), + ) + case "wss": + dialer := websocket.Dialer{ + NetDialTLSContext: transport.dialTLSContext, + Proxy: nil, + HandshakeTimeout: timeout, + EnableCompression: false, + ReadBufferSize: 1024, + WriteBufferSize: 1024, + } + rpcClient, err = rpc.DialOptions( + ctx, + endpoint.canonical, + rpc.WithWebsocketDialer(dialer), + ) + default: + err = fmt.Errorf("primary Ethereum endpoint scheme is unsupported") + } + if err != nil { + transport.closeConnections() + return nil, fmt.Errorf("cannot dial guarded primary Ethereum endpoint: [%w]", err) + } + transport.rpcClient = rpcClient + transport.client = ethclient.NewClient(rpcClient) + + probeContext, probeCancel := context.WithTimeout(ctx, timeout) + defer probeCancel() + chainID, err := transport.client.ChainID(probeContext) + if err != nil { + transport.Close() + return nil, fmt.Errorf( + "cannot probe guarded primary Ethereum endpoint: [%w]", + err, + ) + } + if chainID == nil || !chainID.IsUint64() || chainID.Sign() <= 0 { + transport.Close() + return nil, fmt.Errorf( + "guarded primary Ethereum endpoint returned an invalid chain ID", + ) + } + transport.mutex.RLock() + hasPeer := len(transport.seenPeers) > 0 + transport.mutex.RUnlock() + if !hasPeer { + transport.Close() + return nil, fmt.Errorf( + "guarded primary Ethereum probe has no authenticated peer", + ) + } + return transport, nil +} + +// Client returns the exact client whose connections are guarded by this +// transport. It must be passed to ethereum.ConnectWithClient. +func (transport *FrostPrimaryEthereumTransport) Client() *ethclient.Client { + if transport == nil { + return nil + } + transport.mutex.RLock() + defer transport.mutex.RUnlock() + if transport.closed { + return nil + } + return transport.client +} + +// Close closes the primary RPC client and all idle HTTP connections. +func (transport *FrostPrimaryEthereumTransport) Close() { + if transport == nil { + return + } + transport.mutex.Lock() + if transport.closed { + transport.mutex.Unlock() + return + } + transport.closed = true + rpcClient := transport.rpcClient + httpTransport := transport.httpTransport + transport.mutex.Unlock() + if rpcClient != nil { + rpcClient.Close() + } + if httpTransport != nil { + httpTransport.CloseIdleConnections() + } +} + +func (transport *FrostPrimaryEthereumTransport) closeConnections() { + if transport == nil || transport.httpTransport == nil { + return + } + transport.httpTransport.CloseIdleConnections() +} + +func validateFrostPrimaryEthereumTLSEndpoint(raw string) (*url.URL, error) { + if raw == "" || raw != strings.TrimSpace(raw) { + return nil, fmt.Errorf("URL is empty or has surrounding whitespace") + } + endpoint, err := url.Parse(raw) + if err != nil || endpoint.Host == "" || endpoint.User != nil || + endpoint.Fragment != "" || endpoint.Opaque != "" || + endpoint.RawPath != "" || endpoint.ForceQuery || + strings.Contains(raw, "\\") { + return nil, fmt.Errorf("URL is not an unambiguous TLS endpoint") + } + if endpoint.Scheme != "https" && endpoint.Scheme != "wss" { + return nil, fmt.Errorf("URL must use HTTPS or WSS") + } + hostname := endpoint.Hostname() + if hostname == "" || hostname != strings.ToLower(hostname) || + strings.HasSuffix(hostname, ".") || + !validFrostRetainedGroupEndpointHostname(hostname) { + return nil, fmt.Errorf("URL hostname is not canonical") + } + port := endpoint.Port() + if port == "" { + port = "443" + } else { + parsedPort, parseErr := strconv.ParseUint(port, 10, 16) + if parseErr != nil || parsedPort == 0 || + strconv.FormatUint(parsedPort, 10) != port { + return nil, fmt.Errorf("URL port is not canonical") + } + } + endpoint.Host = net.JoinHostPort(hostname, port) + if endpoint.Path == "" { + endpoint.Path = "/" + } + if !strings.HasPrefix(endpoint.Path, "/") || + path.Clean(endpoint.Path) != endpoint.Path || + (endpoint.Path != "/" && strings.HasSuffix(endpoint.Path, "/")) || + strings.Contains(endpoint.Path, "//") || + endpoint.EscapedPath() != endpoint.Path { + return nil, fmt.Errorf("URL path is not canonical") + } + if endpoint.RawQuery != "" { + query, parseErr := url.ParseQuery(endpoint.RawQuery) + if parseErr != nil || query.Encode() != endpoint.RawQuery { + return nil, fmt.Errorf("URL query is not canonical") + } + } + return endpoint, nil +} + +func (transport *FrostPrimaryEthereumTransport) tlsConfig() *tls.Config { + var roots *x509.CertPool + if transport.rootCAs != nil { + roots = transport.rootCAs.Clone() + } + return &tls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + ServerName: transport.endpoint.endpoint.Hostname(), + RootCAs: roots, + NextProtos: []string{"http/1.1"}, + } +} + +func (transport *FrostPrimaryEthereumTransport) dialTLSContext( + ctx context.Context, + network string, + address string, +) (net.Conn, error) { + if transport == nil || ctx == nil || + !strings.HasPrefix(network, "tcp") { + return nil, fmt.Errorf("primary Ethereum TLS dial is invalid") + } + host, port, err := net.SplitHostPort(address) + if err != nil || host != transport.endpoint.endpoint.Hostname() || + port != transport.endpoint.endpoint.Port() { + return nil, fmt.Errorf( + "primary Ethereum transport attempted an unpinned endpoint", + ) + } + dialer := &net.Dialer{ + Timeout: transport.timeout, + KeepAlive: 30 * time.Second, + } + var lastErr error + for _, pinned := range transport.endpoint.addresses { + raw, dialErr := dialer.DialContext( + ctx, + network, + net.JoinHostPort(pinned.String(), port), + ) + if dialErr != nil { + lastErr = dialErr + continue + } + tracked := &frostPrimaryTrackedRawConnection{ + Conn: raw, + transport: transport, + } + tlsConnection := tls.Client(tracked, transport.tlsConfig()) + if handshakeErr := tlsConnection.HandshakeContext(ctx); handshakeErr != nil { + _ = tlsConnection.Close() + lastErr = handshakeErr + continue + } + state := tlsConnection.ConnectionState() + if verifyErr := verifyFrostPrimaryEthereumTLSConnection( + state, + transport.endpoint, + ); verifyErr != nil { + _ = tlsConnection.Close() + return nil, verifyErr + } + peer, peerErr := frostTransportPeerIdentityFromTLS( + transport.endpoint, + tlsConnection.RemoteAddr(), + state, + ) + if peerErr != nil { + _ = tlsConnection.Close() + return nil, peerErr + } + peerKey := frostTransportPeerIdentityKey(peer) + if recordErr := transport.recordPeer(peerKey, peer); recordErr != nil { + _ = tlsConnection.Close() + return nil, recordErr + } + tracked.activate(peerKey) + return tlsConnection, nil + } + if lastErr == nil { + lastErr = fmt.Errorf("primary Ethereum endpoint has no pinned addresses") + } + return nil, fmt.Errorf( + "cannot connect to a pinned primary Ethereum address: [%w]", + lastErr, + ) +} + +func verifyFrostPrimaryEthereumTLSConnection( + state tls.ConnectionState, + endpoint frostRetainedGroupResolvedEndpoint, +) error { + if endpoint.endpoint == nil || len(state.VerifiedChains) == 0 || + len(state.PeerCertificates) == 0 { + return fmt.Errorf("primary Ethereum TLS peer is not PKIX-verified") + } + if state.Version != tls.VersionTLS13 || + state.NegotiatedProtocol != "http/1.1" || + !state.NegotiatedProtocolIsMutual { + return fmt.Errorf("primary Ethereum TLS protocol profile mismatch") + } + if err := state.PeerCertificates[0].VerifyHostname( + endpoint.endpoint.Hostname(), + ); err != nil { + return fmt.Errorf("primary Ethereum TLS hostname mismatch: [%w]", err) + } + return nil +} + +func frostTransportPeerIdentityFromTLS( + endpoint frostRetainedGroupResolvedEndpoint, + remote net.Addr, + state tls.ConnectionState, +) (frostTransportPeerIdentity, error) { + if endpoint.endpoint == nil || remote == nil || + len(state.PeerCertificates) == 0 || len(state.VerifiedChains) == 0 || + !state.HandshakeComplete || state.Version != tls.VersionTLS13 || + state.NegotiatedProtocol != "http/1.1" { + return frostTransportPeerIdentity{}, + fmt.Errorf("TLS peer observation is incomplete") + } + remoteHost, _, err := net.SplitHostPort(remote.String()) + if err != nil { + return frostTransportPeerIdentity{}, + fmt.Errorf("TLS peer address is invalid") + } + remoteIP, err := netip.ParseAddr(remoteHost) + if err != nil || !remoteIP.IsValid() || remoteIP.Zone() != "" { + return frostTransportPeerIdentity{}, + fmt.Errorf("TLS peer IP is invalid") + } + remoteIP = remoteIP.Unmap() + found := false + for _, pinned := range endpoint.addresses { + found = found || pinned == remoteIP + } + if !found { + return frostTransportPeerIdentity{}, + fmt.Errorf("TLS peer IP is outside the frozen address set") + } + leaf := state.PeerCertificates[0] + authorities := make([]string, 0) + seenAuthorities := make(map[string]bool) + for _, serviceIdentity := range leaf.URIs { + if serviceIdentity == nil || serviceIdentity.Scheme != "spiffe" { + continue + } + if err := validateFrostRetainedGroupServiceIdentity( + serviceIdentity.String(), + ); err != nil { + return frostTransportPeerIdentity{}, + fmt.Errorf("TLS peer has an invalid SPIFFE identity: [%w]", err) + } + authority := serviceIdentity.Hostname() + if !seenAuthorities[authority] { + seenAuthorities[authority] = true + authorities = append(authorities, authority) + } + } + sort.Strings(authorities) + leafSPKIHash := sha256.Sum256(leaf.RawSubjectPublicKeyInfo) + contextTranscript := frostRetainedGroupIdentityTranscript( + frostPrimaryEthereumTLSExporterContextDomain, + ) + contextTranscript.text("endpoint", endpoint.canonical) + contextTranscript.text("remoteIP", remoteIP.String()) + contextTranscript.bytes32("leafSpkiHash", leafSPKIHash) + exporterContext := contextTranscript.sum() + exporterValue, err := state.ExportKeyingMaterial( + frostPrimaryEthereumTLSExporterLabel, + exporterContext[:], + 32, + ) + if err != nil { + return frostTransportPeerIdentity{}, + fmt.Errorf("cannot derive primary Ethereum TLS exporter: [%w]", err) + } + return frostTransportPeerIdentity{ + remoteIP: remoteIP, + leafCertificateHash: sha256.Sum256(leaf.Raw), + leafSPKIHash: leafSPKIHash, + spiffeAuthorities: authorities, + tlsExporterValueHash: sha256.Sum256(exporterValue), + }, nil +} + +func frostTransportPeerIdentityKey( + peer frostTransportPeerIdentity, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostPrimaryEthereumPeerIdentityDomain, + ) + transcript.text("remoteIP", peer.remoteIP.String()) + transcript.bytes32("leafCertificateHash", peer.leafCertificateHash) + transcript.bytes32("leafSpkiHash", peer.leafSPKIHash) + transcript.uint64( + "spiffeAuthorityCount", + uint64(len(peer.spiffeAuthorities)), + ) + for index, authority := range peer.spiffeAuthorities { + transcript.text( + fmt.Sprintf("spiffeAuthority[%d]", index), + authority, + ) + } + transcript.bytes32( + "tlsExporterValueHash", + peer.tlsExporterValueHash, + ) + return transcript.sum() +} + +func (transport *FrostPrimaryEthereumTransport) recordPeer( + key [32]byte, + peer frostTransportPeerIdentity, +) error { + transport.mutex.Lock() + defer transport.mutex.Unlock() + if transport.closed { + return fmt.Errorf("primary Ethereum transport is closed") + } + if _, exists := transport.seenPeers[key]; !exists && + len(transport.seenPeers) >= frostPrimaryEthereumMaximumSeenPeers { + return fmt.Errorf("primary Ethereum peer history limit exceeded") + } + transport.seenPeers[key] = peer + if transport.policy != nil { + if err := transport.policy.registerPrimaryPeer(key, peer); err != nil { + return err + } + } + transport.livePeers[key]++ + return nil +} + +func (connection *frostPrimaryTrackedRawConnection) activate(key [32]byte) { + connection.mutex.Lock() + connection.peerKey = key + connection.active = true + connection.mutex.Unlock() +} + +func (connection *frostPrimaryTrackedRawConnection) Close() error { + err := connection.Conn.Close() + connection.closeOnce.Do(func() { + connection.mutex.Lock() + key := connection.peerKey + active := connection.active + connection.mutex.Unlock() + if active && connection.transport != nil { + connection.transport.releaseLivePeer(key) + } + }) + return err +} + +func (transport *FrostPrimaryEthereumTransport) releaseLivePeer( + key [32]byte, +) { + transport.mutex.Lock() + defer transport.mutex.Unlock() + count := transport.livePeers[key] + if count <= 1 { + delete(transport.livePeers, key) + return + } + transport.livePeers[key] = count - 1 +} + +func (roundTripper *frostPrimaryEthereumHTTPRoundTripper) RoundTrip( + request *http.Request, +) (*http.Response, error) { + if roundTripper == nil || roundTripper.base == nil || + roundTripper.transport == nil || request == nil || + request.URL == nil || request.Method != http.MethodPost || + request.URL.String() != roundTripper.endpoint.canonical || + (request.Host != "" && request.Host != roundTripper.endpoint.endpoint.Host) || + request.Header.Get("Accept-Encoding") != "" { + return nil, fmt.Errorf("primary Ethereum HTTP request escaped its pinned target") + } + connection := make(chan net.Conn, 1) + trace := &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + if info.Conn != nil { + select { + case connection <- info.Conn: + default: + } + } + }, + } + request = request.WithContext( + httptrace.WithClientTrace(request.Context(), trace), + ) + response, err := roundTripper.base.RoundTrip(request) + if err != nil { + return nil, err + } + var actual net.Conn + select { + case actual = <-connection: + default: + _ = response.Body.Close() + return nil, fmt.Errorf( + "primary Ethereum response has no exact connection observation", + ) + } + tlsConnection, ok := actual.(*tls.Conn) + if !ok || response.TLS == nil { + _ = response.Body.Close() + return nil, fmt.Errorf( + "primary Ethereum response did not use the guarded TLS connection", + ) + } + if err := verifyFrostPrimaryEthereumTLSConnection( + *response.TLS, + roundTripper.endpoint, + ); err != nil { + _ = response.Body.Close() + return nil, err + } + peer, err := frostTransportPeerIdentityFromTLS( + roundTripper.endpoint, + tlsConnection.RemoteAddr(), + *response.TLS, + ) + if err != nil { + _ = response.Body.Close() + return nil, err + } + if err := roundTripper.transport.verifySeenPeer( + frostTransportPeerIdentityKey(peer), + ); err != nil { + _ = response.Body.Close() + return nil, err + } + if response.Uncompressed || + (response.Header.Get("Content-Encoding") != "" && + response.Header.Get("Content-Encoding") != "identity") { + _ = response.Body.Close() + return nil, fmt.Errorf( + "primary Ethereum response transformation is forbidden", + ) + } + return response, nil +} + +func (transport *FrostPrimaryEthereumTransport) verifySeenPeer( + key [32]byte, +) error { + transport.mutex.RLock() + defer transport.mutex.RUnlock() + if transport.closed { + return fmt.Errorf("primary Ethereum transport is closed") + } + if _, exists := transport.seenPeers[key]; !exists { + return fmt.Errorf( + "primary Ethereum request used an unauthenticated TLS channel", + ) + } + return nil +} + +func (transport *FrostPrimaryEthereumTransport) bindRetainedEndpoints( + exportEndpoint frostRetainedGroupResolvedEndpoint, + exportIdentity FrostRetainedGroupEndpointIdentity, + verifierEndpoint frostRetainedGroupResolvedEndpoint, + verifierIdentity FrostRetainedGroupEndpointIdentity, +) (*frostPrimaryRetainedSeparationPolicy, error) { + if transport == nil { + return nil, fmt.Errorf("primary Ethereum transport is nil") + } + policy, err := newFrostPrimaryRetainedSeparationPolicy( + transport.endpoint, + exportEndpoint, + exportIdentity, + verifierEndpoint, + verifierIdentity, + ) + if err != nil { + return nil, err + } + transport.mutex.Lock() + defer transport.mutex.Unlock() + if transport.closed || transport.policy != nil || + len(transport.seenPeers) == 0 { + return nil, fmt.Errorf( + "primary Ethereum transport cannot bind retained endpoints", + ) + } + for key, peer := range transport.seenPeers { + if err := policy.registerPrimaryPeer(key, peer); err != nil { + return nil, err + } + } + transport.policy = policy + return policy, nil +} + +func newFrostPrimaryRetainedSeparationPolicy( + primary frostRetainedGroupResolvedEndpoint, + exportEndpoint frostRetainedGroupResolvedEndpoint, + exportIdentity FrostRetainedGroupEndpointIdentity, + verifierEndpoint frostRetainedGroupResolvedEndpoint, + verifierIdentity FrostRetainedGroupEndpointIdentity, +) (*frostPrimaryRetainedSeparationPolicy, error) { + for role, value := range map[string]frostPrimaryRetainedEndpointPolicy{ + "retained-history-export": { + endpoint: exportEndpoint, + identity: exportIdentity, + }, + "retained-history-verifier": { + endpoint: verifierEndpoint, + identity: verifierIdentity, + }, + } { + if value.identity.Role != role || + !frostResolvedEndpointMatchesIdentity( + value.endpoint, + value.identity, + ) { + return nil, fmt.Errorf( + "retained endpoint policy differs from its identity", + ) + } + if frostRetainedGroupEndpointSetsOverlap(primary, value.endpoint) { + return nil, fmt.Errorf( + "primary Ethereum frozen endpoint aliases %s", + role, + ) + } + } + return &frostPrimaryRetainedSeparationPolicy{ + primary: primary, + retained: map[string]frostPrimaryRetainedEndpointPolicy{ + "retained-history-export": { + endpoint: exportEndpoint, + identity: exportIdentity, + }, + "retained-history-verifier": { + endpoint: verifierEndpoint, + identity: verifierIdentity, + }, + }, + primaryPeers: make(map[[32]byte]frostTransportPeerIdentity), + retainedPeers: map[string]map[[32]byte]frostTransportPeerIdentity{ + "retained-history-export": {}, + "retained-history-verifier": {}, + }, + }, nil +} + +func frostResolvedEndpointMatchesIdentity( + endpoint frostRetainedGroupResolvedEndpoint, + identity FrostRetainedGroupEndpointIdentity, +) bool { + return endpoint.endpoint != nil && + endpoint.canonical == identity.CanonicalEndpoint && + endpoint.canonicalDNSName == identity.CanonicalDNSName && + endpoint.resolvedDNSName == identity.ResolvedDNSName && + endpoint.addressSetHash == identity.ResolvedAddressSetHash +} + +func (policy *frostPrimaryRetainedSeparationPolicy) registerPrimaryPeer( + key [32]byte, + peer frostTransportPeerIdentity, +) error { + policy.mutex.Lock() + defer policy.mutex.Unlock() + if policy.failure != nil { + return policy.failure + } + if _, exists := policy.primaryPeers[key]; !exists && + len(policy.primaryPeers) >= frostPrimaryEthereumMaximumSeenPeers { + policy.failure = fmt.Errorf( + "primary Ethereum policy peer history limit exceeded", + ) + return policy.failure + } + policy.primaryPeers[key] = peer + if !frostAddressSetContains(policy.primary.addresses, peer.remoteIP) { + policy.failure = fmt.Errorf( + "primary Ethereum actual peer is outside its frozen address set", + ) + return policy.failure + } + for role, retained := range policy.retained { + if err := frostPeerIndependentOfFrozenEndpoint( + "primary Ethereum", + peer, + role, + retained, + ); err != nil { + policy.failure = err + return err + } + for _, retainedPeer := range policy.retainedPeers[role] { + if err := frostTransportPeersIndependent( + "primary Ethereum", + peer, + role, + retainedPeer, + ); err != nil { + policy.failure = err + return err + } + } + } + return nil +} + +func (policy *frostPrimaryRetainedSeparationPolicy) registerRetainedPeer( + role string, + peer frostTransportPeerIdentity, +) error { + policy.mutex.Lock() + defer policy.mutex.Unlock() + if policy.failure != nil { + return policy.failure + } + retained, exists := policy.retained[role] + if !exists { + return fmt.Errorf("retained peer role is outside the separation policy") + } + peers := policy.retainedPeers[role] + key := frostTransportPeerIdentityKey(peer) + if _, exists := peers[key]; !exists && + len(peers) >= frostPrimaryEthereumMaximumSeenPeers { + policy.failure = fmt.Errorf( + "%s peer history limit exceeded", + role, + ) + return policy.failure + } + peers[key] = peer + if !frostAddressSetContains(retained.endpoint.addresses, peer.remoteIP) || + peer.leafSPKIHash != retained.identity.TLSLeafSPKIHash || + !frostStringSetContains( + peer.spiffeAuthorities, + retained.identity.TrustDomainID, + ) { + policy.failure = fmt.Errorf( + "%s actual TLS peer differs from its frozen identity", + role, + ) + return policy.failure + } + if frostAddressSetContains(policy.primary.addresses, peer.remoteIP) { + policy.failure = fmt.Errorf( + "%s actual peer aliases the primary Ethereum frozen address set", + role, + ) + return policy.failure + } + for _, primaryPeer := range policy.primaryPeers { + if err := frostTransportPeersIndependent( + role, + peer, + "primary Ethereum", + primaryPeer, + ); err != nil { + policy.failure = err + return err + } + } + return nil +} + +func frostPeerIndependentOfFrozenEndpoint( + peerRole string, + peer frostTransportPeerIdentity, + frozenRole string, + frozen frostPrimaryRetainedEndpointPolicy, +) error { + if frostAddressSetContains(frozen.endpoint.addresses, peer.remoteIP) { + return fmt.Errorf( + "%s actual peer aliases %s frozen address set", + peerRole, + frozenRole, + ) + } + if peer.leafSPKIHash == frozen.identity.TLSLeafSPKIHash { + return fmt.Errorf( + "%s TLS leaf SPKI aliases %s", + peerRole, + frozenRole, + ) + } + if frostStringSetContains( + peer.spiffeAuthorities, + frozen.identity.TrustDomainID, + ) { + return fmt.Errorf( + "%s SPIFFE authority aliases %s", + peerRole, + frozenRole, + ) + } + return nil +} + +func frostTransportPeersIndependent( + leftRole string, + left frostTransportPeerIdentity, + rightRole string, + right frostTransportPeerIdentity, +) error { + if left.remoteIP == right.remoteIP { + return fmt.Errorf( + "%s actual peer IP aliases %s", + leftRole, + rightRole, + ) + } + if left.leafCertificateHash == right.leafCertificateHash { + return fmt.Errorf( + "%s TLS leaf certificate aliases %s", + leftRole, + rightRole, + ) + } + if left.leafSPKIHash == right.leafSPKIHash { + return fmt.Errorf( + "%s TLS leaf SPKI aliases %s", + leftRole, + rightRole, + ) + } + for _, authority := range left.spiffeAuthorities { + if frostStringSetContains(right.spiffeAuthorities, authority) { + return fmt.Errorf( + "%s SPIFFE authority aliases %s", + leftRole, + rightRole, + ) + } + } + return nil +} + +func frostAddressSetContains(addresses []netip.Addr, target netip.Addr) bool { + for _, address := range addresses { + if address == target { + return true + } + } + return false +} + +func frostStringSetContains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func (policy *frostPrimaryRetainedSeparationPolicy) verify() error { + if policy == nil { + return fmt.Errorf("primary/retained separation policy is nil") + } + policy.mutex.RLock() + defer policy.mutex.RUnlock() + if policy.failure != nil { + return policy.failure + } + if len(policy.primaryPeers) == 0 { + return fmt.Errorf( + "primary/retained separation policy has no primary TLS peer", + ) + } + return nil +} + +func (transport *FrostPrimaryEthereumTransport) verifyIndependence( + ctx context.Context, + exportEndpoint frostRetainedGroupResolvedEndpoint, + verifierEndpoint frostRetainedGroupResolvedEndpoint, +) error { + if transport == nil || ctx == nil { + return fmt.Errorf("primary Ethereum transport verification is incomplete") + } + transport.mutex.RLock() + if transport.closed || transport.policy == nil { + transport.mutex.RUnlock() + return fmt.Errorf("primary Ethereum transport is not bound") + } + endpoint := transport.endpoint + resolver := transport.resolver + timeout := transport.timeout + policy := transport.policy + transport.mutex.RUnlock() + if frostRetainedGroupEndpointSetsOverlap(endpoint, exportEndpoint) || + frostRetainedGroupEndpointSetsOverlap(endpoint, verifierEndpoint) { + return fmt.Errorf( + "primary Ethereum frozen endpoint aliases a retained endpoint", + ) + } + resolveContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + current, err := resolveFrostRetainedGroupEndpoint( + resolveContext, + endpoint.endpoint, + resolver, + ) + if err != nil { + return fmt.Errorf( + "cannot re-resolve primary Ethereum endpoint: [%w]", + err, + ) + } + if current.canonical != endpoint.canonical || + current.canonicalDNSName != endpoint.canonicalDNSName || + current.resolvedDNSName != endpoint.resolvedDNSName || + current.addressSetHash != endpoint.addressSetHash { + return fmt.Errorf("primary Ethereum DNS identity drifted") + } + if frostRetainedGroupEndpointSetsOverlap(current, exportEndpoint) || + frostRetainedGroupEndpointSetsOverlap(current, verifierEndpoint) { + return fmt.Errorf( + "primary Ethereum endpoint now aliases a retained endpoint", + ) + } + return policy.verify() +} + +func (transport *FrostPrimaryEthereumTransport) frozenEndpoint() ( + frostRetainedGroupResolvedEndpoint, + frostRetainedGroupResolver, + error, +) { + if transport == nil { + return frostRetainedGroupResolvedEndpoint{}, nil, + fmt.Errorf("primary Ethereum transport is nil") + } + transport.mutex.RLock() + defer transport.mutex.RUnlock() + if transport.closed || transport.endpoint.endpoint == nil || + transport.resolver == nil { + return frostRetainedGroupResolvedEndpoint{}, nil, + fmt.Errorf("primary Ethereum transport is incomplete") + } + endpoint := transport.endpoint + endpoint.addresses = append([]netip.Addr{}, endpoint.addresses...) + return endpoint, transport.resolver, nil +} + +func (transport *FrostPrimaryEthereumTransport) peerCounts() ( + seen int, + live uint64, +) { + if transport == nil { + return 0, 0 + } + transport.mutex.RLock() + defer transport.mutex.RUnlock() + for _, count := range transport.livePeers { + live += count + } + return len(transport.seenPeers), live +} diff --git a/pkg/tbtc/frost_retained_group_endpoint_identity.go b/pkg/tbtc/frost_retained_group_endpoint_identity.go index 49d470dd60..0dd10eea69 100644 --- a/pkg/tbtc/frost_retained_group_endpoint_identity.go +++ b/pkg/tbtc/frost_retained_group_endpoint_identity.go @@ -166,6 +166,7 @@ type frostRetainedGroupAttestedRoundTripper struct { base http.RoundTripper endpoint frostRetainedGroupResolvedEndpoint identity FrostRetainedGroupEndpointIdentity + separationPolicy *frostPrimaryRetainedSeparationPolicy random io.Reader now func() time.Time maximumBodyBytes int64 @@ -187,12 +188,18 @@ type frostRetainedGroupResolver interface { LookupNetIP(context.Context, string, string) ([]netip.Addr, error) } +type frostPrimaryEthereumIndependenceVerifier interface { + verifyIndependence( + context.Context, + frostRetainedGroupResolvedEndpoint, + frostRetainedGroupResolvedEndpoint, + ) error +} + type frostRetainedGroupIndependenceMonitor struct { exportEndpoint frostRetainedGroupResolvedEndpoint verifierEndpoint frostRetainedGroupResolvedEndpoint - primaryEndpoint frostRetainedGroupResolvedEndpoint - resolver frostRetainedGroupResolver - timeout time.Duration + primaryTransport frostPrimaryEthereumIndependenceVerifier } func frostRetainedGroupTLSExporterProtocolID() [32]byte { @@ -804,7 +811,7 @@ func resolveFrostRetainedGroupEndpoint( func validateAndResolveFrostRetainedGroupSourceConfig( ctx context.Context, config FrostRetainedGroupHistorySourceConfig, - primaryEthereumURL string, + primaryTransport *FrostPrimaryEthereumTransport, ) (*frostRetainedGroupValidatedSourceConfig, error) { if ctx == nil { return nil, fmt.Errorf("retained-group source validation context is nil") @@ -830,15 +837,12 @@ func validateAndResolveFrostRetainedGroupSourceConfig( if err != nil { return nil, fmt.Errorf("invalid retained-group Ethereum URL: [%w]", err) } - primaryURL, err := validateFrostRetainedGroupPrimaryEndpoint( - primaryEthereumURL, - ) + primaryEndpoint, resolver, err := primaryTransport.frozenEndpoint() if err != nil { - return nil, fmt.Errorf("invalid primary Ethereum URL: [%w]", err) - } - resolver := config.Resolver - if resolver == nil { - resolver = net.DefaultResolver + return nil, fmt.Errorf( + "invalid guarded primary Ethereum transport: [%w]", + err, + ) } resolveContext, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() @@ -858,14 +862,6 @@ func validateAndResolveFrostRetainedGroupSourceConfig( if err != nil { return nil, fmt.Errorf("cannot resolve retained-group Ethereum URL: [%w]", err) } - primaryEndpoint, err := resolveFrostRetainedGroupEndpoint( - resolveContext, - primaryURL, - resolver, - ) - if err != nil { - return nil, fmt.Errorf("cannot resolve primary Ethereum URL: [%w]", err) - } if frostRetainedGroupEndpointSetsOverlap(exportEndpoint, verifierEndpoint) { return nil, fmt.Errorf( "retained-group exporter and Ethereum verifier resolve to an alias or shared backend", @@ -1057,82 +1053,21 @@ func validateAndResolveFrostRetainedGroupSourceConfig( }, nil } -func validateFrostRetainedGroupPrimaryEndpoint(raw string) (*url.URL, error) { - if raw == "" || raw != strings.TrimSpace(raw) { - return nil, fmt.Errorf("URL is empty or has surrounding whitespace") - } - endpoint, err := url.Parse(raw) - if err != nil || endpoint.Host == "" || endpoint.User != nil || - endpoint.Fragment != "" || endpoint.Opaque != "" { - return nil, fmt.Errorf("URL is not an absolute credential-free endpoint") - } - switch endpoint.Scheme { - case "https", "wss": - if endpoint.Port() == "" { - endpoint.Host = net.JoinHostPort(endpoint.Hostname(), "443") - } - case "http", "ws": - if endpoint.Port() == "" { - endpoint.Host = net.JoinHostPort(endpoint.Hostname(), "80") - } - default: - return nil, fmt.Errorf("URL scheme is unsupported") - } - hostname := endpoint.Hostname() - if hostname == "" || hostname != strings.ToLower(hostname) || - strings.HasSuffix(hostname, ".") || - !validFrostRetainedGroupEndpointHostname(hostname) { - return nil, fmt.Errorf("URL hostname is not canonical") - } - port := endpoint.Port() - parsedPort, err := strconv.ParseUint(port, 10, 16) - if err != nil || parsedPort == 0 { - return nil, fmt.Errorf("URL port is invalid") - } - endpoint.Host = net.JoinHostPort(hostname, strconv.FormatUint(parsedPort, 10)) - if endpoint.Path == "" { - endpoint.Path = "/" - } - return endpoint, nil -} - func (monitor *frostRetainedGroupIndependenceMonitor) verify( ctx context.Context, ) error { if monitor == nil || ctx == nil || monitor.exportEndpoint.endpoint == nil || monitor.verifierEndpoint.endpoint == nil || - monitor.primaryEndpoint.endpoint == nil || - monitor.timeout < time.Second || monitor.timeout > time.Minute { + monitor.primaryTransport == nil { return fmt.Errorf("retained-group endpoint independence monitor is incomplete") } - resolver := monitor.resolver - if resolver == nil { - resolver = net.DefaultResolver - } - resolveContext, cancel := context.WithTimeout(ctx, monitor.timeout) - defer cancel() - currentPrimary, err := resolveFrostRetainedGroupEndpoint( - resolveContext, - monitor.primaryEndpoint.endpoint, - resolver, - ) - if err != nil { - return fmt.Errorf( - "cannot re-resolve primary Ethereum endpoint: [%w]", - err, - ) - } - if frostRetainedGroupEndpointSetsOverlap( - currentPrimary, + if err := monitor.primaryTransport.verifyIndependence( + ctx, monitor.exportEndpoint, - ) || frostRetainedGroupEndpointSetsOverlap( - currentPrimary, monitor.verifierEndpoint, - ) { - return fmt.Errorf( - "primary Ethereum endpoint now aliases a retained-group endpoint", - ) + ); err != nil { + return fmt.Errorf("primary Ethereum transport is not independent: [%w]", err) } return nil } @@ -1263,6 +1198,22 @@ func newFrostRetainedGroupAttestedHTTPClient( identity FrostRetainedGroupEndpointIdentity, rootCAs *x509.CertPool, timeout time.Duration, +) (*http.Client, *http.Transport, error) { + return newFrostRetainedGroupAttestedHTTPClientWithSeparationPolicy( + endpoint, + identity, + rootCAs, + timeout, + nil, + ) +} + +func newFrostRetainedGroupAttestedHTTPClientWithSeparationPolicy( + endpoint frostRetainedGroupResolvedEndpoint, + identity FrostRetainedGroupEndpointIdentity, + rootCAs *x509.CertPool, + timeout time.Duration, + separationPolicy *frostPrimaryRetainedSeparationPolicy, ) (*http.Client, *http.Transport, error) { if endpoint.endpoint == nil || endpoint.canonical != identity.CanonicalEndpoint || endpoint.canonicalDNSName != identity.CanonicalDNSName || @@ -1293,6 +1244,7 @@ func newFrostRetainedGroupAttestedHTTPClient( base: transport, endpoint: endpoint, identity: identity, + separationPolicy: separationPolicy, maximumBodyBytes: frostRetainedGroupMaximumTransportBodyBytes, maximumClockSkew: frostRetainedGroupTransportClockSkew, maximumLifetime: frostRetainedGroupTransportAttestationLifetime, @@ -1636,6 +1588,27 @@ func (roundTripper *frostRetainedGroupAttestedRoundTripper) RoundTrip( ); err != nil { return nil, err } + if roundTripper.separationPolicy != nil { + if response.TLS == nil { + return nil, fmt.Errorf( + "retained-group response has no exact TLS peer state", + ) + } + peer, err := frostTransportPeerIdentityFromTLS( + roundTripper.endpoint, + remote, + *response.TLS, + ) + if err != nil { + return nil, err + } + if err := roundTripper.separationPolicy.registerRetainedPeer( + roundTripper.identity.Role, + peer, + ); err != nil { + return nil, err + } + } proof := frostRetainedGroupTransportProof{ role: roundTripper.identity.Role, requestDigest: requestDigest, diff --git a/pkg/tbtc/frost_retained_group_endpoint_identity_test.go b/pkg/tbtc/frost_retained_group_endpoint_identity_test.go index 6b177e1b45..d70cb81532 100644 --- a/pkg/tbtc/frost_retained_group_endpoint_identity_test.go +++ b/pkg/tbtc/frost_retained_group_endpoint_identity_test.go @@ -11,6 +11,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "fmt" "io" "net" "net/http" @@ -506,6 +507,28 @@ type frostRetainedGroupRebindingResolver struct { addresses []netip.Addr } +type testFrostPrimaryEthereumIndependenceVerifier struct { + verify func( + context.Context, + frostRetainedGroupResolvedEndpoint, + frostRetainedGroupResolvedEndpoint, + ) error +} + +func (verifier *testFrostPrimaryEthereumIndependenceVerifier) verifyIndependence( + ctx context.Context, + exportEndpoint frostRetainedGroupResolvedEndpoint, + verifierEndpoint frostRetainedGroupResolvedEndpoint, +) error { + if verifier == nil { + return fmt.Errorf("test primary Ethereum verifier is nil") + } + if verifier.verify == nil { + return nil + } + return verifier.verify(ctx, exportEndpoint, verifierEndpoint) +} + func (resolver *frostRetainedGroupRebindingResolver) LookupCNAME( context.Context, string, @@ -534,6 +557,16 @@ func TestFrostRetainedGroupIndependenceMonitor_RejectsPrimaryDNSRebind( cname: "primary-origin.example.", addresses: []netip.Addr{netip.MustParseAddr("192.0.2.3")}, } + primaryEndpoint := frostRetainedGroupResolvedEndpoint{ + canonical: primaryURL.String(), + canonicalDNSName: "primary.example", + resolvedDNSName: "primary-origin.example", + addresses: append([]netip.Addr{}, resolver.addresses...), + addressSetHash: frostRetainedGroupResolvedAddressSetHash( + resolver.addresses, + ), + endpoint: primaryURL, + } monitor := &frostRetainedGroupIndependenceMonitor{ exportEndpoint: frostRetainedGroupResolvedEndpoint{ canonicalDNSName: "export.example", @@ -547,14 +580,34 @@ func TestFrostRetainedGroupIndependenceMonitor_RejectsPrimaryDNSRebind( addresses: []netip.Addr{verifierAddress}, endpoint: &url.URL{Scheme: "https", Host: "verifier.example:443", Path: "/"}, }, - primaryEndpoint: frostRetainedGroupResolvedEndpoint{ - canonicalDNSName: "primary.example", - resolvedDNSName: "primary-origin.example", - addresses: append([]netip.Addr{}, resolver.addresses...), - endpoint: primaryURL, + primaryTransport: &testFrostPrimaryEthereumIndependenceVerifier{ + verify: func( + ctx context.Context, + exportEndpoint frostRetainedGroupResolvedEndpoint, + verifierEndpoint frostRetainedGroupResolvedEndpoint, + ) error { + currentPrimary, err := resolveFrostRetainedGroupEndpoint( + ctx, + primaryEndpoint.endpoint, + resolver, + ) + if err != nil { + return err + } + if frostRetainedGroupEndpointSetsOverlap( + currentPrimary, + exportEndpoint, + ) || frostRetainedGroupEndpointSetsOverlap( + currentPrimary, + verifierEndpoint, + ) { + return fmt.Errorf( + "primary Ethereum endpoint now aliases a retained endpoint", + ) + } + return nil + }, }, - resolver: resolver, - timeout: time.Second, } if err := monitor.verify(context.Background()); err != nil { t.Fatalf("independent primary endpoint rejected: [%v]", err) diff --git a/pkg/tbtc/frost_retained_group_history_source.go b/pkg/tbtc/frost_retained_group_history_source.go index dd0409119d..070718ef1c 100644 --- a/pkg/tbtc/frost_retained_group_history_source.go +++ b/pkg/tbtc/frost_retained_group_history_source.go @@ -81,6 +81,7 @@ type FrostRetainedGroupHistorySourceConfig struct { EthereumTLSLeafSPKIHash string RequestTimeout time.Duration TLSRootCAs *x509.CertPool `mapstructure:"-"` + PrimaryTLSRootCAs *x509.CertPool `mapstructure:"-"` Resolver *net.Resolver `mapstructure:"-"` } @@ -439,7 +440,7 @@ func FrostRetainedGroupHistoryEndpointFingerprint( func NewFrostRetainedGroupHistorySource( ctx context.Context, config FrostRetainedGroupHistorySourceConfig, - primaryEthereumURL string, + primaryTransport *FrostPrimaryEthereumTransport, expectedChainID uint64, ) (*signedFrostRetainedGroupHistorySource, error) { if ctx == nil { @@ -448,7 +449,7 @@ func NewFrostRetainedGroupHistorySource( validated, err := validateAndResolveFrostRetainedGroupSourceConfig( ctx, config, - primaryEthereumURL, + primaryTransport, ) if err != nil { return nil, err @@ -456,12 +457,25 @@ func NewFrostRetainedGroupHistorySource( if expectedChainID == 0 { return nil, fmt.Errorf("retained-group history expected chain ID is zero") } + separationPolicy, err := primaryTransport.bindRetainedEndpoints( + validated.exportEndpoint, + validated.identity.Export, + validated.verifierEndpoint, + validated.identity.Verifier, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot bind primary Ethereum transport to retained endpoints: [%w]", + err, + ) + } rpcHTTPClient, rpcTransport, err := - newFrostRetainedGroupAttestedHTTPClient( + newFrostRetainedGroupAttestedHTTPClientWithSeparationPolicy( validated.verifierEndpoint, validated.identity.Verifier, validated.rootCAs, validated.requestTimeout, + separationPolicy, ) if err != nil { return nil, fmt.Errorf( @@ -483,11 +497,12 @@ func NewFrostRetainedGroupHistorySource( rpcClient: rpcClient, } exportHTTPClient, exportTransport, err := - newFrostRetainedGroupAttestedHTTPClient( + newFrostRetainedGroupAttestedHTTPClientWithSeparationPolicy( validated.exportEndpoint, validated.identity.Export, validated.rootCAs, validated.requestTimeout, + separationPolicy, ) if err != nil { verifier.Close() @@ -509,9 +524,7 @@ func NewFrostRetainedGroupHistorySource( &frostRetainedGroupIndependenceMonitor{ exportEndpoint: validated.exportEndpoint, verifierEndpoint: validated.verifierEndpoint, - primaryEndpoint: validated.primaryEndpoint, - resolver: config.Resolver, - timeout: validated.requestTimeout, + primaryTransport: primaryTransport, }, ) if err != nil { diff --git a/pkg/tbtc/frost_retained_group_history_source_test.go b/pkg/tbtc/frost_retained_group_history_source_test.go index cd8715e2ad..fe91af85df 100644 --- a/pkg/tbtc/frost_retained_group_history_source_test.go +++ b/pkg/tbtc/frost_retained_group_history_source_test.go @@ -743,20 +743,6 @@ func newFrostRetainedGroupHistorySourceFixture( if err != nil { t.Fatal(err) } - primaryURL, _, err := validateFrostRetainedGroupTLSEndpoint( - "https://127.0.0.3:9444/rpc", - ) - if err != nil { - t.Fatal(err) - } - resolvedPrimary, err := resolveFrostRetainedGroupEndpoint( - context.Background(), - primaryURL, - nil, - ) - if err != nil { - t.Fatal(err) - } source, err := newSignedFrostRetainedGroupHistorySource( context.Background(), endpoint, @@ -769,8 +755,7 @@ func newFrostRetainedGroupHistorySourceFixture( &frostRetainedGroupIndependenceMonitor{ exportEndpoint: resolvedEndpoint, verifierEndpoint: resolvedVerifier, - primaryEndpoint: resolvedPrimary, - timeout: frostRetainedGroupDefaultTimeout, + primaryTransport: &testFrostPrimaryEthereumIndependenceVerifier{}, }, ) if err != nil { From 26a8ef4875d6f4ae35fc94bf34032eb3113db2c2 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 28 Jul 2026 18:20:15 -0400 Subject: [PATCH 32/93] fix(tbtc): enforce live activation and history bounds --- pkg/tbtc/frost_activation_handshake.go | 34 ++- pkg/tbtc/frost_activation_handshake_test.go | 216 +++++++++++++++--- pkg/tbtc/frost_native_signer_readiness.go | 96 ++++++-- .../frost_retained_group_history_source.go | 5 + ...rost_retained_group_history_source_test.go | 79 +++++++ 5 files changed, 386 insertions(+), 44 deletions(-) diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 4239f24e1c..20eb4b0cd9 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -198,7 +198,7 @@ type frostActivationHandshakeExporter struct { storeBinding *frostDurableSessionStoreBinding outbox *bitcoinBroadcastOutbox journal *frostRetainedGroupJournal - readiness frostProductionSignerReadinessVerifier + readiness frostActivationHandshakeReadinessVerifier mutex sync.Mutex listener net.Listener @@ -242,9 +242,18 @@ type frostActivationReconciliationCache struct { journal frostRetainedGroupJournalSnapshot inventory frostNativeSignerInventorySnapshot interactiveSigningReady bool + readiness frostProductionSignerReadinessSnapshot stamp frostActivationJournalStamp } +type frostActivationHandshakeReadinessVerifier interface { + frostProductionSignerReadinessVerifier + verifyFrostProductionSignerReadinessUnchanged( + context.Context, + *frostProductionSignerReadinessSnapshot, + ) error +} + func newFrostActivationHandshakeExporter( endpoint string, privateKeyPath string, @@ -253,7 +262,7 @@ func newFrostActivationHandshakeExporter( storeBinding *frostDurableSessionStoreBinding, outbox *bitcoinBroadcastOutbox, journal *frostRetainedGroupJournal, - readiness frostProductionSignerReadinessVerifier, + readiness frostActivationHandshakeReadinessVerifier, ) (*frostActivationHandshakeExporter, error) { parsedEndpoint, err := validateFrostActivationHandshakeEndpoint(endpoint) if err != nil { @@ -593,6 +602,8 @@ func (fahe *frostActivationHandshakeExporter) cachedReconciliation( return nil } cache := *fahe.reconciliationCompleted + cache.readiness.Journal = &cache.journal + cache.readiness.Inventory = &cache.inventory return &cache } @@ -654,13 +665,17 @@ func (fahe *frostActivationHandshakeExporter) reconcileActivationState( "canonical FROST retained-group journal changed after reconciliation", ) } - return &frostActivationReconciliationCache{ + cache := &frostActivationReconciliationCache{ point: finality, journal: *journalSnapshot, inventory: *inventorySnapshot, interactiveSigningReady: readinessSnapshot.InteractiveSigningReady, + readiness: *readinessSnapshot, stamp: stamp, - }, nil + } + cache.readiness.Journal = &cache.journal + cache.readiness.Inventory = &cache.inventory + return cache, nil } func (fahe *frostActivationHandshakeExporter) validateActivationJournalSnapshot( @@ -1135,6 +1150,17 @@ func (fahe *frostActivationHandshakeExporter) attest( err, ) } + if err := fahe.readiness.verifyFrostProductionSignerReadinessUnchanged( + ctx, + &reconciliation.readiness, + ); err != nil { + fahe.queueReconciliation(finality, true) + return nil, fmt.Errorf( + "%w: cached FROST signer readiness changed before signing: [%v]", + errFrostActivationReconciliationPending, + err, + ) + } if !fahe.journal.mutex.TryLock() { return nil, fmt.Errorf( "%w: %v", diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index d2f3df1edd..ff955ddd9f 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -272,21 +272,77 @@ type testFrostRetainedGroupHistorySource struct { } type testFrostProductionSignerReadiness struct { + mutex sync.Mutex journal *frostRetainedGroupJournal interactive bool err error calls uint64 + inventory frostNativeSignerInventorySnapshot +} + +func testFrostProductionSignerInventorySnapshot() frostNativeSignerInventorySnapshot { + return frostNativeSignerInventorySnapshot{ + Schema: "tbtc-signer-retained-key-package-inventory/v1", + StoreFingerprint: testFrostDurableSessionStoreIdentity().Fingerprint, + StateGeneration: 7, + StateCommitment: [32]byte{0x31}, + PreviousStateCommitment: [32]byte{0x30}, + StateImageDigest: [32]byte{0x33}, + InventoryCommitment: [32]byte{0x32}, + ExternalRollbackAnchorBound: true, + TrustCertificateSequence: 3, + TrustCertificateDigest: [32]byte{0x34}, + AnchorServiceEpoch: 1, + CertifiedFloorRevision: 1, + CertifiedFloorGeneration: 1, + CurrentAnchorRevision: 1, + RestartableRevisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + RestartableGenerationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries - + 6, + AnchorRotationWarning: false, + } +} + +func (readiness *testFrostProductionSignerReadiness) snapshot() ( + bool, + error, + frostNativeSignerInventorySnapshot, +) { + readiness.mutex.Lock() + defer readiness.mutex.Unlock() + readiness.calls++ + inventory := readiness.inventory + if inventory.Schema == "" { + inventory = testFrostProductionSignerInventorySnapshot() + } + return readiness.interactive, readiness.err, inventory +} + +func (readiness *testFrostProductionSignerReadiness) setInteractive( + interactive bool, +) { + readiness.mutex.Lock() + defer readiness.mutex.Unlock() + readiness.interactive = interactive +} + +func (readiness *testFrostProductionSignerReadiness) setInventory( + inventory frostNativeSignerInventorySnapshot, +) { + readiness.mutex.Lock() + defer readiness.mutex.Unlock() + readiness.inventory = inventory } func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSignerReadiness( ctx context.Context, point FrostPreSignFinality, ) (*frostProductionSignerReadinessSnapshot, error) { - readiness.calls++ - if readiness.err != nil { - return nil, readiness.err + interactive, readinessErr, inventory := readiness.snapshot() + if readinessErr != nil { + return nil, readinessErr } - if !readiness.interactive { + if !interactive { return nil, fmt.Errorf("interactive signer is not ready") } journalSnapshot, err := readiness.journal.reconcile(ctx, point) @@ -294,31 +350,33 @@ func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSigner return nil, err } return &frostProductionSignerReadinessSnapshot{ - Journal: journalSnapshot, - Inventory: &frostNativeSignerInventorySnapshot{ - Schema: "tbtc-signer-retained-key-package-inventory/v1", - StoreFingerprint: testFrostDurableSessionStoreIdentity().Fingerprint, - StateGeneration: 7, - StateCommitment: [32]byte{0x31}, - PreviousStateCommitment: [32]byte{0x30}, - StateImageDigest: [32]byte{0x33}, - InventoryCommitment: [32]byte{0x32}, - ExternalRollbackAnchorBound: true, - TrustCertificateSequence: 3, - TrustCertificateDigest: [32]byte{0x34}, - AnchorServiceEpoch: 1, - CertifiedFloorRevision: 1, - CertifiedFloorGeneration: 1, - CurrentAnchorRevision: 1, - RestartableRevisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, - RestartableGenerationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries - - 6, - AnchorRotationWarning: false, - }, + Journal: journalSnapshot, + Inventory: &inventory, InteractiveSigningReady: true, }, nil } +func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSignerReadinessUnchanged( + ctx context.Context, + expected *frostProductionSignerReadinessSnapshot, +) error { + if ctx == nil || expected == nil || expected.Inventory == nil || + !expected.InteractiveSigningReady { + return fmt.Errorf("cached signer readiness is incomplete") + } + interactive, readinessErr, inventory := readiness.snapshot() + if readinessErr != nil { + return readinessErr + } + if !interactive { + return fmt.Errorf("interactive signer is not ready") + } + if inventory != *expected.Inventory { + return fmt.Errorf("native signer state changed since reconciliation") + } + return nil +} + func (source *testFrostRetainedGroupHistorySource) BindFrostRetainedGroupActivationEvidence( _ FrostPreSignActivationProfile, runtimeManifest FrostPreSignActivationRuntimeManifest, @@ -740,6 +798,112 @@ func TestFrostActivationHandshakeExporter_AttestsInclusiveCheckpointAncestry( } } +func TestFrostActivationHandshakeExporter_RevalidatesNativeSignerStateBeforeSigning( + t *testing.T, +) { + testCases := map[string]func(*testFrostProductionSignerReadiness){ + "native state advances": func( + readiness *testFrostProductionSignerReadiness, + ) { + inventory := testFrostProductionSignerInventorySnapshot() + inventory.StateGeneration++ + inventory.PreviousStateCommitment = inventory.StateCommitment + inventory.StateCommitment = [32]byte{0x41} + inventory.StateImageDigest = [32]byte{0x42} + inventory.InventoryCommitment = [32]byte{0x43} + inventory.RestartableGenerationHeadroom-- + readiness.setInventory(inventory) + }, + "native anchor advances": func( + readiness *testFrostProductionSignerReadiness, + ) { + inventory := testFrostProductionSignerInventorySnapshot() + inventory.CurrentAnchorRevision++ + inventory.RestartableRevisionHeadroom-- + readiness.setInventory(inventory) + }, + "interactive readiness changes": func( + readiness *testFrostProductionSignerReadiness, + ) { + readiness.setInteractive(false) + }, + } + + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + exporter, _, _, _, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + readiness, ok := + exporter.readiness.(*testFrostProductionSignerReadiness) + if !ok { + t.Fatal("unexpected production signer readiness verifier") + } + + response := postTestFrostActivationHandshake(t, endpoint, request) + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + response.Body.Close() + + mutate(readiness) + response = postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != + frostActivationHandshakeRetryAfter { + response.Body.Close() + t.Fatalf( + "obsolete cached signer state returned status [%d] with retry [%s]", + response.StatusCode, + response.Header.Get("Retry-After"), + ) + } + response.Body.Close() + + readiness.setInteractive(true) + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + defer response.Body.Close() + handshake := &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { + t.Fatal(err) + } + if !handshake.Payload.State.Healthy { + t.Fatal("fresh stable signer state did not recover healthy attestation") + } + if name == "native state advances" && + (handshake.Payload.State.NativeSignerState.StateGeneration != 8 || + handshake.Payload.State.NativeSignerState.StateCommitment != + frostActivationHex32([32]byte{0x41})) { + t.Fatalf( + "reconciled attestation did not use the current native state: %+v", + handshake.Payload.State.NativeSignerState, + ) + } + if name == "native anchor advances" && + (handshake.Payload.State.NativeSignerState.CurrentAnchorRevision != 2 || + handshake.Payload.State.NativeSignerState.RestartableRevisionHeadroom != + FrostNativeSignerAnchorMaximumHistoryEvents-1) { + t.Fatalf( + "reconciled attestation did not use the current native anchor: %+v", + handshake.Payload.State.NativeSignerState, + ) + } + }) + } +} + func TestFrostActivationHandshakeExporter_PermitsAndAttestsTombstones( t *testing.T, ) { @@ -978,7 +1142,7 @@ func TestFrostActivationHandshakeExporter_FailsClosed(t *testing.T) { outbox.mutex.Lock() outbox.records = make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord) outbox.mutex.Unlock() - readiness.interactive = false + readiness.setInteractive(false) journal.mutex.Lock() journal.state.SnapshotGeneration++ var rootErr error diff --git a/pkg/tbtc/frost_native_signer_readiness.go b/pkg/tbtc/frost_native_signer_readiness.go index 894e16f140..2d463d6cf0 100644 --- a/pkg/tbtc/frost_native_signer_readiness.go +++ b/pkg/tbtc/frost_native_signer_readiness.go @@ -310,6 +310,9 @@ type frostProductionSignerReadinessSnapshot struct { Journal *frostRetainedGroupJournalSnapshot Inventory *frostNativeSignerInventorySnapshot InteractiveSigningReady bool + + inventoryExpectations []frostNativeSignerInventoryExpectation + registryRevision uint64 } type frostProductionSignerReadinessVerifier interface { @@ -357,22 +360,13 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead if err != nil { return nil, err } - firstInventory, err := readiness.inventoryBinding.verify(ctx, expected) - if err != nil { - return nil, err - } - secondInventory, err := readiness.inventoryBinding.verify(ctx, expected) + inventory, err := readiness.verifyStableFrostProductionSignerInventory( + ctx, + expected, + ) if err != nil { return nil, err } - if *firstInventory != *secondInventory { - return nil, fmt.Errorf("native signer state changed during readiness reconciliation") - } - if err := validateFrostNativeSignerAnchorReadinessHeadroom( - secondInventory, - ); err != nil { - return nil, err - } if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( registryRevision, ) { @@ -385,11 +379,85 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead } return &frostProductionSignerReadinessSnapshot{ Journal: journalSnapshot, - Inventory: secondInventory, + Inventory: inventory, InteractiveSigningReady: true, + inventoryExpectations: expected, + registryRevision: registryRevision, }, nil } +func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerReadinessUnchanged( + ctx context.Context, + expected *frostProductionSignerReadinessSnapshot, +) error { + if readiness == nil || readiness.interactiveSigningReady == nil || + readiness.journal == nil || readiness.inventoryBinding == nil || + ctx == nil || expected == nil || expected.Inventory == nil || + !expected.InteractiveSigningReady { + return fmt.Errorf("cached FROST production signer readiness is incomplete") + } + if !readiness.interactiveSigningReady() { + return fmt.Errorf("interactive FROST signing engine is not ready") + } + if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( + expected.registryRevision, + ) { + return fmt.Errorf( + "local FROST signer registry changed since readiness reconciliation", + ) + } + inventory, err := readiness.verifyStableFrostProductionSignerInventory( + ctx, + expected.inventoryExpectations, + ) + if err != nil { + return err + } + if *inventory != *expected.Inventory { + return fmt.Errorf( + "native signer state changed since readiness reconciliation", + ) + } + if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( + expected.registryRevision, + ) { + return fmt.Errorf( + "local FROST signer registry changed during readiness revalidation", + ) + } + if !readiness.interactiveSigningReady() { + return fmt.Errorf( + "interactive FROST signing engine became unavailable during readiness revalidation", + ) + } + return nil +} + +func (readiness *frostProductionSignerReadiness) verifyStableFrostProductionSignerInventory( + ctx context.Context, + expected []frostNativeSignerInventoryExpectation, +) (*frostNativeSignerInventorySnapshot, error) { + firstInventory, err := readiness.inventoryBinding.verify(ctx, expected) + if err != nil { + return nil, err + } + secondInventory, err := readiness.inventoryBinding.verify(ctx, expected) + if err != nil { + return nil, err + } + if *firstInventory != *secondInventory { + return nil, fmt.Errorf( + "native signer state changed during readiness verification", + ) + } + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + secondInventory, + ); err != nil { + return nil, err + } + return secondInventory, nil +} + func validateFrostNativeSignerAnchorReadinessHeadroom( inventory *frostNativeSignerInventorySnapshot, ) error { diff --git a/pkg/tbtc/frost_retained_group_history_source.go b/pkg/tbtc/frost_retained_group_history_source.go index 070718ef1c..dfba0c3b67 100644 --- a/pkg/tbtc/frost_retained_group_history_source.go +++ b/pkg/tbtc/frost_retained_group_history_source.go @@ -977,6 +977,11 @@ func (source *signedFrostRetainedGroupHistorySource) ReadCompleteHistory( ) } blockHashes[blockNumber] = blockHash + if uint64(len(blockHashes)) > source.maximumUniqueBlocks { + return nil, fmt.Errorf( + "retained-group history exceeds the unique-block limit", + ) + } } for blockNumber, blockHash := range blockHashes { if err := source.verifyPointAtFinalizedHead(ctx, FrostPreSignFinality{ diff --git a/pkg/tbtc/frost_retained_group_history_source_test.go b/pkg/tbtc/frost_retained_group_history_source_test.go index fe91af85df..666c515a79 100644 --- a/pkg/tbtc/frost_retained_group_history_source_test.go +++ b/pkg/tbtc/frost_retained_group_history_source_test.go @@ -2117,6 +2117,85 @@ func TestSignedFrostRetainedGroupHistorySource_EnforcesAggregateResourceLimits( } } +func TestSignedFrostRetainedGroupHistorySource_CountsCheckpointPointsTowardUniqueBlockLimit( + t *testing.T, +) { + testCases := map[string]struct { + maximumUniqueBlocks uint64 + rejected bool + }{ + "rejects checkpoint over the limit": { + maximumUniqueBlocks: 6, + rejected: true, + }, + "accepts checkpoint at the limit": { + maximumUniqueBlocks: 7, + rejected: false, + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.to = FrostPreSignFinality{ + BlockNumber: 10, + BlockHash: fixture.verifier.headers[10].Hash(), + } + _, err := fixture.checkpointIssuer( + fixture.checkpointAfter(), + FrostPreSignFinality{ + BlockNumber: 7, + BlockHash: fixture.verifier.headers[7].Hash(), + }, + fixture.mutations, + ) + if err != nil { + t.Fatal(err) + } + // The history bounds and mutation evidence consume exactly six + // distinct blocks. The independently signed checkpoint at block 7 + // must count as a seventh canonical RPC lookup, even though the + // final checkpoint at block 10 is already represented by the + // history target. + fixture.source.maximumUniqueBlocks = + testCase.maximumUniqueBlocks + + history, err := fixture.source.ReadCompleteHistory( + context.Background(), + fixture.from, + fixture.to, + fixture.checkpointAfter(), + ) + if testCase.rejected { + if err == nil || + !strings.Contains( + err.Error(), + "exceeds the unique-block limit", + ) { + t.Fatalf( + "expected checkpoint unique-block rejection, got [%v]", + err, + ) + } + if fixture.verifier.reads[7] != 0 { + t.Fatal( + "over-limit checkpoint reached canonical RPC verification", + ) + } + return + } + if err != nil { + t.Fatalf("checkpoint at the unique-block limit was rejected: [%v]", err) + } + if history == nil || len(history.Checkpoints) != 2 || + fixture.verifier.reads[7] == 0 { + t.Fatal( + "checkpoint at the unique-block limit was not fully verified", + ) + } + }) + } +} + func TestSignedFrostRetainedGroupHistorySource_RejectsOversizedReceiptLogSet( t *testing.T, ) { From 5186668bc94278fa95e66a0db2be65c2dd7f1513 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 28 Jul 2026 19:23:47 -0400 Subject: [PATCH 33/93] fix(tbtc): harden journal and outbox recovery --- pkg/tbtc/bitcoin_broadcast_outbox.go | 93 +++++++++-- pkg/tbtc/bitcoin_broadcast_outbox_test.go | 96 +++++++++++ pkg/tbtc/frost_retained_group_checkpoint.go | 13 +- pkg/tbtc/frost_retained_group_journal.go | 134 ++++++++++++++- pkg/tbtc/frost_retained_group_journal_test.go | 158 ++++++++++++++++++ 5 files changed, 470 insertions(+), 24 deletions(-) diff --git a/pkg/tbtc/bitcoin_broadcast_outbox.go b/pkg/tbtc/bitcoin_broadcast_outbox.go index 2bc10ca745..ea64ff84fd 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox.go @@ -726,7 +726,8 @@ func (bbo *bitcoinBroadcastOutbox) replayOnce() error { type bitcoinBroadcastReplayCandidate struct { reservationID [32]byte - record *bitcoinBroadcastOutboxRecord + primary *bitcoinBroadcastOutboxRecord + alternatives []*bitcoinBroadcastOutboxRecord } type bitcoinBroadcastTransientReplayError struct { @@ -780,9 +781,12 @@ func (errorValue *bitcoinBroadcastReplayErrors) hasFatalFailure() bool { return false } -// replayOnceWithContext checks only the latest/confirmed record of each active -// reservation. Deeply confirmed history is reconciled in a fixed-size rotating -// batch, so canonical RPC work is bounded independently of archived history. +// replayOnceWithContext checks the latest or previously confirmed record first. +// Before rebroadcasting, it also reconciles every superseded variant with a +// durable broadcast attempt, because any one of those conflicting variants +// may be the canonical winner. Deeply confirmed history is reconciled in a +// fixed-size rotating reservation batch; healthy archived reservations still +// require only their primary confirmation read. func (bbo *bitcoinBroadcastOutbox) replayOnceWithContext(ctx context.Context) error { bbo.replayMutex.Lock() defer bbo.replayMutex.Unlock() @@ -792,8 +796,9 @@ func (bbo *bitcoinBroadcastOutbox) replayOnceWithContext(ctx context.Context) er return err } replayErrors := &bitcoinBroadcastReplayErrors{} +candidateLoop: for _, candidate := range append(active, archived...) { - refreshed, err := bbo.refreshConfirmation(ctx, candidate.record) + refreshed, err := bbo.refreshConfirmation(ctx, candidate.primary) if err != nil { replayErrors.failures = append( replayErrors.failures, @@ -801,7 +806,25 @@ func (bbo *bitcoinBroadcastOutbox) replayOnceWithContext(ctx context.Context) er ) continue } - if refreshed.Confirmation != nil || refreshed.Quarantine != nil { + if refreshed.Confirmation != nil { + continue + } + canonicalVariantFound := false + for _, alternative := range candidate.alternatives { + refreshed, err := bbo.refreshConfirmation(ctx, alternative) + if err != nil { + replayErrors.failures = append( + replayErrors.failures, + bitcoinBroadcastReplayFailure{candidate.reservationID, err}, + ) + continue candidateLoop + } + if refreshed.Confirmation != nil { + canonicalVariantFound = true + break + } + } + if canonicalVariantFound { continue } latest, err := bbo.latestBroadcastRecord(candidate.reservationID) @@ -852,6 +875,7 @@ func (bbo *bitcoinBroadcastOutbox) replayCandidates() ( type reservationState struct { latest *bitcoinBroadcastOutboxRecord confirmed *bitcoinBroadcastOutboxRecord + records []*bitcoinBroadcastOutboxRecord } states := make(map[[32]byte]*reservationState) for _, record := range bbo.records { @@ -861,6 +885,7 @@ func (bbo *bitcoinBroadcastOutbox) replayCandidates() ( state = &reservationState{} states[reservationID] = state } + state.records = append(state.records, record) if state.latest == nil || laterBitcoinBroadcastVariant(record, state.latest) { state.latest = record } @@ -878,17 +903,43 @@ func (bbo *bitcoinBroadcastOutbox) replayCandidates() ( active := make([]bitcoinBroadcastReplayCandidate, 0, len(states)) archived := make([]bitcoinBroadcastReplayCandidate, 0, len(states)) for reservationID, state := range states { - record := state.latest + primary := state.latest if state.confirmed != nil { - record = state.confirmed + primary = state.confirmed } + alternatives := make( + []*bitcoinBroadcastOutboxRecord, + 0, + len(state.records)-1, + ) + for _, record := range state.records { + if record.TransactionHash == primary.TransactionHash { + continue + } + if record.TransactionHash != state.latest.TransactionHash && + record.BroadcastAttempts == 0 && + record.Confirmation == nil { + continue + } + alternatives = append( + alternatives, + cloneBitcoinBroadcastOutboxRecord(record), + ) + } + sort.Slice(alternatives, func(i, j int) bool { + return laterBitcoinBroadcastVariant( + alternatives[j], + alternatives[i], + ) + }) candidate := bitcoinBroadcastReplayCandidate{ reservationID: reservationID, - record: cloneBitcoinBroadcastOutboxRecord(record), + primary: cloneBitcoinBroadcastOutboxRecord(primary), + alternatives: alternatives, } - if record.Confirmation != nil && - record.Confirmation.Canonical && - record.Confirmation.Confirmations >= bbo.archiveConfirmations { + if primary.Confirmation != nil && + primary.Confirmation.Canonical && + primary.Confirmation.Confirmations >= bbo.archiveConfirmations { archived = append(archived, candidate) } else { active = append(active, candidate) @@ -1094,12 +1145,24 @@ func (bbo *bitcoinBroadcastOutbox) broadcastAuthorizedRecord( } broadcastErr := bbo.btcChain.BroadcastTransaction(tx) now := bbo.now().Unix() + attemptedAt := maxBitcoinBroadcastTimestamp( + next.UpdatedAtUnix, + now, + ) + attemptedAt = maxBitcoinBroadcastTimestamp( + attemptedAt, + next.FirstBroadcastAtUnix, + ) + attemptedAt = maxBitcoinBroadcastTimestamp( + attemptedAt, + next.LastAttemptUnix, + ) if next.FirstBroadcastAtUnix == 0 { - next.FirstBroadcastAtUnix = now + next.FirstBroadcastAtUnix = attemptedAt } next.BroadcastAttempts++ - next.LastAttemptUnix = now - next.UpdatedAtUnix = maxBitcoinBroadcastTimestamp(next.UpdatedAtUnix, now) + next.LastAttemptUnix = attemptedAt + next.UpdatedAtUnix = attemptedAt if err := bbo.persistAndSwapRecord(record, next); err != nil { return broadcastErr, nil, fmt.Errorf("cannot persist Bitcoin broadcast attempt: [%w]", err) } diff --git a/pkg/tbtc/bitcoin_broadcast_outbox_test.go b/pkg/tbtc/bitcoin_broadcast_outbox_test.go index 5fdf2c5f72..2d301449dd 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox_test.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox_test.go @@ -699,6 +699,55 @@ func TestBitcoinBroadcastOutbox_StartRetriesTransientCandidateFailure( } } +func TestBitcoinBroadcastOutbox_ReconcilesPreviouslyBroadcastSupersededVariant( + t *testing.T, +) { + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, t.TempDir(), chain) + defer outbox.close() + + oldVariant := testOutboxTransaction(31, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + oldVariant, + testBitcoinBroadcastAuthorization(32, 31, 1), + ) + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if chain.broadcastCount(oldVariant.Hash()) != 1 { + t.Fatal("initial variant was not broadcast") + } + + replacement := testOutboxTransaction(31, 6000) + enqueueTestBitcoinTransaction( + t, + outbox, + replacement, + testBitcoinBroadcastAuthorization(33, 31, 2), + ) + chain.setCanonicalStatus( + oldVariant.Hash(), + &bitcoin.CanonicalTransactionStatus{ + Found: true, + Confirmations: 2, + BlockHeight: 800031, + BlockHash: bitcoin.Hash{0x31, 0xcc}, + }, + ) + + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if outbox.records[oldVariant.Hash()].Confirmation == nil { + t.Fatal("canonical superseded RBF variant was not persisted") + } + if chain.broadcastCount(replacement.Hash()) != 0 { + t.Fatal("replacement was broadcast after its predecessor confirmed") + } +} + func TestBitcoinBroadcastOutbox_PersistenceFailureDoesNotPublishConfirmationMutation( t *testing.T, ) { @@ -778,6 +827,53 @@ func TestBitcoinBroadcastOutbox_PersistenceFailureDoesNotPublishAttemptCounters( } } +func TestBitcoinBroadcastOutbox_BroadcastAttemptTimestampsRemainMonotonicAcrossClockRollback( + t *testing.T, +) { + chain := newOutboxTestBitcoinChain() + directory := t.TempDir() + outbox := openTestBitcoinBroadcastOutbox(t, directory, chain) + tx := testOutboxTransaction(34, 7000) + currentTime := time.Unix(2_000, 0) + outbox.now = func() time.Time { + return currentTime + } + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(35, 34, 1), + ) + + currentTime = time.Unix(2_100, 0) + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + currentTime = time.Unix(1_900, 0) + if err := outbox.replayOnce(); err != nil { + t.Fatalf("clock rollback broke durable replay: [%v]", err) + } + record := outbox.records[tx.Hash()] + if record.BroadcastAttempts != 2 || + record.FirstBroadcastAtUnix != 2_100 || + record.LastAttemptUnix != 2_100 || + record.UpdatedAtUnix != 2_100 || + chain.broadcastCount(tx.Hash()) != 2 { + t.Fatalf( + "broadcast attempt timestamps regressed after clock rollback: %+v", + record, + ) + } + if err := outbox.close(); err != nil { + t.Fatal(err) + } + restarted := openTestBitcoinBroadcastOutbox(t, directory, chain) + defer restarted.close() + if restarted.records[tx.Hash()].LastAttemptUnix != 2_100 { + t.Fatal("monotonic broadcast attempt timestamp did not survive restart") + } +} + func TestBitcoinBroadcastOutbox_PersistenceFailureDoesNotPublishQuarantine( t *testing.T, ) { diff --git a/pkg/tbtc/frost_retained_group_checkpoint.go b/pkg/tbtc/frost_retained_group_checkpoint.go index 0ffceebbd9..e55f843bfc 100644 --- a/pkg/tbtc/frost_retained_group_checkpoint.go +++ b/pkg/tbtc/frost_retained_group_checkpoint.go @@ -1163,6 +1163,14 @@ func equalFrostRetainedGroupCheckpointStates( } func (frgj *frostRetainedGroupJournal) initializeCheckpointJournal() error { + if err := recoverFrostRetainedGroupJournalTemporaryFiles( + frgj.checkpointDirectory, + ); err != nil { + return fmt.Errorf( + "cannot recover interrupted FROST checkpoint persistence: [%w]", + err, + ) + } entries, err := os.ReadDir(frgj.checkpointDirectory) if err != nil { return fmt.Errorf("cannot read FROST checkpoint journal: [%w]", err) @@ -1189,11 +1197,6 @@ func (frgj *frostRetainedGroupJournal) initializeCheckpointJournal() error { case strings.HasPrefix(name, frostRetainedGroupCheckpointFilePrefix) && strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix): certificateNames = append(certificateNames, name) - case strings.HasSuffix(name, frostRetainedGroupJournalTempSuffix): - return fmt.Errorf( - "interrupted FROST checkpoint journal temp is present: [%s]", - name, - ) default: return fmt.Errorf( "unexpected file in FROST checkpoint journal: [%s]", diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index 586690ab52..313c92166a 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -1242,6 +1242,14 @@ func (frgj *frostRetainedGroupJournal) initialize() error { if err := frgj.verifyHistorySourceIdentity(context.Background()); err != nil { return err } + if err := recoverFrostRetainedGroupJournalTemporaryFiles( + frgj.directory, + ); err != nil { + return fmt.Errorf( + "cannot recover interrupted FROST retained-group journal persistence: [%w]", + err, + ) + } entries, err := os.ReadDir(frgj.directory) if err != nil { @@ -1267,8 +1275,6 @@ func (frgj *frostRetainedGroupJournal) initialize() error { case strings.HasPrefix(name, frostRetainedGroupJournalBatchPrefix) && strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix): batchNames = append(batchNames, name) - case strings.HasSuffix(name, frostRetainedGroupJournalTempSuffix): - return fmt.Errorf("interrupted FROST retained-group journal temp is present: [%s]", name) default: return fmt.Errorf("unexpected file in FROST retained-group journal: [%s]", name) } @@ -1412,6 +1418,14 @@ func equalFrostRetainedGroupStates( } func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { + if err := recoverFrostRetainedGroupJournalTemporaryFiles( + frgj.quarantineDirectory, + ); err != nil { + return fmt.Errorf( + "cannot recover interrupted FROST retained-group quarantine persistence: [%w]", + err, + ) + } entries, err := os.ReadDir(frgj.quarantineDirectory) if err != nil { return fmt.Errorf("cannot read FROST retained-group quarantine journal: [%w]", err) @@ -1440,8 +1454,6 @@ func (frgj *frostRetainedGroupJournal) initializeQuarantine() error { case strings.HasPrefix(name, frostRetainedGroupLiftCertificatePrefix) && strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix): certificateNames = append(certificateNames, name) - case strings.HasSuffix(name, frostRetainedGroupJournalTempSuffix): - return fmt.Errorf("interrupted FROST retained-group quarantine temp is present: [%s]", name) default: return fmt.Errorf("unexpected file in FROST retained-group quarantine journal: [%s]", name) } @@ -2363,6 +2375,120 @@ func validateFrostRetainedGroupJournalFileAt( return true, nil } +// recoverFrostRetainedGroupJournalTemporaryFiles removes interrupted +// pre-publication files after validating that they have exactly the private, +// owner-bound shape and unpredictable name emitted by persistFrostRetainedGroupEnvelopeAt. +// A temporary name is never a commit point: immutable files commit at Linkat +// and replaceable state commits at Renameat. The final namespace is replayed +// after this cleanup, so an already-linked immutable file is retained and an +// unpublished state file is deterministically rebuilt from its immutable +// prefix. +func recoverFrostRetainedGroupJournalTemporaryFiles( + directory string, +) error { + entries, err := os.ReadDir(directory) + if err != nil { + return err + } + directoryFile, err := openFrostRetainedGroupJournalDirectory(directory) + if err != nil { + return err + } + defer directoryFile.Close() + directoryDescriptor := int(directoryFile.Fd()) + + removed := false + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, frostRetainedGroupJournalTempSuffix) { + continue + } + if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() { + return fmt.Errorf( + "interrupted journal temporary file is unsafe: [%s]", + name, + ) + } + if _, err := frostRetainedGroupJournalTemporaryFinalName(name); err != nil { + return err + } + exists, err := validateFrostRetainedGroupJournalFileAt( + directoryDescriptor, + name, + ) + if err != nil { + return fmt.Errorf( + "interrupted journal temporary file is unsafe [%s]: [%w]", + name, + err, + ) + } + if !exists { + return fmt.Errorf( + "interrupted journal temporary file disappeared: [%s]", + name, + ) + } + if err := unix.Unlinkat(directoryDescriptor, name, 0); err != nil { + return fmt.Errorf( + "cannot remove interrupted journal temporary file [%s]: [%w]", + name, + err, + ) + } + removed = true + } + if removed { + if err := directoryFile.Sync(); err != nil { + return fmt.Errorf( + "cannot sync interrupted journal temporary-file recovery: [%w]", + err, + ) + } + } + return nil +} + +func frostRetainedGroupJournalTemporaryFinalName( + name string, +) (string, error) { + const entropyBytes = 16 + const entropyCharacters = entropyBytes * 2 + + if !strings.HasSuffix(name, frostRetainedGroupJournalTempSuffix) { + return "", fmt.Errorf( + "interrupted journal temporary file name is invalid: [%s]", + name, + ) + } + trimmed := strings.TrimSuffix(name, frostRetainedGroupJournalTempSuffix) + delimiterIndex := len(trimmed) - entropyCharacters - 1 + if delimiterIndex <= 0 || trimmed[delimiterIndex] != '-' { + return "", fmt.Errorf( + "interrupted journal temporary file name is invalid: [%s]", + name, + ) + } + entropyText := trimmed[delimiterIndex+1:] + entropy, err := hex.DecodeString(entropyText) + if err != nil || len(entropy) != entropyBytes || + hex.EncodeToString(entropy) != entropyText { + return "", fmt.Errorf( + "interrupted journal temporary file name is invalid: [%s]", + name, + ) + } + finalName := trimmed[:delimiterIndex] + if err := validateFrostRetainedGroupJournalFileName(finalName); err != nil { + return "", fmt.Errorf( + "interrupted journal temporary destination is invalid [%s]: [%w]", + name, + err, + ) + } + return finalName, nil +} + func createFrostRetainedGroupJournalTemporaryFileAt( directoryDescriptor int, directory string, diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index 5b4bb9edc4..8914d384f1 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -3473,6 +3473,164 @@ func TestPersistFrostRetainedGroupEnvelopeAt_PreservesInternalFiles( } } +func TestFrostRetainedGroupJournal_RecoversInterruptedTemporaryFiles( + t *testing.T, +) { + type component struct { + directory string + stateFile string + metadataFile string + } + components := map[string]component{ + "canonical": { + directory: frostRetainedGroupCanonicalDirectory, + stateFile: frostRetainedGroupJournalStateFile, + metadataFile: frostRetainedGroupJournalMetadataFile, + }, + "quarantine": { + directory: frostRetainedGroupQuarantineDirectory, + stateFile: frostRetainedGroupJournalStateFile, + metadataFile: frostRetainedGroupJournalMetadataFile, + }, + "checkpoint": { + directory: frostRetainedGroupCheckpointDirectory, + stateFile: frostRetainedGroupCheckpointStateFile, + metadataFile: frostRetainedGroupCheckpointMetadataFile, + }, + } + stages := map[string]struct { + finalName func(component) string + prepare func(string, string) error + }{ + "partial before sync": { + finalName: func(component component) string { + return component.stateFile + }, + prepare: func(_ string, temporaryPath string) error { + return os.WriteFile(temporaryPath, []byte("{"), 0600) + }, + }, + "synced before publication": { + finalName: func(component component) string { + return component.stateFile + }, + prepare: func(finalPath string, temporaryPath string) error { + data, err := os.ReadFile(finalPath) + if err != nil { + return err + } + return os.WriteFile(temporaryPath, data, 0600) + }, + }, + "linked before temporary unlink": { + finalName: func(component component) string { + return component.metadataFile + }, + prepare: func(finalPath string, temporaryPath string) error { + return os.Link(finalPath, temporaryPath) + }, + }, + } + + for stageName, stage := range stages { + for componentName, component := range components { + t.Run(stageName+"/"+componentName, func(t *testing.T) { + fixture := newJournalTestFixture(t) + rootDirectory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, rootDirectory) + if _, err := journal.reconcile( + context.Background(), + fixture.target, + ); err != nil { + t.Fatal(err) + } + expectedCanonicalGeneration := + journal.state.SnapshotGeneration + expectedQuarantineGeneration := + journal.quarantineState.Generation + expectedCheckpointSequence := + journal.checkpointState.Sequence + if err := journal.close(); err != nil { + t.Fatal(err) + } + + finalName := stage.finalName(component) + directory := filepath.Join( + rootDirectory, + component.directory, + ) + finalPath := filepath.Join(directory, finalName) + temporaryName := finalName + "-" + + strings.Repeat("ab", 16) + + frostRetainedGroupJournalTempSuffix + temporaryPath := filepath.Join(directory, temporaryName) + if err := stage.prepare( + finalPath, + temporaryPath, + ); err != nil { + t.Fatal(err) + } + + restarted := fixture.openJournal(t, rootDirectory) + defer restarted.close() + if restarted.state.SnapshotGeneration != + expectedCanonicalGeneration || + restarted.quarantineState.Generation != + expectedQuarantineGeneration || + restarted.checkpointState.Sequence != + expectedCheckpointSequence { + t.Fatalf( + "temporary-file recovery changed committed state: canonical=[%d] quarantine=[%d] checkpoint=[%d]", + restarted.state.SnapshotGeneration, + restarted.quarantineState.Generation, + restarted.checkpointState.Sequence, + ) + } + if _, err := os.Lstat(temporaryPath); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf( + "interrupted temporary file was not removed: [%v]", + err, + ) + } + if _, err := os.Lstat(finalPath); err != nil { + t.Fatalf( + "committed journal file was lost during recovery: [%v]", + err, + ) + } + }) + } + } +} + +func TestFrostRetainedGroupJournal_RejectsMalformedTemporaryFileName( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + rootDirectory := filepath.Join(t.TempDir(), "journal") + journal := fixture.openJournal(t, rootDirectory) + if err := journal.close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join( + rootDirectory, + frostRetainedGroupCanonicalDirectory, + "unexpected.tmp", + ), + []byte("partial"), + 0600, + ); err != nil { + t.Fatal(err) + } + if err := fixture.openJournalError(rootDirectory); err == nil { + t.Fatal("malformed journal temporary file name was discarded") + } +} + func TestFrostRetainedGroupJournal_RejectsCorruptOrPublicStoreFiles(t *testing.T) { t.Run("quarantine batch checksum", func(t *testing.T) { fixture := newJournalTestFixture(t) From 2dcabf7ac9d5778d60829e904780740c2fb95891 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 28 Jul 2026 21:10:49 -0400 Subject: [PATCH 34/93] fix(tbtc): bind activation signatures to outbox state --- pkg/tbtc/bitcoin_broadcast_outbox.go | 29 +++- pkg/tbtc/frost_activation_handshake.go | 47 +++--- pkg/tbtc/frost_activation_handshake_test.go | 158 +++++++++++++++++++- 3 files changed, 210 insertions(+), 24 deletions(-) diff --git a/pkg/tbtc/bitcoin_broadcast_outbox.go b/pkg/tbtc/bitcoin_broadcast_outbox.go index ea64ff84fd..cfb68d531b 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox.go @@ -677,6 +677,33 @@ func (bbo *bitcoinBroadcastOutbox) activationSnapshot() ( return bitcoinBroadcastOutboxActivationSnapshot{}, fmt.Errorf("Bitcoin broadcast outbox is closed") } + return bbo.activationSnapshotLocked(), nil +} + +// withUnchangedActivationSnapshot serializes the final activation-state check +// and the operation it authorizes with every outbox mutation. Callers must +// keep operation short and must not call another method that acquires mutex. +func (bbo *bitcoinBroadcastOutbox) withUnchangedActivationSnapshot( + expected bitcoinBroadcastOutboxActivationSnapshot, + operation func() error, +) error { + if operation == nil { + return fmt.Errorf("Bitcoin broadcast outbox activation operation is nil") + } + bbo.mutex.Lock() + defer bbo.mutex.Unlock() + if bbo.closed { + return fmt.Errorf("Bitcoin broadcast outbox is closed") + } + if bbo.activationSnapshotLocked() != expected { + return fmt.Errorf( + "Bitcoin broadcast outbox activation state changed before signing", + ) + } + return operation() +} + +func (bbo *bitcoinBroadcastOutbox) activationSnapshotLocked() bitcoinBroadcastOutboxActivationSnapshot { type reservationState struct { pending bool confirmedVariant bitcoin.Hash @@ -716,7 +743,7 @@ func (bbo *bitcoinBroadcastOutbox) activationSnapshot() ( snapshot.AmbiguousReservationCount++ } } - return snapshot, nil + return snapshot } // replayOnce is the test/internal synchronous entry point. diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 20eb4b0cd9..6f660d4d40 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -1161,25 +1161,38 @@ func (fahe *frostActivationHandshakeExporter) attest( err, ) } - if !fahe.journal.mutex.TryLock() { - return nil, fmt.Errorf( - "%w: %v", - errFrostActivationReconciliationPending, - errFrostActivationJournalBusy, - ) - } - signingStamp, stampErr := fahe.journalStampLocked() - if stampErr != nil || signingStamp != reconciliation.stamp || - !fahe.journal.checkpointDescendsFrom(checkpointFloor) { - fahe.journal.mutex.Unlock() + var signature []byte + journalChanged := false + err = fahe.outbox.withUnchangedActivationSnapshot( + outboxSnapshot, + func() error { + if !fahe.journal.mutex.TryLock() { + return fmt.Errorf( + "%w: %v", + errFrostActivationReconciliationPending, + errFrostActivationJournalBusy, + ) + } + defer fahe.journal.mutex.Unlock() + signingStamp, stampErr := fahe.journalStampLocked() + if stampErr != nil || signingStamp != reconciliation.stamp || + !fahe.journal.checkpointDescendsFrom(checkpointFloor) { + journalChanged = true + return fmt.Errorf( + "%w: canonical or quarantine journal generation changed before signing", + errFrostActivationReconciliationPending, + ) + } + signature = ed25519.Sign(fahe.privateKey, signatureTranscript) + return nil + }, + ) + if journalChanged { fahe.queueReconciliation(finality, true) - return nil, fmt.Errorf( - "%w: canonical or quarantine journal generation changed before signing", - errFrostActivationReconciliationPending, - ) } - signature := ed25519.Sign(fahe.privateKey, signatureTranscript) - fahe.journal.mutex.Unlock() + if err != nil { + return nil, err + } return &frostActivationSignedHandshake{ Payload: payload, SignerPublicKeySPKI: fahe.publicKeySPKI, diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index ff955ddd9f..50c7b001a6 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -272,12 +272,15 @@ type testFrostRetainedGroupHistorySource struct { } type testFrostProductionSignerReadiness struct { - mutex sync.Mutex - journal *frostRetainedGroupJournal - interactive bool - err error - calls uint64 - inventory frostNativeSignerInventorySnapshot + mutex sync.Mutex + journal *frostRetainedGroupJournal + interactive bool + err error + calls uint64 + inventory frostNativeSignerInventorySnapshot + unchangedStarted chan struct{} + unchangedRelease <-chan struct{} + unchangedOnce sync.Once } func testFrostProductionSignerInventorySnapshot() frostNativeSignerInventorySnapshot { @@ -334,6 +337,16 @@ func (readiness *testFrostProductionSignerReadiness) setInventory( readiness.inventory = inventory } +func (readiness *testFrostProductionSignerReadiness) blockUnchangedVerification( + started chan struct{}, + release <-chan struct{}, +) { + readiness.mutex.Lock() + defer readiness.mutex.Unlock() + readiness.unchangedStarted = started + readiness.unchangedRelease = release +} + func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSignerReadiness( ctx context.Context, point FrostPreSignFinality, @@ -364,6 +377,22 @@ func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSigner !expected.InteractiveSigningReady { return fmt.Errorf("cached signer readiness is incomplete") } + readiness.mutex.Lock() + unchangedStarted := readiness.unchangedStarted + unchangedRelease := readiness.unchangedRelease + readiness.mutex.Unlock() + if unchangedStarted != nil { + readiness.unchangedOnce.Do(func() { + close(unchangedStarted) + }) + } + if unchangedRelease != nil { + select { + case <-ctx.Done(): + return ctx.Err() + case <-unchangedRelease: + } + } interactive, readinessErr, inventory := readiness.snapshot() if readinessErr != nil { return readinessErr @@ -904,6 +933,123 @@ func TestFrostActivationHandshakeExporter_RevalidatesNativeSignerStateBeforeSign } } +func TestFrostActivationHandshakeExporter_RevalidatesOutboxStateBeforeSigning( + t *testing.T, +) { + testCases := map[string]func(*bitcoinBroadcastOutbox){ + "quarantine": func(outbox *bitcoinBroadcastOutbox) { + transactionHash := bitcoin.Hash{0x91} + outbox.records[transactionHash] = &bitcoinBroadcastOutboxRecord{ + TransactionHash: transactionHash, + Authorization: bitcoinBroadcastAuthorization{ + ReservationID: [32]byte{0x92}, + }, + Quarantine: &bitcoinBroadcastQuarantine{}, + } + }, + "ambiguous reservation": func(outbox *bitcoinBroadcastOutbox) { + reservationID := [32]byte{0xa1} + for _, transactionHash := range []bitcoin.Hash{ + {0xa2}, + {0xa3}, + } { + outbox.records[transactionHash] = &bitcoinBroadcastOutboxRecord{ + TransactionHash: transactionHash, + Authorization: bitcoinBroadcastAuthorization{ + ReservationID: reservationID, + }, + Confirmation: &bitcoinBroadcastConfirmation{ + Canonical: true, + }, + } + } + }, + } + + for name, mutate := range testCases { + t.Run(name, func(t *testing.T) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + exporter, _, _, _, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + readiness, ok := + exporter.readiness.(*testFrostProductionSignerReadiness) + if !ok { + t.Fatal("unexpected production signer readiness verifier") + } + + response := postTestFrostActivationHandshake(t, endpoint, request) + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + response.Body.Close() + + unchangedStarted := make(chan struct{}) + unchangedRelease := make(chan struct{}) + readiness.blockUnchangedVerification( + unchangedStarted, + unchangedRelease, + ) + type attestationResult struct { + handshake *frostActivationSignedHandshake + err error + } + result := make(chan attestationResult, 1) + go func() { + handshake, err := exporter.attest( + context.Background(), + &request, + ) + result <- attestationResult{handshake: handshake, err: err} + }() + + select { + case <-unchangedStarted: + case <-time.After(time.Second): + t.Fatal("signing-boundary readiness verification did not start") + } + exporter.outbox.mutex.Lock() + mutate(exporter.outbox) + exporter.outbox.mutex.Unlock() + close(unchangedRelease) + + var obsolete attestationResult + select { + case obsolete = <-result: + case <-time.After(time.Second): + t.Fatal("activation attestation did not complete") + } + if obsolete.err == nil || obsolete.handshake != nil { + t.Fatal("activation signed state from an obsolete outbox snapshot") + } + if !strings.Contains( + obsolete.err.Error(), + "outbox activation state changed before signing", + ) { + t.Fatalf("unexpected obsolete outbox error: [%v]", obsolete.err) + } + + exporter.outbox.mutex.Lock() + exporter.outbox.records = + make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord) + exporter.outbox.mutex.Unlock() + handshake, err := exporter.attest(context.Background(), &request) + if err != nil { + t.Fatalf("healthy outbox did not recover signing: [%v]", err) + } + if handshake == nil || !handshake.Payload.State.Healthy { + t.Fatal("healthy stable outbox did not produce a healthy attestation") + } + }) + } +} + func TestFrostActivationHandshakeExporter_PermitsAndAttestsTombstones( t *testing.T, ) { From aa77036cb69f2821b4262a6744d930b40bcab657 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 28 Jul 2026 22:36:14 -0400 Subject: [PATCH 35/93] fix(tbtc): preserve FROST liveness during replay --- pkg/chain/ethereum/ethereum.go | 53 +- .../tbtc_frost_historical_deployment_test.go | 14 +- .../tbtc_frost_pre_sign_authorization.go | 598 +++++++++++++++--- ...st_pre_sign_authorization_verifier_test.go | 459 ++++++++++++++ pkg/tbtc/bitcoin_broadcast_outbox.go | 44 +- pkg/tbtc/bitcoin_broadcast_outbox_test.go | 85 +++ pkg/tbtc/frost_pre_sign_authorization.go | 6 + pkg/tbtc/frost_pre_sign_authorization_test.go | 196 ++++++ pkg/tbtc/frost_primary_ethereum_client.go | 235 +++++++ pkg/tbtc/frost_primary_ethereum_transport.go | 9 +- .../frost_primary_ethereum_transport_test.go | 276 ++++++++ 11 files changed, 1859 insertions(+), 116 deletions(-) create mode 100644 pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go create mode 100644 pkg/tbtc/frost_primary_ethereum_client.go create mode 100644 pkg/tbtc/frost_primary_ethereum_transport_test.go diff --git a/pkg/chain/ethereum/ethereum.go b/pkg/chain/ethereum/ethereum.go index cedc9ad3f6..817450c682 100644 --- a/pkg/chain/ethereum/ethereum.go +++ b/pkg/chain/ethereum/ethereum.go @@ -35,11 +35,12 @@ var logger = log.Logger("keep-ethereum") // provides the implementation of generic features like balance monitor, // block counter and similar. type baseChain struct { - key *keystore.Key - client ethutil.EthereumClient - rpcClient *rpc.Client - rpcLimiter *rate.Limiter - chainID *big.Int + key *keystore.Key + client ethutil.EthereumClient + rpcClient *rpc.Client + rpcLimiter *rate.Limiter + chainID *big.Int + frostPrimaryEthereumRequestTimeout time.Duration blockCounter *ethereum.BlockCounter nonceManager *ethereum.NonceManager @@ -62,6 +63,14 @@ type baseChain struct { tokenStaking *contract.TokenStaking } +// EthereumClient is the client surface required to build chain handles from +// an already-established transport. +type EthereumClient interface { + ethutil.EthereumClient + ChainID(context.Context) (*big.Int, error) + Client() *rpc.Client +} + // Connect creates Random Beacon and TBTC Ethereum chain handles. func Connect( ctx context.Context, @@ -92,7 +101,7 @@ func Connect( func ConnectWithClient( ctx context.Context, config ethereum.Config, - client *ethclient.Client, + client EthereumClient, ) ( *BeaconChain, *TbtcChain, @@ -111,7 +120,7 @@ func ConnectWithClient( func connectWithClient( ctx context.Context, config ethereum.Config, - client *ethclient.Client, + client EthereumClient, ) ( *BeaconChain, *TbtcChain, @@ -256,7 +265,7 @@ func validateContractsAddresses( func newBaseChain( ctx context.Context, config ethereum.Config, - client *ethclient.Client, + client EthereumClient, ) (*baseChain, error) { chainID, err := client.ChainID(ctx) if err != nil { @@ -291,6 +300,13 @@ func newBaseChain( RequestsPerSecondLimit: config.RequestsPerSecondLimit, ConcurrencyLimit: config.ConcurrencyLimit, }) + var frostPrimaryEthereumRequestTimeout time.Duration + if timeoutSource, ok := client.(interface { + FrostPrimaryEthereumRequestTimeout() time.Duration + }); ok { + frostPrimaryEthereumRequestTimeout = + timeoutSource.FrostPrimaryEthereumRequestTimeout() + } blockCounter, err := ethutil.NewBlockCounter(clientWithAddons) if err != nil { @@ -339,16 +355,17 @@ func newBaseChain( } return &baseChain{ - key: key, - client: clientWithAddons, - rpcClient: client.Client(), - rpcLimiter: rpcLimiter, - chainID: chainID, - blockCounter: blockCounter, - nonceManager: nonceManager, - miningWaiter: miningWaiter, - transactionMutex: transactionMutex, - tokenStaking: tokenStaking, + key: key, + client: clientWithAddons, + rpcClient: client.Client(), + rpcLimiter: rpcLimiter, + chainID: chainID, + frostPrimaryEthereumRequestTimeout: frostPrimaryEthereumRequestTimeout, + blockCounter: blockCounter, + nonceManager: nonceManager, + miningWaiter: miningWaiter, + transactionMutex: transactionMutex, + tokenStaking: tokenStaking, }, nil } diff --git a/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go b/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go index b0cc7829e8..b604f85b3d 100644 --- a/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go +++ b/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go @@ -3,6 +3,7 @@ package ethereum import ( "context" "fmt" + "math/big" "strings" "testing" @@ -270,8 +271,19 @@ func TestFrostPreSignExactHashReader_WrappedProductionClient(t *testing.T) { ConcurrencyLimit: config.ConcurrencyLimit, }), } + evidenceReader, err := newFrostPreSignPrimaryEthereumReader( + chain.client, + chain.rpcClient, + big.NewInt(1), + 0, + chain.rpcLimiter, + ) + if err != nil { + t.Fatal(err) + } adapter := &frostPreSignEthereumAdapter{ - chain: &TbtcChain{baseChain: chain}, + chain: &TbtcChain{baseChain: chain}, + reader: evidenceReader, } reader, err := adapter.exactHashReader() diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index 3dadf60483..4bac0bba01 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -12,8 +12,10 @@ import ( "encoding/json" "fmt" "io" + "math" "math/big" "os" + "reflect" "sort" "strings" "sync" @@ -340,6 +342,8 @@ type frostPreSignLinkedLibraryPin struct { type frostPreSignEthereumAdapter struct { chain *TbtcChain + reader tbtc.FrostPreSignEthereumEvidenceVerifier + fromAddress common.Address profile tbtc.FrostPreSignActivationProfile manifest frostPreSignActivationManifest deployments []frostPreSignDeploymentPin @@ -364,6 +368,13 @@ type frostPreSignExactHashReader interface { ) ([]byte, error) } +type frostPreSignStandardEthereumReader interface { + HeaderByNumber(context.Context, *big.Int) (*types.Header, error) + HeaderByHash(context.Context, common.Hash) (*types.Header, error) + TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) + FilterLogs(context.Context, geth.FilterQuery) ([]types.Log, error) +} + type frostPreSignBitcoinTxInfo struct { Version [4]byte InputVector []byte @@ -452,10 +463,19 @@ func (tc *TbtcChain) ConfigureFrostPreSignAuthorization( manifestPath string, trustedEnvelopeSignerKeyHash string, expectedLinkedLibraryDescriptorSetHash string, + ethereumEvidenceVerifier tbtc.FrostPreSignEthereumEvidenceVerifier, ) (*tbtc.FrostPreSignActivationProfile, error) { if ctx == nil { return nil, fmt.Errorf("FROST activation context is nil") } + if tc == nil || tc.baseChain == nil { + return nil, fmt.Errorf("FROST Ethereum chain is unavailable") + } + if ethereumEvidenceVerifier == nil { + return nil, fmt.Errorf( + "independent FROST Ethereum evidence verifier is nil", + ) + } manifest, err := loadFrostPreSignActivationManifest( manifestPath, trustedEnvelopeSignerKeyHash, @@ -475,11 +495,51 @@ func (tc *TbtcChain) ConfigureFrostPreSignAuthorization( if err != nil || expectedDescriptorSetHash != manifestDescriptorSetHash { return nil, fmt.Errorf("signed activation linked-library descriptor set differs from this signer build") } - adapter, err := newFrostPreSignEthereumAdapter(ctx, tc, manifest) + primaryReader, err := newFrostPreSignPrimaryEthereumReader( + tc.client, + tc.rpcClient, + tc.chainID, + tc.frostPrimaryEthereumRequestTimeout, + tc.rpcLimiter, + ) + if err != nil { + return nil, err + } + adapter, err := newFrostPreSignEthereumAdapter( + ctx, + tc, + manifest, + primaryReader, + true, + ) if err != nil { return nil, err } + verifier, err := newFrostPreSignEthereumAdapter( + ctx, + tc, + manifest, + ethereumEvidenceVerifier, + false, + ) + if err != nil { + return nil, fmt.Errorf( + "independent FROST Ethereum verifier rejected activation: [%w]", + err, + ) + } + if _, err := frostPreSignMatchingCurrentFinality( + ctx, + adapter, + verifier, + ); err != nil { + return nil, fmt.Errorf( + "FROST Ethereum endpoints do not share one finalized activation point: [%w]", + err, + ) + } tc.frostPreSignAuthorizationAdapter = adapter + tc.frostPreSignAuthorizationVerifier = verifier profile := adapter.profile return &profile, nil } @@ -586,21 +646,69 @@ func loadFrostPreSignActivationManifest( } type frostPreSignCanonicalHashReader struct { - headerReader interface { + standardReader frostPreSignStandardEthereumReader + headerReader interface { HeaderByHash(context.Context, common.Hash) (*types.Header, error) } - rpcClient *rpc.Client - rpcLimiter interface { + rpcClient *rpc.Client + chainID *big.Int + requestTimeout time.Duration + rpcLimiter interface { AcquirePermit() error ReleasePermit() } } +func (reader *frostPreSignCanonicalHashReader) ChainID( + context.Context, +) (*big.Int, error) { + if reader.chainID == nil { + return nil, fmt.Errorf("Ethereum chain ID is unavailable") + } + return new(big.Int).Set(reader.chainID), nil +} + +func (reader *frostPreSignCanonicalHashReader) HeaderByNumber( + ctx context.Context, + number *big.Int, +) (*types.Header, error) { + if reader.standardReader == nil { + return nil, fmt.Errorf("standard Ethereum reader is unavailable") + } + return reader.standardReader.HeaderByNumber(ctx, number) +} + func (reader *frostPreSignCanonicalHashReader) HeaderByHash( ctx context.Context, blockHash common.Hash, ) (*types.Header, error) { - return reader.headerReader.HeaderByHash(ctx, blockHash) + if reader.standardReader == nil { + if reader.headerReader == nil { + return nil, fmt.Errorf("exact-hash Ethereum reader is unavailable") + } + return reader.headerReader.HeaderByHash(ctx, blockHash) + } + return reader.standardReader.HeaderByHash(ctx, blockHash) +} + +func (reader *frostPreSignCanonicalHashReader) TransactionReceipt( + ctx context.Context, + transactionHash common.Hash, +) (*types.Receipt, error) { + if reader.standardReader == nil { + return nil, fmt.Errorf("standard Ethereum reader is unavailable") + } + return reader.standardReader.TransactionReceipt(ctx, transactionHash) +} + +func (reader *frostPreSignCanonicalHashReader) FilterLogs( + ctx context.Context, + query geth.FilterQuery, +) ([]types.Log, error) { + if reader.standardReader == nil { + return nil, fmt.Errorf("standard Ethereum reader is unavailable") + } + return reader.standardReader.FilterLogs(ctx, query) } func (reader *frostPreSignCanonicalHashReader) CodeAtHash( @@ -608,6 +716,8 @@ func (reader *frostPreSignCanonicalHashReader) CodeAtHash( account common.Address, blockHash common.Hash, ) ([]byte, error) { + ctx, cancel := reader.requestContext(ctx) + defer cancel() var result hexutil.Bytes err := reader.callContext( ctx, @@ -625,6 +735,8 @@ func (reader *frostPreSignCanonicalHashReader) StorageAtHash( key common.Hash, blockHash common.Hash, ) ([]byte, error) { + ctx, cancel := reader.requestContext(ctx) + defer cancel() var result hexutil.Bytes err := reader.callContext( ctx, @@ -642,6 +754,8 @@ func (reader *frostPreSignCanonicalHashReader) CallContractAtHash( message geth.CallMsg, blockHash common.Hash, ) ([]byte, error) { + ctx, cancel := reader.requestContext(ctx) + defer cancel() var result hexutil.Bytes err := reader.callContext( ctx, @@ -665,10 +779,21 @@ func (reader *frostPreSignCanonicalHashReader) callContext( } defer reader.rpcLimiter.ReleasePermit() } - return reader.rpcClient.CallContext(ctx, result, method, args...) } +func (reader *frostPreSignCanonicalHashReader) requestContext( + ctx context.Context, +) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + if reader.requestTimeout <= 0 { + return context.WithCancel(ctx) + } + return context.WithTimeout(ctx, reader.requestTimeout) +} + func frostPreSignCallArgument(message geth.CallMsg) map[string]interface{} { result := map[string]interface{}{ "from": message.From, @@ -708,19 +833,38 @@ func (adapter *frostPreSignEthereumAdapter) exactHashReader() ( frostPreSignExactHashReader, error, ) { - headerReader, ok := adapter.chain.client.(interface { - HeaderByHash(context.Context, common.Hash) (*types.Header, error) - }) + if adapter == nil || adapter.reader == nil { + return nil, fmt.Errorf("FROST Ethereum evidence reader is unavailable") + } + return adapter.reader, nil +} + +func newFrostPreSignPrimaryEthereumReader( + client interface{}, + rpcClient *rpc.Client, + chainID *big.Int, + requestTimeout time.Duration, + rpcLimiter interface { + AcquirePermit() error + ReleasePermit() + }, +) (tbtc.FrostPreSignEthereumEvidenceVerifier, error) { + standardReader, ok := client.(frostPreSignStandardEthereumReader) if !ok { - return nil, fmt.Errorf("Ethereum client does not expose exact-block-hash headers") + return nil, fmt.Errorf( + "Ethereum client does not expose required canonical evidence reads", + ) } - if adapter.chain.rpcClient == nil { + if rpcClient == nil || chainID == nil || chainID.Sign() <= 0 { return nil, fmt.Errorf("Ethereum client does not expose canonical EIP-1898 reads") } return &frostPreSignCanonicalHashReader{ - headerReader: headerReader, - rpcClient: adapter.chain.rpcClient, - rpcLimiter: adapter.chain.rpcLimiter, + standardReader: standardReader, + headerReader: standardReader, + rpcClient: rpcClient, + chainID: new(big.Int).Set(chainID), + requestTimeout: requestTimeout, + rpcLimiter: rpcLimiter, }, nil } @@ -1452,8 +1596,11 @@ func newFrostPreSignEthereumAdapter( ctx context.Context, tc *TbtcChain, manifest *frostPreSignActivationManifest, + reader tbtc.FrostPreSignEthereumEvidenceVerifier, + enableRelay bool, ) (*frostPreSignEthereumAdapter, error) { - if tc == nil || tc.baseChain == nil || tc.client == nil || manifest == nil { + if tc == nil || tc.baseChain == nil || tc.client == nil || + manifest == nil || reader == nil { return nil, fmt.Errorf("FROST Ethereum adapter dependencies are nil") } if tc.frostWalletRegistry == nil || tc.frostSortitionPool == nil { @@ -1470,31 +1617,43 @@ func newFrostPreSignEthereumAdapter( common.Address(profile.FrostRegistry) != tc.frostWalletRegistryAddr { return nil, fmt.Errorf("activation manifest differs from configured Bridge/FROST registry") } + actualChainID, err := reader.ChainID(ctx) + if err != nil || actualChainID == nil || + actualChainID.Cmp(tc.chainID) != 0 { + return nil, fmt.Errorf( + "activation manifest chain ID differs from Ethereum evidence reader: [%w]", + err, + ) + } expectedGenesisHash, err := frostPreSignParseBytes32( manifest.Ethereum.GenesisBlockHash, ) if err != nil { return nil, err } - genesisHeader, err := tc.client.HeaderByNumber(ctx, big.NewInt(0)) + genesisHeader, err := reader.HeaderByNumber(ctx, big.NewInt(0)) if err != nil || genesisHeader == nil || genesisHeader.Number == nil || genesisHeader.Number.Sign() != 0 || genesisHeader.Hash() != common.Hash(expectedGenesisHash) { return nil, fmt.Errorf("activation manifest genesis block differs from connected Ethereum chain: [%w]", err) } - finality, err := frostPreSignCurrentFinality(ctx, tc.client) + finality, err := frostPreSignCurrentFinality(ctx, reader) if err != nil { return nil, err } adapter := &frostPreSignEthereumAdapter{ chain: tc, + reader: reader, + fromAddress: tc.key.Address, profile: profile, manifest: *manifest, deployments: deployments, - bridge: bind.NewBoundContract( + } + if enableRelay { + adapter.bridge = bind.NewBoundContract( common.Address(profile.BridgeAddress), frostPreSignBridgeABI, tc.client, tc.client, tc.client, - ), + ) } if err := adapter.verifyDeploymentAt(ctx, finality); err != nil { return nil, fmt.Errorf("FROST activation manifest verification failed: [%w]", err) @@ -2116,7 +2275,9 @@ func frostPreSignCurrentFinality( if err != nil { return nil, fmt.Errorf("cannot obtain finalized Ethereum header: [%w]", err) } - if header == nil || header.Number == nil || header.Number.Sign() <= 0 { + if header == nil || header.Number == nil || + !header.Number.IsUint64() || header.Number.Sign() <= 0 || + header.Hash() == (common.Hash{}) { return nil, fmt.Errorf("finalized Ethereum header is invalid") } return &tbtc.FrostPreSignFinality{ @@ -2381,7 +2542,7 @@ func (adapter *frostPreSignEthereumAdapter) requireCanonicalFinality( if finality == nil || finality.BlockNumber == 0 || finality.BlockHash == [32]byte{} { return fmt.Errorf("Ethereum finality checkpoint is invalid") } - before, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) + before, err := frostPreSignCurrentFinality(ctx, adapter.reader) if err != nil { return err } @@ -2408,7 +2569,7 @@ func (adapter *frostPreSignEthereumAdapter) requireCanonicalFinality( exactHeader.Hash() != common.Hash(finality.BlockHash) { return fmt.Errorf("exact finalized Ethereum header mismatch") } - header, err := adapter.chain.client.HeaderByNumber( + header, err := adapter.reader.HeaderByNumber( ctx, new(big.Int).SetUint64(finality.BlockNumber), ) @@ -2418,7 +2579,7 @@ func (adapter *frostPreSignEthereumAdapter) requireCanonicalFinality( if header == nil || [32]byte(header.Hash()) != finality.BlockHash { return fmt.Errorf("finalized Ethereum block hash mismatch") } - after, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) + after, err := frostPreSignCurrentFinality(ctx, adapter.reader) if err != nil { return err } @@ -2428,7 +2589,7 @@ func (adapter *frostPreSignEthereumAdapter) requireCanonicalFinality( (before.BlockNumber == after.BlockNumber && before.BlockHash != after.BlockHash) { return fmt.Errorf("Ethereum finalized head changed inconsistently while verifying checkpoint") } - headerAfter, err := adapter.chain.client.HeaderByNumber( + headerAfter, err := adapter.reader.HeaderByNumber( ctx, new(big.Int).SetUint64(finality.BlockNumber), ) @@ -2476,7 +2637,7 @@ func (adapter *frostPreSignEthereumAdapter) callAtHash( output, err := exactReader.CallContractAtHash( ctx, geth.CallMsg{ - From: adapter.chain.key.Address, + From: adapter.fromAddress, To: &address, Data: callData, }, @@ -2541,22 +2702,67 @@ func (tc *TbtcChain) PrepareFrostPreSignAuthorization( transaction *tbtc.FrostPreSignTransaction, walletOperators []chain.Address, ) (*tbtc.FrostPreSignAuthorizationProposal, error) { - adapter, err := tc.frostPreSignAdapter() + adapter, verifier, err := tc.frostPreSignAdapterPair() if err != nil { return nil, err } - return adapter.prepare(ctx, transaction, walletOperators) + finality, err := frostPreSignMatchingCurrentFinality( + ctx, + adapter, + verifier, + ) + if err != nil { + return nil, err + } + primaryProposal, err := adapter.prepareAtFinality( + ctx, + transaction, + walletOperators, + finality, + ) + if err != nil { + return nil, err + } + verifiedProposal, err := verifier.prepareAtFinality( + ctx, + transaction, + walletOperators, + finality, + ) + if err != nil { + return nil, fmt.Errorf( + "independent FROST authorization preparation failed: [%w]", + err, + ) + } + if err := frostPreSignRequireMatchingEvidence( + "authorization preparation", + primaryProposal, + verifiedProposal, + ); err != nil { + return nil, err + } + return primaryProposal, nil } func (tc *TbtcChain) VerifyFrostPreSignActivationPoint( ctx context.Context, finality tbtc.FrostPreSignFinality, ) error { - adapter, err := tc.frostPreSignAdapter() + adapter, verifier, err := tc.frostPreSignAdapterPair() if err != nil { return err } - return adapter.verifyDeploymentAt(ctx, &finality) + if err := adapter.verifyDeploymentAt(ctx, &finality); err != nil { + return err + } + if err := verifier.verifyDeploymentAt(ctx, &finality); err != nil { + return fmt.Errorf( + "independent FROST activation-point verification failed: [%w]", + err, + ) + } + return nil } func (tc *TbtcChain) FrostPreSignActivationRuntimeManifest() ( @@ -2990,10 +3196,27 @@ func (adapter *frostPreSignEthereumAdapter) prepare( if ctx == nil || transaction == nil { return nil, fmt.Errorf("FROST authorization preparation input is nil") } - finality, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) + finality, err := frostPreSignCurrentFinality(ctx, adapter.reader) if err != nil { return nil, err } + return adapter.prepareAtFinality( + ctx, + transaction, + walletOperators, + finality, + ) +} + +func (adapter *frostPreSignEthereumAdapter) prepareAtFinality( + ctx context.Context, + transaction *tbtc.FrostPreSignTransaction, + walletOperators []chain.Address, + finality *tbtc.FrostPreSignFinality, +) (*tbtc.FrostPreSignAuthorizationProposal, error) { + if ctx == nil || transaction == nil || finality == nil { + return nil, fmt.Errorf("FROST authorization preparation input is nil") + } if err := adapter.verifyDeploymentAt(ctx, finality); err != nil { return nil, err } @@ -3120,6 +3343,66 @@ func (tc *TbtcChain) frostPreSignAdapter() (*frostPreSignEthereumAdapter, error) return tc.frostPreSignAuthorizationAdapter, nil } +func (tc *TbtcChain) frostPreSignAdapterPair() ( + *frostPreSignEthereumAdapter, + *frostPreSignEthereumAdapter, + error, +) { + if tc == nil || tc.frostPreSignAuthorizationAdapter == nil || + tc.frostPreSignAuthorizationVerifier == nil { + return nil, nil, fmt.Errorf( + "production FROST authorization verifier pair is not configured", + ) + } + return tc.frostPreSignAuthorizationAdapter, + tc.frostPreSignAuthorizationVerifier, + nil +} + +func frostPreSignMatchingCurrentFinality( + ctx context.Context, + primary *frostPreSignEthereumAdapter, + verifier *frostPreSignEthereumAdapter, +) (*tbtc.FrostPreSignFinality, error) { + if ctx == nil || primary == nil || verifier == nil || + primary.reader == nil || verifier.reader == nil { + return nil, fmt.Errorf("FROST Ethereum verifier pair is incomplete") + } + primaryFinality, err := frostPreSignCurrentFinality(ctx, primary.reader) + if err != nil { + return nil, err + } + verifiedFinality, err := frostPreSignCurrentFinality(ctx, verifier.reader) + if err != nil { + return nil, fmt.Errorf( + "cannot obtain independent finalized Ethereum header: [%w]", + err, + ) + } + if primaryFinality.BlockNumber != verifiedFinality.BlockNumber || + primaryFinality.BlockHash != verifiedFinality.BlockHash { + return nil, fmt.Errorf( + "FROST Ethereum endpoints disagree on the current finalized block", + ) + } + if err := primary.requireCanonicalFinality( + ctx, + primaryFinality, + ); err != nil { + return nil, err + } + if err := verifier.requireCanonicalFinality( + ctx, + verifiedFinality, + ); err != nil { + return nil, fmt.Errorf( + "independent finalized Ethereum checkpoint verification failed: [%w]", + err, + ) + } + return primaryFinality, nil +} + func (adapter *frostPreSignEthereumAdapter) resolveWalletMembersAt( ctx context.Context, operators []chain.Address, @@ -3464,6 +3747,11 @@ func (adapter *frostPreSignEthereumAdapter) relay( if ctx == nil || proposal == nil || proposal.Transaction == nil || attestation == nil { return [32]byte{}, fmt.Errorf("FROST authorization relay input is nil") } + if adapter == nil || adapter.chain == nil || adapter.bridge == nil { + return [32]byte{}, fmt.Errorf( + "FROST authorization relay adapter is unavailable", + ) + } if !bytes.Equal(frostPreSignUint32SliceBytes(proposal.WalletMembersIDs), frostPreSignUint32SliceBytes(attestation.WalletMembersIDs)) { return [32]byte{}, fmt.Errorf("FROST relay attestation wallet members differ from preview") } @@ -3543,11 +3831,37 @@ func (tc *TbtcChain) WaitForFrostPreSignAuthorizationFinality( relayTransactionHash [32]byte, proposal *tbtc.FrostPreSignAuthorizationProposal, ) (*tbtc.FrostPreSignFinality, error) { - adapter, err := tc.frostPreSignAdapter() + adapter, verifier, err := tc.frostPreSignAdapterPair() if err != nil { return nil, err } - return adapter.waitForFinality(ctx, relayTransactionHash, proposal) + verifiedFinality, err := verifier.waitForFinality( + ctx, + relayTransactionHash, + proposal, + ) + if err != nil { + return nil, fmt.Errorf( + "independent COMPLETE relay finality verification failed: [%w]", + err, + ) + } + primaryFinality, err := adapter.waitForFinality( + ctx, + relayTransactionHash, + proposal, + ) + if err != nil { + return nil, err + } + if err := frostPreSignRequireMatchingEvidence( + "COMPLETE relay finality", + primaryFinality, + verifiedFinality, + ); err != nil { + return nil, err + } + return primaryFinality, nil } func (adapter *frostPreSignEthereumAdapter) waitForFinality( @@ -3561,54 +3875,62 @@ func (adapter *frostPreSignEthereumAdapter) waitForFinality( } ticker := time.NewTicker(time.Second) defer ticker.Stop() - var receipt *types.Receipt - for receipt == nil { - var err error - receipt, err = adapter.chain.client.TransactionReceipt( + for { + // A pre-finality receipt is provisional. Re-read it on every poll so a + // transaction re-included at a different canonical block after a reorg + // can still reach finality. + receipt, err := adapter.reader.TransactionReceipt( ctx, common.Hash(relayTransactionHash), ) if err != nil && err != geth.NotFound { return nil, fmt.Errorf("cannot obtain COMPLETE relay receipt: [%w]", err) } - if receipt != nil { - break - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-ticker.C: - } - } - if receipt.Status != types.ReceiptStatusSuccessful || receipt.BlockNumber == nil { - return nil, fmt.Errorf("COMPLETE relay transaction reverted or has no inclusion block") - } - sequence, logIndex, err := adapter.validateAuthorizationReceipt(receipt, proposal) - if err != nil { - return nil, err - } - - for { - finalized, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) - if err != nil { - return nil, err - } - if finalized.BlockNumber >= receipt.BlockNumber.Uint64() { - header, err := adapter.chain.client.HeaderByNumber(ctx, receipt.BlockNumber) + if err != geth.NotFound && receipt != nil { + if receipt.BlockNumber == nil || !receipt.BlockNumber.IsUint64() || + receipt.BlockNumber.Sign() <= 0 { + return nil, fmt.Errorf( + "COMPLETE relay transaction has no valid inclusion block", + ) + } + finalized, err := frostPreSignCurrentFinality(ctx, adapter.reader) if err != nil { - return nil, fmt.Errorf("cannot verify COMPLETE receipt block: [%w]", err) + return nil, err } - if header == nil || header.Hash() != receipt.BlockHash { - return nil, fmt.Errorf("COMPLETE relay receipt is not canonical") + if finalized.BlockNumber >= receipt.BlockNumber.Uint64() { + header, err := adapter.reader.HeaderByNumber(ctx, receipt.BlockNumber) + if err != nil { + return nil, fmt.Errorf( + "cannot verify COMPLETE receipt block: [%w]", + err, + ) + } + if header != nil && header.Hash() == receipt.BlockHash { + if receipt.Status != types.ReceiptStatusSuccessful { + return nil, fmt.Errorf( + "COMPLETE relay transaction reverted", + ) + } + sequence, logIndex, err := adapter.validateAuthorizationReceipt( + receipt, + relayTransactionHash, + proposal, + ) + if err != nil { + return nil, err + } + return &tbtc.FrostPreSignFinality{ + RelayTransactionHash: relayTransactionHash, + BlockNumber: receipt.BlockNumber.Uint64(), + BlockHash: [32]byte(receipt.BlockHash), + TransactionIndex: uint32(receipt.TransactionIndex), + LogIndex: logIndex, + AuthorizationSequence: sequence, + }, nil + } + // The observed receipt was orphaned. Continue polling for the + // same transaction's canonical re-inclusion. } - return &tbtc.FrostPreSignFinality{ - RelayTransactionHash: relayTransactionHash, - BlockNumber: receipt.BlockNumber.Uint64(), - BlockHash: [32]byte(receipt.BlockHash), - TransactionIndex: uint32(receipt.TransactionIndex), - LogIndex: logIndex, - AuthorizationSequence: sequence, - }, nil } select { case <-ctx.Done(): @@ -3620,8 +3942,20 @@ func (adapter *frostPreSignEthereumAdapter) waitForFinality( func (adapter *frostPreSignEthereumAdapter) validateAuthorizationReceipt( receipt *types.Receipt, + relayTransactionHash [32]byte, proposal *tbtc.FrostPreSignAuthorizationProposal, ) ([32]byte, uint32, error) { + if receipt == nil || proposal == nil || proposal.Transaction == nil || + receipt.TxHash != common.Hash(relayTransactionHash) || + receipt.BlockHash == (common.Hash{}) || + receipt.BlockNumber == nil || + !receipt.BlockNumber.IsUint64() || + receipt.BlockNumber.Sign() <= 0 || + uint64(receipt.TransactionIndex) > uint64(math.MaxUint32) { + return [32]byte{}, 0, fmt.Errorf( + "COMPLETE receipt transaction identity mismatch", + ) + } authorizedEvent := frostPreSignRegistryABI.Events["P2TRPreSigningReservationAuthorized"] advancedEvent := frostPreSignRegistryABI.Events["P2TRAuthorizedVariantAdvanced"] registryAddress := common.Address(adapter.profile.RegistryAddress) @@ -3630,6 +3964,15 @@ func (adapter *frostPreSignEthereumAdapter) validateAuthorizationReceipt( if logEntry == nil || logEntry.Address != registryAddress || len(logEntry.Topics) == 0 { continue } + if logEntry.Removed || + logEntry.TxHash != receipt.TxHash || + logEntry.BlockHash != receipt.BlockHash || + logEntry.BlockNumber != receipt.BlockNumber.Uint64() || + logEntry.TxIndex != receipt.TransactionIndex { + return [32]byte{}, 0, fmt.Errorf( + "COMPLETE receipt log transaction identity mismatch", + ) + } switch logEntry.Topics[0] { case authorizedEvent.ID: if authorizedLog != nil { @@ -3670,17 +4013,22 @@ func (adapter *frostPreSignEthereumAdapter) validateAuthorizationReceipt( if sequence == [32]byte{} { return [32]byte{}, 0, fmt.Errorf("COMPLETE authorization sequence is zero") } + if uint64(advancedLog.Index) > uint64(math.MaxUint32) { + return [32]byte{}, 0, fmt.Errorf( + "COMPLETE authorization log index overflows", + ) + } return sequence, uint32(advancedLog.Index), nil } func (tc *TbtcChain) CurrentFrostPreSignFinality( ctx context.Context, ) (*tbtc.FrostPreSignFinality, error) { - adapter, err := tc.frostPreSignAdapter() + adapter, verifier, err := tc.frostPreSignAdapterPair() if err != nil { return nil, err } - return frostPreSignCurrentFinality(ctx, adapter.chain.client) + return frostPreSignMatchingCurrentFinality(ctx, adapter, verifier) } func (tc *TbtcChain) ReadFrostPreSignAuthorizationState( @@ -3688,11 +4036,37 @@ func (tc *TbtcChain) ReadFrostPreSignAuthorizationState( proposal *tbtc.FrostPreSignAuthorizationProposal, finality tbtc.FrostPreSignFinality, ) (*tbtc.FrostPreSignAuthorizationState, error) { - adapter, err := tc.frostPreSignAdapter() + adapter, verifier, err := tc.frostPreSignAdapterPair() + if err != nil { + return nil, err + } + primaryState, err := adapter.readAuthorizationState( + ctx, + proposal, + finality, + ) if err != nil { return nil, err } - return adapter.readAuthorizationState(ctx, proposal, finality) + verifiedState, err := verifier.readAuthorizationState( + ctx, + proposal, + finality, + ) + if err != nil { + return nil, fmt.Errorf( + "independent FROST authorization state verification failed: [%w]", + err, + ) + } + if err := frostPreSignRequireMatchingEvidence( + "authorization state", + primaryState, + verifiedState, + ); err != nil { + return nil, err + } + return primaryState, nil } func (adapter *frostPreSignEthereumAdapter) readAuthorizationState( @@ -3861,20 +4235,70 @@ func (tc *TbtcChain) GetCanonicalFrostBitcoinBroadcastAuthorizationStatus( ctx context.Context, request *tbtc.FrostBitcoinBroadcastAuthorizationStatusRequest, ) (*tbtc.FrostBitcoinBroadcastAuthorizationStatus, error) { - adapter, err := tc.frostPreSignAdapter() + adapter, verifier, err := tc.frostPreSignAdapterPair() + if err != nil { + return nil, err + } + current, err := frostPreSignMatchingCurrentFinality( + ctx, + adapter, + verifier, + ) + if err != nil { + return nil, err + } + primaryStatus, err := adapter.canonicalBroadcastStatus( + ctx, + request, + current, + ) + if err != nil { + return nil, err + } + verifiedStatus, err := verifier.canonicalBroadcastStatus( + ctx, + request, + current, + ) if err != nil { + return nil, fmt.Errorf( + "independent Bitcoin broadcast authorization verification failed: [%w]", + err, + ) + } + if err := frostPreSignRequireMatchingEvidence( + "Bitcoin broadcast authorization", + primaryStatus, + verifiedStatus, + ); err != nil { return nil, err } - return adapter.canonicalBroadcastStatus(ctx, request) + return primaryStatus, nil +} + +func frostPreSignRequireMatchingEvidence( + evidenceName string, + primary interface{}, + verified interface{}, +) error { + if !reflect.DeepEqual(primary, verified) { + return fmt.Errorf( + "FROST Ethereum endpoints disagree on %s", + evidenceName, + ) + } + return nil } func (adapter *frostPreSignEthereumAdapter) canonicalBroadcastStatus( ctx context.Context, request *tbtc.FrostBitcoinBroadcastAuthorizationStatusRequest, + current *tbtc.FrostPreSignFinality, ) (*tbtc.FrostBitcoinBroadcastAuthorizationStatus, error) { if ctx == nil || request == nil || request.FinalizedBlock == 0 || request.FinalizedBlockHash == [32]byte{} || - request.VariantSequence.AuthorizationSequence == [32]byte{} { + request.VariantSequence.AuthorizationSequence == [32]byte{} || + current == nil { return nil, fmt.Errorf("Bitcoin broadcast authorization request is invalid") } requestHash := request.ComputeHash() @@ -3907,10 +4331,6 @@ func (adapter *frostPreSignEthereumAdapter) canonicalBroadcastStatus( }, nil } - current, err := frostPreSignCurrentFinality(ctx, adapter.chain.client) - if err != nil { - return nil, err - } if err := adapter.verifyDeploymentAt(ctx, current); err != nil { return nil, err } @@ -3943,7 +4363,7 @@ func (adapter *frostPreSignEthereumAdapter) validateHistoricalBroadcastEvent( ) error { blockHash := common.Hash(request.FinalizedBlockHash) event := frostPreSignRegistryABI.Events["P2TRAuthorizedVariantAdvanced"] - logs, err := adapter.chain.client.FilterLogs(ctx, geth.FilterQuery{ + logs, err := adapter.reader.FilterLogs(ctx, geth.FilterQuery{ BlockHash: &blockHash, Addresses: []common.Address{common.Address(adapter.profile.RegistryAddress)}, Topics: [][]common.Hash{ @@ -3957,7 +4377,17 @@ func (adapter *frostPreSignEthereumAdapter) validateHistoricalBroadcastEvent( return fmt.Errorf("cannot read historical COMPLETE authorization event: [%w]", err) } if len(logs) != 1 || logs[0].Index != uint(request.FinalizedLogIndex) || - logs[0].TxIndex != uint(request.FinalizedTransactionIndex) || logs[0].Removed { + logs[0].TxIndex != uint(request.FinalizedTransactionIndex) || + logs[0].Removed || + logs[0].Address != common.Address(adapter.profile.RegistryAddress) || + logs[0].BlockHash != blockHash || + logs[0].TxHash == (common.Hash{}) || + len(logs[0].Topics) != 4 || + logs[0].Topics[0] != event.ID || + logs[0].Topics[1] != common.Hash(request.ReservationID) || + logs[0].Topics[2] != common.Hash(request.TransactionHash) || + logs[0].Topics[3] != + common.Hash(request.VariantSequence.AuthorizationSequence) { return fmt.Errorf("historical COMPLETE authorization event identity mismatch") } return nil diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go new file mode 100644 index 0000000000..deb2bdefd2 --- /dev/null +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go @@ -0,0 +1,459 @@ +package ethereum + +import ( + "context" + "fmt" + "math/big" + "testing" + "time" + + geth "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + ethereumConfig "github.com/keep-network/keep-common/pkg/chain/ethereum" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/tbtc" +) + +type testFrostPreSignEvidenceReader struct { + finalized *types.Header + headers map[uint64]*types.Header + receipts []*types.Receipt + receiptCall int +} + +func TestNewFrostPreSignPrimaryEthereumReaderAcceptsWrappedClient( + t *testing.T, +) { + server := rpc.NewServer() + rpcClient := rpc.DialInProc(server) + defer rpcClient.Close() + client := ethclient.NewClient(rpcClient) + wrapped := wrapClientAddons(ethereumConfig.Config{}, client) + + reader, err := newFrostPreSignPrimaryEthereumReader( + wrapped, + rpcClient, + big.NewInt(1), + 0, + nil, + ) + if err != nil { + t.Fatal(err) + } + if reader == nil { + t.Fatal("wrapped primary Ethereum reader is nil") + } +} + +func (reader *testFrostPreSignEvidenceReader) ChainID( + context.Context, +) (*big.Int, error) { + return big.NewInt(1), nil +} + +func (reader *testFrostPreSignEvidenceReader) HeaderByNumber( + _ context.Context, + number *big.Int, +) (*types.Header, error) { + if reader.finalized == nil || number == nil { + return nil, fmt.Errorf("header unavailable") + } + if number.Sign() < 0 { + return reader.finalized, nil + } + if number.IsUint64() && reader.headers != nil { + if header := reader.headers[number.Uint64()]; header != nil { + return header, nil + } + } + if reader.finalized.Number != nil && + number.Cmp(reader.finalized.Number) == 0 { + return reader.finalized, nil + } + return nil, fmt.Errorf("header unavailable") +} + +func (reader *testFrostPreSignEvidenceReader) HeaderByHash( + _ context.Context, + hash common.Hash, +) (*types.Header, error) { + if reader.finalized != nil && reader.finalized.Hash() == hash { + return reader.finalized, nil + } + return nil, fmt.Errorf("header unavailable") +} + +func (reader *testFrostPreSignEvidenceReader) TransactionReceipt( + context.Context, + common.Hash, +) (*types.Receipt, error) { + if len(reader.receipts) != 0 { + index := reader.receiptCall + if index >= len(reader.receipts) { + index = len(reader.receipts) - 1 + } + reader.receiptCall++ + return reader.receipts[index], nil + } + return nil, fmt.Errorf("receipt unavailable") +} + +func (*testFrostPreSignEvidenceReader) FilterLogs( + context.Context, + geth.FilterQuery, +) ([]types.Log, error) { + return nil, nil +} + +func (*testFrostPreSignEvidenceReader) CodeAtHash( + context.Context, + common.Address, + common.Hash, +) ([]byte, error) { + return nil, fmt.Errorf("code unavailable") +} + +func (*testFrostPreSignEvidenceReader) StorageAtHash( + context.Context, + common.Address, + common.Hash, + common.Hash, +) ([]byte, error) { + return nil, fmt.Errorf("storage unavailable") +} + +func (*testFrostPreSignEvidenceReader) CallContractAtHash( + context.Context, + geth.CallMsg, + common.Hash, +) ([]byte, error) { + return nil, fmt.Errorf("call unavailable") +} + +func TestFrostPreSignCurrentFinalityRequiresEndpointAgreement(t *testing.T) { + primaryHeader := &types.Header{ + Number: big.NewInt(10), + Time: 10, + Extra: []byte{0x01}, + } + verifierHeader := &types.Header{ + Number: big.NewInt(10), + Time: 10, + Extra: []byte{0x02}, + } + chain := &TbtcChain{ + frostPreSignAuthorizationAdapter: &frostPreSignEthereumAdapter{ + reader: &testFrostPreSignEvidenceReader{ + finalized: primaryHeader, + }, + }, + frostPreSignAuthorizationVerifier: &frostPreSignEthereumAdapter{ + reader: &testFrostPreSignEvidenceReader{ + finalized: verifierHeader, + }, + }, + } + + if _, err := chain.CurrentFrostPreSignFinality( + context.Background(), + ); err == nil { + t.Fatal("different finalized block hashes were accepted") + } + + chain.frostPreSignAuthorizationVerifier.reader = + &testFrostPreSignEvidenceReader{finalized: primaryHeader} + actual, err := chain.CurrentFrostPreSignFinality(context.Background()) + if err != nil { + t.Fatal(err) + } + if actual.BlockNumber != 10 || + actual.BlockHash != [32]byte(primaryHeader.Hash()) { + t.Fatalf("unexpected common finality [%+v]", actual) + } +} + +func TestFrostPreSignAuthorizationStateRequiresVerifierAgreement( + t *testing.T, +) { + canonical := &tbtc.FrostPreSignAuthorizationState{ + ActiveReservationID: [32]byte{0x01}, + } + forged := *canonical + forged.ActiveReservationID = [32]byte{0xa5} + + if err := frostPreSignRequireMatchingEvidence( + "authorization state", + &forged, + canonical, + ); err == nil { + t.Fatal("forged primary reservation agreed with verifier") + } + if err := frostPreSignRequireMatchingEvidence( + "authorization state", + canonical, + canonical, + ); err != nil { + t.Fatalf("matching authorization state rejected: [%v]", err) + } +} + +func TestFrostPreSignAuthorizationReceiptBindsTransactionAndLogs( + t *testing.T, +) { + registry := common.HexToAddress( + "0x1111111111111111111111111111111111111111", + ) + adapter := &frostPreSignEthereumAdapter{ + profile: tbtc.FrostPreSignActivationProfile{ + RegistryAddress: [20]byte(registry), + }, + } + relayHash := [32]byte{0x11} + blockHash := common.HexToHash("0x22") + proposal := &tbtc.FrostPreSignAuthorizationProposal{ + Transaction: &tbtc.FrostPreSignTransaction{ + Action: tbtc.FrostPreSignActionRedemption, + TransactionHash: bitcoin.Hash{0x33}, + }, + ReservationID: [32]byte{0x44}, + WalletID: [32]byte{0x55}, + AuthorizationRoot: [32]byte{0x66}, + SnapshotHash: [32]byte{0x77}, + ResourceHash: [32]byte{0x88}, + } + authorizedEvent := + frostPreSignRegistryABI.Events["P2TRPreSigningReservationAuthorized"] + authorizedData, err := authorizedEvent.Inputs.NonIndexed().Pack( + proposal.AuthorizationRoot, + proposal.SnapshotHash, + proposal.ResourceHash, + uint8(proposal.Transaction.Action), + ) + if err != nil { + t.Fatal(err) + } + advancedEvent := + frostPreSignRegistryABI.Events["P2TRAuthorizedVariantAdvanced"] + transactionHash := common.Hash(proposal.Transaction.TransactionHash) + receipt := &types.Receipt{ + TxHash: common.Hash(relayHash), + BlockHash: blockHash, + BlockNumber: big.NewInt(10), + TransactionIndex: 3, + Logs: []*types.Log{ + { + Address: registry, + Topics: []common.Hash{ + authorizedEvent.ID, + common.Hash(proposal.ReservationID), + transactionHash, + common.Hash(proposal.WalletID), + }, + Data: authorizedData, + BlockHash: blockHash, + BlockNumber: 10, + TxHash: common.Hash(relayHash), + TxIndex: 3, + Index: 7, + }, + { + Address: registry, + Topics: []common.Hash{ + advancedEvent.ID, + common.Hash(proposal.ReservationID), + transactionHash, + common.HexToHash("0x99"), + }, + BlockHash: blockHash, + BlockNumber: 10, + TxHash: common.Hash(relayHash), + TxIndex: 3, + Index: 8, + }, + }, + } + + if _, _, err := adapter.validateAuthorizationReceipt( + receipt, + relayHash, + proposal, + ); err != nil { + t.Fatalf("valid receipt rejected: [%v]", err) + } + + wrongRelayHash := relayHash + wrongRelayHash[1] = 0x01 + if _, _, err := adapter.validateAuthorizationReceipt( + receipt, + wrongRelayHash, + proposal, + ); err == nil { + t.Fatal("receipt for a different relay transaction accepted") + } + + receipt.Logs[1].TxHash = common.HexToHash("0xaa") + if _, _, err := adapter.validateAuthorizationReceipt( + receipt, + relayHash, + proposal, + ); err == nil { + t.Fatal("event from a different transaction accepted") + } +} + +func TestFrostPreSignWaitForFinalityRefreshesReincludedReceipt( + t *testing.T, +) { + registry := common.HexToAddress( + "0x1111111111111111111111111111111111111111", + ) + relayHash := [32]byte{0x11} + proposal := &tbtc.FrostPreSignAuthorizationProposal{ + Transaction: &tbtc.FrostPreSignTransaction{ + Action: tbtc.FrostPreSignActionRedemption, + TransactionHash: bitcoin.Hash{0x33}, + }, + ReservationID: [32]byte{0x44}, + WalletID: [32]byte{0x55}, + AuthorizationRoot: [32]byte{0x66}, + SnapshotHash: [32]byte{0x77}, + ResourceHash: [32]byte{0x88}, + } + orphanedHeader := &types.Header{ + Number: big.NewInt(10), + Time: 10, + Extra: []byte{0x01}, + } + canonicalHeader := &types.Header{ + Number: big.NewInt(10), + Time: 10, + Extra: []byte{0x02}, + } + reincludedHeader := &types.Header{ + Number: big.NewInt(11), + Time: 11, + Extra: []byte{0x03}, + } + orphanedReceipt := testFrostPreSignAuthorizationReceipt( + t, + registry, + relayHash, + proposal, + orphanedHeader, + 3, + 7, + 8, + ) + reincludedReceipt := testFrostPreSignAuthorizationReceipt( + t, + registry, + relayHash, + proposal, + reincludedHeader, + 4, + 17, + 18, + ) + reader := &testFrostPreSignEvidenceReader{ + finalized: reincludedHeader, + headers: map[uint64]*types.Header{ + 10: canonicalHeader, + 11: reincludedHeader, + }, + receipts: []*types.Receipt{ + orphanedReceipt, + reincludedReceipt, + }, + } + adapter := &frostPreSignEthereumAdapter{ + reader: reader, + profile: tbtc.FrostPreSignActivationProfile{ + RegistryAddress: [20]byte(registry), + }, + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + finality, err := adapter.waitForFinality(ctx, relayHash, proposal) + if err != nil { + t.Fatalf("canonically re-included relay was rejected: [%v]", err) + } + if reader.receiptCall < 2 { + t.Fatal("relay receipt was not refreshed while waiting for finality") + } + if finality.BlockNumber != 11 || + finality.BlockHash != [32]byte(reincludedHeader.Hash()) || + finality.TransactionIndex != 4 || + finality.LogIndex != 18 { + t.Fatalf("finality retained obsolete inclusion: [%+v]", finality) + } +} + +func testFrostPreSignAuthorizationReceipt( + t *testing.T, + registry common.Address, + relayHash [32]byte, + proposal *tbtc.FrostPreSignAuthorizationProposal, + header *types.Header, + transactionIndex uint, + authorizedLogIndex uint, + advancedLogIndex uint, +) *types.Receipt { + t.Helper() + authorizedEvent := + frostPreSignRegistryABI.Events["P2TRPreSigningReservationAuthorized"] + authorizedData, err := authorizedEvent.Inputs.NonIndexed().Pack( + proposal.AuthorizationRoot, + proposal.SnapshotHash, + proposal.ResourceHash, + uint8(proposal.Transaction.Action), + ) + if err != nil { + t.Fatal(err) + } + advancedEvent := + frostPreSignRegistryABI.Events["P2TRAuthorizedVariantAdvanced"] + transactionHash := common.Hash(proposal.Transaction.TransactionHash) + blockNumber := header.Number.Uint64() + blockHash := header.Hash() + return &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: common.Hash(relayHash), + BlockHash: blockHash, + BlockNumber: new(big.Int).Set(header.Number), + TransactionIndex: transactionIndex, + Logs: []*types.Log{ + { + Address: registry, + Topics: []common.Hash{ + authorizedEvent.ID, + common.Hash(proposal.ReservationID), + transactionHash, + common.Hash(proposal.WalletID), + }, + Data: authorizedData, + BlockHash: blockHash, + BlockNumber: blockNumber, + TxHash: common.Hash(relayHash), + TxIndex: transactionIndex, + Index: authorizedLogIndex, + }, + { + Address: registry, + Topics: []common.Hash{ + advancedEvent.ID, + common.Hash(proposal.ReservationID), + transactionHash, + common.HexToHash("0x99"), + }, + BlockHash: blockHash, + BlockNumber: blockNumber, + TxHash: common.Hash(relayHash), + TxIndex: transactionIndex, + Index: advancedLogIndex, + }, + }, + } +} diff --git a/pkg/tbtc/bitcoin_broadcast_outbox.go b/pkg/tbtc/bitcoin_broadcast_outbox.go index cfb68d531b..d0943c3677 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox.go @@ -20,6 +20,7 @@ import ( "time" "github.com/keep-network/keep-core/pkg/bitcoin" + "golang.org/x/sync/semaphore" "golang.org/x/sys/unix" ) @@ -235,12 +236,12 @@ type bitcoinBroadcastOutbox struct { deepReconcileCursor int now func() time.Time - replayMutex sync.Mutex - mutex sync.Mutex - records map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord - lockFile *os.File - closed bool - recovered bool + replaySemaphore *semaphore.Weighted + mutex sync.Mutex + records map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord + lockFile *os.File + closed bool + recovered bool persistFailureHook func(*bitcoinBroadcastOutboxRecord) error } @@ -291,6 +292,7 @@ func newBitcoinBroadcastOutbox( archiveConfirmations: defaultBitcoinBroadcastArchiveConfirmations, deepReconcileBatch: defaultBitcoinBroadcastDeepReconcileBatch, now: time.Now, + replaySemaphore: semaphore.NewWeighted(1), records: make(map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord), lockFile: lockFile, } @@ -815,8 +817,10 @@ func (errorValue *bitcoinBroadcastReplayErrors) hasFatalFailure() bool { // fixed-size rotating reservation batch; healthy archived reservations still // require only their primary confirmation read. func (bbo *bitcoinBroadcastOutbox) replayOnceWithContext(ctx context.Context) error { - bbo.replayMutex.Lock() - defer bbo.replayMutex.Unlock() + if err := bbo.acquireReplaySemaphore(ctx); err != nil { + return err + } + defer bbo.replaySemaphore.Release(1) active, archived, err := bbo.replayCandidates() if err != nil { @@ -1200,8 +1204,10 @@ func (bbo *bitcoinBroadcastOutbox) broadcastTransaction( ctx context.Context, transactionHash bitcoin.Hash, ) error { - bbo.replayMutex.Lock() - defer bbo.replayMutex.Unlock() + if err := bbo.acquireReplaySemaphore(ctx); err != nil { + return err + } + defer bbo.replaySemaphore.Release(1) bbo.mutex.Lock() record := bbo.records[transactionHash] @@ -1232,6 +1238,24 @@ func (bbo *bitcoinBroadcastOutbox) broadcastTransaction( return broadcastErr } +func (bbo *bitcoinBroadcastOutbox) acquireReplaySemaphore( + ctx context.Context, +) error { + if ctx == nil { + return fmt.Errorf("Bitcoin broadcast replay context is nil") + } + if bbo == nil || bbo.replaySemaphore == nil { + return fmt.Errorf("Bitcoin broadcast replay semaphore is unavailable") + } + if err := bbo.replaySemaphore.Acquire(ctx, 1); err != nil { + return fmt.Errorf( + "cannot acquire Bitcoin broadcast replay semaphore: [%w]", + err, + ) + } + return nil +} + func (bbo *bitcoinBroadcastOutbox) persistAndSwapRecord( expected *bitcoinBroadcastOutboxRecord, next *bitcoinBroadcastOutboxRecord, diff --git a/pkg/tbtc/bitcoin_broadcast_outbox_test.go b/pkg/tbtc/bitcoin_broadcast_outbox_test.go index 2d301449dd..820e07fad2 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox_test.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox_test.go @@ -748,6 +748,74 @@ func TestBitcoinBroadcastOutbox_ReconcilesPreviouslyBroadcastSupersededVariant( } } +func TestBitcoinBroadcastOutbox_BroadcastLockWaitHonorsContext( + t *testing.T, +) { + statusStarted := make(chan struct{}) + statusRelease := make(chan struct{}) + chain := &blockingOutboxTestBitcoinChain{ + outboxTestBitcoinChain: newOutboxTestBitcoinChain(), + statusStarted: statusStarted, + statusRelease: statusRelease, + } + outbox := openTestBitcoinBroadcastOutbox( + t, + t.TempDir(), + chain, + ) + defer outbox.close() + tx := testOutboxTransaction(0x4a, 9000) + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(0x4b, 0x4c, 1), + ) + + replayResult := make(chan error, 1) + go func() { + replayResult <- outbox.replayOnceWithContext(context.Background()) + }() + select { + case <-statusStarted: + case <-time.After(time.Second): + close(statusRelease) + t.Fatal("background replay did not enter the blocking Bitcoin read") + } + + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) + defer cancel() + broadcastResult := make(chan error, 1) + go func() { + broadcastResult <- outbox.broadcastTransaction(ctx, tx.Hash()) + }() + + var broadcastErr error + select { + case broadcastErr = <-broadcastResult: + case <-time.After(500 * time.Millisecond): + close(statusRelease) + <-replayResult + broadcastErr = <-broadcastResult + t.Fatalf( + "foreground broadcast ignored its context while waiting for replay; eventual result: [%v]", + broadcastErr, + ) + } + if !errors.Is(broadcastErr, context.DeadlineExceeded) { + close(statusRelease) + <-replayResult + t.Fatalf("unexpected canceled broadcast result: [%v]", broadcastErr) + } + close(statusRelease) + if err := <-replayResult; err != nil { + t.Fatalf("background replay failed after release: [%v]", err) + } +} + func TestBitcoinBroadcastOutbox_PersistenceFailureDoesNotPublishConfirmationMutation( t *testing.T, ) { @@ -1441,6 +1509,23 @@ type outboxTestBitcoinChain struct { statusCalls uint } +type blockingOutboxTestBitcoinChain struct { + *outboxTestBitcoinChain + statusStarted chan struct{} + statusRelease <-chan struct{} + statusOnce sync.Once +} + +func (botbc *blockingOutboxTestBitcoinChain) GetCanonicalTransactionStatus( + hash bitcoin.Hash, +) (*bitcoin.CanonicalTransactionStatus, error) { + botbc.statusOnce.Do(func() { + close(botbc.statusStarted) + }) + <-botbc.statusRelease + return botbc.outboxTestBitcoinChain.GetCanonicalTransactionStatus(hash) +} + type outboxTestAuthorizationStatusSource struct { mutex sync.Mutex err error diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index 0643d3c877..e4cdeceb7e 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -2070,6 +2070,12 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) collectSeatAttestations( if !tfpsag.membershipValidator.IsValidMembership(seat, message.SenderPublicKey()) { return } + // The wallet broadcast topic is reused across proposals. Reject stale + // (including replayed) attestations before claiming the authenticated + // seat so they cannot suppress that seat's current attestation. + if !bytes.Equal(payload.Digest, proposal.Digest[:]) { + return + } if !claimFrostPreSignRemoteSeat( seat, len(proposal.WalletMembersIDs), diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go index cbb4c7428c..0aa852b08f 100644 --- a/pkg/tbtc/frost_pre_sign_authorization_test.go +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -1,16 +1,21 @@ package tbtc import ( + "bytes" "context" + "crypto/sha512" "encoding/hex" "fmt" "math/big" "strings" "sync" "testing" + "time" + "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/protocol/group" ) @@ -671,6 +676,197 @@ func TestClaimFrostPreSignRemoteSeat_BoundsAdmissionPerAuthenticatedSeat( } } +func TestThresholdFrostPreSignAuthorizationGate_RejectsStaleDigestBeforeSeatAdmission( + t *testing.T, +) { + localChain := Connect() + remoteChain := Connect() + localSigning := &testFrostPreSignFixedSignatureSigning{ + Signing: localChain.Signing(), + } + remoteSigning := &testFrostPreSignFixedSignatureSigning{ + Signing: remoteChain.Signing(), + } + proposal := &FrostPreSignAuthorizationProposal{ + Digest: [32]byte{0x11}, + WalletMembersIDs: []uint32{1, 2}, + } + staleDigest := proposal.Digest + staleDigest[0] ^= 0xff + staleSignature, err := remoteSigning.Sign(staleDigest[:]) + if err != nil { + t.Fatal(err) + } + currentSignature, err := remoteSigning.Sign(proposal.Digest[:]) + if err != nil { + t.Fatal(err) + } + remotePublicKey := remoteSigning.PublicKey() + channel := &testFrostPreSignAuthorizationBroadcastChannel{ + messages: []net.Message{ + &testFrostPreSignAuthorizationNetworkMessage{ + publicKey: remotePublicKey, + payload: &frostPreSignAuthorizationMessage{ + SenderIDValue: 2, + Digest: staleDigest[:], + PublicKey: remotePublicKey, + Signature: staleSignature, + }, + }, + &testFrostPreSignAuthorizationNetworkMessage{ + publicKey: remotePublicKey, + payload: &frostPreSignAuthorizationMessage{ + SenderIDValue: 2, + Digest: proposal.Digest[:], + PublicKey: remotePublicKey, + Signature: currentSignature, + }, + }, + }, + } + operators := []chain.Address{ + localSigning.Address(), + remoteSigning.Address(), + } + gate := &thresholdFrostPreSignAuthorizationGate{ + signing: localSigning, + broadcastChannel: channel, + membershipValidator: group.NewMembershipValidator( + &testutils.MockLogger{}, + operators, + localSigning, + ), + wallet: wallet{ + signingGroupOperators: operators, + }, + localMemberIndexes: []group.MemberIndex{1}, + threshold: 2, + } + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + defer cancel() + attestation, err := gate.collectSeatAttestations(ctx, proposal) + if err != nil { + t.Fatalf("current attestation was suppressed by stale replay: [%v]", err) + } + if len(attestation.SigningMemberIndices) != 2 || + attestation.SigningMemberIndices[0] != 1 || + attestation.SigningMemberIndices[1] != 2 { + t.Fatalf("unexpected seat attestation: [%+v]", attestation) + } +} + +type testFrostPreSignFixedSignatureSigning struct { + chain.Signing +} + +func (signing *testFrostPreSignFixedSignatureSigning) Sign( + message []byte, +) ([]byte, error) { + return testFrostPreSignFixedSignature( + signing.PublicKey(), + message, + ), nil +} + +func (signing *testFrostPreSignFixedSignatureSigning) Verify( + message []byte, + signature []byte, +) (bool, error) { + return signing.VerifyWithPublicKey( + message, + signature, + signing.PublicKey(), + ) +} + +func (*testFrostPreSignFixedSignatureSigning) VerifyWithPublicKey( + message []byte, + signature []byte, + publicKey []byte, +) (bool, error) { + return bytes.Equal( + signature, + testFrostPreSignFixedSignature(publicKey, message), + ), nil +} + +func testFrostPreSignFixedSignature( + publicKey []byte, + message []byte, +) []byte { + payload := make([]byte, 0, len(publicKey)+len(message)) + payload = append(payload, publicKey...) + payload = append(payload, message...) + digest := sha512.Sum512(payload) + return append(digest[:], byte(0)) +} + +type testFrostPreSignAuthorizationBroadcastChannel struct { + messages []net.Message +} + +func (*testFrostPreSignAuthorizationBroadcastChannel) Name() string { + return "test-frost-pre-sign-authorization" +} + +func (*testFrostPreSignAuthorizationBroadcastChannel) Send( + context.Context, + net.TaggedMarshaler, + ...net.RetransmissionStrategy, +) error { + return nil +} + +func (channel *testFrostPreSignAuthorizationBroadcastChannel) Recv( + ctx context.Context, + handler func(net.Message), +) { + for _, message := range channel.messages { + select { + case <-ctx.Done(): + return + default: + handler(message) + } + } +} + +func (*testFrostPreSignAuthorizationBroadcastChannel) SetUnmarshaler( + func() net.TaggedUnmarshaler, +) { +} + +func (*testFrostPreSignAuthorizationBroadcastChannel) SetFilter( + net.BroadcastChannelFilter, +) error { + return nil +} + +type testFrostPreSignAuthorizationNetworkMessage struct { + publicKey []byte + payload *frostPreSignAuthorizationMessage +} + +func (*testFrostPreSignAuthorizationNetworkMessage) TransportSenderID() net.TransportIdentifier { + return nil +} + +func (message *testFrostPreSignAuthorizationNetworkMessage) SenderPublicKey() []byte { + return message.publicKey +} + +func (message *testFrostPreSignAuthorizationNetworkMessage) Payload() interface{} { + return message.payload +} + +func (*testFrostPreSignAuthorizationNetworkMessage) Type() string { + return "test-frost-pre-sign-authorization" +} + +func (*testFrostPreSignAuthorizationNetworkMessage) Seqno() uint64 { + return 0 +} + type testFrostPreSignAuthorizationGate struct { mutex sync.Mutex authorizeErr error diff --git a/pkg/tbtc/frost_primary_ethereum_client.go b/pkg/tbtc/frost_primary_ethereum_client.go new file mode 100644 index 0000000000..68cd9ec9fe --- /dev/null +++ b/pkg/tbtc/frost_primary_ethereum_client.go @@ -0,0 +1,235 @@ +package tbtc + +import ( + "context" + "math/big" + "time" + + geth "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" +) + +// FrostPrimaryEthereumClient is the guarded primary Ethereum client exposed +// to the chain package. All finite request methods apply the configured +// transport timeout even when their caller supplies context.Background. +type FrostPrimaryEthereumClient interface { + ethutil.EthereumClient + ChainID(context.Context) (*big.Int, error) + Client() *rpc.Client + FrostPrimaryEthereumRequestTimeout() time.Duration +} + +type frostPrimaryEthereumTimeoutClient struct { + client *ethclient.Client + requestTimeout time.Duration +} + +var _ FrostPrimaryEthereumClient = (*frostPrimaryEthereumTimeoutClient)(nil) + +func (client *frostPrimaryEthereumTimeoutClient) FrostPrimaryEthereumRequestTimeout() time.Duration { + return client.requestTimeout +} + +func (client *frostPrimaryEthereumTimeoutClient) Client() *rpc.Client { + return client.client.Client() +} + +func (client *frostPrimaryEthereumTimeoutClient) SubscribeNewHead( + ctx context.Context, + channel chan<- *types.Header, +) (geth.Subscription, error) { + return client.client.SubscribeNewHead(ctx, channel) +} + +func (client *frostPrimaryEthereumTimeoutClient) SubscribeFilterLogs( + ctx context.Context, + query geth.FilterQuery, + channel chan<- types.Log, +) (geth.Subscription, error) { + return client.client.SubscribeFilterLogs(ctx, query, channel) +} + +func (client *frostPrimaryEthereumTimeoutClient) requestContext( + ctx context.Context, +) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + return context.WithTimeout(ctx, client.requestTimeout) +} + +func (client *frostPrimaryEthereumTimeoutClient) ChainID( + ctx context.Context, +) (*big.Int, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.ChainID(ctx) +} + +func (client *frostPrimaryEthereumTimeoutClient) BlockByHash( + ctx context.Context, + hash common.Hash, +) (*types.Block, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.BlockByHash(ctx, hash) +} + +func (client *frostPrimaryEthereumTimeoutClient) BlockByNumber( + ctx context.Context, + number *big.Int, +) (*types.Block, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.BlockByNumber(ctx, number) +} + +func (client *frostPrimaryEthereumTimeoutClient) HeaderByHash( + ctx context.Context, + hash common.Hash, +) (*types.Header, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.HeaderByHash(ctx, hash) +} + +func (client *frostPrimaryEthereumTimeoutClient) HeaderByNumber( + ctx context.Context, + number *big.Int, +) (*types.Header, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.HeaderByNumber(ctx, number) +} + +func (client *frostPrimaryEthereumTimeoutClient) TransactionCount( + ctx context.Context, + blockHash common.Hash, +) (uint, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.TransactionCount(ctx, blockHash) +} + +func (client *frostPrimaryEthereumTimeoutClient) TransactionInBlock( + ctx context.Context, + blockHash common.Hash, + index uint, +) (*types.Transaction, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.TransactionInBlock(ctx, blockHash, index) +} + +func (client *frostPrimaryEthereumTimeoutClient) TransactionByHash( + ctx context.Context, + transactionHash common.Hash, +) (*types.Transaction, bool, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.TransactionByHash(ctx, transactionHash) +} + +func (client *frostPrimaryEthereumTimeoutClient) TransactionReceipt( + ctx context.Context, + transactionHash common.Hash, +) (*types.Receipt, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.TransactionReceipt(ctx, transactionHash) +} + +func (client *frostPrimaryEthereumTimeoutClient) BalanceAt( + ctx context.Context, + account common.Address, + blockNumber *big.Int, +) (*big.Int, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.BalanceAt(ctx, account, blockNumber) +} + +func (client *frostPrimaryEthereumTimeoutClient) CodeAt( + ctx context.Context, + account common.Address, + blockNumber *big.Int, +) ([]byte, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.CodeAt(ctx, account, blockNumber) +} + +func (client *frostPrimaryEthereumTimeoutClient) CallContract( + ctx context.Context, + message geth.CallMsg, + blockNumber *big.Int, +) ([]byte, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.CallContract(ctx, message, blockNumber) +} + +func (client *frostPrimaryEthereumTimeoutClient) PendingCodeAt( + ctx context.Context, + account common.Address, +) ([]byte, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.PendingCodeAt(ctx, account) +} + +func (client *frostPrimaryEthereumTimeoutClient) PendingNonceAt( + ctx context.Context, + account common.Address, +) (uint64, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.PendingNonceAt(ctx, account) +} + +func (client *frostPrimaryEthereumTimeoutClient) SuggestGasPrice( + ctx context.Context, +) (*big.Int, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.SuggestGasPrice(ctx) +} + +func (client *frostPrimaryEthereumTimeoutClient) SuggestGasTipCap( + ctx context.Context, +) (*big.Int, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.SuggestGasTipCap(ctx) +} + +func (client *frostPrimaryEthereumTimeoutClient) EstimateGas( + ctx context.Context, + message geth.CallMsg, +) (uint64, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.EstimateGas(ctx, message) +} + +func (client *frostPrimaryEthereumTimeoutClient) SendTransaction( + ctx context.Context, + transaction *types.Transaction, +) error { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.SendTransaction(ctx, transaction) +} + +func (client *frostPrimaryEthereumTimeoutClient) FilterLogs( + ctx context.Context, + query geth.FilterQuery, +) ([]types.Log, error) { + ctx, cancel := client.requestContext(ctx) + defer cancel() + return client.client.FilterLogs(ctx, query) +} diff --git a/pkg/tbtc/frost_primary_ethereum_transport.go b/pkg/tbtc/frost_primary_ethereum_transport.go index 330ca54e32..e1e5fd3965 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport.go +++ b/pkg/tbtc/frost_primary_ethereum_transport.go @@ -50,7 +50,7 @@ type FrostPrimaryEthereumTransport struct { timeout time.Duration rootCAs *x509.CertPool rpcClient *rpc.Client - client *ethclient.Client + client FrostPrimaryEthereumClient httpTransport *http.Transport seenPeers map[[32]byte]frostTransportPeerIdentity livePeers map[[32]byte]uint64 @@ -212,7 +212,10 @@ func newFrostPrimaryEthereumTransport( return nil, fmt.Errorf("cannot dial guarded primary Ethereum endpoint: [%w]", err) } transport.rpcClient = rpcClient - transport.client = ethclient.NewClient(rpcClient) + transport.client = &frostPrimaryEthereumTimeoutClient{ + client: ethclient.NewClient(rpcClient), + requestTimeout: timeout, + } probeContext, probeCancel := context.WithTimeout(ctx, timeout) defer probeCancel() @@ -244,7 +247,7 @@ func newFrostPrimaryEthereumTransport( // Client returns the exact client whose connections are guarded by this // transport. It must be passed to ethereum.ConnectWithClient. -func (transport *FrostPrimaryEthereumTransport) Client() *ethclient.Client { +func (transport *FrostPrimaryEthereumTransport) Client() FrostPrimaryEthereumClient { if transport == nil { return nil } diff --git a/pkg/tbtc/frost_primary_ethereum_transport_test.go b/pkg/tbtc/frost_primary_ethereum_transport_test.go new file mode 100644 index 0000000000..ca4c3c865e --- /dev/null +++ b/pkg/tbtc/frost_primary_ethereum_transport_test.go @@ -0,0 +1,276 @@ +package tbtc + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "math/big" + "net/netip" + "strings" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/rpc" +) + +func testFrostTransportPeer(exporter byte) frostTransportPeerIdentity { + return frostTransportPeerIdentity{ + remoteIP: netip.MustParseAddr("192.0.2.1"), + leafCertificateHash: [32]byte{0x01}, + leafSPKIHash: [32]byte{0x02}, + spiffeAuthorities: []string{"primary.example"}, + tlsExporterValueHash: [32]byte{exporter}, + } +} + +func newTestFrostPrimaryEthereumTransport() *FrostPrimaryEthereumTransport { + return &FrostPrimaryEthereumTransport{ + seenPeers: make(map[[32]byte]frostTransportPeerIdentity), + livePeers: make(map[[32]byte]uint64), + } +} + +func TestFrostTransportPeerKeysSeparateHistoryFromLiveChannel(t *testing.T) { + first := testFrostTransportPeer(0x11) + second := testFrostTransportPeer(0x22) + + if frostTransportPeerIdentityKey(first) != + frostTransportPeerIdentityKey(second) { + t.Fatal("TLS reconnect changed stable peer identity") + } + if frostTransportPeerChannelKey(first) == + frostTransportPeerChannelKey(second) { + t.Fatal("distinct TLS exporters produced the same live channel key") + } +} + +func TestFrostPrimaryEthereumTransportReconnectsDoNotExhaustHistory( + t *testing.T, +) { + transport := newTestFrostPrimaryEthereumTransport() + + for index := 0; index <= frostPrimaryEthereumMaximumSeenPeers; index++ { + peer := testFrostTransportPeer(byte(index)) + peer.tlsExporterValueHash = sha256.Sum256( + []byte(fmt.Sprintf("exporter-%d", index)), + ) + if err := transport.recordPeer( + frostTransportPeerIdentityKey(peer), + peer, + ); err != nil { + t.Fatalf("reconnect [%d] failed: [%v]", index, err) + } + transport.releaseLivePeer(frostTransportPeerChannelKey(peer)) + } + + seen, live := transport.peerCounts() + if seen != 1 || live != 0 { + t.Fatalf("unexpected peer counts [%d, %d]", seen, live) + } +} + +func TestFrostRetainedPeerReconnectsDoNotExhaustHistory(t *testing.T) { + primaryIP := netip.MustParseAddr("192.0.2.1") + retainedIP := netip.MustParseAddr("192.0.2.2") + retainedPeer := testFrostTransportPeer(0x11) + retainedPeer.remoteIP = retainedIP + retainedPeer.leafSPKIHash = [32]byte{0x44} + retainedPeer.spiffeAuthorities = []string{"retained.example"} + + policy := &frostPrimaryRetainedSeparationPolicy{ + primary: frostRetainedGroupResolvedEndpoint{ + addresses: []netip.Addr{primaryIP}, + }, + retained: map[string]frostPrimaryRetainedEndpointPolicy{ + "retained-history-export": { + endpoint: frostRetainedGroupResolvedEndpoint{ + addresses: []netip.Addr{retainedIP}, + }, + identity: FrostRetainedGroupEndpointIdentity{ + TrustDomainID: "retained.example", + TLSLeafSPKIHash: retainedPeer.leafSPKIHash, + }, + }, + }, + primaryPeers: map[[32]byte]frostTransportPeerIdentity{ + {0x01}: { + remoteIP: primaryIP, + leafCertificateHash: [32]byte{0x55}, + leafSPKIHash: [32]byte{0x66}, + spiffeAuthorities: []string{"primary.example"}, + }, + }, + retainedPeers: map[string]map[[32]byte]frostTransportPeerIdentity{ + "retained-history-export": {}, + }, + } + + for index := 0; index <= frostPrimaryEthereumMaximumSeenPeers; index++ { + retainedPeer.tlsExporterValueHash = sha256.Sum256( + []byte(fmt.Sprintf("retained-exporter-%d", index)), + ) + if err := policy.registerRetainedPeer( + "retained-history-export", + retainedPeer, + ); err != nil { + t.Fatalf("retained reconnect [%d] failed: [%v]", index, err) + } + } + + if actual := len( + policy.retainedPeers["retained-history-export"], + ); actual != 1 { + t.Fatalf("unexpected retained peer history size [%d]", actual) + } +} + +func TestFrostPrimaryEthereumTransportRejectsNewStablePeerPastLimit( + t *testing.T, +) { + transport := newTestFrostPrimaryEthereumTransport() + + for index := 0; index < frostPrimaryEthereumMaximumSeenPeers; index++ { + peer := testFrostTransportPeer(0x11) + peer.leafCertificateHash = sha256.Sum256( + []byte(fmt.Sprintf("certificate-%d", index)), + ) + if err := transport.recordPeer( + frostTransportPeerIdentityKey(peer), + peer, + ); err != nil { + t.Fatalf("stable peer [%d] failed: [%v]", index, err) + } + } + + peer := testFrostTransportPeer(0x11) + peer.leafCertificateHash = sha256.Sum256([]byte("one-too-many")) + if err := transport.recordPeer( + frostTransportPeerIdentityKey(peer), + peer, + ); err == nil { + t.Fatal("new stable peer beyond history limit was accepted") + } +} + +func TestFrostPrimaryEthereumTransportRequiresExactLiveChannel(t *testing.T) { + transport := newTestFrostPrimaryEthereumTransport() + peer := testFrostTransportPeer(0x11) + if err := transport.recordPeer( + frostTransportPeerIdentityKey(peer), + peer, + ); err != nil { + t.Fatal(err) + } + channelKey := frostTransportPeerChannelKey(peer) + + if err := transport.verifySeenPeer(peer); err != nil { + t.Fatalf("live channel rejected: [%v]", err) + } + + otherChannel := peer + otherChannel.tlsExporterValueHash = [32]byte{0x22} + if err := transport.verifySeenPeer(otherChannel); err == nil { + t.Fatal("unrecorded TLS channel accepted") + } + + transport.releaseLivePeer(channelKey) + if err := transport.verifySeenPeer(peer); err == nil { + t.Fatal("closed TLS channel accepted") + } +} + +func TestFrostPrimaryEthereumTransportWSSAppliesRequestTimeout( + t *testing.T, +) { + service := &testFrostPrimaryEthereumStalledRPC{ + stalled: make(chan struct{}), + release: make(chan struct{}), + } + rpcServer := rpc.NewServer() + if err := rpcServer.RegisterName("eth", service); err != nil { + t.Fatal(err) + } + server, _, roots := newFrostRetainedGroupHistoryTLSTestServer( + t, + rpcServer.WebsocketHandler([]string{"*"}), + "spiffe://primary.example/rpc", + ) + transport, err := NewFrostPrimaryEthereumTransport( + context.Background(), + FrostPrimaryEthereumTransportConfig{ + URL: strings.Replace( + server.URL, + "https://", + "wss://", + 1, + ), + RequestTimeout: time.Second, + TLSRootCAs: roots, + }, + ) + if err != nil { + t.Fatal(err) + } + defer transport.Close() + + result := make(chan error, 1) + go func() { + _, err := transport.Client().ChainID(context.Background()) + result <- err + }() + select { + case <-service.stalled: + case <-time.After(time.Second): + close(service.release) + t.Fatal("post-probe WSS request did not reach the stalled provider") + } + + select { + case err := <-result: + close(service.release) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("stalled WSS request returned unexpected error: [%v]", err) + } + case <-time.After(1500 * time.Millisecond): + close(service.release) + err := <-result + t.Fatalf( + "stalled WSS request exceeded configured timeout; eventual result: [%v]", + err, + ) + } +} + +type testFrostPrimaryEthereumStalledRPC struct { + mutex sync.Mutex + calls int + stalled chan struct{} + release chan struct{} + once sync.Once +} + +func (service *testFrostPrimaryEthereumStalledRPC) ChainId( + ctx context.Context, +) (*hexutil.Big, error) { + service.mutex.Lock() + service.calls++ + call := service.calls + service.mutex.Unlock() + if call == 1 { + value := hexutil.Big(*big.NewInt(1)) + return &value, nil + } + service.once.Do(func() { + close(service.stalled) + }) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-service.release: + value := hexutil.Big(*big.NewInt(1)) + return &value, nil + } +} From 24254d0086fe4b22dc5fe5c0056af406d9384266 Mon Sep 17 00:00:00 2001 From: maclane Date: Tue, 28 Jul 2026 22:37:01 -0400 Subject: [PATCH 36/93] test(tbtc): relax WSS timeout watchdog --- pkg/tbtc/frost_primary_ethereum_transport_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tbtc/frost_primary_ethereum_transport_test.go b/pkg/tbtc/frost_primary_ethereum_transport_test.go index ca4c3c865e..2c91864f11 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport_test.go +++ b/pkg/tbtc/frost_primary_ethereum_transport_test.go @@ -234,7 +234,7 @@ func TestFrostPrimaryEthereumTransportWSSAppliesRequestTimeout( if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("stalled WSS request returned unexpected error: [%v]", err) } - case <-time.After(1500 * time.Millisecond): + case <-time.After(3 * time.Second): close(service.release) err := <-result t.Fatalf( From e243bcf9d9fc76bc44535c5ec76a0727eec5e92d Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 07:51:02 -0400 Subject: [PATCH 37/93] fix(tbtc): reconcile externally broadcast variants --- pkg/tbtc/bitcoin_broadcast_outbox.go | 15 +++----- pkg/tbtc/bitcoin_broadcast_outbox_test.go | 46 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/pkg/tbtc/bitcoin_broadcast_outbox.go b/pkg/tbtc/bitcoin_broadcast_outbox.go index d0943c3677..195507bc5b 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox.go @@ -811,11 +811,11 @@ func (errorValue *bitcoinBroadcastReplayErrors) hasFatalFailure() bool { } // replayOnceWithContext checks the latest or previously confirmed record first. -// Before rebroadcasting, it also reconciles every superseded variant with a -// durable broadcast attempt, because any one of those conflicting variants -// may be the canonical winner. Deeply confirmed history is reconciled in a -// fixed-size rotating reservation batch; healthy archived reservations still -// require only their primary confirmation read. +// Before rebroadcasting, it also reconciles every superseded signed variant, +// because another wallet operator may have broadcast any one of those +// conflicting variants. Deeply confirmed history is reconciled in a fixed-size +// rotating reservation batch; healthy archived reservations still require only +// their primary confirmation read. func (bbo *bitcoinBroadcastOutbox) replayOnceWithContext(ctx context.Context) error { if err := bbo.acquireReplaySemaphore(ctx); err != nil { return err @@ -947,11 +947,6 @@ func (bbo *bitcoinBroadcastOutbox) replayCandidates() ( if record.TransactionHash == primary.TransactionHash { continue } - if record.TransactionHash != state.latest.TransactionHash && - record.BroadcastAttempts == 0 && - record.Confirmation == nil { - continue - } alternatives = append( alternatives, cloneBitcoinBroadcastOutboxRecord(record), diff --git a/pkg/tbtc/bitcoin_broadcast_outbox_test.go b/pkg/tbtc/bitcoin_broadcast_outbox_test.go index 820e07fad2..e4b0e82887 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox_test.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox_test.go @@ -748,6 +748,52 @@ func TestBitcoinBroadcastOutbox_ReconcilesPreviouslyBroadcastSupersededVariant( } } +func TestBitcoinBroadcastOutbox_ReconcilesExternallyBroadcastSupersededVariant( + t *testing.T, +) { + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, t.TempDir(), chain) + defer outbox.close() + + oldVariant := testOutboxTransaction(34, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + oldVariant, + testBitcoinBroadcastAuthorization(35, 34, 1), + ) + + replacement := testOutboxTransaction(34, 6000) + enqueueTestBitcoinTransaction( + t, + outbox, + replacement, + testBitcoinBroadcastAuthorization(36, 34, 2), + ) + chain.setCanonicalStatus( + oldVariant.Hash(), + &bitcoin.CanonicalTransactionStatus{ + Found: true, + Confirmations: 2, + BlockHeight: 800034, + BlockHash: bitcoin.Hash{0x34, 0xcc}, + }, + ) + + if outbox.records[oldVariant.Hash()].BroadcastAttempts != 0 { + t.Fatal("old variant unexpectedly has a local broadcast attempt") + } + if err := outbox.replayOnce(); err != nil { + t.Fatal(err) + } + if outbox.records[oldVariant.Hash()].Confirmation == nil { + t.Fatal("externally broadcast superseded RBF variant was not persisted") + } + if chain.broadcastCount(replacement.Hash()) != 0 { + t.Fatal("replacement was broadcast after its predecessor confirmed externally") + } +} + func TestBitcoinBroadcastOutbox_BroadcastLockWaitHonorsContext( t *testing.T, ) { From c69224851d6f0fb9de932ea2f10a8e92d568a5a1 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 09:24:31 -0400 Subject: [PATCH 38/93] fix(tbtc): harden retained history transport startup --- cmd/start.go | 2 +- ...-retained-group-transport-attestation.adoc | 37 ++++-- pkg/chain/ethereum/tbtc.go | 25 ++-- pkg/tbtc/frost_pre_sign_authorization.go | 1 + pkg/tbtc/frost_primary_ethereum_transport.go | 112 ++++++++++++++---- .../frost_primary_ethereum_transport_test.go | 6 + .../frost_retained_group_endpoint_identity.go | 20 ++-- ...t_retained_group_endpoint_identity_test.go | 31 +++++ .../frost_retained_group_history_source.go | 37 +++++- ...rost_retained_group_history_source_test.go | 7 ++ pkg/tbtc/frost_retained_group_journal.go | 9 ++ pkg/tbtc/frost_retained_group_journal_test.go | 79 ++++++++++++ pkg/tbtc/node.go | 17 +++ 13 files changed, 321 insertions(+), 62 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index 7cd916d166..c9323ff311 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -221,7 +221,7 @@ func start(cmd *cobra.Command) error { ctx, clientConfig.Tbtc.FrostRetainedGroupHistory, primaryEthereumTransport, - uint64(clientConfig.Ethereum.Network.ChainID()), + primaryEthereumTransport.ChainID(), ) if err != nil { return fmt.Errorf( diff --git a/docs/development/frost-retained-group-transport-attestation.adoc b/docs/development/frost-retained-group-transport-attestation.adoc index 0a109d0de9..e5726b4a3a 100644 --- a/docs/development/frost-retained-group-transport-attestation.adoc +++ b/docs/development/frost-retained-group-transport-attestation.adoc @@ -228,16 +228,27 @@ SHA-256 of the exact JSON response-attestation object: An independently implemented endpoint must reproduce these values before it is eligible for activation. -== Primary-endpoint independence limitation - -The retained export and verifier transports are channel-bound as specified -above. The current keep-core primary Ethereum client does not expose its actual -connected peer or TLS identity to this component. The retained-history client -therefore re-resolves the primary URL at every verification boundary and -rejects DNS/CNAME/IP overlap, but cannot cryptographically bind that observation -to the primary client's existing connection. - -Production activation remains no-go until the primary client exposes a -manifest-bound connected-peer identity (or is created through a shared pinned -transport) so split-horizon DNS, proxies, and persistent-connection drift can -also be excluded. +== Primary-endpoint independence enforcement + +A FROST-enabled node creates its primary Ethereum client through +`FrostPrimaryEthereumTransport`. The transport freezes the first complete DNS +answer, disables proxies and redirects, requires TLS 1.3, and records the +certificate, SPKI, SPIFFE authority, remote IP, and TLS-exporter identity of +every live HTTPS connection or WSS reconnect before that connection can carry +an RPC response. + +The retained-history source binds its export and verifier endpoint identities +to that same transport. Construction rejects overlap in the frozen DNS/CNAME/IP +sets and in the manifest-pinned role identities. Every subsequently observed +primary, export, or verifier TLS peer is registered with the shared separation +policy; an address, certificate, SPKI, or SPIFFE-authority alias poisons the +policy fail closed. Verification boundaries also re-resolve the primary name +and require the result to equal the frozen answer, excluding split-horizon DNS +or later DNS drift. + +The primary and retained clients therefore enforce independence against the +connections that actually carry their responses. This replaces the earlier +URL-only observation limitation. Operational activation still requires three +genuinely independent endpoint identities and network paths satisfying these +checks; deploying distinct hostnames in front of shared TLS or backend +identity is intentionally rejected. diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 73df3de69b..413f923da8 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -86,18 +86,19 @@ var frostWalletRegistryAuthorizationABI = mustParseABI( type TbtcChain struct { *baseChain - bridge *tbtccontract.Bridge - bridgeAddress common.Address - maintainerProxy *tbtccontract.MaintainerProxy - walletRegistry *ecdsacontract.WalletRegistry - sortitionPool *ecdsacontract.EcdsaSortitionPool - frostWalletRegistry *frostabi.FrostWalletRegistry - frostWalletRegistryAddr common.Address - frostDkgValidator *frostvalidatorabi.FrostDkgValidator - frostSortitionPool *ecdsacontract.EcdsaSortitionPool - walletProposalValidator *tbtccontract.WalletProposalValidator - redemptionWatchtower *tbtccontract.RedemptionWatchtower - frostPreSignAuthorizationAdapter *frostPreSignEthereumAdapter + bridge *tbtccontract.Bridge + bridgeAddress common.Address + maintainerProxy *tbtccontract.MaintainerProxy + walletRegistry *ecdsacontract.WalletRegistry + sortitionPool *ecdsacontract.EcdsaSortitionPool + frostWalletRegistry *frostabi.FrostWalletRegistry + frostWalletRegistryAddr common.Address + frostDkgValidator *frostvalidatorabi.FrostDkgValidator + frostSortitionPool *ecdsacontract.EcdsaSortitionPool + walletProposalValidator *tbtccontract.WalletProposalValidator + redemptionWatchtower *tbtccontract.RedemptionWatchtower + frostPreSignAuthorizationAdapter *frostPreSignEthereumAdapter + frostPreSignAuthorizationVerifier *frostPreSignEthereumAdapter // ecdsaDkgValidatorAddress optional; when zero, TBTC uses defaultGroupParameters(network). ecdsaDkgValidatorAddress common.Address diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index e4cdeceb7e..07c2e4cd13 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1283,6 +1283,7 @@ type FrostPreSignAuthorizationConfigurator interface { string, string, string, + FrostPreSignEthereumEvidenceVerifier, ) (*FrostPreSignActivationProfile, error) } diff --git a/pkg/tbtc/frost_primary_ethereum_transport.go b/pkg/tbtc/frost_primary_ethereum_transport.go index e1e5fd3965..62b4fd8a41 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport.go +++ b/pkg/tbtc/frost_primary_ethereum_transport.go @@ -27,6 +27,7 @@ const ( frostPrimaryEthereumTLSExporterLabel = "EXPORTER-tbtc-frost-primary-ethereum-v1" frostPrimaryEthereumTLSExporterContextDomain = "tbtc-frost-primary-ethereum-tls-exporter-context/v1\x00" frostPrimaryEthereumPeerIdentityDomain = "tbtc-frost-primary-ethereum-peer-identity/v1\x00" + frostPrimaryEthereumPeerChannelDomain = "tbtc-frost-primary-ethereum-peer-channel/v1\x00" frostPrimaryEthereumMaximumSeenPeers = 4096 ) @@ -51,6 +52,7 @@ type FrostPrimaryEthereumTransport struct { rootCAs *x509.CertPool rpcClient *rpc.Client client FrostPrimaryEthereumClient + chainID uint64 httpTransport *http.Transport seenPeers map[[32]byte]frostTransportPeerIdentity livePeers map[[32]byte]uint64 @@ -233,6 +235,9 @@ func newFrostPrimaryEthereumTransport( "guarded primary Ethereum endpoint returned an invalid chain ID", ) } + transport.mutex.Lock() + transport.chainID = chainID.Uint64() + transport.mutex.Unlock() transport.mutex.RLock() hasPeer := len(transport.seenPeers) > 0 transport.mutex.RUnlock() @@ -245,6 +250,18 @@ func newFrostPrimaryEthereumTransport( return transport, nil } +// ChainID returns the positive chain ID authenticated during the guarded +// transport's startup probe. It remains available after Close so startup +// wiring never falls back to the static Developer network sentinel. +func (transport *FrostPrimaryEthereumTransport) ChainID() uint64 { + if transport == nil { + return 0 + } + transport.mutex.RLock() + defer transport.mutex.RUnlock() + return transport.chainID +} + // Client returns the exact client whose connections are guarded by this // transport. It must be passed to ethereum.ConnectWithClient. func (transport *FrostPrimaryEthereumTransport) Client() FrostPrimaryEthereumClient { @@ -410,12 +427,15 @@ func (transport *FrostPrimaryEthereumTransport) dialTLSContext( _ = tlsConnection.Close() return nil, peerErr } - peerKey := frostTransportPeerIdentityKey(peer) - if recordErr := transport.recordPeer(peerKey, peer); recordErr != nil { + identityKey := frostTransportPeerIdentityKey(peer) + if recordErr := transport.recordPeer( + identityKey, + peer, + ); recordErr != nil { _ = tlsConnection.Close() return nil, recordErr } - tracked.activate(peerKey) + tracked.activate(frostTransportPeerChannelKey(peer)) return tlsConnection, nil } if lastErr == nil { @@ -544,6 +564,19 @@ func frostTransportPeerIdentityKey( authority, ) } + return transcript.sum() +} + +func frostTransportPeerChannelKey( + peer frostTransportPeerIdentity, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostPrimaryEthereumPeerChannelDomain, + ) + transcript.bytes32( + "peerIdentityKey", + frostTransportPeerIdentityKey(peer), + ) transcript.bytes32( "tlsExporterValueHash", peer.tlsExporterValueHash, @@ -551,26 +584,46 @@ func frostTransportPeerIdentityKey( return transcript.sum() } +func frostTransportStablePeerIdentity( + peer frostTransportPeerIdentity, +) frostTransportPeerIdentity { + peer.spiffeAuthorities = append([]string{}, peer.spiffeAuthorities...) + peer.tlsExporterValueHash = [32]byte{} + return peer +} + func (transport *FrostPrimaryEthereumTransport) recordPeer( key [32]byte, peer frostTransportPeerIdentity, ) error { + identityKey := frostTransportPeerIdentityKey(peer) + if key != identityKey { + return fmt.Errorf("primary Ethereum peer identity key mismatch") + } + channelKey := frostTransportPeerChannelKey(peer) + stablePeer := frostTransportStablePeerIdentity(peer) + transport.mutex.Lock() defer transport.mutex.Unlock() if transport.closed { return fmt.Errorf("primary Ethereum transport is closed") } - if _, exists := transport.seenPeers[key]; !exists && + if _, exists := transport.seenPeers[identityKey]; !exists && len(transport.seenPeers) >= frostPrimaryEthereumMaximumSeenPeers { - return fmt.Errorf("primary Ethereum peer history limit exceeded") + return fmt.Errorf( + "primary Ethereum peer history limit exceeded", + ) } - transport.seenPeers[key] = peer + transport.seenPeers[identityKey] = stablePeer if transport.policy != nil { - if err := transport.policy.registerPrimaryPeer(key, peer); err != nil { + if err := transport.policy.registerPrimaryPeer( + identityKey, + stablePeer, + ); err != nil { return err } } - transport.livePeers[key]++ + transport.livePeers[channelKey]++ return nil } @@ -619,14 +672,16 @@ func (roundTripper *frostPrimaryEthereumHTTPRoundTripper) RoundTrip( request.Header.Get("Accept-Encoding") != "" { return nil, fmt.Errorf("primary Ethereum HTTP request escaped its pinned target") } - connection := make(chan net.Conn, 1) + var ( + connectionMutex sync.Mutex + connection net.Conn + ) trace := &httptrace.ClientTrace{ GotConn: func(info httptrace.GotConnInfo) { if info.Conn != nil { - select { - case connection <- info.Conn: - default: - } + connectionMutex.Lock() + connection = info.Conn + connectionMutex.Unlock() } }, } @@ -637,10 +692,10 @@ func (roundTripper *frostPrimaryEthereumHTTPRoundTripper) RoundTrip( if err != nil { return nil, err } - var actual net.Conn - select { - case actual = <-connection: - default: + connectionMutex.Lock() + actual := connection + connectionMutex.Unlock() + if actual == nil { _ = response.Body.Close() return nil, fmt.Errorf( "primary Ethereum response has no exact connection observation", @@ -670,7 +725,7 @@ func (roundTripper *frostPrimaryEthereumHTTPRoundTripper) RoundTrip( return nil, err } if err := roundTripper.transport.verifySeenPeer( - frostTransportPeerIdentityKey(peer), + peer, ); err != nil { _ = response.Body.Close() return nil, err @@ -687,14 +742,18 @@ func (roundTripper *frostPrimaryEthereumHTTPRoundTripper) RoundTrip( } func (transport *FrostPrimaryEthereumTransport) verifySeenPeer( - key [32]byte, + peer frostTransportPeerIdentity, ) error { + identityKey := frostTransportPeerIdentityKey(peer) + channelKey := frostTransportPeerChannelKey(peer) + transport.mutex.RLock() defer transport.mutex.RUnlock() if transport.closed { return fmt.Errorf("primary Ethereum transport is closed") } - if _, exists := transport.seenPeers[key]; !exists { + if _, exists := transport.seenPeers[identityKey]; !exists || + transport.livePeers[channelKey] == 0 { return fmt.Errorf( "primary Ethereum request used an unauthenticated TLS channel", ) @@ -865,6 +924,7 @@ func (policy *frostPrimaryRetainedSeparationPolicy) registerRetainedPeer( } peers := policy.retainedPeers[role] key := frostTransportPeerIdentityKey(peer) + stablePeer := frostTransportStablePeerIdentity(peer) if _, exists := peers[key]; !exists && len(peers) >= frostPrimaryEthereumMaximumSeenPeers { policy.failure = fmt.Errorf( @@ -873,11 +933,11 @@ func (policy *frostPrimaryRetainedSeparationPolicy) registerRetainedPeer( ) return policy.failure } - peers[key] = peer - if !frostAddressSetContains(retained.endpoint.addresses, peer.remoteIP) || - peer.leafSPKIHash != retained.identity.TLSLeafSPKIHash || + peers[key] = stablePeer + if !frostAddressSetContains(retained.endpoint.addresses, stablePeer.remoteIP) || + stablePeer.leafSPKIHash != retained.identity.TLSLeafSPKIHash || !frostStringSetContains( - peer.spiffeAuthorities, + stablePeer.spiffeAuthorities, retained.identity.TrustDomainID, ) { policy.failure = fmt.Errorf( @@ -886,7 +946,7 @@ func (policy *frostPrimaryRetainedSeparationPolicy) registerRetainedPeer( ) return policy.failure } - if frostAddressSetContains(policy.primary.addresses, peer.remoteIP) { + if frostAddressSetContains(policy.primary.addresses, stablePeer.remoteIP) { policy.failure = fmt.Errorf( "%s actual peer aliases the primary Ethereum frozen address set", role, @@ -896,7 +956,7 @@ func (policy *frostPrimaryRetainedSeparationPolicy) registerRetainedPeer( for _, primaryPeer := range policy.primaryPeers { if err := frostTransportPeersIndependent( role, - peer, + stablePeer, "primary Ethereum", primaryPeer, ); err != nil { diff --git a/pkg/tbtc/frost_primary_ethereum_transport_test.go b/pkg/tbtc/frost_primary_ethereum_transport_test.go index 2c91864f11..b130e47a0f 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport_test.go +++ b/pkg/tbtc/frost_primary_ethereum_transport_test.go @@ -215,6 +215,12 @@ func TestFrostPrimaryEthereumTransportWSSAppliesRequestTimeout( t.Fatal(err) } defer transport.Close() + if transport.ChainID() != 1 { + t.Fatalf( + "guarded transport did not retain probed chain ID: [%d]", + transport.ChainID(), + ) + } result := make(chan error, 1) go func() { diff --git a/pkg/tbtc/frost_retained_group_endpoint_identity.go b/pkg/tbtc/frost_retained_group_endpoint_identity.go index 0dd10eea69..6b98779056 100644 --- a/pkg/tbtc/frost_retained_group_endpoint_identity.go +++ b/pkg/tbtc/frost_retained_group_endpoint_identity.go @@ -1521,8 +1521,10 @@ func (roundTripper *frostRetainedGroupAttestedRoundTripper) RoundTrip( if request.Body != nil { _ = request.Body.Close() } - request.Body = io.NopCloser(bytes.NewReader(body)) - request.ContentLength = int64(len(body)) + outbound := request.Clone(request.Context()) + outbound.Header = request.Header.Clone() + outbound.Body = io.NopCloser(bytes.NewReader(body)) + outbound.ContentLength = int64(len(body)) requestDigest := sha256.Sum256(body) var challenge [32]byte randomSource := roundTripper.random @@ -1532,11 +1534,11 @@ func (roundTripper *frostRetainedGroupAttestedRoundTripper) RoundTrip( if _, err := io.ReadFull(randomSource, challenge[:]); err != nil { return nil, fmt.Errorf("cannot create retained-group transport challenge: [%w]", err) } - request.Header.Set( + outbound.Header.Set( frostRetainedGroupTransportChallengeHeader, hex.EncodeToString(challenge[:]), ) - request.Header.Set("Accept-Encoding", "identity") + outbound.Header.Set("Accept-Encoding", "identity") var remote net.Addr trace := &httptrace.ClientTrace{ @@ -1546,10 +1548,10 @@ func (roundTripper *frostRetainedGroupAttestedRoundTripper) RoundTrip( } }, } - request = request.WithContext( - httptrace.WithClientTrace(request.Context(), trace), + outbound = outbound.WithContext( + httptrace.WithClientTrace(outbound.Context(), trace), ) - response, err := roundTripper.base.RoundTrip(request) + response, err := roundTripper.base.RoundTrip(outbound) if err != nil { return nil, err } @@ -1578,7 +1580,7 @@ func (roundTripper *frostRetainedGroupAttestedRoundTripper) RoundTrip( roundTripper.endpoint, roundTripper.identity, challenge, - request.Method, + outbound.Method, target, requestDigest, responseDigest, @@ -1616,7 +1618,7 @@ func (roundTripper *frostRetainedGroupAttestedRoundTripper) RoundTrip( challenge: challenge, } if response.Request == nil { - response.Request = request + response.Request = outbound } response.Request = response.Request.Clone( context.WithValue( diff --git a/pkg/tbtc/frost_retained_group_endpoint_identity_test.go b/pkg/tbtc/frost_retained_group_endpoint_identity_test.go index d70cb81532..d948d5f601 100644 --- a/pkg/tbtc/frost_retained_group_endpoint_identity_test.go +++ b/pkg/tbtc/frost_retained_group_endpoint_identity_test.go @@ -17,6 +17,7 @@ import ( "net/http" "net/netip" "net/url" + "reflect" "strings" "sync/atomic" "testing" @@ -917,6 +918,36 @@ func TestFrostRetainedGroupAttestedTransport_RejectsHostAndPathAmbiguity( } } +func TestFrostRetainedGroupAttestedTransport_DoesNotMutateCallerRequest( + t *testing.T, +) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + client := fixture.source.httpClient.(*http.Client) + request, err := http.NewRequest( + http.MethodPost, + fixture.server.URL+"/operator-id", + strings.NewReader("{}"), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("X-Caller-Header", "preserved") + originalHeader := request.Header.Clone() + + response, err := client.Do(request) + if err != nil { + t.Fatalf("attested request failed: [%v]", err) + } + _ = response.Body.Close() + if !reflect.DeepEqual(request.Header, originalHeader) { + t.Fatalf( + "attested transport mutated caller headers: before [%v], after [%v]", + originalHeader, + request.Header, + ) + } +} + func TestVerifyFrostRetainedGroupTLSConnection_RequiresLeafAndServicePins( t *testing.T, ) { diff --git a/pkg/tbtc/frost_retained_group_history_source.go b/pkg/tbtc/frost_retained_group_history_source.go index dfba0c3b67..b19e9490d3 100644 --- a/pkg/tbtc/frost_retained_group_history_source.go +++ b/pkg/tbtc/frost_retained_group_history_source.go @@ -85,14 +85,30 @@ type FrostRetainedGroupHistorySourceConfig struct { Resolver *net.Resolver `mapstructure:"-"` } -type frostRetainedGroupEthereumVerifier interface { +// FrostPreSignEthereumEvidenceVerifier is the read-only Ethereum view used to +// independently authenticate security-sensitive FROST authorization evidence. +// Exact-hash methods must use EIP-1898 with requireCanonical=true. +type FrostPreSignEthereumEvidenceVerifier interface { ChainID(context.Context) (*big.Int, error) HeaderByNumber(context.Context, *big.Int) (*types.Header, error) HeaderByHash(context.Context, common.Hash) (*types.Header, error) TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) + FilterLogs(context.Context, ethereum.FilterQuery) ([]types.Log, error) CodeAtHash(context.Context, common.Address, common.Hash) ([]byte, error) StorageAtHash(context.Context, common.Address, common.Hash, common.Hash) ([]byte, error) CallContractAtHash(context.Context, ethereum.CallMsg, common.Hash) ([]byte, error) +} + +// FrostPreSignEthereumEvidenceVerifierSource exposes an independently +// identity-bound Ethereum verifier to the production authorization adapter. +type FrostPreSignEthereumEvidenceVerifierSource interface { + FrostPreSignEthereumEvidenceVerifier( + context.Context, + ) (FrostPreSignEthereumEvidenceVerifier, error) +} + +type frostRetainedGroupEthereumVerifier interface { + FrostPreSignEthereumEvidenceVerifier Close() } @@ -215,6 +231,7 @@ type signedFrostRetainedGroupHistorySource struct { } var _ FrostRetainedGroupHistorySource = (*signedFrostRetainedGroupHistorySource)(nil) +var _ FrostPreSignEthereumEvidenceVerifierSource = (*signedFrostRetainedGroupHistorySource)(nil) type frostRetainedGroupSignedEnvelope struct { Schema string `json:"schema"` @@ -603,6 +620,24 @@ func (source *signedFrostRetainedGroupHistorySource) Close() { } } +func (source *signedFrostRetainedGroupHistorySource) FrostPreSignEthereumEvidenceVerifier( + ctx context.Context, +) (FrostPreSignEthereumEvidenceVerifier, error) { + if source == nil || ctx == nil || source.verifier == nil || + source.independenceMonitor == nil { + return nil, fmt.Errorf( + "independent FROST Ethereum verifier is unavailable", + ) + } + if err := source.independenceMonitor.verify(ctx); err != nil { + return nil, fmt.Errorf( + "independent FROST Ethereum verifier lost endpoint separation: [%w]", + err, + ) + } + return source.verifier, nil +} + func (source *signedFrostRetainedGroupHistorySource) requestContext( ctx context.Context, ) (context.Context, context.CancelFunc) { diff --git a/pkg/tbtc/frost_retained_group_history_source_test.go b/pkg/tbtc/frost_retained_group_history_source_test.go index 666c515a79..4616b8f4d8 100644 --- a/pkg/tbtc/frost_retained_group_history_source_test.go +++ b/pkg/tbtc/frost_retained_group_history_source_test.go @@ -203,6 +203,13 @@ func (verifier *frostRetainedGroupHistoryTestVerifier) TransactionReceipt( return receipt, nil } +func (verifier *frostRetainedGroupHistoryTestVerifier) FilterLogs( + context.Context, + ethereum.FilterQuery, +) ([]types.Log, error) { + return nil, nil +} + func (verifier *frostRetainedGroupHistoryTestVerifier) CodeAtHash( _ context.Context, address common.Address, diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index 313c92166a..e501bda357 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -733,6 +733,15 @@ func validateFrostRetainedGroupLiftCertificateShape( signature.AuthorityID, ) } + if err := validateFrostRetainedGroupPrimeOrderEd25519PublicKey( + publicKey, + ); err != nil { + return [32]byte{}, fmt.Errorf( + "FROST quarantine lift authority [%s] key is not a nonidentity prime-order Ed25519 point: [%w]", + signature.AuthorityID, + err, + ) + } signatureBytes, err := base64.StdEncoding.Strict().DecodeString( signature.Signature, ) diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index 8914d384f1..9827085680 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -2623,6 +2623,85 @@ func TestFrostRetainedGroupJournal_LiftCertificateRejectsBypasses( }) } +func TestFrostRetainedGroupJournal_LiftRejectsNonPrimeOrderAuthorityKeys( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + journal, quarantine := fixture.openActiveQuarantine( + t, + filepath.Join(t.TempDir(), "journal"), + ) + defer journal.close() + lift := fixture.liftMutation( + t, + journal, + quarantine, + FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + }, + ) + + identityKey := make(ed25519.PublicKey, ed25519.PublicKeySize) + identityKey[0] = 1 + identityKeyDER, err := x509.MarshalPKIXPublicKey(identityKey) + if err != nil { + t.Fatal(err) + } + policy := journal.liftPolicy + policy.Authorities = append( + []FrostRetainedGroupAuthority{}, + policy.Authorities..., + ) + policy.Authorities[0].PublicKeySPKIHash = + sha256.Sum256(identityKeyDER) + policy.AuthoritySetHash, err = frostRetainedGroupLiftAuthoritySetHash( + policy.AuthorityThreshold, + policy.Authorities, + ) + if err != nil { + t.Fatal(err) + } + lift.LiftCertificate.Body.AuthoritySetHash = policy.AuthoritySetHash + fixture.resignLiftMutation(t, &lift, []int{0, 1}) + signatureHash := frostRetainedGroupLiftSignatureHash( + lift.LiftCertificate.BodyHash, + ) + trivialSignature := make([]byte, ed25519.SignatureSize) + trivialSignature[0] = 1 // R is the identity; S is zero. + if !ed25519.Verify( + identityKey, + signatureHash[:], + trivialSignature, + ) { + t.Fatal("test runtime no longer accepts the identity-key Ed25519 forgery") + } + lift.LiftCertificate.Signatures[0] = + FrostRetainedGroupQuarantineLiftSignature{ + AuthorityID: policy.Authorities[0].AuthorityID, + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString( + identityKeyDER, + ), + Signature: base64.StdEncoding.EncodeToString( + trivialSignature, + ), + } + refreshJournalTestLiftCertificateHash(t, &lift) + + if _, err := validateFrostRetainedGroupLiftCertificateShape( + policy, + lift.LiftCertificate, + ); err == nil || !strings.Contains( + err.Error(), + "nonidentity prime-order", + ) { + t.Fatalf("identity lift authority key was not rejected: [%v]", err) + } +} + func TestFrostRetainedGroupJournal_LiftAuthorityStrictMajority( t *testing.T, ) { diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index 06724a1f67..214ae663f0 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -254,11 +254,28 @@ func newNode( "cannot enable production FROST pre-sign authorization without an activation manifest", ) } + verifierSource, ok := config.FrostRetainedGroupHistorySource.(FrostPreSignEthereumEvidenceVerifierSource) + if !ok { + return nil, fmt.Errorf( + "cannot enable production FROST pre-sign authorization without an independent Ethereum evidence verifier", + ) + } + ethereumEvidenceVerifier, err := + verifierSource.FrostPreSignEthereumEvidenceVerifier( + context.Background(), + ) + if err != nil { + return nil, fmt.Errorf( + "cannot obtain independent FROST Ethereum evidence verifier: [%w]", + err, + ) + } configuredProfile, err := configurator.ConfigureFrostPreSignAuthorization( context.Background(), config.FrostPreSignActivationManifestPath, config.FrostPreSignActivationEnvelopeSignerKeyHash, config.FrostPreSignLinkedLibraryDescriptorSetHash, + ethereumEvidenceVerifier, ) if err != nil { return nil, fmt.Errorf( From d416220b5a3376b68cab45be7bb1001905bb7041 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 10:07:17 -0400 Subject: [PATCH 39/93] fix(tbtc): tolerate endpoint availability skew --- .../tbtc_frost_pre_sign_authorization.go | 86 ++++++++++++++++--- ...st_pre_sign_authorization_verifier_test.go | 81 +++++++++++++++-- pkg/tbtc/frost_primary_ethereum_transport.go | 34 ++++++-- .../frost_primary_ethereum_transport_test.go | 73 ++++++++++++++++ 4 files changed, 247 insertions(+), 27 deletions(-) diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index 4bac0bba01..0ecdd8532e 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -35,7 +35,12 @@ import ( "github.com/keep-network/keep-core/pkg/tbtc" ) -const frostPreSignManifestVersion = "tbtc-p2tr-fraud-production-activation/v5" +const ( + frostPreSignManifestVersion = "tbtc-p2tr-fraud-production-activation/v5" + + frostPreSignFinalityAgreementAttempts = 4 + frostPreSignFinalityAgreementRetryDelay = time.Second +) const frostPreSignBridgeABIJSON = `[ {"type":"function","name":"previewP2TRTransactionAuthorization","stateMutability":"view","inputs":[{"name":"payload","type":"bytes"}],"outputs":[{"name":"","type":"bytes"}]}, @@ -3363,28 +3368,81 @@ func frostPreSignMatchingCurrentFinality( ctx context.Context, primary *frostPreSignEthereumAdapter, verifier *frostPreSignEthereumAdapter, +) (*tbtc.FrostPreSignFinality, error) { + return frostPreSignMatchingCurrentFinalityWithRetry( + ctx, + primary, + verifier, + frostPreSignFinalityAgreementAttempts, + frostPreSignFinalityAgreementRetryDelay, + ) +} + +func frostPreSignMatchingCurrentFinalityWithRetry( + ctx context.Context, + primary *frostPreSignEthereumAdapter, + verifier *frostPreSignEthereumAdapter, + attempts int, + retryDelay time.Duration, ) (*tbtc.FrostPreSignFinality, error) { if ctx == nil || primary == nil || verifier == nil || primary.reader == nil || verifier.reader == nil { return nil, fmt.Errorf("FROST Ethereum verifier pair is incomplete") } - primaryFinality, err := frostPreSignCurrentFinality(ctx, primary.reader) - if err != nil { - return nil, err + if attempts <= 0 || retryDelay < 0 { + return nil, fmt.Errorf("FROST Ethereum finality retry policy is invalid") } - verifiedFinality, err := frostPreSignCurrentFinality(ctx, verifier.reader) - if err != nil { - return nil, fmt.Errorf( - "cannot obtain independent finalized Ethereum header: [%w]", - err, + + var primaryFinality *tbtc.FrostPreSignFinality + var verifiedFinality *tbtc.FrostPreSignFinality + for attempt := 0; attempt < attempts; attempt++ { + var err error + primaryFinality, err = frostPreSignCurrentFinality( + ctx, + primary.reader, ) - } - if primaryFinality.BlockNumber != verifiedFinality.BlockNumber || - primaryFinality.BlockHash != verifiedFinality.BlockHash { - return nil, fmt.Errorf( - "FROST Ethereum endpoints disagree on the current finalized block", + if err != nil { + return nil, err + } + verifiedFinality, err = frostPreSignCurrentFinality( + ctx, + verifier.reader, ) + if err != nil { + return nil, fmt.Errorf( + "cannot obtain independent finalized Ethereum header: [%w]", + err, + ) + } + if primaryFinality.BlockNumber == verifiedFinality.BlockNumber && + primaryFinality.BlockHash == verifiedFinality.BlockHash { + break + } + if attempt == attempts-1 { + return nil, fmt.Errorf( + "FROST Ethereum endpoints disagree on the current finalized block", + ) + } + if retryDelay == 0 { + continue + } + timer := time.NewTimer(retryDelay) + select { + case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return nil, fmt.Errorf( + "cannot retry independent finalized Ethereum headers: [%w]", + ctx.Err(), + ) + case <-timer.C: + } } + if err := primary.requireCanonicalFinality( ctx, primaryFinality, diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go index deb2bdefd2..93a56bcee0 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go @@ -18,10 +18,12 @@ import ( ) type testFrostPreSignEvidenceReader struct { - finalized *types.Header - headers map[uint64]*types.Header - receipts []*types.Receipt - receiptCall int + finalized *types.Header + finalizedSequence []*types.Header + finalizedCall int + headers map[uint64]*types.Header + receipts []*types.Receipt + receiptCall int } func TestNewFrostPreSignPrimaryEthereumReaderAcceptsWrappedClient( @@ -62,7 +64,15 @@ func (reader *testFrostPreSignEvidenceReader) HeaderByNumber( return nil, fmt.Errorf("header unavailable") } if number.Sign() < 0 { - return reader.finalized, nil + if len(reader.finalizedSequence) == 0 { + return reader.finalized, nil + } + index := reader.finalizedCall + if index >= len(reader.finalizedSequence) { + index = len(reader.finalizedSequence) - 1 + } + reader.finalizedCall++ + return reader.finalizedSequence[index], nil } if number.IsUint64() && reader.headers != nil { if header := reader.headers[number.Uint64()]; header != nil { @@ -157,8 +167,12 @@ func TestFrostPreSignCurrentFinalityRequiresEndpointAgreement(t *testing.T) { }, } - if _, err := chain.CurrentFrostPreSignFinality( + if _, err := frostPreSignMatchingCurrentFinalityWithRetry( context.Background(), + chain.frostPreSignAuthorizationAdapter, + chain.frostPreSignAuthorizationVerifier, + 1, + 0, ); err == nil { t.Fatal("different finalized block hashes were accepted") } @@ -175,6 +189,61 @@ func TestFrostPreSignCurrentFinalityRequiresEndpointAgreement(t *testing.T) { } } +func TestFrostPreSignCurrentFinalityRetriesTransientEndpointSkew( + t *testing.T, +) { + olderHeader := &types.Header{ + Number: big.NewInt(10), + Time: 10, + Extra: []byte{0x01}, + } + newerHeader := &types.Header{ + Number: big.NewInt(11), + Time: 11, + Extra: []byte{0x02}, + } + primaryReader := &testFrostPreSignEvidenceReader{ + finalized: newerHeader, + finalizedSequence: []*types.Header{ + olderHeader, + newerHeader, + newerHeader, + newerHeader, + }, + headers: map[uint64]*types.Header{ + 10: olderHeader, + 11: newerHeader, + }, + } + verifierReader := &testFrostPreSignEvidenceReader{ + finalized: newerHeader, + finalizedSequence: []*types.Header{ + newerHeader, + newerHeader, + newerHeader, + newerHeader, + }, + headers: map[uint64]*types.Header{ + 11: newerHeader, + }, + } + + actual, err := frostPreSignMatchingCurrentFinalityWithRetry( + context.Background(), + &frostPreSignEthereumAdapter{reader: primaryReader}, + &frostPreSignEthereumAdapter{reader: verifierReader}, + 2, + 0, + ) + if err != nil { + t.Fatalf("temporary finalized-head skew was not retried: [%v]", err) + } + if actual.BlockNumber != 11 || + actual.BlockHash != [32]byte(newerHeader.Hash()) { + t.Fatalf("unexpected converged finality [%+v]", actual) + } +} + func TestFrostPreSignAuthorizationStateRequiresVerifierAgreement( t *testing.T, ) { diff --git a/pkg/tbtc/frost_primary_ethereum_transport.go b/pkg/tbtc/frost_primary_ethereum_transport.go index 62b4fd8a41..2243ff40cb 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport.go +++ b/pkg/tbtc/frost_primary_ethereum_transport.go @@ -385,18 +385,34 @@ func (transport *FrostPrimaryEthereumTransport) dialTLSContext( "primary Ethereum transport attempted an unpinned endpoint", ) } - dialer := &net.Dialer{ - Timeout: transport.timeout, - KeepAlive: 30 * time.Second, - } + dialContext, dialCancel := context.WithTimeout(ctx, transport.timeout) + defer dialCancel() + dialer := &net.Dialer{KeepAlive: 30 * time.Second} var lastErr error - for _, pinned := range transport.endpoint.addresses { + for index, pinned := range transport.endpoint.addresses { + deadline, ok := dialContext.Deadline() + if !ok { + return nil, fmt.Errorf( + "primary Ethereum TLS dial has no bounded deadline", + ) + } + remaining := time.Until(deadline) + if remaining <= 0 { + lastErr = dialContext.Err() + break + } + attemptsRemaining := len(transport.endpoint.addresses) - index + attemptContext, attemptCancel := context.WithTimeout( + dialContext, + remaining/time.Duration(attemptsRemaining), + ) raw, dialErr := dialer.DialContext( - ctx, + attemptContext, network, net.JoinHostPort(pinned.String(), port), ) if dialErr != nil { + attemptCancel() lastErr = dialErr continue } @@ -405,11 +421,15 @@ func (transport *FrostPrimaryEthereumTransport) dialTLSContext( transport: transport, } tlsConnection := tls.Client(tracked, transport.tlsConfig()) - if handshakeErr := tlsConnection.HandshakeContext(ctx); handshakeErr != nil { + if handshakeErr := tlsConnection.HandshakeContext( + attemptContext, + ); handshakeErr != nil { _ = tlsConnection.Close() + attemptCancel() lastErr = handshakeErr continue } + attemptCancel() state := tlsConnection.ConnectionState() if verifyErr := verifyFrostPrimaryEthereumTLSConnection( state, diff --git a/pkg/tbtc/frost_primary_ethereum_transport_test.go b/pkg/tbtc/frost_primary_ethereum_transport_test.go index b130e47a0f..c4fa850742 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport_test.go +++ b/pkg/tbtc/frost_primary_ethereum_transport_test.go @@ -6,7 +6,10 @@ import ( "errors" "fmt" "math/big" + "net" + "net/http" "net/netip" + "net/url" "strings" "sync" "testing" @@ -182,6 +185,76 @@ func TestFrostPrimaryEthereumTransportRequiresExactLiveChannel(t *testing.T) { } } +func TestFrostPrimaryEthereumTransportTriesEveryPinnedAddressWithinDeadline( + t *testing.T, +) { + server, _, roots := newFrostRetainedGroupHistoryTLSTestServer( + t, + http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), + "spiffe://primary.example/rpc", + ) + endpoint, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + _, port, err := net.SplitHostPort(endpoint.Host) + if err != nil { + t.Fatal(err) + } + + stalledListener, err := net.Listen( + "tcp6", + net.JoinHostPort("::1", port), + ) + if err != nil { + t.Fatal(err) + } + releaseStalledConnection := make(chan struct{}) + stalledConnectionDone := make(chan struct{}) + go func() { + defer close(stalledConnectionDone) + connection, acceptErr := stalledListener.Accept() + if acceptErr != nil { + return + } + defer connection.Close() + <-releaseStalledConnection + }() + t.Cleanup(func() { + close(releaseStalledConnection) + _ = stalledListener.Close() + <-stalledConnectionDone + }) + + transport := &FrostPrimaryEthereumTransport{ + endpoint: frostRetainedGroupResolvedEndpoint{ + endpoint: endpoint, + canonical: endpoint.String(), + addresses: []netip.Addr{ + netip.MustParseAddr("::1"), + netip.MustParseAddr("127.0.0.1"), + }, + }, + timeout: time.Second, + rootCAs: roots, + seenPeers: make(map[[32]byte]frostTransportPeerIdentity), + livePeers: make(map[[32]byte]uint64), + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + connection, err := transport.dialTLSContext(ctx, "tcp", endpoint.Host) + if err != nil { + t.Fatalf( + "healthy pinned address was starved by a stalled predecessor: [%v]", + err, + ) + } + if err := connection.Close(); err != nil { + t.Fatal(err) + } +} + func TestFrostPrimaryEthereumTransportWSSAppliesRequestTimeout( t *testing.T, ) { From 592e060311a6933057b71389b49b52ed22125df8 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 11:11:58 -0400 Subject: [PATCH 40/93] fix(ethereum): share RPC limiter with FROST reads --- pkg/chain/ethereum/ethereum.go | 33 +++++++++++-- .../tbtc_frost_historical_deployment_test.go | 15 +++--- ...st_pre_sign_authorization_verifier_test.go | 47 +++++++++++++++++++ 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/pkg/chain/ethereum/ethereum.go b/pkg/chain/ethereum/ethereum.go index 817450c682..945580508d 100644 --- a/pkg/chain/ethereum/ethereum.go +++ b/pkg/chain/ethereum/ethereum.go @@ -38,7 +38,7 @@ type baseChain struct { key *keystore.Key client ethutil.EthereumClient rpcClient *rpc.Client - rpcLimiter *rate.Limiter + rpcLimiter ethereumRPCLimiter chainID *big.Int frostPrimaryEthereumRequestTimeout time.Duration @@ -71,6 +71,11 @@ type EthereumClient interface { Client() *rpc.Client } +type ethereumRPCLimiter interface { + AcquirePermit() error + ReleasePermit() +} + // Connect creates Random Beacon and TBTC Ethereum chain handles. func Connect( ctx context.Context, @@ -296,10 +301,10 @@ func newBaseChain( } clientWithAddons := wrapClientAddons(config, client) - rpcLimiter := rate.NewLimiter(&rate.LimiterConfig{ - RequestsPerSecondLimit: config.RequestsPerSecondLimit, - ConcurrencyLimit: config.ConcurrencyLimit, - }) + rpcLimiter, err := sharedEthereumRPCLimiter(config, clientWithAddons) + if err != nil { + return nil, err + } var frostPrimaryEthereumRequestTimeout time.Duration if timeoutSource, ok := client.(interface { FrostPrimaryEthereumRequestTimeout() time.Duration @@ -578,6 +583,24 @@ func wrapClientAddons( return loggingClient } +func sharedEthereumRPCLimiter( + config ethereum.Config, + client ethutil.EthereumClient, +) (ethereumRPCLimiter, error) { + if config.RequestsPerSecondLimit <= 0 && config.ConcurrencyLimit <= 0 { + return nil, nil + } + + limiter, ok := client.(ethereumRPCLimiter) + if !ok { + return nil, fmt.Errorf( + "configured Ethereum rate-limited client does not expose its shared limiter", + ) + } + + return limiter, nil +} + // decryptKey decrypts the chain key pointed by the config. func decryptKey(config ethereum.Config) (*keystore.Key, error) { return ethutil.DecryptKeyFile( diff --git a/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go b/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go index b604f85b3d..5959aec2d5 100644 --- a/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go +++ b/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go @@ -14,7 +14,6 @@ import ( "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" commonethereum "github.com/keep-network/keep-common/pkg/chain/ethereum" - "github.com/keep-network/keep-common/pkg/rate" "github.com/keep-network/keep-core/pkg/tbtc" ) @@ -263,13 +262,15 @@ func TestFrostPreSignExactHashReader_WrappedProductionClient(t *testing.T) { RequestsPerSecondLimit: 10, ConcurrencyLimit: 2, } + wrappedClient := wrapClientAddons(config, client) + rpcLimiter, err := sharedEthereumRPCLimiter(config, wrappedClient) + if err != nil { + t.Fatal(err) + } chain := &baseChain{ - client: wrapClientAddons(config, client), - rpcClient: client.Client(), - rpcLimiter: rate.NewLimiter(&rate.LimiterConfig{ - RequestsPerSecondLimit: config.RequestsPerSecondLimit, - ConcurrencyLimit: config.ConcurrencyLimit, - }), + client: wrappedClient, + rpcClient: client.Client(), + rpcLimiter: rpcLimiter, } evidenceReader, err := newFrostPreSignPrimaryEthereumReader( chain.client, diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go index 93a56bcee0..80c572b146 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go @@ -50,6 +50,53 @@ func TestNewFrostPreSignPrimaryEthereumReaderAcceptsWrappedClient( } } +func TestFrostPrimaryEthereumRPCSharesConfiguredLimiter(t *testing.T) { + server := rpc.NewServer() + rpcClient := rpc.DialInProc(server) + defer rpcClient.Close() + client := ethclient.NewClient(rpcClient) + config := ethereumConfig.Config{ConcurrencyLimit: 1} + wrapped := wrapClientAddons(config, client) + limiter, err := sharedEthereumRPCLimiter(config, wrapped) + if err != nil { + t.Fatal(err) + } + if limiter == nil { + t.Fatal("shared Ethereum RPC limiter is nil") + } + if err := limiter.AcquirePermit(); err != nil { + t.Fatal(err) + } + permitHeld := true + defer func() { + if permitHeld { + limiter.ReleasePermit() + } + }() + + result := make(chan error, 1) + go func() { + _, err := wrapped.HeaderByNumber(context.Background(), nil) + result <- err + }() + select { + case err := <-result: + t.Fatalf( + "ordinary Ethereum RPC bypassed the limiter held by the raw FROST path: [%v]", + err, + ) + case <-time.After(50 * time.Millisecond): + } + + limiter.ReleasePermit() + permitHeld = false + select { + case <-result: + case <-time.After(time.Second): + t.Fatal("ordinary Ethereum RPC did not resume after the shared permit was released") + } +} + func (reader *testFrostPreSignEvidenceReader) ChainID( context.Context, ) (*big.Int, error) { From 182b9c9560cd55dde8f6bf365f09c71bce2ae10f Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 11:51:49 -0400 Subject: [PATCH 41/93] fix(tbtc): cache signer readiness during authorization --- pkg/tbtc/frost_native_signer_readiness.go | 33 +++++ pkg/tbtc/frost_pre_sign_authorization.go | 65 +++++++++- pkg/tbtc/frost_pre_sign_authorization_test.go | 114 ++++++++++++++++-- pkg/tbtc/wallet.go | 4 +- 4 files changed, 202 insertions(+), 14 deletions(-) diff --git a/pkg/tbtc/frost_native_signer_readiness.go b/pkg/tbtc/frost_native_signer_readiness.go index 2d463d6cf0..ed7708fe2b 100644 --- a/pkg/tbtc/frost_native_signer_readiness.go +++ b/pkg/tbtc/frost_native_signer_readiness.go @@ -320,6 +320,39 @@ type frostProductionSignerReadinessVerifier interface { context.Context, FrostPreSignFinality, ) (*frostProductionSignerReadinessSnapshot, error) + verifyFrostProductionSignerReadinessUnchanged( + context.Context, + *frostProductionSignerReadinessSnapshot, + ) error +} + +func cloneFrostProductionSignerReadinessSnapshot( + snapshot *frostProductionSignerReadinessSnapshot, +) *frostProductionSignerReadinessSnapshot { + if snapshot == nil { + return nil + } + result := *snapshot + if snapshot.Journal != nil { + journal := *snapshot.Journal + result.Journal = &journal + } + if snapshot.Inventory != nil { + inventory := *snapshot.Inventory + result.Inventory = &inventory + } + result.inventoryExpectations = make( + []frostNativeSignerInventoryExpectation, + len(snapshot.inventoryExpectations), + ) + for i, expectation := range snapshot.inventoryExpectations { + result.inventoryExpectations[i] = expectation + result.inventoryExpectations[i].ParticipantSeats = append( + []uint16{}, + expectation.ParticipantSeats..., + ) + } + return &result } type frostProductionSignerReadiness struct { diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index 07c2e4cd13..d1383412fe 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1507,6 +1507,10 @@ type frostPreSignAuthorization struct { VariantSequence FrostPreSignVariantSequence proposal *FrostPreSignAuthorizationProposal anchorReservation *frostNativeSignerAnchorRevisionReservation + + readinessMutex sync.Mutex + readinessPoint FrostPreSignFinality + readinessSnapshot *frostProductionSignerReadinessSnapshot } func (authorization *frostPreSignAuthorization) releaseAnchorReservation() { @@ -1516,6 +1520,37 @@ func (authorization *frostPreSignAuthorization) releaseAnchorReservation() { authorization.anchorReservation.Release() } +func (authorization *frostPreSignAuthorization) cacheReadiness( + point FrostPreSignFinality, + snapshot *frostProductionSignerReadinessSnapshot, +) { + if authorization == nil || snapshot == nil { + return + } + authorization.readinessMutex.Lock() + defer authorization.readinessMutex.Unlock() + authorization.readinessPoint = point + authorization.readinessSnapshot = + cloneFrostProductionSignerReadinessSnapshot(snapshot) +} + +func (authorization *frostPreSignAuthorization) cachedReadiness( + point FrostPreSignFinality, +) *frostProductionSignerReadinessSnapshot { + if authorization == nil { + return nil + } + authorization.readinessMutex.Lock() + defer authorization.readinessMutex.Unlock() + if authorization.readinessSnapshot == nil || + authorization.readinessPoint != point { + return nil + } + return cloneFrostProductionSignerReadinessSnapshot( + authorization.readinessSnapshot, + ) +} + type frostPreSignAuthorizationGate interface { authorize( context.Context, @@ -1620,8 +1655,8 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) authorize( if err := transaction.validate(); err != nil { return nil, err } - _, readinessSnapshot, err := - tfpsag.verifyCurrentProductionSignerReadiness(ctx) + readinessPoint, readinessSnapshot, err := + tfpsag.verifyCurrentProductionSignerReadiness(ctx, nil) if err != nil { return nil, err } @@ -1729,6 +1764,7 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) authorize( proposal: proposal, anchorReservation: anchorReservation, } + authorization.cacheReadiness(*readinessPoint, readinessSnapshot) if err := tfpsag.revalidate(ctx, authorization); err != nil { return nil, err } @@ -1741,8 +1777,8 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( ctx context.Context, authorization *frostPreSignAuthorization, ) error { - currentFinality, _, err := - tfpsag.verifyCurrentProductionSignerReadiness(ctx) + currentFinality, readinessSnapshot, err := + tfpsag.verifyCurrentProductionSignerReadiness(ctx, authorization) if err != nil { return err } @@ -1794,9 +1830,9 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( ); err != nil { return err } - if _, err := tfpsag.productionReadiness.verifyFrostProductionSignerReadiness( + if err := tfpsag.productionReadiness.verifyFrostProductionSignerReadinessUnchanged( ctx, - *currentFinality, + readinessSnapshot, ); err != nil { return fmt.Errorf("FROST signer readiness changed during authorization revalidation: [%w]", err) } @@ -1806,6 +1842,7 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( func (tfpsag *thresholdFrostPreSignAuthorizationGate) verifyCurrentProductionSignerReadiness( ctx context.Context, + authorization *frostPreSignAuthorization, ) ( *FrostPreSignFinality, *frostProductionSignerReadinessSnapshot, @@ -1834,6 +1871,21 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) verifyCurrentProductionSig "invalid current FROST pre-sign finalized checkpoint", ) } + if readinessSnapshot := authorization.cachedReadiness( + *currentFinality, + ); readinessSnapshot != nil { + if err := tfpsag.productionReadiness. + verifyFrostProductionSignerReadinessUnchanged( + ctx, + readinessSnapshot, + ); err != nil { + return nil, nil, fmt.Errorf( + "cached FROST production signer readiness changed: [%w]", + err, + ) + } + return currentFinality, readinessSnapshot, nil + } readinessSnapshot, err := tfpsag.productionReadiness.verifyFrostProductionSignerReadiness( ctx, @@ -1850,6 +1902,7 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) verifyCurrentProductionSig "FROST production signer readiness snapshot is nil", ) } + authorization.cacheReadiness(*currentFinality, readinessSnapshot) return currentFinality, readinessSnapshot, nil } diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go index 0aa852b08f..6f1ea5f9f9 100644 --- a/pkg/tbtc/frost_pre_sign_authorization_test.go +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -20,10 +20,12 @@ import ( ) type testFrostProductionAuthorizationReadiness struct { - err error - calls uint64 - points []FrostPreSignFinality - headrooms []uint64 + err error + unchangedErr error + calls uint64 + unchangedCalls uint64 + points []FrostPreSignFinality + headrooms []uint64 } func (readiness *testFrostProductionAuthorizationReadiness) verifyFrostProductionSignerReadiness( @@ -46,6 +48,14 @@ func (readiness *testFrostProductionAuthorizationReadiness) verifyFrostProductio return testFrostAnchorAdmissionReadinessSnapshot(headroom, headroom), nil } +func (readiness *testFrostProductionAuthorizationReadiness) verifyFrostProductionSignerReadinessUnchanged( + _ context.Context, + _ *frostProductionSignerReadinessSnapshot, +) error { + readiness.unchangedCalls++ + return readiness.unchangedErr +} + func testFrostPreSignTransaction( t *testing.T, unsignedTx *bitcoin.TransactionBuilder, @@ -324,6 +334,94 @@ func TestThresholdFrostPreSignAuthorizationGate_RefusesUnreadyInteractiveSigner( } } +func TestThresholdFrostPreSignAuthorizationGate_ReusesReadinessAtUnchangedFinality( + t *testing.T, +) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + transaction := testFrostPreSignTransaction(t, unsignedTx) + proposal := completeTestFrostPreSignProposal(transaction) + relayFinality := FrostPreSignFinality{ + RelayTransactionHash: [32]byte{0x71}, + BlockNumber: 100, + BlockHash: [32]byte{0x72}, + AuthorizationSequence: [32]byte{31: 1}, + } + currentFinality := FrostPreSignFinality{ + BlockNumber: 101, + BlockHash: [32]byte{0x73}, + } + laterFinality := FrostPreSignFinality{ + BlockNumber: 102, + BlockHash: [32]byte{0x74}, + } + backend := &testFrostPreSignAuthorizationBackend{ + currentFinality: ¤tFinality, + states: map[uint64]*FrostPreSignAuthorizationState{ + relayFinality.BlockNumber: completeTestFrostPreSignState( + proposal, + relayFinality, + ), + currentFinality.BlockNumber: completeTestFrostPreSignState( + proposal, + currentFinality, + ), + laterFinality.BlockNumber: completeTestFrostPreSignState( + proposal, + laterFinality, + ), + }, + } + readiness := &testFrostProductionAuthorizationReadiness{} + gate := &thresholdFrostPreSignAuthorizationGate{ + backend: backend, + activationProfile: activationProfileForTestProposal(proposal), + storeBinding: testFrostDurableSessionStoreBinding(t), + productionReadiness: readiness, + } + authorization := &frostPreSignAuthorization{ + ActivationProfileHash: gate.activationProfile.ProfileHash, + AuthorizationID: proposal.Digest, + ReservationID: proposal.ReservationID, + VariantRoot: proposal.AuthorizationRoot, + TransactionHash: proposal.Transaction.TransactionHash, + Finality: relayFinality, + VariantSequence: frostPreSignVariantSequence(relayFinality), + proposal: proposal, + } + + if err := gate.revalidate(context.Background(), authorization); err != nil { + t.Fatal(err) + } + if err := gate.revalidate(context.Background(), authorization); err != nil { + t.Fatal(err) + } + if readiness.calls != 1 || readiness.unchangedCalls != 3 { + t.Fatalf( + "unchanged finality repeated full readiness reconciliation: full [%d], unchanged [%d]", + readiness.calls, + readiness.unchangedCalls, + ) + } + + backend.currentFinality = &laterFinality + if err := gate.revalidate(context.Background(), authorization); err != nil { + t.Fatal(err) + } + if readiness.calls != 2 || readiness.unchangedCalls != 4 { + t.Fatalf( + "new finality did not refresh exactly one readiness reconciliation: full [%d], unchanged [%d]", + readiness.calls, + readiness.unchangedCalls, + ) + } + if backend.currentFinalityCalls != 3 { + t.Fatalf( + "revalidation did not poll finalized point on every pass: [%d]", + backend.currentFinalityCalls, + ) + } +} + func TestFrostPreSignActivationProfile_IndependentlyPinsProposal(t *testing.T) { unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) transaction := testFrostPreSignTransaction(t, unsignedTx) @@ -1076,9 +1174,10 @@ func (cfpscs *countingFrostPreSignChainSigning) Sign(message []byte) ([]byte, er } type testFrostPreSignAuthorizationBackend struct { - proposal *FrostPreSignAuthorizationProposal - currentFinality *FrostPreSignFinality - states map[uint64]*FrostPreSignAuthorizationState + proposal *FrostPreSignAuthorizationProposal + currentFinality *FrostPreSignFinality + currentFinalityCalls uint64 + states map[uint64]*FrostPreSignAuthorizationState } func (tfpsab *testFrostPreSignAuthorizationBackend) PrepareFrostPreSignAuthorization( @@ -1108,6 +1207,7 @@ func (tfpsab *testFrostPreSignAuthorizationBackend) WaitForFrostPreSignAuthoriza func (tfpsab *testFrostPreSignAuthorizationBackend) CurrentFrostPreSignFinality( ctx context.Context, ) (*FrostPreSignFinality, error) { + tfpsab.currentFinalityCalls++ if tfpsab.currentFinality == nil { return nil, nil } diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index 8c293da5e8..13bff7c10d 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -358,7 +358,9 @@ const defaultFrostAuthorizationMonitorInterval = time.Second // frostPreSignAuthorizationMonitor keeps the pinned Ethereum authorization // live for the entire native signing window. All explicit nonce/share guards // and the periodic monitor serialize through validationMutex so a backend never -// observes overlapping reads for one authorization. +// observes overlapping reads for one authorization. Production revalidation +// polls the current finalized point on every pass but reuses the authorization's +// cached signer-readiness reconciliation while that exact point is unchanged. type frostPreSignAuthorizationMonitor struct { gate frostPreSignAuthorizationGate authorization *frostPreSignAuthorization From 5431ea87e7f278f782433fd9c5bee749b88d5e82 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 12:24:02 -0400 Subject: [PATCH 42/93] fix(tbtc): recheck retained journal before signing --- pkg/tbtc/frost_native_signer_readiness.go | 99 ++++++++++++++++++- .../frost_native_signer_readiness_test.go | 36 +++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/pkg/tbtc/frost_native_signer_readiness.go b/pkg/tbtc/frost_native_signer_readiness.go index ed7708fe2b..028d998b19 100644 --- a/pkg/tbtc/frost_native_signer_readiness.go +++ b/pkg/tbtc/frost_native_signer_readiness.go @@ -425,13 +425,19 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead ) error { if readiness == nil || readiness.interactiveSigningReady == nil || readiness.journal == nil || readiness.inventoryBinding == nil || - ctx == nil || expected == nil || expected.Inventory == nil || + ctx == nil || expected == nil || expected.Journal == nil || + expected.Inventory == nil || !expected.InteractiveSigningReady { return fmt.Errorf("cached FROST production signer readiness is incomplete") } if !readiness.interactiveSigningReady() { return fmt.Errorf("interactive FROST signing engine is not ready") } + if err := readiness.verifyFrostRetainedGroupJournalStampUnchanged( + expected.Journal, + ); err != nil { + return err + } if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( expected.registryRevision, ) { @@ -463,6 +469,97 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead "interactive FROST signing engine became unavailable during readiness revalidation", ) } + if err := readiness.verifyFrostRetainedGroupJournalStampUnchanged( + expected.Journal, + ); err != nil { + return err + } + return nil +} + +func (readiness *frostProductionSignerReadiness) verifyFrostRetainedGroupJournalStampUnchanged( + expected *frostRetainedGroupJournalSnapshot, +) error { + if readiness == nil || readiness.journal == nil || expected == nil { + return fmt.Errorf( + "cached FROST retained-group journal readiness is incomplete", + ) + } + journal := readiness.journal + if !journal.mutex.TryLock() { + return fmt.Errorf( + "canonical FROST retained-group journal is busy during readiness revalidation", + ) + } + defer journal.mutex.Unlock() + + if !expected.Complete || + expected.Schema != frostRetainedGroupJournalSnapshotSchema || + journal.closed || + journal.metadata.Schema != frostRetainedGroupJournalMetadataSchema || + journal.metadata.BindingHash != expected.BindingHash || + journal.metadata.StoreID != expected.StoreID || + journal.metadata.StoreFingerprint != expected.StoreFingerprint || + journal.metadata.ClusterFingerprint != expected.ClusterFingerprint || + journal.state.Schema != frostRetainedGroupJournalStateSchema || + journal.state.BindingHash != expected.BindingHash || + journal.state.CurrentPoint != expected.CurrentPoint || + journal.state.SnapshotGeneration != expected.SnapshotGeneration || + journal.state.BatchRoot != expected.BatchRoot || + journal.state.InventoryRoot != expected.InventoryRoot || + journal.quarantineMetadata.Schema != + frostRetainedGroupQuarantineMetadataSchema || + journal.quarantineMetadata.BindingHash != expected.BindingHash || + journal.quarantineMetadata.ProtocolID != expected.QuarantineProtocolID || + journal.quarantineMetadata.StoreID != expected.QuarantineStoreID || + journal.quarantineMetadata.StoreFingerprint != + expected.QuarantineStoreFingerprint || + journal.quarantineMetadata.ClusterFingerprint != + expected.QuarantineClusterFingerprint || + journal.quarantineMinimumGeneration != + expected.QuarantineMinimumGeneration || + journal.quarantineState.Schema != + frostRetainedGroupQuarantineStateSchema || + journal.quarantineState.BindingHash != expected.BindingHash || + journal.quarantineState.CurrentPoint != expected.CurrentPoint || + journal.quarantineState.Generation != expected.QuarantineGeneration || + journal.quarantineState.Root != expected.QuarantineRoot || + journal.quarantineState.ActiveRoot != expected.QuarantineActiveRoot || + journal.quarantineState.TombstoneRoot != + expected.QuarantineTombstoneRoot || + frostRetainedGroupActiveQuarantineCount(journal.quarantineState) != + expected.QuarantineCount || + uint64(len(journal.quarantineState.Tombstones)) != + expected.QuarantineTombstoneCount || + journal.checkpointPolicy.MinimumSequence != + expected.CheckpointMinimumSequence || + journal.checkpointPolicy.PredecessorHash != + expected.CheckpointPredecessorHash || + journal.checkpointState.Schema != + frostRetainedGroupCheckpointStateSchema || + journal.checkpointState.BindingHash != expected.BindingHash || + journal.checkpointState.Point != expected.CurrentPoint || + journal.checkpointState.Sequence != expected.CheckpointSequence || + journal.checkpointState.CertificateHash != + expected.CheckpointCertificateHash || + journal.checkpointState.HistoryRoot != + expected.CheckpointHistoryRoot || + journal.checkpointState.CanonicalGeneration != + expected.SnapshotGeneration || + journal.checkpointState.CanonicalInventoryRoot != + expected.InventoryRoot || + journal.checkpointState.QuarantineGeneration != + expected.QuarantineGeneration || + journal.checkpointState.QuarantineEventRoot != + expected.QuarantineRoot || + journal.checkpointState.QuarantineActiveRoot != + expected.QuarantineActiveRoot || + journal.checkpointState.QuarantineTombstoneRoot != + expected.QuarantineTombstoneRoot { + return fmt.Errorf( + "canonical, quarantine, or checkpoint FROST retained-group journal changed since readiness reconciliation", + ) + } return nil } diff --git a/pkg/tbtc/frost_native_signer_readiness_test.go b/pkg/tbtc/frost_native_signer_readiness_test.go index 25e4f4f681..ddf5bc9613 100644 --- a/pkg/tbtc/frost_native_signer_readiness_test.go +++ b/pkg/tbtc/frost_native_signer_readiness_test.go @@ -645,3 +645,39 @@ func TestFrostProductionSignerReadinessRejectsConcurrentRegistryChange( t.Fatalf("concurrent local registry change was accepted: [%v]", err) } } + +func TestFrostProductionSignerReadinessRejectsChangedOrBusyJournalStamp( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + journalDirectory := t.TempDir() + if err := os.Chmod(journalDirectory, 0700); err != nil { + t.Fatal(err) + } + journal := fixture.openJournal(t, journalDirectory) + expected, err := journal.reconcile(context.Background(), fixture.target) + if err != nil { + t.Fatal(err) + } + readiness := &frostProductionSignerReadiness{journal: journal} + if err := readiness.verifyFrostRetainedGroupJournalStampUnchanged( + expected, + ); err != nil { + t.Fatalf("matching retained-group journal stamp was rejected: [%v]", err) + } + + journal.quarantineState.Generation++ + if err := readiness.verifyFrostRetainedGroupJournalStampUnchanged( + expected, + ); err == nil || !strings.Contains(err.Error(), "journal changed") { + t.Fatalf("advanced quarantine journal stamp was accepted: [%v]", err) + } + journal.quarantineState.Generation-- + + journal.mutex.Lock() + err = readiness.verifyFrostRetainedGroupJournalStampUnchanged(expected) + journal.mutex.Unlock() + if err == nil || !strings.Contains(err.Error(), "journal is busy") { + t.Fatalf("busy retained-group journal did not fail closed: [%v]", err) + } +} From d33fc50bd8f197e7c618bfe4b3202c18066acce7 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 12:54:54 -0400 Subject: [PATCH 43/93] fix(tbtc): fail over retained TLS replicas --- .../frost_retained_group_endpoint_identity.go | 67 ++++++-- ...t_retained_group_endpoint_identity_test.go | 147 ++++++++++++++++++ 2 files changed, 201 insertions(+), 13 deletions(-) diff --git a/pkg/tbtc/frost_retained_group_endpoint_identity.go b/pkg/tbtc/frost_retained_group_endpoint_identity.go index 6b98779056..8bcb50512e 100644 --- a/pkg/tbtc/frost_retained_group_endpoint_identity.go +++ b/pkg/tbtc/frost_retained_group_endpoint_identity.go @@ -1130,19 +1130,20 @@ func frostRetainedGroupEndpointSetsOverlap( return false } -func frostRetainedGroupPinnedDialContext( +func frostRetainedGroupPinnedDialTLSContext( endpoint frostRetainedGroupResolvedEndpoint, + tlsConfig *tls.Config, timeout time.Duration, ) func(context.Context, string, string) (net.Conn, error) { - dialer := &net.Dialer{ - Timeout: timeout, - KeepAlive: -1, - } return func( ctx context.Context, network string, address string, ) (net.Conn, error) { + if ctx == nil || tlsConfig == nil || + !strings.HasPrefix(network, "tcp") { + return nil, fmt.Errorf("retained-group TLS dial is invalid") + } host, port, err := net.SplitHostPort(address) if err != nil || host != endpoint.endpoint.Hostname() || @@ -1151,17 +1152,53 @@ func frostRetainedGroupPinnedDialContext( "retained-group transport attempted an unpinned endpoint", ) } + dialContext, dialCancel := context.WithTimeout(ctx, timeout) + defer dialCancel() + dialer := &net.Dialer{KeepAlive: -1} var lastErr error - for _, pinned := range endpoint.addresses { - connection, err := dialer.DialContext( - ctx, + for index, pinned := range endpoint.addresses { + deadline, ok := dialContext.Deadline() + if !ok { + return nil, fmt.Errorf( + "retained-group TLS dial has no bounded deadline", + ) + } + remaining := time.Until(deadline) + if remaining <= 0 { + lastErr = dialContext.Err() + break + } + attemptsRemaining := len(endpoint.addresses) - index + attemptContext, attemptCancel := context.WithTimeout( + dialContext, + remaining/time.Duration(attemptsRemaining), + ) + raw, dialErr := dialer.DialContext( + attemptContext, network, net.JoinHostPort(pinned.String(), port), ) - if err == nil { - return connection, nil + if dialErr != nil { + attemptCancel() + lastErr = dialErr + continue + } + connection := tls.Client(raw, tlsConfig.Clone()) + if handshakeErr := connection.HandshakeContext( + attemptContext, + ); handshakeErr != nil { + _ = connection.Close() + attemptCancel() + lastErr = handshakeErr + continue } - lastErr = err + attemptCancel() + return connection, nil + } + if lastErr == nil { + lastErr = fmt.Errorf( + "retained-group endpoint has no pinned addresses", + ) } return nil, fmt.Errorf( "cannot connect any pinned retained-group endpoint address: [%w]", @@ -1228,8 +1265,12 @@ func newFrostRetainedGroupAttestedHTTPClientWithSeparationPolicy( return nil, nil, err } transport := &http.Transport{ - Proxy: nil, - DialContext: frostRetainedGroupPinnedDialContext(endpoint, timeout), + Proxy: nil, + DialTLSContext: frostRetainedGroupPinnedDialTLSContext( + endpoint, + tlsConfig, + timeout, + ), DisableKeepAlives: true, DisableCompression: true, ForceAttemptHTTP2: false, diff --git a/pkg/tbtc/frost_retained_group_endpoint_identity_test.go b/pkg/tbtc/frost_retained_group_endpoint_identity_test.go index d948d5f601..a43b18a772 100644 --- a/pkg/tbtc/frost_retained_group_endpoint_identity_test.go +++ b/pkg/tbtc/frost_retained_group_endpoint_identity_test.go @@ -1227,6 +1227,153 @@ func TestFrostRetainedGroupVerifierRPC_RequiresPerResponseConformanceAttestation } } +func TestFrostRetainedGroupPinnedTLS_TriesEveryAddressWithinDeadline( + t *testing.T, +) { + for _, test := range []struct { + name string + firstServer func(*testing.T, net.Listener) func() + }{ + { + name: "stalled TLS handshake", + firstServer: func(t *testing.T, listener net.Listener) func() { + t.Helper() + release := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + connection, err := listener.Accept() + if err != nil { + return + } + defer connection.Close() + <-release + }() + return func() { + close(release) + _ = listener.Close() + <-done + } + }, + }, + { + name: "invalid TLS certificate", + firstServer: func(t *testing.T, listener net.Listener) func() { + t.Helper() + badServer, _, _ := newFrostRetainedGroupHistoryTLSTestServer( + t, + http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), + "spiffe://retained-export.example/export", + ) + done := make(chan struct{}) + go func() { + defer close(done) + raw, err := listener.Accept() + if err != nil { + return + } + connection := tls.Server(raw, badServer.TLS.Clone()) + defer connection.Close() + _ = connection.Handshake() + }() + return func() { + _ = listener.Close() + <-done + } + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + const serviceIdentity = "spiffe://retained-export.example/export" + server, leaf, roots := newFrostRetainedGroupHistoryTLSTestServer( + t, + http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), + serviceIdentity, + ) + endpointURL, canonicalEndpoint, err := + validateFrostRetainedGroupTLSEndpoint(server.URL + "/") + if err != nil { + t.Fatal(err) + } + _, port, err := net.SplitHostPort(endpointURL.Host) + if err != nil { + t.Fatal(err) + } + firstListener, err := net.Listen( + "tcp6", + net.JoinHostPort("::1", port), + ) + if err != nil { + t.Fatal(err) + } + stopFirst := test.firstServer(t, firstListener) + defer stopFirst() + + addresses := []netip.Addr{ + netip.MustParseAddr("::1"), + netip.MustParseAddr("127.0.0.1"), + } + endpoint := frostRetainedGroupResolvedEndpoint{ + endpoint: endpointURL, + canonical: canonicalEndpoint, + canonicalDNSName: endpointURL.Hostname(), + resolvedDNSName: endpointURL.Hostname(), + addresses: addresses, + addressSetHash: frostRetainedGroupResolvedAddressSetHash( + addresses, + ), + } + identity := testFrostRetainedGroupCompleteIdentity().Export + identity.CanonicalEndpoint = endpoint.canonical + identity.CanonicalDNSName = endpoint.canonicalDNSName + identity.ResolvedDNSName = endpoint.resolvedDNSName + identity.ResolvedAddressSetHash = endpoint.addressSetHash + identity.TLSLeafSPKIHash = sha256.Sum256( + leaf.RawSubjectPublicKeyInfo, + ) + identity.ServiceIdentity = serviceIdentity + identity.EndpointFingerprint = + computeFrostRetainedGroupEndpointFingerprint(identity) + tlsConfig, err := newFrostRetainedGroupPinnedTLSConfig( + identity, + roots, + ) + if err != nil { + t.Fatal(err) + } + dial := frostRetainedGroupPinnedDialTLSContext( + endpoint, + tlsConfig, + 2*time.Second, + ) + ctx, cancel := context.WithTimeout( + context.Background(), + 2*time.Second, + ) + defer cancel() + + connection, err := dial(ctx, "tcp", endpointURL.Host) + if err != nil { + t.Fatalf( + "healthy pinned address was ignored after first replica failure: [%v]", + err, + ) + } + defer connection.Close() + remoteIP, err := frostRetainedGroupRemoteIP( + connection.RemoteAddr(), + ) + if err != nil || remoteIP != netip.MustParseAddr("127.0.0.1") { + t.Fatalf( + "TLS dial did not fail over to the healthy replica: [%v] [%v]", + remoteIP, + err, + ) + } + }) + } +} + func TestFrostRetainedGroupTLSExporterProtocolIDFrozen(t *testing.T) { const expected = "42e4447e7981f7691f5d7a0f93fa5500bbc3dda7e58438a9db475ae6c4e39b5c" protocolID := frostRetainedGroupTLSExporterProtocolID() From 28ce22bd5a2cb0d6a2ddb71831eaaeb8ccb8d795 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 13:19:37 -0400 Subject: [PATCH 44/93] fix(ethereum): cancel FROST limiter waits --- .../tbtc_frost_pre_sign_authorization.go | 61 ++++++++++++- ...st_pre_sign_authorization_verifier_test.go | 90 +++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index 0ecdd8532e..177df46b4b 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -19,6 +19,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" geth "github.com/ethereum/go-ethereum" @@ -779,7 +780,10 @@ func (reader *frostPreSignCanonicalHashReader) callContext( args ...interface{}, ) error { if reader.rpcLimiter != nil { - if err := reader.rpcLimiter.AcquirePermit(); err != nil { + if err := acquireFrostPreSignEthereumRPCPermit( + ctx, + reader.rpcLimiter, + ); err != nil { return fmt.Errorf("cannot acquire Ethereum RPC rate limiter permit: [%w]", err) } defer reader.rpcLimiter.ReleasePermit() @@ -787,6 +791,61 @@ func (reader *frostPreSignCanonicalHashReader) callContext( return reader.rpcClient.CallContext(ctx, result, method, args...) } +func acquireFrostPreSignEthereumRPCPermit( + ctx context.Context, + limiter interface { + AcquirePermit() error + ReleasePermit() + }, +) error { + if err := ctx.Err(); err != nil { + return err + } + + const ( + acquisitionPending uint32 = iota + acquisitionCanceled + acquisitionDelivered + ) + var acquisitionState atomic.Uint32 + acquisitionResult := make(chan error, 1) + go func() { + err := limiter.AcquirePermit() + if acquisitionState.CompareAndSwap( + acquisitionPending, + acquisitionDelivered, + ) { + acquisitionResult <- err + return + } + + // The caller stopped waiting before the legacy limiter completed. + // Return any permit acquired after cancellation instead of leaking it. + if err == nil { + limiter.ReleasePermit() + } + }() + + select { + case err := <-acquisitionResult: + return err + case <-ctx.Done(): + if acquisitionState.CompareAndSwap( + acquisitionPending, + acquisitionCanceled, + ) { + return ctx.Err() + } + + // Acquisition won the race with cancellation. Drain the delivered + // result and return a successfully acquired permit before exiting. + if err := <-acquisitionResult; err == nil { + limiter.ReleasePermit() + } + return ctx.Err() + } +} + func (reader *frostPreSignCanonicalHashReader) requestContext( ctx context.Context, ) (context.Context, context.CancelFunc) { diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go index 80c572b146..50ad7ab806 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go @@ -2,6 +2,7 @@ package ethereum import ( "context" + "errors" "fmt" "math/big" "testing" @@ -26,6 +27,22 @@ type testFrostPreSignEvidenceReader struct { receiptCall int } +type testBlockingEthereumRPCLimiter struct { + acquisitionStarted chan struct{} + allowAcquisition chan struct{} + permitReleased chan struct{} +} + +func (limiter *testBlockingEthereumRPCLimiter) AcquirePermit() error { + close(limiter.acquisitionStarted) + <-limiter.allowAcquisition + return nil +} + +func (limiter *testBlockingEthereumRPCLimiter) ReleasePermit() { + close(limiter.permitReleased) +} + func TestNewFrostPreSignPrimaryEthereumReaderAcceptsWrappedClient( t *testing.T, ) { @@ -50,6 +67,79 @@ func TestNewFrostPreSignPrimaryEthereumReaderAcceptsWrappedClient( } } +func TestFrostPrimaryEthereumRPCLimiterWaitHonorsContext(t *testing.T) { + for _, test := range []struct { + name string + requestTimeout time.Duration + cancelRequest bool + expectedError error + }{ + { + name: "caller cancellation", + cancelRequest: true, + expectedError: context.Canceled, + }, + { + name: "request timeout", + requestTimeout: 25 * time.Millisecond, + expectedError: context.DeadlineExceeded, + }, + } { + t.Run(test.name, func(t *testing.T) { + limiter := &testBlockingEthereumRPCLimiter{ + acquisitionStarted: make(chan struct{}), + allowAcquisition: make(chan struct{}), + permitReleased: make(chan struct{}), + } + reader := &frostPreSignCanonicalHashReader{ + requestTimeout: test.requestTimeout, + rpcLimiter: limiter, + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + result := make(chan error, 1) + go func() { + _, err := reader.CodeAtHash( + ctx, + common.HexToAddress( + "0x1111111111111111111111111111111111111111", + ), + common.HexToHash("0x1234"), + ) + result <- err + }() + + select { + case <-limiter.acquisitionStarted: + case <-time.After(time.Second): + t.Fatal("rate limiter acquisition did not start") + } + if test.cancelRequest { + cancel() + } + + select { + case err := <-result: + if !errors.Is(err, test.expectedError) { + t.Fatalf( + "unexpected rate limiter cancellation error: [%v]", + err, + ) + } + case <-time.After(time.Second): + t.Fatal("rate limiter wait ignored the request context") + } + + close(limiter.allowAcquisition) + select { + case <-limiter.permitReleased: + case <-time.After(time.Second): + t.Fatal("permit acquired after cancellation was not released") + } + }) + } +} + func TestFrostPrimaryEthereumRPCSharesConfiguredLimiter(t *testing.T) { server := rpc.NewServer() rpcClient := rpc.DialInProc(server) From 009c7272be8c040b34e3f5d1b1ce8408387ad640 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 13:45:19 -0400 Subject: [PATCH 45/93] fix(ethereum): make shared limiter context-aware --- pkg/chain/ethereum/ethereum.go | 13 +- pkg/chain/ethereum/ethereum_rpc_limiter.go | 329 ++++++++++++++++++ .../ethereum/ethereum_rpc_limiter_test.go | 56 +++ .../tbtc_frost_pre_sign_authorization.go | 81 +---- ...st_pre_sign_authorization_verifier_test.go | 103 +++++- 5 files changed, 498 insertions(+), 84 deletions(-) create mode 100644 pkg/chain/ethereum/ethereum_rpc_limiter.go create mode 100644 pkg/chain/ethereum/ethereum_rpc_limiter_test.go diff --git a/pkg/chain/ethereum/ethereum.go b/pkg/chain/ethereum/ethereum.go index 945580508d..414340bb8b 100644 --- a/pkg/chain/ethereum/ethereum.go +++ b/pkg/chain/ethereum/ethereum.go @@ -17,7 +17,6 @@ import ( "github.com/keep-network/keep-common/pkg/chain/ethereum" "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" - "github.com/keep-network/keep-common/pkg/rate" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/chain/ethereum/threshold/gen/contract" "github.com/keep-network/keep-core/pkg/maintainer" @@ -72,7 +71,7 @@ type EthereumClient interface { } type ethereumRPCLimiter interface { - AcquirePermit() error + AcquirePermit(context.Context) error ReleasePermit() } @@ -571,12 +570,12 @@ func wrapClientAddons( config.ConcurrencyLimit, ) - return ethutil.WrapRateLimiting( + return wrapEthereumClientWithRPCLimiter( loggingClient, - &rate.LimiterConfig{ - RequestsPerSecondLimit: config.RequestsPerSecondLimit, - ConcurrencyLimit: config.ConcurrencyLimit, - }, + newEthereumRPCLimiter( + config.RequestsPerSecondLimit, + config.ConcurrencyLimit, + ), ) } diff --git a/pkg/chain/ethereum/ethereum_rpc_limiter.go b/pkg/chain/ethereum/ethereum_rpc_limiter.go new file mode 100644 index 0000000000..a61d2b4c5c --- /dev/null +++ b/pkg/chain/ethereum/ethereum_rpc_limiter.go @@ -0,0 +1,329 @@ +package ethereum + +import ( + "context" + "fmt" + "math/big" + "time" + + geth "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" + "golang.org/x/sync/semaphore" + "golang.org/x/time/rate" +) + +const ethereumRPCAcquirePermitTimeout = 5 * time.Minute + +type contextAwareEthereumRPCLimiter struct { + requestRate *rate.Limiter + concurrency *semaphore.Weighted +} + +func newEthereumRPCLimiter( + requestsPerSecondLimit int, + concurrencyLimit int, +) *contextAwareEthereumRPCLimiter { + result := &contextAwareEthereumRPCLimiter{} + if requestsPerSecondLimit > 0 { + result.requestRate = rate.NewLimiter( + rate.Limit(requestsPerSecondLimit), + 1, + ) + } + if concurrencyLimit > 0 { + result.concurrency = semaphore.NewWeighted( + int64(concurrencyLimit), + ) + } + return result +} + +func (limiter *contextAwareEthereumRPCLimiter) AcquirePermit( + ctx context.Context, +) error { + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout( + ctx, + ethereumRPCAcquirePermitTimeout, + ) + defer cancel() + + if limiter.requestRate != nil { + if err := limiter.requestRate.Wait(ctx); err != nil { + return err + } + } + if limiter.concurrency != nil { + if err := limiter.concurrency.Acquire(ctx, 1); err != nil { + return err + } + } + return nil +} + +func (limiter *contextAwareEthereumRPCLimiter) ReleasePermit() { + if limiter.concurrency != nil { + limiter.concurrency.Release(1) + } +} + +type ethereumRPCLimitingClient struct { + ethutil.EthereumClient + limiter *contextAwareEthereumRPCLimiter +} + +func wrapEthereumClientWithRPCLimiter( + client ethutil.EthereumClient, + limiter *contextAwareEthereumRPCLimiter, +) ethutil.EthereumClient { + return ðereumRPCLimitingClient{ + EthereumClient: client, + limiter: limiter, + } +} + +func (client *ethereumRPCLimitingClient) AcquirePermit( + ctx context.Context, +) error { + return client.limiter.AcquirePermit(ctx) +} + +func (client *ethereumRPCLimitingClient) ReleasePermit() { + client.limiter.ReleasePermit() +} + +func (client *ethereumRPCLimitingClient) acquirePermit( + ctx context.Context, +) error { + if err := client.limiter.AcquirePermit(ctx); err != nil { + return fmt.Errorf("cannot acquire rate limiter permit: [%w]", err) + } + return nil +} + +func (client *ethereumRPCLimitingClient) CodeAt( + ctx context.Context, + contract common.Address, + blockNumber *big.Int, +) ([]byte, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.CodeAt(ctx, contract, blockNumber) +} + +func (client *ethereumRPCLimitingClient) CallContract( + ctx context.Context, + call geth.CallMsg, + blockNumber *big.Int, +) ([]byte, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.CallContract(ctx, call, blockNumber) +} + +func (client *ethereumRPCLimitingClient) PendingCodeAt( + ctx context.Context, + account common.Address, +) ([]byte, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.PendingCodeAt(ctx, account) +} + +func (client *ethereumRPCLimitingClient) PendingNonceAt( + ctx context.Context, + account common.Address, +) (uint64, error) { + if err := client.acquirePermit(ctx); err != nil { + return 0, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.PendingNonceAt(ctx, account) +} + +func (client *ethereumRPCLimitingClient) SuggestGasPrice( + ctx context.Context, +) (*big.Int, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.SuggestGasPrice(ctx) +} + +func (client *ethereumRPCLimitingClient) SuggestGasTipCap( + ctx context.Context, +) (*big.Int, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.SuggestGasTipCap(ctx) +} + +func (client *ethereumRPCLimitingClient) EstimateGas( + ctx context.Context, + call geth.CallMsg, +) (uint64, error) { + if err := client.acquirePermit(ctx); err != nil { + return 0, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.EstimateGas(ctx, call) +} + +func (client *ethereumRPCLimitingClient) SendTransaction( + ctx context.Context, + transaction *types.Transaction, +) error { + if err := client.acquirePermit(ctx); err != nil { + return err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.SendTransaction(ctx, transaction) +} + +func (client *ethereumRPCLimitingClient) FilterLogs( + ctx context.Context, + query geth.FilterQuery, +) ([]types.Log, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.FilterLogs(ctx, query) +} + +func (client *ethereumRPCLimitingClient) SubscribeFilterLogs( + ctx context.Context, + query geth.FilterQuery, + channel chan<- types.Log, +) (geth.Subscription, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.SubscribeFilterLogs(ctx, query, channel) +} + +func (client *ethereumRPCLimitingClient) BlockByHash( + ctx context.Context, + hash common.Hash, +) (*types.Block, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.BlockByHash(ctx, hash) +} + +func (client *ethereumRPCLimitingClient) BlockByNumber( + ctx context.Context, + number *big.Int, +) (*types.Block, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.BlockByNumber(ctx, number) +} + +func (client *ethereumRPCLimitingClient) HeaderByHash( + ctx context.Context, + hash common.Hash, +) (*types.Header, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.HeaderByHash(ctx, hash) +} + +func (client *ethereumRPCLimitingClient) HeaderByNumber( + ctx context.Context, + number *big.Int, +) (*types.Header, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.HeaderByNumber(ctx, number) +} + +func (client *ethereumRPCLimitingClient) TransactionCount( + ctx context.Context, + blockHash common.Hash, +) (uint, error) { + if err := client.acquirePermit(ctx); err != nil { + return 0, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.TransactionCount(ctx, blockHash) +} + +func (client *ethereumRPCLimitingClient) TransactionInBlock( + ctx context.Context, + blockHash common.Hash, + index uint, +) (*types.Transaction, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.TransactionInBlock(ctx, blockHash, index) +} + +func (client *ethereumRPCLimitingClient) SubscribeNewHead( + ctx context.Context, + channel chan<- *types.Header, +) (geth.Subscription, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.SubscribeNewHead(ctx, channel) +} + +func (client *ethereumRPCLimitingClient) TransactionByHash( + ctx context.Context, + transactionHash common.Hash, +) (*types.Transaction, bool, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, false, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.TransactionByHash(ctx, transactionHash) +} + +func (client *ethereumRPCLimitingClient) TransactionReceipt( + ctx context.Context, + transactionHash common.Hash, +) (*types.Receipt, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.TransactionReceipt(ctx, transactionHash) +} + +func (client *ethereumRPCLimitingClient) BalanceAt( + ctx context.Context, + account common.Address, + blockNumber *big.Int, +) (*big.Int, error) { + if err := client.acquirePermit(ctx); err != nil { + return nil, err + } + defer client.limiter.ReleasePermit() + return client.EthereumClient.BalanceAt(ctx, account, blockNumber) +} diff --git a/pkg/chain/ethereum/ethereum_rpc_limiter_test.go b/pkg/chain/ethereum/ethereum_rpc_limiter_test.go new file mode 100644 index 0000000000..cac55476c7 --- /dev/null +++ b/pkg/chain/ethereum/ethereum_rpc_limiter_test.go @@ -0,0 +1,56 @@ +package ethereum + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestEthereumRPCLimiterRateWaitHonorsCancellation(t *testing.T) { + limiter := newEthereumRPCLimiter(1, 0) + if err := limiter.AcquirePermit(context.Background()); err != nil { + t.Fatal(err) + } + limiter.ReleasePermit() + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + result <- limiter.AcquirePermit(ctx) + }() + time.AfterFunc(25*time.Millisecond, cancel) + + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("unexpected canceled rate wait error: [%v]", err) + } + case <-time.After(time.Second): + t.Fatal("request-rate wait ignored context cancellation") + } +} + +func TestEthereumRPCLimiterConcurrencyWaitHonorsCancellation(t *testing.T) { + limiter := newEthereumRPCLimiter(0, 1) + if err := limiter.AcquirePermit(context.Background()); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := limiter.AcquirePermit(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("unexpected canceled concurrency wait error: [%v]", err) + } + + limiter.ReleasePermit() + resumeCtx, resumeCancel := context.WithTimeout( + context.Background(), + time.Second, + ) + defer resumeCancel() + if err := limiter.AcquirePermit(resumeCtx); err != nil { + t.Fatalf("canceled waiter retained limiter capacity: [%v]", err) + } + limiter.ReleasePermit() +} diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index 177df46b4b..5846f45258 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -19,7 +19,6 @@ import ( "sort" "strings" "sync" - "sync/atomic" "time" geth "github.com/ethereum/go-ethereum" @@ -659,10 +658,7 @@ type frostPreSignCanonicalHashReader struct { rpcClient *rpc.Client chainID *big.Int requestTimeout time.Duration - rpcLimiter interface { - AcquirePermit() error - ReleasePermit() - } + rpcLimiter ethereumRPCLimiter } func (reader *frostPreSignCanonicalHashReader) ChainID( @@ -681,6 +677,8 @@ func (reader *frostPreSignCanonicalHashReader) HeaderByNumber( if reader.standardReader == nil { return nil, fmt.Errorf("standard Ethereum reader is unavailable") } + ctx, cancel := reader.requestContext(ctx) + defer cancel() return reader.standardReader.HeaderByNumber(ctx, number) } @@ -692,8 +690,12 @@ func (reader *frostPreSignCanonicalHashReader) HeaderByHash( if reader.headerReader == nil { return nil, fmt.Errorf("exact-hash Ethereum reader is unavailable") } + ctx, cancel := reader.requestContext(ctx) + defer cancel() return reader.headerReader.HeaderByHash(ctx, blockHash) } + ctx, cancel := reader.requestContext(ctx) + defer cancel() return reader.standardReader.HeaderByHash(ctx, blockHash) } @@ -704,6 +706,8 @@ func (reader *frostPreSignCanonicalHashReader) TransactionReceipt( if reader.standardReader == nil { return nil, fmt.Errorf("standard Ethereum reader is unavailable") } + ctx, cancel := reader.requestContext(ctx) + defer cancel() return reader.standardReader.TransactionReceipt(ctx, transactionHash) } @@ -714,6 +718,8 @@ func (reader *frostPreSignCanonicalHashReader) FilterLogs( if reader.standardReader == nil { return nil, fmt.Errorf("standard Ethereum reader is unavailable") } + ctx, cancel := reader.requestContext(ctx) + defer cancel() return reader.standardReader.FilterLogs(ctx, query) } @@ -780,10 +786,7 @@ func (reader *frostPreSignCanonicalHashReader) callContext( args ...interface{}, ) error { if reader.rpcLimiter != nil { - if err := acquireFrostPreSignEthereumRPCPermit( - ctx, - reader.rpcLimiter, - ); err != nil { + if err := reader.rpcLimiter.AcquirePermit(ctx); err != nil { return fmt.Errorf("cannot acquire Ethereum RPC rate limiter permit: [%w]", err) } defer reader.rpcLimiter.ReleasePermit() @@ -791,61 +794,6 @@ func (reader *frostPreSignCanonicalHashReader) callContext( return reader.rpcClient.CallContext(ctx, result, method, args...) } -func acquireFrostPreSignEthereumRPCPermit( - ctx context.Context, - limiter interface { - AcquirePermit() error - ReleasePermit() - }, -) error { - if err := ctx.Err(); err != nil { - return err - } - - const ( - acquisitionPending uint32 = iota - acquisitionCanceled - acquisitionDelivered - ) - var acquisitionState atomic.Uint32 - acquisitionResult := make(chan error, 1) - go func() { - err := limiter.AcquirePermit() - if acquisitionState.CompareAndSwap( - acquisitionPending, - acquisitionDelivered, - ) { - acquisitionResult <- err - return - } - - // The caller stopped waiting before the legacy limiter completed. - // Return any permit acquired after cancellation instead of leaking it. - if err == nil { - limiter.ReleasePermit() - } - }() - - select { - case err := <-acquisitionResult: - return err - case <-ctx.Done(): - if acquisitionState.CompareAndSwap( - acquisitionPending, - acquisitionCanceled, - ) { - return ctx.Err() - } - - // Acquisition won the race with cancellation. Drain the delivered - // result and return a successfully acquired permit before exiting. - if err := <-acquisitionResult; err == nil { - limiter.ReleasePermit() - } - return ctx.Err() - } -} - func (reader *frostPreSignCanonicalHashReader) requestContext( ctx context.Context, ) (context.Context, context.CancelFunc) { @@ -908,10 +856,7 @@ func newFrostPreSignPrimaryEthereumReader( rpcClient *rpc.Client, chainID *big.Int, requestTimeout time.Duration, - rpcLimiter interface { - AcquirePermit() error - ReleasePermit() - }, + rpcLimiter ethereumRPCLimiter, ) (tbtc.FrostPreSignEthereumEvidenceVerifier, error) { standardReader, ok := client.(frostPreSignStandardEthereumReader) if !ok { diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go index 50ad7ab806..9162d751f6 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go @@ -29,14 +29,17 @@ type testFrostPreSignEvidenceReader struct { type testBlockingEthereumRPCLimiter struct { acquisitionStarted chan struct{} - allowAcquisition chan struct{} + acquisitionStopped chan struct{} permitReleased chan struct{} } -func (limiter *testBlockingEthereumRPCLimiter) AcquirePermit() error { +func (limiter *testBlockingEthereumRPCLimiter) AcquirePermit( + ctx context.Context, +) error { close(limiter.acquisitionStarted) - <-limiter.allowAcquisition - return nil + defer close(limiter.acquisitionStopped) + <-ctx.Done() + return ctx.Err() } func (limiter *testBlockingEthereumRPCLimiter) ReleasePermit() { @@ -88,7 +91,7 @@ func TestFrostPrimaryEthereumRPCLimiterWaitHonorsContext(t *testing.T) { t.Run(test.name, func(t *testing.T) { limiter := &testBlockingEthereumRPCLimiter{ acquisitionStarted: make(chan struct{}), - allowAcquisition: make(chan struct{}), + acquisitionStopped: make(chan struct{}), permitReleased: make(chan struct{}), } reader := &frostPreSignCanonicalHashReader{ @@ -130,11 +133,93 @@ func TestFrostPrimaryEthereumRPCLimiterWaitHonorsContext(t *testing.T) { t.Fatal("rate limiter wait ignored the request context") } - close(limiter.allowAcquisition) select { - case <-limiter.permitReleased: + case <-limiter.acquisitionStopped: case <-time.After(time.Second): - t.Fatal("permit acquired after cancellation was not released") + t.Fatal("underlying rate limiter acquisition was not canceled") + } + select { + case <-limiter.permitReleased: + t.Fatal("unacquired rate limiter permit was released") + default: + } + }) + } +} + +func TestFrostPrimaryEthereumStandardReadsHonorRequestTimeout(t *testing.T) { + server := rpc.NewServer() + rpcClient := rpc.DialInProc(server) + defer rpcClient.Close() + client := ethclient.NewClient(rpcClient) + config := ethereumConfig.Config{ConcurrencyLimit: 1} + wrapped := wrapClientAddons(config, client) + limiter, err := sharedEthereumRPCLimiter(config, wrapped) + if err != nil { + t.Fatal(err) + } + if err := limiter.AcquirePermit(context.Background()); err != nil { + t.Fatal(err) + } + defer limiter.ReleasePermit() + + reader := &frostPreSignCanonicalHashReader{ + standardReader: wrapped, + requestTimeout: 25 * time.Millisecond, + } + for _, test := range []struct { + name string + read func() error + }{ + { + name: "header by number", + read: func() error { + _, err := reader.HeaderByNumber(context.Background(), nil) + return err + }, + }, + { + name: "header by hash", + read: func() error { + _, err := reader.HeaderByHash( + context.Background(), + common.HexToHash("0x1234"), + ) + return err + }, + }, + { + name: "transaction receipt", + read: func() error { + _, err := reader.TransactionReceipt( + context.Background(), + common.HexToHash("0x1234"), + ) + return err + }, + }, + { + name: "filter logs", + read: func() error { + _, err := reader.FilterLogs( + context.Background(), + geth.FilterQuery{}, + ) + return err + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + startedAt := time.Now() + err := test.read() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("unexpected standard read error: [%v]", err) + } + if elapsed := time.Since(startedAt); elapsed > time.Second { + t.Fatalf( + "standard read exceeded its request timeout: [%v]", + elapsed, + ) } }) } @@ -154,7 +239,7 @@ func TestFrostPrimaryEthereumRPCSharesConfiguredLimiter(t *testing.T) { if limiter == nil { t.Fatal("shared Ethereum RPC limiter is nil") } - if err := limiter.AcquirePermit(); err != nil { + if err := limiter.AcquirePermit(context.Background()); err != nil { t.Fatal(err) } permitHeld := true From 7f3f182bc317497e348df3d1b7f3e34e5f69793c Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 14:31:56 -0400 Subject: [PATCH 46/93] fix(tbtc): admit DKG before readiness --- pkg/tbtc/frost_dkg_execution_frost_native.go | 127 +++++++++++++----- .../frost_dkg_execution_frost_native_test.go | 103 ++++++++++++++ 2 files changed, 198 insertions(+), 32 deletions(-) diff --git a/pkg/tbtc/frost_dkg_execution_frost_native.go b/pkg/tbtc/frost_dkg_execution_frost_native.go index 02e3fe5840..bd72848420 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native.go @@ -121,17 +121,30 @@ func executeFrostDKGIfPossible( sessionID := fmt.Sprintf("%s-%s", channelName, "attempt-1") - // Capture DKG round messages off the channel BEFORE the readiness barrier below. - // announceFrostDKGReadiness releases every peer once the quorum announces, but a - // node installs its DKG receiver only later, inside executeDistributedFrostDKG. - // A peer released ahead of a slower node can broadcast round-1 before that node - // is receiving; the transport would drop it (no subscriber) and not retransmit, - // stalling the DKG. The prebuffer catches those from before the barrier so they - // are replayed once the receiver is up. - dkgPrebuffer := frostsigning.StartDKGMessagePrebuffer(dkgCtx, channel) - - activeMemberIndexes, misbehavedMembersIndices, err := - announceFrostDKGReadiness( + var dkgPrebuffer *frostsigning.DKGMessagePrebuffer + announceReadiness := func() ( + []group.MemberIndex, + registry.MisbehavedMemberIndices, + error, + ) { + // Capture DKG round messages off the channel BEFORE the + // readiness barrier below. The reservation is deliberately + // acquired before this callback, so no local seat can announce + // readiness unless worst-case anchor capacity is already held. + // + // announceFrostDKGReadiness releases every peer once the + // quorum announces, but a node installs its DKG receiver only + // later, inside executeDistributedFrostDKG. A peer released + // ahead of a slower node can broadcast round-1 before that + // node is receiving; the transport would drop it (no + // subscriber) and not retransmit, stalling the DKG. The + // prebuffer catches those messages so they are replayed once + // the receiver is up. + dkgPrebuffer = frostsigning.StartDKGMessagePrebuffer( + dkgCtx, + channel, + ) + return announceFrostDKGReadiness( dkgCtx, node, channel, @@ -141,10 +154,21 @@ func executeFrostDKGIfPossible( memberIndexes, len(groupSelectionResult.OperatorsIDs), ) + } + readinessAdmission, err := reserveAndAnnounceFrostDKGReadiness( + dkgCtx, + node.frostNativeSignerAnchorAdmission, + memberIndexes, + announceReadiness, + ) if err != nil { - dkgLogger.Errorf("FROST DKG readiness announcement failed: [%v]", err) + dkgLogger.Errorf("FROST DKG admission or readiness failed: [%v]", err) return } + defer readinessAdmission.anchorReservation.Release() + activeMemberIndexes := readinessAdmission.activeMemberIndexes + misbehavedMembersIndices := + readinessAdmission.misbehavedMembersIndices localActiveMemberIndexes := localActiveFrostMemberIndexes( memberIndexes, @@ -162,26 +186,6 @@ func executeFrostDKGIfPossible( ) return } - if node.frostNativeSignerAnchorAdmission == nil { - dkgLogger.Errorf( - "FROST DKG is blocked: native signer anchor admission is unavailable", - ) - return - } - anchorReservation, err := - node.frostNativeSignerAnchorAdmission.reserveDKG( - dkgCtx, - uint64(len(localActiveMemberIndexes)), - ) - if err != nil { - dkgLogger.Errorf( - "FROST DKG is blocked by native signer anchor admission: [%v]", - err, - ) - return - } - defer anchorReservation.Release() - tbtcSignerMemberIndexes, err := finalFrostDKGMemberIndexes( activeMemberIndexes, groupSelectionResult, @@ -281,6 +285,65 @@ func executeFrostDKGIfPossible( return true } +type frostDKGReadinessAdmission struct { + anchorReservation *frostNativeSignerAnchorRevisionReservation + activeMemberIndexes []group.MemberIndex + misbehavedMembersIndices registry.MisbehavedMemberIndices +} + +func reserveAndAnnounceFrostDKGReadiness( + ctx context.Context, + anchorAdmission *frostNativeSignerAnchorAdmissionController, + localMemberIndexes []group.MemberIndex, + announce func() ( + []group.MemberIndex, + registry.MisbehavedMemberIndices, + error, + ), +) ( + *frostDKGReadinessAdmission, + error, +) { + if anchorAdmission == nil { + return nil, fmt.Errorf( + "native signer anchor admission is unavailable", + ) + } + if announce == nil { + return nil, fmt.Errorf( + "FROST DKG readiness announcement is unavailable", + ) + } + + // Every selected local seat may become active once readiness is announced. + // Reserve that worst-case persistence cost before invoking the announcer. + anchorReservation, err := anchorAdmission.reserveDKG( + ctx, + uint64(len(localMemberIndexes)), + ) + if err != nil { + return nil, fmt.Errorf( + "native signer anchor admission failed: [%w]", + err, + ) + } + + activeMemberIndexes, misbehavedMembersIndices, err := announce() + if err != nil { + anchorReservation.Release() + return nil, fmt.Errorf( + "readiness announcement failed: [%w]", + err, + ) + } + + return &frostDKGReadinessAdmission{ + anchorReservation: anchorReservation, + activeMemberIndexes: activeMemberIndexes, + misbehavedMembersIndices: misbehavedMembersIndices, + }, nil +} + type frostDKGExecutionResult struct { outputKey frost.OutputKey signerMaterial *frostsigning.NativeSignerMaterial diff --git a/pkg/tbtc/frost_dkg_execution_frost_native_test.go b/pkg/tbtc/frost_dkg_execution_frost_native_test.go index c52cb9ed4d..9ecfb1a8a5 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/hex" "math/big" + "strings" "testing" "github.com/keep-network/keep-core/pkg/chain" @@ -15,6 +16,108 @@ import ( "github.com/keep-network/keep-core/pkg/protocol/group" ) +func TestReserveAndAnnounceFrostDKGReadiness_AdmissionFailureDoesNotAnnounce( + t *testing.T, +) { + controller := &frostNativeSignerAnchorAdmissionController{ + readHeadroom: func( + context.Context, + ) (frostNativeSignerAnchorCapacity, error) { + return frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorRotationWarningHeadroom + 1, + Generations: FrostNativeSignerAnchorRotationWarningHeadroom + + 1, + }, nil + }, + reserved: frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorRotationWarningHeadroom, + Generations: FrostNativeSignerAnchorRotationWarningHeadroom, + }, + } + announced := false + + admission, err := reserveAndAnnounceFrostDKGReadiness( + context.Background(), + controller, + []group.MemberIndex{1, 2}, + func() ( + []group.MemberIndex, + registry.MisbehavedMemberIndices, + error, + ) { + announced = true + return nil, nil, nil + }, + ) + if err == nil || !strings.Contains(err.Error(), "unreserved") { + t.Fatalf("unexpected DKG admission result: [%v]", err) + } + if admission != nil { + t.Fatal("failed DKG admission returned a reservation") + } + if announced { + t.Fatal("DKG readiness was announced after admission failed") + } +} + +func TestReserveAndAnnounceFrostDKGReadiness_ReservesEverySelectedLocalSeat( + t *testing.T, +) { + controller := &frostNativeSignerAnchorAdmissionController{ + readHeadroom: func( + context.Context, + ) (frostNativeSignerAnchorCapacity, error) { + return frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorRotationWarningHeadroom + 10, + Generations: FrostNativeSignerAnchorRotationWarningHeadroom + + 10, + }, nil + }, + } + localMemberIndexes := []group.MemberIndex{2, 4, 6} + + admission, err := + reserveAndAnnounceFrostDKGReadiness( + context.Background(), + controller, + localMemberIndexes, + func() ( + []group.MemberIndex, + registry.MisbehavedMemberIndices, + error, + ) { + return []group.MemberIndex{1, 2}, nil, nil + }, + ) + if err != nil { + t.Fatal(err) + } + if admission == nil || admission.anchorReservation == nil { + t.Fatal("successful DKG admission returned no reservation") + } + if len(admission.activeMemberIndexes) != 2 { + t.Fatalf( + "unexpected active member indexes: [%v]", + admission.activeMemberIndexes, + ) + } + if controller.reserved.Revisions != uint64(len(localMemberIndexes)) || + controller.reserved.Generations != uint64(len(localMemberIndexes)) { + t.Fatalf( + "DKG did not reserve every selected local seat: [%+v]", + controller.reserved, + ) + } + + admission.anchorReservation.Release() + if controller.reserved != (frostNativeSignerAnchorCapacity{}) { + t.Fatalf( + "released DKG reservation remained charged: [%+v]", + controller.reserved, + ) + } +} + func TestExecuteFrostDKGIfPossible_RequiresRoastRetryReadiness(t *testing.T) { t.Setenv(frostsigning.InteractiveSigningOptInEnvVar, "true") t.Setenv(frostsigning.RoastRetryReadinessOptInEnvVar, "") From 853e0826428fe2ed1e80d201862d0eb2a9f1cb59 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 14:56:43 -0400 Subject: [PATCH 47/93] fix(tbtc): fail over primary TLS profiles --- pkg/tbtc/frost_primary_ethereum_transport.go | 3 +- .../frost_primary_ethereum_transport_test.go | 72 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/pkg/tbtc/frost_primary_ethereum_transport.go b/pkg/tbtc/frost_primary_ethereum_transport.go index 2243ff40cb..0f35c9087a 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport.go +++ b/pkg/tbtc/frost_primary_ethereum_transport.go @@ -436,7 +436,8 @@ func (transport *FrostPrimaryEthereumTransport) dialTLSContext( transport.endpoint, ); verifyErr != nil { _ = tlsConnection.Close() - return nil, verifyErr + lastErr = verifyErr + continue } peer, peerErr := frostTransportPeerIdentityFromTLS( transport.endpoint, diff --git a/pkg/tbtc/frost_primary_ethereum_transport_test.go b/pkg/tbtc/frost_primary_ethereum_transport_test.go index c4fa850742..4041be290b 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport_test.go +++ b/pkg/tbtc/frost_primary_ethereum_transport_test.go @@ -3,6 +3,7 @@ package tbtc import ( "context" "crypto/sha256" + "crypto/tls" "errors" "fmt" "math/big" @@ -255,6 +256,77 @@ func TestFrostPrimaryEthereumTransportTriesEveryPinnedAddressWithinDeadline( } } +func TestFrostPrimaryEthereumTransportTriesNextAddressAfterTLSProfileRejection( + t *testing.T, +) { + server, _, roots := newFrostRetainedGroupHistoryTLSTestServer( + t, + http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), + "spiffe://primary.example/rpc", + ) + endpoint, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + _, port, err := net.SplitHostPort(endpoint.Host) + if err != nil { + t.Fatal(err) + } + + profileMismatchListener, err := net.Listen( + "tcp6", + net.JoinHostPort("::1", port), + ) + if err != nil { + t.Fatal(err) + } + profileMismatchDone := make(chan struct{}) + go func() { + defer close(profileMismatchDone) + raw, acceptErr := profileMismatchListener.Accept() + if acceptErr != nil { + return + } + serverTLSConfig := server.TLS.Clone() + serverTLSConfig.NextProtos = nil + connection := tls.Server(raw, serverTLSConfig) + defer connection.Close() + _ = connection.Handshake() + }() + t.Cleanup(func() { + _ = profileMismatchListener.Close() + <-profileMismatchDone + }) + + transport := &FrostPrimaryEthereumTransport{ + endpoint: frostRetainedGroupResolvedEndpoint{ + endpoint: endpoint, + canonical: endpoint.String(), + addresses: []netip.Addr{ + netip.MustParseAddr("::1"), + netip.MustParseAddr("127.0.0.1"), + }, + }, + timeout: time.Second, + rootCAs: roots, + seenPeers: make(map[[32]byte]frostTransportPeerIdentity), + livePeers: make(map[[32]byte]uint64), + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + connection, err := transport.dialTLSContext(ctx, "tcp", endpoint.Host) + if err != nil { + t.Fatalf( + "healthy pinned address was ignored after TLS profile rejection: [%v]", + err, + ) + } + if err := connection.Close(); err != nil { + t.Fatal(err) + } +} + func TestFrostPrimaryEthereumTransportWSSAppliesRequestTimeout( t *testing.T, ) { From 3b7fa2f55bc170227ee8b64c0cb11fb0a8229e32 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 15:41:30 -0400 Subject: [PATCH 48/93] fix(bitcoin): add canonical Electrum status --- .../electrum/canonical_transaction_status.go | 305 ++++++++++++++++++ .../canonical_transaction_status_test.go | 232 +++++++++++++ 2 files changed, 537 insertions(+) create mode 100644 pkg/bitcoin/electrum/canonical_transaction_status.go create mode 100644 pkg/bitcoin/electrum/canonical_transaction_status_test.go diff --git a/pkg/bitcoin/electrum/canonical_transaction_status.go b/pkg/bitcoin/electrum/canonical_transaction_status.go new file mode 100644 index 0000000000..ef302018bd --- /dev/null +++ b/pkg/bitcoin/electrum/canonical_transaction_status.go @@ -0,0 +1,305 @@ +package electrum + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + + electrumclient "github.com/checksum0/go-electrum/electrum" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/internal/byteutils" +) + +var _ bitcoin.CanonicalTransactionStatusSource = (*Connection)(nil) + +type canonicalTransactionStatusReader interface { + canonicalRawTransaction(string) (string, bool, error) + canonicalScriptHistory([]byte) ([]*electrumclient.GetMempoolResult, error) + GetLatestBlockHeight() (uint, error) + GetBlockHeader(uint) (*bitcoin.BlockHeader, error) + GetTransactionMerkleProof( + bitcoin.Hash, + uint, + ) (*bitcoin.TransactionMerkleProof, error) +} + +// GetCanonicalTransactionStatus returns the transaction's current observation +// from Electrum's canonical transaction and script indexes. Confirmed results +// are bound to a block only after their Merkle branch matches that block's +// header. A successful transaction-not-found response is distinct from an RPC +// or index inconsistency and is the only case that returns Found=false. +func (c *Connection) GetCanonicalTransactionStatus( + transactionHash bitcoin.Hash, +) (*bitcoin.CanonicalTransactionStatus, error) { + return canonicalTransactionStatus(c, transactionHash) +} + +func canonicalTransactionStatus( + reader canonicalTransactionStatusReader, + transactionHash bitcoin.Hash, +) (*bitcoin.CanonicalTransactionStatus, error) { + if reader == nil { + return nil, fmt.Errorf("canonical Electrum transaction reader is nil") + } + txID := transactionHash.Hex(bitcoin.ReversedByteOrder) + rawTransaction, found, err := reader.canonicalRawTransaction(txID) + if err != nil { + return nil, fmt.Errorf( + "failed to get canonical raw transaction with ID [%s]: [%w]", + txID, + err, + ) + } + if !found { + return &bitcoin.CanonicalTransactionStatus{Found: false}, nil + } + transaction, err := convertRawTransaction(rawTransaction) + if err != nil { + return nil, fmt.Errorf( + "failed to decode canonical raw transaction with ID [%s]: [%w]", + txID, + err, + ) + } + if actualHash := transaction.Hash(); actualHash != transactionHash { + return nil, fmt.Errorf( + "canonical raw transaction hash mismatch: expected [%s], got [%s]", + txID, + actualHash.Hex(bitcoin.ReversedByteOrder), + ) + } + + blockHeight, err := canonicalTransactionBlockHeight( + reader, + txID, + transaction, + ) + if err != nil { + return nil, err + } + if blockHeight == 0 { + return &bitcoin.CanonicalTransactionStatus{Found: true}, nil + } + + header, err := reader.GetBlockHeader(blockHeight) + if err != nil { + return nil, fmt.Errorf( + "failed to get canonical block header at height [%d]: [%w]", + blockHeight, + err, + ) + } + if header == nil { + return nil, fmt.Errorf( + "canonical block header at height [%d] is nil", + blockHeight, + ) + } + proof, err := reader.GetTransactionMerkleProof( + transactionHash, + blockHeight, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get canonical transaction Merkle proof: [%w]", + err, + ) + } + if err := verifyCanonicalTransactionMerkleProof( + transactionHash, + blockHeight, + proof, + header.MerkleRootHash, + ); err != nil { + return nil, err + } + latestBlockHeight, err := reader.GetLatestBlockHeight() + if err != nil { + return nil, fmt.Errorf( + "failed to get canonical Bitcoin tip: [%w]", + err, + ) + } + if latestBlockHeight < blockHeight { + return nil, fmt.Errorf( + "canonical transaction height [%d] exceeds tip [%d]", + blockHeight, + latestBlockHeight, + ) + } + serializedHeader := header.Serialize() + + return &bitcoin.CanonicalTransactionStatus{ + Found: true, + Confirmations: latestBlockHeight - blockHeight + 1, + BlockHeight: blockHeight, + BlockHash: bitcoin.ComputeHash(serializedHeader[:]), + }, nil +} + +func (c *Connection) canonicalRawTransaction( + txID string, +) (string, bool, error) { + type result struct { + raw string + found bool + } + observation, err := requestWithRetry( + c, + func( + ctx context.Context, + client *electrumclient.Client, + ) (result, error) { + rawTransaction, err := client.GetRawTransaction(ctx, txID) + if err != nil { + if isTxNotFoundErr(err) { + return result{}, nil + } + return result{}, err + } + if rawTransaction == "" { + return result{}, fmt.Errorf( + "Electrum returned an empty raw transaction", + ) + } + return result{raw: rawTransaction, found: true}, nil + }, + "GetCanonicalRawTransaction", + ) + if err != nil { + return "", false, err + } + return observation.raw, observation.found, nil +} + +func (c *Connection) canonicalScriptHistory( + script []byte, +) ([]*electrumclient.GetMempoolResult, error) { + scriptHash := sha256.Sum256(script) + reversedScriptHash := byteutils.Reverse(scriptHash[:]) + return requestWithRetry( + c, + func( + ctx context.Context, + client *electrumclient.Client, + ) ([]*electrumclient.GetMempoolResult, error) { + return client.GetHistory( + ctx, + hex.EncodeToString(reversedScriptHash), + ) + }, + "GetCanonicalScriptHistory", + ) +} + +func canonicalTransactionBlockHeight( + reader canonicalTransactionStatusReader, + txID string, + transaction *bitcoin.Transaction, +) (uint, error) { + if transaction == nil || len(transaction.Outputs) == 0 { + return 0, fmt.Errorf( + "canonical transaction [%s] has no indexed outputs", + txID, + ) + } + seenScripts := make(map[string]bool) + for _, output := range transaction.Outputs { + if output == nil { + return 0, fmt.Errorf( + "canonical transaction [%s] has a nil output", + txID, + ) + } + scriptKey := string(output.PublicKeyScript) + if seenScripts[scriptKey] { + continue + } + seenScripts[scriptKey] = true + history, err := reader.canonicalScriptHistory( + output.PublicKeyScript, + ) + if err != nil { + return 0, fmt.Errorf( + "failed to get canonical script history for transaction [%s]: [%w]", + txID, + err, + ) + } + matched := false + var matchedHeight uint + for _, item := range history { + if item == nil || item.Hash != txID { + continue + } + height := uint(0) + if item.Height > 0 { + height = uint(item.Height) + } + if matched && matchedHeight != height { + return 0, fmt.Errorf( + "canonical transaction [%s] has inconsistent block heights [%d/%d]", + txID, + matchedHeight, + height, + ) + } + matched = true + matchedHeight = height + } + if matched { + // A matching indexed output establishes the candidate height. + // Confirmed candidates are independently checked against the block + // header and Merkle proof by canonicalTransactionStatus. + return matchedHeight, nil + } + } + return 0, fmt.Errorf( + "canonical transaction [%s] is missing from all output-script histories", + txID, + ) +} + +func verifyCanonicalTransactionMerkleProof( + transactionHash bitcoin.Hash, + blockHeight uint, + proof *bitcoin.TransactionMerkleProof, + expectedRoot bitcoin.Hash, +) error { + if proof == nil || proof.BlockHeight != blockHeight { + return fmt.Errorf( + "canonical transaction Merkle proof has an unexpected block height", + ) + } + current := transactionHash + position := proof.Position + for _, node := range proof.MerkleNodes { + sibling, err := bitcoin.NewHashFromString( + node, + bitcoin.ReversedByteOrder, + ) + if err != nil { + return fmt.Errorf( + "canonical transaction Merkle proof contains an invalid node: [%w]", + err, + ) + } + var pair [2 * bitcoin.HashByteLength]byte + if position&1 == 0 { + copy(pair[:bitcoin.HashByteLength], current[:]) + copy(pair[bitcoin.HashByteLength:], sibling[:]) + } else { + copy(pair[:bitcoin.HashByteLength], sibling[:]) + copy(pair[bitcoin.HashByteLength:], current[:]) + } + current = bitcoin.ComputeHash(pair[:]) + position >>= 1 + } + if position != 0 || current != expectedRoot { + return fmt.Errorf( + "canonical transaction Merkle proof does not match the block header", + ) + } + return nil +} diff --git a/pkg/bitcoin/electrum/canonical_transaction_status_test.go b/pkg/bitcoin/electrum/canonical_transaction_status_test.go new file mode 100644 index 0000000000..5908b5d2b3 --- /dev/null +++ b/pkg/bitcoin/electrum/canonical_transaction_status_test.go @@ -0,0 +1,232 @@ +package electrum + +import ( + "encoding/hex" + "testing" + + electrumclient "github.com/checksum0/go-electrum/electrum" + "github.com/keep-network/keep-core/pkg/bitcoin" +) + +type canonicalTransactionStatusTestReader struct { + rawTransaction string + found bool + rawError error + histories map[string][]*electrumclient.GetMempoolResult + historyError error + latestHeight uint + header *bitcoin.BlockHeader + headerError error + proof *bitcoin.TransactionMerkleProof + proofError error +} + +func (reader *canonicalTransactionStatusTestReader) canonicalRawTransaction( + string, +) (string, bool, error) { + return reader.rawTransaction, reader.found, reader.rawError +} + +func (reader *canonicalTransactionStatusTestReader) canonicalScriptHistory( + script []byte, +) ([]*electrumclient.GetMempoolResult, error) { + return reader.histories[hex.EncodeToString(script)], reader.historyError +} + +func (reader *canonicalTransactionStatusTestReader) GetLatestBlockHeight() ( + uint, + error, +) { + return reader.latestHeight, nil +} + +func (reader *canonicalTransactionStatusTestReader) GetBlockHeader( + uint, +) (*bitcoin.BlockHeader, error) { + return reader.header, reader.headerError +} + +func (reader *canonicalTransactionStatusTestReader) GetTransactionMerkleProof( + bitcoin.Hash, + uint, +) (*bitcoin.TransactionMerkleProof, error) { + return reader.proof, reader.proofError +} + +func TestConnectionImplementsCanonicalTransactionStatusSource(t *testing.T) { + var backend interface{} = &Connection{} + if _, ok := backend.(bitcoin.CanonicalTransactionStatusSource); !ok { + t.Fatal("Electrum connection does not implement canonical transaction status") + } +} + +func TestCanonicalTransactionStatus(t *testing.T) { + transaction := testCanonicalStatusTransaction() + transactionHash := transaction.Hash() + txID := transactionHash.Hex(bitcoin.ReversedByteOrder) + rawTransaction := hex.EncodeToString(transaction.Serialize()) + scriptKey := hex.EncodeToString(transaction.Outputs[0].PublicKeyScript) + + t.Run("not found", func(t *testing.T) { + status, err := canonicalTransactionStatus( + &canonicalTransactionStatusTestReader{}, + transactionHash, + ) + if err != nil { + t.Fatal(err) + } + if status == nil || status.Found { + t.Fatalf("unexpected absent transaction status: [%+v]", status) + } + }) + + t.Run("mempool", func(t *testing.T) { + reader := &canonicalTransactionStatusTestReader{ + rawTransaction: rawTransaction, + found: true, + histories: map[string][]*electrumclient.GetMempoolResult{ + scriptKey: {{Hash: txID, Height: 0}}, + }, + } + status, err := canonicalTransactionStatus(reader, transactionHash) + if err != nil { + t.Fatal(err) + } + if status == nil || !status.Found || status.Confirmations != 0 || + status.BlockHeight != 0 || status.BlockHash != (bitcoin.Hash{}) { + t.Fatalf("unexpected mempool transaction status: [%+v]", status) + } + }) + + t.Run("confirmed", func(t *testing.T) { + const blockHeight = 100 + header := &bitcoin.BlockHeader{ + Version: 1, + MerkleRootHash: transactionHash, + Time: 1234, + Bits: 0x1d00ffff, + Nonce: 10, + } + reader := &canonicalTransactionStatusTestReader{ + rawTransaction: rawTransaction, + found: true, + histories: map[string][]*electrumclient.GetMempoolResult{ + scriptKey: {{Hash: txID, Height: blockHeight}}, + }, + latestHeight: 120, + header: header, + proof: &bitcoin.TransactionMerkleProof{ + BlockHeight: blockHeight, + Position: 0, + }, + } + status, err := canonicalTransactionStatus(reader, transactionHash) + if err != nil { + t.Fatal(err) + } + serializedHeader := header.Serialize() + expectedBlockHash := bitcoin.ComputeHash(serializedHeader[:]) + if status == nil || !status.Found || status.Confirmations != 21 || + status.BlockHeight != blockHeight || + status.BlockHash != expectedBlockHash { + t.Fatalf("unexpected confirmed transaction status: [%+v]", status) + } + }) + + t.Run("invalid merkle proof", func(t *testing.T) { + reader := &canonicalTransactionStatusTestReader{ + rawTransaction: rawTransaction, + found: true, + histories: map[string][]*electrumclient.GetMempoolResult{ + scriptKey: {{Hash: txID, Height: 100}}, + }, + header: &bitcoin.BlockHeader{MerkleRootHash: bitcoin.Hash{1}}, + proof: &bitcoin.TransactionMerkleProof{ + BlockHeight: 100, + }, + } + if _, err := canonicalTransactionStatus( + reader, + transactionHash, + ); err == nil { + t.Fatal("invalid canonical transaction Merkle proof was accepted") + } + }) + + t.Run("incomplete index", func(t *testing.T) { + reader := &canonicalTransactionStatusTestReader{ + rawTransaction: rawTransaction, + found: true, + histories: map[string][]*electrumclient.GetMempoolResult{}, + } + if _, err := canonicalTransactionStatus( + reader, + transactionHash, + ); err == nil { + t.Fatal("incomplete canonical transaction index reported absence") + } + }) +} + +func TestVerifyCanonicalTransactionMerkleProof(t *testing.T) { + transactionHash := bitcoin.Hash{1} + sibling := bitcoin.Hash{2} + for _, test := range []struct { + name string + position uint + left bitcoin.Hash + right bitcoin.Hash + }{ + { + name: "transaction on left", + position: 0, + left: transactionHash, + right: sibling, + }, + { + name: "transaction on right", + position: 1, + left: sibling, + right: transactionHash, + }, + } { + t.Run(test.name, func(t *testing.T) { + var pair [2 * bitcoin.HashByteLength]byte + copy(pair[:bitcoin.HashByteLength], test.left[:]) + copy(pair[bitcoin.HashByteLength:], test.right[:]) + expectedRoot := bitcoin.ComputeHash(pair[:]) + proof := &bitcoin.TransactionMerkleProof{ + BlockHeight: 100, + MerkleNodes: []string{ + sibling.Hex(bitcoin.ReversedByteOrder), + }, + Position: test.position, + } + if err := verifyCanonicalTransactionMerkleProof( + transactionHash, + 100, + proof, + expectedRoot, + ); err != nil { + t.Fatal(err) + } + }) + } +} + +func testCanonicalStatusTransaction() *bitcoin.Transaction { + return &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{1}, + OutputIndex: 0, + }, + Sequence: 0xffffffff, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 1000, + PublicKeyScript: []byte{0x51}, + }}, + } +} From eccebb0b09aa8b14e8bff1ba316f9a08d1a483b8 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 16:01:38 -0400 Subject: [PATCH 49/93] fix: reconcile canonical replica observations --- .../electrum/canonical_transaction_status.go | 16 ++-- .../canonical_transaction_status_test.go | 31 ++++++++ pkg/tbtc/frost_primary_ethereum_transport.go | 3 +- .../frost_primary_ethereum_transport_test.go | 76 +++++++++++++++++++ 4 files changed, 117 insertions(+), 9 deletions(-) diff --git a/pkg/bitcoin/electrum/canonical_transaction_status.go b/pkg/bitcoin/electrum/canonical_transaction_status.go index ef302018bd..ec9a669133 100644 --- a/pkg/bitcoin/electrum/canonical_transaction_status.go +++ b/pkg/bitcoin/electrum/canonical_transaction_status.go @@ -205,6 +205,8 @@ func canonicalTransactionBlockHeight( ) } seenScripts := make(map[string]bool) + matched := false + var matchedHeight uint for _, output := range transaction.Outputs { if output == nil { return 0, fmt.Errorf( @@ -227,8 +229,6 @@ func canonicalTransactionBlockHeight( err, ) } - matched := false - var matchedHeight uint for _, item := range history { if item == nil || item.Hash != txID { continue @@ -248,12 +248,12 @@ func canonicalTransactionBlockHeight( matched = true matchedHeight = height } - if matched { - // A matching indexed output establishes the candidate height. - // Confirmed candidates are independently checked against the block - // header and Merkle proof by canonicalTransactionStatus. - return matchedHeight, nil - } + } + if matched { + // Every unique output index agreed on the candidate height. Confirmed + // candidates are independently checked against the block header and + // Merkle proof by canonicalTransactionStatus. + return matchedHeight, nil } return 0, fmt.Errorf( "canonical transaction [%s] is missing from all output-script histories", diff --git a/pkg/bitcoin/electrum/canonical_transaction_status_test.go b/pkg/bitcoin/electrum/canonical_transaction_status_test.go index 5908b5d2b3..b4c659228e 100644 --- a/pkg/bitcoin/electrum/canonical_transaction_status_test.go +++ b/pkg/bitcoin/electrum/canonical_transaction_status_test.go @@ -166,6 +166,37 @@ func TestCanonicalTransactionStatus(t *testing.T) { t.Fatal("incomplete canonical transaction index reported absence") } }) + + t.Run("inconsistent output indexes", func(t *testing.T) { + transaction := testCanonicalStatusTransaction() + transaction.Outputs = append( + transaction.Outputs, + &bitcoin.TransactionOutput{ + Value: 2000, + PublicKeyScript: []byte{0x52}, + }, + ) + transactionHash := transaction.Hash() + txID := transactionHash.Hex(bitcoin.ReversedByteOrder) + reader := &canonicalTransactionStatusTestReader{ + rawTransaction: hex.EncodeToString(transaction.Serialize()), + found: true, + histories: map[string][]*electrumclient.GetMempoolResult{ + hex.EncodeToString( + transaction.Outputs[0].PublicKeyScript, + ): {{Hash: txID, Height: 0}}, + hex.EncodeToString( + transaction.Outputs[1].PublicKeyScript, + ): {{Hash: txID, Height: 100}}, + }, + } + if _, err := canonicalTransactionStatus( + reader, + transactionHash, + ); err == nil { + t.Fatal("inconsistent canonical output indexes were accepted") + } + }) } func TestVerifyCanonicalTransactionMerkleProof(t *testing.T) { diff --git a/pkg/tbtc/frost_primary_ethereum_transport.go b/pkg/tbtc/frost_primary_ethereum_transport.go index 0f35c9087a..45c7de3bb6 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport.go +++ b/pkg/tbtc/frost_primary_ethereum_transport.go @@ -446,7 +446,8 @@ func (transport *FrostPrimaryEthereumTransport) dialTLSContext( ) if peerErr != nil { _ = tlsConnection.Close() - return nil, peerErr + lastErr = peerErr + continue } identityKey := frostTransportPeerIdentityKey(peer) if recordErr := transport.recordPeer( diff --git a/pkg/tbtc/frost_primary_ethereum_transport_test.go b/pkg/tbtc/frost_primary_ethereum_transport_test.go index 4041be290b..7acdbc9b5f 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport_test.go +++ b/pkg/tbtc/frost_primary_ethereum_transport_test.go @@ -327,6 +327,82 @@ func TestFrostPrimaryEthereumTransportTriesNextAddressAfterTLSProfileRejection( } } +func TestFrostPrimaryEthereumTransportTriesNextAddressAfterPeerIdentityRejection( + t *testing.T, +) { + server, _, roots := newFrostRetainedGroupHistoryTLSTestServer( + t, + http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), + "spiffe://primary.example/rpc", + ) + identityMismatchServer, identityMismatchLeaf, _ := + newFrostRetainedGroupHistoryTLSTestServer( + t, + http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), + "spiffe://Primary.example/rpc", + ) + roots.AddCert(identityMismatchLeaf) + endpoint, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + _, port, err := net.SplitHostPort(endpoint.Host) + if err != nil { + t.Fatal(err) + } + + identityMismatchListener, err := net.Listen( + "tcp6", + net.JoinHostPort("::1", port), + ) + if err != nil { + t.Fatal(err) + } + identityMismatchDone := make(chan struct{}) + go func() { + defer close(identityMismatchDone) + raw, acceptErr := identityMismatchListener.Accept() + if acceptErr != nil { + return + } + connection := tls.Server(raw, identityMismatchServer.TLS.Clone()) + defer connection.Close() + _ = connection.Handshake() + }() + t.Cleanup(func() { + _ = identityMismatchListener.Close() + <-identityMismatchDone + }) + + transport := &FrostPrimaryEthereumTransport{ + endpoint: frostRetainedGroupResolvedEndpoint{ + endpoint: endpoint, + canonical: endpoint.String(), + addresses: []netip.Addr{ + netip.MustParseAddr("::1"), + netip.MustParseAddr("127.0.0.1"), + }, + }, + timeout: time.Second, + rootCAs: roots, + seenPeers: make(map[[32]byte]frostTransportPeerIdentity), + livePeers: make(map[[32]byte]uint64), + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + connection, err := transport.dialTLSContext(ctx, "tcp", endpoint.Host) + if err != nil { + t.Fatalf( + "healthy pinned address was ignored after peer identity rejection: [%v]", + err, + ) + } + if err := connection.Close(); err != nil { + t.Fatal(err) + } +} + func TestFrostPrimaryEthereumTransportWSSAppliesRequestTimeout( t *testing.T, ) { From 8ea4b02d64029f451b4832d98df5dd36ae005aef Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 16:26:27 -0400 Subject: [PATCH 50/93] fix(tbtc): drain outbox before unlock --- pkg/tbtc/bitcoin_broadcast_outbox.go | 43 +++++++- pkg/tbtc/bitcoin_broadcast_outbox_test.go | 126 ++++++++++++++++++++++ 2 files changed, 164 insertions(+), 5 deletions(-) diff --git a/pkg/tbtc/bitcoin_broadcast_outbox.go b/pkg/tbtc/bitcoin_broadcast_outbox.go index 195507bc5b..77d6ebe6e6 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox.go @@ -237,9 +237,11 @@ type bitcoinBroadcastOutbox struct { now func() time.Time replaySemaphore *semaphore.Weighted + closeMutex sync.Mutex mutex sync.Mutex records map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord lockFile *os.File + closing bool closed bool recovered bool @@ -423,11 +425,29 @@ func openSecureBitcoinBroadcastFile( } func (bbo *bitcoinBroadcastOutbox) close() error { + bbo.closeMutex.Lock() + defer bbo.closeMutex.Unlock() + bbo.mutex.Lock() - defer bbo.mutex.Unlock() if bbo.closed { + bbo.mutex.Unlock() return nil } + bbo.closing = true + bbo.mutex.Unlock() + + if bbo.replaySemaphore != nil { + if err := bbo.replaySemaphore.Acquire(context.Background(), 1); err != nil { + return fmt.Errorf( + "cannot drain Bitcoin broadcast outbox replay: [%w]", + err, + ) + } + defer bbo.replaySemaphore.Release(1) + } + + bbo.mutex.Lock() + defer bbo.mutex.Unlock() bbo.closed = true if bbo.lockFile == nil { return nil @@ -503,7 +523,7 @@ func (bbo *bitcoinBroadcastOutbox) enqueue( bbo.mutex.Lock() defer bbo.mutex.Unlock() - if bbo.closed { + if bbo.closing || bbo.closed { return fmt.Errorf("Bitcoin broadcast outbox is closed") } if existing, ok := bbo.records[transactionHash]; ok { @@ -633,7 +653,7 @@ func (bbo *bitcoinBroadcastOutbox) start(ctx context.Context) error { ) } bbo.mutex.Lock() - if bbo.closed { + if bbo.closing || bbo.closed { bbo.mutex.Unlock() return fmt.Errorf("Bitcoin broadcast outbox is closed") } @@ -675,7 +695,7 @@ func (bbo *bitcoinBroadcastOutbox) activationSnapshot() ( ) { bbo.mutex.Lock() defer bbo.mutex.Unlock() - if bbo.closed { + if bbo.closing || bbo.closed { return bitcoinBroadcastOutboxActivationSnapshot{}, fmt.Errorf("Bitcoin broadcast outbox is closed") } @@ -694,7 +714,7 @@ func (bbo *bitcoinBroadcastOutbox) withUnchangedActivationSnapshot( } bbo.mutex.Lock() defer bbo.mutex.Unlock() - if bbo.closed { + if bbo.closing || bbo.closed { return fmt.Errorf("Bitcoin broadcast outbox is closed") } if bbo.activationSnapshotLocked() != expected { @@ -1242,12 +1262,25 @@ func (bbo *bitcoinBroadcastOutbox) acquireReplaySemaphore( if bbo == nil || bbo.replaySemaphore == nil { return fmt.Errorf("Bitcoin broadcast replay semaphore is unavailable") } + bbo.mutex.Lock() + unavailable := bbo.closing || bbo.closed + bbo.mutex.Unlock() + if unavailable { + return fmt.Errorf("Bitcoin broadcast outbox is closed") + } if err := bbo.replaySemaphore.Acquire(ctx, 1); err != nil { return fmt.Errorf( "cannot acquire Bitcoin broadcast replay semaphore: [%w]", err, ) } + bbo.mutex.Lock() + unavailable = bbo.closing || bbo.closed + bbo.mutex.Unlock() + if unavailable { + bbo.replaySemaphore.Release(1) + return fmt.Errorf("Bitcoin broadcast outbox is closed") + } return nil } diff --git a/pkg/tbtc/bitcoin_broadcast_outbox_test.go b/pkg/tbtc/bitcoin_broadcast_outbox_test.go index e4b0e82887..3187b0f9fd 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox_test.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox_test.go @@ -327,6 +327,115 @@ func TestBitcoinBroadcastOutbox_ExclusiveOwnershipAndStaleLock(t *testing.T) { defer second.close() } +func TestBitcoinBroadcastOutbox_CloseRetainsLockUntilBroadcastFinishes( + t *testing.T, +) { + directory := t.TempDir() + broadcastStarted := make(chan struct{}) + broadcastRelease := make(chan struct{}) + var releaseOnce sync.Once + releaseBroadcast := func() { + releaseOnce.Do(func() { + close(broadcastRelease) + }) + } + defer releaseBroadcast() + + chain := &blockingBroadcastOutboxTestBitcoinChain{ + outboxTestBitcoinChain: newOutboxTestBitcoinChain(), + broadcastStarted: broadcastStarted, + broadcastRelease: broadcastRelease, + } + outbox := openTestBitcoinBroadcastOutbox(t, directory, chain) + defer outbox.close() + tx := testOutboxTransaction(0x3a, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + tx, + testBitcoinBroadcastAuthorization(0x3b, 0x3c, 1), + ) + + broadcastResult := make(chan error, 1) + go func() { + broadcastResult <- outbox.broadcastTransaction( + context.Background(), + tx.Hash(), + ) + }() + select { + case <-broadcastStarted: + case <-time.After(time.Second): + t.Fatal("broadcast did not reach the blocking Bitcoin call") + } + + closeResult := make(chan error, 1) + go func() { + closeResult <- outbox.close() + }() + closingDeadline := time.After(time.Second) + for { + outbox.mutex.Lock() + closing := outbox.closing + outbox.mutex.Unlock() + if closing { + break + } + select { + case <-closingDeadline: + releaseBroadcast() + <-broadcastResult + t.Fatal("outbox close did not begin") + case <-time.After(time.Millisecond): + } + } + select { + case err := <-closeResult: + releaseBroadcast() + <-broadcastResult + t.Fatalf( + "outbox close returned before the in-flight broadcast finished: [%v]", + err, + ) + default: + } + + if replacement, err := newTestBitcoinBroadcastOutbox( + directory, + chain, + ); err == nil { + _ = replacement.close() + releaseBroadcast() + <-broadcastResult + <-closeResult + t.Fatal("replacement outbox acquired the in-flight owner's lock") + } + + releaseBroadcast() + select { + case err := <-broadcastResult: + if err != nil { + t.Fatalf("in-flight broadcast did not persist during shutdown: [%v]", err) + } + case <-time.After(time.Second): + t.Fatal("in-flight broadcast did not finish after release") + } + select { + case err := <-closeResult: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("outbox close did not finish after the broadcast drained") + } + + restarted := openTestBitcoinBroadcastOutbox(t, directory, chain) + defer restarted.close() + if restarted.records[tx.Hash()].BroadcastAttempts != 1 { + t.Fatal("in-flight broadcast attempt was not durable before unlock") + } +} + func TestBitcoinBroadcastOutbox_StorageHardening(t *testing.T) { newOutbox := func(directory string) (*bitcoinBroadcastOutbox, error) { return newBitcoinBroadcastOutbox( @@ -1562,6 +1671,23 @@ type blockingOutboxTestBitcoinChain struct { statusOnce sync.Once } +type blockingBroadcastOutboxTestBitcoinChain struct { + *outboxTestBitcoinChain + broadcastStarted chan struct{} + broadcastRelease <-chan struct{} + broadcastOnce sync.Once +} + +func (bbotbc *blockingBroadcastOutboxTestBitcoinChain) BroadcastTransaction( + tx *bitcoin.Transaction, +) error { + bbotbc.broadcastOnce.Do(func() { + close(bbotbc.broadcastStarted) + }) + <-bbotbc.broadcastRelease + return bbotbc.outboxTestBitcoinChain.BroadcastTransaction(tx) +} + func (botbc *blockingOutboxTestBitcoinChain) GetCanonicalTransactionStatus( hash bitcoin.Hash, ) (*bitcoin.CanonicalTransactionStatus, error) { From 862db37c5756020a7d3181807c0e6aa1186b0baa Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 17:12:04 -0400 Subject: [PATCH 51/93] Retire orphaned FROST DKG packages --- .github/workflows/frost-cgo-integration.yml | 1 + ci/frost-signer-pin.env | 2 +- .../electrum/canonical_transaction_status.go | 29 +- .../canonical_transaction_status_test.go | 49 +++ ...tributed_dkg_orchestration_frost_native.go | 14 +- ...e_tbtc_signer_registration_frost_native.go | 107 +++++++ ...c_signer_registration_frost_native_test.go | 48 +++ .../signing/native_tbtc_signer_abi_version.go | 5 +- .../native_tbtc_signer_abi_version_test.go | 10 +- ...ner_readback_real_cgo_frost_native_test.go | 18 +- pkg/tbtc/frost_dkg_execution_frost_native.go | 192 +++++++++++- .../frost_dkg_execution_frost_native_test.go | 79 ++++- pkg/tbtc/frost_dkg_retirement_default.go | 16 + pkg/tbtc/frost_dkg_retirement_frost_native.go | 294 ++++++++++++++++++ .../frost_dkg_retirement_frost_native_test.go | 180 +++++++++++ .../frost_native_signer_anchor_admission.go | 39 ++- pkg/tbtc/frost_retained_group_journal.go | 13 + pkg/tbtc/node.go | 50 ++- pkg/tbtc/registry.go | 15 + pkg/tbtc/tbtc.go | 40 ++- 20 files changed, 1155 insertions(+), 46 deletions(-) create mode 100644 pkg/tbtc/frost_dkg_retirement_default.go create mode 100644 pkg/tbtc/frost_dkg_retirement_frost_native.go create mode 100644 pkg/tbtc/frost_dkg_retirement_frost_native_test.go diff --git a/.github/workflows/frost-cgo-integration.yml b/.github/workflows/frost-cgo-integration.yml index b8db16b374..d6df61d5f4 100644 --- a/.github/workflows/frost-cgo-integration.yml +++ b/.github/workflows/frost-cgo-integration.yml @@ -86,6 +86,7 @@ jobs: missing=0 for sym in \ frost_tbtc_persist_distributed_dkg_key_package \ + frost_tbtc_retire_distributed_dkg_key_packages \ frost_tbtc_dkg_part1 \ frost_tbtc_derive_interactive_attempt_context \ frost_tbtc_interactive_session_open \ diff --git a/ci/frost-signer-pin.env b/ci/frost-signer-pin.env index 8171a9a68b..fc45e27fb9 100644 --- a/ci/frost-signer-pin.env +++ b/ci/frost-signer-pin.env @@ -15,4 +15,4 @@ # # After the scaffold and mirror branches merge into one, replace the cross-branch # checkout with an in-tree cargo build and retire this pin (keep the gate). -FROST_SIGNER_MIRROR_REF=f3480af3162f38725fea15e09c765af028bef349 +FROST_SIGNER_MIRROR_REF=b9f691a9daa215b6a1bd8b5c50fc6b8bb3214e04 diff --git a/pkg/bitcoin/electrum/canonical_transaction_status.go b/pkg/bitcoin/electrum/canonical_transaction_status.go index ec9a669133..c7597bfb21 100644 --- a/pkg/bitcoin/electrum/canonical_transaction_status.go +++ b/pkg/bitcoin/electrum/canonical_transaction_status.go @@ -128,13 +128,40 @@ func canonicalTransactionStatus( latestBlockHeight, ) } + + // Bind the proof and tip observations to one canonical chain snapshot. + // A reorganization can replace the block after the proof is verified but + // before the tip is read. Re-reading the header after observing the tip + // detects that race instead of reporting the old branch as canonical. + revalidatedHeader, err := reader.GetBlockHeader(blockHeight) + if err != nil { + return nil, fmt.Errorf( + "failed to revalidate canonical block header at height [%d]: [%w]", + blockHeight, + err, + ) + } + if revalidatedHeader == nil { + return nil, fmt.Errorf( + "revalidated canonical block header at height [%d] is nil", + blockHeight, + ) + } serializedHeader := header.Serialize() + revalidatedSerializedHeader := revalidatedHeader.Serialize() + if serializedHeader != revalidatedSerializedHeader { + return nil, fmt.Errorf( + "canonical block header at height [%d] changed while "+ + "transaction status was read", + blockHeight, + ) + } return &bitcoin.CanonicalTransactionStatus{ Found: true, Confirmations: latestBlockHeight - blockHeight + 1, BlockHeight: blockHeight, - BlockHash: bitcoin.ComputeHash(serializedHeader[:]), + BlockHash: bitcoin.ComputeHash(revalidatedSerializedHeader[:]), }, nil } diff --git a/pkg/bitcoin/electrum/canonical_transaction_status_test.go b/pkg/bitcoin/electrum/canonical_transaction_status_test.go index b4c659228e..0923526738 100644 --- a/pkg/bitcoin/electrum/canonical_transaction_status_test.go +++ b/pkg/bitcoin/electrum/canonical_transaction_status_test.go @@ -16,6 +16,8 @@ type canonicalTransactionStatusTestReader struct { historyError error latestHeight uint header *bitcoin.BlockHeader + headers []*bitcoin.BlockHeader + headerCalls int headerError error proof *bitcoin.TransactionMerkleProof proofError error @@ -43,6 +45,12 @@ func (reader *canonicalTransactionStatusTestReader) GetLatestBlockHeight() ( func (reader *canonicalTransactionStatusTestReader) GetBlockHeader( uint, ) (*bitcoin.BlockHeader, error) { + if reader.headerCalls < len(reader.headers) { + header := reader.headers[reader.headerCalls] + reader.headerCalls++ + return header, reader.headerError + } + reader.headerCalls++ return reader.header, reader.headerError } @@ -131,6 +139,47 @@ func TestCanonicalTransactionStatus(t *testing.T) { status.BlockHash != expectedBlockHash { t.Fatalf("unexpected confirmed transaction status: [%+v]", status) } + if reader.headerCalls != 2 { + t.Fatalf( + "expected the canonical header to be read twice; got [%d] reads", + reader.headerCalls, + ) + } + }) + + t.Run("reorganization while reading tip", func(t *testing.T) { + const blockHeight = 100 + header := &bitcoin.BlockHeader{ + Version: 1, + MerkleRootHash: transactionHash, + Time: 1234, + Bits: 0x1d00ffff, + Nonce: 10, + } + reorganizedHeader := *header + reorganizedHeader.Nonce++ + reader := &canonicalTransactionStatusTestReader{ + rawTransaction: rawTransaction, + found: true, + histories: map[string][]*electrumclient.GetMempoolResult{ + scriptKey: {{Hash: txID, Height: blockHeight}}, + }, + latestHeight: 120, + headers: []*bitcoin.BlockHeader{ + header, + &reorganizedHeader, + }, + proof: &bitcoin.TransactionMerkleProof{ + BlockHeight: blockHeight, + Position: 0, + }, + } + if _, err := canonicalTransactionStatus( + reader, + transactionHash, + ); err == nil { + t.Fatal("reorganized canonical block was accepted") + } }) t.Run("invalid merkle proof", func(t *testing.T) { diff --git a/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go b/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go index 1e483bfe1b..05ee248361 100644 --- a/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go +++ b/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go @@ -41,6 +41,14 @@ type NativeTBTCSignerDistributedDKGEngine interface { ) (*NativeTBTCSignerDKGResult, error) } +// NativeTBTCSignerDistributedDKGRetirementEngine durably removes every local +// key package for an exact DKG key group. It is intentionally separate from the +// execution interface so callers cannot silently assume an older native signer +// can clean up a failed DKG. +type NativeTBTCSignerDistributedDKGRetirementEngine interface { + RetireDistributedDKGKeyPackages(keyGroup string) error +} + // CanonicalFROSTIdentifier returns the canonical FROST identifier string for a // participant: the identifier as a 32-byte big-endian scalar (value in the // least-significant byte), hex-encoded and JSON-quoted. It matches the engine's @@ -208,7 +216,11 @@ func RunDistributedDKGForSeats( persistBySeat[outcome.member] = outcome.persist } if firstErr != nil { - return nil, firstErr + // Return successful durable writes alongside the error. The caller must + // retire their shared key group before abandoning the DKG; discarding the + // partial map here would strand an orphan when one local seat persists + // before a sibling fails. + return persistBySeat, firstErr } return persistBySeat, nil diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go index 6a95529bff..2a01fb5dde 100644 --- a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go @@ -39,6 +39,10 @@ typedef TbtcSignerResult (*tbtc_persist_distributed_dkg_key_package_fn)( const uint8_t* request_ptr, size_t request_len ); +typedef TbtcSignerResult (*tbtc_retire_distributed_dkg_key_packages_fn)( + const uint8_t* request_ptr, + size_t request_len +); typedef TbtcSignerResult (*tbtc_new_signing_package_fn)( const uint8_t* request_ptr, size_t request_len @@ -163,6 +167,19 @@ static TbtcSignerResult tbtc_signer_persist_distributed_dkg_key_package(const ui return persist(request_ptr, request_len); } +static TbtcSignerResult tbtc_signer_retire_distributed_dkg_key_packages(const uint8_t* request_ptr, size_t request_len) { + tbtc_retire_distributed_dkg_key_packages_fn retire = + (tbtc_retire_distributed_dkg_key_packages_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_retire_distributed_dkg_key_packages" + ); + if (retire == NULL) { + return unavailable_tbtc_signer_result(); + } + + return retire(request_ptr, request_len); +} + static TbtcSignerResult tbtc_signer_new_signing_package(const uint8_t* request_ptr, size_t request_len) { tbtc_new_signing_package_fn new_signing_package = (tbtc_new_signing_package_fn)dlsym( RTLD_DEFAULT, @@ -340,6 +357,7 @@ var _ interactiveSigningEngine = (*buildTaggedTBTCSignerEngine)(nil) // the registered engine to it to classify interactive aggregate share-verification // culprits. Compile-check it here against the real engine. var _ Round2ShareVerifyingEngine = (*buildTaggedTBTCSignerEngine)(nil) +var _ NativeTBTCSignerDistributedDKGRetirementEngine = (*buildTaggedTBTCSignerEngine)(nil) type buildTaggedTBTCSignerRunDKGResponse struct { SessionID string `json:"session_id"` @@ -411,6 +429,16 @@ type buildTaggedTBTCSignerPersistDistributedDKGKeyPackageRequest struct { PublicKeyPackage *buildTaggedTBTCSignerNativeFROSTPublicKeyPackage `json:"public_key_package"` } +type buildTaggedTBTCSignerRetireDistributedDKGKeyPackagesRequest struct { + KeyGroup string `json:"key_group"` +} + +type buildTaggedTBTCSignerRetireDistributedDKGKeyPackagesResponse struct { + KeyGroup string `json:"key_group"` + Retired bool `json:"retired"` + RetiredKeyPackageCount uint16 `json:"retired_key_package_count"` +} + type buildTaggedTBTCSignerNativeFROSTCommitment struct { Identifier string `json:"identifier"` DataHex string `json:"data_hex"` @@ -640,6 +668,25 @@ func (bttse *buildTaggedTBTCSignerEngine) PersistDistributedDKGKeyPackage( return decodeBuildTaggedTBTCSignerRunDKGResponse(responsePayload) } +func (bttse *buildTaggedTBTCSignerEngine) RetireDistributedDKGKeyPackages( + keyGroup string, +) error { + requestPayload, err := + buildTaggedTBTCSignerRetireDistributedDKGKeyPackagesRequestPayload(keyGroup) + if err != nil { + return err + } + responsePayload, err := + callBuildTaggedTBTCSignerRetireDistributedDKGKeyPackages(requestPayload) + if err != nil { + return err + } + return decodeBuildTaggedTBTCSignerRetireDistributedDKGKeyPackagesResponse( + responsePayload, + keyGroup, + ) +} + func (bttse *buildTaggedTBTCSignerEngine) NewSigningPackage( message []byte, commitments []nativeFROSTCommitment, @@ -1022,6 +1069,51 @@ func buildTaggedTBTCSignerPersistDistributedDKGKeyPackageRequestPayload( ) } +func buildTaggedTBTCSignerRetireDistributedDKGKeyPackagesRequestPayload( + keyGroup string, +) ([]byte, error) { + const op = "RetireDistributedDKGKeyPackages" + if keyGroup == "" { + return nil, buildTaggedTBTCSignerOperationError( + op, + "key group is empty", + ) + } + return buildTaggedTBTCSignerMarshalRequest( + op, + buildTaggedTBTCSignerRetireDistributedDKGKeyPackagesRequest{ + KeyGroup: keyGroup, + }, + ) +} + +func decodeBuildTaggedTBTCSignerRetireDistributedDKGKeyPackagesResponse( + responsePayload []byte, + expectedKeyGroup string, +) error { + const op = "RetireDistributedDKGKeyPackages" + var response buildTaggedTBTCSignerRetireDistributedDKGKeyPackagesResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return buildTaggedTBTCSignerOperationError( + op, + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + if response.KeyGroup != expectedKeyGroup { + return buildTaggedTBTCSignerOperationError( + op, + "response key group does not match request", + ) + } + if response.Retired != (response.RetiredKeyPackageCount > 0) { + return buildTaggedTBTCSignerOperationError( + op, + "response retirement status and key-package count disagree", + ) + } + return nil +} + func decodeBuildTaggedTBTCSignerDKGPart3Response( responsePayload []byte, ) (*NativeFROSTDKGResult, error) { @@ -1698,6 +1790,21 @@ func callBuildTaggedTBTCSignerPersistDistributedDKGKeyPackage( ) } +func callBuildTaggedTBTCSignerRetireDistributedDKGKeyPackages( + requestPayload []byte, +) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "RetireDistributedDKGKeyPackages", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_retire_distributed_dkg_key_packages( + requestPtr, + requestLen, + ) + }, + ) +} + func callBuildTaggedTBTCSignerNewSigningPackage( requestPayload []byte, ) ([]byte, error) { diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go index ba8a058c93..0c78d6436c 100644 --- a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go @@ -283,6 +283,54 @@ func TestDecodeBuildTaggedTBTCSignerRunDKGResponse(t *testing.T) { } } +func TestBuildTaggedTBTCSignerRetireDistributedDKGKeyPackagesPayloadAndResponse( + t *testing.T, +) { + const keyGroup = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + payload, err := + buildTaggedTBTCSignerRetireDistributedDKGKeyPackagesRequestPayload( + keyGroup, + ) + if err != nil { + t.Fatal(err) + } + var request buildTaggedTBTCSignerRetireDistributedDKGKeyPackagesRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatal(err) + } + if request.KeyGroup != keyGroup { + t.Fatalf("unexpected retirement key group: [%s]", request.KeyGroup) + } + + for _, response := range [][]byte{ + []byte( + `{"key_group":"` + keyGroup + + `","retired":true,"retired_key_package_count":2}`, + ), + []byte( + `{"key_group":"` + keyGroup + + `","retired":false,"retired_key_package_count":0}`, + ), + } { + if err := decodeBuildTaggedTBTCSignerRetireDistributedDKGKeyPackagesResponse( + response, + keyGroup, + ); err != nil { + t.Fatalf("valid retirement response was rejected: [%v]", err) + } + } + + if err := decodeBuildTaggedTBTCSignerRetireDistributedDKGKeyPackagesResponse( + []byte( + `{"key_group":"`+keyGroup+ + `","retired":false,"retired_key_package_count":1}`, + ), + keyGroup, + ); err == nil { + t.Fatal("inconsistent retirement response was accepted") + } +} + func TestBuildTaggedTBTCSignerBuildTaprootTxRequestPayload(t *testing.T) { scriptTreeHex := "deadbeef" diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version.go b/pkg/frost/signing/native_tbtc_signer_abi_version.go index 77f4f439e9..dbedef4773 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version.go @@ -38,7 +38,10 @@ const ( // Minor 3 adds the durable state-anchor trust-head/transition and bootstrap // facts symbols. Production startup and offline provisioning require that // complete surface and must reject an ABI-4.2 library before dlsym. - requiredTBTCSignerABIMinMinor uint32 = 3 + // + // Minor 4 adds durable distributed-DKG key-package retirement. Failed DKG + // reconciliation must reject ABI 4.3 rather than preserving orphaned keys. + requiredTBTCSignerABIMinMinor uint32 = 4 ) // ErrTBTCSignerABIIncompatible marks a linked libfrost_tbtc whose FFI contract version diff --git a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go index 957e4d675e..fc43e22c3c 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go @@ -94,9 +94,10 @@ func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { // commitments bind only the stable `.store-id` and no longer break when a // benign filesystem change alters the lock file, directory inode, or device. // Minor 3 adds the trust transition/head and bootstrap-facts surface used - // before production signing can start. The matching library version is - // compatible; ABI 4.2 and a different major are not. - if requiredTBTCSignerABIMajor != 4 || requiredTBTCSignerABIMinMinor != 3 { + // before production signing can start. Minor 4 adds durable distributed-DKG + // retirement. The matching library version is compatible; ABI 4.3 and a + // different major are not. + if requiredTBTCSignerABIMajor != 4 || requiredTBTCSignerABIMinMinor != 4 { t.Fatalf( "unexpected required tbtc-signer ABI: [%d.%d]", requiredTBTCSignerABIMajor, @@ -115,6 +116,9 @@ func TestCheckTBTCSignerABICompatibility_CurrentContract(t *testing.T) { if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, 2); err == nil { t.Fatal("ABI 4.2 without trust transition and bootstrap-facts symbols must be incompatible") } + if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor, 3); err == nil { + t.Fatal("ABI 4.3 without distributed-DKG retirement must be incompatible") + } if err := checkTBTCSignerABICompatibility(requiredTBTCSignerABIMajor+1, requiredTBTCSignerABIMinMinor); err == nil { t.Fatal("a higher major must be incompatible") } diff --git a/pkg/frost/signing/native_tbtc_signer_readback_real_cgo_frost_native_test.go b/pkg/frost/signing/native_tbtc_signer_readback_real_cgo_frost_native_test.go index 0be8dd480d..6d0b1b67f6 100644 --- a/pkg/frost/signing/native_tbtc_signer_readback_real_cgo_frost_native_test.go +++ b/pkg/frost/signing/native_tbtc_signer_readback_real_cgo_frost_native_test.go @@ -8,8 +8,8 @@ import ( "testing" ) -// TestRealCgoSignerReadinessReadbacks exercises every ABI-4.2 readiness -// readback against the actually linked libfrost_tbtc. Pure Go decoder tests +// TestRealCgoSignerReadinessReadbacks exercises the ABI-4.4 readiness and DKG +// retirement surfaces against the actually linked libfrost_tbtc. Pure Go decoder tests // cannot detect a stale library that reports a compatible-looking ABI version // while omitting a symbol or emitting a different transcript. func TestRealCgoSignerReadinessReadbacks(t *testing.T) { @@ -86,4 +86,18 @@ func TestRealCgoSignerReadinessReadbacks(t *testing.T) { if !found { t.Fatalf("real persisted key group [%s] is absent from inventory", keyGroup) } + + if err := engine.RetireDistributedDKGKeyPackages(keyGroup); err != nil { + t.Fatalf("cannot retire real distributed-DKG key packages: [%v]", err) + } + afterRetirement, err := ReadNativeTBTCSignerRetainedKeyPackageInventory() + skipFrostUnavailable(t, "retained key-package inventory after retirement", err) + for _, entry := range afterRetirement.Entries { + if entry.KeyGroup == keyGroup { + t.Fatalf("retired real key group [%s] remains in inventory", keyGroup) + } + } + if err := engine.RetireDistributedDKGKeyPackages(keyGroup); err != nil { + t.Fatalf("idempotent real DKG retirement failed: [%v]", err) + } } diff --git a/pkg/tbtc/frost_dkg_execution_frost_native.go b/pkg/tbtc/frost_dkg_execution_frost_native.go index bd72848420..a9a6830d18 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native.go @@ -7,7 +7,9 @@ import ( "crypto/ecdsa" "encoding/hex" "encoding/json" + "errors" "fmt" + "slices" "github.com/btcsuite/btcd/btcec/v2" "go.uber.org/zap" @@ -23,6 +25,10 @@ import ( const frostDKGResultSubmissionDelayStepBlocks = 30 +var errFrostDKGSubmissionOutcomeUncertain = errors.New( + "FROST DKG submission outcome is uncertain", +) + func executeFrostDKGIfPossible( ctx context.Context, node *node, @@ -41,6 +47,15 @@ func executeFrostDKGIfPossible( ) return false } + retirementEngine, ok := nativeTBTCSignerEngine.(frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine) + if !ok { + logger.Errorf( + "FROST DKG with seed [0x%x] selected this operator, but the native "+ + "tbtc-signer engine cannot durably retire failed DKG packages", + event.Seed, + ) + return false + } // Distributed DKG produces signing material usable ONLY via the complete // interactive ROAST path. The interactive audit flag alone is insufficient: @@ -213,6 +228,22 @@ func executeFrostDKGIfPossible( dkgLogger.Errorf("FROST DKG execution failed: [%v]", err) return } + resultRetained := false + defer func() { + if resultRetained { + return + } + if err := retireFailedFrostDKG( + retirementEngine, + node.walletRegistry, + executionResult.keyGroup, + ); err != nil { + dkgLogger.Errorf( + "failed to retire rejected FROST DKG key packages: [%v]", + err, + ) + } + }() for _, localMemberIndex := range localActiveMemberIndexes { if err := registerFrostSignerWithMaterial( @@ -277,9 +308,18 @@ func executeFrostDKGIfPossible( activeMemberIndexes, signedResult, ); err != nil { + if errors.Is(err, errFrostDKGSubmissionOutcomeUncertain) { + // An Ethereum RPC may report an error after accepting the + // transaction. Preserve the packages while the registry still + // reports AwaitingResult; finalized journal reconciliation will + // retain an exact Challenge result or retire the packages after + // the attempt resolves/expires. + resultRetained = true + } dkgLogger.Errorf("failed to submit FROST DKG result: [%v]", err) return } + resultRetained = true }() return true @@ -346,6 +386,7 @@ func reserveAndAnnounceFrostDKGReadiness( type frostDKGExecutionResult struct { outputKey frost.OutputKey + keyGroup string signerMaterial *frostsigning.NativeSignerMaterial } @@ -437,6 +478,28 @@ func executeDistributedFrostDKG( prebuffer, ) if err != nil { + for _, persisted := range persistBySeat { + if persisted == nil || persisted.KeyGroup == "" { + continue + } + retirementEngine, ok := nativeEngine.(frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine) + if !ok { + return nil, fmt.Errorf( + "%w; native signer cannot retire partially persisted DKG material", + err, + ) + } + if retirementErr := retirementEngine. + RetireDistributedDKGKeyPackages(persisted.KeyGroup); retirementErr != nil { + return nil, fmt.Errorf( + "%w; cannot retire partially persisted DKG key group [%s]: [%v]", + err, + persisted.KeyGroup, + retirementErr, + ) + } + break + } return nil, err } @@ -476,6 +539,7 @@ func executeDistributedFrostDKG( return &frostDKGExecutionResult{ outputKey: outputKey, + keyGroup: persisted.KeyGroup, signerMaterial: &frostsigning.NativeSignerMaterial{ Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, Payload: payload, @@ -765,15 +829,133 @@ func submitFrostDKGResultWithDelay( return err } if state != AwaitingResult { - logger.Infof( - "skipping FROST DKG result submission by member [%d]; current state is [%v]", - memberIndex, + matches, matchErr := currentFrostDKGResultMatches(frostChain, result) + if matchErr != nil { + return fmt.Errorf( + "cannot verify the result that advanced FROST DKG state to [%v]: [%w]", + state, + matchErr, + ) + } + if matches { + return nil + } + return fmt.Errorf( + "FROST DKG state advanced to [%v] without this wallet result", state, ) - return nil } - return frostChain.SubmitFrostDKGResult(result) + if err := frostChain.SubmitFrostDKGResult(result); err != nil { + matches, matchErr := currentFrostDKGResultMatches(frostChain, result) + if matchErr == nil && matches { + return nil + } + if matchErr != nil { + return fmt.Errorf( + "%w: submission failed [%v] and its canonical outcome "+ + "could not be verified: [%w]", + errFrostDKGSubmissionOutcomeUncertain, + err, + matchErr, + ) + } + stateAfterSubmission, stateErr := frostChain.GetFrostDKGState() + if stateErr != nil || stateAfterSubmission == AwaitingResult { + return fmt.Errorf( + "%w: submission failed: [%v]", + errFrostDKGSubmissionOutcomeUncertain, + err, + ) + } + return err + } + return nil +} + +func currentFrostDKGResultMatches( + frostChain FrostDKGChain, + expected *registry.Result, +) (bool, error) { + if frostChain == nil || expected == nil { + return false, fmt.Errorf("FROST DKG result comparison dependencies are nil") + } + state, err := frostChain.GetFrostDKGState() + if err != nil { + return false, err + } + if state != Challenge { + return false, nil + } + events, err := frostChain.PastFrostDKGResultSubmittedEvents(nil) + if err != nil { + return false, err + } + var latest *FrostDKGResultSubmittedEvent + for _, event := range events { + if event == nil || event.Result == nil { + continue + } + if latest == nil || event.BlockNumber >= latest.BlockNumber { + latest = event + } + } + if latest == nil || !sameFrostDKGWalletResult(latest.Result, expected) { + return false, nil + } + valid, _, err := frostChain.IsFrostDKGResultValid(latest.Result) + if err != nil { + return false, err + } + return valid, nil +} + +func sameFrostDKGWalletResult(first, second *registry.Result) bool { + return first != nil && + second != nil && + first.XOnlyOutputKey == second.XOnlyOutputKey && + first.MembersHash == second.MembersHash && + slices.Equal(first.Members, second.Members) && + slices.Equal( + first.MisbehavedMembersIndices, + second.MisbehavedMembersIndices, + ) +} + +func retireFailedFrostDKG( + retirementEngine frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine, + walletRegistry *walletRegistry, + keyGroup string, +) error { + if retirementEngine == nil || walletRegistry == nil || keyGroup == "" { + return fmt.Errorf("failed FROST DKG retirement dependencies are incomplete") + } + + // Retire native secret packages first. If the process crashes after this + // durable mutation, startup reconciliation can safely repeat it and then + // archive the Go wallet. Archiving first would lose the identity needed to + // prove which native package must be removed. + if err := retirementEngine.RetireDistributedDKGKeyPackages(keyGroup); err != nil { + return fmt.Errorf("cannot retire native DKG packages: [%w]", err) + } + + sessions, err := walletRegistry.frostLocalSessionSnapshot() + if err != nil { + return fmt.Errorf("cannot inspect local FROST sessions: [%w]", err) + } + for _, session := range sessions { + if session.KeyGroup != keyGroup { + continue + } + if err := walletRegistry.archiveWallet(session.WalletPublicKeyHash); err != nil { + return fmt.Errorf( + "cannot archive failed FROST DKG wallet [0x%x]: [%w]", + session.WalletID, + err, + ) + } + } + return nil } func frostOutputKeyToECDSAPublicKey( diff --git a/pkg/tbtc/frost_dkg_execution_frost_native_test.go b/pkg/tbtc/frost_dkg_execution_frost_native_test.go index 9ecfb1a8a5..e5a6820cad 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native_test.go @@ -101,10 +101,11 @@ func TestReserveAndAnnounceFrostDKGReadiness_ReservesEverySelectedLocalSeat( admission.activeMemberIndexes, ) } - if controller.reserved.Revisions != uint64(len(localMemberIndexes)) || - controller.reserved.Generations != uint64(len(localMemberIndexes)) { + expectedPersistenceCalls := uint64(len(localMemberIndexes) + 1) + if controller.reserved.Revisions != expectedPersistenceCalls || + controller.reserved.Generations != expectedPersistenceCalls { t.Fatalf( - "DKG did not reserve every selected local seat: [%+v]", + "DKG did not reserve every selected local seat and retirement: [%+v]", controller.reserved, ) } @@ -137,8 +138,80 @@ func TestExecuteFrostDKGIfPossible_RequiresRoastRetryReadiness(t *testing.T) { } } +type currentFrostDKGResultTestChain struct { + FrostDKGChain + state DKGState + events []*FrostDKGResultSubmittedEvent + valid bool +} + +func (chain *currentFrostDKGResultTestChain) GetFrostDKGState() ( + DKGState, + error, +) { + return chain.state, nil +} + +func (chain *currentFrostDKGResultTestChain) PastFrostDKGResultSubmittedEvents( + *FrostDKGResultSubmittedEventFilter, +) ([]*FrostDKGResultSubmittedEvent, error) { + return chain.events, nil +} + +func (chain *currentFrostDKGResultTestChain) IsFrostDKGResultValid( + *registry.Result, +) (bool, string, error) { + return chain.valid, "", nil +} + +func TestCurrentFrostDKGResultMatchesExactPendingWallet(t *testing.T) { + expected := ®istry.Result{ + XOnlyOutputKey: [32]byte{1}, + MembersHash: [32]byte{2}, + Members: registry.FullMembers{10, 20, 30}, + MisbehavedMembersIndices: registry.MisbehavedMemberIndices{2}, + Signatures: []byte{1}, + } + peerSubmission := *expected + peerSubmission.SubmitterMemberIndex = 3 + peerSubmission.Signatures = []byte{2, 3} + peerSubmission.SigningMembersIndices = []uint64{1, 3} + chain := ¤tFrostDKGResultTestChain{ + state: Challenge, + events: []*FrostDKGResultSubmittedEvent{{ + BlockNumber: 10, + Result: &peerSubmission, + }}, + valid: true, + } + + matches, err := currentFrostDKGResultMatches(chain, expected) + if err != nil { + t.Fatal(err) + } + if !matches { + t.Fatal("the same valid pending wallet result was not recognized") + } + + other := *expected + other.XOnlyOutputKey[0]++ + matches, err = currentFrostDKGResultMatches(chain, &other) + if err != nil { + t.Fatal(err) + } + if matches { + t.Fatal("a different pending wallet result was accepted") + } +} + type frostDKGReadinessTestEngine struct{} +func (*frostDKGReadinessTestEngine) RetireDistributedDKGKeyPackages( + string, +) error { + return nil +} + func (*frostDKGReadinessTestEngine) BuildTaprootTx( string, []frostsigning.NativeTBTCSignerTxInput, diff --git a/pkg/tbtc/frost_dkg_retirement_default.go b/pkg/tbtc/frost_dkg_retirement_default.go new file mode 100644 index 0000000000..1ce9c9d38a --- /dev/null +++ b/pkg/tbtc/frost_dkg_retirement_default.go @@ -0,0 +1,16 @@ +//go:build !frost_native + +package tbtc + +import "context" + +func newFrostOrphanedDKGReconciler( + _ Chain, + _ *walletRegistry, + _ *frostNativeSignerAnchorAdmissionController, +) ( + func(context.Context, map[[32]byte]struct{}) error, + error, +) { + return nil, nil +} diff --git a/pkg/tbtc/frost_dkg_retirement_frost_native.go b/pkg/tbtc/frost_dkg_retirement_frost_native.go new file mode 100644 index 0000000000..f0ec81327e --- /dev/null +++ b/pkg/tbtc/frost_dkg_retirement_frost_native.go @@ -0,0 +1,294 @@ +//go:build frost_native + +package tbtc + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "sort" + + "github.com/keep-network/keep-core/pkg/frost/registry" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +type frostPendingDKGDescriptor struct { + walletID [32]byte + keyGroup string + participantCount uint16 + preserveAll bool +} + +type frostOrphanedDKGReconciler struct { + dkgChain FrostDKGChain + registrationChain frostWalletRegistrationChain + walletRegistry *walletRegistry + anchorAdmission *frostNativeSignerAnchorAdmissionController + retirementEngine frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine + readInventory func() (*frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error) +} + +type frostDKGRetirementCandidate struct { + walletID [32]byte + keyGroup string + participantCount uint16 + walletPublicKeyHash [20]byte + hasLocalSession bool + hasNativeInventory bool +} + +func newFrostOrphanedDKGReconciler( + chain Chain, + walletRegistry *walletRegistry, + anchorAdmission *frostNativeSignerAnchorAdmissionController, +) ( + func(context.Context, map[[32]byte]struct{}) error, + error, +) { + if chain == nil || walletRegistry == nil || anchorAdmission == nil { + return nil, fmt.Errorf( + "orphaned FROST DKG reconciliation dependencies are incomplete", + ) + } + dkgChain, ok := chain.(FrostDKGChain) + if !ok { + return nil, fmt.Errorf( + "chain does not expose FROST DKG state for orphan reconciliation", + ) + } + registrationChain, ok := chain.(frostWalletRegistrationChain) + if !ok { + return nil, fmt.Errorf( + "chain does not expose FROST wallet registration for orphan reconciliation", + ) + } + retirementEngine, ok := frostsigning.CurrentNativeTBTCSignerEngine().(frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine) + if !ok { + return nil, fmt.Errorf( + "native signer does not support durable distributed-DKG retirement", + ) + } + reconciler := &frostOrphanedDKGReconciler{ + dkgChain: dkgChain, + registrationChain: registrationChain, + walletRegistry: walletRegistry, + anchorAdmission: anchorAdmission, + retirementEngine: retirementEngine, + readInventory: frostsigning. + ReadNativeTBTCSignerRetainedKeyPackageInventory, + } + return reconciler.reconcile, nil +} + +func (reconciler *frostOrphanedDKGReconciler) reconcile( + ctx context.Context, + canonicalWallets map[[32]byte]struct{}, +) error { + if reconciler == nil || ctx == nil || canonicalWallets == nil || + reconciler.readInventory == nil { + return fmt.Errorf("orphaned FROST DKG reconciliation is not configured") + } + if err := ctx.Err(); err != nil { + return err + } + + inventory, err := reconciler.readInventory() + if err != nil { + return fmt.Errorf("cannot read native key-package inventory: [%w]", err) + } + if inventory == nil { + return fmt.Errorf("native key-package inventory is nil") + } + sessions, err := reconciler.walletRegistry.frostLocalSessionSnapshot() + if err != nil { + return err + } + + candidatesByWallet := make(map[[32]byte]*frostDKGRetirementCandidate) + for _, entry := range inventory.Entries { + candidatesByWallet[entry.WalletID] = &frostDKGRetirementCandidate{ + walletID: entry.WalletID, + keyGroup: entry.KeyGroup, + participantCount: entry.ParticipantCount, + hasNativeInventory: true, + } + } + for _, session := range sessions { + candidate, exists := candidatesByWallet[session.WalletID] + if !exists { + candidate = &frostDKGRetirementCandidate{ + walletID: session.WalletID, + } + candidatesByWallet[session.WalletID] = candidate + } + if candidate.hasLocalSession { + return fmt.Errorf("duplicate local FROST DKG wallet session") + } + if candidate.hasNativeInventory && + (candidate.keyGroup != session.KeyGroup || + candidate.participantCount != uint16(len(session.OperatorAddresses))) { + return fmt.Errorf( + "native and Go FROST DKG material disagree for wallet [0x%x]", + session.WalletID, + ) + } + candidate.keyGroup = session.KeyGroup + candidate.participantCount = uint16(len(session.OperatorAddresses)) + candidate.walletPublicKeyHash = session.WalletPublicKeyHash + candidate.hasLocalSession = true + } + + walletIDs := make([][32]byte, 0, len(candidatesByWallet)) + for walletID := range candidatesByWallet { + walletIDs = append(walletIDs, walletID) + } + sort.Slice(walletIDs, func(i, j int) bool { + return bytes.Compare(walletIDs[i][:], walletIDs[j][:]) < 0 + }) + + var pendingRead bool + var pending *frostPendingDKGDescriptor + retirements := make([]*frostDKGRetirementCandidate, 0) + for _, walletID := range walletIDs { + candidate := candidatesByWallet[walletID] + if _, canonical := canonicalWallets[walletID]; canonical { + continue + } + registered, err := + reconciler.registrationChain.IsFrostWalletRegistered(walletID) + if err != nil { + return fmt.Errorf( + "cannot check FROST registration for wallet [0x%x]: [%w]", + walletID, + err, + ) + } + if registered { + continue + } + if !pendingRead { + pending, err = reconciler.currentPendingDescriptor() + if err != nil { + return err + } + pendingRead = true + } + if pending != nil { + if pending.preserveAll || + (pending.walletID == candidate.walletID && + pending.keyGroup == candidate.keyGroup && + pending.participantCount == candidate.participantCount) { + continue + } + } + retirements = append(retirements, candidate) + } + + var nativeRetirementCount uint64 + for _, candidate := range retirements { + if candidate.hasNativeInventory { + nativeRetirementCount++ + } + } + var reservation *frostNativeSignerAnchorRevisionReservation + if nativeRetirementCount > 0 { + reservation, err = reconciler.anchorAdmission.reserveDKGRetirement( + ctx, + nativeRetirementCount, + ) + if err != nil { + return err + } + defer reservation.Release() + } + + for _, candidate := range retirements { + if err := ctx.Err(); err != nil { + return err + } + if candidate.hasNativeInventory { + if err := reconciler.retirementEngine. + RetireDistributedDKGKeyPackages(candidate.keyGroup); err != nil { + return fmt.Errorf( + "cannot retire native key group [%s]: [%w]", + candidate.keyGroup, + err, + ) + } + } + if candidate.hasLocalSession { + if err := reconciler.walletRegistry.archiveWallet( + candidate.walletPublicKeyHash, + ); err != nil { + return fmt.Errorf( + "cannot archive orphaned FROST wallet [0x%x]: [%w]", + candidate.walletID, + err, + ) + } + } + } + return nil +} + +func (reconciler *frostOrphanedDKGReconciler) currentPendingDescriptor() ( + *frostPendingDKGDescriptor, + error, +) { + state, err := reconciler.dkgChain.GetFrostDKGState() + if err != nil { + return nil, fmt.Errorf("cannot read current FROST DKG state: [%w]", err) + } + if state == AwaitingResult { + // A package may have been persisted by the current DKG, or its result + // transaction may have been accepted despite an ambiguous RPC error. + // Without an on-chain output key there is no exact identity to compare, + // so preserve noncanonical packages until Challenge or timeout makes the + // outcome provable. This can delay readiness but cannot release signing. + return &frostPendingDKGDescriptor{preserveAll: true}, nil + } + if state != Challenge { + return nil, nil + } + events, err := reconciler.dkgChain.PastFrostDKGResultSubmittedEvents(nil) + if err != nil { + return nil, fmt.Errorf("cannot read pending FROST DKG result: [%w]", err) + } + var latest *FrostDKGResultSubmittedEvent + for _, event := range events { + if event == nil || event.Result == nil { + continue + } + if latest == nil || event.BlockNumber >= latest.BlockNumber { + latest = event + } + } + if latest == nil { + return nil, fmt.Errorf( + "FROST DKG state is Challenge but no submitted result is available", + ) + } + valid, _, err := reconciler.dkgChain.IsFrostDKGResultValid(latest.Result) + if err != nil { + return nil, fmt.Errorf("cannot validate pending FROST DKG result: [%w]", err) + } + if !valid { + return nil, nil + } + activeMembers, err := registry.ActiveMembersFromMisbehaved( + latest.Result.Members, + latest.Result.MisbehavedMembersIndices, + ) + if err != nil { + return nil, fmt.Errorf("invalid pending FROST DKG members: [%w]", err) + } + if len(activeMembers) > int(^uint16(0)) { + return nil, fmt.Errorf("pending FROST DKG participant count overflows") + } + return &frostPendingDKGDescriptor{ + walletID: [32]byte(latest.Result.XOnlyOutputKey), + keyGroup: "02" + hex.EncodeToString(latest.Result.XOnlyOutputKey[:]), + participantCount: uint16(len(activeMembers)), + }, nil +} diff --git a/pkg/tbtc/frost_dkg_retirement_frost_native_test.go b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go new file mode 100644 index 0000000000..c7c4b7479e --- /dev/null +++ b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go @@ -0,0 +1,180 @@ +//go:build frost_native + +package tbtc + +import ( + "context" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +type orphanedDKGRegistrationTestChain struct { + registered map[[32]byte]bool +} + +func (chain *orphanedDKGRegistrationTestChain) IsFrostWalletRegistered( + walletID [32]byte, +) (bool, error) { + return chain.registered[walletID], nil +} + +type orphanedDKGStateTestChain struct { + FrostDKGChain + state DKGState +} + +func (chain *orphanedDKGStateTestChain) GetFrostDKGState() ( + DKGState, + error, +) { + return chain.state, nil +} + +type orphanedDKGRetirementTestEngine struct { + retired []string +} + +func (engine *orphanedDKGRetirementTestEngine) RetireDistributedDKGKeyPackages( + keyGroup string, +) error { + engine.retired = append(engine.retired, keyGroup) + return nil +} + +func TestFrostOrphanedDKGReconcilerRetiresNativeOnlyOrphan(t *testing.T) { + walletID := [32]byte{1} + const keyGroup = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + engine := &orphanedDKGRetirementTestEngine{} + reconciler := &frostOrphanedDKGReconciler{ + dkgChain: &orphanedDKGStateTestChain{state: Idle}, + registrationChain: &orphanedDKGRegistrationTestChain{ + registered: make(map[[32]byte]bool), + }, + walletRegistry: &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + }, + anchorAdmission: &frostNativeSignerAnchorAdmissionController{ + readHeadroom: func( + context.Context, + ) (frostNativeSignerAnchorCapacity, error) { + return frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorRotationWarningHeadroom + 10, + Generations: FrostNativeSignerAnchorRotationWarningHeadroom + + 10, + }, nil + }, + }, + retirementEngine: engine, + readInventory: func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, + ) { + return &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Entries: []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ + WalletID: walletID, + KeyGroup: keyGroup, + ParticipantCount: 3, + }}, + }, nil + }, + } + + if err := reconciler.reconcile( + context.Background(), + map[[32]byte]struct{}{}, + ); err != nil { + t.Fatal(err) + } + if len(engine.retired) != 1 || engine.retired[0] != keyGroup { + t.Fatalf("unexpected retired key groups: [%v]", engine.retired) + } + if reconciler.anchorAdmission.reserved != + (frostNativeSignerAnchorCapacity{}) { + t.Fatalf( + "retirement reservation was not released: [%+v]", + reconciler.anchorAdmission.reserved, + ) + } +} + +func TestFrostOrphanedDKGReconcilerPreservesCanonicalAndRegistered(t *testing.T) { + canonicalWalletID := [32]byte{1} + registeredWalletID := [32]byte{2} + engine := &orphanedDKGRetirementTestEngine{} + reconciler := &frostOrphanedDKGReconciler{ + dkgChain: &orphanedDKGStateTestChain{state: Idle}, + registrationChain: &orphanedDKGRegistrationTestChain{ + registered: map[[32]byte]bool{registeredWalletID: true}, + }, + walletRegistry: &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + }, + anchorAdmission: &frostNativeSignerAnchorAdmissionController{}, + retirementEngine: engine, + readInventory: func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, + ) { + return &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Entries: []frostsigning.NativeTBTCSignerRetainedKeyGroup{ + { + WalletID: canonicalWalletID, + KeyGroup: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + }, + { + WalletID: registeredWalletID, + KeyGroup: "0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + }, + }, + }, nil + }, + } + + if err := reconciler.reconcile( + context.Background(), + map[[32]byte]struct{}{canonicalWalletID: {}}, + ); err != nil { + t.Fatal(err) + } + if len(engine.retired) != 0 { + t.Fatalf("canonical or registered key groups were retired: [%v]", engine.retired) + } +} + +func TestFrostOrphanedDKGReconcilerPreservesAwaitingResultMaterial(t *testing.T) { + walletID := [32]byte{3} + engine := &orphanedDKGRetirementTestEngine{} + reconciler := &frostOrphanedDKGReconciler{ + dkgChain: &orphanedDKGStateTestChain{state: AwaitingResult}, + registrationChain: &orphanedDKGRegistrationTestChain{ + registered: make(map[[32]byte]bool), + }, + walletRegistry: &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + }, + anchorAdmission: &frostNativeSignerAnchorAdmissionController{}, + retirementEngine: engine, + readInventory: func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, + ) { + return &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Entries: []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ + WalletID: walletID, + KeyGroup: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + }}, + }, nil + }, + } + + if err := reconciler.reconcile( + context.Background(), + map[[32]byte]struct{}{}, + ); err != nil { + t.Fatal(err) + } + if len(engine.retired) != 0 { + t.Fatalf("AwaitingResult DKG material was retired: [%v]", engine.retired) + } +} diff --git a/pkg/tbtc/frost_native_signer_anchor_admission.go b/pkg/tbtc/frost_native_signer_anchor_admission.go index ff695745aa..5d7d61d19e 100644 --- a/pkg/tbtc/frost_native_signer_anchor_admission.go +++ b/pkg/tbtc/frost_native_signer_anchor_admission.go @@ -123,9 +123,10 @@ func (controller *frostNativeSignerAnchorAdmissionController) reservePreSign( ) } -// reserveDKG accounts for one request-taking persistence call per local seat. -// DKG has no interactive sweep prologue, so each call advances at most one -// service revision and one Rust generation. +// reserveDKG accounts for one request-taking persistence call per local seat +// plus one worst-case retirement call if the persisted DKG result is rejected +// or cannot be submitted. DKG has no interactive sweep prologue, so each call +// advances at most one service revision and one Rust generation. func (controller *frostNativeSignerAnchorAdmissionController) reserveDKG( ctx context.Context, localSeatCount uint64, @@ -135,9 +136,15 @@ func (controller *frostNativeSignerAnchorAdmissionController) reserveDKG( "FROST native DKG anchor admission controls no local seats", ) } + if localSeatCount == ^uint64(0) { + return nil, fmt.Errorf( + "FROST native DKG anchor admission seat count overflows", + ) + } + maximumPersistenceCalls := localSeatCount + 1 cost := frostNativeSignerAnchorCapacity{ - Revisions: localSeatCount, - Generations: localSeatCount, + Revisions: maximumPersistenceCalls, + Generations: maximumPersistenceCalls, } return controller.reserve( @@ -149,6 +156,28 @@ func (controller *frostNativeSignerAnchorAdmissionController) reserveDKG( ) } +func (controller *frostNativeSignerAnchorAdmissionController) reserveDKGRetirement( + ctx context.Context, + keyGroupCount uint64, +) (*frostNativeSignerAnchorRevisionReservation, error) { + if keyGroupCount == 0 { + return nil, fmt.Errorf( + "FROST native DKG retirement controls no key groups", + ) + } + cost := frostNativeSignerAnchorCapacity{ + Revisions: keyGroupCount, + Generations: keyGroupCount, + } + return controller.reserve( + "FROST native DKG retirement", + cost, + func() (frostNativeSignerAnchorCapacity, error) { + return controller.currentRestartableHeadroom(ctx) + }, + ) +} + func (controller *frostNativeSignerAnchorAdmissionController) reserve( workflow string, cost frostNativeSignerAnchorCapacity, diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index e501bda357..47cad45f05 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -995,6 +995,7 @@ type frostRetainedGroupJournal struct { checkpointState frostRetainedGroupCheckpointJournalState checkpointCertificates map[uint64]FrostRetainedGroupCheckpointCertificate checkpointHashes map[uint64][32]byte + orphanedDKGReconciler func(context.Context, map[[32]byte]struct{}) error persistFailureHook func(string) error checkpointPersistFailureHook func(string) error closed bool @@ -3818,6 +3819,18 @@ func (frgj *frostRetainedGroupJournal) reconcile( } return nil, withCheckpointRecoveryProgress(nil) } + if frgj.orphanedDKGReconciler != nil { + canonicalWallets := make(map[[32]byte]struct{}, len(frgj.state.Wallets)) + for _, wallet := range frgj.state.Wallets { + canonicalWallets[wallet.WalletID] = struct{}{} + } + if err := frgj.orphanedDKGReconciler(ctx, canonicalWallets); err != nil { + return nil, fmt.Errorf( + "cannot retire orphaned native FROST DKG material: [%w]", + err, + ) + } + } localSessionCount, err := frgj.reconcileLocalSessions(ctx, target) if err != nil { return nil, err diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index 214ae663f0..0c48f59082 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -781,6 +781,20 @@ func newNode( return nil, fmt.Errorf("cannot initialize canonical FROST retained-group journal: [%w]", err) } node.frostRetainedGroupJournal = journal + orphanedDKGReconciler, err := newFrostOrphanedDKGReconciler( + chain, + walletRegistry, + anchorAdmission, + ) + if err != nil { + _ = journal.close() + _ = outbox.close() + return nil, fmt.Errorf( + "cannot initialize orphaned FROST DKG reconciliation: [%w]", + err, + ) + } + journal.orphanedDKGReconciler = orphanedDKGReconciler readiness, err := newFrostProductionSignerReadiness( currentFrostInteractiveSigningReadiness, journal, @@ -2124,13 +2138,13 @@ func (n *node) archiveClosedWallets() error { walletChainData, err := n.chain.GetWallet(walletPublicKeyHash) if err != nil { - walletID, err = n.chain.CalculateWalletID(walletPublicKey) - if err != nil { + var found bool + walletID, found = n.walletRegistry. + getWalletIDByPublicKeyHash(walletPublicKeyHash) + if !found { return fmt.Errorf( - "could not resolve wallet IDs for wallet with public key "+ - "hash [0x%x]: [%v]", + "could not resolve local wallet ID for public key hash [0x%x]", walletPublicKeyHash, - err, ) } @@ -2148,12 +2162,32 @@ func (n *node) archiveClosedWallets() error { } if !isRegistered && n.frostWalletRegistryAvailable() { + frostChain, ok := n.chain.(frostWalletRegistrationChain) + if !ok { + return fmt.Errorf( + "FROST wallet registration is available but the chain " + + "does not expose registration checks", + ) + } + isFrostRegistered, err := + frostChain.IsFrostWalletRegistered(walletID) + if err != nil { + return fmt.Errorf( + "could not check FROST registration for wallet with ID "+ + "[0x%x]: [%v]", + walletID, + err, + ) + } + if isFrostRegistered { + continue + } logger.Infof( "wallet with ECDSA ID [0x%x] and public key hash [0x%x] "+ "was not found in Bridge or the legacy ECDSA registry; "+ - "preserving local key material because FROST wallet "+ - "registration is available and the wallet may be "+ - "pending Bridge registration", + "deferring unregistered FROST material to anchored "+ + "retained-history reconciliation so a valid pending "+ + "DKG can be distinguished from an orphan", walletID, walletPublicKeyHash, ) diff --git a/pkg/tbtc/registry.go b/pkg/tbtc/registry.go index ac0c222c45..a7f182db91 100644 --- a/pkg/tbtc/registry.go +++ b/pkg/tbtc/registry.go @@ -265,6 +265,21 @@ func (wr *walletRegistry) getWalletByPublicKeyHash( return wallet{}, false } +func (wr *walletRegistry) getWalletIDByPublicKeyHash( + walletPublicKeyHash [20]byte, +) ([32]byte, bool) { + wr.mutex.Lock() + defer wr.mutex.Unlock() + + for _, value := range wr.walletCache { + if value.walletPublicKeyHash == walletPublicKeyHash { + return value.walletID, true + } + } + + return [32]byte{}, false +} + // getWalletByID gets the given wallet by its 32-byte wallet ID. Second boolean // return value denotes whether the wallet was found in the registry or not. func (wr *walletRegistry) getWalletByID(walletID [32]byte) (wallet, bool) { diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 96e8334ebd..6a449ef77f 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "runtime" + "sync" "time" "github.com/keep-network/keep-core/pkg/bitcoin" @@ -288,6 +289,27 @@ func Initialize( } node.frostGroupParameters = frostGroupParameters + var closeFrostResourcesOnce sync.Once + closeFrostResources := func() { + closeFrostResourcesOnce.Do(func() { + if node.frostActivationHandshakeExporter != nil { + _ = node.frostActivationHandshakeExporter.close() + } + if node.frostRetainedGroupJournal != nil { + _ = node.frostRetainedGroupJournal.close() + } + if node.bitcoinBroadcastOutbox != nil { + _ = node.bitcoinBroadcastOutbox.close() + } + }) + } + initializationComplete := false + defer func() { + if !initializationComplete { + closeFrostResources() + } + }() + // Note: the FROST signing-backend guard runs inside newNode above (right // after the backend is configured and before the legacy pre-params pool is // started), so an invalid backend fails Initialize with no protocol or @@ -295,16 +317,12 @@ func Initialize( if node.bitcoinBroadcastOutbox != nil { if err := node.bitcoinBroadcastOutbox.start(ctx); err != nil { - _ = node.frostRetainedGroupJournal.close() - _ = node.bitcoinBroadcastOutbox.close() return fmt.Errorf( "cannot replay durable Bitcoin broadcast outbox before coordination: [%w]", err, ) } if err := node.frostActivationHandshakeExporter.start(ctx); err != nil { - _ = node.frostRetainedGroupJournal.close() - _ = node.bitcoinBroadcastOutbox.close() return fmt.Errorf( "cannot start FROST activation handshake exporter: [%w]", err, @@ -312,23 +330,12 @@ func Initialize( } go func() { <-ctx.Done() - _ = node.frostActivationHandshakeExporter.close() - _ = node.frostRetainedGroupJournal.close() - _ = node.bitcoinBroadcastOutbox.close() + closeFrostResources() }() } err = node.runCoordinationLayer(ctx) if err != nil { - if node.frostActivationHandshakeExporter != nil { - _ = node.frostActivationHandshakeExporter.close() - } - if node.frostRetainedGroupJournal != nil { - _ = node.frostRetainedGroupJournal.close() - } - if node.bitcoinBroadcastOutbox != nil { - _ = node.bitcoinBroadcastOutbox.close() - } return fmt.Errorf("cannot run coordination layer: [%w]", err) } @@ -634,6 +641,7 @@ func Initialize( }() }) + initializationComplete = true return nil } From cde74f03e3dcc9553e0e19889124b7051e905271 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 18:11:34 -0400 Subject: [PATCH 52/93] Keep FROST signing epoch group-consistent --- pkg/tbtc/signing.go | 7 ----- pkg/tbtc/wallet.go | 28 ++++++------------- ..._sign_transaction_build_taproot_tx_test.go | 14 ++++++++-- 3 files changed, 19 insertions(+), 30 deletions(-) diff --git a/pkg/tbtc/signing.go b/pkg/tbtc/signing.go index 080d0b42f8..7d4d859ecf 100644 --- a/pkg/tbtc/signing.go +++ b/pkg/tbtc/signing.go @@ -340,13 +340,6 @@ func (se *signingExecutor) bitcoinOutbox() *bitcoinBroadcastOutbox { return se.broadcastOutbox } -func (se *signingExecutor) currentSigningBlock() (uint64, error) { - if se.getCurrentBlockFn == nil { - return 0, fmt.Errorf("current signing block source is nil") - } - return se.getCurrentBlockFn() -} - // signBatch performs the signing process for each message from the given // messages batch, one after another. If at least one message cannot be signed, // this function returns an error. If all messages were signed successfully, diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index 13bff7c10d..334a11fd87 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -332,10 +332,6 @@ type frostTransactionSafetyProvider interface { bitcoinOutbox() *bitcoinBroadcastOutbox } -type signingCurrentBlockProvider interface { - currentSigningBlock() (uint64, error) -} - // walletTransactionExecutor is a component allowing to sign and broadcast // wallet Bitcoin transactions. type walletTransactionExecutor struct { @@ -565,12 +561,6 @@ func (wte *walletTransactionExecutor) signTransaction( "FROST signing is disabled: durable Bitcoin broadcast outbox is unavailable", ) } - currentBlockProvider, ok := wte.signingExecutor.(signingCurrentBlockProvider) - if !ok { - return nil, fmt.Errorf( - "FROST signing is disabled: current anchoring block source is unavailable", - ) - } preSignTransaction, buildErr := newFrostPreSignTransaction( wte.action, @@ -614,16 +604,14 @@ func (wte *walletTransactionExecutor) signTransaction( ) } - effectiveSigningStartBlock, err = currentBlockProvider.currentSigningBlock() - if err != nil { - return nil, fmt.Errorf( - "cannot determine fresh post-authorization signing block: [%w]", - err, - ) - } - if effectiveSigningStartBlock < authorization.Finality.BlockNumber { - effectiveSigningStartBlock = authorization.Finality.BlockNumber - } + // The relay finality point is part of the finalized authorization all + // signers revalidate. Use it as the common protocol epoch for both ROAST + // session derivation and retry scheduling. A local head observed after + // authorization may differ between honest operators and would partition + // them into permanently offset signing sessions. The retry loop already + // skips elapsed windows when finality confirmation puts this epoch in the + // past. + effectiveSigningStartBlock = authorization.Finality.BlockNumber if effectiveSigningStartBlock >= signingTimeoutBlock { return nil, fmt.Errorf( "FROST authorization finalized at/after signing timeout block [%d]", diff --git a/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go b/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go index e17304d727..23d9b5b82a 100644 --- a/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go +++ b/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go @@ -1183,7 +1183,7 @@ func TestWalletTransactionExecutor_SignTransaction_ReorgDuringNativeSigningCance } } -func TestWalletTransactionExecutor_SignTransaction_UsesFreshPostFinalityBlockAndPersistsBeforeReturn( +func TestWalletTransactionExecutor_SignTransaction_UsesSharedFinalityBlockAndPersistsBeforeReturn( t *testing.T, ) { unsignedTx, privateKey := buildTaprootKeyPathUnsignedTxForTest(t) @@ -1219,12 +1219,18 @@ func TestWalletTransactionExecutor_SignTransaction_UsesFreshPostFinalityBlockAnd } if signingExecutor.authorizedCalls != 1 || len(signingExecutor.authorizedStarts) != 1 || - signingExecutor.authorizedStarts[0] != 520 { + signingExecutor.authorizedStarts[0] != 500 { t.Fatalf( - "signing did not use the fresh post-finality block: [%v]", + "signing did not use the shared authorization finality block: [%v]", signingExecutor.authorizedStarts, ) } + if signingExecutor.currentBlockCalls != 0 { + t.Fatalf( + "signing consulted the node-local head [%d] times", + signingExecutor.currentBlockCalls, + ) + } if gate.revalidateCalls < 6 { t.Fatalf("finalized state was not guarded through signing: [%d]", gate.revalidateCalls) } @@ -1781,6 +1787,7 @@ type deterministicSchnorrSigningExecutorForTaproot struct { beforePolicyBinding func() error duringAuthorizedSigning func(context.Context) error currentBlock uint64 + currentBlockCalls int authorizedCalls int signatureCalls int authorizedStarts []uint64 @@ -1894,6 +1901,7 @@ func (dsseft *deterministicSchnorrSigningExecutorForTaproot) signBatchWithAuthor } func (dsseft *deterministicSchnorrSigningExecutorForTaproot) currentSigningBlock() (uint64, error) { + dsseft.currentBlockCalls++ if dsseft.currentBlock == 0 { return 20, nil } From 4a4a7cd8cc11f77121690f61ffd1017aa1ff6664 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 18:35:46 -0400 Subject: [PATCH 53/93] Preserve unresolved FROST DKG packages --- pkg/tbtc/frost_dkg_retirement_frost_native.go | 11 +- .../frost_dkg_retirement_frost_native_test.go | 158 +++++++++++++++++- 2 files changed, 163 insertions(+), 6 deletions(-) diff --git a/pkg/tbtc/frost_dkg_retirement_frost_native.go b/pkg/tbtc/frost_dkg_retirement_frost_native.go index f0ec81327e..088b16dbad 100644 --- a/pkg/tbtc/frost_dkg_retirement_frost_native.go +++ b/pkg/tbtc/frost_dkg_retirement_frost_native.go @@ -5,7 +5,6 @@ package tbtc import ( "bytes" "context" - "encoding/hex" "fmt" "sort" @@ -15,7 +14,6 @@ import ( type frostPendingDKGDescriptor struct { walletID [32]byte - keyGroup string participantCount uint16 preserveAll bool } @@ -177,7 +175,6 @@ func (reconciler *frostOrphanedDKGReconciler) reconcile( if pending != nil { if pending.preserveAll || (pending.walletID == candidate.walletID && - pending.keyGroup == candidate.keyGroup && pending.participantCount == candidate.participantCount) { continue } @@ -274,7 +271,12 @@ func (reconciler *frostOrphanedDKGReconciler) currentPendingDescriptor() ( return nil, fmt.Errorf("cannot validate pending FROST DKG result: [%w]", err) } if !valid { - return nil, nil + // A successfully challenged invalid result returns this same DKG + // attempt to AwaitingResult, where another member may submit the valid + // result backed by the packages already persisted by this node. The + // invalid submission gives us no trustworthy wallet identity to match, + // so retain every noncanonical package until the attempt is resolved. + return &frostPendingDKGDescriptor{preserveAll: true}, nil } activeMembers, err := registry.ActiveMembersFromMisbehaved( latest.Result.Members, @@ -288,7 +290,6 @@ func (reconciler *frostOrphanedDKGReconciler) currentPendingDescriptor() ( } return &frostPendingDKGDescriptor{ walletID: [32]byte(latest.Result.XOnlyOutputKey), - keyGroup: "02" + hex.EncodeToString(latest.Result.XOnlyOutputKey[:]), participantCount: uint16(len(activeMembers)), }, nil } diff --git a/pkg/tbtc/frost_dkg_retirement_frost_native_test.go b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go index c7c4b7479e..90aff5097f 100644 --- a/pkg/tbtc/frost_dkg_retirement_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go @@ -4,8 +4,11 @@ package tbtc import ( "context" + "encoding/hex" "testing" + "github.com/keep-network/keep-core/pkg/frost" + "github.com/keep-network/keep-core/pkg/frost/registry" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" ) @@ -21,7 +24,9 @@ func (chain *orphanedDKGRegistrationTestChain) IsFrostWalletRegistered( type orphanedDKGStateTestChain struct { FrostDKGChain - state DKGState + state DKGState + events []*FrostDKGResultSubmittedEvent + valid bool } func (chain *orphanedDKGStateTestChain) GetFrostDKGState() ( @@ -31,6 +36,18 @@ func (chain *orphanedDKGStateTestChain) GetFrostDKGState() ( return chain.state, nil } +func (chain *orphanedDKGStateTestChain) PastFrostDKGResultSubmittedEvents( + *FrostDKGResultSubmittedEventFilter, +) ([]*FrostDKGResultSubmittedEvent, error) { + return chain.events, nil +} + +func (chain *orphanedDKGStateTestChain) IsFrostDKGResultValid( + *registry.Result, +) (bool, string, error) { + return chain.valid, "", nil +} + type orphanedDKGRetirementTestEngine struct { retired []string } @@ -178,3 +195,142 @@ func TestFrostOrphanedDKGReconcilerPreservesAwaitingResultMaterial(t *testing.T) t.Fatalf("AwaitingResult DKG material was retired: [%v]", engine.retired) } } + +func TestFrostOrphanedDKGReconcilerPreservesMaterialDuringInvalidChallenge( + t *testing.T, +) { + walletID := orphanedDKGTestWalletID(t) + const keyGroup = "0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + engine := &orphanedDKGRetirementTestEngine{} + reconciler := &frostOrphanedDKGReconciler{ + dkgChain: &orphanedDKGStateTestChain{ + state: Challenge, + events: []*FrostDKGResultSubmittedEvent{{ + Result: ®istry.Result{}, + BlockNumber: 100, + }}, + valid: false, + }, + registrationChain: &orphanedDKGRegistrationTestChain{ + registered: make(map[[32]byte]bool), + }, + walletRegistry: &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + }, + anchorAdmission: orphanedDKGTestAnchorAdmission(), + retirementEngine: engine, + readInventory: func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, + ) { + return &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Entries: []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ + WalletID: walletID, + KeyGroup: keyGroup, + ParticipantCount: 3, + }}, + }, nil + }, + } + + if err := reconciler.reconcile( + context.Background(), + map[[32]byte]struct{}{}, + ); err != nil { + t.Fatal(err) + } + if len(engine.retired) != 0 { + t.Fatalf("unresolved DKG material was retired: [%v]", engine.retired) + } +} + +func TestFrostOrphanedDKGReconcilerMatchesPendingMaterialByXOnlyIdentity( + t *testing.T, +) { + walletID := orphanedDKGTestWalletID(t) + result := ®istry.Result{ + XOnlyOutputKey: frost.OutputKey(walletID), + Members: registry.FullMembers{1, 2, 3}, + } + testCases := map[string]string{ + "odd compressed key": "0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "legacy x-only key": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + } + + for name, keyGroup := range testCases { + t.Run(name, func(t *testing.T) { + engine := &orphanedDKGRetirementTestEngine{} + reconciler := &frostOrphanedDKGReconciler{ + dkgChain: &orphanedDKGStateTestChain{ + state: Challenge, + events: []*FrostDKGResultSubmittedEvent{{ + Result: result, + BlockNumber: 100, + }}, + valid: true, + }, + registrationChain: &orphanedDKGRegistrationTestChain{ + registered: make(map[[32]byte]bool), + }, + walletRegistry: &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + }, + anchorAdmission: orphanedDKGTestAnchorAdmission(), + retirementEngine: engine, + readInventory: func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, + ) { + return &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Entries: []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ + WalletID: walletID, + KeyGroup: keyGroup, + ParticipantCount: 3, + }}, + }, nil + }, + } + + if err := reconciler.reconcile( + context.Background(), + map[[32]byte]struct{}{}, + ); err != nil { + t.Fatal(err) + } + if len(engine.retired) != 0 { + t.Fatalf( + "pending DKG material [%s] was retired: [%v]", + keyGroup, + engine.retired, + ) + } + }) + } +} + +func orphanedDKGTestWalletID(t *testing.T) [32]byte { + t.Helper() + decoded, err := hex.DecodeString( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + if err != nil { + t.Fatal(err) + } + var walletID [32]byte + copy(walletID[:], decoded) + return walletID +} + +func orphanedDKGTestAnchorAdmission() *frostNativeSignerAnchorAdmissionController { + return &frostNativeSignerAnchorAdmissionController{ + readHeadroom: func( + context.Context, + ) (frostNativeSignerAnchorCapacity, error) { + return frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorRotationWarningHeadroom + 10, + Generations: FrostNativeSignerAnchorRotationWarningHeadroom + + 10, + }, nil + }, + } +} From 338df934dbdb9e05bec4469e24ee97e0206ec29e Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 19:03:35 -0400 Subject: [PATCH 54/93] Harden FROST DKG package cleanup --- ...tributed_dkg_orchestration_frost_native.go | 35 +++-- ...ted_dkg_orchestration_frost_native_test.go | 45 +++++++ pkg/tbtc/frost_dkg_execution_frost_native.go | 120 ++++++++++++++--- .../frost_dkg_execution_frost_native_test.go | 123 ++++++++++++++++++ pkg/tbtc/node.go | 56 ++++---- pkg/tbtc/node_archive_frost_native_test.go | 61 +++++++++ pkg/tbtc/node_test.go | 16 ++- pkg/tbtc/registry.go | 20 +++ 8 files changed, 418 insertions(+), 58 deletions(-) create mode 100644 pkg/tbtc/node_archive_frost_native_test.go diff --git a/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go b/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go index 05ee248361..cccae24b6e 100644 --- a/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go +++ b/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go @@ -49,6 +49,12 @@ type NativeTBTCSignerDistributedDKGRetirementEngine interface { RetireDistributedDKGKeyPackages(keyGroup string) error } +type distributedDKGSeatOutcome struct { + member group.MemberIndex + persist *NativeTBTCSignerDKGResult + err error +} + // CanonicalFROSTIdentifier returns the canonical FROST identifier string for a // participant: the identifier as a 32-byte big-endian scalar (value in the // least-significant byte), hex-encoded and JSON-quoted. It matches the engine's @@ -149,19 +155,14 @@ func RunDistributedDKGForSeats( prebuffer.DrainAndForward(bus.Deliver) } - type seatOutcome struct { - member group.MemberIndex - persist *NativeTBTCSignerDKGResult - err error - } - outcomes := make(chan seatOutcome, len(runners)) + outcomes := make(chan distributedDKGSeatOutcome, len(runners)) for seat, runner := range runners { seat := seat runner := runner go func() { dkgResult, err := runner.Run(ctx) if err != nil { - outcomes <- seatOutcome{member: seat, err: fmt.Errorf("distributed DKG for seat [%v] failed: [%w]", seat, err)} + outcomes <- distributedDKGSeatOutcome{member: seat, err: fmt.Errorf("distributed DKG for seat [%v] failed: [%w]", seat, err)} return } // dkgResult.KeyPackage.Data is this seat's long-term SECRET share. The engine @@ -182,17 +183,24 @@ func RunDistributedDKGForSeats( dkgResult.PublicKeyPackage, ) if err != nil { - outcomes <- seatOutcome{member: seat, err: fmt.Errorf("cannot persist the key package for seat [%v]: [%w]", seat, err)} + outcomes <- distributedDKGSeatOutcome{member: seat, err: fmt.Errorf("cannot persist the key package for seat [%v]: [%w]", seat, err)} return } - outcomes <- seatOutcome{member: seat, persist: persisted} + outcomes <- distributedDKGSeatOutcome{member: seat, persist: persisted} }() } - persistBySeat := make(map[group.MemberIndex]*NativeTBTCSignerDKGResult, len(runners)) + return collectDistributedDKGSeatOutcomes(outcomes, len(runners)) +} + +func collectDistributedDKGSeatOutcomes( + outcomes <-chan distributedDKGSeatOutcome, + count int, +) (map[group.MemberIndex]*NativeTBTCSignerDKGResult, error) { + persistBySeat := make(map[group.MemberIndex]*NativeTBTCSignerDKGResult, count) var keyGroup string var firstErr error - for range runners { + for range count { outcome := <-outcomes if outcome.err != nil { // Keep draining so no goroutine blocks on the channel, but remember the @@ -202,6 +210,10 @@ func RunDistributedDKGForSeats( } continue } + // Record every successful durable write before checking agreement. + // A mismatching handle is precisely the case where the caller needs all + // persisted outcomes in order to retire every orphaned key group. + persistBySeat[outcome.member] = outcome.persist if keyGroup == "" { keyGroup = outcome.persist.KeyGroup } else if outcome.persist.KeyGroup != keyGroup { @@ -213,7 +225,6 @@ func RunDistributedDKGForSeats( } continue } - persistBySeat[outcome.member] = outcome.persist } if firstErr != nil { // Return successful durable writes alongside the error. The caller must diff --git a/pkg/frost/signing/distributed_dkg_orchestration_frost_native_test.go b/pkg/frost/signing/distributed_dkg_orchestration_frost_native_test.go index 81748dc318..8a66a29b02 100644 --- a/pkg/frost/signing/distributed_dkg_orchestration_frost_native_test.go +++ b/pkg/frost/signing/distributed_dkg_orchestration_frost_native_test.go @@ -5,7 +5,10 @@ package signing import ( "encoding/hex" "fmt" + "strings" "testing" + + "github.com/keep-network/keep-core/pkg/protocol/group" ) // TestCanonicalFROSTIdentifier pins the canonical identifier string the node @@ -46,3 +49,45 @@ func TestCanonicalFROSTIdentifier(t *testing.T) { seen[id] = struct{}{} } } + +func TestCollectDistributedDKGSeatOutcomesReturnsEveryDivergentPersist( + t *testing.T, +) { + outcomes := make(chan distributedDKGSeatOutcome, 2) + outcomes <- distributedDKGSeatOutcome{ + member: 1, + persist: &NativeTBTCSignerDKGResult{ + KeyGroup: "key-group-a", + }, + } + outcomes <- distributedDKGSeatOutcome{ + member: 2, + persist: &NativeTBTCSignerDKGResult{ + KeyGroup: "key-group-b", + }, + } + + persistBySeat, err := collectDistributedDKGSeatOutcomes(outcomes, 2) + if err == nil || !strings.Contains(err.Error(), "disagreed") { + t.Fatalf("unexpected divergent-seat result: [%v]", err) + } + if len(persistBySeat) != 2 { + t.Fatalf( + "successful divergent persists were dropped: [%v]", + persistBySeat, + ) + } + for seat, expectedKeyGroup := range map[group.MemberIndex]string{ + 1: "key-group-a", + 2: "key-group-b", + } { + persisted := persistBySeat[seat] + if persisted == nil || persisted.KeyGroup != expectedKeyGroup { + t.Fatalf( + "unexpected persisted outcome for seat [%d]: [%+v]", + seat, + persisted, + ) + } + } +} diff --git a/pkg/tbtc/frost_dkg_execution_frost_native.go b/pkg/tbtc/frost_dkg_execution_frost_native.go index a9a6830d18..3e9c88a362 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native.go @@ -233,6 +233,21 @@ func executeFrostDKGIfPossible( if resultRetained { return } + canRetire, resolutionErr := canRetireFailedFrostDKG( + frostChain, + executionResult.outputKey, + ) + if resolutionErr != nil { + dkgLogger.Errorf( + "preserving unresolved FROST DKG key packages because "+ + "the on-chain attempt outcome could not be proven: [%v]", + resolutionErr, + ) + return + } + if !canRetire { + return + } if err := retireFailedFrostDKG( retirementEngine, node.walletRegistry, @@ -478,27 +493,11 @@ func executeDistributedFrostDKG( prebuffer, ) if err != nil { - for _, persisted := range persistBySeat { - if persisted == nil || persisted.KeyGroup == "" { - continue - } - retirementEngine, ok := nativeEngine.(frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine) - if !ok { - return nil, fmt.Errorf( - "%w; native signer cannot retire partially persisted DKG material", - err, - ) - } - if retirementErr := retirementEngine. - RetireDistributedDKGKeyPackages(persisted.KeyGroup); retirementErr != nil { - return nil, fmt.Errorf( - "%w; cannot retire partially persisted DKG key group [%s]: [%v]", - err, - persisted.KeyGroup, - retirementErr, - ) - } - break + if retirementErr := retirePersistedFrostDKGKeyGroups( + nativeEngine, + persistBySeat, + ); retirementErr != nil { + return nil, fmt.Errorf("%w; %v", err, retirementErr) } return nil, err } @@ -922,6 +921,85 @@ func sameFrostDKGWalletResult(first, second *registry.Result) bool { ) } +func canRetireFailedFrostDKG( + frostChain FrostDKGChain, + outputKey frost.OutputKey, +) (bool, error) { + if frostChain == nil { + return false, fmt.Errorf("FROST DKG chain is nil") + } + state, err := frostChain.GetFrostDKGState() + if err != nil { + return false, fmt.Errorf("cannot read FROST DKG state: [%w]", err) + } + if state == AwaitingResult || state == Challenge { + return false, nil + } + + registrationChain, ok := frostChain.(frostWalletRegistrationChain) + if !ok { + return false, fmt.Errorf( + "chain does not expose FROST wallet registration", + ) + } + registered, err := registrationChain.IsFrostWalletRegistered( + [32]byte(outputKey), + ) + if err != nil { + return false, fmt.Errorf( + "cannot check failed FROST DKG wallet registration: [%w]", + err, + ) + } + return !registered, nil +} + +func retirePersistedFrostDKGKeyGroups( + nativeEngine frostsigning.NativeTBTCSignerEngine, + persistBySeat map[group.MemberIndex]*frostsigning.NativeTBTCSignerDKGResult, +) error { + keyGroupSet := make(map[string]struct{}) + for _, persisted := range persistBySeat { + if persisted == nil || persisted.KeyGroup == "" { + continue + } + keyGroupSet[persisted.KeyGroup] = struct{}{} + } + if len(keyGroupSet) == 0 { + return nil + } + + retirementEngine, ok := + nativeEngine.(frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine) + if !ok { + return fmt.Errorf( + "native signer cannot retire partially persisted DKG material", + ) + } + + keyGroups := make([]string, 0, len(keyGroupSet)) + for keyGroup := range keyGroupSet { + keyGroups = append(keyGroups, keyGroup) + } + slices.Sort(keyGroups) + + retirementErrors := make([]error, 0) + for _, keyGroup := range keyGroups { + if err := retirementEngine. + RetireDistributedDKGKeyPackages(keyGroup); err != nil { + retirementErrors = append( + retirementErrors, + fmt.Errorf( + "cannot retire partially persisted DKG key group [%s]: [%w]", + keyGroup, + err, + ), + ) + } + } + return errors.Join(retirementErrors...) +} + func retireFailedFrostDKG( retirementEngine frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine, walletRegistry *walletRegistry, diff --git a/pkg/tbtc/frost_dkg_execution_frost_native_test.go b/pkg/tbtc/frost_dkg_execution_frost_native_test.go index e5a6820cad..b52885c7ac 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native_test.go @@ -6,11 +6,13 @@ import ( "bytes" "context" "encoding/hex" + "errors" "math/big" "strings" "testing" "github.com/keep-network/keep-core/pkg/chain" + "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/frost/registry" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/protocol/group" @@ -143,12 +145,19 @@ type currentFrostDKGResultTestChain struct { state DKGState events []*FrostDKGResultSubmittedEvent valid bool + + stateErr error + registered bool + registrationErr error } func (chain *currentFrostDKGResultTestChain) GetFrostDKGState() ( DKGState, error, ) { + if chain.stateErr != nil { + return 0, chain.stateErr + } return chain.state, nil } @@ -164,6 +173,12 @@ func (chain *currentFrostDKGResultTestChain) IsFrostDKGResultValid( return chain.valid, "", nil } +func (chain *currentFrostDKGResultTestChain) IsFrostWalletRegistered( + [32]byte, +) (bool, error) { + return chain.registered, chain.registrationErr +} + func TestCurrentFrostDKGResultMatchesExactPendingWallet(t *testing.T) { expected := ®istry.Result{ XOnlyOutputKey: [32]byte{1}, @@ -204,6 +219,72 @@ func TestCurrentFrostDKGResultMatchesExactPendingWallet(t *testing.T) { } } +func TestCanRetireFailedFrostDKGRequiresResolvedUnregisteredAttempt( + t *testing.T, +) { + outputKey := frost.OutputKey{1} + testCases := map[string]struct { + chain *currentFrostDKGResultTestChain + expected bool + errorExpected bool + }{ + "awaiting result": { + chain: ¤tFrostDKGResultTestChain{ + state: AwaitingResult, + }, + }, + "challenge": { + chain: ¤tFrostDKGResultTestChain{ + state: Challenge, + }, + }, + "resolved registered wallet": { + chain: ¤tFrostDKGResultTestChain{ + state: Idle, + registered: true, + }, + }, + "resolved unregistered wallet": { + chain: ¤tFrostDKGResultTestChain{ + state: Idle, + }, + expected: true, + }, + "ambiguous state": { + chain: ¤tFrostDKGResultTestChain{ + stateErr: errors.New("state unavailable"), + }, + errorExpected: true, + }, + "ambiguous registration": { + chain: ¤tFrostDKGResultTestChain{ + state: Idle, + registrationErr: errors.New("registration unavailable"), + }, + errorExpected: true, + }, + } + + for name, test := range testCases { + t.Run(name, func(t *testing.T) { + actual, err := canRetireFailedFrostDKG( + test.chain, + outputKey, + ) + if (err != nil) != test.errorExpected { + t.Fatalf("unexpected retirement proof error: [%v]", err) + } + if actual != test.expected { + t.Fatalf( + "unexpected retirement decision\nexpected: [%t]\nactual: [%t]", + test.expected, + actual, + ) + } + }) + } +} + type frostDKGReadinessTestEngine struct{} func (*frostDKGReadinessTestEngine) RetireDistributedDKGKeyPackages( @@ -252,6 +333,48 @@ func registerFrostDKGReadinessTestEngine(t *testing.T) { }) } +type frostDKGRetirementRecordingEngine struct { + frostDKGReadinessTestEngine + retired []string + fail map[string]error +} + +func (engine *frostDKGRetirementRecordingEngine) RetireDistributedDKGKeyPackages( + keyGroup string, +) error { + engine.retired = append(engine.retired, keyGroup) + return engine.fail[keyGroup] +} + +func TestRetirePersistedFrostDKGKeyGroupsRetiresEveryDistinctGroup( + t *testing.T, +) { + engine := &frostDKGRetirementRecordingEngine{ + fail: map[string]error{ + "key-group-a": errors.New("injected retirement failure"), + }, + } + err := retirePersistedFrostDKGKeyGroups( + engine, + map[group.MemberIndex]*frostsigning.NativeTBTCSignerDKGResult{ + 1: {KeyGroup: "key-group-b"}, + 2: {KeyGroup: "key-group-a"}, + 3: {KeyGroup: "key-group-b"}, + }, + ) + if err == nil || !strings.Contains(err.Error(), "key-group-a") { + t.Fatalf("unexpected retirement result: [%v]", err) + } + if len(engine.retired) != 2 || + engine.retired[0] != "key-group-a" || + engine.retired[1] != "key-group-b" { + t.Fatalf( + "not every distinct key group was retired after a failure: [%v]", + engine.retired, + ) + } +} + func TestLowestLocalActiveMemberIndex(t *testing.T) { testCases := map[string]struct { local []group.MemberIndex diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index 0c48f59082..62c2615fb1 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -2162,36 +2162,48 @@ func (n *node) archiveClosedWallets() error { } if !isRegistered && n.frostWalletRegistryAvailable() { - frostChain, ok := n.chain.(frostWalletRegistrationChain) - if !ok { - return fmt.Errorf( - "FROST wallet registration is available but the chain " + - "does not expose registration checks", - ) - } - isFrostRegistered, err := - frostChain.IsFrostWalletRegistered(walletID) + isFrostWallet, err := n.walletRegistry. + isFrostWalletByPublicKeyHash(walletPublicKeyHash) if err != nil { return fmt.Errorf( - "could not check FROST registration for wallet with ID "+ + "could not classify local wallet with public key hash "+ "[0x%x]: [%v]", - walletID, + walletPublicKeyHash, err, ) } - if isFrostRegistered { + if isFrostWallet { + frostChain, ok := n.chain.(frostWalletRegistrationChain) + if !ok { + return fmt.Errorf( + "FROST wallet registration is available but the chain " + + "does not expose registration checks", + ) + } + isFrostRegistered, err := + frostChain.IsFrostWalletRegistered(walletID) + if err != nil { + return fmt.Errorf( + "could not check FROST registration for wallet with ID "+ + "[0x%x]: [%v]", + walletID, + err, + ) + } + if isFrostRegistered { + continue + } + logger.Infof( + "FROST wallet with ID [0x%x] and public key hash [0x%x] "+ + "was not found in Bridge or the FROST registry; "+ + "deferring its material to anchored retained-history "+ + "reconciliation so a valid pending DKG can be "+ + "distinguished from an orphan", + walletID, + walletPublicKeyHash, + ) continue } - logger.Infof( - "wallet with ECDSA ID [0x%x] and public key hash [0x%x] "+ - "was not found in Bridge or the legacy ECDSA registry; "+ - "deferring unregistered FROST material to anchored "+ - "retained-history reconciliation so a valid pending "+ - "DKG can be distinguished from an orphan", - walletID, - walletPublicKeyHash, - ) - continue } archiveWallet = !isRegistered diff --git a/pkg/tbtc/node_archive_frost_native_test.go b/pkg/tbtc/node_archive_frost_native_test.go new file mode 100644 index 0000000000..3ba67c8bc8 --- /dev/null +++ b/pkg/tbtc/node_archive_frost_native_test.go @@ -0,0 +1,61 @@ +//go:build frost_native + +package tbtc + +import ( + "encoding/hex" + "encoding/json" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +func TestArchiveClosedWalletsDefersUnregisteredFrostMaterial(t *testing.T) { + node, signer, chain := setupNodeWithChain(t) + walletPublicKeyHash := bitcoin.PublicKeyHash(signer.wallet.publicKey) + + const xOnlyOutputKey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + payload, err := json.Marshal(frostsigning.NativeTBTCSignerMaterialPayload{ + KeyGroup: "03" + xOnlyOutputKey, + TaprootOutputKey: xOnlyOutputKey, + KeyGroupSource: frostsigning.NativeTBTCSignerKeyGroupSourceDKGPersisted, + }) + if err != nil { + t.Fatal(err) + } + nativeMaterial := &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: payload, + } + + decodedWalletID, err := hex.DecodeString(xOnlyOutputKey) + if err != nil { + t.Fatal(err) + } + var walletID [32]byte + copy(walletID[:], decodedWalletID) + + node.walletRegistry.mutex.Lock() + for _, value := range node.walletRegistry.walletCache { + if value.walletPublicKeyHash == walletPublicKeyHash { + value.walletID = walletID + value.signers[0].signerMaterial = nativeMaterial + } + } + node.walletRegistry.mutex.Unlock() + + chain.walletsMutex.Lock() + delete(chain.wallets, walletPublicKeyHash) + chain.frostWalletRegistryAvailable = true + chain.walletsMutex.Unlock() + + if err := node.archiveClosedWallets(); err != nil { + t.Fatal(err) + } + if _, ok := node.walletRegistry.getWalletByPublicKeyHash( + walletPublicKeyHash, + ); !ok { + t.Fatal("unregistered FROST material was archived before DKG reconciliation") + } +} diff --git a/pkg/tbtc/node_test.go b/pkg/tbtc/node_test.go index b3bdd775a1..810b812922 100644 --- a/pkg/tbtc/node_test.go +++ b/pkg/tbtc/node_test.go @@ -470,7 +470,7 @@ func TestNode_KeepsLiveBridgeWalletWithoutLegacyRegistration(t *testing.T) { } } -func TestNode_KeepsPendingFrostWalletWithoutBridgeRegistration(t *testing.T) { +func TestNode_ArchivesLegacyWalletMissingFromEveryRegistry(t *testing.T) { groupParameters := &GroupParameters{ GroupSize: 5, GroupQuorum: 4, @@ -500,8 +500,18 @@ func TestNode_KeepsPendingFrostWalletWithoutBridgeRegistration(t *testing.T) { } _, ok := n.walletRegistry.getWalletByPublicKeyHash(walletPublicKeyHash) - if !ok { - t.Fatal("pending FROST wallet should not be archived") + if ok { + t.Fatal("unregistered legacy wallet was not archived") + } + + localChain.walletRegistrationChecksMutex.Lock() + frostChecks := localChain.frostWalletRegistrationChecks + localChain.walletRegistrationChecksMutex.Unlock() + if frostChecks != 0 { + t.Fatalf( + "legacy wallet cleanup queried the FROST registry [%d] times", + frostChecks, + ) } } diff --git a/pkg/tbtc/registry.go b/pkg/tbtc/registry.go index a7f182db91..de9b9606bc 100644 --- a/pkg/tbtc/registry.go +++ b/pkg/tbtc/registry.go @@ -280,6 +280,26 @@ func (wr *walletRegistry) getWalletIDByPublicKeyHash( return [32]byte{}, false } +func (wr *walletRegistry) isFrostWalletByPublicKeyHash( + walletPublicKeyHash [20]byte, +) (bool, error) { + wr.mutex.Lock() + defer wr.mutex.Unlock() + + for _, value := range wr.walletCache { + if value.walletPublicKeyHash != walletPublicKeyHash { + continue + } + _, isFrostWallet, err := frostKeyGroupFromWalletCacheValue(value) + if err != nil { + return false, err + } + return isFrostWallet, nil + } + + return false, fmt.Errorf("wallet not found in the wallet cache") +} + // getWalletByID gets the given wallet by its 32-byte wallet ID. Second boolean // return value denotes whether the wallet was found in the registry or not. func (wr *walletRegistry) getWalletByID(walletID [32]byte) (wallet, bool) { From cca329d2f1db2c3e59d77d2f64fd47b2a0b45cca Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 19:33:32 -0400 Subject: [PATCH 55/93] Defer FROST DKG package retirement --- pkg/tbtc/frost_dkg_execution_frost_native.go | 121 +----------------- .../frost_dkg_execution_frost_native_test.go | 84 +----------- .../frost_native_signer_anchor_admission.go | 13 +- 3 files changed, 16 insertions(+), 202 deletions(-) diff --git a/pkg/tbtc/frost_dkg_execution_frost_native.go b/pkg/tbtc/frost_dkg_execution_frost_native.go index 3e9c88a362..e94b43a07b 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native.go @@ -47,11 +47,10 @@ func executeFrostDKGIfPossible( ) return false } - retirementEngine, ok := nativeTBTCSignerEngine.(frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine) - if !ok { + if _, ok := nativeTBTCSignerEngine.(frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine); !ok { logger.Errorf( "FROST DKG with seed [0x%x] selected this operator, but the native "+ - "tbtc-signer engine cannot durably retire failed DKG packages", + "tbtc-signer engine cannot durably retire partially persisted DKG packages", event.Seed, ) return false @@ -228,37 +227,11 @@ func executeFrostDKGIfPossible( dkgLogger.Errorf("FROST DKG execution failed: [%v]", err) return } - resultRetained := false - defer func() { - if resultRetained { - return - } - canRetire, resolutionErr := canRetireFailedFrostDKG( - frostChain, - executionResult.outputKey, - ) - if resolutionErr != nil { - dkgLogger.Errorf( - "preserving unresolved FROST DKG key packages because "+ - "the on-chain attempt outcome could not be proven: [%v]", - resolutionErr, - ) - return - } - if !canRetire { - return - } - if err := retireFailedFrostDKG( - retirementEngine, - node.walletRegistry, - executionResult.keyGroup, - ); err != nil { - dkgLogger.Errorf( - "failed to retire rejected FROST DKG key packages: [%v]", - err, - ) - } - }() + // From this point every local key package is durable. Do not retire it + // from any error exit below using latest-state DKG or registration reads: + // replicas can observe those values at different chain points. Only + // reconciliation rooted in finalized retained history may prove that the + // attempt resolved without this wallet and retire the material. for _, localMemberIndex := range localActiveMemberIndexes { if err := registerFrostSignerWithMaterial( @@ -323,18 +296,9 @@ func executeFrostDKGIfPossible( activeMemberIndexes, signedResult, ); err != nil { - if errors.Is(err, errFrostDKGSubmissionOutcomeUncertain) { - // An Ethereum RPC may report an error after accepting the - // transaction. Preserve the packages while the registry still - // reports AwaitingResult; finalized journal reconciliation will - // retain an exact Challenge result or retire the packages after - // the attempt resolves/expires. - resultRetained = true - } dkgLogger.Errorf("failed to submit FROST DKG result: [%v]", err) return } - resultRetained = true }() return true @@ -401,7 +365,6 @@ func reserveAndAnnounceFrostDKGReadiness( type frostDKGExecutionResult struct { outputKey frost.OutputKey - keyGroup string signerMaterial *frostsigning.NativeSignerMaterial } @@ -538,7 +501,6 @@ func executeDistributedFrostDKG( return &frostDKGExecutionResult{ outputKey: outputKey, - keyGroup: persisted.KeyGroup, signerMaterial: &frostsigning.NativeSignerMaterial{ Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, Payload: payload, @@ -921,39 +883,6 @@ func sameFrostDKGWalletResult(first, second *registry.Result) bool { ) } -func canRetireFailedFrostDKG( - frostChain FrostDKGChain, - outputKey frost.OutputKey, -) (bool, error) { - if frostChain == nil { - return false, fmt.Errorf("FROST DKG chain is nil") - } - state, err := frostChain.GetFrostDKGState() - if err != nil { - return false, fmt.Errorf("cannot read FROST DKG state: [%w]", err) - } - if state == AwaitingResult || state == Challenge { - return false, nil - } - - registrationChain, ok := frostChain.(frostWalletRegistrationChain) - if !ok { - return false, fmt.Errorf( - "chain does not expose FROST wallet registration", - ) - } - registered, err := registrationChain.IsFrostWalletRegistered( - [32]byte(outputKey), - ) - if err != nil { - return false, fmt.Errorf( - "cannot check failed FROST DKG wallet registration: [%w]", - err, - ) - } - return !registered, nil -} - func retirePersistedFrostDKGKeyGroups( nativeEngine frostsigning.NativeTBTCSignerEngine, persistBySeat map[group.MemberIndex]*frostsigning.NativeTBTCSignerDKGResult, @@ -1000,42 +929,6 @@ func retirePersistedFrostDKGKeyGroups( return errors.Join(retirementErrors...) } -func retireFailedFrostDKG( - retirementEngine frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine, - walletRegistry *walletRegistry, - keyGroup string, -) error { - if retirementEngine == nil || walletRegistry == nil || keyGroup == "" { - return fmt.Errorf("failed FROST DKG retirement dependencies are incomplete") - } - - // Retire native secret packages first. If the process crashes after this - // durable mutation, startup reconciliation can safely repeat it and then - // archive the Go wallet. Archiving first would lose the identity needed to - // prove which native package must be removed. - if err := retirementEngine.RetireDistributedDKGKeyPackages(keyGroup); err != nil { - return fmt.Errorf("cannot retire native DKG packages: [%w]", err) - } - - sessions, err := walletRegistry.frostLocalSessionSnapshot() - if err != nil { - return fmt.Errorf("cannot inspect local FROST sessions: [%w]", err) - } - for _, session := range sessions { - if session.KeyGroup != keyGroup { - continue - } - if err := walletRegistry.archiveWallet(session.WalletPublicKeyHash); err != nil { - return fmt.Errorf( - "cannot archive failed FROST DKG wallet [0x%x]: [%w]", - session.WalletID, - err, - ) - } - } - return nil -} - func frostOutputKeyToECDSAPublicKey( outputKey frost.OutputKey, ) (*ecdsa.PublicKey, error) { diff --git a/pkg/tbtc/frost_dkg_execution_frost_native_test.go b/pkg/tbtc/frost_dkg_execution_frost_native_test.go index b52885c7ac..35b0c42205 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native_test.go @@ -12,7 +12,6 @@ import ( "testing" "github.com/keep-network/keep-core/pkg/chain" - "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/frost/registry" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/protocol/group" @@ -103,11 +102,11 @@ func TestReserveAndAnnounceFrostDKGReadiness_ReservesEverySelectedLocalSeat( admission.activeMemberIndexes, ) } - expectedPersistenceCalls := uint64(len(localMemberIndexes) + 1) + expectedPersistenceCalls := uint64(len(localMemberIndexes) * 2) if controller.reserved.Revisions != expectedPersistenceCalls || controller.reserved.Generations != expectedPersistenceCalls { t.Fatalf( - "DKG did not reserve every selected local seat and retirement: [%+v]", + "DKG did not reserve persistence and retirement for every selected local seat: [%+v]", controller.reserved, ) } @@ -145,19 +144,12 @@ type currentFrostDKGResultTestChain struct { state DKGState events []*FrostDKGResultSubmittedEvent valid bool - - stateErr error - registered bool - registrationErr error } func (chain *currentFrostDKGResultTestChain) GetFrostDKGState() ( DKGState, error, ) { - if chain.stateErr != nil { - return 0, chain.stateErr - } return chain.state, nil } @@ -173,12 +165,6 @@ func (chain *currentFrostDKGResultTestChain) IsFrostDKGResultValid( return chain.valid, "", nil } -func (chain *currentFrostDKGResultTestChain) IsFrostWalletRegistered( - [32]byte, -) (bool, error) { - return chain.registered, chain.registrationErr -} - func TestCurrentFrostDKGResultMatchesExactPendingWallet(t *testing.T) { expected := ®istry.Result{ XOnlyOutputKey: [32]byte{1}, @@ -219,72 +205,6 @@ func TestCurrentFrostDKGResultMatchesExactPendingWallet(t *testing.T) { } } -func TestCanRetireFailedFrostDKGRequiresResolvedUnregisteredAttempt( - t *testing.T, -) { - outputKey := frost.OutputKey{1} - testCases := map[string]struct { - chain *currentFrostDKGResultTestChain - expected bool - errorExpected bool - }{ - "awaiting result": { - chain: ¤tFrostDKGResultTestChain{ - state: AwaitingResult, - }, - }, - "challenge": { - chain: ¤tFrostDKGResultTestChain{ - state: Challenge, - }, - }, - "resolved registered wallet": { - chain: ¤tFrostDKGResultTestChain{ - state: Idle, - registered: true, - }, - }, - "resolved unregistered wallet": { - chain: ¤tFrostDKGResultTestChain{ - state: Idle, - }, - expected: true, - }, - "ambiguous state": { - chain: ¤tFrostDKGResultTestChain{ - stateErr: errors.New("state unavailable"), - }, - errorExpected: true, - }, - "ambiguous registration": { - chain: ¤tFrostDKGResultTestChain{ - state: Idle, - registrationErr: errors.New("registration unavailable"), - }, - errorExpected: true, - }, - } - - for name, test := range testCases { - t.Run(name, func(t *testing.T) { - actual, err := canRetireFailedFrostDKG( - test.chain, - outputKey, - ) - if (err != nil) != test.errorExpected { - t.Fatalf("unexpected retirement proof error: [%v]", err) - } - if actual != test.expected { - t.Fatalf( - "unexpected retirement decision\nexpected: [%t]\nactual: [%t]", - test.expected, - actual, - ) - } - }) - } -} - type frostDKGReadinessTestEngine struct{} func (*frostDKGReadinessTestEngine) RetireDistributedDKGKeyPackages( diff --git a/pkg/tbtc/frost_native_signer_anchor_admission.go b/pkg/tbtc/frost_native_signer_anchor_admission.go index 5d7d61d19e..e3896edc01 100644 --- a/pkg/tbtc/frost_native_signer_anchor_admission.go +++ b/pkg/tbtc/frost_native_signer_anchor_admission.go @@ -123,10 +123,11 @@ func (controller *frostNativeSignerAnchorAdmissionController) reservePreSign( ) } -// reserveDKG accounts for one request-taking persistence call per local seat -// plus one worst-case retirement call if the persisted DKG result is rejected -// or cannot be submitted. DKG has no interactive sweep prologue, so each call -// advances at most one service revision and one Rust generation. +// reserveDKG accounts for one request-taking persistence call and one +// worst-case retirement call per local seat. Successful seats normally share +// one key group and need one retirement, but a disagreement can produce one +// distinct durable group per seat. DKG has no interactive sweep prologue, so +// each call advances at most one service revision and one Rust generation. func (controller *frostNativeSignerAnchorAdmissionController) reserveDKG( ctx context.Context, localSeatCount uint64, @@ -136,12 +137,12 @@ func (controller *frostNativeSignerAnchorAdmissionController) reserveDKG( "FROST native DKG anchor admission controls no local seats", ) } - if localSeatCount == ^uint64(0) { + if localSeatCount > ^uint64(0)/2 { return nil, fmt.Errorf( "FROST native DKG anchor admission seat count overflows", ) } - maximumPersistenceCalls := localSeatCount + 1 + maximumPersistenceCalls := localSeatCount * 2 cost := frostNativeSignerAnchorCapacity{ Revisions: maximumPersistenceCalls, Generations: maximumPersistenceCalls, From 7f3aa2d6af6f451494751f43865b476bab345fe1 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 20:03:48 -0400 Subject: [PATCH 56/93] Pin FROST DKG retirement state --- pkg/chain/ethereum/frost_dkg.go | 112 ++++++++++++ pkg/chain/ethereum/frost_dkg_test.go | 106 +++++++++++ pkg/tbtc/chain_test.go | 34 ++++ pkg/tbtc/frost_dkg_chain.go | 20 +++ pkg/tbtc/frost_dkg_coordinator.go | 34 +++- pkg/tbtc/frost_dkg_coordinator_test.go | 95 +++++++++- pkg/tbtc/frost_dkg_execution_frost_native.go | 69 +++---- .../frost_dkg_execution_frost_native_test.go | 42 +---- pkg/tbtc/frost_dkg_retirement_default.go | 4 +- pkg/tbtc/frost_dkg_retirement_frost_native.go | 169 ++++++------------ .../frost_dkg_retirement_frost_native_test.go | 117 ++++++------ pkg/tbtc/frost_retained_group_journal.go | 14 +- 12 files changed, 542 insertions(+), 274 deletions(-) diff --git a/pkg/chain/ethereum/frost_dkg.go b/pkg/chain/ethereum/frost_dkg.go index a766bd0f30..9a48df308e 100644 --- a/pkg/chain/ethereum/frost_dkg.go +++ b/pkg/chain/ethereum/frost_dkg.go @@ -7,6 +7,7 @@ import ( "sort" "time" + ethabi "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -649,6 +650,117 @@ func (tc *TbtcChain) GetFrostDKGState() (tbtc.DKGState, error) { return tbtc.DKGState(state), nil } +// FrostDKGRetirementSnapshot reads every predicate used for irreversible +// native-package retirement at one exact finalized block. The independent +// authorization verifier uses EIP-1898 requireCanonical calls, so a lagging or +// forked endpoint fails closed instead of silently serving latest state. +func (tc *TbtcChain) FrostDKGRetirementSnapshot( + ctx context.Context, + point tbtc.FrostPreSignFinality, + walletIDs [][32]byte, +) (*tbtc.FrostDKGRetirementSnapshot, error) { + if ctx == nil || point.BlockNumber == 0 || + point.BlockHash == [32]byte{} || + tc.frostWalletRegistryAddr == (common.Address{}) { + return nil, fmt.Errorf("FROST DKG retirement snapshot identity is incomplete") + } + _, verifier, err := tc.frostPreSignAdapterPair() + if err != nil { + return nil, err + } + verifyPoint := func() error { + header, err := verifier.reader.HeaderByNumber( + ctx, + new(big.Int).SetUint64(point.BlockNumber), + ) + if err != nil { + return err + } + if header == nil || header.Number == nil || + !header.Number.IsUint64() || + header.Number.Uint64() != point.BlockNumber || + header.Hash() != common.Hash(point.BlockHash) { + return fmt.Errorf( + "FROST DKG retirement point is not canonical by height", + ) + } + return nil + } + if err := verifyPoint(); err != nil { + return nil, err + } + + registryABI, err := frostabi.FrostWalletRegistryMetaData.GetAbi() + if err != nil { + return nil, fmt.Errorf("cannot load FROST wallet registry ABI: [%w]", err) + } + stateOutput, err := verifier.callAtHash( + ctx, + tc.frostWalletRegistryAddr, + *registryABI, + "getWalletCreationState", + common.Hash(point.BlockHash), + ) + if err != nil { + return nil, fmt.Errorf( + "cannot read exact FROST DKG state: [%w]", + err, + ) + } + if len(stateOutput) != 1 { + return nil, fmt.Errorf("exact FROST DKG state response is malformed") + } + state := tbtc.DKGState( + *ethabi.ConvertType(stateOutput[0], new(uint8)).(*uint8), + ) + if state < tbtc.Idle || state > tbtc.Challenge { + return nil, fmt.Errorf("exact FROST DKG state is invalid") + } + + registeredWallets := make(map[[32]byte]bool, len(walletIDs)) + for _, walletID := range walletIDs { + if _, duplicate := registeredWallets[walletID]; duplicate { + return nil, fmt.Errorf( + "FROST DKG retirement snapshot contains a duplicate wallet", + ) + } + registrationOutput, err := verifier.callAtHash( + ctx, + tc.frostWalletRegistryAddr, + *registryABI, + "isWalletRegistered", + common.Hash(point.BlockHash), + walletID, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot read exact FROST registration for wallet [0x%x]: [%w]", + walletID, + err, + ) + } + if len(registrationOutput) != 1 { + return nil, fmt.Errorf( + "exact FROST registration response is malformed", + ) + } + registeredWallets[walletID] = + *ethabi.ConvertType(registrationOutput[0], new(bool)).(*bool) + } + if err := verifyPoint(); err != nil { + return nil, fmt.Errorf( + "FROST DKG retirement point changed during snapshot: [%w]", + err, + ) + } + + return &tbtc.FrostDKGRetirementSnapshot{ + Point: point, + State: state, + RegisteredWallets: registeredWallets, + }, nil +} + // IsFrostDKGResultValid validates the submitted FROST DKG result using the // registry-level view. This intentionally avoids passing seed/startBlock from // off-chain code. diff --git a/pkg/chain/ethereum/frost_dkg_test.go b/pkg/chain/ethereum/frost_dkg_test.go index 51fef57385..72046501fa 100644 --- a/pkg/chain/ethereum/frost_dkg_test.go +++ b/pkg/chain/ethereum/frost_dkg_test.go @@ -1,9 +1,16 @@ package ethereum import ( + "context" + "fmt" + "math/big" "testing" + geth "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi" + "github.com/keep-network/keep-core/pkg/tbtc" ) func TestTbtcChainFrostWalletRegistryAvailable(t *testing.T) { @@ -19,3 +26,102 @@ func TestTbtcChainFrostWalletRegistryAvailable(t *testing.T) { t.Fatal("expected FROST wallet registry to be available") } } + +type frostDKGRetirementSnapshotTestReader struct { + *testFrostPreSignEvidenceReader + registryAddress common.Address + registered map[[32]byte]bool + callHashes []common.Hash +} + +func (reader *frostDKGRetirementSnapshotTestReader) CallContractAtHash( + _ context.Context, + message geth.CallMsg, + blockHash common.Hash, +) ([]byte, error) { + if message.To == nil || *message.To != reader.registryAddress { + return nil, fmt.Errorf("unexpected retirement snapshot contract") + } + registryABI, err := frostabi.FrostWalletRegistryMetaData.GetAbi() + if err != nil { + return nil, err + } + method, err := registryABI.MethodById(message.Data[:4]) + if err != nil { + return nil, err + } + reader.callHashes = append(reader.callHashes, blockHash) + switch method.Name { + case "getWalletCreationState": + return method.Outputs.Pack(uint8(tbtc.Challenge)) + case "isWalletRegistered": + values, err := method.Inputs.Unpack(message.Data[4:]) + if err != nil || len(values) != 1 { + return nil, fmt.Errorf("cannot decode retirement wallet ID") + } + walletID, ok := values[0].([32]byte) + if !ok { + return nil, fmt.Errorf("retirement wallet ID has an invalid type") + } + return method.Outputs.Pack(reader.registered[walletID]) + default: + return nil, fmt.Errorf("unexpected retirement snapshot method [%s]", method.Name) + } +} + +func TestFrostDKGRetirementSnapshotPinsEveryPredicateToOnePoint( + t *testing.T, +) { + header := &types.Header{ + Number: big.NewInt(100), + Time: 100, + Extra: []byte{0xaa}, + } + registryAddress := common.HexToAddress( + "0x0000000000000000000000000000000000000011", + ) + registeredWalletID := [32]byte{2} + reader := &frostDKGRetirementSnapshotTestReader{ + testFrostPreSignEvidenceReader: &testFrostPreSignEvidenceReader{ + finalized: header, + }, + registryAddress: registryAddress, + registered: map[[32]byte]bool{ + registeredWalletID: true, + }, + } + chain := &TbtcChain{ + frostWalletRegistryAddr: registryAddress, + frostPreSignAuthorizationAdapter: &frostPreSignEthereumAdapter{ + reader: reader, + }, + frostPreSignAuthorizationVerifier: &frostPreSignEthereumAdapter{ + reader: reader, + }, + } + point := tbtc.FrostPreSignFinality{ + BlockNumber: header.Number.Uint64(), + BlockHash: header.Hash(), + } + snapshot, err := chain.FrostDKGRetirementSnapshot( + context.Background(), + point, + [][32]byte{{1}, registeredWalletID}, + ) + if err != nil { + t.Fatal(err) + } + if snapshot.Point != point || snapshot.State != tbtc.Challenge || + snapshot.RegisteredWallets[[32]byte{1}] || + !snapshot.RegisteredWallets[registeredWalletID] { + t.Fatalf("unexpected retirement snapshot: [%+v]", snapshot) + } + if len(reader.callHashes) != 3 { + t.Fatalf("unexpected exact-hash call count: [%d]", len(reader.callHashes)) + } + for _, callHash := range reader.callHashes { + if callHash != common.Hash(point.BlockHash) { + t.Fatalf("retirement predicate used a different block: [%s]", callHash) + } + } +} diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index eecbe6e157..087f001e27 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -2,6 +2,7 @@ package tbtc import ( "bytes" + "context" "crypto/ecdsa" "crypto/sha256" "encoding/binary" @@ -1011,6 +1012,39 @@ func (lc *localChain) IsFrostWalletRegistered(walletID [32]byte) (bool, error) { return false, nil } +func (lc *localChain) FrostDKGRetirementSnapshot( + _ context.Context, + point FrostPreSignFinality, + walletIDs [][32]byte, +) (*FrostDKGRetirementSnapshot, error) { + lc.dkgMutex.Lock() + state := lc.dkgState + lc.dkgMutex.Unlock() + + lc.walletsMutex.Lock() + defer lc.walletsMutex.Unlock() + registeredWallets := make(map[[32]byte]bool, len(walletIDs)) + for _, walletID := range walletIDs { + for _, walletData := range lc.wallets { + if walletID == walletData.WalletID && + walletData.State != StateClosed && + walletData.State != StateTerminated { + registeredWallets[walletID] = true + break + } + } + if _, exists := registeredWallets[walletID]; !exists { + registeredWallets[walletID] = false + } + } + + return &FrostDKGRetirementSnapshot{ + Point: point, + State: state, + RegisteredWallets: registeredWallets, + }, nil +} + func (lc *localChain) setWallet( walletPublicKeyHash [20]byte, walletChainData *WalletChainData, diff --git a/pkg/tbtc/frost_dkg_chain.go b/pkg/tbtc/frost_dkg_chain.go index 94a168fb9f..1dc6b96a19 100644 --- a/pkg/tbtc/frost_dkg_chain.go +++ b/pkg/tbtc/frost_dkg_chain.go @@ -1,6 +1,7 @@ package tbtc import ( + "context" "math/big" "github.com/keep-network/keep-core/pkg/chain" @@ -8,6 +9,25 @@ import ( "github.com/keep-network/keep-core/pkg/subscription" ) +// FrostDKGRetirementSnapshot binds every chain predicate used to retire local +// DKG packages to one exact finalized block. +type FrostDKGRetirementSnapshot struct { + Point FrostPreSignFinality + State DKGState + RegisteredWallets map[[32]byte]bool +} + +// frostDKGRetirementSnapshotChain exposes exact-hash DKG state used only by +// finalized retained-history reconciliation. Implementations must reject a +// noncanonical point rather than falling back to latest-state reads. +type frostDKGRetirementSnapshotChain interface { + FrostDKGRetirementSnapshot( + context.Context, + FrostPreSignFinality, + [][32]byte, + ) (*FrostDKGRetirementSnapshot, error) +} + // FrostDKGChain defines the FROST wallet-registry chain surface. It is kept // separate from the legacy ECDSA DKG chain so the existing coordinator remains // unchanged until FROST creation is explicitly enabled. diff --git a/pkg/tbtc/frost_dkg_coordinator.go b/pkg/tbtc/frost_dkg_coordinator.go index 4e754d8724..084273a74e 100644 --- a/pkg/tbtc/frost_dkg_coordinator.go +++ b/pkg/tbtc/frost_dkg_coordinator.go @@ -7,6 +7,15 @@ import ( "github.com/keep-network/keep-core/pkg/protocol/group" ) +type frostDKGExecutor func( + context.Context, + *node, + FrostDKGChain, + *FrostDKGStartedEvent, + []group.MemberIndex, + *GroupSelectionResult, +) bool + func initializeFrostDKGCoordinator( ctx context.Context, node *node, @@ -61,6 +70,26 @@ func handleFrostDKGStarted( deduplicator *deduplicator, event *FrostDKGStartedEvent, waitForConfirmation bool, +) { + handleFrostDKGStartedWithExecutor( + ctx, + node, + frostChain, + deduplicator, + event, + waitForConfirmation, + executeFrostDKGIfPossible, + ) +} + +func handleFrostDKGStartedWithExecutor( + ctx context.Context, + node *node, + frostChain FrostDKGChain, + deduplicator *deduplicator, + event *FrostDKGStartedEvent, + waitForConfirmation bool, + execute frostDKGExecutor, ) { lease, ok := deduplicator.beginDKGStarted(event.Seed) if !ok { @@ -146,13 +175,14 @@ func handleFrostDKGStarted( // failed and released its lease, this path safely becomes the retry. completed = true lease.finish(completed) - handleFrostDKGStarted( + handleFrostDKGStartedWithExecutor( ctx, node, frostChain, deduplicator, lastEvent, waitForConfirmation, + execute, ) return } @@ -177,7 +207,7 @@ func handleFrostDKGStarted( return } - completed = executeFrostDKGIfPossible( + completed = execute( ctx, node, frostChain, diff --git a/pkg/tbtc/frost_dkg_coordinator_test.go b/pkg/tbtc/frost_dkg_coordinator_test.go index bc45727afc..bc36c2c4d6 100644 --- a/pkg/tbtc/frost_dkg_coordinator_test.go +++ b/pkg/tbtc/frost_dkg_coordinator_test.go @@ -10,11 +10,12 @@ import ( "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/frost/registry" + "github.com/keep-network/keep-core/pkg/protocol/group" ) func TestHandleFrostDKGStartedReleasesDeduplicationKeyAfterFailure(t *testing.T) { localChain := Connect() - node := &node{chain: localChain} + testNode := &node{chain: localChain} event := &FrostDKGStartedEvent{Seed: big.NewInt(100), BlockNumber: 500} frostChain := &transientFrostDKGStartedChain{event: event} deduplicator := newDeduplicator() @@ -24,7 +25,7 @@ func TestHandleFrostDKGStartedReleasesDeduplicationKeyAfterFailure(t *testing.T) for i := 0; i < 4; i++ { handleFrostDKGStarted( context.Background(), - node, + testNode, frostChain, deduplicator, event, @@ -47,7 +48,7 @@ func TestHandleFrostDKGStartedReleasesDeduplicationKeyAfterFailure(t *testing.T) // terminal local handling; further duplicates must stay suppressed. handleFrostDKGStarted( context.Background(), - node, + testNode, frostChain, deduplicator, event, @@ -58,6 +59,65 @@ func TestHandleFrostDKGStartedReleasesDeduplicationKeyAfterFailure(t *testing.T) } } +func TestHandleFrostDKGStartedRetriesWhenExecutionAdmissionFails( + t *testing.T, +) { + localChain := Connect() + operatorAddress, err := localChain.operatorAddress() + if err != nil { + t.Fatal(err) + } + testNode := &node{chain: localChain} + event := &FrostDKGStartedEvent{Seed: big.NewInt(101), BlockNumber: 500} + frostChain := &retryableFrostDKGExecutionChain{ + event: event, + operatorAddress: operatorAddress, + } + deduplicator := newDeduplicator() + executionAttempts := 0 + execute := func( + context.Context, + *node, + FrostDKGChain, + *FrostDKGStartedEvent, + []group.MemberIndex, + *GroupSelectionResult, + ) bool { + executionAttempts++ + return executionAttempts > 1 + } + + handleFrostDKGStartedWithExecutor( + context.Background(), + testNode, + frostChain, + deduplicator, + event, + false, + execute, + ) + if executionAttempts != 1 { + t.Fatalf("unexpected first execution attempt count: [%d]", executionAttempts) + } + if deduplicator.dkgSeedCache.Has(event.Seed.Text(16)) { + t.Fatal("transient execution admission failure completed the DKG seed") + } + + handleFrostDKGStartedWithExecutor( + context.Background(), + testNode, + frostChain, + deduplicator, + event, + false, + execute, + ) + if executionAttempts != 2 { + t.Fatalf("DKG execution admission was not retried") + } + assertFrostDKGStartedSeedCompleted(t, deduplicator, event.Seed) +} + func TestHandleFrostDKGStartedRekeysToLatestSeedAlreadyInProgress( t *testing.T, ) { @@ -368,6 +428,35 @@ type transientFrostDKGStartedChain struct { selectionCalls int } +type retryableFrostDKGExecutionChain struct { + FrostDKGChain + + event *FrostDKGStartedEvent + operatorAddress chain.Address +} + +func (testChain *retryableFrostDKGExecutionChain) GetFrostDKGState() ( + DKGState, + error, +) { + return AwaitingResult, nil +} + +func (testChain *retryableFrostDKGExecutionChain) PastFrostDKGStartedEvents( + *FrostDKGStartedEventFilter, +) ([]*FrostDKGStartedEvent, error) { + return []*FrostDKGStartedEvent{testChain.event}, nil +} + +func (testChain *retryableFrostDKGExecutionChain) SelectFrostGroup() ( + *GroupSelectionResult, + error, +) { + return &GroupSelectionResult{ + OperatorsAddresses: chain.Addresses{testChain.operatorAddress}, + }, nil +} + type rekeyingFrostDKGStartedChain struct { FrostDKGChain diff --git a/pkg/tbtc/frost_dkg_execution_frost_native.go b/pkg/tbtc/frost_dkg_execution_frost_native.go index e94b43a07b..3ba8d38b71 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native.go @@ -117,7 +117,22 @@ func executeFrostDKGIfPossible( fullMembers := frostFullMembers(groupSelectionResult) dkgTimeoutBlock := event.BlockNumber + params.SubmissionTimeoutBlocks + // Acquire the reversible capacity reservation before crossing the + // goroutine boundary. A transient admission failure must be visible to the + // coordinator so it releases the event lease and a replay can retry. + anchorReservation, err := reserveFrostDKGReadiness( + ctx, + node.frostNativeSignerAnchorAdmission, + memberIndexes, + ) + if err != nil { + logger.Errorf("FROST DKG anchor admission failed: [%v]", err) + return false + } + go func() { + defer anchorReservation.Release() + dkgLogger := logger.With( zap.String("seed", fmt.Sprintf("0x%x", event.Seed)), zap.String("memberIndexes", fmt.Sprintf("%v", memberIndexes)), @@ -169,20 +184,12 @@ func executeFrostDKGIfPossible( len(groupSelectionResult.OperatorsIDs), ) } - readinessAdmission, err := reserveAndAnnounceFrostDKGReadiness( - dkgCtx, - node.frostNativeSignerAnchorAdmission, - memberIndexes, - announceReadiness, - ) + activeMemberIndexes, misbehavedMembersIndices, err := + announceReadiness() if err != nil { - dkgLogger.Errorf("FROST DKG admission or readiness failed: [%v]", err) + dkgLogger.Errorf("FROST DKG readiness failed: [%v]", err) return } - defer readinessAdmission.anchorReservation.Release() - activeMemberIndexes := readinessAdmission.activeMemberIndexes - misbehavedMembersIndices := - readinessAdmission.misbehavedMembersIndices localActiveMemberIndexes := localActiveFrostMemberIndexes( memberIndexes, @@ -304,23 +311,12 @@ func executeFrostDKGIfPossible( return true } -type frostDKGReadinessAdmission struct { - anchorReservation *frostNativeSignerAnchorRevisionReservation - activeMemberIndexes []group.MemberIndex - misbehavedMembersIndices registry.MisbehavedMemberIndices -} - -func reserveAndAnnounceFrostDKGReadiness( +func reserveFrostDKGReadiness( ctx context.Context, anchorAdmission *frostNativeSignerAnchorAdmissionController, localMemberIndexes []group.MemberIndex, - announce func() ( - []group.MemberIndex, - registry.MisbehavedMemberIndices, - error, - ), ) ( - *frostDKGReadinessAdmission, + *frostNativeSignerAnchorRevisionReservation, error, ) { if anchorAdmission == nil { @@ -328,14 +324,9 @@ func reserveAndAnnounceFrostDKGReadiness( "native signer anchor admission is unavailable", ) } - if announce == nil { - return nil, fmt.Errorf( - "FROST DKG readiness announcement is unavailable", - ) - } - // Every selected local seat may become active once readiness is announced. - // Reserve that worst-case persistence cost before invoking the announcer. + // Reserve that worst-case persistence cost before starting asynchronous + // readiness work. anchorReservation, err := anchorAdmission.reserveDKG( ctx, uint64(len(localMemberIndexes)), @@ -346,21 +337,7 @@ func reserveAndAnnounceFrostDKGReadiness( err, ) } - - activeMemberIndexes, misbehavedMembersIndices, err := announce() - if err != nil { - anchorReservation.Release() - return nil, fmt.Errorf( - "readiness announcement failed: [%w]", - err, - ) - } - - return &frostDKGReadinessAdmission{ - anchorReservation: anchorReservation, - activeMemberIndexes: activeMemberIndexes, - misbehavedMembersIndices: misbehavedMembersIndices, - }, nil + return anchorReservation, nil } type frostDKGExecutionResult struct { diff --git a/pkg/tbtc/frost_dkg_execution_frost_native_test.go b/pkg/tbtc/frost_dkg_execution_frost_native_test.go index 35b0c42205..e4bb249f6d 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native_test.go @@ -17,7 +17,7 @@ import ( "github.com/keep-network/keep-core/pkg/protocol/group" ) -func TestReserveAndAnnounceFrostDKGReadiness_AdmissionFailureDoesNotAnnounce( +func TestReserveFrostDKGReadiness_AdmissionFailureIsSynchronous( t *testing.T, ) { controller := &frostNativeSignerAnchorAdmissionController{ @@ -35,33 +35,20 @@ func TestReserveAndAnnounceFrostDKGReadiness_AdmissionFailureDoesNotAnnounce( Generations: FrostNativeSignerAnchorRotationWarningHeadroom, }, } - announced := false - - admission, err := reserveAndAnnounceFrostDKGReadiness( + reservation, err := reserveFrostDKGReadiness( context.Background(), controller, []group.MemberIndex{1, 2}, - func() ( - []group.MemberIndex, - registry.MisbehavedMemberIndices, - error, - ) { - announced = true - return nil, nil, nil - }, ) if err == nil || !strings.Contains(err.Error(), "unreserved") { t.Fatalf("unexpected DKG admission result: [%v]", err) } - if admission != nil { + if reservation != nil { t.Fatal("failed DKG admission returned a reservation") } - if announced { - t.Fatal("DKG readiness was announced after admission failed") - } } -func TestReserveAndAnnounceFrostDKGReadiness_ReservesEverySelectedLocalSeat( +func TestReserveFrostDKGReadiness_ReservesEverySelectedLocalSeat( t *testing.T, ) { controller := &frostNativeSignerAnchorAdmissionController{ @@ -77,31 +64,18 @@ func TestReserveAndAnnounceFrostDKGReadiness_ReservesEverySelectedLocalSeat( } localMemberIndexes := []group.MemberIndex{2, 4, 6} - admission, err := - reserveAndAnnounceFrostDKGReadiness( + reservation, err := + reserveFrostDKGReadiness( context.Background(), controller, localMemberIndexes, - func() ( - []group.MemberIndex, - registry.MisbehavedMemberIndices, - error, - ) { - return []group.MemberIndex{1, 2}, nil, nil - }, ) if err != nil { t.Fatal(err) } - if admission == nil || admission.anchorReservation == nil { + if reservation == nil { t.Fatal("successful DKG admission returned no reservation") } - if len(admission.activeMemberIndexes) != 2 { - t.Fatalf( - "unexpected active member indexes: [%v]", - admission.activeMemberIndexes, - ) - } expectedPersistenceCalls := uint64(len(localMemberIndexes) * 2) if controller.reserved.Revisions != expectedPersistenceCalls || controller.reserved.Generations != expectedPersistenceCalls { @@ -111,7 +85,7 @@ func TestReserveAndAnnounceFrostDKGReadiness_ReservesEverySelectedLocalSeat( ) } - admission.anchorReservation.Release() + reservation.Release() if controller.reserved != (frostNativeSignerAnchorCapacity{}) { t.Fatalf( "released DKG reservation remained charged: [%+v]", diff --git a/pkg/tbtc/frost_dkg_retirement_default.go b/pkg/tbtc/frost_dkg_retirement_default.go index 1ce9c9d38a..5b147bde32 100644 --- a/pkg/tbtc/frost_dkg_retirement_default.go +++ b/pkg/tbtc/frost_dkg_retirement_default.go @@ -2,14 +2,12 @@ package tbtc -import "context" - func newFrostOrphanedDKGReconciler( _ Chain, _ *walletRegistry, _ *frostNativeSignerAnchorAdmissionController, ) ( - func(context.Context, map[[32]byte]struct{}) error, + frostOrphanedDKGReconcilerFunc, error, ) { return nil, nil diff --git a/pkg/tbtc/frost_dkg_retirement_frost_native.go b/pkg/tbtc/frost_dkg_retirement_frost_native.go index 088b16dbad..ee7e04e4ff 100644 --- a/pkg/tbtc/frost_dkg_retirement_frost_native.go +++ b/pkg/tbtc/frost_dkg_retirement_frost_native.go @@ -8,23 +8,15 @@ import ( "fmt" "sort" - "github.com/keep-network/keep-core/pkg/frost/registry" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" ) -type frostPendingDKGDescriptor struct { - walletID [32]byte - participantCount uint16 - preserveAll bool -} - type frostOrphanedDKGReconciler struct { - dkgChain FrostDKGChain - registrationChain frostWalletRegistrationChain - walletRegistry *walletRegistry - anchorAdmission *frostNativeSignerAnchorAdmissionController - retirementEngine frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine - readInventory func() (*frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error) + snapshotChain frostDKGRetirementSnapshotChain + walletRegistry *walletRegistry + anchorAdmission *frostNativeSignerAnchorAdmissionController + retirementEngine frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine + readInventory func() (*frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error) } type frostDKGRetirementCandidate struct { @@ -41,7 +33,7 @@ func newFrostOrphanedDKGReconciler( walletRegistry *walletRegistry, anchorAdmission *frostNativeSignerAnchorAdmissionController, ) ( - func(context.Context, map[[32]byte]struct{}) error, + frostOrphanedDKGReconcilerFunc, error, ) { if chain == nil || walletRegistry == nil || anchorAdmission == nil { @@ -49,16 +41,10 @@ func newFrostOrphanedDKGReconciler( "orphaned FROST DKG reconciliation dependencies are incomplete", ) } - dkgChain, ok := chain.(FrostDKGChain) + snapshotChain, ok := chain.(frostDKGRetirementSnapshotChain) if !ok { return nil, fmt.Errorf( - "chain does not expose FROST DKG state for orphan reconciliation", - ) - } - registrationChain, ok := chain.(frostWalletRegistrationChain) - if !ok { - return nil, fmt.Errorf( - "chain does not expose FROST wallet registration for orphan reconciliation", + "chain does not expose exact finalized FROST DKG snapshots for orphan reconciliation", ) } retirementEngine, ok := frostsigning.CurrentNativeTBTCSignerEngine().(frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine) @@ -68,11 +54,10 @@ func newFrostOrphanedDKGReconciler( ) } reconciler := &frostOrphanedDKGReconciler{ - dkgChain: dkgChain, - registrationChain: registrationChain, - walletRegistry: walletRegistry, - anchorAdmission: anchorAdmission, - retirementEngine: retirementEngine, + snapshotChain: snapshotChain, + walletRegistry: walletRegistry, + anchorAdmission: anchorAdmission, + retirementEngine: retirementEngine, readInventory: frostsigning. ReadNativeTBTCSignerRetainedKeyPackageInventory, } @@ -81,12 +66,16 @@ func newFrostOrphanedDKGReconciler( func (reconciler *frostOrphanedDKGReconciler) reconcile( ctx context.Context, + target FrostPreSignFinality, canonicalWallets map[[32]byte]struct{}, ) error { if reconciler == nil || ctx == nil || canonicalWallets == nil || - reconciler.readInventory == nil { + reconciler.snapshotChain == nil || reconciler.readInventory == nil { return fmt.Errorf("orphaned FROST DKG reconciliation is not configured") } + if target.BlockNumber == 0 || target.BlockHash == [32]byte{} { + return fmt.Errorf("orphaned FROST DKG reconciliation point is invalid") + } if err := ctx.Err(); err != nil { return err } @@ -145,40 +134,47 @@ func (reconciler *frostOrphanedDKGReconciler) reconcile( return bytes.Compare(walletIDs[i][:], walletIDs[j][:]) < 0 }) - var pendingRead bool - var pending *frostPendingDKGDescriptor - retirements := make([]*frostDKGRetirementCandidate, 0) + noncanonicalWalletIDs := make([][32]byte, 0, len(walletIDs)) for _, walletID := range walletIDs { - candidate := candidatesByWallet[walletID] - if _, canonical := canonicalWallets[walletID]; canonical { - continue + if _, canonical := canonicalWallets[walletID]; !canonical { + noncanonicalWalletIDs = append(noncanonicalWalletIDs, walletID) } - registered, err := - reconciler.registrationChain.IsFrostWalletRegistered(walletID) - if err != nil { + } + if len(noncanonicalWalletIDs) == 0 { + return nil + } + snapshot, err := reconciler.snapshotChain.FrostDKGRetirementSnapshot( + ctx, + target, + noncanonicalWalletIDs, + ) + if err != nil { + return fmt.Errorf( + "cannot read finalized FROST DKG retirement snapshot: [%w]", + err, + ) + } + if snapshot == nil || snapshot.Point != target || + snapshot.State < Idle || snapshot.State > Challenge || + len(snapshot.RegisteredWallets) != len(noncanonicalWalletIDs) { + return fmt.Errorf("finalized FROST DKG retirement snapshot is incomplete") + } + + unresolvedDKG := snapshot.State == AwaitingResult || + snapshot.State == Challenge + retirements := make([]*frostDKGRetirementCandidate, 0) + for _, walletID := range noncanonicalWalletIDs { + candidate := candidatesByWallet[walletID] + registered, present := snapshot.RegisteredWallets[walletID] + if !present { return fmt.Errorf( - "cannot check FROST registration for wallet [0x%x]: [%w]", + "finalized FROST DKG retirement snapshot omits wallet [0x%x]", walletID, - err, ) } - if registered { + if registered || unresolvedDKG { continue } - if !pendingRead { - pending, err = reconciler.currentPendingDescriptor() - if err != nil { - return err - } - pendingRead = true - } - if pending != nil { - if pending.preserveAll || - (pending.walletID == candidate.walletID && - pending.participantCount == candidate.participantCount) { - continue - } - } retirements = append(retirements, candidate) } @@ -228,68 +224,3 @@ func (reconciler *frostOrphanedDKGReconciler) reconcile( } return nil } - -func (reconciler *frostOrphanedDKGReconciler) currentPendingDescriptor() ( - *frostPendingDKGDescriptor, - error, -) { - state, err := reconciler.dkgChain.GetFrostDKGState() - if err != nil { - return nil, fmt.Errorf("cannot read current FROST DKG state: [%w]", err) - } - if state == AwaitingResult { - // A package may have been persisted by the current DKG, or its result - // transaction may have been accepted despite an ambiguous RPC error. - // Without an on-chain output key there is no exact identity to compare, - // so preserve noncanonical packages until Challenge or timeout makes the - // outcome provable. This can delay readiness but cannot release signing. - return &frostPendingDKGDescriptor{preserveAll: true}, nil - } - if state != Challenge { - return nil, nil - } - events, err := reconciler.dkgChain.PastFrostDKGResultSubmittedEvents(nil) - if err != nil { - return nil, fmt.Errorf("cannot read pending FROST DKG result: [%w]", err) - } - var latest *FrostDKGResultSubmittedEvent - for _, event := range events { - if event == nil || event.Result == nil { - continue - } - if latest == nil || event.BlockNumber >= latest.BlockNumber { - latest = event - } - } - if latest == nil { - return nil, fmt.Errorf( - "FROST DKG state is Challenge but no submitted result is available", - ) - } - valid, _, err := reconciler.dkgChain.IsFrostDKGResultValid(latest.Result) - if err != nil { - return nil, fmt.Errorf("cannot validate pending FROST DKG result: [%w]", err) - } - if !valid { - // A successfully challenged invalid result returns this same DKG - // attempt to AwaitingResult, where another member may submit the valid - // result backed by the packages already persisted by this node. The - // invalid submission gives us no trustworthy wallet identity to match, - // so retain every noncanonical package until the attempt is resolved. - return &frostPendingDKGDescriptor{preserveAll: true}, nil - } - activeMembers, err := registry.ActiveMembersFromMisbehaved( - latest.Result.Members, - latest.Result.MisbehavedMembersIndices, - ) - if err != nil { - return nil, fmt.Errorf("invalid pending FROST DKG members: [%w]", err) - } - if len(activeMembers) > int(^uint16(0)) { - return nil, fmt.Errorf("pending FROST DKG participant count overflows") - } - return &frostPendingDKGDescriptor{ - walletID: [32]byte(latest.Result.XOnlyOutputKey), - participantCount: uint16(len(activeMembers)), - }, nil -} diff --git a/pkg/tbtc/frost_dkg_retirement_frost_native_test.go b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go index 90aff5097f..b71c9ccd6c 100644 --- a/pkg/tbtc/frost_dkg_retirement_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go @@ -7,45 +7,30 @@ import ( "encoding/hex" "testing" - "github.com/keep-network/keep-core/pkg/frost" - "github.com/keep-network/keep-core/pkg/frost/registry" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" ) -type orphanedDKGRegistrationTestChain struct { +type orphanedDKGSnapshotTestChain struct { + state DKGState registered map[[32]byte]bool + points []FrostPreSignFinality } -func (chain *orphanedDKGRegistrationTestChain) IsFrostWalletRegistered( - walletID [32]byte, -) (bool, error) { - return chain.registered[walletID], nil -} - -type orphanedDKGStateTestChain struct { - FrostDKGChain - state DKGState - events []*FrostDKGResultSubmittedEvent - valid bool -} - -func (chain *orphanedDKGStateTestChain) GetFrostDKGState() ( - DKGState, - error, -) { - return chain.state, nil -} - -func (chain *orphanedDKGStateTestChain) PastFrostDKGResultSubmittedEvents( - *FrostDKGResultSubmittedEventFilter, -) ([]*FrostDKGResultSubmittedEvent, error) { - return chain.events, nil -} - -func (chain *orphanedDKGStateTestChain) IsFrostDKGResultValid( - *registry.Result, -) (bool, string, error) { - return chain.valid, "", nil +func (chain *orphanedDKGSnapshotTestChain) FrostDKGRetirementSnapshot( + _ context.Context, + point FrostPreSignFinality, + walletIDs [][32]byte, +) (*FrostDKGRetirementSnapshot, error) { + chain.points = append(chain.points, point) + registered := make(map[[32]byte]bool, len(walletIDs)) + for _, walletID := range walletIDs { + registered[walletID] = chain.registered[walletID] + } + return &FrostDKGRetirementSnapshot{ + Point: point, + State: chain.state, + RegisteredWallets: registered, + }, nil } type orphanedDKGRetirementTestEngine struct { @@ -63,11 +48,12 @@ func TestFrostOrphanedDKGReconcilerRetiresNativeOnlyOrphan(t *testing.T) { walletID := [32]byte{1} const keyGroup = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" engine := &orphanedDKGRetirementTestEngine{} + snapshotChain := &orphanedDKGSnapshotTestChain{ + state: Idle, + registered: make(map[[32]byte]bool), + } reconciler := &frostOrphanedDKGReconciler{ - dkgChain: &orphanedDKGStateTestChain{state: Idle}, - registrationChain: &orphanedDKGRegistrationTestChain{ - registered: make(map[[32]byte]bool), - }, + snapshotChain: snapshotChain, walletRegistry: &walletRegistry{ walletCache: make(map[string]*walletCacheValue), }, @@ -97,12 +83,20 @@ func TestFrostOrphanedDKGReconcilerRetiresNativeOnlyOrphan(t *testing.T) { }, } + target := orphanedDKGTestPoint() if err := reconciler.reconcile( context.Background(), + target, map[[32]byte]struct{}{}, ); err != nil { t.Fatal(err) } + if len(snapshotChain.points) != 1 || snapshotChain.points[0] != target { + t.Fatalf( + "retirement was not pinned to the journal point: [%+v]", + snapshotChain.points, + ) + } if len(engine.retired) != 1 || engine.retired[0] != keyGroup { t.Fatalf("unexpected retired key groups: [%v]", engine.retired) } @@ -120,8 +114,8 @@ func TestFrostOrphanedDKGReconcilerPreservesCanonicalAndRegistered(t *testing.T) registeredWalletID := [32]byte{2} engine := &orphanedDKGRetirementTestEngine{} reconciler := &frostOrphanedDKGReconciler{ - dkgChain: &orphanedDKGStateTestChain{state: Idle}, - registrationChain: &orphanedDKGRegistrationTestChain{ + snapshotChain: &orphanedDKGSnapshotTestChain{ + state: Idle, registered: map[[32]byte]bool{registeredWalletID: true}, }, walletRegistry: &walletRegistry{ @@ -150,6 +144,7 @@ func TestFrostOrphanedDKGReconcilerPreservesCanonicalAndRegistered(t *testing.T) if err := reconciler.reconcile( context.Background(), + orphanedDKGTestPoint(), map[[32]byte]struct{}{canonicalWalletID: {}}, ); err != nil { t.Fatal(err) @@ -163,8 +158,8 @@ func TestFrostOrphanedDKGReconcilerPreservesAwaitingResultMaterial(t *testing.T) walletID := [32]byte{3} engine := &orphanedDKGRetirementTestEngine{} reconciler := &frostOrphanedDKGReconciler{ - dkgChain: &orphanedDKGStateTestChain{state: AwaitingResult}, - registrationChain: &orphanedDKGRegistrationTestChain{ + snapshotChain: &orphanedDKGSnapshotTestChain{ + state: AwaitingResult, registered: make(map[[32]byte]bool), }, walletRegistry: &walletRegistry{ @@ -187,6 +182,7 @@ func TestFrostOrphanedDKGReconcilerPreservesAwaitingResultMaterial(t *testing.T) if err := reconciler.reconcile( context.Background(), + orphanedDKGTestPoint(), map[[32]byte]struct{}{}, ); err != nil { t.Fatal(err) @@ -196,22 +192,15 @@ func TestFrostOrphanedDKGReconcilerPreservesAwaitingResultMaterial(t *testing.T) } } -func TestFrostOrphanedDKGReconcilerPreservesMaterialDuringInvalidChallenge( +func TestFrostOrphanedDKGReconcilerPreservesMaterialDuringChallenge( t *testing.T, ) { walletID := orphanedDKGTestWalletID(t) const keyGroup = "0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" engine := &orphanedDKGRetirementTestEngine{} reconciler := &frostOrphanedDKGReconciler{ - dkgChain: &orphanedDKGStateTestChain{ - state: Challenge, - events: []*FrostDKGResultSubmittedEvent{{ - Result: ®istry.Result{}, - BlockNumber: 100, - }}, - valid: false, - }, - registrationChain: &orphanedDKGRegistrationTestChain{ + snapshotChain: &orphanedDKGSnapshotTestChain{ + state: Challenge, registered: make(map[[32]byte]bool), }, walletRegistry: &walletRegistry{ @@ -235,6 +224,7 @@ func TestFrostOrphanedDKGReconcilerPreservesMaterialDuringInvalidChallenge( if err := reconciler.reconcile( context.Background(), + orphanedDKGTestPoint(), map[[32]byte]struct{}{}, ); err != nil { t.Fatal(err) @@ -244,14 +234,10 @@ func TestFrostOrphanedDKGReconcilerPreservesMaterialDuringInvalidChallenge( } } -func TestFrostOrphanedDKGReconcilerMatchesPendingMaterialByXOnlyIdentity( +func TestFrostOrphanedDKGReconcilerPreservesEveryKeyEncodingDuringChallenge( t *testing.T, ) { walletID := orphanedDKGTestWalletID(t) - result := ®istry.Result{ - XOnlyOutputKey: frost.OutputKey(walletID), - Members: registry.FullMembers{1, 2, 3}, - } testCases := map[string]string{ "odd compressed key": "0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", "legacy x-only key": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", @@ -261,15 +247,8 @@ func TestFrostOrphanedDKGReconcilerMatchesPendingMaterialByXOnlyIdentity( t.Run(name, func(t *testing.T) { engine := &orphanedDKGRetirementTestEngine{} reconciler := &frostOrphanedDKGReconciler{ - dkgChain: &orphanedDKGStateTestChain{ - state: Challenge, - events: []*FrostDKGResultSubmittedEvent{{ - Result: result, - BlockNumber: 100, - }}, - valid: true, - }, - registrationChain: &orphanedDKGRegistrationTestChain{ + snapshotChain: &orphanedDKGSnapshotTestChain{ + state: Challenge, registered: make(map[[32]byte]bool), }, walletRegistry: &walletRegistry{ @@ -293,6 +272,7 @@ func TestFrostOrphanedDKGReconcilerMatchesPendingMaterialByXOnlyIdentity( if err := reconciler.reconcile( context.Background(), + orphanedDKGTestPoint(), map[[32]byte]struct{}{}, ); err != nil { t.Fatal(err) @@ -321,6 +301,13 @@ func orphanedDKGTestWalletID(t *testing.T) [32]byte { return walletID } +func orphanedDKGTestPoint() FrostPreSignFinality { + return FrostPreSignFinality{ + BlockNumber: 100, + BlockHash: [32]byte{0xaa}, + } +} + func orphanedDKGTestAnchorAdmission() *frostNativeSignerAnchorAdmissionController { return &frostNativeSignerAnchorAdmissionController{ readHeadroom: func( diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index 47cad45f05..5d2c763fcd 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -969,6 +969,12 @@ type frostRetainedGroupJournalSnapshot struct { Complete bool } +type frostOrphanedDKGReconcilerFunc func( + context.Context, + FrostPreSignFinality, + map[[32]byte]struct{}, +) error + type frostRetainedGroupJournal struct { mutex sync.Mutex rootDirectory string @@ -995,7 +1001,7 @@ type frostRetainedGroupJournal struct { checkpointState frostRetainedGroupCheckpointJournalState checkpointCertificates map[uint64]FrostRetainedGroupCheckpointCertificate checkpointHashes map[uint64][32]byte - orphanedDKGReconciler func(context.Context, map[[32]byte]struct{}) error + orphanedDKGReconciler frostOrphanedDKGReconcilerFunc persistFailureHook func(string) error checkpointPersistFailureHook func(string) error closed bool @@ -3824,7 +3830,11 @@ func (frgj *frostRetainedGroupJournal) reconcile( for _, wallet := range frgj.state.Wallets { canonicalWallets[wallet.WalletID] = struct{}{} } - if err := frgj.orphanedDKGReconciler(ctx, canonicalWallets); err != nil { + if err := frgj.orphanedDKGReconciler( + ctx, + target, + canonicalWallets, + ); err != nil { return nil, fmt.Errorf( "cannot retire orphaned native FROST DKG material: [%w]", err, From 410f6a6890915d58f0447d3da56902555d6ebbb6 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 20:39:59 -0400 Subject: [PATCH 57/93] Protect DKG packages from stale retirement snapshots --- pkg/tbtc/frost_dkg_attempt_store.go | 291 ++++++++++++++++++ pkg/tbtc/frost_dkg_attempt_store_test.go | 161 ++++++++++ pkg/tbtc/frost_dkg_execution_frost_native.go | 14 + pkg/tbtc/frost_dkg_retirement_frost_native.go | 14 +- .../frost_dkg_retirement_frost_native_test.go | 129 +++++++- pkg/tbtc/registry.go | 32 +- 6 files changed, 616 insertions(+), 25 deletions(-) create mode 100644 pkg/tbtc/frost_dkg_attempt_store.go create mode 100644 pkg/tbtc/frost_dkg_attempt_store_test.go diff --git a/pkg/tbtc/frost_dkg_attempt_store.go b/pkg/tbtc/frost_dkg_attempt_store.go new file mode 100644 index 0000000000..d9895a4ace --- /dev/null +++ b/pkg/tbtc/frost_dkg_attempt_store.go @@ -0,0 +1,291 @@ +package tbtc + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "sync" +) + +const ( + frostDKGAttemptDirectory = "frost-dkg-attempts" + frostDKGAttemptSchema = "tbtc-frost-dkg-attempt/v1" + frostDKGAttemptDomain = "tbtc-frost-dkg-attempt-v1\x00" + frostDKGAttemptMaxSize = 1024 +) + +type frostDKGAttemptRecord struct { + Schema string `json:"schema"` + Seed string `json:"seed"` + StartBlock uint64 `json:"startBlock"` + Checksum [32]byte `json:"checksum"` +} + +func canonicalFrostDKGAttemptSeed(seed *big.Int) (string, error) { + if seed == nil || seed.Sign() < 0 || seed.BitLen() > 256 { + return "", fmt.Errorf("FROST DKG seed is not a uint256") + } + return fmt.Sprintf("%064x", seed), nil +} + +func validateCanonicalFrostDKGAttemptSeed(seed string) error { + if len(seed) != 64 { + return fmt.Errorf("FROST DKG seed is not canonical uint256 hex") + } + decoded, err := hex.DecodeString(seed) + if err != nil || len(decoded) != 32 { + return fmt.Errorf("FROST DKG seed is not canonical uint256 hex") + } + if fmt.Sprintf("%064x", new(big.Int).SetBytes(decoded)) != seed { + return fmt.Errorf("FROST DKG seed is not canonical uint256 hex") + } + return nil +} + +func frostDKGAttemptFile(seed string) string { + return seed + ".json" +} + +func frostDKGAttemptChecksum(seed string, startBlock uint64) [32]byte { + digest := sha256.New() + _, _ = digest.Write([]byte(frostDKGAttemptDomain)) + _, _ = digest.Write([]byte(seed)) + var encodedStartBlock [8]byte + binary.BigEndian.PutUint64(encodedStartBlock[:], startBlock) + _, _ = digest.Write(encodedStartBlock[:]) + var result [32]byte + copy(result[:], digest.Sum(nil)) + return result +} + +func (ws *walletStorage) saveFrostDKGAttempt( + seed string, + startBlock uint64, +) error { + if ws == nil || ws.persistence == nil { + return fmt.Errorf("wallet storage persistence is unavailable") + } + if err := validateCanonicalFrostDKGAttemptSeed(seed); err != nil { + return err + } + if startBlock == 0 { + return fmt.Errorf("FROST DKG start block is zero") + } + record := frostDKGAttemptRecord{ + Schema: frostDKGAttemptSchema, + Seed: seed, + StartBlock: startBlock, + Checksum: frostDKGAttemptChecksum(seed, startBlock), + } + encoded, err := json.Marshal(&record) + if err != nil { + return err + } + return ws.persistence.Save( + encoded, + frostDKGAttemptDirectory, + frostDKGAttemptFile(seed), + ) +} + +func (ws *walletStorage) loadFrostDKGAttemptStartBlocks() ( + map[string]uint64, + error, +) { + if ws == nil || ws.persistence == nil { + return nil, fmt.Errorf("wallet storage persistence is unavailable") + } + + result := make(map[string]uint64) + descriptors, readErrors := ws.persistence.ReadAll() + var wg sync.WaitGroup + var mutex sync.Mutex + var firstErr error + setError := func(err error) { + mutex.Lock() + defer mutex.Unlock() + if firstErr == nil { + firstErr = err + } + } + + wg.Add(2) + go func() { + defer wg.Done() + for descriptor := range descriptors { + if descriptor.Directory() != frostDKGAttemptDirectory { + continue + } + content, err := descriptor.Content() + if err != nil { + setError(fmt.Errorf( + "cannot read FROST DKG attempt [%s]: [%w]", + descriptor.Name(), + err, + )) + continue + } + if len(content) == 0 || len(content) > frostDKGAttemptMaxSize { + setError(fmt.Errorf( + "FROST DKG attempt [%s] has invalid size", + descriptor.Name(), + )) + continue + } + record := frostDKGAttemptRecord{} + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&record); err != nil { + setError(fmt.Errorf( + "cannot decode FROST DKG attempt [%s]: [%w]", + descriptor.Name(), + err, + )) + continue + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + setError(fmt.Errorf( + "FROST DKG attempt [%s] has trailing data", + descriptor.Name(), + )) + continue + } + if record.Schema != frostDKGAttemptSchema || + record.StartBlock == 0 || + descriptor.Name() != frostDKGAttemptFile(record.Seed) || + record.Checksum != frostDKGAttemptChecksum( + record.Seed, + record.StartBlock, + ) { + setError(fmt.Errorf( + "FROST DKG attempt [%s] is invalid", + descriptor.Name(), + )) + continue + } + if err := validateCanonicalFrostDKGAttemptSeed(record.Seed); err != nil { + setError(fmt.Errorf( + "FROST DKG attempt [%s] is invalid: [%w]", + descriptor.Name(), + err, + )) + continue + } + + mutex.Lock() + if _, exists := result[record.Seed]; exists { + if firstErr == nil { + firstErr = fmt.Errorf( + "duplicate durable FROST DKG attempt [%s]", + record.Seed, + ) + } + } else { + result[record.Seed] = record.StartBlock + } + mutex.Unlock() + } + }() + go func() { + defer wg.Done() + for err := range readErrors { + if err != nil { + setError(fmt.Errorf( + "cannot enumerate durable FROST DKG attempts: [%w]", + err, + )) + } + } + }() + wg.Wait() + if firstErr != nil { + return nil, firstErr + } + return result, nil +} + +func (wr *walletRegistry) recordFrostDKGAttempt( + seed *big.Int, + startBlock uint64, +) error { + if wr == nil { + return fmt.Errorf("wallet registry is nil") + } + canonicalSeed, err := canonicalFrostDKGAttemptSeed(seed) + if err != nil { + return err + } + if startBlock == 0 { + return fmt.Errorf("FROST DKG start block is zero") + } + + wr.mutex.Lock() + defer wr.mutex.Unlock() + if existing, ok := wr.frostDKGAttemptStartBlocks[canonicalSeed]; ok { + if existing != startBlock { + return fmt.Errorf( + "durable FROST DKG attempt [%s] has conflicting start blocks [%d] and [%d]", + canonicalSeed, + existing, + startBlock, + ) + } + return nil + } + if err := wr.walletStorage.saveFrostDKGAttempt( + canonicalSeed, + startBlock, + ); err != nil { + return fmt.Errorf("cannot persist FROST DKG attempt boundary: [%w]", err) + } + if wr.frostDKGAttemptStartBlocks == nil { + wr.frostDKGAttemptStartBlocks = make(map[string]uint64) + } + wr.frostDKGAttemptStartBlocks[canonicalSeed] = startBlock + wr.revision++ + return nil +} + +func (wr *walletRegistry) frostDKGRetirementMaterialSnapshot() ( + []frostLocalSessionSnapshot, + uint64, + bool, + error, +) { + if wr == nil { + return nil, 0, false, fmt.Errorf("wallet registry is nil") + } + + wr.mutex.Lock() + defer wr.mutex.Unlock() + sessions, err := wr.frostLocalSessionSnapshotLocked() + if err != nil { + return nil, 0, false, err + } + + var latestStartBlock uint64 + for seed, startBlock := range wr.frostDKGAttemptStartBlocks { + if err := validateCanonicalFrostDKGAttemptSeed(seed); err != nil { + return nil, 0, false, fmt.Errorf( + "durable FROST DKG attempt is invalid: [%w]", + err, + ) + } + if startBlock == 0 { + return nil, 0, false, fmt.Errorf( + "durable FROST DKG attempt [%s] has zero start block", + seed, + ) + } + if startBlock > latestStartBlock { + latestStartBlock = startBlock + } + } + return sessions, latestStartBlock, latestStartBlock != 0, nil +} diff --git a/pkg/tbtc/frost_dkg_attempt_store_test.go b/pkg/tbtc/frost_dkg_attempt_store_test.go new file mode 100644 index 0000000000..3b6e05569d --- /dev/null +++ b/pkg/tbtc/frost_dkg_attempt_store_test.go @@ -0,0 +1,161 @@ +package tbtc + +import ( + "errors" + "math/big" + "strings" + "testing" + + "github.com/keep-network/keep-common/pkg/persistence" +) + +func TestFrostDKGAttemptBoundarySurvivesRestart(t *testing.T) { + persistenceHandle := &mockPersistenceHandle{} + registry, err := newWalletRegistry( + persistenceHandle, + Connect().CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } + seed := big.NewInt(100) + if err := registry.recordFrostDKGAttempt(seed, 101); err != nil { + t.Fatal(err) + } + if len(persistenceHandle.saved) != 1 { + t.Fatalf( + "unexpected persisted attempt count: [%d]", + len(persistenceHandle.saved), + ) + } + + // Event recovery can deliver the same DkgStarted event repeatedly. The + // durable boundary is idempotent and does not create ambiguous records. + if err := registry.recordFrostDKGAttempt(seed, 101); err != nil { + t.Fatal(err) + } + if len(persistenceHandle.saved) != 1 { + t.Fatalf( + "idempotent attempt was persisted again: [%d]", + len(persistenceHandle.saved), + ) + } + + reopened, err := newWalletRegistry( + persistenceHandle, + Connect().CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } + sessions, latestStartBlock, hasAttempt, err := + reopened.frostDKGRetirementMaterialSnapshot() + if err != nil { + t.Fatal(err) + } + if len(sessions) != 0 || !hasAttempt || latestStartBlock != 101 { + t.Fatalf( + "durable attempt boundary was not recovered: sessions=%d latest=%d present=%t", + len(sessions), + latestStartBlock, + hasAttempt, + ) + } +} + +func TestFrostDKGAttemptBoundaryRejectsConflictingReplay(t *testing.T) { + registry := &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + walletStorage: newWalletStorage(&mockPersistenceHandle{}), + frostDKGAttemptStartBlocks: make(map[string]uint64), + } + seed := big.NewInt(100) + if err := registry.recordFrostDKGAttempt(seed, 101); err != nil { + t.Fatal(err) + } + err := registry.recordFrostDKGAttempt(seed, 102) + if err == nil || !strings.Contains(err.Error(), "conflicting start blocks") { + t.Fatalf("conflicting attempt replay was accepted: [%v]", err) + } +} + +func TestFrostDKGAttemptBoundarySurvivesRealPersistenceRestart(t *testing.T) { + storagePath := t.TempDir() + handle, err := persistence.NewProtectedDiskHandle(storagePath) + if err != nil { + t.Fatal(err) + } + registry, err := newWalletRegistry(handle, Connect().CalculateWalletID) + if err != nil { + t.Fatal(err) + } + if err := registry.recordFrostDKGAttempt(big.NewInt(200), 201); err != nil { + t.Fatal(err) + } + + reopenedHandle, err := persistence.NewProtectedDiskHandle(storagePath) + if err != nil { + t.Fatal(err) + } + reopened, err := newWalletRegistry( + reopenedHandle, + Connect().CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } + _, latestStartBlock, hasAttempt, err := + reopened.frostDKGRetirementMaterialSnapshot() + if err != nil { + t.Fatal(err) + } + if !hasAttempt || latestStartBlock != 201 { + t.Fatalf( + "real persistence lost the attempt boundary: latest=%d present=%t", + latestStartBlock, + hasAttempt, + ) + } +} + +type failingFrostDKGAttemptPersistence struct { + *mockPersistenceHandle +} + +func (persistence *failingFrostDKGAttemptPersistence) Save( + data []byte, + directory string, + name string, +) error { + if directory == frostDKGAttemptDirectory { + return errFrostDKGAttemptPersistenceTest + } + return persistence.mockPersistenceHandle.Save(data, directory, name) +} + +var errFrostDKGAttemptPersistenceTest = errors.New( + "injected FROST DKG attempt persistence failure", +) + +func TestFrostDKGAttemptBoundaryPersistenceFailureIsNotAdmitted(t *testing.T) { + handle := &failingFrostDKGAttemptPersistence{ + mockPersistenceHandle: &mockPersistenceHandle{}, + } + registry := &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + walletStorage: newWalletStorage(handle), + frostDKGAttemptStartBlocks: make(map[string]uint64), + } + + err := registry.recordFrostDKGAttempt(big.NewInt(100), 101) + if err == nil || !strings.Contains( + err.Error(), + errFrostDKGAttemptPersistenceTest.Error(), + ) { + t.Fatalf("attempt persistence failure was ignored: [%v]", err) + } + if len(registry.frostDKGAttemptStartBlocks) != 0 || + registry.revision != 0 { + t.Fatal("failed attempt persistence changed registry state") + } +} diff --git a/pkg/tbtc/frost_dkg_execution_frost_native.go b/pkg/tbtc/frost_dkg_execution_frost_native.go index 3ba8d38b71..5e7ade34c1 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native.go @@ -117,6 +117,20 @@ func executeFrostDKGIfPossible( fullMembers := frostFullMembers(groupSelectionResult) dkgTimeoutBlock := event.BlockNumber + params.SubmissionTimeoutBlocks + // Persist the attempt boundary before readiness or native DKG execution can + // create key packages. Orphan reconciliation is intentionally unable to + // retire packages until its finalized snapshot covers this block. + if err := node.walletRegistry.recordFrostDKGAttempt( + event.Seed, + event.BlockNumber, + ); err != nil { + logger.Errorf( + "failed to persist FROST DKG attempt boundary: [%v]", + err, + ) + return false + } + // Acquire the reversible capacity reservation before crossing the // goroutine boundary. A transient admission failure must be visible to the // coordinator so it releases the event lease and a replay can retry. diff --git a/pkg/tbtc/frost_dkg_retirement_frost_native.go b/pkg/tbtc/frost_dkg_retirement_frost_native.go index ee7e04e4ff..4d209b9356 100644 --- a/pkg/tbtc/frost_dkg_retirement_frost_native.go +++ b/pkg/tbtc/frost_dkg_retirement_frost_native.go @@ -87,7 +87,8 @@ func (reconciler *frostOrphanedDKGReconciler) reconcile( if inventory == nil { return fmt.Errorf("native key-package inventory is nil") } - sessions, err := reconciler.walletRegistry.frostLocalSessionSnapshot() + sessions, latestAttemptStartBlock, hasAttemptBoundary, err := + reconciler.walletRegistry.frostDKGRetirementMaterialSnapshot() if err != nil { return err } @@ -143,6 +144,17 @@ func (reconciler *frostOrphanedDKGReconciler) reconcile( if len(noncanonicalWalletIDs) == 0 { return nil } + + // Native package persistence is ordered after the durable attempt marker. + // If no marker exists, or the retained journal point predates the newest + // admitted attempt, the snapshot cannot prove that every candidate's DKG + // had even started. Preserve all material rather than infer orphanhood from + // a pre-DKG Idle/AwaitingSeed state. + if !hasAttemptBoundary || + target.BlockNumber < latestAttemptStartBlock { + return nil + } + snapshot, err := reconciler.snapshotChain.FrostDKGRetirementSnapshot( ctx, target, diff --git a/pkg/tbtc/frost_dkg_retirement_frost_native_test.go b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go index b71c9ccd6c..6cd51fb073 100644 --- a/pkg/tbtc/frost_dkg_retirement_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go @@ -5,6 +5,7 @@ package tbtc import ( "context" "encoding/hex" + "math/big" "testing" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" @@ -53,10 +54,8 @@ func TestFrostOrphanedDKGReconcilerRetiresNativeOnlyOrphan(t *testing.T) { registered: make(map[[32]byte]bool), } reconciler := &frostOrphanedDKGReconciler{ - snapshotChain: snapshotChain, - walletRegistry: &walletRegistry{ - walletCache: make(map[string]*walletCacheValue), - }, + snapshotChain: snapshotChain, + walletRegistry: orphanedDKGTestWalletRegistry(t, 90), anchorAdmission: &frostNativeSignerAnchorAdmissionController{ readHeadroom: func( context.Context, @@ -118,9 +117,7 @@ func TestFrostOrphanedDKGReconcilerPreservesCanonicalAndRegistered(t *testing.T) state: Idle, registered: map[[32]byte]bool{registeredWalletID: true}, }, - walletRegistry: &walletRegistry{ - walletCache: make(map[string]*walletCacheValue), - }, + walletRegistry: orphanedDKGTestWalletRegistry(t, 90), anchorAdmission: &frostNativeSignerAnchorAdmissionController{}, retirementEngine: engine, readInventory: func() ( @@ -162,9 +159,7 @@ func TestFrostOrphanedDKGReconcilerPreservesAwaitingResultMaterial(t *testing.T) state: AwaitingResult, registered: make(map[[32]byte]bool), }, - walletRegistry: &walletRegistry{ - walletCache: make(map[string]*walletCacheValue), - }, + walletRegistry: orphanedDKGTestWalletRegistry(t, 90), anchorAdmission: &frostNativeSignerAnchorAdmissionController{}, retirementEngine: engine, readInventory: func() ( @@ -203,9 +198,7 @@ func TestFrostOrphanedDKGReconcilerPreservesMaterialDuringChallenge( state: Challenge, registered: make(map[[32]byte]bool), }, - walletRegistry: &walletRegistry{ - walletCache: make(map[string]*walletCacheValue), - }, + walletRegistry: orphanedDKGTestWalletRegistry(t, 90), anchorAdmission: orphanedDKGTestAnchorAdmission(), retirementEngine: engine, readInventory: func() ( @@ -251,9 +244,7 @@ func TestFrostOrphanedDKGReconcilerPreservesEveryKeyEncodingDuringChallenge( state: Challenge, registered: make(map[[32]byte]bool), }, - walletRegistry: &walletRegistry{ - walletCache: make(map[string]*walletCacheValue), - }, + walletRegistry: orphanedDKGTestWalletRegistry(t, 90), anchorAdmission: orphanedDKGTestAnchorAdmission(), retirementEngine: engine, readInventory: func() ( @@ -288,6 +279,95 @@ func TestFrostOrphanedDKGReconcilerPreservesEveryKeyEncodingDuringChallenge( } } +func TestFrostOrphanedDKGReconcilerPreservesMaterialBeforeAttemptFinality( + t *testing.T, +) { + walletID := [32]byte{4} + engine := &orphanedDKGRetirementTestEngine{} + snapshotChain := &orphanedDKGSnapshotTestChain{ + state: Idle, + registered: make(map[[32]byte]bool), + } + reconciler := &frostOrphanedDKGReconciler{ + snapshotChain: snapshotChain, + walletRegistry: orphanedDKGTestWalletRegistry(t, 101), + anchorAdmission: orphanedDKGTestAnchorAdmission(), + retirementEngine: engine, + readInventory: func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, + ) { + return &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Entries: []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ + WalletID: walletID, + KeyGroup: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + }}, + }, nil + }, + } + + if err := reconciler.reconcile( + context.Background(), + orphanedDKGTestPoint(), + map[[32]byte]struct{}{}, + ); err != nil { + t.Fatal(err) + } + if len(snapshotChain.points) != 0 { + t.Fatalf( + "pre-attempt finalized point triggered a retirement snapshot read: [%+v]", + snapshotChain.points, + ) + } + if len(engine.retired) != 0 { + t.Fatalf( + "in-flight DKG material was retired from a pre-attempt snapshot: [%v]", + engine.retired, + ) + } +} + +func TestFrostOrphanedDKGReconcilerPreservesMaterialWithoutAttemptBoundary( + t *testing.T, +) { + walletID := [32]byte{5} + engine := &orphanedDKGRetirementTestEngine{} + snapshotChain := &orphanedDKGSnapshotTestChain{ + state: Idle, + registered: make(map[[32]byte]bool), + } + reconciler := &frostOrphanedDKGReconciler{ + snapshotChain: snapshotChain, + walletRegistry: &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + }, + anchorAdmission: orphanedDKGTestAnchorAdmission(), + retirementEngine: engine, + readInventory: func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, + ) { + return &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Entries: []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ + WalletID: walletID, + KeyGroup: "0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + }}, + }, nil + }, + } + + if err := reconciler.reconcile( + context.Background(), + orphanedDKGTestPoint(), + map[[32]byte]struct{}{}, + ); err != nil { + t.Fatal(err) + } + if len(snapshotChain.points) != 0 || len(engine.retired) != 0 { + t.Fatal("material without a durable attempt boundary was not preserved") + } +} + func orphanedDKGTestWalletID(t *testing.T) [32]byte { t.Helper() decoded, err := hex.DecodeString( @@ -301,6 +381,23 @@ func orphanedDKGTestWalletID(t *testing.T) [32]byte { return walletID } +func orphanedDKGTestWalletRegistry( + t *testing.T, + attemptStartBlock uint64, +) *walletRegistry { + t.Helper() + seed, err := canonicalFrostDKGAttemptSeed(big.NewInt(100)) + if err != nil { + t.Fatal(err) + } + return &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + frostDKGAttemptStartBlocks: map[string]uint64{ + seed: attemptStartBlock, + }, + } +} + func orphanedDKGTestPoint() FrostPreSignFinality { return FrostPreSignFinality{ BlockNumber: 100, diff --git a/pkg/tbtc/registry.go b/pkg/tbtc/registry.go index de9b9606bc..935c645984 100644 --- a/pkg/tbtc/registry.go +++ b/pkg/tbtc/registry.go @@ -39,9 +39,15 @@ type walletRegistry struct { // to identify itself. retainedFrostKeyGroups map[[32]byte]string - // revision advances after each successful active-session registration or - // archive. Readiness pins it across its Go/Rust reconciliation so a wallet - // cannot disappear or gain a local seat mid-check. + // frostDKGAttemptStartBlocks is an append-only durable ledger of every DKG + // this node admitted before native package persistence. Orphan retirement + // uses its newest block to reject finalized snapshots that predate locally + // held packages. + frostDKGAttemptStartBlocks map[string]uint64 + + // revision advances after each successful DKG-attempt admission, + // active-session registration, or archive. Readiness pins it across its + // Go/Rust reconciliation so relevant durable state cannot change mid-check. revision uint64 // calculateWalletIdFunc calculates the ECDSA wallet ID based on the @@ -74,6 +80,14 @@ func newWalletRegistry( err, ) } + frostDKGAttemptStartBlocks, err := + walletStorage.loadFrostDKGAttemptStartBlocks() + if err != nil { + return nil, fmt.Errorf( + "could not load durable FROST DKG attempts: [%w]", + err, + ) + } // Pre-populate the wallet cache using the wallet storage. walletCache := make(map[string]*walletCacheValue) @@ -141,10 +155,11 @@ func newWalletRegistry( } return &walletRegistry{ - walletCache: walletCache, - walletStorage: walletStorage, - retainedFrostKeyGroups: retainedFrostKeyGroups, - calculateWalletIdFunc: calculateWalletIdFunc, + walletCache: walletCache, + walletStorage: walletStorage, + retainedFrostKeyGroups: retainedFrostKeyGroups, + frostDKGAttemptStartBlocks: frostDKGAttemptStartBlocks, + calculateWalletIdFunc: calculateWalletIdFunc, }, nil } @@ -454,7 +469,8 @@ func (ws *walletStorage) loadSigners() map[string][]*signer { go func() { for descriptor := range descriptorsChan { if descriptor.Directory() == - frostRetainedKeyGroupBindingDirectory { + frostRetainedKeyGroupBindingDirectory || + descriptor.Directory() == frostDKGAttemptDirectory { continue } content, err := descriptor.Content() From 60b90cbff7196f166a7a6f2132db422466b8bc1c Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 29 Jul 2026 21:54:48 -0400 Subject: [PATCH 58/93] Handle legacy and re-included FROST DKG boundaries --- pkg/tbtc/frost_dkg_attempt_store.go | 291 ------------ .../frost_dkg_retirement_boundary_store.go | 439 ++++++++++++++++++ ...ost_dkg_retirement_boundary_store_test.go} | 121 ++++- pkg/tbtc/frost_dkg_retirement_frost_native.go | 51 +- .../frost_dkg_retirement_frost_native_test.go | 113 ++++- pkg/tbtc/registry.go | 36 +- 6 files changed, 716 insertions(+), 335 deletions(-) delete mode 100644 pkg/tbtc/frost_dkg_attempt_store.go create mode 100644 pkg/tbtc/frost_dkg_retirement_boundary_store.go rename pkg/tbtc/{frost_dkg_attempt_store_test.go => frost_dkg_retirement_boundary_store_test.go} (52%) diff --git a/pkg/tbtc/frost_dkg_attempt_store.go b/pkg/tbtc/frost_dkg_attempt_store.go deleted file mode 100644 index d9895a4ace..0000000000 --- a/pkg/tbtc/frost_dkg_attempt_store.go +++ /dev/null @@ -1,291 +0,0 @@ -package tbtc - -import ( - "bytes" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "math/big" - "sync" -) - -const ( - frostDKGAttemptDirectory = "frost-dkg-attempts" - frostDKGAttemptSchema = "tbtc-frost-dkg-attempt/v1" - frostDKGAttemptDomain = "tbtc-frost-dkg-attempt-v1\x00" - frostDKGAttemptMaxSize = 1024 -) - -type frostDKGAttemptRecord struct { - Schema string `json:"schema"` - Seed string `json:"seed"` - StartBlock uint64 `json:"startBlock"` - Checksum [32]byte `json:"checksum"` -} - -func canonicalFrostDKGAttemptSeed(seed *big.Int) (string, error) { - if seed == nil || seed.Sign() < 0 || seed.BitLen() > 256 { - return "", fmt.Errorf("FROST DKG seed is not a uint256") - } - return fmt.Sprintf("%064x", seed), nil -} - -func validateCanonicalFrostDKGAttemptSeed(seed string) error { - if len(seed) != 64 { - return fmt.Errorf("FROST DKG seed is not canonical uint256 hex") - } - decoded, err := hex.DecodeString(seed) - if err != nil || len(decoded) != 32 { - return fmt.Errorf("FROST DKG seed is not canonical uint256 hex") - } - if fmt.Sprintf("%064x", new(big.Int).SetBytes(decoded)) != seed { - return fmt.Errorf("FROST DKG seed is not canonical uint256 hex") - } - return nil -} - -func frostDKGAttemptFile(seed string) string { - return seed + ".json" -} - -func frostDKGAttemptChecksum(seed string, startBlock uint64) [32]byte { - digest := sha256.New() - _, _ = digest.Write([]byte(frostDKGAttemptDomain)) - _, _ = digest.Write([]byte(seed)) - var encodedStartBlock [8]byte - binary.BigEndian.PutUint64(encodedStartBlock[:], startBlock) - _, _ = digest.Write(encodedStartBlock[:]) - var result [32]byte - copy(result[:], digest.Sum(nil)) - return result -} - -func (ws *walletStorage) saveFrostDKGAttempt( - seed string, - startBlock uint64, -) error { - if ws == nil || ws.persistence == nil { - return fmt.Errorf("wallet storage persistence is unavailable") - } - if err := validateCanonicalFrostDKGAttemptSeed(seed); err != nil { - return err - } - if startBlock == 0 { - return fmt.Errorf("FROST DKG start block is zero") - } - record := frostDKGAttemptRecord{ - Schema: frostDKGAttemptSchema, - Seed: seed, - StartBlock: startBlock, - Checksum: frostDKGAttemptChecksum(seed, startBlock), - } - encoded, err := json.Marshal(&record) - if err != nil { - return err - } - return ws.persistence.Save( - encoded, - frostDKGAttemptDirectory, - frostDKGAttemptFile(seed), - ) -} - -func (ws *walletStorage) loadFrostDKGAttemptStartBlocks() ( - map[string]uint64, - error, -) { - if ws == nil || ws.persistence == nil { - return nil, fmt.Errorf("wallet storage persistence is unavailable") - } - - result := make(map[string]uint64) - descriptors, readErrors := ws.persistence.ReadAll() - var wg sync.WaitGroup - var mutex sync.Mutex - var firstErr error - setError := func(err error) { - mutex.Lock() - defer mutex.Unlock() - if firstErr == nil { - firstErr = err - } - } - - wg.Add(2) - go func() { - defer wg.Done() - for descriptor := range descriptors { - if descriptor.Directory() != frostDKGAttemptDirectory { - continue - } - content, err := descriptor.Content() - if err != nil { - setError(fmt.Errorf( - "cannot read FROST DKG attempt [%s]: [%w]", - descriptor.Name(), - err, - )) - continue - } - if len(content) == 0 || len(content) > frostDKGAttemptMaxSize { - setError(fmt.Errorf( - "FROST DKG attempt [%s] has invalid size", - descriptor.Name(), - )) - continue - } - record := frostDKGAttemptRecord{} - decoder := json.NewDecoder(bytes.NewReader(content)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&record); err != nil { - setError(fmt.Errorf( - "cannot decode FROST DKG attempt [%s]: [%w]", - descriptor.Name(), - err, - )) - continue - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - setError(fmt.Errorf( - "FROST DKG attempt [%s] has trailing data", - descriptor.Name(), - )) - continue - } - if record.Schema != frostDKGAttemptSchema || - record.StartBlock == 0 || - descriptor.Name() != frostDKGAttemptFile(record.Seed) || - record.Checksum != frostDKGAttemptChecksum( - record.Seed, - record.StartBlock, - ) { - setError(fmt.Errorf( - "FROST DKG attempt [%s] is invalid", - descriptor.Name(), - )) - continue - } - if err := validateCanonicalFrostDKGAttemptSeed(record.Seed); err != nil { - setError(fmt.Errorf( - "FROST DKG attempt [%s] is invalid: [%w]", - descriptor.Name(), - err, - )) - continue - } - - mutex.Lock() - if _, exists := result[record.Seed]; exists { - if firstErr == nil { - firstErr = fmt.Errorf( - "duplicate durable FROST DKG attempt [%s]", - record.Seed, - ) - } - } else { - result[record.Seed] = record.StartBlock - } - mutex.Unlock() - } - }() - go func() { - defer wg.Done() - for err := range readErrors { - if err != nil { - setError(fmt.Errorf( - "cannot enumerate durable FROST DKG attempts: [%w]", - err, - )) - } - } - }() - wg.Wait() - if firstErr != nil { - return nil, firstErr - } - return result, nil -} - -func (wr *walletRegistry) recordFrostDKGAttempt( - seed *big.Int, - startBlock uint64, -) error { - if wr == nil { - return fmt.Errorf("wallet registry is nil") - } - canonicalSeed, err := canonicalFrostDKGAttemptSeed(seed) - if err != nil { - return err - } - if startBlock == 0 { - return fmt.Errorf("FROST DKG start block is zero") - } - - wr.mutex.Lock() - defer wr.mutex.Unlock() - if existing, ok := wr.frostDKGAttemptStartBlocks[canonicalSeed]; ok { - if existing != startBlock { - return fmt.Errorf( - "durable FROST DKG attempt [%s] has conflicting start blocks [%d] and [%d]", - canonicalSeed, - existing, - startBlock, - ) - } - return nil - } - if err := wr.walletStorage.saveFrostDKGAttempt( - canonicalSeed, - startBlock, - ); err != nil { - return fmt.Errorf("cannot persist FROST DKG attempt boundary: [%w]", err) - } - if wr.frostDKGAttemptStartBlocks == nil { - wr.frostDKGAttemptStartBlocks = make(map[string]uint64) - } - wr.frostDKGAttemptStartBlocks[canonicalSeed] = startBlock - wr.revision++ - return nil -} - -func (wr *walletRegistry) frostDKGRetirementMaterialSnapshot() ( - []frostLocalSessionSnapshot, - uint64, - bool, - error, -) { - if wr == nil { - return nil, 0, false, fmt.Errorf("wallet registry is nil") - } - - wr.mutex.Lock() - defer wr.mutex.Unlock() - sessions, err := wr.frostLocalSessionSnapshotLocked() - if err != nil { - return nil, 0, false, err - } - - var latestStartBlock uint64 - for seed, startBlock := range wr.frostDKGAttemptStartBlocks { - if err := validateCanonicalFrostDKGAttemptSeed(seed); err != nil { - return nil, 0, false, fmt.Errorf( - "durable FROST DKG attempt is invalid: [%w]", - err, - ) - } - if startBlock == 0 { - return nil, 0, false, fmt.Errorf( - "durable FROST DKG attempt [%s] has zero start block", - seed, - ) - } - if startBlock > latestStartBlock { - latestStartBlock = startBlock - } - } - return sessions, latestStartBlock, latestStartBlock != 0, nil -} diff --git a/pkg/tbtc/frost_dkg_retirement_boundary_store.go b/pkg/tbtc/frost_dkg_retirement_boundary_store.go new file mode 100644 index 0000000000..211f8aa565 --- /dev/null +++ b/pkg/tbtc/frost_dkg_retirement_boundary_store.go @@ -0,0 +1,439 @@ +package tbtc + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "sync" +) + +const ( + frostDKGAttemptDirectory = "frost-dkg-attempts" + frostDKGAttemptLegacySchema = "tbtc-frost-dkg-attempt/v1" + frostDKGAttemptLegacyDomain = "tbtc-frost-dkg-attempt-v1\x00" + frostDKGRetirementBoundarySchema = "tbtc-frost-dkg-retirement-boundary/v2" + frostDKGRetirementBoundaryDomain = "tbtc-frost-dkg-retirement-boundary-v2\x00" + frostDKGAttemptMaxSize = 1024 + + frostDKGRetirementBoundaryKindAttempt = "attempt" + frostDKGRetirementBoundaryKindMigration = "migration" +) + +type frostDKGRetirementBoundaryRecord struct { + Schema string `json:"schema"` + Kind string `json:"kind,omitempty"` + Seed string `json:"seed,omitempty"` + StartBlock uint64 `json:"startBlock"` + Checksum [32]byte `json:"checksum"` +} + +func canonicalFrostDKGAttemptSeed(seed *big.Int) (string, error) { + if seed == nil || seed.Sign() < 0 || seed.BitLen() > 256 { + return "", fmt.Errorf("FROST DKG seed is not a uint256") + } + return fmt.Sprintf("%064x", seed), nil +} + +func validateCanonicalFrostDKGAttemptSeed(seed string) error { + if len(seed) != 64 { + return fmt.Errorf("FROST DKG seed is not canonical uint256 hex") + } + decoded, err := hex.DecodeString(seed) + if err != nil || len(decoded) != 32 { + return fmt.Errorf("FROST DKG seed is not canonical uint256 hex") + } + if fmt.Sprintf("%064x", new(big.Int).SetBytes(decoded)) != seed { + return fmt.Errorf("FROST DKG seed is not canonical uint256 hex") + } + return nil +} + +func frostDKGLegacyAttemptFile(seed string) string { + return seed + ".json" +} + +func frostDKGLegacyAttemptChecksum(seed string, startBlock uint64) [32]byte { + digest := sha256.New() + _, _ = digest.Write([]byte(frostDKGAttemptLegacyDomain)) + _, _ = digest.Write([]byte(seed)) + var encodedStartBlock [8]byte + binary.BigEndian.PutUint64(encodedStartBlock[:], startBlock) + _, _ = digest.Write(encodedStartBlock[:]) + var result [32]byte + copy(result[:], digest.Sum(nil)) + return result +} + +func frostDKGRetirementBoundaryIdentity( + kind string, + seed string, + startBlock uint64, +) string { + return fmt.Sprintf("%s:%s:%020d", kind, seed, startBlock) +} + +func frostDKGRetirementBoundaryFile( + kind string, + seed string, + startBlock uint64, +) string { + return frostDKGRetirementBoundaryIdentity(kind, seed, startBlock) + ".json" +} + +func frostDKGRetirementBoundaryChecksum( + kind string, + seed string, + startBlock uint64, +) [32]byte { + digest := sha256.New() + _, _ = digest.Write([]byte(frostDKGRetirementBoundaryDomain)) + for _, value := range []string{kind, seed} { + var valueLength [2]byte + binary.BigEndian.PutUint16(valueLength[:], uint16(len(value))) + _, _ = digest.Write(valueLength[:]) + _, _ = digest.Write([]byte(value)) + } + var encodedStartBlock [8]byte + binary.BigEndian.PutUint64(encodedStartBlock[:], startBlock) + _, _ = digest.Write(encodedStartBlock[:]) + var result [32]byte + copy(result[:], digest.Sum(nil)) + return result +} + +func validateFrostDKGRetirementBoundary( + kind string, + seed string, + startBlock uint64, +) error { + if startBlock == 0 { + return fmt.Errorf("FROST DKG start block is zero") + } + switch kind { + case frostDKGRetirementBoundaryKindAttempt: + if err := validateCanonicalFrostDKGAttemptSeed(seed); err != nil { + return err + } + case frostDKGRetirementBoundaryKindMigration: + if seed != "" { + return fmt.Errorf("FROST DKG migration boundary has a seed") + } + default: + return fmt.Errorf("FROST DKG retirement boundary kind is invalid") + } + return nil +} + +func (ws *walletStorage) saveFrostDKGRetirementBoundary( + kind string, + seed string, + startBlock uint64, +) error { + if ws == nil || ws.persistence == nil { + return fmt.Errorf("wallet storage persistence is unavailable") + } + if err := validateFrostDKGRetirementBoundary( + kind, + seed, + startBlock, + ); err != nil { + return err + } + record := frostDKGRetirementBoundaryRecord{ + Schema: frostDKGRetirementBoundarySchema, + Kind: kind, + Seed: seed, + StartBlock: startBlock, + Checksum: frostDKGRetirementBoundaryChecksum( + kind, + seed, + startBlock, + ), + } + encoded, err := json.Marshal(&record) + if err != nil { + return err + } + return ws.persistence.Save( + encoded, + frostDKGAttemptDirectory, + frostDKGRetirementBoundaryFile(kind, seed, startBlock), + ) +} + +func (ws *walletStorage) loadFrostDKGRetirementBoundaries() ( + map[string]uint64, + error, +) { + if ws == nil || ws.persistence == nil { + return nil, fmt.Errorf("wallet storage persistence is unavailable") + } + + result := make(map[string]uint64) + descriptors, readErrors := ws.persistence.ReadAll() + var wg sync.WaitGroup + var mutex sync.Mutex + var firstErr error + setError := func(err error) { + mutex.Lock() + defer mutex.Unlock() + if firstErr == nil { + firstErr = err + } + } + + wg.Add(2) + go func() { + defer wg.Done() + for descriptor := range descriptors { + if descriptor.Directory() != frostDKGAttemptDirectory { + continue + } + content, err := descriptor.Content() + if err != nil { + setError(fmt.Errorf( + "cannot read FROST DKG retirement boundary [%s]: [%w]", + descriptor.Name(), + err, + )) + continue + } + if len(content) == 0 || len(content) > frostDKGAttemptMaxSize { + setError(fmt.Errorf( + "FROST DKG retirement boundary [%s] has invalid size", + descriptor.Name(), + )) + continue + } + record := frostDKGRetirementBoundaryRecord{} + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&record); err != nil { + setError(fmt.Errorf( + "cannot decode FROST DKG retirement boundary [%s]: [%w]", + descriptor.Name(), + err, + )) + continue + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + setError(fmt.Errorf( + "FROST DKG retirement boundary [%s] has trailing data", + descriptor.Name(), + )) + continue + } + var identity string + switch record.Schema { + case frostDKGAttemptLegacySchema: + if record.Kind != "" || + record.StartBlock == 0 || + descriptor.Name() != + frostDKGLegacyAttemptFile(record.Seed) || + record.Checksum != frostDKGLegacyAttemptChecksum( + record.Seed, + record.StartBlock, + ) { + setError(fmt.Errorf( + "FROST DKG attempt [%s] is invalid", + descriptor.Name(), + )) + continue + } + record.Kind = frostDKGRetirementBoundaryKindAttempt + identity = frostDKGRetirementBoundaryIdentity( + record.Kind, + record.Seed, + record.StartBlock, + ) + case frostDKGRetirementBoundarySchema: + if descriptor.Name() != frostDKGRetirementBoundaryFile( + record.Kind, + record.Seed, + record.StartBlock, + ) || + record.Checksum != frostDKGRetirementBoundaryChecksum( + record.Kind, + record.Seed, + record.StartBlock, + ) { + setError(fmt.Errorf( + "FROST DKG retirement boundary [%s] is invalid", + descriptor.Name(), + )) + continue + } + identity = frostDKGRetirementBoundaryIdentity( + record.Kind, + record.Seed, + record.StartBlock, + ) + default: + setError(fmt.Errorf( + "FROST DKG retirement boundary [%s] has an unsupported schema", + descriptor.Name(), + )) + continue + } + if err := validateFrostDKGRetirementBoundary( + record.Kind, + record.Seed, + record.StartBlock, + ); err != nil { + setError(fmt.Errorf( + "FROST DKG retirement boundary [%s] is invalid: [%w]", + descriptor.Name(), + err, + )) + continue + } + + mutex.Lock() + if _, exists := result[identity]; exists { + if firstErr == nil { + firstErr = fmt.Errorf( + "duplicate durable FROST DKG retirement boundary [%s]", + identity, + ) + } + } else { + result[identity] = record.StartBlock + } + mutex.Unlock() + } + }() + go func() { + defer wg.Done() + for err := range readErrors { + if err != nil { + setError(fmt.Errorf( + "cannot enumerate durable FROST DKG retirement boundaries: [%w]", + err, + )) + } + } + }() + wg.Wait() + if firstErr != nil { + return nil, firstErr + } + return result, nil +} + +func (wr *walletRegistry) recordFrostDKGAttempt( + seed *big.Int, + startBlock uint64, +) error { + if wr == nil { + return fmt.Errorf("wallet registry is nil") + } + canonicalSeed, err := canonicalFrostDKGAttemptSeed(seed) + if err != nil { + return err + } + if startBlock == 0 { + return fmt.Errorf("FROST DKG start block is zero") + } + + wr.mutex.Lock() + defer wr.mutex.Unlock() + identity := frostDKGRetirementBoundaryIdentity( + frostDKGRetirementBoundaryKindAttempt, + canonicalSeed, + startBlock, + ) + if _, ok := wr.frostDKGRetirementBoundaries[identity]; ok { + return nil + } + if err := wr.walletStorage.saveFrostDKGRetirementBoundary( + frostDKGRetirementBoundaryKindAttempt, + canonicalSeed, + startBlock, + ); err != nil { + return fmt.Errorf("cannot persist FROST DKG attempt boundary: [%w]", err) + } + if wr.frostDKGRetirementBoundaries == nil { + wr.frostDKGRetirementBoundaries = make(map[string]uint64) + } + wr.frostDKGRetirementBoundaries[identity] = startBlock + wr.revision++ + return nil +} + +func (wr *walletRegistry) recordFrostDKGMigrationBoundary( + startBlock uint64, +) error { + if wr == nil { + return fmt.Errorf("wallet registry is nil") + } + if startBlock == 0 { + return fmt.Errorf("FROST DKG migration boundary block is zero") + } + + identity := frostDKGRetirementBoundaryIdentity( + frostDKGRetirementBoundaryKindMigration, + "", + startBlock, + ) + wr.mutex.Lock() + defer wr.mutex.Unlock() + if len(wr.frostDKGRetirementBoundaries) != 0 { + return fmt.Errorf( + "cannot migrate FROST DKG inventory after retirement boundaries exist", + ) + } + if err := wr.walletStorage.saveFrostDKGRetirementBoundary( + frostDKGRetirementBoundaryKindMigration, + "", + startBlock, + ); err != nil { + return fmt.Errorf( + "cannot persist FROST DKG migration boundary: [%w]", + err, + ) + } + if wr.frostDKGRetirementBoundaries == nil { + wr.frostDKGRetirementBoundaries = make(map[string]uint64) + } + wr.frostDKGRetirementBoundaries[identity] = startBlock + wr.revision++ + return nil +} + +func (wr *walletRegistry) frostDKGRetirementMaterialSnapshot() ( + []frostLocalSessionSnapshot, + uint64, + bool, + error, +) { + if wr == nil { + return nil, 0, false, fmt.Errorf("wallet registry is nil") + } + + wr.mutex.Lock() + defer wr.mutex.Unlock() + sessions, err := wr.frostLocalSessionSnapshotLocked() + if err != nil { + return nil, 0, false, err + } + + var latestBoundary uint64 + for identity, startBlock := range wr.frostDKGRetirementBoundaries { + if startBlock == 0 { + return nil, 0, false, fmt.Errorf( + "durable FROST DKG retirement boundary [%s] has zero start block", + identity, + ) + } + if startBlock > latestBoundary { + latestBoundary = startBlock + } + } + return sessions, latestBoundary, latestBoundary != 0, nil +} diff --git a/pkg/tbtc/frost_dkg_attempt_store_test.go b/pkg/tbtc/frost_dkg_retirement_boundary_store_test.go similarity index 52% rename from pkg/tbtc/frost_dkg_attempt_store_test.go rename to pkg/tbtc/frost_dkg_retirement_boundary_store_test.go index 3b6e05569d..fe16663044 100644 --- a/pkg/tbtc/frost_dkg_attempt_store_test.go +++ b/pkg/tbtc/frost_dkg_retirement_boundary_store_test.go @@ -1,6 +1,7 @@ package tbtc import ( + "encoding/json" "errors" "math/big" "strings" @@ -63,19 +64,56 @@ func TestFrostDKGAttemptBoundarySurvivesRestart(t *testing.T) { } } -func TestFrostDKGAttemptBoundaryRejectsConflictingReplay(t *testing.T) { +func TestFrostDKGAttemptBoundaryAcceptsCanonicalReinclusion(t *testing.T) { + persistenceHandle := &mockPersistenceHandle{} registry := &walletRegistry{ - walletCache: make(map[string]*walletCacheValue), - walletStorage: newWalletStorage(&mockPersistenceHandle{}), - frostDKGAttemptStartBlocks: make(map[string]uint64), + walletCache: make(map[string]*walletCacheValue), + walletStorage: newWalletStorage(persistenceHandle), + frostDKGRetirementBoundaries: make(map[string]uint64), } seed := big.NewInt(100) if err := registry.recordFrostDKGAttempt(seed, 101); err != nil { t.Fatal(err) } - err := registry.recordFrostDKGAttempt(seed, 102) - if err == nil || !strings.Contains(err.Error(), "conflicting start blocks") { - t.Fatalf("conflicting attempt replay was accepted: [%v]", err) + if err := registry.recordFrostDKGAttempt(seed, 102); err != nil { + t.Fatalf("canonical re-inclusion was rejected: [%v]", err) + } + if err := registry.recordFrostDKGAttempt(seed, 102); err != nil { + t.Fatal(err) + } + if len(persistenceHandle.saved) != 2 { + t.Fatalf( + "unexpected persisted re-inclusion boundaries: [%d]", + len(persistenceHandle.saved), + ) + } + _, latestStartBlock, hasAttempt, err := + registry.frostDKGRetirementMaterialSnapshot() + if err != nil { + t.Fatal(err) + } + if !hasAttempt || latestStartBlock != 102 { + t.Fatalf( + "re-included attempt did not advance the retirement boundary: latest=%d present=%t", + latestStartBlock, + hasAttempt, + ) + } + + reopened, err := newWalletRegistry( + persistenceHandle, + Connect().CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } + _, latestStartBlock, hasAttempt, err = + reopened.frostDKGRetirementMaterialSnapshot() + if err != nil { + t.Fatal(err) + } + if !hasAttempt || latestStartBlock != 102 { + t.Fatal("re-included attempt boundaries did not survive restart") } } @@ -118,6 +156,67 @@ func TestFrostDKGAttemptBoundarySurvivesRealPersistenceRestart(t *testing.T) { } } +func TestFrostDKGAttemptBoundaryLoadsLegacyRecord(t *testing.T) { + seed, err := canonicalFrostDKGAttemptSeed(big.NewInt(300)) + if err != nil { + t.Fatal(err) + } + record := frostDKGRetirementBoundaryRecord{ + Schema: frostDKGAttemptLegacySchema, + Seed: seed, + StartBlock: 301, + Checksum: frostDKGLegacyAttemptChecksum(seed, 301), + } + encoded, err := json.Marshal(&record) + if err != nil { + t.Fatal(err) + } + persistenceHandle := &mockPersistenceHandle{ + saved: []persistence.DataDescriptor{&mockDescriptor{ + name: frostDKGLegacyAttemptFile(seed), + directory: frostDKGAttemptDirectory, + content: encoded, + }}, + } + + registry, err := newWalletRegistry( + persistenceHandle, + Connect().CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } + _, latestStartBlock, hasAttempt, err := + registry.frostDKGRetirementMaterialSnapshot() + if err != nil { + t.Fatal(err) + } + if !hasAttempt || latestStartBlock != 301 { + t.Fatal("legacy attempt boundary was not recovered") + } + + // A canonical re-inclusion writes a distinct v2 boundary next to the + // legacy record and advances the high-water mark. + if err := registry.recordFrostDKGAttempt(big.NewInt(300), 302); err != nil { + t.Fatal(err) + } + reopened, err := newWalletRegistry( + persistenceHandle, + Connect().CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } + _, latestStartBlock, hasAttempt, err = + reopened.frostDKGRetirementMaterialSnapshot() + if err != nil { + t.Fatal(err) + } + if !hasAttempt || latestStartBlock != 302 { + t.Fatal("legacy record prevented canonical re-inclusion") + } +} + type failingFrostDKGAttemptPersistence struct { *mockPersistenceHandle } @@ -142,9 +241,9 @@ func TestFrostDKGAttemptBoundaryPersistenceFailureIsNotAdmitted(t *testing.T) { mockPersistenceHandle: &mockPersistenceHandle{}, } registry := &walletRegistry{ - walletCache: make(map[string]*walletCacheValue), - walletStorage: newWalletStorage(handle), - frostDKGAttemptStartBlocks: make(map[string]uint64), + walletCache: make(map[string]*walletCacheValue), + walletStorage: newWalletStorage(handle), + frostDKGRetirementBoundaries: make(map[string]uint64), } err := registry.recordFrostDKGAttempt(big.NewInt(100), 101) @@ -154,7 +253,7 @@ func TestFrostDKGAttemptBoundaryPersistenceFailureIsNotAdmitted(t *testing.T) { ) { t.Fatalf("attempt persistence failure was ignored: [%v]", err) } - if len(registry.frostDKGAttemptStartBlocks) != 0 || + if len(registry.frostDKGRetirementBoundaries) != 0 || registry.revision != 0 { t.Fatal("failed attempt persistence changed registry state") } diff --git a/pkg/tbtc/frost_dkg_retirement_frost_native.go b/pkg/tbtc/frost_dkg_retirement_frost_native.go index 4d209b9356..954b436cd0 100644 --- a/pkg/tbtc/frost_dkg_retirement_frost_native.go +++ b/pkg/tbtc/frost_dkg_retirement_frost_native.go @@ -17,6 +17,7 @@ type frostOrphanedDKGReconciler struct { anchorAdmission *frostNativeSignerAnchorAdmissionController retirementEngine frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine readInventory func() (*frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error) + readCurrentBlock func() (uint64, error) } type frostDKGRetirementCandidate struct { @@ -53,6 +54,13 @@ func newFrostOrphanedDKGReconciler( "native signer does not support durable distributed-DKG retirement", ) } + blockCounter, err := chain.BlockCounter() + if err != nil { + return nil, fmt.Errorf( + "cannot initialize orphaned FROST DKG migration boundary: [%w]", + err, + ) + } reconciler := &frostOrphanedDKGReconciler{ snapshotChain: snapshotChain, walletRegistry: walletRegistry, @@ -60,6 +68,7 @@ func newFrostOrphanedDKGReconciler( retirementEngine: retirementEngine, readInventory: frostsigning. ReadNativeTBTCSignerRetainedKeyPackageInventory, + readCurrentBlock: blockCounter.CurrentBlock, } return reconciler.reconcile, nil } @@ -87,7 +96,7 @@ func (reconciler *frostOrphanedDKGReconciler) reconcile( if inventory == nil { return fmt.Errorf("native key-package inventory is nil") } - sessions, latestAttemptStartBlock, hasAttemptBoundary, err := + sessions, latestRetirementBoundary, hasRetirementBoundary, err := reconciler.walletRegistry.frostDKGRetirementMaterialSnapshot() if err != nil { return err @@ -146,12 +155,40 @@ func (reconciler *frostOrphanedDKGReconciler) reconcile( } // Native package persistence is ordered after the durable attempt marker. - // If no marker exists, or the retained journal point predates the newest - // admitted attempt, the snapshot cannot prove that every candidate's DKG - // had even started. Preserve all material rather than infer orphanhood from - // a pre-DKG Idle/AwaitingSeed state. - if !hasAttemptBoundary || - target.BlockNumber < latestAttemptStartBlock { + // Packages created by older binaries have no marker, so observe and + // persist the latest canonical head after reading their inventory. A later + // finalized point covering that head can prove those pre-existing + // packages' attempts are no longer hidden beyond the journal cursor. + if !hasRetirementBoundary { + if reconciler.readCurrentBlock == nil { + return fmt.Errorf( + "FROST DKG migration boundary source is unavailable", + ) + } + migrationBoundary, err := reconciler.readCurrentBlock() + if err != nil { + return fmt.Errorf( + "cannot read FROST DKG migration boundary: [%w]", + err, + ) + } + if migrationBoundary < target.BlockNumber { + return fmt.Errorf( + "FROST DKG migration boundary is behind finalized history", + ) + } + if err := reconciler.walletRegistry.recordFrostDKGMigrationBoundary( + migrationBoundary, + ); err != nil { + return err + } + return nil + } + + // Require finality to advance beyond the boundary, not merely equal it. + // This also makes the one-time migration fail closed if reconciliation is + // repeated at the same finalized point immediately after persisting it. + if target.BlockNumber <= latestRetirementBoundary { return nil } diff --git a/pkg/tbtc/frost_dkg_retirement_frost_native_test.go b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go index 6cd51fb073..7bed07c534 100644 --- a/pkg/tbtc/frost_dkg_retirement_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go @@ -327,30 +327,43 @@ func TestFrostOrphanedDKGReconcilerPreservesMaterialBeforeAttemptFinality( } } -func TestFrostOrphanedDKGReconcilerPreservesMaterialWithoutAttemptBoundary( +func TestFrostOrphanedDKGReconcilerBackfillsLegacyInventoryBoundary( t *testing.T, ) { walletID := [32]byte{5} + const keyGroup = "0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" engine := &orphanedDKGRetirementTestEngine{} snapshotChain := &orphanedDKGSnapshotTestChain{ state: Idle, registered: make(map[[32]byte]bool), } + persistenceHandle := &mockPersistenceHandle{} + walletRegistry := &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + walletStorage: newWalletStorage(persistenceHandle), + frostDKGRetirementBoundaries: make(map[string]uint64), + } + currentBlockReads := 0 + operationOrder := make([]string, 0) reconciler := &frostOrphanedDKGReconciler{ - snapshotChain: snapshotChain, - walletRegistry: &walletRegistry{ - walletCache: make(map[string]*walletCacheValue), - }, + snapshotChain: snapshotChain, + walletRegistry: walletRegistry, anchorAdmission: orphanedDKGTestAnchorAdmission(), retirementEngine: engine, + readCurrentBlock: func() (uint64, error) { + currentBlockReads++ + operationOrder = append(operationOrder, "head") + return 120, nil + }, readInventory: func() ( *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error, ) { + operationOrder = append(operationOrder, "inventory") return &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ Entries: []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ WalletID: walletID, - KeyGroup: "0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + KeyGroup: keyGroup, }}, }, nil }, @@ -364,7 +377,85 @@ func TestFrostOrphanedDKGReconcilerPreservesMaterialWithoutAttemptBoundary( t.Fatal(err) } if len(snapshotChain.points) != 0 || len(engine.retired) != 0 { - t.Fatal("material without a durable attempt boundary was not preserved") + t.Fatal("legacy material was evaluated before migration finality") + } + if currentBlockReads != 1 || len(persistenceHandle.saved) != 1 { + t.Fatalf( + "legacy migration boundary was not persisted exactly once: reads=%d saves=%d", + currentBlockReads, + len(persistenceHandle.saved), + ) + } + if len(operationOrder) != 2 || + operationOrder[0] != "inventory" || + operationOrder[1] != "head" { + t.Fatalf( + "migration boundary was not observed after inventory: [%v]", + operationOrder, + ) + } + + reopened, err := newWalletRegistry( + persistenceHandle, + Connect().CalculateWalletID, + ) + if err != nil { + t.Fatal(err) + } + reconciler.walletRegistry = reopened + beforeBoundary := FrostPreSignFinality{ + BlockNumber: 119, + BlockHash: [32]byte{0xbb}, + } + if err := reconciler.reconcile( + context.Background(), + beforeBoundary, + map[[32]byte]struct{}{}, + ); err != nil { + t.Fatal(err) + } + if len(snapshotChain.points) != 0 || len(engine.retired) != 0 { + t.Fatal("legacy material was evaluated before the migration boundary") + } + + atBoundary := FrostPreSignFinality{ + BlockNumber: 120, + BlockHash: [32]byte{0xcc}, + } + if err := reconciler.reconcile( + context.Background(), + atBoundary, + map[[32]byte]struct{}{}, + ); err != nil { + t.Fatal(err) + } + if len(snapshotChain.points) != 0 || len(engine.retired) != 0 { + t.Fatal("legacy material was evaluated at the migration boundary") + } + + afterBoundary := FrostPreSignFinality{ + BlockNumber: 121, + BlockHash: [32]byte{0xdd}, + } + if err := reconciler.reconcile( + context.Background(), + afterBoundary, + map[[32]byte]struct{}{}, + ); err != nil { + t.Fatal(err) + } + if len(snapshotChain.points) != 1 || + snapshotChain.points[0] != afterBoundary || + len(engine.retired) != 1 || + engine.retired[0] != keyGroup { + t.Fatalf( + "legacy orphan was not retired after migration finality: points=%+v retired=%v", + snapshotChain.points, + engine.retired, + ) + } + if currentBlockReads != 1 { + t.Fatalf("migration head was read again: [%d]", currentBlockReads) } } @@ -392,8 +483,12 @@ func orphanedDKGTestWalletRegistry( } return &walletRegistry{ walletCache: make(map[string]*walletCacheValue), - frostDKGAttemptStartBlocks: map[string]uint64{ - seed: attemptStartBlock, + frostDKGRetirementBoundaries: map[string]uint64{ + frostDKGRetirementBoundaryIdentity( + frostDKGRetirementBoundaryKindAttempt, + seed, + attemptStartBlock, + ): attemptStartBlock, }, } } diff --git a/pkg/tbtc/registry.go b/pkg/tbtc/registry.go index 935c645984..07d09e4d23 100644 --- a/pkg/tbtc/registry.go +++ b/pkg/tbtc/registry.go @@ -39,15 +39,17 @@ type walletRegistry struct { // to identify itself. retainedFrostKeyGroups map[[32]byte]string - // frostDKGAttemptStartBlocks is an append-only durable ledger of every DKG - // this node admitted before native package persistence. Orphan retirement - // uses its newest block to reject finalized snapshots that predate locally - // held packages. - frostDKGAttemptStartBlocks map[string]uint64 - - // revision advances after each successful DKG-attempt admission, - // active-session registration, or archive. Readiness pins it across its - // Go/Rust reconciliation so relevant durable state cannot change mid-check. + // frostDKGRetirementBoundaries is an append-only durable ledger of every + // DKG this node admitted before native package persistence, plus the + // one-time upgrade boundary for packages created before the ledger + // existed. Orphan retirement rejects finalized snapshots older than its + // newest block. + frostDKGRetirementBoundaries map[string]uint64 + + // revision advances after each successful DKG retirement-boundary + // persistence, active-session registration, or archive. Readiness pins it + // across its Go/Rust reconciliation so relevant durable state cannot change + // mid-check. revision uint64 // calculateWalletIdFunc calculates the ECDSA wallet ID based on the @@ -80,11 +82,11 @@ func newWalletRegistry( err, ) } - frostDKGAttemptStartBlocks, err := - walletStorage.loadFrostDKGAttemptStartBlocks() + frostDKGRetirementBoundaries, err := + walletStorage.loadFrostDKGRetirementBoundaries() if err != nil { return nil, fmt.Errorf( - "could not load durable FROST DKG attempts: [%w]", + "could not load durable FROST DKG retirement boundaries: [%w]", err, ) } @@ -155,11 +157,11 @@ func newWalletRegistry( } return &walletRegistry{ - walletCache: walletCache, - walletStorage: walletStorage, - retainedFrostKeyGroups: retainedFrostKeyGroups, - frostDKGAttemptStartBlocks: frostDKGAttemptStartBlocks, - calculateWalletIdFunc: calculateWalletIdFunc, + walletCache: walletCache, + walletStorage: walletStorage, + retainedFrostKeyGroups: retainedFrostKeyGroups, + frostDKGRetirementBoundaries: frostDKGRetirementBoundaries, + calculateWalletIdFunc: calculateWalletIdFunc, }, nil } From 692a321dc605d48366b33bd25280d903c4a01ad0 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 15:10:54 -0400 Subject: [PATCH 59/93] fix(tbtc): preserve partially persisted FROST DKG shares executeDistributedFrostDKG retired every key group that HAD been durably persisted whenever RunDistributedDKGForSeats returned an error. That decision was made from local information only, and it is not sound: a seat reaches the persist stage strictly after its runner completes part 3, so it has already broadcast every round-1 and round-2 package. A node-local persist failure - a sibling seat's store I/O, an anchor briefly unreachable between two anchored persists - says nothing about the distributed outcome. The remaining members can collect HonestThreshold result signatures without this node and submit a result whose member list is the full sortition group, this operator included. The eager retirement therefore irreversibly destroyed the secret share of a wallet that then registered on-chain, leaving it permanently short of this node's seat; correlated occurrences across operators can push a wallet below threshold. This is the same rule commit cca329d2f applied to the post-run error exits ("only reconciliation rooted in finalized retained history may prove that the attempt resolved without this wallet"); the RunDistributedDKGForSeats error exit was the one place still deciding locally, on material that is equally durable and equally likely to be globally live. Drop the eager retirement and log what a failed run left durable instead. The attempt boundary recorded before the run keeps the orphaned key group visible to frostOrphanedDKGReconciler, which reads it from the native inventory and preserves it while the wallet is registered or the DKG is unresolved, retiring it only after a finalized snapshot proves the attempt resolved without this wallet - so nothing is stranded and nothing is destroyed on a guess. Co-Authored-By: Claude Fable 5 --- ...tributed_dkg_orchestration_frost_native.go | 15 +- pkg/tbtc/frost_dkg_execution_frost_native.go | 77 +++---- .../frost_dkg_execution_frost_native_test.go | 212 ++++++++++++++++-- 3 files changed, 225 insertions(+), 79 deletions(-) diff --git a/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go b/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go index cccae24b6e..9f32a665ff 100644 --- a/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go +++ b/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go @@ -212,7 +212,8 @@ func collectDistributedDKGSeatOutcomes( } // Record every successful durable write before checking agreement. // A mismatching handle is precisely the case where the caller needs all - // persisted outcomes in order to retire every orphaned key group. + // persisted outcomes in order to know every key group this run left + // behind. persistBySeat[outcome.member] = outcome.persist if keyGroup == "" { keyGroup = outcome.persist.KeyGroup @@ -227,10 +228,14 @@ func collectDistributedDKGSeatOutcomes( } } if firstErr != nil { - // Return successful durable writes alongside the error. The caller must - // retire their shared key group before abandoning the DKG; discarding the - // partial map here would strand an orphan when one local seat persists - // before a sibling fails. + // Return successful durable writes alongside the error. Those seats + // finished part 3, so they already broadcast every round package and the + // group they belong to may still be completed and registered by the + // other members: the caller must PRESERVE this material, not retire it + // from local failure information. The map is returned so the caller can + // account for exactly what a failed run left durable; discarding it here + // would hide an orphan created when one local seat persists before a + // sibling fails. return persistBySeat, firstErr } diff --git a/pkg/tbtc/frost_dkg_execution_frost_native.go b/pkg/tbtc/frost_dkg_execution_frost_native.go index 5e7ade34c1..a103fa9fe8 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native.go @@ -50,7 +50,9 @@ func executeFrostDKGIfPossible( if _, ok := nativeTBTCSignerEngine.(frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine); !ok { logger.Errorf( "FROST DKG with seed [0x%x] selected this operator, but the native "+ - "tbtc-signer engine cannot durably retire partially persisted DKG packages", + "tbtc-signer engine cannot durably retire orphaned DKG packages, "+ + "so key material this attempt creates could never be reclaimed "+ + "if the attempt resolves without this wallet", event.Seed, ) return false @@ -447,11 +449,28 @@ func executeDistributedFrostDKG( prebuffer, ) if err != nil { - if retirementErr := retirePersistedFrostDKGKeyGroups( - nativeEngine, - persistBySeat, - ); retirementErr != nil { - return nil, fmt.Errorf("%w; %v", err, retirementErr) + // Every seat that reached the persist stage owns a DURABLE secret share, + // and it broadcast all of its round-1 and round-2 packages before that: + // persistence runs strictly after the runner completed part 3. A local + // failure here - a sibling seat's persist I/O, an anchor briefly + // unreachable between two anchored persists - therefore proves nothing + // about the DISTRIBUTED outcome. The remaining members can still collect + // HonestThreshold result signatures without this node and register a + // wallet whose member list is the full sortition group, this operator + // included. Retiring the persisted key groups from this error exit would + // irreversibly destroy the shares of a wallet that goes live, so it is + // deliberately not done - the same rule the caller applies to every + // error exit after this function returns. The attempt boundary recorded + // before this run keeps the orphaned key group visible to orphan + // reconciliation, which retires it only once finalized retained history + // proves the attempt resolved without this wallet. + if len(persistBySeat) > 0 { + logger.Warnf( + "preserving [%d] durably persisted FROST DKG key package(s) "+ + "from a failed local DKG run; only finalized reconciliation "+ + "may retire them", + len(persistBySeat), + ) } return nil, err } @@ -874,52 +893,6 @@ func sameFrostDKGWalletResult(first, second *registry.Result) bool { ) } -func retirePersistedFrostDKGKeyGroups( - nativeEngine frostsigning.NativeTBTCSignerEngine, - persistBySeat map[group.MemberIndex]*frostsigning.NativeTBTCSignerDKGResult, -) error { - keyGroupSet := make(map[string]struct{}) - for _, persisted := range persistBySeat { - if persisted == nil || persisted.KeyGroup == "" { - continue - } - keyGroupSet[persisted.KeyGroup] = struct{}{} - } - if len(keyGroupSet) == 0 { - return nil - } - - retirementEngine, ok := - nativeEngine.(frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine) - if !ok { - return fmt.Errorf( - "native signer cannot retire partially persisted DKG material", - ) - } - - keyGroups := make([]string, 0, len(keyGroupSet)) - for keyGroup := range keyGroupSet { - keyGroups = append(keyGroups, keyGroup) - } - slices.Sort(keyGroups) - - retirementErrors := make([]error, 0) - for _, keyGroup := range keyGroups { - if err := retirementEngine. - RetireDistributedDKGKeyPackages(keyGroup); err != nil { - retirementErrors = append( - retirementErrors, - fmt.Errorf( - "cannot retire partially persisted DKG key group [%s]: [%w]", - keyGroup, - err, - ), - ) - } - } - return errors.Join(retirementErrors...) -} - func frostOutputKeyToECDSAPublicKey( outputKey frost.OutputKey, ) (*ecdsa.PublicKey, error) { diff --git a/pkg/tbtc/frost_dkg_execution_frost_native_test.go b/pkg/tbtc/frost_dkg_execution_frost_native_test.go index e4bb249f6d..2fcbcc64c4 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native_test.go @@ -7,13 +7,18 @@ import ( "context" "encoding/hex" "errors" + "fmt" "math/big" "strings" + "sync" "testing" + "time" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/frost/registry" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + netlocal "github.com/keep-network/keep-core/pkg/net/local" + "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/protocol/group" ) @@ -227,44 +232,207 @@ func registerFrostDKGReadinessTestEngine(t *testing.T) { }) } -type frostDKGRetirementRecordingEngine struct { +// frostDKGPartialPersistTestEngine drives a complete distributed DKG for two +// co-located local seats with opaque round payloads - the smallest engine that +// can reach the persist stage, which is the only place a DURABLE key package can +// exist. Persist then fails for one seat, reproducing exactly the partial-persist +// failure whose local key groups must survive. +type frostDKGPartialPersistTestEngine struct { frostDKGReadinessTestEngine - retired []string - fail map[string]error + keyGroup string + failPersistForSeat uint16 + + mutex sync.Mutex + persisted []uint16 + retired []string +} + +func (engine *frostDKGPartialPersistTestEngine) Part1( + participantIdentifier string, + _ uint16, + _ uint16, +) (*frostsigning.NativeFROSTDKGPart1Result, error) { + return &frostsigning.NativeFROSTDKGPart1Result{ + SecretPackage: &frostsigning.NativeFROSTDKGRound1SecretPackage{ + Data: []byte("round1-secret-" + participantIdentifier), + }, + Package: &frostsigning.NativeFROSTDKGRound1Package{ + Identifier: participantIdentifier, + Data: []byte("round1-" + participantIdentifier), + }, + }, nil +} + +func (engine *frostDKGPartialPersistTestEngine) Part2( + _ *frostsigning.NativeFROSTDKGRound1SecretPackage, + round1Packages []*frostsigning.NativeFROSTDKGRound1Package, +) (*frostsigning.NativeFROSTDKGPart2Result, error) { + // One round-2 package per OTHER member, addressed by that member's + // identifier - the shape the runner routes and seals by. + packages := make( + []*frostsigning.NativeFROSTDKGRound2Package, + 0, + len(round1Packages), + ) + for _, round1Package := range round1Packages { + packages = append( + packages, + &frostsigning.NativeFROSTDKGRound2Package{ + Identifier: round1Package.Identifier, + Data: []byte("round2-for-" + round1Package.Identifier), + }, + ) + } + return &frostsigning.NativeFROSTDKGPart2Result{ + SecretPackage: &frostsigning.NativeFROSTDKGRound2SecretPackage{ + Data: []byte("round2-secret"), + }, + Packages: packages, + }, nil +} + +func (engine *frostDKGPartialPersistTestEngine) Part3( + _ *frostsigning.NativeFROSTDKGRound2SecretPackage, + _ []*frostsigning.NativeFROSTDKGRound1Package, + _ []*frostsigning.NativeFROSTDKGRound2Package, +) (*frostsigning.NativeFROSTDKGResult, error) { + return &frostsigning.NativeFROSTDKGResult{ + KeyPackage: &frostsigning.NativeFROSTKeyPackage{ + Data: []byte("key-package"), + }, + PublicKeyPackage: &frostsigning.NativeFROSTPublicKeyPackage{ + VerifyingKey: engine.keyGroup, + }, + }, nil } -func (engine *frostDKGRetirementRecordingEngine) RetireDistributedDKGKeyPackages( +func (engine *frostDKGPartialPersistTestEngine) PersistDistributedDKGKeyPackage( + sessionID string, + participantIdentifier uint16, + threshold uint16, + participantCount uint16, + _ *frostsigning.NativeFROSTKeyPackage, + _ *frostsigning.NativeFROSTPublicKeyPackage, +) (*frostsigning.NativeTBTCSignerDKGResult, error) { + if participantIdentifier == engine.failPersistForSeat { + return nil, errors.New("injected key-package persistence failure") + } + engine.mutex.Lock() + engine.persisted = append(engine.persisted, participantIdentifier) + engine.mutex.Unlock() + return &frostsigning.NativeTBTCSignerDKGResult{ + SessionID: sessionID, + KeyGroup: engine.keyGroup, + ParticipantCount: participantCount, + Threshold: threshold, + }, nil +} + +func (engine *frostDKGPartialPersistTestEngine) RetireDistributedDKGKeyPackages( keyGroup string, ) error { + engine.mutex.Lock() + defer engine.mutex.Unlock() engine.retired = append(engine.retired, keyGroup) - return engine.fail[keyGroup] + return nil } -func TestRetirePersistedFrostDKGKeyGroupsRetiresEveryDistinctGroup( +func (engine *frostDKGPartialPersistTestEngine) outcome() ([]uint16, []string) { + engine.mutex.Lock() + defer engine.mutex.Unlock() + return append([]uint16{}, engine.persisted...), + append([]string{}, engine.retired...) +} + +// TestExecuteDistributedFrostDKG_PreservesPartiallyPersistedKeyGroups pins the +// invariant that a LOCAL distributed-DKG failure must never destroy durable +// secret shares. A seat only reaches the persist stage after its runner finished +// part 3, so it already broadcast every round package; the other members can +// finish the DKG and register a wallet that still lists this operator. Retiring +// the persisted key group here would leave that live wallet permanently short of +// this node's share, so the node preserves it and leaves retirement to +// reconciliation rooted in finalized retained history. +func TestExecuteDistributedFrostDKG_PreservesPartiallyPersistedKeyGroups( t *testing.T, ) { - engine := &frostDKGRetirementRecordingEngine{ - fail: map[string]error{ - "key-group-a": errors.New("injected retirement failure"), - }, + const keyGroup = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + + localChain := Connect() + _, operatorPublicKey, err := localChain.OperatorKeyPair() + if err != nil { + t.Fatal(err) } - err := retirePersistedFrostDKGKeyGroups( + operatorAddress := localChain.Signing().PublicKeyBytesToAddress( + operator.MarshalUncompressed(operatorPublicKey), + ) + + sessionID := fmt.Sprintf( + "frost-dkg-partial-persist-%d", + time.Now().UnixNano(), + ) + channel, err := netlocal.ConnectWithKey(operatorPublicKey). + BroadcastChannelFor(sessionID) + if err != nil { + t.Fatal(err) + } + + engine := &frostDKGPartialPersistTestEngine{ + keyGroup: keyGroup, + // Seat 2 fails to persist AFTER seat 1 has durably persisted the very + // same key group. + failPersistForSeat: 2, + } + + ctx, cancelCtx := context.WithTimeout(context.Background(), 30*time.Second) + defer cancelCtx() + + executionResult, err := executeDistributedFrostDKG( + ctx, engine, - map[group.MemberIndex]*frostsigning.NativeTBTCSignerDKGResult{ - 1: {KeyGroup: "key-group-b"}, - 2: {KeyGroup: "key-group-a"}, - 3: {KeyGroup: "key-group-b"}, + &node{ + chain: localChain, + frostGroupParameters: &GroupParameters{ + GroupSize: 3, + GroupQuorum: 2, + HonestThreshold: 2, + }, + }, + channel, + []group.MemberIndex{1, 2}, + []group.MemberIndex{1, 2}, + []group.MemberIndex{1, 2}, + &GroupSelectionResult{ + OperatorsAddresses: chain.Addresses{ + operatorAddress, + operatorAddress, + operatorAddress, + }, }, + 2, + sessionID, + nil, ) - if err == nil || !strings.Contains(err.Error(), "key-group-a") { - t.Fatalf("unexpected retirement result: [%v]", err) + if err == nil || executionResult != nil { + t.Fatalf( + "partially persisted FROST DKG reported success: [%+v]", + executionResult, + ) + } + if !strings.Contains(err.Error(), "cannot persist the key package") { + t.Fatalf("unexpected FROST DKG execution failure: [%v]", err) + } + + persisted, retired := engine.outcome() + if len(persisted) != 1 || persisted[0] != 1 { + t.Fatalf( + "the run did not reach a partial-persist state: persisted=[%v]", + persisted, + ) } - if len(engine.retired) != 2 || - engine.retired[0] != "key-group-a" || - engine.retired[1] != "key-group-b" { + if len(retired) != 0 { t.Fatalf( - "not every distinct key group was retired after a failure: [%v]", - engine.retired, + "a local DKG failure destroyed durably persisted key groups: [%v]", + retired, ) } } From 82e19f4447863219ed6bc858fe56c669a6c0d9bd Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 15:11:39 -0400 Subject: [PATCH 60/93] test(tbtc): cover destructive FROST DKG reconciliation paths Every existing frostOrphanedDKGReconciler test built the reconciler over a walletRegistry with an empty wallet cache, so frostLocalSessionSnapshotLocked returned nothing and candidate.hasLocalSession was false in all of them. The destructive half of the newly shipped reconciliation code - the half that archives a wallet and the fail-closed checks guarding it - was therefore never executed. Cover it with a registry that holds a real local FROST session: - an orphan with both native inventory and a local session must retire the native key group BEFORE archiving the session, since a crash in between must leave a state the next pass can finish rather than one where the identity of the native material is gone; - the crash-recovery half-state (session present, native material already retired) must archive and must not ask the engine to retire again; - a native inventory and a Go session that describe the same wallet with different key groups or participant counts must stop reconciliation before anything destructive runs; - two local sessions resolving to one wallet ID must do the same. The boundary-before-persist ordering in executeFrostDKGIfPossible had no test either: it is unreachable from a unit test because the path first requires InteractiveSigningReady(), which needs an interactive engine provider that only the cgo build registers and that pkg/frost/signing exposes no seam for. Extract the two ordered admission steps into admitFrostDKGAttempt - which documents why the order is load-bearing - and pin the fail-closed direction: when the attempt boundary cannot be persisted, no anchor capacity is reserved and no reservation is handed back, so nothing can reach the persist stage behind an unrecorded boundary. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/frost_dkg_execution_frost_native.go | 74 +++- .../frost_dkg_execution_frost_native_test.go | 43 +++ .../frost_dkg_retirement_frost_native_test.go | 361 ++++++++++++++++++ 3 files changed, 459 insertions(+), 19 deletions(-) diff --git a/pkg/tbtc/frost_dkg_execution_frost_native.go b/pkg/tbtc/frost_dkg_execution_frost_native.go index a103fa9fe8..80810c3cad 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "math/big" "slices" "github.com/btcsuite/btcd/btcec/v2" @@ -119,30 +120,15 @@ func executeFrostDKGIfPossible( fullMembers := frostFullMembers(groupSelectionResult) dkgTimeoutBlock := event.BlockNumber + params.SubmissionTimeoutBlocks - // Persist the attempt boundary before readiness or native DKG execution can - // create key packages. Orphan reconciliation is intentionally unable to - // retire packages until its finalized snapshot covers this block. - if err := node.walletRegistry.recordFrostDKGAttempt( + anchorReservation, err := admitFrostDKGAttempt( + ctx, + node, event.Seed, event.BlockNumber, - ); err != nil { - logger.Errorf( - "failed to persist FROST DKG attempt boundary: [%v]", - err, - ) - return false - } - - // Acquire the reversible capacity reservation before crossing the - // goroutine boundary. A transient admission failure must be visible to the - // coordinator so it releases the event lease and a replay can retry. - anchorReservation, err := reserveFrostDKGReadiness( - ctx, - node.frostNativeSignerAnchorAdmission, memberIndexes, ) if err != nil { - logger.Errorf("FROST DKG anchor admission failed: [%v]", err) + logger.Errorf("FROST DKG attempt was not admitted: [%v]", err) return false } @@ -327,6 +313,56 @@ func executeFrostDKGIfPossible( return true } +// admitFrostDKGAttempt performs the two ordered, fail-closed steps that must +// both succeed before this operator may run a DKG attempt, and returns the +// anchor reservation the attempt goroutine releases when it finishes. +// +// The order is load-bearing. The attempt boundary is persisted FIRST, before +// readiness or native DKG execution can create any key package: orphan +// reconciliation is intentionally unable to retire packages until its finalized +// snapshot covers this block, so a package that exists without a recorded +// boundary could be retired from a snapshot taken before the attempt started. +// A boundary that cannot be persisted therefore aborts the attempt outright - +// nothing may reach the persist stage behind an unrecorded boundary. +// +// The reversible capacity reservation is acquired second, still before crossing +// the goroutine boundary, so a transient admission failure stays visible to the +// coordinator, which releases the event lease so a replay can retry. +func admitFrostDKGAttempt( + ctx context.Context, + node *node, + seed *big.Int, + startBlock uint64, + memberIndexes []group.MemberIndex, +) ( + *frostNativeSignerAnchorRevisionReservation, + error, +) { + if node == nil { + return nil, fmt.Errorf("node is unavailable") + } + if err := node.walletRegistry.recordFrostDKGAttempt( + seed, + startBlock, + ); err != nil { + return nil, fmt.Errorf( + "cannot persist the FROST DKG attempt boundary: [%w]", + err, + ) + } + + anchorReservation, err := reserveFrostDKGReadiness( + ctx, + node.frostNativeSignerAnchorAdmission, + memberIndexes, + ) + if err != nil { + return nil, err + } + + return anchorReservation, nil +} + func reserveFrostDKGReadiness( ctx context.Context, anchorAdmission *frostNativeSignerAnchorAdmissionController, diff --git a/pkg/tbtc/frost_dkg_execution_frost_native_test.go b/pkg/tbtc/frost_dkg_execution_frost_native_test.go index 2fcbcc64c4..a06f3bca8e 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native_test.go @@ -437,6 +437,49 @@ func TestExecuteDistributedFrostDKG_PreservesPartiallyPersistedKeyGroups( } } +func TestAdmitFrostDKGAttempt_UnrecordedBoundaryBlocksAdmission(t *testing.T) { + persistenceHandle := &failingFrostDKGAttemptPersistence{ + mockPersistenceHandle: &mockPersistenceHandle{}, + } + anchorAdmission := orphanedDKGTestAnchorAdmission() + dkgNode := &node{ + walletRegistry: &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + walletStorage: newWalletStorage(persistenceHandle), + frostDKGRetirementBoundaries: make(map[string]uint64), + }, + frostNativeSignerAnchorAdmission: anchorAdmission, + } + + reservation, err := admitFrostDKGAttempt( + context.Background(), + dkgNode, + big.NewInt(100), + 101, + []group.MemberIndex{1, 2}, + ) + if err == nil || !strings.Contains( + err.Error(), + errFrostDKGAttemptPersistenceTest.Error(), + ) { + t.Fatalf("unrecorded attempt boundary was admitted: [%v]", err) + } + if reservation != nil { + t.Fatal("rejected DKG attempt returned an anchor reservation") + } + // Nothing may run behind an unrecorded boundary: a key package created by + // this attempt could be retired from a snapshot taken before it started. + if anchorAdmission.reserved != (frostNativeSignerAnchorCapacity{}) { + t.Fatalf( + "anchor capacity was reserved for an unrecorded attempt: [%+v]", + anchorAdmission.reserved, + ) + } + if len(dkgNode.walletRegistry.frostDKGRetirementBoundaries) != 0 { + t.Fatal("a failed boundary persistence left an in-memory boundary") + } +} + func TestLowestLocalActiveMemberIndex(t *testing.T) { testCases := map[string]struct { local []group.MemberIndex diff --git a/pkg/tbtc/frost_dkg_retirement_frost_native_test.go b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go index 7bed07c534..5a9d211545 100644 --- a/pkg/tbtc/frost_dkg_retirement_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go @@ -4,10 +4,15 @@ package tbtc import ( "context" + "crypto/ecdsa" "encoding/hex" + "fmt" "math/big" + "strings" "testing" + "github.com/keep-network/keep-common/pkg/persistence" + "github.com/keep-network/keep-core/pkg/chain" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" ) @@ -459,6 +464,362 @@ func TestFrostOrphanedDKGReconcilerBackfillsLegacyInventoryBoundary( } } +// orphanedDKGOrderedTestEngine and orphanedDKGOrderedTestPersistence share one +// operation log so a test can pin the ORDER of the two durable mutations a +// retirement performs. Native key packages must go first: a crash between them +// leaves a state a later pass can finish (no native material, session still +// present), whereas archiving first would destroy the identity proving which +// native key group belongs to the orphan. +type orphanedDKGOrderedTestEngine struct { + operations *[]string +} + +func (engine *orphanedDKGOrderedTestEngine) RetireDistributedDKGKeyPackages( + keyGroup string, +) error { + *engine.operations = append(*engine.operations, "retire:"+keyGroup) + return nil +} + +type orphanedDKGOrderedTestPersistence struct { + *mockPersistenceHandle + operations *[]string +} + +func (persistence *orphanedDKGOrderedTestPersistence) Archive( + directory string, +) error { + *persistence.operations = append(*persistence.operations, "archive") + return persistence.mockPersistenceHandle.Archive(directory) +} + +// orphanedDKGTestSessionRegistry builds a wallet registry holding ONE real local +// FROST session, so the reconciler's local-session paths run against the same +// snapshot production takes rather than an empty wallet cache. +func orphanedDKGTestSessionRegistry( + t *testing.T, + attemptStartBlock uint64, + keyGroup string, + operatorCount int, + persistenceHandle persistence.ProtectedHandle, +) *walletRegistry { + t.Helper() + publicKey := frostBindingWalletPublicKey() + walletPublicKeyHash := [20]byte{0x51} + operators := make([]chain.Address, 0, operatorCount) + for index := 0; index < operatorCount; index++ { + operators = append( + operators, + chain.Address(fmt.Sprintf("0x%040d", index+1)), + ) + } + + registry := orphanedDKGTestWalletRegistry(t, attemptStartBlock) + registry.walletStorage = newWalletStorage(persistenceHandle) + registry.retainedFrostKeyGroups = make(map[[32]byte]string) + registry.walletCache[getWalletStorageKey(publicKey)] = + orphanedDKGTestSession(t, publicKey, walletPublicKeyHash, keyGroup, operators) + + return registry +} + +func orphanedDKGTestSession( + t *testing.T, + publicKey *ecdsa.PublicKey, + walletPublicKeyHash [20]byte, + keyGroup string, + operators []chain.Address, +) *walletCacheValue { + t.Helper() + return &walletCacheValue{ + walletID: frostBindingWalletID(t), + walletPublicKeyHash: walletPublicKeyHash, + signers: []*signer{{ + wallet: wallet{ + publicKey: publicKey, + signingGroupOperators: operators, + }, + signingGroupMemberIndex: 1, + signerMaterial: frostBindingSignerMaterial(t, keyGroup), + }}, + } +} + +func TestFrostOrphanedDKGReconcilerRetiresNativeMaterialBeforeArchiving( + t *testing.T, +) { + operations := make([]string, 0) + persistenceHandle := &orphanedDKGOrderedTestPersistence{ + mockPersistenceHandle: &mockPersistenceHandle{}, + operations: &operations, + } + registry := orphanedDKGTestSessionRegistry( + t, + 90, + frostBindingEvenKey, + 3, + persistenceHandle, + ) + walletID := frostBindingWalletID(t) + snapshotChain := &orphanedDKGSnapshotTestChain{ + state: Idle, + registered: make(map[[32]byte]bool), + } + reconciler := &frostOrphanedDKGReconciler{ + snapshotChain: snapshotChain, + walletRegistry: registry, + anchorAdmission: orphanedDKGTestAnchorAdmission(), + retirementEngine: &orphanedDKGOrderedTestEngine{operations: &operations}, + readInventory: func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, + ) { + return &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Entries: []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ + WalletID: walletID, + KeyGroup: frostBindingEvenKey, + ParticipantCount: 3, + }}, + }, nil + }, + } + + if err := reconciler.reconcile( + context.Background(), + orphanedDKGTestPoint(), + map[[32]byte]struct{}{}, + ); err != nil { + t.Fatal(err) + } + if len(operations) != 2 || + operations[0] != "retire:"+frostBindingEvenKey || + operations[1] != "archive" { + t.Fatalf( + "orphan was not retired natively before archiving: [%v]", + operations, + ) + } + if len(registry.walletCache) != 0 { + t.Fatalf( + "orphaned local session survived reconciliation: [%d]", + len(registry.walletCache), + ) + } + if registry.retainedFrostKeyGroups[walletID] != frostBindingEvenKey { + t.Fatal("archived orphan did not retain its exact key-group binding") + } + if reconciler.anchorAdmission.reserved != + (frostNativeSignerAnchorCapacity{}) { + t.Fatalf( + "retirement reservation was not released: [%+v]", + reconciler.anchorAdmission.reserved, + ) + } +} + +// TestFrostOrphanedDKGReconcilerArchivesSessionWithoutNativeMaterial covers the +// crash-recovery half-state: a previous pass retired the native key group and +// died before archiving the Go session. The repeat pass must finish the archive +// and must NOT ask the engine to retire material that is already gone. +func TestFrostOrphanedDKGReconcilerArchivesSessionWithoutNativeMaterial( + t *testing.T, +) { + operations := make([]string, 0) + persistenceHandle := &orphanedDKGOrderedTestPersistence{ + mockPersistenceHandle: &mockPersistenceHandle{}, + operations: &operations, + } + registry := orphanedDKGTestSessionRegistry( + t, + 90, + frostBindingEvenKey, + 3, + persistenceHandle, + ) + reconciler := &frostOrphanedDKGReconciler{ + snapshotChain: &orphanedDKGSnapshotTestChain{ + state: Idle, + registered: make(map[[32]byte]bool), + }, + walletRegistry: registry, + anchorAdmission: orphanedDKGTestAnchorAdmission(), + retirementEngine: &orphanedDKGOrderedTestEngine{operations: &operations}, + readInventory: func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, + ) { + return &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Entries: []frostsigning.NativeTBTCSignerRetainedKeyGroup{}, + }, nil + }, + } + + if err := reconciler.reconcile( + context.Background(), + orphanedDKGTestPoint(), + map[[32]byte]struct{}{}, + ); err != nil { + t.Fatal(err) + } + if len(operations) != 1 || operations[0] != "archive" { + t.Fatalf( + "half-retired orphan was not archived exactly once: [%v]", + operations, + ) + } + if len(registry.walletCache) != 0 { + t.Fatal("half-retired orphan kept its local session") + } + // No native inventory means nothing to charge the anchor for. + if reconciler.anchorAdmission.reserved != + (frostNativeSignerAnchorCapacity{}) { + t.Fatalf( + "session-only retirement charged anchor capacity: [%+v]", + reconciler.anchorAdmission.reserved, + ) + } +} + +// TestFrostOrphanedDKGReconcilerRejectsDisagreeingMaterial pins the fail-closed +// check guarding destructive reconciliation: if the native inventory and the Go +// session describe the same wallet differently, the reconciler cannot know which +// key group a retirement would destroy, so it must refuse to retire anything. +func TestFrostOrphanedDKGReconcilerRejectsDisagreeingMaterial(t *testing.T) { + walletID := frostBindingWalletID(t) + testCases := map[string]struct { + inventoryKeyGroup string + inventoryParticipantCount uint16 + }{ + "key group parity disagrees": { + inventoryKeyGroup: frostBindingOddKey, + inventoryParticipantCount: 3, + }, + "participant count disagrees": { + inventoryKeyGroup: frostBindingEvenKey, + inventoryParticipantCount: 4, + }, + } + + for name, test := range testCases { + t.Run(name, func(t *testing.T) { + operations := make([]string, 0) + persistenceHandle := &orphanedDKGOrderedTestPersistence{ + mockPersistenceHandle: &mockPersistenceHandle{}, + operations: &operations, + } + registry := orphanedDKGTestSessionRegistry( + t, + 90, + frostBindingEvenKey, + 3, + persistenceHandle, + ) + snapshotChain := &orphanedDKGSnapshotTestChain{ + state: Idle, + registered: make(map[[32]byte]bool), + } + reconciler := &frostOrphanedDKGReconciler{ + snapshotChain: snapshotChain, + walletRegistry: registry, + anchorAdmission: orphanedDKGTestAnchorAdmission(), + retirementEngine: &orphanedDKGOrderedTestEngine{ + operations: &operations, + }, + readInventory: func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, + ) { + return &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Entries: []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ + WalletID: walletID, + KeyGroup: test.inventoryKeyGroup, + ParticipantCount: test.inventoryParticipantCount, + }}, + }, nil + }, + } + + err := reconciler.reconcile( + context.Background(), + orphanedDKGTestPoint(), + map[[32]byte]struct{}{}, + ) + if err == nil || + !strings.Contains(err.Error(), "FROST DKG material disagree") { + t.Fatalf("disagreeing DKG material was accepted: [%v]", err) + } + if len(operations) != 0 || len(snapshotChain.points) != 0 { + t.Fatalf( + "disagreeing DKG material was acted on: [%v]", + operations, + ) + } + if len(registry.walletCache) != 1 { + t.Fatal("disagreeing DKG material lost its local session") + } + }) + } +} + +func TestFrostOrphanedDKGReconcilerRejectsDuplicateLocalSession(t *testing.T) { + operations := make([]string, 0) + persistenceHandle := &orphanedDKGOrderedTestPersistence{ + mockPersistenceHandle: &mockPersistenceHandle{}, + operations: &operations, + } + registry := orphanedDKGTestSessionRegistry( + t, + 90, + frostBindingEvenKey, + 3, + persistenceHandle, + ) + // A second cache entry resolving to the SAME wallet ID: reconciliation + // cannot tell which session a retirement would archive, so it must stop. + registry.walletCache["duplicate-frost-session"] = + orphanedDKGTestSession( + t, + frostBindingWalletPublicKey(), + [20]byte{0x52}, + frostBindingEvenKey, + []chain.Address{"0x01", "0x02", "0x03"}, + ) + snapshotChain := &orphanedDKGSnapshotTestChain{ + state: Idle, + registered: make(map[[32]byte]bool), + } + reconciler := &frostOrphanedDKGReconciler{ + snapshotChain: snapshotChain, + walletRegistry: registry, + anchorAdmission: orphanedDKGTestAnchorAdmission(), + retirementEngine: &orphanedDKGOrderedTestEngine{operations: &operations}, + readInventory: func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, + ) { + return &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Entries: []frostsigning.NativeTBTCSignerRetainedKeyGroup{}, + }, nil + }, + } + + err := reconciler.reconcile( + context.Background(), + orphanedDKGTestPoint(), + map[[32]byte]struct{}{}, + ) + if err == nil || + !strings.Contains(err.Error(), "duplicate local FROST DKG wallet session") { + t.Fatalf("duplicate local FROST session was accepted: [%v]", err) + } + if len(operations) != 0 || len(snapshotChain.points) != 0 { + t.Fatalf("duplicate local FROST session was acted on: [%v]", operations) + } + if len(registry.walletCache) != 2 { + t.Fatal("duplicate local FROST sessions were mutated") + } +} + func orphanedDKGTestWalletID(t *testing.T) [32]byte { t.Helper() decoded, err := hex.DecodeString( From 32b4dbd5194851cfb814bad498700b3f46ec39aa Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 15:21:46 -0400 Subject: [PATCH 61/93] fix(tbtc): gate FROST signing on quarantine and its own durable advance Three interacting defects on the native FROST signing path. An active canonical quarantine reached the activation handshake but never the signing path. reconcile() copied QuarantineCount into its snapshot and no consumer read it, so a quarantined wallet stayed in state.Wallets, kept full native signer expectations, and kept signing Bitcoin - while lifting that same quarantine required a manifest-pinned authority quorum certificate. Quarantine records, including recovery-required ones, are raised against exactly one WalletID and lifted against that same WalletID, so the snapshot now carries every unlifted record joined to its canonical Bridge wallet, and the pre-sign authorization gate refuses to authorize or keep authorizing a batch for that wallet. Node-wide emptiness stays a bootstrap requirement of the handshake and is documented as such. The cached readiness path compared the whole native inventory snapshot by value, including the state generation and anchor revision. The authorized signing window is exactly what advances those: admission reserves their durable cost up front and the engine persists a consumption marker before releasing each share. Every guard and every monitor tick therefore saw G+1 != G and failed, and the monitor latches the first error permanently, so native signing aborted deterministically. Identity, trust head and key material stay strictly pinned; the state checkpoint, anchor revision and derived headrooms may only advance monotonically and only while the anchor stays out of the rotation-warning band. A rollback, an equal-generation fork or a rotated trust certificate still fail closed. The journal stamp check used TryLock and reported contention as a fatal authorization error. The journal mutex is held for a whole reconcile, including paginated independent-history reads, so any other wallet's authorization or the handshake exporter's background reconciliation could cancel a valid signing session. Contention is now waited out under the caller's context, which is the pre-caching behaviour; only a genuine stamp change fails. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/frost_native_signer_readiness.go | 133 ++++++- .../frost_native_signer_readiness_test.go | 354 +++++++++++++++++- pkg/tbtc/frost_pre_sign_authorization.go | 106 ++++-- pkg/tbtc/frost_pre_sign_authorization_test.go | 118 +++++- pkg/tbtc/frost_retained_group_journal.go | 101 ++++- pkg/tbtc/frost_retained_group_journal_test.go | 71 ++++ 6 files changed, 828 insertions(+), 55 deletions(-) diff --git a/pkg/tbtc/frost_native_signer_readiness.go b/pkg/tbtc/frost_native_signer_readiness.go index 028d998b19..fea75377b0 100644 --- a/pkg/tbtc/frost_native_signer_readiness.go +++ b/pkg/tbtc/frost_native_signer_readiness.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" "sync" + "time" "github.com/keep-network/keep-core/pkg/chain" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" @@ -335,6 +336,10 @@ func cloneFrostProductionSignerReadinessSnapshot( result := *snapshot if snapshot.Journal != nil { journal := *snapshot.Journal + journal.ActiveQuarantines = append( + []frostRetainedGroupActiveQuarantine{}, + snapshot.Journal.ActiveQuarantines..., + ) result.Journal = &journal } if snapshot.Inventory != nil { @@ -434,6 +439,7 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead return fmt.Errorf("interactive FROST signing engine is not ready") } if err := readiness.verifyFrostRetainedGroupJournalStampUnchanged( + ctx, expected.Journal, ); err != nil { return err @@ -452,10 +458,11 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead if err != nil { return err } - if *inventory != *expected.Inventory { - return fmt.Errorf( - "native signer state changed since readiness reconciliation", - ) + if err := verifyFrostNativeSignerInventoryUnchanged( + expected.Inventory, + inventory, + ); err != nil { + return err } if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( expected.registryRevision, @@ -470,6 +477,7 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead ) } if err := readiness.verifyFrostRetainedGroupJournalStampUnchanged( + ctx, expected.Journal, ); err != nil { return err @@ -477,19 +485,126 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead return nil } +// verifyFrostNativeSignerInventoryUnchanged separates the native signer facts +// that must be byte-identical for the whole authorized signing window from the +// ones the window itself legitimately advances. +// +// The authorized window is exactly what mutates the Rust store: the anchor +// admission controller reserves the durable cost of every anchored call up +// front, the engine persists a consumption marker before releasing each share, +// and the inventory endpoint reports the live tip. Requiring the whole snapshot +// to compare equal therefore turns each session's own persistence into +// "readiness changed", and because the pre-sign authorization monitor latches +// the first revalidation error permanently, that aborts the very signing it is +// guarding. +// +// Identity, trust and key material stay strictly pinned. The state checkpoint, +// the anchor revision and the derived restartable headrooms may only advance +// monotonically, and only while the anchor stays out of its rotation-warning +// band - the same threshold below which the admission controller refuses new +// work. A rollback, an equal-generation fork, a different store or trust head, +// a changed key-package commitment, or an advance that consumes the warning +// headroom all still fail closed. +func verifyFrostNativeSignerInventoryUnchanged( + expected *frostNativeSignerInventorySnapshot, + actual *frostNativeSignerInventorySnapshot, +) error { + if expected == nil || actual == nil { + return fmt.Errorf("native signer readiness snapshot is nil") + } + if actual.Schema != expected.Schema || + actual.StoreFingerprint != expected.StoreFingerprint || + actual.InventoryCommitment != expected.InventoryCommitment || + actual.WalletCount != expected.WalletCount || + actual.KeyPackageCount != expected.KeyPackageCount || + actual.ExternalRollbackAnchorBound != + expected.ExternalRollbackAnchorBound || + actual.TrustCertificateSequence != expected.TrustCertificateSequence || + actual.TrustCertificateDigest != expected.TrustCertificateDigest || + actual.AnchorServiceEpoch != expected.AnchorServiceEpoch || + actual.CertifiedFloorRevision != expected.CertifiedFloorRevision || + actual.CertifiedFloorGeneration != expected.CertifiedFloorGeneration || + actual.AnchorRotationWarning != expected.AnchorRotationWarning { + return fmt.Errorf( + "native signer identity, trust head, or retained key material changed since readiness reconciliation", + ) + } + if actual.StateGeneration < expected.StateGeneration || + actual.CurrentAnchorRevision < expected.CurrentAnchorRevision || + actual.RestartableGenerationHeadroom > + expected.RestartableGenerationHeadroom || + actual.RestartableRevisionHeadroom > + expected.RestartableRevisionHeadroom { + return fmt.Errorf( + "native signer state or anchor revision rolled back since readiness reconciliation", + ) + } + // A durable advance always moves the state commitment chain forward, so an + // unchanged generation carrying a different commitment is a fork, not the + // session's own progress. + if actual.StateGeneration == expected.StateGeneration && + (actual.StateCommitment != expected.StateCommitment || + actual.PreviousStateCommitment != expected.PreviousStateCommitment || + actual.StateImageDigest != expected.StateImageDigest) { + return fmt.Errorf( + "native signer state forked at an unchanged generation since readiness reconciliation", + ) + } + return nil +} + +// frostRetainedGroupJournalStampRetryInterval bounds how often a readiness +// revalidation retries the canonical journal mutex while another workflow +// holds it. +const frostRetainedGroupJournalStampRetryInterval = 5 * time.Millisecond + +// lockFrostRetainedGroupJournalStamp waits for the canonical journal mutex +// under the caller's context instead of treating contention as a failure. The +// mutex is held for an entire reconcile, including paginated reads against the +// independent history source, so multi-second holds are routine and any other +// wallet's authorization - or the activation-handshake exporter's background +// reconciliation - can hold it. Contention is not evidence that anything +// changed, and the pre-sign authorization monitor latches the first +// revalidation error permanently, so returning one here would cancel a +// perfectly valid signing session. Only a genuine stamp change, or the +// caller's own context ending, fails. +func lockFrostRetainedGroupJournalStamp( + ctx context.Context, + journal *frostRetainedGroupJournal, +) error { + if journal.mutex.TryLock() { + return nil + } + ticker := time.NewTicker(frostRetainedGroupJournalStampRetryInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return fmt.Errorf( + "canonical FROST retained-group journal stayed busy until the readiness revalidation deadline: [%w]", + ctx.Err(), + ) + case <-ticker.C: + if journal.mutex.TryLock() { + return nil + } + } + } +} + func (readiness *frostProductionSignerReadiness) verifyFrostRetainedGroupJournalStampUnchanged( + ctx context.Context, expected *frostRetainedGroupJournalSnapshot, ) error { - if readiness == nil || readiness.journal == nil || expected == nil { + if readiness == nil || readiness.journal == nil || expected == nil || + ctx == nil { return fmt.Errorf( "cached FROST retained-group journal readiness is incomplete", ) } journal := readiness.journal - if !journal.mutex.TryLock() { - return fmt.Errorf( - "canonical FROST retained-group journal is busy during readiness revalidation", - ) + if err := lockFrostRetainedGroupJournalStamp(ctx, journal); err != nil { + return err } defer journal.mutex.Unlock() diff --git a/pkg/tbtc/frost_native_signer_readiness_test.go b/pkg/tbtc/frost_native_signer_readiness_test.go index ddf5bc9613..0da11d7850 100644 --- a/pkg/tbtc/frost_native_signer_readiness_test.go +++ b/pkg/tbtc/frost_native_signer_readiness_test.go @@ -7,6 +7,7 @@ import ( "os" "strings" "testing" + "time" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" ) @@ -646,7 +647,7 @@ func TestFrostProductionSignerReadinessRejectsConcurrentRegistryChange( } } -func TestFrostProductionSignerReadinessRejectsChangedOrBusyJournalStamp( +func TestFrostProductionSignerReadinessRejectsChangedJournalStamp( t *testing.T, ) { fixture := newJournalTestFixture(t) @@ -661,6 +662,7 @@ func TestFrostProductionSignerReadinessRejectsChangedOrBusyJournalStamp( } readiness := &frostProductionSignerReadiness{journal: journal} if err := readiness.verifyFrostRetainedGroupJournalStampUnchanged( + context.Background(), expected, ); err != nil { t.Fatalf("matching retained-group journal stamp was rejected: [%v]", err) @@ -668,16 +670,358 @@ func TestFrostProductionSignerReadinessRejectsChangedOrBusyJournalStamp( journal.quarantineState.Generation++ if err := readiness.verifyFrostRetainedGroupJournalStampUnchanged( + context.Background(), expected, ); err == nil || !strings.Contains(err.Error(), "journal changed") { t.Fatalf("advanced quarantine journal stamp was accepted: [%v]", err) } journal.quarantineState.Generation-- +} +func TestFrostProductionSignerReadinessWaitsOutBusyJournalStamp( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + journalDirectory := t.TempDir() + if err := os.Chmod(journalDirectory, 0700); err != nil { + t.Fatal(err) + } + journal := fixture.openJournal(t, journalDirectory) + expected, err := journal.reconcile(context.Background(), fixture.target) + if err != nil { + t.Fatal(err) + } + readiness := &frostProductionSignerReadiness{journal: journal} + + // Another workflow holding the journal is contention, not a readiness + // change. The monitor latches the first revalidation error permanently, so + // the stamp check must wait the holder out instead of reporting one. journal.mutex.Lock() - err = readiness.verifyFrostRetainedGroupJournalStampUnchanged(expected) - journal.mutex.Unlock() - if err == nil || !strings.Contains(err.Error(), "journal is busy") { - t.Fatalf("busy retained-group journal did not fail closed: [%v]", err) + released := make(chan struct{}) + go func() { + defer close(released) + time.Sleep(50 * time.Millisecond) + journal.mutex.Unlock() + }() + if err := readiness.verifyFrostRetainedGroupJournalStampUnchanged( + context.Background(), + expected, + ); err != nil { + t.Fatalf("busy retained-group journal was not waited out: [%v]", err) + } + <-released + + // Only the caller's own deadline ends the wait. + journal.mutex.Lock() + defer journal.mutex.Unlock() + deadlineContext, cancel := context.WithTimeout( + context.Background(), + 20*time.Millisecond, + ) + defer cancel() + if err := readiness.verifyFrostRetainedGroupJournalStampUnchanged( + deadlineContext, + expected, + ); err == nil || !strings.Contains(err.Error(), "stayed busy") { + t.Fatalf("busy journal ignored the revalidation deadline: [%v]", err) + } +} + +// TestFrostProductionSignerReadinessAcceptsAuthorizedGenerationAdvance pins the +// asymmetry the cached revalidation path depends on: the authorized signing +// window durably advances the native store it is guarding, so its own advance +// must not read as a readiness change, while a rollback still must. +func TestFrostProductionSignerReadinessAcceptsAuthorizedGenerationAdvance( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + keyGroup := hex.EncodeToString(fixture.walletID[:]) + fixture.registry.retainedFrostKeyGroups = map[[32]byte]string{ + fixture.walletID: keyGroup, + } + journalDirectory := t.TempDir() + if err := os.Chmod(journalDirectory, 0700); err != nil { + t.Fatal(err) + } + journal := fixture.openJournal(t, journalDirectory) + + storeBinding := testFrostDurableSessionStoreBinding(t) + storeFingerprint, err := storeBinding.verify() + if err != nil { + t.Fatal(err) + } + previousStateCommitment := [32]byte{0x71} + stateImageDigest := [32]byte{0x72} + stateCommitment := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + 1, + previousStateCommitment, + stateImageDigest, + ) + entries := []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ + WalletID: fixture.walletID, + KeyGroup: keyGroup, + Threshold: frostPreSignAuthorizationThreshold, + ParticipantCount: uint16(len(fixture.operatorIDs)), + KeyPackages: []frostsigning.NativeTBTCSignerRetainedKeyPackage{{ + ParticipantSeat: 7, + }}, + }} + inventory := &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Schema: frostsigning.NativeTBTCSignerRetainedKeyPackageInventorySchema, + StoreFingerprint: storeFingerprint, + StateGeneration: 1, + StateCommitment: stateCommitment, + PreviousStateCommitment: previousStateCommitment, + StateImageDigest: stateImageDigest, + InventoryCommitment: frostsigning.ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment(entries), + Entries: entries, + } + checkpoint := FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: storeFingerprint, + Generation: 1, + PreviousStateCommitment: previousStateCommitment, + StateImageDigest: stateImageDigest, + StateCommitment: stateCommitment, + } + anchorBinding, anchorStore := + testFrostNativeSignerInventoryAnchorBinding(storeFingerprint, checkpoint) + trustHead := testFrostNativeSignerInventoryTrustHead(anchorBinding) + inventoryBinding, err := newFrostNativeSignerInventoryBinding( + storeBinding, + anchorBinding, + func() (*frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error) { + return inventory, nil + }, + func() (*frostsigning.NativeTBTCSignerStateAnchorTrustHead, error) { + copy := *trustHead + return ©, nil + }, + trustHead, + ) + if err != nil { + t.Fatal(err) + } + readiness, err := newFrostProductionSignerReadiness( + func() bool { return true }, + journal, + inventoryBinding, + ) + if err != nil { + t.Fatal(err) + } + snapshot, err := readiness.verifyFrostProductionSignerReadiness( + context.Background(), + fixture.target, + ) + if err != nil { + t.Fatal(err) + } + if err := readiness.verifyFrostProductionSignerReadinessUnchanged( + context.Background(), + snapshot, + ); err != nil { + t.Fatalf("unmutated native signer state was rejected: [%v]", err) + } + + // Advance the durable state exactly as one anchored interactive call does: + // the consumption marker persists a new generation and the process output + // barrier advances the anchor revision once. + baselineTip, err := anchorBinding.readTip() + if err != nil { + t.Fatal(err) + } + baselineRecord := *anchorStore.record + advancedImage := [32]byte{0x73} + advancedCommitment := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + storeFingerprint, + 2, + stateCommitment, + advancedImage, + ) + advancedCheckpoint := FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: storeFingerprint, + Generation: 2, + PreviousStateCommitment: stateCommitment, + StateImageDigest: advancedImage, + StateCommitment: advancedCommitment, + } + advancedEventRoot := [32]byte{0x74} + advancedTip := *baselineTip + advancedTip.Generation = advancedCheckpoint.Generation + advancedTip.PreviousStateCommitment = advancedCheckpoint.PreviousStateCommitment + advancedTip.StateImageDigest = advancedCheckpoint.StateImageDigest + advancedTip.StateCommitment = advancedCheckpoint.StateCommitment + advancedTip.AnchorRevision = baselineTip.AnchorRevision + 1 + advancedTip.AnchorEventRoot = advancedEventRoot + anchorBinding.readTip = func() ( + *frostsigning.NativeTBTCSignerStateWitnessTip, + error, + ) { + copy := advancedTip + return ©, nil + } + anchorStore.record.Checkpoint = advancedCheckpoint + anchorStore.record.Revision = advancedTip.AnchorRevision + anchorStore.record.PreviousEventRoot = baselineRecord.EventRoot + anchorStore.record.EventRoot = advancedEventRoot + inventory.StateGeneration = advancedCheckpoint.Generation + inventory.PreviousStateCommitment = advancedCheckpoint.PreviousStateCommitment + inventory.StateImageDigest = advancedCheckpoint.StateImageDigest + inventory.StateCommitment = advancedCheckpoint.StateCommitment + + if err := readiness.verifyFrostProductionSignerReadinessUnchanged( + context.Background(), + snapshot, + ); err != nil { + t.Fatalf( + "the signing session's own authorized durable advance aborted revalidation: [%v]", + err, + ) + } + + // A rolled-back native store is still a fatal readiness change. + anchorBinding.readTip = func() ( + *frostsigning.NativeTBTCSignerStateWitnessTip, + error, + ) { + copy := *baselineTip + return ©, nil + } + *anchorStore.record = baselineRecord + inventory.StateGeneration = checkpoint.Generation + inventory.PreviousStateCommitment = checkpoint.PreviousStateCommitment + inventory.StateImageDigest = checkpoint.StateImageDigest + inventory.StateCommitment = checkpoint.StateCommitment + snapshot.Inventory.StateGeneration = advancedCheckpoint.Generation + snapshot.Inventory.StateCommitment = advancedCheckpoint.StateCommitment + snapshot.Inventory.PreviousStateCommitment = + advancedCheckpoint.PreviousStateCommitment + snapshot.Inventory.StateImageDigest = advancedCheckpoint.StateImageDigest + snapshot.Inventory.CurrentAnchorRevision = advancedTip.AnchorRevision + snapshot.Inventory.RestartableRevisionHeadroom-- + snapshot.Inventory.RestartableGenerationHeadroom-- + if err := readiness.verifyFrostProductionSignerReadinessUnchanged( + context.Background(), + snapshot, + ); err == nil || !strings.Contains(err.Error(), "rolled back") { + t.Fatalf("rolled-back native signer state was accepted: [%v]", err) + } +} + +func TestVerifyFrostNativeSignerInventoryUnchangedSeparatesAdvanceFromChange( + t *testing.T, +) { + baseline := func() *frostNativeSignerInventorySnapshot { + return &frostNativeSignerInventorySnapshot{ + Schema: "inventory/v1", + StoreFingerprint: [32]byte{0x01}, + StateGeneration: 10, + StateCommitment: [32]byte{0x02}, + PreviousStateCommitment: [32]byte{0x03}, + StateImageDigest: [32]byte{0x04}, + InventoryCommitment: [32]byte{0x05}, + WalletCount: 1, + KeyPackageCount: 1, + ExternalRollbackAnchorBound: true, + TrustCertificateSequence: 1, + TrustCertificateDigest: [32]byte{0x06}, + AnchorServiceEpoch: 1, + CertifiedFloorRevision: 1, + CertifiedFloorGeneration: 1, + CurrentAnchorRevision: 5, + RestartableRevisionHeadroom: 4092, + RestartableGenerationHeadroom: 4087, + } + } + tests := map[string]struct { + mutate func(*frostNativeSignerInventorySnapshot) + accepted bool + message string + }{ + "unchanged": { + mutate: func(*frostNativeSignerInventorySnapshot) {}, + accepted: true, + }, + "authorized durable advance": { + mutate: func(actual *frostNativeSignerInventorySnapshot) { + actual.StateGeneration++ + actual.PreviousStateCommitment = actual.StateCommitment + actual.StateCommitment = [32]byte{0x12} + actual.StateImageDigest = [32]byte{0x14} + actual.CurrentAnchorRevision++ + actual.RestartableRevisionHeadroom-- + actual.RestartableGenerationHeadroom-- + }, + accepted: true, + }, + "generation rollback": { + mutate: func(actual *frostNativeSignerInventorySnapshot) { + actual.StateGeneration-- + actual.RestartableGenerationHeadroom++ + }, + message: "rolled back", + }, + "anchor revision rollback": { + mutate: func(actual *frostNativeSignerInventorySnapshot) { + actual.CurrentAnchorRevision-- + actual.RestartableRevisionHeadroom++ + }, + message: "rolled back", + }, + "fork at unchanged generation": { + mutate: func(actual *frostNativeSignerInventorySnapshot) { + actual.StateCommitment = [32]byte{0x22} + }, + message: "forked at an unchanged generation", + }, + "another durable store": { + mutate: func(actual *frostNativeSignerInventorySnapshot) { + actual.StoreFingerprint = [32]byte{0x31} + }, + message: "identity, trust head, or retained key material changed", + }, + "replaced key material": { + mutate: func(actual *frostNativeSignerInventorySnapshot) { + actual.InventoryCommitment = [32]byte{0x32} + }, + message: "identity, trust head, or retained key material changed", + }, + "rotated trust certificate": { + mutate: func(actual *frostNativeSignerInventorySnapshot) { + actual.TrustCertificateSequence++ + }, + message: "identity, trust head, or retained key material changed", + }, + "advance into the rotation warning band": { + mutate: func(actual *frostNativeSignerInventorySnapshot) { + actual.StateGeneration += + actual.RestartableGenerationHeadroom - + FrostNativeSignerAnchorRotationWarningHeadroom + actual.RestartableGenerationHeadroom = + FrostNativeSignerAnchorRotationWarningHeadroom + actual.PreviousStateCommitment = actual.StateCommitment + actual.StateCommitment = [32]byte{0x42} + actual.AnchorRotationWarning = true + }, + message: "identity, trust head, or retained key material changed", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + expected := baseline() + actual := baseline() + test.mutate(actual) + err := verifyFrostNativeSignerInventoryUnchanged(expected, actual) + if test.accepted { + if err != nil { + t.Fatalf("legitimate native signer state was rejected: [%v]", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), test.message) { + t.Fatalf("native signer state change was accepted: [%v]", err) + } + }) } } diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index d1383412fe..dc6e4f4786 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1656,7 +1656,11 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) authorize( return nil, err } readinessPoint, readinessSnapshot, err := - tfpsag.verifyCurrentProductionSignerReadiness(ctx, nil) + tfpsag.verifyCurrentProductionSignerReadiness( + ctx, + transaction.WalletPublicKeyHash, + nil, + ) if err != nil { return nil, err } @@ -1777,17 +1781,25 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( ctx context.Context, authorization *frostPreSignAuthorization, ) error { + // The signing wallet's identity is needed before the first readiness check + // so a quarantined wallet is refused at the same boundary as every other + // authorization fact. + if authorization == nil || authorization.proposal == nil || + authorization.proposal.Transaction == nil { + return fmt.Errorf("FROST pre-sign authorization is nil") + } currentFinality, readinessSnapshot, err := - tfpsag.verifyCurrentProductionSignerReadiness(ctx, authorization) + tfpsag.verifyCurrentProductionSignerReadiness( + ctx, + authorization.proposal.Transaction.WalletPublicKeyHash, + authorization, + ) if err != nil { return err } if _, err := tfpsag.storeBinding.verify(); err != nil { return fmt.Errorf("FROST durable session store binding failed: [%w]", err) } - if authorization == nil || authorization.proposal == nil { - return fmt.Errorf("FROST pre-sign authorization is nil") - } if err := authorization.proposal.validate(); err != nil { return fmt.Errorf("FROST pre-sign authorization proposal changed: [%w]", err) } @@ -1840,8 +1852,41 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( return nil } +// verifyFrostPreSignSigningWalletNotQuarantined fails closed when the exact +// wallet whose UTXOs the authorized batch spends carries an active canonical +// quarantine. Raising a quarantine is an authenticated operational stop for one +// wallet; lifting it needs a manifest-pinned authority quorum certificate bound +// to that same wallet, so an unlifted record must reach the signing path rather +// than only the activation handshake. Other wallets keep signing: the record +// binds to one WalletID and node-wide emptiness stays a bootstrap requirement. +func verifyFrostPreSignSigningWalletNotQuarantined( + walletPublicKeyHash [20]byte, + readiness *frostProductionSignerReadinessSnapshot, +) error { + if readiness == nil || readiness.Journal == nil { + return fmt.Errorf( + "FROST production signer readiness snapshot is incomplete", + ) + } + if walletPublicKeyHash == [20]byte{} { + return fmt.Errorf("FROST pre-sign signing wallet is unidentified") + } + quarantine := readiness.Journal.activeQuarantineFor(walletPublicKeyHash) + if quarantine == nil { + return nil + } + return fmt.Errorf( + "FROST wallet [%x] is under active canonical quarantine [%x] (recovery required: [%t]); "+ + "an authority-certified lift is required before it can sign", + walletPublicKeyHash, + quarantine.QuarantineID, + quarantine.RecoveryRequired, + ) +} + func (tfpsag *thresholdFrostPreSignAuthorizationGate) verifyCurrentProductionSignerReadiness( ctx context.Context, + walletPublicKeyHash [20]byte, authorization *frostPreSignAuthorization, ) ( *FrostPreSignFinality, @@ -1871,9 +1916,8 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) verifyCurrentProductionSig "invalid current FROST pre-sign finalized checkpoint", ) } - if readinessSnapshot := authorization.cachedReadiness( - *currentFinality, - ); readinessSnapshot != nil { + readinessSnapshot := authorization.cachedReadiness(*currentFinality) + if readinessSnapshot != nil { if err := tfpsag.productionReadiness. verifyFrostProductionSignerReadinessUnchanged( ctx, @@ -1884,25 +1928,35 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) verifyCurrentProductionSig err, ) } - return currentFinality, readinessSnapshot, nil - } - readinessSnapshot, err := - tfpsag.productionReadiness.verifyFrostProductionSignerReadiness( - ctx, - *currentFinality, - ) - if err != nil { - return nil, nil, fmt.Errorf( - "FROST production signer is not authorization-ready: [%w]", - err, - ) - } - if readinessSnapshot == nil { - return nil, nil, fmt.Errorf( - "FROST production signer readiness snapshot is nil", - ) + } else { + reconciled, err := + tfpsag.productionReadiness.verifyFrostProductionSignerReadiness( + ctx, + *currentFinality, + ) + if err != nil { + return nil, nil, fmt.Errorf( + "FROST production signer is not authorization-ready: [%w]", + err, + ) + } + if reconciled == nil { + return nil, nil, fmt.Errorf( + "FROST production signer readiness snapshot is nil", + ) + } + readinessSnapshot = reconciled + authorization.cacheReadiness(*currentFinality, readinessSnapshot) + } + // The cached branch is safe to gate on too: the journal stamp comparison + // pins the quarantine generation and active root, so a quarantine raised + // after the cached reconciliation fails the stamp before this point. + if err := verifyFrostPreSignSigningWalletNotQuarantined( + walletPublicKeyHash, + readinessSnapshot, + ); err != nil { + return nil, nil, err } - authorization.cacheReadiness(*currentFinality, readinessSnapshot) return currentFinality, readinessSnapshot, nil } diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go index 6f1ea5f9f9..919da544f2 100644 --- a/pkg/tbtc/frost_pre_sign_authorization_test.go +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -20,12 +20,13 @@ import ( ) type testFrostProductionAuthorizationReadiness struct { - err error - unchangedErr error - calls uint64 - unchangedCalls uint64 - points []FrostPreSignFinality - headrooms []uint64 + err error + unchangedErr error + calls uint64 + unchangedCalls uint64 + points []FrostPreSignFinality + headrooms []uint64 + activeQuarantines []frostRetainedGroupActiveQuarantine } func (readiness *testFrostProductionAuthorizationReadiness) verifyFrostProductionSignerReadiness( @@ -45,7 +46,17 @@ func (readiness *testFrostProductionAuthorizationReadiness) verifyFrostProductio } headroom = readiness.headrooms[index] } - return testFrostAnchorAdmissionReadinessSnapshot(headroom, headroom), nil + snapshot := testFrostAnchorAdmissionReadinessSnapshot(headroom, headroom) + snapshot.Journal = &frostRetainedGroupJournalSnapshot{ + Schema: frostRetainedGroupJournalSnapshotSchema, + ActiveQuarantines: append( + []frostRetainedGroupActiveQuarantine{}, + readiness.activeQuarantines..., + ), + QuarantineCount: uint64(len(readiness.activeQuarantines)), + Complete: true, + } + return snapshot, nil } func (readiness *testFrostProductionAuthorizationReadiness) verifyFrostProductionSignerReadinessUnchanged( @@ -1226,3 +1237,96 @@ func (tfpsab *testFrostPreSignAuthorizationBackend) ReadFrostPreSignAuthorizatio result := *tfpsab.states[finality.BlockNumber] return &result, nil } + +// TestThresholdFrostPreSignAuthorizationGate_RefusesQuarantinedSigningWallet +// pins the scope of an active canonical quarantine on the signing path. Raising +// one is an authenticated operational stop for exactly one wallet, so that +// wallet must stop signing Bitcoin immediately, while wallets the record does +// not name keep working. +func TestThresholdFrostPreSignAuthorizationGate_RefusesQuarantinedSigningWallet( + t *testing.T, +) { + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + transaction := testFrostPreSignTransaction(t, unsignedTx) + proposal := completeTestFrostPreSignProposal(transaction) + relayFinality := FrostPreSignFinality{ + RelayTransactionHash: [32]byte{0x71}, + BlockNumber: 100, + BlockHash: [32]byte{0x72}, + AuthorizationSequence: [32]byte{31: 1}, + } + currentFinality := FrostPreSignFinality{ + BlockNumber: 101, + BlockHash: [32]byte{0x73}, + } + tests := map[string]struct { + walletID [32]byte + walletPublicKeyHash [20]byte + accepted bool + }{ + "signing wallet quarantined": { + walletID: proposal.WalletID, + walletPublicKeyHash: transaction.WalletPublicKeyHash, + }, + "unrelated wallet quarantined": { + walletID: [32]byte{0x91}, + walletPublicKeyHash: [20]byte{0x92}, + accepted: true, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + backend := &testFrostPreSignAuthorizationBackend{ + proposal: proposal, + currentFinality: ¤tFinality, + states: map[uint64]*FrostPreSignAuthorizationState{ + relayFinality.BlockNumber: completeTestFrostPreSignState( + proposal, + relayFinality, + ), + currentFinality.BlockNumber: completeTestFrostPreSignState( + proposal, + currentFinality, + ), + }, + } + gate := &thresholdFrostPreSignAuthorizationGate{ + backend: backend, + activationProfile: activationProfileForTestProposal(proposal), + storeBinding: testFrostDurableSessionStoreBinding(t), + productionReadiness: &testFrostProductionAuthorizationReadiness{ + activeQuarantines: []frostRetainedGroupActiveQuarantine{{ + QuarantineID: [32]byte{0x51}, + WalletID: test.walletID, + WalletPublicKeyHash: test.walletPublicKeyHash, + RecoveryRequired: true, + }}, + }, + } + authorization := &frostPreSignAuthorization{ + ActivationProfileHash: gate.activationProfile.ProfileHash, + AuthorizationID: proposal.Digest, + ReservationID: proposal.ReservationID, + VariantRoot: proposal.AuthorizationRoot, + TransactionHash: proposal.Transaction.TransactionHash, + Finality: relayFinality, + VariantSequence: frostPreSignVariantSequence(relayFinality), + proposal: proposal, + } + err := gate.revalidate(context.Background(), authorization) + if test.accepted { + if err != nil { + t.Fatalf( + "unrelated wallet quarantine blocked signing: [%v]", + err, + ) + } + return + } + if err == nil || + !strings.Contains(err.Error(), "active canonical quarantine") { + t.Fatalf("quarantined wallet was authorized to sign: [%v]", err) + } + }) + } +} diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index 5d2c763fcd..32ab94d096 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -936,6 +936,26 @@ type frostRetainedGroupEnvelope struct { Checksum [32]byte `json:"checksum"` } +// frostRetainedGroupActiveQuarantine is one active quarantine reduced to the +// identities a signing caller can present. A quarantine - including the +// recovery-required kind - is always raised against exactly one WalletID, and +// the authority-quorum certificate that lifts it is bound to that same +// WalletID, so quarantine scope is per wallet and never node-wide. +// +// WalletPublicKeyHash is the canonical journal's exact Bridge binding for the +// quarantined wallet. It is zero when no canonical retained group carries that +// wallet ID, and such a quarantine cannot hide a locally signable wallet: +// nativeSignerInventoryExpectations derives the expected retained key-group set +// from state.Wallets alone and the native inventory must match it entry for +// entry, so a wallet absent from the canonical journal can hold no local +// signing material. +type frostRetainedGroupActiveQuarantine struct { + QuarantineID [32]byte + WalletID [32]byte + WalletPublicKeyHash [20]byte + RecoveryRequired bool +} + type frostRetainedGroupJournalSnapshot struct { Schema string BindingHash [32]byte @@ -959,6 +979,7 @@ type frostRetainedGroupJournalSnapshot struct { QuarantineActiveRoot [32]byte QuarantineTombstoneRoot [32]byte QuarantineCount uint64 + ActiveQuarantines []frostRetainedGroupActiveQuarantine QuarantineTombstoneCount uint64 CheckpointMinimumSequence uint64 CheckpointPredecessorHash [32]byte @@ -3076,6 +3097,57 @@ func frostRetainedGroupActiveQuarantineCount( return count } +// frostRetainedGroupActiveQuarantines joins the independent quarantine journal +// with the canonical retained-group inventory so a signing caller can decide, +// from one authenticated snapshot, whether the exact wallet it is about to sign +// for is quarantined. Ordering follows the quarantine ID, matching the active +// quarantine root's canonical order. +func frostRetainedGroupActiveQuarantines( + state frostRetainedGroupJournalState, + quarantineState frostRetainedGroupQuarantineJournalState, +) []frostRetainedGroupActiveQuarantine { + publicKeyHashes := make(map[[32]byte][20]byte, len(state.Wallets)) + for _, wallet := range state.Wallets { + publicKeyHashes[wallet.WalletID] = wallet.WalletPublicKeyHash + } + active := make([]frostRetainedGroupActiveQuarantine, 0) + for _, quarantine := range quarantineState.Quarantines { + if quarantine.Status != frostRetainedGroupQuarantineActive { + continue + } + active = append(active, frostRetainedGroupActiveQuarantine{ + QuarantineID: quarantine.RaisedRecord.QuarantineID, + WalletID: quarantine.RaisedRecord.WalletID, + WalletPublicKeyHash: publicKeyHashes[quarantine.RaisedRecord.WalletID], + RecoveryRequired: quarantine.RaisedRecord.RecoveryRequired, + }) + } + sort.Slice(active, func(i, j int) bool { + return bytes.Compare( + active[i].QuarantineID[:], + active[j].QuarantineID[:], + ) < 0 + }) + return active +} + +// activeQuarantineFor returns the active quarantine raised against the wallet +// identified by the given Bridge public-key hash, or nil when that wallet is +// not quarantined at the reconciled point this snapshot pins. +func (frgjs *frostRetainedGroupJournalSnapshot) activeQuarantineFor( + walletPublicKeyHash [20]byte, +) *frostRetainedGroupActiveQuarantine { + if frgjs == nil { + return nil + } + for index, quarantine := range frgjs.ActiveQuarantines { + if quarantine.WalletPublicKeyHash == walletPublicKeyHash { + return &frgjs.ActiveQuarantines[index] + } + } + return nil +} + func validFrostRetainedGroupTransition( current FrostRetainedGroupLifecycle, next FrostRetainedGroupLifecycle, @@ -3852,6 +3924,15 @@ func (frgj *frostRetainedGroupJournal) reconcile( if root != frgj.state.InventoryRoot || frgj.state.SnapshotGeneration < frgj.minimumGeneration { return nil, fmt.Errorf("FROST retained-group journal generation or inventory root is not activation-ready") } + // An active quarantine is deliberately not an activation-ready failure here. + // The node-wide "no active quarantine" requirement belongs to the activation + // handshake, which refuses to bootstrap or attest health while any + // quarantine is unresolved. A quarantine is raised against exactly one + // WalletID and lifted by an authority certificate bound to that same + // WalletID, so a node that is already running fails closed per wallet + // instead: ActiveQuarantines carries every unlifted record and the pre-sign + // authorization gate refuses to authorize, or to keep authorizing, a + // Bitcoin signing batch for a quarantined wallet. if frgj.quarantineState.CurrentPoint != target || frgj.quarantineState.Generation < frgj.quarantineMinimumGeneration || frgj.quarantineState.Root == [32]byte{} || @@ -3891,14 +3972,18 @@ func (frgj *frostRetainedGroupJournal) reconcile( QuarantineActiveRoot: frgj.quarantineState.ActiveRoot, QuarantineTombstoneRoot: frgj.quarantineState.TombstoneRoot, QuarantineCount: frostRetainedGroupActiveQuarantineCount(frgj.quarantineState), - QuarantineTombstoneCount: uint64(len(frgj.quarantineState.Tombstones)), - CheckpointMinimumSequence: frgj.checkpointPolicy.MinimumSequence, - CheckpointPredecessorHash: frgj.checkpointPolicy.PredecessorHash, - CheckpointSequence: frgj.checkpointState.Sequence, - CheckpointCertificateHash: frgj.checkpointState.CertificateHash, - CheckpointHistoryRoot: frgj.checkpointState.HistoryRoot, - LocalSessionCount: localSessionCount, - Complete: true, + ActiveQuarantines: frostRetainedGroupActiveQuarantines( + frgj.state, + frgj.quarantineState, + ), + QuarantineTombstoneCount: uint64(len(frgj.quarantineState.Tombstones)), + CheckpointMinimumSequence: frgj.checkpointPolicy.MinimumSequence, + CheckpointPredecessorHash: frgj.checkpointPolicy.PredecessorHash, + CheckpointSequence: frgj.checkpointState.Sequence, + CheckpointCertificateHash: frgj.checkpointState.CertificateHash, + CheckpointHistoryRoot: frgj.checkpointState.HistoryRoot, + LocalSessionCount: localSessionCount, + Complete: true, }, nil } diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index 9827085680..956cda7a72 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -3935,3 +3935,74 @@ func journalTestInventoryWallet( } return wallet } + +// TestFrostRetainedGroupJournal_SnapshotBindsActiveQuarantineToItsWallet pins +// the per-wallet scope the signing path gates on: an unlifted record resolves +// to the exact canonical wallet it names, and only that wallet. +func TestFrostRetainedGroupJournal_SnapshotBindsActiveQuarantineToItsWallet( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + quarantine := FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: 5, + BlockHash: [32]byte{0x05}, + TransactionHash: [32]byte{0xa5}, + TransactionIndex: 1, + LogIndex: 1, + }, + Kind: FrostRetainedGroupRecoveryRequiredMutation, + WalletID: fixture.walletID, + QuarantineID: [32]byte{0x51}, + EvidenceHash: [32]byte{0x52}, + Reason: "manual recovery is required", + } + fixture.source.mutations = append(fixture.source.mutations, quarantine) + journal := fixture.openJournal(t, filepath.Join(t.TempDir(), "journal")) + defer journal.close() + raised, err := journal.reconcile(context.Background(), fixture.target) + if err != nil { + t.Fatal(err) + } + if len(raised.ActiveQuarantines) != 1 { + t.Fatalf("unexpected active quarantine bindings: %+v", raised.ActiveQuarantines) + } + binding := raised.ActiveQuarantines[0] + if binding.QuarantineID != quarantine.QuarantineID || + binding.WalletID != fixture.walletID || + binding.WalletPublicKeyHash != fixture.walletPKH || + !binding.RecoveryRequired { + t.Fatalf("active quarantine was not bound to its canonical wallet: %+v", binding) + } + if raised.activeQuarantineFor(fixture.walletPKH) == nil { + t.Fatal("quarantined wallet was not reported as quarantined") + } + if raised.activeQuarantineFor([20]byte{0xee}) != nil { + t.Fatal("unrelated wallet was reported as quarantined") + } + + lift := fixture.liftMutation( + t, + journal, + quarantine, + FrostRetainedGroupEventPoint{ + BlockNumber: 15, + BlockHash: [32]byte{0x0f}, + TransactionHash: [32]byte{0xaf}, + TransactionIndex: 2, + LogIndex: 3, + }, + ) + fixture.source.mutations = append(fixture.source.mutations, lift) + lifted, err := journal.reconcile(context.Background(), fixture.later) + if err != nil { + t.Fatal(err) + } + if len(lifted.ActiveQuarantines) != 0 || + lifted.activeQuarantineFor(fixture.walletPKH) != nil { + t.Fatalf( + "authenticated lift did not clear the wallet binding: %+v", + lifted.ActiveQuarantines, + ) + } +} From 5df61b91a455bd432ef76da5a8c9b5d7d09af078 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 15:24:33 -0400 Subject: [PATCH 62/93] fix(tbtc): adopt orphan FROST journal batches without a restart Batch publication and its state checkpoint are two separate writes. A failure between them leaves the batch durable and the checkpoint one sequence behind, and because batch files are immutable no-replace publications every later reconciliation recomputed the same sequence and failed with "immutable journal file already exists". The journal then stayed wedged in-process until an operator restarted the node, even though initialize() already adopts exactly that shape on startup. Reconciliation now performs the same adoption for both the canonical and the quarantine journal before it reads any history. Adoption is not a trust shortcut: each batch is revalidated against its exact durable predecessor, persisted lift certificates are rechecked, and the reconciliation that follows still rejects the whole durable prefix if the canonical history rewrote, omitted or reordered an adopted event. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/frost_retained_group_journal.go | 175 ++++++++++++++++++ pkg/tbtc/frost_retained_group_journal_test.go | 38 ++++ 2 files changed, 213 insertions(+) diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index 32ab94d096..e8873fa706 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -3399,6 +3399,178 @@ func equalFrostRetainedGroupSemanticHistories( return true, nil } +// adoptDurableBatchSuffixes integrates batches that are already durable but not +// yet reflected by their state checkpoint. Batch publication and the state +// checkpoint are two separate writes, so a failure between them - a full disk, +// an I/O error, or the persistence hooks used by the crash tests - leaves +// exactly that shape. Batch files are immutable no-replace publications, so +// without adoption every later reconciliation recomputes the same sequence, +// fails with "immutable journal file already exists", and the in-process +// journal stays wedged until a restart replays it. initialize() already +// performs this adoption on startup; doing it here keeps a running process +// consistent with its own restart semantics. +// +// Adoption is not a trust shortcut. The batch is revalidated against the exact +// durable predecessor state, and the reconciliation that follows re-derives the +// canonical history and rejects the whole durable prefix if the source rewrote, +// omitted, or reordered any adopted event. +func (frgj *frostRetainedGroupJournal) adoptDurableBatchSuffixes() error { + if err := frgj.adoptDurableCanonicalBatches(); err != nil { + return err + } + return frgj.adoptDurableQuarantineBatches() +} + +func (frgj *frostRetainedGroupJournal) adoptDurableCanonicalBatches() error { + for { + name := frostRetainedGroupBatchFileName(frgj.state.BatchSequence + 1) + batch := frostRetainedGroupJournalBatch{} + if err := frgj.readEnvelope(name, &batch); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf( + "cannot read durable FROST retained-group journal batch [%s]: [%w]", + name, + err, + ) + } + if err := validateFrostRetainedGroupBatch(batch, frgj.state); err != nil { + return fmt.Errorf( + "invalid durable FROST retained-group journal batch [%s]: [%w]", + name, + err, + ) + } + candidate := cloneFrostRetainedGroupState(frgj.state) + if err := applyFrostRetainedGroupMutations( + &candidate, + batch.Mutations, + ); err != nil { + return fmt.Errorf( + "cannot replay durable FROST retained-group journal batch [%s]: [%w]", + name, + err, + ) + } + candidate.BatchSequence = batch.Sequence + candidate.CurrentPoint = batch.To + candidate.BatchRoot = frostRetainedGroupBatchRoot( + batch.PriorBatchRoot, + batch.Checksum, + ) + inventoryRoot, _, _, _, err := frostRetainedGroupInventoryRoot(candidate) + if err != nil { + return err + } + candidate.InventoryRoot = inventoryRoot + if err := frgj.persistEnvelope( + frostRetainedGroupJournalStateFile, + &candidate, + true, + ); err != nil { + return fmt.Errorf( + "cannot integrate durable FROST retained-group journal batch [%s]: [%w]", + name, + err, + ) + } + frgj.state = candidate + frgj.mutations = append( + frgj.mutations, + cloneFrostRetainedGroupMutations(batch.Mutations)..., + ) + } +} + +func (frgj *frostRetainedGroupJournal) adoptDurableQuarantineBatches() error { + for { + name := frostRetainedGroupBatchFileName( + frgj.quarantineState.BatchSequence + 1, + ) + wireBatch := frostRetainedGroupWireQuarantineJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + name, + &wireBatch, + ); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf( + "cannot read durable FROST retained-group quarantine batch [%s]: [%w]", + name, + err, + ) + } + batch, err := frostRetainedGroupQuarantineBatchFromWire( + wireBatch, + frgj.liftCertificates, + ) + if err != nil { + return fmt.Errorf( + "cannot decode durable FROST retained-group quarantine batch [%s]: [%w]", + name, + err, + ) + } + if err := validateFrostRetainedGroupQuarantineBatch( + batch, + frgj.quarantineState, + ); err != nil { + return fmt.Errorf( + "invalid durable FROST retained-group quarantine batch [%s]: [%w]", + name, + err, + ) + } + if err := frgj.validatePersistedLiftCertificates( + batch.Mutations, + ); err != nil { + return fmt.Errorf( + "invalid persisted FROST quarantine lift certificate in durable batch [%s]: [%w]", + name, + err, + ) + } + candidate := cloneFrostRetainedGroupQuarantineState(frgj.quarantineState) + if err := applyFrostRetainedGroupQuarantineMutations( + &candidate, + batch.Mutations, + frgj.liftPolicy, + ); err != nil { + return fmt.Errorf( + "cannot replay durable FROST retained-group quarantine batch [%s]: [%w]", + name, + err, + ) + } + candidate.BatchSequence = batch.Sequence + candidate.CurrentPoint = batch.To + candidate.BatchRoot = frostRetainedGroupQuarantineBatchRoot( + batch.PriorBatchRoot, + batch.Checksum, + ) + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + frostRetainedGroupJournalStateFile, + &candidate, + true, + ); err != nil { + return fmt.Errorf( + "cannot integrate durable FROST retained-group quarantine batch [%s]: [%w]", + name, + err, + ) + } + frgj.quarantineState = candidate + frgj.quarantineMutations = append( + frgj.quarantineMutations, + cloneFrostRetainedGroupMutations(batch.Mutations)..., + ) + } +} + func (frgj *frostRetainedGroupJournal) reconcile( ctx context.Context, target FrostPreSignFinality, @@ -3417,6 +3589,9 @@ func (frgj *frostRetainedGroupJournal) reconcile( if frgj.closed { return nil, fmt.Errorf("FROST retained-group journal is closed") } + if err := frgj.adoptDurableBatchSuffixes(); err != nil { + return nil, err + } if target.BlockNumber == 0 || target.BlockHash == [32]byte{} || target.BlockNumber < frgj.metadata.Checkpoint.BlockNumber || target.BlockNumber < frgj.state.CurrentPoint.BlockNumber || diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index 956cda7a72..87f7ea417b 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -4006,3 +4006,41 @@ func TestFrostRetainedGroupJournal_SnapshotBindsActiveQuarantineToItsWallet( ) } } + +// TestFrostRetainedGroupJournal_AdoptsCommittedOrphanBatchWithoutRestart covers +// the in-process half of orphan-batch integration. initialize() already adopts +// a batch whose state checkpoint never landed; a running journal must do the +// same or the immutable batch file wedges every later reconciliation. +func TestFrostRetainedGroupJournal_AdoptsCommittedOrphanBatchWithoutRestart( + t *testing.T, +) { + fixture := newJournalTestFixture(t) + journal := fixture.openJournal(t, filepath.Join(t.TempDir(), "journal")) + defer journal.close() + journal.persistFailureHook = func(stage string) error { + if stage != "after-batch-before-state" { + t.Fatalf("unexpected failure stage [%s]", stage) + } + return fmt.Errorf("simulated persist failure") + } + if _, err := journal.reconcile(context.Background(), fixture.target); err == nil || + !strings.Contains(err.Error(), "simulated persist failure") { + t.Fatalf("expected simulated persist failure, got [%v]", err) + } + if journal.state.BatchSequence != 0 || len(journal.mutations) != 0 { + t.Fatalf( + "failed persistence advanced the in-process cursor: %+v", + journal.state, + ) + } + + journal.persistFailureHook = nil + snapshot, err := journal.reconcile(context.Background(), fixture.later) + if err != nil { + t.Fatalf("durable orphan batch wedged the running journal: [%v]", err) + } + if snapshot.SnapshotGeneration != 1 || snapshot.CurrentPoint != fixture.later || + journal.state.BatchSequence != 2 || len(journal.mutations) != 1 { + t.Fatalf("orphan batch was not adopted exactly once: %+v", journal.state) + } +} From 57e64311511988320125162138c318250bb5934b Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 15:25:11 -0400 Subject: [PATCH 63/93] fix(tbtc): bound FROST journal files at write time The read path refuses any journal file above 8 MiB, but the write path had no matching bound, so an oversized batch was published and fsynced successfully and only the next initialize() discovered it - by which point the journal was permanently unopenable and the file could no longer be attributed to the write that produced it. Persist now fails closed with the offending file name before the temporary file is created. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/frost_retained_group_journal.go | 14 ++++++++++ pkg/tbtc/frost_retained_group_journal_test.go | 26 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index e8873fa706..bb97c5c301 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -2198,6 +2198,20 @@ func persistFrostRetainedGroupEnvelopeAt( if err != nil { return err } + // The read path refuses any journal file above + // frostRetainedGroupJournalMaximumFile. Without the symmetric write-side + // bound an oversized batch is published and fsynced successfully and only + // the next initialize() discovers it, leaving the journal permanently + // unopenable with no signal at the point the file was produced. Fail closed + // here instead, while the caller can still name the offending file. + if len(envelopeBytes) > frostRetainedGroupJournalMaximumFile { + return fmt.Errorf( + "FROST retained-group journal file [%s] is [%d] bytes, exceeding the readable maximum [%d]", + name, + len(envelopeBytes), + frostRetainedGroupJournalMaximumFile, + ) + } directoryFile, err := openFrostRetainedGroupJournalDirectory(directory) if err != nil { diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index 87f7ea417b..bdd915df14 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -4044,3 +4044,29 @@ func TestFrostRetainedGroupJournal_AdoptsCommittedOrphanBatchWithoutRestart( t.Fatalf("orphan batch was not adopted exactly once: %+v", journal.state) } } +func TestPersistFrostRetainedGroupEnvelopeAtRejectsUnreadableSize(t *testing.T) { + directory := t.TempDir() + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + oversized := struct { + Blob string `json:"blob"` + }{ + Blob: strings.Repeat("a", frostRetainedGroupJournalMaximumFile), + } + if err := persistFrostRetainedGroupEnvelopeAt( + directory, + frostRetainedGroupJournalStateFile, + &oversized, + true, + ); err == nil || + !strings.Contains(err.Error(), "exceeding the readable maximum") { + t.Fatalf("oversized journal file was persisted: [%v]", err) + } + if _, err := os.Lstat(filepath.Join( + directory, + frostRetainedGroupJournalStateFile, + )); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rejected journal file was still published: [%v]", err) + } +} From d6c8a46e671ecbbcaceb2c32f7ba352e254f394e Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 15:04:52 -0400 Subject: [PATCH 64/93] fix(tbtc): match the signer's six-record terminal witness reserve in Go The native signer reserves six terminal witness-journal records so an in-flight interactive request can finish before a checkpoint has to be acknowledged, and enforces rotationThreshold + 6 <= maximumRecords at both of its geometry intakes: configured_state_anchor behind frost_tbtc_init_signer_config, and parse_endpoint behind frost_tbtc_transition_state_witness_anchor. Go still enforced the retired two-record reserve in seven independent copies of the same arithmetic. Go is the layer that mints and pre-verifies the offline-authority-signed trust certificates and validates the installed init configuration, so a geometry such as maximum 64 with threshold 60 passed the operator plan, the certificate endpoint, the startup manifest, the anchor identity, the activation manifest, and the installed init config, was signed by the offline authority, and was first rejected by the signer at node startup - which can only be undone by re-running the whole ceremony. Every geometry with a maximum in [4,7] was Go-legal and signer-invalid outright. Replace all seven copies with one exported reservation constant and one validation helper in pkg/frost/signing, mirroring the signer's error wording so operators can correlate the two layers. The trust-head decoder is tightened with the rest: its input is emitted from an endpoint the signer parsed through this same rule, so decoding at exactly that rule cannot reject a head the signer can produce, while decoding any looser lets a readback the signer would refuse to install look installable to the Go startup path. Co-Authored-By: Claude Fable 5 --- .../tbtc_frost_pre_sign_authorization.go | 13 +-- .../signing/native_tbtc_signer_init_config.go | 14 ++- .../native_tbtc_signer_state_anchor_trust.go | 79 +++++++++++++-- ...ive_tbtc_signer_state_anchor_trust_test.go | 98 +++++++++++++++++++ ...t_native_signer_anchor_bootstrap_client.go | 16 +-- .../frost_native_signer_anchor_protocol.go | 11 ++- ..._native_signer_anchor_provisioning_test.go | 78 +++++++++++++++ pkg/tbtc/frost_native_signer_anchor_trust.go | 12 +-- ...rost_native_signer_anchor_trust_startup.go | 12 +-- 9 files changed, 295 insertions(+), 38 deletions(-) diff --git a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go index 5846f45258..296a20bcaf 100644 --- a/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -32,6 +32,7 @@ import ( "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" tbtcabi "github.com/keep-network/keep-core/pkg/chain/ethereum/tbtc/gen/abi" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/tbtc" ) @@ -1341,13 +1342,13 @@ func frostPreSignNativeSignerAnchorManifest( return result, err } anchor := manifest.FrostSigner.NativeSignerAnchor - if anchor.WitnessMaximumRecords < 2 || - anchor.WitnessMaximumRecords > 1_000_000 || - anchor.WitnessRotationThresholdRecords < 2 || - anchor.WitnessRotationThresholdRecords > - anchor.WitnessMaximumRecords-2 { + if err := frostsigning.ValidateNativeTBTCSignerStateWitnessGeometry( + anchor.WitnessMaximumRecords, + anchor.WitnessRotationThresholdRecords, + ); err != nil { return result, fmt.Errorf( - "FROST native signer witness geometry is invalid", + "FROST native signer witness geometry is invalid: %w", + err, ) } result.Identity = identity diff --git a/pkg/frost/signing/native_tbtc_signer_init_config.go b/pkg/frost/signing/native_tbtc_signer_init_config.go index 08b3eb0d2a..c0ab47fe77 100644 --- a/pkg/frost/signing/native_tbtc_signer_init_config.go +++ b/pkg/frost/signing/native_tbtc_signer_init_config.go @@ -213,11 +213,19 @@ func recordNativeTBTCSignerInstalledStateAnchorConfig( nativeTBTCSignerEd25519SPKISHA256(offlineAuthorityPublicKey) != offlineAuthoritySPKIHash || *wire.StateAnchorTrustCertificateSequence == 0 || - trustCertificateDigest == [32]byte{} || - maximum < 2 || maximum > 1_000_000 || threshold < 2 || - threshold > maximum-2 { + trustCertificateDigest == [32]byte{} { return fmt.Errorf("installed signer witness geometry is invalid") } + // This config is handed to the signer's own init-config intake, which + // re-validates the geometry against its terminal-record reservation. Reject + // here at exactly that rule so an unusable pin is reported by the installer + // instead of by the signer at node startup. + if err := ValidateNativeTBTCSignerStateWitnessGeometry( + maximum, + threshold, + ); err != nil { + return fmt.Errorf("installed signer witness geometry is invalid: %w", err) + } value := &NativeTBTCSignerInstalledStateAnchorConfig{ ProtocolID: protocolID, StreamID: streamID, diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go index 5090661eb7..662ab106bd 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go @@ -13,8 +13,67 @@ const ( // shared Go-side admission bound for Rust's startup transition FFI. NativeTBTCSignerStateAnchorTrustTransitionMaximumRequestBytes = 16 * 1024 * 1024 NativeTBTCSignerStateAnchorTrustTransitionMaximumCertificateCount uint64 = 64 + + // NativeTBTCSignerStateWitnessRotationTerminalRecordReservation mirrors the + // native signer's TBTC_SIGNER_STATE_WITNESS_ROTATION_TERMINAL_RECORD_RESERVATION + // (pkg/tbtc/signer/src/engine/store.rs). Reconciliation can commit an + // interrupted write at the rotation threshold before a mutating interactive + // retry persists two expiry-sweep repairs and its requested mutation; those + // three snapshots need six PREPARE/COMMIT records to finish the in-flight + // request before a checkpoint can be acknowledged. + NativeTBTCSignerStateWitnessRotationTerminalRecordReservation uint64 = 6 + // NativeTBTCSignerStateWitnessMinimumRotationThresholdRecords mirrors the + // signer's lower bound on the rotation threshold: the terminal reserve is + // entered only after at least one complete PREPARE/COMMIT pair. + NativeTBTCSignerStateWitnessMinimumRotationThresholdRecords uint64 = 2 + // NativeTBTCSignerStateWitnessHardMaximumRecords mirrors the signer's + // TBTC_SIGNER_HARD_MAX_STATE_WITNESS_MAX_RECORDS. + NativeTBTCSignerStateWitnessHardMaximumRecords uint64 = 1_000_000 ) +// ValidateNativeTBTCSignerStateWitnessGeometry is the single Go copy of the +// witness-record geometry the native signer enforces at both of its own +// intakes: configured_state_anchor behind frost_tbtc_init_signer_config, and +// parse_endpoint behind frost_tbtc_transition_state_witness_anchor. Go mints +// and pre-verifies the offline-authority-signed trust certificates and +// validates the installed init configuration, so a geometry Go accepts must be +// exactly a geometry Rust accepts; anything looser lets an operator complete +// the whole offline ceremony against a plan the signer rejects at node startup, +// which can only be undone by re-running the ceremony. +// +// The bound is deliberately expressed as one helper over one exported +// reservation constant. Every previous copy of this arithmetic drifted +// independently when the reservation grew from two records to six. +func ValidateNativeTBTCSignerStateWitnessGeometry( + maximumRecords uint64, + rotationThresholdRecords uint64, +) error { + if maximumRecords == 0 || + maximumRecords > NativeTBTCSignerStateWitnessHardMaximumRecords { + return fmt.Errorf( + "witnessMaximumRecords [%d] is outside [1,%d]", + maximumRecords, + NativeTBTCSignerStateWitnessHardMaximumRecords, + ) + } + reserved := rotationThresholdRecords + + NativeTBTCSignerStateWitnessRotationTerminalRecordReservation + if rotationThresholdRecords < + NativeTBTCSignerStateWitnessMinimumRotationThresholdRecords || + reserved < rotationThresholdRecords || + reserved > maximumRecords { + return fmt.Errorf( + "witnessRotationThresholdRecords [%d] must be at least %d and "+ + "reserve %d terminal records below witnessMaximumRecords [%d]", + rotationThresholdRecords, + NativeTBTCSignerStateWitnessMinimumRotationThresholdRecords, + NativeTBTCSignerStateWitnessRotationTerminalRecordReservation, + maximumRecords, + ) + } + return nil +} + var ErrNativeTBTCSignerStateAnchorTrustHeadAbsent = errors.New( "native tbtc signer state-anchor trust head is absent", ) @@ -242,14 +301,22 @@ func decodeNativeTBTCSignerStateAnchorTrustHeadWire( result.ActivationManifestSequence == 0 || result.ServiceEpoch == 0 || result.ServiceEpoch != result.CertifiedFloor.ServiceEpoch || - result.CertifiedFloor.Revision != 1 || - result.WitnessMaximumRecords < 2 || - result.WitnessMaximumRecords > 1_000_000 || - result.WitnessRotationThresholdRecords < 2 || - result.WitnessRotationThresholdRecords > - result.WitnessMaximumRecords-2 { + result.CertifiedFloor.Revision != 1 { return nil, fmt.Errorf("native signer state-anchor trust head is incomplete") } + // The head is emitted from an endpoint the signer itself parsed through the + // same geometry rule, so decoding at exactly that rule cannot reject a head + // the signer can produce. Decoding any looser would let a readback the + // signer would refuse to install look installable to the Go startup path. + if err := ValidateNativeTBTCSignerStateWitnessGeometry( + result.WitnessMaximumRecords, + result.WitnessRotationThresholdRecords, + ); err != nil { + return nil, fmt.Errorf( + "native signer state-anchor trust head witness geometry is invalid: %w", + err, + ) + } return result, nil } diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go index a9f624378b..8093fe4c87 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go @@ -299,3 +299,101 @@ func uint64ToCanonicalString(value uint64) string { } return string(buffer[index:]) } + +// TestValidateNativeTBTCSignerStateWitnessGeometry pins the exact bound the +// native signer enforces in configured_state_anchor (engine/anchor.rs) and +// parse_endpoint (engine/anchor_trust.rs). Both reject a rotation threshold +// below two or one that does not leave +// TBTC_SIGNER_STATE_WITNESS_ROTATION_TERMINAL_RECORD_RESERVATION records below +// the maximum. Raising that reservation in Rust must fail this test so the Go +// validators are updated in the same change instead of silently accepting +// geometries the signer refuses at node startup. +func TestValidateNativeTBTCSignerStateWitnessGeometry(t *testing.T) { + if NativeTBTCSignerStateWitnessRotationTerminalRecordReservation != 6 { + t.Fatalf( + "terminal-record reservation [%d] no longer mirrors the signer's six", + NativeTBTCSignerStateWitnessRotationTerminalRecordReservation, + ) + } + + var tests = map[string]struct { + maximumRecords uint64 + rotationThresholdRecords uint64 + valid bool + }{ + "production geometry": { + maximumRecords: 4096, + rotationThresholdRecords: 1024, + valid: true, + }, + "smallest geometry the signer accepts": { + maximumRecords: 8, + rotationThresholdRecords: 2, + valid: true, + }, + "threshold exactly at the terminal reserve": { + maximumRecords: 1024, + rotationThresholdRecords: 1018, + valid: true, + }, + "threshold one record inside the terminal reserve": { + maximumRecords: 1024, + rotationThresholdRecords: 1019, + valid: false, + }, + "threshold leaving only the retired two-record reserve": { + maximumRecords: 64, + rotationThresholdRecords: 60, + valid: false, + }, + "maximum below the terminal reserve": { + maximumRecords: 7, + rotationThresholdRecords: 2, + valid: false, + }, + "threshold below the signer minimum": { + maximumRecords: 4096, + rotationThresholdRecords: 1, + valid: false, + }, + "zero maximum": { + maximumRecords: 0, + rotationThresholdRecords: 2, + valid: false, + }, + "maximum above the signer hard maximum": { + maximumRecords: NativeTBTCSignerStateWitnessHardMaximumRecords + 1, + rotationThresholdRecords: 2, + valid: false, + }, + "threshold at the top of the unsigned range": { + maximumRecords: 4096, + rotationThresholdRecords: ^uint64(0), + valid: false, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + err := ValidateNativeTBTCSignerStateWitnessGeometry( + test.maximumRecords, + test.rotationThresholdRecords, + ) + if test.valid && err != nil { + t.Fatalf( + "geometry [%d/%d] the signer accepts was rejected: %v", + test.maximumRecords, + test.rotationThresholdRecords, + err, + ) + } + if !test.valid && err == nil { + t.Fatalf( + "geometry [%d/%d] the signer rejects was accepted", + test.maximumRecords, + test.rotationThresholdRecords, + ) + } + }) + } +} diff --git a/pkg/tbtc/frost_native_signer_anchor_bootstrap_client.go b/pkg/tbtc/frost_native_signer_anchor_bootstrap_client.go index 38f479c554..1847c647cb 100644 --- a/pkg/tbtc/frost_native_signer_anchor_bootstrap_client.go +++ b/pkg/tbtc/frost_native_signer_anchor_bootstrap_client.go @@ -472,14 +472,18 @@ func (client *FrostNativeSignerAnchorBootstrapHTTPClient) validateBootstrapAutho endpoint := certificate.To if endpoint.ActivationManifestHash == [32]byte{} || endpoint.ActivationManifestSequence == 0 || - endpoint.BindingHash == [32]byte{} || - endpoint.WitnessMaximumRecords < 2 || - endpoint.WitnessMaximumRecords > 1_000_000 || - endpoint.WitnessRotationThresholdRecords < 2 || - endpoint.WitnessRotationThresholdRecords > - endpoint.WitnessMaximumRecords-2 { + endpoint.BindingHash == [32]byte{} { return fmt.Errorf("bootstrap authorization endpoint pins are invalid") } + if err := frostsigning.ValidateNativeTBTCSignerStateWitnessGeometry( + endpoint.WitnessMaximumRecords, + endpoint.WitnessRotationThresholdRecords, + ); err != nil { + return fmt.Errorf( + "bootstrap authorization endpoint witness geometry is invalid: %w", + err, + ) + } if endpoint.ResponsePublicKey != client.responseKeyRaw || endpoint.ResponsePublicKeySPKISHA256 != client.responseKeyPin { return fmt.Errorf( diff --git a/pkg/tbtc/frost_native_signer_anchor_protocol.go b/pkg/tbtc/frost_native_signer_anchor_protocol.go index 0e0fd925db..c0cde64aff 100644 --- a/pkg/tbtc/frost_native_signer_anchor_protocol.go +++ b/pkg/tbtc/frost_native_signer_anchor_protocol.go @@ -1133,15 +1133,16 @@ func validateFrostNativeSignerAnchorIdentity( ) } if identity.ActivationManifestSequence == 0 || - identity.WitnessMaximumRecords < 2 || - identity.WitnessMaximumRecords > 1_000_000 || - identity.WitnessRotationThresholdRecords < 2 || - identity.WitnessRotationThresholdRecords > - identity.WitnessMaximumRecords-2 || !frostNativeSignerAnchorCanonicalIdentityString(identity.TrustDomainID, 256) || !frostNativeSignerAnchorCanonicalIdentityString(identity.HistoryStoreID, 256) { return fmt.Errorf("anchor identity strings or manifest sequence are invalid") } + if err := frostsigning.ValidateNativeTBTCSignerStateWitnessGeometry( + identity.WitnessMaximumRecords, + identity.WitnessRotationThresholdRecords, + ); err != nil { + return fmt.Errorf("anchor identity witness geometry is invalid: %w", err) + } if ComputeFrostNativeSignerAnchorStreamID(identity) != identity.StreamID { return fmt.Errorf("anchor stream ID does not match its stable identity") } diff --git a/pkg/tbtc/frost_native_signer_anchor_provisioning_test.go b/pkg/tbtc/frost_native_signer_anchor_provisioning_test.go index ef64e1cb89..072d0d8adf 100644 --- a/pkg/tbtc/frost_native_signer_anchor_provisioning_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_provisioning_test.go @@ -1226,3 +1226,81 @@ func TestFrostNativeSignerAnchorBootstrapOutputBundleDecode(t *testing.T) { }) } } + +// TestFrostNativeSignerAnchorRejectsRustInvalidWitnessGeometry pins every Go +// intake that pre-verifies a witness geometry against the exact bound the +// native signer enforces. Go mints the offline-authority-signed trust +// certificate from the operator plan, so a geometry only Go accepts survives +// the entire offline ceremony and is first rejected by the signer at node +// startup, which can only be undone by re-running the ceremony. The listed +// geometries are the ones the retired two-record reserve accepted and the +// signer's six-record terminal reserve does not. +func TestFrostNativeSignerAnchorRejectsRustInvalidWitnessGeometry(t *testing.T) { + var geometries = map[string]struct { + maximumRecords uint64 + rotationThresholdRecords uint64 + }{ + "threshold inside the terminal reserve": { + maximumRecords: 64, + rotationThresholdRecords: 60, + }, + "threshold one record inside the terminal reserve": { + maximumRecords: 1000, + rotationThresholdRecords: 995, + }, + "maximum below the terminal reserve": { + maximumRecords: 4, + rotationThresholdRecords: 2, + }, + } + + for geometryName, geometry := range geometries { + t.Run(geometryName, func(t *testing.T) { + fixture := newBootstrapProvisioningTestFixture() + identity := fixture.plan.Identity + identity.WitnessMaximumRecords = geometry.maximumRecords + identity.WitnessRotationThresholdRecords = + geometry.rotationThresholdRecords + identity.StreamID = ComputeFrostNativeSignerAnchorStreamID(identity) + + // The operator plan is the ceremony input; rejecting it here is what + // keeps an unusable geometry from ever reaching the offline + // authority's signature. + plan := *fixture.plan + plan.Identity = identity + if err := validateFrostNativeSignerAnchorBootstrapPlan( + &plan, + ); err == nil || !strings.Contains(err.Error(), "witness geometry") { + t.Fatalf( + "bootstrap plan with a signer-invalid geometry was accepted: [%v]", + err, + ) + } + + // The certificate endpoint is verified again on every trust-chain + // intake, including the one that installs an already-signed + // certificate, so it must reject the same geometry. + certificate, _ := trustTestBootstrapCertificate(t) + certificate.To.WitnessMaximumRecords = geometry.maximumRecords + certificate.To.WitnessRotationThresholdRecords = + geometry.rotationThresholdRecords + if err := frostNativeSignerAnchorTrustValidateEndpoint( + &certificate.To, + certificate.SignerStoreFingerprint, + "target", + ); err == nil || !strings.Contains(err.Error(), "witness geometry") { + t.Fatalf( + "trust certificate endpoint with a signer-invalid geometry was accepted: [%v]", + err, + ) + } + + if err := frostsigning.ValidateNativeTBTCSignerStateWitnessGeometry( + geometry.maximumRecords, + geometry.rotationThresholdRecords, + ); err == nil { + t.Fatal("shared witness geometry bound accepted a signer-invalid geometry") + } + }) + } +} diff --git a/pkg/tbtc/frost_native_signer_anchor_trust.go b/pkg/tbtc/frost_native_signer_anchor_trust.go index 2e63fc274e..d8dc985747 100644 --- a/pkg/tbtc/frost_native_signer_anchor_trust.go +++ b/pkg/tbtc/frost_native_signer_anchor_trust.go @@ -1112,14 +1112,14 @@ func frostNativeSignerAnchorTrustValidateEndpoint( err, ) } - if endpoint.WitnessMaximumRecords < 4 || - endpoint.WitnessMaximumRecords > 1_000_000 || - endpoint.WitnessRotationThresholdRecords < 2 || - endpoint.WitnessRotationThresholdRecords > - endpoint.WitnessMaximumRecords-2 { + if err := frostsigning.ValidateNativeTBTCSignerStateWitnessGeometry( + endpoint.WitnessMaximumRecords, + endpoint.WitnessRotationThresholdRecords, + ); err != nil { return fmt.Errorf( - "native signer anchor trust %s witness geometry is invalid", + "native signer anchor trust %s witness geometry is invalid: %w", name, + err, ) } if err := validateFrostNativeSignerAnchorCheckpoint( diff --git a/pkg/tbtc/frost_native_signer_anchor_trust_startup.go b/pkg/tbtc/frost_native_signer_anchor_trust_startup.go index 072bafdb25..560d02c12c 100644 --- a/pkg/tbtc/frost_native_signer_anchor_trust_startup.go +++ b/pkg/tbtc/frost_native_signer_anchor_trust_startup.go @@ -776,13 +776,13 @@ func validateFrostNativeSignerAnchorTrustRuntimePins( "native signer anchor online response key aliases the offline authority", ) } - if anchorManifest.WitnessMaximumRecords < 4 || - anchorManifest.WitnessMaximumRecords > 1_000_000 || - anchorManifest.WitnessRotationThresholdRecords < 2 || - anchorManifest.WitnessRotationThresholdRecords > - anchorManifest.WitnessMaximumRecords-2 { + if err := frostsigning.ValidateNativeTBTCSignerStateWitnessGeometry( + anchorManifest.WitnessMaximumRecords, + anchorManifest.WitnessRotationThresholdRecords, + ); err != nil { return fmt.Errorf( - "native signer anchor witness geometry is invalid", + "native signer anchor witness geometry is invalid: %w", + err, ) } return nil From 37b4e36d41d9b40caba779bd8a5c2223aa296c62 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 15:47:32 -0400 Subject: [PATCH 65/93] fix(tbtc): amortize the pre-sign generation reservation over its workflow Pre-sign admission multiplied the hard per-operation generation bound by every anchored call in the workflow. With production parameters that made a maximum-size deposit sweep (21 inputs, 5 signing attempts) cost 4158 generations for an operator holding three local seats, above the 4096 certified proof window, so authorize() failed outright before any headroom accounting - a freshly rotated anchor could not admit it either. Three or four local seats is an ordinary sortition result in a hundred-seat wallet, and every excluded seat is lost to the wallet's signing threshold on every batch of that size, so enough of them stop the group from assembling a threshold at all while each node reports only a local admission error. The per-operation bound of three is correct and stays: the signer's terminal reserve of six journal records is three snapshots, and node startup installs this same constant as the output barrier's MaximumStateGenerationAdvancePerOperation, where a call advancing further poisons the process. What was wrong is multiplying it by the call count. The third advance of any single call is always a repair - both the expiry sweep's second snapshot and the Round2/Aggregate marker re-persist require a pending persistence operation that only a failed snapshot creates, and the marker re-persist excludes that call's own mutation because flushing the marker makes the replay gate reject the retry. A snapshot that failed advanced no generation, so its call leaves exactly the advance the later repair lands. Reserve the amortized two advances per call instead, plus one workflow-wide terminal allowance for the two interleavings that outrun it and cannot recur: a witness committed before a failed post-commit revalidation (replace_state revalidates first, so every later call then fails closed), and one witness prepared before admission (prepare_witness refuses a second). Both restart windows are finite, so a seat ceiling remains - four local seats at the maximum batch size, up from two. Report it in the rejection itself, with the input count, the ceiling, and the threshold consequence, so an operator that crosses it is not left inferring the cause from a wallet that quietly stops signing full-size batches. Co-Authored-By: Claude Fable 5 --- .../frost_native_signer_anchor_admission.go | 200 +++++++++++++++--- ...ost_native_signer_anchor_admission_test.go | 68 +++++- 2 files changed, 227 insertions(+), 41 deletions(-) diff --git a/pkg/tbtc/frost_native_signer_anchor_admission.go b/pkg/tbtc/frost_native_signer_anchor_admission.go index e3896edc01..aa1d82dff3 100644 --- a/pkg/tbtc/frost_native_signer_anchor_admission.go +++ b/pkg/tbtc/frost_native_signer_anchor_admission.go @@ -8,14 +8,46 @@ import ( const ( // One request-taking interactive signer call can durably advance the Rust - // generation up to three times: reconcile one previously prepared witness - // snapshot, persist the sweep/repair snapshot (the expiry sweep and any - // protected retirement share it), then persist the endpoint's own + // generation up to three times: the expiry-sweep prologue takes one + // snapshot and, when a prior write left a repair pending, a second for the + // retirement that repair unblocked, then the endpoint persists its own // mutation. Matching pending operations are covered by the sweep; // nonmatching operations remain pending without another write. The // process output barrier still advances the remote anchor revision only // once for the call's final checkpoint. + // + // This is a hard per-operation bound, not an accounting convenience: node + // startup installs it as the output barrier's MaximumStateGenerationAdvance + // PerOperation, and one call advancing further poisons the process. It must + // stay at the signer's own worst case for one in-flight request, which + // TBTC_SIGNER_STATE_WITNESS_ROTATION_TERMINAL_RECORD_RESERVATION states as + // three snapshots (six journal records, two per snapshot). frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall uint64 = 3 + + // A workflow reservation cannot simply multiply the per-operation bound by + // its call count, because the third advance of any single call is always a + // repair and no workflow can pay it on every call. Both extra writes - the + // sweep's second snapshot and the Round2/Aggregate marker re-persist - are + // reached only when an earlier snapshot failed and left a pending + // persistence operation, and the marker re-persist excludes that call's own + // mutation because flushing the marker makes the replay gate reject the + // retry. A snapshot that failed before replacing the state image, or that + // replaced it and left its witness prepared and uncommitted, advanced no + // generation at all, so its call spent one of the two advances reserved + // here and the later repair lands the one it did not. The steady-state cost + // is therefore the shared sweep prologue plus the endpoint's own mutation. + frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall uint64 = 2 + + // Two interleavings outrun the amortized bound, and neither recurs. A + // snapshot that committed its witness and then failed its post-commit + // revalidation both spends an advance and leaves a repair pending; it + // cannot happen twice in a workflow because replace_state revalidates the + // store before replacing anything, so every later anchored call fails + // closed without advancing a generation. A witness prepared before + // admission and reconciled inside the workflow lands one further advance; + // prepare_witness refuses to prepare a second, so at most one is ever + // outstanding. This workflow-wide allowance covers both. + frostNativeSignerTerminalGenerationAdvancesPerWorkflow uint64 = 3 ) type frostNativeSignerAnchorCapacity struct { @@ -329,22 +361,145 @@ func (reservation *frostNativeSignerAnchorRevisionReservation) Release() { } // frostPreSignMaximumAnchorCapacityCost freezes the upper bound for one -// authorized transaction. For every input: +// authorized transaction and rejects a workflow no certified restart window +// can cover. For every input: // // - BuildTaprootTx is one request-taking call; // - every attempt lets each local seat call Open, Round1, Round2, and Abort; // - Aggregate is process-memoized to one call per attempt. // // Each of those calls can advance one service revision when its sweep prologue -// mutates otherwise-unrelated state. Each interactive call can advance up to -// three Rust generations (prepared-witness reconciliation, one sweep/repair -// write, and its own write), so the proof-window cost is three times the -// anchored-call count. Attempt derivation, package/evidence handling, and -// authorization guards do not persist Rust state. +// mutates otherwise-unrelated state. The proof-window cost is the amortized +// per-call generation bound times the anchored-call count plus the +// workflow-wide terminal allowance, not the hard per-operation bound times +// that count: no workflow can pay a repair advance on every one of its calls. +// Attempt derivation, package/evidence handling, and authorization guards do +// not persist Rust state. +// +// Both windows are finite, so the local seat count an operator can serve at a +// given batch size is finite too. With production parameters (21 inputs, the +// standard 20-deposit sweep plus its main UTXO, and 5 signing attempts) the +// ceiling is four local seats. This is a real operational limit rather than an +// accounting artifact: the windows bound how far a restarting node can still +// prove its own history back to the last offline-authorized floor, and a +// workflow admitted beyond them could not be recovered after a crash. A seat +// above the ceiling is excluded from every batch of that size on every one of +// its attempts, so the errors below name the ceiling explicitly - an operator +// that crosses it would otherwise have to infer it from a wallet that quietly +// stops reaching its signing threshold on full-size batches. func frostPreSignMaximumAnchorCapacityCost( inputCount uint64, localSeatCount uint64, maximumSigningAttempts uint64, +) (frostNativeSignerAnchorCapacity, error) { + cost, err := frostPreSignAnchoredWorkflowCost( + inputCount, + localSeatCount, + maximumSigningAttempts, + ) + if err != nil { + return frostNativeSignerAnchorCapacity{}, err + } + if cost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST pre-sign workflow over [%d] inputs with [%d] local seats "+ + "requires [%d] anchor revisions, exceeding the certified history "+ + "window [%d]; %s", + inputCount, + localSeatCount, + cost.Revisions, + FrostNativeSignerAnchorMaximumHistoryEvents, + frostPreSignAdmissibleLocalSeatAdvice( + inputCount, + maximumSigningAttempts, + ), + ) + } + if cost.Generations > FrostNativeSignerAnchorMaximumHistoryProofEntries { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST pre-sign workflow over [%d] inputs with [%d] local seats "+ + "requires [%d] signer generations, exceeding the certified proof "+ + "window [%d]; %s", + inputCount, + localSeatCount, + cost.Generations, + FrostNativeSignerAnchorMaximumHistoryProofEntries, + frostPreSignAdmissibleLocalSeatAdvice( + inputCount, + maximumSigningAttempts, + ), + ) + } + return cost, nil +} + +// frostPreSignAdmissibleLocalSeatAdvice states the seat ceiling that produced a +// window rejection in operator-actionable terms. Every local seat above the +// ceiling is permanently excluded from batches of this size, and the wallet +// loses those seats toward its signing threshold, so this belongs in the error +// itself: the exclusion is otherwise visible only as a group that stops +// assembling a threshold on full-size batches, with no node reporting a cause. +func frostPreSignAdmissibleLocalSeatAdvice( + inputCount uint64, + maximumSigningAttempts uint64, +) string { + admissibleSeats := frostPreSignMaximumAdmissibleLocalSeatCount( + inputCount, + maximumSigningAttempts, + ) + if admissibleSeats == 0 { + return fmt.Sprintf( + "no local seat count can sign [%d] inputs within the certified "+ + "restart windows; the wallet cannot serve batches of this size "+ + "until those windows or the signing-attempt limit change", + inputCount, + ) + } + return fmt.Sprintf( + "at most [%d] local seats can sign [%d] inputs within the certified "+ + "restart windows, so every local seat above that ceiling is excluded "+ + "from every batch of this size and is lost to the wallet's signing "+ + "threshold", + admissibleSeats, + inputCount, + ) +} + +// frostPreSignMaximumAdmissibleLocalSeatCount reports the largest local seat +// count whose complete worst-case workflow still fits both certified restart +// windows at this input count, or zero when no seat count does. Cost rises +// monotonically with the seat count, so the first rejection ends the scan. +func frostPreSignMaximumAdmissibleLocalSeatCount( + inputCount uint64, + maximumSigningAttempts uint64, +) uint64 { + admissibleSeats := uint64(0) + for seats := uint64(1); seats <= + uint64(frostPreSignAuthorizationMaximumSeats); seats++ { + cost, err := frostPreSignAnchoredWorkflowCost( + inputCount, + seats, + maximumSigningAttempts, + ) + if err != nil || + cost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents || + cost.Generations > + FrostNativeSignerAnchorMaximumHistoryProofEntries { + break + } + admissibleSeats = seats + } + return admissibleSeats +} + +// frostPreSignAnchoredWorkflowCost is the window-independent arithmetic behind +// frostPreSignMaximumAnchorCapacityCost. It is kept separate so the seat +// ceiling can be scanned without re-entering the window diagnostics that report +// it. +func frostPreSignAnchoredWorkflowCost( + inputCount uint64, + localSeatCount uint64, + maximumSigningAttempts uint64, ) (frostNativeSignerAnchorCapacity, error) { if inputCount == 0 || inputCount > uint64(frostPreSignAuthorizationMaximumInputs) { @@ -387,31 +542,16 @@ func frostPreSignMaximumAnchorCapacityCost( ) } revisions := inputCount * perInputCalls - if revisions > maxUint64/ - frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall { + if revisions > (maxUint64- + frostNativeSignerTerminalGenerationAdvancesPerWorkflow)/ + frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall { return frostNativeSignerAnchorCapacity{}, fmt.Errorf( "FROST pre-sign generation cost overflow", ) } - generations := - revisions * frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall - - if revisions > FrostNativeSignerAnchorMaximumHistoryEvents { - return frostNativeSignerAnchorCapacity{}, fmt.Errorf( - "FROST pre-sign workflow requires [%d] anchor revisions, exceeding "+ - "the certified history window [%d]", - revisions, - FrostNativeSignerAnchorMaximumHistoryEvents, - ) - } - if generations > FrostNativeSignerAnchorMaximumHistoryProofEntries { - return frostNativeSignerAnchorCapacity{}, fmt.Errorf( - "FROST pre-sign workflow requires [%d] signer generations, exceeding "+ - "the certified proof window [%d]", - generations, - FrostNativeSignerAnchorMaximumHistoryProofEntries, - ) - } + generations := revisions* + frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall + + frostNativeSignerTerminalGenerationAdvancesPerWorkflow return frostNativeSignerAnchorCapacity{ Revisions: revisions, diff --git a/pkg/tbtc/frost_native_signer_anchor_admission_test.go b/pkg/tbtc/frost_native_signer_anchor_admission_test.go index 497deea9a8..8928be8069 100644 --- a/pkg/tbtc/frost_native_signer_anchor_admission_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_admission_test.go @@ -15,7 +15,7 @@ func TestFrostPreSignMaximumAnchorCapacityCost(t *testing.T) { if err != nil { t.Fatal(err) } - if cost.Revisions != 546 || cost.Generations != 1638 { + if cost.Revisions != 546 || cost.Generations != 1095 { t.Fatalf("unexpected single-seat maximum cost [%+v]", cost) } if cost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents || @@ -37,17 +37,63 @@ func TestFrostPreSignMaximumAnchorCapacityCost(t *testing.T) { t.Fatal(err) } if multiSeatCost.Revisions != 966 || - multiSeatCost.Generations != 2898 { + multiSeatCost.Generations != 1935 { t.Fatalf("unexpected multi-seat maximum cost [%+v]", multiSeatCost) } - if _, err := frostPreSignMaximumAnchorCapacityCost( + // Three and four local seats are an ordinary sortition result in a + // hundred-seat wallet, and a twenty-one-input batch is the standard + // deposit sweep maximum. Both must be admissible at the maximum batch + // size: a seat excluded here is lost to the wallet's signing threshold on + // every batch of that size, and enough excluded seats stop the group from + // assembling a threshold at all. + for _, seats := range []uint64{3, 4} { + seatCost, err := frostPreSignMaximumAnchorCapacityCost( + frostPreSignAuthorizationMaximumInputs, + seats, + signingAttemptsLimit, + ) + if err != nil { + t.Fatalf("maximum batch with [%d] local seats was rejected: %v", seats, err) + } + if seatCost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents || + seatCost.Generations > + FrostNativeSignerAnchorMaximumHistoryProofEntries { + t.Fatalf( + "[%d]-seat maximum [%+v] exceeds restart windows [%d/%d]", + seats, + seatCost, + FrostNativeSignerAnchorMaximumHistoryEvents, + FrostNativeSignerAnchorMaximumHistoryProofEntries, + ) + } + } + + // The windows are finite, so the ceiling is real and must stay pinned + // where the accounting actually puts it. Crossing it is reported with the + // ceiling itself, because a node above it is otherwise silently excluded + // from every batch of this size. + if admissibleSeats := frostPreSignMaximumAdmissibleLocalSeatCount( + frostPreSignAuthorizationMaximumInputs, + signingAttemptsLimit, + ); admissibleSeats != 4 { + t.Fatalf( + "unexpected maximum-batch local seat ceiling [%d]", + admissibleSeats, + ) + } + _, err = frostPreSignMaximumAnchorCapacityCost( frostPreSignAuthorizationMaximumInputs, - 3, + 5, signingAttemptsLimit, - ); err == nil || !strings.Contains(err.Error(), "proof window") { + ) + if err == nil || !strings.Contains(err.Error(), "proof window") { t.Fatalf("oversized workflow was accepted: [%v]", err) } + if !strings.Contains(err.Error(), "at most [4] local seats") || + !strings.Contains(err.Error(), "signing threshold") { + t.Fatalf("oversized workflow did not report its seat ceiling: [%v]", err) + } } func TestFrostNativeSignerAnchorAdmissionController_AtomicallyReservesNearWarning( @@ -69,13 +115,13 @@ func TestFrostNativeSignerAnchorAdmissionController_AtomicallyReservesNearWarnin FrostNativeSignerAnchorRotationWarningHeadroom+1, ) - // Three inputs and one local seat reserve 78 revisions and 234 + // Four inputs and one local seat reserve 104 revisions and 211 // generations. Admission one unit above the warning boundary is safe // because the full dual-dimensional cost is reserved before the workflow. first, err := controller.reservePreSign( context.Background(), snapshot, - 3, + 4, 1, signingAttemptsLimit, ) @@ -143,7 +189,7 @@ func TestFrostNativeSignerAnchorAdmissionController_RejectsStaleReadinessHeadroo ) { currentHeadroom := frostNativeSignerAnchorCapacity{ Revisions: 400, - Generations: 300, + Generations: 500, } controller := &frostNativeSignerAnchorAdmissionController{ readHeadroom: func( @@ -152,15 +198,15 @@ func TestFrostNativeSignerAnchorAdmissionController_RejectsStaleReadinessHeadroo return currentHeadroom, nil }, } - staleSnapshot := testFrostAnchorAdmissionReadinessSnapshot(400, 400) + staleSnapshot := testFrostAnchorAdmissionReadinessSnapshot(400, 700) - // Four inputs and one seat cost 104 revisions and 312 generations. The + // Twelve inputs and one seat cost 312 revisions and 627 generations. The // stale snapshot would admit it, but the authenticated current tip has // advanced after that snapshot and must be authoritative. if _, err := controller.reservePreSign( context.Background(), staleSnapshot, - 4, + 12, 1, signingAttemptsLimit, ); err == nil || !strings.Contains(err.Error(), "unreserved") { From 6a6365720470c098a44a241522251e61134b4dcb Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 15:00:04 -0400 Subject: [PATCH 66/93] test(tbtc): cover the guarded HTTPS round trip end to end The identity-frozen HTTPS round tripper had no end-to-end coverage: the existing tests exercise the pieces around it - peer keys, the history cap, the live-channel bookkeeping, and the TLS dialer's address fail-over - but nothing drove a real request through RoundTrip, so the verification chain that actually enforces the frozen TLS identity was untested. Stand up a live JSON-RPC TLS server, dial it through the production constructor, and assert both directions. The frozen peer must round-trip a real eth_chainId call; a second TLS server serving the same SPIFFE identity from a rotated key at the same pinned loopback address must be refused, even though it satisfies PKIX, the TLS 1.3 http/1.1 profile, the hostname, and the frozen address set, because it never passed the guarded dialer and was therefore never recorded as an authenticated peer. Co-Authored-By: Claude Fable 5 --- .../frost_primary_ethereum_transport_test.go | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/pkg/tbtc/frost_primary_ethereum_transport_test.go b/pkg/tbtc/frost_primary_ethereum_transport_test.go index 7acdbc9b5f..009ef1eba4 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport_test.go +++ b/pkg/tbtc/frost_primary_ethereum_transport_test.go @@ -403,6 +403,144 @@ func TestFrostPrimaryEthereumTransportTriesNextAddressAfterPeerIdentityRejection } } +// TestFrostPrimaryEthereumTransportHTTPSRoundTripFreezesPeerIdentity drives a +// real JSON-RPC exchange through the guarded HTTPS round tripper against a live +// TLS server, then replays the exact same request over a second TLS server that +// serves the same SPIFFE identity from a rotated key at the same pinned address. +// Both directions matter: the frozen peer must round-trip, and a peer that never +// passed the guarded dialer must be refused even though it satisfies PKIX, the +// TLS profile, the hostname, and the frozen address set. +func TestFrostPrimaryEthereumTransportHTTPSRoundTripFreezesPeerIdentity( + t *testing.T, +) { + rpcServer := rpc.NewServer() + if err := rpcServer.RegisterName( + "eth", + &testFrostPrimaryEthereumChainIDRPC{}, + ); err != nil { + t.Fatal(err) + } + server, _, roots := newFrostRetainedGroupHistoryTLSTestServer( + t, + rpcServer, + "spiffe://primary.example/rpc", + ) + transport, err := NewFrostPrimaryEthereumTransport( + context.Background(), + FrostPrimaryEthereumTransportConfig{ + URL: server.URL, + RequestTimeout: time.Second, + TLSRootCAs: roots, + }, + ) + if err != nil { + t.Fatal(err) + } + defer transport.Close() + + requestContext, cancel := context.WithTimeout( + context.Background(), + 5*time.Second, + ) + defer cancel() + chainID, err := transport.Client().ChainID(requestContext) + if err != nil { + t.Fatalf("guarded HTTPS round trip rejected the frozen peer: [%v]", err) + } + if chainID == nil || chainID.Uint64() != 1 { + t.Fatalf("guarded HTTPS round trip returned chain ID [%v]", chainID) + } + if seen, live := transport.peerCounts(); seen != 1 || live == 0 { + t.Fatalf( + "guarded HTTPS round trip left peer counts [%d, %d]", + seen, + live, + ) + } + + rotatedServer, rotatedLeaf, _ := newFrostRetainedGroupHistoryTLSTestServer( + t, + rpcServer, + "spiffe://primary.example/rpc", + ) + rotatedEndpoint, err := url.Parse(rotatedServer.URL) + if err != nil { + t.Fatal(err) + } + rotatedRoots := roots.Clone() + rotatedRoots.AddCert(rotatedLeaf) + rotatedTLSConfig := &tls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + ServerName: transport.endpoint.endpoint.Hostname(), + RootCAs: rotatedRoots, + NextProtos: []string{"http/1.1"}, + } + rotated := &frostPrimaryEthereumHTTPRoundTripper{ + base: &http.Transport{ + Proxy: nil, + DisableCompression: true, + ForceAttemptHTTP2: false, + DialTLSContext: func( + ctx context.Context, + network string, + _ string, + ) (net.Conn, error) { + dialer := &net.Dialer{} + raw, dialErr := dialer.DialContext( + ctx, + network, + rotatedEndpoint.Host, + ) + if dialErr != nil { + return nil, dialErr + } + connection := tls.Client(raw, rotatedTLSConfig) + if handshakeErr := connection.HandshakeContext( + ctx, + ); handshakeErr != nil { + _ = connection.Close() + return nil, handshakeErr + } + return connection, nil + }, + }, + transport: transport, + endpoint: transport.endpoint, + } + defer rotated.base.CloseIdleConnections() + + rotatedRequest, err := http.NewRequestWithContext( + requestContext, + http.MethodPost, + transport.endpoint.canonical, + strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}`, + ), + ) + if err != nil { + t.Fatal(err) + } + rotatedRequest.Header.Set("Content-Type", "application/json") + response, err := rotated.RoundTrip(rotatedRequest) + if err == nil { + _ = response.Body.Close() + t.Fatal("rotated TLS identity round-tripped through the frozen transport") + } + if response != nil { + t.Fatal("rejected round trip returned a response") + } + if !strings.Contains(err.Error(), "unauthenticated TLS channel") { + t.Fatalf("rotated TLS identity was rejected for [%v]", err) + } + if seen, _ := transport.peerCounts(); seen != 1 { + t.Fatalf( + "rejected round trip recorded the rotated identity; seen [%d]", + seen, + ) + } +} + func TestFrostPrimaryEthereumTransportWSSAppliesRequestTimeout( t *testing.T, ) { @@ -471,6 +609,15 @@ func TestFrostPrimaryEthereumTransportWSSAppliesRequestTimeout( } } +type testFrostPrimaryEthereumChainIDRPC struct{} + +func (service *testFrostPrimaryEthereumChainIDRPC) ChainId( + context.Context, +) (*hexutil.Big, error) { + value := hexutil.Big(*big.NewInt(1)) + return &value, nil +} + type testFrostPrimaryEthereumStalledRPC struct { mutex sync.Mutex calls int From a864911edf92dea3713fe586c8e03234592e634c Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 15:02:01 -0400 Subject: [PATCH 67/93] fix(tbtc): surface rejected Bitcoin rebroadcasts during replay The periodic replay pass discarded the Bitcoin-side result of every rebroadcast it performed: a rejection produced no log line, no error, and no counter, so a reservation the node had been failing to broadcast for hours looked exactly like a healthy one from the outside. Report the rejection as a transient replay failure, the same shape this file already uses for recoverable candidate failures. Retry and backoff are unchanged - the record keeps its authorized variant and the next tick retries it, start-up recovery still proceeds because the failure is not fatal - but the periodic ticker now logs it, and the attempt counter travels with the message so a stuck entry is distinguishable from a single mempool hiccup. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/bitcoin_broadcast_outbox.go | 20 ++++++ pkg/tbtc/bitcoin_broadcast_outbox_test.go | 87 +++++++++++++++++++++-- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/pkg/tbtc/bitcoin_broadcast_outbox.go b/pkg/tbtc/bitcoin_broadcast_outbox.go index 77d6ebe6e6..4de0e27e63 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox.go @@ -898,6 +898,26 @@ candidateLoop: continue } if broadcastErr != nil { + // A Bitcoin-side rejection is recoverable: the durable record is + // unchanged apart from its attempt counter, and the next replay + // tick retries the very same authorized variant. It is reported as + // a transient failure so it neither aborts this pass nor blocks + // start-up recovery, but it must not stay invisible either: the + // attempt counter travels with the message, so an entry a node has + // been failing to broadcast for hours is distinguishable from a + // single mempool hiccup in one log line. + replayErrors.failures = append( + replayErrors.failures, + bitcoinBroadcastReplayFailure{ + candidate.reservationID, + &bitcoinBroadcastTransientReplayError{fmt.Errorf( + "cannot broadcast Bitcoin transaction [%x] on attempt [%d]: [%w]", + broadcastRecord.TransactionHash, + broadcastRecord.BroadcastAttempts, + broadcastErr, + )}, + }, + ) continue } if _, err := bbo.refreshConfirmation(ctx, broadcastRecord); err != nil { diff --git a/pkg/tbtc/bitcoin_broadcast_outbox_test.go b/pkg/tbtc/bitcoin_broadcast_outbox_test.go index 3187b0f9fd..057aaf6250 100644 --- a/pkg/tbtc/bitcoin_broadcast_outbox_test.go +++ b/pkg/tbtc/bitcoin_broadcast_outbox_test.go @@ -770,6 +770,70 @@ func TestBitcoinBroadcastOutbox_ReplayIsolatesCandidateFailures(t *testing.T) { } } +func TestBitcoinBroadcastOutbox_ReportsRejectedBitcoinRebroadcast(t *testing.T) { + chain := newOutboxTestBitcoinChain() + outbox := openTestBitcoinBroadcastOutbox(t, t.TempDir(), chain) + defer outbox.close() + + rejected := testOutboxTransaction(41, 7000) + healthy := testOutboxTransaction(42, 7000) + enqueueTestBitcoinTransaction( + t, + outbox, + rejected, + testBitcoinBroadcastAuthorization(41, 11, 1), + ) + enqueueTestBitcoinTransaction( + t, + outbox, + healthy, + testBitcoinBroadcastAuthorization(42, 12, 1), + ) + chain.setBroadcastError( + rejected.Hash(), + errors.New("txn-mempool-conflict"), + ) + + err := outbox.replayOnce() + if err == nil || !strings.Contains(err.Error(), "txn-mempool-conflict") || + !strings.Contains(err.Error(), "on attempt [1]") { + t.Fatalf("Bitcoin rebroadcast rejection was not surfaced: [%v]", err) + } + var replayErrors *bitcoinBroadcastReplayErrors + if !errors.As(err, &replayErrors) || replayErrors.hasFatalFailure() { + t.Fatalf("Bitcoin rebroadcast rejection is not retryable: [%v]", err) + } + if chain.broadcastCount(healthy.Hash()) != 1 { + t.Fatal("candidate after a rejected rebroadcast was starved") + } + + // The attempt counter separates a persistently failing entry from a single + // transient rejection, so a stuck reservation is visible in one log line. + if err := outbox.replayOnce(); err == nil || + !strings.Contains(err.Error(), "on attempt [2]") { + t.Fatalf("repeated Bitcoin rebroadcast rejection was not surfaced: [%v]", err) + } + + // A rejected rebroadcast must not stop recovery: it is retried, not fatal. + contextValue, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := outbox.start(contextValue); err != nil { + t.Fatalf("rejected rebroadcast stopped outbox recovery: [%v]", err) + } + snapshot, err := outbox.activationSnapshot() + if err != nil { + t.Fatal(err) + } + if !snapshot.Recovered || snapshot.PendingReservationCount != 2 { + t.Fatalf("unexpected recovery snapshot: [%+v]", snapshot) + } + + chain.setBroadcastError(rejected.Hash(), nil) + if err := outbox.replayOnce(); err != nil { + t.Fatalf("accepted rebroadcast still reported a failure: [%v]", err) + } +} + func TestBitcoinBroadcastOutbox_StartRetriesTransientCandidateFailure( t *testing.T, ) { @@ -1657,11 +1721,12 @@ func testOutboxTransaction(inputByte byte, outputValue int64) *bitcoin.Transacti type outboxTestBitcoinChain struct { *localBitcoinChain - mutex sync.Mutex - statuses map[bitcoin.Hash]*bitcoin.CanonicalTransactionStatus - statusErrs map[bitcoin.Hash]error - broadcasts map[bitcoin.Hash]uint - statusCalls uint + mutex sync.Mutex + statuses map[bitcoin.Hash]*bitcoin.CanonicalTransactionStatus + statusErrs map[bitcoin.Hash]error + broadcastErrs map[bitcoin.Hash]error + broadcasts map[bitcoin.Hash]uint + statusCalls uint } type blockingOutboxTestBitcoinChain struct { @@ -1790,6 +1855,7 @@ func newOutboxTestBitcoinChain() *outboxTestBitcoinChain { localBitcoinChain: newLocalBitcoinChain(), statuses: make(map[bitcoin.Hash]*bitcoin.CanonicalTransactionStatus), statusErrs: make(map[bitcoin.Hash]error), + broadcastErrs: make(map[bitcoin.Hash]error), broadcasts: make(map[bitcoin.Hash]uint), } } @@ -1817,7 +1883,7 @@ func (otbc *outboxTestBitcoinChain) BroadcastTransaction( otbc.mutex.Lock() defer otbc.mutex.Unlock() otbc.broadcasts[tx.Hash()]++ - return nil + return otbc.broadcastErrs[tx.Hash()] } func (otbc *outboxTestBitcoinChain) setCanonicalStatus( @@ -1830,6 +1896,15 @@ func (otbc *outboxTestBitcoinChain) setCanonicalStatus( otbc.statuses[hash] = &clone } +func (otbc *outboxTestBitcoinChain) setBroadcastError( + hash bitcoin.Hash, + err error, +) { + otbc.mutex.Lock() + defer otbc.mutex.Unlock() + otbc.broadcastErrs[hash] = err +} + func (otbc *outboxTestBitcoinChain) setCanonicalError( hash bitcoin.Hash, err error, From 0651412e4f91abfdf60dfaa5be762c6782bd351d Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 15:07:01 -0400 Subject: [PATCH 68/93] fix(tbtc): retain retained-endpoint peers by separation identity The retained peer history was keyed by the full peer identity, so every leaf certificate the retained endpoint ever presented took its own slot. That rotation is routine and expected - registerRetainedPeer already pins the leaf SPKI to the frozen identity, so a rotated leaf keeps its key and changes only the certificate around it - and once the bounded history filled, the cap latched a permanent separation failure on a healthy endpoint, which verifyIndependence can never clear. Key the history by the state the policy actually compares across endpoints: address, leaf SPKI, and SPIFFE authorities. Certificate aliasing is already reported by the SPKI comparison, because identities sharing a certificate necessarily share its public key, so collapsing observations that differ only in the certificate leaves every independence verdict unchanged. The cap still latches for 4096 genuinely distinct separation identities. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/frost_primary_ethereum_transport.go | 26 ++++- .../frost_primary_ethereum_transport_test.go | 96 +++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/pkg/tbtc/frost_primary_ethereum_transport.go b/pkg/tbtc/frost_primary_ethereum_transport.go index 45c7de3bb6..3d7e40c51c 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport.go +++ b/pkg/tbtc/frost_primary_ethereum_transport.go @@ -606,6 +606,30 @@ func frostTransportPeerChannelKey( return transcript.sum() } +// frostTransportRetainedPeerRetentionKey identifies a retained peer by exactly +// the state the separation policy compares across endpoints: its address, its +// TLS leaf public key, and its SPIFFE authorities. The leaf certificate hash is +// deliberately excluded. Two identities that share a certificate necessarily +// share that certificate's public key, so certificate aliasing is already +// reported by the leaf SPKI comparison in frostTransportPeersIndependent, and +// every retained observation collapsed under one retention key carries the same +// address, the same SPKI and the same authorities - the surviving entry decides +// every independence comparison exactly as each collapsed observation would. +// +// Retaining one entry per certificate would instead let routine leaf rotation +// consume the bounded history: registerRetainedPeer pins the leaf SPKI to the +// frozen identity, so a rotated retained leaf is expected to keep its key and +// change only the certificate around it, and once the cap was reached the +// policy latched a permanent separation failure on a healthy endpoint. The cap +// still latches for 4096 genuinely distinct separation identities, which no +// single frozen endpoint can legitimately present. +func frostTransportRetainedPeerRetentionKey( + peer frostTransportPeerIdentity, +) [32]byte { + peer.leafCertificateHash = [32]byte{} + return frostTransportPeerIdentityKey(peer) +} + func frostTransportStablePeerIdentity( peer frostTransportPeerIdentity, ) frostTransportPeerIdentity { @@ -945,7 +969,7 @@ func (policy *frostPrimaryRetainedSeparationPolicy) registerRetainedPeer( return fmt.Errorf("retained peer role is outside the separation policy") } peers := policy.retainedPeers[role] - key := frostTransportPeerIdentityKey(peer) + key := frostTransportRetainedPeerRetentionKey(peer) stablePeer := frostTransportStablePeerIdentity(peer) if _, exists := peers[key]; !exists && len(peers) >= frostPrimaryEthereumMaximumSeenPeers { diff --git a/pkg/tbtc/frost_primary_ethereum_transport_test.go b/pkg/tbtc/frost_primary_ethereum_transport_test.go index 009ef1eba4..0c9bb925c3 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport_test.go +++ b/pkg/tbtc/frost_primary_ethereum_transport_test.go @@ -131,6 +131,102 @@ func TestFrostRetainedPeerReconnectsDoNotExhaustHistory(t *testing.T) { } } +// TestFrostRetainedPeerLeafRotationDoesNotLatchSeparationFailure rotates the +// retained leaf certificate past the bounded history cap while its key, address +// and SPIFFE authorities stay frozen, which is what routine certificate +// rotation looks like to this policy. The rotation must not consume history, +// and the separation property must still hold afterwards. +func TestFrostRetainedPeerLeafRotationDoesNotLatchSeparationFailure( + t *testing.T, +) { + primaryIP := netip.MustParseAddr("192.0.2.1") + retainedIP := netip.MustParseAddr("192.0.2.2") + retainedPeer := testFrostTransportPeer(0x11) + retainedPeer.remoteIP = retainedIP + retainedPeer.leafSPKIHash = [32]byte{0x44} + retainedPeer.spiffeAuthorities = []string{ + "retained.example", + "shared.example", + } + + policy := &frostPrimaryRetainedSeparationPolicy{ + primary: frostRetainedGroupResolvedEndpoint{ + addresses: []netip.Addr{primaryIP}, + }, + retained: map[string]frostPrimaryRetainedEndpointPolicy{ + "retained-history-export": { + endpoint: frostRetainedGroupResolvedEndpoint{ + addresses: []netip.Addr{retainedIP}, + }, + identity: FrostRetainedGroupEndpointIdentity{ + TrustDomainID: "retained.example", + TLSLeafSPKIHash: retainedPeer.leafSPKIHash, + }, + }, + }, + primaryPeers: map[[32]byte]frostTransportPeerIdentity{}, + retainedPeers: map[string]map[[32]byte]frostTransportPeerIdentity{ + "retained-history-export": {}, + }, + } + primaryPeer := frostTransportPeerIdentity{ + remoteIP: primaryIP, + leafCertificateHash: [32]byte{0x55}, + leafSPKIHash: [32]byte{0x66}, + spiffeAuthorities: []string{"primary.example"}, + } + if err := policy.registerPrimaryPeer( + frostTransportPeerIdentityKey(primaryPeer), + primaryPeer, + ); err != nil { + t.Fatal(err) + } + + for index := 0; index <= frostPrimaryEthereumMaximumSeenPeers; index++ { + retainedPeer.leafCertificateHash = sha256.Sum256( + []byte(fmt.Sprintf("retained-certificate-%d", index)), + ) + retainedPeer.tlsExporterValueHash = sha256.Sum256( + []byte(fmt.Sprintf("retained-exporter-%d", index)), + ) + if err := policy.registerRetainedPeer( + "retained-history-export", + retainedPeer, + ); err != nil { + t.Fatalf("retained leaf rotation [%d] failed: [%v]", index, err) + } + } + + if actual := len( + policy.retainedPeers["retained-history-export"], + ); actual != 1 { + t.Fatalf("unexpected retained peer history size [%d]", actual) + } + if err := policy.verify(); err != nil { + t.Fatalf("routine leaf rotation latched a separation failure: [%v]", err) + } + + // The surviving entry still decides the comparisons the frozen identities + // cannot: this primary peer satisfies the frozen retained identity, and only + // the retained history reveals that it shares a SPIFFE authority with the + // retained endpoint's actual peer. + aliasingPrimaryPeer := frostTransportPeerIdentity{ + remoteIP: primaryIP, + leafCertificateHash: [32]byte{0x77}, + leafSPKIHash: [32]byte{0x78}, + spiffeAuthorities: []string{"primary.example", "shared.example"}, + } + if err := policy.registerPrimaryPeer( + frostTransportPeerIdentityKey(aliasingPrimaryPeer), + aliasingPrimaryPeer, + ); err == nil { + t.Fatal("primary peer aliasing the retained peer was accepted") + } + if err := policy.verify(); err == nil { + t.Fatal("separation policy stayed healthy after an aliasing peer") + } +} + func TestFrostPrimaryEthereumTransportRejectsNewStablePeerPastLimit( t *testing.T, ) { From 957b02632036512c3ca2fd2357e2a0654e125a39 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 15:08:19 -0400 Subject: [PATCH 69/93] docs(tbtc): state what binds the cached handshake snapshots The journal stamp compared before the cached snapshots are read covers journal state only, which reads as if the native signer inventory and interactive signing readiness were exported unchecked. They are not: verifyFrostProductionSignerReadinessUnchanged re-reads live native signer state at the signing boundary and refuses to sign unless it still equals the cached value the payload carries. Record that where the snapshots are taken, together with the residual window a consumer of a signed handshake actually has to tolerate. No behaviour changes; TestFrostActivationHandshakeExporter_RevalidatesNativeSignerStateBeforeSigning already pins the revalidation. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/frost_activation_handshake.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 6f660d4d40..6a500cd68b 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -925,6 +925,16 @@ func (fahe *frostActivationHandshakeExporter) attest( err, ) } + // Both snapshots are served from the reconciliation cache, and the journal + // stamp compared above covers journal state only: it says nothing about the + // native signer inventory or about interactive signing readiness. Their + // freshness comes from verifyFrostProductionSignerReadinessUnchanged at the + // signing boundary below, which re-reads live native signer state and + // refuses to sign unless it still equals reconciliation.inventory - the + // exact value exported into the payload - and unless the interactive engine + // is still ready. A consumer of a signed handshake therefore tolerates only + // the window between that revalidation and the signature, never the age of + // the cache. journalSnapshot := &reconciliation.journal nativeSignerSnapshot := &reconciliation.inventory if !nativeSignerSnapshot.ExternalRollbackAnchorBound || From 1cef89d34094895959030b91a42c77862ecb0d7d Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 16:38:59 -0400 Subject: [PATCH 70/93] fix(tbtc): reserve the signer's quarantine records in the Go geometry bound The signer keeps a PREPARE/COMMIT pair above the terminal band so a corrupt state image can still be quarantined once an interrupted retry has parked the journal at the terminal limit, and enforces rotationThreshold + 6 + 2 <= maximumRecords at both geometry intakes. The Go bound mirrored only the six terminal records, so Go would again mint and pre-verify offline-authority certificates for geometries the signer rejects at node startup - the same drift the shared helper was introduced to end, one reservation later. Fold the quarantine pair into the same helper and pin both constants, so raising either one in Rust fails the Go test instead of surfacing after an offline ceremony. Co-Authored-By: Claude Fable 5 --- .../native_tbtc_signer_state_anchor_trust.go | 22 ++++++++++---- ...ive_tbtc_signer_state_anchor_trust_test.go | 30 +++++++++++++------ 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go index 662ab106bd..901cc1aafc 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go @@ -22,6 +22,13 @@ const ( // three snapshots need six PREPARE/COMMIT records to finish the in-flight // request before a checkpoint can be acknowledged. NativeTBTCSignerStateWitnessRotationTerminalRecordReservation uint64 = 6 + // NativeTBTCSignerStateWitnessQuarantineRecordReservation mirrors the + // signer's TBTC_SIGNER_STATE_WITNESS_QUARANTINE_RECORD_RESERVATION, the + // PREPARE/COMMIT pair kept above the terminal band so corruption recovery + // stays reachable once an interrupted retry has parked the journal at the + // terminal limit. Quarantine commits an absence, so it never extends usable + // state, but it still needs two records that ordinary writes cannot consume. + NativeTBTCSignerStateWitnessQuarantineRecordReservation uint64 = 2 // NativeTBTCSignerStateWitnessMinimumRotationThresholdRecords mirrors the // signer's lower bound on the rotation threshold: the terminal reserve is // entered only after at least one complete PREPARE/COMMIT pair. @@ -41,9 +48,10 @@ const ( // the whole offline ceremony against a plan the signer rejects at node startup, // which can only be undone by re-running the ceremony. // -// The bound is deliberately expressed as one helper over one exported -// reservation constant. Every previous copy of this arithmetic drifted -// independently when the reservation grew from two records to six. +// The bound is deliberately expressed as one helper over the exported +// reservation constants. Every previous copy of this arithmetic drifted +// independently when the reservation grew from two records to six, and the +// quarantine pair added on top of it would have drifted the same way. func ValidateNativeTBTCSignerStateWitnessGeometry( maximumRecords uint64, rotationThresholdRecords uint64, @@ -57,17 +65,19 @@ func ValidateNativeTBTCSignerStateWitnessGeometry( ) } reserved := rotationThresholdRecords + - NativeTBTCSignerStateWitnessRotationTerminalRecordReservation + NativeTBTCSignerStateWitnessRotationTerminalRecordReservation + + NativeTBTCSignerStateWitnessQuarantineRecordReservation if rotationThresholdRecords < NativeTBTCSignerStateWitnessMinimumRotationThresholdRecords || reserved < rotationThresholdRecords || reserved > maximumRecords { return fmt.Errorf( "witnessRotationThresholdRecords [%d] must be at least %d and "+ - "reserve %d terminal records below witnessMaximumRecords [%d]", + "reserve %d records below witnessMaximumRecords [%d]", rotationThresholdRecords, NativeTBTCSignerStateWitnessMinimumRotationThresholdRecords, - NativeTBTCSignerStateWitnessRotationTerminalRecordReservation, + NativeTBTCSignerStateWitnessRotationTerminalRecordReservation+ + NativeTBTCSignerStateWitnessQuarantineRecordReservation, maximumRecords, ) } diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go index 8093fe4c87..0ac943cbe0 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go @@ -304,8 +304,9 @@ func uint64ToCanonicalString(value uint64) string { // native signer enforces in configured_state_anchor (engine/anchor.rs) and // parse_endpoint (engine/anchor_trust.rs). Both reject a rotation threshold // below two or one that does not leave -// TBTC_SIGNER_STATE_WITNESS_ROTATION_TERMINAL_RECORD_RESERVATION records below -// the maximum. Raising that reservation in Rust must fail this test so the Go +// TBTC_SIGNER_STATE_WITNESS_ROTATION_TERMINAL_RECORD_RESERVATION plus +// TBTC_SIGNER_STATE_WITNESS_QUARANTINE_RECORD_RESERVATION records below the +// maximum. Raising either reservation in Rust must fail this test so the Go // validators are updated in the same change instead of silently accepting // geometries the signer refuses at node startup. func TestValidateNativeTBTCSignerStateWitnessGeometry(t *testing.T) { @@ -315,6 +316,12 @@ func TestValidateNativeTBTCSignerStateWitnessGeometry(t *testing.T) { NativeTBTCSignerStateWitnessRotationTerminalRecordReservation, ) } + if NativeTBTCSignerStateWitnessQuarantineRecordReservation != 2 { + t.Fatalf( + "quarantine-record reservation [%d] no longer mirrors the signer's two", + NativeTBTCSignerStateWitnessQuarantineRecordReservation, + ) + } var tests = map[string]struct { maximumRecords uint64 @@ -327,18 +334,23 @@ func TestValidateNativeTBTCSignerStateWitnessGeometry(t *testing.T) { valid: true, }, "smallest geometry the signer accepts": { - maximumRecords: 8, + maximumRecords: 10, rotationThresholdRecords: 2, valid: true, }, - "threshold exactly at the terminal reserve": { + "threshold exactly at the reserved band": { maximumRecords: 1024, - rotationThresholdRecords: 1018, + rotationThresholdRecords: 1016, valid: true, }, - "threshold one record inside the terminal reserve": { + "threshold one record inside the reserved band": { maximumRecords: 1024, - rotationThresholdRecords: 1019, + rotationThresholdRecords: 1017, + valid: false, + }, + "threshold leaving only the terminal band without its quarantine pair": { + maximumRecords: 1024, + rotationThresholdRecords: 1018, valid: false, }, "threshold leaving only the retired two-record reserve": { @@ -346,8 +358,8 @@ func TestValidateNativeTBTCSignerStateWitnessGeometry(t *testing.T) { rotationThresholdRecords: 60, valid: false, }, - "maximum below the terminal reserve": { - maximumRecords: 7, + "maximum one record below the reserved band": { + maximumRecords: 9, rotationThresholdRecords: 2, valid: false, }, From 5c4ecd94d2f1d1ddbecbdd46b0bd622c19770cfb Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 16:39:16 -0400 Subject: [PATCH 71/93] ci: pin the signer mirror to the ABI 4.4 retirement head The pin pointed at codex/dkg-retirement-signer, a branch in no open pull request, which forked from this stack four hardening commits before the signer PR's head. That commit supplies the ABI 4.4 retirement symbol this branch requires, but not the witness rollback, state-loading, rotation boundary and interrupted-retry fixes, so the cgo gate never built the signer source actually under review and the two pull requests could not merge as a pair - this branch requires minor 4 and the signer PR published 4.3, so the bridge would reject the library at load. Move the pin to the signer head that carries both: the reviewed hardening commits and the retirement engine restacked on top of them. Co-Authored-By: Claude Fable 5 --- ci/frost-signer-pin.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/frost-signer-pin.env b/ci/frost-signer-pin.env index fc45e27fb9..91e4eae83f 100644 --- a/ci/frost-signer-pin.env +++ b/ci/frost-signer-pin.env @@ -15,4 +15,4 @@ # # After the scaffold and mirror branches merge into one, replace the cross-branch # checkout with an in-tree cargo build and retire this pin (keep the gate). -FROST_SIGNER_MIRROR_REF=b9f691a9daa215b6a1bd8b5c50fc6b8bb3214e04 +FROST_SIGNER_MIRROR_REF=08b6d6f40027016101f32c1ffc509fde4746d0a2 From 4f054e89b3466a0e93c2f2fa2909cb00bddf04a5 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 17:13:38 -0400 Subject: [PATCH 72/93] ci: compare the witness geometry reservations across languages The Go geometry test can only pin Go's own constants: the signer crate lives on the mirror branch, so nothing in this branch notices the signer raising a reservation. That is precisely the drift that shipped once already, when Rust moved the terminal reserve from two records to six and seven Go validators kept the old bound, and again when the quarantine pair was added on top. The cgo gate is the one place that holds both sources at once, because it checks out the pinned crate to build the library. Compare the reservation constants there, so a Rust-side change fails in the same gate that builds the library rather than after an operator has run the offline signing ceremony against a geometry the node rejects at startup. Co-Authored-By: Claude Fable 5 --- .github/workflows/frost-cgo-integration.yml | 43 +++++++++++++++++++ ...ive_tbtc_signer_state_anchor_trust_test.go | 12 ++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/.github/workflows/frost-cgo-integration.yml b/.github/workflows/frost-cgo-integration.yml index d6df61d5f4..e223dc2f85 100644 --- a/.github/workflows/frost-cgo-integration.yml +++ b/.github/workflows/frost-cgo-integration.yml @@ -76,6 +76,49 @@ jobs: test -f "$lib" || { echo "libfrost_tbtc.so not found at $lib"; exit 1; } echo "FROST_LIB_DIR=${CARGO_TARGET_DIR}/debug" >> "$GITHUB_ENV" + - name: Verify the witness geometry reservations agree across languages + run: | + set -euo pipefail + # The Go validators mint and pre-verify the offline-authority-signed trust + # certificates, so a geometry Go accepts must be exactly a geometry the signer + # accepts. Go cannot see the crate (it lives on the mirror branch), so its own + # test can only pin its own constants - it cannot notice the signer moving. + # This job can: it has the pinned crate checked out, so it compares the two + # sources directly. A reservation raised in Rust fails HERE, in the same gate + # that builds the pinned library, instead of surfacing after an operator has + # already run the offline signing ceremony against a geometry the node rejects. + rust_const() { + grep -oE "pub\(crate\) const $1: usize = [0-9]+" \ + _signer-mirror/pkg/tbtc/signer/src/engine/store.rs \ + | grep -oE '[0-9]+$' + } + go_const() { + grep -oE "$1 uint64 = [0-9]+" \ + pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go \ + | grep -oE '[0-9]+$' + } + mismatch=0 + check() { + rust_value="$(rust_const "$1" || true)" + go_value="$(go_const "$2" || true)" + if [ -z "$rust_value" ] || [ -z "$go_value" ]; then + echo "could not read $1 (rust) / $2 (go); a constant was renamed" + mismatch=1 + return + fi + if [ "$rust_value" != "$go_value" ]; then + echo "witness geometry drift: $1 is [$rust_value] but $2 is [$go_value]" + mismatch=1 + fi + } + check TBTC_SIGNER_STATE_WITNESS_ROTATION_TERMINAL_RECORD_RESERVATION \ + NativeTBTCSignerStateWitnessRotationTerminalRecordReservation + check TBTC_SIGNER_STATE_WITNESS_QUARANTINE_RECORD_RESERVATION \ + NativeTBTCSignerStateWitnessQuarantineRecordReservation + test "$mismatch" -eq 0 || { + echo "update the Go reservation constants and their validator in the same change" + exit 1; } + - name: Verify the engine ABI symbols are exported run: | set -euo pipefail diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go index 0ac943cbe0..cbe96e87cd 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go @@ -306,9 +306,15 @@ func uint64ToCanonicalString(value uint64) string { // below two or one that does not leave // TBTC_SIGNER_STATE_WITNESS_ROTATION_TERMINAL_RECORD_RESERVATION plus // TBTC_SIGNER_STATE_WITNESS_QUARANTINE_RECORD_RESERVATION records below the -// maximum. Raising either reservation in Rust must fail this test so the Go -// validators are updated in the same change instead of silently accepting -// geometries the signer refuses at node startup. +// maximum. +// +// This pins the Go side only. The crate is not in this branch, so nothing here +// can observe the signer moving; raising a reservation in Rust does NOT fail +// this test. The cross-language comparison lives in the frost-cgo-integration +// workflow, which has the pinned crate checked out and compares the two sources +// directly. What this test does buy: the bound is computed in one helper over +// named constants, so once that workflow reports a drift there is exactly one +// place to change, and these cases document the boundary that moves. func TestValidateNativeTBTCSignerStateWitnessGeometry(t *testing.T) { if NativeTBTCSignerStateWitnessRotationTerminalRecordReservation != 6 { t.Fatalf( From c9977a31903ca9514f7ce5c958d9b477ce2d8a47 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 17:39:09 -0400 Subject: [PATCH 73/93] fix(tbtc): admit the signing window's own advance, and attest live state Two follow-ups to the cached-readiness fix. The stability check inside a single verification was left comparing the two native signer reads by value. Both reads verify the state tip against the authenticated external anchor, so a full round trip separates them, and the authorized signing window's own consumption marker routinely persists in that gap: the reconciliation and every revalidation therefore still failed with "native signer state changed during readiness verification", and the pre-sign authorization monitor latches the first such error permanently. The check now applies the same advance-versus-change discipline the cached comparison uses. A rollback, an equal-generation fork, a rotated trust head or any strict-field change between the two reads still fails closed. The activation handshake exported the native signer state from the reconciliation cache, which is keyed on the finalized Ethereum point and refreshed only when finality advances. The state generation, the commitment chain, the anchor revision and both restartable headrooms are exactly the fields the revalidation now permits to move, so a concurrent authorized batch could leave the node signing a nonce-bound attestation claiming a headroom stale by orders of magnitude. The revalidation now returns the live inventory it read and the payload is built from that, so the attested volatile values are bounds taken at the signing boundary rather than the age of the cache. The strict fields, including AnchorRotationWarning, stay pinned to the reconciled values, so a healthier-than-reality anchor still cannot be attested. The comment at the cache read said the revalidation refused to sign unless live state equalled the exported value; it now states what is pinned, what may drift, and in which direction. The handshake test double still compared the whole inventory by value, so its "native anchor advances" case asserted a 503 production no longer returns. It now mirrors the production comparison, and a new test pins that an authorized advance is answered from the same cache with the live values in the signed payload. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/frost_activation_handshake.go | 139 ++++--- pkg/tbtc/frost_activation_handshake_test.go | 166 +++++++- pkg/tbtc/frost_native_signer_readiness.go | 64 ++- .../frost_native_signer_readiness_test.go | 371 ++++++++++++++---- 4 files changed, 573 insertions(+), 167 deletions(-) diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 6a500cd68b..8f51bf5656 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -248,10 +248,14 @@ type frostActivationReconciliationCache struct { type frostActivationHandshakeReadinessVerifier interface { frostProductionSignerReadinessVerifier - verifyFrostProductionSignerReadinessUnchanged( + // revalidateFrostProductionSignerReadinessInventory revalidates cached + // readiness and hands back the live native signer inventory it read, so a + // signed attestation can export what the signer reports now rather than + // what the finality-keyed reconciliation cache recorded minutes ago. + revalidateFrostProductionSignerReadinessInventory( context.Context, *frostProductionSignerReadinessSnapshot, - ) error + ) (*frostNativeSignerInventorySnapshot, error) } func newFrostActivationHandshakeExporter( @@ -925,49 +929,13 @@ func (fahe *frostActivationHandshakeExporter) attest( err, ) } - // Both snapshots are served from the reconciliation cache, and the journal - // stamp compared above covers journal state only: it says nothing about the - // native signer inventory or about interactive signing readiness. Their - // freshness comes from verifyFrostProductionSignerReadinessUnchanged at the - // signing boundary below, which re-reads live native signer state and - // refuses to sign unless it still equals reconciliation.inventory - the - // exact value exported into the payload - and unless the interactive engine - // is still ready. A consumer of a signed handshake therefore tolerates only - // the window between that revalidation and the signature, never the age of - // the cache. + // The journal snapshot is served from the reconciliation cache, and the + // stamp compared above pins exactly it: canonical and quarantine + // generations and roots move the stamp, so a cached journal state that no + // longer matches the live journal cannot reach the payload. The stamp says + // nothing about the native signer inventory, which is why that snapshot is + // re-read live further down instead of being exported from the cache. journalSnapshot := &reconciliation.journal - nativeSignerSnapshot := &reconciliation.inventory - if !nativeSignerSnapshot.ExternalRollbackAnchorBound || - nativeSignerSnapshot.TrustCertificateSequence == 0 || - nativeSignerSnapshot.TrustCertificateDigest == [32]byte{} || - nativeSignerSnapshot.AnchorServiceEpoch == 0 || - nativeSignerSnapshot.CertifiedFloorRevision == 0 || - nativeSignerSnapshot.CertifiedFloorGeneration == 0 || - nativeSignerSnapshot.CurrentAnchorRevision < - nativeSignerSnapshot.CertifiedFloorRevision || - nativeSignerSnapshot.RestartableRevisionHeadroom == 0 || - nativeSignerSnapshot.StateGeneration < - nativeSignerSnapshot.CertifiedFloorGeneration || - nativeSignerSnapshot.RestartableGenerationHeadroom == 0 || - nativeSignerSnapshot.CurrentAnchorRevision- - nativeSignerSnapshot.CertifiedFloorRevision+ - nativeSignerSnapshot.RestartableRevisionHeadroom != - FrostNativeSignerAnchorMaximumHistoryEvents || - nativeSignerSnapshot.StateGeneration- - nativeSignerSnapshot.CertifiedFloorGeneration+ - nativeSignerSnapshot.RestartableGenerationHeadroom != - FrostNativeSignerAnchorMaximumHistoryProofEntries || - nativeSignerSnapshot.AnchorRotationWarning != - frostNativeSignerAnchorRotationWarning( - minFrostNativeSignerAnchorHeadroom( - nativeSignerSnapshot.RestartableRevisionHeadroom, - nativeSignerSnapshot.RestartableGenerationHeadroom, - ), - ) { - return nil, fmt.Errorf( - "native signer state lacks an authenticated external rollback anchor trust certificate", - ) - } journalManifest := fahe.manifest.CanonicalJournal if !fahe.journal.mutex.TryLock() { return nil, fmt.Errorf( @@ -1017,6 +985,78 @@ func (fahe *frostActivationHandshakeExporter) attest( err, ) } + // The reconciliation cache is keyed on the finalized Ethereum point and is + // refreshed only when finality advances, so its native signer inventory can + // be many minutes old by the time a challenge arrives. This revalidation + // re-reads live native signer state, and the payload below is built from + // that live read rather than from the cache, because the two are pinned to + // each other only in part: + // + // - the strict facts - store identity and fingerprint, retained key + // material, trust head, anchor service epoch, certified floor and the + // AnchorRotationWarning flag - must still equal the reconciled values + // or signing fails closed here. An attestation can therefore never + // claim a healthier anchor, or different key material, than the signer + // actually has; + // - the volatile facts that an authorized signing window advances by + // design - state generation, the state commitment chain, the state + // image digest, the anchor revision and both restartable headrooms - + // are only held to a monotone advance. A concurrent authorized batch + // can consume thousands of generations inside one finality window, so + // exporting the cached values would sign a headroom that is stale by + // orders of magnitude. They are exported as read here instead. + // + // The residual a consumer of a signed handshake must tolerate is the window + // between this read and the signature - the checkpoint self-verification, + // the transcript, one activation-point check and the journal stamp taken + // under the signing lock - never the age of the cache. A concurrent batch + // can still advance within that window, so the six volatile values are + // bounds rather than instants: attested generation and anchor revision are + // lower bounds on live state, attested headrooms are upper bounds. + nativeSignerSnapshot, err := + fahe.readiness.revalidateFrostProductionSignerReadinessInventory( + ctx, + &reconciliation.readiness, + ) + if err != nil { + fahe.queueReconciliation(finality, true) + return nil, fmt.Errorf( + "%w: cached FROST signer readiness changed before signing: [%v]", + errFrostActivationReconciliationPending, + err, + ) + } + if !nativeSignerSnapshot.ExternalRollbackAnchorBound || + nativeSignerSnapshot.TrustCertificateSequence == 0 || + nativeSignerSnapshot.TrustCertificateDigest == [32]byte{} || + nativeSignerSnapshot.AnchorServiceEpoch == 0 || + nativeSignerSnapshot.CertifiedFloorRevision == 0 || + nativeSignerSnapshot.CertifiedFloorGeneration == 0 || + nativeSignerSnapshot.CurrentAnchorRevision < + nativeSignerSnapshot.CertifiedFloorRevision || + nativeSignerSnapshot.RestartableRevisionHeadroom == 0 || + nativeSignerSnapshot.StateGeneration < + nativeSignerSnapshot.CertifiedFloorGeneration || + nativeSignerSnapshot.RestartableGenerationHeadroom == 0 || + nativeSignerSnapshot.CurrentAnchorRevision- + nativeSignerSnapshot.CertifiedFloorRevision+ + nativeSignerSnapshot.RestartableRevisionHeadroom != + FrostNativeSignerAnchorMaximumHistoryEvents || + nativeSignerSnapshot.StateGeneration- + nativeSignerSnapshot.CertifiedFloorGeneration+ + nativeSignerSnapshot.RestartableGenerationHeadroom != + FrostNativeSignerAnchorMaximumHistoryProofEntries || + nativeSignerSnapshot.AnchorRotationWarning != + frostNativeSignerAnchorRotationWarning( + minFrostNativeSignerAnchorHeadroom( + nativeSignerSnapshot.RestartableRevisionHeadroom, + nativeSignerSnapshot.RestartableGenerationHeadroom, + ), + ) { + return nil, fmt.Errorf( + "native signer state lacks an authenticated external rollback anchor trust certificate", + ) + } state := frostActivationHandshakeState{ ProtocolID: frostActivationHex32(fahe.manifest.SignerProtocolID), ReservationProtocolID: frostActivationHex32(fahe.manifest.ReservationProtocolID), @@ -1160,17 +1200,6 @@ func (fahe *frostActivationHandshakeExporter) attest( err, ) } - if err := fahe.readiness.verifyFrostProductionSignerReadinessUnchanged( - ctx, - &reconciliation.readiness, - ); err != nil { - fahe.queueReconciliation(finality, true) - return nil, fmt.Errorf( - "%w: cached FROST signer readiness changed before signing: [%v]", - errFrostActivationReconciliationPending, - err, - ) - } var signature []byte journalChanged := false err = fahe.outbox.withUnchangedActivationSnapshot( diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index 50c7b001a6..8391c7b262 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -373,9 +373,27 @@ func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSigner ctx context.Context, expected *frostProductionSignerReadinessSnapshot, ) error { + _, err := readiness.revalidateFrostProductionSignerReadinessInventory( + ctx, + expected, + ) + return err +} + +// revalidateFrostProductionSignerReadinessInventory mirrors production: it +// compares the live inventory against the reconciled one with exactly the +// comparison the real verifier uses - strict on identity, key material, trust +// head and the rotation warning, monotone on the state checkpoint, the anchor +// revision and both headrooms - and returns the live snapshot. Comparing the +// whole struct by value here would model a production behaviour that no longer +// exists and would assert a 503 the exporter no longer returns. +func (readiness *testFrostProductionSignerReadiness) revalidateFrostProductionSignerReadinessInventory( + ctx context.Context, + expected *frostProductionSignerReadinessSnapshot, +) (*frostNativeSignerInventorySnapshot, error) { if ctx == nil || expected == nil || expected.Inventory == nil || !expected.InteractiveSigningReady { - return fmt.Errorf("cached signer readiness is incomplete") + return nil, fmt.Errorf("cached signer readiness is incomplete") } readiness.mutex.Lock() unchangedStarted := readiness.unchangedStarted @@ -389,21 +407,24 @@ func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSigner if unchangedRelease != nil { select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() case <-unchangedRelease: } } interactive, readinessErr, inventory := readiness.snapshot() if readinessErr != nil { - return readinessErr + return nil, readinessErr } if !interactive { - return fmt.Errorf("interactive signer is not ready") + return nil, fmt.Errorf("interactive signer is not ready") } - if inventory != *expected.Inventory { - return fmt.Errorf("native signer state changed since reconciliation") + if err := verifyFrostNativeSignerInventoryUnchanged( + expected.Inventory, + &inventory, + ); err != nil { + return nil, err } - return nil + return &inventory, nil } func (source *testFrostRetainedGroupHistorySource) BindFrostRetainedGroupActivationEvidence( @@ -830,8 +851,12 @@ func TestFrostActivationHandshakeExporter_AttestsInclusiveCheckpointAncestry( func TestFrostActivationHandshakeExporter_RevalidatesNativeSignerStateBeforeSigning( t *testing.T, ) { + // Only changes the revalidation fails closed on belong here. A pure + // monotone advance of the state checkpoint, the anchor revision or the + // headrooms is the authorized signing window's own progress and is covered + // by TestFrostActivationHandshakeExporter_SignsLiveNativeSignerState. testCases := map[string]func(*testFrostProductionSignerReadiness){ - "native state advances": func( + "retained key material changes": func( readiness *testFrostProductionSignerReadiness, ) { inventory := testFrostProductionSignerInventorySnapshot() @@ -843,12 +868,23 @@ func TestFrostActivationHandshakeExporter_RevalidatesNativeSignerStateBeforeSign inventory.RestartableGenerationHeadroom-- readiness.setInventory(inventory) }, - "native anchor advances": func( + "native state rolls back": func( + readiness *testFrostProductionSignerReadiness, + ) { + inventory := testFrostProductionSignerInventorySnapshot() + inventory.StateGeneration-- + inventory.StateCommitment = inventory.PreviousStateCommitment + inventory.PreviousStateCommitment = [32]byte{0x2f} + inventory.StateImageDigest = [32]byte{0x35} + inventory.RestartableGenerationHeadroom++ + readiness.setInventory(inventory) + }, + "rotated anchor trust certificate": func( readiness *testFrostProductionSignerReadiness, ) { inventory := testFrostProductionSignerInventorySnapshot() - inventory.CurrentAnchorRevision++ - inventory.RestartableRevisionHeadroom-- + inventory.TrustCertificateSequence++ + inventory.TrustCertificateDigest = [32]byte{0x3a} readiness.setInventory(inventory) }, "interactive readiness changes": func( @@ -911,7 +947,7 @@ func TestFrostActivationHandshakeExporter_RevalidatesNativeSignerStateBeforeSign if !handshake.Payload.State.Healthy { t.Fatal("fresh stable signer state did not recover healthy attestation") } - if name == "native state advances" && + if name == "retained key material changes" && (handshake.Payload.State.NativeSignerState.StateGeneration != 8 || handshake.Payload.State.NativeSignerState.StateCommitment != frostActivationHex32([32]byte{0x41})) { @@ -920,12 +956,10 @@ func TestFrostActivationHandshakeExporter_RevalidatesNativeSignerStateBeforeSign handshake.Payload.State.NativeSignerState, ) } - if name == "native anchor advances" && - (handshake.Payload.State.NativeSignerState.CurrentAnchorRevision != 2 || - handshake.Payload.State.NativeSignerState.RestartableRevisionHeadroom != - FrostNativeSignerAnchorMaximumHistoryEvents-1) { + if name == "native state rolls back" && + handshake.Payload.State.NativeSignerState.StateGeneration != 6 { t.Fatalf( - "reconciled attestation did not use the current native anchor: %+v", + "reconciled attestation did not use the current native state: %+v", handshake.Payload.State.NativeSignerState, ) } @@ -933,6 +967,104 @@ func TestFrostActivationHandshakeExporter_RevalidatesNativeSignerStateBeforeSign } } +// TestFrostActivationHandshakeExporter_SignsLiveNativeSignerState pins that the +// signed attestation carries native signer facts read at the signing boundary, +// not the ones the finality-keyed reconciliation cache recorded. +// +// The cache is refreshed only when finality advances, which is minutes apart, +// while an authorized signing batch advances the state generation, the anchor +// revision and both restartable headrooms continuously. Those fields are +// exactly the ones the readiness revalidation permits to move, so exporting +// them from the cache would sign a headroom that is stale by orders of +// magnitude and mislead a consumer scheduling offline anchor rotation. +func TestFrostActivationHandshakeExporter_SignsLiveNativeSignerState( + t *testing.T, +) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + exporter, _, _, _, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + readiness, ok := exporter.readiness.(*testFrostProductionSignerReadiness) + if !ok { + t.Fatal("unexpected production signer readiness verifier") + } + + response := postTestFrostActivationHandshake(t, endpoint, request) + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + response.Body.Close() + + cached := testFrostProductionSignerInventorySnapshot() + + // One authorized batch: the engine persists a consumption marker and the + // output barrier advances the anchor revision once, so the checkpoint + // chain, the revision and both headrooms move while identity, key material + // and the trust head stay put. + advanced := cached + advanced.StateGeneration++ + advanced.PreviousStateCommitment = cached.StateCommitment + advanced.StateCommitment = [32]byte{0x41} + advanced.StateImageDigest = [32]byte{0x42} + advanced.RestartableGenerationHeadroom-- + advanced.CurrentAnchorRevision++ + advanced.RestartableRevisionHeadroom-- + readiness.setInventory(advanced) + + // The advance is not a readiness change, so the very next challenge is + // answered from the same cache without a reconciliation round trip. + response = postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + response.Body.Close() + t.Fatalf( + "authorized durable advance was refused with status [%d]: %s", + response.StatusCode, + body, + ) + } + defer response.Body.Close() + handshake := &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { + t.Fatal(err) + } + + exporter.reconciliationMutex.Lock() + stillCached := exporter.reconciliationCompleted != nil && + exporter.reconciliationCompleted.inventory == cached + exporter.reconciliationMutex.Unlock() + if !stillCached { + t.Fatal("reconciliation cache was refreshed; the export is not proven live") + } + + signed := handshake.Payload.State.NativeSignerState + if signed.StateGeneration != advanced.StateGeneration || + signed.StateCommitment != frostActivationHex32(advanced.StateCommitment) || + signed.PreviousStateCommitment != + frostActivationHex32(advanced.PreviousStateCommitment) || + signed.StateImageDigest != + frostActivationHex32(advanced.StateImageDigest) || + signed.CurrentAnchorRevision != advanced.CurrentAnchorRevision || + signed.RestartableRevisionHeadroom != + advanced.RestartableRevisionHeadroom || + signed.RestartableGenerationHeadroom != + advanced.RestartableGenerationHeadroom { + t.Fatalf( + "attestation signed the cached native signer state instead of the live one: %+v", + signed, + ) + } + if !handshake.Payload.State.Healthy { + t.Fatal("live native signer state did not produce a healthy attestation") + } +} + func TestFrostActivationHandshakeExporter_RevalidatesOutboxStateBeforeSigning( t *testing.T, ) { diff --git a/pkg/tbtc/frost_native_signer_readiness.go b/pkg/tbtc/frost_native_signer_readiness.go index fea75377b0..42b1adff1c 100644 --- a/pkg/tbtc/frost_native_signer_readiness.go +++ b/pkg/tbtc/frost_native_signer_readiness.go @@ -424,30 +424,58 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead }, nil } +// verifyFrostProductionSignerReadinessUnchanged revalidates cached readiness +// and discards the live native signer inventory it read. Callers that export +// native signer facts into a signed statement must use +// revalidateFrostProductionSignerReadinessInventory instead and publish the +// returned live snapshot: the cached one is only pinned on the strict fields. func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerReadinessUnchanged( ctx context.Context, expected *frostProductionSignerReadinessSnapshot, ) error { + _, err := readiness.revalidateFrostProductionSignerReadinessInventory( + ctx, + expected, + ) + return err +} + +// revalidateFrostProductionSignerReadinessInventory revalidates cached +// readiness and returns the live native signer inventory it read. +// +// The returned snapshot is not the cached one. Everything +// verifyFrostNativeSignerInventoryUnchanged pins strictly - store identity, +// retained key material, trust head, certified floor and the anchor rotation +// warning - is guaranteed equal to the cached value or this returns an error. +// The state checkpoint, the anchor revision and both restartable headrooms are +// only held to a monotone advance, so on those the returned live values and +// the cached ones can legitimately differ. +func (readiness *frostProductionSignerReadiness) revalidateFrostProductionSignerReadinessInventory( + ctx context.Context, + expected *frostProductionSignerReadinessSnapshot, +) (*frostNativeSignerInventorySnapshot, error) { if readiness == nil || readiness.interactiveSigningReady == nil || readiness.journal == nil || readiness.inventoryBinding == nil || ctx == nil || expected == nil || expected.Journal == nil || expected.Inventory == nil || !expected.InteractiveSigningReady { - return fmt.Errorf("cached FROST production signer readiness is incomplete") + return nil, fmt.Errorf( + "cached FROST production signer readiness is incomplete", + ) } if !readiness.interactiveSigningReady() { - return fmt.Errorf("interactive FROST signing engine is not ready") + return nil, fmt.Errorf("interactive FROST signing engine is not ready") } if err := readiness.verifyFrostRetainedGroupJournalStampUnchanged( ctx, expected.Journal, ); err != nil { - return err + return nil, err } if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( expected.registryRevision, ) { - return fmt.Errorf( + return nil, fmt.Errorf( "local FROST signer registry changed since readiness reconciliation", ) } @@ -456,23 +484,23 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead expected.inventoryExpectations, ) if err != nil { - return err + return nil, err } if err := verifyFrostNativeSignerInventoryUnchanged( expected.Inventory, inventory, ); err != nil { - return err + return nil, err } if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( expected.registryRevision, ) { - return fmt.Errorf( + return nil, fmt.Errorf( "local FROST signer registry changed during readiness revalidation", ) } if !readiness.interactiveSigningReady() { - return fmt.Errorf( + return nil, fmt.Errorf( "interactive FROST signing engine became unavailable during readiness revalidation", ) } @@ -480,9 +508,9 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead ctx, expected.Journal, ); err != nil { - return err + return nil, err } - return nil + return inventory, nil } // verifyFrostNativeSignerInventoryUnchanged separates the native signer facts @@ -690,9 +718,21 @@ func (readiness *frostProductionSignerReadiness) verifyStableFrostProductionSign if err != nil { return nil, err } - if *firstInventory != *secondInventory { + // The two reads are not adjacent: each one verifies the state tip against + // the authenticated external anchor, which is a full round trip to the + // anchor service. An authorized signing session persisting its own + // consumption marker routinely lands in that window, so the same + // advance-versus-change discipline the cached revalidation uses has to + // apply here too. Comparing the whole snapshot by value instead would let + // a session's own durable advance fail its guard - and the pre-sign + // authorization monitor latches the first such error permanently. + if err := verifyFrostNativeSignerInventoryUnchanged( + firstInventory, + secondInventory, + ); err != nil { return nil, fmt.Errorf( - "native signer state changed during readiness verification", + "native signer state changed during readiness verification: [%w]", + err, ) } if err := validateFrostNativeSignerAnchorReadinessHeadroom( diff --git a/pkg/tbtc/frost_native_signer_readiness_test.go b/pkg/tbtc/frost_native_signer_readiness_test.go index 0da11d7850..bb3bcc4ed6 100644 --- a/pkg/tbtc/frost_native_signer_readiness_test.go +++ b/pkg/tbtc/frost_native_signer_readiness_test.go @@ -727,23 +727,49 @@ func TestFrostProductionSignerReadinessWaitsOutBusyJournalStamp( } } -// TestFrostProductionSignerReadinessAcceptsAuthorizedGenerationAdvance pins the -// asymmetry the cached revalidation path depends on: the authorized signing -// window durably advances the native store it is guarding, so its own advance -// must not read as a readiness change, while a rollback still must. -func TestFrostProductionSignerReadinessAcceptsAuthorizedGenerationAdvance( +// testFrostNativeSignerDurableState is the durable native signer state a +// readiness verification observes: the local state-witness tip and the +// authenticated remote anchor record that has to agree with it. +type testFrostNativeSignerDurableState struct { + tip frostsigning.NativeTBTCSignerStateWitnessTip + record FrostNativeSignerStateWitnessAnchorRecord +} + +// testFrostProductionSignerReadinessFixture wires a production readiness +// verifier over a mutable native signer store, so a test can move the durable +// state the way an authorized signing session does - and, through beforeRead, +// at a chosen point inside a single verification. +type testFrostProductionSignerReadinessFixture struct { + journal *frostRetainedGroupJournal + readiness *frostProductionSignerReadiness + anchorBinding *frostNativeSignerAnchorBinding + anchorStore *testFrostNativeSignerStateWitnessAnchorStore + inventory *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory + storeFingerprint [32]byte + target FrostPreSignFinality + tip frostsigning.NativeTBTCSignerStateWitnessTip + + // reads counts anchored native signer reads. beforeRead runs at the start + // of each one, which is where a test injects a durable change that lands + // strictly between two reads of the same stability check. + reads int + beforeRead func(read int) +} + +func newTestFrostProductionSignerReadinessFixture( t *testing.T, -) { - fixture := newJournalTestFixture(t) - keyGroup := hex.EncodeToString(fixture.walletID[:]) - fixture.registry.retainedFrostKeyGroups = map[[32]byte]string{ - fixture.walletID: keyGroup, +) *testFrostProductionSignerReadinessFixture { + t.Helper() + journalFixture := newJournalTestFixture(t) + keyGroup := hex.EncodeToString(journalFixture.walletID[:]) + journalFixture.registry.retainedFrostKeyGroups = map[[32]byte]string{ + journalFixture.walletID: keyGroup, } journalDirectory := t.TempDir() if err := os.Chmod(journalDirectory, 0700); err != nil { t.Fatal(err) } - journal := fixture.openJournal(t, journalDirectory) + journal := journalFixture.openJournal(t, journalDirectory) storeBinding := testFrostDurableSessionStoreBinding(t) storeFingerprint, err := storeBinding.verify() @@ -759,10 +785,10 @@ func TestFrostProductionSignerReadinessAcceptsAuthorizedGenerationAdvance( stateImageDigest, ) entries := []frostsigning.NativeTBTCSignerRetainedKeyGroup{{ - WalletID: fixture.walletID, + WalletID: journalFixture.walletID, KeyGroup: keyGroup, Threshold: frostPreSignAuthorizationThreshold, - ParticipantCount: uint16(len(fixture.operatorIDs)), + ParticipantCount: uint16(len(journalFixture.operatorIDs)), KeyPackages: []frostsigning.NativeTBTCSignerRetainedKeyPackage{{ ParticipantSeat: 7, }}, @@ -787,15 +813,44 @@ func TestFrostProductionSignerReadinessAcceptsAuthorizedGenerationAdvance( anchorBinding, anchorStore := testFrostNativeSignerInventoryAnchorBinding(storeFingerprint, checkpoint) trustHead := testFrostNativeSignerInventoryTrustHead(anchorBinding) + baselineTip, err := anchorBinding.readTip() + if err != nil { + t.Fatal(err) + } + + fixture := &testFrostProductionSignerReadinessFixture{ + journal: journal, + anchorBinding: anchorBinding, + anchorStore: anchorStore, + inventory: inventory, + storeFingerprint: storeFingerprint, + target: journalFixture.target, + tip: *baselineTip, + } + anchorBinding.readTip = func() ( + *frostsigning.NativeTBTCSignerStateWitnessTip, + error, + ) { + tip := fixture.tip + return &tip, nil + } inventoryBinding, err := newFrostNativeSignerInventoryBinding( storeBinding, anchorBinding, func() (*frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error) { - return inventory, nil + result := *fixture.inventory + return &result, nil }, func() (*frostsigning.NativeTBTCSignerStateAnchorTrustHead, error) { - copy := *trustHead - return ©, nil + // The trust head is the first thing a native signer verification + // reads, so counting here counts whole reads and a hook here lands + // strictly between two of them. + fixture.reads++ + if fixture.beforeRead != nil { + fixture.beforeRead(fixture.reads) + } + head := *trustHead + return &head, nil }, trustHead, ) @@ -810,67 +865,111 @@ func TestFrostProductionSignerReadinessAcceptsAuthorizedGenerationAdvance( if err != nil { t.Fatal(err) } - snapshot, err := readiness.verifyFrostProductionSignerReadiness( + fixture.readiness = readiness + return fixture +} + +func (fixture *testFrostProductionSignerReadinessFixture) durableState() testFrostNativeSignerDurableState { + return testFrostNativeSignerDurableState{ + tip: fixture.tip, + record: *fixture.anchorStore.record, + } +} + +func (fixture *testFrostProductionSignerReadinessFixture) setDurableState( + state testFrostNativeSignerDurableState, +) { + fixture.tip = state.tip + *fixture.anchorStore.record = state.record + fixture.inventory.StateGeneration = state.tip.Generation + fixture.inventory.PreviousStateCommitment = state.tip.PreviousStateCommitment + fixture.inventory.StateImageDigest = state.tip.StateImageDigest + fixture.inventory.StateCommitment = state.tip.StateCommitment +} + +// advance moves the durable state exactly as one anchored interactive call +// does: the consumption marker persists a new generation and the process +// output barrier advances the anchor revision once. +func (fixture *testFrostProductionSignerReadinessFixture) advance( + imageDigest [32]byte, + eventRoot [32]byte, +) { + fixture.transition( + fixture.tip.Generation+1, + fixture.tip.StateCommitment, + imageDigest, + eventRoot, + ) +} + +// fork re-anchors a different state image at the generation the signer is +// already on. That is a durable fork, not the session's own progress, and must +// always fail closed. +func (fixture *testFrostProductionSignerReadinessFixture) fork( + imageDigest [32]byte, + eventRoot [32]byte, +) { + fixture.transition( + fixture.tip.Generation, + fixture.tip.PreviousStateCommitment, + imageDigest, + eventRoot, + ) +} + +func (fixture *testFrostProductionSignerReadinessFixture) transition( + generation uint64, + previousStateCommitment [32]byte, + imageDigest [32]byte, + eventRoot [32]byte, +) { + next := fixture.durableState() + next.tip.Generation = generation + next.tip.PreviousStateCommitment = previousStateCommitment + next.tip.StateImageDigest = imageDigest + next.tip.StateCommitment = + frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + fixture.storeFingerprint, + generation, + previousStateCommitment, + imageDigest, + ) + next.tip.AnchorRevision = fixture.tip.AnchorRevision + 1 + next.tip.AnchorEventRoot = eventRoot + next.record.Checkpoint = frostNativeSignerCheckpointFromTip(next.tip) + next.record.Revision = next.tip.AnchorRevision + next.record.PreviousEventRoot = fixture.anchorStore.record.EventRoot + next.record.EventRoot = eventRoot + fixture.setDurableState(next) +} + +// TestFrostProductionSignerReadinessAcceptsAuthorizedGenerationAdvance pins the +// asymmetry the cached revalidation path depends on: the authorized signing +// window durably advances the native store it is guarding, so its own advance +// must not read as a readiness change, while a rollback still must. +func TestFrostProductionSignerReadinessAcceptsAuthorizedGenerationAdvance( + t *testing.T, +) { + fixture := newTestFrostProductionSignerReadinessFixture(t) + baseline := fixture.durableState() + + snapshot, err := fixture.readiness.verifyFrostProductionSignerReadiness( context.Background(), fixture.target, ) if err != nil { t.Fatal(err) } - if err := readiness.verifyFrostProductionSignerReadinessUnchanged( + if err := fixture.readiness.verifyFrostProductionSignerReadinessUnchanged( context.Background(), snapshot, ); err != nil { t.Fatalf("unmutated native signer state was rejected: [%v]", err) } - // Advance the durable state exactly as one anchored interactive call does: - // the consumption marker persists a new generation and the process output - // barrier advances the anchor revision once. - baselineTip, err := anchorBinding.readTip() - if err != nil { - t.Fatal(err) - } - baselineRecord := *anchorStore.record - advancedImage := [32]byte{0x73} - advancedCommitment := frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( - storeFingerprint, - 2, - stateCommitment, - advancedImage, - ) - advancedCheckpoint := FrostNativeSignerStateWitnessCheckpoint{ - StoreFingerprint: storeFingerprint, - Generation: 2, - PreviousStateCommitment: stateCommitment, - StateImageDigest: advancedImage, - StateCommitment: advancedCommitment, - } - advancedEventRoot := [32]byte{0x74} - advancedTip := *baselineTip - advancedTip.Generation = advancedCheckpoint.Generation - advancedTip.PreviousStateCommitment = advancedCheckpoint.PreviousStateCommitment - advancedTip.StateImageDigest = advancedCheckpoint.StateImageDigest - advancedTip.StateCommitment = advancedCheckpoint.StateCommitment - advancedTip.AnchorRevision = baselineTip.AnchorRevision + 1 - advancedTip.AnchorEventRoot = advancedEventRoot - anchorBinding.readTip = func() ( - *frostsigning.NativeTBTCSignerStateWitnessTip, - error, - ) { - copy := advancedTip - return ©, nil - } - anchorStore.record.Checkpoint = advancedCheckpoint - anchorStore.record.Revision = advancedTip.AnchorRevision - anchorStore.record.PreviousEventRoot = baselineRecord.EventRoot - anchorStore.record.EventRoot = advancedEventRoot - inventory.StateGeneration = advancedCheckpoint.Generation - inventory.PreviousStateCommitment = advancedCheckpoint.PreviousStateCommitment - inventory.StateImageDigest = advancedCheckpoint.StateImageDigest - inventory.StateCommitment = advancedCheckpoint.StateCommitment - - if err := readiness.verifyFrostProductionSignerReadinessUnchanged( + fixture.advance([32]byte{0x73}, [32]byte{0x74}) + advanced := fixture.durableState() + if err := fixture.readiness.verifyFrostProductionSignerReadinessUnchanged( context.Background(), snapshot, ); err != nil { @@ -881,27 +980,16 @@ func TestFrostProductionSignerReadinessAcceptsAuthorizedGenerationAdvance( } // A rolled-back native store is still a fatal readiness change. - anchorBinding.readTip = func() ( - *frostsigning.NativeTBTCSignerStateWitnessTip, - error, - ) { - copy := *baselineTip - return ©, nil - } - *anchorStore.record = baselineRecord - inventory.StateGeneration = checkpoint.Generation - inventory.PreviousStateCommitment = checkpoint.PreviousStateCommitment - inventory.StateImageDigest = checkpoint.StateImageDigest - inventory.StateCommitment = checkpoint.StateCommitment - snapshot.Inventory.StateGeneration = advancedCheckpoint.Generation - snapshot.Inventory.StateCommitment = advancedCheckpoint.StateCommitment + fixture.setDurableState(baseline) + snapshot.Inventory.StateGeneration = advanced.tip.Generation + snapshot.Inventory.StateCommitment = advanced.tip.StateCommitment snapshot.Inventory.PreviousStateCommitment = - advancedCheckpoint.PreviousStateCommitment - snapshot.Inventory.StateImageDigest = advancedCheckpoint.StateImageDigest - snapshot.Inventory.CurrentAnchorRevision = advancedTip.AnchorRevision + advanced.tip.PreviousStateCommitment + snapshot.Inventory.StateImageDigest = advanced.tip.StateImageDigest + snapshot.Inventory.CurrentAnchorRevision = advanced.tip.AnchorRevision snapshot.Inventory.RestartableRevisionHeadroom-- snapshot.Inventory.RestartableGenerationHeadroom-- - if err := readiness.verifyFrostProductionSignerReadinessUnchanged( + if err := fixture.readiness.verifyFrostProductionSignerReadinessUnchanged( context.Background(), snapshot, ); err == nil || !strings.Contains(err.Error(), "rolled back") { @@ -909,6 +997,123 @@ func TestFrostProductionSignerReadinessAcceptsAuthorizedGenerationAdvance( } } +// TestFrostProductionSignerReadinessAcceptsAdvanceBetweenStabilityReads covers +// the twice-read stability check inside a single verification, which the +// cached-snapshot comparison does not reach. +// +// The two reads are separated by a full anchored round trip to the external +// anchor service, so the signing session's own consumption marker routinely +// persists between them. Comparing the two reads by value reports that as +// "native signer state changed during readiness verification", and the pre-sign +// authorization monitor latches the first revalidation error permanently, so +// the guard cancels the very session it is guarding. +func TestFrostProductionSignerReadinessAcceptsAdvanceBetweenStabilityReads( + t *testing.T, +) { + fixture := newTestFrostProductionSignerReadinessFixture(t) + + // The initial reconciliation runs the same twice-read check. + fixture.reads = 0 + fixture.beforeRead = func(read int) { + if read == 2 { + fixture.advance([32]byte{0x75}, [32]byte{0x76}) + } + } + snapshot, err := fixture.readiness.verifyFrostProductionSignerReadiness( + context.Background(), + fixture.target, + ) + if err != nil { + t.Fatalf( + "an authorized durable advance between the two reconciliation reads was rejected: [%v]", + err, + ) + } + if snapshot.Inventory.StateGeneration != 2 || + snapshot.Inventory.CurrentAnchorRevision != 2 { + t.Fatalf( + "reconciliation did not adopt the fresher of its two reads: %+v", + snapshot.Inventory, + ) + } + + // And so does every revalidation the pre-sign monitor performs. + fixture.reads = 0 + fixture.beforeRead = func(read int) { + if read == 2 { + fixture.advance([32]byte{0x77}, [32]byte{0x78}) + } + } + if err := fixture.readiness.verifyFrostProductionSignerReadinessUnchanged( + context.Background(), + snapshot, + ); err != nil { + t.Fatalf( + "an authorized durable advance between the two revalidation reads aborted the session: [%v]", + err, + ) + } +} + +// TestFrostProductionSignerReadinessRejectsRollbackOrForkBetweenStabilityReads +// is the other half: widening the twice-read check to accept a monotone +// advance must not make it accept a rollback or an equal-generation fork. +func TestFrostProductionSignerReadinessRejectsRollbackOrForkBetweenStabilityReads( + t *testing.T, +) { + fixture := newTestFrostProductionSignerReadinessFixture(t) + baseline := fixture.durableState() + fixture.advance([32]byte{0x79}, [32]byte{0x7a}) + advanced := fixture.durableState() + + snapshot, err := fixture.readiness.verifyFrostProductionSignerReadiness( + context.Background(), + fixture.target, + ) + if err != nil { + t.Fatal(err) + } + + fixture.reads = 0 + fixture.beforeRead = func(read int) { + if read == 2 { + fixture.setDurableState(baseline) + } + } + err = fixture.readiness.verifyFrostProductionSignerReadinessUnchanged( + context.Background(), + snapshot, + ) + if err == nil || + !strings.Contains(err.Error(), "changed during readiness verification") || + !strings.Contains(err.Error(), "rolled back") { + t.Fatalf( + "a rollback between the two stability reads was accepted: [%v]", + err, + ) + } + + fixture.setDurableState(advanced) + fixture.reads = 0 + fixture.beforeRead = func(read int) { + if read == 2 { + fixture.fork([32]byte{0x7b}, [32]byte{0x7c}) + } + } + err = fixture.readiness.verifyFrostProductionSignerReadinessUnchanged( + context.Background(), + snapshot, + ) + if err == nil || + !strings.Contains(err.Error(), "changed during readiness verification") || + !strings.Contains(err.Error(), "forked at an unchanged generation") { + t.Fatalf( + "a fork between the two stability reads was accepted: [%v]", + err, + ) + } +} + func TestVerifyFrostNativeSignerInventoryUnchangedSeparatesAdvanceFromChange( t *testing.T, ) { From 682540292f249a034edd2d909bf92bab79322cca Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 17:37:44 -0400 Subject: [PATCH 74/93] docs(tbtc): state what the generation reservation does not cover The generation accounting around the pre-sign reservation documented two guarantees the signer does not provide. The per-operation enumeration counted three durable writes per anchored call and called that three generations. It is not: one generation is one committed state witness, and replace_state commits up to two per write - reconcile_pending_witness first commits a witness an earlier call prepared and left uncommitted after its rename won, then the write prepares, renames, and commits its own. A call whose sweep repairs such a carry-in reaches four (reconcile plus own commit in the sweep's first snapshot, the second snapshot the repair unblocked, the endpoint's own mutation), and the output barrier poisons the process at four. That interleaving predates this accounting and the constant stays at three: raising it widens the only check on a call mutating more than admission reserved, and would have to raise the frozen barrier ceiling with it. Say so instead of implying four cannot happen, in both places that enumerate the bound - the barrier's copy still carried the older, differently wrong version, claiming the sweep and the protected retirement share a snapshot. The amortization argument was also incomplete. "A failed persist advanced no generation" holds only before the rename; a persist that commits a carried-in witness and then fails, or commits its own and fails the post-commit revalidation, advances one while returning an error, and the repair call that follows still pays its own sweep, its repair snapshot, and its own mutation. Each post-rename persist failure therefore costs about one generation more than the amortized two reserve. Keep the reservation - nothing bounds how often a failing store can fail after rename, so a larger allowance would buy a false sense of coverage on a node that should fail closed - and document the residual with what an operator sees when it bites: request-taking calls blocked for want of certified generation window, new pre-sign work rejected, recovery by the offline anchor rotation window exhaustion always requires. No share is released and no replay gate weakens. Pin both gaps in tests, and pin the seat exclusion the amortized reservation still leaves: four local seats at the maximum batch means a five-seat holder in a hundred-seat wallet - an ordinary sortition result one seat over - is refused every standard 20-deposit sweep, topping out at 19 inputs, and a six-seat node at 16. Moving that needs the certified windows or the attempt limit to change, so the rejection now also reports the batch size this node's own seats could have signed; both levers are the operator's and neither is inferable from the other. Co-Authored-By: Claude Fable 5 --- ...native_tbtc_signer_state_anchor_barrier.go | 35 +++- ...e_tbtc_signer_state_anchor_barrier_test.go | 81 ++++++++ .../frost_native_signer_anchor_admission.go | 195 ++++++++++++++---- ...ost_native_signer_anchor_admission_test.go | 150 ++++++++++++++ 4 files changed, 415 insertions(+), 46 deletions(-) diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go index 4bc7ab8425..cdf2315ac0 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go @@ -38,10 +38,39 @@ const ( NativeTBTCSignerStateAnchorMaximumGenerationDistance uint64 = 4096 // NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation is the - // maximum durable Rust state writes one request-taking call may perform: - // one prepared-witness reconciliation, a sweep/repair snapshot (which - // also covers protected retirement), and the operation's own write. + // frozen maximum number of durable Rust generations one request-taking + // call may advance before the barrier treats the process as terminally + // poisoned. One generation is one committed state witness, and a call + // reaches three durable writes: the expiry-sweep prologue's snapshot, the + // second snapshot the sweep takes for a retirement its own repair + // unblocked, and the endpoint's own write (or, on Round2/Aggregate, the + // re-persist of a fail-closed marker in place of that write). + // + // This ceiling is deliberately one below what the engine can reach; see + // NativeTBTCSignerStateAnchorEngineReachableGenerationAdvancePerOperation. NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation uint64 = 3 + + // NativeTBTCSignerStateAnchorEngineReachableGenerationAdvancePerOperation + // records what the signer engine can actually reach in one request-taking + // call, which is one more than the frozen ceiling above. Every durable + // write goes through the engine's replace_state, which commits up to two + // witnesses: it first reconciles a witness an earlier call prepared and + // left uncommitted after that call's rename won, and then prepares, + // renames, and commits its own. Only the first of a call's three writes + // can find such a carried-in witness, so the reachable worst case is + // three writes plus one reconciliation. + // + // A call that reaches it exceeds the ceiling and poisons the barrier for + // the life of the process. That is a documented residual, not an assertion + // that it cannot happen: the interleaving needs an earlier persist that + // failed after its rename and before its commit, so it is fault-driven, + // and poisoning is fail-closed - request-taking calls then return + // ErrNativeTBTCSignerStateAnchorTerminal, no signature share is released, + // and no replay gate weakens. Raising the ceiling to four would widen the + // only check that catches a call mutating more state than the pre-sign + // admission accounting reserved for it, and is a protocol change to this + // frozen bound rather than a documentation fix. + NativeTBTCSignerStateAnchorEngineReachableGenerationAdvancePerOperation uint64 = 4 ) // NativeTBTCSignerStateAnchorCommitter durably commits a transition to the diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go index 89326ee8ff..8de25e9146 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go @@ -852,6 +852,87 @@ func TestNativeTBTCSignerStateAnchorBarrierPoisonsOversizedGenerationAdvance( } } +// TestNativeTBTCSignerStateAnchorBarrierPoisonsTheEngineReachableAdvance pins +// the residual documented at the two generation-advance constants. One +// request-taking call performs up to three durable writes, and the first of +// them can also commit a witness carried in from an earlier persist that +// failed after its rename, so the engine can reach one advance more than the +// frozen ceiling admits. That reachable case must keep poisoning the process +// rather than being quietly admitted: raising the ceiling to accept it widens +// the only check that catches an anchored call mutating more state than +// pre-sign admission reserved for it. +func TestNativeTBTCSignerStateAnchorBarrierPoisonsTheEngineReachableAdvance( + t *testing.T, +) { + if NativeTBTCSignerStateAnchorEngineReachableGenerationAdvancePerOperation != + NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation+1 { + t.Fatalf( + "engine-reachable advance [%d] is no longer one above the frozen "+ + "ceiling [%d]; the residual documented at both constants must be "+ + "restated before this changes", + NativeTBTCSignerStateAnchorEngineReachableGenerationAdvancePerOperation, + NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ) + } + + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{current: ¤t} + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: &initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: NativeTBTCSignerStateAnchorMaximumRevisionDistance, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := current + return ©, nil + }, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, + }, + ); err != nil { + t.Fatal(err) + } + + var discarded atomic.Int32 + // Reconcile a carried-in witness and commit its own inside the sweep's + // first snapshot, commit the second snapshot the repair unblocked, then + // commit the endpoint's own mutation. + _, err := executeNativeTBTCSignerStateAnchoredOutput( + "InteractiveSessionAbort", + func() { + current = testNativeTBTCSignerStateWitnessTip( + initial.Generation+ + NativeTBTCSignerStateAnchorEngineReachableGenerationAdvancePerOperation, + initial.StateCommitment, + ) + }, + func() ([]byte, error) { + t.Fatal("engine-reachable generation advance released native output") + return nil, nil + }, + func() { + discarded.Add(1) + }, + ) + if !errors.Is(err, ErrNativeTBTCSignerStateAnchorTerminal) || + discarded.Load() != 1 || committer.calls != 0 { + t.Fatalf( + "engine-reachable generation advance was not terminally rejected: [%v] discarded [%d] commits [%d]", + err, + discarded.Load(), + committer.calls, + ) + } +} + func TestNativeTBTCSignerStateAnchorBarrierAcceptsMaximumGenerationAdvance( t *testing.T, ) { diff --git a/pkg/tbtc/frost_native_signer_anchor_admission.go b/pkg/tbtc/frost_native_signer_anchor_admission.go index aa1d82dff3..1bc5063e51 100644 --- a/pkg/tbtc/frost_native_signer_anchor_admission.go +++ b/pkg/tbtc/frost_native_signer_anchor_admission.go @@ -7,46 +7,90 @@ import ( ) const ( - // One request-taking interactive signer call can durably advance the Rust - // generation up to three times: the expiry-sweep prologue takes one - // snapshot and, when a prior write left a repair pending, a second for the - // retirement that repair unblocked, then the endpoint persists its own - // mutation. Matching pending operations are covered by the sweep; - // nonmatching operations remain pending without another write. The - // process output barrier still advances the remote anchor revision only - // once for the call's final checkpoint. + // One request-taking interactive signer call performs up to three durable + // Rust writes: the expiry-sweep prologue takes one snapshot and, when a + // prior write left a repair pending, a second for the retirement that + // repair unblocked, then the endpoint persists its own mutation (or, on + // Round2/Aggregate, re-persists a fail-closed marker instead of it). + // Matching pending operations are covered by the sweep; nonmatching + // operations remain pending without another write. The process output + // barrier still advances the remote anchor revision only once for the + // call's final checkpoint. // - // This is a hard per-operation bound, not an accounting convenience: node - // startup installs it as the output barrier's MaximumStateGenerationAdvance - // PerOperation, and one call advancing further poisons the process. It must - // stay at the signer's own worst case for one in-flight request, which - // TBTC_SIGNER_STATE_WITNESS_ROTATION_TERMINAL_RECORD_RESERVATION states as - // three snapshots (six journal records, two per snapshot). + // Those three writes are not three generations. One generation is one + // committed state witness, and the signer's replace_state commits up to + // two per write: it first reconciles a witness an earlier call prepared + // and left uncommitted after that call's rename won, then prepares, + // renames, and commits its own. Only a call's first write can find such a + // carried-in witness, so a single call can reach FOUR advances - see + // frostsigning.NativeTBTCSignerStateAnchorEngineReachableGeneration + // AdvancePerOperation, which pins that reachable worst case. + // + // This constant stays at three anyway, and the gap is a documented + // residual rather than a claim that four cannot happen. Node startup + // installs it as the output barrier's MaximumStateGenerationAdvance + // PerOperation, whose own frozen protocol ceiling is three, and a call + // that advances four poisons the process terminally. The fourth advance + // needs an earlier persist that failed after its rename and before its + // commit, so it is fault-driven, and the poison is fail-closed: no share + // is released and no replay gate weakens. Raising it would widen the only + // check that catches an anchored call mutating more than this accounting + // reserved, and would have to raise the frozen barrier ceiling with it. frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall uint64 = 3 - // A workflow reservation cannot simply multiply the per-operation bound by - // its call count, because the third advance of any single call is always a - // repair and no workflow can pay it on every call. Both extra writes - the - // sweep's second snapshot and the Round2/Aggregate marker re-persist - are - // reached only when an earlier snapshot failed and left a pending - // persistence operation, and the marker re-persist excludes that call's own - // mutation because flushing the marker makes the replay gate reject the - // retry. A snapshot that failed before replacing the state image, or that - // replaced it and left its witness prepared and uncommitted, advanced no - // generation at all, so its call spent one of the two advances reserved - // here and the later repair lands the one it did not. The steady-state cost - // is therefore the shared sweep prologue plus the endpoint's own mutation. + // A workflow reservation cannot multiply the per-operation bound by its + // call count: at production parameters that reserves more of the certified + // proof window than exists, and excludes every operator holding three or + // more local seats from full-size batches. What actually bounds the + // consumption is the witness journal - generations advanced equals witness + // COMMITs, and every COMMIT belongs either to a persist that reached its + // rename or to the single witness that may have been carried into the + // workflow already prepared. The cost is therefore the number of persists + // that reach a rename, not the call count times the per-call ceiling. + // + // Two per call is the fault-free steady state, not a proven upper bound: + // the sweep prologue's snapshot plus the endpoint's own mutation. The + // sweep's second snapshot and the Round2/Aggregate marker re-persist both + // require a pending persistence operation that only a failed persist + // creates, and the marker re-persist excludes that call's own mutation + // because flushing the marker makes the replay gate reject the retry. A + // persist that fails BEFORE its rename advances nothing, so its call + // leaves an advance unspent and the later repair lands the one it did not. + // A persist that fails AFTER its rename does not leave that slack: it has + // either already committed its own witness (a failed post-commit + // revalidation) or left it for the next call's reconciliation to commit, + // while the repair call that follows still pays its own sweep snapshot, + // its repair snapshot, and its own mutation. Each post-rename persist + // failure therefore costs roughly one generation more than this reserves; + // the workflow-wide allowance below is what absorbs them. frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall uint64 = 2 - // Two interleavings outrun the amortized bound, and neither recurs. A - // snapshot that committed its witness and then failed its post-commit - // revalidation both spends an advance and leaves a repair pending; it - // cannot happen twice in a workflow because replace_state revalidates the - // store before replacing anything, so every later anchored call fails - // closed without advancing a generation. A witness prepared before - // admission and reconciled inside the workflow lands one further advance; - // prepare_witness refuses to prepare a second, so at most one is ever - // outstanding. This workflow-wide allowance covers both. + // The workflow-wide allowance on top of the amortized per-call cost. It + // covers one witness prepared before admission and reconciled inside the + // workflow - prepare_witness refuses to prepare a second, so at most one + // is ever outstanding - plus roughly two further post-rename persist + // failures, each of which overruns the amortized bound by about one + // generation. + // + // It is an allowance, not a proof, and the reservation it completes is not + // exact. Nothing in the signer bounds how many times a failing store can + // fail a persist after its rename, so a workflow that suffers more of them + // than this covers consumes more of the proof window than it reserved. The + // allowance is kept small deliberately: the slack that really absorbs + // faults is that most calls sweep nothing and spend one advance rather + // than two, and a node failing this many persists after rename has a + // failing store, where blocking is the correct outcome. + // + // The residual is fail-closed availability, never authority. The + // reservation only promises that the certified proof window can cover the + // whole workflow; overrunning it means that window can run out mid + // workflow. What an operator then sees is request-taking calls blocked + // with "the certified signer-generation window cannot cover its maximum + // advance; offline anchor rotation is required" and new pre-sign work + // rejected for want of unreserved headroom - on a node that was already + // failing every one of those persists. No share is released, no replay + // gate weakens, and the recovery is the offline anchor rotation that + // window exhaustion always requires. frostNativeSignerTerminalGenerationAdvancesPerWorkflow uint64 = 3 ) @@ -372,7 +416,11 @@ func (reservation *frostNativeSignerAnchorRevisionReservation) Release() { // mutates otherwise-unrelated state. The proof-window cost is the amortized // per-call generation bound times the anchored-call count plus the // workflow-wide terminal allowance, not the hard per-operation bound times -// that count: no workflow can pay a repair advance on every one of its calls. +// that count: no workflow can pay a repair advance on every one of its calls, +// because every repair advance consumes a pending operation that only an +// earlier failed persist creates. That makes the generation figure an +// amortized reservation with a bounded allowance rather than an exact upper +// bound - the constants above state precisely what it does not cover. // Attempt derivation, package/evidence handling, and authorization guards do // not persist Rust state. // @@ -387,6 +435,16 @@ func (reservation *frostNativeSignerAnchorRevisionReservation) Release() { // its attempts, so the errors below name the ceiling explicitly - an operator // that crosses it would otherwise have to infer it from a wallet that quietly // stops reaching its signing threshold on full-size batches. +// +// The ceiling of four is an accepted exclusion, not a solved problem. Five +// seats in a hundred-seat wallet is an ordinary sortition result for a large +// staker, one seat over the ceiling, and such a node is refused every standard +// 20-deposit sweep: it tops out at 19 inputs, and a six-seat node at 16. Those +// seats are lost to the wallet's signing threshold on full-size batches for as +// long as the node holds them. Moving the ceiling requires enlarging the +// certified restart windows or lowering the signing-attempt limit, both of +// which are protocol-level changes to constants this function only reads, so +// this accounting cannot make the exclusion smaller on its own. func frostPreSignMaximumAnchorCapacityCost( inputCount uint64, localSeatCount uint64, @@ -411,6 +469,7 @@ func frostPreSignMaximumAnchorCapacityCost( FrostNativeSignerAnchorMaximumHistoryEvents, frostPreSignAdmissibleLocalSeatAdvice( inputCount, + localSeatCount, maximumSigningAttempts, ), ) @@ -426,6 +485,7 @@ func frostPreSignMaximumAnchorCapacityCost( FrostNativeSignerAnchorMaximumHistoryProofEntries, frostPreSignAdmissibleLocalSeatAdvice( inputCount, + localSeatCount, maximumSigningAttempts, ), ) @@ -434,13 +494,17 @@ func frostPreSignMaximumAnchorCapacityCost( } // frostPreSignAdmissibleLocalSeatAdvice states the seat ceiling that produced a -// window rejection in operator-actionable terms. Every local seat above the -// ceiling is permanently excluded from batches of this size, and the wallet -// loses those seats toward its signing threshold, so this belongs in the error -// itself: the exclusion is otherwise visible only as a group that stops +// window rejection in operator-actionable terms, together with the largest +// batch this node's own seat count could still have signed. Every local seat +// above the ceiling is permanently excluded from batches of this size, and the +// wallet loses those seats toward its signing threshold, so this belongs in the +// error itself: the exclusion is otherwise visible only as a group that stops // assembling a threshold on full-size batches, with no node reporting a cause. +// Both numbers are reported because both levers are the operator's - shed +// seats, or sign smaller batches - and neither is inferable from the other. func frostPreSignAdmissibleLocalSeatAdvice( inputCount uint64, + localSeatCount uint64, maximumSigningAttempts uint64, ) string { admissibleSeats := frostPreSignMaximumAdmissibleLocalSeatCount( @@ -455,16 +519,61 @@ func frostPreSignAdmissibleLocalSeatAdvice( inputCount, ) } + admissibleInputs := frostPreSignMaximumAdmissibleInputCount( + localSeatCount, + maximumSigningAttempts, + ) + if admissibleInputs == 0 { + return fmt.Sprintf( + "at most [%d] local seats can sign [%d] inputs within the certified "+ + "restart windows, and [%d] local seats can sign no batch size at "+ + "all, so every local seat above that ceiling is excluded from "+ + "every batch of this size and is lost to the wallet's signing "+ + "threshold", + admissibleSeats, + inputCount, + localSeatCount, + ) + } return fmt.Sprintf( "at most [%d] local seats can sign [%d] inputs within the certified "+ - "restart windows, so every local seat above that ceiling is excluded "+ - "from every batch of this size and is lost to the wallet's signing "+ - "threshold", + "restart windows, and [%d] local seats can sign at most [%d] inputs, "+ + "so every local seat above that ceiling is excluded from every batch "+ + "of this size and is lost to the wallet's signing threshold", admissibleSeats, inputCount, + localSeatCount, + admissibleInputs, ) } +// frostPreSignMaximumAdmissibleInputCount reports the largest batch size this +// local seat count can still sign within both certified restart windows, or +// zero when no batch size can. Cost rises monotonically with the input count, +// so the first rejection ends the scan. +func frostPreSignMaximumAdmissibleInputCount( + localSeatCount uint64, + maximumSigningAttempts uint64, +) uint64 { + admissibleInputs := uint64(0) + for inputs := uint64(1); inputs <= + uint64(frostPreSignAuthorizationMaximumInputs); inputs++ { + cost, err := frostPreSignAnchoredWorkflowCost( + inputs, + localSeatCount, + maximumSigningAttempts, + ) + if err != nil || + cost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents || + cost.Generations > + FrostNativeSignerAnchorMaximumHistoryProofEntries { + break + } + admissibleInputs = inputs + } + return admissibleInputs +} + // frostPreSignMaximumAdmissibleLocalSeatCount reports the largest local seat // count whose complete worst-case workflow still fits both certified restart // windows at this input count, or zero when no seat count does. Cost rises diff --git a/pkg/tbtc/frost_native_signer_anchor_admission_test.go b/pkg/tbtc/frost_native_signer_anchor_admission_test.go index 8928be8069..d32dc4d32a 100644 --- a/pkg/tbtc/frost_native_signer_anchor_admission_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_admission_test.go @@ -4,6 +4,8 @@ import ( "context" "strings" "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" ) func TestFrostPreSignMaximumAnchorCapacityCost(t *testing.T) { @@ -96,6 +98,154 @@ func TestFrostPreSignMaximumAnchorCapacityCost(t *testing.T) { } } +// TestFrostPreSignGenerationReservationIsAmortizedNotWorstCase pins the two +// gaps the generation accounting deliberately leaves open, both of which are +// documented residuals at the constants rather than proven bounds. The +// reservation sits below the hard per-operation bound the output barrier +// enforces, and that bound itself sits below what one anchored call can +// actually reach in the signer engine. Closing either gap silently - by +// reserving the worst case again, or by widening the barrier - must be a +// deliberate decision that revisits the documentation with it. +func TestFrostPreSignGenerationReservationIsAmortizedNotWorstCase(t *testing.T) { + if frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall >= + frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall { + t.Fatalf( + "amortized reservation [%d] no longer sits below the per-operation "+ + "bound [%d]", + frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall, + frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall, + ) + } + if frostsigning. + NativeTBTCSignerStateAnchorEngineReachableGenerationAdvancePerOperation <= + frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall { + t.Fatalf( + "the engine-reachable per-call advance [%d] no longer exceeds the "+ + "per-operation bound [%d]; the four-advance residual documented "+ + "at both constants must stay stated or stay closed deliberately", + frostsigning. + NativeTBTCSignerStateAnchorEngineReachableGenerationAdvancePerOperation, + frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall, + ) + } + + for _, test := range []struct { + inputCount uint64 + localSeatCount uint64 + }{ + {inputCount: 1, localSeatCount: 1}, + {inputCount: frostPreSignAuthorizationMaximumInputs, localSeatCount: 4}, + } { + cost, err := frostPreSignAnchoredWorkflowCost( + test.inputCount, + test.localSeatCount, + signingAttemptsLimit, + ) + if err != nil { + t.Fatal(err) + } + expected := cost.Revisions* + frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall + + frostNativeSignerTerminalGenerationAdvancesPerWorkflow + if cost.Generations != expected { + t.Fatalf( + "[%d] inputs with [%d] seats reserved [%d] generations, not the "+ + "amortized [%d]", + test.inputCount, + test.localSeatCount, + cost.Generations, + expected, + ) + } + // The reservation is explicitly not the engine's per-call worst case + // on every call. Repeated persist failures after their rename can + // overrun it; the terminal allowance is what absorbs a few of them. + if cost.Generations >= cost.Revisions*frostsigning. + NativeTBTCSignerStateAnchorEngineReachableGenerationAdvancePerOperation { + t.Fatalf( + "[%d] inputs with [%d] seats reserved the per-call worst case "+ + "[%d] rather than an amortized bound", + test.inputCount, + test.localSeatCount, + cost.Generations, + ) + } + } +} + +// TestFrostPreSignSeatCeilingResidual pins the exclusion the amortized +// reservation still leaves in place. Four local seats is the ceiling at the +// standard maximum batch, so a five-seat holder in a hundred-seat wallet - an +// ordinary sortition result for a large staker, one seat over - is refused +// every 20-deposit sweep, and those seats are lost to the wallet's signing +// threshold. That is an accepted operational limit; moving it needs the +// certified windows or the attempt limit to change, so the numbers are pinned +// here and reported in the rejection itself. +func TestFrostPreSignSeatCeilingResidual(t *testing.T) { + if admissibleSeats := frostPreSignMaximumAdmissibleLocalSeatCount( + frostPreSignAuthorizationMaximumInputs, + signingAttemptsLimit, + ); admissibleSeats != 4 { + t.Fatalf( + "unexpected maximum-batch local seat ceiling [%d]", + admissibleSeats, + ) + } + for _, test := range []struct { + localSeatCount uint64 + admissibleInputs uint64 + servesStandardSweep bool + }{ + {localSeatCount: 4, admissibleInputs: 21, servesStandardSweep: true}, + {localSeatCount: 5, admissibleInputs: 19, servesStandardSweep: false}, + {localSeatCount: 6, admissibleInputs: 16, servesStandardSweep: false}, + } { + admissibleInputs := frostPreSignMaximumAdmissibleInputCount( + test.localSeatCount, + signingAttemptsLimit, + ) + if admissibleInputs != test.admissibleInputs { + t.Fatalf( + "[%d] local seats top out at [%d] inputs, expected [%d]", + test.localSeatCount, + admissibleInputs, + test.admissibleInputs, + ) + } + if servesStandardSweep := admissibleInputs >= + uint64(frostPreSignAuthorizationMaximumInputs); servesStandardSweep != + test.servesStandardSweep { + t.Fatalf( + "[%d] local seats serving the standard sweep is [%t], expected [%t]", + test.localSeatCount, + servesStandardSweep, + test.servesStandardSweep, + ) + } + } + + // The rejection has to name both levers the operator holds: how many seats + // this batch admits, and how large a batch these seats admit. Neither is + // inferable from the other, and the exclusion is otherwise visible only as + // a wallet that quietly stops reaching its signing threshold. + _, err := frostPreSignMaximumAnchorCapacityCost( + frostPreSignAuthorizationMaximumInputs, + 5, + signingAttemptsLimit, + ) + if err == nil { + t.Fatal("five local seats were admitted at the maximum batch size") + } + if !strings.Contains(err.Error(), "at most [4] local seats") || + !strings.Contains( + err.Error(), + "[5] local seats can sign at most [19] inputs", + ) || + !strings.Contains(err.Error(), "signing threshold") { + t.Fatalf("seat-ceiling rejection lost an operator lever: [%v]", err) + } +} + func TestFrostNativeSignerAnchorAdmissionController_AtomicallyReservesNearWarning( t *testing.T, ) { From adacf557d00f039efa91d3456533469a9c1c4263 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 17:38:20 -0400 Subject: [PATCH 75/93] test(tbtc): cover preserving FROST DKG reconciliation and caller admission Round 1 covered the destructive half of frostOrphanedDKGReconciler. The mirror-image half was still open, and it is the half that matters now: with the eager retirement removed from the DKG error exits, this reconciler is the only code that ever destroys a FROST wallet's durable material, so preserving a wallet that is registered or whose DKG is unresolved is what protects the key shares. No preserve-class test built a candidate with a LOCAL SESSION, so nothing pinned the archive side of preservation. A reconciler that skipped the preserve check whenever a candidate had a local session - archiving the Go session and retiring its native key group while the wallet is registered or its DKG is still unresolved - passed the entire `-tags frost_native ./pkg/tbtc/` suite. Cover all five combinations (registered, AwaitingResult, Challenge; with and without native inventory) and assert the ABSENCE of the destructive calls: no engine retirement, no archive, the session still cached, no retained key-group binding written, no anchor capacity charged. Each case also asserts the finalized snapshot WAS read, so a reconciler that returned before classifying anything cannot pass by doing nothing. The crash half-state test claimed "no native inventory means nothing to charge the anchor for" but asserted anchorAdmission.reserved == {}, which reconcile restores through its deferred Release() whether or not a reservation was made. Charging one revision per retirement regardless of native inventory left that assertion green. Count headroom reads instead, which distinguishes "never charged" from "charged and released", and say so in the comment. Finally, the boundary-before-execution ordering was pinned only at admitFrostDKGAttempt; the caller that has to honour its failure was untested because executeFrostDKGIfPossible first requires InteractiveSigningReady(), whose engine-provider registration takes an unexported pkg/frost/signing interface no other package can satisfy. Route that one gate through a package variable production never reassigns, and test the caller itself: a boundary that cannot be persisted must leave the event unhandled, must not reach anchor admission, and must not start the attempt goroutine. A positive control on the same fixture proves the goroutine probe fires when the attempt IS admitted, so the negative case is a real absence rather than a dead detector. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/frost_dkg_execution_frost_native.go | 17 +- .../frost_dkg_execution_frost_native_test.go | 214 ++++++++++++++++++ .../frost_dkg_retirement_frost_native_test.go | 185 ++++++++++++++- 3 files changed, 408 insertions(+), 8 deletions(-) diff --git a/pkg/tbtc/frost_dkg_execution_frost_native.go b/pkg/tbtc/frost_dkg_execution_frost_native.go index 80810c3cad..2c5a444e1e 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native.go @@ -30,6 +30,21 @@ var errFrostDKGSubmissionOutcomeUncertain = errors.New( "FROST DKG submission outcome is uncertain", ) +// frostDKGInteractiveSigningReady is the process-wide interactive-signing gate +// executeFrostDKGIfPossible evaluates before it may run an attempt. Production +// never reassigns it; it is a variable solely so tests in this package can +// reach the code BELOW the gate. +// +// That seam is needed because the real predicate additionally requires a +// registered interactive engine provider, and the provider registration takes +// an unexported pkg/frost/signing interface, so no package outside +// pkg/frost/signing can install one. Without the seam every unit test of +// executeFrostDKGIfPossible stops at this gate and the attempt-admission +// ordering below it - persist the DKG attempt boundary BEFORE anything can +// create a key package - would be exercised only through its extracted helper, +// never through the caller that has to honour its failure. +var frostDKGInteractiveSigningReady = frostsigning.InteractiveSigningReady + func executeFrostDKGIfPossible( ctx context.Context, node *node, @@ -64,7 +79,7 @@ func executeFrostDKGIfPossible( // without the ROAST readiness opt-in or a build containing the transition // producer, orchestration takes its static fallback and reaches the removed // coarse primitive. Refuse to run rather than create an unsignable wallet. - if !frostsigning.InteractiveSigningReady() { + if !frostDKGInteractiveSigningReady() { logger.Errorf( "FROST DKG with seed [0x%x] selected this operator, but the distributed "+ "DKG requires the complete interactive ROAST signing path (%s=true, "+ diff --git a/pkg/tbtc/frost_dkg_execution_frost_native_test.go b/pkg/tbtc/frost_dkg_execution_frost_native_test.go index a06f3bca8e..6736cd8dfb 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native_test.go @@ -14,9 +14,11 @@ import ( "testing" "time" + "github.com/keep-network/keep-common/pkg/persistence" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/frost/registry" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "github.com/keep-network/keep-core/pkg/generator" netlocal "github.com/keep-network/keep-core/pkg/net/local" "github.com/keep-network/keep-core/pkg/operator" "github.com/keep-network/keep-core/pkg/protocol/group" @@ -480,6 +482,218 @@ func TestAdmitFrostDKGAttempt_UnrecordedBoundaryBlocksAdmission(t *testing.T) { } } +// frostDKGAdmissionProbeChain reports the first moment the spawned DKG attempt +// goroutine touches the chain. executeFrostDKGIfPossible reads Signing() on its +// synchronous path but never BlockCounter(); the first BlockCounter() call comes +// from the attempt goroutine (its block-timeout context and the readiness +// announcement both need one), so closing a channel there is a live signal that +// the goroutine started. The positive control below proves the probe fires, so +// the negative case is a real absence rather than a broken detector. +type frostDKGAdmissionProbeChain struct { + Chain + spawned chan struct{} + spawnOnce sync.Once +} + +func (probe *frostDKGAdmissionProbeChain) BlockCounter() ( + chain.BlockCounter, + error, +) { + probe.spawnOnce.Do(func() { close(probe.spawned) }) + return probe.Chain.BlockCounter() +} + +type frostDKGParametersTestChain struct { + FrostDKGChain +} + +func (*frostDKGParametersTestChain) FrostDKGParameters() ( + *DKGParameters, + error, +) { + return &DKGParameters{SubmissionTimeoutBlocks: 1000}, nil +} + +type frostDKGAdmissionFixture struct { + node *node + frostChain FrostDKGChain + event *FrostDKGStartedEvent + memberIndexes []group.MemberIndex + groupSelectionResult *GroupSelectionResult + headroomReads *int + spawned chan struct{} +} + +// newFrostDKGAdmissionFixture assembles the smallest node that lets +// executeFrostDKGIfPossible run its whole synchronous prologue, so the caller's +// own handling of a failed attempt-boundary record is what the test observes. +func newFrostDKGAdmissionFixture( + t *testing.T, + seed *big.Int, + persistenceHandle persistence.ProtectedHandle, +) *frostDKGAdmissionFixture { + t.Helper() + registerFrostDKGReadinessTestEngine(t) + // Step past the interactive-signing gate; see the seam's comment in + // frost_dkg_execution_frost_native.go for why no test can satisfy the real + // predicate from outside pkg/frost/signing. + previousReadiness := frostDKGInteractiveSigningReady + frostDKGInteractiveSigningReady = func() bool { return true } + t.Cleanup(func() { + frostDKGInteractiveSigningReady = previousReadiness + }) + + localChain := Connect() + _, operatorPublicKey, err := localChain.OperatorKeyPair() + if err != nil { + t.Fatal(err) + } + operatorAddress := localChain.Signing().PublicKeyBytesToAddress( + operator.MarshalUncompressed(operatorPublicKey), + ) + + anchorAdmission, headroomReads := frostDKGTestAnchorAdmissionWithReadCount() + probeChain := &frostDKGAdmissionProbeChain{ + Chain: localChain, + spawned: make(chan struct{}), + } + + return &frostDKGAdmissionFixture{ + node: &node{ + chain: probeChain, + netProvider: netlocal.ConnectWithKey(operatorPublicKey), + walletRegistry: &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + walletStorage: newWalletStorage(persistenceHandle), + frostDKGRetirementBoundaries: make(map[string]uint64), + }, + frostGroupParameters: &GroupParameters{ + GroupSize: 3, + GroupQuorum: 2, + HonestThreshold: 2, + }, + frostNativeSignerAnchorAdmission: anchorAdmission, + protocolLatch: generator.NewProtocolLatch(), + }, + frostChain: &frostDKGParametersTestChain{}, + event: &FrostDKGStartedEvent{Seed: seed, BlockNumber: 101}, + memberIndexes: []group.MemberIndex{1}, + groupSelectionResult: &GroupSelectionResult{ + OperatorsIDs: chain.OperatorIDs{1, 2, 3}, + OperatorsAddresses: chain.Addresses{ + operatorAddress, + operatorAddress, + operatorAddress, + }, + }, + headroomReads: headroomReads, + spawned: probeChain.spawned, + } +} + +func (fixture *frostDKGAdmissionFixture) execute(ctx context.Context) bool { + return executeFrostDKGIfPossible( + ctx, + fixture.node, + fixture.frostChain, + fixture.event, + fixture.memberIndexes, + fixture.groupSelectionResult, + ) +} + +// TestExecuteFrostDKGIfPossible_UnrecordedAttemptBoundaryStopsTheAttempt pins +// the CALLER side of the boundary-before-execution ordering. Every later +// protection against a stale retirement snapshot rests on the attempt boundary +// being durable before any key package can exist, so a boundary that cannot be +// persisted must stop executeFrostDKGIfPossible itself: it must report the event +// unhandled, must not reserve anchor capacity, and must not start the attempt +// goroutine that would announce readiness and run the DKG. +func TestExecuteFrostDKGIfPossible_UnrecordedAttemptBoundaryStopsTheAttempt( + t *testing.T, +) { + fixture := newFrostDKGAdmissionFixture( + t, + big.NewInt(0x5eed01), + &failingFrostDKGAttemptPersistence{ + mockPersistenceHandle: &mockPersistenceHandle{}, + }, + ) + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + if fixture.execute(ctx) { + t.Fatal("an unrecorded attempt boundary was reported as handled") + } + if *fixture.headroomReads != 0 { + t.Fatalf( + "an unrecorded attempt boundary reached anchor admission: reads=[%d]", + *fixture.headroomReads, + ) + } + if fixture.node.frostNativeSignerAnchorAdmission.reserved != + (frostNativeSignerAnchorCapacity{}) { + t.Fatalf( + "anchor capacity was reserved for an unrecorded attempt: [%+v]", + fixture.node.frostNativeSignerAnchorAdmission.reserved, + ) + } + if len(fixture.node.walletRegistry.frostDKGRetirementBoundaries) != 0 { + t.Fatal("a failed boundary persistence left an in-memory boundary") + } + select { + case <-fixture.spawned: + t.Fatal( + "the FROST DKG attempt goroutine ran behind an unrecorded boundary", + ) + case <-time.After(500 * time.Millisecond): + } + if fixture.node.protocolLatch.IsExecuting() { + t.Fatal("an unrecorded attempt boundary still took the protocol latch") + } +} + +// TestExecuteFrostDKGIfPossible_RecordedAttemptBoundaryStartsTheAttempt is the +// positive control for the test above: the same fixture, the same call, only the +// attempt boundary now persists. It proves the fixture really does reach +// admission and really does start the attempt goroutine, so the absence of both +// in the failing case is caused by the unrecorded boundary and not by the +// prologue stopping somewhere earlier. +func TestExecuteFrostDKGIfPossible_RecordedAttemptBoundaryStartsTheAttempt( + t *testing.T, +) { + fixture := newFrostDKGAdmissionFixture( + t, + big.NewInt(0x5eed02), + &mockPersistenceHandle{}, + ) + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + if !fixture.execute(ctx) { + t.Fatal("a recorded attempt boundary did not start the attempt") + } + if *fixture.headroomReads != 1 { + t.Fatalf( + "the admitted attempt did not consult anchor admission exactly once: reads=[%d]", + *fixture.headroomReads, + ) + } + if len(fixture.node.walletRegistry.frostDKGRetirementBoundaries) != 1 { + t.Fatalf( + "the admitted attempt recorded no boundary: [%v]", + fixture.node.walletRegistry.frostDKGRetirementBoundaries, + ) + } + select { + case <-fixture.spawned: + case <-time.After(30 * time.Second): + t.Fatal("the admitted FROST DKG attempt goroutine never started") + } +} + func TestLowestLocalActiveMemberIndex(t *testing.T) { testCases := map[string]struct { local []group.MemberIndex diff --git a/pkg/tbtc/frost_dkg_retirement_frost_native_test.go b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go index 5a9d211545..c6d3ca6180 100644 --- a/pkg/tbtc/frost_dkg_retirement_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go @@ -636,13 +636,14 @@ func TestFrostOrphanedDKGReconcilerArchivesSessionWithoutNativeMaterial( 3, persistenceHandle, ) + anchorAdmission, headroomReads := frostDKGTestAnchorAdmissionWithReadCount() reconciler := &frostOrphanedDKGReconciler{ snapshotChain: &orphanedDKGSnapshotTestChain{ state: Idle, registered: make(map[[32]byte]bool), }, walletRegistry: registry, - anchorAdmission: orphanedDKGTestAnchorAdmission(), + anchorAdmission: anchorAdmission, retirementEngine: &orphanedDKGOrderedTestEngine{operations: &operations}, readInventory: func() ( *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, @@ -670,16 +671,171 @@ func TestFrostOrphanedDKGReconcilerArchivesSessionWithoutNativeMaterial( if len(registry.walletCache) != 0 { t.Fatal("half-retired orphan kept its local session") } - // No native inventory means nothing to charge the anchor for. - if reconciler.anchorAdmission.reserved != - (frostNativeSignerAnchorCapacity{}) { + // No native inventory means nothing to charge the anchor for, so the + // controller must never even be asked for headroom. Asserting on + // anchorAdmission.reserved instead would prove nothing: reconcile releases + // every reservation it takes before it returns, so that field reads zero + // whether or not a reservation was made. + if *headroomReads != 0 { t.Fatalf( - "session-only retirement charged anchor capacity: [%+v]", - reconciler.anchorAdmission.reserved, + "session-only retirement charged anchor capacity: reads=[%d]", + *headroomReads, ) } } +// TestFrostOrphanedDKGReconcilerPreservesLocalSessionsItMayNotRetire covers the +// PRESERVING half of the classification for candidates that own durable local +// material - the half that actually protects key shares. Since the eager +// retirement was dropped from the DKG error exits, this reconciler is the only +// thing that ever destroys a FROST wallet's material, so "registered" and +// "the DKG is still unresolved" must both leave a wallet completely untouched. +// +// Every case therefore asserts the ABSENCE of the destructive calls - no engine +// retirement, no archive, the session still in the cache, no retained key-group +// binding written, and no anchor capacity charged - and also asserts that the +// finalized snapshot WAS read, so a reconciler that bailed out before +// classifying anything cannot pass by doing nothing. +func TestFrostOrphanedDKGReconcilerPreservesLocalSessionsItMayNotRetire( + t *testing.T, +) { + walletID := frostBindingWalletID(t) + testCases := map[string]struct { + state DKGState + registered bool + hasNativeInventory bool + }{ + "registered wallet with a local session": { + state: Idle, + registered: true, + }, + "registered wallet with a local session and native inventory": { + state: Idle, + registered: true, + hasNativeInventory: true, + }, + "unresolved DKG with a local session": { + state: AwaitingResult, + }, + "unresolved DKG with a local session and native inventory": { + state: AwaitingResult, + hasNativeInventory: true, + }, + "challenged DKG with a local session and native inventory": { + state: Challenge, + hasNativeInventory: true, + }, + } + + for name, test := range testCases { + t.Run(name, func(t *testing.T) { + operations := make([]string, 0) + persistenceHandle := &orphanedDKGOrderedTestPersistence{ + mockPersistenceHandle: &mockPersistenceHandle{}, + operations: &operations, + } + registry := orphanedDKGTestSessionRegistry( + t, + 90, + frostBindingEvenKey, + 3, + persistenceHandle, + ) + registered := make(map[[32]byte]bool) + if test.registered { + registered[walletID] = true + } + snapshotChain := &orphanedDKGSnapshotTestChain{ + state: test.state, + registered: registered, + } + entries := make( + []frostsigning.NativeTBTCSignerRetainedKeyGroup, + 0, + 1, + ) + if test.hasNativeInventory { + entries = append( + entries, + frostsigning.NativeTBTCSignerRetainedKeyGroup{ + WalletID: walletID, + KeyGroup: frostBindingEvenKey, + ParticipantCount: 3, + }, + ) + } + anchorAdmission, headroomReads := + frostDKGTestAnchorAdmissionWithReadCount() + reconciler := &frostOrphanedDKGReconciler{ + snapshotChain: snapshotChain, + walletRegistry: registry, + anchorAdmission: anchorAdmission, + retirementEngine: &orphanedDKGOrderedTestEngine{ + operations: &operations, + }, + readInventory: func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, + ) { + return &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Entries: entries, + }, nil + }, + } + + target := orphanedDKGTestPoint() + if err := reconciler.reconcile( + context.Background(), + target, + map[[32]byte]struct{}{}, + ); err != nil { + t.Fatal(err) + } + // The wallet must have been classified, not skipped: without this + // the assertions below would also pass for a reconciler that + // returned before reading the finalized snapshot at all. + if len(snapshotChain.points) != 1 || + snapshotChain.points[0] != target { + t.Fatalf( + "the preserved wallet was never classified against the "+ + "finalized point: [%+v]", + snapshotChain.points, + ) + } + if len(operations) != 0 { + t.Fatalf( + "preserved FROST DKG material was retired or archived: [%v]", + operations, + ) + } + if len(registry.walletCache) != 1 { + t.Fatalf( + "preserved wallet lost its local session: [%d]", + len(registry.walletCache), + ) + } + if len(registry.retainedFrostKeyGroups) != 0 { + t.Fatalf( + "preserved wallet was recorded as an archived orphan: [%v]", + registry.retainedFrostKeyGroups, + ) + } + if len(persistenceHandle.archived) != 0 { + t.Fatalf( + "preserved wallet was archived on disk: [%v]", + persistenceHandle.archived, + ) + } + if *headroomReads != 0 { + t.Fatalf( + "preserved wallet charged retirement anchor capacity: reads=[%d]", + *headroomReads, + ) + } + }) + } +} + // TestFrostOrphanedDKGReconcilerRejectsDisagreeingMaterial pins the fail-closed // check guarding destructive reconciliation: if the native inventory and the Go // session describe the same wallet differently, the reconciler cannot know which @@ -862,15 +1018,30 @@ func orphanedDKGTestPoint() FrostPreSignFinality { } func orphanedDKGTestAnchorAdmission() *frostNativeSignerAnchorAdmissionController { + controller, _ := frostDKGTestAnchorAdmissionWithReadCount() + return controller +} + +// frostDKGTestAnchorAdmissionWithReadCount returns an admission controller and +// the number of times a reservation asked it for headroom. That count is the +// only way a test can tell "the anchor was never charged" from "the anchor was +// charged and the reservation released": every reservation is released before +// the workflow returns, so controller.reserved is back to zero either way. +func frostDKGTestAnchorAdmissionWithReadCount() ( + *frostNativeSignerAnchorAdmissionController, + *int, +) { + headroomReads := 0 return &frostNativeSignerAnchorAdmissionController{ readHeadroom: func( context.Context, ) (frostNativeSignerAnchorCapacity, error) { + headroomReads++ return frostNativeSignerAnchorCapacity{ Revisions: FrostNativeSignerAnchorRotationWarningHeadroom + 10, Generations: FrostNativeSignerAnchorRotationWarningHeadroom + 10, }, nil }, - } + }, &headroomReads } From f7fb16dfc5a08272b07185cce60d87fc8e2eddf8 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 17:50:01 -0400 Subject: [PATCH 76/93] style(tbtc): gofmt the retained transport test The HTTPS round-trip test left a misaligned struct field that the client-format CI job rejects. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/frost_primary_ethereum_transport_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tbtc/frost_primary_ethereum_transport_test.go b/pkg/tbtc/frost_primary_ethereum_transport_test.go index 0c9bb925c3..03049604f6 100644 --- a/pkg/tbtc/frost_primary_ethereum_transport_test.go +++ b/pkg/tbtc/frost_primary_ethereum_transport_test.go @@ -164,7 +164,7 @@ func TestFrostRetainedPeerLeafRotationDoesNotLatchSeparationFailure( }, }, }, - primaryPeers: map[[32]byte]frostTransportPeerIdentity{}, + primaryPeers: map[[32]byte]frostTransportPeerIdentity{}, retainedPeers: map[string]map[[32]byte]frostTransportPeerIdentity{ "retained-history-export": {}, }, From fcc4f08400395c0b0e9aaf9d67da6cf8b63b3709 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 30 Jul 2026 17:46:22 -0400 Subject: [PATCH 77/93] fix(tbtc): derive the FROST journal file bound and ride out unreachable dependencies Two follow-ups to the first round of FROST review fixes. The 8 MiB journal file bound was reachable by ordinary canonical history, so mirroring it onto the write path turned a latent post-restart failure into an immediate one. A batch carries the suffix of a single complete-history read, which reconcile() caps at 4096 mutations, and one mutation with every field at its widest accepted value marshals to 10248 bytes - encoding/json renders each [32]byte as an array of decimal numbers, and a maximum-length reason of control characters escapes six bytes to one. The full retained-wallet set admitted with 100-seat groups and then transitioned is 4096 ordinary mutations that serialize to about 13 MB in one batch, so a healthy mainnet node could hit the cap on write and, before the write bound existed, publish a file no later initialize() would reopen. Neither has a recovery path on an append-only journal. The bound is now derived from the mutation count and per-mutation width the producer already enforces, read and write still share the one constant, and a replayed batch is held to the same mutation count so the derivation stays exact. The authorization monitor still latched permanently on transient failures. The cached readiness path now waits for the journal lock, but the cache-miss route runs a whole readiness reconciliation - paginated canonical-history reads and an anchor read - every time the finalized point advances, and the monitor cancels the signing session on the first error revalidation returns. A single RPC timeout during a multi-minute signing window therefore killed a session nothing was wrong with. Revalidation now retries a dependency that is merely unreachable, under the caller's context, for a bounded budget. Only transport-level causes qualify: a raised quarantine, a rollback, a fork, a rewritten proposal or a moved readiness stamp is a comparison against data that was read successfully, so it returns on the first attempt and still latches. A pass never substitutes a stale answer for a fresh one, and an outage that outlives the budget is reported so the session still fails closed. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/frost_pre_sign_authorization.go | 131 ++++++ pkg/tbtc/frost_pre_sign_authorization_test.go | 230 ++++++++++- pkg/tbtc/frost_retained_group_journal.go | 58 ++- pkg/tbtc/frost_retained_group_journal_test.go | 381 ++++++++++++++++++ 4 files changed, 798 insertions(+), 2 deletions(-) diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index dc6e4f4786..7986b1bb03 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -7,11 +7,16 @@ import ( "encoding/binary" "encoding/hex" "encoding/json" + "errors" "fmt" "math/big" + stdnet "net" + "os" "reflect" "sort" "sync" + "syscall" + "time" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" @@ -28,6 +33,16 @@ const ( frostPreSignAuthorizationMaximumSeats = 100 frostPreSignAuthorizationMaximumInputs = 21 + // frostPreSignAuthorizationTransientRetryBudget bounds how long one + // revalidation pass keeps re-attempting after an authorization dependency + // proved unreachable, and frostPreSignAuthorizationTransientRetryBackoff + // paces those attempts. The budget is deliberately short relative to a + // signing window: past it the pass reports the failure and the monitor + // fails closed, because a dependency nobody can reach is a dependency + // nobody can check. + frostPreSignAuthorizationTransientRetryBudget = 15 * time.Second + frostPreSignAuthorizationTransientRetryBackoff = 250 * time.Millisecond + frostPreSignReservationProtocolDomain = "tbtc/p2tr-pre-signing-reservation/threshold-v1" frostPreSignSigningPolicyDomain = "tbtc/p2tr-pre-signing-policy/default-no-annex-51-seats-v1" frostPreSignChallengeIdentityDomain = "tbtc-p2tr-signature-fraud-authorization-v3" @@ -1575,6 +1590,13 @@ type thresholdFrostPreSignAuthorizationGate struct { localMemberIndexes []group.MemberIndex threshold int maximumAttempts uint64 + + // transientRetryBudget and transientRetryBackoff override the package + // defaults used by revalidate. They exist so tests can drive the + // unreachable-dependency path without waiting out the production budget; + // zero means the default. + transientRetryBudget time.Duration + transientRetryBackoff time.Duration } func newThresholdFrostPreSignAuthorizationGate( @@ -1777,9 +1799,118 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) authorize( return authorization, nil } +// isFrostPreSignTransientAuthorizationFailure reports whether err is a failure +// to reach an authorization dependency rather than an authorization fact +// observed at one. +// +// The distinction is load-bearing on the monitor path. +// frostPreSignAuthorizationMonitor.revalidate latches the first error it sees +// permanently and cancels the signing session, so an error that merely means +// "the RPC endpoint did not answer this second" would destroy a live, +// still-valid signing window. Every genuine authorization change - a raised +// quarantine, a rollback, an equal-generation fork, a rewritten proposal, a +// moved readiness stamp - is a comparison against data that was successfully +// read, and produces a plain error that never carries any of the causes below. +// So this can only ever classify a transport failure, never an authorization +// fact, and anything it does not recognize stays fatal. +// +// context.Canceled is deliberately excluded: it means the caller went away, and +// callers handle their own context separately. +func isFrostPreSignTransientAuthorizationFailure(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, os.ErrDeadlineExceeded) || + errors.Is(err, syscall.ECONNREFUSED) || + errors.Is(err, syscall.ECONNRESET) || + errors.Is(err, syscall.ECONNABORTED) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.EHOSTUNREACH) || + errors.Is(err, syscall.ENETUNREACH) || + errors.Is(err, syscall.ENETDOWN) { + return true + } + var operationError *stdnet.OpError + if errors.As(err, &operationError) { + return true + } + var resolverError *stdnet.DNSError + if errors.As(err, &resolverError) { + return true + } + var networkError stdnet.Error + if errors.As(err, &networkError) && networkError.Timeout() { + return true + } + return false +} + +// revalidate re-establishes every pinned authorization fact, retrying a +// dependency that is merely unreachable under the caller's context for a +// bounded budget. +// +// Revalidation reads the current finalized point, the pinned authorization +// state at two points, and - whenever the finalized point has advanced past the +// cached one - a complete signer-readiness reconciliation whose canonical +// history arrives over paginated network reads. Any of those can fail +// transiently, and the monitor latches the first failure permanently, so +// without this a single RPC timeout during a multi-minute signing window kills +// a session that nothing was wrong with. +// +// This does not make the gate permissive. Retrying never substitutes a stale +// answer for a fresh one: the pass returns success only when a whole +// revalidation actually succeeded. A genuine authorization change is +// deterministic and is not a transport failure, so it returns on the first +// attempt and still latches. If the dependency stays unreachable for the whole +// budget the failure is reported and the session still fails closed, because +// facts nobody can read are facts nobody can verify. func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( ctx context.Context, authorization *frostPreSignAuthorization, +) error { + if tfpsag == nil || ctx == nil { + return tfpsag.revalidateOnce(ctx, authorization) + } + budget := tfpsag.transientRetryBudget + if budget <= 0 { + budget = frostPreSignAuthorizationTransientRetryBudget + } + backoff := tfpsag.transientRetryBackoff + if backoff <= 0 { + backoff = frostPreSignAuthorizationTransientRetryBackoff + } + deadline := time.Now().Add(budget) + for attempt := 1; ; attempt++ { + err := tfpsag.revalidateOnce(ctx, authorization) + if err == nil { + return nil + } + if ctx.Err() != nil || + !isFrostPreSignTransientAuthorizationFailure(err) { + return err + } + if !time.Now().Before(deadline) { + return fmt.Errorf( + "FROST authorization dependency stayed unreachable across [%d] attempts over [%s]: [%w]", + attempt, + budget, + err, + ) + } + backoffTimer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + backoffTimer.Stop() + return err + case <-backoffTimer.C: + } + } +} + +func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidateOnce( + ctx context.Context, + authorization *frostPreSignAuthorization, ) error { // The signing wallet's identity is needed before the first readiness check // so a quarantined wallet is refused at the same boundary as every other diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go index 919da544f2..556c7c78dc 100644 --- a/pkg/tbtc/frost_pre_sign_authorization_test.go +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -7,8 +7,11 @@ import ( "encoding/hex" "fmt" "math/big" + stdnet "net" + "os" "strings" "sync" + "syscall" "testing" "time" @@ -27,6 +30,10 @@ type testFrostProductionAuthorizationReadiness struct { points []FrostPreSignFinality headrooms []uint64 activeQuarantines []frostRetainedGroupActiveQuarantine + // failingCalls is how many leading reconciliations fail with err before the + // verifier starts succeeding. Zero with a non-nil err means every call + // fails, which is the pre-existing behaviour. + failingCalls uint64 } func (readiness *testFrostProductionAuthorizationReadiness) verifyFrostProductionSignerReadiness( @@ -35,7 +42,8 @@ func (readiness *testFrostProductionAuthorizationReadiness) verifyFrostProductio ) (*frostProductionSignerReadinessSnapshot, error) { readiness.calls++ readiness.points = append(readiness.points, point) - if readiness.err != nil { + if readiness.err != nil && + (readiness.failingCalls == 0 || readiness.calls <= readiness.failingCalls) { return nil, readiness.err } headroom := uint64(FrostNativeSignerAnchorMaximumHistoryEvents) @@ -1330,3 +1338,223 @@ func TestThresholdFrostPreSignAuthorizationGate_RefusesQuarantinedSigningWallet( }) } } + +func TestIsFrostPreSignTransientAuthorizationFailure(t *testing.T) { + tests := map[string]struct { + err error + transient bool + }{ + "no failure": {}, + "observed authorization change": { + err: fmt.Errorf( + "FROST pre-sign authorization identity changed", + ), + }, + "caller cancelled": { + err: fmt.Errorf("readiness: [%w]", context.Canceled), + }, + "history read timed out": { + err: fmt.Errorf( + "FROST production signer is not authorization-ready: [%w]", + fmt.Errorf( + "cannot read retained-group history page [3]: [%w]", + context.DeadlineExceeded, + ), + ), + transient: true, + }, + "endpoint refused the connection": { + err: fmt.Errorf("anchor read: [%w]", &stdnet.OpError{ + Op: "dial", + Net: "tcp", + Err: syscall.ECONNREFUSED, + }), + transient: true, + }, + "read deadline elapsed": { + err: fmt.Errorf("anchor read: [%w]", os.ErrDeadlineExceeded), + transient: true, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if got := isFrostPreSignTransientAuthorizationFailure( + test.err, + ); got != test.transient { + t.Fatalf( + "classified [%v] as transient=[%t], want [%t]", + test.err, + got, + test.transient, + ) + } + }) + } +} + +// testFrostPreSignRevalidationFixture builds a gate and a matching finalized +// authorization whose revalidation passes, so a test only has to perturb the +// one dependency it is about. +func testFrostPreSignRevalidationFixture( + t *testing.T, + readiness *testFrostProductionAuthorizationReadiness, +) ( + *thresholdFrostPreSignAuthorizationGate, + *frostPreSignAuthorization, +) { + t.Helper() + unsignedTx, _ := buildTaprootKeyPathUnsignedTxForTest(t) + transaction := testFrostPreSignTransaction(t, unsignedTx) + proposal := completeTestFrostPreSignProposal(transaction) + relayFinality := FrostPreSignFinality{ + RelayTransactionHash: [32]byte{0x71}, + BlockNumber: 100, + BlockHash: [32]byte{0x72}, + AuthorizationSequence: [32]byte{31: 1}, + } + currentFinality := FrostPreSignFinality{ + BlockNumber: 101, + BlockHash: [32]byte{0x73}, + } + backend := &testFrostPreSignAuthorizationBackend{ + proposal: proposal, + currentFinality: ¤tFinality, + states: map[uint64]*FrostPreSignAuthorizationState{ + relayFinality.BlockNumber: completeTestFrostPreSignState( + proposal, + relayFinality, + ), + currentFinality.BlockNumber: completeTestFrostPreSignState( + proposal, + currentFinality, + ), + }, + } + gate := &thresholdFrostPreSignAuthorizationGate{ + backend: backend, + activationProfile: activationProfileForTestProposal(proposal), + storeBinding: testFrostDurableSessionStoreBinding(t), + productionReadiness: readiness, + transientRetryBudget: time.Second, + transientRetryBackoff: time.Millisecond, + } + authorization := &frostPreSignAuthorization{ + ActivationProfileHash: gate.activationProfile.ProfileHash, + AuthorizationID: proposal.Digest, + ReservationID: proposal.ReservationID, + VariantRoot: proposal.AuthorizationRoot, + TransactionHash: proposal.Transaction.TransactionHash, + Finality: relayFinality, + VariantSequence: frostPreSignVariantSequence(relayFinality), + proposal: proposal, + } + return gate, authorization +} + +// TestThresholdFrostPreSignAuthorizationGate_SurvivesTransientReadinessFailure +// pins the monitor path against a single unreachable dependency. The monitor +// latches the first error revalidation returns and cancels the signing session +// permanently, so a readiness reconciliation that times out - which the +// cache-miss route performs over paginated network reads every time the +// finalized point advances - must not be reported as an authorization change. +func TestThresholdFrostPreSignAuthorizationGate_SurvivesTransientReadinessFailure( + t *testing.T, +) { + readiness := &testFrostProductionAuthorizationReadiness{ + err: fmt.Errorf( + "cannot reconstruct complete FROST retained-group history: [%w]", + context.DeadlineExceeded, + ), + failingCalls: 2, + } + gate, authorization := testFrostPreSignRevalidationFixture(t, readiness) + if err := gate.revalidate( + context.Background(), + authorization, + ); err != nil { + t.Fatalf("a transient readiness failure killed the session: [%v]", err) + } + if readiness.calls != 3 { + t.Fatalf( + "expected the unreachable reconciliation to be retried, saw [%d] attempts", + readiness.calls, + ) + } +} + +// TestThresholdFrostPreSignAuthorizationGate_LatchesObservedReadinessChange +// keeps the gate fail-closed. An authorization fact that was actually read and +// disagrees is deterministic, so it must be reported on the first attempt +// rather than retried into a delayed cancellation. +func TestThresholdFrostPreSignAuthorizationGate_LatchesObservedReadinessChange( + t *testing.T, +) { + readiness := &testFrostProductionAuthorizationReadiness{ + err: fmt.Errorf( + "canonical FROST retained-group history rewrote, omitted, or reordered event [7]", + ), + } + gate, authorization := testFrostPreSignRevalidationFixture(t, readiness) + err := gate.revalidate(context.Background(), authorization) + if err == nil || + !strings.Contains(err.Error(), "rewrote, omitted, or reordered") { + t.Fatalf("observed readiness change was not reported: [%v]", err) + } + if readiness.calls != 1 { + t.Fatalf( + "observed readiness change was retried [%d] times", + readiness.calls, + ) + } +} + +// TestThresholdFrostPreSignAuthorizationGate_FailsClosedOnPersistentOutage +// bounds the tolerance. A dependency that stays unreachable is a dependency +// whose authorization facts cannot be checked at all, so the pass gives up and +// lets the monitor cancel the session. +func TestThresholdFrostPreSignAuthorizationGate_FailsClosedOnPersistentOutage( + t *testing.T, +) { + readiness := &testFrostProductionAuthorizationReadiness{ + err: fmt.Errorf("anchor read: [%w]", &stdnet.OpError{ + Op: "dial", + Net: "tcp", + Err: syscall.ECONNREFUSED, + }), + } + gate, authorization := testFrostPreSignRevalidationFixture(t, readiness) + gate.transientRetryBudget = 20 * time.Millisecond + err := gate.revalidate(context.Background(), authorization) + if err == nil || !strings.Contains(err.Error(), "stayed unreachable") { + t.Fatalf("persistent outage did not fail closed: [%v]", err) + } + if readiness.calls < 2 { + t.Fatalf( + "persistent outage was not retried at all, saw [%d] attempts", + readiness.calls, + ) + } +} + +// TestThresholdFrostPreSignAuthorizationGate_StopsRetryingWhenCallerCancels +// keeps a cancelled caller from being held for the retry budget; the monitor +// treats its own cancellation separately and must not latch on it. +func TestThresholdFrostPreSignAuthorizationGate_StopsRetryingWhenCallerCancels( + t *testing.T, +) { + readiness := &testFrostProductionAuthorizationReadiness{ + err: fmt.Errorf("anchor read: [%w]", os.ErrDeadlineExceeded), + } + gate, authorization := testFrostPreSignRevalidationFixture(t, readiness) + gate.transientRetryBudget = time.Minute + gate.transientRetryBackoff = time.Hour + ctx, cancel := context.WithCancel(context.Background()) + cancel() + start := time.Now() + if err := gate.revalidate(ctx, authorization); err == nil { + t.Fatal("cancelled revalidation reported success") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("cancelled revalidation waited out the backoff: [%s]", elapsed) + } +} diff --git a/pkg/tbtc/frost_retained_group_journal.go b/pkg/tbtc/frost_retained_group_journal.go index bb97c5c301..6d1fbff0bd 100644 --- a/pkg/tbtc/frost_retained_group_journal.go +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -39,10 +39,48 @@ const ( frostRetainedGroupLiftCertificatePrefix = "lift-certificate-" frostRetainedGroupJournalFileSuffix = ".json" frostRetainedGroupJournalTempSuffix = ".tmp" - frostRetainedGroupJournalMaximumFile = 8 * 1024 * 1024 frostRetainedGroupCanonicalDirectory = "canonical" frostRetainedGroupQuarantineDirectory = "quarantine" + // frostRetainedGroupJournalMaximumMutation bounds one serialized + // FrostRetainedGroupMutation. A mutation with every field at its widest + // accepted value marshals to just over 10 KiB: five event points, 100 + // operator IDs, six 32-byte digests - encoding/json renders a [32]byte as + // an array of decimal numbers, not base64 - and a + // frostRetainedGroupMaximumReasonBytes reason made of control characters, + // which JSON escapes six bytes to one. Nothing per-mutation is unbounded. + frostRetainedGroupJournalMaximumMutation = 12 * 1024 + + // frostRetainedGroupJournalMaximumFile bounds every journal file, on the + // read path and the write path alike. It is derived from the structural + // maxima this package already enforces rather than picked, because a cap + // below the largest legitimate file is not a safety property - it is a + // permanent wedge with no supported way forward. The write side would + // refuse the file the producer just built and the read side would refuse + // to reopen a file that had already been published and fsynced. + // + // The widest legitimate file is one canonical batch. reconcile() refuses a + // complete history above frostRetainedGroupMaximumMutations mutations and + // a batch carries the suffix of exactly one such read, so no batch holds + // more than that many mutations, each bounded by + // frostRetainedGroupJournalMaximumMutation; the extra mebibyte covers the + // batch header and the envelope wrapper. Every other persisted shape is + // strictly narrower - a state file holds at most + // frostRetainedGroupMaximumWallets wallet records, a quarantine batch is + // the same mutation count in a narrower wire form, and metadata, lift and + // checkpoint certificates are fixed-size records. + // + // 8 MiB, the previous value, was reachable by ordinary canonical history: + // the full retained-wallet set admitted with 100-seat signing groups and + // then transitioned - 4096 mutations in one batch - serializes to about + // 13 MB, so a healthy mainnet node could have published a batch it could + // never read back. See + // TestFrostRetainedGroupJournalMaximumFileCoversTheWidestLegalShapes and + // TestFrostRetainedGroupJournalPersistsAndReadsBackTheWidestLegalBatch, + // which measure the real worst case and fail if any input bound moves. + frostRetainedGroupJournalMaximumFile = frostRetainedGroupMaximumMutations* + frostRetainedGroupJournalMaximumMutation + 1024*1024 + frostRetainedGroupQuarantineMetadataSchema = "tbtc-frost-retained-group-quarantine-metadata/v3" frostRetainedGroupQuarantineBatchSchema = "tbtc-frost-retained-group-quarantine-batch/v3" frostRetainedGroupQuarantineStateSchema = "tbtc-frost-retained-group-quarantine-state/v3" @@ -2097,6 +2135,18 @@ func validateFrostRetainedGroupBatchMutationBounds( to FrostPreSignFinality, mutations []FrostRetainedGroupMutation, ) error { + // A batch carries the suffix of exactly one complete-history read, and + // reconcile() refuses a history above frostRetainedGroupMaximumMutations. + // Enforcing the same count when the batch is replayed keeps a durable file + // inside the geometry frostRetainedGroupJournalMaximumFile is derived from, + // so a corrupted file cannot buy unbounded replay work under the size cap. + if len(mutations) > frostRetainedGroupMaximumMutations { + return fmt.Errorf( + "batch carries [%d] mutations, above the per-batch limit [%d]", + len(mutations), + frostRetainedGroupMaximumMutations, + ) + } var previous FrostRetainedGroupEventPoint for index, mutation := range mutations { if !mutation.Point.valid() || mutation.Point.BlockNumber <= from.BlockNumber || @@ -2204,6 +2254,12 @@ func persistFrostRetainedGroupEnvelopeAt( // the next initialize() discovers it, leaving the journal permanently // unopenable with no signal at the point the file was produced. Fail closed // here instead, while the caller can still name the offending file. + // + // This bound is unreachable by valid input: the constant is derived from + // the mutation count and per-mutation width the producer already enforces, + // so only a corrupted or hostile payload can trip it. That is deliberate - + // a bound a healthy node can hit would wedge bootstrap rather than protect + // it, since the journal is append-only and offers no way to shed the file. if len(envelopeBytes) > frostRetainedGroupJournalMaximumFile { return fmt.Errorf( "FROST retained-group journal file [%s] is [%d] bytes, exceeding the readable maximum [%d]", diff --git a/pkg/tbtc/frost_retained_group_journal_test.go b/pkg/tbtc/frost_retained_group_journal_test.go index bdd915df14..fca4af122a 100644 --- a/pkg/tbtc/frost_retained_group_journal_test.go +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -4070,3 +4070,384 @@ func TestPersistFrostRetainedGroupEnvelopeAtRejectsUnreadableSize(t *testing.T) t.Fatalf("rejected journal file was still published: [%v]", err) } } + +// journalTestWidestMutation is one FrostRetainedGroupMutation with every field +// at the widest value the signed history source accepts: the largest canonical +// JSON block number, 0xff digests - encoding/json renders a [32]byte as an +// array of decimal numbers, so 0xff is the widest byte - 100 maximal operator +// IDs, and a full-length reason of NUL bytes, which JSON escapes six to one. +func journalTestWidestMutation() FrostRetainedGroupMutation { + digest := [32]byte{} + for index := range digest { + digest[index] = 0xff + } + publicKeyHash := [20]byte{} + for index := range publicKeyHash { + publicKeyHash[index] = 0xff + } + point := FrostRetainedGroupEventPoint{ + BlockNumber: frostRetainedGroupMaximumCanonicalJSONInteger, + BlockHash: digest, + TransactionHash: digest, + TransactionIndex: ^uint32(0), + LogIndex: ^uint32(0), + } + operatorIDs := make([]uint32, 100) + for index := range operatorIDs { + operatorIDs[index] = ^uint32(0) + } + return FrostRetainedGroupMutation{ + Point: point, + Kind: FrostRetainedGroupRecoveryRequiredMutation, + WalletID: digest, + WalletPublicKeyHash: publicKeyHash, + OperatorIDs: operatorIDs, + RetainedGroupHash: digest, + DkgResultHash: digest, + DkgSubmissionPoint: point, + DkgApprovalPoint: point, + CreationPoint: point, + BridgeRegistrationPoint: point, + QuarantineID: digest, + EvidenceHash: digest, + LiftCertificateHash: digest, + Reason: strings.Repeat( + "\x00", + frostRetainedGroupMaximumReasonBytes, + ), + } +} + +// TestFrostRetainedGroupJournalMaximumFileCoversTheWidestLegalShapes proves the +// shared read/write size bound is derived from the journal's own input limits +// and still has headroom over every shape the producer can publish. It fails if +// a field is added to a persisted record, if a per-field limit is raised, or if +// the file bound is lowered back under the geometry it must cover - all of +// which would turn the bound from a corruption guard into a bootstrap wedge. +func TestFrostRetainedGroupJournalMaximumFileCoversTheWidestLegalShapes( + t *testing.T, +) { + widestMutation, err := json.Marshal(journalTestWidestMutation()) + if err != nil { + t.Fatal(err) + } + if len(widestMutation) > frostRetainedGroupJournalMaximumMutation { + t.Fatalf( + "widest legal mutation is [%d] bytes, above the derived per-mutation bound [%d]", + len(widestMutation), + frostRetainedGroupJournalMaximumMutation, + ) + } + + digest := [32]byte{} + for index := range digest { + digest[index] = 0xff + } + header, err := json.Marshal(frostRetainedGroupJournalBatch{ + Schema: frostRetainedGroupJournalBatchSchema, + BindingHash: digest, + Sequence: frostRetainedGroupMaximumCanonicalJSONInteger, + From: FrostPreSignFinality{BlockNumber: 1, BlockHash: digest}, + To: FrostPreSignFinality{BlockNumber: 2, BlockHash: digest}, + PriorBatchRoot: digest, + Checksum: digest, + }) + if err != nil { + t.Fatal(err) + } + envelope, err := json.Marshal(frostRetainedGroupEnvelope{ + Payload: json.RawMessage(header), + Checksum: digest, + }) + if err != nil { + t.Fatal(err) + } + // A JSON array of N elements costs the elements plus N-1 separators and + // two brackets, so this is an exact upper bound on the widest batch file + // rather than a sampled one. + widestBatchFile := frostRetainedGroupMaximumMutations* + (frostRetainedGroupJournalMaximumMutation+1) + + len(envelope) + if widestBatchFile > frostRetainedGroupJournalMaximumFile { + t.Fatalf( + "widest legal canonical batch is [%d] bytes, above the journal file bound [%d]", + widestBatchFile, + frostRetainedGroupJournalMaximumFile, + ) + } + + widestWallet, err := json.Marshal(frostRetainedGroupWalletState{ + WalletID: digest, + WalletPublicKeyHash: [20]byte{0xff}, + OperatorIDs: journalTestWidestMutation().OperatorIDs, + RetainedGroupHash: digest, + Lifecycle: FrostRetainedGroupMovingFunds, + CreationPoint: journalTestWidestMutation().Point, + BridgeRegistrationPoint: journalTestWidestMutation().Point, + LifecyclePoint: journalTestWidestMutation().Point, + LastBridgePoint: journalTestWidestMutation().Point, + RegistryClosurePoint: journalTestWidestMutation().Point, + RegistryClosed: true, + }) + if err != nil { + t.Fatal(err) + } + widestStateFile := frostRetainedGroupMaximumWallets* + (len(widestWallet)+1) + len(envelope) + if widestStateFile > frostRetainedGroupJournalMaximumFile { + t.Fatalf( + "widest legal state file is [%d] bytes, above the journal file bound [%d]", + widestStateFile, + frostRetainedGroupJournalMaximumFile, + ) + } + t.Logf( + "widest mutation [%d] B; widest batch file [%d] B; widest state file [%d] B; bound [%d] B", + len(widestMutation), + widestBatchFile, + widestStateFile, + frostRetainedGroupJournalMaximumFile, + ) +} + +// journalTestWidestLegalBatchMutations builds the widest canonical mutation set +// a healthy mainnet node can produce: every retained wallet the journal admits, +// each with a full 100-seat signing group, followed by one lifecycle transition +// per wallet. The set is deliberately ordinary - no adversarial widths - and is +// asserted below to satisfy the journal's own semantic replay. +func journalTestWidestLegalBatchMutations() []FrostRetainedGroupMutation { + operatorIDs := make([]uint32, 100) + for index := range operatorIDs { + operatorIDs[index] = uint32(100000 + index) + } + reason := strings.Repeat("r", frostRetainedGroupMaximumReasonBytes) + admissions := make( + []FrostRetainedGroupMutation, + 0, + frostRetainedGroupMaximumWallets, + ) + lifecycles := make( + []FrostRetainedGroupMutation, + 0, + frostRetainedGroupMaximumWallets, + ) + for index := 0; index < frostRetainedGroupMaximumWallets; index++ { + identifier := uint64(index + 1) + walletID := [32]byte{0xa1} + binary.BigEndian.PutUint64(walletID[24:], identifier) + walletPublicKeyHash := [20]byte{0xa2} + binary.BigEndian.PutUint64(walletPublicKeyHash[12:], identifier) + blockNumber := uint64(index + 2) + blockHash := [32]byte{0xa3} + binary.BigEndian.PutUint64(blockHash[24:], blockNumber) + submissionTransaction := [32]byte{0xa4} + binary.BigEndian.PutUint64(submissionTransaction[24:], identifier) + admissionTransaction := [32]byte{0xa5} + binary.BigEndian.PutUint64(admissionTransaction[24:], identifier) + submission := FrostRetainedGroupEventPoint{ + BlockNumber: blockNumber, + BlockHash: blockHash, + TransactionHash: submissionTransaction, + TransactionIndex: 0, + LogIndex: 1, + } + approval := FrostRetainedGroupEventPoint{ + BlockNumber: blockNumber, + BlockHash: blockHash, + TransactionHash: admissionTransaction, + TransactionIndex: 1, + LogIndex: 1, + } + creation := approval + creation.LogIndex = 2 + registration := approval + registration.LogIndex = 3 + retainedGroupHash := [32]byte{0xa6} + binary.BigEndian.PutUint64(retainedGroupHash[24:], identifier) + dkgResultHash := [32]byte{0xa7} + binary.BigEndian.PutUint64(dkgResultHash[24:], identifier) + admissions = append(admissions, FrostRetainedGroupMutation{ + Point: registration, + Kind: FrostRetainedGroupAdmissionMutation, + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + OperatorIDs: append([]uint32{}, operatorIDs...), + RetainedGroupHash: retainedGroupHash, + DkgResultHash: dkgResultHash, + DkgSubmissionPoint: submission, + DkgApprovalPoint: approval, + CreationPoint: creation, + BridgeRegistrationPoint: registration, + Reason: reason, + }) + + lifecycleBlock := uint64(frostRetainedGroupMaximumWallets + index + 2) + lifecycleBlockHash := [32]byte{0xb3} + binary.BigEndian.PutUint64(lifecycleBlockHash[24:], lifecycleBlock) + lifecycleTransaction := [32]byte{0xb5} + binary.BigEndian.PutUint64(lifecycleTransaction[24:], identifier) + lifecycles = append(lifecycles, FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: lifecycleBlock, + BlockHash: lifecycleBlockHash, + TransactionHash: lifecycleTransaction, + TransactionIndex: 0, + LogIndex: 1, + }, + Kind: FrostRetainedGroupMovingFundsMutation, + WalletID: walletID, + WalletPublicKeyHash: walletPublicKeyHash, + Reason: reason, + }) + } + return append(admissions, lifecycles...) +} + +// TestFrostRetainedGroupJournalPersistsAndReadsBackTheWidestLegalBatch is the +// regression anchor for the write-side size bound. The batch it builds is +// ordinary canonical history - it replays cleanly through the journal's own +// semantic validator - and it serializes past the 8 MiB the bound used to +// carry. Under that value persist() refused a batch a healthy node had to +// write, and, before the write bound existed, the same file was published and +// then rejected by every later initialize(). Both are unrecoverable, so the +// widest legal batch must round-trip. +func TestFrostRetainedGroupJournalPersistsAndReadsBackTheWidestLegalBatch( + t *testing.T, +) { + const previousMaximumFile = 8 * 1024 * 1024 + + mutations := journalTestWidestLegalBatchMutations() + if len(mutations) > frostRetainedGroupMaximumMutations { + t.Fatalf( + "test batch of [%d] mutations is above the history limit [%d]", + len(mutations), + frostRetainedGroupMaximumMutations, + ) + } + from := FrostPreSignFinality{BlockNumber: 1, BlockHash: [32]byte{0xc1}} + to := FrostPreSignFinality{ + BlockNumber: uint64(2*frostRetainedGroupMaximumWallets + 2), + BlockHash: [32]byte{0xc2}, + } + state := frostRetainedGroupJournalState{ + Schema: frostRetainedGroupJournalStateSchema, + BindingHash: [32]byte{0xc3}, + CurrentPoint: from, + Wallets: []frostRetainedGroupWalletState{}, + } + candidate := cloneFrostRetainedGroupState(state) + if err := applyFrostRetainedGroupMutations( + &candidate, + mutations, + ); err != nil { + t.Fatalf("widest legal batch is not valid canonical history: [%v]", err) + } + + batch := frostRetainedGroupJournalBatch{ + Schema: frostRetainedGroupJournalBatchSchema, + BindingHash: state.BindingHash, + Sequence: state.BatchSequence + 1, + From: from, + To: to, + PriorBatchRoot: state.BatchRoot, + Mutations: mutations, + } + checksumPayload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + t.Fatal(err) + } + batch.Checksum = sha256.Sum256(checksumPayload) + if err := validateFrostRetainedGroupBatch(batch, state); err != nil { + t.Fatalf("widest legal batch failed batch validation: [%v]", err) + } + + measured, err := json.Marshal(&batch) + if err != nil { + t.Fatal(err) + } + if len(measured) <= previousMaximumFile { + t.Fatalf( + "widest legal batch is only [%d] bytes; it no longer exercises the [%d] byte bound it regressed against", + len(measured), + previousMaximumFile, + ) + } + + directory := t.TempDir() + if err := os.Chmod(directory, 0700); err != nil { + t.Fatal(err) + } + name := frostRetainedGroupBatchFileName(batch.Sequence) + if err := persistFrostRetainedGroupEnvelopeAt( + directory, + name, + &batch, + false, + ); err != nil { + t.Fatalf("widest legal batch could not be published: [%v]", err) + } + readBack := frostRetainedGroupJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt( + directory, + name, + &readBack, + ); err != nil { + t.Fatalf("published widest legal batch could not be read back: [%v]", err) + } + if len(readBack.Mutations) != len(mutations) || + readBack.Checksum != batch.Checksum { + t.Fatalf( + "widest legal batch did not round-trip: [%d] of [%d] mutations", + len(readBack.Mutations), + len(mutations), + ) + } +} + +// TestValidateFrostRetainedGroupBatchMutationBounds_EnforcesPerBatchLimit keeps +// a durable batch inside the geometry the file bound is derived from. A batch +// only ever carries the suffix of one complete-history read, which reconcile() +// caps at frostRetainedGroupMaximumMutations. +func TestValidateFrostRetainedGroupBatchMutationBounds_EnforcesPerBatchLimit( + t *testing.T, +) { + from := FrostPreSignFinality{BlockNumber: 1, BlockHash: [32]byte{0xd1}} + to := FrostPreSignFinality{ + BlockNumber: uint64(frostRetainedGroupMaximumMutations + 2), + BlockHash: [32]byte{0xd2}, + } + mutations := make( + []FrostRetainedGroupMutation, + frostRetainedGroupMaximumMutations+1, + ) + for index := range mutations { + blockNumber := uint64(index + 2) + blockHash := [32]byte{0xd3} + binary.BigEndian.PutUint64(blockHash[24:], blockNumber) + transactionHash := [32]byte{0xd4} + binary.BigEndian.PutUint64(transactionHash[24:], blockNumber) + mutations[index] = FrostRetainedGroupMutation{ + Point: FrostRetainedGroupEventPoint{ + BlockNumber: blockNumber, + BlockHash: blockHash, + TransactionHash: transactionHash, + LogIndex: 1, + }, + Kind: FrostRetainedGroupClosingMutation, + } + } + if err := validateFrostRetainedGroupBatchMutationBounds( + from, + to, + mutations, + ); err == nil || !strings.Contains(err.Error(), "per-batch limit") { + t.Fatalf("oversized durable batch was accepted: [%v]", err) + } + if err := validateFrostRetainedGroupBatchMutationBounds( + from, + to, + mutations[:frostRetainedGroupMaximumMutations], + ); err != nil { + t.Fatalf("largest legal durable batch was rejected: [%v]", err) + } +} From b38f8e40e9efdb4c4413ab39d0078d17cc5bdd28 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 11:21:30 -0400 Subject: [PATCH 78/93] fix(frost): stop a transport blip from terminally poisoning the signer anchor The state-anchor barrier authenticates the remote anchor before every request-taking FFI call, and until now any failure of that check poisoned the package-global barrier. Poisoning is process-lifetime - only a restart clears it - so an anchor-service redeploy, a TLS or DNS hiccup, a reset connection or a momentary load spike permanently disabled FROST signing on the node, over a fault that healed on its own. The single remote read behind that check had no retry anywhere either, so one lost packet was enough. The pre-operation check now classifies its failure. Reaching it means the local readback and trust head already matched the installed identity, no FFI symbol has been entered and no Rust generation has advanced, so a failure to REACH the service cannot have diverged anything: it takes the barrier's existing non-poisoning refusal, releases the mutex, and the next call re-reads and re-authenticates from scratch. Only a positively recognized transport cause qualifies - the same set pkg/tbtc's pre-sign monitor uses, duplicated rather than imported because pkg/tbtc imports this package. Everything else still poisons, because a stale, rolled-back, forked or unauthenticated anchor is a comparison against data that was read successfully and genuinely means it is unsafe to proceed. Both branches fail closed for the operation in hand; the only difference is whether the next one may try again. The post-operation commit is deliberately NOT declassified, and now says so. By then the native call has advanced durable Rust state, the CAS is not idempotent, and the anchor client has already exhausted the only safe disambiguation of an ambiguous CAS, so a transport failure there is indistinguishable from the rollback the anchor exists to catch. Releasing without poisoning would also gain nothing: the committed process tip would mismatch on the next call and poison there instead, one call later and without the original cause. Recovery for a local-ahead state is a restart under reconcileStartup, not a retry. The authenticated read itself now rides out a single blip. Up to three attempts with a doubling 50ms backoff share ONE existing request-timeout budget, so total wall time is exactly what a single attempt could already take. Only unreachability is retried - transport causes plus the two statuses a restarting service or an intermediary answers with while it cannot answer at all. Anything the service actually said is deterministic and still surfaces on the first attempt. The CAS is left alone. Finally, the poisoning is now observable at its source. Nothing was logged where it fires; the cause only reached a log at WARN from one caller per attempt. recordNativeTBTCSignerStateAnchorPoisoning is now the single place the barrier latches, logs one ERROR naming the cause and the remedy - the barrier re-reports the same cause on every later call, so a line per refusal would flood - and keeps the first cause rather than a later consequence. NativeTBTCSignerStateAnchorPoisoned exposes that state to health and admission callers through a lock-free mirror, because the barrier mutex is held across a whole signing operation and a health probe must not stall behind one. Co-Authored-By: Claude Fable 5 --- ...native_tbtc_signer_state_anchor_barrier.go | 216 +++++++++- ...e_tbtc_signer_state_anchor_barrier_test.go | 401 ++++++++++++++++++ pkg/tbtc/frost_native_signer_anchor_client.go | 109 ++++- .../frost_native_signer_anchor_client_test.go | 232 ++++++++++ 4 files changed, 950 insertions(+), 8 deletions(-) diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go index cdf2315ac0..931ed9069f 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go @@ -4,8 +4,14 @@ import ( "context" "errors" "fmt" + stdnet "net" + "os" "sync" + "sync/atomic" + "syscall" "time" + + "github.com/ipfs/go-log/v2" ) var ( @@ -23,6 +29,21 @@ var ( ) ) +// nativeTBTCSignerStateAnchorEventLogger is the only logging capability this +// file needs. It is narrowed to one method so the poisoning event can be +// observed in tests without standing up a whole logger. +type nativeTBTCSignerStateAnchorEventLogger interface { + Errorf(format string, args ...interface{}) +} + +// nativeTBTCSignerStateAnchorLogger names the one event this file emits: the +// transition into the terminal poisoned state. Nothing else here logs, because +// the barrier runs under the process-global signer mutation lock on every +// request-taking call and its refusals are already reported by the caller. +var nativeTBTCSignerStateAnchorLogger nativeTBTCSignerStateAnchorEventLogger = log.Logger( + "keep-frost-tbtc-signer-state-anchor", +) + const ( defaultNativeTBTCSignerStateAnchorTimeout = 15 * time.Second @@ -107,9 +128,27 @@ type NativeTBTCSignerStateAnchorBarrierConfig struct { Timeout time.Duration } +// nativeTBTCSignerStateAnchorPoisonRecord carries one poisoning cause to +// readers that must not take the barrier mutex. It is a struct rather than a +// bare error so a stored value is always non-nil and unambiguous. +type nativeTBTCSignerStateAnchorPoisonRecord struct { + cause error +} + type nativeTBTCSignerStateAnchorBarrier struct { mutex sync.Mutex + // poisonedSignal mirrors poisoned for callers that must observe the + // terminal state without taking the barrier mutex. That mutex is held for + // the whole of a request-taking call - the native call, the remote CAS, + // and the acknowledgement readback - so a health or attestation path that + // took it to read poisoned would stall behind a signing operation for the + // full anchor timeout. Every write happens under the mutex in + // recordNativeTBTCSignerStateAnchorPoisoning, so this can never report + // poisoned before the barrier itself is, and it becomes visible in the + // same critical section that poisons the barrier. + poisonedSignal atomic.Pointer[nativeTBTCSignerStateAnchorPoisonRecord] + installed bool poisoned error tip NativeTBTCSignerStateWitnessTip @@ -375,6 +414,40 @@ func beginNativeTBTCSignerStateAnchoredOperation( ctx, *readback, ); err != nil { + // This is the only remote call the barrier makes BEFORE the native + // call runs, so failing here cannot have diverged anything: no FFI + // request-taking symbol has been entered, no Rust generation has + // advanced, barrier.tip is untouched, and the local readback and trust + // head were already checked against the installed identity above. + // Releasing the mutex therefore leaves the barrier byte-for-byte as it + // was, and the very next call re-reads and re-authenticates everything + // from scratch. + // + // So a failure that only means "the anchor service did not answer this + // second" - a redeploy, a TLS or DNS hiccup, a reset connection, a + // momentary load spike - must not be terminal. Poisoning is + // process-lifetime and only a restart clears it, so spending it on a + // transport blip permanently disables FROST signing on this node for a + // fault that healed on its own. + // + // Anything this does not positively recognize as a transport failure + // still poisons. That is deliberate: a stale, rolled-back, forked, or + // unauthenticated anchor is a comparison against data that was + // successfully read, produces a plain error carrying none of the causes + // below, and genuinely means it is unsafe to proceed. + // + // Both branches fail closed for THIS operation - no native call runs + // and no signature share is released either way. The only difference is + // whether the next call may try again. + if isNativeTBTCSignerStateAnchorTransportFailure(err) { + barrier.mutex.Unlock() + return nil, fmt.Errorf( + "%w: request-taking operation [%s] is blocked because the state anchor could not be reached before any signer state changed: %v", + ErrNativeTBTCSignerStateAnchorUnavailable, + operation, + err, + ) + } return nil, poisonAndUnlockNativeTBTCSignerStateAnchor( barrier, fmt.Errorf("cannot authenticate pre-operation remote state anchor: %w", err), @@ -445,6 +518,39 @@ func (lease *nativeTBTCSignerStateAnchorLease) commit() error { *candidate, ) if err != nil { + // Unlike the pre-operation authentication in + // beginNativeTBTCSignerStateAnchoredOperation, a transport failure + // HERE is not declassified, and the distinction is the whole reason + // this comment exists. + // + // By this line the native call has already advanced durable Rust state + // past lease.expected, so local and remote may disagree in either + // direction and this node cannot tell which from the error alone. The + // CAS is not idempotent and the transport cannot say whether the + // service applied it: the anchor client already exhausts the only safe + // disambiguation - on an ambiguous CAS it takes a fresh signed read and + // either recovers the exact acknowledgement or refuses - so by the time + // an error surfaces here every recoverable outcome has been tried. A + // bare "the request timed out" is therefore indistinguishable from a + // genuine CAS conflict, which is exactly the rollback/fork case the + // anchor exists to catch. + // + // Releasing without poisoning would also not buy anything. barrier.tip + // still holds lease.expected while Rust sits at candidate, so the next + // call's pre-operation readback would mismatch the committed process + // tip and poison there instead - one call later, with the original + // cause lost. And lease.release poisons any lease that leaves + // incomplete, so a non-poisoning exit would have to claim completion + // for an operation that did not complete. + // + // The designed recovery for a local-ahead-of-remote state is a restart, + // not a retry: frostNativeSignerAnchorBinding.reconcileStartup + // re-authenticates the whole service history from the certified floor + // and, when local is ahead and carries the exact remote anchor + // reference it advanced from, proves the gap and catches the anchor up. + // That path only runs at startup, under the offline-certified floor, + // and after full history authentication - none of which this line can + // reproduce while holding the signer lock mid-operation. return lease.poison(fmt.Errorf( "cannot commit native tbtc signer state transition: %w", err, @@ -489,7 +595,7 @@ func (lease *nativeTBTCSignerStateAnchorLease) poison(cause error) error { if lease == nil || lease.barrier == nil { return fmt.Errorf("%w: %v", ErrNativeTBTCSignerStateAnchorTerminal, cause) } - lease.barrier.poisoned = cause + recordNativeTBTCSignerStateAnchorPoisoning(lease.barrier, cause) lease.completed = true return fmt.Errorf("%w: %v", ErrNativeTBTCSignerStateAnchorTerminal, cause) } @@ -499,10 +605,10 @@ func (lease *nativeTBTCSignerStateAnchorLease) release() { return } if !lease.completed { - lease.barrier.poisoned = fmt.Errorf( + recordNativeTBTCSignerStateAnchorPoisoning(lease.barrier, fmt.Errorf( "native signer operation [%s] escaped without anchor completion", lease.operation, - ) + )) } lease.barrier.mutex.Unlock() lease.barrier = nil @@ -512,11 +618,112 @@ func poisonAndUnlockNativeTBTCSignerStateAnchor( barrier *nativeTBTCSignerStateAnchorBarrier, cause error, ) error { - barrier.poisoned = cause + recordNativeTBTCSignerStateAnchorPoisoning(barrier, cause) barrier.mutex.Unlock() return fmt.Errorf("%w: %v", ErrNativeTBTCSignerStateAnchorTerminal, cause) } +// recordNativeTBTCSignerStateAnchorPoisoning is the single place the barrier +// becomes terminally poisoned. It must be called with the barrier mutex held. +// +// It logs at ERROR exactly once per poisoning rather than once per refusal: +// every later request-taking call re-reports the same latched cause, and an +// operator whose node is refusing every signing round would otherwise get one +// ERROR line per attempt for the life of the process. The line names the +// remedy because it is not guessable from the message alone - poisoned lives on +// the package-global barrier and nothing clears it in-process, so only a +// restart recovers, and a restart is safe: startup re-runs anchor +// reconciliation and any durable witness the failed operation carried in is +// self-consuming on reload. +// +// The first cause wins. Poisoning is latched, and the first failure is the one +// that explains what actually happened; a later escape or refusal is only its +// consequence. +func recordNativeTBTCSignerStateAnchorPoisoning( + barrier *nativeTBTCSignerStateAnchorBarrier, + cause error, +) { + if barrier == nil || barrier.poisoned != nil { + return + } + barrier.poisoned = cause + barrier.poisonedSignal.Store( + &nativeTBTCSignerStateAnchorPoisonRecord{cause: cause}, + ) + nativeTBTCSignerStateAnchorLogger.Errorf( + "FROST native tBTC signer state anchor is now terminally poisoned: "+ + "[%v]; every request-taking native signer call on this node is "+ + "refused from now on, and only restarting this process clears it", + cause, + ) +} + +// NativeTBTCSignerStateAnchorPoisoned reports the latched terminal anchor +// failure, or nil while the barrier is healthy. It is the supported way for +// health, admission, and attestation paths to observe that this node has +// stopped being able to sign, and it never blocks on an in-flight signer +// operation: it reads the lock-free mirror rather than taking the barrier +// mutex, which a request-taking call holds across its native call and remote +// commit. +// +// A nil result is not a promise that the next call will be admitted. The +// barrier can still refuse recoverably - it is not installed yet, a certified +// window is exhausted, or the anchor is momentarily unreachable - and those +// refusals are deliberately not terminal. +func NativeTBTCSignerStateAnchorPoisoned() error { + record := globalNativeTBTCSignerStateAnchorBarrier.poisonedSignal.Load() + if record == nil { + return nil + } + return fmt.Errorf( + "%w: %v", + ErrNativeTBTCSignerStateAnchorTerminal, + record.cause, + ) +} + +// isNativeTBTCSignerStateAnchorTransportFailure reports whether err is a +// failure to REACH the anchor service rather than something the anchor service +// said. +// +// This mirrors isFrostPreSignTransientAuthorizationFailure in pkg/tbtc, cause +// for cause and deliberately no wider. It is duplicated rather than shared +// because pkg/tbtc imports this package, so importing it back would be an +// import cycle, and because the two callers must stay in lockstep: both use it +// to keep a transport blip from latching a permanent refusal. +// +// context.Canceled is excluded for the same reason it is there: it means the +// caller went away, not that the dependency is unreachable. +func isNativeTBTCSignerStateAnchorTransportFailure(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, os.ErrDeadlineExceeded) || + errors.Is(err, syscall.ECONNREFUSED) || + errors.Is(err, syscall.ECONNRESET) || + errors.Is(err, syscall.ECONNABORTED) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.EHOSTUNREACH) || + errors.Is(err, syscall.ENETUNREACH) || + errors.Is(err, syscall.ENETDOWN) { + return true + } + var operationError *stdnet.OpError + if errors.As(err, &operationError) { + return true + } + var resolverError *stdnet.DNSError + if errors.As(err, &resolverError) { + return true + } + var networkError stdnet.Error + if errors.As(err, &networkError) && networkError.Timeout() { + return true + } + return false +} + func validateNativeTBTCSignerStateWitnessTip( tip *NativeTBTCSignerStateWitnessTip, ) error { @@ -657,6 +864,7 @@ func resetNativeTBTCSignerStateAnchorBarrierForTest() { defer barrier.mutex.Unlock() barrier.installed = false barrier.poisoned = nil + barrier.poisonedSignal.Store(nil) barrier.tip = NativeTBTCSignerStateWitnessTip{} barrier.readTip = nil barrier.readTrustHead = nil diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go index 8de25e9146..7ea160b99c 100644 --- a/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go @@ -3,8 +3,13 @@ package signing import ( "context" "errors" + "fmt" + "net" + "os" + "strings" "sync" "sync/atomic" + "syscall" "testing" "time" ) @@ -1132,6 +1137,402 @@ func TestValidateNativeTBTCSignerAcknowledgedTipRequiresNextRevisionInPinnedEpoc } } +// installTestNativeTBTCSignerStateAnchorBarrier installs the barrier over a +// tip the caller keeps mutating, which is how these tests stand in for Rust +// advancing its durable state. +func installTestNativeTBTCSignerStateAnchorBarrier( + t *testing.T, + initial *NativeTBTCSignerStateWitnessTip, + current *NativeTBTCSignerStateWitnessTip, + committer NativeTBTCSignerStateAnchorCommitter, +) { + t.Helper() + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + InitialTip: initial, + ExpectedAnchorBindingHash: [32]byte{10}, + MinimumAnchorServiceEpoch: 1, + MaximumAnchorRevisionDistance: 4096, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := *current + return ©, nil + }, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: committer, + }, + ); err != nil { + t.Fatal(err) + } +} + +// testNativeTBTCSignerStateAnchorUnreachable is what the anchor binding hands +// back when the service could not be reached at all: the binding's own wrapper +// around the client's wrapper around a dial failure. +func testNativeTBTCSignerStateAnchorUnreachable() error { + return fmt.Errorf( + "cannot read native signer remote anchor: %w", + fmt.Errorf("native signer anchor request failed: %w", &net.OpError{ + Op: "dial", + Net: "tcp", + Err: syscall.ECONNREFUSED, + }), + ) +} + +// TestNativeTBTCSignerStateAnchorBarrierDoesNotPoisonOnPreOperationTransportFailure +// pins that an unreachable anchor before the native call is recoverable. This +// check runs ahead of every request-taking call and nothing has been mutated +// when it fails, so treating a redeploy or a reset connection as terminal would +// disable FROST signing on the node for the life of the process over a fault +// that healed by itself. +func TestNativeTBTCSignerStateAnchorBarrierDoesNotPoisonOnPreOperationTransportFailure( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + candidate := testNativeTBTCSignerStateWitnessTip(2, initial.StateCommitment) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{ + current: ¤t, + acknowledge: func( + candidate NativeTBTCSignerStateWitnessTip, + ) NativeTBTCSignerStateWitnessTip { + candidate.AnchorRevision = 2 + candidate.AnchorEventRoot = [32]byte{13} + candidate.AnchorAcknowledgementDigest = [32]byte{14} + return candidate + }, + } + installTestNativeTBTCSignerStateAnchorBarrier( + t, &initial, ¤t, committer, + ) + + committer.verifyErr = testNativeTBTCSignerStateAnchorUnreachable() + invoked := false + released := false + discarded := false + _, err := executeNativeTBTCSignerStateAnchoredOutput( + "InteractiveRound1", + func() { + invoked = true + current = candidate + }, + func() ([]byte, error) { + released = true + return []byte("must-not-escape"), nil + }, + func() { + discarded = true + }, + ) + if errors.Is(err, ErrNativeTBTCSignerStateAnchorTerminal) { + t.Fatalf("an unreachable anchor terminally poisoned the barrier: %v", err) + } + if !errors.Is(err, ErrNativeTBTCSignerStateAnchorUnavailable) { + t.Fatalf("an unreachable anchor was not refused recoverably: %v", err) + } + if invoked || released || discarded || current != initial { + t.Fatal("a refused operation reached the native call") + } + if err := NativeTBTCSignerStateAnchorPoisoned(); err != nil { + t.Fatalf("a recoverable refusal was reported as terminal: %v", err) + } + + // The anchor comes back and the very next call proceeds normally. + committer.verifyErr = nil + payload, err := executeNativeTBTCSignerStateAnchoredOutput( + "InteractiveRound1", + func() { + current = candidate + }, + func() ([]byte, error) { + return []byte("sentinel-output"), nil + }, + func() {}, + ) + if err != nil { + t.Fatalf("barrier did not recover after the anchor answered again: %v", err) + } + if string(payload) != "sentinel-output" { + t.Fatal("recovered operation did not release its output") + } +} + +// TestNativeTBTCSignerStateAnchorBarrierPoisonsPreOperationAnchorDisagreement +// is the other half of the classification: anything the anchor actually +// answered - a rollback, a fork, an unauthenticated tip - is a fact about the +// anchor and stays terminal. +func TestNativeTBTCSignerStateAnchorBarrierPoisonsPreOperationAnchorDisagreement( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{current: ¤t} + installTestNativeTBTCSignerStateAnchorBarrier( + t, &initial, ¤t, committer, + ) + + committer.verifyErr = errors.New( + "local native signer state tip differs from the authenticated remote anchor", + ) + if _, err := beginNativeTBTCSignerStateAnchoredOperation( + "InteractiveRound1", + ); !errors.Is(err, ErrNativeTBTCSignerStateAnchorTerminal) { + t.Fatalf("a forked remote anchor was not treated as terminal: %v", err) + } + poisoned := NativeTBTCSignerStateAnchorPoisoned() + if !errors.Is(poisoned, ErrNativeTBTCSignerStateAnchorTerminal) { + t.Fatalf("poisoned barrier was not reported by its accessor: %v", poisoned) + } + if !strings.Contains(poisoned.Error(), "authenticated remote anchor") { + t.Fatalf("poisoning cause was not carried to the accessor: %v", poisoned) + } + committer.verifyErr = nil + if _, err := beginNativeTBTCSignerStateAnchoredOperation( + "InteractiveRound2", + ); !errors.Is(err, ErrNativeTBTCSignerStateAnchorTerminal) { + t.Fatalf("poisoned barrier allowed another operation: %v", err) + } +} + +// TestNativeTBTCSignerStateAnchorBarrierPoisonsUnreachableAnchorAfterMutation +// pins the deliberate asymmetry with the pre-operation path. Once the native +// call has advanced durable Rust state, an unreachable anchor is +// indistinguishable from a lost CAS: local and remote may disagree, and +// recovery is a restart under startup reconciliation, not a retry. This must +// stay terminal even though the identical error before the call does not. +func TestNativeTBTCSignerStateAnchorBarrierPoisonsUnreachableAnchorAfterMutation( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + candidate := testNativeTBTCSignerStateWitnessTip(2, initial.StateCommitment) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{ + current: ¤t, + err: testNativeTBTCSignerStateAnchorUnreachable(), + } + installTestNativeTBTCSignerStateAnchorBarrier( + t, &initial, ¤t, committer, + ) + + _, err := executeNativeTBTCSignerStateAnchoredOutput( + "InteractiveRound2", + func() { + current = candidate + }, + func() ([]byte, error) { + return []byte("must-not-escape"), nil + }, + func() {}, + ) + if !errors.Is(err, ErrNativeTBTCSignerStateAnchorTerminal) { + t.Fatalf( + "an unreachable anchor after a durable mutation was not terminal: %v", + err, + ) + } + if poisoned := NativeTBTCSignerStateAnchorPoisoned(); !errors.Is( + poisoned, + ErrNativeTBTCSignerStateAnchorTerminal, + ) { + t.Fatalf("post-mutation poisoning was not reported: %v", poisoned) + } +} + +// TestNativeTBTCSignerStateAnchorPoisonedAccessorDoesNotBlockOnAnOperation +// pins that health and admission callers can read the terminal state while a +// signing operation holds the barrier. That operation owns the barrier mutex +// across its native call and its remote commit, so an accessor that took the +// mutex would stall a health probe for the whole anchor timeout. +func TestNativeTBTCSignerStateAnchorPoisonedAccessorDoesNotBlockOnAnOperation( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + if err := NativeTBTCSignerStateAnchorPoisoned(); err != nil { + t.Fatalf("an uninstalled barrier was reported as poisoned: %v", err) + } + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{current: ¤t} + installTestNativeTBTCSignerStateAnchorBarrier( + t, &initial, ¤t, committer, + ) + + lease, err := beginNativeTBTCSignerStateAnchoredOperation("VerifySignatureShare") + if err != nil { + t.Fatal(err) + } + observed := make(chan error, 1) + go func() { + observed <- NativeTBTCSignerStateAnchorPoisoned() + }() + blocked := false + var poisoned error + select { + case poisoned = <-observed: + case <-time.After(5 * time.Second): + blocked = true + } + // The lease must be surrendered before any assertion fails, or the failure + // would leave the process-global barrier locked and wedge every later test. + if err := lease.commit(); err != nil { + lease.release() + t.Fatal(err) + } + lease.release() + if blocked { + t.Fatal("poison accessor blocked behind an in-flight signer operation") + } + if poisoned != nil { + t.Fatalf("healthy barrier was reported as poisoned: %v", poisoned) + } +} + +type testNativeTBTCSignerStateAnchorLogRecorder struct { + mutex sync.Mutex + lines []string +} + +func (recorder *testNativeTBTCSignerStateAnchorLogRecorder) Errorf( + format string, + args ...interface{}, +) { + recorder.mutex.Lock() + defer recorder.mutex.Unlock() + recorder.lines = append(recorder.lines, fmt.Sprintf(format, args...)) +} + +func (recorder *testNativeTBTCSignerStateAnchorLogRecorder) recorded() []string { + recorder.mutex.Lock() + defer recorder.mutex.Unlock() + return append([]string{}, recorder.lines...) +} + +// TestNativeTBTCSignerStateAnchorPoisoningIsLoggedOnceWithItsRemedy pins that +// the poisoning is visible where it happens. Without it the cause is only ever +// seen at WARN by whichever caller happened to attempt the operation, while the +// terminal state itself - the thing that keeps the node down until a restart - +// is never named. It has to be exactly one line: the barrier re-reports the +// same latched cause on every later call, so logging per refusal would flood. +func TestNativeTBTCSignerStateAnchorPoisoningIsLoggedOnceWithItsRemedy( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + recorder := &testNativeTBTCSignerStateAnchorLogRecorder{} + previousLogger := nativeTBTCSignerStateAnchorLogger + nativeTBTCSignerStateAnchorLogger = recorder + t.Cleanup(func() { nativeTBTCSignerStateAnchorLogger = previousLogger }) + + initial := testNativeTBTCSignerStateWitnessTip(1, [32]byte{2}) + current := initial + committer := &testNativeTBTCSignerStateAnchorCommitter{current: ¤t} + installTestNativeTBTCSignerStateAnchorBarrier( + t, &initial, ¤t, committer, + ) + + committer.verifyErr = errors.New("startup native signer local state forks the authenticated remote anchor") + for attempt := 0; attempt < 4; attempt++ { + if _, err := beginNativeTBTCSignerStateAnchoredOperation( + "InteractiveRound1", + ); !errors.Is(err, ErrNativeTBTCSignerStateAnchorTerminal) { + t.Fatalf("attempt [%d] was not refused terminally: %v", attempt, err) + } + } + + lines := recorder.recorded() + if len(lines) != 1 { + t.Fatalf( + "expected exactly one poisoning line across four refusals, got [%d]: %v", + len(lines), + lines, + ) + } + if !strings.Contains(lines[0], "forks the authenticated remote anchor") { + t.Fatalf("poisoning line did not name its cause: %q", lines[0]) + } + if !strings.Contains(lines[0], "restart") { + t.Fatalf("poisoning line did not name its remedy: %q", lines[0]) + } +} + +func TestIsNativeTBTCSignerStateAnchorTransportFailure(t *testing.T) { + tests := []struct { + name string + err error + transport bool + }{ + {"nil", nil, false}, + { + "deadline exceeded", + fmt.Errorf("verify failed: %w", context.DeadlineExceeded), + true, + }, + { + "io deadline exceeded", + fmt.Errorf("verify failed: %w", os.ErrDeadlineExceeded), + true, + }, + {"connection refused", testNativeTBTCSignerStateAnchorUnreachable(), true}, + { + "connection reset", + fmt.Errorf("verify failed: %w", syscall.ECONNRESET), + true, + }, + { + "name resolution failure", + fmt.Errorf("verify failed: %w", &net.DNSError{ + Err: "no such host", + Name: "anchor.example", + }), + true, + }, + { + "caller cancelled", + fmt.Errorf("verify failed: %w", context.Canceled), + false, + }, + { + "anchor answered a fork", + errors.New( + "local native signer state tip differs from the authenticated remote anchor", + ), + false, + }, + { + "anchor answered a rollback", + errors.New( + "authenticated native signer anchor record is incomplete", + ), + false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := isNativeTBTCSignerStateAnchorTransportFailure( + test.err, + ); got != test.transport { + t.Fatalf("expected transport [%v], got [%v]", test.transport, got) + } + }) + } +} + func testNativeTBTCSignerStateWitnessTip( generation uint64, previousCommitment [32]byte, diff --git a/pkg/tbtc/frost_native_signer_anchor_client.go b/pkg/tbtc/frost_native_signer_anchor_client.go index 3f05f79f03..3511fab80f 100644 --- a/pkg/tbtc/frost_native_signer_anchor_client.go +++ b/pkg/tbtc/frost_native_signer_anchor_client.go @@ -11,6 +11,7 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" + "errors" "fmt" "io" "mime" @@ -35,8 +36,36 @@ const ( frostNativeSignerAnchorMaximumClockSkew = 5 * time.Second frostNativeSignerAnchorDefaultAcknowledgementLifetime = 30 * time.Second frostNativeSignerAnchorMaximumAcknowledgementLifetime = 30 * time.Second + + // frostNativeSignerAnchorReadAttempts bounds how many times one + // authenticated anchor read may be re-issued when the service could not be + // reached. It is small on purpose: the point is to ride out a single blip, + // not to wait out an outage, and the attempts share one request-timeout + // budget rather than each getting their own. + frostNativeSignerAnchorReadAttempts = 3 + + // frostNativeSignerAnchorReadRetryBackoff is the pause before the second + // attempt and doubles for the third. Two pauses plus three attempts stay + // well inside the smallest sensible request timeout, so the backoff never + // becomes the reason a budget is exhausted. + frostNativeSignerAnchorReadRetryBackoff = 50 * time.Millisecond ) +// frostNativeSignerAnchorStatusError reports the HTTP status an anchor request +// was answered with. It exists so an idempotent read can tell a service that is +// temporarily unable to answer from one that answered something wrong; its +// message is the one this failure has always carried. +type frostNativeSignerAnchorStatusError struct { + statusCode int +} + +func (err *frostNativeSignerAnchorStatusError) Error() string { + return fmt.Sprintf( + "native signer anchor returned HTTP status [%d]", + err.statusCode, + ) +} + // FrostNativeSignerAnchorClientConfig contains runtime transport material and // the complete identity extracted from an independently authenticated // activation manifest. OnlinePublicKeySPKI and ClientPrivateKey are copied by @@ -911,8 +940,81 @@ func frostNativeSignerAnchorReferenceFromAcknowledgement( } } +// readLocked performs one authenticated anchor read, retrying a small bounded +// number of times when the service could not be reached at all. +// +// Every native signer operation is preceded by one of these reads, and its +// failure is expensive out of proportion to its cause: the state-anchor barrier +// treats an unauthenticated anchor as a reason to refuse the operation, so a +// single connection reset during an anchor-service redeploy takes down a +// signing round that nothing was actually wrong with. A read is idempotent - +// it is a fresh nonce-bound signed request that mutates nothing here and +// nothing at the service - so the blip can simply be ridden out. +// +// The whole loop runs inside ONE requestTimeout budget, so this never lengthens +// how long a caller can wait: total wall time is exactly what a single attempt +// could already take. That is also why the retries are worth having, because +// the failures they cover - refused connection, reset connection, unresolvable +// name, 503 from a restarting service - come back in milliseconds and leave +// nearly the whole budget for another try, while a genuine hang consumes the +// budget on the first attempt and correctly gets no second one. +// +// Only a failure to reach the service is retried. Anything the service actually +// answered - a bad signature, a nonce or digest mismatch, an absent stream, a +// non-monotonic acknowledgement - is a fact about the anchor, is deterministic, +// and must surface on the first attempt rather than be papered over. func (client *FrostNativeSignerAnchorClient) readLocked( ctx context.Context, +) (*FrostNativeSignerCheckpointAcknowledgement, error) { + budgetContext, cancel := context.WithTimeout(ctx, client.requestTimeout) + defer cancel() + backoff := frostNativeSignerAnchorReadRetryBackoff + for attempt := 1; ; attempt++ { + acknowledgement, err := client.readOnceLocked(budgetContext) + if err == nil { + return acknowledgement, nil + } + if attempt >= frostNativeSignerAnchorReadAttempts || + budgetContext.Err() != nil || + !isFrostNativeSignerAnchorRetryableReadFailure(err) { + return nil, err + } + backoffTimer := time.NewTimer(backoff) + select { + case <-budgetContext.Done(): + backoffTimer.Stop() + return nil, err + case <-backoffTimer.C: + } + backoff *= 2 + } +} + +// isFrostNativeSignerAnchorRetryableReadFailure reports whether a failed +// authenticated read may be retried within its budget. +// +// The transport cases are exactly isFrostPreSignTransientAuthorizationFailure's +// - the anchor was not reached, so nothing was learned about it. The status +// cases are the two an HTTP intermediary or a restarting service produces while +// it is unable to answer at all; every other status is the service answering +// something wrong and stays fatal on the first attempt. +func isFrostNativeSignerAnchorRetryableReadFailure(err error) bool { + if err == nil { + return false + } + if isFrostPreSignTransientAuthorizationFailure(err) { + return true + } + var statusError *frostNativeSignerAnchorStatusError + if errors.As(err, &statusError) { + return statusError.statusCode == http.StatusTooManyRequests || + statusError.statusCode >= http.StatusInternalServerError + } + return false +} + +func (client *FrostNativeSignerAnchorClient) readOnceLocked( + ctx context.Context, ) (*FrostNativeSignerCheckpointAcknowledgement, error) { nonce, err := client.randomBytes32() if err != nil { @@ -1475,10 +1577,9 @@ func (client *FrostNativeSignerAnchorClient) postWithResponseLimit( return nil, true, fmt.Errorf("native signer anchor response size is invalid") } if response.StatusCode != http.StatusOK { - return nil, true, fmt.Errorf( - "native signer anchor returned HTTP status [%d]", - response.StatusCode, - ) + return nil, true, &frostNativeSignerAnchorStatusError{ + statusCode: response.StatusCode, + } } mediaType, parameters, err := mime.ParseMediaType(response.Header.Get("Content-Type")) if err != nil || mediaType != "application/json" || len(parameters) != 0 { diff --git a/pkg/tbtc/frost_native_signer_anchor_client_test.go b/pkg/tbtc/frost_native_signer_anchor_client_test.go index 6e4cc94095..18ba170ec5 100644 --- a/pkg/tbtc/frost_native_signer_anchor_client_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_client_test.go @@ -14,6 +14,8 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" + "syscall" "testing" "time" @@ -853,9 +855,186 @@ func TestFrostNativeSignerAnchorClientRejectsSignedAbsentStream(t *testing.T) { } } +// TestFrostNativeSignerAnchorClientReadRidesOutOneTransientFailure pins that a +// single unanswerable read does not surface at all. Every native signer +// operation is gated on this read, so a blip that reaches the caller costs a +// whole signing round. +func TestFrostNativeSignerAnchorClientReadRidesOutOneTransientFailure( + t *testing.T, +) { + for _, status := range []int{ + http.StatusServiceUnavailable, + http.StatusBadGateway, + http.StatusInternalServerError, + http.StatusTooManyRequests, + } { + t.Run(http.StatusText(status), func(t *testing.T) { + environment := newTestFrostNativeSignerAnchorEnvironment(t, "normal") + defer environment.server.Close() + + environment.readFaults.armOnce(status) + record, err := environment.client.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ) + if err != nil { + t.Fatalf("a single [%d] read failure was not ridden out: %v", status, err) + } + if record == nil || record.Revision == 0 { + t.Fatal("retried read did not return the authenticated record") + } + if requests := environment.readFaults.count(); requests != 2 { + t.Fatalf("expected exactly one retry, observed [%d] read requests", requests) + } + }) + } +} + +// TestFrostNativeSignerAnchorClientReadRetryIsBoundedAndOnlyForUnreachability +// pins the two limits that keep the retry honest: it stops after a small fixed +// number of attempts, and it never re-issues a read the service actually +// answered, because such an answer is a deterministic fact about the anchor. +func TestFrostNativeSignerAnchorClientReadRetryIsBoundedAndOnlyForUnreachability( + t *testing.T, +) { + tests := []struct { + name string + status int + expectedRequests int + }{ + { + "unreachable service stops at the attempt bound", + http.StatusServiceUnavailable, + frostNativeSignerAnchorReadAttempts, + }, + { + "rejected client is not retried", + http.StatusUnauthorized, + 1, + }, + { + "rejected request is not retried", + http.StatusBadRequest, + 1, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + environment := newTestFrostNativeSignerAnchorEnvironment(t, "normal") + defer environment.server.Close() + + environment.readFaults.armPersistent(test.status) + started := time.Now() + if _, err := environment.client.ReadFrostNativeSignerStateWitnessAnchor( + context.Background(), + ); err == nil { + t.Fatalf("read against a [%d] service unexpectedly succeeded", test.status) + } + if elapsed := time.Since(started); elapsed > environment.client.requestTimeout { + t.Fatalf( + "read retries overran the configured request timeout [%s]: took [%s]", + environment.client.requestTimeout, + elapsed, + ) + } + if requests := environment.readFaults.count(); requests != test.expectedRequests { + t.Fatalf( + "expected [%d] read requests, observed [%d]", + test.expectedRequests, + requests, + ) + } + }) + } +} + +// TestIsFrostNativeSignerAnchorRetryableReadFailure pins exactly which failures +// an idempotent read may re-issue. Everything the service answered has to stay +// fatal on the first attempt. +func TestIsFrostNativeSignerAnchorRetryableReadFailure(t *testing.T) { + tests := []struct { + name string + err error + retryable bool + }{ + {"nil", nil, false}, + { + "deadline exceeded", + fmt.Errorf("request failed: %w", context.DeadlineExceeded), + true, + }, + { + "connection refused", + fmt.Errorf("request failed: %w", syscall.ECONNREFUSED), + true, + }, + { + "connection reset", + fmt.Errorf("request failed: %w", syscall.ECONNRESET), + true, + }, + { + "dial failure", + fmt.Errorf("request failed: %w", &net.OpError{ + Op: "dial", + Err: syscall.EHOSTUNREACH, + }), + true, + }, + { + "name resolution failure", + fmt.Errorf("request failed: %w", &net.DNSError{ + Err: "no such host", + Name: "anchor.example", + }), + true, + }, + { + "service unavailable", + &frostNativeSignerAnchorStatusError{ + statusCode: http.StatusServiceUnavailable, + }, + true, + }, + { + "too many requests", + &frostNativeSignerAnchorStatusError{ + statusCode: http.StatusTooManyRequests, + }, + true, + }, + { + "unauthorized", + &frostNativeSignerAnchorStatusError{ + statusCode: http.StatusUnauthorized, + }, + false, + }, + { + "caller cancelled", + fmt.Errorf("request failed: %w", context.Canceled), + false, + }, + { + "invalid response signature", + fmt.Errorf("native signer anchor read response signature is invalid"), + false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := isFrostNativeSignerAnchorRetryableReadFailure( + test.err, + ); got != test.retryable { + t.Fatalf("expected retryable [%v], got [%v]", test.retryable, got) + } + }) + } +} + type testFrostNativeSignerAnchorEnvironment struct { server *httptest.Server client *FrostNativeSignerAnchorClient + readFaults *testFrostNativeSignerAnchorReadFaults expected FrostNativeSignerStateWitnessCheckpoint candidate FrostNativeSignerStateWitnessCheckpoint proof []frostsigning.NativeTBTCSignerStateWitnessProofEntry @@ -863,6 +1042,53 @@ type testFrostNativeSignerAnchorEnvironment struct { historyTarget FrostNativeSignerStateWitnessAnchorReference } +// testFrostNativeSignerAnchorReadFaults counts read requests and answers the +// first ones with injected HTTP statuses. Its zero value injects nothing, so +// environments that do not arm it behave exactly as before. +type testFrostNativeSignerAnchorReadFaults struct { + mutex sync.Mutex + statuses []int + persistent int + requests int +} + +// armOnce answers the next reads with the given statuses and everything after +// them normally. +func (faults *testFrostNativeSignerAnchorReadFaults) armOnce(statuses ...int) { + faults.mutex.Lock() + defer faults.mutex.Unlock() + faults.statuses = append([]int{}, statuses...) + faults.persistent = 0 + faults.requests = 0 +} + +// armPersistent answers every read with the given status. +func (faults *testFrostNativeSignerAnchorReadFaults) armPersistent(status int) { + faults.mutex.Lock() + defer faults.mutex.Unlock() + faults.statuses = nil + faults.persistent = status + faults.requests = 0 +} + +func (faults *testFrostNativeSignerAnchorReadFaults) next() int { + faults.mutex.Lock() + defer faults.mutex.Unlock() + faults.requests++ + if len(faults.statuses) > 0 { + status := faults.statuses[0] + faults.statuses = faults.statuses[1:] + return status + } + return faults.persistent +} + +func (faults *testFrostNativeSignerAnchorReadFaults) count() int { + faults.mutex.Lock() + defer faults.mutex.Unlock() + return faults.requests +} + func newTestFrostNativeSignerAnchorEnvironment( t *testing.T, mode string, @@ -913,10 +1139,15 @@ func newTestFrostNativeSignerAnchorEnvironment( var historyTarget FrostNativeSignerStateWitnessAnchorReference var historyEvents []FrostNativeSignerStateWitnessAnchorHistoryEvent advanceCalls := 0 + readFaults := &testFrostNativeSignerAnchorReadFaults{} handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "application/json") switch request.URL.Path { case "/anchor/read": + if status := readFaults.next(); status != 0 { + http.Error(writer, "injected read fault", status) + return + } payload, _ := io.ReadAll(request.Body) readRequest := frostNativeSignerAnchorReadRequest{} if err := decodeStrictFrostNativeSignerAnchorJSON(payload, &readRequest); err != nil { @@ -1212,6 +1443,7 @@ func newTestFrostNativeSignerAnchorEnvironment( return &testFrostNativeSignerAnchorEnvironment{ server: server, client: client, + readFaults: readFaults, expected: expected, candidate: candidate, proof: proof, From 908365864a887e37696e692f3024a02705c05702 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 11:20:19 -0400 Subject: [PATCH 79/93] docs(tbtc): document the offline FROST anchor rotation ceremony Five Go source strings tell an operator that "offline anchor rotation is required". Nothing in docs/ said what that is, how often it is needed, how to see it coming, or how to perform it - and there is no `tbtc-signer anchor rotate` subcommand to discover by trying. Derive the cadence from the code rather than assuming rotation is rare. frostPreSignAnchoredWorkflowCost reserves inputs * (1 + attempts * (4*seats + 1)) revisions and twice that plus three generations against two 4096-entry windows, so at 21 inputs and five attempts a 4-seat node reserves 3615 generations while a completed sweep consumes only a few hundred. That is one to two full-size sweeps per anchor epoch at 4 seats, and the refusal arrives with 3508 of 4096 generations still unspent. State the gaps instead of papering over them: both headroom values are readable only from the loopback activation-handshake JSON and appear in no metric or log; the certificate chain must start at sequence one and holds at most 64 entries, capping a signer store at 63 rotations for its lifetime; and no code here produces a rotation certificate, only a bootstrap one. Placed under docs/development/ with the other FROST operator documents, since docs/operations/ does not exist and the readiness manifest already records the decision not to create it. Co-Authored-By: Claude Fable 5 --- docs/development/frost-anchor-rotation.adoc | 608 ++++++++++++++++++++ 1 file changed, 608 insertions(+) create mode 100644 docs/development/frost-anchor-rotation.adoc diff --git a/docs/development/frost-anchor-rotation.adoc b/docs/development/frost-anchor-rotation.adoc new file mode 100644 index 0000000000..c436bae573 --- /dev/null +++ b/docs/development/frost-anchor-rotation.adoc @@ -0,0 +1,608 @@ += FROST Native Signer Anchor Rotation + +*Audience:* node operators and the offline anchor authority +*Status:* Draft - describes a ceremony that has no tooling in this +repository +*Date:* 2026-07-31 + +This document was originally planned for a `docs/operations/` +directory. That directory does not exist in `keep-core`, and the FROST +readiness manifest already records the decision not to create one +(`docs/development/frost-readiness-manifest.adoc`); every other FROST +operator document, including the rollout guide written for node +operators, lives under `docs/development/`. This one follows them. + +== Summary + +Every durable write the native tBTC signer makes is anchored to an +external history service. The node may only prove its own state back +to a *certified floor* that an offline authority signed. Two bounded +windows measure the distance from that floor - one counts anchor +service *revisions*, the other counts signer state *generations* - and +both are 4096 entries wide +(`FrostNativeSignerAnchorMaximumHistoryEvents` and +`FrostNativeSignerAnchorMaximumHistoryProofEntries` in +`pkg/tbtc/frost_native_signer_anchor_protocol.go`). + +The windows never refill on their own. Only an offline-authorized +*rotation* moves the floor forward, and a rotation is installed at node +startup. Between two rotations a node has a fixed, non-renewable budget +of durable signer writes. + +IMPORTANT: There is *no* `tbtc-signer anchor rotate` subcommand, and no +other command in this repository performs a rotation. `cmd/tbtc_signer.go` +implements exactly one anchor ceremony - +`keep-client tbtc-signer anchor bootstrap`, with the four subcommands +`facts`, `core`, `initialize` and `finalize`. +Rotation is entirely external and manual: the offline authority +produces a signed certificate by means this repository does not +provide, an operator installs it in three files, and the node is +restarted. Do not go looking for a subcommand; it is not hidden, it +does not exist. + +The rotation budget is smaller than it looks. At the production +maximum batch (21 inputs) a node holding 4 local seats reserves 3615 of +the 4096 generations for a *single* transaction workflow, and a completed +workflow consumes a few hundred. The practical result is *one to two +full-size sweeps per anchor epoch at 4 seats*. The derivation is in +<>. + +== What the anchor is, in operator terms + +[cols="1,3"] +|=== +| Term | Meaning + +| Anchor service +| The external, authenticated checkpoint/history service reached over + `FrostNativeSignerAnchorURL`. It stores one append-only stream of + signed events for this signer store. + +| Service epoch +| A numbered generation of that stream. A rotation, and only a + rotation, increments it. Within one epoch, revisions count from 1. + +| Revision +| One event in the stream. In practice one request-taking signer call + advances one revision, because the process output barrier advances + the remote anchor revision once for the call's final checkpoint. + +| Generation +| One committed state witness inside the Rust signer - one durable + state image. A single anchored call can commit one or two of them in + fault-free operation (the expiry-sweep prologue's snapshot, plus the + endpoint's own mutation), and up to three under the frozen barrier + ceiling. + +| Certified floor +| The `(service epoch, revision, checkpoint)` triple named by the final + trust certificate the node loaded at startup. It is the oldest point + the node can still prove back to. + +| Restartable headroom +| How far the node may still travel from that floor: + + `restartableRevisionHeadroom = 4096 - (currentAnchorRevision - certifiedFloorRevision)` + + `restartableGenerationHeadroom = 4096 - (stateGeneration - certifiedFloorGeneration)` + + (`restartableRevisionHeadroom` / `restartableGenerationHeadroom` in + `pkg/tbtc/frost_native_signer_anchor_binding.go`) +|=== + +=== Why rotation exists at all + +The floor is a rollback boundary. If the node could move it, a node +that had been rolled back could declare its rolled-back state to be the +new truth, and the external anchor would stop being external. The code +states this directly where an absent anchor stream is refused +(`pkg/tbtc/frost_native_signer_anchor_binding.go`, +`reconcileStartup`): + +.... +// In particular, an absent stream remains a hard failure. The online +// signer is never authorized to create its own rollback boundary. +.... + +So the floor moves only under an Ed25519 signature from the offline +authority, checked at startup against pins that the running node cannot +influence. A finite window is the price: the node must be able to +replay its whole history from the floor to prove it did not roll back, +and that replay is bounded. Running out of window is therefore not a +failure of the anchor - it is the anchor working, and asking for a +fresh signed floor. + +[[when-rotation-is-required]] +== When rotation is required + +=== The reservation arithmetic + +Before a FROST pre-sign workflow starts, the admission controller +reserves its entire worst case in both dimensions and holds the +reservation until the workflow exits +(`frostPreSignAnchoredWorkflowCost` in +`pkg/tbtc/frost_native_signer_anchor_admission.go`): + +.... +perAttemptCalls = 4 * localSeats + 1 +perInputCalls = 1 + maximumAttempts * perAttemptCalls +revisions = inputs * perInputCalls +generations = 2 * revisions + 3 +.... + +The `4 * seats + 1` is `Open`, `Round1`, `Round2` and `Abort` for every +local seat plus one memoized `Aggregate`; the leading `1` per input is +`BuildTaprootTx`. Production parameters are `inputs <= 21` +(`frostPreSignAuthorizationMaximumInputs`), `maximumAttempts = 5` +(`signingAttemptsLimit` in `pkg/tbtc/node.go`), and up to 100 wallet +seats (`frostPreSignAuthorizationMaximumSeats`, threshold 51). + +At 21 inputs and 5 attempts: + +[cols="1,1,1,3"] +|=== +| Local seats | Revisions reserved | Generations reserved | Admissible at 21 inputs? + +| 1 | 546 | 1095 | yes +| 2 | 966 | 1935 | yes +| 3 | 1386 | 2775 | yes +| 4 | 1806 | 3615 | yes - this is the ceiling +| 5 | 2226 | 4455 | *no*: exceeds the 4096 proof window; caps at 19 inputs +| 6 | - | - | *no*: caps at 16 inputs +|=== + +The 1-seat and 2-seat rows and the 4-seat ceiling are pinned by +`TestFrostPreSignMaximumAnchorCapacityCost` and +`TestFrostPreSignSeatCeilingResidual` in +`pkg/tbtc/frost_native_signer_anchor_admission_test.go`; the rest +follow from the same formula. + +=== What a completed workflow actually consumes + +The reservation is a worst case over five attempts. A fault-free +workflow that succeeds on its first attempt makes, per input: + +* one `BuildTaprootTx` call; +* three calls per local seat - `Open`, `Round1`, `Round2`. The runner + suppresses `Abort` for a seat that reached round 2, because round 2 + already consumed its nonces (the cleanup defer in + `pkg/frost/signing/roast_runner_frost_native.go`). A seat that + committed but was not selected into the signing + subset calls `Open`, `Round1`, `Abort` instead - also three; and +* one memoized `Aggregate`. + +That is `3 * seats + 2` anchored calls per input, each costing one +revision and one to two generations: + +[cols="1,1,1,1"] +|=== +| Local seats | Calls per 21-input sweep | Revisions consumed | Generations consumed + +| 1 | 105 | 105 | 105-210 +| 2 | 168 | 168 | 168-336 +| 3 | 231 | 231 | 231-462 +| 4 | 294 | 294 | 294-588 +|=== + +=== Cadence + +A workflow is admitted only while `cost <= headroom - alreadyReserved` +in *both* dimensions and the smaller headroom is still above 256. Since +the generation cost is roughly twice the revision cost while the +consumption rates are comparable, the generation window always binds +first. Starting from a fresh rotation (both windows at 4096) and +running full-size sweeps one at a time: + +[cols="1,1,3"] +|=== +| Local seats | Full-size sweeps per anchor epoch | Note + +| 1 | 15-29 | best case assumes one generation per anchored call +| 2 | 7-13 | +| 3 | 3-6 | +| 4 | 1-2 | in both the best and the worst case the next sweep is + refused with 3508 generations still unspent +|=== + +The 4-seat row is the operationally important one. A 4-seat node stops +admitting full-size batches with 3508 of 4096 generations - 86% of its +proof window - still unspent, purely because the *next* workflow's +worst case no longer fits. Rotation cadence at production seat counts +is therefore measured in a handful of sweeps, not in months of uptime. + +NOTE: An earlier operability assessment put these at "1-2 at 4 seats, +about 5 at 3 seats, about 28 at 1 seat". The 4-seat figure agrees and +the 1-seat figure matches the optimistic end of the range here. The +3-seat figure differs because this derivation counts three calls per +seat on a successful attempt rather than four - `Abort` is suppressed +for a seat that signed round 2, so four calls per seat is the shape of +a *failed* attempt. Use the ranges above. + +=== Concurrency + +One admission controller is shared by pre-sign authorization, native +DKG and DKG retirement, and each reservation holds its full worst case +until its workflow exits. Two concurrent full-size workflows at 4 seats +would need 7230 generations and can never both be admitted, so such a +node serializes full-size work. The controller also intentionally +double-counts in-flight mutations when admitting later work, so +concurrent load reaches the refusal sooner than the table suggests. + +=== What an operator actually sees + +There is no warning phase that names a remedy. In headroom order: + +. *Admission refusal (no remedy named).* Emitted while headroom is + still large but smaller than the next workflow's worst case: ++ +---- +FROST pre-sign authorization requires [3615] signer generations but only [3508] are unreserved +---- ++ +and the revision-dimension twin, `... requires [1806] anchor revisions +but only [N] are unreserved`. The workflow label is +`FROST pre-sign authorization`, `FROST native DKG` or +`FROST native DKG retirement`. *This string does not mention rotation +at all*, and it is the message a healthy, correctly configured node +produces first. Treat it as the rotation signal. ++ +It reaches the log wrapped twice, at ERROR, from the wallet +dispatcher: ++ +---- +action execution terminated with error: [FROST pre-sign authorization failed +before nonce generation: [FROST pre-sign anchor admission failed: [FROST +pre-sign authorization requires [3615] signer generations but only [3508] +are unreserved]]] +---- + +. *Rotation-floor refusal.* Once `min(revisionHeadroom, + generationHeadroom) <= 256` + (`FrostNativeSignerAnchorRotationWarningHeadroom`), *all* new work is + refused regardless of size: ++ +---- +FROST pre-sign authorization is blocked with revision/generation headroom [R/G]; offline anchor rotation is required before admitting new work +---- + +. *Barrier freeze.* Deeper still, individual request-taking calls are + refused by the Rust-side output barrier + (`pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go`), + wrapping `native tbtc signer state anchor is unavailable`: ++ +---- +... request-taking operation [] is blocked because the certified signer-generation window cannot cover its maximum advance; offline anchor rotation is required +... request-taking operation [] is blocked because the certified anchor revision window is exhausted; offline anchor rotation is required +---- + +. *Startup and readiness refusals.* A node that reaches zero headroom + will not come back up cleanly: ++ +---- +native signer certified anchor revision or generation window is exhausted; offline anchor rotation is required +native signer anchor certified-floor history bound is exhausted; offline anchor rotation is required +startup native signer local-ahead state cannot cross the certified-floor history bound; offline anchor rotation is required +---- + +Every one of these refusals is fail-closed: no share is released and no +replay gate weakens. Nothing is lost by hitting them except availability - but +availability is lost until an offline ceremony completes, which is why +the cadence above matters. + +== Reading current headroom today + +*This is the weakest part of the current tooling, and the honest answer +is that there is no good way.* + +`restartableRevisionHeadroom` and `restartableGenerationHeadroom` exist +in exactly one place an operator can reach: the JSON body of the +activation-handshake response +(`pkg/tbtc/frost_activation_handshake.go`, struct +`frostActivationNativeSignerState`). Specifically: + +---- +payload.state.nativeSignerState.restartableRevisionHeadroom +payload.state.nativeSignerState.restartableGenerationHeadroom +payload.state.nativeSignerState.anchorRotationWarning +payload.state.nativeSignerState.currentAnchorRevision +payload.state.nativeSignerState.certifiedFloorRevision +payload.state.nativeSignerState.certifiedFloorGeneration +payload.state.nativeSignerState.stateGeneration +---- + +`anchorRotationWarning` is true exactly when the smaller headroom is at +or below 256, and it also forces the payload's top-level +`state.healthy` to false (`frostActivationHandshakeHealthy`). + +=== The endpoint + +The endpoint is configured by `FrostActivationHandshakeURL` and is +loopback-only, twice over: + +* `validateFrostActivationHandshakeEndpoint` requires an `http` URL + whose host is a *numeric loopback* address, with a fixed non-zero + port and a clean non-empty path; and +* the handler rejects any request whose remote address is not a + loopback IP, with `403 forbidden`. + +It answers `POST` on exactly the configured path, requires +`Content-Type: application/json`, caps the body at 4096 bytes, and +rejects unknown JSON fields. The response is signed by the node's +attestation key. + +=== The concrete read + +You must run this on the node host, and you must supply a complete +auditor challenge - the endpoint exists for the independent activation +auditor, not for monitoring: + +---- +curl -sS -X POST http://127.0.0.1:/ \ + -H 'Content-Type: application/json' \ + -d '{ + "schema": "tbtc-p2tr-production-activation-handshake/v4", + "challenge": { + "nonce": "0x<32 random bytes, lowercase hex>", + "manifestHash": "0x", + "bindingHash": "0x", + "ethereumPoint": { + "blockNumber": , + "blockHash": "0x" + }, + "checkpointFloor": { + "sequence": , + "certificateHash": "0x" + } + } + }' \ +| jq '.payload.state.nativeSignerState + | {restartableRevisionHeadroom, restartableGenerationHeadroom, + anchorRotationWarning}' +---- + +Every `0x` value is lowercase 32-byte hex; a mismatch on +`manifestHash` or `bindingHash` is rejected. The first call for a given +Ethereum point returns `503 activation state is not ready` with +`Retry-After: 1` while reconciliation is queued - retry until it +answers. + +=== Gaps in this area + +* *No metric.* Nothing anchor-related is registered with `clientinfo`, + so `/metrics` has no headroom gauge and no rotation warning. The only + scrapeable trace of a refusal is the generic + `wallet_action_failed_total` counter, which cannot distinguish a + headroom refusal from any other action failure. +* *No log line.* Neither the readiness snapshot nor the anchor binding + logs headroom. The first log evidence is the refusal itself, by which + time the node is already turning work away. +* *No client for the endpoint.* Nothing in this repository builds the + challenge above; there is no `keep-client` subcommand for it and no + script under `scripts/`. Assembling `bindingHash`, the finalized Ethereum + point and the checkpoint-floor cursor by hand is the current + procedure. +* *Reading headroom needs a running, healthy node.* The handshake + refuses to answer while activation state is not reconciled, so the + reading is unavailable exactly when a node is unhealthy. + +== The rotation ceremony + +A rotation is an offline-signed statement that "the anchor stream moves +to a new service epoch, starting from this exact frozen checkpoint". +The node verifies it at startup; nothing at runtime can install one. + +=== What the offline authority must produce + +One certificate object, schema +`tbtc-frost-native-signer-state-anchor-trust-certificate/v1`, with +`"kind": "rotation"`. Every hash and key is lowercase `0x`-prefixed +hex; every integer is a canonical decimal *string*. Validation lives in +`ValidateFrostNativeSignerAnchorTrustCertificate` and +`ValidateFrostNativeSignerAnchorTrustCertificateChain` +(`pkg/tbtc/frost_native_signer_anchor_trust.go`). The requirements +that are specific to a rotation: + +* `from` must be present and complete. The bootstrap kind is the only + one that may carry `"from": null`, and the member must exist in the + JSON either way. +* `certificateSequence` is the previous certificate's sequence plus + one, and `previousCertificateDigest` is the previous certificate's + `certificateDigest`. Sequence 1 with a zero previous digest is the + "legacy adoption" special case and is only reachable on a signer + whose trust journal has no head. +* `to.reference.serviceEpoch` = `from.reference.serviceEpoch + 1`. +* `to.reference.revision` = `1`. +* `to.reference.previousEventRoot` = `from.reference.eventRoot`, so the + epoch boundary cannot erase ancestry. +* `to.activationManifestSequence` = + `from.activationManifestSequence + 1` - exactly one greater, so a new + signed activation manifest is part of every rotation. +* `to.activationManifestHash` must *differ* from `from`'s, and + `to.bindingHash` must *differ* from `from`'s. A rotation that does + not change its manifest binding is refused with + `native signer anchor rotation does not change its manifest binding`. +* `coreSignature` and `finalSignature` are Ed25519 signatures by the + *`from`* offline authority key over the derived `coreDigest` and + final digest; `operationID`, `transitionDigest` and + `certificateDigest` must equal their derivations. +* `targetAcknowledgementBase64` holds the *exact bytes* of the history + service's signed checkpoint acknowledgement + (`tbtc-signer-state-witness-checkpoint-ack/v1`) for the new epoch's + revision-1 event, with `targetAcknowledgementSHA256` its SHA-256. It + is retained byte-for-byte and must not be re-encoded. + +This implies a service-side step the certificate alone cannot perform: +the authority must first drive the anchor service to open service epoch +N+1 at revision 1, carrying the frozen checkpoint and +`previousEventRoot`, and collect that event's signed acknowledgement. +The node fetches the matching Read response itself at startup, so the +new epoch must already be served before the node is restarted. + +=== What a rotation may not change + +* *The offline authority key.* `to.offlineAuthorityPublicKey` and its + SPKI hash must equal `from`'s - + `native signer anchor offline authority rotation is unsupported`. + There is no key-rotation path for the authority itself. +* *The signer state.* `to.reference.checkpoint` must be byte-identical + to `from.reference.checkpoint`, including its `generation`. A + rotation re-certifies the current state as the new floor; it never + advances, rewinds or repairs it. +* *The stream identity.* `protocolID`, `streamID` and + `signerStoreFingerprint` are pinned across the entire chain and + against the manifest. `streamID` is derived from + `(protocolID, trustDomainID, signerStoreFingerprint)` only, so it is + deliberately stable across every rotation. +* *The witness bounds.* `witnessMaximumRecords` and + `witnessRotationThresholdRecords` must be identical on both sides. + +The online service response key, endpoint leaf pins and manifest +contents *may* change in a rotation; the offline authority and the +signer state may not. + +=== Where it is installed + +Three artifacts must be updated together, and all three are checked +against each other at startup. A mismatch fails startup closed. + +[cols="2,2,3"] +|=== +| Artifact | Set by | What changes on rotation + +| Trust certificate chain +| `[tbtc]` `FrostNativeSignerAnchorTrustCertificatePath` in the client + config file (there is no CLI flag) +| Append the new rotation certificate to the JSON array. + +| Native signer init config +| The JSON file at `TBTC_SIGNER_INIT_CONFIG_PATH` +| `state_anchor_activation_manifest_hash`, + `state_anchor_activation_manifest_sequence`, + `state_anchor_binding_hash`, + `state_anchor_trust_certificate_sequence`, + `state_anchor_trust_certificate_digest` + +| Activation manifest +| `[tbtc]` `FrostPreSignActivationManifestPath` +| The new signed manifest whose sequence is one greater and whose hash + the certificate's `to` endpoint names. +|=== + +The certificate file is a JSON array of *one to 64* certificates and +must *begin at `certificateSequence` 1*: startup independently +re-authenticates the complete artifact as a crash-recovery source +(`authenticateFrostNativeSignerAnchorTrustRecoveryArtifact` in +`pkg/tbtc/frost_native_signer_anchor_trust_startup.go`), and refuses +anything else with +`native signer anchor recovery requires a complete sequence-one certificate artifact`. +The file is opened `O_NOFOLLOW` and must be a regular file owned by the +node's effective uid with mode exactly `0600`; the whole file is read +under a 16 MiB bound and each certificate under a 120 KiB bound. + +WARNING: Because the file must always start at sequence 1 and may hold +at most 64 entries, a signer store admits at most *63 rotations for its +entire lifetime* - 64 anchor epochs counting the bootstrap. At 1-2 +full-size sweeps per epoch at 4 seats that is roughly 64-128 full-size +sweeps before the chain can no longer be extended. Nothing in this tree +implements what happens next. This is a gap, not a documented plan. + +=== Restart, and the order of operations + +The certified floor is read from the final certificate during +`tbtc.Initialize` and installed once +(`certifiedFloor := ...finalTrustCertificate.To.Reference` in +`pkg/tbtc/node.go`). There is no reload path. *A rotation takes effect +only on node restart.* + +The startup sequence also constrains the ceremony order. The node +submits the missing certificate suffix to the Rust signer before any +durable store access, then reconciles, then requires the reconciled tip +to equal the certificate's target exactly - otherwise +`reconciled native signer tip differs from the fresh trust-transition target`. +Consequently: + +. Stop the node. The checkpoint frozen into the certificate must be the + node's *final* durable checkpoint; any durable write between the + authority capturing it and the restart invalidates the certificate. +. Capture the current reference - service epoch, revision, event root, + acknowledgement digest and checkpoint - as the certificate's `from`. +. Have the authority open epoch N+1 revision 1 on the anchor service + and collect the signed acknowledgement. +. Sign the rotation certificate offline; issue the new activation + manifest. +. Install all three artifacts on the node, preserving mode `0600` and + ownership by the node's uid. +. Restart. Startup performs the trust transition, reconciles, and + re-reads headroom from the new floor - both windows return to 4096. + +=== Gaps in this area + +* *No producer.* `pkg/tbtc/frost_native_signer_anchor_provisioning.go` + implements the four-phase ceremony for `kind: "bootstrap"` only, and + emits both the one-element chain and the matching init config. There + is no rotation counterpart: no `Prepare`/`Initialize`/`Finalize` API, + no CLI, no artifact schema for the operator side of a rotation. +* *No dry run.* Nothing validates a candidate rotation certificate + against a stopped node's actual state short of restarting the node + with it. A bad certificate is discovered as a startup failure. +* *Downtime is unmeasured.* The whole ceremony happens with the node + stopped, and no rehearsal figure exists. + +== Pre-activation checklist + +The FROST path is pre-production: both go-live gates in +`docs/development/frost-readiness-manifest.adoc` currently read +`missing-no-go`. The following should exist before the rotation cadence +derived above is run against real value. + +. *A rotation certificate producer,* offline and air-gapped, with + frozen test vectors, mirroring what `tbtc-signer anchor bootstrap` + and its four subcommands provide for the bootstrap case. + Detached-signature-only, never accepting the offline authority + private key online. +. *A supported way to read headroom* that does not require an + auditor challenge assembled by hand: a `tbtc-signer` subcommand, or + a `clientinfo` gauge for both headroom dimensions plus the + rotation-warning flag. +. *An alert threshold set well above the danger points* - above 256 + (where all work stops) and above the node's own per-workflow + reservation for its seat count (3615 generations at 4 seats). An + alert that fires at 256 fires far too late. +. *A documented, rehearsed anchor-service epoch transition:* who opens + epoch N+1 revision 1, how the frozen checkpoint and previous event + root are supplied, and how the signed acknowledgement bytes are + transported to the offline authority unaltered. +. *A rehearsed full cycle on testnet* - stop, capture, sign, install + three artifacts, restart - with the measured downtime recorded, and a + verified rollback if the certificate is rejected at startup. +. *A decision on the 64-certificate lifetime bound,* including what a + store does at certificate 64. +. *A local-seat policy.* 5 or more local seats are excluded from + 21-input batches entirely and lose those seats to the wallet's + signing threshold; 4 seats yields 1-2 sweeps per epoch. Decide the + supported seat count per node before sortition produces one. +. *A consistency check before restart* over the three artifacts - + certificate chain, init config, activation manifest - since each is + verified against the others at startup and any mismatch is a + fail-closed startup error. +. *Both readiness-manifest gates flipped to `present`* with evidence, + per that document's update discipline. + +== References + +* Certificate protocol and verification: + `pkg/tbtc/frost_native_signer_anchor_trust.go` +* Startup transition and recovery artifact: + `pkg/tbtc/frost_native_signer_anchor_trust_startup.go`, + `pkg/tbtc/node.go` +* Windows, floors and headroom: + `pkg/tbtc/frost_native_signer_anchor_protocol.go`, + `pkg/tbtc/frost_native_signer_anchor_binding.go` +* Reservation arithmetic and refusals: + `pkg/tbtc/frost_native_signer_anchor_admission.go` +* Barrier freeze: + `pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go` +* Headroom exposure: + `pkg/tbtc/frost_activation_handshake.go`, + `pkg/tbtc/frost_native_signer_readiness.go` +* Bootstrap ceremony (the only implemented one): + `cmd/tbtc_signer.go`, + `pkg/tbtc/frost_native_signer_anchor_provisioning.go` +* Readiness gates: `docs/development/frost-readiness-manifest.adoc` From 44ad440266e4591282ae225d65880b349ede20e1 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 11:41:45 -0400 Subject: [PATCH 80/93] fix(tbtc): fail the activation health verdict on a poisoned signer anchor The state-anchor barrier guards every request-taking call into the native tBTC signer, and once it latches its terminal failure the node keeps running while refusing every one of those calls until it is restarted. None of the terms of frostActivationHandshakeHealthy moved when that happened: interactive readiness is opt-in configuration plus a registered engine, and the whole native signer snapshot is read through no-arg entry points - state witness tip, retained key package inventory, anchor trust head - that never enter the barrier's request-taking path. A poisoned member therefore signed an attestation asserting health while it could not contribute a single signature share, so a permissioned FROST set could stop reaching threshold with every member attesting healthy and no node reporting a cause. Add the barrier verdict to the signed payload as nativeSignerState.stateAnchorPoisoned and make it a health term, so a consumer reads why the verdict flipped rather than only that it did, and can recompute the verdict from the transcript it verified. The verdict is read live at the signing boundary rather than taken from the finality-keyed reconciliation cache, which is refreshed only when finality advances and would otherwise attest away a poisoning that happened since. It is re-read under the signing lock, because the window between building the payload and signing it spans the checkpoint self-verification and one activation point check over the network; a latch inside that window refuses the attestation as retryable instead of signing a health claim the node has already contradicted. The latch is one-way and sticky until restart, so the retry answers with the poisoned verdict rather than flapping. The node also logs the barrier cause and the restart remedy on every attestation it answers while poisoned, since the handshake endpoint is the only place this fact is reachable today. Adding a field to the signed payload bumps the handshake schema to v5, as v2 -> v3 and v3 -> v4 did before it: an auditor pinned to v4 is refused rather than handed a payload whose health verdict it cannot recompute. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/frost_activation_handshake.go | 81 +++++- pkg/tbtc/frost_activation_handshake_test.go | 261 +++++++++++++++++++- 2 files changed, 339 insertions(+), 3 deletions(-) diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 8f51bf5656..72b97f52da 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -25,11 +25,12 @@ import ( "sync" "time" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" "golang.org/x/sys/unix" ) const ( - frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v4" + frostActivationHandshakeSchema = "tbtc-p2tr-production-activation-handshake/v5" frostActivationInventorySchema = "tbtc-p2tr-frost-wallet-group-inventory/v1" frostActivationHandshakeSignatureDomain = "tbtc-p2tr-production-activation-handshake-signature/v3\x00" frostActivationHandshakeReconciliationTimeout = frostRetainedGroupMaximumReconciliationDuration @@ -46,6 +47,19 @@ var errFrostActivationJournalBusy = errors.New( "FROST activation journal live-state check is busy", ) +// frostActivationNativeSignerStateAnchorPoisoned reports the latched terminal +// state-anchor failure that makes this node refuse every request-taking native +// signer call until the process is restarted, or nil while the barrier is +// healthy. It reads an atomic and never takes the barrier mutex, so calling it +// on the attestation path cannot block behind an in-flight signing operation. +// +// It is a variable only so that tests can drive the poisoned branch. The +// barrier it reads is a process-wide singleton in pkg/frost/signing whose +// poisoned state is deliberately one-way and clearable only by restart, so a +// test that latched it for real would leave every later test in this package +// unable to sign. +var frostActivationNativeSignerStateAnchorPoisoned = frostsigning.NativeTBTCSignerStateAnchorPoisoned + type frostActivationEthereumPoint struct { BlockNumber uint64 `json:"blockNumber"` BlockHash string `json:"blockHash"` @@ -127,6 +141,7 @@ type frostActivationNativeSignerState struct { RestartableRevisionHeadroom uint64 `json:"restartableRevisionHeadroom"` RestartableGenerationHeadroom uint64 `json:"restartableGenerationHeadroom"` AnchorRotationWarning bool `json:"anchorRotationWarning"` + StateAnchorPoisoned bool `json:"stateAnchorPoisoned"` Complete bool `json:"complete"` } @@ -1057,6 +1072,27 @@ func (fahe *frostActivationHandshakeExporter) attest( "native signer state lacks an authenticated external rollback anchor trust certificate", ) } + // Every fact revalidated above is read through no-arg native entry points - + // the state witness tip, the retained key package inventory and the anchor + // trust head - and none of them takes the state-anchor barrier's + // request-taking path. A node whose barrier has latched its terminal + // failure therefore still reports a complete, anchor-bound, rotation-quiet + // native signer state while it refuses every signing request it is asked + // for, which is precisely the silent failure this attestation exists to + // make visible. The barrier verdict is read live here rather than taken + // from the finality-keyed reconciliation cache, because the cache is + // refreshed only when finality advances and a poisoning that happened after + // the last refresh must not be attested away as health. + stateAnchorPoisoning := frostActivationNativeSignerStateAnchorPoisoned() + if stateAnchorPoisoning != nil { + logger.Errorf( + "FROST activation handshake is attesting an unhealthy node: the "+ + "native tBTC signer state anchor is terminally poisoned: [%v]; "+ + "this node refuses every request-taking native signer call and "+ + "cannot contribute a signature share until it is restarted", + stateAnchorPoisoning, + ) + } state := frostActivationHandshakeState{ ProtocolID: frostActivationHex32(fahe.manifest.SignerProtocolID), ReservationProtocolID: frostActivationHex32(fahe.manifest.ReservationProtocolID), @@ -1154,6 +1190,7 @@ func (fahe *frostActivationHandshakeExporter) attest( RestartableRevisionHeadroom: nativeSignerSnapshot.RestartableRevisionHeadroom, RestartableGenerationHeadroom: nativeSignerSnapshot.RestartableGenerationHeadroom, AnchorRotationWarning: nativeSignerSnapshot.AnchorRotationWarning, + StateAnchorPoisoned: stateAnchorPoisoning != nil, Complete: true, }, InteractiveSigningReady: reconciliation.interactiveSigningReady, @@ -1213,6 +1250,26 @@ func (fahe *frostActivationHandshakeExporter) attest( ) } defer fahe.journal.mutex.Unlock() + // The barrier can latch at any moment, including after the payload + // above recorded its verdict. Everything the volatile native signer + // values tolerate in that window is a bound - an attested + // generation is a lower bound on the live one - but health is a + // verdict, not a bound: signing "healthy" for a node that has + // already stopped being able to sign is exactly the failure this + // attestation must not produce, and the window is not short, since + // it spans the checkpoint self-verification and one activation + // point check over the network. Refuse instead, as a retryable + // pending state. The latch is one-way and sticky until restart, so + // the retry cannot flap back: it rebuilds the payload with the + // poisoned verdict and signs that. + if poisoning := frostActivationNativeSignerStateAnchorPoisoned(); (poisoning != nil) != + state.NativeSignerState.StateAnchorPoisoned { + return fmt.Errorf( + "%w: native tBTC signer state anchor poisoning changed before signing: [%v]", + errFrostActivationReconciliationPending, + poisoning, + ) + } signingStamp, stampErr := fahe.journalStampLocked() if stampErr != nil || signingStamp != reconciliation.stamp || !fahe.journal.checkpointDescendsFrom(checkpointFloor) { @@ -1239,6 +1296,25 @@ func (fahe *frostActivationHandshakeExporter) attest( }, nil } +// frostActivationHandshakeHealthy derives the attested health verdict from the +// signed payload alone, so that a consumer that keeps the handshake can +// recompute the verdict from the bytes it verified rather than trusting the +// flag. Every term is therefore a field of the payload, including +// StateAnchorPoisoned. +// +// StateAnchorPoisoned is the only term that no other term implies. The +// remaining ones are configuration and no-arg native reads: +// InteractiveSigningReady is opt-in configuration plus a registered engine, and +// the whole native signer snapshot is read through entry points that never +// enter the state-anchor barrier's request-taking path. A node whose barrier is +// poisoned keeps every one of them true while it cannot produce a single +// signature share, so without this term a permissioned set could have several +// members silently unable to sign while all of them attest health. +// +// The term is one-way. The barrier's poisoning is latched until the process +// restarts, so an attestation that reports it unhealthy is never followed by +// one that reports it healthy again short of a restart, and nothing here caches +// the verdict across that transition: it is read live on every attestation. func frostActivationHandshakeHealthy( state frostActivationHandshakeState, ) bool { @@ -1248,7 +1324,8 @@ func frostActivationHandshakeHealthy( state.QuarantineFailClosed && state.NativeSignerState.Complete && state.NativeSignerState.ExternalRollbackAnchorBound && - !state.NativeSignerState.AnchorRotationWarning + !state.NativeSignerState.AnchorRotationWarning && + !state.NativeSignerState.StateAnchorPoisoned } func frostActivationHandshakeSignatureTranscript( diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index 8391c7b262..3710fa6990 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -714,7 +714,7 @@ func TestFrostActivationHandshakeExporter_AttestsExactReadyState(t *testing.T) { "complete", "currentAnchorRevision", "externalRollbackAnchorBound", "inventoryCommitment", "previousStateCommitment", "retainedKeyPackageCount", "retainedWalletCount", "restartableGenerationHeadroom", "restartableRevisionHeadroom", - "schema", "stateCommitment", "stateGeneration", + "schema", "stateAnchorPoisoned", "stateCommitment", "stateGeneration", "stateImageDigest", "storeFingerprint", "trustCertificateDigest", "trustCertificateSequence", }) @@ -1065,6 +1065,258 @@ func TestFrostActivationHandshakeExporter_SignsLiveNativeSignerState( } } +// TestFrostActivationHandshakeHealthy_RequiresAnUnpoisonedStateAnchor pins the +// health term itself, independently of how the payload is assembled: an +// otherwise perfect state is unhealthy once the native signer state anchor is +// terminally poisoned. +func TestFrostActivationHandshakeHealthy_RequiresAnUnpoisonedStateAnchor( + t *testing.T, +) { + state := frostActivationHandshakeState{ + InteractiveSigningReady: true, + NonceShareGateEnforced: true, + DurableBitcoinOutboxRecovered: true, + QuarantineFailClosed: true, + NativeSignerState: frostActivationNativeSignerState{ + ExternalRollbackAnchorBound: true, + Complete: true, + }, + } + if !frostActivationHandshakeHealthy(state) { + t.Fatal("a ready state did not attest healthy") + } + state.NativeSignerState.StateAnchorPoisoned = true + if frostActivationHandshakeHealthy(state) { + t.Fatal( + "a terminally poisoned native signer state anchor still attested " + + "healthy, while the node refuses every request-taking signer " + + "call it is asked for", + ) + } +} + +// TestFrostActivationHandshakeExporter_AttestsPoisonedStateAnchorAsUnhealthy +// pins that a terminally poisoned native tBTC signer state anchor reaches the +// signed attestation, both as the health verdict and as the named reason for +// it. +// +// The barrier guards every request-taking native signer call, so a node that +// latches it keeps running and silently stops signing. Nothing else in this +// payload moves when that happens: interactive readiness is configuration, and +// every native signer fact is read through no-arg entry points that never enter +// the barrier's request-taking path. Without the poisoning term, a permissioned +// FROST set could therefore lose threshold while every member attests health, +// with no node reporting a cause. +func TestFrostActivationHandshakeExporter_AttestsPoisonedStateAnchorAsUnhealthy( + t *testing.T, +) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + signal := installTestFrostActivationStateAnchorPoisonSignal(t) + exporter, _, _, _, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + + response := postTestFrostActivationHandshake(t, endpoint, request) + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + healthy := &frostActivationSignedHandshake{} + healthyErr := json.NewDecoder(response.Body).Decode(healthy) + response.Body.Close() + if healthyErr != nil { + t.Fatal(healthyErr) + } + if !healthy.Payload.State.Healthy || + healthy.Payload.State.NativeSignerState.StateAnchorPoisoned { + t.Fatalf( + "an unpoisoned state anchor attested healthy [%t] with "+ + "stateAnchorPoisoned [%t]", + healthy.Payload.State.Healthy, + healthy.Payload.State.NativeSignerState.StateAnchorPoisoned, + ) + } + exporter.reconciliationMutex.Lock() + cached := exporter.reconciliationCompleted + exporter.reconciliationMutex.Unlock() + if cached == nil { + t.Fatal("the healthy attestation left no reconciliation cache") + } + + // One more healthy read, so the next attestation builds its payload before + // the barrier latches and meets the latch only under the signing lock. That + // attestation must be refused rather than signed, and must be refused as + // retryable: the latch is one-way, so the immediate retry answers with the + // poisoned verdict instead of flapping. + poisoning := fmt.Errorf( + "native tBTC signer state anchor is terminally poisoned: " + + "anchor compare-and-swap lost after the native mutation", + ) + signal.latch(poisoning, 1) + refused := postTestFrostActivationHandshake(t, endpoint, request) + refusedBody, _ := io.ReadAll(refused.Body) + refused.Body.Close() + if refused.StatusCode != http.StatusServiceUnavailable || + refused.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + t.Fatalf( + "an anchor poisoned during signing was attested with status [%d] "+ + "and Retry-After [%s]: %s", + refused.StatusCode, + refused.Header.Get("Retry-After"), + refusedBody, + ) + } + + response = postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + response.Body.Close() + t.Fatalf( + "a poisoned node refused to attest at all with status [%d]: %s; "+ + "the attestation is the only place the reason is reported", + response.StatusCode, + body, + ) + } + defer response.Body.Close() + poisoned := &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(poisoned); err != nil { + t.Fatal(err) + } + if poisoned.Payload.State.Healthy || + !poisoned.Payload.State.NativeSignerState.StateAnchorPoisoned { + t.Fatalf( + "a terminally poisoned node attested healthy [%t] with "+ + "stateAnchorPoisoned [%t], while it refuses every "+ + "request-taking native signer call it is asked for", + poisoned.Payload.State.Healthy, + poisoned.Payload.State.NativeSignerState.StateAnchorPoisoned, + ) + } + // Exactly two bits differ from the healthy attestation: the verdict and the + // reason for it. An operator holding both must be able to tell that this + // node stopped signing because of the anchor and not because some other + // activation invariant also broke. + expected := healthy.Payload.State + expected.NativeSignerState.StateAnchorPoisoned = true + expected.Healthy = false + if !reflect.DeepEqual(poisoned.Payload.State, expected) { + t.Fatal( + "the poisoned attestation moved more than the health verdict and " + + "its reason, so it does not name the anchor as the cause", + ) + } + + // The reason must be inside what the node signed, not decoration added on + // the way out: a consumer recomputes the verdict from the transcript it + // verified. + publicKeyDER, err := base64.StdEncoding.Strict().DecodeString( + poisoned.SignerPublicKeySPKI, + ) + if err != nil { + t.Fatal(err) + } + parsedPublicKey, err := x509.ParsePKIXPublicKey(publicKeyDER) + if err != nil { + t.Fatal(err) + } + publicKey, ok := parsedPublicKey.(ed25519.PublicKey) + if !ok { + t.Fatal("attestation public key is not Ed25519") + } + canonicalPayload, err := canonicalFrostActivationValue(poisoned.Payload) + if err != nil { + t.Fatal(err) + } + signature, err := base64.StdEncoding.Strict().DecodeString(poisoned.Signature) + if err != nil || !ed25519.Verify( + publicKey, + append( + []byte(frostActivationHandshakeSignatureDomain), + canonicalPayload..., + ), + signature, + ) { + t.Fatal( + "poisoned attestation signature did not verify over the canonical payload", + ) + } + if frostActivationHandshakeHealthy(poisoned.Payload.State) { + t.Fatal( + "the health verdict recomputed from the signed payload disagrees " + + "with the attested one", + ) + } + + // The unhealthy attestation was served from the same reconciliation cache + // that produced the healthy one, which is what proves the verdict is read + // live at the signing boundary instead of being cached with the rest of the + // activation state and going stale across the transition. + exporter.reconciliationMutex.Lock() + sameCache := exporter.reconciliationCompleted == cached + exporter.reconciliationMutex.Unlock() + if !sameCache { + t.Fatal( + "the unhealthy attestation came from a refreshed reconciliation " + + "cache, so it does not prove the barrier verdict is read live", + ) + } +} + +// testFrostActivationStateAnchorPoisonSignal stands in for the process-wide +// state anchor barrier of pkg/frost/signing. The real barrier latches its +// poisoning until the process restarts, so a test that poisoned it for real +// would leave every later test in this package unable to sign. +type testFrostActivationStateAnchorPoisonSignal struct { + mutex sync.Mutex + // healthyReads is the number of leading reads still answered healthy after + // the poisoning was latched, which is how a test places the latch inside + // the window between building an attestation and signing it. + healthyReads int + poisoning error +} + +func (signal *testFrostActivationStateAnchorPoisonSignal) read() error { + signal.mutex.Lock() + defer signal.mutex.Unlock() + if signal.poisoning == nil { + return nil + } + if signal.healthyReads > 0 { + signal.healthyReads-- + return nil + } + return signal.poisoning +} + +func (signal *testFrostActivationStateAnchorPoisonSignal) latch( + poisoning error, + healthyReads int, +) { + signal.mutex.Lock() + defer signal.mutex.Unlock() + signal.poisoning = poisoning + signal.healthyReads = healthyReads +} + +func installTestFrostActivationStateAnchorPoisonSignal( + t *testing.T, +) *testFrostActivationStateAnchorPoisonSignal { + t.Helper() + signal := &testFrostActivationStateAnchorPoisonSignal{} + previous := frostActivationNativeSignerStateAnchorPoisoned + frostActivationNativeSignerStateAnchorPoisoned = signal.read + t.Cleanup(func() { + frostActivationNativeSignerStateAnchorPoisoned = previous + }) + return signal +} + func TestFrostActivationHandshakeExporter_RevalidatesOutboxStateBeforeSigning( t *testing.T, ) { @@ -1474,6 +1726,13 @@ func TestFrostActivationHandshakeExporter_RejectsUnboundOrLegacyChallenge( "legacy v2 schema": func(request *frostActivationHandshakeRequest) { request.Schema = "tbtc-p2tr-production-activation-handshake/v2" }, + // v4 is the schema that did not carry the state-anchor poisoning + // verdict, so an auditor still pinned to it would read a payload whose + // health verdict it cannot recompute. It is refused like any other + // superseded schema. + "legacy v4 schema": func(request *frostActivationHandshakeRequest) { + request.Schema = "tbtc-p2tr-production-activation-handshake/v4" + }, "different binding": func(request *frostActivationHandshakeRequest) { request.Challenge.BindingHash = frostActivationHex32([32]byte{0xff}) }, From 1394663ebe346950c6d063cfc3440532c90e24ca Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 11:41:41 -0400 Subject: [PATCH 81/93] fix(tbtc): stop admitting FROST work a poisoned anchor cannot finish The state-anchor admission controller is the single chokepoint for pre-sign, native DKG and DKG retirement reservations, and it could not see the one failure that matters most. currentRestartableHeadroom reads the witness tip through the inventory bridge and authenticates it against the anchor service; neither path takes the request-taking barrier, so a barrier that has already latched a terminal failure still reports perfectly healthy headroom. A poisoned node therefore kept accepting workflows whose every signer call was already doomed, and the wallet lost that member's seats with no node reporting a cause. Refuse in reserve() while frostsigning.NativeTBTCSignerStateAnchorPoisoned reports a latched failure, before the headroom read and outside the admission mutex: one atomic load is cheaper than an anchor round trip, and nothing clears poisoned in-process, so no later headroom value can make the workflow admissible. All three reservation paths share reserve(), so DKG and DKG retirement are covered too. The refusal names the process restart, because that is the only remedy and it is not guessable from the message. Name the remedy in the unreserved-headroom refusals as well. They are the first refusal a healthy, correctly configured node produces - a four-seat node is refused its next maximum-size sweep with 3508 of 4096 generations still unspent - so the numbers read healthy and the refusal looks transient. Neither certified window refills on its own, so both messages now say that offline anchor rotation is required, matching the sibling rotation-floor refusal and docs/development/frost-anchor-rotation.adoc. The doc path itself is not cited: no production error string in this tree references docs/. Add frostNativeSignerAnchorWorkloadRotationWarning, which measures headroom against the largest workflow this node can actually be admitted for rather than against the flat 256 floor. The flat floor is kept as a hard lower bound and the workload term is added to it, not substituted: 256 guards the rotation-blocked refusal, where all new work has already stopped, so it fires roughly 3359 generations after a four-seat node stops being able to sign a maximum-size batch. Each window is compared against its own dimension's cost, and the measuring stick is the largest ADMISSIBLE batch so that a node above the seat ceiling is not warned - and marked unhealthy - permanently for an exclusion no rotation fixes. The local seat count is a parameter because this layer has no view of the node's sortition result. Add rejection counters keyed by cause - seat-ceiling, unreserved headroom, rotation floor, poisoned - following the roast_interactive_signing_metrics.go pattern, so a monitoring system can alert on any non-zero seat-ceiling or poisoned value. Nothing anchor-related was registered with clientinfo before, so a node refusing every signing request was indistinguishable from one that had not been asked. Co-Authored-By: Claude Fable 5 --- .../frost_native_signer_anchor_admission.go | 291 +++++++++++- ...ost_native_signer_anchor_admission_test.go | 413 ++++++++++++++++++ 2 files changed, 702 insertions(+), 2 deletions(-) diff --git a/pkg/tbtc/frost_native_signer_anchor_admission.go b/pkg/tbtc/frost_native_signer_anchor_admission.go index 1bc5063e51..ef0ddf431a 100644 --- a/pkg/tbtc/frost_native_signer_anchor_admission.go +++ b/pkg/tbtc/frost_native_signer_anchor_admission.go @@ -4,6 +4,10 @@ import ( "context" "fmt" "sync" + "sync/atomic" + + "github.com/keep-network/keep-core/pkg/clientinfo" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" ) const ( @@ -118,6 +122,13 @@ type frostNativeSignerAnchorAdmissionController struct { // readHeadroom is test-only injection. Production always authenticates the // current tip through anchorBinding while holding mutex. readHeadroom func(context.Context) (frostNativeSignerAnchorCapacity, error) + // anchorPoisoned is test-only injection. Production always reads the + // process-global state-anchor barrier through + // frostsigning.NativeTBTCSignerStateAnchorPoisoned. The seam exists because + // that barrier is a package-global in another package with no exported way + // to poison it, and poisoning it for real inside a test would latch a + // terminal failure into every later test in this process. + anchorPoisoned func() error reserved frostNativeSignerAnchorCapacity } @@ -268,6 +279,42 @@ func (controller *frostNativeSignerAnchorAdmissionController) reserve( ) } + // A terminally poisoned state anchor is the one refusal reason this + // controller cannot otherwise see. currentRestartableHeadroom reads the + // witness tip straight through the inventory bridge and authenticates it + // against the anchor service; neither path takes the request-taking + // barrier, so a barrier that has already latched a terminal failure still + // reports perfectly healthy headroom here. Without this check the node goes + // on admitting pre-sign, DKG and DKG-retirement workflows whose every + // request-taking signer call will be refused with + // ErrNativeTBTCSignerStateAnchorTerminal - it accepts work it cannot + // finish, and the wallet loses this member's seats toward its signing + // threshold with no node reporting a cause. + // + // This is checked before the headroom read, and outside the admission + // mutex, because it is both cheaper (one atomic load against an anchor + // round trip) and strictly more terminal than anything the headroom can + // say: nothing clears poisoned in-process, so no later headroom value can + // make this workflow admissible. Racing a poisoning that lands immediately + // after this load is harmless - the reservation is then made and the + // workflow's own calls are refused by the barrier, which is the behavior + // that already existed. + anchorPoisoned := frostsigning.NativeTBTCSignerStateAnchorPoisoned + if controller.anchorPoisoned != nil { + anchorPoisoned = controller.anchorPoisoned + } + if poisoned := anchorPoisoned(); poisoned != nil { + recordFrostNativeSignerAnchorPoisonedRejection() + return nil, fmt.Errorf( + "%s is blocked because this node's native signer state anchor is "+ + "terminally poisoned: [%w]; every request-taking native signer "+ + "call is refused until this process is restarted, and only a "+ + "process restart clears it", + workflow, + poisoned, + ) + } + controller.mutex.Lock() defer controller.mutex.Unlock() @@ -284,6 +331,7 @@ func (controller *frostNativeSignerAnchorAdmissionController) reserve( headroom.Generations, ) if minimumHeadroom <= FrostNativeSignerAnchorRotationWarningHeadroom { + recordFrostNativeSignerAnchorRotationFloorRejection() return nil, fmt.Errorf( "%s is blocked with revision/generation headroom [%d/%d]; "+ "offline anchor rotation is required before admitting new work", @@ -292,11 +340,25 @@ func (controller *frostNativeSignerAnchorAdmissionController) reserve( headroom.Generations, ) } + // Both unreserved-headroom refusals name the same remedy as the + // rotation-floor refusal above. They are not a smaller version of it: a + // node refused here still has most of both windows unspent - a four-seat + // node is refused its next maximum-size sweep with 3508 of 4096 generations + // left - so the number in the message reads healthy and the refusal looks + // transient. It is not. Neither window refills on its own; only an + // offline-authorized rotation moves the certified floor forward, so this is + // the first message an operator of a correctly configured node sees, and it + // has to say what to do about it rather than leave rotation to be inferred + // from the far rarer floor refusal. if controller.reserved.Revisions > headroom.Revisions || cost.Revisions > headroom.Revisions-controller.reserved.Revisions { + recordFrostNativeSignerAnchorUnreservedHeadroomRejection() return nil, fmt.Errorf( - "%s requires [%d] anchor revisions but only [%d] are unreserved", + "%s requires [%d] anchor revisions but only [%d] are unreserved; "+ + "the certified history window does not refill on its own, so "+ + "offline anchor rotation is required before this workflow can "+ + "be admitted", workflow, cost.Revisions, headroom.Revisions-controller.reserved.Revisions, @@ -305,8 +367,12 @@ func (controller *frostNativeSignerAnchorAdmissionController) reserve( if controller.reserved.Generations > headroom.Generations || cost.Generations > headroom.Generations-controller.reserved.Generations { + recordFrostNativeSignerAnchorUnreservedHeadroomRejection() return nil, fmt.Errorf( - "%s requires [%d] signer generations but only [%d] are unreserved", + "%s requires [%d] signer generations but only [%d] are unreserved; "+ + "the certified proof window does not refill on its own, so "+ + "offline anchor rotation is required before this workflow can "+ + "be admitted", workflow, cost.Generations, headroom.Generations-controller.reserved.Generations, @@ -459,6 +525,7 @@ func frostPreSignMaximumAnchorCapacityCost( return frostNativeSignerAnchorCapacity{}, err } if cost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents { + recordFrostNativeSignerAnchorSeatCeilingRejection() return frostNativeSignerAnchorCapacity{}, fmt.Errorf( "FROST pre-sign workflow over [%d] inputs with [%d] local seats "+ "requires [%d] anchor revisions, exceeding the certified history "+ @@ -475,6 +542,7 @@ func frostPreSignMaximumAnchorCapacityCost( ) } if cost.Generations > FrostNativeSignerAnchorMaximumHistoryProofEntries { + recordFrostNativeSignerAnchorSeatCeilingRejection() return frostNativeSignerAnchorCapacity{}, fmt.Errorf( "FROST pre-sign workflow over [%d] inputs with [%d] local seats "+ "requires [%d] signer generations, exceeding the certified proof "+ @@ -667,3 +735,222 @@ func frostPreSignAnchoredWorkflowCost( Generations: generations, }, nil } + +// frostNativeSignerAnchorWorkloadRotationWarning reports whether this node +// should already be arranging an offline anchor rotation, measured against the +// work it can actually be asked to admit rather than against a flat number. +// +// localSeatCount is the largest number of local seats this node holds in any +// one wallet, because a pre-sign workflow is per wallet and reserves against +// that wallet's local member indexes. It is a parameter rather than something +// derived here because this file has no view of the node's sortition result; +// guessing it would be worse than requiring the caller that does know it to +// say so. +// +// The flat floor (FrostNativeSignerAnchorRotationWarningHeadroom, 256) is kept +// as a hard lower bound, and this condition is added to it rather than +// substituted for it, because the two guard different things. The flat floor +// guards the rotation-blocked refusal in reserve(): at or below it ALL new work +// is refused regardless of size, so a node that reaches it has already stopped +// signing outright. That makes it a useless warning threshold, because +// admission stops long before it is reached. A node holding four local seats - +// an ordinary sortition result, and the ceiling for the standard 21-input +// sweep - reserves 3615 generations for one maximum-size workflow, so its next +// sweep is refused with 3508 of the 4096 generations still unspent, roughly +// 3359 generations before the flat warning would fire. An operator alerting on +// the flat flag alone learns that the node stopped signing long after it +// stopped signing. +// +// Each window is compared against its own dimension's cost rather than +// comparing the smaller headroom against the generation cost. The generation +// cost is about twice the revision cost, so collapsing the two would raise the +// warning on the revision window for a shortfall that only the generation +// window has. +// +// The measuring stick is deliberately the largest ADMISSIBLE workflow, not +// unconditionally a 21-input batch. A node above the seat ceiling can never be +// admitted for 21 inputs at all, and its 21-input cost exceeds the whole +// 4096-entry window, so measuring it that way would latch this warning on for +// the life of the process; because the warning also forces the activation +// handshake's top-level health to false, that would take an otherwise healthy +// five-seat node out of service for a seat-ceiling exclusion no rotation can +// fix. frostPreSignMaximumAdmissibleInputCount reports what this seat count can +// actually be admitted for, so a five-seat node is measured against its own +// 19-input maximum. +// +// Native DKG and DKG retirement share the same admission controller but reserve +// two capacity units per local seat - orders of magnitude below a pre-sign +// workflow - so the pre-sign cost bounds them too and they need no separate +// term. +func frostNativeSignerAnchorWorkloadRotationWarning( + revisionHeadroom uint64, + generationHeadroom uint64, + localSeatCount uint64, +) bool { + if frostNativeSignerAnchorRotationWarning( + minFrostNativeSignerAnchorHeadroom( + revisionHeadroom, + generationHeadroom, + ), + ) { + return true + } + cost, admissible := + frostNativeSignerAnchorLargestAdmissibleWorkflowCost(localSeatCount) + if !admissible { + return false + } + return revisionHeadroom <= cost.Revisions || + generationHeadroom <= cost.Generations +} + +// frostNativeSignerAnchorLargestAdmissibleWorkflowCost reports the worst-case +// capacity one pre-sign workflow can reserve on a node holding this many local +// seats in a single wallet, together with whether any workflow is admissible at +// all. +// +// It reports false, rather than a cost, for a seat count that no batch size can +// serve and for a seat count of zero. Both mean the same thing for this +// purpose: pre-sign admission is not what will consume the windows, so only the +// flat rotation floor is a meaningful warning threshold, and inventing a cost +// here would warn permanently on a node whose seats a rotation cannot restore. +func frostNativeSignerAnchorLargestAdmissibleWorkflowCost( + localSeatCount uint64, +) (frostNativeSignerAnchorCapacity, bool) { + admissibleInputs := frostPreSignMaximumAdmissibleInputCount( + localSeatCount, + signingAttemptsLimit, + ) + if admissibleInputs == 0 { + return frostNativeSignerAnchorCapacity{}, false + } + cost, err := frostPreSignAnchoredWorkflowCost( + admissibleInputs, + localSeatCount, + signingAttemptsLimit, + ) + if err != nil { + return frostNativeSignerAnchorCapacity{}, false + } + return cost, true +} + +// FROST native signer anchor admission rejection counters. +// +// A node that stops admitting FROST work does so silently: the refusals reach +// the log wrapped inside a generic wallet-action failure, and nothing anchored +// is registered with clientinfo at all, so a monitoring system cannot tell a +// node that is refusing every signing request from one that simply has not been +// asked. These process-wide cumulative counters make each refusal cause +// countable on its own, because the four causes need four different responses: +// +// - seatCeiling is a CONFIGURATION signal. It never clears on its own and no +// rotation fixes it: this node holds more local seats than the certified +// windows can serve at the requested batch size, and those seats are lost to +// the wallet's signing threshold until the operator sheds them. Any non-zero +// value should alert. +// - poisoned is a TERMINAL signal. The state anchor has latched a permanent +// failure and only a process restart clears it. Any non-zero value should +// alert. +// - unreservedHeadroom is the ROTATION-DUE signal. It is the first refusal a +// healthy, correctly configured node produces, and it arrives while most of +// both windows are still unspent. +// - rotationFloor is the ROTATION-OVERDUE signal. All new work is already +// being refused regardless of size. +// +// They follow the roast_interactive_signing_metrics.go pattern, are emitted in +// every build, and stay at zero until an admission is actually refused - so +// they are inert by default and registering them activates no behavior. +var ( + frostNativeSignerAnchorSeatCeilingRejections atomic.Uint64 + frostNativeSignerAnchorUnreservedHeadroomRejections atomic.Uint64 + frostNativeSignerAnchorRotationFloorRejections atomic.Uint64 + frostNativeSignerAnchorPoisonedRejections atomic.Uint64 +) + +// frostNativeSignerAnchorAdmissionMetricsApplication is the clientinfo +// application-label prefix; the registry concatenates it with each per-source +// name, so the final labels look like +// "frost_native_signer_anchor_admission_poisoned_rejected_total". +const frostNativeSignerAnchorAdmissionMetricsApplication = "frost_native_signer_anchor_admission" + +const ( + frostNativeSignerAnchorSeatCeilingMetricName = "seat_ceiling_rejected_total" + frostNativeSignerAnchorUnreservedHeadroomMetricName = "unreserved_headroom_rejected_total" + frostNativeSignerAnchorRotationFloorMetricName = "rotation_floor_rejected_total" + frostNativeSignerAnchorPoisonedMetricName = "poisoned_rejected_total" +) + +// RegisterFrostNativeSignerAnchorAdmissionMetrics registers the cumulative +// anchor-admission rejection counters with the supplied clientinfo registry. +// The node's startup sequence calls it alongside +// frostsigning.RegisterInteractiveSigningMetrics so the counters appear in the +// Prometheus scrape; without that call they increment internally and never +// reach an operator. A nil registry is a no-op. +func RegisterFrostNativeSignerAnchorAdmissionMetrics( + registry *clientinfo.Registry, +) { + if registry == nil { + return + } + registry.ObserveApplicationSource( + frostNativeSignerAnchorAdmissionMetricsApplication, + map[string]clientinfo.Source{ + frostNativeSignerAnchorSeatCeilingMetricName: func() float64 { + return float64( + frostNativeSignerAnchorSeatCeilingRejections.Load(), + ) + }, + frostNativeSignerAnchorUnreservedHeadroomMetricName: func() float64 { + return float64( + frostNativeSignerAnchorUnreservedHeadroomRejections.Load(), + ) + }, + frostNativeSignerAnchorRotationFloorMetricName: func() float64 { + return float64( + frostNativeSignerAnchorRotationFloorRejections.Load(), + ) + }, + frostNativeSignerAnchorPoisonedMetricName: func() float64 { + return float64( + frostNativeSignerAnchorPoisonedRejections.Load(), + ) + }, + }, + ) +} + +// recordFrostNativeSignerAnchorSeatCeilingRejection marks one workflow refused +// because its worst case cannot fit a certified restart window at this node's +// local seat count. +func recordFrostNativeSignerAnchorSeatCeilingRejection() { + frostNativeSignerAnchorSeatCeilingRejections.Add(1) +} + +// recordFrostNativeSignerAnchorUnreservedHeadroomRejection marks one workflow +// refused because the unreserved part of a certified window cannot cover it. +func recordFrostNativeSignerAnchorUnreservedHeadroomRejection() { + frostNativeSignerAnchorUnreservedHeadroomRejections.Add(1) +} + +// recordFrostNativeSignerAnchorRotationFloorRejection marks one workflow +// refused because a certified window has fallen to the rotation floor, where +// all new work is refused regardless of size. +func recordFrostNativeSignerAnchorRotationFloorRejection() { + frostNativeSignerAnchorRotationFloorRejections.Add(1) +} + +// recordFrostNativeSignerAnchorPoisonedRejection marks one workflow refused +// because the native signer state anchor is terminally poisoned. +func recordFrostNativeSignerAnchorPoisonedRejection() { + frostNativeSignerAnchorPoisonedRejections.Add(1) +} + +// resetFrostNativeSignerAnchorAdmissionMetricsForTest clears the cumulative +// counters. Exposed only for the package's own tests; not a production helper. +func resetFrostNativeSignerAnchorAdmissionMetricsForTest() { + frostNativeSignerAnchorSeatCeilingRejections.Store(0) + frostNativeSignerAnchorUnreservedHeadroomRejections.Store(0) + frostNativeSignerAnchorRotationFloorRejections.Store(0) + frostNativeSignerAnchorPoisonedRejections.Store(0) +} diff --git a/pkg/tbtc/frost_native_signer_anchor_admission_test.go b/pkg/tbtc/frost_native_signer_anchor_admission_test.go index d32dc4d32a..44d28c6a7f 100644 --- a/pkg/tbtc/frost_native_signer_anchor_admission_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_admission_test.go @@ -2,6 +2,8 @@ package tbtc import ( "context" + "errors" + "fmt" "strings" "testing" @@ -364,6 +366,417 @@ func TestFrostNativeSignerAnchorAdmissionController_RejectsStaleReadinessHeadroo } } +// TestFrostNativeSignerAnchorAdmissionController_RefusesWhilePoisoned pins the +// invariant that admission must not accept work this node cannot finish. The +// controller reads headroom through the anchor binding and the inventory +// bridge, neither of which takes the request-taking barrier, so a barrier that +// has latched a terminal failure leaves every headroom number here looking +// perfectly healthy. Without the poisoned check a poisoned node keeps admitting +// pre-sign and DKG workflows whose every signer call is already doomed, and the +// wallet loses this member's seats with no node reporting a cause. +func TestFrostNativeSignerAnchorAdmissionController_RefusesWhilePoisoned( + t *testing.T, +) { + resetFrostNativeSignerAnchorAdmissionMetricsForTest() + + poisonCause := fmt.Errorf( + "%w: post-mutation anchor advance did not match", + frostsigning.ErrNativeTBTCSignerStateAnchorTerminal, + ) + poisoned := poisonCause + controller := &frostNativeSignerAnchorAdmissionController{ + readHeadroom: func( + context.Context, + ) (frostNativeSignerAnchorCapacity, error) { + // Both windows completely unspent: nothing but the poison can + // refuse anything here. + return frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorMaximumHistoryEvents, + Generations: FrostNativeSignerAnchorMaximumHistoryProofEntries, + }, nil + }, + anchorPoisoned: func() error { return poisoned }, + } + snapshot := testFrostAnchorAdmissionReadinessSnapshot( + FrostNativeSignerAnchorMaximumHistoryEvents, + FrostNativeSignerAnchorMaximumHistoryProofEntries, + ) + + // Every reservation path shares reserve(), so pre-sign, native DKG and DKG + // retirement all have to refuse. DKG is not exempt: a poisoned node cannot + // persist a key package any more than it can sign. + for _, test := range []struct { + name string + reserve func() (*frostNativeSignerAnchorRevisionReservation, error) + }{ + { + name: "pre-sign", + reserve: func() ( + *frostNativeSignerAnchorRevisionReservation, + error, + ) { + return controller.reservePreSign( + context.Background(), + snapshot, + 1, + 1, + signingAttemptsLimit, + ) + }, + }, + { + name: "native DKG", + reserve: func() ( + *frostNativeSignerAnchorRevisionReservation, + error, + ) { + return controller.reserveDKG(context.Background(), 1) + }, + }, + { + name: "native DKG retirement", + reserve: func() ( + *frostNativeSignerAnchorRevisionReservation, + error, + ) { + return controller.reserveDKGRetirement(context.Background(), 1) + }, + }, + } { + reservation, err := test.reserve() + if err == nil { + reservation.Release() + t.Fatalf( + "[%s] was admitted while the state anchor is poisoned", + test.name, + ) + } + if !errors.Is( + err, + frostsigning.ErrNativeTBTCSignerStateAnchorTerminal, + ) { + t.Fatalf( + "[%s] refusal did not carry the terminal anchor cause: [%v]", + test.name, + err, + ) + } + // The refusal has to be operator-actionable on its own. Poisoning is + // latched on a package-global barrier and nothing clears it in + // process, so a message that does not name the restart leaves an + // operator waiting for a recovery that cannot arrive. + if !strings.Contains(err.Error(), "terminally poisoned") || + !strings.Contains(err.Error(), "restart") { + t.Fatalf( + "[%s] refusal did not name the cause and the remedy: [%v]", + test.name, + err, + ) + } + } + + if rejections := + frostNativeSignerAnchorPoisonedRejections.Load(); rejections != 3 { + t.Fatalf( + "poisoned rejections counted [%d], expected [3]", + rejections, + ) + } + // Nothing else refused, so no other cause may have been counted. A counter + // that fires on the wrong cause sends an operator to the wrong remedy. + if seatCeiling := frostNativeSignerAnchorSeatCeilingRejections.Load(); seatCeiling != 0 { + t.Fatalf("seat-ceiling rejections counted [%d]", seatCeiling) + } + if unreserved := frostNativeSignerAnchorUnreservedHeadroomRejections.Load(); unreserved != 0 { + t.Fatalf("unreserved-headroom rejections counted [%d]", unreserved) + } + if rotationFloor := frostNativeSignerAnchorRotationFloorRejections.Load(); rotationFloor != 0 { + t.Fatalf("rotation-floor rejections counted [%d]", rotationFloor) + } + + // The poison was the only thing refusing: with it cleared the identical + // reservation is admitted, so the refusals above cannot be explained by + // headroom. + poisoned = nil + reservation, err := controller.reservePreSign( + context.Background(), + snapshot, + 1, + 1, + signingAttemptsLimit, + ) + if err != nil { + t.Fatalf("healthy anchor refused an admissible workflow: [%v]", err) + } + reservation.Release() +} + +// TestFrostNativeSignerAnchorWorkloadRotationWarning pins the warning against +// the workload it is supposed to warn about. The flat floor fires only once all +// new work is already refused, which is far too late to be a warning: a +// four-seat node - an ordinary sortition result, and the ceiling for the +// standard 21-input sweep - stops admitting maximum-size batches thousands of +// generations before it. +func TestFrostNativeSignerAnchorWorkloadRotationWarning(t *testing.T) { + fourSeatCost, err := frostPreSignAnchoredWorkflowCost( + frostPreSignAuthorizationMaximumInputs, + 4, + signingAttemptsLimit, + ) + if err != nil { + t.Fatal(err) + } + if fourSeatCost.Revisions != 1806 || fourSeatCost.Generations != 3615 { + t.Fatalf("unexpected four-seat maximum-batch cost [%+v]", fourSeatCost) + } + // The gap the flat threshold leaves open, pinned as a number so that + // shrinking it is a deliberate act. + if gap := fourSeatCost.Generations - + uint64(FrostNativeSignerAnchorRotationWarningHeadroom); gap != 3359 { + t.Fatalf( + "the flat warning now fires [%d] generations after a four-seat "+ + "node stops signing, expected [3359]", + gap, + ) + } + + for _, test := range []struct { + name string + revisionHeadroom uint64 + generationHeadroom uint64 + localSeatCount uint64 + warning bool + }{ + // A fresh rotation warns about nothing. + { + name: "four seats, both windows unspent", + revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, + localSeatCount: 4, + warning: false, + }, + // One generation above the next workflow's cost is the last moment + // this node can still admit a maximum-size batch, so it is the last + // moment before the warning. + { + name: "four seats, one generation above the next workflow", + revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + generationHeadroom: 3616, + localSeatCount: 4, + warning: false, + }, + { + name: "four seats, exactly the next workflow's generations", + revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + generationHeadroom: 3615, + localSeatCount: 4, + warning: true, + }, + // Each window is measured against its own dimension's cost. The + // revision cost is about half the generation cost, so a revision + // shortfall has to be caught on the revision number. + { + name: "four seats, revision window at the next workflow's revisions", + revisionHeadroom: 1806, + generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, + localSeatCount: 4, + warning: true, + }, + { + name: "four seats, revision window one above", + revisionHeadroom: 1807, + generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, + localSeatCount: 4, + warning: false, + }, + // A node above the seat ceiling must not be warned forever. It can + // never be admitted for 21 inputs at all, so it is measured against + // the 19-input batch it can actually be admitted for; warning + // permanently would also force its activation-handshake health to + // false for an exclusion that no rotation can fix. + { + name: "five seats, both windows unspent", + revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, + localSeatCount: 5, + warning: false, + }, + { + name: "five seats, at its own admissible maximum", + revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + generationHeadroom: 4031, + localSeatCount: 5, + warning: true, + }, + // A seat count that can serve no batch at all leaves only the flat + // floor, which still has to work. + { + name: "no local seats, windows unspent", + revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, + localSeatCount: 0, + warning: false, + }, + { + name: "no local seats, at the flat floor", + revisionHeadroom: FrostNativeSignerAnchorRotationWarningHeadroom, + generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, + localSeatCount: 0, + warning: true, + }, + // The flat floor is kept, not replaced: it still fires for a seat + // count whose workload term would not have. + { + name: "one seat, at the flat floor", + revisionHeadroom: FrostNativeSignerAnchorRotationWarningHeadroom, + generationHeadroom: FrostNativeSignerAnchorRotationWarningHeadroom, + localSeatCount: 1, + warning: true, + }, + } { + if warning := frostNativeSignerAnchorWorkloadRotationWarning( + test.revisionHeadroom, + test.generationHeadroom, + test.localSeatCount, + ); warning != test.warning { + t.Fatalf( + "[%s] warned [%t], expected [%t]", + test.name, + warning, + test.warning, + ) + } + } + + // The whole point of the change: the flat predicate is still silent at the + // exact headroom where a four-seat node stops being able to sign a + // maximum-size batch. + if frostNativeSignerAnchorRotationWarning( + minFrostNativeSignerAnchorHeadroom( + FrostNativeSignerAnchorMaximumHistoryEvents, + fourSeatCost.Generations, + ), + ) { + t.Fatal( + "the flat rotation warning already fires at the four-seat " + + "workload threshold, so the workload-relative term adds nothing", + ) + } +} + +// TestFrostNativeSignerAnchorAdmissionRefusalsNameARemedy pins that every +// admission refusal an operator can hit says what to do about it, and that each +// one is countable on its own. The unreserved-headroom refusal is the one a +// healthy, correctly configured node produces first, and it arrives while most +// of both windows are still unspent - so a message that only reports the +// numbers reads as transient when it is not. +func TestFrostNativeSignerAnchorAdmissionRefusalsNameARemedy(t *testing.T) { + resetFrostNativeSignerAnchorAdmissionMetricsForTest() + + currentHeadroom := frostNativeSignerAnchorCapacity{} + controller := &frostNativeSignerAnchorAdmissionController{ + readHeadroom: func( + context.Context, + ) (frostNativeSignerAnchorCapacity, error) { + return currentHeadroom, nil + }, + } + + // Native DKG reserves one revision and one generation per persistence + // call, so 300 local seats cost exactly 600 of each and each dimension can + // be starved on its own while the other stays healthy. + currentHeadroom = frostNativeSignerAnchorCapacity{ + Revisions: 300, + Generations: 1000, + } + if _, err := controller.reserveDKG( + context.Background(), + 300, + ); err == nil || !strings.Contains(err.Error(), "anchor revisions") || + !strings.Contains(err.Error(), "offline anchor rotation is required") { + t.Fatalf("revision-dimension refusal did not name the remedy: [%v]", err) + } + + currentHeadroom = frostNativeSignerAnchorCapacity{ + Revisions: 1000, + Generations: 300, + } + if _, err := controller.reserveDKG( + context.Background(), + 300, + ); err == nil || !strings.Contains(err.Error(), "signer generations") || + !strings.Contains(err.Error(), "offline anchor rotation is required") { + t.Fatalf("generation-dimension refusal did not name the remedy: [%v]", err) + } + + // The sibling rotation-floor refusal keeps its own wording and its own + // counter, so a monitoring system can tell "rotation is due" from + // "rotation is overdue and everything is already refused". + currentHeadroom = frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorRotationWarningHeadroom, + Generations: 1000, + } + if _, err := controller.reserveDKG( + context.Background(), + 1, + ); err == nil || !strings.Contains(err.Error(), "is blocked with") || + !strings.Contains(err.Error(), "offline anchor rotation is required") { + t.Fatalf("rotation-floor refusal changed shape: [%v]", err) + } + + // The seat-ceiling refusal is a configuration signal no rotation fixes, so + // it must stay separately countable from the rotation causes. + if _, err := frostPreSignMaximumAnchorCapacityCost( + frostPreSignAuthorizationMaximumInputs, + 5, + signingAttemptsLimit, + ); err == nil { + t.Fatal("five local seats were admitted at the maximum batch size") + } + + for _, test := range []struct { + name string + counted uint64 + expected uint64 + }{ + { + name: "unreserved headroom", + counted: frostNativeSignerAnchorUnreservedHeadroomRejections.Load(), + expected: 2, + }, + { + name: "rotation floor", + counted: frostNativeSignerAnchorRotationFloorRejections.Load(), + expected: 1, + }, + { + name: "seat ceiling", + counted: frostNativeSignerAnchorSeatCeilingRejections.Load(), + expected: 1, + }, + { + name: "poisoned", + counted: frostNativeSignerAnchorPoisonedRejections.Load(), + expected: 0, + }, + } { + if test.counted != test.expected { + t.Fatalf( + "[%s] rejections counted [%d], expected [%d]", + test.name, + test.counted, + test.expected, + ) + } + } + + // Registration must never be what breaks a node that has no metrics + // endpoint configured. + RegisterFrostNativeSignerAnchorAdmissionMetrics(nil) + + resetFrostNativeSignerAnchorAdmissionMetricsForTest() +} + func testFrostAnchorAdmissionReadinessSnapshot( revisionHeadroom uint64, generationHeadroom uint64, From 725e3c06fdc65cf57e9d78fecbb76416eaece468 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 11:43:22 -0400 Subject: [PATCH 82/93] feat(tbtc): make a silently non-signing FROST node visible before activation Two conditions stop this node from signing without stopping the process, and neither was observable from outside the box. The state-anchor barrier latches terminally poisoned and refuses every later request-taking native call until a restart; the certified restart windows drain until admission refuses work. In both cases the node keeps running, keeps its wallet seats, and keeps attesting healthy on the signed activation handshake. In a permissioned FROST set several members can be in that state at once while every one of them attests healthy, and the only symptom is a wallet that stops reaching its signing threshold with no node reporting a cause. Three gauges and a counter are now registered with clientinfo, next to the ROAST and interactive-signing counters and on exactly the same terms: not gated on FROST being active, because a source that only appears once the gated path runs reports nothing during the window an operator is trying to observe. frost_native_signer_anchor_poisoned reads the barrier's lock-free mirror, so it is authoritative in every build the moment it is registered. The two headroom gauges publish the restartable revision and generation counts that until now existed only as JSON on the activation-handshake endpoint, whose validator requires a loopback host and so cannot be scraped from a monitoring host at all. The headroom pair is mirrored rather than recomputed on the scrape. Every accessor that can answer the question from live state goes through currentRestartableHeadroom, which reads the local tip and then authenticates it against the remote anchor while holding the anchor binding mutex - the same mutex CommitNativeTBTCSignerStateTransition holds across the remote CAS for the whole of a signing commit. Putting that on the clientinfo tick would stall the scrape behind an in-flight signing operation and put a network call on a timer, so the node publishes what the readiness reconciliation already computed instead. That is a decorator on the readiness verifier, so it covers the startup verification and every later pre-sign authorization without a second reader. Only successful reconciliations publish: zero is the value that means the windows are exhausted, and an unreachable anchor service must not be reported as that. headroom_observations_total exists so "nothing has ever reported headroom" is distinguishable from "the windows are empty" - both read as zero on the gauges, and alerts must require the counter to be non-zero. The seat ceiling is now stated when the wallet executor is built rather than weeks later. A node whose local seat count exceeds what anchor admission can serve for a maximum-size batch is refused every full-size deposit sweep for that wallet's whole life, and the refusal is whole-node - reservePreSign is charged for the complete local seat set at once - so all of its seats are lost to the wallet's signing threshold, not just the surplus ones. Under production parameters the ceiling is four seats: five seats tops out at 19 inputs and six at 16, which are ordinary sortition results. Until now the only place that said so was the text of an authorization the node had already been refused, which an operator first sees when a 20-deposit backlog forms. The warning names the seat count, the ceiling, the batch size that produced it, and the largest batch this node can actually sign, and it is emitted only for wallets that sign with Schnorr, since a FROST-enabled node builds a gate for its draining ECDSA wallets too. Co-Authored-By: Claude Fable 5 --- ...native_tbtc_signer_state_anchor_metrics.go | 190 +++++++++ ...e_tbtc_signer_state_anchor_metrics_test.go | 167 ++++++++ ...native_signer_anchor_observability_test.go | 381 ++++++++++++++++++ pkg/tbtc/node.go | 212 +++++++++- pkg/tbtc/tbtc.go | 9 + 5 files changed, 957 insertions(+), 2 deletions(-) create mode 100644 pkg/frost/signing/native_tbtc_signer_state_anchor_metrics.go create mode 100644 pkg/frost/signing/native_tbtc_signer_state_anchor_metrics_test.go create mode 100644 pkg/tbtc/frost_native_signer_anchor_observability_test.go diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_metrics.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_metrics.go new file mode 100644 index 0000000000..b6b18fba1d --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_metrics.go @@ -0,0 +1,190 @@ +package signing + +import ( + "sync/atomic" + + "github.com/keep-network/keep-core/pkg/clientinfo" +) + +// Native tBTC signer state-anchor observability. +// +// Two independent conditions stop this node from signing without stopping the +// process, and until now neither of them was scrapeable: +// +// - the state-anchor barrier latches terminally poisoned, after which every +// request-taking native signer call is refused until the process is +// restarted. The node keeps running, keeps its wallet seats, and keeps +// attesting healthy on the activation handshake; it simply never produces +// another signature. In a permissioned FROST set several members can be in +// that state at once while every one of them attests healthy, and the only +// visible symptom is a wallet that stops reaching its signing threshold +// with no node reporting a cause. +// +// - the certified restart windows drain. Revision and generation headroom +// shrink as anchored work commits and are only replenished by the offline +// rotation ceremony, so an operator needs to watch them fall in order to +// schedule that ceremony before admission starts refusing work outright. +// Both numbers existed only as JSON on the activation-handshake endpoint, +// whose validator requires a loopback host, so nothing off-box could read +// them. +// +// These sources follow roast_retry_metrics.go and +// roast_interactive_signing_metrics.go: package-level state, one +// Register*Metrics helper called once from the node startup sequence, and no +// gating on whether FROST is actually active. The gating point matters and is +// the same reasoning tbtc.go already records for the ROAST counters: a source +// that is registered only once the gated path runs is a source that reports +// nothing during exactly the window an operator is trying to observe. +// +// Every source here is a plain atomic load. That is a hard requirement rather +// than a preference: clientinfo drives each source from its own goroutine on a +// fixed tick, and the accessors that could answer these questions from live +// state cannot be called from there. NativeTBTCSignerStateAnchorPoisoned reads +// the barrier's lock-free mirror for that reason, and the headroom pair is +// mirrored here rather than recomputed because computing it requires the +// anchor binding mutex - held across a remote CAS for the whole duration of a +// signing commit - plus an authenticated read of the remote anchor service. A +// scrape must not stall behind a signing operation and must not put a network +// call on a timer, so the last value observed by the paths that already +// compute it is the correct thing to publish. +var ( + // nativeTBTCSignerStateAnchorHeadroomSignal mirrors the most recent + // restartable headroom pair. It is a single pointer rather than two + // counters so a scrape can never pair a fresh revision count with a stale + // generation count; the two are only meaningful together, because + // admission refuses work as soon as EITHER dimension runs out. + nativeTBTCSignerStateAnchorHeadroomSignal atomic.Pointer[nativeTBTCSignerStateAnchorHeadroomRecord] + + // nativeTBTCSignerStateAnchorHeadroomObservations counts how many times + // the headroom mirror has been refreshed. It is what makes the two + // headroom gauges interpretable: a node that has never run an anchored + // workflow - or is not a FROST node at all - publishes zero headroom + // simply because nothing has ever reported any, and that is + // indistinguishable from genuinely exhausted windows unless this counter + // is consulted. Alerts on the headroom gauges MUST require + // headroom_observations_total > 0, and its rate is also the staleness + // signal for the two gauges. + nativeTBTCSignerStateAnchorHeadroomObservations atomic.Uint64 +) + +// nativeTBTCSignerStateAnchorHeadroomRecord is the immutable snapshot stored +// in the mirror. Values are replaced, never mutated in place. +type nativeTBTCSignerStateAnchorHeadroomRecord struct { + revisions uint64 + generations uint64 +} + +// nativeTBTCSignerStateAnchorMetricsApplication is the clientinfo +// application-label prefix; the registry concatenates it with each per-source +// name, so the final labels look like "frost_native_signer_anchor_poisoned". +const nativeTBTCSignerStateAnchorMetricsApplication = "frost_native_signer_anchor" + +const ( + nativeTBTCSignerStateAnchorPoisonedMetricName = "poisoned" + nativeTBTCSignerStateAnchorRevisionHeadroomMetricName = "restartable_revision_headroom" + nativeTBTCSignerStateAnchorGenerationHeadroomMetricName = "restartable_generation_headroom" + nativeTBTCSignerStateAnchorHeadroomObservationsMetricName = "headroom_observations_total" +) + +// RegisterNativeTBTCSignerStateAnchorMetrics registers the state-anchor health +// and capacity sources with the supplied clientinfo registry. Operators call +// this from the node's startup sequence, alongside RegisterRoastRetryMetrics +// and RegisterInteractiveSigningMetrics. A nil registry is a no-op. +// +// The poisoned source is authoritative the moment it is registered: it reads +// the barrier mirror directly, so it reports the real state on any node in any +// build without anything else having to be wired. The two headroom sources +// report whatever RecordNativeTBTCSignerStateAnchorRestartableHeadroom last +// published, and read zero until then - see the observations counter above. +func RegisterNativeTBTCSignerStateAnchorMetrics(registry *clientinfo.Registry) { + if registry == nil { + return + } + registry.ObserveApplicationSource( + nativeTBTCSignerStateAnchorMetricsApplication, + nativeTBTCSignerStateAnchorMetricSources(), + ) +} + +// nativeTBTCSignerStateAnchorMetricSources builds the exact source set +// RegisterNativeTBTCSignerStateAnchorMetrics hands to the registry. It is +// separate so the published names and the values behind them can be asserted +// without standing up a Prometheus registry - a source that reads the wrong +// state is indistinguishable from a correct one until something reads it. +func nativeTBTCSignerStateAnchorMetricSources() map[string]clientinfo.Source { + return map[string]clientinfo.Source{ + nativeTBTCSignerStateAnchorPoisonedMetricName: func() float64 { + if NativeTBTCSignerStateAnchorPoisoned() != nil { + return 1 + } + return 0 + }, + nativeTBTCSignerStateAnchorRevisionHeadroomMetricName: func() float64 { + record := nativeTBTCSignerStateAnchorHeadroomSignal.Load() + if record == nil { + return 0 + } + return float64(record.revisions) + }, + nativeTBTCSignerStateAnchorGenerationHeadroomMetricName: func() float64 { + record := nativeTBTCSignerStateAnchorHeadroomSignal.Load() + if record == nil { + return 0 + } + return float64(record.generations) + }, + nativeTBTCSignerStateAnchorHeadroomObservationsMetricName: func() float64 { + return float64( + nativeTBTCSignerStateAnchorHeadroomObservations.Load(), + ) + }, + } +} + +// RecordNativeTBTCSignerStateAnchorRestartableHeadroom publishes the +// restartable revision and generation headroom last computed by a caller that +// already had to compute it. It is deliberately a dumb setter: it performs no +// I/O, takes no lock, and never fails, so it is safe to call from inside a +// path that is holding the anchor or admission mutex, which is exactly where +// these numbers become available. +// +// Callers must pass an authenticated pair. This is an observability mirror and +// nothing reads it back to make a decision, so a wrong value here misleads an +// operator but cannot admit work; even so, publishing an unauthenticated +// reading would defeat the purpose of the gauge. +func RecordNativeTBTCSignerStateAnchorRestartableHeadroom( + revisions uint64, + generations uint64, +) { + nativeTBTCSignerStateAnchorHeadroomSignal.Store( + &nativeTBTCSignerStateAnchorHeadroomRecord{ + revisions: revisions, + generations: generations, + }, + ) + nativeTBTCSignerStateAnchorHeadroomObservations.Add(1) +} + +// NativeTBTCSignerStateAnchorRestartableHeadroom returns the mirrored headroom +// pair and whether anything has ever published one. Exposed so a caller can +// assert on what the gauges will report without reaching into package state. +func NativeTBTCSignerStateAnchorRestartableHeadroom() ( + revisions uint64, + generations uint64, + observed bool, +) { + record := nativeTBTCSignerStateAnchorHeadroomSignal.Load() + if record == nil { + return 0, 0, false + } + return record.revisions, record.generations, true +} + +// resetNativeTBTCSignerStateAnchorMetricsForTest clears the headroom mirror +// and its observation counter. Exposed only for the package's own tests; not a +// production helper. The poisoned mirror is owned by the barrier and is not +// touched here. +func resetNativeTBTCSignerStateAnchorMetricsForTest() { + nativeTBTCSignerStateAnchorHeadroomSignal.Store(nil) + nativeTBTCSignerStateAnchorHeadroomObservations.Store(0) +} diff --git a/pkg/frost/signing/native_tbtc_signer_state_anchor_metrics_test.go b/pkg/frost/signing/native_tbtc_signer_state_anchor_metrics_test.go new file mode 100644 index 0000000000..f37c66900a --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_metrics_test.go @@ -0,0 +1,167 @@ +package signing + +import ( + "errors" + "testing" +) + +// poisonGlobalNativeTBTCSignerStateAnchorBarrierForTest latches the package +// barrier the way a real failure would, through the one function that is +// allowed to do it, so the test observes the same mirror production callers +// observe rather than a value it wrote itself. +func poisonGlobalNativeTBTCSignerStateAnchorBarrierForTest(cause error) { + barrier := &globalNativeTBTCSignerStateAnchorBarrier + barrier.mutex.Lock() + defer barrier.mutex.Unlock() + recordNativeTBTCSignerStateAnchorPoisoning(barrier, cause) +} + +func nativeTBTCSignerStateAnchorMetricSourceForTest( + t *testing.T, + name string, +) float64 { + t.Helper() + sources := nativeTBTCSignerStateAnchorMetricSources() + source, ok := sources[name] + if !ok { + t.Fatalf("metric source [%s] is not registered", name) + } + return source() +} + +// The poisoned gauge is the whole point of the lane: a poisoned node keeps +// running and keeps attesting healthy, so the only way an operator learns it +// has stopped signing is a scrapeable signal that tracks the barrier's latched +// state. +func TestNativeTBTCSignerStateAnchorMetrics_PoisonedGaugeTracksTheBarrier( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorBarrierForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorBarrierForTest) + + if got := nativeTBTCSignerStateAnchorMetricSourceForTest( + t, + nativeTBTCSignerStateAnchorPoisonedMetricName, + ); got != 0 { + t.Fatalf("poisoned gauge on a healthy barrier: got %v want 0", got) + } + + poisonGlobalNativeTBTCSignerStateAnchorBarrierForTest( + errors.New("anchor forked its certified floor"), + ) + + if got := nativeTBTCSignerStateAnchorMetricSourceForTest( + t, + nativeTBTCSignerStateAnchorPoisonedMetricName, + ); got != 1 { + t.Fatalf("poisoned gauge on a poisoned barrier: got %v want 1", got) + } +} + +// The headroom gauges must report the last published pair, and the +// observations counter must distinguish "nothing has ever reported headroom" +// from "the certified windows are exhausted" - both of which read as zero on +// the gauges themselves. +func TestNativeTBTCSignerStateAnchorMetrics_HeadroomGaugesMirrorTheLastReading( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorMetricsForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorMetricsForTest) + + if got := nativeTBTCSignerStateAnchorMetricSourceForTest( + t, + nativeTBTCSignerStateAnchorHeadroomObservationsMetricName, + ); got != 0 { + t.Fatalf("observations before any reading: got %v want 0", got) + } + if _, _, observed := + NativeTBTCSignerStateAnchorRestartableHeadroom(); observed { + t.Fatal("headroom reported as observed before any reading") + } + + RecordNativeTBTCSignerStateAnchorRestartableHeadroom(4000, 3900) + RecordNativeTBTCSignerStateAnchorRestartableHeadroom(1234, 567) + + if got := nativeTBTCSignerStateAnchorMetricSourceForTest( + t, + nativeTBTCSignerStateAnchorRevisionHeadroomMetricName, + ); got != 1234 { + t.Fatalf("revision headroom gauge: got %v want 1234", got) + } + if got := nativeTBTCSignerStateAnchorMetricSourceForTest( + t, + nativeTBTCSignerStateAnchorGenerationHeadroomMetricName, + ); got != 567 { + t.Fatalf("generation headroom gauge: got %v want 567", got) + } + if got := nativeTBTCSignerStateAnchorMetricSourceForTest( + t, + nativeTBTCSignerStateAnchorHeadroomObservationsMetricName, + ); got != 2 { + t.Fatalf("observations after two readings: got %v want 2", got) + } + + revisions, generations, observed := + NativeTBTCSignerStateAnchorRestartableHeadroom() + if !observed || revisions != 1234 || generations != 567 { + t.Fatalf( + "accessor: got (%d, %d, %v) want (1234, 567, true)", + revisions, + generations, + observed, + ) + } +} + +// Exhausted windows must be reportable as genuinely zero, not swallowed as +// "never observed": that is precisely the reading an operator alerts on. +func TestNativeTBTCSignerStateAnchorMetrics_ExhaustedHeadroomIsObserved( + t *testing.T, +) { + resetNativeTBTCSignerStateAnchorMetricsForTest() + t.Cleanup(resetNativeTBTCSignerStateAnchorMetricsForTest) + + RecordNativeTBTCSignerStateAnchorRestartableHeadroom(0, 0) + + if got := nativeTBTCSignerStateAnchorMetricSourceForTest( + t, + nativeTBTCSignerStateAnchorHeadroomObservationsMetricName, + ); got != 1 { + t.Fatalf("observations after an exhausted reading: got %v want 1", got) + } + if _, _, observed := + NativeTBTCSignerStateAnchorRestartableHeadroom(); !observed { + t.Fatal("an exhausted reading must still count as observed") + } +} + +func TestNativeTBTCSignerStateAnchorMetrics_RegisteredSourceNames(t *testing.T) { + sources := nativeTBTCSignerStateAnchorMetricSources() + for _, name := range []string{ + nativeTBTCSignerStateAnchorPoisonedMetricName, + nativeTBTCSignerStateAnchorRevisionHeadroomMetricName, + nativeTBTCSignerStateAnchorGenerationHeadroomMetricName, + nativeTBTCSignerStateAnchorHeadroomObservationsMetricName, + } { + if _, ok := sources[name]; !ok { + t.Errorf("metric source [%s] is not registered", name) + } + } + if len(sources) != 4 { + t.Errorf("registered source count: got %d want 4", len(sources)) + } + if nativeTBTCSignerStateAnchorMetricsApplication != + "frost_native_signer_anchor" { + t.Errorf( + "application prefix changed to [%s]; scrape labels and any "+ + "operator alerts built on them move with it", + nativeTBTCSignerStateAnchorMetricsApplication, + ) + } +} + +func TestRegisterNativeTBTCSignerStateAnchorMetrics_NilRegistryIsNoOp( + t *testing.T, +) { + RegisterNativeTBTCSignerStateAnchorMetrics(nil) // must not panic +} diff --git a/pkg/tbtc/frost_native_signer_anchor_observability_test.go b/pkg/tbtc/frost_native_signer_anchor_observability_test.go new file mode 100644 index 0000000000..a5b7871ab7 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_observability_test.go @@ -0,0 +1,381 @@ +package tbtc + +import ( + "context" + "errors" + "strings" + "testing" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +// The warning must fire on exactly the seat counts anchor admission would +// refuse for a maximum-size batch, and stay quiet on the rest. This is the +// coupling that matters: a warning that fires on an admissible seat count is +// noise an operator learns to ignore, and one that stays quiet on an +// inadmissible one leaves the wallet to discover the exclusion weeks later, +// when a full-size sweep first forms. +func TestFrostPreSignLocalSeatCeilingWarning_MatchesAdmission(t *testing.T) { + const maximumInputs = uint64(frostPreSignAuthorizationMaximumInputs) + + for seats := uint64(1); seats <= 8; seats++ { + _, admissionErr := frostPreSignMaximumAnchorCapacityCost( + maximumInputs, + seats, + signingAttemptsLimit, + ) + warning, warned := frostPreSignLocalSeatCeilingWarning( + seats, + maximumInputs, + signingAttemptsLimit, + ) + if warned != (admissionErr != nil) { + t.Errorf( + "[%d] local seats: warned=%v but admission refusal=%v", + seats, + warned, + admissionErr != nil, + ) + } + if !warned && warning != "" { + t.Errorf( + "[%d] local seats: no warning expected but text was [%s]", + seats, + warning, + ) + } + } +} + +// The production ceiling is four local seats at the 21-input maximum batch and +// five signing attempts, and a five-seat node tops out at 19 inputs. Those are +// the numbers the admission accounting documents; pinning them here means a +// change to the certified windows or the attempt limit shows up as a failing +// test rather than as a silently different operator warning. +func TestFrostPreSignLocalSeatCeilingWarning_ProductionParameters(t *testing.T) { + const maximumInputs = uint64(frostPreSignAuthorizationMaximumInputs) + + if ceiling := frostPreSignMaximumAdmissibleLocalSeatCount( + maximumInputs, + signingAttemptsLimit, + ); ceiling != 4 { + t.Fatalf("production local seat ceiling: got %d want 4", ceiling) + } + + if _, warned := frostPreSignLocalSeatCeilingWarning( + 4, + maximumInputs, + signingAttemptsLimit, + ); warned { + t.Error("a four-seat node is admissible and must not be warned") + } + + warning, warned := frostPreSignLocalSeatCeilingWarning( + 5, + maximumInputs, + signingAttemptsLimit, + ) + if !warned { + t.Fatal("a five-seat node is above the ceiling and must be warned") + } + // The operator needs all four numbers to act: how many seats it holds, the + // ceiling it is over, the batch size that produced the ceiling, and the + // largest batch it can actually sign. + for _, want := range []string{"[5]", "[4]", "[21]", "[19]"} { + if !strings.Contains(warning, want) { + t.Errorf("warning is missing %s: [%s]", want, warning) + } + } + // The exclusion is whole-node, not just the surplus seat, because + // reservePreSign is charged for the complete local seat set at once. + if !strings.Contains(warning, "signing threshold") { + t.Errorf( + "warning does not name the consequence for the wallet: [%s]", + warning, + ) + } + + sixSeatWarning, warned := frostPreSignLocalSeatCeilingWarning( + 6, + maximumInputs, + signingAttemptsLimit, + ) + if !warned || !strings.Contains(sixSeatWarning, "[16]") { + t.Errorf( + "a six-seat node tops out at 16 inputs; warning was [%s]", + sixSeatWarning, + ) + } +} + +// An unset attempt limit must be read the way authorize reads it, otherwise +// the warning describes a ceiling admission never applies. +func TestFrostPreSignLocalSeatCeilingWarning_ZeroAttemptsUsesTheDefault( + t *testing.T, +) { + const maximumInputs = uint64(frostPreSignAuthorizationMaximumInputs) + + zeroWarning, zeroWarned := frostPreSignLocalSeatCeilingWarning( + 5, + maximumInputs, + 0, + ) + defaultWarning, defaultWarned := frostPreSignLocalSeatCeilingWarning( + 5, + maximumInputs, + signingAttemptsLimit, + ) + if zeroWarned != defaultWarned || zeroWarning != defaultWarning { + t.Fatalf( + "zero attempts did not fall back to the default limit: [%s] vs [%s]", + zeroWarning, + defaultWarning, + ) + } +} + +// A seat count that can sign no batch size at all must still be reported, and +// must not offer a largest admissible batch of zero inputs as if it were a +// usable setting. Under today's constants this needs a seat count admission +// rejects outright - at the 100-seat maximum a node can still sign a +// single-input batch - so the case is defensive, and it is covered because the +// arithmetic that makes it unreachable is exactly the arithmetic a constant +// change would move. +func TestFrostPreSignLocalSeatCeilingWarning_NoAdmissibleBatch(t *testing.T) { + const maximumInputs = uint64(frostPreSignAuthorizationMaximumInputs) + + if admissible := frostPreSignMaximumAdmissibleInputCount( + frostPreSignAuthorizationMaximumSeats, + signingAttemptsLimit, + ); admissible == 0 { + t.Fatalf( + "the maximum seat count now signs nothing; this test needs a seat "+ + "count admission rejects outright, not [%d]", + frostPreSignAuthorizationMaximumSeats, + ) + } + + warning, warned := frostPreSignLocalSeatCeilingWarning( + frostPreSignAuthorizationMaximumSeats+1, + maximumInputs, + signingAttemptsLimit, + ) + if !warned { + t.Fatal("a seat count that can sign nothing must be warned") + } + if !strings.Contains(warning, "no batch size at all") { + t.Errorf( + "warning does not say the node can sign nothing: [%s]", + warning, + ) + } +} + +type stubFrostProductionSignerReadinessVerifier struct { + snapshot *frostProductionSignerReadinessSnapshot + err error + unchangedErr error + unchangedCalls int + reconcileCalls int + lastFinality FrostPreSignFinality + lastUnchangedIn *frostProductionSignerReadinessSnapshot +} + +func (stub *stubFrostProductionSignerReadinessVerifier) verifyFrostProductionSignerReadiness( + _ context.Context, + finality FrostPreSignFinality, +) (*frostProductionSignerReadinessSnapshot, error) { + stub.reconcileCalls++ + stub.lastFinality = finality + return stub.snapshot, stub.err +} + +func (stub *stubFrostProductionSignerReadinessVerifier) verifyFrostProductionSignerReadinessUnchanged( + _ context.Context, + snapshot *frostProductionSignerReadinessSnapshot, +) error { + stub.unchangedCalls++ + stub.lastUnchangedIn = snapshot + return stub.unchangedErr +} + +func readinessSnapshotWithHeadroom( + revisions uint64, + generations uint64, +) *frostProductionSignerReadinessSnapshot { + return &frostProductionSignerReadinessSnapshot{ + Inventory: &frostNativeSignerInventorySnapshot{ + RestartableRevisionHeadroom: revisions, + RestartableGenerationHeadroom: generations, + }, + } +} + +// publishFrostNativeSignerAnchorHeadroomForTest drives one successful +// reconciliation through the real observer so the process-wide mirror holds a +// known reading. The mirror is last-write-wins and has no un-observe, so every +// test below establishes its own baseline this way rather than depending on +// the order tests run in. +func publishFrostNativeSignerAnchorHeadroomForTest( + t *testing.T, + revisions uint64, + generations uint64, +) { + t.Helper() + if _, err := newFrostNativeSignerAnchorHeadroomObserver( + &stubFrostProductionSignerReadinessVerifier{ + snapshot: readinessSnapshotWithHeadroom(revisions, generations), + }, + ).verifyFrostProductionSignerReadiness( + context.Background(), + FrostPreSignFinality{BlockNumber: 1}, + ); err != nil { + t.Fatalf("seeding the headroom mirror failed: %v", err) + } +} + +func requireFrostNativeSignerAnchorHeadroomForTest( + t *testing.T, + context string, + revisions uint64, + generations uint64, +) { + t.Helper() + gotRevisions, gotGenerations, observed := + frostsigning.NativeTBTCSignerStateAnchorRestartableHeadroom() + if !observed || gotRevisions != revisions || + gotGenerations != generations { + t.Errorf( + "%s: headroom mirror is (%d, %d, %v), want (%d, %d, true)", + context, + gotRevisions, + gotGenerations, + observed, + revisions, + generations, + ) + } +} + +// A successful reconciliation is the only place at this layer where the +// restartable headroom is both fresh and authenticated, so it is the only +// place allowed to publish it to the scrape. Without this the two numbers +// exist only on the loopback-only activation-handshake endpoint. +func TestFrostNativeSignerAnchorHeadroomObserver_PublishesOnSuccess( + t *testing.T, +) { + stub := &stubFrostProductionSignerReadinessVerifier{ + snapshot: readinessSnapshotWithHeadroom(3971, 3820), + } + observer := newFrostNativeSignerAnchorHeadroomObserver(stub) + + snapshot, err := observer.verifyFrostProductionSignerReadiness( + context.Background(), + FrostPreSignFinality{BlockNumber: 7}, + ) + if err != nil || snapshot == nil { + t.Fatalf("delegation failed: snapshot=%v err=%v", snapshot, err) + } + if stub.reconcileCalls != 1 || stub.lastFinality.BlockNumber != 7 { + t.Fatalf( + "inner verifier was not called through: calls=%d finality=%d", + stub.reconcileCalls, + stub.lastFinality.BlockNumber, + ) + } + + requireFrostNativeSignerAnchorHeadroomForTest( + t, + "after a successful reconciliation", + 3971, + 3820, + ) +} + +// Zero is the value that means "the certified windows are exhausted". A failed +// reconciliation - an unreachable anchor service, say - must not be able to +// publish it, and must leave the last good reading standing. +func TestFrostNativeSignerAnchorHeadroomObserver_FailurePublishesNothing( + t *testing.T, +) { + publishFrostNativeSignerAnchorHeadroomForTest(t, 2048, 1024) + + for _, testCase := range []struct { + name string + stub *stubFrostProductionSignerReadinessVerifier + }{ + { + name: "reconciliation error", + stub: &stubFrostProductionSignerReadinessVerifier{ + snapshot: readinessSnapshotWithHeadroom(0, 0), + err: errors.New("anchor service unreachable"), + }, + }, + { + name: "nil snapshot", + stub: &stubFrostProductionSignerReadinessVerifier{}, + }, + { + name: "nil inventory", + stub: &stubFrostProductionSignerReadinessVerifier{ + snapshot: &frostProductionSignerReadinessSnapshot{}, + }, + }, + } { + _, _ = newFrostNativeSignerAnchorHeadroomObserver(testCase.stub). + verifyFrostProductionSignerReadiness( + context.Background(), + FrostPreSignFinality{BlockNumber: 2}, + ) + requireFrostNativeSignerAnchorHeadroomForTest( + t, + testCase.name+" overwrote the last good reading", + 2048, + 1024, + ) + } +} + +// The unchanged check proves a previously reconciled snapshot still holds; it +// produces no fresh reading, so republishing through it would only restate a +// known value while resetting the staleness signal that tells an operator how +// fresh the gauges are. +func TestFrostNativeSignerAnchorHeadroomObserver_UnchangedDelegatesOnly( + t *testing.T, +) { + publishFrostNativeSignerAnchorHeadroomForTest(t, 777, 888) + + stub := &stubFrostProductionSignerReadinessVerifier{ + unchangedErr: errors.New("cached readiness changed"), + } + observer := newFrostNativeSignerAnchorHeadroomObserver(stub) + + snapshot := readinessSnapshotWithHeadroom(4096, 4096) + err := observer.verifyFrostProductionSignerReadinessUnchanged( + context.Background(), + snapshot, + ) + if err == nil || !strings.Contains(err.Error(), "cached readiness changed") { + t.Fatalf("inner error was not propagated: %v", err) + } + if stub.unchangedCalls != 1 || stub.lastUnchangedIn != snapshot { + t.Fatalf( + "inner verifier was not called through: calls=%d", + stub.unchangedCalls, + ) + } + requireFrostNativeSignerAnchorHeadroomForTest( + t, + "the unchanged check published a headroom reading", + 777, + 888, + ) +} + +func TestFrostNativeSignerAnchorHeadroomObserver_NilInnerIsNil(t *testing.T) { + if observer := newFrostNativeSignerAnchorHeadroomObserver( + nil, + ); observer != nil { + t.Fatalf("wrapping a nil verifier produced [%v]", observer) + } +} diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index 62c2615fb1..601a8410c3 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -822,7 +822,21 @@ func newNode( "cannot obtain a valid startup FROST signer-readiness checkpoint", ) } - if _, err := readiness.verifyFrostProductionSignerReadiness( + // Every authenticated readiness reconciliation already carries the + // restartable revision and generation headroom, so the scrapeable + // mirror is fed from the path that computes them instead of being + // recomputed on the scrape. Recomputing costs the anchor binding + // mutex - which CommitNativeTBTCSignerStateTransition holds across the + // remote CAS for the whole of a signing commit - plus an authenticated + // read of the remote anchor service, and neither belongs on a metrics + // timer: the scrape would stall behind an in-flight signing operation + // and would put a network call on a fixed tick. Wrapping here covers + // both the startup verification immediately below and every later + // pre-sign authorization, which is the only recurring caller of the + // verifier this node stores. + observedReadiness := + newFrostNativeSignerAnchorHeadroomObserver(readiness) + if _, err := observedReadiness.verifyFrostProductionSignerReadiness( context.Background(), *startupFinality, ); err != nil { @@ -851,7 +865,7 @@ func newNode( node.frostPreSignAuthorizationBackend = backend node.frostPreSignActivationProfile = verifiedActivationProfile node.frostDurableSessionStoreBinding = storeBinding - node.frostProductionSignerReadiness = readiness + node.frostProductionSignerReadiness = observedReadiness node.frostNativeSignerAnchorAdmission = anchorAdmission node.bitcoinBroadcastOutbox = outbox node.frostActivationHandshakeExporter = exporter @@ -860,6 +874,70 @@ func newNode( return node, nil } +// frostNativeSignerAnchorHeadroomObserver decorates the production signer +// readiness verifier so that the restartable revision and generation headroom +// each successful reconciliation already computed reaches the scrapeable +// mirror in pkg/frost/signing. +// +// It is a decorator rather than a call inside the verifier because those two +// numbers are only trustworthy once the reconciliation that produced them has +// succeeded: the snapshot is assembled after the local tip has been +// authenticated against the remote anchor, and a failed verification can +// return a partially built or nil snapshot whose headroom means nothing. So +// only the success path publishes, and a failed reconciliation deliberately +// leaves the previous value standing rather than zeroing it - zero is the +// value that means "the certified windows are exhausted", and a transport blip +// must not be reported as that. +// +// Publishing costs one atomic store on a path that has just performed remote +// I/O, so it is not measurable there. Nothing reads the mirror back to make a +// decision; it exists solely so an operator can see the windows drain in time +// to schedule the offline rotation ceremony. +type frostNativeSignerAnchorHeadroomObserver struct { + inner frostProductionSignerReadinessVerifier +} + +func newFrostNativeSignerAnchorHeadroomObserver( + inner frostProductionSignerReadinessVerifier, +) frostProductionSignerReadinessVerifier { + if inner == nil { + return nil + } + return &frostNativeSignerAnchorHeadroomObserver{inner: inner} +} + +func (fnsaho *frostNativeSignerAnchorHeadroomObserver) verifyFrostProductionSignerReadiness( + ctx context.Context, + finality FrostPreSignFinality, +) (*frostProductionSignerReadinessSnapshot, error) { + snapshot, err := fnsaho.inner.verifyFrostProductionSignerReadiness( + ctx, + finality, + ) + if err == nil && snapshot != nil && snapshot.Inventory != nil { + signing.RecordNativeTBTCSignerStateAnchorRestartableHeadroom( + snapshot.Inventory.RestartableRevisionHeadroom, + snapshot.Inventory.RestartableGenerationHeadroom, + ) + } + return snapshot, err +} + +// verifyFrostProductionSignerReadinessUnchanged publishes nothing. It proves a +// previously reconciled snapshot has not changed and returns no fresh headroom +// of its own, so republishing the caller's snapshot here would only restate a +// value the mirror already holds while bumping the observation counter that +// tells an operator how fresh that value is. +func (fnsaho *frostNativeSignerAnchorHeadroomObserver) verifyFrostProductionSignerReadinessUnchanged( + ctx context.Context, + snapshot *frostProductionSignerReadinessSnapshot, +) error { + return fnsaho.inner.verifyFrostProductionSignerReadinessUnchanged( + ctx, + snapshot, + ) +} + func configureFrostSigningBackend(config Config) error { return signing.SetExecutionBackendByName(config.FrostSigningBackend) } @@ -1187,6 +1265,36 @@ func (n *node) getSigningExecutor( } executor.preSignAuthorizationGate = gate executor.broadcastOutbox = n.bitcoinBroadcastOutbox + + // The seat ceiling is a property of this node's seat count in this + // wallet and of protocol constants, so it is decided the moment the + // wallet is formed and never changes for the wallet's whole life. The + // gate is the only thing that reports it today, and only inside the + // error of an authorization it has already refused, so an operator + // over the ceiling learns about it the first time a full-size deposit + // sweep is proposed - which may be weeks after the seats were awarded, + // and is the worst possible moment. Say it once, here, at the same + // place the executor announces how many signers it controls. + // + // The gate's own deduplicated, validated seat set is used rather than + // localMemberIndexes because that is exactly what reservePreSign + // charges for; a duplicated index in the registry would otherwise make + // this warn about a seat count admission never sees. + // + // Gated on Schnorr material because getSigningExecutor builds a gate + // for every wallet on a FROST-enabled node, including the legacy ECDSA + // wallets still draining, and walletTransactionExecutor only routes + // through the gate when the wallet signs with Schnorr. Warning about a + // seat ceiling that cannot apply to an ECDSA wallet would be noise. + if executor.usesSchnorrSignatures() { + if warning, exceeded := frostPreSignLocalSeatCeilingWarning( + uint64(len(gate.localMemberIndexes)), + uint64(frostPreSignAuthorizationMaximumInputs), + gate.maximumAttempts, + ); exceeded { + executorLogger.Warnf("%s", warning) + } + } } // Wire metrics recorder if available @@ -1199,6 +1307,106 @@ func (n *node) getSigningExecutor( return executor, true, nil } +// frostPreSignLocalSeatCeilingWarning states, at wallet-executor construction +// time, that this node's seat count in a FROST wallet is above the count +// anchor admission can serve for a maximum-size pre-sign batch. It returns the +// warning text and whether the ceiling is exceeded at all. +// +// The exclusion this reports is whole-node, not per-seat. +// thresholdFrostPreSignAuthorizationGate.authorize charges reservePreSign for +// its complete local seat set in one reservation, so a set one seat over the +// ceiling fails the reservation and the node contributes nothing at all to +// that batch - every one of its seats is lost to the wallet's signing +// threshold, not just the surplus one. That is worth stating explicitly +// because the natural reading of "a ceiling of four" is that four seats still +// sign, and they do not. +// +// Both levers are named because neither is inferable from the other and both +// belong to the operator: shed seats down to the ceiling, or keep batches at +// or below the largest this seat count can serve. Neither the certified +// restart windows nor the signing-attempt limit are configurable here, so +// there is no third option to offer. +// +// Kept as a pure function of the three numbers so it can be exercised without +// standing up a wallet, a gate, or a native build. +func frostPreSignLocalSeatCeilingWarning( + localSeatCount uint64, + maximumInputCount uint64, + maximumSigningAttempts uint64, +) (string, bool) { + // Mirrors authorize: an unset limit means the package default, and + // charging the ceiling scan a different attempt count than admission uses + // would make this warn about a ceiling that does not exist. + if maximumSigningAttempts == 0 { + maximumSigningAttempts = signingAttemptsLimit + } + + admissibleSeats := frostPreSignMaximumAdmissibleLocalSeatCount( + maximumInputCount, + maximumSigningAttempts, + ) + if admissibleSeats > 0 && localSeatCount <= admissibleSeats { + return "", false + } + if admissibleSeats == 0 { + // Not this node's problem to fix: no seat count at all can serve a + // maximum-size batch under the current windows, so shedding seats + // would not help and only a protocol-level change would. + return fmt.Sprintf( + "this node holds [%d] of this FROST wallet's seats, and no local "+ + "seat count can sign a maximum-size [%d]-input pre-sign batch "+ + "within the certified anchor restart windows; every full-size "+ + "deposit sweep on this wallet will be refused for every member, "+ + "and only enlarging those windows or lowering the "+ + "signing-attempt limit [%d] changes that", + localSeatCount, + maximumInputCount, + maximumSigningAttempts, + ), true + } + + admissibleInputs := frostPreSignMaximumAdmissibleInputCount( + localSeatCount, + maximumSigningAttempts, + ) + if admissibleInputs == 0 { + return fmt.Sprintf( + "this node holds [%d] of this FROST wallet's seats, above the "+ + "[%d]-seat ceiling for a maximum-size [%d]-input pre-sign "+ + "batch, and [%d] seats can sign no batch size at all; anchor "+ + "admission will refuse this node every signing request for "+ + "this wallet for the wallet's whole life, so all [%d] of its "+ + "seats are lost to the wallet's signing threshold - shed seats "+ + "down to [%d]", + localSeatCount, + admissibleSeats, + maximumInputCount, + localSeatCount, + localSeatCount, + admissibleSeats, + ), true + } + + return fmt.Sprintf( + "this node holds [%d] of this FROST wallet's seats, above the "+ + "[%d]-seat ceiling for a maximum-size [%d]-input pre-sign batch; "+ + "the largest batch [%d] seats can sign is [%d] inputs, and anchor "+ + "admission refuses the node as a whole rather than the surplus "+ + "seats, so all [%d] of its seats are excluded from every full-size "+ + "deposit sweep for this wallet's whole life and are lost to the "+ + "wallet's signing threshold on those sweeps - shed seats down to "+ + "[%d], or keep sweeps at or below [%d] inputs", + localSeatCount, + admissibleSeats, + maximumInputCount, + localSeatCount, + admissibleInputs, + localSeatCount, + admissibleSeats, + admissibleInputs, + ), true +} + // getCoordinationExecutor gets the coordination executor responsible for // executing coordination related to a specific wallet whose part is controlled // by this node. The second boolean return value indicates whether the node diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 6a449ef77f..770fc0c804 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -368,6 +368,15 @@ func Initialize( // Prometheus. frostsigning.RegisterRoastRetryMetrics(clientInfo) frostsigning.RegisterInteractiveSigningMetrics(clientInfo) + // The native signer state anchor is registered on exactly the same + // terms, and it needs them more than the counters above do. Its two + // failure modes - the barrier latching terminally poisoned, and the + // certified restart windows draining - both leave the process running + // and attesting healthy while it silently stops signing, and the + // headroom numbers were until now readable only through the activation + // handshake, whose endpoint validator requires a loopback host and so + // cannot be reached from a monitoring host at all. + frostsigning.RegisterNativeTBTCSignerStateAnchorMetrics(clientInfo) if perfMetrics == nil { perfMetrics = clientinfo.NewPerformanceMetrics(ctx, clientInfo) From 0e4d69f24d04288b7492044e5c9e015d289b5349 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 11:44:40 -0400 Subject: [PATCH 83/93] fix(tbtc): register the anchor admission rejection counters RegisterFrostNativeSignerAnchorAdmissionMetrics was defined and unit tested but called from nothing, so the admission controller's refusal counters incremented internally and never reached a scrape - the exact failure the comment above this block warns about for the counters that came before them. The two causes worth alerting on are the ones an operator cannot infer any other way: a seat-ceiling refusal, which excludes a node from every full-size sweep for the life of the wallet, and a poisoned-anchor refusal, which means the node has stopped accepting work until it is restarted. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/tbtc.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index 770fc0c804..c7e4457c21 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -377,6 +377,12 @@ func Initialize( // handshake, whose endpoint validator requires a loopback host and so // cannot be reached from a monitoring host at all. frostsigning.RegisterNativeTBTCSignerStateAnchorMetrics(clientInfo) + // The admission controller's refusals are the other half of that + // picture: the anchor gauges say the window is draining, these say + // which workflows were already turned away and why. A non-zero + // seat-ceiling or poisoned count is the signal that a node has stopped + // taking work it will never be able to finish. + RegisterFrostNativeSignerAnchorAdmissionMetrics(clientInfo) if perfMetrics == nil { perfMetrics = clientinfo.NewPerformanceMetrics(ctx, clientInfo) From b2f699023fef1cd90183bedb2b9e0941236b6840 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 11:46:12 -0400 Subject: [PATCH 84/93] docs(tbtc): cover the poisoned anchor in the rotation runbook The runbook was written against the v4 handshake schema, which the poisoned-anchor health term has since replaced with v5, so its worked example sent a request the node now refuses. It also described only one way for a node to stop signing. There are two, they present identically from outside - a running process answering everything except signing requests - and the ceremony in this document fixes only one of them. An operator who reaches for rotation on a poisoned anchor performs an offline signing ceremony and is no better off. Name the second failure, the string that identifies it, and the fact that only a restart clears it. Co-Authored-By: Claude Fable 5 --- docs/development/frost-anchor-rotation.adoc | 46 ++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/development/frost-anchor-rotation.adoc b/docs/development/frost-anchor-rotation.adoc index c436bae573..c3f16d6fe8 100644 --- a/docs/development/frost-anchor-rotation.adoc +++ b/docs/development/frost-anchor-rotation.adoc @@ -286,6 +286,43 @@ replay gate weakens. Nothing is lost by hitting them except availability - but availability is lost until an offline ceremony completes, which is why the cadence above matters. +[[recovering-a-poisoned-anchor]] +=== When rotation is not the remedy + +A node can also stop signing for a reason rotation will not fix, and the +two look similar from the outside: both leave the process running, +answering every other request, and quietly producing no signatures. + +If the refusal names a *terminally poisoned* anchor, the node has +latched a barrier fault - a rollback, a fork, an authentication failure, +or a commit whose outcome could not be established after the local state +had already advanced: + +---- +native tbtc signer state anchor is terminally poisoned: +---- + +That latch is process-wide and sticky. It is cleared by *restarting the +node*, and by nothing else - running the ceremony in this document will +not clear it, and neither will waiting. The signed activation handshake +reports it as `payload.state.nativeSignerState.stateAnchorPoisoned`, and +the poisoning is logged once at ERROR with its cause when it first +latches. + +Distinguishing the two before acting: + +* headroom at or near zero, refusals naming *offline anchor rotation* -> + the window is spent, run the ceremony in this document; +* refusals naming *terminally poisoned* -> restart the node, then + investigate the logged cause, because a poisoning that recurs + immediately after a restart is a genuine anchor disagreement and not a + transient fault. + +A transient failure to reach the anchor service no longer poisons: those +are classified and retried, and a node that cannot reach its anchor +refuses work with `native tbtc signer state anchor is unavailable` and +recovers on its own once the service answers again. + == Reading current headroom today *This is the weakest part of the current tooling, and the honest answer @@ -305,12 +342,19 @@ payload.state.nativeSignerState.currentAnchorRevision payload.state.nativeSignerState.certifiedFloorRevision payload.state.nativeSignerState.certifiedFloorGeneration payload.state.nativeSignerState.stateGeneration +payload.state.nativeSignerState.stateAnchorPoisoned ---- `anchorRotationWarning` is true exactly when the smaller headroom is at or below 256, and it also forces the payload's top-level `state.healthy` to false (`frostActivationHandshakeHealthy`). +`stateAnchorPoisoned` is the other term that forces `state.healthy` +false, and it reports the different failure described in +<>: the node is not out of window, it has +latched a terminal barrier fault and will refuse every request-taking +signer call until it is restarted. Rotation does not clear it. + === The endpoint The endpoint is configured by `FrostActivationHandshakeURL` and is @@ -337,7 +381,7 @@ auditor, not for monitoring: curl -sS -X POST http://127.0.0.1:/ \ -H 'Content-Type: application/json' \ -d '{ - "schema": "tbtc-p2tr-production-activation-handshake/v4", + "schema": "tbtc-p2tr-production-activation-handshake/v5", "challenge": { "nonce": "0x<32 random bytes, lowercase hex>", "manifestHash": "0x", From 2fc4da8e5fe025e4d4907096f6f7fd1f7ae1ea9c Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 13:59:40 -0400 Subject: [PATCH 85/93] docs(tbtc): name the rollback the anchor actually defends against The runbook implied a restore from backup was the threat. It is not: nonces are never written to disk, and interactive_session_open zeroizes any prior nonce on every new attempt, so a file-level restore plus a restart destroys the spent nonce rather than resurrecting it. The threat is a memory-inclusive rollback - a VM snapshot, a CRIU checkpoint, a live-migration rollback - which is the only thing that brings a consumed nonce back. Round 2 pins the message and the member's own commitment but not the signing subset, so a resurrected nonce signed against enough distinct subsets recovers the key share. An operator who does not know that will snapshot a running signer. Also correct the per-call cost table's preamble. The barrier skips the remote CAS when the Rust tip is unchanged, so Round1 - which persists nothing - spends neither a revision nor a generation. The table is an upper bound that the admission reservation is charged against, not real consumption. Co-Authored-By: Claude Fable 5 --- docs/development/frost-anchor-rotation.adoc | 29 +++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/development/frost-anchor-rotation.adoc b/docs/development/frost-anchor-rotation.adoc index c3f16d6fe8..7c5896bc3b 100644 --- a/docs/development/frost-anchor-rotation.adoc +++ b/docs/development/frost-anchor-rotation.adoc @@ -87,6 +87,24 @@ full-size sweeps per anchor epoch at 4 seats*. The derivation is in `pkg/tbtc/frost_native_signer_anchor_binding.go`) |=== +=== Which rollback this actually defends against + +Not a restore from backup. Nonces are never written to disk - +`PersistedSessionState` has no nonce field, its comment calls the +consumption markers "the ONLY durable artifact", and +`interactive_session_open` zeroizes any prior nonce on every new +attempt. Restore a signer store from a file-level backup and restart, +and the spent nonce is simply gone; round 1 mints a fresh one. + +The threat is a *memory-inclusive* rollback - a VM snapshot restore, a +CRIU checkpoint, a live-migration rollback - because that is the only +thing that resurrects a nonce the node had already consumed. Round 2 +pins the message and the member's own commitment, but not the signing +subset, so a resurrected nonce signed against enough distinct subsets +recovers the private key share. Treat hypervisor-level snapshots of a +running signer as a key-compromise event, not an operational +convenience. + === Why rotation exists at all The floor is a rollback boundary. If the node could move it, a node @@ -168,8 +186,15 @@ workflow that succeeds on its first attempt makes, per input: subset calls `Open`, `Round1`, `Abort` instead - also three; and * one memoized `Aggregate`. -That is `3 * seats + 2` anchored calls per input, each costing one -revision and one to two generations: +That is `3 * seats + 2` anchored calls per input. Only the calls that +actually advance durable state cost anything: the barrier skips the +remote CAS when the Rust tip is unchanged +(`if *candidate == lease.expected` in +`pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go`), so +`Round1` — which mints its nonce in memory and persists nothing — +spends no revision and no generation at all. The figures below are +therefore an upper bound, and the pre-sign admission reservation is +charged against that bound rather than against real consumption: [cols="1,1,1,1"] |=== From c616e86d72210fab6f87a9e20b45c7da0f70497a Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 14:14:26 -0400 Subject: [PATCH 86/93] fix(tbtc): reserve FROST anchor capacity per input, not per batch Pre-sign anchor admission charged a whole batch's worst case up front: revisions = inputCount * (1 + attempts*(4*seats + 1)) generations = 2*revisions + 3 At production parameters (21 inputs, 5 signing attempts) that admitted at most FOUR local seats against the 4096-entry certified proof window. Mainnet runs a hundred-seat wallet across roughly twenty operators with replacement sortition and no per-operator cap, so the average operator holds five seats: most of the stake-weighted seats were refused every full-size sweep, wallets formed normally and then could not move the deposits they had already received. The arithmetic was wrong rather than merely tight. A batch signs its inputs strictly sequentially, so it never needs more than one input's worth of unconsumed window at any instant. Charge one input instead: cost = 2*(1 + attempts*(4*seats + 1)) + 3 == 40*seats + 15 That is 4015 generations for a wallet's entire hundred-seat set, so the unchanged 4096-entry window covers every protocol-legal seat count and the ceiling disappears rather than moving. The unit is an input rather than an attempt so an admitted input keeps its whole retry budget. signBatchWithTaprootPolicy's per-input loop now takes and releases the reservation for each input through a new admitInput closure threaded alongside the authorization guard. The per-input body moved into signBatchInputUnderAnchorAdmission so the release is a defer and covers the admission failing, the policy binding failing, the signing failing, context cancellation and panic unwind alike. admitInput is a method on frostPreSignAuthorizationGate, so no signing path or test double can reach native signing without one, and the Schnorr precondition check refuses a batch that arrives without it. authorize() keeps its reservation, sized at one input, so the on-chain relay stays gated by admission; walletTransactionExecutor releases it as the per-input admissions take over, because holding both would charge two inputs for one input's work and reinstate a ceiling at fifty seats. Release is sync.Once-guarded and the existing deferred release still covers every earlier exit. What is given up is the whole-batch promise: a batch can be admitted for input 0 and refused at input 7. That refusal is fail-closed - the input's reservation is released, nothing is partially applied, no share is produced, the wallet action fails naming the rotation remedy - and it is now separately countable as frost_native_signer_anchor_admission_pre_sign_input_rejected_total, which is the difference between a batch refused before its relay and one abandoned after it. The batch-wide charge never prevented the window running out mid batch; it prevented most operators from starting one. DKG and DKG-retirement reservations are untouched. Batch size is no longer a lever, so the seat-ceiling advice, the startup warning and the rotation runbook drop it and say so; the ceiling guard itself stays, unreachable under the shipped constants, to catch a future change to the windows, the attempt limit or the seat cap. Co-Authored-By: Claude Fable 5 --- docs/development/frost-anchor-rotation.adoc | 181 +++-- .../frost_native_signer_anchor_admission.go | 461 ++++++------ ...ost_native_signer_anchor_admission_test.go | 585 ++++++++++----- ...native_signer_anchor_observability_test.go | 213 +++--- ..._signer_anchor_per_input_admission_test.go | 689 ++++++++++++++++++ pkg/tbtc/frost_pre_sign_authorization.go | 132 +++- pkg/tbtc/frost_pre_sign_authorization_test.go | 36 + pkg/tbtc/node.go | 117 ++- pkg/tbtc/signing.go | 154 +++- pkg/tbtc/wallet.go | 39 + ..._sign_transaction_build_taproot_tx_test.go | 46 ++ 11 files changed, 1972 insertions(+), 681 deletions(-) create mode 100644 pkg/tbtc/frost_native_signer_anchor_per_input_admission_test.go diff --git a/docs/development/frost-anchor-rotation.adoc b/docs/development/frost-anchor-rotation.adoc index 7c5896bc3b..008f86f596 100644 --- a/docs/development/frost-anchor-rotation.adoc +++ b/docs/development/frost-anchor-rotation.adoc @@ -40,12 +40,16 @@ provide, an operator installs it in three files, and the node is restarted. Do not go looking for a subcommand; it is not hidden, it does not exist. -The rotation budget is smaller than it looks. At the production -maximum batch (21 inputs) a node holding 4 local seats reserves 3615 of -the 4096 generations for a *single* transaction workflow, and a completed -workflow consumes a few hundred. The practical result is *one to two -full-size sweeps per anchor epoch at 4 seats*. The derivation is in -<>. +The rotation budget is a throughput budget, not an admission ceiling. +Admission reserves *one transaction input at a time* - a batch signs its +inputs sequentially and never needs more than one input's worth of +unconsumed window at once - so no seat count a wallet can award is +excluded from signing. What is still finite is the number of inputs a +node can actually sign between two rotations: a fault-free 21-input +sweep consumes roughly `21 * (3 * localSeats + 2)` revisions and one to +two generations per call, so a 4-seat node gets *six to thirteen +full-size sweeps per anchor epoch* and a 20-seat node one to two. The +derivation is in <>. == What the anchor is, in operator terms @@ -132,45 +136,71 @@ fresh signed floor. === The reservation arithmetic -Before a FROST pre-sign workflow starts, the admission controller -reserves its entire worst case in both dimensions and holds the -reservation until the workflow exits -(`frostPreSignAnchoredWorkflowCost` in +Anchor admission reserves *one transaction input* at a time, for that +input's whole attempt budget, and releases it before the next input is +admitted (`frostPreSignAnchoredInputCost` in `pkg/tbtc/frost_native_signer_anchor_admission.go`): .... perAttemptCalls = 4 * localSeats + 1 -perInputCalls = 1 + maximumAttempts * perAttemptCalls -revisions = inputs * perInputCalls +revisions = 1 + maximumAttempts * perAttemptCalls generations = 2 * revisions + 3 .... The `4 * seats + 1` is `Open`, `Round1`, `Round2` and `Abort` for every -local seat plus one memoized `Aggregate`; the leading `1` per input is +local seat plus one memoized `Aggregate`; the leading `1` is `BuildTaprootTx`. Production parameters are `inputs <= 21` (`frostPreSignAuthorizationMaximumInputs`), `maximumAttempts = 5` (`signingAttemptsLimit` in `pkg/tbtc/node.go`), and up to 100 wallet -seats (`frostPreSignAuthorizationMaximumSeats`, threshold 51). +seats (`frostPreSignAuthorizationMaximumSeats`, threshold 51). At five +attempts this closes to `20 * seats + 6` revisions and `40 * seats + 15` +generations per input. -At 21 inputs and 5 attempts: +The batch size does not enter the reservation. A batch's inputs are +signed strictly sequentially, so a one-input batch and a full sweep +reserve the same thing - they just reserve it once per input. [cols="1,1,1,3"] |=== -| Local seats | Revisions reserved | Generations reserved | Admissible at 21 inputs? - -| 1 | 546 | 1095 | yes -| 2 | 966 | 1935 | yes -| 3 | 1386 | 2775 | yes -| 4 | 1806 | 3615 | yes - this is the ceiling -| 5 | 2226 | 4455 | *no*: exceeds the 4096 proof window; caps at 19 inputs -| 6 | - | - | *no*: caps at 16 inputs +| Local seats | Revisions per input | Generations per input | Admissible? + +| 1 | 26 | 55 | yes +| 2 | 46 | 95 | yes +| 4 | 86 | 175 | yes +| 5 | 106 | 215 | yes +| 20 | 406 | 815 | yes +| 50 | 1006 | 2015 | yes +| 100 | 2006 | 4015 | yes - the wallet's entire seat set on one node |=== -The 1-seat and 2-seat rows and the 4-seat ceiling are pinned by -`TestFrostPreSignMaximumAnchorCapacityCost` and -`TestFrostPreSignSeatCeilingResidual` in -`pkg/tbtc/frost_native_signer_anchor_admission_test.go`; the rest -follow from the same formula. +The generation window binds first and the last seat count it covers is +102, which a 100-seat wallet cannot exceed, so *no protocol-legal seat +count is excluded from signing*. The rows above and the absence of a ceiling +are pinned by `TestFrostPreSignMaximumAnchorCapacityCost` and +`TestFrostPreSignPerInputReservationAdmitsMainnetSeatCounts` in +`pkg/tbtc/frost_native_signer_anchor_admission_test.go`. + +[NOTE] +==== +This replaces a batch-wide reservation - `inputs * perInputCalls`, +holding the whole sweep's worst case from the first input to the last - +which admitted at most *four* local seats for a 21-input sweep. On a +hundred-seat wallet shared by roughly twenty operators with replacement +sortition and no per-operator cap, that excluded most of the +stake-weighted seats: wallets formed normally and then could not sweep, +because the group never assembled its 51-seat signing threshold on a +full-size batch. The arithmetic was simply wrong - inputs are signed +sequentially, so the whole batch's window is never needed at once. + +The exchange is that a batch can now be admitted for its first inputs +and refused part way through, after its authorization has already been +relayed and finalized on chain. That refusal is fail-closed - the +input's reservation is released, no share is produced, and the wallet +action fails naming the rotation remedy - and it is separately countable +as `frost_native_signer_anchor_admission_pre_sign_input_rejected_total`. +The batch-wide charge did not actually prevent the window running out +mid batch; it prevented most operators from starting one. +==== === What a completed workflow actually consumes @@ -212,43 +242,62 @@ A workflow is admitted only while `cost <= headroom - alreadyReserved` in *both* dimensions and the smaller headroom is still above 256. Since the generation cost is roughly twice the revision cost while the consumption rates are comparable, the generation window always binds -first. Starting from a fresh rotation (both windows at 4096) and -running full-size sweeps one at a time: +first. Because the reservation is now one input rather than a whole +batch, a node keeps signing until its *consumption* - not its +reservation - runs the window down: the refusal point is +`max(257, 40 * localSeats + 15)` generations of headroom. -[cols="1,1,3"] -|=== -| Local seats | Full-size sweeps per anchor epoch | Note +Starting from a fresh rotation (both windows at 4096) and running +full-size sweeps one at a time: -| 1 | 15-29 | best case assumes one generation per anchored call -| 2 | 7-13 | -| 3 | 3-6 | -| 4 | 1-2 | in both the best and the worst case the next sweep is - refused with 3508 generations still unspent +[cols="1,1,1,3"] +|=== +| Local seats | Generations per 21-input sweep | Full-size sweeps per anchor epoch | Note + +| 1 | 105-210 | 18-36 | best case assumes one generation per anchored call +| 2 | 168-336 | 11-22 | +| 3 | 231-462 | 8-16 | +| 4 | 294-588 | 6-13 | +| 5 | 357-714 | 5-10 | the average mainnet holder; excluded entirely before this change +| 20 | 1302-2604 | 1-2 | +| 100| 6342-12684 | 0-0 | a single sweep consumes more than the whole window |=== -The 4-seat row is the operationally important one. A 4-seat node stops -admitting full-size batches with 3508 of 4096 generations - 86% of its -proof window - still unspent, purely because the *next* workflow's -worst case no longer fits. Rotation cadence at production seat counts -is therefore measured in a handful of sweeps, not in months of uptime. +The 100-seat row is the honest edge of the model. Such a node is +*admitted* for every input - the ceiling is gone - but a full sweep +consumes more window than one epoch has, so it will be refused part way +through and needs a rotation to finish. That is a throughput limit an +offline ceremony fixes, not a permanent exclusion from the signing +threshold, which is what the old ceiling was. It is also not a +configuration any real wallet produces: one operator holding all 100 +seats of a 100-seat wallet is the protocol maximum, not a sortition +result. NOTE: An earlier operability assessment put these at "1-2 at 4 seats, -about 5 at 3 seats, about 28 at 1 seat". The 4-seat figure agrees and -the 1-seat figure matches the optimistic end of the range here. The -3-seat figure differs because this derivation counts three calls per -seat on a successful attempt rather than four - `Abort` is suppressed -for a seat that signed round 2, so four calls per seat is the shape of -a *failed* attempt. Use the ranges above. +about 5 at 3 seats, about 28 at 1 seat". Those figures were measured +against the batch-wide reservation, where the *next* workflow's worst +case, not consumption, decided when a node stopped. They no longer +apply; use the ranges above. === Concurrency One admission controller is shared by pre-sign authorization, native DKG and DKG retirement, and each reservation holds its full worst case -until its workflow exits. Two concurrent full-size workflows at 4 seats -would need 7230 generations and can never both be admitted, so such a -node serializes full-size work. The controller also intentionally -double-counts in-flight mutations when admitting later work, so -concurrent load reaches the refusal sooner than the table suggests. +until the admitted unit of work exits. That unit is one input for +pre-sign signing, so two wallets signing concurrently on one node hold +one input's worth each - 350 generations at 4 seats, not the 7230 two +full-size batches used to need - and they interleave between each +other's inputs rather than serializing whole sweeps. + +Admission never waits: `reserve` takes one mutex, compares the cost +against headroom minus everything currently reserved, and either +succeeds or fails immediately. There is no queue to deadlock and no +retry to livelock. Nor is there a large-request starvation path, because +every pre-sign admission costs the same regardless of batch size - a +21-input sweep asks for exactly what a 1-input redemption asks for. The +controller also intentionally double-counts in-flight mutations when +admitting later work, so concurrent load reaches the refusal sooner than +the table suggests. === What an operator actually sees @@ -258,10 +307,10 @@ There is no warning phase that names a remedy. In headroom order: still large but smaller than the next workflow's worst case: + ---- -FROST pre-sign authorization requires [3615] signer generations but only [3508] are unreserved +FROST pre-sign authorization requires [815] signer generations but only [612] are unreserved ---- + -and the revision-dimension twin, `... requires [1806] anchor revisions +and the revision-dimension twin, `... requires [406] anchor revisions but only [N] are unreserved`. The workflow label is `FROST pre-sign authorization`, `FROST native DKG` or `FROST native DKG retirement`. *This string does not mention rotation @@ -274,7 +323,7 @@ dispatcher: ---- action execution terminated with error: [FROST pre-sign authorization failed before nonce generation: [FROST pre-sign anchor admission failed: [FROST -pre-sign authorization requires [3615] signer generations but only [3508] +pre-sign authorization requires [815] signer generations but only [612] are unreserved]]] ---- @@ -631,9 +680,11 @@ derived above is run against real value. a `clientinfo` gauge for both headroom dimensions plus the rotation-warning flag. . *An alert threshold set well above the danger points* - above 256 - (where all work stops) and above the node's own per-workflow - reservation for its seat count (3615 generations at 4 seats). An - alert that fires at 256 fires far too late. + (where all work stops), above the node's own per-input reservation for + its seat count (175 generations at 4 seats, 815 at 20), and above what + one full sweep consumes (up to 588 generations at 4 seats). An alert + that fires at 256 fires after the node has already abandoned a batch + whose authorization it paid to relay. . *A documented, rehearsed anchor-service epoch transition:* who opens epoch N+1 revision 1, how the frozen checkpoint and previous event root are supplied, and how the signed acknowledgement bytes are @@ -643,10 +694,12 @@ derived above is run against real value. verified rollback if the certificate is rejected at startup. . *A decision on the 64-certificate lifetime bound,* including what a store does at certificate 64. -. *A local-seat policy.* 5 or more local seats are excluded from - 21-input batches entirely and lose those seats to the wallet's - signing threshold; 4 seats yields 1-2 sweeps per epoch. Decide the - supported seat count per node before sortition produces one. +. *A local-seat policy.* No seat count is excluded from signing any + more, but seat count still sets rotation cadence: 4 seats yields 6-13 + full-size sweeps per epoch, 20 seats yields 1-2, and above roughly 30 + a single full-size sweep can no longer be completed inside one epoch. + Decide the supported seat count per node, and the rotation cadence + that goes with it, before sortition produces one. . *A consistency check before restart* over the three artifacts - certificate chain, init config, activation manifest - since each is verified against the others at startup and any mismatch is a diff --git a/pkg/tbtc/frost_native_signer_anchor_admission.go b/pkg/tbtc/frost_native_signer_anchor_admission.go index ef0ddf431a..204af3e10a 100644 --- a/pkg/tbtc/frost_native_signer_anchor_admission.go +++ b/pkg/tbtc/frost_native_signer_anchor_admission.go @@ -42,15 +42,16 @@ const ( // reserved, and would have to raise the frozen barrier ceiling with it. frostNativeSignerMaximumGenerationAdvancesPerAnchoredCall uint64 = 3 - // A workflow reservation cannot multiply the per-operation bound by its - // call count: at production parameters that reserves more of the certified - // proof window than exists, and excludes every operator holding three or - // more local seats from full-size batches. What actually bounds the - // consumption is the witness journal - generations advanced equals witness - // COMMITs, and every COMMIT belongs either to a persist that reached its - // rename or to the single witness that may have been carried into the - // workflow already prepared. The cost is therefore the number of persists - // that reach a rename, not the call count times the per-call ceiling. + // A reservation cannot multiply the per-operation bound by its call count: + // at production parameters that reserves more of the certified proof window + // than exists for large local seat counts - charging three per call puts one + // input's worth at 60*seats+21, which passes the 4096-entry window at 68 + // local seats. What actually bounds the consumption is the witness journal - + // generations advanced equals witness COMMITs, and every COMMIT belongs + // either to a persist that reached its rename or to the single witness that + // may have been carried into the admitted work already prepared. The cost is + // therefore the number of persists that reach a rename, not the call count + // times the per-call ceiling. // // Two per call is the fault-free steady state, not a proven upper bound: // the sweep prologue's snapshot plus the endpoint's own mutation. The @@ -66,19 +67,27 @@ const ( // while the repair call that follows still pays its own sweep snapshot, // its repair snapshot, and its own mutation. Each post-rename persist // failure therefore costs roughly one generation more than this reserves; - // the workflow-wide allowance below is what absorbs them. + // the per-input allowance below is what absorbs them. frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall uint64 = 2 - // The workflow-wide allowance on top of the amortized per-call cost. It - // covers one witness prepared before admission and reconciled inside the - // workflow - prepare_witness refuses to prepare a second, so at most one - // is ever outstanding - plus roughly two further post-rename persist - // failures, each of which overruns the amortized bound by about one - // generation. + // The allowance added on top of the amortized per-call cost, once per + // admitted input. It covers one witness prepared before that input's + // admission and reconciled inside it - prepare_witness refuses to prepare a + // second, so at most one is ever outstanding - plus roughly two further + // post-rename persist failures, each of which overruns the amortized bound + // by about one generation. + // + // It is charged per input rather than once per batch because one input is + // the unit admission reserves: a batch signs its inputs strictly + // sequentially and each input takes and releases its own reservation, so + // each of them can meet a carried-in witness at its own boundary. A + // 21-input sweep therefore carries 21 of these allowances over its life + // rather than one, which is more slack than the batch-wide charge gave, not + // less. // // It is an allowance, not a proof, and the reservation it completes is not // exact. Nothing in the signer bounds how many times a failing store can - // fail a persist after its rename, so a workflow that suffers more of them + // fail a persist after its rename, so an input that suffers more of them // than this covers consumes more of the proof window than it reserved. The // allowance is kept small deliberately: the slack that really absorbs // faults is that most calls sweep nothing and spend one advance rather @@ -87,15 +96,15 @@ const ( // // The residual is fail-closed availability, never authority. The // reservation only promises that the certified proof window can cover the - // whole workflow; overrunning it means that window can run out mid - // workflow. What an operator then sees is request-taking calls blocked + // admitted input; overrunning it means that window can run out part way + // through one. What an operator then sees is request-taking calls blocked // with "the certified signer-generation window cannot cover its maximum // advance; offline anchor rotation is required" and new pre-sign work // rejected for want of unreserved headroom - on a node that was already // failing every one of those persists. No share is released, no replay // gate weakens, and the recovery is the offline anchor rotation that // window exhaustion always requires. - frostNativeSignerTerminalGenerationAdvancesPerWorkflow uint64 = 3 + frostNativeSignerTerminalGenerationAdvancesPerAdmittedInput uint64 = 3 ) type frostNativeSignerAnchorCapacity struct { @@ -112,9 +121,28 @@ type frostNativeSignerAnchorCapacity struct { // // Pre-sign authorization and native DKG share one controller, so concurrent // workflows cannot consume either dimension already promised to admitted work. -// Reservations retain their full worst-case cost until workflow exit. Because -// current headroom already reflects consumed work, this intentionally -// double-counts in-flight mutations for later admissions and is conservative. +// Reservations retain their full worst-case cost until the admitted unit of +// work exits. Because current headroom already reflects consumed work, this +// intentionally double-counts in-flight mutations for later admissions and is +// conservative. +// +// The admitted unit for pre-sign signing is ONE transaction input, not a whole +// batch. A batch signs its inputs strictly sequentially, so no batch ever needs +// more than one input's worth of unconsumed window at any instant; charging the +// whole batch up front reserved 21 times what the peak demand is and put a hard +// four-local-seat ceiling on an operator's ability to sign a full deposit sweep +// at all. Nothing about that reservation is given up by charging per input: the +// unit still covers every signing attempt for its input, so an admitted input +// keeps its full retry budget, and the reservation is still taken before any +// anchored call for that input runs. +// +// What is given up is the whole-batch promise. Under a per-input reservation a +// batch can be admitted for input 0 and refused at input 7 because the window +// ran out under it. That is a clean, fail-closed refusal - the input's +// reservation is released, no share is produced, and the wallet action fails +// with the rotation remedy named - and it replaces a guarantee that was +// unpurchasable anyway: the whole-batch charge did not stop the window running +// out mid batch, it only stopped most operators from starting one. type frostNativeSignerAnchorAdmissionController struct { mutex sync.Mutex @@ -153,11 +181,27 @@ func newFrostNativeSignerAnchorAdmissionController( }, nil } -// reservePreSign reserves the entire upper bound before backend authorization -// or native signing begins. The readiness snapshot was independently -// reconciled twice, but may be stale by the time this mutex is acquired. The -// authenticated current tip is therefore read under the admission lock and the -// smaller headroom in each dimension is authoritative. +// reservePreSign reserves one input's complete upper bound. A pre-sign workflow +// calls it once per input plus once up front, and every call charges the same +// one-input cost: +// +// - once inside authorize(), before the authorization is relayed on chain, so +// the relay is still gated by a node that has the capacity to act on it. It +// is released before the loop below starts, because the two are the same +// size and holding them together would charge two inputs for one input's +// work; and +// - once for each input of the sequential signing loop, released as soon as +// that input is signed or has failed. +// +// inputCount is the size of the batch this admission belongs to. It bounds +// nothing in the cost - one input costs the same whether it is the only input +// or one of twenty-one - and is taken so a batch outside the protocol's legal +// range is refused here as well as at proposal validation. +// +// The readiness snapshot was independently reconciled twice, but may be stale +// by the time this mutex is acquired. The authenticated current tip is +// therefore read under the admission lock and the smaller headroom in each +// dimension is authoritative. func (controller *frostNativeSignerAnchorAdmissionController) reservePreSign( ctx context.Context, snapshot *frostProductionSignerReadinessSnapshot, @@ -341,15 +385,24 @@ func (controller *frostNativeSignerAnchorAdmissionController) reserve( ) } // Both unreserved-headroom refusals name the same remedy as the - // rotation-floor refusal above. They are not a smaller version of it: a - // node refused here still has most of both windows unspent - a four-seat - // node is refused its next maximum-size sweep with 3508 of 4096 generations - // left - so the number in the message reads healthy and the refusal looks - // transient. It is not. Neither window refills on its own; only an - // offline-authorized rotation moves the certified floor forward, so this is - // the first message an operator of a correctly configured node sees, and it - // has to say what to do about it rather than leave rotation to be inferred - // from the far rarer floor refusal. + // rotation-floor refusal above. They are not a smaller version of it: a node + // refused here can still have a large part of both windows unspent - a + // fifty-seat node needs 2015 generations for its next input and is refused + // with 2015 of 4096 left, and a node sharing the window with other wallets + // is refused on what they hold rather than on what it has spent - so the + // number in the message reads healthy and the refusal looks transient. It is + // not. Neither window refills on its own; only an offline-authorized + // rotation moves the certified floor forward, so this is the first message + // an operator of a correctly configured node sees, and it has to say what to + // do about it rather than leave rotation to be inferred from the far rarer + // floor refusal. + // + // Since admission reserves one input at a time, this refusal can also land + // part way through a batch whose authorization is already relayed and + // finalized on chain. That is a deliberate trade - the batch-wide charge + // that would have caught it earlier is what excluded most operators from + // signing at all - and it is made visible by its own counter rather than + // hidden behind this message. if controller.reserved.Revisions > headroom.Revisions || cost.Revisions > headroom.Revisions-controller.reserved.Revisions { @@ -470,9 +523,9 @@ func (reservation *frostNativeSignerAnchorRevisionReservation) Release() { }) } -// frostPreSignMaximumAnchorCapacityCost freezes the upper bound for one -// authorized transaction and rejects a workflow no certified restart window -// can cover. For every input: +// frostPreSignMaximumAnchorCapacityCost freezes the upper bound for ONE +// admitted transaction input and rejects work no certified restart window can +// cover. For that one input: // // - BuildTaprootTx is one request-taking call; // - every attempt lets each local seat call Open, Round1, Round2, and Abort; @@ -480,44 +533,44 @@ func (reservation *frostNativeSignerAnchorRevisionReservation) Release() { // // Each of those calls can advance one service revision when its sweep prologue // mutates otherwise-unrelated state. The proof-window cost is the amortized -// per-call generation bound times the anchored-call count plus the -// workflow-wide terminal allowance, not the hard per-operation bound times -// that count: no workflow can pay a repair advance on every one of its calls, -// because every repair advance consumes a pending operation that only an -// earlier failed persist creates. That makes the generation figure an -// amortized reservation with a bounded allowance rather than an exact upper -// bound - the constants above state precisely what it does not cover. -// Attempt derivation, package/evidence handling, and authorization guards do -// not persist Rust state. +// per-call generation bound times the anchored-call count plus the per-input +// terminal allowance, not the hard per-operation bound times that count: no +// input can pay a repair advance on every one of its calls, because every +// repair advance consumes a pending operation that only an earlier failed +// persist creates. That makes the generation figure an amortized reservation +// with a bounded allowance rather than an exact upper bound - the constants +// above state precisely what it does not cover. Attempt derivation, +// package/evidence handling, and authorization guards do not persist Rust +// state. // -// Both windows are finite, so the local seat count an operator can serve at a -// given batch size is finite too. With production parameters (21 inputs, the -// standard 20-deposit sweep plus its main UTXO, and 5 signing attempts) the -// ceiling is four local seats. This is a real operational limit rather than an -// accounting artifact: the windows bound how far a restarting node can still -// prove its own history back to the last offline-authorized floor, and a -// workflow admitted beyond them could not be recovered after a crash. A seat -// above the ceiling is excluded from every batch of that size on every one of -// its attempts, so the errors below name the ceiling explicitly - an operator -// that crosses it would otherwise have to infer it from a wallet that quietly -// stops reaching its signing threshold on full-size batches. +// inputCount is validated but does not scale the cost. A batch's inputs are +// signed strictly sequentially and each one holds its own reservation for its +// own lifetime, so a batch never needs more than one input's worth of +// unconsumed window at any instant. Charging the whole batch up front was the +// arithmetic error this replaces: at production parameters (21 inputs, 5 +// signing attempts) it admitted at most four local seats, which on a +// hundred-seat wallet shared by around twenty operators excluded most of the +// stake-weighted seats and left formed wallets unable to sweep the deposits +// they had already received. // -// The ceiling of four is an accepted exclusion, not a solved problem. Five -// seats in a hundred-seat wallet is an ordinary sortition result for a large -// staker, one seat over the ceiling, and such a node is refused every standard -// 20-deposit sweep: it tops out at 19 inputs, and a six-seat node at 16. Those -// seats are lost to the wallet's signing threshold on full-size batches for as -// long as the node holds them. Moving the ceiling requires enlarging the -// certified restart windows or lowering the signing-attempt limit, both of -// which are protocol-level changes to constants this function only reads, so -// this accounting cannot make the exclusion smaller on its own. +// A ceiling still exists in principle, because both windows are finite. One +// input costs 20*seats+6 revisions and 40*seats+15 generations, so the +// 4096-entry proof window is the binding one and the last seat count it covers +// is 102. A wallet has only frostPreSignAuthorizationMaximumSeats (100) seats to +// give in total, so no protocol-legal seat count is excluded and the ceiling is +// unreachable rather than merely large. The rejections below are kept, and +// still name the ceiling, because they are the guard that makes a future change +// to the certified windows, the signing-attempt limit or the seat cap visible +// instead of silent. func frostPreSignMaximumAnchorCapacityCost( inputCount uint64, localSeatCount uint64, maximumSigningAttempts uint64, ) (frostNativeSignerAnchorCapacity, error) { - cost, err := frostPreSignAnchoredWorkflowCost( - inputCount, + if err := validateFrostPreSignAdmissionInputCount(inputCount); err != nil { + return frostNativeSignerAnchorCapacity{}, err + } + cost, err := frostPreSignAnchoredInputCost( localSeatCount, maximumSigningAttempts, ) @@ -527,15 +580,12 @@ func frostPreSignMaximumAnchorCapacityCost( if cost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents { recordFrostNativeSignerAnchorSeatCeilingRejection() return frostNativeSignerAnchorCapacity{}, fmt.Errorf( - "FROST pre-sign workflow over [%d] inputs with [%d] local seats "+ - "requires [%d] anchor revisions, exceeding the certified history "+ - "window [%d]; %s", - inputCount, + "one FROST pre-sign input with [%d] local seats requires [%d] "+ + "anchor revisions, exceeding the certified history window [%d]; %s", localSeatCount, cost.Revisions, FrostNativeSignerAnchorMaximumHistoryEvents, frostPreSignAdmissibleLocalSeatAdvice( - inputCount, localSeatCount, maximumSigningAttempts, ), @@ -544,15 +594,12 @@ func frostPreSignMaximumAnchorCapacityCost( if cost.Generations > FrostNativeSignerAnchorMaximumHistoryProofEntries { recordFrostNativeSignerAnchorSeatCeilingRejection() return frostNativeSignerAnchorCapacity{}, fmt.Errorf( - "FROST pre-sign workflow over [%d] inputs with [%d] local seats "+ - "requires [%d] signer generations, exceeding the certified proof "+ - "window [%d]; %s", - inputCount, + "one FROST pre-sign input with [%d] local seats requires [%d] "+ + "signer generations, exceeding the certified proof window [%d]; %s", localSeatCount, cost.Generations, FrostNativeSignerAnchorMaximumHistoryProofEntries, frostPreSignAdmissibleLocalSeatAdvice( - inputCount, localSeatCount, maximumSigningAttempts, ), @@ -561,100 +608,77 @@ func frostPreSignMaximumAnchorCapacityCost( return cost, nil } +// validateFrostPreSignAdmissionInputCount refuses a batch outside the +// protocol's legal size. Batch size no longer scales what admission reserves, +// so this is a legality check rather than a cost input; it is kept here as well +// as at proposal validation so a batch that never passed a proposal check +// cannot reach the native signer through this path. +func validateFrostPreSignAdmissionInputCount(inputCount uint64) error { + if inputCount == 0 || + inputCount > uint64(frostPreSignAuthorizationMaximumInputs) { + return fmt.Errorf( + "FROST pre-sign anchor admission input count [%d] is outside [1,%d]", + inputCount, + frostPreSignAuthorizationMaximumInputs, + ) + } + return nil +} + // frostPreSignAdmissibleLocalSeatAdvice states the seat ceiling that produced a -// window rejection in operator-actionable terms, together with the largest -// batch this node's own seat count could still have signed. Every local seat -// above the ceiling is permanently excluded from batches of this size, and the -// wallet loses those seats toward its signing threshold, so this belongs in the -// error itself: the exclusion is otherwise visible only as a group that stops -// assembling a threshold on full-size batches, with no node reporting a cause. -// Both numbers are reported because both levers are the operator's - shed -// seats, or sign smaller batches - and neither is inferable from the other. +// window rejection in operator-actionable terms. Every local seat above the +// ceiling is excluded from every batch, whatever its size, and the wallet loses +// those seats toward its signing threshold, so this belongs in the error +// itself: the exclusion is otherwise visible only as a group that stops +// assembling a threshold, with no node reporting a cause. +// +// Only one lever is named now. Signing a smaller batch was the second lever +// while the reservation scaled with the batch; it no longer helps at all, +// because one input costs the same in a one-input batch as in a full sweep. +// Offering it would send an operator to a remedy that cannot work. func frostPreSignAdmissibleLocalSeatAdvice( - inputCount uint64, localSeatCount uint64, maximumSigningAttempts uint64, ) string { admissibleSeats := frostPreSignMaximumAdmissibleLocalSeatCount( - inputCount, maximumSigningAttempts, ) if admissibleSeats == 0 { - return fmt.Sprintf( - "no local seat count can sign [%d] inputs within the certified "+ - "restart windows; the wallet cannot serve batches of this size "+ - "until those windows or the signing-attempt limit change", - inputCount, - ) - } - admissibleInputs := frostPreSignMaximumAdmissibleInputCount( - localSeatCount, - maximumSigningAttempts, - ) - if admissibleInputs == 0 { - return fmt.Sprintf( - "at most [%d] local seats can sign [%d] inputs within the certified "+ - "restart windows, and [%d] local seats can sign no batch size at "+ - "all, so every local seat above that ceiling is excluded from "+ - "every batch of this size and is lost to the wallet's signing "+ - "threshold", - admissibleSeats, - inputCount, - localSeatCount, - ) + return "no local seat count can sign a single pre-sign input within " + + "the certified restart windows; this wallet can serve no batch of " + + "any size until those windows or the signing-attempt limit change" } return fmt.Sprintf( - "at most [%d] local seats can sign [%d] inputs within the certified "+ - "restart windows, and [%d] local seats can sign at most [%d] inputs, "+ - "so every local seat above that ceiling is excluded from every batch "+ - "of this size and is lost to the wallet's signing threshold", + "at most [%d] local seats can sign one pre-sign input within the "+ + "certified restart windows and this node holds [%d], so it is "+ + "excluded from every batch of every size and all of its seats are "+ + "lost to the wallet's signing threshold; batch size is not a lever "+ + "here - one input costs the same in a one-input batch as in a full "+ + "sweep - so the only remedy is to shed seats down to [%d]", admissibleSeats, - inputCount, localSeatCount, - admissibleInputs, + admissibleSeats, ) } -// frostPreSignMaximumAdmissibleInputCount reports the largest batch size this -// local seat count can still sign within both certified restart windows, or -// zero when no batch size can. Cost rises monotonically with the input count, -// so the first rejection ends the scan. -func frostPreSignMaximumAdmissibleInputCount( - localSeatCount uint64, - maximumSigningAttempts uint64, -) uint64 { - admissibleInputs := uint64(0) - for inputs := uint64(1); inputs <= - uint64(frostPreSignAuthorizationMaximumInputs); inputs++ { - cost, err := frostPreSignAnchoredWorkflowCost( - inputs, - localSeatCount, - maximumSigningAttempts, - ) - if err != nil || - cost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents || - cost.Generations > - FrostNativeSignerAnchorMaximumHistoryProofEntries { - break - } - admissibleInputs = inputs - } - return admissibleInputs -} - // frostPreSignMaximumAdmissibleLocalSeatCount reports the largest local seat -// count whose complete worst-case workflow still fits both certified restart -// windows at this input count, or zero when no seat count does. Cost rises +// count whose complete worst-case single-input reservation still fits both +// certified restart windows, or zero when no seat count does. Cost rises // monotonically with the seat count, so the first rejection ends the scan. +// +// It takes no input count: batch size does not enter the reservation any more, +// so the answer is the same for a one-input batch and for a full sweep. The +// scan stops at frostPreSignAuthorizationMaximumSeats because that is the most +// seats a wallet has to give, and under the current constants every one of them +// fits - a hundred-seat holder reserves 4015 of the 4096-entry proof window for +// one input - so this returns the cap itself and the ceiling excludes nobody. func frostPreSignMaximumAdmissibleLocalSeatCount( - inputCount uint64, maximumSigningAttempts uint64, ) uint64 { admissibleSeats := uint64(0) for seats := uint64(1); seats <= uint64(frostPreSignAuthorizationMaximumSeats); seats++ { - cost, err := frostPreSignAnchoredWorkflowCost( - inputCount, + cost, err := frostPreSignAnchoredInputCost( seats, maximumSigningAttempts, ) @@ -669,23 +693,14 @@ func frostPreSignMaximumAdmissibleLocalSeatCount( return admissibleSeats } -// frostPreSignAnchoredWorkflowCost is the window-independent arithmetic behind -// frostPreSignMaximumAnchorCapacityCost. It is kept separate so the seat -// ceiling can be scanned without re-entering the window diagnostics that report -// it. -func frostPreSignAnchoredWorkflowCost( - inputCount uint64, +// frostPreSignAnchoredInputCost is the window-independent arithmetic behind +// frostPreSignMaximumAnchorCapacityCost, for exactly one transaction input. It +// is kept separate so the seat ceiling can be scanned without re-entering the +// window diagnostics that report it. +func frostPreSignAnchoredInputCost( localSeatCount uint64, maximumSigningAttempts uint64, ) (frostNativeSignerAnchorCapacity, error) { - if inputCount == 0 || - inputCount > uint64(frostPreSignAuthorizationMaximumInputs) { - return frostNativeSignerAnchorCapacity{}, fmt.Errorf( - "FROST pre-sign anchor admission input count [%d] is outside [1,%d]", - inputCount, - frostPreSignAuthorizationMaximumInputs, - ) - } if localSeatCount == 0 || localSeatCount > uint64(frostPreSignAuthorizationMaximumSeats) { return frostNativeSignerAnchorCapacity{}, fmt.Errorf( @@ -712,15 +727,9 @@ func frostPreSignAnchoredWorkflowCost( "FROST pre-sign anchored-call cost overflow", ) } - perInputCalls := uint64(1) + maximumSigningAttempts*perAttemptCalls - if inputCount > maxUint64/perInputCalls { - return frostNativeSignerAnchorCapacity{}, fmt.Errorf( - "FROST pre-sign anchored-call cost overflow", - ) - } - revisions := inputCount * perInputCalls + revisions := uint64(1) + maximumSigningAttempts*perAttemptCalls if revisions > (maxUint64- - frostNativeSignerTerminalGenerationAdvancesPerWorkflow)/ + frostNativeSignerTerminalGenerationAdvancesPerAdmittedInput)/ frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall { return frostNativeSignerAnchorCapacity{}, fmt.Errorf( "FROST pre-sign generation cost overflow", @@ -728,7 +737,7 @@ func frostPreSignAnchoredWorkflowCost( } generations := revisions* frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall + - frostNativeSignerTerminalGenerationAdvancesPerWorkflow + frostNativeSignerTerminalGenerationAdvancesPerAdmittedInput return frostNativeSignerAnchorCapacity{ Revisions: revisions, @@ -741,7 +750,7 @@ func frostPreSignAnchoredWorkflowCost( // work it can actually be asked to admit rather than against a flat number. // // localSeatCount is the largest number of local seats this node holds in any -// one wallet, because a pre-sign workflow is per wallet and reserves against +// one wallet, because a pre-sign reservation is per wallet and reserves against // that wallet's local member indexes. It is a parameter rather than something // derived here because this file has no view of the node's sortition result; // guessing it would be worse than requiring the caller that does know it to @@ -751,15 +760,18 @@ func frostPreSignAnchoredWorkflowCost( // as a hard lower bound, and this condition is added to it rather than // substituted for it, because the two guard different things. The flat floor // guards the rotation-blocked refusal in reserve(): at or below it ALL new work -// is refused regardless of size, so a node that reaches it has already stopped -// signing outright. That makes it a useless warning threshold, because -// admission stops long before it is reached. A node holding four local seats - -// an ordinary sortition result, and the ceiling for the standard 21-input -// sweep - reserves 3615 generations for one maximum-size workflow, so its next -// sweep is refused with 3508 of the 4096 generations still unspent, roughly -// 3359 generations before the flat warning would fire. An operator alerting on -// the flat flag alone learns that the node stopped signing long after it -// stopped signing. +// is refused regardless of size. This term guards the earlier moment at which +// this particular node stops being able to admit an input of its own. +// +// Which of the two fires first now depends on the seat count, and that is the +// honest answer rather than a weakness. One input costs 40*seats+15 +// generations, so a node holding six or fewer local seats needs less than the +// flat floor's 256 and the flat floor is genuinely the binding warning for it; +// a node holding fifty reserves 2015 and is warned about 1759 generations +// earlier than the flat floor would. While the reservation covered a whole +// batch this term was doing far heavier lifting - a four-seat node then +// reserved 3615 generations at once and stopped signing 3359 generations before +// the flat floor - and that gap is exactly what reserving per input removed. // // Each window is compared against its own dimension's cost rather than // comparing the smaller headroom against the generation cost. The generation @@ -767,21 +779,9 @@ func frostPreSignAnchoredWorkflowCost( // warning on the revision window for a shortfall that only the generation // window has. // -// The measuring stick is deliberately the largest ADMISSIBLE workflow, not -// unconditionally a 21-input batch. A node above the seat ceiling can never be -// admitted for 21 inputs at all, and its 21-input cost exceeds the whole -// 4096-entry window, so measuring it that way would latch this warning on for -// the life of the process; because the warning also forces the activation -// handshake's top-level health to false, that would take an otherwise healthy -// five-seat node out of service for a seat-ceiling exclusion no rotation can -// fix. frostPreSignMaximumAdmissibleInputCount reports what this seat count can -// actually be admitted for, so a five-seat node is measured against its own -// 19-input maximum. -// // Native DKG and DKG retirement share the same admission controller but reserve -// two capacity units per local seat - orders of magnitude below a pre-sign -// workflow - so the pre-sign cost bounds them too and they need no separate -// term. +// two capacity units per local seat - well below one pre-sign input - so the +// pre-sign cost bounds them too and they need no separate term. func frostNativeSignerAnchorWorkloadRotationWarning( revisionHeadroom uint64, generationHeadroom uint64, @@ -805,31 +805,29 @@ func frostNativeSignerAnchorWorkloadRotationWarning( } // frostNativeSignerAnchorLargestAdmissibleWorkflowCost reports the worst-case -// capacity one pre-sign workflow can reserve on a node holding this many local -// seats in a single wallet, together with whether any workflow is admissible at -// all. +// capacity one pre-sign admission can reserve on a node holding this many local +// seats in a single wallet, together with whether anything is admissible at +// all. That is one input's cost: admission's unit is an input, and a batch +// never holds more than one input's reservation at a time. // -// It reports false, rather than a cost, for a seat count that no batch size can -// serve and for a seat count of zero. Both mean the same thing for this +// It reports false, rather than a cost, for a seat count no single input can be +// admitted for and for a seat count of zero. Both mean the same thing for this // purpose: pre-sign admission is not what will consume the windows, so only the // flat rotation floor is a meaningful warning threshold, and inventing a cost // here would warn permanently on a node whose seats a rotation cannot restore. func frostNativeSignerAnchorLargestAdmissibleWorkflowCost( localSeatCount uint64, ) (frostNativeSignerAnchorCapacity, bool) { - admissibleInputs := frostPreSignMaximumAdmissibleInputCount( - localSeatCount, - signingAttemptsLimit, - ) - if admissibleInputs == 0 { + if localSeatCount == 0 { return frostNativeSignerAnchorCapacity{}, false } - cost, err := frostPreSignAnchoredWorkflowCost( - admissibleInputs, + cost, err := frostPreSignAnchoredInputCost( localSeatCount, signingAttemptsLimit, ) - if err != nil { + if err != nil || + cost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents || + cost.Generations > FrostNativeSignerAnchorMaximumHistoryProofEntries { return frostNativeSignerAnchorCapacity{}, false } return cost, true @@ -842,13 +840,16 @@ func frostNativeSignerAnchorLargestAdmissibleWorkflowCost( // is registered with clientinfo at all, so a monitoring system cannot tell a // node that is refusing every signing request from one that simply has not been // asked. These process-wide cumulative counters make each refusal cause -// countable on its own, because the four causes need four different responses: +// countable on its own, because the five causes need five different responses: // // - seatCeiling is a CONFIGURATION signal. It never clears on its own and no // rotation fixes it: this node holds more local seats than the certified -// windows can serve at the requested batch size, and those seats are lost to -// the wallet's signing threshold until the operator sheds them. Any non-zero -// value should alert. +// windows can serve for a single transaction input, and those seats are lost +// to the wallet's signing threshold until the operator sheds them. Any +// non-zero value should alert. Under the current constants it is +// unreachable - a wallet's whole hundred-seat set costs 4015 of the +// 4096-entry proof window for one input - so a non-zero value means a +// protocol constant moved, which is exactly why the counter is kept. // - poisoned is a TERMINAL signal. The state anchor has latched a permanent // failure and only a process restart clears it. Any non-zero value should // alert. @@ -857,6 +858,15 @@ func frostNativeSignerAnchorLargestAdmissibleWorkflowCost( // both windows are still unspent. // - rotationFloor is the ROTATION-OVERDUE signal. All new work is already // being refused regardless of size. +// - preSignInput is the WORK-LOST signal, and it is the one that costs money. +// A batch is admitted one input at a time, so a batch whose authorization +// has already been relayed and finalized on chain can still be refused at +// input 7 of 21 when the window runs out under it. The gas is spent, the +// wallet action fails, and the underlying cause is counted by +// unreservedHeadroom or rotationFloor alongside it - this counter is what +// separates "refused before we spent anything" from "refused after we did". +// It counts refusals of the per-input reservation, including the first +// input's, because at that point the relay has already happened either way. // // They follow the roast_interactive_signing_metrics.go pattern, are emitted in // every build, and stay at zero until an admission is actually refused - so @@ -866,6 +876,7 @@ var ( frostNativeSignerAnchorUnreservedHeadroomRejections atomic.Uint64 frostNativeSignerAnchorRotationFloorRejections atomic.Uint64 frostNativeSignerAnchorPoisonedRejections atomic.Uint64 + frostNativeSignerAnchorPreSignInputRejections atomic.Uint64 ) // frostNativeSignerAnchorAdmissionMetricsApplication is the clientinfo @@ -879,6 +890,7 @@ const ( frostNativeSignerAnchorUnreservedHeadroomMetricName = "unreserved_headroom_rejected_total" frostNativeSignerAnchorRotationFloorMetricName = "rotation_floor_rejected_total" frostNativeSignerAnchorPoisonedMetricName = "poisoned_rejected_total" + frostNativeSignerAnchorPreSignInputMetricName = "pre_sign_input_rejected_total" ) // RegisterFrostNativeSignerAnchorAdmissionMetrics registers the cumulative @@ -916,13 +928,18 @@ func RegisterFrostNativeSignerAnchorAdmissionMetrics( frostNativeSignerAnchorPoisonedRejections.Load(), ) }, + frostNativeSignerAnchorPreSignInputMetricName: func() float64 { + return float64( + frostNativeSignerAnchorPreSignInputRejections.Load(), + ) + }, }, ) } -// recordFrostNativeSignerAnchorSeatCeilingRejection marks one workflow refused -// because its worst case cannot fit a certified restart window at this node's -// local seat count. +// recordFrostNativeSignerAnchorSeatCeilingRejection marks one admission refused +// because one input's worst case cannot fit a certified restart window at this +// node's local seat count. func recordFrostNativeSignerAnchorSeatCeilingRejection() { frostNativeSignerAnchorSeatCeilingRejections.Add(1) } @@ -946,6 +963,13 @@ func recordFrostNativeSignerAnchorPoisonedRejection() { frostNativeSignerAnchorPoisonedRejections.Add(1) } +// recordFrostNativeSignerAnchorPreSignInputRejection marks one input of an +// already-relayed pre-sign batch refused for want of anchor capacity. The cause +// is counted separately by whichever reserve() refusal produced it. +func recordFrostNativeSignerAnchorPreSignInputRejection() { + frostNativeSignerAnchorPreSignInputRejections.Add(1) +} + // resetFrostNativeSignerAnchorAdmissionMetricsForTest clears the cumulative // counters. Exposed only for the package's own tests; not a production helper. func resetFrostNativeSignerAnchorAdmissionMetricsForTest() { @@ -953,4 +977,5 @@ func resetFrostNativeSignerAnchorAdmissionMetricsForTest() { frostNativeSignerAnchorUnreservedHeadroomRejections.Store(0) frostNativeSignerAnchorRotationFloorRejections.Store(0) frostNativeSignerAnchorPoisonedRejections.Store(0) + frostNativeSignerAnchorPreSignInputRejections.Store(0) } diff --git a/pkg/tbtc/frost_native_signer_anchor_admission_test.go b/pkg/tbtc/frost_native_signer_anchor_admission_test.go index 44d28c6a7f..1b7f875637 100644 --- a/pkg/tbtc/frost_native_signer_anchor_admission_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_admission_test.go @@ -10,7 +10,15 @@ import ( frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" ) +// TestFrostPreSignMaximumAnchorCapacityCost pins the reservation contract: +// anchor admission charges for ONE transaction input, and the batch that input +// belongs to does not scale it. Inputs are signed strictly sequentially, so a +// batch never needs more than one input's worth of unconsumed window at any +// instant; charging the whole batch reserved 21 times the peak demand. func TestFrostPreSignMaximumAnchorCapacityCost(t *testing.T) { + // One input with one local seat: BuildTaprootTx, then five attempts of + // (Open, Round1, Round2, Abort) per seat plus one memoized Aggregate, so + // 1 + 5*(4*1+1) = 26 revisions and 2*26+3 = 55 generations. cost, err := frostPreSignMaximumAnchorCapacityCost( frostPreSignAuthorizationMaximumInputs, 1, @@ -19,53 +27,109 @@ func TestFrostPreSignMaximumAnchorCapacityCost(t *testing.T) { if err != nil { t.Fatal(err) } - if cost.Revisions != 546 || cost.Generations != 1095 { - t.Fatalf("unexpected single-seat maximum cost [%+v]", cost) - } - if cost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents || - cost.Generations > FrostNativeSignerAnchorMaximumHistoryProofEntries { - t.Fatalf( - "single-seat maximum [%+v] exceeds restart windows [%d/%d]", - cost, - FrostNativeSignerAnchorMaximumHistoryEvents, - FrostNativeSignerAnchorMaximumHistoryProofEntries, - ) + if cost.Revisions != 26 || cost.Generations != 55 { + t.Fatalf("unexpected single-seat per-input cost [%+v]", cost) } - multiSeatCost, err := frostPreSignMaximumAnchorCapacityCost( - frostPreSignAuthorizationMaximumInputs, + // The batch size is validated but must not enter the cost. A one-input + // batch and a full sweep reserve exactly the same thing, because they + // reserve it once per input either way. + for _, inputCount := range []uint64{ + 1, 2, - signingAttemptsLimit, - ) - if err != nil { - t.Fatal(err) + uint64(frostPreSignAuthorizationMaximumInputs), + } { + batchCost, err := frostPreSignMaximumAnchorCapacityCost( + inputCount, + 1, + signingAttemptsLimit, + ) + if err != nil { + t.Fatalf("[%d]-input batch was rejected: %v", inputCount, err) + } + if batchCost != cost { + t.Fatalf( + "[%d]-input batch reserved [%+v], not the per-input [%+v]; the "+ + "reservation is scaling with the batch again", + inputCount, + batchCost, + cost, + ) + } } - if multiSeatCost.Revisions != 966 || - multiSeatCost.Generations != 1935 { - t.Fatalf("unexpected multi-seat maximum cost [%+v]", multiSeatCost) + + // An illegal batch size is still refused here, so a batch that never + // passed proposal validation cannot reach the native signer this way. + for _, inputCount := range []uint64{ + 0, + uint64(frostPreSignAuthorizationMaximumInputs) + 1, + } { + if _, err := frostPreSignMaximumAnchorCapacityCost( + inputCount, + 1, + signingAttemptsLimit, + ); err == nil || !strings.Contains(err.Error(), "input count") { + t.Fatalf( + "[%d]-input batch was admitted: [%v]", + inputCount, + err, + ) + } } - // Three and four local seats are an ordinary sortition result in a - // hundred-seat wallet, and a twenty-one-input batch is the standard - // deposit sweep maximum. Both must be admissible at the maximum batch - // size: a seat excluded here is lost to the wallet's signing threshold on - // every batch of that size, and enough excluded seats stop the group from - // assembling a threshold at all. - for _, seats := range []uint64{3, 4} { + // The seat counts that matter operationally. Mainnet sortition samples a + // hundred-seat wallet with replacement across roughly twenty operators, so + // the average holder has five seats and a large staker can hold twenty or + // more. Every one of them must be able to sign a full 20-deposit sweep plus + // its main UTXO; a seat excluded here is lost to the wallet's signing + // threshold, and enough excluded seats leave a formed wallet unable to move + // the deposits it has already received. + for _, test := range []struct { + seats uint64 + revisions uint64 + generations uint64 + }{ + {seats: 1, revisions: 26, generations: 55}, + {seats: 4, revisions: 86, generations: 175}, + {seats: 5, revisions: 106, generations: 215}, + {seats: 20, revisions: 406, generations: 815}, + // The whole wallet held by one operator - not a realistic sortition + // result, but the protocol's own maximum, and it has to fit for the + // ceiling to be gone rather than merely moved. + { + seats: uint64(frostPreSignAuthorizationMaximumSeats), + revisions: 2006, + generations: 4015, + }, + } { seatCost, err := frostPreSignMaximumAnchorCapacityCost( frostPreSignAuthorizationMaximumInputs, - seats, + test.seats, signingAttemptsLimit, ) if err != nil { - t.Fatalf("maximum batch with [%d] local seats was rejected: %v", seats, err) + t.Fatalf( + "maximum batch with [%d] local seats was rejected: %v", + test.seats, + err, + ) + } + if seatCost.Revisions != test.revisions || + seatCost.Generations != test.generations { + t.Fatalf( + "[%d]-seat per-input cost [%+v], expected [%d/%d]", + test.seats, + seatCost, + test.revisions, + test.generations, + ) } if seatCost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents || seatCost.Generations > FrostNativeSignerAnchorMaximumHistoryProofEntries { t.Fatalf( - "[%d]-seat maximum [%+v] exceeds restart windows [%d/%d]", - seats, + "[%d]-seat per-input cost [%+v] exceeds restart windows [%d/%d]", + test.seats, seatCost, FrostNativeSignerAnchorMaximumHistoryEvents, FrostNativeSignerAnchorMaximumHistoryProofEntries, @@ -73,30 +137,104 @@ func TestFrostPreSignMaximumAnchorCapacityCost(t *testing.T) { } } - // The windows are finite, so the ceiling is real and must stay pinned - // where the accounting actually puts it. Crossing it is reported with the - // ceiling itself, because a node above it is otherwise silently excluded - // from every batch of this size. + // The ceiling is gone rather than moved: every seat count a wallet can + // award fits. The closed form is 40*seats+15 generations, which passes the + // 4096-entry proof window only at 103 seats, and a wallet has 100 to give. if admissibleSeats := frostPreSignMaximumAdmissibleLocalSeatCount( - frostPreSignAuthorizationMaximumInputs, signingAttemptsLimit, - ); admissibleSeats != 4 { + ); admissibleSeats != uint64(frostPreSignAuthorizationMaximumSeats) { t.Fatalf( - "unexpected maximum-batch local seat ceiling [%d]", + "local seat ceiling is [%d], expected every one of the wallet's "+ + "[%d] seats to be admissible", admissibleSeats, + frostPreSignAuthorizationMaximumSeats, ) } - _, err = frostPreSignMaximumAnchorCapacityCost( - frostPreSignAuthorizationMaximumInputs, - 5, +} + +// TestFrostPreSignPerInputReservationAdmitsMainnetSeatCounts is the regression +// this change exists for. Charging a whole batch up front admitted at most four +// local seats for a 21-input sweep. Mainnet runs a hundred-seat wallet across +// roughly twenty operators with replacement sortition and no per-operator cap, +// so the average operator holds five seats: wallets formed normally and then +// could not sweep, because most of the stake-weighted seats were refused every +// full-size batch and the group never reached its signing threshold. +// +// Reserving per input removes the exclusion outright rather than raising the +// bar, and this test states both halves - that the old arithmetic really did +// refuse these operators, and that the new one admits them. +func TestFrostPreSignPerInputReservationAdmitsMainnetSeatCounts(t *testing.T) { + const maximumInputs = uint64(frostPreSignAuthorizationMaximumInputs) + + for _, seats := range []uint64{ + 1, 4, 5, 6, 7, 10, 20, 50, + uint64(frostPreSignAuthorizationMaximumSeats), + } { + cost, err := frostPreSignMaximumAnchorCapacityCost( + maximumInputs, + seats, + signingAttemptsLimit, + ) + if err != nil { + t.Fatalf( + "[%d] local seats cannot sign a [%d]-input sweep: %v", + seats, + maximumInputs, + err, + ) + } + + // The reservation an operator of this size is charged has to be + // coverable by a freshly rotated window with room to keep working, not + // merely to fit inside it once. + if cost.Generations >= + uint64(FrostNativeSignerAnchorMaximumHistoryProofEntries) && + seats != uint64(frostPreSignAuthorizationMaximumSeats) { + t.Fatalf( + "[%d] local seats reserve [%d] of the [%d]-entry proof window "+ + "for one input", + seats, + cost.Generations, + FrostNativeSignerAnchorMaximumHistoryProofEntries, + ) + } + + // What the batch-wide charge would have been for the same operator. + // Anything over the window is an operator the old accounting excluded. + batchGenerations := maximumInputs*cost.Revisions* + frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall + + frostNativeSignerTerminalGenerationAdvancesPerAdmittedInput + excludedBefore := batchGenerations > + uint64(FrostNativeSignerAnchorMaximumHistoryProofEntries) + if seats >= 5 && !excludedBefore { + t.Fatalf( + "[%d] local seats were not excluded by the batch-wide charge "+ + "([%d] generations); this test no longer covers the defect", + seats, + batchGenerations, + ) + } + } + + // The five-seat operator is the average mainnet holder and the first one + // the old ceiling of four excluded. Pin it as a number so a regression + // reads as this test failing rather than as wallets quietly not sweeping. + fiveSeatCost, err := frostPreSignAnchoredInputCost(5, signingAttemptsLimit) + if err != nil { + t.Fatal(err) + } + if fiveSeatCost.Revisions != 106 || fiveSeatCost.Generations != 215 { + t.Fatalf("unexpected five-seat per-input cost [%+v]", fiveSeatCost) + } + twentySeatCost, err := frostPreSignAnchoredInputCost( + 20, signingAttemptsLimit, ) - if err == nil || !strings.Contains(err.Error(), "proof window") { - t.Fatalf("oversized workflow was accepted: [%v]", err) + if err != nil { + t.Fatal(err) } - if !strings.Contains(err.Error(), "at most [4] local seats") || - !strings.Contains(err.Error(), "signing threshold") { - t.Fatalf("oversized workflow did not report its seat ceiling: [%v]", err) + if twentySeatCost.Revisions != 406 || twentySeatCost.Generations != 815 { + t.Fatalf("unexpected twenty-seat per-input cost [%+v]", twentySeatCost) } } @@ -131,16 +269,13 @@ func TestFrostPreSignGenerationReservationIsAmortizedNotWorstCase(t *testing.T) ) } - for _, test := range []struct { - inputCount uint64 - localSeatCount uint64 - }{ - {inputCount: 1, localSeatCount: 1}, - {inputCount: frostPreSignAuthorizationMaximumInputs, localSeatCount: 4}, + for _, localSeatCount := range []uint64{ + 1, + 4, + uint64(frostPreSignAuthorizationMaximumSeats), } { - cost, err := frostPreSignAnchoredWorkflowCost( - test.inputCount, - test.localSeatCount, + cost, err := frostPreSignAnchoredInputCost( + localSeatCount, signingAttemptsLimit, ) if err != nil { @@ -148,13 +283,11 @@ func TestFrostPreSignGenerationReservationIsAmortizedNotWorstCase(t *testing.T) } expected := cost.Revisions* frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall + - frostNativeSignerTerminalGenerationAdvancesPerWorkflow + frostNativeSignerTerminalGenerationAdvancesPerAdmittedInput if cost.Generations != expected { t.Fatalf( - "[%d] inputs with [%d] seats reserved [%d] generations, not the "+ - "amortized [%d]", - test.inputCount, - test.localSeatCount, + "[%d] seats reserved [%d] generations, not the amortized [%d]", + localSeatCount, cost.Generations, expected, ) @@ -165,92 +298,121 @@ func TestFrostPreSignGenerationReservationIsAmortizedNotWorstCase(t *testing.T) if cost.Generations >= cost.Revisions*frostsigning. NativeTBTCSignerStateAnchorEngineReachableGenerationAdvancePerOperation { t.Fatalf( - "[%d] inputs with [%d] seats reserved the per-call worst case "+ - "[%d] rather than an amortized bound", - test.inputCount, - test.localSeatCount, + "[%d] seats reserved the per-call worst case [%d] rather than "+ + "an amortized bound", + localSeatCount, cost.Generations, ) } } } -// TestFrostPreSignSeatCeilingResidual pins the exclusion the amortized -// reservation still leaves in place. Four local seats is the ceiling at the -// standard maximum batch, so a five-seat holder in a hundred-seat wallet - an -// ordinary sortition result for a large staker, one seat over - is refused -// every 20-deposit sweep, and those seats are lost to the wallet's signing -// threshold. That is an accepted operational limit; moving it needs the -// certified windows or the attempt limit to change, so the numbers are pinned -// here and reported in the rejection itself. -func TestFrostPreSignSeatCeilingResidual(t *testing.T) { - if admissibleSeats := frostPreSignMaximumAdmissibleLocalSeatCount( - frostPreSignAuthorizationMaximumInputs, - signingAttemptsLimit, - ); admissibleSeats != 4 { +// TestFrostPreSignSeatCeilingGuardStillFires pins that the seat-ceiling refusal +// is unreachable rather than deleted. Nothing a wallet can award trips it now - +// one input costs 40*seats+15 generations and a wallet has a hundred seats to +// give - but it is the guard that would catch a raised signing-attempt limit or +// a shrunken certified window before it silently excluded operators again, so +// it must keep working and keep naming the ceiling. +func TestFrostPreSignSeatCeilingGuardStillFires(t *testing.T) { + resetFrostNativeSignerAnchorAdmissionMetricsForTest() + t.Cleanup(resetFrostNativeSignerAnchorAdmissionMetricsForTest) + + // No protocol-legal seat count is refused under the shipped constants. + for seats := uint64(1); seats <= + uint64(frostPreSignAuthorizationMaximumSeats); seats++ { + if _, err := frostPreSignMaximumAnchorCapacityCost( + uint64(frostPreSignAuthorizationMaximumInputs), + seats, + signingAttemptsLimit, + ); err != nil { + t.Fatalf("[%d] local seats were refused: %v", seats, err) + } + } + if rejections := + frostNativeSignerAnchorSeatCeilingRejections.Load(); rejections != 0 { t.Fatalf( - "unexpected maximum-batch local seat ceiling [%d]", - admissibleSeats, + "the seat ceiling refused [%d] admissible seat counts", + rejections, ) } - for _, test := range []struct { - localSeatCount uint64 - admissibleInputs uint64 - servesStandardSweep bool - }{ - {localSeatCount: 4, admissibleInputs: 21, servesStandardSweep: true}, - {localSeatCount: 5, admissibleInputs: 19, servesStandardSweep: false}, - {localSeatCount: 6, admissibleInputs: 16, servesStandardSweep: false}, + + // A raised attempt limit is the change most likely to move the ceiling + // back into reach, so it is what drives the guard here. At twenty attempts + // one input costs 160*seats+45 generations and the proof window binds at + // twenty-five local seats. + const attempts = uint64(20) + if ceiling := frostPreSignMaximumAdmissibleLocalSeatCount( + attempts, + ); ceiling != 25 { + t.Fatalf("unexpected [%d]-attempt seat ceiling [%d]", attempts, ceiling) + } + if _, err := frostPreSignMaximumAnchorCapacityCost( + uint64(frostPreSignAuthorizationMaximumInputs), + 25, + attempts, + ); err != nil { + t.Fatalf("the [%d]-attempt ceiling itself was refused: %v", attempts, err) + } + _, err := frostPreSignMaximumAnchorCapacityCost( + uint64(frostPreSignAuthorizationMaximumInputs), + 26, + attempts, + ) + if err == nil { + t.Fatal("a seat count over the ceiling was admitted") + } + // The refusal has to name the ceiling, this node's own seat count, and the + // only remedy. Batch size must NOT be offered: it is no longer a lever, and + // an operator who shrinks sweeps because the error suggested it would lose + // throughput and still be excluded. + for _, want := range []string{ + "at most [25] local seats", + "this node holds [26]", + "shed seats down to [25]", + "signing threshold", + "batch size is not a lever", } { - admissibleInputs := frostPreSignMaximumAdmissibleInputCount( - test.localSeatCount, - signingAttemptsLimit, - ) - if admissibleInputs != test.admissibleInputs { - t.Fatalf( - "[%d] local seats top out at [%d] inputs, expected [%d]", - test.localSeatCount, - admissibleInputs, - test.admissibleInputs, - ) - } - if servesStandardSweep := admissibleInputs >= - uint64(frostPreSignAuthorizationMaximumInputs); servesStandardSweep != - test.servesStandardSweep { - t.Fatalf( - "[%d] local seats serving the standard sweep is [%t], expected [%t]", - test.localSeatCount, - servesStandardSweep, - test.servesStandardSweep, - ) + if !strings.Contains(err.Error(), want) { + t.Errorf("seat-ceiling refusal is missing %q: [%v]", want, err) } } + if !strings.Contains(err.Error(), "proof window") { + t.Errorf("seat-ceiling refusal did not name the window: [%v]", err) + } - // The rejection has to name both levers the operator holds: how many seats - // this batch admits, and how large a batch these seats admit. Neither is - // inferable from the other, and the exclusion is otherwise visible only as - // a wallet that quietly stops reaching its signing threshold. - _, err := frostPreSignMaximumAnchorCapacityCost( - frostPreSignAuthorizationMaximumInputs, - 5, - signingAttemptsLimit, + // The revision window is the binding one at a high enough attempt count, + // and it has its own message. + _, err = frostPreSignMaximumAnchorCapacityCost( + uint64(frostPreSignAuthorizationMaximumInputs), + 1, + 1000, ) - if err == nil { - t.Fatal("five local seats were admitted at the maximum batch size") + if err == nil || !strings.Contains(err.Error(), "history window") { + t.Fatalf("the revision window did not refuse an oversized cost: [%v]", err) + } + if !strings.Contains(err.Error(), "no local seat count can sign") { + t.Errorf( + "a configuration no seat count can serve did not say so: [%v]", + err, + ) } - if !strings.Contains(err.Error(), "at most [4] local seats") || - !strings.Contains( - err.Error(), - "[5] local seats can sign at most [19] inputs", - ) || - !strings.Contains(err.Error(), "signing threshold") { - t.Fatalf("seat-ceiling rejection lost an operator lever: [%v]", err) + + if rejections := + frostNativeSignerAnchorSeatCeilingRejections.Load(); rejections != 2 { + t.Fatalf( + "seat-ceiling rejections counted [%d], expected [2]", + rejections, + ) } } func TestFrostNativeSignerAnchorAdmissionController_AtomicallyReservesNearWarning( t *testing.T, ) { + // Six local seats cost 126 revisions and 255 generations for one input, so + // one fits a headroom of 257 and two do not. That is the property under + // test: the second workflow is refused on what the first was promised, not + // on what the anchor has already spent. currentHeadroom := frostNativeSignerAnchorCapacity{ Revisions: FrostNativeSignerAnchorRotationWarningHeadroom + 1, Generations: FrostNativeSignerAnchorRotationWarningHeadroom + 1, @@ -267,14 +429,11 @@ func TestFrostNativeSignerAnchorAdmissionController_AtomicallyReservesNearWarnin FrostNativeSignerAnchorRotationWarningHeadroom+1, ) - // Four inputs and one local seat reserve 104 revisions and 211 - // generations. Admission one unit above the warning boundary is safe - // because the full dual-dimensional cost is reserved before the workflow. first, err := controller.reservePreSign( context.Background(), snapshot, 4, - 1, + 6, signingAttemptsLimit, ) if err != nil { @@ -282,13 +441,13 @@ func TestFrostNativeSignerAnchorAdmissionController_AtomicallyReservesNearWarnin } defer first.Release() - // A concurrent workflow cannot spend the revisions promised to the first, + // A concurrent workflow cannot spend the capacity promised to the first, // even though both observed the same pre-mutation readiness snapshot. if _, err := controller.reservePreSign( context.Background(), snapshot, 1, - 1, + 6, signingAttemptsLimit, ); err == nil || !strings.Contains(err.Error(), "unreserved") { t.Fatalf("concurrent reservation exceeded headroom: [%v]", err) @@ -329,7 +488,7 @@ func TestFrostNativeSignerAnchorAdmissionController_AtomicallyReservesNearWarnin context.Background(), snapshot, 1, - 1, + 6, signingAttemptsLimit, ); err != nil { t.Fatalf("released reservation remained charged: [%v]", err) @@ -352,14 +511,14 @@ func TestFrostNativeSignerAnchorAdmissionController_RejectsStaleReadinessHeadroo } staleSnapshot := testFrostAnchorAdmissionReadinessSnapshot(400, 700) - // Twelve inputs and one seat cost 312 revisions and 627 generations. The - // stale snapshot would admit it, but the authenticated current tip has - // advanced after that snapshot and must be authoritative. + // Thirteen local seats cost 266 revisions and 535 generations for one + // input. The stale snapshot would admit that, but the authenticated current + // tip has advanced after that snapshot and must be authoritative. if _, err := controller.reservePreSign( context.Background(), staleSnapshot, 12, - 1, + 13, signingAttemptsLimit, ); err == nil || !strings.Contains(err.Error(), "unreserved") { t.Fatalf("stale readiness headroom was trusted: [%v]", err) @@ -493,6 +652,9 @@ func TestFrostNativeSignerAnchorAdmissionController_RefusesWhilePoisoned( if rotationFloor := frostNativeSignerAnchorRotationFloorRejections.Load(); rotationFloor != 0 { t.Fatalf("rotation-floor rejections counted [%d]", rotationFloor) } + if preSignInput := frostNativeSignerAnchorPreSignInputRejections.Load(); preSignInput != 0 { + t.Fatalf("pre-sign input rejections counted [%d]", preSignInput) + } // The poison was the only thing refusing: with it cleared the identical // reservation is admitted, so the refusals above cannot be explained by @@ -509,34 +671,47 @@ func TestFrostNativeSignerAnchorAdmissionController_RefusesWhilePoisoned( t.Fatalf("healthy anchor refused an admissible workflow: [%v]", err) } reservation.Release() + + resetFrostNativeSignerAnchorAdmissionMetricsForTest() } // TestFrostNativeSignerAnchorWorkloadRotationWarning pins the warning against -// the workload it is supposed to warn about. The flat floor fires only once all -// new work is already refused, which is far too late to be a warning: a -// four-seat node - an ordinary sortition result, and the ceiling for the -// standard 21-input sweep - stops admitting maximum-size batches thousands of -// generations before it. +// the workload it is supposed to warn about: the largest single admission this +// node can be asked to make, which is one transaction input. +// +// Which of the two terms binds now depends on the seat count, and that is the +// honest answer. One input costs 40*seats+15 generations, so a small holder +// needs less than the flat floor's 256 and the flat floor genuinely is its +// warning; a large holder is warned much earlier by the workload term. While +// the reservation covered a whole batch this term carried everything - a +// four-seat node then stopped signing 3359 generations before the flat floor - +// and that gap is exactly what reserving per input removed. func TestFrostNativeSignerAnchorWorkloadRotationWarning(t *testing.T) { - fourSeatCost, err := frostPreSignAnchoredWorkflowCost( - frostPreSignAuthorizationMaximumInputs, - 4, - signingAttemptsLimit, - ) + fiftySeatCost, err := frostPreSignAnchoredInputCost(50, signingAttemptsLimit) + if err != nil { + t.Fatal(err) + } + if fiftySeatCost.Revisions != 1006 || fiftySeatCost.Generations != 2015 { + t.Fatalf("unexpected fifty-seat per-input cost [%+v]", fiftySeatCost) + } + fourSeatCost, err := frostPreSignAnchoredInputCost(4, signingAttemptsLimit) if err != nil { t.Fatal(err) } - if fourSeatCost.Revisions != 1806 || fourSeatCost.Generations != 3615 { - t.Fatalf("unexpected four-seat maximum-batch cost [%+v]", fourSeatCost) + if fourSeatCost.Revisions != 86 || fourSeatCost.Generations != 175 { + t.Fatalf("unexpected four-seat per-input cost [%+v]", fourSeatCost) } - // The gap the flat threshold leaves open, pinned as a number so that - // shrinking it is a deliberate act. - if gap := fourSeatCost.Generations - - uint64(FrostNativeSignerAnchorRotationWarningHeadroom); gap != 3359 { + // The gap the flat threshold used to leave open is gone for a small + // holder: its next admission now costs less than the flat floor, so the + // flat floor fires first and there is nothing left for the workload term + // to catch. Pinning this keeps a future reservation increase honest. + if fourSeatCost.Generations >= + uint64(FrostNativeSignerAnchorRotationWarningHeadroom) { t.Fatalf( - "the flat warning now fires [%d] generations after a four-seat "+ - "node stops signing, expected [3359]", - gap, + "a four-seat node again needs [%d] generations per admission, at "+ + "or above the flat rotation floor [%d]", + fourSeatCost.Generations, + FrostNativeSignerAnchorRotationWarningHeadroom, ) } @@ -547,7 +722,8 @@ func TestFrostNativeSignerAnchorWorkloadRotationWarning(t *testing.T) { localSeatCount uint64 warning bool }{ - // A fresh rotation warns about nothing. + // A fresh rotation warns about nothing, at any seat count a wallet can + // award. { name: "four seats, both windows unspent", revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, @@ -555,61 +731,66 @@ func TestFrostNativeSignerAnchorWorkloadRotationWarning(t *testing.T) { localSeatCount: 4, warning: false, }, - // One generation above the next workflow's cost is the last moment - // this node can still admit a maximum-size batch, so it is the last - // moment before the warning. { - name: "four seats, one generation above the next workflow", + name: "the whole wallet on one node, both windows unspent", revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, - generationHeadroom: 3616, - localSeatCount: 4, + generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, + localSeatCount: uint64(frostPreSignAuthorizationMaximumSeats), warning: false, }, + // One generation above the next admission's cost is the last moment + // this node can still be admitted, so it is the last moment before the + // warning. { - name: "four seats, exactly the next workflow's generations", + name: "fifty seats, one generation above the next admission", revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, - generationHeadroom: 3615, - localSeatCount: 4, + generationHeadroom: 2016, + localSeatCount: 50, + warning: false, + }, + { + name: "fifty seats, exactly the next admission's generations", + revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + generationHeadroom: 2015, + localSeatCount: 50, warning: true, }, // Each window is measured against its own dimension's cost. The // revision cost is about half the generation cost, so a revision // shortfall has to be caught on the revision number. { - name: "four seats, revision window at the next workflow's revisions", - revisionHeadroom: 1806, + name: "fifty seats, revision window at the next admission's revisions", + revisionHeadroom: 1006, generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, - localSeatCount: 4, + localSeatCount: 50, warning: true, }, { - name: "four seats, revision window one above", - revisionHeadroom: 1807, + name: "fifty seats, revision window one above", + revisionHeadroom: 1007, generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, - localSeatCount: 4, + localSeatCount: 50, warning: false, }, - // A node above the seat ceiling must not be warned forever. It can - // never be admitted for 21 inputs at all, so it is measured against - // the 19-input batch it can actually be admitted for; warning - // permanently would also force its activation-handshake health to - // false for an exclusion that no rotation can fix. + // A small holder's admission costs less than the flat floor, so the + // flat floor is what warns it and the workload term stays quiet above + // that floor. { - name: "five seats, both windows unspent", - revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, - generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, - localSeatCount: 5, + name: "four seats, one above the flat floor", + revisionHeadroom: FrostNativeSignerAnchorRotationWarningHeadroom + 1, + generationHeadroom: FrostNativeSignerAnchorRotationWarningHeadroom + 1, + localSeatCount: 4, warning: false, }, { - name: "five seats, at its own admissible maximum", - revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, - generationHeadroom: 4031, - localSeatCount: 5, + name: "four seats, at the flat floor", + revisionHeadroom: FrostNativeSignerAnchorRotationWarningHeadroom, + generationHeadroom: FrostNativeSignerAnchorRotationWarningHeadroom, + localSeatCount: 4, warning: true, }, - // A seat count that can serve no batch at all leaves only the flat - // floor, which still has to work. + // A seat count that can be admitted for nothing at all leaves only the + // flat floor, which still has to work. { name: "no local seats, windows unspent", revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, @@ -624,8 +805,6 @@ func TestFrostNativeSignerAnchorWorkloadRotationWarning(t *testing.T) { localSeatCount: 0, warning: true, }, - // The flat floor is kept, not replaced: it still fires for a seat - // count whose workload term would not have. { name: "one seat, at the flat floor", revisionHeadroom: FrostNativeSignerAnchorRotationWarningHeadroom, @@ -648,17 +827,17 @@ func TestFrostNativeSignerAnchorWorkloadRotationWarning(t *testing.T) { } } - // The whole point of the change: the flat predicate is still silent at the - // exact headroom where a four-seat node stops being able to sign a - // maximum-size batch. + // The workload term still earns its place for the seat counts whose + // admission costs more than the flat floor: the flat predicate is silent at + // the exact headroom where a fifty-seat node stops being admissible. if frostNativeSignerAnchorRotationWarning( minFrostNativeSignerAnchorHeadroom( FrostNativeSignerAnchorMaximumHistoryEvents, - fourSeatCost.Generations, + fiftySeatCost.Generations, ), ) { t.Fatal( - "the flat rotation warning already fires at the four-seat " + + "the flat rotation warning already fires at the fifty-seat " + "workload threshold, so the workload-relative term adds nothing", ) } @@ -725,13 +904,15 @@ func TestFrostNativeSignerAnchorAdmissionRefusalsNameARemedy(t *testing.T) { } // The seat-ceiling refusal is a configuration signal no rotation fixes, so - // it must stay separately countable from the rotation causes. + // it must stay separately countable from the rotation causes. Nothing a + // wallet can award reaches it now, so it is driven here the only way that + // still can: a signing-attempt limit no certified window could serve. if _, err := frostPreSignMaximumAnchorCapacityCost( - frostPreSignAuthorizationMaximumInputs, - 5, - signingAttemptsLimit, + uint64(frostPreSignAuthorizationMaximumInputs), + 1, + 1000, ); err == nil { - t.Fatal("five local seats were admitted at the maximum batch size") + t.Fatal("an unservable signing-attempt limit was admitted") } for _, test := range []struct { @@ -759,6 +940,14 @@ func TestFrostNativeSignerAnchorAdmissionRefusalsNameARemedy(t *testing.T) { counted: frostNativeSignerAnchorPoisonedRejections.Load(), expected: 0, }, + // Nothing here went through the per-input admission, so the counter + // that tells an operator "an already-relayed batch was abandoned" must + // stay clean. + { + name: "pre-sign input", + counted: frostNativeSignerAnchorPreSignInputRejections.Load(), + expected: 0, + }, } { if test.counted != test.expected { t.Fatalf( diff --git a/pkg/tbtc/frost_native_signer_anchor_observability_test.go b/pkg/tbtc/frost_native_signer_anchor_observability_test.go index a5b7871ab7..ee342db085 100644 --- a/pkg/tbtc/frost_native_signer_anchor_observability_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_observability_test.go @@ -10,82 +10,118 @@ import ( ) // The warning must fire on exactly the seat counts anchor admission would -// refuse for a maximum-size batch, and stay quiet on the rest. This is the -// coupling that matters: a warning that fires on an admissible seat count is -// noise an operator learns to ignore, and one that stays quiet on an -// inadmissible one leaves the wallet to discover the exclusion weeks later, -// when a full-size sweep first forms. +// refuse, and stay quiet on the rest. This is the coupling that matters: a +// warning that fires on an admissible seat count is noise an operator learns to +// ignore, and one that stays quiet on an inadmissible one leaves the wallet to +// discover the exclusion weeks later, when a sweep first forms. +// +// Both sides are checked at the shipped attempt limit, where nothing is +// refused, and at an attempt limit high enough to put the ceiling back in +// reach, where the two must agree on exactly where it falls. func TestFrostPreSignLocalSeatCeilingWarning_MatchesAdmission(t *testing.T) { const maximumInputs = uint64(frostPreSignAuthorizationMaximumInputs) - for seats := uint64(1); seats <= 8; seats++ { - _, admissionErr := frostPreSignMaximumAnchorCapacityCost( - maximumInputs, - seats, - signingAttemptsLimit, - ) - warning, warned := frostPreSignLocalSeatCeilingWarning( - seats, - maximumInputs, - signingAttemptsLimit, - ) - if warned != (admissionErr != nil) { - t.Errorf( - "[%d] local seats: warned=%v but admission refusal=%v", + for _, attempts := range []uint64{signingAttemptsLimit, 20} { + for seats := uint64(1); seats <= 30; seats++ { + _, admissionErr := frostPreSignMaximumAnchorCapacityCost( + maximumInputs, seats, - warned, - admissionErr != nil, + attempts, ) - } - if !warned && warning != "" { - t.Errorf( - "[%d] local seats: no warning expected but text was [%s]", + warning, warned := frostPreSignLocalSeatCeilingWarning( seats, - warning, + attempts, ) + if warned != (admissionErr != nil) { + t.Errorf( + "[%d] attempts, [%d] local seats: warned=%v but admission "+ + "refusal=%v", + attempts, + seats, + warned, + admissionErr != nil, + ) + } + if !warned && warning != "" { + t.Errorf( + "[%d] attempts, [%d] local seats: no warning expected but "+ + "text was [%s]", + attempts, + seats, + warning, + ) + } } } } -// The production ceiling is four local seats at the 21-input maximum batch and -// five signing attempts, and a five-seat node tops out at 19 inputs. Those are -// the numbers the admission accounting documents; pinning them here means a -// change to the certified windows or the attempt limit shows up as a failing -// test rather than as a silently different operator warning. +// Under the shipped constants there is no ceiling left to warn about: anchor +// admission reserves one transaction input at a time, one input costs +// 40*seats+15 generations, and a whole hundred-seat wallet held by a single +// node still fits the 4096-entry proof window. Pinning that here means a change +// to the certified windows or the attempt limit shows up as a failing test +// rather than as operators silently excluded from signing again. func TestFrostPreSignLocalSeatCeilingWarning_ProductionParameters(t *testing.T) { - const maximumInputs = uint64(frostPreSignAuthorizationMaximumInputs) - if ceiling := frostPreSignMaximumAdmissibleLocalSeatCount( - maximumInputs, signingAttemptsLimit, - ); ceiling != 4 { - t.Fatalf("production local seat ceiling: got %d want 4", ceiling) + ); ceiling != uint64(frostPreSignAuthorizationMaximumSeats) { + t.Fatalf( + "production local seat ceiling: got %d want %d", + ceiling, + frostPreSignAuthorizationMaximumSeats, + ) } - if _, warned := frostPreSignLocalSeatCeilingWarning( - 4, - maximumInputs, - signingAttemptsLimit, - ); warned { - t.Error("a four-seat node is admissible and must not be warned") + // Every seat count a wallet can award, including the five-seat average + // mainnet holder that the batch-wide reservation used to exclude. + for seats := uint64(1); seats <= + uint64(frostPreSignAuthorizationMaximumSeats); seats++ { + if warning, warned := frostPreSignLocalSeatCeilingWarning( + seats, + signingAttemptsLimit, + ); warned { + t.Fatalf( + "[%d] local seats are admissible but were warned: [%s]", + seats, + warning, + ) + } } +} - warning, warned := frostPreSignLocalSeatCeilingWarning( - 5, - maximumInputs, - signingAttemptsLimit, - ) +// A seat count above the ceiling still has to be reported, and the report has +// to be actionable. Only a raised attempt limit can produce one now, so that is +// what drives it. +func TestFrostPreSignLocalSeatCeilingWarning_AboveTheCeiling(t *testing.T) { + const attempts = uint64(20) + + if ceiling := frostPreSignMaximumAdmissibleLocalSeatCount( + attempts, + ); ceiling != 25 { + t.Fatalf("[%d]-attempt seat ceiling: got %d want 25", attempts, ceiling) + } + if _, warned := frostPreSignLocalSeatCeilingWarning(25, attempts); warned { + t.Error("a node at the ceiling is admissible and must not be warned") + } + + warning, warned := frostPreSignLocalSeatCeilingWarning(26, attempts) if !warned { - t.Fatal("a five-seat node is above the ceiling and must be warned") + t.Fatal("a node above the ceiling must be warned") } - // The operator needs all four numbers to act: how many seats it holds, the - // ceiling it is over, the batch size that produced the ceiling, and the - // largest batch it can actually sign. - for _, want := range []string{"[5]", "[4]", "[21]", "[19]"} { + // The operator needs the numbers it can act on: how many seats it holds and + // the ceiling it is over. The batch size it used to be told is deliberately + // absent - admission reserves per input now, so smaller sweeps do not help. + for _, want := range []string{"[26]", "[25]"} { if !strings.Contains(warning, want) { t.Errorf("warning is missing %s: [%s]", want, warning) } } + if !strings.Contains(warning, "Batch size is not a lever") { + t.Errorf( + "warning does not rule out the lever that no longer works: [%s]", + warning, + ) + } // The exclusion is whole-node, not just the surplus seat, because // reservePreSign is charged for the complete local seat set at once. if !strings.Contains(warning, "signing threshold") { @@ -94,37 +130,29 @@ func TestFrostPreSignLocalSeatCeilingWarning_ProductionParameters(t *testing.T) warning, ) } - - sixSeatWarning, warned := frostPreSignLocalSeatCeilingWarning( - 6, - maximumInputs, - signingAttemptsLimit, - ) - if !warned || !strings.Contains(sixSeatWarning, "[16]") { - t.Errorf( - "a six-seat node tops out at 16 inputs; warning was [%s]", - sixSeatWarning, - ) - } } -// An unset attempt limit must be read the way authorize reads it, otherwise -// the warning describes a ceiling admission never applies. +// An unset attempt limit must be read the way the gate reads it, otherwise the +// warning describes a ceiling admission never applies. func TestFrostPreSignLocalSeatCeilingWarning_ZeroAttemptsUsesTheDefault( t *testing.T, ) { - const maximumInputs = uint64(frostPreSignAuthorizationMaximumInputs) + // A seat count above what any wallet can award is the only input that + // warns under the shipped attempt limit, which makes it the only one that + // can compare two non-empty warnings. + const overCapSeats = uint64(frostPreSignAuthorizationMaximumSeats) + 1 zeroWarning, zeroWarned := frostPreSignLocalSeatCeilingWarning( - 5, - maximumInputs, + overCapSeats, 0, ) defaultWarning, defaultWarned := frostPreSignLocalSeatCeilingWarning( - 5, - maximumInputs, + overCapSeats, signingAttemptsLimit, ) + if !zeroWarned { + t.Fatal("a seat count no wallet can award must be warned") + } if zeroWarned != defaultWarned || zeroWarning != defaultWarning { t.Fatalf( "zero attempts did not fall back to the default limit: [%s] vs [%s]", @@ -134,41 +162,38 @@ func TestFrostPreSignLocalSeatCeilingWarning_ZeroAttemptsUsesTheDefault( } } -// A seat count that can sign no batch size at all must still be reported, and -// must not offer a largest admissible batch of zero inputs as if it were a -// usable setting. Under today's constants this needs a seat count admission -// rejects outright - at the 100-seat maximum a node can still sign a -// single-input batch - so the case is defensive, and it is covered because the -// arithmetic that makes it unreachable is exactly the arithmetic a constant -// change would move. -func TestFrostPreSignLocalSeatCeilingWarning_NoAdmissibleBatch(t *testing.T) { - const maximumInputs = uint64(frostPreSignAuthorizationMaximumInputs) - - if admissible := frostPreSignMaximumAdmissibleInputCount( - frostPreSignAuthorizationMaximumSeats, +// A configuration in which no seat count can be admitted at all must still be +// reported, and must not offer shedding seats as if it were a usable remedy. +// Under today's constants this needs an attempt limit no certified window could +// serve, because a single local seat signing a single input costs 55 of 4096 +// generations. +func TestFrostPreSignLocalSeatCeilingWarning_NoAdmissibleSeatCount(t *testing.T) { + if admissible := frostPreSignMaximumAdmissibleLocalSeatCount( signingAttemptsLimit, ); admissible == 0 { - t.Fatalf( - "the maximum seat count now signs nothing; this test needs a seat "+ - "count admission rejects outright, not [%d]", - frostPreSignAuthorizationMaximumSeats, + t.Fatal( + "the shipped attempt limit now admits no seat count at all; this " + + "test needs a configuration admission rejects outright", ) } - warning, warned := frostPreSignLocalSeatCeilingWarning( - frostPreSignAuthorizationMaximumSeats+1, - maximumInputs, - signingAttemptsLimit, - ) + warning, warned := frostPreSignLocalSeatCeilingWarning(1, 1000) if !warned { - t.Fatal("a seat count that can sign nothing must be warned") + t.Fatal("a configuration that can sign nothing must be warned") } - if !strings.Contains(warning, "no batch size at all") { + if !strings.Contains(warning, "no local seat count can sign") { t.Errorf( "warning does not say the node can sign nothing: [%s]", warning, ) } + if strings.Contains(warning, "shed seats") { + t.Errorf( + "warning offers shedding seats for a limit no seat count can "+ + "serve: [%s]", + warning, + ) + } } type stubFrostProductionSignerReadinessVerifier struct { diff --git a/pkg/tbtc/frost_native_signer_anchor_per_input_admission_test.go b/pkg/tbtc/frost_native_signer_anchor_per_input_admission_test.go new file mode 100644 index 0000000000..510cab441f --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_per_input_admission_test.go @@ -0,0 +1,689 @@ +package tbtc + +import ( + "context" + "fmt" + "math/big" + "strings" + "sync" + "testing" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// testFrostAnchorAdmissionHarness is a real admission controller with a +// settable headroom, plus the bookkeeping the tests below need: how many +// per-input admissions were asked for, and the high-water mark of what the +// controller had reserved at once. +// +// The controller itself is the production one. Only the headroom read is +// injected, exactly as the other admission tests inject it, so what is under +// test is the real reserve/release accounting rather than a model of it. +type testFrostAnchorAdmissionHarness struct { + controller *frostNativeSignerAnchorAdmissionController + + mutex sync.Mutex + headroom frostNativeSignerAnchorCapacity + admits int + failures int + peak frostNativeSignerAnchorCapacity + onAdmit func(admits int) + seatCount uint64 +} + +func newTestFrostAnchorAdmissionHarness( + headroom frostNativeSignerAnchorCapacity, + seatCount uint64, +) *testFrostAnchorAdmissionHarness { + harness := &testFrostAnchorAdmissionHarness{ + headroom: headroom, + seatCount: seatCount, + } + harness.controller = &frostNativeSignerAnchorAdmissionController{ + readHeadroom: func( + context.Context, + ) (frostNativeSignerAnchorCapacity, error) { + harness.mutex.Lock() + defer harness.mutex.Unlock() + return harness.headroom, nil + }, + } + return harness +} + +func (harness *testFrostAnchorAdmissionHarness) setHeadroom( + headroom frostNativeSignerAnchorCapacity, +) { + harness.mutex.Lock() + defer harness.mutex.Unlock() + harness.headroom = headroom +} + +// admitInput is shaped exactly like the closure walletTransactionExecutor hands +// the signing executor, and charges what the gate charges: one input, for this +// node's whole local seat set, for the full signing-attempt budget. +func (harness *testFrostAnchorAdmissionHarness) admitInput( + inputCount uint64, +) func(context.Context) (func(), error) { + return func(ctx context.Context) (func(), error) { + harness.mutex.Lock() + harness.admits++ + admits := harness.admits + onAdmit := harness.onAdmit + harness.mutex.Unlock() + if onAdmit != nil { + onAdmit(admits) + } + + reservation, err := harness.controller.reservePreSign( + ctx, + testFrostAnchorAdmissionReadinessSnapshot( + FrostNativeSignerAnchorMaximumHistoryEvents, + FrostNativeSignerAnchorMaximumHistoryProofEntries, + ), + inputCount, + harness.seatCount, + signingAttemptsLimit, + ) + if err != nil { + harness.mutex.Lock() + harness.failures++ + harness.mutex.Unlock() + return nil, err + } + harness.recordPeak() + return reservation.Release, nil + } +} + +func (harness *testFrostAnchorAdmissionHarness) recordPeak() { + reserved := harness.reserved() + harness.mutex.Lock() + defer harness.mutex.Unlock() + if reserved.Revisions > harness.peak.Revisions { + harness.peak.Revisions = reserved.Revisions + } + if reserved.Generations > harness.peak.Generations { + harness.peak.Generations = reserved.Generations + } +} + +func (harness *testFrostAnchorAdmissionHarness) reserved() frostNativeSignerAnchorCapacity { + harness.controller.mutex.Lock() + defer harness.controller.mutex.Unlock() + return harness.controller.reserved +} + +func (harness *testFrostAnchorAdmissionHarness) counts() (int, int) { + harness.mutex.Lock() + defer harness.mutex.Unlock() + return harness.admits, harness.failures +} + +// TestSigningExecutor_ReservesAnchorCapacityPerInputNotPerBatch is the +// behavioural half of the fix. The arithmetic tests prove one input's cost is +// what admission charges; this proves the batch loop actually charges it once +// per input and gives it back before the next one, so a node never holds more +// than one input's worth however large the sweep is. +// +// The executor is the ordinary in-process one the rest of signing_test.go uses. +// Its signing backend is irrelevant here - the admission discipline under test +// lives in signBatchWithTaprootPolicy's loop and is the same on every backend - +// and using it means the loop being exercised is the real one rather than a +// stand-in. +func TestSigningExecutor_ReservesAnchorCapacityPerInputNotPerBatch( + t *testing.T, +) { + executor := setupSigningExecutor(t) + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + messages := []*big.Int{ + big.NewInt(1000), + big.NewInt(2000), + big.NewInt(3000), + } + + // A twenty-seat operator - a large but entirely ordinary mainnet holder - + // signing a full-size sweep. One input costs 406 revisions and 815 + // generations; the whole 21-input batch would have cost 8526 and 17055, + // four times the certified windows. + const localSeats = uint64(20) + const sweepInputs = uint64(frostPreSignAuthorizationMaximumInputs) + + inputCost, err := frostPreSignAnchoredInputCost( + localSeats, + signingAttemptsLimit, + ) + if err != nil { + t.Fatal(err) + } + if inputCost.Revisions != 406 || inputCost.Generations != 815 { + t.Fatalf("unexpected twenty-seat per-input cost [%+v]", inputCost) + } + + harness := newTestFrostAnchorAdmissionHarness( + frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorMaximumHistoryEvents, + Generations: FrostNativeSignerAnchorMaximumHistoryProofEntries, + }, + localSeats, + ) + + signatures, err := executor.signBatchWithTaprootPolicy( + ctx, + messages, + nil, + 0, + nil, + nil, + nil, + harness.admitInput(sweepInputs), + ) + if err != nil { + t.Fatal(err) + } + if len(signatures) != len(messages) { + t.Fatalf( + "signed [%d] of [%d] messages", + len(signatures), + len(messages), + ) + } + + admits, failures := harness.counts() + if admits != len(messages) { + t.Fatalf( + "anchor admission was asked [%d] times for [%d] inputs; the "+ + "reservation is not per input", + admits, + len(messages), + ) + } + if failures != 0 { + t.Fatalf("[%d] admissions were refused on an unspent window", failures) + } + + // The high-water mark is the whole point: whatever the batch size, the + // node commits one input's worth at a time. + if harness.peak != inputCost { + t.Fatalf( + "peak reservation [%+v], expected exactly one input's [%+v]; a "+ + "batch that holds more than one input's capacity at once puts "+ + "the seat ceiling back", + harness.peak, + inputCost, + ) + } + + // Nothing may be left charged once the batch is done. A leaked reservation + // is capacity no other wallet on this node can ever use again, because the + // certified windows do not refill and the controller cannot notice an + // owner that walked away. + if reserved := harness.reserved(); reserved != + (frostNativeSignerAnchorCapacity{}) { + t.Fatalf("[%+v] stayed reserved after the batch finished", reserved) + } +} + +// TestSigningExecutor_MidBatchAnchorExhaustionRefusesAndReleases pins the +// residual the per-input reservation accepts in exchange for removing the seat +// ceiling: a batch can be admitted for its first inputs and refused part way +// through. That has to be a clean refusal - named, counted, and holding +// nothing - rather than a partially applied reservation or a silent continue. +func TestSigningExecutor_MidBatchAnchorExhaustionRefusesAndReleases( + t *testing.T, +) { + resetFrostNativeSignerAnchorAdmissionMetricsForTest() + t.Cleanup(resetFrostNativeSignerAnchorAdmissionMetricsForTest) + + executor := setupSigningExecutor(t) + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + messages := []*big.Int{ + big.NewInt(1000), + big.NewInt(2000), + big.NewInt(3000), + } + + harness := newTestFrostAnchorAdmissionHarness( + frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorMaximumHistoryEvents, + Generations: FrostNativeSignerAnchorMaximumHistoryProofEntries, + }, + 20, + ) + // The window runs out under the batch after its first input, exactly as it + // would on a node whose anchor is due a rotation. + harness.onAdmit = func(admits int) { + if admits == 2 { + harness.setHeadroom(frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorMaximumHistoryEvents, + Generations: 700, + }) + } + } + + signatures, err := executor.signBatchWithTaprootPolicy( + ctx, + messages, + nil, + 0, + nil, + nil, + nil, + harness.admitInput(uint64(frostPreSignAuthorizationMaximumInputs)), + ) + if err == nil { + t.Fatal("a batch was signed through an exhausted proof window") + } + if signatures != nil { + t.Fatalf( + "a refused batch returned [%d] signatures; a partial batch must "+ + "never reach the caller", + len(signatures), + ) + } + + // The refusal has to say which input it gave up on and why, or an operator + // sees a wallet action fail with no cause and no remedy. + for _, want := range []string{ + "input [1]", + "signer generations", + "offline anchor rotation is required", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("mid-batch refusal is missing %q: [%v]", want, err) + } + } + + // The first input's reservation was taken and released before the second + // was even attempted, and the failed one took nothing, so the controller + // must be back to zero. + if reserved := harness.reserved(); reserved != + (frostNativeSignerAnchorCapacity{}) { + t.Fatalf( + "[%+v] stayed reserved after a mid-batch refusal", + reserved, + ) + } + + admits, failures := harness.counts() + if admits != 2 || failures != 1 { + t.Fatalf( + "admission was asked [%d] times with [%d] refusals; the loop must "+ + "stop at the first refused input", + admits, + failures, + ) + } + + // The underlying cause is counted where it always was. The per-input + // counter is what tells an operator the difference between a batch refused + // before its authorization was relayed and one abandoned after. + if unreserved := + frostNativeSignerAnchorUnreservedHeadroomRejections.Load(); unreserved != 1 { + t.Fatalf( + "unreserved-headroom rejections counted [%d], expected [1]", + unreserved, + ) + } +} + +// TestSigningExecutor_ReleasesAnchorAdmissionOnEveryInputExitPath walks the +// ways one input can end other than by producing a signature. Each of them has +// to give the reservation back: the release is deferred precisely so that no +// future edit has to remember to add it to a new error return. +func TestSigningExecutor_ReleasesAnchorAdmissionOnEveryInputExitPath( + t *testing.T, +) { + executor := setupSigningExecutor(t) + + harness := newTestFrostAnchorAdmissionHarness( + frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorMaximumHistoryEvents, + Generations: FrostNativeSignerAnchorMaximumHistoryProofEntries, + }, + 20, + ) + admitInput := harness.admitInput( + uint64(frostPreSignAuthorizationMaximumInputs), + ) + + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + for _, test := range []struct { + name string + ctx context.Context + roastKeyGroupID string + unsignedTx *bitcoin.TransactionBuilder + wantMessage string + }{ + { + // The policy binding is the first thing the reservation pays for, + // and it can fail before any signer call happens. + name: "policy binding fails", + ctx: context.Background(), + roastKeyGroupID: "", + unsignedTx: bitcoin.NewTransactionBuilder(newLocalBitcoinChain()), + wantMessage: "policy artifact", + }, + { + // A cancelled context is the ordinary way a signing window ends, + // and it unwinds through the signing call rather than through a + // returned error of the loop's own. + name: "signing context cancelled", + ctx: cancelledCtx, + roastKeyGroupID: "", + unsignedTx: nil, + wantMessage: "", + }, + } { + t.Run(test.name, func(t *testing.T) { + before := harness.reserved() + _, _, err := executor.signBatchInputUnderAnchorAdmission( + test.ctx, + 0, + big.NewInt(1000), + nil, + 0, + test.roastKeyGroupID, + test.unsignedTx, + nil, + nil, + admitInput, + ) + if err == nil { + t.Fatal("the input did not fail") + } + if test.wantMessage != "" && + !strings.Contains(err.Error(), test.wantMessage) { + t.Fatalf("unexpected failure: [%v]", err) + } + if after := harness.reserved(); after != before { + t.Fatalf( + "the reservation was not released: [%+v] before, [%+v] "+ + "after", + before, + after, + ) + } + }) + } + + // A refused admission must take nothing at all, not take and then fail to + // give back. Twenty local seats need 815 generations for one input, and + // this leaves 500 - short of the cost but well clear of the rotation floor, + // so the refusal is the unreserved-headroom one rather than the blanket + // one. + harness.setHeadroom(frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorMaximumHistoryEvents, + Generations: 500, + }) + if _, _, err := executor.signBatchInputUnderAnchorAdmission( + context.Background(), + 3, + big.NewInt(1000), + nil, + 0, + "", + nil, + nil, + nil, + admitInput, + ); err == nil || !strings.Contains(err.Error(), "input [3]") { + t.Fatalf("a refused admission was not reported against its input: [%v]", err) + } + if reserved := harness.reserved(); reserved != + (frostNativeSignerAnchorCapacity{}) { + t.Fatalf("a refused admission left [%+v] reserved", reserved) + } +} + +// TestFrostNativeSignerAnchorAdmission_ConcurrentPerInputWorkflowsCannotOverCommit +// covers the property the whole controller exists for, under the interleaving +// that reserving per input makes possible: several wallets on one node, +// admitting and releasing inputs independently, must never between them promise +// more of a certified window than it has. +// +// Per-input reservations make the interleaving finer, not looser. Two wallets +// can now sit between each other's inputs where before one held the window for +// a whole batch, but each admission still takes the single controller mutex, +// still compares its cost against headroom minus everything currently reserved, +// and still either succeeds outright or fails outright - there is no waiting, so +// no deadlock and no livelock. What per-input reservations remove is the +// scenario where a large batch could never start at all. +func TestFrostNativeSignerAnchorAdmission_ConcurrentPerInputWorkflowsCannotOverCommit( + t *testing.T, +) { + // Sized so a twenty-seat and a five-seat wallet fit together (815+215) but + // a second twenty-seat input does not. + const headroomGenerations = uint64(1200) + + headroom := frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorMaximumHistoryEvents, + Generations: headroomGenerations, + } + + var overCommitted string + var overCommitMutex sync.Mutex + controller := &frostNativeSignerAnchorAdmissionController{} + controller.readHeadroom = func( + context.Context, + ) (frostNativeSignerAnchorCapacity, error) { + // readHeadroom runs while reserve() holds the admission mutex, so this + // observes the reserved total at the one instant it is authoritative. + if controller.reserved.Revisions > headroom.Revisions || + controller.reserved.Generations > headroom.Generations { + overCommitMutex.Lock() + overCommitted = fmt.Sprintf( + "reserved [%+v] exceeds headroom [%+v]", + controller.reserved, + headroom, + ) + overCommitMutex.Unlock() + } + return headroom, nil + } + + snapshot := testFrostAnchorAdmissionReadinessSnapshot( + FrostNativeSignerAnchorMaximumHistoryEvents, + headroomGenerations, + ) + + // Two wallets of different sizes, plus the DKG paths that share this + // controller, all cycling reservations against the same window. + var waitGroup sync.WaitGroup + var admitted, refused [3]int + var resultMutex sync.Mutex + for worker, seats := range []uint64{20, 5, 0} { + waitGroup.Add(1) + go func(worker int, seats uint64) { + defer waitGroup.Done() + for i := 0; i < 200; i++ { + var reservation *frostNativeSignerAnchorRevisionReservation + var err error + if seats == 0 { + // Native DKG shares the controller and must keep its own + // semantics: two capacity units per local seat, refused by + // the same accounting. + reservation, err = controller.reserveDKG( + context.Background(), + 4, + ) + } else { + reservation, err = controller.reservePreSign( + context.Background(), + snapshot, + uint64(frostPreSignAuthorizationMaximumInputs), + seats, + signingAttemptsLimit, + ) + } + resultMutex.Lock() + if err != nil { + refused[worker]++ + } else { + admitted[worker]++ + } + resultMutex.Unlock() + if err == nil { + reservation.Release() + } + } + }(worker, seats) + } + waitGroup.Wait() + + overCommitMutex.Lock() + defer overCommitMutex.Unlock() + if overCommitted != "" { + t.Fatalf("concurrent admissions over-committed the window: %s", overCommitted) + } + if reserved := controller.reserved; reserved != + (frostNativeSignerAnchorCapacity{}) { + t.Fatalf("[%+v] stayed reserved after every workflow finished", reserved) + } + // No worker may be shut out entirely. Per-input admissions all cost the + // same regardless of batch size, so the only asymmetry left is between + // wallets of different seat counts, and a window this size admits the + // larger one too. + for worker, count := range admitted { + if count == 0 { + t.Fatalf( + "worker [%d] was never admitted in [%d] attempts; a workflow "+ + "that can never make progress is a starvation path", + worker, + refused[worker], + ) + } + } +} + +// TestFrostPreSignAuthorizationGate_AdmitInputChargesOneInput covers the gate +// side of the split, including the thing that makes the hundred-seat case fit: +// the reservation authorize() takes to gate the on-chain relay must be released +// before the per-input admissions start, because the two are the same size and +// the window has room for one of them at that seat count. +func TestFrostPreSignAuthorizationGate_AdmitInputChargesOneInput(t *testing.T) { + resetFrostNativeSignerAnchorAdmissionMetricsForTest() + t.Cleanup(resetFrostNativeSignerAnchorAdmissionMetricsForTest) + + const seats = uint64(frostPreSignAuthorizationMaximumSeats) + + controller := &frostNativeSignerAnchorAdmissionController{ + readHeadroom: func( + context.Context, + ) (frostNativeSignerAnchorCapacity, error) { + return frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorMaximumHistoryEvents, + Generations: FrostNativeSignerAnchorMaximumHistoryProofEntries, + }, nil + }, + } + localMemberIndexes := make([]group.MemberIndex, 0, seats) + for i := uint64(1); i <= seats; i++ { + localMemberIndexes = append(localMemberIndexes, group.MemberIndex(i)) + } + gate := &thresholdFrostPreSignAuthorizationGate{ + anchorAdmission: controller, + localMemberIndexes: localMemberIndexes, + } + // An unset attempt limit must be read as the package default here exactly + // as authorize reads it, or the two would charge different amounts. + if gate.effectiveMaximumAttempts() != signingAttemptsLimit { + t.Fatalf( + "unset attempt limit read as [%d]", + gate.effectiveMaximumAttempts(), + ) + } + + signatureHashes := make([][32]byte, frostPreSignAuthorizationMaximumInputs) + authorization := &frostPreSignAuthorization{ + proposal: &FrostPreSignAuthorizationProposal{ + Transaction: &FrostPreSignTransaction{ + SignatureHashes: signatureHashes, + }, + }, + } + + // Admission before any readiness reconciliation must fail closed rather + // than reserve against an unauthenticated view of the windows. + if _, err := gate.admitInput( + context.Background(), + authorization, + ); err == nil || !strings.Contains(err.Error(), "reconciled signer readiness") { + t.Fatalf("admission without reconciled readiness was allowed: [%v]", err) + } + + authorization.cacheReadiness( + FrostPreSignFinality{BlockNumber: 1}, + testFrostAnchorAdmissionReadinessSnapshot( + FrostNativeSignerAnchorMaximumHistoryEvents, + FrostNativeSignerAnchorMaximumHistoryProofEntries, + ), + ) + + // The relay gate: what authorize() holds while it relays and finalizes. + relayReservation, err := controller.reservePreSign( + context.Background(), + testFrostAnchorAdmissionReadinessSnapshot( + FrostNativeSignerAnchorMaximumHistoryEvents, + FrostNativeSignerAnchorMaximumHistoryProofEntries, + ), + uint64(frostPreSignAuthorizationMaximumInputs), + seats, + signingAttemptsLimit, + ) + if err != nil { + t.Fatalf("the relay gate itself was refused: [%v]", err) + } + + // Held alongside a per-input admission, two inputs' worth is 8030 of a + // 4096-entry window. This is exactly why walletTransactionExecutor hands + // the reservation over instead of keeping it for the batch. + if _, err := gate.admitInput( + context.Background(), + authorization, + ); err == nil || !strings.Contains(err.Error(), "unreserved") { + t.Fatalf( + "the relay reservation and a per-input admission were charged "+ + "together: [%v]", + err, + ) + } + if rejections := + frostNativeSignerAnchorPreSignInputRejections.Load(); rejections != 1 { + t.Fatalf( + "per-input rejections counted [%d], expected [1]", + rejections, + ) + } + + // Handed over, the same node signs a full sweep at the wallet's entire seat + // count - the case the batch-wide reservation could not admit at four + // seats, let alone a hundred. + relayReservation.Release() + for input := 0; input < frostPreSignAuthorizationMaximumInputs; input++ { + release, err := gate.admitInput(context.Background(), authorization) + if err != nil { + t.Fatalf("input [%d] of a full sweep was refused: [%v]", input, err) + } + if release == nil { + t.Fatalf("input [%d] was admitted with no release", input) + } + release() + } + if reserved := func() frostNativeSignerAnchorCapacity { + controller.mutex.Lock() + defer controller.mutex.Unlock() + return controller.reserved + }(); reserved != (frostNativeSignerAnchorCapacity{}) { + t.Fatalf("[%+v] stayed reserved after the sweep", reserved) + } +} diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index 7986b1bb03..5c9fa7ae09 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1549,6 +1549,32 @@ func (authorization *frostPreSignAuthorization) cacheReadiness( cloneFrostProductionSignerReadinessSnapshot(snapshot) } +// currentReadinessSnapshot returns the most recently reconciled readiness +// snapshot regardless of which finalized point produced it, or nil when none +// has been cached yet. +// +// It differs from cachedReadiness deliberately. cachedReadiness answers "may I +// skip a reconciliation at THIS point", so it must refuse a snapshot taken at +// any other point. Per-input anchor admission asks a different question: it +// needs the inventory headroom the last authenticated reconciliation reported, +// as one of the two bounds reservePreSign takes the minimum of. The other bound +// is the current tip read under the admission lock, which is authoritative, so +// a snapshot that lags can only make the admission more conservative, never +// less. +func (authorization *frostPreSignAuthorization) currentReadinessSnapshot() *frostProductionSignerReadinessSnapshot { + if authorization == nil { + return nil + } + authorization.readinessMutex.Lock() + defer authorization.readinessMutex.Unlock() + if authorization.readinessSnapshot == nil { + return nil + } + return cloneFrostProductionSignerReadinessSnapshot( + authorization.readinessSnapshot, + ) +} + func (authorization *frostPreSignAuthorization) cachedReadiness( point FrostPreSignFinality, ) *frostProductionSignerReadinessSnapshot { @@ -1575,6 +1601,22 @@ type frostPreSignAuthorizationGate interface { context.Context, *frostPreSignAuthorization, ) error + // admitInput reserves the native signer anchor capacity for exactly one + // transaction input of an already finalized authorization, and returns the + // function that gives it back. The returned release is non-nil whenever the + // error is nil, and the caller must run it on every exit path from that + // input - a reservation held past its input is capacity no other wallet on + // this node can use, and the certified windows never refill. + // + // It lives on the gate rather than on the authorization because the gate is + // what holds the node-wide admission controller, this wallet's validated + // local seat set and this wallet's signing-attempt limit. Putting it in the + // interface makes it impossible to add a signing path, or a test double, + // that reaches native signing with no per-input admission at all. + admitInput( + context.Context, + *frostPreSignAuthorization, + ) (func(), error) } type thresholdFrostPreSignAuthorizationGate struct { @@ -1691,16 +1733,20 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) authorize( "FROST native signer anchor admission controller is nil", ) } - maximumAttempts := tfpsag.maximumAttempts - if maximumAttempts == 0 { - maximumAttempts = signingAttemptsLimit - } + // One input's worth, not the batch's. Native signing reserves per input in + // its own sequential loop, so this reservation is not the signing budget: + // it is what keeps the node from spending gas relaying an authorization it + // has no capacity to act on, and from letting several wallets relay against + // capacity only one of them can use. walletTransactionExecutor releases it + // at the moment the per-input reservations take over, so the two are never + // charged at once - holding both would put the ceiling back at fifty local + // seats instead of removing it. anchorReservation, err := tfpsag.anchorAdmission.reservePreSign( ctx, readinessSnapshot, uint64(len(transaction.SignatureHashes)), uint64(len(tfpsag.localMemberIndexes)), - maximumAttempts, + tfpsag.effectiveMaximumAttempts(), ) if err != nil { return nil, fmt.Errorf( @@ -1799,6 +1845,82 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) authorize( return authorization, nil } +// effectiveMaximumAttempts is the signing-attempt limit anchor admission has to +// charge for. It is read in one place so the reservation, the per-input +// admission and the startup seat-ceiling warning can never disagree about what +// an unset limit means. +func (tfpsag *thresholdFrostPreSignAuthorizationGate) effectiveMaximumAttempts() uint64 { + if tfpsag == nil || tfpsag.maximumAttempts == 0 { + return signingAttemptsLimit + } + return tfpsag.maximumAttempts +} + +// admitInput reserves anchor capacity for exactly one input of an authorized +// batch and returns the release for it. +// +// The reservation covers that input's whole attempt budget - its BuildTaprootTx +// call and, for every one of the signing-attempt limit's attempts, an Open, +// Round1, Round2 and Abort per local seat plus the memoized Aggregate - so an +// admitted input keeps every retry the ROAST loop can give it. That is why the +// unit is an input and not an attempt: reserving per attempt would be cheaper +// still, but it would admit an input that then cannot pay for its own second +// attempt, and re-admitting mid-input has nothing sensible to do when it is +// refused half way through a signing round. +// +// Nothing fallible runs after reservePreSign returns a reservation - it is +// wrapped and handed back on the next line - so a refusal from this function +// can never be a refusal that has already taken capacity. Every earlier check +// returns before a reservation exists at all. +// +// The counter records capacity refusals only. The nil-argument refusals above +// are programming errors that cannot arise from a gate this package builds, and +// counting them would put wiring mistakes on the same operator dashboard line +// as an anchor window that needs rotating. +func (tfpsag *thresholdFrostPreSignAuthorizationGate) admitInput( + ctx context.Context, + authorization *frostPreSignAuthorization, +) (func(), error) { + if tfpsag == nil || tfpsag.anchorAdmission == nil { + return nil, fmt.Errorf( + "FROST native signer anchor admission controller is nil", + ) + } + if ctx == nil { + return nil, fmt.Errorf("FROST pre-sign input admission context is nil") + } + if authorization == nil || authorization.proposal == nil || + authorization.proposal.Transaction == nil { + return nil, fmt.Errorf( + "FROST pre-sign input admission has no finalized authorization", + ) + } + // The snapshot is one of the two bounds reservePreSign takes the minimum + // of; the other is the tip it authenticates under the admission lock. + // Refusing without one is deliberate: an authorization that never + // reconciled readiness has no business reaching the native signer, and the + // monitor refreshes this on every pass for the whole signing window. + readinessSnapshot := authorization.currentReadinessSnapshot() + if readinessSnapshot == nil { + return nil, fmt.Errorf( + "FROST pre-sign input admission has no reconciled signer readiness", + ) + } + + reservation, err := tfpsag.anchorAdmission.reservePreSign( + ctx, + readinessSnapshot, + uint64(len(authorization.proposal.Transaction.SignatureHashes)), + uint64(len(tfpsag.localMemberIndexes)), + tfpsag.effectiveMaximumAttempts(), + ) + if err != nil { + recordFrostNativeSignerAnchorPreSignInputRejection() + return nil, err + } + return reservation.Release, nil +} + // isFrostPreSignTransientAuthorizationFailure reports whether err is a failure // to reach an authorization dependency rather than an authorization fact // observed at one. diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go index 556c7c78dc..75953c9892 100644 --- a/pkg/tbtc/frost_pre_sign_authorization_test.go +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -988,8 +988,12 @@ type testFrostPreSignAuthorizationGate struct { mutex sync.Mutex authorizeErr error revalidateErr error + admitInputErr error authorizeCalls int revalidateCalls int + admitInputCalls int + admitInputHeld int + admitInputPeak int finalizedBlock uint64 proposal *FrostPreSignAuthorizationProposal } @@ -1041,6 +1045,38 @@ func (tfpsag *testFrostPreSignAuthorizationGate) revalidate( return tfpsag.revalidateErr } +// admitInput stands in for the node-wide anchor admission controller. It takes +// nothing real, but it counts: the batch loop must call it once per input and +// run the release it returns before the next one, so admitInputHeld is back to +// zero between inputs and admitInputPeak never exceeds one. A stub that simply +// returned a no-op would let a regression to a single batch-wide reservation +// pass unnoticed here. +func (tfpsag *testFrostPreSignAuthorizationGate) admitInput( + ctx context.Context, + authorization *frostPreSignAuthorization, +) (func(), error) { + tfpsag.mutex.Lock() + defer tfpsag.mutex.Unlock() + tfpsag.admitInputCalls++ + if tfpsag.admitInputErr != nil { + return nil, tfpsag.admitInputErr + } + tfpsag.admitInputHeld++ + if tfpsag.admitInputHeld > tfpsag.admitInputPeak { + tfpsag.admitInputPeak = tfpsag.admitInputHeld + } + released := false + return func() { + tfpsag.mutex.Lock() + defer tfpsag.mutex.Unlock() + if released { + return + } + released = true + tfpsag.admitInputHeld-- + }, nil +} + func (tfpsag *testFrostPreSignAuthorizationGate) setRevalidateError(err error) { tfpsag.mutex.Lock() defer tfpsag.mutex.Unlock() diff --git a/pkg/tbtc/node.go b/pkg/tbtc/node.go index 601a8410c3..017d6c9335 100644 --- a/pkg/tbtc/node.go +++ b/pkg/tbtc/node.go @@ -1269,12 +1269,18 @@ func (n *node) getSigningExecutor( // The seat ceiling is a property of this node's seat count in this // wallet and of protocol constants, so it is decided the moment the // wallet is formed and never changes for the wallet's whole life. The - // gate is the only thing that reports it today, and only inside the - // error of an authorization it has already refused, so an operator - // over the ceiling learns about it the first time a full-size deposit - // sweep is proposed - which may be weeks after the seats were awarded, - // and is the worst possible moment. Say it once, here, at the same - // place the executor announces how many signers it controls. + // gate is the only thing that reports it otherwise, and only inside the + // error of an authorization it has already refused, so an operator over + // the ceiling would learn about it the first time a deposit sweep is + // proposed - which may be weeks after the seats were awarded, and is + // the worst possible moment. Say it once, here, at the same place the + // executor announces how many signers it controls. + // + // No production node is expected to trip this now that admission + // reserves one input at a time: a wallet's whole hundred-seat set fits + // the certified windows for one input. It stays because the numbers it + // reads are protocol constants, and a change to any of them must + // announce itself rather than quietly excluding an operator again. // // The gate's own deduplicated, validated seat set is used rather than // localMemberIndexes because that is exactly what reservePreSign @@ -1289,7 +1295,6 @@ func (n *node) getSigningExecutor( if executor.usesSchnorrSignatures() { if warning, exceeded := frostPreSignLocalSeatCeilingWarning( uint64(len(gate.localMemberIndexes)), - uint64(frostPreSignAuthorizationMaximumInputs), gate.maximumAttempts, ); exceeded { executorLogger.Warnf("%s", warning) @@ -1308,102 +1313,74 @@ func (n *node) getSigningExecutor( } // frostPreSignLocalSeatCeilingWarning states, at wallet-executor construction -// time, that this node's seat count in a FROST wallet is above the count -// anchor admission can serve for a maximum-size pre-sign batch. It returns the +// time, that this node's seat count in a FROST wallet is above the count anchor +// admission can serve for a single pre-sign transaction input. It returns the // warning text and whether the ceiling is exceeded at all. // // The exclusion this reports is whole-node, not per-seat. -// thresholdFrostPreSignAuthorizationGate.authorize charges reservePreSign for -// its complete local seat set in one reservation, so a set one seat over the -// ceiling fails the reservation and the node contributes nothing at all to -// that batch - every one of its seats is lost to the wallet's signing -// threshold, not just the surplus one. That is worth stating explicitly -// because the natural reading of "a ceiling of four" is that four seats still -// sign, and they do not. +// thresholdFrostPreSignAuthorizationGate charges reservePreSign for its +// complete local seat set in one reservation, so a set one seat over the +// ceiling fails the reservation and the node contributes nothing at all - every +// one of its seats is lost to the wallet's signing threshold, not just the +// surplus one. That is worth stating explicitly because the natural reading of +// "a ceiling of N" is that N seats still sign, and they do not. // -// Both levers are named because neither is inferable from the other and both -// belong to the operator: shed seats down to the ceiling, or keep batches at -// or below the largest this seat count can serve. Neither the certified -// restart windows nor the signing-attempt limit are configurable here, so -// there is no third option to offer. +// It no longer takes a batch size, and there is no longer a second lever to +// offer. Anchor admission reserves one input at a time and a batch signs its +// inputs sequentially, so one input costs the same in a one-input batch as in a +// full twenty-one input sweep: an operator over this ceiling cannot get under +// it by proposing smaller sweeps, only by shedding seats. Under the current +// constants nothing is over it - a wallet's entire hundred-seat set fits inside +// the certified windows for one input - so this is a guard against a constant +// change rather than a warning any production node is expected to see. // -// Kept as a pure function of the three numbers so it can be exercised without +// Kept as a pure function of the two numbers so it can be exercised without // standing up a wallet, a gate, or a native build. func frostPreSignLocalSeatCeilingWarning( localSeatCount uint64, - maximumInputCount uint64, maximumSigningAttempts uint64, ) (string, bool) { - // Mirrors authorize: an unset limit means the package default, and - // charging the ceiling scan a different attempt count than admission uses - // would make this warn about a ceiling that does not exist. + // Mirrors the gate: an unset limit means the package default, and charging + // the ceiling scan a different attempt count than admission uses would make + // this warn about a ceiling that does not exist. if maximumSigningAttempts == 0 { maximumSigningAttempts = signingAttemptsLimit } admissibleSeats := frostPreSignMaximumAdmissibleLocalSeatCount( - maximumInputCount, maximumSigningAttempts, ) if admissibleSeats > 0 && localSeatCount <= admissibleSeats { return "", false } if admissibleSeats == 0 { - // Not this node's problem to fix: no seat count at all can serve a - // maximum-size batch under the current windows, so shedding seats - // would not help and only a protocol-level change would. + // Not this node's problem to fix: no seat count at all can serve even a + // single input under the current windows, so shedding seats would not + // help and only a protocol-level change would. return fmt.Sprintf( "this node holds [%d] of this FROST wallet's seats, and no local "+ - "seat count can sign a maximum-size [%d]-input pre-sign batch "+ - "within the certified anchor restart windows; every full-size "+ - "deposit sweep on this wallet will be refused for every member, "+ - "and only enlarging those windows or lowering the "+ - "signing-attempt limit [%d] changes that", + "seat count can sign a single pre-sign transaction input within "+ + "the certified anchor restart windows; every deposit sweep on this "+ + "wallet will be refused for every member, and only enlarging those "+ + "windows or lowering the signing-attempt limit [%d] changes that", localSeatCount, - maximumInputCount, maximumSigningAttempts, ), true } - admissibleInputs := frostPreSignMaximumAdmissibleInputCount( - localSeatCount, - maximumSigningAttempts, - ) - if admissibleInputs == 0 { - return fmt.Sprintf( - "this node holds [%d] of this FROST wallet's seats, above the "+ - "[%d]-seat ceiling for a maximum-size [%d]-input pre-sign "+ - "batch, and [%d] seats can sign no batch size at all; anchor "+ - "admission will refuse this node every signing request for "+ - "this wallet for the wallet's whole life, so all [%d] of its "+ - "seats are lost to the wallet's signing threshold - shed seats "+ - "down to [%d]", - localSeatCount, - admissibleSeats, - maximumInputCount, - localSeatCount, - localSeatCount, - admissibleSeats, - ), true - } - return fmt.Sprintf( - "this node holds [%d] of this FROST wallet's seats, above the "+ - "[%d]-seat ceiling for a maximum-size [%d]-input pre-sign batch; "+ - "the largest batch [%d] seats can sign is [%d] inputs, and anchor "+ - "admission refuses the node as a whole rather than the surplus "+ - "seats, so all [%d] of its seats are excluded from every full-size "+ - "deposit sweep for this wallet's whole life and are lost to the "+ - "wallet's signing threshold on those sweeps - shed seats down to "+ - "[%d], or keep sweeps at or below [%d] inputs", + "this node holds [%d] of this FROST wallet's seats, above the [%d]-seat "+ + "ceiling for a single pre-sign transaction input; anchor admission "+ + "refuses the node as a whole rather than the surplus seats, so all "+ + "[%d] of its seats are excluded from every deposit sweep for this "+ + "wallet's whole life and are lost to the wallet's signing threshold. "+ + "Batch size is not a lever - admission reserves one input at a time "+ + "and a sweep signs its inputs sequentially - so shed seats down to "+ + "[%d]", localSeatCount, admissibleSeats, - maximumInputCount, - localSeatCount, - admissibleInputs, localSeatCount, admissibleSeats, - admissibleInputs, ), true } diff --git a/pkg/tbtc/signing.go b/pkg/tbtc/signing.go index 7d4d859ecf..aa8bd33b5d 100644 --- a/pkg/tbtc/signing.go +++ b/pkg/tbtc/signing.go @@ -368,6 +368,7 @@ func (se *signingExecutor) signBatchWithTaprootMerkleRoots( nil, nil, nil, + nil, ) } @@ -395,12 +396,19 @@ func (se *signingExecutor) signBatchWithTaprootTransaction( unsignedTx, nil, nil, + nil, ) } // signBatchWithAuthorizedTaprootTransaction is the only wallet-transaction // entry point that may reach native FROST signing. authorizationID is folded // into both the stable ROAST namespace and every attempt session namespace. +// +// admitInput reserves this node's native signer anchor capacity for one input +// and returns the release for it. It is required rather than optional on this +// path: the signer's certified restart windows are finite and do not refill, so +// a native signing run that never asked admission for capacity is a run that +// can exhaust the window it needs to prove its own history after a crash. func (se *signingExecutor) signBatchWithAuthorizedTaprootTransaction( ctx context.Context, messages []*big.Int, @@ -409,6 +417,7 @@ func (se *signingExecutor) signBatchWithAuthorizedTaprootTransaction( unsignedTx *bitcoin.TransactionBuilder, authorizationID [32]byte, authorizationGuard func(context.Context) error, + admitInput func(context.Context) (func(), error), ) ([]*frost.Signature, error) { if authorizationID == [32]byte{} { return nil, fmt.Errorf("pre-sign authorization ID is zero") @@ -419,6 +428,9 @@ func (se *signingExecutor) signBatchWithAuthorizedTaprootTransaction( if authorizationGuard == nil { return nil, fmt.Errorf("pre-sign authorization guard is nil") } + if admitInput == nil { + return nil, fmt.Errorf("native signer anchor input admission is nil") + } return se.signBatchWithTaprootPolicy( ctx, @@ -428,6 +440,7 @@ func (se *signingExecutor) signBatchWithAuthorizedTaprootTransaction( unsignedTx, &authorizationID, authorizationGuard, + admitInput, ) } @@ -439,11 +452,18 @@ func (se *signingExecutor) signBatchWithTaprootPolicy( unsignedTx *bitcoin.TransactionBuilder, authorizationID *[32]byte, authorizationGuard func(context.Context) error, + admitInput func(context.Context) (func(), error), ) ([]*frost.Signature, error) { // This check must precede policy-artifact binding: binding enters the native // signer and may allocate session state even before nonce generation. + // + // A missing per-input anchor admission fails here with the rest of the + // authorization preconditions, and for the same reason: both are things the + // native signer path must never run without, and neither can be recovered + // from once binding has entered the signer. if se.usesSchnorrSignatures() && - (authorizationID == nil || *authorizationID == [32]byte{}) { + (authorizationID == nil || *authorizationID == [32]byte{} || + admitInput == nil) { return nil, fmt.Errorf( "FROST signing requires a finalized transaction authorization", ) @@ -532,44 +552,18 @@ func (se *signingExecutor) signBatchWithTaprootPolicy( ) } } - policyBoundRoastSID, err := bindTaprootPolicyArtifactForAuthorizedSigning( + signature, endBlock, err := se.signBatchInputUnderAnchorAdmission( + ctx, + i, message, taprootMerkleRoot, signingStartBlock, roastKeyGroupID, unsignedTx, - i, authorizationID, + authorizationGuard, + admitInput, ) - if err != nil { - return nil, fmt.Errorf( - "cannot bind input [%d] to the native signer policy artifact: [%w]", - i, - err, - ) - } - - var signature *frost.Signature - var endBlock uint64 - if authorizationID != nil { - signature, _, endBlock, err = se.signWithTaprootMerkleRootForAuthorizedSession( - ctx, - message, - taprootMerkleRoot, - signingStartBlock, - policyBoundRoastSID, - authorizationID, - authorizationGuard, - ) - } else { - signature, _, endBlock, err = se.signWithTaprootMerkleRootForSession( - ctx, - message, - taprootMerkleRoot, - signingStartBlock, - policyBoundRoastSID, - ) - } if err != nil { // Error metrics are recorded in the sign() method for all error paths. return nil, err @@ -588,6 +582,102 @@ func (se *signingExecutor) signBatchWithTaprootPolicy( return signatures, nil } +// signBatchInputUnderAnchorAdmission binds and signs exactly one input of a +// batch while holding exactly one input's native signer anchor reservation. +// +// It is a separate function purely so the release can be deferred. Go defers +// run at function exit, not at the end of a loop iteration, and this input has +// three ways out - the admission itself failing, the policy binding failing, +// and the signing failing or its context being cancelled. Releasing by hand on +// each of them is how a reservation leaks: a leaked one is capacity no other +// wallet on this node can ever use again, because the certified windows do not +// refill and the controller has no way to notice an owner that walked away. +// The defer also covers the unwind if anything below panics. +// +// The reservation is taken AFTER the caller's authorization guard and BEFORE +// the policy binding, because BuildTaprootTx inside the binding is the input's +// first request-taking signer call and is already part of what the reservation +// pays for. Nothing between the guard and here consumes anchor capacity, so +// ordering them the other way would only hold capacity while revalidating an +// authorization that may be about to be refused anyway. +// +// admitInput is nil on the non-authorized paths, which cannot reach the native +// signer at all; signBatchWithTaprootPolicy has already refused a Schnorr batch +// that arrives without one. +func (se *signingExecutor) signBatchInputUnderAnchorAdmission( + ctx context.Context, + inputIndex int, + message *big.Int, + taprootMerkleRoot *[32]byte, + signingStartBlock uint64, + roastKeyGroupID string, + unsignedTx *bitcoin.TransactionBuilder, + authorizationID *[32]byte, + authorizationGuard func(context.Context) error, + admitInput func(context.Context) (func(), error), +) (*frost.Signature, uint64, error) { + if admitInput != nil { + releaseInputAdmission, err := admitInput(ctx) + if err != nil { + return nil, 0, fmt.Errorf( + "cannot reserve native signer anchor capacity for input [%d]: [%w]", + inputIndex, + err, + ) + } + if releaseInputAdmission == nil { + return nil, 0, fmt.Errorf( + "native signer anchor admission for input [%d] returned no release", + inputIndex, + ) + } + defer releaseInputAdmission() + } + + policyBoundRoastSID, err := bindTaprootPolicyArtifactForAuthorizedSigning( + message, + taprootMerkleRoot, + signingStartBlock, + roastKeyGroupID, + unsignedTx, + inputIndex, + authorizationID, + ) + if err != nil { + return nil, 0, fmt.Errorf( + "cannot bind input [%d] to the native signer policy artifact: [%w]", + inputIndex, + err, + ) + } + + var signature *frost.Signature + var endBlock uint64 + if authorizationID != nil { + signature, _, endBlock, err = se.signWithTaprootMerkleRootForAuthorizedSession( + ctx, + message, + taprootMerkleRoot, + signingStartBlock, + policyBoundRoastSID, + authorizationID, + authorizationGuard, + ) + } else { + signature, _, endBlock, err = se.signWithTaprootMerkleRootForSession( + ctx, + message, + taprootMerkleRoot, + signingStartBlock, + policyBoundRoastSID, + ) + } + if err != nil { + return nil, 0, err + } + return signature, endBlock, nil +} + // sign performs the signing process for the given message. The process is // triggered according to the given start block. If the message cannot be signed // within a limited time window, an error is returned. If the message was diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index 334a11fd87..ee81b10d9f 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -315,6 +315,16 @@ type taprootPolicyBoundWalletSigningExecutor interface { ) ([]*frost.Signature, error) } +// authorizedTaprootPolicyBoundWalletSigningExecutor is the only signing entry +// point that may reach native FROST signing. +// +// admitInput is a sibling of authorizationGuard rather than something the +// executor derives, and both are per input for the same reason: the batch is +// signed one input at a time, and each input has to re-establish that it is +// still permitted (the guard) and that the node still has the anchor capacity +// to run it (the admission). The executor calls admitInput once per input and +// runs the release it returns before moving to the next one, so the node holds +// exactly one input's reservation at a time no matter how large the batch is. type authorizedTaprootPolicyBoundWalletSigningExecutor interface { signBatchWithAuthorizedTaprootTransaction( ctx context.Context, @@ -324,6 +334,7 @@ type authorizedTaprootPolicyBoundWalletSigningExecutor interface { unsignedTx *bitcoin.TransactionBuilder, authorizationID [32]byte, authorizationGuard func(context.Context) error, + admitInput func(context.Context) (func(), error), ) ([]*frost.Signature, error) } @@ -646,6 +657,28 @@ func (wte *walletTransactionExecutor) signTransaction( defer authorizationMonitor.stop() signTxLogger.Infof("signing transaction's sig hashes") + + // Hand the anchor reservation over to the per-input admissions before + // the first input runs. + // + // The reservation taken inside authorize() is one input's worth and its + // job is done: it gated the on-chain relay on a node that had the + // capacity to act on it. From here the sequential loop reserves an + // input's worth for each input it signs, and holding both at once would + // charge two inputs for one input's work - at the hundred-seat maximum + // that is 8030 of a 4096-entry proof window, which would reinstate a + // seat ceiling at fifty rather than remove it. + // + // Releasing here rather than leaving it to the deferred release is safe + // and is not a hole: the release is idempotent, the deferred call still + // covers every path that leaves before this point, and the window this + // opens - another wallet on this node taking the capacity between here + // and the first admitInput - is the same interleaving that already + // exists between any two inputs of the loop. Its worst outcome is a + // clean refusal of an already-relayed batch, which the pre-sign input + // rejection counter reports. + authorization.releaseAnchorReservation() + signatures, err = authorizedSigningExecutor.signBatchWithAuthorizedTaprootTransaction( authorizationMonitor.ctx, sigHashes, @@ -654,6 +687,12 @@ func (wte *walletTransactionExecutor) signTransaction( unsignedTx, authorization.AuthorizationID, authorizationMonitor.guard, + func(ctx context.Context) (func(), error) { + return wte.preSignAuthorizationGate.admitInput( + ctx, + authorization, + ) + }, ) if authorizationErr := authorizationMonitor.err(); authorizationErr != nil { err = authorizationErr diff --git a/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go b/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go index 23d9b5b82a..6fd07032de 100644 --- a/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go +++ b/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go @@ -1858,6 +1858,36 @@ func (dsseft *deterministicSchnorrSigningExecutorForTaproot) signBatchWithTaproo return dsseft.signBatch(ctx, messages, startBlock) } +// exerciseTestPerInputAnchorAdmission mirrors what the real batch loop does +// with the admission it is handed: one reservation per input, released before +// the next one is taken. The stubs below sign nothing real, but they must still +// exercise the admission the same way, or a regression that takes a single +// reservation for the whole batch would pass every test that uses them. +func exerciseTestPerInputAnchorAdmission( + ctx context.Context, + messageCount int, + admitInput func(context.Context) (func(), error), +) error { + if admitInput == nil { + return errors.New("nil native signer anchor input admission") + } + for i := 0; i < messageCount; i++ { + release, err := admitInput(ctx) + if err != nil { + return fmt.Errorf( + "cannot reserve native signer anchor capacity for input [%d]: [%w]", + i, + err, + ) + } + if release == nil { + return errors.New("anchor input admission returned no release") + } + release() + } + return nil +} + func (dsseft *deterministicSchnorrSigningExecutorForTaproot) signBatchWithAuthorizedTaprootTransaction( ctx context.Context, messages []*big.Int, @@ -1866,6 +1896,7 @@ func (dsseft *deterministicSchnorrSigningExecutorForTaproot) signBatchWithAuthor unsignedTx *bitcoin.TransactionBuilder, authorizationID [32]byte, authorizationGuard func(context.Context) error, + admitInput func(context.Context) (func(), error), ) ([]*frost.Signature, error) { if authorizationID == [32]byte{} { return nil, errors.New("zero authorization ID") @@ -1876,6 +1907,13 @@ func (dsseft *deterministicSchnorrSigningExecutorForTaproot) signBatchWithAuthor if err := authorizationGuard(ctx); err != nil { return nil, err } + if err := exerciseTestPerInputAnchorAdmission( + ctx, + len(messages), + admitInput, + ); err != nil { + return nil, err + } dsseft.authorizedCalls++ dsseft.authorizedStarts = append(dsseft.authorizedStarts, startBlock) dsseft.authorizationIDs = append(dsseft.authorizationIDs, authorizationID) @@ -1991,6 +2029,7 @@ func (tmrrsse *taprootMerkleRootRecordingSchnorrSigningExecutor) signBatchWithAu unsignedTx *bitcoin.TransactionBuilder, authorizationID [32]byte, authorizationGuard func(context.Context) error, + admitInput func(context.Context) (func(), error), ) ([]*frost.Signature, error) { if authorizationID == [32]byte{} { return nil, errors.New("zero authorization ID") @@ -2001,6 +2040,13 @@ func (tmrrsse *taprootMerkleRootRecordingSchnorrSigningExecutor) signBatchWithAu if err := authorizationGuard(ctx); err != nil { return nil, err } + if err := exerciseTestPerInputAnchorAdmission( + ctx, + len(messages), + admitInput, + ); err != nil { + return nil, err + } tmrrsse.authorizedCalls++ tmrrsse.authorizedStarts = append(tmrrsse.authorizedStarts, startBlock) signatures, err := tmrrsse.signBatchWithTaprootTransaction( From 9da4cce7cea731234dfd4f0be91b18d2fc7a4e5f Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 14:18:37 -0400 Subject: [PATCH 87/93] docs(tbtc): correct the revision cost in the terms table The per-input arithmetic section already says Round1 spends nothing; the terms table still claimed every request-taking call advances a revision. Make them agree. Co-Authored-By: Claude Fable 5 --- docs/development/frost-anchor-rotation.adoc | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/development/frost-anchor-rotation.adoc b/docs/development/frost-anchor-rotation.adoc index 008f86f596..1d3c06641b 100644 --- a/docs/development/frost-anchor-rotation.adoc +++ b/docs/development/frost-anchor-rotation.adoc @@ -67,9 +67,11 @@ derivation is in <>. rotation, increments it. Within one epoch, revisions count from 1. | Revision -| One event in the stream. In practice one request-taking signer call - advances one revision, because the process output barrier advances - the remote anchor revision once for the call's final checkpoint. +| One event in the stream. A request-taking signer call advances at most + one revision, and only if it actually changed durable state: the + output barrier skips the remote compare-and-swap entirely when the + Rust tip is unchanged, so a call such as `Round1` - which mints its + nonce in memory and persists nothing - costs none. | Generation | One committed state witness inside the Rust signer - one durable From 5247561b86e72f802181b0ab9f7a48c3c04c81b6 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 15:39:08 -0400 Subject: [PATCH 88/93] fix(tbtc): align signer health with workload capacity --- pkg/tbtc/frost_activation_handshake.go | 9 +- pkg/tbtc/frost_activation_handshake_test.go | 80 +++++++++++++++ pkg/tbtc/frost_native_signer_readiness.go | 28 +++--- .../frost_native_signer_readiness_test.go | 97 ++++++++++++++++++- pkg/tbtc/frost_pre_sign_authorization.go | 8 ++ pkg/tbtc/frost_pre_sign_authorization_test.go | 47 +++++++++ 6 files changed, 249 insertions(+), 20 deletions(-) diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 72b97f52da..7b2f401028 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -1062,11 +1062,10 @@ func (fahe *frostActivationHandshakeExporter) attest( nativeSignerSnapshot.RestartableGenerationHeadroom != FrostNativeSignerAnchorMaximumHistoryProofEntries || nativeSignerSnapshot.AnchorRotationWarning != - frostNativeSignerAnchorRotationWarning( - minFrostNativeSignerAnchorHeadroom( - nativeSignerSnapshot.RestartableRevisionHeadroom, - nativeSignerSnapshot.RestartableGenerationHeadroom, - ), + frostNativeSignerAnchorWorkloadRotationWarning( + nativeSignerSnapshot.RestartableRevisionHeadroom, + nativeSignerSnapshot.RestartableGenerationHeadroom, + nativeSignerSnapshot.LargestLocalSeatCount, ) { return nil, fmt.Errorf( "native signer state lacks an authenticated external rollback anchor trust certificate", diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index 3710fa6990..3e6baeb660 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -292,6 +292,7 @@ func testFrostProductionSignerInventorySnapshot() frostNativeSignerInventorySnap PreviousStateCommitment: [32]byte{0x30}, StateImageDigest: [32]byte{0x33}, InventoryCommitment: [32]byte{0x32}, + LargestLocalSeatCount: 20, ExternalRollbackAnchorBound: true, TrustCertificateSequence: 3, TrustCertificateDigest: [32]byte{0x34}, @@ -1065,6 +1066,85 @@ func TestFrostActivationHandshakeExporter_SignsLiveNativeSignerState( } } +func TestFrostActivationHandshakeExporter_RejectsFlatFloorOnlyAnchorWarning( + t *testing.T, +) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + exporter, _, _, _, endpoint, request := + startTestFrostActivationHandshakeExporter(t, point) + readiness, ok := exporter.readiness.(*testFrostProductionSignerReadiness) + if !ok { + t.Fatal("unexpected production signer readiness verifier") + } + + response := postTestFrostActivationHandshake(t, endpoint, request) + response.Body.Close() + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + response.Body.Close() + + cost, err := frostPreSignAnchoredInputCost(20, signingAttemptsLimit) + if err != nil { + t.Fatal(err) + } + inventory := testFrostProductionSignerInventorySnapshot() + inventory.StateGeneration = inventory.CertifiedFloorGeneration + + FrostNativeSignerAnchorMaximumHistoryProofEntries - + (cost.Generations - 1) + inventory.PreviousStateCommitment = inventory.StateCommitment + inventory.StateCommitment = [32]byte{0x51} + inventory.StateImageDigest = [32]byte{0x52} + inventory.RestartableGenerationHeadroom = cost.Generations - 1 + inventory.CurrentAnchorRevision = inventory.CertifiedFloorRevision + + FrostNativeSignerAnchorMaximumHistoryEvents - + (cost.Revisions - 1) + inventory.RestartableRevisionHeadroom = cost.Revisions - 1 + // This is the production bug under test: both windows remain above the flat + // floor, but neither can reserve this node's next twenty-seat input. + inventory.AnchorRotationWarning = false + readiness.setInventory(inventory) + + response = postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable { + body, _ := io.ReadAll(response.Body) + response.Body.Close() + t.Fatalf( + "flat-floor-only warning produced status [%d]: %s", + response.StatusCode, + body, + ) + } + response.Body.Close() + + inventory.AnchorRotationWarning = true + readiness.setInventory(inventory) + response = awaitTestFrostActivationHandshake( + t, + endpoint, + request, + http.StatusOK, + ) + defer response.Body.Close() + handshake := &frostActivationSignedHandshake{} + if err := json.NewDecoder(response.Body).Decode(handshake); err != nil { + t.Fatal(err) + } + if handshake.Payload.State.Healthy || + !handshake.Payload.State.NativeSignerState.AnchorRotationWarning { + t.Fatalf( + "workload-exhausted signer attested healthy: %+v", + handshake.Payload.State.NativeSignerState, + ) + } +} + // TestFrostActivationHandshakeHealthy_RequiresAnUnpoisonedStateAnchor pins the // health term itself, independently of how the payload is assembled: an // otherwise perfect state is unhealthy once the native signer state anchor is diff --git a/pkg/tbtc/frost_native_signer_readiness.go b/pkg/tbtc/frost_native_signer_readiness.go index 42b1adff1c..d9d7ef9cbc 100644 --- a/pkg/tbtc/frost_native_signer_readiness.go +++ b/pkg/tbtc/frost_native_signer_readiness.go @@ -57,6 +57,7 @@ type frostNativeSignerInventorySnapshot struct { InventoryCommitment [32]byte WalletCount uint64 KeyPackageCount uint64 + LargestLocalSeatCount uint64 ExternalRollbackAnchorBound bool TrustCertificateSequence uint64 TrustCertificateDigest [32]byte @@ -198,8 +199,13 @@ func (binding *frostNativeSignerInventoryBinding) verify( } keyPackageCount := uint64(0) + largestLocalSeatCount := uint64(0) for _, entry := range inventory.Entries { - keyPackageCount += uint64(len(entry.KeyPackages)) + localSeatCount := uint64(len(entry.KeyPackages)) + keyPackageCount += localSeatCount + if localSeatCount > largestLocalSeatCount { + largestLocalSeatCount = localSeatCount + } } return &frostNativeSignerInventorySnapshot{ Schema: inventory.Schema, @@ -211,6 +217,7 @@ func (binding *frostNativeSignerInventoryBinding) verify( InventoryCommitment: inventory.InventoryCommitment, WalletCount: uint64(len(inventory.Entries)), KeyPackageCount: keyPackageCount, + LargestLocalSeatCount: largestLocalSeatCount, ExternalRollbackAnchorBound: true, TrustCertificateSequence: trustHead.CertificateSequence, TrustCertificateDigest: trustHead.CertificateDigest, @@ -220,11 +227,10 @@ func (binding *frostNativeSignerInventoryBinding) verify( CurrentAnchorRevision: localTip.AnchorRevision, RestartableRevisionHeadroom: revisionHeadroom, RestartableGenerationHeadroom: generationHeadroom, - AnchorRotationWarning: frostNativeSignerAnchorRotationWarning( - minFrostNativeSignerAnchorHeadroom( - revisionHeadroom, - generationHeadroom, - ), + AnchorRotationWarning: frostNativeSignerAnchorWorkloadRotationWarning( + revisionHeadroom, + generationHeadroom, + largestLocalSeatCount, ), }, nil } @@ -545,6 +551,7 @@ func verifyFrostNativeSignerInventoryUnchanged( actual.InventoryCommitment != expected.InventoryCommitment || actual.WalletCount != expected.WalletCount || actual.KeyPackageCount != expected.KeyPackageCount || + actual.LargestLocalSeatCount != expected.LargestLocalSeatCount || actual.ExternalRollbackAnchorBound != expected.ExternalRollbackAnchorBound || actual.TrustCertificateSequence != expected.TrustCertificateSequence || @@ -758,11 +765,10 @@ func validateFrostNativeSignerAnchorReadinessHeadroom( inventory.RestartableGenerationHeadroom != FrostNativeSignerAnchorMaximumHistoryProofEntries || inventory.AnchorRotationWarning != - frostNativeSignerAnchorRotationWarning( - minFrostNativeSignerAnchorHeadroom( - inventory.RestartableRevisionHeadroom, - inventory.RestartableGenerationHeadroom, - ), + frostNativeSignerAnchorWorkloadRotationWarning( + inventory.RestartableRevisionHeadroom, + inventory.RestartableGenerationHeadroom, + inventory.LargestLocalSeatCount, ) { return fmt.Errorf( "native signer certified anchor revision or generation headroom is inconsistent", diff --git a/pkg/tbtc/frost_native_signer_readiness_test.go b/pkg/tbtc/frost_native_signer_readiness_test.go index bb3bcc4ed6..3af07f04cc 100644 --- a/pkg/tbtc/frost_native_signer_readiness_test.go +++ b/pkg/tbtc/frost_native_signer_readiness_test.go @@ -264,6 +264,41 @@ func TestValidateFrostNativeSignerAnchorReadinessHeadroomWarningUsesMinimum( } } +func TestValidateFrostNativeSignerAnchorReadinessHeadroomUsesLargestLocalSeatCount( + t *testing.T, +) { + cost, err := frostPreSignAnchoredInputCost(20, signingAttemptsLimit) + if err != nil { + t.Fatal(err) + } + if cost.Revisions != 406 || cost.Generations != 815 { + t.Fatalf("unexpected twenty-seat per-input cost: %+v", cost) + } + + inventory := &frostNativeSignerInventorySnapshot{ + LargestLocalSeatCount: 20, + CertifiedFloorRevision: 1, + CurrentAnchorRevision: 1 + FrostNativeSignerAnchorMaximumHistoryEvents - (cost.Revisions - 1), + RestartableRevisionHeadroom: cost.Revisions - 1, + CertifiedFloorGeneration: 1, + StateGeneration: 1 + FrostNativeSignerAnchorMaximumHistoryProofEntries - (cost.Generations - 1), + RestartableGenerationHeadroom: cost.Generations - 1, + AnchorRotationWarning: true, + } + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + inventory, + ); err != nil { + t.Fatalf("workload-aware warning snapshot was rejected: %v", err) + } + + inventory.AnchorRotationWarning = false + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + inventory, + ); err == nil || !strings.Contains(err.Error(), "inconsistent") { + t.Fatalf("flat-floor-only warning remained readiness-valid: %v", err) + } +} + func TestVerifyFrostNativeSignerInventoryEntriesRejectsMismatch(t *testing.T) { walletID := [32]byte{0x21} valid := []frostsigning.NativeTBTCSignerRetainedKeyGroup{ @@ -343,6 +378,19 @@ func TestFrostNativeSignerInventoryBindingRequiresExactAuthenticatedAnchor(t *te stateImage, ) walletID := [32]byte{0x43} + keyPackages := make( + []frostsigning.NativeTBTCSignerRetainedKeyPackage, + 20, + ) + participantSeats := make([]uint16, len(keyPackages)) + for index := range keyPackages { + seat := uint16(index + 1) + keyPackages[index] = frostsigning.NativeTBTCSignerRetainedKeyPackage{ + ParticipantSeat: seat, + KeyPackageCommitment: [32]byte{byte(index + 1)}, + } + participantSeats[index] = seat + } entries := []frostsigning.NativeTBTCSignerRetainedKeyGroup{ { WalletID: walletID, @@ -350,9 +398,7 @@ func TestFrostNativeSignerInventoryBindingRequiresExactAuthenticatedAnchor(t *te Threshold: 51, ParticipantCount: 100, PublicKeyPackageCommitment: [32]byte{0x44}, - KeyPackages: []frostsigning.NativeTBTCSignerRetainedKeyPackage{ - {ParticipantSeat: 2, KeyPackageCommitment: [32]byte{0x45}}, - }, + KeyPackages: keyPackages, }, } inventory := &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ @@ -398,7 +444,7 @@ func TestFrostNativeSignerInventoryBindingRequiresExactAuthenticatedAnchor(t *te KeyGroup: hex.EncodeToString(walletID[:]), Threshold: 51, ParticipantCount: 100, - ParticipantSeats: []uint16{2}, + ParticipantSeats: participantSeats, }, } snapshot, err := binding.verify(context.Background(), expected) @@ -413,6 +459,7 @@ func TestFrostNativeSignerInventoryBindingRequiresExactAuthenticatedAnchor(t *te FrostNativeSignerAnchorMaximumHistoryEvents || snapshot.RestartableGenerationHeadroom != FrostNativeSignerAnchorMaximumHistoryProofEntries || + snapshot.LargestLocalSeatCount != 20 || snapshot.AnchorRotationWarning { t.Fatalf("unexpected native anchor readiness headroom: %+v", snapshot) } @@ -423,6 +470,48 @@ func TestFrostNativeSignerInventoryBindingRequiresExactAuthenticatedAnchor(t *te } baselineTip := *warningTip baselineRecord := *anchorStore.record + workloadCost, err := frostPreSignAnchoredInputCost(20, signingAttemptsLimit) + if err != nil { + t.Fatal(err) + } + warningTip.AnchorRevision = + anchorBinding.floor.Revision + + FrostNativeSignerAnchorMaximumHistoryEvents - + (workloadCost.Revisions - 1) + warningTip.AnchorEventRoot = [32]byte{0xc1} + warningTip.AnchorAcknowledgementDigest = [32]byte{0xc2} + anchorBinding.readTip = func() ( + *frostsigning.NativeTBTCSignerStateWitnessTip, + error, + ) { + copy := *warningTip + return ©, nil + } + anchorStore.record.Revision = warningTip.AnchorRevision + anchorStore.record.PreviousEventRoot = [32]byte{0xc0} + anchorStore.record.EventRoot = warningTip.AnchorEventRoot + anchorStore.record.AcknowledgementDigest = + warningTip.AnchorAcknowledgementDigest + workloadWarningSnapshot, err := binding.verify( + context.Background(), + expected, + ) + if err != nil { + t.Fatalf("workload-warning inventory was rejected: %v", err) + } + if workloadWarningSnapshot.RestartableRevisionHeadroom != + workloadCost.Revisions-1 || + workloadWarningSnapshot.RestartableRevisionHeadroom <= + FrostNativeSignerAnchorRotationWarningHeadroom || + !workloadWarningSnapshot.AnchorRotationWarning { + t.Fatalf( + "workload-relative anchor exhaustion warning was not surfaced: %+v", + workloadWarningSnapshot, + ) + } + + *warningTip = baselineTip + *anchorStore.record = baselineRecord warningTip.AnchorRevision = anchorBinding.floor.Revision + FrostNativeSignerAnchorMaximumHistoryEvents - 1 diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index 5c9fa7ae09..b25eecf38d 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -11,6 +11,7 @@ import ( "fmt" "math/big" stdnet "net" + "net/http" "os" "reflect" "sort" @@ -21,6 +22,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" ethereumCrypto "github.com/ethereum/go-ethereum/crypto" + ethereumRPC "github.com/ethereum/go-ethereum/rpc" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/net" @@ -1965,6 +1967,12 @@ func isFrostPreSignTransientAuthorizationFailure(err error) bool { if errors.As(err, &networkError) && networkError.Timeout() { return true } + var httpError ethereumRPC.HTTPError + if errors.As(err, &httpError) { + return httpError.StatusCode == http.StatusTooManyRequests || + httpError.StatusCode >= http.StatusInternalServerError && + httpError.StatusCode <= 599 + } return false } diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go index 75953c9892..5f3cbcc3be 100644 --- a/pkg/tbtc/frost_pre_sign_authorization_test.go +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -8,6 +8,7 @@ import ( "fmt" "math/big" stdnet "net" + "net/http" "os" "strings" "sync" @@ -15,6 +16,7 @@ import ( "testing" "time" + ethereumRPC "github.com/ethereum/go-ethereum/rpc" "github.com/keep-network/keep-core/internal/testutils" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/chain" @@ -1411,6 +1413,26 @@ func TestIsFrostPreSignTransientAuthorizationFailure(t *testing.T) { err: fmt.Errorf("anchor read: [%w]", os.ErrDeadlineExceeded), transient: true, }, + "Ethereum RPC rate limited": { + err: fmt.Errorf("Ethereum RPC: [%w]", ethereumRPC.HTTPError{ + StatusCode: http.StatusTooManyRequests, + Status: "429 Too Many Requests", + }), + transient: true, + }, + "Ethereum RPC service unavailable": { + err: fmt.Errorf("Ethereum RPC: [%w]", ethereumRPC.HTTPError{ + StatusCode: http.StatusServiceUnavailable, + Status: "503 Service Unavailable", + }), + transient: true, + }, + "Ethereum RPC authentication rejected": { + err: fmt.Errorf("Ethereum RPC: [%w]", ethereumRPC.HTTPError{ + StatusCode: http.StatusUnauthorized, + Status: "401 Unauthorized", + }), + }, } for name, test := range tests { t.Run(name, func(t *testing.T) { @@ -1518,6 +1540,31 @@ func TestThresholdFrostPreSignAuthorizationGate_SurvivesTransientReadinessFailur } } +func TestThresholdFrostPreSignAuthorizationGate_SurvivesTemporaryEthereumHTTPFailure( + t *testing.T, +) { + readiness := &testFrostProductionAuthorizationReadiness{ + err: fmt.Errorf("Ethereum RPC: [%w]", ethereumRPC.HTTPError{ + StatusCode: http.StatusServiceUnavailable, + Status: "503 Service Unavailable", + }), + failingCalls: 2, + } + gate, authorization := testFrostPreSignRevalidationFixture(t, readiness) + if err := gate.revalidate( + context.Background(), + authorization, + ); err != nil { + t.Fatalf("a temporary Ethereum HTTP failure killed the session: [%v]", err) + } + if readiness.calls != 3 { + t.Fatalf( + "expected the Ethereum HTTP failure to be retried, saw [%d] attempts", + readiness.calls, + ) + } +} + // TestThresholdFrostPreSignAuthorizationGate_LatchesObservedReadinessChange // keeps the gate fail-closed. An authorization fact that was actually read and // disagrees is deterministic, so it must be reported on the first attempt From 276f01b52cfd977e5ceeaf8ce4046aa020c5e93d Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 16:22:23 -0400 Subject: [PATCH 89/93] fix(tbtc): preserve admitted signing capacity --- pkg/tbtc/frost_activation_handshake.go | 11 +-- pkg/tbtc/frost_activation_handshake_test.go | 10 +-- pkg/tbtc/frost_native_signer_readiness.go | 25 ++++--- .../frost_native_signer_readiness_test.go | 75 ++++++++++++++++++- pkg/tbtc/frost_pre_sign_authorization.go | 3 +- pkg/tbtc/frost_pre_sign_authorization_test.go | 53 ++++++++----- 6 files changed, 137 insertions(+), 40 deletions(-) diff --git a/pkg/tbtc/frost_activation_handshake.go b/pkg/tbtc/frost_activation_handshake.go index 7b2f401028..407f6a24ea 100644 --- a/pkg/tbtc/frost_activation_handshake.go +++ b/pkg/tbtc/frost_activation_handshake.go @@ -1008,11 +1008,12 @@ func (fahe *frostActivationHandshakeExporter) attest( // each other only in part: // // - the strict facts - store identity and fingerprint, retained key - // material, trust head, anchor service epoch, certified floor and the - // AnchorRotationWarning flag - must still equal the reconciled values - // or signing fails closed here. An attestation can therefore never - // claim a healthier anchor, or different key material, than the signer - // actually has; + // material, trust head, anchor service epoch and certified floor - must + // still equal the reconciled values or signing fails closed here. The + // AnchorRotationWarning flag may turn on, but not off, as an admitted + // input consumes its reserved capacity. An attestation can therefore + // never claim a healthier anchor, or different key material, than the + // signer actually has; // - the volatile facts that an authorized signing window advances by // design - state generation, the state commitment chain, the state // image digest, the anchor revision and both restartable headrooms - diff --git a/pkg/tbtc/frost_activation_handshake_test.go b/pkg/tbtc/frost_activation_handshake_test.go index 3e6baeb660..eab98d2aa8 100644 --- a/pkg/tbtc/frost_activation_handshake_test.go +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -383,11 +383,11 @@ func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSigner // revalidateFrostProductionSignerReadinessInventory mirrors production: it // compares the live inventory against the reconciled one with exactly the -// comparison the real verifier uses - strict on identity, key material, trust -// head and the rotation warning, monotone on the state checkpoint, the anchor -// revision and both headrooms - and returns the live snapshot. Comparing the -// whole struct by value here would model a production behaviour that no longer -// exists and would assert a 503 the exporter no longer returns. +// comparison the real verifier uses - strict on identity, key material and the +// trust head, monotone on the state checkpoint, the anchor revision, both +// headrooms and the rotation warning - and returns the live snapshot. Comparing +// the whole struct by value here would model a production behaviour that no +// longer exists and would assert a 503 the exporter no longer returns. func (readiness *testFrostProductionSignerReadiness) revalidateFrostProductionSignerReadinessInventory( ctx context.Context, expected *frostProductionSignerReadinessSnapshot, diff --git a/pkg/tbtc/frost_native_signer_readiness.go b/pkg/tbtc/frost_native_signer_readiness.go index d9d7ef9cbc..c1cf3c2421 100644 --- a/pkg/tbtc/frost_native_signer_readiness.go +++ b/pkg/tbtc/frost_native_signer_readiness.go @@ -451,8 +451,10 @@ func (readiness *frostProductionSignerReadiness) verifyFrostProductionSignerRead // // The returned snapshot is not the cached one. Everything // verifyFrostNativeSignerInventoryUnchanged pins strictly - store identity, -// retained key material, trust head, certified floor and the anchor rotation -// warning - is guaranteed equal to the cached value or this returns an error. +// retained key material, trust head and certified floor - is guaranteed equal +// to the cached value or this returns an error. The anchor rotation warning may +// turn on as the authorized window consumes its reserved capacity, but it may +// not turn off. // The state checkpoint, the anchor revision and both restartable headrooms are // only held to a monotone advance, so on those the returned live values and // the cached ones can legitimately differ. @@ -534,11 +536,12 @@ func (readiness *frostProductionSignerReadiness) revalidateFrostProductionSigner // // Identity, trust and key material stay strictly pinned. The state checkpoint, // the anchor revision and the derived restartable headrooms may only advance -// monotonically, and only while the anchor stays out of its rotation-warning -// band - the same threshold below which the admission controller refuses new -// work. A rollback, an equal-generation fork, a different store or trust head, -// a changed key-package commitment, or an advance that consumes the warning -// headroom all still fail closed. +// monotonically. The anchor rotation warning may move from false to true as an +// admitted input spends the capacity reserved for it; that transition reports +// the live node unhealthy to new activation consumers without revoking work +// already admitted. It may not clear within the same certified anchor context. +// A rollback, an equal-generation fork, a different store or trust head, or a +// changed key-package commitment still fails closed. func verifyFrostNativeSignerInventoryUnchanged( expected *frostNativeSignerInventorySnapshot, actual *frostNativeSignerInventorySnapshot, @@ -558,8 +561,7 @@ func verifyFrostNativeSignerInventoryUnchanged( actual.TrustCertificateDigest != expected.TrustCertificateDigest || actual.AnchorServiceEpoch != expected.AnchorServiceEpoch || actual.CertifiedFloorRevision != expected.CertifiedFloorRevision || - actual.CertifiedFloorGeneration != expected.CertifiedFloorGeneration || - actual.AnchorRotationWarning != expected.AnchorRotationWarning { + actual.CertifiedFloorGeneration != expected.CertifiedFloorGeneration { return fmt.Errorf( "native signer identity, trust head, or retained key material changed since readiness reconciliation", ) @@ -574,6 +576,11 @@ func verifyFrostNativeSignerInventoryUnchanged( "native signer state or anchor revision rolled back since readiness reconciliation", ) } + if expected.AnchorRotationWarning && !actual.AnchorRotationWarning { + return fmt.Errorf( + "native signer anchor rotation warning cleared within the certified anchor context", + ) + } // A durable advance always moves the state commitment chain forward, so an // unchanged generation carrying a different commitment is a fork, not the // session's own progress. diff --git a/pkg/tbtc/frost_native_signer_readiness_test.go b/pkg/tbtc/frost_native_signer_readiness_test.go index 3af07f04cc..8e6bd7f264 100644 --- a/pkg/tbtc/frost_native_signer_readiness_test.go +++ b/pkg/tbtc/frost_native_signer_readiness_test.go @@ -1298,7 +1298,7 @@ func TestVerifyFrostNativeSignerInventoryUnchangedSeparatesAdvanceFromChange( actual.StateCommitment = [32]byte{0x42} actual.AnchorRotationWarning = true }, - message: "identity, trust head, or retained key material changed", + accepted: true, }, } for name, test := range tests { @@ -1319,3 +1319,76 @@ func TestVerifyFrostNativeSignerInventoryUnchangedSeparatesAdvanceFromChange( }) } } + +func TestVerifyFrostNativeSignerInventoryUnchangedPreservesAdmittedInputAcrossWorkloadWarning( + t *testing.T, +) { + cost, err := frostPreSignAnchoredInputCost(20, signingAttemptsLimit) + if err != nil { + t.Fatal(err) + } + if cost.Revisions != 406 || cost.Generations != 815 { + t.Fatalf("unexpected twenty-seat per-input cost: %+v", cost) + } + expected := &frostNativeSignerInventorySnapshot{ + Schema: "inventory/v1", + StoreFingerprint: [32]byte{0x01}, + StateGeneration: 10, + StateCommitment: [32]byte{0x02}, + PreviousStateCommitment: [32]byte{0x03}, + StateImageDigest: [32]byte{0x04}, + InventoryCommitment: [32]byte{0x05}, + WalletCount: 1, + KeyPackageCount: 20, + LargestLocalSeatCount: 20, + ExternalRollbackAnchorBound: true, + TrustCertificateSequence: 1, + TrustCertificateDigest: [32]byte{0x06}, + AnchorServiceEpoch: 1, + CertifiedFloorRevision: 1, + CertifiedFloorGeneration: 1, + CurrentAnchorRevision: 5, + RestartableRevisionHeadroom: cost.Revisions + 1, + RestartableGenerationHeadroom: cost.Generations + 1, + AnchorRotationWarning: false, + } + if frostNativeSignerAnchorWorkloadRotationWarning( + expected.RestartableRevisionHeadroom, + expected.RestartableGenerationHeadroom, + expected.LargestLocalSeatCount, + ) { + t.Fatal("admissible pre-input snapshot unexpectedly warned") + } + + actual := *expected + actual.StateGeneration++ + actual.PreviousStateCommitment = expected.StateCommitment + actual.StateCommitment = [32]byte{0x12} + actual.StateImageDigest = [32]byte{0x14} + actual.CurrentAnchorRevision++ + actual.RestartableRevisionHeadroom-- + actual.RestartableGenerationHeadroom-- + actual.AnchorRotationWarning = frostNativeSignerAnchorWorkloadRotationWarning( + actual.RestartableRevisionHeadroom, + actual.RestartableGenerationHeadroom, + actual.LargestLocalSeatCount, + ) + if !actual.AnchorRotationWarning { + t.Fatal("the admitted input did not cross the workload warning threshold") + } + if err := verifyFrostNativeSignerInventoryUnchanged( + expected, + &actual, + ); err != nil { + t.Fatalf("the admitted input was revoked after consuming reserved capacity: %v", err) + } + + cleared := actual + cleared.AnchorRotationWarning = false + if err := verifyFrostNativeSignerInventoryUnchanged( + &actual, + &cleared, + ); err == nil || !strings.Contains(err.Error(), "warning cleared") { + t.Fatalf("rotation warning cleared within one certified context: %v", err) + } +} diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index b25eecf38d..9b3232d64c 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1969,7 +1969,8 @@ func isFrostPreSignTransientAuthorizationFailure(err error) bool { } var httpError ethereumRPC.HTTPError if errors.As(err, &httpError) { - return httpError.StatusCode == http.StatusTooManyRequests || + return httpError.StatusCode == http.StatusRequestTimeout || + httpError.StatusCode == http.StatusTooManyRequests || httpError.StatusCode >= http.StatusInternalServerError && httpError.StatusCode <= 599 } diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go index 5f3cbcc3be..7f03078863 100644 --- a/pkg/tbtc/frost_pre_sign_authorization_test.go +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -1413,6 +1413,13 @@ func TestIsFrostPreSignTransientAuthorizationFailure(t *testing.T) { err: fmt.Errorf("anchor read: [%w]", os.ErrDeadlineExceeded), transient: true, }, + "Ethereum RPC request timed out": { + err: fmt.Errorf("Ethereum RPC: [%w]", ethereumRPC.HTTPError{ + StatusCode: http.StatusRequestTimeout, + Status: "408 Request Timeout", + }), + transient: true, + }, "Ethereum RPC rate limited": { err: fmt.Errorf("Ethereum RPC: [%w]", ethereumRPC.HTTPError{ StatusCode: http.StatusTooManyRequests, @@ -1543,25 +1550,33 @@ func TestThresholdFrostPreSignAuthorizationGate_SurvivesTransientReadinessFailur func TestThresholdFrostPreSignAuthorizationGate_SurvivesTemporaryEthereumHTTPFailure( t *testing.T, ) { - readiness := &testFrostProductionAuthorizationReadiness{ - err: fmt.Errorf("Ethereum RPC: [%w]", ethereumRPC.HTTPError{ - StatusCode: http.StatusServiceUnavailable, - Status: "503 Service Unavailable", - }), - failingCalls: 2, - } - gate, authorization := testFrostPreSignRevalidationFixture(t, readiness) - if err := gate.revalidate( - context.Background(), - authorization, - ); err != nil { - t.Fatalf("a temporary Ethereum HTTP failure killed the session: [%v]", err) - } - if readiness.calls != 3 { - t.Fatalf( - "expected the Ethereum HTTP failure to be retried, saw [%d] attempts", - readiness.calls, - ) + for name, statusCode := range map[string]int{ + "request timeout": http.StatusRequestTimeout, + "rate limited": http.StatusTooManyRequests, + "service unavailable": http.StatusServiceUnavailable, + } { + t.Run(name, func(t *testing.T) { + readiness := &testFrostProductionAuthorizationReadiness{ + err: fmt.Errorf("Ethereum RPC: [%w]", ethereumRPC.HTTPError{ + StatusCode: statusCode, + Status: http.StatusText(statusCode), + }), + failingCalls: 2, + } + gate, authorization := testFrostPreSignRevalidationFixture(t, readiness) + if err := gate.revalidate( + context.Background(), + authorization, + ); err != nil { + t.Fatalf("a temporary Ethereum HTTP failure killed the session: [%v]", err) + } + if readiness.calls != 3 { + t.Fatalf( + "expected the Ethereum HTTP failure to be retried, saw [%d] attempts", + readiness.calls, + ) + } + }) } } From 0ae0cf042112d877490c7412332d491af0f0469a Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 16:44:32 -0400 Subject: [PATCH 90/93] fix(tbtc): classify transient signer dependencies --- .../frost_native_signer_anchor_admission.go | 124 ++++++++++++------ ...ost_native_signer_anchor_admission_test.go | 113 +++++++++++++++- pkg/tbtc/frost_native_signer_anchor_client.go | 24 ++-- .../frost_native_signer_anchor_client_test.go | 8 ++ ..._signer_anchor_per_input_admission_test.go | 2 +- pkg/tbtc/frost_pre_sign_authorization.go | 24 +++- pkg/tbtc/frost_pre_sign_authorization_test.go | 57 ++++++-- .../frost_retained_group_history_source.go | 19 ++- ...rost_retained_group_history_source_test.go | 6 + 9 files changed, 309 insertions(+), 68 deletions(-) diff --git a/pkg/tbtc/frost_native_signer_anchor_admission.go b/pkg/tbtc/frost_native_signer_anchor_admission.go index 204af3e10a..cd11fd3511 100644 --- a/pkg/tbtc/frost_native_signer_anchor_admission.go +++ b/pkg/tbtc/frost_native_signer_anchor_admission.go @@ -384,18 +384,13 @@ func (controller *frostNativeSignerAnchorAdmissionController) reserve( headroom.Generations, ) } - // Both unreserved-headroom refusals name the same remedy as the - // rotation-floor refusal above. They are not a smaller version of it: a node - // refused here can still have a large part of both windows unspent - a - // fifty-seat node needs 2015 generations for its next input and is refused - // with 2015 of 4096 left, and a node sharing the window with other wallets - // is refused on what they hold rather than on what it has spent - so the - // number in the message reads healthy and the refusal looks transient. It is - // not. Neither window refills on its own; only an offline-authorized - // rotation moves the certified floor forward, so this is the first message - // an operator of a correctly configured node sees, and it has to say what to - // do about it rather than leave rotation to be inferred from the far rarer - // floor refusal. + // Raw-window exhaustion and reservation contention need different remedies. + // If cost exceeds raw headroom, no in-flight workflow release can make it + // fit: neither window refills on its own, so offline rotation is required. + // If raw headroom can cover cost but the conservative in-flight reservations + // leave too little unreserved, the refusal is temporary and must not tell an + // operator to rotate. The reservation is released when its workflow exits + // and commonly spends less than its worst-case charge. // // Since admission reserves one input at a time, this refusal can also land // part way through a batch whose authorization is already relayed and @@ -403,9 +398,22 @@ func (controller *frostNativeSignerAnchorAdmissionController) reserve( // that would have caught it earlier is what excluded most operators from // signing at all - and it is made visible by its own counter rather than // hidden behind this message. - if controller.reserved.Revisions > headroom.Revisions || - cost.Revisions > - headroom.Revisions-controller.reserved.Revisions { + unreservedRevisions := headroom.Revisions + if controller.reserved.Revisions >= headroom.Revisions { + unreservedRevisions = 0 + } else { + unreservedRevisions -= controller.reserved.Revisions + } + unreservedGenerations := headroom.Generations + if controller.reserved.Generations >= headroom.Generations { + unreservedGenerations = 0 + } else { + unreservedGenerations -= controller.reserved.Generations + } + // Exhaustion in either raw window takes precedence over temporary + // contention in the other: releasing reservations cannot make a workflow + // fit a dimension whose live headroom is already too small. + if cost.Revisions > headroom.Revisions { recordFrostNativeSignerAnchorUnreservedHeadroomRejection() return nil, fmt.Errorf( "%s requires [%d] anchor revisions but only [%d] are unreserved; "+ @@ -414,12 +422,10 @@ func (controller *frostNativeSignerAnchorAdmissionController) reserve( "be admitted", workflow, cost.Revisions, - headroom.Revisions-controller.reserved.Revisions, + unreservedRevisions, ) } - if controller.reserved.Generations > headroom.Generations || - cost.Generations > - headroom.Generations-controller.reserved.Generations { + if cost.Generations > headroom.Generations { recordFrostNativeSignerAnchorUnreservedHeadroomRejection() return nil, fmt.Errorf( "%s requires [%d] signer generations but only [%d] are unreserved; "+ @@ -428,7 +434,29 @@ func (controller *frostNativeSignerAnchorAdmissionController) reserve( "be admitted", workflow, cost.Generations, - headroom.Generations-controller.reserved.Generations, + unreservedGenerations, + ) + } + if cost.Revisions > unreservedRevisions { + recordFrostNativeSignerAnchorReservationContentionRejection() + return nil, fmt.Errorf( + "%s requires [%d] anchor revisions but only [%d] are currently "+ + "unreserved because in-flight workflows hold temporary "+ + "reservations; retry after those workflows finish", + workflow, + cost.Revisions, + unreservedRevisions, + ) + } + if cost.Generations > unreservedGenerations { + recordFrostNativeSignerAnchorReservationContentionRejection() + return nil, fmt.Errorf( + "%s requires [%d] signer generations but only [%d] are currently "+ + "unreserved because in-flight workflows hold temporary "+ + "reservations; retry after those workflows finish", + workflow, + cost.Generations, + unreservedGenerations, ) } @@ -840,7 +868,7 @@ func frostNativeSignerAnchorLargestAdmissibleWorkflowCost( // is registered with clientinfo at all, so a monitoring system cannot tell a // node that is refusing every signing request from one that simply has not been // asked. These process-wide cumulative counters make each refusal cause -// countable on its own, because the five causes need five different responses: +// countable on its own, because the six causes need six different responses: // // - seatCeiling is a CONFIGURATION signal. It never clears on its own and no // rotation fixes it: this node holds more local seats than the certified @@ -853,9 +881,15 @@ func frostNativeSignerAnchorLargestAdmissibleWorkflowCost( // - poisoned is a TERMINAL signal. The state anchor has latched a permanent // failure and only a process restart clears it. Any non-zero value should // alert. +// - reservationContention is a TEMPORARY signal. Raw certified headroom can +// cover the workflow, but other in-flight workflows currently hold enough +// conservative worst-case capacity to prevent another admission. Their +// reservations release on exit, so this is a retry signal, not a rotation +// signal. // - unreservedHeadroom is the ROTATION-DUE signal. It is the first refusal a -// healthy, correctly configured node produces, and it arrives while most of -// both windows are still unspent. +// healthy, correctly configured node produces once raw headroom cannot +// cover the workflow, and it can arrive while most of one window remains +// unspent. // - rotationFloor is the ROTATION-OVERDUE signal. All new work is already // being refused regardless of size. // - preSignInput is the WORK-LOST signal, and it is the one that costs money. @@ -863,20 +897,22 @@ func frostNativeSignerAnchorLargestAdmissibleWorkflowCost( // has already been relayed and finalized on chain can still be refused at // input 7 of 21 when the window runs out under it. The gas is spent, the // wallet action fails, and the underlying cause is counted by -// unreservedHeadroom or rotationFloor alongside it - this counter is what -// separates "refused before we spent anything" from "refused after we did". -// It counts refusals of the per-input reservation, including the first -// input's, because at that point the relay has already happened either way. +// reservationContention, unreservedHeadroom or rotationFloor alongside it - +// this counter is what separates "refused before we spent anything" from +// "refused after we did". It counts refusals of the per-input reservation, +// including the first input's, because at that point the relay has already +// happened either way. // // They follow the roast_interactive_signing_metrics.go pattern, are emitted in // every build, and stay at zero until an admission is actually refused - so // they are inert by default and registering them activates no behavior. var ( - frostNativeSignerAnchorSeatCeilingRejections atomic.Uint64 - frostNativeSignerAnchorUnreservedHeadroomRejections atomic.Uint64 - frostNativeSignerAnchorRotationFloorRejections atomic.Uint64 - frostNativeSignerAnchorPoisonedRejections atomic.Uint64 - frostNativeSignerAnchorPreSignInputRejections atomic.Uint64 + frostNativeSignerAnchorSeatCeilingRejections atomic.Uint64 + frostNativeSignerAnchorReservationContentionRejections atomic.Uint64 + frostNativeSignerAnchorUnreservedHeadroomRejections atomic.Uint64 + frostNativeSignerAnchorRotationFloorRejections atomic.Uint64 + frostNativeSignerAnchorPoisonedRejections atomic.Uint64 + frostNativeSignerAnchorPreSignInputRejections atomic.Uint64 ) // frostNativeSignerAnchorAdmissionMetricsApplication is the clientinfo @@ -886,11 +922,12 @@ var ( const frostNativeSignerAnchorAdmissionMetricsApplication = "frost_native_signer_anchor_admission" const ( - frostNativeSignerAnchorSeatCeilingMetricName = "seat_ceiling_rejected_total" - frostNativeSignerAnchorUnreservedHeadroomMetricName = "unreserved_headroom_rejected_total" - frostNativeSignerAnchorRotationFloorMetricName = "rotation_floor_rejected_total" - frostNativeSignerAnchorPoisonedMetricName = "poisoned_rejected_total" - frostNativeSignerAnchorPreSignInputMetricName = "pre_sign_input_rejected_total" + frostNativeSignerAnchorSeatCeilingMetricName = "seat_ceiling_rejected_total" + frostNativeSignerAnchorReservationContentionMetricName = "reservation_contention_rejected_total" + frostNativeSignerAnchorUnreservedHeadroomMetricName = "unreserved_headroom_rejected_total" + frostNativeSignerAnchorRotationFloorMetricName = "rotation_floor_rejected_total" + frostNativeSignerAnchorPoisonedMetricName = "poisoned_rejected_total" + frostNativeSignerAnchorPreSignInputMetricName = "pre_sign_input_rejected_total" ) // RegisterFrostNativeSignerAnchorAdmissionMetrics registers the cumulative @@ -913,6 +950,11 @@ func RegisterFrostNativeSignerAnchorAdmissionMetrics( frostNativeSignerAnchorSeatCeilingRejections.Load(), ) }, + frostNativeSignerAnchorReservationContentionMetricName: func() float64 { + return float64( + frostNativeSignerAnchorReservationContentionRejections.Load(), + ) + }, frostNativeSignerAnchorUnreservedHeadroomMetricName: func() float64 { return float64( frostNativeSignerAnchorUnreservedHeadroomRejections.Load(), @@ -944,6 +986,13 @@ func recordFrostNativeSignerAnchorSeatCeilingRejection() { frostNativeSignerAnchorSeatCeilingRejections.Add(1) } +// recordFrostNativeSignerAnchorReservationContentionRejection marks one +// workflow refused only because in-flight workflows temporarily hold enough +// worst-case capacity to prevent another admission. +func recordFrostNativeSignerAnchorReservationContentionRejection() { + frostNativeSignerAnchorReservationContentionRejections.Add(1) +} + // recordFrostNativeSignerAnchorUnreservedHeadroomRejection marks one workflow // refused because the unreserved part of a certified window cannot cover it. func recordFrostNativeSignerAnchorUnreservedHeadroomRejection() { @@ -974,6 +1023,7 @@ func recordFrostNativeSignerAnchorPreSignInputRejection() { // counters. Exposed only for the package's own tests; not a production helper. func resetFrostNativeSignerAnchorAdmissionMetricsForTest() { frostNativeSignerAnchorSeatCeilingRejections.Store(0) + frostNativeSignerAnchorReservationContentionRejections.Store(0) frostNativeSignerAnchorUnreservedHeadroomRejections.Store(0) frostNativeSignerAnchorRotationFloorRejections.Store(0) frostNativeSignerAnchorPoisonedRejections.Store(0) diff --git a/pkg/tbtc/frost_native_signer_anchor_admission_test.go b/pkg/tbtc/frost_native_signer_anchor_admission_test.go index 1b7f875637..a79c5e9a9c 100644 --- a/pkg/tbtc/frost_native_signer_anchor_admission_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_admission_test.go @@ -409,6 +409,9 @@ func TestFrostPreSignSeatCeilingGuardStillFires(t *testing.T) { func TestFrostNativeSignerAnchorAdmissionController_AtomicallyReservesNearWarning( t *testing.T, ) { + resetFrostNativeSignerAnchorAdmissionMetricsForTest() + t.Cleanup(resetFrostNativeSignerAnchorAdmissionMetricsForTest) + // Six local seats cost 126 revisions and 255 generations for one input, so // one fits a headroom of 257 and two do not. That is the property under // test: the second workflow is refused on what the first was promised, not @@ -449,9 +452,18 @@ func TestFrostNativeSignerAnchorAdmissionController_AtomicallyReservesNearWarnin 1, 6, signingAttemptsLimit, - ); err == nil || !strings.Contains(err.Error(), "unreserved") { + ); err == nil || !strings.Contains(err.Error(), "temporary reservations") || + strings.Contains(err.Error(), "offline anchor rotation") { t.Fatalf("concurrent reservation exceeded headroom: [%v]", err) } + if contention := + frostNativeSignerAnchorReservationContentionRejections.Load(); contention != 1 { + t.Fatalf("reservation-contention rejections counted [%d], expected [1]", contention) + } + if exhausted := + frostNativeSignerAnchorUnreservedHeadroomRejections.Load(); exhausted != 0 { + t.Fatalf("temporary contention counted as anchor exhaustion [%d] times", exhausted) + } // Crossing into the warning band does not revoke the admitted reservation. // Boundary revalidation uses ordinary readiness (which rejects only zero); @@ -495,6 +507,100 @@ func TestFrostNativeSignerAnchorAdmissionController_AtomicallyReservesNearWarnin } } +func TestFrostNativeSignerAnchorAdmissionController_ContentionSaturatesAvailableHeadroom( + t *testing.T, +) { + resetFrostNativeSignerAnchorAdmissionMetricsForTest() + t.Cleanup(resetFrostNativeSignerAnchorAdmissionMetricsForTest) + + currentHeadroom := frostNativeSignerAnchorCapacity{ + Revisions: FrostNativeSignerAnchorMaximumHistoryEvents, + Generations: FrostNativeSignerAnchorMaximumHistoryProofEntries, + } + controller := &frostNativeSignerAnchorAdmissionController{ + readHeadroom: func( + context.Context, + ) (frostNativeSignerAnchorCapacity, error) { + return currentHeadroom, nil + }, + } + snapshot := testFrostAnchorAdmissionReadinessSnapshot( + FrostNativeSignerAnchorMaximumHistoryEvents, + FrostNativeSignerAnchorMaximumHistoryProofEntries, + ) + + first, err := controller.reservePreSign( + context.Background(), + snapshot, + 1, + 20, + signingAttemptsLimit, + ) + if err != nil { + t.Fatal(err) + } + currentHeadroom = frostNativeSignerAnchorCapacity{ + Revisions: 300, + Generations: FrostNativeSignerAnchorMaximumHistoryProofEntries, + } + _, err = controller.reservePreSign( + context.Background(), + snapshot, + 1, + 1, + signingAttemptsLimit, + ) + if err == nil || + !strings.Contains(err.Error(), "only [0] are currently unreserved") || + !strings.Contains(err.Error(), "temporary reservations") || + strings.Contains(err.Error(), "offline anchor rotation") { + t.Fatalf("wrapped or terminal reservation-contention refusal: [%v]", err) + } + if contention := + frostNativeSignerAnchorReservationContentionRejections.Load(); contention != 1 { + t.Fatalf("reservation-contention rejections counted [%d], expected [1]", contention) + } + if exhausted := + frostNativeSignerAnchorUnreservedHeadroomRejections.Load(); exhausted != 0 { + t.Fatalf("temporary contention counted as anchor exhaustion [%d] times", exhausted) + } + + _, err = controller.reservePreSign( + context.Background(), + snapshot, + 1, + 20, + signingAttemptsLimit, + ) + if err == nil || + !strings.Contains(err.Error(), "only [0] are unreserved") || + !strings.Contains(err.Error(), "offline anchor rotation") || + strings.Contains(err.Error(), "temporary reservations") { + t.Fatalf("wrapped or transient raw-exhaustion refusal: [%v]", err) + } + if contention := + frostNativeSignerAnchorReservationContentionRejections.Load(); contention != 1 { + t.Fatalf("raw exhaustion changed contention count to [%d]", contention) + } + if exhausted := + frostNativeSignerAnchorUnreservedHeadroomRejections.Load(); exhausted != 1 { + t.Fatalf("raw exhaustion rejections counted [%d], expected [1]", exhausted) + } + + first.Release() + retry, err := controller.reservePreSign( + context.Background(), + snapshot, + 1, + 1, + signingAttemptsLimit, + ) + if err != nil { + t.Fatalf("released temporary reservation still blocked retry: [%v]", err) + } + retry.Release() +} + func TestFrostNativeSignerAnchorAdmissionController_RejectsStaleReadinessHeadroom( t *testing.T, ) { @@ -920,6 +1026,11 @@ func TestFrostNativeSignerAnchorAdmissionRefusalsNameARemedy(t *testing.T) { counted uint64 expected uint64 }{ + { + name: "reservation contention", + counted: frostNativeSignerAnchorReservationContentionRejections.Load(), + expected: 0, + }, { name: "unreserved headroom", counted: frostNativeSignerAnchorUnreservedHeadroomRejections.Load(), diff --git a/pkg/tbtc/frost_native_signer_anchor_client.go b/pkg/tbtc/frost_native_signer_anchor_client.go index 3511fab80f..4f915a4ee7 100644 --- a/pkg/tbtc/frost_native_signer_anchor_client.go +++ b/pkg/tbtc/frost_native_signer_anchor_client.go @@ -11,7 +11,6 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" - "errors" "fmt" "io" "mime" @@ -66,6 +65,10 @@ func (err *frostNativeSignerAnchorStatusError) Error() string { ) } +func (err *frostNativeSignerAnchorStatusError) HTTPStatusCode() int { + return err.statusCode +} + // FrostNativeSignerAnchorClientConfig contains runtime transport material and // the complete identity extracted from an independently authenticated // activation manifest. OnlinePublicKeySPKI and ClientPrivateKey are copied by @@ -995,22 +998,11 @@ func (client *FrostNativeSignerAnchorClient) readLocked( // // The transport cases are exactly isFrostPreSignTransientAuthorizationFailure's // - the anchor was not reached, so nothing was learned about it. The status -// cases are the two an HTTP intermediary or a restarting service produces while -// it is unable to answer at all; every other status is the service answering -// something wrong and stays fatal on the first attempt. +// cases are 408, 429, and 5xx responses an HTTP intermediary or a restarting +// service produces while it is unable to answer at all; every other status is +// the service answering something wrong and stays fatal on the first attempt. func isFrostNativeSignerAnchorRetryableReadFailure(err error) bool { - if err == nil { - return false - } - if isFrostPreSignTransientAuthorizationFailure(err) { - return true - } - var statusError *frostNativeSignerAnchorStatusError - if errors.As(err, &statusError) { - return statusError.statusCode == http.StatusTooManyRequests || - statusError.statusCode >= http.StatusInternalServerError - } - return false + return isFrostPreSignTransientAuthorizationFailure(err) } func (client *FrostNativeSignerAnchorClient) readOnceLocked( diff --git a/pkg/tbtc/frost_native_signer_anchor_client_test.go b/pkg/tbtc/frost_native_signer_anchor_client_test.go index 18ba170ec5..a3eb19521e 100644 --- a/pkg/tbtc/frost_native_signer_anchor_client_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_client_test.go @@ -863,6 +863,7 @@ func TestFrostNativeSignerAnchorClientReadRidesOutOneTransientFailure( t *testing.T, ) { for _, status := range []int{ + http.StatusRequestTimeout, http.StatusServiceUnavailable, http.StatusBadGateway, http.StatusInternalServerError, @@ -988,6 +989,13 @@ func TestIsFrostNativeSignerAnchorRetryableReadFailure(t *testing.T) { }), true, }, + { + "request timeout", + &frostNativeSignerAnchorStatusError{ + statusCode: http.StatusRequestTimeout, + }, + true, + }, { "service unavailable", &frostNativeSignerAnchorStatusError{ diff --git a/pkg/tbtc/frost_native_signer_anchor_per_input_admission_test.go b/pkg/tbtc/frost_native_signer_anchor_per_input_admission_test.go index 510cab441f..d11ae53c35 100644 --- a/pkg/tbtc/frost_native_signer_anchor_per_input_admission_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_per_input_admission_test.go @@ -650,7 +650,7 @@ func TestFrostPreSignAuthorizationGate_AdmitInputChargesOneInput(t *testing.T) { if _, err := gate.admitInput( context.Background(), authorization, - ); err == nil || !strings.Contains(err.Error(), "unreserved") { + ); err == nil || !strings.Contains(err.Error(), "temporary reservations") { t.Fatalf( "the relay reservation and a per-input admission were charged "+ "together: [%v]", diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index 9b3232d64c..5f354cacc3 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -1940,6 +1940,17 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) admitInput( // // context.Canceled is deliberately excluded: it means the caller went away, and // callers handle their own context separately. +type frostAuthorizationDependencyHTTPStatusError interface { + error + HTTPStatusCode() int +} + +func isFrostPreSignTransientAuthorizationHTTPStatus(statusCode int) bool { + return statusCode == http.StatusRequestTimeout || + statusCode == http.StatusTooManyRequests || + statusCode >= http.StatusInternalServerError && statusCode <= 599 +} + func isFrostPreSignTransientAuthorizationFailure(err error) bool { if err == nil { return false @@ -1969,10 +1980,15 @@ func isFrostPreSignTransientAuthorizationFailure(err error) bool { } var httpError ethereumRPC.HTTPError if errors.As(err, &httpError) { - return httpError.StatusCode == http.StatusRequestTimeout || - httpError.StatusCode == http.StatusTooManyRequests || - httpError.StatusCode >= http.StatusInternalServerError && - httpError.StatusCode <= 599 + return isFrostPreSignTransientAuthorizationHTTPStatus( + httpError.StatusCode, + ) + } + var dependencyHTTPError frostAuthorizationDependencyHTTPStatusError + if errors.As(err, &dependencyHTTPError) { + return isFrostPreSignTransientAuthorizationHTTPStatus( + dependencyHTTPError.HTTPStatusCode(), + ) } return false } diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go index 7f03078863..d30e42dce2 100644 --- a/pkg/tbtc/frost_pre_sign_authorization_test.go +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -1440,6 +1440,23 @@ func TestIsFrostPreSignTransientAuthorizationFailure(t *testing.T) { Status: "401 Unauthorized", }), }, + "retained history rate limited": { + err: fmt.Errorf("history read: [%w]", &frostRetainedGroupHistoryStatusError{ + statusCode: http.StatusTooManyRequests, + }), + transient: true, + }, + "native anchor service unavailable": { + err: fmt.Errorf("anchor read: [%w]", &frostNativeSignerAnchorStatusError{ + statusCode: http.StatusServiceUnavailable, + }), + transient: true, + }, + "retained history authentication rejected": { + err: fmt.Errorf("history read: [%w]", &frostRetainedGroupHistoryStatusError{ + statusCode: http.StatusUnauthorized, + }), + }, } for name, test := range tests { t.Run(name, func(t *testing.T) { @@ -1550,17 +1567,41 @@ func TestThresholdFrostPreSignAuthorizationGate_SurvivesTransientReadinessFailur func TestThresholdFrostPreSignAuthorizationGate_SurvivesTemporaryEthereumHTTPFailure( t *testing.T, ) { - for name, statusCode := range map[string]int{ - "request timeout": http.StatusRequestTimeout, - "rate limited": http.StatusTooManyRequests, - "service unavailable": http.StatusServiceUnavailable, + for name, failure := range map[string]error{ + "Ethereum request timeout": ethereumRPC.HTTPError{ + StatusCode: http.StatusRequestTimeout, + Status: http.StatusText(http.StatusRequestTimeout), + }, + "Ethereum rate limited": ethereumRPC.HTTPError{ + StatusCode: http.StatusTooManyRequests, + Status: http.StatusText(http.StatusTooManyRequests), + }, + "Ethereum service unavailable": ethereumRPC.HTTPError{ + StatusCode: http.StatusServiceUnavailable, + Status: http.StatusText(http.StatusServiceUnavailable), + }, + "retained history request timeout": &frostRetainedGroupHistoryStatusError{ + statusCode: http.StatusRequestTimeout, + }, + "retained history rate limited": &frostRetainedGroupHistoryStatusError{ + statusCode: http.StatusTooManyRequests, + }, + "retained history service unavailable": &frostRetainedGroupHistoryStatusError{ + statusCode: http.StatusServiceUnavailable, + }, + "native anchor request timeout": &frostNativeSignerAnchorStatusError{ + statusCode: http.StatusRequestTimeout, + }, + "native anchor rate limited": &frostNativeSignerAnchorStatusError{ + statusCode: http.StatusTooManyRequests, + }, + "native anchor service unavailable": &frostNativeSignerAnchorStatusError{ + statusCode: http.StatusServiceUnavailable, + }, } { t.Run(name, func(t *testing.T) { readiness := &testFrostProductionAuthorizationReadiness{ - err: fmt.Errorf("Ethereum RPC: [%w]", ethereumRPC.HTTPError{ - StatusCode: statusCode, - Status: http.StatusText(statusCode), - }), + err: fmt.Errorf("authorization dependency: [%w]", failure), failingCalls: 2, } gate, authorization := testFrostPreSignRevalidationFixture(t, readiness) diff --git a/pkg/tbtc/frost_retained_group_history_source.go b/pkg/tbtc/frost_retained_group_history_source.go index b19e9490d3..2d9ccfb0ef 100644 --- a/pkg/tbtc/frost_retained_group_history_source.go +++ b/pkg/tbtc/frost_retained_group_history_source.go @@ -116,6 +116,21 @@ type frostRetainedGroupHTTPClient interface { Do(*http.Request) (*http.Response, error) } +type frostRetainedGroupHistoryStatusError struct { + statusCode int +} + +func (err *frostRetainedGroupHistoryStatusError) Error() string { + return fmt.Sprintf( + "retained-group export returned HTTP status [%d]", + err.statusCode, + ) +} + +func (err *frostRetainedGroupHistoryStatusError) HTTPStatusCode() int { + return err.statusCode +} + type canonicalFrostRetainedGroupEthereumVerifier struct { *ethclient.Client rpcClient *rpc.Client @@ -1232,7 +1247,9 @@ func (source *signedFrostRetainedGroupHistorySource) postSigned( return 0, err } if response.StatusCode != http.StatusOK { - return 0, fmt.Errorf("retained-group export returned HTTP status [%d]", response.StatusCode) + return 0, &frostRetainedGroupHistoryStatusError{ + statusCode: response.StatusCode, + } } mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) if err != nil || mediaType != "application/json" { diff --git a/pkg/tbtc/frost_retained_group_history_source_test.go b/pkg/tbtc/frost_retained_group_history_source_test.go index 4616b8f4d8..4049a2ca39 100644 --- a/pkg/tbtc/frost_retained_group_history_source_test.go +++ b/pkg/tbtc/frost_retained_group_history_source_test.go @@ -14,6 +14,7 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" + "errors" "fmt" "io" "math/big" @@ -2052,6 +2053,11 @@ func TestSignedFrostRetainedGroupHistorySource_RejectsTruncationAndForgery( if err == nil || !strings.Contains(err.Error(), "HTTP status [503]") { t.Fatalf("expected truncated pagination rejection, got [%v]", err) } + statusError := &frostRetainedGroupHistoryStatusError{} + if !errors.As(err, &statusError) || + statusError.HTTPStatusCode() != http.StatusServiceUnavailable { + t.Fatalf("retained-history HTTP status lost its typed cause: [%v]", err) + } }) t.Run("forged envelope", func(t *testing.T) { From 744fe5ee871017c7025ec69e2530e53f1b57a762 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 17:01:40 -0400 Subject: [PATCH 91/93] fix(tbtc): bound authorization dependency retries --- pkg/tbtc/frost_native_signer_anchor_client.go | 10 +++---- .../frost_native_signer_anchor_client_test.go | 5 +++- pkg/tbtc/frost_pre_sign_authorization.go | 28 +++++++++++++++-- pkg/tbtc/frost_pre_sign_authorization_test.go | 30 +++++++++++++++++++ 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/pkg/tbtc/frost_native_signer_anchor_client.go b/pkg/tbtc/frost_native_signer_anchor_client.go index 4f915a4ee7..998200c4de 100644 --- a/pkg/tbtc/frost_native_signer_anchor_client.go +++ b/pkg/tbtc/frost_native_signer_anchor_client.go @@ -1560,6 +1560,11 @@ func (client *FrostNativeSignerAnchorClient) postWithResponseLimit( ) } defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, true, &frostNativeSignerAnchorStatusError{ + statusCode: response.StatusCode, + } + } limited := io.LimitReader(response.Body, responseLimit+1) body, readErr := io.ReadAll(limited) if readErr != nil { @@ -1568,11 +1573,6 @@ func (client *FrostNativeSignerAnchorClient) postWithResponseLimit( if len(body) == 0 || int64(len(body)) > responseLimit { return nil, true, fmt.Errorf("native signer anchor response size is invalid") } - if response.StatusCode != http.StatusOK { - return nil, true, &frostNativeSignerAnchorStatusError{ - statusCode: response.StatusCode, - } - } mediaType, parameters, err := mime.ParseMediaType(response.Header.Get("Content-Type")) if err != nil || mediaType != "application/json" || len(parameters) != 0 { return nil, true, fmt.Errorf("native signer anchor response content type is invalid") diff --git a/pkg/tbtc/frost_native_signer_anchor_client_test.go b/pkg/tbtc/frost_native_signer_anchor_client_test.go index a3eb19521e..d97b091112 100644 --- a/pkg/tbtc/frost_native_signer_anchor_client_test.go +++ b/pkg/tbtc/frost_native_signer_anchor_client_test.go @@ -1153,7 +1153,10 @@ func newTestFrostNativeSignerAnchorEnvironment( switch request.URL.Path { case "/anchor/read": if status := readFaults.next(); status != 0 { - http.Error(writer, "injected read fault", status) + // A proxy or overloaded service commonly sends only the status + // line. The client must classify that status before imposing the + // successful acknowledgement's non-empty body contract. + writer.WriteHeader(status) return } payload, _ := io.ReadAll(request.Body) diff --git a/pkg/tbtc/frost_pre_sign_authorization.go b/pkg/tbtc/frost_pre_sign_authorization.go index 5f354cacc3..5487325137 100644 --- a/pkg/tbtc/frost_pre_sign_authorization.go +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -2028,8 +2028,9 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( backoff = frostPreSignAuthorizationTransientRetryBackoff } deadline := time.Now().Add(budget) - for attempt := 1; ; attempt++ { - err := tfpsag.revalidateOnce(ctx, authorization) + attempt := 1 + err := tfpsag.revalidateOnce(ctx, authorization) + for { if err == nil { return nil } @@ -2045,13 +2046,34 @@ func (tfpsag *thresholdFrostPreSignAuthorizationGate) revalidate( err, ) } - backoffTimer := time.NewTimer(backoff) + remaining := time.Until(deadline) + backoffDuration := backoff + if backoffDuration > remaining { + backoffDuration = remaining + } + backoffTimer := time.NewTimer(backoffDuration) select { case <-ctx.Done(): backoffTimer.Stop() return err case <-backoffTimer.C: } + if !time.Now().Before(deadline) { + return fmt.Errorf( + "FROST authorization dependency stayed unreachable across [%d] attempts over [%s]: [%w]", + attempt, + budget, + err, + ) + } + + // Only retries are bounded by this deadline. The initial validation is + // allowed to use the caller's whole context; once it proves transient, + // no subsequent dependency call may outlive the declared retry budget. + retryContext, cancelRetry := context.WithDeadline(ctx, deadline) + attempt++ + err = tfpsag.revalidateOnce(retryContext, authorization) + cancelRetry() } } diff --git a/pkg/tbtc/frost_pre_sign_authorization_test.go b/pkg/tbtc/frost_pre_sign_authorization_test.go index d30e42dce2..e18324e54a 100644 --- a/pkg/tbtc/frost_pre_sign_authorization_test.go +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -1675,6 +1675,36 @@ func TestThresholdFrostPreSignAuthorizationGate_FailsClosedOnPersistentOutage( } } +func TestThresholdFrostPreSignAuthorizationGate_DoesNotStartRetryAfterBudgetExpiresDuringBackoff( + t *testing.T, +) { + readiness := &testFrostProductionAuthorizationReadiness{ + err: fmt.Errorf("anchor read: [%w]", os.ErrDeadlineExceeded), + } + gate, authorization := testFrostPreSignRevalidationFixture(t, readiness) + gate.transientRetryBudget = 20 * time.Millisecond + gate.transientRetryBackoff = time.Second + + started := time.Now() + err := gate.revalidate(context.Background(), authorization) + if err == nil || !strings.Contains(err.Error(), "stayed unreachable") { + t.Fatalf("expired retry budget did not fail closed: [%v]", err) + } + if readiness.calls != 1 { + t.Fatalf( + "[%d] dependency attempts ran even though the budget expired during backoff", + readiness.calls, + ) + } + if elapsed := time.Since(started); elapsed >= gate.transientRetryBackoff { + t.Fatalf( + "retry budget [%s] waited out the full backoff [%s]", + gate.transientRetryBudget, + elapsed, + ) + } +} + // TestThresholdFrostPreSignAuthorizationGate_StopsRetryingWhenCallerCancels // keeps a cancelled caller from being held for the retry budget; the monitor // treats its own cancellation separately and must not latch on it. From 02a2840155c4b34cfeb16e21094ecde57853cda9 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 17:27:42 -0400 Subject: [PATCH 92/93] fix(spv): propagate target wallet lookup failures --- pkg/maintainer/spv/moving_funds.go | 20 ++++----- pkg/maintainer/spv/moving_funds_test.go | 60 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/pkg/maintainer/spv/moving_funds.go b/pkg/maintainer/spv/moving_funds.go index 607c6cf7cb..7e2f9ffe28 100644 --- a/pkg/maintainer/spv/moving_funds.go +++ b/pkg/maintainer/spv/moving_funds.go @@ -212,20 +212,20 @@ func getUnprovenMovingFundsTransactions( targetWalletPublicKeyHash := targetWallets[0] targetWallet, err := spvChain.GetWallet(targetWalletPublicKeyHash) - var walletTransactions []*bitcoin.Transaction if err != nil { - walletTransactions, err = btcChain.GetTransactionsForPublicKeyHash( - targetWalletPublicKeyHash, - transactionLimit, - ) - } else { - walletTransactions, err = getWalletTransactions( + return nil, fmt.Errorf( + "failed to get target wallet [%x]: [%w]", targetWalletPublicKeyHash, - targetWallet, - transactionLimit, - btcChain, + err, ) } + + walletTransactions, err := getWalletTransactions( + targetWalletPublicKeyHash, + targetWallet, + transactionLimit, + btcChain, + ) if err != nil { return nil, fmt.Errorf( "failed to get transactions for wallet: [%v]", diff --git a/pkg/maintainer/spv/moving_funds_test.go b/pkg/maintainer/spv/moving_funds_test.go index 3afa89b5a5..029579f1f1 100644 --- a/pkg/maintainer/spv/moving_funds_test.go +++ b/pkg/maintainer/spv/moving_funds_test.go @@ -551,3 +551,63 @@ func TestGetUnprovenMovingFundsTransactions_FindsTaprootTargetOutput( actualHash[:], ) } + +func TestGetUnprovenMovingFundsTransactions_TargetWalletLookupFailure( + t *testing.T, +) { + historyDepth := uint64(5) + transactionLimit := 10 + + btcChain := newLocalBitcoinChain() + spvChain := newLocalChain() + + currentBlock := uint64(1000) + blockCounter := newMockBlockCounter() + blockCounter.SetCurrentBlock(currentBlock) + spvChain.setBlockCounter(blockCounter) + + sourceWalletPublicKeyHash := bytes20FromHex( + t, + "8db50eb52063ea9d98b3eac91489a90f738986f6", + ) + targetWalletPublicKeyHash := bytes20FromHex( + t, + "c7302d75072d78be94eb8d36c4b77583c7abb06e", + ) + + spvChain.setWallet(sourceWalletPublicKeyHash, &tbtc.WalletChainData{ + State: tbtc.StateMovingFunds, + }) + + err := spvChain.addPastMovingFundsCommitmentSubmittedEvent( + &tbtc.MovingFundsCommitmentSubmittedEventFilter{ + StartBlock: currentBlock - historyDepth, + }, + &tbtc.MovingFundsCommitmentSubmittedEvent{ + WalletPublicKeyHash: sourceWalletPublicKeyHash, + TargetWallets: [][20]byte{ + targetWalletPublicKeyHash, + }, + BlockNumber: currentBlock - 1, + }, + ) + if err != nil { + t.Fatal(err) + } + + _, err = getUnprovenMovingFundsTransactions( + historyDepth, + transactionLimit, + btcChain, + spvChain, + ) + if err == nil { + t.Fatal("expected target wallet lookup failure") + } + + expectedError := fmt.Sprintf( + "failed to get target wallet [%x]: [no wallet for given PKH]", + targetWalletPublicKeyHash, + ) + testutils.AssertStringsEqual(t, "error", expectedError, err.Error()) +} From 97bb19048fcb189a1cea3d3731bce9d25dceff3f Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 31 Jul 2026 18:00:51 -0400 Subject: [PATCH 93/93] fix(tbtc): preserve signing wire and DKG recovery --- pkg/tbtc/frost_dkg_coordinator.go | 140 +++++++++++++++++------- pkg/tbtc/frost_dkg_coordinator_test.go | 145 +++++++++++++++++++++++++ pkg/tbtc/marshaling.go | 46 +++++++- pkg/tbtc/marshaling_test.go | 114 +++++++++++++++++++ pkg/tbtc/signing.go | 3 + pkg/tbtc/signing_done.go | 108 ++++++++++++++++-- 6 files changed, 501 insertions(+), 55 deletions(-) diff --git a/pkg/tbtc/frost_dkg_coordinator.go b/pkg/tbtc/frost_dkg_coordinator.go index 084273a74e..f55854c54c 100644 --- a/pkg/tbtc/frost_dkg_coordinator.go +++ b/pkg/tbtc/frost_dkg_coordinator.go @@ -3,10 +3,13 @@ package tbtc import ( "context" "fmt" + "time" "github.com/keep-network/keep-core/pkg/protocol/group" ) +const frostDKGRecoveryRetryInterval = 15 * time.Second + type frostDKGExecutor func( context.Context, *node, @@ -70,8 +73,8 @@ func handleFrostDKGStarted( deduplicator *deduplicator, event *FrostDKGStartedEvent, waitForConfirmation bool, -) { - handleFrostDKGStartedWithExecutor( +) bool { + return handleFrostDKGStartedWithExecutor( ctx, node, frostChain, @@ -90,7 +93,7 @@ func handleFrostDKGStartedWithExecutor( event *FrostDKGStartedEvent, waitForConfirmation bool, execute frostDKGExecutor, -) { +) (completed bool) { lease, ok := deduplicator.beginDKGStarted(event.Seed) if !ok { logger.Infof( @@ -98,9 +101,8 @@ func handleFrostDKGStartedWithExecutor( "being processed", event.Seed, ) - return + return deduplicator.dkgSeedCache.Has(event.Seed.Text(16)) } - completed := false defer func() { lease.finish(completed) }() if waitForConfirmation { @@ -115,18 +117,18 @@ func handleFrostDKGStartedWithExecutor( if err := node.waitForBlockHeight(ctx, confirmationBlock); err != nil { logger.Errorf("failed to confirm FROST DKG started event: [%v]", err) - return + return false } if ctx.Err() != nil { logger.Errorf("stopping FROST DKG started event handling: [%v]", ctx.Err()) - return + return false } } dkgState, err := frostChain.GetFrostDKGState() if err != nil { logger.Errorf("failed to check FROST DKG state: [%v]", err) - return + return false } if dkgState != AwaitingResult { logger.Infof( @@ -135,8 +137,7 @@ func handleFrostDKGStartedWithExecutor( event.Seed, event.BlockNumber, ) - completed = true - return + return true } startBlock := uint64(0) @@ -151,11 +152,11 @@ func handleFrostDKGStartedWithExecutor( ) if err != nil { logger.Errorf("failed to get past FROST DKG started events: [%v]", err) - return + return false } if len(pastEvents) == 0 { logger.Errorf("no past FROST DKG started events") - return + return false } lastEvent := pastEvents[len(pastEvents)-1] @@ -175,7 +176,7 @@ func handleFrostDKGStartedWithExecutor( // failed and released its lease, this path safely becomes the retry. completed = true lease.finish(completed) - handleFrostDKGStartedWithExecutor( + return handleFrostDKGStartedWithExecutor( ctx, node, frostChain, @@ -184,7 +185,6 @@ func handleFrostDKGStartedWithExecutor( waitForConfirmation, execute, ) - return } memberIndexes, groupSelectionResult, err := localFrostMembership( @@ -193,7 +193,7 @@ func handleFrostDKGStartedWithExecutor( ) if err != nil { logger.Errorf("failed to resolve FROST DKG membership: [%v]", err) - return + return false } if len(memberIndexes) == 0 { @@ -203,11 +203,10 @@ func handleFrostDKGStartedWithExecutor( lastEvent.Seed, lastEvent.BlockNumber, ) - completed = true - return + return true } - completed = execute( + return execute( ctx, node, frostChain, @@ -223,10 +222,66 @@ func recoverFrostDKGCoordinatorState( frostChain FrostDKGChain, deduplicator *deduplicator, ) { + recoverFrostDKGCoordinatorStateWithRetryInterval( + ctx, + node, + frostChain, + deduplicator, + frostDKGRecoveryRetryInterval, + ) +} + +func recoverFrostDKGCoordinatorStateWithRetryInterval( + ctx context.Context, + node *node, + frostChain FrostDKGChain, + deduplicator *deduplicator, + retryInterval time.Duration, +) { + for { + select { + case <-ctx.Done(): + return + default: + } + + if recoverFrostDKGCoordinatorStateOnce( + ctx, + node, + frostChain, + deduplicator, + ) { + return + } + + timer := time.NewTimer(retryInterval) + select { + case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return + case <-timer.C: + } + } +} + +// recoverFrostDKGCoordinatorStateOnce returns true when recovery reached a +// terminal on-chain state or successfully handed the active state to its +// handler. It returns false for retryable reads and handler failures. +func recoverFrostDKGCoordinatorStateOnce( + ctx context.Context, + node *node, + frostChain FrostDKGChain, + deduplicator *deduplicator, +) bool { state, err := frostChain.GetFrostDKGState() if err != nil { logger.Errorf("failed to recover FROST DKG state: [%v]", err) - return + return false } switch state { @@ -234,7 +289,7 @@ func recoverFrostDKGCoordinatorState( startBlock, err := frostDKGRecoveryStartBlock(node, frostChain) if err != nil { logger.Errorf("failed to resolve FROST DKG recovery start block: [%v]", err) - return + return false } events, err := frostChain.PastFrostDKGStartedEvents( @@ -242,14 +297,14 @@ func recoverFrostDKGCoordinatorState( ) if err != nil { logger.Errorf("failed to recover past FROST DKG started events: [%v]", err) - return + return false } if len(events) == 0 { logger.Warnf("FROST DKG state is AwaitingResult but no DkgStarted event was found") - return + return false } - handleFrostDKGStarted( + return handleFrostDKGStarted( ctx, node, frostChain, @@ -262,7 +317,7 @@ func recoverFrostDKGCoordinatorState( startBlock, err := frostDKGRecoveryStartBlock(node, frostChain) if err != nil { logger.Errorf("failed to resolve FROST DKG recovery start block: [%v]", err) - return + return false } events, err := frostChain.PastFrostDKGResultSubmittedEvents( @@ -270,14 +325,14 @@ func recoverFrostDKGCoordinatorState( ) if err != nil { logger.Errorf("failed to recover past FROST DKG result submissions: [%v]", err) - return + return false } if len(events) == 0 { logger.Warnf("FROST DKG state is Challenge but no result submission was found") - return + return false } - handleFrostDKGResultSubmitted( + return handleFrostDKGResultSubmitted( ctx, node, frostChain, @@ -285,6 +340,8 @@ func recoverFrostDKGCoordinatorState( events[len(events)-1], ) } + + return true } func handleFrostDKGResultSubmitted( @@ -293,7 +350,7 @@ func handleFrostDKGResultSubmitted( frostChain FrostDKGChain, deduplicator *deduplicator, event *FrostDKGResultSubmittedEvent, -) { +) (completed bool) { lease, ok := deduplicator.beginDKGResultSubmitted( event.Seed, event.ResultHash, @@ -307,9 +364,14 @@ func handleFrostDKGResultSubmitted( event.Seed, event.BlockNumber, ) - return + return deduplicator.dkgResultHashCache.Has( + dkgResultSubmittedCacheKey( + event.Seed, + event.ResultHash, + event.BlockNumber, + ), + ) } - completed := false defer func() { lease.finish(completed) }() valid, reason, err := frostChain.IsFrostDKGResultValid(event.Result) @@ -319,7 +381,7 @@ func handleFrostDKGResultSubmitted( event.ResultHash, err, ) - return + return false } if !valid { @@ -328,14 +390,13 @@ func handleFrostDKGResultSubmitted( event.ResultHash, reason, ) - completed = challengeInvalidFrostDKGResult(ctx, node, frostChain, event) - return + return challengeInvalidFrostDKGResult(ctx, node, frostChain, event) } memberIndexes, _, err := localFrostMembership(node, frostChain) if err != nil { logger.Errorf("failed to resolve local FROST DKG membership: [%v]", err) - return + return false } if len(memberIndexes) == 0 { logger.Infof( @@ -343,22 +404,21 @@ func handleFrostDKGResultSubmitted( "selected group and will not approve", event.ResultHash, ) - completed = true - return + return true } params, err := frostChain.FrostDKGParameters() if err != nil { logger.Errorf("failed to get FROST DKG parameters: [%v]", err) - return + return false } if params == nil { logger.Errorf("FROST DKG parameters are nil") - return + return false } if ctx.Err() != nil { logger.Errorf("stopping FROST DKG result handling: [%v]", ctx.Err()) - return + return false } challengePeriodEndBlock := event.BlockNumber + params.ChallengePeriodBlocks @@ -385,7 +445,7 @@ func handleFrostDKGResultSubmitted( approvalBlock, ) } - completed = true + return true } func challengeInvalidFrostDKGResult( diff --git a/pkg/tbtc/frost_dkg_coordinator_test.go b/pkg/tbtc/frost_dkg_coordinator_test.go index bc36c2c4d6..ab2473bb2a 100644 --- a/pkg/tbtc/frost_dkg_coordinator_test.go +++ b/pkg/tbtc/frost_dkg_coordinator_test.go @@ -345,6 +345,87 @@ func TestHandleFrostDKGResultSubmittedReleasesDeduplicationKeyAfterFailure( } } +func TestRecoverFrostDKGCoordinatorStateRetriesInitialStateRead( + t *testing.T, +) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + frostChain := &retryingFrostDKGRecoveryStateChain{failures: 2} + recoverFrostDKGCoordinatorStateWithRetryInterval( + ctx, + &node{}, + frostChain, + newDeduplicator(), + time.Millisecond, + ) + + if frostChain.calls != 3 { + t.Fatalf( + "unexpected recovery state calls\nexpected: [3]\nactual: [%d]", + frostChain.calls, + ) + } +} + +func TestRecoverFrostDKGCoordinatorStateStopsBeforeReadAfterCancellation( + t *testing.T, +) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + frostChain := &retryingFrostDKGRecoveryStateChain{failures: 1} + recoverFrostDKGCoordinatorStateWithRetryInterval( + ctx, + &node{}, + frostChain, + newDeduplicator(), + time.Millisecond, + ) + + if frostChain.calls != 0 { + t.Fatalf("recovery queried state after cancellation") + } +} + +func TestRecoverFrostDKGCoordinatorStateRetriesActiveStateReads( + t *testing.T, +) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + localChain := Connect() + testNode := &node{ + chain: localChain, + frostGroupParameters: &GroupParameters{ + GroupSize: 1, + }, + } + event := &FrostDKGStartedEvent{Seed: big.NewInt(100), BlockNumber: 1} + frostChain := &retryingFrostDKGRecoveryReadsChain{event: event} + + recoverFrostDKGCoordinatorStateWithRetryInterval( + ctx, + testNode, + frostChain, + newDeduplicator(), + time.Millisecond, + ) + + if frostChain.stateCalls != 3 || + frostChain.parametersCalls != 2 || + frostChain.pastEventsCalls != 3 || + frostChain.selectionCalls != 1 { + t.Fatalf( + "unexpected recovery calls: state [%d], parameters [%d], past events [%d], selection [%d]", + frostChain.stateCalls, + frostChain.parametersCalls, + frostChain.pastEventsCalls, + frostChain.selectionCalls, + ) + } +} + func TestScheduleFrostDKGResultApprovalStopsAfterContextCancellation( t *testing.T, ) { @@ -419,6 +500,70 @@ type retryingFrostDKGChallengeChain struct { successOnAttempt int } +type retryingFrostDKGRecoveryStateChain struct { + FrostDKGChain + + calls int + failures int +} + +func (rfdkgrsc *retryingFrostDKGRecoveryStateChain) GetFrostDKGState() ( + DKGState, + error, +) { + rfdkgrsc.calls++ + if rfdkgrsc.calls <= rfdkgrsc.failures { + return Idle, fmt.Errorf("transient state error") + } + + return Idle, nil +} + +type retryingFrostDKGRecoveryReadsChain struct { + FrostDKGChain + + event *FrostDKGStartedEvent + stateCalls int + parametersCalls int + pastEventsCalls int + selectionCalls int +} + +func (rfdkgrrc *retryingFrostDKGRecoveryReadsChain) GetFrostDKGState() ( + DKGState, + error, +) { + rfdkgrrc.stateCalls++ + return AwaitingResult, nil +} + +func (rfdkgrrc *retryingFrostDKGRecoveryReadsChain) FrostDKGParameters() ( + *DKGParameters, + error, +) { + rfdkgrrc.parametersCalls++ + return &DKGParameters{}, nil +} + +func (rfdkgrrc *retryingFrostDKGRecoveryReadsChain) PastFrostDKGStartedEvents( + *FrostDKGStartedEventFilter, +) ([]*FrostDKGStartedEvent, error) { + rfdkgrrc.pastEventsCalls++ + if rfdkgrrc.pastEventsCalls == 1 { + return nil, fmt.Errorf("transient past events error") + } + + return []*FrostDKGStartedEvent{rfdkgrrc.event}, nil +} + +func (rfdkgrrc *retryingFrostDKGRecoveryReadsChain) SelectFrostGroup() ( + *GroupSelectionResult, + error, +) { + rfdkgrrc.selectionCalls++ + return &GroupSelectionResult{}, nil +} + type transientFrostDKGStartedChain struct { FrostDKGChain diff --git a/pkg/tbtc/marshaling.go b/pkg/tbtc/marshaling.go index a5d71043f3..d04ef676f2 100644 --- a/pkg/tbtc/marshaling.go +++ b/pkg/tbtc/marshaling.go @@ -1,6 +1,7 @@ package tbtc import ( + stdbytes "bytes" "crypto/ecdsa" "fmt" "math" @@ -13,11 +14,17 @@ import ( "github.com/keep-network/keep-core/pkg/chain" "github.com/keep-network/keep-core/pkg/crypto/secp256k1" "github.com/keep-network/keep-core/pkg/frost" + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tbtc/gen/pb" "github.com/keep-network/keep-core/pkg/tecdsa" ) +// frostSigningDoneSignaturePrefix versions native FROST signatures inside the +// existing SigningDoneMessage signature field. Legacy ECDSA attempts leave the +// field byte-for-byte compatible with the pre-FROST tecdsa.Signature protobuf. +const frostSigningDoneSignaturePrefix = "tbtc-frost-signature-v1:" + var errIncompatiblePublicKey = fmt.Errorf( "public key is not tECDSA compatible and will cause unmarshaling error", ) @@ -99,9 +106,20 @@ func (s *signer) Unmarshal(bytes []byte) error { // Marshal converts the signingDoneMessage to a byte array. func (sdm *signingDoneMessage) Marshal() ([]byte, error) { - signatureBytes, err := sdm.signature.Marshal() + var signatureBytes []byte + var err error + if sdm.legacySignature != nil { + signatureBytes, err = sdm.legacySignature.Marshal() + } else { + var rawSignature []byte + rawSignature, err = sdm.signature.Marshal() + signatureBytes = append( + []byte(frostSigningDoneSignaturePrefix), + rawSignature..., + ) + } if err != nil { - return nil, err + return nil, fmt.Errorf("cannot marshal signature: [%w]", err) } return proto.Marshal(&pb.SigningDoneMessage{ @@ -124,15 +142,33 @@ func (sdm *signingDoneMessage) Unmarshal(bytes []byte) error { return err } - signature := &frost.Signature{} - if err := signature.Unmarshal(pbMsg.Signature); err != nil { - return fmt.Errorf("cannot unmarshal signature: [%v]", err) + var signature *frost.Signature + var legacySignature *tecdsa.Signature + if stdbytes.HasPrefix(pbMsg.Signature, []byte(frostSigningDoneSignaturePrefix)) { + signature = &frost.Signature{} + if err := signature.Unmarshal( + pbMsg.Signature[len(frostSigningDoneSignaturePrefix):], + ); err != nil { + return fmt.Errorf("cannot unmarshal FROST signature: [%v]", err) + } + } else { + legacySignature = &tecdsa.Signature{} + if err := legacySignature.Unmarshal(pbMsg.Signature); err != nil { + return fmt.Errorf("cannot unmarshal legacy signature: [%v]", err) + } + + var err error + signature, err = frostsigning.FromTECDSASignature(legacySignature) + if err != nil { + return fmt.Errorf("cannot convert legacy signature: [%v]", err) + } } sdm.senderID = group.MemberIndex(pbMsg.SenderID) sdm.message = new(big.Int).SetBytes(pbMsg.Message) sdm.attemptNumber = pbMsg.AttemptNumber sdm.signature = signature + sdm.legacySignature = legacySignature sdm.endBlock = pbMsg.EndBlock return nil diff --git a/pkg/tbtc/marshaling_test.go b/pkg/tbtc/marshaling_test.go index afde393911..11a49bb510 100644 --- a/pkg/tbtc/marshaling_test.go +++ b/pkg/tbtc/marshaling_test.go @@ -1,14 +1,17 @@ package tbtc import ( + "bytes" "crypto/ecdsa" "crypto/elliptic" + "crypto/sha256" "encoding/hex" "math/big" "reflect" "strings" "testing" + "github.com/btcsuite/btcd/btcec" "github.com/keep-network/keep-core/pkg/bitcoin" fuzz "github.com/google/gofuzz" @@ -19,6 +22,7 @@ import ( "github.com/keep-network/keep-core/pkg/internal/pbutils" "github.com/keep-network/keep-core/pkg/protocol/group" "github.com/keep-network/keep-core/pkg/tbtc/gen/pb" + "github.com/keep-network/keep-core/pkg/tecdsa" ) func TestSignerMarshalling(t *testing.T) { @@ -95,6 +99,116 @@ func TestSigningDoneMessage_MarshalingRoundtrip(t *testing.T) { if !reflect.DeepEqual(msg, unmarshaled) { t.Fatalf("unexpected content of unmarshaled message") } + + marshaled, err := msg.Marshal() + if err != nil { + t.Fatal(err) + } + pbMsg := &pb.SigningDoneMessage{} + if err := proto.Unmarshal(marshaled, pbMsg); err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(pbMsg.Signature, []byte(frostSigningDoneSignaturePrefix)) { + t.Fatal("native FROST signature is missing its wire-format version") + } +} + +func TestSigningDoneMessage_LegacyWireCompatibility(t *testing.T) { + messageDigest := sha256.Sum256([]byte("legacy signing done compatibility")) + privateKey, publicKey := btcec.PrivKeyFromBytes( + btcec.S256(), + bytes.Repeat([]byte{0x01}, 32), + ) + compactSignature, err := btcec.SignCompact( + btcec.S256(), + privateKey, + messageDigest[:], + true, + ) + if err != nil { + t.Fatal(err) + } + + legacySignature := &tecdsa.Signature{ + R: new(big.Int).SetBytes(compactSignature[1:33]), + S: new(big.Int).SetBytes(compactSignature[33:]), + RecoveryID: int8( + (compactSignature[0] - 27) &^ byte(4), + ), + } + legacySignatureBytes, err := legacySignature.Marshal() + if err != nil { + t.Fatal(err) + } + + oldWireMessage := &pb.SigningDoneMessage{ + SenderID: 10, + Message: messageDigest[:], + AttemptNumber: 2, + Signature: legacySignatureBytes, + EndBlock: 4500, + } + oldWire, err := proto.Marshal(oldWireMessage) + if err != nil { + t.Fatal(err) + } + + decoded := &signingDoneMessage{} + if err := decoded.Unmarshal(oldWire); err != nil { + t.Fatalf("new peer rejected legacy signing done message: [%v]", err) + } + if decoded.legacySignature == nil || + !legacySignature.Equals(decoded.legacySignature) { + t.Fatal("legacy signature was not preserved") + } + + reencoded, err := decoded.Marshal() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(oldWire, reencoded) { + t.Fatal("legacy signing done wire format changed after roundtrip") + } + + recoveredLegacySignature, err := legacySigningDoneSignature( + new(big.Int).SetBytes(messageDigest[:]), + decoded.signature, + publicKey.ToECDSA(), + ) + if err != nil { + t.Fatal(err) + } + if !legacySignature.Equals(recoveredLegacySignature) { + t.Fatalf( + "new peer did not reproduce the legacy signature\nexpected: [%v]\nactual: [%v]", + legacySignature, + recoveredLegacySignature, + ) + } + + newPeerMessage := &signingDoneMessage{ + senderID: 10, + message: new(big.Int).SetBytes(messageDigest[:]), + attemptNumber: 2, + signature: decoded.signature, + legacySignature: recoveredLegacySignature, + endBlock: 4500, + } + newPeerWire, err := newPeerMessage.Marshal() + if err != nil { + t.Fatal(err) + } + newPeerPB := &pb.SigningDoneMessage{} + if err := proto.Unmarshal(newPeerWire, newPeerPB); err != nil { + t.Fatal(err) + } + oldPeerSignature := &tecdsa.Signature{} + if err := oldPeerSignature.Unmarshal(newPeerPB.Signature); err != nil { + t.Fatalf("old peer rejected new peer's legacy signature: [%v]", err) + } + if !legacySignature.Equals(oldPeerSignature) { + t.Fatal("new peer changed the signature observed by an old peer") + } } func TestFuzzSigningDoneMessage_MarshalingRoundtrip(t *testing.T) { diff --git a/pkg/tbtc/signing.go b/pkg/tbtc/signing.go index aa8bd33b5d..356e3795a2 100644 --- a/pkg/tbtc/signing.go +++ b/pkg/tbtc/signing.go @@ -931,6 +931,9 @@ func (se *signingExecutor) signWithTaprootMerkleRootForSessionIntentAndAuthoriza se.broadcastChannel, se.membershipValidator, ) + if !se.usesSchnorrSignatures() { + doneCheck.useLegacySignatureWireFormat(wallet.publicKey) + } retryLoop := newSigningRetryLoop( signingLogger, diff --git a/pkg/tbtc/signing_done.go b/pkg/tbtc/signing_done.go index 7b3b717054..f3be587c91 100644 --- a/pkg/tbtc/signing_done.go +++ b/pkg/tbtc/signing_done.go @@ -2,15 +2,18 @@ package tbtc import ( "context" + "crypto/ecdsa" "fmt" "math/big" "sync" "time" + "github.com/btcsuite/btcd/btcec" "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/frost/signing" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/protocol/group" + "github.com/keep-network/keep-core/pkg/tecdsa" ) // signingDoneReceiveBuffer is a buffer for messages received from the broadcast @@ -36,7 +39,10 @@ type signingDoneMessage struct { message *big.Int attemptNumber uint64 signature *frost.Signature - endBlock uint64 + // legacySignature preserves the pre-FROST protobuf wire representation for + // ECDSA wallet attempts. It is nil for versioned native FROST signatures. + legacySignature *tecdsa.Signature + endBlock uint64 } func (sdm *signingDoneMessage) Type() string { @@ -46,10 +52,11 @@ func (sdm *signingDoneMessage) Type() string { // signingDoneCheck is a component that is responsible for signaling a // successful signature calculation across all signing group members. type signingDoneCheck struct { - groupSize int - honestThreshold int - broadcastChannel net.BroadcastChannel - membershipValidator *group.MembershipValidator + groupSize int + honestThreshold int + broadcastChannel net.BroadcastChannel + membershipValidator *group.MembershipValidator + legacyWalletPublicKey *ecdsa.PublicKey receiveCtx context.Context cancelReceiveCtx context.CancelFunc @@ -74,6 +81,12 @@ type signingDoneCheck struct { doneSignersMutex sync.RWMutex } +func (sdc *signingDoneCheck) useLegacySignatureWireFormat( + walletPublicKey *ecdsa.PublicKey, +) { + sdc.legacyWalletPublicKey = walletPublicKey +} + func newSigningDoneCheck( groupSize int, honestThreshold int, @@ -169,15 +182,79 @@ func (sdc *signingDoneCheck) signalDone( result *signing.Result, endBlock uint64, ) error { + var legacySignature *tecdsa.Signature + if sdc.legacyWalletPublicKey != nil { + var err error + legacySignature, err = legacySigningDoneSignature( + message, + result.Signature, + sdc.legacyWalletPublicKey, + ) + if err != nil { + return fmt.Errorf("cannot encode legacy signing done signature: [%w]", err) + } + } + return sdc.broadcastChannel.Send(ctx, &signingDoneMessage{ - senderID: memberIndex, - message: message, - attemptNumber: attemptNumber, - signature: result.Signature, - endBlock: endBlock, + senderID: memberIndex, + message: message, + attemptNumber: attemptNumber, + signature: result.Signature, + legacySignature: legacySignature, + endBlock: endBlock, }, net.BackoffRetransmissionStrategy) } +func legacySigningDoneSignature( + message *big.Int, + signature *frost.Signature, + walletPublicKey *ecdsa.PublicKey, +) (*tecdsa.Signature, error) { + if message == nil { + return nil, fmt.Errorf("message is nil") + } + if signature == nil { + return nil, fmt.Errorf("signature is nil") + } + if walletPublicKey == nil || walletPublicKey.X == nil || walletPublicKey.Y == nil { + return nil, fmt.Errorf("wallet public key is nil") + } + + messageBytes := message.Bytes() + if len(messageBytes) > 32 { + return nil, fmt.Errorf("message exceeds 32 bytes") + } + messageDigest := make([]byte, 32) + copy(messageDigest[32-len(messageBytes):], messageBytes) + + serializedSignature := signature.Serialize() + compactSignature := make([]byte, 1+len(serializedSignature)) + copy(compactSignature[1:], serializedSignature[:]) + + for recoveryID := byte(0); recoveryID < 4; recoveryID++ { + compactSignature[0] = 27 + 4 + recoveryID + recoveredPublicKey, _, err := btcec.RecoverCompact( + btcec.S256(), + compactSignature, + messageDigest, + ) + if err != nil { + continue + } + + if recoveredPublicKey.X.Cmp(walletPublicKey.X) == 0 && + recoveredPublicKey.Y.Cmp(walletPublicKey.Y) == 0 { + return &tecdsa.Signature{ + R: new(big.Int).SetBytes(signature.R[:]), + S: new(big.Int).SetBytes(signature.S[:]), + RecoveryID: int8(recoveryID), + }, nil + } + } + + return nil, fmt.Errorf("cannot recover wallet public key from signature") +} + // waitUntilAllDone blocks until the attempt's completion rule is met or the // passed context is done. On success it returns the agreed signature and a // deterministic end block (the same value on every honest node): on the legacy @@ -386,6 +463,17 @@ func (sdm *signingDoneMessage) clone() *signingDoneMessage { signatureCopy := *sdm.signature result.signature = &signatureCopy } + if sdm.legacySignature != nil { + result.legacySignature = &tecdsa.Signature{ + RecoveryID: sdm.legacySignature.RecoveryID, + } + if sdm.legacySignature.R != nil { + result.legacySignature.R = new(big.Int).Set(sdm.legacySignature.R) + } + if sdm.legacySignature.S != nil { + result.legacySignature.S = new(big.Int).Set(sdm.legacySignature.S) + } + } return result }