diff --git a/.github/workflows/frost-cgo-integration.yml b/.github/workflows/frost-cgo-integration.yml index 28963148fd..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 @@ -86,6 +129,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 \ @@ -93,6 +137,15 @@ 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_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 \ frost_tbtc_abi_version; do if ! nm -D --defined-only "$lib" | grep -q " ${sym}$"; then diff --git a/ci/frost-signer-pin.env b/ci/frost-signer-pin.env index 39b550eb20..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=6e0fa9741ffb6b0bb5603eabe26fb4cc1b13ecd9 +FROST_SIGNER_MIRROR_REF=08b6d6f40027016101f32c1ffc509fde4746d0a2 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/start.go b/cmd/start.go index c5bc8902f2..c9323ff311 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, @@ -162,6 +215,25 @@ func start(cmd *cobra.Command) error { btcChain, ) + var retainedGroupHistorySource interface{ Close() } + if clientConfig.Tbtc.EnableFrostPreSignAuthorization { + source, err := tbtc.NewFrostRetainedGroupHistorySource( + ctx, + clientConfig.Tbtc.FrostRetainedGroupHistory, + primaryEthereumTransport, + primaryEthereumTransport.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/cmd/tbtc_signer.go b/cmd/tbtc_signer.go new file mode 100644 index 0000000000..b72c5c39b4 --- /dev/null +++ b/cmd/tbtc_signer.go @@ -0,0 +1,389 @@ +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) + +// 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 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, +) *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/cmd/tbtc_signer_test.go b/cmd/tbtc_signer_test.go new file mode 100644 index 0000000000..ab4a53da99 --- /dev/null +++ b/cmd/tbtc_signer_test.go @@ -0,0 +1,669 @@ +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) + } +} + +// 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, +) { + 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/docs/development/frost-anchor-rotation.adoc b/docs/development/frost-anchor-rotation.adoc new file mode 100644 index 0000000000..1d3c06641b --- /dev/null +++ b/docs/development/frost-anchor-rotation.adoc @@ -0,0 +1,732 @@ += 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 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 + +[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. 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 + 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`) +|=== + +=== 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 +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 + +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 +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` 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 five +attempts this closes to `20 * seats + 6` revisions and `40 * seats + 15` +generations per input. + +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 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 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 + +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. 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"] +|=== +| 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. 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. + +Starting from a fresh rotation (both windows at 4096) and running +full-size sweeps one at a time: + +[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 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". 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 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 + +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 [815] signer generations but only [612] are unreserved +---- ++ +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 +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 [815] signer generations but only [612] +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. + +[[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 +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 +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 +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/v5", + "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), 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 + 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.* 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 + 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` 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..e5726b4a3a --- /dev/null +++ b/docs/development/frost-retained-group-transport-attestation.adoc @@ -0,0 +1,254 @@ += 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 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/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/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/bitcoin/electrum/canonical_transaction_status.go b/pkg/bitcoin/electrum/canonical_transaction_status.go new file mode 100644 index 0000000000..c7597bfb21 --- /dev/null +++ b/pkg/bitcoin/electrum/canonical_transaction_status.go @@ -0,0 +1,332 @@ +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, + ) + } + + // 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(revalidatedSerializedHeader[:]), + }, 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) + matched := false + var matchedHeight uint + 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, + ) + } + 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 { + // 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", + 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..0923526738 --- /dev/null +++ b/pkg/bitcoin/electrum/canonical_transaction_status_test.go @@ -0,0 +1,312 @@ +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 + headers []*bitcoin.BlockHeader + headerCalls int + 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) { + if reader.headerCalls < len(reader.headers) { + header := reader.headers[reader.headerCalls] + reader.headerCalls++ + return header, reader.headerError + } + reader.headerCalls++ + 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) + } + 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) { + 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") + } + }) + + 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) { + 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}, + }}, + } +} 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..414340bb8b 100644 --- a/pkg/chain/ethereum/ethereum.go +++ b/pkg/chain/ethereum/ethereum.go @@ -12,11 +12,11 @@ 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" "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" @@ -34,9 +34,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 - chainID *big.Int + key *keystore.Key + client ethutil.EthereumClient + rpcClient *rpc.Client + rpcLimiter ethereumRPCLimiter + chainID *big.Int + frostPrimaryEthereumRequestTimeout time.Duration blockCounter *ethereum.BlockCounter nonceManager *ethereum.NonceManager @@ -59,6 +62,19 @@ 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 +} + +type ethereumRPCLimiter interface { + AcquirePermit(context.Context) error + ReleasePermit() +} + // Connect creates Random Beacon and TBTC Ethereum chain handles. func Connect( ctx context.Context, @@ -79,7 +95,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 EthereumClient, +) ( + *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 EthereumClient, +) ( + *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( @@ -216,7 +269,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 { @@ -247,6 +300,17 @@ func newBaseChain( } clientWithAddons := wrapClientAddons(config, client) + rpcLimiter, err := sharedEthereumRPCLimiter(config, clientWithAddons) + if err != nil { + return nil, err + } + var frostPrimaryEthereumRequestTimeout time.Duration + if timeoutSource, ok := client.(interface { + FrostPrimaryEthereumRequestTimeout() time.Duration + }); ok { + frostPrimaryEthereumRequestTimeout = + timeoutSource.FrostPrimaryEthereumRequestTimeout() + } blockCounter, err := ethutil.NewBlockCounter(clientWithAddons) if err != nil { @@ -295,14 +359,17 @@ func newBaseChain( } return &baseChain{ - key: key, - client: clientWithAddons, - 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 } @@ -503,18 +570,36 @@ func wrapClientAddons( config.ConcurrencyLimit, ) - return ethutil.WrapRateLimiting( + return wrapEthereumClientWithRPCLimiter( loggingClient, - &rate.LimiterConfig{ - RequestsPerSecondLimit: config.RequestsPerSecondLimit, - ConcurrencyLimit: config.ConcurrencyLimit, - }, + newEthereumRPCLimiter( + config.RequestsPerSecondLimit, + config.ConcurrencyLimit, + ), ) } 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/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/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/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 2a5dac344a..413f923da8 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -86,17 +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 + 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/chain/ethereum/tbtc_frost_historical_deployment_test.go b/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go new file mode 100644 index 0000000000..5959aec2d5 --- /dev/null +++ b/pkg/chain/ethereum/tbtc_frost_historical_deployment_test.go @@ -0,0 +1,310 @@ +package ethereum + +import ( + "context" + "fmt" + "math/big" + "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/ethclient" + "github.com/ethereum/go-ethereum/rpc" + commonethereum "github.com/keep-network/keep-common/pkg/chain/ethereum" + "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) + } + } +} + +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, + } + wrappedClient := wrapClientAddons(config, client) + rpcLimiter, err := sharedEthereumRPCLimiter(config, wrappedClient) + if err != nil { + t.Fatal(err) + } + chain := &baseChain{ + client: wrappedClient, + rpcClient: client.Client(), + rpcLimiter: rpcLimiter, + } + 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}, + reader: evidenceReader, + } + + 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 new file mode 100644 index 0000000000..296a20bcaf --- /dev/null +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization.go @@ -0,0 +1,4593 @@ +package ethereum + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math" + "math/big" + "os" + "reflect" + "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/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/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/v5" + + frostPreSignFinalityAgreementAttempts = 4 + frostPreSignFinalityAgreementRetryDelay = time.Second +) + +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":"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"}]}]} +]` + +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 frostPreSignManifestDeploymentEpoch struct { + Start frostPreSignManifestPoint `json:"start"` + End *frostPreSignManifestPoint `json:"end,omitempty"` + Address string `json:"address"` + RuntimeCodeHash string `json:"runtimeCodeHash"` + 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"` + 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"` + GenesisBlockHash string `json:"genesisBlockHash"` + 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 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"` + 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 { + ProtocolID string `json:"protocolID"` + LiftProtocolID string `json:"liftProtocolID"` + 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"` + 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 { + 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 { + 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 { + 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 + activationAuthorityKeyHash [32]byte +} + +type frostPreSignDeploymentPin struct { + role string + name string + deploymentBlock uint64 + relevantEventStartBlock uint64 + 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 + historicalEpochs []frostPreSignDeploymentEpochPin +} + +type frostPreSignDeploymentEpochPin struct { + start tbtc.FrostPreSignFinality + end *tbtc.FrostPreSignFinality + descriptor frostPreSignDeploymentPin +} + +type frostPreSignLinkedLibraryPin struct { + protocolRole string + address [20]byte + runtimeCodeHash [32]byte + references []frostPreSignManifestLinkReference + linkedLibraryDescriptorHash [32]byte + linkedLibraries []frostPreSignLinkedLibraryPin +} + +type frostPreSignEthereumAdapter struct { + chain *TbtcChain + reader tbtc.FrostPreSignEthereumEvidenceVerifier + fromAddress common.Address + profile tbtc.FrostPreSignActivationProfile + manifest frostPreSignActivationManifest + deployments []frostPreSignDeploymentPin + bridge *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 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 + 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, + 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, + ) + 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") + } + 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 +} + +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") + } + 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) { + 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 + copy(manifest.activationAuthorityPublicKey[:], publicKey) + manifest.activationAuthorityKeyHash = trustedKeyHash + 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", + ) + } + 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 +} + +type frostPreSignCanonicalHashReader struct { + standardReader frostPreSignStandardEthereumReader + headerReader interface { + HeaderByHash(context.Context, common.Hash) (*types.Header, error) + } + rpcClient *rpc.Client + chainID *big.Int + requestTimeout time.Duration + rpcLimiter ethereumRPCLimiter +} + +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") + } + ctx, cancel := reader.requestContext(ctx) + defer cancel() + return reader.standardReader.HeaderByNumber(ctx, number) +} + +func (reader *frostPreSignCanonicalHashReader) HeaderByHash( + ctx context.Context, + blockHash common.Hash, +) (*types.Header, error) { + if reader.standardReader == nil { + 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) +} + +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") + } + ctx, cancel := reader.requestContext(ctx) + defer cancel() + 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") + } + ctx, cancel := reader.requestContext(ctx) + defer cancel() + return reader.standardReader.FilterLogs(ctx, query) +} + +func (reader *frostPreSignCanonicalHashReader) CodeAtHash( + ctx context.Context, + account common.Address, + blockHash common.Hash, +) ([]byte, error) { + ctx, cancel := reader.requestContext(ctx) + defer cancel() + var result hexutil.Bytes + err := reader.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) { + ctx, cancel := reader.requestContext(ctx) + defer cancel() + var result hexutil.Bytes + err := reader.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) { + ctx, cancel := reader.requestContext(ctx) + defer cancel() + var result hexutil.Bytes + err := reader.callContext( + ctx, + &result, + "eth_call", + frostPreSignCallArgument(message), + rpc.BlockNumberOrHashWithHash(blockHash, true), + ) + 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(ctx); 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 (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, + "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, +) { + 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 ethereumRPCLimiter, +) (tbtc.FrostPreSignEthereumEvidenceVerifier, error) { + standardReader, ok := client.(frostPreSignStandardEthereumReader) + if !ok { + return nil, fmt.Errorf( + "Ethereum client does not expose required canonical evidence reads", + ) + } + if rpcClient == nil || chainID == nil || chainID.Sign() <= 0 { + return nil, fmt.Errorf("Ethereum client does not expose canonical EIP-1898 reads") + } + return &frostPreSignCanonicalHashReader{ + standardReader: standardReader, + headerReader: standardReader, + rpcClient: rpcClient, + chainID: new(big.Int).Set(chainID), + requestTimeout: requestTimeout, + rpcLimiter: rpcLimiter, + }, 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") + } + 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) + } + 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, + "quarantine journal protocol": frost.QuarantineJournal.ProtocolID, + "quarantine lift protocol": frost.QuarantineJournal.LiftProtocolID, + "quarantine tombstone protocol": frost.QuarantineJournal.TombstoneProtocolID, + } { + parsed, err := frostPreSignParseBytes32(value) + if err != nil || parsed == [32]byte{} { + return fmt.Errorf("invalid FROST activation %s", name) + } + } + durableSessionStoreFingerprint, err := frostPreSignParseBytes32( + frost.DurableSessionStoreFingerprint, + ) + 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) == "" || + 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 + } + 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, + "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) + } + } + 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 != 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 { + 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") + } + 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") + } + 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 err := frostsigning.ValidateNativeTBTCSignerStateWitnessGeometry( + anchor.WitnessMaximumRecords, + anchor.WitnessRotationThresholdRecords, + ); err != nil { + return result, fmt.Errorf( + "FROST native signer witness geometry is invalid: %w", + err, + ) + } + result.Identity = identity + result.WitnessMaximumRecords = anchor.WitnessMaximumRecords + result.WitnessRotationThresholdRecords = + anchor.WitnessRotationThresholdRecords + 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 { + 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, + "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{} { + 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" + } + 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, + 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, + manifest *frostPreSignActivationManifest, + reader tbtc.FrostPreSignEthereumEvidenceVerifier, + enableRelay bool, +) (*frostPreSignEthereumAdapter, error) { + 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 { + 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") + } + 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 := 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, reader) + if err != nil { + return nil, err + } + adapter := &frostPreSignEthereumAdapter{ + chain: tc, + reader: reader, + fromAddress: tc.key.Address, + profile: profile, + manifest: *manifest, + deployments: deployments, + } + 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) + } + 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, 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 + 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 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), + 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/v2", + "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-v2\x00")) + for _, deployment := range deployments { + 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") { + 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.IsUint64() || header.Number.Sign() <= 0 || + header.Hash() == (common.Hash{}) { + 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 + } + 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 := exactReader.CodeAtHash( + ctx, + common.Address(expected.address), + blockHash, + ) + 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 := exactReader.StorageAtHash( + ctx, + common.Address(expected.address), + implementationSlot, + blockHash, + ) + if err != nil || len(implementationValue) != 32 { + return fmt.Errorf("cannot read %s EIP-1967 implementation slot: [%w]", expected.name, err) + } + adminValue, err := exactReader.StorageAtHash( + ctx, + common.Address(expected.address), + adminSlot, + blockHash, + ) + 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 := exactReader.CodeAtHash( + ctx, + common.Address(expected.implementationAddress), + 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 := exactReader.CodeAtHash( + ctx, + common.Address(expected.adminAddress), + blockHash, + ) + 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, + blockHash, + exactReader, + ); err != nil { + return err + } + } + + 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.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.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) + } + for method, expected := range map[string][32]byte{ + "evidenceProtocolID": profile.EvidenceProtocolID, + "preauthorizationProtocolID": profile.ReservationProtocolID, + "signingPolicyHash": profile.SigningPolicyHash, + } { + 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.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) + } + 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") + } + + sortitionPool, err := adapter.callAddressAtHash( + ctx, + common.Address(profile.FrostRegistry), + frostPreSignCrosslinkABI, + "sortitionPool", + blockHash, + ) + if err != nil || sortitionPool != common.Address(profile.SortitionPool) { + return fmt.Errorf("FROST registry sortition pool crosslink mismatch: [%w]", err) + } + validatorBridge, err := adapter.callAddressAtHash( + ctx, + common.Address(profile.ProposalValidator), + frostPreSignCrosslinkABI, + "bridge", + blockHash, + ) + if err != nil || validatorBridge != common.Address(profile.BridgeAddress) { + return fmt.Errorf("proposal validator Bridge crosslink mismatch: [%w]", err) + } + // 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( + ctx context.Context, + owner string, + ownerCode []byte, + libraries []frostPreSignLinkedLibraryPin, + blockHash common.Hash, + reader frostPreSignExactHashReader, +) 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 := reader.CodeAtHash( + ctx, + common.Address(library.address), + blockHash, + ) + 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, + blockHash, + reader, + ); 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") + } + before, err := frostPreSignCurrentFinality(ctx, adapter.reader) + 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") + } + 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.reader.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") + } + after, err := frostPreSignCurrentFinality(ctx, adapter.reader) + 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.reader.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") + } + 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) callAtHash( + ctx context.Context, + address common.Address, + contractABI abi.ABI, + method string, + blockHash common.Hash, + parameters ...interface{}, +) ([]interface{}, error) { + 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.fromAddress, + To: &address, + Data: callData, + }, + blockHash, + ) + 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) callAddressAtHash( + ctx context.Context, + address common.Address, + contractABI abi.ABI, + method string, + blockHash common.Hash, + parameters ...interface{}, +) (common.Address, error) { + 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) callBytes32AtHash( + ctx context.Context, + address common.Address, + contractABI abi.ABI, + method string, + blockHash common.Hash, + parameters ...interface{}, +) ([32]byte, error) { + result, err := adapter.callAtHash( + ctx, + address, + contractABI, + method, + blockHash, + 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, verifier, err := tc.frostPreSignAdapterPair() + if err != nil { + return nil, err + } + 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, verifier, err := tc.frostPreSignAdapterPair() + if err != nil { + return err + } + 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() ( + 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 + } + 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 + } + endpointIdentitySetHash, err := frostPreSignEndpointIdentitySetHash( + adapter.manifest, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + quarantine := frost.QuarantineJournal + quarantineJournalProtocolID, err := parse(quarantine.ProtocolID) + 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, + } + } + checkpointPredecessorHash, err := parse( + quarantine.CheckpointPredecessorHash, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + 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 { + 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 + } + sourceIdentity, err := frostPreSignRetainedSourceIdentity( + journal.SourceIdentity, + ) + 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 + } + nativeSignerAnchor, err := frostPreSignNativeSignerAnchorManifest( + &adapter.manifest, + ) + if err != nil { + return tbtc.FrostPreSignActivationRuntimeManifest{}, err + } + 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, + ImplementationSetHash: adapter.profile.ImplementationSetHash, + LinkedLibraryDescriptorSetHash: linkedLibraryDescriptorSetHash, + EndpointIdentitySetHash: endpointIdentitySetHash, + Deployments: frostPreSignRuntimeDeploymentEvidence(adapter.deployments), + 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, + NativeSignerAnchor: nativeSignerAnchor, + ActivationAuthorityPublicKey: adapter.manifest.activationAuthorityPublicKey, + 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, + SourceIdentity: sourceIdentity, + MinimumGeneration: journal.MinimumGeneration, + }, + QuarantineJournal: tbtc.FrostRetainedGroupQuarantineJournalManifest{ + ProtocolID: quarantineJournalProtocolID, + LiftProtocolID: quarantineLiftProtocolID, + TombstoneProtocolID: quarantineTombstoneProtocolID, + CheckpointAuthorityThreshold: quarantine.CheckpointAuthorityThreshold, + CheckpointAuthorities: checkpointAuthorities, + CheckpointMinimumSequence: quarantine.CheckpointMinimumSequence, + CheckpointPredecessorHash: checkpointPredecessorHash, + LiftAuthorityThreshold: quarantine.LiftAuthorityThreshold, + LiftAuthorities: liftAuthorities, + StoreID: quarantine.StoreID, + StoreFingerprint: quarantineStoreFingerprint, + ClusterFingerprint: quarantineClusterFingerprint, + MinimumGeneration: quarantine.MinimumGeneration, + }, + }, nil +} + +func frostPreSignEndpointIdentitySetHash( + manifest frostPreSignActivationManifest, +) ([32]byte, error) { + ethereum := manifest.Ethereum + frost := manifest.FrostSigner + type identity struct { + role string + trustDomainID string + endpointFingerprint string + tlsLeafSPKIHash string + operatorFingerprint string + backendFingerprint string + attestationKeyHash string + historySignerKeyHash 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-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", + trustDomainID: frost.TrustDomainID, + endpointFingerprint: frost.HandshakeEndpointFingerprint, + operatorFingerprint: frost.HandshakeOperatorFingerprint, + }, + } + hasher := sha256.New() + hasher.Write([]byte("tbtc-frost-endpoint-identity-set-v3\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[:]) + 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)) + } 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 { + 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, + 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.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 + } + blockHash := common.Hash(finality.BlockHash) + members, err := adapter.resolveWalletMembersAt(ctx, walletOperators, blockHash) + 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.callAtHash( + ctx, + common.Address(adapter.profile.BridgeAddress), + frostPreSignBridgeABI, + "previewP2TRTransactionAuthorization", + blockHash, + 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, + blockHash, + ) + 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") + } + + 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, + 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 (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) { + 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") + } + if attempts <= 0 || retryDelay < 0 { + return nil, fmt.Errorf("FROST Ethereum finality retry policy is invalid") + } + + var primaryFinality *tbtc.FrostPreSignFinality + var verifiedFinality *tbtc.FrostPreSignFinality + for attempt := 0; attempt < attempts; attempt++ { + var err error + 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 { + 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, + ); 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, + blockHash common.Hash, +) ([]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) + } + operatorAddress := common.HexToAddress(operator.String()) + callResult, err := adapter.callAtHash( + ctx, + common.Address(adapter.profile.SortitionPool), + frostPreSignCrosslinkABI, + "getOperatorID", + blockHash, + operatorAddress, + ) + 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) + } + 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, + blockHash common.Hash, +) ([][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.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) + } + 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 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") + } + 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, verifier, err := tc.frostPreSignAdapterPair() + if err != nil { + return nil, err + } + 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( + 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() + 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 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, err + } + 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. + } + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + } + } +} + +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) + var authorizedLog, advancedLog *types.Log + for _, logEntry := range receipt.Logs { + 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 { + 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") + } + 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, verifier, err := tc.frostPreSignAdapterPair() + if err != nil { + return nil, err + } + return frostPreSignMatchingCurrentFinality(ctx, adapter, verifier) +} + +func (tc *TbtcChain) ReadFrostPreSignAuthorizationState( + ctx context.Context, + proposal *tbtc.FrostPreSignAuthorizationProposal, + finality tbtc.FrostPreSignFinality, +) (*tbtc.FrostPreSignAuthorizationState, error) { + adapter, verifier, err := tc.frostPreSignAdapterPair() + if err != nil { + return nil, err + } + primaryState, err := adapter.readAuthorizationState( + ctx, + proposal, + finality, + ) + if err != nil { + return nil, err + } + 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( + 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 + } + blockHash := common.Hash(finality.BlockHash) + + lifecycle, err := adapter.callAtHash( + ctx, + common.Address(adapter.profile.BridgeAddress), + frostPreSignBridgeABI, + "frostLifecycleContext", + blockHash, + 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.callAtHash( + ctx, + common.Address(adapter.profile.BridgeAddress), + frostPreSignBridgeABI, + "wallets", + blockHash, + 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) + + frostWalletResult, err := adapter.callAtHash( + ctx, + common.Address(adapter.profile.FrostRegistry), + frostPreSignCrosslinkABI, + "getWallet", + blockHash, + 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.callBytes32AtHash( + ctx, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, + "activeReservation", + blockHash, + proposal.Transaction.WalletPublicKeyHash, + ) + if err != nil { + return nil, fmt.Errorf("cannot read finalized active reservation: [%w]", err) + } + reservation, err := adapter.callAtHash( + ctx, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, + "getReservation", + blockHash, + proposal.ReservationID, + ) + if err != nil || len(reservation) != 11 { + return nil, fmt.Errorf("cannot read finalized reservation: [%w]", err) + } + variant, err := adapter.callAtHash( + ctx, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, + "getAuthorizedVariantStatus", + 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.callAtHash( + ctx, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, + "latestAuthorizedVariant", + 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, + 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, 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 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{} || + current == nil { + 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 + } + if err := adapter.validateHistoricalBroadcastEvent(ctx, request); err != nil { + return nil, err + } + canonical, err := adapter.validateBroadcastAuthorizationAt( + ctx, + request, + 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, + Canonical: false, + }, nil + } + + 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, + 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, + 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.reader.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 || + 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 +} + +func (adapter *frostPreSignEthereumAdapter) validateBroadcastAuthorizationAt( + ctx context.Context, + request *tbtc.FrostBitcoinBroadcastAuthorizationStatusRequest, + blockHash common.Hash, + requirePlan bool, +) (bool, error) { + reservation, err := adapter.callAtHash( + ctx, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, + "getReservation", + blockHash, + request.ReservationID, + ) + if err != nil || len(reservation) != 11 { + return false, fmt.Errorf("cannot read canonical broadcast reservation: [%w]", err) + } + variant, err := adapter.callAtHash( + ctx, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, + "getAuthorizedVariantStatus", + 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.callAtHash( + ctx, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, + "latestAuthorizedVariant", + blockHash, + 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.callBytes32AtHash( + ctx, + common.Address(adapter.profile.RegistryAddress), + frostPreSignRegistryABI, + "preAuthorizationDigest", + blockHash, + 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/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..9162d751f6 --- /dev/null +++ b/pkg/chain/ethereum/tbtc_frost_pre_sign_authorization_verifier_test.go @@ -0,0 +1,750 @@ +package ethereum + +import ( + "context" + "errors" + "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 + finalizedSequence []*types.Header + finalizedCall int + headers map[uint64]*types.Header + receipts []*types.Receipt + receiptCall int +} + +type testBlockingEthereumRPCLimiter struct { + acquisitionStarted chan struct{} + acquisitionStopped chan struct{} + permitReleased chan struct{} +} + +func (limiter *testBlockingEthereumRPCLimiter) AcquirePermit( + ctx context.Context, +) error { + close(limiter.acquisitionStarted) + defer close(limiter.acquisitionStopped) + <-ctx.Done() + return ctx.Err() +} + +func (limiter *testBlockingEthereumRPCLimiter) ReleasePermit() { + close(limiter.permitReleased) +} + +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 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{}), + acquisitionStopped: 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") + } + + select { + case <-limiter.acquisitionStopped: + case <-time.After(time.Second): + 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, + ) + } + }) + } +} + +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(context.Background()); 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) { + 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 { + 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 { + 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 := frostPreSignMatchingCurrentFinalityWithRetry( + context.Background(), + chain.frostPreSignAuthorizationAdapter, + chain.frostPreSignAuthorizationVerifier, + 1, + 0, + ); 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 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, +) { + 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/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..2eedb2bc4a --- /dev/null +++ b/pkg/chain/ethereum/tbtc_frost_retained_group_manifest_test.go @@ -0,0 +1,500 @@ +package ethereum + +import ( + "fmt" + "strings" + "testing" + + "github.com/keep-network/keep-core/pkg/tbtc" +) + +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, + ActivationID: testManifestHex32(0x01), + Environment: "test", + manifestHash: [32]byte{0x99}, + activationAuthorityKeyHash: [32]byte{0x40}, + Ethereum: frostPreSignManifestEthereum{ + ChainID: 1, + GenesisBlockHash: testManifestHex32(0x30), + 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: fmt.Sprintf("0x%x", sourceIdentity.EndpointFingerprint), + SourceOperatorFingerprint: fmt.Sprintf("0x%x", sourceIdentity.OperatorFingerprint), + SourceIdentity: wireSourceIdentity, + MinimumGeneration: 7, + }, + QuarantineJournal: frostPreSignManifestQuarantineJournal{ + 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)}, + }, + CheckpointMinimumSequence: 1, + CheckpointPredecessorHash: testManifestHex32(0x00), + 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), + 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), + 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) { + 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(), "differs from its aggregate") { + 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) + } + }) + 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) + // 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) + } + }) + 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) + } + }) + 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( + 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("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 = + 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) + } + }) + 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) { + 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/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/distributed_dkg_orchestration_frost_native.go b/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go index 1e483bfe1b..9f32a665ff 100644 --- a/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go +++ b/pkg/frost/signing/distributed_dkg_orchestration_frost_native.go @@ -41,6 +41,20 @@ 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 +} + +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 @@ -141,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 @@ -174,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 @@ -194,6 +210,11 @@ 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 know every key group this run left + // behind. + persistBySeat[outcome.member] = outcome.persist if keyGroup == "" { keyGroup = outcome.persist.KeyGroup } else if outcome.persist.KeyGroup != keyGroup { @@ -205,10 +226,17 @@ func RunDistributedDKGForSeats( } continue } - persistBySeat[outcome.member] = outcome.persist } if firstErr != nil { - return nil, firstErr + // 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 } return persistBySeat, nil 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/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_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/native_ffi_primitive_transitional_frost_native.go b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go index 53948f06c5..68b55725de 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" @@ -52,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", @@ -67,6 +66,7 @@ func installConfiguredTBTCSignerInitConfig() error { ) return err } + defer zeroBytes(configJSON) result, err := InstallNativeTBTCSignerConfig(configJSON) if err != nil { @@ -83,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], "+ @@ -328,50 +344,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_frost_engine_tbtc_signer_registration_frost_native.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go index 5c4bfbc598..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 @@ -79,6 +83,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) { @@ -162,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, @@ -282,7 +300,30 @@ static TbtcSignerResult tbtc_signer_init_signer_config(const uint8_t* request_pt return init_signer_config(request_ptr, request_len); } -static void tbtc_signer_free_buffer(uint8_t* ptr, size_t 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 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" @@ -316,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"` @@ -387,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"` @@ -616,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, @@ -998,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) { @@ -1606,6 +1722,9 @@ func decodeBuildTaggedTBTCSignerBuildTaprootTxResponse( } func callBuildTaggedTBTCSignerVersion() ([]byte, error) { + if err := ensureTBTCSignerFreeBufferAvailable(); err != nil { + return nil, err + } result := C.tbtc_signer_version() return parseBuildTaggedTBTCSignerResult("Version", result) } @@ -1616,6 +1735,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) } @@ -1668,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) { @@ -1724,19 +1861,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( @@ -1749,7 +1912,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) @@ -1813,13 +1979,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 @@ -1850,6 +2034,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_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..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" @@ -1221,6 +1269,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_abi_version.go b/pkg/frost/signing/native_tbtc_signer_abi_version.go index ceac7e2252..dbedef4773 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version.go @@ -19,14 +19,29 @@ 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, including its independent - // policy-sample minimum, and pins this bridge to the complete - // durability/rollout-assurance signer stack. - requiredTBTCSignerABIMinMinor uint32 = 3 + // + // 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 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. + // + // 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. + // + // 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. + // + // 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 39c48a12a4..fc43e22c3c 100644 --- a/pkg/frost/signing/native_tbtc_signer_abi_version_test.go +++ b/pkg/frost/signing/native_tbtc_signer_abi_version_test.go @@ -89,13 +89,15 @@ 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 - // major is not. A regression here means the required constants drifted from - // what the bridge actually speaks. - if requiredTBTCSignerABIMajor != 3 || requiredTBTCSignerABIMinMinor != 3 { + // 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. + // Minor 3 adds the trust transition/head and bootstrap-facts surface used + // 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, @@ -105,6 +107,18 @@ 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); 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, 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_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..5c8dc26106 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_anchor_real_cgo_frost_native_test.go @@ -0,0 +1,412 @@ +//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 +} + +// 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( + _ 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 + } + trustHead := realCgoTestSignerAnchorTrustHead(tip, publicKeySPKIHash) + if err := InstallNativeTBTCSignerStateAnchorBarrier( + NativeTBTCSignerStateAnchorBarrierConfig{ + 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) + } +} + +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_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 725c2942d3..c0ab47fe77 100644 --- a/pkg/frost/signing/native_tbtc_signer_init_config.go +++ b/pkg/frost/signing/native_tbtc_signer_init_config.go @@ -1,5 +1,12 @@ package signing +import ( + "crypto/sha256" + "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 @@ -21,6 +28,8 @@ package signing // (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 { @@ -29,3 +38,246 @@ 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 { + 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 +} + +var nativeTBTCSignerInstalledStateAnchorConfig struct { + sync.RWMutex + value *NativeTBTCSignerInstalledStateAnchorConfig +} + +func recordNativeTBTCSignerInstalledStateAnchorConfig( + configJSON []byte, + 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) + } + 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.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, + ) + 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) + } + 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 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{} { + 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, + 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, + } + 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 +} + +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 +// 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..5dd52bb7a2 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_init_config_test.go @@ -0,0 +1,180 @@ +package signing + +import ( + "encoding/hex" + "fmt" + "strings" + "testing" +) + +func TestRecordNativeTBTCSignerInstalledStateAnchorConfig(t *testing.T) { + resetInstalledNativeTBTCSignerStateAnchorConfigForTest() + t.Cleanup(resetInstalledNativeTBTCSignerStateAnchorConfigForTest) + + config := testNativeTBTCSignerInstalledAnchorConfig(1024) + 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.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" { + 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") + } + + valid := testNativeTBTCSignerInstalledAnchorConfig(1024) + 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 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() + nativeTBTCSignerInstalledStateAnchorConfig.value = nil +} 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..52acc8f2e5 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_inventory.go @@ -0,0 +1,598 @@ +package signing + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "hash" + "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" + nativeTBTCSignerStateWitnessGenesisDomain = "tbtc-signer-state-witness-genesis-v2\x00" + nativeTBTCSignerStateWitnessCommitmentDomain = "tbtc-signer-state-witness-commitment-v2\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 && 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", + ) + } + 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(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 + 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 +} + +// 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( + 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 { + 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 { + 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 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[:]) +} + +func writeNativeTBTCSignerUint16(destination hash.Hash, value uint16) { + var encoded [2]byte + binary.BigEndian.PutUint16(encoded[:], value) + _, _ = destination.Write(encoded[:]) +} + +func writeNativeTBTCSignerUint32(destination hash.Hash, value uint32) { + var encoded [4]byte + binary.BigEndian.PutUint32(encoded[:], value) + _, _ = destination.Write(encoded[:]) +} + +func writeNativeTBTCSignerUint64(destination hash.Hash, 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..4360ead5e9 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_inventory_cgo.go @@ -0,0 +1,531 @@ +//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_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 +); +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 +); +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_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_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_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 +) { + 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 +) { + 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 +) { + 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 ( + "errors" + "fmt" + "math" + "unsafe" +) + +const ( + nativeTBTCSignerStateAnchorTrustHeadAbsentCode = "state_anchor_trust_head_absent" + nativeTBTCSignerStateAnchorTrustRecoveryRequiredCode = "state_anchor_trust_recovery_required" +) + +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 +} + +// 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 +} + +// 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 +} + +// 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) { + 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 +// 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) { + 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..8b3b4441e6 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_inventory_default.go @@ -0,0 +1,81 @@ +//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 ReadNativeTBTCSignerStateWitnessTip() ( + *NativeTBTCSignerStateWitnessTip, + error, +) { + return nil, fmt.Errorf( + "%w: tbtc-signer bridge operation [StateWitnessTip] is unavailable in this build", + ErrNativeCryptographyUnavailable, + ) +} + +func ReadNativeTBTCSignerStateAnchorTrustHead() ( + *NativeTBTCSignerStateAnchorTrustHead, + error, +) { + return nil, fmt.Errorf( + "%w: tbtc-signer bridge operation [StateAnchorTrustHead] is unavailable in this build", + ErrNativeCryptographyUnavailable, + ) +} + +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) { + return nil, fmt.Errorf( + "%w: tbtc-signer bridge operation [TransitionStateWitnessAnchor] 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) { + 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..bfd09bfcd1 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_inventory_test.go @@ -0,0 +1,317 @@ +package signing + +import ( + "crypto/sha256" + "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 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, +) { + 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/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..6d0b1b67f6 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_readback_real_cgo_frost_native_test.go @@ -0,0 +1,103 @@ +//go:build frost_native && frost_tbtc_signer && cgo + +package signing + +import ( + "bytes" + "fmt" + "testing" +) + +// 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) { + 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) + } + + 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/frost/signing/native_tbtc_signer_secure_config.go b/pkg/frost/signing/native_tbtc_signer_secure_config.go new file mode 100644 index 0000000000..553857306e --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_secure_config.go @@ -0,0 +1,173 @@ +package signing + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" + + "golang.org/x/sys/unix" +) + +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 +// 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_NONBLOCK|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 +} + +// 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_secure_config_test.go b/pkg/frost/signing/native_tbtc_signer_secure_config_test.go new file mode 100644 index 0000000000..c134109a4f --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_secure_config_test.go @@ -0,0 +1,86 @@ +package signing + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/unix" +) + +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("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( + []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..931ed9069f --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier.go @@ -0,0 +1,879 @@ +package signing + +import ( + "context" + "errors" + "fmt" + stdnet "net" + "os" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/ipfs/go-log/v2" +) + +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", + ) +) + +// 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 + + // 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 + // 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 +// 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 + MaximumAnchorRevisionDistance uint64 + MaximumStateGenerationDistance uint64 + MaximumStateGenerationAdvancePerOperation uint64 + ExpectedTrustHead *NativeTBTCSignerStateAnchorTrustHead + ReadTip func() (*NativeTBTCSignerStateWitnessTip, error) + ReadTrustHead func() (*NativeTBTCSignerStateAnchorTrustHead, error) + Committer NativeTBTCSignerStateAnchorCommitter + 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 + 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 + +// 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.ReadTrustHead == nil || config.ExpectedTrustHead == nil || + config.Committer == nil || + config.ExpectedAnchorBindingHash == [32]byte{} || + 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 + 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", + ) + } + 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 + } + 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") + } + 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( + 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.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 +} + +// 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"), + ) + } + 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( + 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), + ) + } + + 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.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 + 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 { + // 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, + )) + } + 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) + } + recordNativeTBTCSignerStateAnchorPoisoning(lease.barrier, 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 { + recordNativeTBTCSignerStateAnchorPoisoning(lease.barrier, 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 { + 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 { + 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.poisonedSignal.Store(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 new file mode 100644 index 0000000000..7ea160b99c --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_barrier_test.go @@ -0,0 +1,1568 @@ +package signing + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "strings" + "sync" + "sync/atomic" + "syscall" + "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 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, +) 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, + 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) + } + + 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, + 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) + } + 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, + 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) + } + + 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, + 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) + } + + 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, + 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) + } + 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, + 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) + } + + 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, + MaximumAnchorRevisionDistance: 4096, + MaximumStateGenerationDistance: NativeTBTCSignerStateAnchorMaximumGenerationDistance, + MaximumStateGenerationAdvancePerOperation: NativeTBTCSignerStateAnchorMaximumGenerationAdvancePerOperation, + ExpectedTrustHead: testNativeTBTCSignerStateAnchorTrustHead(), + ReadTip: func() (*NativeTBTCSignerStateWitnessTip, error) { + copy := postMigration + return ©, nil + }, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + 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, + 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 + }, + ReadTrustHead: readTestNativeTBTCSignerStateAnchorTrustHead, + Committer: &testNativeTBTCSignerStateAnchorCommitter{}, + }, + ) + if err == nil { + 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, + ) + } +} + +// 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, +) { + 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, +) { + 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, + ) + } +} + +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") + } + }) + } +} + +// 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, +) 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_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/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/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/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..901cc1aafc --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust.go @@ -0,0 +1,603 @@ +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 + + // 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 + // 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. + 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 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, +) error { + if maximumRecords == 0 || + maximumRecords > NativeTBTCSignerStateWitnessHardMaximumRecords { + return fmt.Errorf( + "witnessMaximumRecords [%d] is outside [1,%d]", + maximumRecords, + NativeTBTCSignerStateWitnessHardMaximumRecords, + ) + } + reserved := rotationThresholdRecords + + NativeTBTCSignerStateWitnessRotationTerminalRecordReservation + + NativeTBTCSignerStateWitnessQuarantineRecordReservation + if rotationThresholdRecords < + NativeTBTCSignerStateWitnessMinimumRotationThresholdRecords || + reserved < rotationThresholdRecords || + reserved > maximumRecords { + return fmt.Errorf( + "witnessRotationThresholdRecords [%d] must be at least %d and "+ + "reserve %d records below witnessMaximumRecords [%d]", + rotationThresholdRecords, + NativeTBTCSignerStateWitnessMinimumRotationThresholdRecords, + NativeTBTCSignerStateWitnessRotationTerminalRecordReservation+ + NativeTBTCSignerStateWitnessQuarantineRecordReservation, + maximumRecords, + ) + } + return nil +} + +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 { + 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 +} + +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..3ae9bee005 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_cgo_test.go @@ -0,0 +1,138 @@ +//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) + } + } +} + +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 new file mode 100644 index 0000000000..cbe96e87cd --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_state_anchor_trust_test.go @@ -0,0 +1,417 @@ +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 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, +) 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:]) +} + +// 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 plus +// TBTC_SIGNER_STATE_WITNESS_QUARANTINE_RECORD_RESERVATION records below the +// 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( + "terminal-record reservation [%d] no longer mirrors the signer's six", + 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 + rotationThresholdRecords uint64 + valid bool + }{ + "production geometry": { + maximumRecords: 4096, + rotationThresholdRecords: 1024, + valid: true, + }, + "smallest geometry the signer accepts": { + maximumRecords: 10, + rotationThresholdRecords: 2, + valid: true, + }, + "threshold exactly at the reserved band": { + maximumRecords: 1024, + rotationThresholdRecords: 1016, + valid: true, + }, + "threshold one record inside the reserved band": { + maximumRecords: 1024, + 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": { + maximumRecords: 64, + rotationThresholdRecords: 60, + valid: false, + }, + "maximum one record below the reserved band": { + maximumRecords: 9, + 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/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/native_tbtc_signer_store_identity.go b/pkg/frost/signing/native_tbtc_signer_store_identity.go new file mode 100644 index 0000000000..1d6b8be4a0 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_store_identity.go @@ -0,0 +1,210 @@ +package signing + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "hash" + "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/v2" + + nativeTBTCSignerDurableStoreIdentityDomain = "tbtc-signer-durable-session-store-fingerprint-v2\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 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 + 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. +// 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) { + 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[:]) + + var result [32]byte + copy(result[:], digest.Sum(nil)) + return result, nil +} + +func writeNativeTBTCSignerStoreFingerprintField( + destination hash.Hash, + value []byte, +) { + var length [4]byte + binary.BigEndian.PutUint32(length[:], uint32(len(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/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..808121ae51 --- /dev/null +++ b/pkg/frost/signing/native_tbtc_signer_store_identity_test.go @@ -0,0 +1,225 @@ +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 store ID": func(identity *NativeTBTCSignerDurableStoreIdentity) { + identity.StoreID[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 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, +) { + 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 repeatedNativeTBTCSignerBytes32(value byte) [32]byte { + var result [32]byte + for index := range result { + result[index] = value + } + return result +} + +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/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_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_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_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_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/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_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/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..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 @@ -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,25 @@ func aggregateInteractiveOnce( aggregate func() ([]byte, error), ) ([]byte, error) { 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{} + 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 +160,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..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 @@ -124,3 +124,197 @@ 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, + ) + } +} + +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") + } +} 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() 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/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/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()) +} diff --git a/pkg/tbtc/bitcoin_broadcast_outbox.go b/pkg/tbtc/bitcoin_broadcast_outbox.go new file mode 100644 index 0000000000..4de0e27e63 --- /dev/null +++ b/pkg/tbtc/bitcoin_broadcast_outbox.go @@ -0,0 +1,1959 @@ +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/sync/semaphore" + "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 + + replaySemaphore *semaphore.Weighted + closeMutex sync.Mutex + mutex sync.Mutex + records map[bitcoin.Hash]*bitcoinBroadcastOutboxRecord + lockFile *os.File + closing bool + 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, + replaySemaphore: semaphore.NewWeighted(1), + 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_NONBLOCK|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.closeMutex.Lock() + defer bbo.closeMutex.Unlock() + + bbo.mutex.Lock() + 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 + } + 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.closing || 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 { + 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() || + !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 { + 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.closing || 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.closing || bbo.closed { + 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.closing || 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 + 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 +} + +// replayOnce is the test/internal synchronous entry point. +func (bbo *bitcoinBroadcastOutbox) replayOnce() error { + return bbo.replayOnceWithContext(context.Background()) +} + +type bitcoinBroadcastReplayCandidate struct { + reservationID [32]byte + primary *bitcoinBroadcastOutboxRecord + alternatives []*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 the latest or previously confirmed record first. +// 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 + } + defer bbo.replaySemaphore.Release(1) + + active, archived, err := bbo.replayCandidates() + if err != nil { + return err + } + replayErrors := &bitcoinBroadcastReplayErrors{} +candidateLoop: + for _, candidate := range append(active, archived...) { + refreshed, err := bbo.refreshConfirmation(ctx, candidate.primary) + if err != nil { + replayErrors.failures = append( + replayErrors.failures, + bitcoinBroadcastReplayFailure{candidate.reservationID, err}, + ) + continue + } + 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) + if err != nil { + 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 + } + replayErrors.failures = append( + replayErrors.failures, + bitcoinBroadcastReplayFailure{candidate.reservationID, err}, + ) + 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 { + replayErrors.failures = append( + replayErrors.failures, + bitcoinBroadcastReplayFailure{candidate.reservationID, err}, + ) + } + } + if len(replayErrors.failures) > 0 { + return replayErrors + } + 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 + records []*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 + } + state.records = append(state.records, record) + 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 { + primary := state.latest + if state.confirmed != nil { + primary = state.confirmed + } + alternatives := make( + []*bitcoinBroadcastOutboxRecord, + 0, + len(state.records)-1, + ) + for _, record := range state.records { + if record.TransactionHash == primary.TransactionHash { + continue + } + alternatives = append( + alternatives, + cloneBitcoinBroadcastOutboxRecord(record), + ) + } + sort.Slice(alternatives, func(i, j int) bool { + return laterBitcoinBroadcastVariant( + alternatives[j], + alternatives[i], + ) + }) + candidate := bitcoinBroadcastReplayCandidate{ + reservationID: reservationID, + primary: cloneBitcoinBroadcastOutboxRecord(primary), + alternatives: alternatives, + } + if primary.Confirmation != nil && + primary.Confirmation.Canonical && + primary.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() + attemptedAt := maxBitcoinBroadcastTimestamp( + next.UpdatedAtUnix, + now, + ) + attemptedAt = maxBitcoinBroadcastTimestamp( + attemptedAt, + next.FirstBroadcastAtUnix, + ) + attemptedAt = maxBitcoinBroadcastTimestamp( + attemptedAt, + next.LastAttemptUnix, + ) + if next.FirstBroadcastAtUnix == 0 { + next.FirstBroadcastAtUnix = attemptedAt + } + next.BroadcastAttempts++ + 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) + } + return broadcastErr, cloneBitcoinBroadcastOutboxRecord(next), nil +} + +func (bbo *bitcoinBroadcastOutbox) broadcastTransaction( + ctx context.Context, + transactionHash bitcoin.Hash, +) error { + if err := bbo.acquireReplaySemaphore(ctx); err != nil { + return err + } + defer bbo.replaySemaphore.Release(1) + + 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) 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") + } + 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 +} + +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..057aaf6250 --- /dev/null +++ b/pkg/tbtc/bitcoin_broadcast_outbox_test.go @@ -0,0 +1,1946 @@ +package tbtc + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "golang.org/x/sys/unix" +) + +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_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() + 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_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( + 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_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_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, +) { + 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_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_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, +) { + 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, +) { + 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_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, +) { + 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 + broadcastErrs map[bitcoin.Hash]error + broadcasts map[bitcoin.Hash]uint + statusCalls uint +} + +type blockingOutboxTestBitcoinChain struct { + *outboxTestBitcoinChain + statusStarted chan struct{} + statusRelease <-chan 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) { + botbc.statusOnce.Do(func() { + close(botbc.statusStarted) + }) + <-botbc.statusRelease + return botbc.outboxTestBitcoinChain.GetCanonicalTransactionStatus(hash) +} + +type outboxTestAuthorizationStatusSource struct { + mutex sync.Mutex + err error + transactionErrs map[bitcoin.Hash]error + canonical bool + broadcastAllowed bool + calls int + lastRequest *FrostBitcoinBroadcastAuthorizationStatusRequest +} + +func newOutboxTestAuthorizationStatusSource() *outboxTestAuthorizationStatusSource { + return &outboxTestAuthorizationStatusSource{ + canonical: true, + broadcastAllowed: true, + transactionErrs: make(map[bitcoin.Hash]error), + } +} + +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 + } + if err := otass.transactionErrs[request.TransactionHash]; err != nil { + return nil, err + } + return &FrostBitcoinBroadcastAuthorizationStatus{ + RequestHash: request.ComputeHash(), + Canonical: otass.canonical, + BroadcastAllowed: otass.broadcastAllowed, + }, 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, +) { + 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), + broadcastErrs: 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 otbc.broadcastErrs[tx.Hash()] +} + +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) 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, +) { + 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/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/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..407f6a24ea --- /dev/null +++ b/pkg/tbtc/frost_activation_handshake.go @@ -0,0 +1,1821 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "mime" + "net" + "net/http" + "net/url" + "path" + "reflect" + "sort" + "strings" + "sync" + "time" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" + "golang.org/x/sys/unix" +) + +const ( + 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 + 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", +) + +// 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"` +} + +type frostActivationChallenge struct { + Nonce string `json:"nonce"` + ManifestHash string `json:"manifestHash"` + BindingHash string `json:"bindingHash"` + EthereumPoint frostActivationEthereumPoint `json:"ethereumPoint"` + CheckpointFloor frostRetainedGroupWireCheckpointCursor `json:"checkpointFloor"` +} + +type frostActivationHandshakeRequest struct { + Schema string `json:"schema"` + Challenge frostActivationChallenge `json:"challenge"` +} + +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"` + SourceIdentity frostRetainedGroupWireIdentity `json:"sourceIdentity"` + Generation uint64 `json:"generation"` + Complete bool `json:"complete"` +} + +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 frostActivationQuarantineJournalState struct { + ProtocolID string `json:"protocolID"` + StoreID string `json:"storeID"` + 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"` +} + +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"` + 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"` + StateAnchorPoisoned bool `json:"stateAnchorPoisoned"` + 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"` + 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"` + CheckpointJournal frostActivationCheckpointJournalState `json:"checkpointJournal"` + NativeSignerState frostActivationNativeSignerState `json:"nativeSignerState"` + InteractiveSigningReady bool `json:"interactiveSigningReady"` + 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 { + Schema string `json:"schema"` + Kind string `json:"kind"` + Nonce string `json:"nonce"` + ManifestHash string `json:"manifestHash"` + BindingHash string `json:"bindingHash"` + 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 + privateKey ed25519.PrivateKey + publicKeySPKI string + manifest FrostPreSignActivationRuntimeManifest + bindingHash [32]byte + pointVerifier FrostPreSignActivationPointVerifier + storeBinding *frostDurableSessionStoreBinding + outbox *bitcoinBroadcastOutbox + journal *frostRetainedGroupJournal + readiness frostActivationHandshakeReadinessVerifier + + 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 + quarantineActiveRoot [32]byte + quarantineTombstoneRoot [32]byte + checkpointSequence uint64 + checkpointHash [32]byte + checkpointHistoryRoot [32]byte +} + +type frostActivationReconciliationCache struct { + point FrostPreSignFinality + journal frostRetainedGroupJournalSnapshot + inventory frostNativeSignerInventorySnapshot + interactiveSigningReady bool + readiness frostProductionSignerReadinessSnapshot + stamp frostActivationJournalStamp +} + +type frostActivationHandshakeReadinessVerifier interface { + frostProductionSignerReadinessVerifier + // 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, + ) (*frostNativeSignerInventorySnapshot, error) +} + +func newFrostActivationHandshakeExporter( + endpoint string, + privateKeyPath string, + manifest FrostPreSignActivationRuntimeManifest, + pointVerifier FrostPreSignActivationPointVerifier, + storeBinding *frostDurableSessionStoreBinding, + outbox *bitcoinBroadcastOutbox, + journal *frostRetainedGroupJournal, + readiness frostActivationHandshakeReadinessVerifier, +) (*frostActivationHandshakeExporter, error) { + parsedEndpoint, err := validateFrostActivationHandshakeEndpoint(endpoint) + if err != nil { + return nil, err + } + durableSessionStoreFingerprint, durableSessionStoreFingerprintErr := + parseFrostActivationHex32(manifest.DurableSessionStoreFingerprint) + if manifest.ManifestHash == [32]byte{} || + manifest.AttestationSignerKeyHash == [32]byte{} || + manifest.Threshold != 51 || manifest.MaximumGroupSize != 100 || + 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 || storeBinding == nil || outbox == nil || + journal == nil || readiness == 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 + } + 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.checkpointState.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, + journal: journal, + readiness: readiness, + reconciliationWake: make(chan struct{}, 1), + } + 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) 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: 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) + } + }() + 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.reconciliationCancel != nil { + fahe.reconciliationCancel() + } + 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) 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 { + case <-ctx.Done(): + return + case <-fahe.reconciliationWake: + } + for { + job, reconciliationContext, cancel := fahe.takeReconciliationJob(ctx) + if job == nil { + break + } + cache, err := reconcile( + 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 + } + 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 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, + 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 + cache.readiness.Journal = &cache.journal + cache.readiness.Inventory = &cache.inventory + 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", + ) + } + cache := &frostActivationReconciliationCache{ + point: finality, + journal: *journalSnapshot, + inventory: *inventorySnapshot, + interactiveSigningReady: readinessSnapshot.InteractiveSigningReady, + readiness: *readinessSnapshot, + stamp: stamp, + } + cache.readiness.Journal = &cache.journal + cache.readiness.Inventory = &cache.inventory + return cache, 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.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", + ) + } + 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 || + journal.checkpointState.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, + quarantineActiveRoot: journal.quarantineState.ActiveRoot, + quarantineTombstoneRoot: journal.quarantineState.TombstoneRoot, + checkpointSequence: journal.checkpointState.Sequence, + checkpointHash: journal.checkpointState.CertificateHash, + checkpointHistoryRoot: journal.checkpointState.HistoryRoot, + }, 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 && + stamp.quarantineActiveRoot == snapshot.QuarantineActiveRoot && + stamp.quarantineTombstoneRoot == snapshot.QuarantineTombstoneRoot && + stamp.checkpointSequence == snapshot.CheckpointSequence && + stamp.checkpointHash == snapshot.CheckpointCertificateHash && + stamp.checkpointHistoryRoot == snapshot.CheckpointHistoryRoot +} + +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, +) { + 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 + } + 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 + } + 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") + } + bindingHash, err := parseFrostActivationHex32(request.Challenge.BindingHash) + 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") + } + finality := FrostPreSignFinality{ + BlockNumber: request.Challenge.EthereumPoint.BlockNumber, + BlockHash: blockHash, + } + reconciliation := fahe.cachedReconciliation(finality) + if reconciliation == nil { + fahe.queueReconciliation(finality, false) + return nil, errFrostActivationReconciliationPending + } + liveStamp, stampErr := fahe.tryJournalStamp() + if errors.Is(stampErr, errFrostActivationJournalBusy) { + return nil, errFrostActivationReconciliationPending + } + 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, + ) + } + // 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 + 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.checkpointAncestryFrom(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 + } + if !outboxSnapshot.Recovered || outboxSnapshot.AmbiguousReservationCount != 0 || + outboxSnapshot.QuarantineCount != 0 { + return nil, fmt.Errorf("durable Bitcoin outbox is not activation-ready") + } + durableSessionStoreFingerprint, err := fahe.storeBinding.verify() + if err != nil { + return nil, fmt.Errorf( + "FROST durable session store changed during readiness reconciliation: [%w]", + 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 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 - + // 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 != + frostNativeSignerAnchorWorkloadRotationWarning( + nativeSignerSnapshot.RestartableRevisionHeadroom, + nativeSignerSnapshot.RestartableGenerationHeadroom, + nativeSignerSnapshot.LargestLocalSeatCount, + ) { + return nil, fmt.Errorf( + "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), + BitcoinOutboxProtocolID: frostActivationHex32(fahe.manifest.BitcoinOutboxProtocolID), + SigningPolicyHash: frostActivationHex32(fahe.manifest.SigningPolicyHash), + DurableSessionStoreFingerprint: frostActivationHex32(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), + BindingHash: frostActivationHex32(journalSnapshot.BindingHash), + 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), + SourceIdentity: frostRetainedGroupIdentityToWire(journalManifest.SourceIdentity), + 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), + ActiveRoot: frostActivationHex32(journalSnapshot.QuarantineActiveRoot), + TombstoneRoot: frostActivationHex32(journalSnapshot.QuarantineTombstoneRoot), + Generation: journalSnapshot.QuarantineGeneration, + CurrentQuarantineCount: journalSnapshot.QuarantineCount, + 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), + 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, + StateAnchorPoisoned: stateAnchorPoisoning != nil, + Complete: true, + }, + InteractiveSigningReady: reconciliation.interactiveSigningReady, + FinalizedReservationReadbackEnforced: true, + ExactTransactionAuthorizationRootEnforced: true, + NonceShareGateEnforced: reconciliation.interactiveSigningReady && + nativeSignerSnapshot.StateGeneration > 0 && + nativeSignerSnapshot.StateCommitment != [32]byte{}, + DurableBitcoinOutboxRecovered: outboxSnapshot.Recovered, + 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", + Nonce: request.Challenge.Nonce, + ManifestHash: request.Challenge.ManifestHash, + BindingHash: request.Challenge.BindingHash, + EthereumPoint: request.Challenge.EthereumPoint, + State: state, + } + signatureTranscript, err := frostActivationHandshakeSignatureTranscript( + payload, + ) + 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, + ) + } + 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() + // 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) { + 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) + } + if err != nil { + return nil, err + } + return &frostActivationSignedHandshake{ + Payload: payload, + SignerPublicKeySPKI: fahe.publicKeySPKI, + Signature: base64.StdEncoding.EncodeToString(signature), + }, 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 { + return state.InteractiveSigningReady && + state.NonceShareGateEnforced && + state.DurableBitcoinOutboxRecovered && + state.QuarantineFailClosed && + state.NativeSignerState.Complete && + state.NativeSignerState.ExternalRollbackAnchorBound && + !state.NativeSignerState.AnchorRotationWarning && + !state.NativeSignerState.StateAnchorPoisoned +} + +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 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 + } + 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() + 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 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 + } + 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..eab98d2aa8 --- /dev/null +++ b/pkg/tbtc/frost_activation_handshake_test.go @@ -0,0 +1,2763 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/keep-network/keep-core/pkg/bitcoin" + "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 + point FrostPreSignFinality + calls uint64 +} + +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 { + 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 { + 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 { + 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}, + LargestLocalSeatCount: 20, + 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) 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, +) (*frostProductionSignerReadinessSnapshot, error) { + interactive, readinessErr, inventory := readiness.snapshot() + if readinessErr != nil { + return nil, readinessErr + } + if !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: &inventory, + InteractiveSigningReady: true, + }, nil +} + +func (readiness *testFrostProductionSignerReadiness) verifyFrostProductionSignerReadinessUnchanged( + 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 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, +) (*frostNativeSignerInventorySnapshot, error) { + if ctx == nil || expected == nil || expected.Inventory == nil || + !expected.InteractiveSigningReady { + return nil, 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 nil, ctx.Err() + case <-unchangedRelease: + } + } + interactive, readinessErr, inventory := readiness.snapshot() + if readinessErr != nil { + return nil, readinessErr + } + if !interactive { + return nil, fmt.Errorf("interactive signer is not ready") + } + if err := verifyFrostNativeSignerInventoryUnchanged( + expected.Inventory, + &inventory, + ); err != nil { + return nil, err + } + return &inventory, nil +} + +func (source *testFrostRetainedGroupHistorySource) BindFrostRetainedGroupActivationEvidence( + _ FrostPreSignActivationProfile, + runtimeManifest FrostPreSignActivationRuntimeManifest, +) error { + 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) { + return source.manifest.SourceIdentity, nil +} + +func (source *testFrostRetainedGroupHistorySource) FinalizedHead( + context.Context, +) (FrostPreSignFinality, error) { + source.mutex.Lock() + defer source.mutex.Unlock() + return source.target, nil +} + +func (source *testFrostRetainedGroupHistorySource) VerifyPoint( + context.Context, + FrostPreSignFinality, +) error { + return nil +} + +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 { + 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, + 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 +} + +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, + FrostPreSignFinality, +) (chain.OperatorID, error) { + return 1, nil +} + +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}), + } + journal := testFrostRetainedGroupJournal(t, manifest, point) + 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()) + 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), + BindingHash: frostActivationHex32(journal.metadata.BindingHash), + EthereumPoint: point, + CheckpointFloor: frostRetainedGroupWireCheckpointCursor{ + Sequence: journal.checkpointState.Sequence, + CertificateHash: frostActivationHex32( + journal.checkpointState.CertificateHash, + ), + }, + }, + } + response := postTestFrostActivationHandshake(t, endpoint, request) + if response.StatusCode != http.StatusServiceUnavailable || + response.Header.Get("Retry-After") != frostActivationHandshakeRetryAfter { + body, _ := io.ReadAll(response.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 || + 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 || + !handshake.Payload.State.DurableBitcoinOutboxRecovered || + verifiedPoint.BlockNumber != point.BlockNumber || + frostActivationHex32(verifiedPoint.BlockHash) != point.BlockHash || + verifierCalls != 4 { + t.Fatalf("unexpected handshake: %+v", handshake) + } + canonicalPayload, err := canonicalFrostActivationValue(handshake.Payload) + 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, 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", + "exactTransactionAuthorizationRootEnforced", "finalizedReservationReadbackEnforced", + "frostWalletGroupInventory", "healthy", "maximumGroupSize", "nonceShareGateEnforced", + "interactiveSigningReady", "nativeSignerState", + "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{ + "bindingHash", "checkpoint", "clusterFingerprint", "complete", "current", + "descriptorSetHash", "generation", "sourceEndpointFingerprint", "sourceOperatorFingerprint", + "sourceIdentity", "sourceTrustDomainID", "storeFingerprint", "storeID", + }) + assertFrostActivationObjectKeys(t, handshake.Payload.State.QuarantineJournal, []string{ + "activeRoot", "clusterFingerprint", "complete", "currentQuarantineCount", + "generation", "protocolID", "root", "storeFingerprint", "storeID", + "tombstoneCount", "tombstoneRoot", + }) + assertFrostActivationObjectKeys(t, handshake.Payload.State.NativeSignerState, []string{ + "anchorRotationWarning", "anchorServiceEpoch", "certifiedFloorGeneration", + "certifiedFloorRevision", + "complete", "currentAnchorRevision", "externalRollbackAnchorBound", "inventoryCommitment", + "previousStateCommitment", "retainedKeyPackageCount", "retainedWalletCount", + "restartableGenerationHeadroom", "restartableRevisionHeadroom", + "schema", "stateAnchorPoisoned", "stateCommitment", "stateGeneration", + "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_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_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){ + "retained key material changes": 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 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.TrustCertificateSequence++ + inventory.TrustCertificateDigest = [32]byte{0x3a} + 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 == "retained key material changes" && + (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 state rolls back" && + handshake.Payload.State.NativeSignerState.StateGeneration != 6 { + t.Fatalf( + "reconciled attestation did not use the current native state: %+v", + handshake.Payload.State.NativeSignerState, + ) + } + }) + } +} + +// 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_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 +// 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, +) { + 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, +) { + point := frostActivationEthereumPoint{ + BlockNumber: 123, + BlockHash: frostActivationHex32([32]byte{0x44}), + } + _, journal, source, _, 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) + } + recertifyTestFrostActivationJournal( + t, + journal, + source, + &request, + journal.checkpointPolicy.MinimumSequence, + ) + + 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{}, + 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) { + 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}), + } + journal := testFrostRetainedGroupJournal(t, 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, + } + readiness := &testFrostProductionSignerReadiness{journal: journal, interactive: true} + exporter, err := newFrostActivationHandshakeExporter( + endpoint, + privateKeyPath, + manifest, + &testFrostActivationPointVerifier{}, + testFrostDurableSessionStoreBinding(t), + outbox, + journal, + readiness, + ) + 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), + BindingHash: frostActivationHex32(journal.metadata.BindingHash), + EthereumPoint: point, + CheckpointFloor: frostRetainedGroupWireCheckpointCursor{ + Sequence: journal.checkpointState.Sequence, + CertificateHash: frostActivationHex32( + journal.checkpointState.CertificateHash, + ), + }, + }, + } + 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.setInteractive(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 { + t.Fatalf("unready interactive signer returned status [%d]", response.StatusCode) + } +} + +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 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" + }, + // 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}) + }, + "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) { + 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 + 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) + journal.mutex.Unlock() + 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 { + 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 + 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 { + 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: quarantinePoint.BlockNumber + 1, + BlockHash: [32]byte{0x47}, + } + 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), + } + 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 { + 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}, + "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 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 { + checkpointAuthorities, _, _ := + testFrostActivationCheckpointCredentials() + sourceIdentity := testFrostRetainedGroupCompleteIdentity() + 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}, + SigningPolicyHash: [32]byte{0x14}, + DurableSessionStoreFingerprint: frostActivationHex32(testFrostDurableSessionStoreIdentity().Fingerprint), + 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: sourceIdentity.TrustDomainID, + SourceEndpointFingerprint: sourceIdentity.EndpointFingerprint, + SourceOperatorFingerprint: sourceIdentity.OperatorFingerprint, + SourceIdentity: sourceIdentity, + MinimumGeneration: 1, + }, + QuarantineJournal: FrostRetainedGroupQuarantineJournalManifest{ + ProtocolID: [32]byte{0x25}, + LiftProtocolID: [32]byte{0x35}, + TombstoneProtocolID: [32]byte{0x36}, + CheckpointAuthorityThreshold: 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}}, + {AuthorityID: "lift-3", PublicKeySPKIHash: [32]byte{0x45}}, + }, + StoreID: "quarantine-store-id", + StoreFingerprint: [32]byte{0x26}, + ClusterFingerprint: [32]byte{0x27}, + MinimumGeneration: 0, + }, + } +} + +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, +) ( + *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, + CheckpointFloor: frostRetainedGroupWireCheckpointCursor{ + Sequence: journal.checkpointState.Sequence, + CertificateHash: frostActivationHex32( + journal.checkpointState.CertificateHash, + ), + }, + }, + } +} + +func testFrostRetainedGroupJournal( + t *testing.T, + manifest FrostPreSignActivationRuntimeManifest, + point frostActivationEthereumPoint, +) *frostRetainedGroupJournal { + t.Helper() + blockHash, err := parseFrostActivationHex32(point.BlockHash) + if err != nil { + 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{}, + } + 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) + } + 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) + } + 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, + } + source := &testFrostRetainedGroupHistorySource{ + manifest: manifest.CanonicalJournal, + bindingHash: bindingHash, + checkpointHead: checkpointHead, + historyRoot: historyRoot, + target: target, + } + return &frostRetainedGroupJournal{ + metadata: frostRetainedGroupJournalMetadata{ + Schema: frostRetainedGroupJournalMetadataSchema, + ManifestHash: manifest.ManifestHash, + BindingHash: bindingHash, + 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, + SourceIdentity: manifest.CanonicalJournal.SourceIdentity, + }, + quarantineMetadata: frostRetainedGroupQuarantineMetadata{ + 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, + 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, + 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: map[uint64]FrostRetainedGroupCheckpointCertificate{ + checkpointHead.Sequence: checkpointCertificate, + }, + checkpointHashes: map[uint64][32]byte{ + checkpointHead.Sequence: checkpointHead.CertificateHash, + }, + quarantineState: frostRetainedGroupQuarantineJournalState{ + Schema: frostRetainedGroupQuarantineStateSchema, + BindingHash: bindingHash, + CurrentPoint: target, + Root: quarantineRoot, + ActiveRoot: activeRoot, + TombstoneRoot: tombstoneRoot, + Quarantines: []frostRetainedGroupQuarantineState{}, + Tombstones: []frostRetainedGroupQuarantineTombstone{}, + }, + } +} + +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 := FrostRetainedGroupCheckpointCommitment{ + 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, + } + 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, + 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.checkpointCertificates[sequence] = certificate + 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") + 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 +} + +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) + } +} 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..f55854c54c 100644 --- a/pkg/tbtc/frost_dkg_coordinator.go +++ b/pkg/tbtc/frost_dkg_coordinator.go @@ -3,10 +3,22 @@ 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, + FrostDKGChain, + *FrostDKGStartedEvent, + []group.MemberIndex, + *GroupSelectionResult, +) bool + func initializeFrostDKGCoordinator( ctx context.Context, node *node, @@ -61,7 +73,27 @@ func handleFrostDKGStarted( deduplicator *deduplicator, event *FrostDKGStartedEvent, waitForConfirmation bool, -) { +) bool { + return 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, +) (completed bool) { lease, ok := deduplicator.beginDKGStarted(event.Seed) if !ok { logger.Infof( @@ -69,9 +101,8 @@ func handleFrostDKGStarted( "being processed", event.Seed, ) - return + return deduplicator.dkgSeedCache.Has(event.Seed.Text(16)) } - completed := false defer func() { lease.finish(completed) }() if waitForConfirmation { @@ -86,18 +117,18 @@ func handleFrostDKGStarted( 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( @@ -106,8 +137,7 @@ func handleFrostDKGStarted( event.Seed, event.BlockNumber, ) - completed = true - return + return true } startBlock := uint64(0) @@ -122,11 +152,11 @@ func handleFrostDKGStarted( ) 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] @@ -146,15 +176,15 @@ func handleFrostDKGStarted( // failed and released its lease, this path safely becomes the retry. completed = true lease.finish(completed) - handleFrostDKGStarted( + return handleFrostDKGStartedWithExecutor( ctx, node, frostChain, deduplicator, lastEvent, waitForConfirmation, + execute, ) - return } memberIndexes, groupSelectionResult, err := localFrostMembership( @@ -163,7 +193,7 @@ func handleFrostDKGStarted( ) if err != nil { logger.Errorf("failed to resolve FROST DKG membership: [%v]", err) - return + return false } if len(memberIndexes) == 0 { @@ -173,11 +203,10 @@ func handleFrostDKGStarted( lastEvent.Seed, lastEvent.BlockNumber, ) - completed = true - return + return true } - completed = executeFrostDKGIfPossible( + return execute( ctx, node, frostChain, @@ -193,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 { @@ -204,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( @@ -212,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, @@ -232,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( @@ -240,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, @@ -255,6 +340,8 @@ func recoverFrostDKGCoordinatorState( events[len(events)-1], ) } + + return true } func handleFrostDKGResultSubmitted( @@ -263,7 +350,7 @@ func handleFrostDKGResultSubmitted( frostChain FrostDKGChain, deduplicator *deduplicator, event *FrostDKGResultSubmittedEvent, -) { +) (completed bool) { lease, ok := deduplicator.beginDKGResultSubmitted( event.Seed, event.ResultHash, @@ -277,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) @@ -289,7 +381,7 @@ func handleFrostDKGResultSubmitted( event.ResultHash, err, ) - return + return false } if !valid { @@ -298,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( @@ -313,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 @@ -355,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 bc45727afc..ab2473bb2a 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, ) { @@ -285,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, ) { @@ -359,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 @@ -368,6 +573,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 80bca245c5..2c5a444e1e 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native.go @@ -7,7 +7,10 @@ import ( "crypto/ecdsa" "encoding/hex" "encoding/json" + "errors" "fmt" + "math/big" + "slices" "github.com/btcsuite/btcd/btcec/v2" "go.uber.org/zap" @@ -23,6 +26,25 @@ import ( const frostDKGResultSubmissionDelayStepBlocks = 30 +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, @@ -41,13 +63,23 @@ func executeFrostDKGIfPossible( ) return false } + 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 orphaned DKG packages, "+ + "so key material this attempt creates could never be reclaimed "+ + "if the attempt resolves without this wallet", + event.Seed, + ) + return false + } // Distributed DKG produces signing material usable ONLY via the complete // interactive ROAST path. The interactive audit flag alone is insufficient: // 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, "+ @@ -103,7 +135,21 @@ func executeFrostDKGIfPossible( fullMembers := frostFullMembers(groupSelectionResult) dkgTimeoutBlock := event.BlockNumber + params.SubmissionTimeoutBlocks + anchorReservation, err := admitFrostDKGAttempt( + ctx, + node, + event.Seed, + event.BlockNumber, + memberIndexes, + ) + if err != nil { + logger.Errorf("FROST DKG attempt was not admitted: [%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)), @@ -121,17 +167,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,8 +200,11 @@ func executeFrostDKGIfPossible( memberIndexes, len(groupSelectionResult.OperatorsIDs), ) + } + activeMemberIndexes, misbehavedMembersIndices, err := + announceReadiness() if err != nil { - dkgLogger.Errorf("FROST DKG readiness announcement failed: [%v]", err) + dkgLogger.Errorf("FROST DKG readiness failed: [%v]", err) return } @@ -162,7 +224,6 @@ func executeFrostDKGIfPossible( ) return } - tbtcSignerMemberIndexes, err := finalFrostDKGMemberIndexes( activeMemberIndexes, groupSelectionResult, @@ -190,6 +251,11 @@ func executeFrostDKGIfPossible( dkgLogger.Errorf("FROST DKG execution failed: [%v]", err) return } + // 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( @@ -262,6 +328,85 @@ 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, + localMemberIndexes []group.MemberIndex, +) ( + *frostNativeSignerAnchorRevisionReservation, + error, +) { + if anchorAdmission == nil { + return nil, fmt.Errorf( + "native signer anchor admission is unavailable", + ) + } + // Every selected local seat may become active once readiness is announced. + // Reserve that worst-case persistence cost before starting asynchronous + // readiness work. + anchorReservation, err := anchorAdmission.reserveDKG( + ctx, + uint64(len(localMemberIndexes)), + ) + if err != nil { + return nil, fmt.Errorf( + "native signer anchor admission failed: [%w]", + err, + ) + } + return anchorReservation, nil +} + type frostDKGExecutionResult struct { outputKey frost.OutputKey signerMaterial *frostsigning.NativeSignerMaterial @@ -355,6 +500,29 @@ func executeDistributedFrostDKG( prebuffer, ) if err != nil { + // 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 } @@ -683,15 +851,97 @@ 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 frostOutputKeyToECDSAPublicKey( diff --git a/pkg/tbtc/frost_dkg_execution_frost_native_test.go b/pkg/tbtc/frost_dkg_execution_frost_native_test.go index c52cb9ed4d..6736cd8dfb 100644 --- a/pkg/tbtc/frost_dkg_execution_frost_native_test.go +++ b/pkg/tbtc/frost_dkg_execution_frost_native_test.go @@ -6,15 +6,101 @@ import ( "bytes" "context" "encoding/hex" + "errors" + "fmt" "math/big" + "strings" + "sync" "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" ) +func TestReserveFrostDKGReadiness_AdmissionFailureIsSynchronous( + 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, + }, + } + reservation, err := reserveFrostDKGReadiness( + context.Background(), + controller, + []group.MemberIndex{1, 2}, + ) + if err == nil || !strings.Contains(err.Error(), "unreserved") { + t.Fatalf("unexpected DKG admission result: [%v]", err) + } + if reservation != nil { + t.Fatal("failed DKG admission returned a reservation") + } +} + +func TestReserveFrostDKGReadiness_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} + + reservation, err := + reserveFrostDKGReadiness( + context.Background(), + controller, + localMemberIndexes, + ) + if err != nil { + t.Fatal(err) + } + if reservation == nil { + t.Fatal("successful DKG admission returned no reservation") + } + expectedPersistenceCalls := uint64(len(localMemberIndexes) * 2) + if controller.reserved.Revisions != expectedPersistenceCalls || + controller.reserved.Generations != expectedPersistenceCalls { + t.Fatalf( + "DKG did not reserve persistence and retirement for every selected local seat: [%+v]", + controller.reserved, + ) + } + + reservation.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, "") @@ -34,8 +120,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, @@ -76,6 +234,466 @@ func registerFrostDKGReadinessTestEngine(t *testing.T) { }) } +// 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 + 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 *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 nil +} + +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, +) { + const keyGroup = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + + localChain := Connect() + _, operatorPublicKey, err := localChain.OperatorKeyPair() + if err != nil { + t.Fatal(err) + } + 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, + &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 || 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(retired) != 0 { + t.Fatalf( + "a local DKG failure destroyed durably persisted key groups: [%v]", + retired, + ) + } +} + +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") + } +} + +// 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_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_retirement_boundary_store_test.go b/pkg/tbtc/frost_dkg_retirement_boundary_store_test.go new file mode 100644 index 0000000000..fe16663044 --- /dev/null +++ b/pkg/tbtc/frost_dkg_retirement_boundary_store_test.go @@ -0,0 +1,260 @@ +package tbtc + +import ( + "encoding/json" + "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 TestFrostDKGAttemptBoundaryAcceptsCanonicalReinclusion(t *testing.T) { + persistenceHandle := &mockPersistenceHandle{} + registry := &walletRegistry{ + 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) + } + 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") + } +} + +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, + ) + } +} + +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 +} + +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), + frostDKGRetirementBoundaries: 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.frostDKGRetirementBoundaries) != 0 || + registry.revision != 0 { + t.Fatal("failed attempt persistence changed registry state") + } +} diff --git a/pkg/tbtc/frost_dkg_retirement_default.go b/pkg/tbtc/frost_dkg_retirement_default.go new file mode 100644 index 0000000000..5b147bde32 --- /dev/null +++ b/pkg/tbtc/frost_dkg_retirement_default.go @@ -0,0 +1,14 @@ +//go:build !frost_native + +package tbtc + +func newFrostOrphanedDKGReconciler( + _ Chain, + _ *walletRegistry, + _ *frostNativeSignerAnchorAdmissionController, +) ( + 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 new file mode 100644 index 0000000000..954b436cd0 --- /dev/null +++ b/pkg/tbtc/frost_dkg_retirement_frost_native.go @@ -0,0 +1,275 @@ +//go:build frost_native + +package tbtc + +import ( + "bytes" + "context" + "fmt" + "sort" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +type frostOrphanedDKGReconciler struct { + snapshotChain frostDKGRetirementSnapshotChain + walletRegistry *walletRegistry + anchorAdmission *frostNativeSignerAnchorAdmissionController + retirementEngine frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine + readInventory func() (*frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error) + readCurrentBlock func() (uint64, 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, +) ( + frostOrphanedDKGReconcilerFunc, + error, +) { + if chain == nil || walletRegistry == nil || anchorAdmission == nil { + return nil, fmt.Errorf( + "orphaned FROST DKG reconciliation dependencies are incomplete", + ) + } + snapshotChain, ok := chain.(frostDKGRetirementSnapshotChain) + if !ok { + return nil, fmt.Errorf( + "chain does not expose exact finalized FROST DKG snapshots for orphan reconciliation", + ) + } + retirementEngine, ok := frostsigning.CurrentNativeTBTCSignerEngine().(frostsigning.NativeTBTCSignerDistributedDKGRetirementEngine) + if !ok { + return nil, fmt.Errorf( + "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, + anchorAdmission: anchorAdmission, + retirementEngine: retirementEngine, + readInventory: frostsigning. + ReadNativeTBTCSignerRetainedKeyPackageInventory, + readCurrentBlock: blockCounter.CurrentBlock, + } + return reconciler.reconcile, nil +} + +func (reconciler *frostOrphanedDKGReconciler) reconcile( + ctx context.Context, + target FrostPreSignFinality, + canonicalWallets map[[32]byte]struct{}, +) error { + if reconciler == nil || ctx == nil || canonicalWallets == 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 + } + + 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, latestRetirementBoundary, hasRetirementBoundary, err := + reconciler.walletRegistry.frostDKGRetirementMaterialSnapshot() + 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 + }) + + noncanonicalWalletIDs := make([][32]byte, 0, len(walletIDs)) + for _, walletID := range walletIDs { + if _, canonical := canonicalWallets[walletID]; !canonical { + noncanonicalWalletIDs = append(noncanonicalWalletIDs, walletID) + } + } + if len(noncanonicalWalletIDs) == 0 { + return nil + } + + // Native package persistence is ordered after the durable attempt marker. + // 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 + } + + 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( + "finalized FROST DKG retirement snapshot omits wallet [0x%x]", + walletID, + ) + } + if registered || unresolvedDKG { + 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 +} 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..c6d3ca6180 --- /dev/null +++ b/pkg/tbtc/frost_dkg_retirement_frost_native_test.go @@ -0,0 +1,1047 @@ +//go:build frost_native + +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" +) + +type orphanedDKGSnapshotTestChain struct { + state DKGState + registered map[[32]byte]bool + points []FrostPreSignFinality +} + +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 { + 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{} + snapshotChain := &orphanedDKGSnapshotTestChain{ + state: Idle, + registered: make(map[[32]byte]bool), + } + reconciler := &frostOrphanedDKGReconciler{ + snapshotChain: snapshotChain, + walletRegistry: orphanedDKGTestWalletRegistry(t, 90), + 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 + }, + } + + 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) + } + 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{ + snapshotChain: &orphanedDKGSnapshotTestChain{ + state: Idle, + registered: map[[32]byte]bool{registeredWalletID: true}, + }, + walletRegistry: orphanedDKGTestWalletRegistry(t, 90), + 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(), + orphanedDKGTestPoint(), + 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{ + snapshotChain: &orphanedDKGSnapshotTestChain{ + state: AwaitingResult, + registered: make(map[[32]byte]bool), + }, + walletRegistry: orphanedDKGTestWalletRegistry(t, 90), + 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(), + orphanedDKGTestPoint(), + map[[32]byte]struct{}{}, + ); err != nil { + t.Fatal(err) + } + if len(engine.retired) != 0 { + t.Fatalf("AwaitingResult DKG material was retired: [%v]", engine.retired) + } +} + +func TestFrostOrphanedDKGReconcilerPreservesMaterialDuringChallenge( + t *testing.T, +) { + walletID := orphanedDKGTestWalletID(t) + const keyGroup = "0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + engine := &orphanedDKGRetirementTestEngine{} + reconciler := &frostOrphanedDKGReconciler{ + snapshotChain: &orphanedDKGSnapshotTestChain{ + state: Challenge, + registered: make(map[[32]byte]bool), + }, + walletRegistry: orphanedDKGTestWalletRegistry(t, 90), + 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(), + orphanedDKGTestPoint(), + 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 TestFrostOrphanedDKGReconcilerPreservesEveryKeyEncodingDuringChallenge( + t *testing.T, +) { + walletID := orphanedDKGTestWalletID(t) + 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{ + snapshotChain: &orphanedDKGSnapshotTestChain{ + state: Challenge, + registered: make(map[[32]byte]bool), + }, + walletRegistry: orphanedDKGTestWalletRegistry(t, 90), + 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(), + orphanedDKGTestPoint(), + 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 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 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, + 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: keyGroup, + }}, + }, 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("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) + } +} + +// 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, + ) + anchorAdmission, headroomReads := frostDKGTestAnchorAdmissionWithReadCount() + reconciler := &frostOrphanedDKGReconciler{ + snapshotChain: &orphanedDKGSnapshotTestChain{ + state: Idle, + registered: make(map[[32]byte]bool), + }, + walletRegistry: registry, + anchorAdmission: anchorAdmission, + 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, 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: 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 +// 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( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + if err != nil { + t.Fatal(err) + } + var walletID [32]byte + copy(walletID[:], decoded) + 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), + frostDKGRetirementBoundaries: map[string]uint64{ + frostDKGRetirementBoundaryIdentity( + frostDKGRetirementBoundaryKindAttempt, + seed, + attemptStartBlock, + ): attemptStartBlock, + }, + } +} + +func orphanedDKGTestPoint() FrostPreSignFinality { + return FrostPreSignFinality{ + BlockNumber: 100, + BlockHash: [32]byte{0xaa}, + } +} + +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 +} diff --git a/pkg/tbtc/frost_durable_session_store.go b/pkg/tbtc/frost_durable_session_store.go new file mode 100644 index 0000000000..1167a63a89 --- /dev/null +++ b/pkg/tbtc/frost_durable_session_store.go @@ -0,0 +1,105 @@ +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. 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 + + 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..e52ed026ac --- /dev/null +++ b/pkg/tbtc/frost_durable_session_store_test.go @@ -0,0 +1,160 @@ +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 backend": func(identity *signing.NativeTBTCSignerDurableStoreIdentity) { + identity.Backend = "encrypted-database-v1" + }, + "wrong store ID": func(identity *signing.NativeTBTCSignerDurableStoreIdentity) { + identity.StoreID[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 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, +) { + 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_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_anchor_admission.go b/pkg/tbtc/frost_native_signer_anchor_admission.go new file mode 100644 index 0000000000..cd11fd3511 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_admission.go @@ -0,0 +1,1031 @@ +package tbtc + +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 ( + // 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. + // + // 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 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 + // 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 per-input allowance below is what absorbs them. + frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall uint64 = 2 + + // 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 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 + // 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 + // 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. + frostNativeSignerTerminalGenerationAdvancesPerAdmittedInput 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 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 + + anchorBinding *frostNativeSignerAnchorBinding + // 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 +} + +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 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, + 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 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, +) (*frostNativeSignerAnchorRevisionReservation, error) { + if localSeatCount == 0 { + return nil, fmt.Errorf( + "FROST native DKG anchor admission controls no local seats", + ) + } + if localSeatCount > ^uint64(0)/2 { + return nil, fmt.Errorf( + "FROST native DKG anchor admission seat count overflows", + ) + } + maximumPersistenceCalls := localSeatCount * 2 + cost := frostNativeSignerAnchorCapacity{ + Revisions: maximumPersistenceCalls, + Generations: maximumPersistenceCalls, + } + + return controller.reserve( + "FROST native DKG", + cost, + func() (frostNativeSignerAnchorCapacity, error) { + return controller.currentRestartableHeadroom(ctx) + }, + ) +} + +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, + 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, + ) + } + + // 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() + + 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 { + recordFrostNativeSignerAnchorRotationFloorRejection() + 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, + ) + } + // 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 + // 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. + 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; "+ + "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, + unreservedRevisions, + ) + } + if cost.Generations > headroom.Generations { + recordFrostNativeSignerAnchorUnreservedHeadroomRejection() + return nil, fmt.Errorf( + "%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, + 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, + ) + } + + 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 +// 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; +// - 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. The proof-window cost is the amortized +// 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. +// +// 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. +// +// 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) { + if err := validateFrostPreSignAdmissionInputCount(inputCount); err != nil { + return frostNativeSignerAnchorCapacity{}, err + } + cost, err := frostPreSignAnchoredInputCost( + localSeatCount, + maximumSigningAttempts, + ) + if err != nil { + return frostNativeSignerAnchorCapacity{}, err + } + if cost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents { + recordFrostNativeSignerAnchorSeatCeilingRejection() + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "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( + localSeatCount, + maximumSigningAttempts, + ), + ) + } + if cost.Generations > FrostNativeSignerAnchorMaximumHistoryProofEntries { + recordFrostNativeSignerAnchorSeatCeilingRejection() + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "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( + localSeatCount, + maximumSigningAttempts, + ), + ) + } + 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. 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( + localSeatCount uint64, + maximumSigningAttempts uint64, +) string { + admissibleSeats := frostPreSignMaximumAdmissibleLocalSeatCount( + maximumSigningAttempts, + ) + if admissibleSeats == 0 { + 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 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, + localSeatCount, + admissibleSeats, + ) +} + +// frostPreSignMaximumAdmissibleLocalSeatCount reports the largest local seat +// 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( + maximumSigningAttempts uint64, +) uint64 { + admissibleSeats := uint64(0) + for seats := uint64(1); seats <= + uint64(frostPreSignAuthorizationMaximumSeats); seats++ { + cost, err := frostPreSignAnchoredInputCost( + seats, + maximumSigningAttempts, + ) + if err != nil || + cost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents || + cost.Generations > + FrostNativeSignerAnchorMaximumHistoryProofEntries { + break + } + admissibleSeats = seats + } + return admissibleSeats +} + +// 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 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", + ) + } + revisions := uint64(1) + maximumSigningAttempts*perAttemptCalls + if revisions > (maxUint64- + frostNativeSignerTerminalGenerationAdvancesPerAdmittedInput)/ + frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall { + return frostNativeSignerAnchorCapacity{}, fmt.Errorf( + "FROST pre-sign generation cost overflow", + ) + } + generations := revisions* + frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall + + frostNativeSignerTerminalGenerationAdvancesPerAdmittedInput + + return frostNativeSignerAnchorCapacity{ + Revisions: revisions, + 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 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 +// 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. 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 +// 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. +// +// Native DKG and DKG retirement share the same admission controller but reserve +// 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, + 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 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 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) { + if localSeatCount == 0 { + return frostNativeSignerAnchorCapacity{}, false + } + cost, err := frostPreSignAnchoredInputCost( + localSeatCount, + signingAttemptsLimit, + ) + if err != nil || + cost.Revisions > FrostNativeSignerAnchorMaximumHistoryEvents || + cost.Generations > FrostNativeSignerAnchorMaximumHistoryProofEntries { + 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 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 +// 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. +// - 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 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. +// 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 +// 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 + frostNativeSignerAnchorReservationContentionRejections atomic.Uint64 + frostNativeSignerAnchorUnreservedHeadroomRejections atomic.Uint64 + frostNativeSignerAnchorRotationFloorRejections atomic.Uint64 + frostNativeSignerAnchorPoisonedRejections atomic.Uint64 + frostNativeSignerAnchorPreSignInputRejections 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" + 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 +// 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(), + ) + }, + frostNativeSignerAnchorReservationContentionMetricName: func() float64 { + return float64( + frostNativeSignerAnchorReservationContentionRejections.Load(), + ) + }, + frostNativeSignerAnchorUnreservedHeadroomMetricName: func() float64 { + return float64( + frostNativeSignerAnchorUnreservedHeadroomRejections.Load(), + ) + }, + frostNativeSignerAnchorRotationFloorMetricName: func() float64 { + return float64( + frostNativeSignerAnchorRotationFloorRejections.Load(), + ) + }, + frostNativeSignerAnchorPoisonedMetricName: func() float64 { + return float64( + frostNativeSignerAnchorPoisonedRejections.Load(), + ) + }, + frostNativeSignerAnchorPreSignInputMetricName: func() float64 { + return float64( + frostNativeSignerAnchorPreSignInputRejections.Load(), + ) + }, + }, + ) +} + +// 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) +} + +// 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() { + 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) +} + +// 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() { + frostNativeSignerAnchorSeatCeilingRejections.Store(0) + frostNativeSignerAnchorReservationContentionRejections.Store(0) + 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 new file mode 100644 index 0000000000..a79c5e9a9c --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_admission_test.go @@ -0,0 +1,1105 @@ +package tbtc + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + 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, + signingAttemptsLimit, + ) + if err != nil { + t.Fatal(err) + } + if cost.Revisions != 26 || cost.Generations != 55 { + t.Fatalf("unexpected single-seat per-input cost [%+v]", cost) + } + + // 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, + 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, + ) + } + } + + // 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, + ) + } + } + + // 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, + test.seats, + signingAttemptsLimit, + ) + if err != nil { + 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 per-input cost [%+v] exceeds restart windows [%d/%d]", + test.seats, + seatCost, + FrostNativeSignerAnchorMaximumHistoryEvents, + FrostNativeSignerAnchorMaximumHistoryProofEntries, + ) + } + } + + // 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( + signingAttemptsLimit, + ); admissibleSeats != uint64(frostPreSignAuthorizationMaximumSeats) { + t.Fatalf( + "local seat ceiling is [%d], expected every one of the wallet's "+ + "[%d] seats to be admissible", + admissibleSeats, + frostPreSignAuthorizationMaximumSeats, + ) + } +} + +// 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 { + t.Fatal(err) + } + if twentySeatCost.Revisions != 406 || twentySeatCost.Generations != 815 { + t.Fatalf("unexpected twenty-seat per-input cost [%+v]", twentySeatCost) + } +} + +// 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 _, localSeatCount := range []uint64{ + 1, + 4, + uint64(frostPreSignAuthorizationMaximumSeats), + } { + cost, err := frostPreSignAnchoredInputCost( + localSeatCount, + signingAttemptsLimit, + ) + if err != nil { + t.Fatal(err) + } + expected := cost.Revisions* + frostNativeSignerAmortizedGenerationAdvancesPerAnchoredCall + + frostNativeSignerTerminalGenerationAdvancesPerAdmittedInput + if cost.Generations != expected { + t.Fatalf( + "[%d] seats reserved [%d] generations, not the amortized [%d]", + 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] seats reserved the per-call worst case [%d] rather than "+ + "an amortized bound", + localSeatCount, + cost.Generations, + ) + } + } +} + +// 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( + "the seat ceiling refused [%d] admissible seat counts", + rejections, + ) + } + + // 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", + } { + 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 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 || !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 rejections := + frostNativeSignerAnchorSeatCeilingRejections.Load(); rejections != 2 { + t.Fatalf( + "seat-ceiling rejections counted [%d], expected [2]", + rejections, + ) + } +} + +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 + // on what the anchor has already spent. + 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, + ) + + first, err := controller.reservePreSign( + context.Background(), + snapshot, + 4, + 6, + signingAttemptsLimit, + ) + if err != nil { + t.Fatal(err) + } + defer first.Release() + + // 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, + 6, + signingAttemptsLimit, + ); 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); + // 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, + 6, + signingAttemptsLimit, + ); err != nil { + t.Fatalf("released reservation remained charged: [%v]", err) + } +} + +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, +) { + currentHeadroom := frostNativeSignerAnchorCapacity{ + Revisions: 400, + Generations: 500, + } + controller := &frostNativeSignerAnchorAdmissionController{ + readHeadroom: func( + context.Context, + ) (frostNativeSignerAnchorCapacity, error) { + return currentHeadroom, nil + }, + } + staleSnapshot := testFrostAnchorAdmissionReadinessSnapshot(400, 700) + + // 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, + 13, + signingAttemptsLimit, + ); err == nil || !strings.Contains(err.Error(), "unreserved") { + t.Fatalf("stale readiness headroom was trusted: [%v]", err) + } +} + +// 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) + } + 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 + // 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() + + resetFrostNativeSignerAnchorAdmissionMetricsForTest() +} + +// TestFrostNativeSignerAnchorWorkloadRotationWarning pins the warning against +// 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) { + 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 != 86 || fourSeatCost.Generations != 175 { + t.Fatalf("unexpected four-seat per-input cost [%+v]", fourSeatCost) + } + // 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( + "a four-seat node again needs [%d] generations per admission, at "+ + "or above the flat rotation floor [%d]", + fourSeatCost.Generations, + FrostNativeSignerAnchorRotationWarningHeadroom, + ) + } + + for _, test := range []struct { + name string + revisionHeadroom uint64 + generationHeadroom uint64 + localSeatCount uint64 + warning bool + }{ + // A fresh rotation warns about nothing, at any seat count a wallet can + // award. + { + name: "four seats, both windows unspent", + revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, + localSeatCount: 4, + warning: false, + }, + { + name: "the whole wallet on one node, both windows unspent", + revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + 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: "fifty seats, one generation above the next admission", + revisionHeadroom: FrostNativeSignerAnchorMaximumHistoryEvents, + 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: "fifty seats, revision window at the next admission's revisions", + revisionHeadroom: 1006, + generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, + localSeatCount: 50, + warning: true, + }, + { + name: "fifty seats, revision window one above", + revisionHeadroom: 1007, + generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, + localSeatCount: 50, + warning: false, + }, + // 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: "four seats, one above the flat floor", + revisionHeadroom: FrostNativeSignerAnchorRotationWarningHeadroom + 1, + generationHeadroom: FrostNativeSignerAnchorRotationWarningHeadroom + 1, + localSeatCount: 4, + warning: false, + }, + { + name: "four seats, at the flat floor", + revisionHeadroom: FrostNativeSignerAnchorRotationWarningHeadroom, + generationHeadroom: FrostNativeSignerAnchorRotationWarningHeadroom, + localSeatCount: 4, + warning: true, + }, + // 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, + generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, + localSeatCount: 0, + warning: false, + }, + { + name: "no local seats, at the flat floor", + revisionHeadroom: FrostNativeSignerAnchorRotationWarningHeadroom, + generationHeadroom: FrostNativeSignerAnchorMaximumHistoryProofEntries, + localSeatCount: 0, + warning: true, + }, + { + 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 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, + fiftySeatCost.Generations, + ), + ) { + t.Fatal( + "the flat rotation warning already fires at the fifty-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. 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( + uint64(frostPreSignAuthorizationMaximumInputs), + 1, + 1000, + ); err == nil { + t.Fatal("an unservable signing-attempt limit was admitted") + } + + for _, test := range []struct { + name string + counted uint64 + expected uint64 + }{ + { + name: "reservation contention", + counted: frostNativeSignerAnchorReservationContentionRejections.Load(), + expected: 0, + }, + { + 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, + }, + // 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( + "[%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, +) *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 new file mode 100644 index 0000000000..68faf12a88 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_binding.go @@ -0,0 +1,710 @@ +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 FrostNativeSignerStateWitnessAnchorReference + floorPreviousEventRoot [32]byte + + readTip frostNativeSignerStateWitnessTipReader + readProof frostNativeSignerStateWitnessProofReader + acknowledge frostNativeSignerStateWitnessAcknowledger + recover frostNativeSignerStateWitnessRecoverer + now func() time.Time + + mutex sync.Mutex +} + +func newFrostNativeSignerAnchorBinding( + store FrostNativeSignerStateWitnessAnchorStore, + manifest FrostNativeSignerAnchorManifest, + floor FrostNativeSignerStateWitnessAnchorReference, + floorPreviousEventRoot [32]byte, + 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 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 certified floor is invalid") + } + computedFloorCommitment := + frostsigning.ComputeNativeTBTCSignerStateWitnessCommitment( + floor.Checkpoint.StoreFingerprint, + floor.Checkpoint.Generation, + floor.Checkpoint.PreviousStateCommitment, + floor.Checkpoint.StateImageDigest, + ) + if floor.Checkpoint.Generation == 0 || + computedFloorCommitment != floor.Checkpoint.StateCommitment { + return nil, fmt.Errorf( + "FROST native signer certified 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: floor, + floorPreviousEventRoot: floorPreviousEventRoot, + 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. + 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), + ) { + 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) + } + 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( + 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.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 { + 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") + } + 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", + ) + } + 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, +) 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..0595449e7f --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_binding_test.go @@ -0,0 +1,781 @@ +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, + WitnessMaximumRecords: identity.WitnessMaximumRecords, + WitnessRotationThresholdRecords: identity.WitnessRotationThresholdRecords, + } + binding, err := newFrostNativeSignerAnchorBinding( + store, + manifest, + floor, + [32]byte{}, + 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 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, +) { + 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 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, + 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_bootstrap_client.go b/pkg/tbtc/frost_native_signer_anchor_bootstrap_client.go new file mode 100644 index 0000000000..1847c647cb --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_bootstrap_client.go @@ -0,0 +1,973 @@ +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{} { + 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( + "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_bootstrap_client_test.go b/pkg/tbtc/frost_native_signer_anchor_bootstrap_client_test.go new file mode 100644 index 0000000000..1cfa0baf7d --- /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(), "request failed") { + 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) + } +} 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..998200c4de --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_client.go @@ -0,0 +1,1697 @@ +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/http/httptrace" + "net/url" + "path" + "strconv" + "strings" + "sync" + "sync/atomic" + "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 + + // 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, + ) +} + +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 +// 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 + certifiedTrustFloor *FrostNativeSignerAnchorTrustCertificate + 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) { + 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 { + 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") + } + 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 { + 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...), + certifiedTrustFloor: certifiedTrustFloor, + 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, + } +} + +// 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 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 { + return isFrostPreSignTransientAuthorizationFailure(err) +} + +func (client *FrostNativeSignerAnchorClient) readOnceLocked( + 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 + } + 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) + } + 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") + 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, wroteRequest.Load(), fmt.Errorf( + "native signer anchor request failed: %w", + err, + ) + } + 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 { + 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") + } + 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") + } + 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") + } + 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..d97b091112 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_client_test.go @@ -0,0 +1,1731 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "syscall" + "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 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, +) { + 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, + 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) + } +} + +// 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.StatusRequestTimeout, + 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, + }, + { + "request timeout", + &frostNativeSignerAnchorStatusError{ + statusCode: http.StatusRequestTimeout, + }, + 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 + historyFloor FrostNativeSignerStateWitnessAnchorReference + 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, +) *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 + 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 { + // 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) + 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, + readFaults: readFaults, + 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..60b3688bfa --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_history.go @@ -0,0 +1,283 @@ +package tbtc + +import ( + "bytes" + "fmt" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +func (binding *frostNativeSignerAnchorBinding) manifestFloorReference() FrostNativeSignerStateWitnessAnchorReference { + return binding.floor +} + +// 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.Checkpoint + 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..1f2c4f7584 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_keys.go @@ -0,0 +1,108 @@ +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", + ) + } + 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( + "FROST native signer anchor online key is not canonical DER SPKI", + ) + } + 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_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_observability_test.go b/pkg/tbtc/frost_native_signer_anchor_observability_test.go new file mode 100644 index 0000000000..ee342db085 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_observability_test.go @@ -0,0 +1,406 @@ +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, 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 _, attempts := range []uint64{signingAttemptsLimit, 20} { + for seats := uint64(1); seats <= 30; seats++ { + _, admissionErr := frostPreSignMaximumAnchorCapacityCost( + maximumInputs, + seats, + attempts, + ) + warning, warned := frostPreSignLocalSeatCeilingWarning( + seats, + 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, + ) + } + } + } +} + +// 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) { + if ceiling := frostPreSignMaximumAdmissibleLocalSeatCount( + signingAttemptsLimit, + ); ceiling != uint64(frostPreSignAuthorizationMaximumSeats) { + t.Fatalf( + "production local seat ceiling: got %d want %d", + ceiling, + frostPreSignAuthorizationMaximumSeats, + ) + } + + // 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, + ) + } + } +} + +// 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 node above the ceiling must be warned") + } + // 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") { + t.Errorf( + "warning does not name the consequence for the wallet: [%s]", + warning, + ) + } +} + +// 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, +) { + // 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( + overCapSeats, + 0, + ) + defaultWarning, defaultWarned := frostPreSignLocalSeatCeilingWarning( + 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]", + zeroWarning, + defaultWarning, + ) + } +} + +// 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.Fatal( + "the shipped attempt limit now admits no seat count at all; this " + + "test needs a configuration admission rejects outright", + ) + } + + warning, warned := frostPreSignLocalSeatCeilingWarning(1, 1000) + if !warned { + t.Fatal("a configuration that can sign nothing must be warned") + } + 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 { + 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/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..d11ae53c35 --- /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(), "temporary reservations") { + 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_native_signer_anchor_protocol.go b/pkg/tbtc/frost_native_signer_anchor_protocol.go new file mode 100644 index 0000000000..c0cde64aff --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_protocol.go @@ -0,0 +1,1421 @@ +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" + 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 + // 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 + // 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 + 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" + 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 +// 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.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 || + !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") + } + 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_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..2e5c3f6f85 --- /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_NONBLOCK|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 +} 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..eb005a8348 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_provisioning_files_test.go @@ -0,0 +1,285 @@ +package tbtc + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +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("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") + 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..072d0d8adf --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_provisioning_test.go @@ -0,0 +1,1306 @@ +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, + ) + } + }) + } +} + +// 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 new file mode 100644 index 0000000000..d8dc985747 --- /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 err := frostsigning.ValidateNativeTBTCSignerStateWitnessGeometry( + endpoint.WitnessMaximumRecords, + endpoint.WitnessRotationThresholdRecords, + ); err != nil { + return fmt.Errorf( + "native signer anchor trust %s witness geometry is invalid: %w", + name, + err, + ) + } + 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..560d02c12c --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_trust_startup.go @@ -0,0 +1,879 @@ +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 +// 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, + recoveryReplay bool, +) 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 { + 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 != + 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 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( + "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 err := frostsigning.ValidateNativeTBTCSignerStateWitnessGeometry( + anchorManifest.WitnessMaximumRecords, + anchorManifest.WitnessRotationThresholdRecords, + ); err != nil { + return fmt.Errorf( + "native signer anchor witness geometry is invalid: %w", + err, + ) + } + 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..ce8b2f5a22 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_anchor_trust_startup_test.go @@ -0,0 +1,1466 @@ +package tbtc + +import ( + "bytes" + "context" + "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 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 := + 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, + false, + ); 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, + false, + ); 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, + false, + ); 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, + false, + ); 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, + 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, + 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 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, +) { + 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 new file mode 100644 index 0000000000..c1cf3c2421 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_readiness.go @@ -0,0 +1,868 @@ +package tbtc + +import ( + "bytes" + "context" + "fmt" + "sort" + "sync" + "time" + + "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 +} + +type frostNativeSignerInventoryReader func() ( + *frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, + error, +) + +type frostNativeSignerStateWitnessProofReader func( + *frostsigning.NativeTBTCSignerStateWitnessProofRequest, +) (*frostsigning.NativeTBTCSignerStateWitnessProof, error) + +type frostNativeSignerStateAnchorTrustHeadReader func() ( + *frostsigning.NativeTBTCSignerStateAnchorTrustHead, + error, +) + +type frostNativeSignerInventoryExpectation struct { + WalletID [32]byte + KeyGroup string + 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 + LargestLocalSeatCount 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 + readTrustHead frostNativeSignerStateAnchorTrustHeadReader + expectedTrustHead frostsigning.NativeTBTCSignerStateAnchorTrustHead + + mutex sync.Mutex +} + +func newFrostNativeSignerInventoryBinding( + storeBinding *frostDurableSessionStoreBinding, + anchorBinding *frostNativeSignerAnchorBinding, + readInventory frostNativeSignerInventoryReader, + readTrustHead frostNativeSignerStateAnchorTrustHeadReader, + expectedTrustHead *frostsigning.NativeTBTCSignerStateAnchorTrustHead, +) (*frostNativeSignerInventoryBinding, error) { + 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, + readTrustHead: readTrustHead, + expectedTrustHead: *expectedTrustHead, + }, 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) + } + 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) + } + if inventory == nil || inventory.StoreFingerprint != storeFingerprint { + return nil, fmt.Errorf("native retained key-package inventory is absent or belongs to another store") + } + 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 localTip == nil || frostNativeSignerCheckpointFromTip(*localTip) != target { + return nil, fmt.Errorf( + "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, + ); err != nil { + return nil, fmt.Errorf( + "native signer state is not bound to the authenticated external anchor: [%w]", + 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 + } + + keyPackageCount := uint64(0) + largestLocalSeatCount := uint64(0) + for _, entry := range inventory.Entries { + localSeatCount := uint64(len(entry.KeyPackages)) + keyPackageCount += localSeatCount + if localSeatCount > largestLocalSeatCount { + largestLocalSeatCount = localSeatCount + } + } + 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, + LargestLocalSeatCount: largestLocalSeatCount, + 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: frostNativeSignerAnchorWorkloadRotationWarning( + revisionHeadroom, + generationHeadroom, + largestLocalSeatCount, + ), + }, 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, +) 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 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 || + 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 + + inventoryExpectations []frostNativeSignerInventoryExpectation + registryRevision uint64 +} + +type frostProductionSignerReadinessVerifier interface { + verifyFrostProductionSignerReadiness( + 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 + journal.ActiveQuarantines = append( + []frostRetainedGroupActiveQuarantine{}, + snapshot.Journal.ActiveQuarantines..., + ) + 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 { + 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, registryRevision, err := + readiness.journal.nativeSignerInventoryExpectations(ctx, point) + if err != nil { + return nil, err + } + inventory, err := readiness.verifyStableFrostProductionSignerInventory( + ctx, + expected, + ) + if err != nil { + return nil, err + } + 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") + } + return &frostProductionSignerReadinessSnapshot{ + Journal: journalSnapshot, + Inventory: inventory, + InteractiveSigningReady: true, + inventoryExpectations: expected, + registryRevision: registryRevision, + }, 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 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. +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 nil, fmt.Errorf( + "cached FROST production signer readiness is incomplete", + ) + } + if !readiness.interactiveSigningReady() { + return nil, fmt.Errorf("interactive FROST signing engine is not ready") + } + if err := readiness.verifyFrostRetainedGroupJournalStampUnchanged( + ctx, + expected.Journal, + ); err != nil { + return nil, err + } + if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( + expected.registryRevision, + ) { + return nil, fmt.Errorf( + "local FROST signer registry changed since readiness reconciliation", + ) + } + inventory, err := readiness.verifyStableFrostProductionSignerInventory( + ctx, + expected.inventoryExpectations, + ) + if err != nil { + return nil, err + } + if err := verifyFrostNativeSignerInventoryUnchanged( + expected.Inventory, + inventory, + ); err != nil { + return nil, err + } + if !readiness.journal.walletRegistry.frostReadinessRevisionMatches( + expected.registryRevision, + ) { + return nil, fmt.Errorf( + "local FROST signer registry changed during readiness revalidation", + ) + } + if !readiness.interactiveSigningReady() { + return nil, fmt.Errorf( + "interactive FROST signing engine became unavailable during readiness revalidation", + ) + } + if err := readiness.verifyFrostRetainedGroupJournalStampUnchanged( + ctx, + expected.Journal, + ); err != nil { + return nil, err + } + return inventory, 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. 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, +) 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.LargestLocalSeatCount != expected.LargestLocalSeatCount || + actual.ExternalRollbackAnchorBound != + expected.ExternalRollbackAnchorBound || + actual.TrustCertificateSequence != expected.TrustCertificateSequence || + actual.TrustCertificateDigest != expected.TrustCertificateDigest || + actual.AnchorServiceEpoch != expected.AnchorServiceEpoch || + actual.CertifiedFloorRevision != expected.CertifiedFloorRevision || + actual.CertifiedFloorGeneration != expected.CertifiedFloorGeneration { + 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", + ) + } + 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. + 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 || + ctx == nil { + return fmt.Errorf( + "cached FROST retained-group journal readiness is incomplete", + ) + } + journal := readiness.journal + if err := lockFrostRetainedGroupJournalStamp(ctx, journal); err != nil { + return err + } + 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 +} + +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 + } + // 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: [%w]", + err, + ) + } + if err := validateFrostNativeSignerAnchorReadinessHeadroom( + secondInventory, + ); err != nil { + return nil, err + } + return secondInventory, 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 != + frostNativeSignerAnchorWorkloadRotationWarning( + inventory.RestartableRevisionHeadroom, + inventory.RestartableGenerationHeadroom, + inventory.LargestLocalSeatCount, + ) { + 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, +) ([]frostNativeSignerInventoryExpectation, uint64, error) { + if journal == nil || ctx == nil { + 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, 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, 0, 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 + } + 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, registryRevision, 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..8e6bd7f264 --- /dev/null +++ b/pkg/tbtc/frost_native_signer_readiness_test.go @@ -0,0 +1,1394 @@ +package tbtc + +import ( + "context" + "encoding/hex" + "fmt" + "os" + "strings" + "testing" + "time" + + frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" +) + +type testFrostNativeSignerStateWitnessAnchorStore struct { + record *FrostNativeSignerStateWitnessAnchorRecord + err error +} + +func (store *testFrostNativeSignerStateWitnessAnchorStore) ReadFrostNativeSignerStateWitnessAnchor( + context.Context, +) (*FrostNativeSignerStateWitnessAnchorRecord, error) { + if store.err != nil { + return nil, store.err + } + if store.record == nil { + return nil, nil + } + 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: FrostNativeSignerStateWitnessAnchorReference{ + ServiceEpoch: 1, + Revision: 1, + Checkpoint: checkpoint, + }, + readTip: func() (*frostsigning.NativeTBTCSignerStateWitnessTip, error) { + result := *tip + return &result, nil + }, + }, 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, +) { + 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 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 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{ + { + 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, + KeyGroup: hex.EncodeToString(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 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 + }, + "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 TestFrostNativeSignerInventoryBindingRequiresExactAuthenticatedAnchor(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} + 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, + KeyGroup: hex.EncodeToString(walletID[:]), + Threshold: 51, + ParticipantCount: 100, + PublicKeyPackageCommitment: [32]byte{0x44}, + KeyPackages: keyPackages, + }, + } + inventory := &frostsigning.NativeTBTCSignerRetainedKeyPackageInventory{ + Schema: frostsigning.NativeTBTCSignerRetainedKeyPackageInventorySchema, + StoreFingerprint: storeFingerprint, + StateGeneration: 2, + StateCommitment: target, + PreviousStateCommitment: ancestor, + StateImageDigest: stateImage, + InventoryCommitment: frostsigning.ComputeNativeTBTCSignerRetainedKeyPackageInventoryCommitment( + entries, + ), + Entries: entries, + } + checkpoint := FrostNativeSignerStateWitnessCheckpoint{ + StoreFingerprint: storeFingerprint, + Generation: 2, + PreviousStateCommitment: ancestor, + StateImageDigest: stateImage, + StateCommitment: target, + } + 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) + } + expected := []frostNativeSignerInventoryExpectation{ + { + WalletID: walletID, + KeyGroup: hex.EncodeToString(walletID[:]), + Threshold: 51, + ParticipantCount: 100, + ParticipantSeats: participantSeats, + }, + } + 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.LargestLocalSeatCount != 20 || + 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 + 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 + 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{ + 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(), "differs from the authenticated remote anchor") { + t.Fatalf("equal-generation rollback fork was accepted: [%v]", err) + } + + 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(), "differs from the authenticated remote anchor") { + 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 + terminalWalletID := [32]byte{0x62} + terminalKeyGroup := hex.EncodeToString(terminalWalletID[:]) + journal := &frostRetainedGroupJournal{ + source: &testFrostRetainedGroupHistorySource{}, + walletRegistry: &walletRegistry{ + walletCache: make(map[string]*walletCacheValue), + retainedFrostKeyGroups: map[[32]byte]string{ + terminalWalletID: terminalKeyGroup, + }, + }, + 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 != 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) + } + 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, _ := + testFrostNativeSignerInventoryAnchorBinding(storeFingerprint, checkpoint) + trustHead := testFrostNativeSignerInventoryTrustHead(anchorBinding) + inventoryReads := 0 + inventoryBinding, err := newFrostNativeSignerInventoryBinding( + storeBinding, + anchorBinding, + func() (*frostsigning.NativeTBTCSignerRetainedKeyPackageInventory, error) { + inventoryReads++ + if inventoryReads == 2 { + fixture.registry.mutex.Lock() + fixture.registry.revision++ + fixture.registry.mutex.Unlock() + } + 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) + } + + _, 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) + } +} + +func TestFrostProductionSignerReadinessRejectsChangedJournalStamp( + 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( + context.Background(), + expected, + ); err != nil { + t.Fatalf("matching retained-group journal stamp was rejected: [%v]", err) + } + + 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() + 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) + } +} + +// 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, +) *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 := journalFixture.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: journalFixture.walletID, + KeyGroup: keyGroup, + Threshold: frostPreSignAuthorizationThreshold, + ParticipantCount: uint16(len(journalFixture.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) + 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) { + result := *fixture.inventory + return &result, nil + }, + func() (*frostsigning.NativeTBTCSignerStateAnchorTrustHead, error) { + // 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, + ) + if err != nil { + t.Fatal(err) + } + readiness, err := newFrostProductionSignerReadiness( + func() bool { return true }, + journal, + inventoryBinding, + ) + if err != nil { + t.Fatal(err) + } + 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 := fixture.readiness.verifyFrostProductionSignerReadinessUnchanged( + context.Background(), + snapshot, + ); err != nil { + t.Fatalf("unmutated native signer state was rejected: [%v]", err) + } + + fixture.advance([32]byte{0x73}, [32]byte{0x74}) + advanced := fixture.durableState() + if err := fixture.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. + fixture.setDurableState(baseline) + snapshot.Inventory.StateGeneration = advanced.tip.Generation + snapshot.Inventory.StateCommitment = advanced.tip.StateCommitment + snapshot.Inventory.PreviousStateCommitment = + advanced.tip.PreviousStateCommitment + snapshot.Inventory.StateImageDigest = advanced.tip.StateImageDigest + snapshot.Inventory.CurrentAnchorRevision = advanced.tip.AnchorRevision + snapshot.Inventory.RestartableRevisionHeadroom-- + snapshot.Inventory.RestartableGenerationHeadroom-- + if err := fixture.readiness.verifyFrostProductionSignerReadinessUnchanged( + context.Background(), + snapshot, + ); err == nil || !strings.Contains(err.Error(), "rolled back") { + t.Fatalf("rolled-back native signer state was accepted: [%v]", err) + } +} + +// 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, +) { + 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 + }, + accepted: true, + }, + } + 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) + } + }) + } +} + +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 new file mode 100644 index 0000000000..5487325137 --- /dev/null +++ b/pkg/tbtc/frost_pre_sign_authorization.go @@ -0,0 +1,2642 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + stdnet "net" + "net/http" + "os" + "reflect" + "sort" + "sync" + "syscall" + "time" + + "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" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +const ( + frostPreSignAuthorizationMessageTypePrefix = "tbtc/frost_pre_sign_authorization/" + frostPreSignAuthorizationThreshold = 51 + 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" + 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-v5")) + 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, + FrostPreSignEthereumEvidenceVerifier, + ) (*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 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 + ActivationAuthorityKeyHash [32]byte + VerifierOperatorFingerprint [32]byte + HandshakeOperatorFingerprint [32]byte + DomainChainID [32]byte + GenesisBlockHash [32]byte + ProfileHash [32]byte + ImplementationSetHash [32]byte + LinkedLibraryDescriptorSetHash [32]byte + EndpointIdentitySetHash [32]byte + Deployments []FrostPreSignDeploymentEvidence + 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 + 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 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 + WitnessMaximumRecords uint64 + WitnessRotationThresholdRecords uint64 +} + +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 + anchorReservation *frostNativeSignerAnchorRevisionReservation + + readinessMutex sync.Mutex + readinessPoint FrostPreSignFinality + readinessSnapshot *frostProductionSignerReadinessSnapshot +} + +func (authorization *frostPreSignAuthorization) releaseAnchorReservation() { + if authorization == nil { + return + } + 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) +} + +// 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 { + 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, + *FrostPreSignTransaction, + ) (*frostPreSignAuthorization, error) + revalidate( + 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 { + backend FrostPreSignAuthorizationBackend + 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 + + // 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( + backend FrostPreSignAuthorizationBackend, + activationProfile FrostPreSignActivationProfile, + storeBinding *frostDurableSessionStoreBinding, + productionReadiness frostProductionSignerReadinessVerifier, + anchorAdmission *frostNativeSignerAnchorAdmissionController, + 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 _, err := storeBinding.verify(); err != nil { + return nil, fmt.Errorf("FROST durable session store is not activation-ready: [%w]", err) + } + if productionReadiness == nil || anchorAdmission == 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") + } + 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, + storeBinding: storeBinding, + productionReadiness: productionReadiness, + anchorAdmission: anchorAdmission, + signing: signing, + broadcastChannel: broadcastChannel, + membershipValidator: membershipValidator, + wallet: wallet, + localMemberIndexes: indexes, + threshold: frostPreSignAuthorizationThreshold, + maximumAttempts: signingAttemptsLimit, + }, 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 + } + readinessPoint, readinessSnapshot, err := + tfpsag.verifyCurrentProductionSignerReadiness( + ctx, + transaction.WalletPublicKeyHash, + nil, + ) + if err != nil { + return nil, err + } + if tfpsag.anchorAdmission == nil { + return nil, fmt.Errorf( + "FROST native signer anchor admission controller is nil", + ) + } + // 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)), + tfpsag.effectiveMaximumAttempts(), + ) + 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) + } + // 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, + anchorReservation: anchorReservation, + } + authorization.cacheReadiness(*readinessPoint, readinessSnapshot) + if err := tfpsag.revalidate(ctx, authorization); err != nil { + return nil, err + } + + reservationTransferred = true + 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. +// +// 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. +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 + } + 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 + } + var httpError ethereumRPC.HTTPError + if errors.As(err, &httpError) { + return isFrostPreSignTransientAuthorizationHTTPStatus( + httpError.StatusCode, + ) + } + var dependencyHTTPError frostAuthorizationDependencyHTTPStatusError + if errors.As(err, &dependencyHTTPError) { + return isFrostPreSignTransientAuthorizationHTTPStatus( + dependencyHTTPError.HTTPStatusCode(), + ) + } + 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) + attempt := 1 + err := tfpsag.revalidateOnce(ctx, authorization) + for { + 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, + ) + } + 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() + } +} + +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 + // 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.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 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. + if 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 + } + if err := tfpsag.productionReadiness.verifyFrostProductionSignerReadinessUnchanged( + ctx, + readinessSnapshot, + ); err != nil { + return fmt.Errorf("FROST signer readiness changed during authorization revalidation: [%w]", err) + } + + 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, + *frostProductionSignerReadinessSnapshot, + error, +) { + if tfpsag == nil || tfpsag.backend == nil || tfpsag.productionReadiness == nil { + return nil, nil, fmt.Errorf( + "FROST production signer readiness dependencies are incomplete", + ) + } + if ctx == nil { + return nil, nil, fmt.Errorf( + "FROST production signer readiness context is nil", + ) + } + currentFinality, err := tfpsag.backend.CurrentFrostPreSignFinality(ctx) + if err != nil { + 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, nil, fmt.Errorf( + "invalid current FROST pre-sign finalized checkpoint", + ) + } + readinessSnapshot := authorization.cachedReadiness(*currentFinality) + if readinessSnapshot != nil { + if err := tfpsag.productionReadiness. + verifyFrostProductionSignerReadinessUnchanged( + ctx, + readinessSnapshot, + ); err != nil { + return nil, nil, fmt.Errorf( + "cached FROST production signer readiness changed: [%w]", + err, + ) + } + } 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 + } + return currentFinality, readinessSnapshot, 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 + } + // 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), + 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..e18324e54a --- /dev/null +++ b/pkg/tbtc/frost_pre_sign_authorization_test.go @@ -0,0 +1,1729 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/sha512" + "encoding/hex" + "fmt" + "math/big" + stdnet "net" + "net/http" + "os" + "strings" + "sync" + "syscall" + "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" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +type testFrostProductionAuthorizationReadiness struct { + err error + unchangedErr error + calls uint64 + unchangedCalls uint64 + 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( + _ context.Context, + point FrostPreSignFinality, +) (*frostProductionSignerReadinessSnapshot, error) { + readiness.calls++ + readiness.points = append(readiness.points, point) + if readiness.err != nil && + (readiness.failingCalls == 0 || readiness.calls <= readiness.failingCalls) { + return nil, readiness.err + } + 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] + } + 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( + _ context.Context, + _ *frostProductionSignerReadinessSnapshot, +) error { + readiness.unchangedCalls++ + return readiness.unchangedErr +} + +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), + storeBinding: testFrostDurableSessionStoreBinding(t), + productionReadiness: &testFrostProductionAuthorizationReadiness{}, + } + 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 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 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) + 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(), + } + currentFinality := &FrostPreSignFinality{BlockNumber: 9, BlockHash: [32]byte{0x70}} + gate := &thresholdFrostPreSignAuthorizationGate{ + backend: &testFrostPreSignAuthorizationBackend{ + proposal: proposal, + currentFinality: currentFinality, + }, + activationProfile: profile, + storeBinding: testFrostDurableSessionStoreBinding(t), + productionReadiness: &testFrostProductionAuthorizationReadiness{}, + anchorAdmission: &frostNativeSignerAnchorAdmissionController{}, + 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") + } +} + +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 + revalidateErr error + admitInputErr error + authorizeCalls int + revalidateCalls int + admitInputCalls int + admitInputHeld int + admitInputPeak 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 +} + +// 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() + 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 + currentFinalityCalls uint64 + 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) { + tfpsab.currentFinalityCalls++ + 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 +} + +// 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) + } + }) + } +} + +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, + }, + "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, + 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", + }), + }, + "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) { + 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, + ) + } +} + +func TestThresholdFrostPreSignAuthorizationGate_SurvivesTemporaryEthereumHTTPFailure( + t *testing.T, +) { + 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("authorization dependency: [%w]", failure), + 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 +// 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, + ) + } +} + +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. +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_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 new file mode 100644 index 0000000000..3d7e40c51c --- /dev/null +++ b/pkg/tbtc/frost_primary_ethereum_transport.go @@ -0,0 +1,1209 @@ +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" + frostPrimaryEthereumPeerChannelDomain = "tbtc-frost-primary-ethereum-peer-channel/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 FrostPrimaryEthereumClient + chainID uint64 + 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 = &frostPrimaryEthereumTimeoutClient{ + client: ethclient.NewClient(rpcClient), + requestTimeout: timeout, + } + + 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.Lock() + transport.chainID = chainID.Uint64() + transport.mutex.Unlock() + 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 +} + +// 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 { + 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", + ) + } + dialContext, dialCancel := context.WithTimeout(ctx, transport.timeout) + defer dialCancel() + dialer := &net.Dialer{KeepAlive: 30 * time.Second} + var lastErr error + 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( + attemptContext, + network, + net.JoinHostPort(pinned.String(), port), + ) + if dialErr != nil { + attemptCancel() + lastErr = dialErr + continue + } + tracked := &frostPrimaryTrackedRawConnection{ + Conn: raw, + transport: transport, + } + tlsConnection := tls.Client(tracked, transport.tlsConfig()) + if handshakeErr := tlsConnection.HandshakeContext( + attemptContext, + ); handshakeErr != nil { + _ = tlsConnection.Close() + attemptCancel() + lastErr = handshakeErr + continue + } + attemptCancel() + state := tlsConnection.ConnectionState() + if verifyErr := verifyFrostPrimaryEthereumTLSConnection( + state, + transport.endpoint, + ); verifyErr != nil { + _ = tlsConnection.Close() + lastErr = verifyErr + continue + } + peer, peerErr := frostTransportPeerIdentityFromTLS( + transport.endpoint, + tlsConnection.RemoteAddr(), + state, + ) + if peerErr != nil { + _ = tlsConnection.Close() + lastErr = peerErr + continue + } + identityKey := frostTransportPeerIdentityKey(peer) + if recordErr := transport.recordPeer( + identityKey, + peer, + ); recordErr != nil { + _ = tlsConnection.Close() + return nil, recordErr + } + tracked.activate(frostTransportPeerChannelKey(peer)) + 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, + ) + } + return transcript.sum() +} + +func frostTransportPeerChannelKey( + peer frostTransportPeerIdentity, +) [32]byte { + transcript := frostRetainedGroupIdentityTranscript( + frostPrimaryEthereumPeerChannelDomain, + ) + transcript.bytes32( + "peerIdentityKey", + frostTransportPeerIdentityKey(peer), + ) + transcript.bytes32( + "tlsExporterValueHash", + peer.tlsExporterValueHash, + ) + 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 { + 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[identityKey]; !exists && + len(transport.seenPeers) >= frostPrimaryEthereumMaximumSeenPeers { + return fmt.Errorf( + "primary Ethereum peer history limit exceeded", + ) + } + transport.seenPeers[identityKey] = stablePeer + if transport.policy != nil { + if err := transport.policy.registerPrimaryPeer( + identityKey, + stablePeer, + ); err != nil { + return err + } + } + transport.livePeers[channelKey]++ + 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") + } + var ( + connectionMutex sync.Mutex + connection net.Conn + ) + trace := &httptrace.ClientTrace{ + GotConn: func(info httptrace.GotConnInfo) { + if info.Conn != nil { + connectionMutex.Lock() + connection = info.Conn + connectionMutex.Unlock() + } + }, + } + request = request.WithContext( + httptrace.WithClientTrace(request.Context(), trace), + ) + response, err := roundTripper.base.RoundTrip(request) + if err != nil { + return nil, err + } + connectionMutex.Lock() + actual := connection + connectionMutex.Unlock() + if actual == nil { + _ = 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( + 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( + 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[identityKey]; !exists || + transport.livePeers[channelKey] == 0 { + 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 := frostTransportRetainedPeerRetentionKey(peer) + stablePeer := frostTransportStablePeerIdentity(peer) + if _, exists := peers[key]; !exists && + len(peers) >= frostPrimaryEthereumMaximumSeenPeers { + policy.failure = fmt.Errorf( + "%s peer history limit exceeded", + role, + ) + return policy.failure + } + peers[key] = stablePeer + if !frostAddressSetContains(retained.endpoint.addresses, stablePeer.remoteIP) || + stablePeer.leafSPKIHash != retained.identity.TLSLeafSPKIHash || + !frostStringSetContains( + stablePeer.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, stablePeer.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, + stablePeer, + "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_primary_ethereum_transport_test.go b/pkg/tbtc/frost_primary_ethereum_transport_test.go new file mode 100644 index 0000000000..03049604f6 --- /dev/null +++ b/pkg/tbtc/frost_primary_ethereum_transport_test.go @@ -0,0 +1,746 @@ +package tbtc + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "net/netip" + "net/url" + "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) + } +} + +// 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, +) { + 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 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 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 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) + } +} + +// 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, +) { + 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() + if transport.ChainID() != 1 { + t.Fatalf( + "guarded transport did not retain probed chain ID: [%d]", + transport.ChainID(), + ) + } + + 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(3 * time.Second): + close(service.release) + err := <-result + t.Fatalf( + "stalled WSS request exceeded configured timeout; eventual result: [%v]", + err, + ) + } +} + +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 + 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 + } +} diff --git a/pkg/tbtc/frost_retained_group_checkpoint.go b/pkg/tbtc/frost_retained_group_checkpoint.go new file mode 100644 index 0000000000..e55f843bfc --- /dev/null +++ b/pkg/tbtc/frost_retained_group_checkpoint.go @@ -0,0 +1,1727 @@ +package tbtc + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "fmt" + "math/big" + "os" + "sort" + "strings" + + "github.com/decred/dcrd/dcrec/edwards/v2" +) + +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 + // 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 + // 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 +// 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 +} + +// 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"` + 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) { + 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, + identity, + ) +} + +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, + ) + } + 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, + ) + 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 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, + 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 { + 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) + } + 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) + 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, + ) + } + persistedCertificates := make( + []FrostRetainedGroupCheckpointCertificate, + len(certificates), + ) + 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, + ) + } + 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", + ); 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 persistedCertificates { + 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, +) (FrostRetainedGroupCheckpointCertificate, 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 FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "conflicting immutable checkpoint certificate exists for sequence [%d]", + sequence, + ) + } + existingName = name + } + expectedWire := + frostRetainedGroupCheckpointCertificateToWire(certificate) + if existingName == "" { + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + expectedName, + expectedWire, + false, + ); err != nil { + return FrostRetainedGroupCheckpointCertificate{}, err + } + return certificate, nil + } + storedWire := frostRetainedGroupWireCheckpointCertificate{} + if err := readFrostRetainedGroupEnvelopeAt( + frgj.checkpointDirectory, + existingName, + &storedWire, + ); err != nil { + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "cannot read orphan checkpoint certificate: [%w]", + err, + ) + } + storedCertificate, err := + frostRetainedGroupCheckpointCertificateFromWire(storedWire) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "cannot decode orphan checkpoint certificate: [%w]", + err, + ) + } + storedHash, err := validateFrostRetainedGroupCheckpointCertificateShape( + frgj.checkpointPolicy, + storedCertificate, + ) + if err != nil { + return FrostRetainedGroupCheckpointCertificate{}, fmt.Errorf( + "cannot validate orphan checkpoint certificate: [%w]", + err, + ) + } + 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", + ) + } + // 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( + 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) checkpointAncestryFrom( + 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+1), + ) + 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( + "FROST checkpoint ancestry is missing certificate [%d]", + 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_endpoint_identity.go b/pkg/tbtc/frost_retained_group_endpoint_identity.go new file mode 100644 index 0000000000..8bcb50512e --- /dev/null +++ b/pkg/tbtc/frost_retained_group_endpoint_identity.go @@ -0,0 +1,2086 @@ +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 + separationPolicy *frostPrimaryRetainedSeparationPolicy + 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 frostPrimaryEthereumIndependenceVerifier interface { + verifyIndependence( + context.Context, + frostRetainedGroupResolvedEndpoint, + frostRetainedGroupResolvedEndpoint, + ) error +} + +type frostRetainedGroupIndependenceMonitor struct { + exportEndpoint frostRetainedGroupResolvedEndpoint + verifierEndpoint frostRetainedGroupResolvedEndpoint + primaryTransport frostPrimaryEthereumIndependenceVerifier +} + +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, + primaryTransport *FrostPrimaryEthereumTransport, +) (*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) + } + primaryEndpoint, resolver, err := primaryTransport.frozenEndpoint() + if err != nil { + return nil, fmt.Errorf( + "invalid guarded primary Ethereum transport: [%w]", + err, + ) + } + 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) + } + 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 (monitor *frostRetainedGroupIndependenceMonitor) verify( + ctx context.Context, +) error { + if monitor == nil || ctx == nil || + monitor.exportEndpoint.endpoint == nil || + monitor.verifierEndpoint.endpoint == nil || + monitor.primaryTransport == nil { + return fmt.Errorf("retained-group endpoint independence monitor is incomplete") + } + if err := monitor.primaryTransport.verifyIndependence( + ctx, + monitor.exportEndpoint, + monitor.verifierEndpoint, + ); err != nil { + return fmt.Errorf("primary Ethereum transport is not independent: [%w]", err) + } + 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 frostRetainedGroupPinnedDialTLSContext( + endpoint frostRetainedGroupResolvedEndpoint, + tlsConfig *tls.Config, + timeout time.Duration, +) func(context.Context, string, string) (net.Conn, error) { + 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() || + port != endpoint.endpoint.Port() { + return nil, fmt.Errorf( + "retained-group transport attempted an unpinned endpoint", + ) + } + dialContext, dialCancel := context.WithTimeout(ctx, timeout) + defer dialCancel() + dialer := &net.Dialer{KeepAlive: -1} + var lastErr error + 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 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 + } + 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]", + 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) { + 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 || + 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, + DialTLSContext: frostRetainedGroupPinnedDialTLSContext( + endpoint, + tlsConfig, + 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, + separationPolicy: separationPolicy, + 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() + } + 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 + 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) + } + outbound.Header.Set( + frostRetainedGroupTransportChallengeHeader, + hex.EncodeToString(challenge[:]), + ) + outbound.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() + } + }, + } + outbound = outbound.WithContext( + httptrace.WithClientTrace(outbound.Context(), trace), + ) + response, err := roundTripper.base.RoundTrip(outbound) + 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, + outbound.Method, + target, + requestDigest, + responseDigest, + roundTripper.now, + roundTripper.maximumClockSkew, + roundTripper.maximumLifetime, + ); 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, + responseDigest: responseDigest, + challenge: challenge, + } + if response.Request == nil { + response.Request = outbound + } + 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..a43b18a772 --- /dev/null +++ b/pkg/tbtc/frost_retained_group_endpoint_identity_test.go @@ -0,0 +1,1384 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "reflect" + "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 +} + +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, +) (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")}, + } + 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", + 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: "/"}, + }, + 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 + }, + }, + } + 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 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, +) { + 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 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() + 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 new file mode 100644 index 0000000000..b47f8fc847 --- /dev/null +++ b/pkg/tbtc/frost_retained_group_history_evidence.go @@ -0,0 +1,1413 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "math/big" + "sort" + "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" + frostregistry "github.com/keep-network/keep-core/pkg/frost/registry" +) + +// 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, + FrostPreSignActivationRuntimeManifest, + ) error +} + +type FrostRetainedGroupProtocolBindingSource interface { + FrostRetainedGroupProtocolBindingHash() ([32]byte, error) +} + +type frostRetainedGroupEvidenceProfile struct { + 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 + liftPolicy frostRetainedGroupQuarantineLiftPolicy + checkpointPolicy frostRetainedGroupCheckpointPolicy + deployments map[string]FrostPreSignDeploymentEvidence + bridgeABI ethabi.ABI + registryABI ethabi.ABI +} + +type frostRetainedGroupReceiptCache map[common.Hash]*types.Receipt + +type frostRetainedGroupCodeCacheKey struct { + address common.Address + blockHash common.Hash + codeHash common.Hash + descriptorHash common.Hash + verified bool +} + +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, + runtimeManifest FrostPreSignActivationRuntimeManifest, +) 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 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 || + runtimeManifest.CanonicalJournal.SourceIdentity != source.identity || + 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 + } + liftPolicy, err := frostRetainedGroupLiftPolicyFromRuntimeManifest( + bindingHash, + runtimeManifest, + ) + if err != nil { + return fmt.Errorf( + "retained-group quarantine lift policy is invalid: [%w]", + 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) + } + parsedRegistryABI, err := ethabi.JSON(strings.NewReader(frostabi.FrostWalletRegistryMetaData.ABI)) + if err != nil { + return fmt.Errorf("cannot parse pinned FROST registry ABI: [%w]", err) + } + evidence := &frostRetainedGroupEvidenceProfile{ + 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, + liftPolicy: liftPolicy, + checkpointPolicy: checkpointPolicy, + deployments: deployments, + bridgeABI: parsedBridgeABI, + registryABI: parsedRegistryABI, + } + 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) 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/v4", + 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), + LiftProtocolID: frostActivationHex32(runtimeManifest.QuarantineJournal.LiftProtocolID), + 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( + runtimeManifest.CanonicalJournal.StoreFingerprint, + ), + CanonicalClusterFingerprint: frostActivationHex32( + runtimeManifest.CanonicalJournal.ClusterFingerprint, + ), + QuarantineStoreID: runtimeManifest.QuarantineJournal.StoreID, + QuarantineStoreFingerprint: frostActivationHex32( + runtimeManifest.QuarantineJournal.StoreFingerprint, + ), + QuarantineClusterFingerprint: frostActivationHex32( + runtimeManifest.QuarantineJournal.ClusterFingerprint, + ), + SourceIdentity: frostRetainedGroupIdentityToWire(source.identity), + } + 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, +) { + 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) 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, + 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) { + 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 + } + + 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.deployments["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.deployments["frostWalletRegistry"], + 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.deployments["frostWalletRegistry"], + evidence.registryABI.Events["DkgResultSubmitted"].ID, + receipts, + code, + ) + if err != nil { + return err + } + result, resultHash, err := frostRetainedGroupDecodeDkgSubmission( + submissionLog, + evidence, + ) + if err != nil { + return err + } + 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 || + result.MembersHash != activeMembersHash || + !frostRetainedGroupEqualOperatorIDs( + []uint32(activeMembers), + mutation.OperatorIDs, + ) { + return fmt.Errorf("DKG submission does not encode the exported admission") + } + + approvalLog, err := source.authenticatedEventLog( + ctx, + mutation.DkgApprovalPoint, + evidence.deployments["frostWalletRegistry"], + 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.deployments["frostWalletRegistry"], + 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.deployments["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, + deployment FrostPreSignDeploymentEvidence, + topic common.Hash, + receipts frostRetainedGroupReceiptCache, + code frostRetainedGroupCodeCache, +) (*types.Log, error) { + if !point.valid() || topic == (common.Hash{}) { + return nil, fmt.Errorf("event evidence descriptor is incomplete") + } + 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) + receipt, ok := receipts[transactionHash] + if !ok { + if len(receipts) >= frostRetainedGroupMaximumEvidenceReceipts { + return nil, fmt.Errorf("retained-group evidence exceeds the receipt limit") + } + var err error + 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) + } + if receipt == nil { + return nil, fmt.Errorf("transaction receipt [%s] is missing", transactionHash.Hex()) + } + 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 || + 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 != 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 { + return nil, fmt.Errorf("receipt log does not match the exact contract/event point") + } + return matched, nil +} + +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, + descriptor FrostPreSignDeploymentDescriptorEvidence, + blockNumber uint64, + blockHash [32]byte, + cache frostRetainedGroupCodeCache, +) error { + 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") + } + 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 nil, fmt.Errorf("cannot read pinned contract code at block [%d]: [%w]", blockNumber, err) + } + if len(code) == 0 || len(code) > frostRetainedGroupMaximumContractCodeBytes || + 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 + } + } + 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, + at FrostPreSignFinality, + evidence *frostRetainedGroupEvidenceProfile, +) (uint32, error) { + if evidence == nil || operator == (common.Address{}) { + return 0, fmt.Errorf("operator-resolution evidence is incomplete") + } + deployment := evidence.deployments["frostSortitionPool"] + descriptor, err := frostRetainedGroupDeploymentDescriptorAt( + deployment, + at.BlockNumber, + at.BlockHash, + false, + ) + if err != nil { + return 0, err + } + if err := source.authenticateContractDeployment( + ctx, + descriptor, + at.BlockNumber, + at.BlockHash, + 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 := common.Address(descriptor.Address) + requestContext, cancel := source.requestContext(ctx) + output, err := source.verifier.CallContractAtHash( + requestContext, + ethereum.CallMsg{To: &to, Data: callData}, + common.Hash(at.BlockHash), + ) + cancel() + 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..2d9ccfb0ef --- /dev/null +++ b/pkg/tbtc/frost_retained_group_history_source.go @@ -0,0 +1,1913 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "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/common/hexutil" + "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/v5" + frostRetainedGroupOperatorReceiptSchema = "tbtc-frost-retained-group-operator-receipt/v4" + frostRetainedGroupHistoryRequestSchema = "tbtc-frost-retained-group-history-request/v4" + frostRetainedGroupOperatorRequestSchema = "tbtc-frost-retained-group-operator-request/v4" + frostRetainedGroupHistorySignatureDomain = "tbtc-frost-retained-group-export-signature-v5\x00" + frostRetainedGroupHistoryQueryDomain = "tbtc-frost-retained-group-history-query-v4\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" + + 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, +// receipt-complete retained-group history service. ExportURL and EthereumURL +// must be distinct from each other and from the primary Ethereum endpoint. The +// 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 + 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:"-"` + PrimaryTLSRootCAs *x509.CertPool `mapstructure:"-"` + Resolver *net.Resolver `mapstructure:"-"` +} + +// 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() +} + +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 +} + +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 +// not use eth_getLogs: generic RPC providers cannot prove a capped response is +// complete. +type signedFrostRetainedGroupHistorySource struct { + exportEndpoint *url.URL + verifier frostRetainedGroupEthereumVerifier + httpClient frostRetainedGroupHTTPClient + httpTransports []*http.Transport + independenceMonitor *frostRetainedGroupIndependenceMonitor + chainID uint64 + identity FrostRetainedGroupHistoryIdentity + trustedSignerKeyHash [32]byte + evidenceMutex sync.RWMutex + evidence *frostRetainedGroupEvidenceProfile + maximumPages uint64 + maximumMutations uint64 + maximumResponseBytes uint64 + maximumUniqueBlocks uint64 + maximumReadDuration time.Duration + requestTimeout time.Duration +} + +var _ FrostRetainedGroupHistorySource = (*signedFrostRetainedGroupHistorySource)(nil) +var _ FrostPreSignEthereumEvidenceVerifierSource = (*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"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + Signature string `json:"signature"` +} + +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 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"` + LiftProtocolID string `json:"liftProtocolID"` + 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"` + 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"` + TransactionHash string `json:"transactionHash"` + TransactionIndex uint32 `json:"transactionIndex"` + 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"` + LiftCertificateHash string `json:"liftCertificateHash"` + LiftCertificate *frostRetainedGroupWireQuarantineLiftCertificate `json:"liftCertificate"` + Reason string `json:"reason"` +} + +type frostRetainedGroupHistoryQuery struct { + Schema string `json:"schema"` + BindingHash string `json:"bindingHash"` + From frostRetainedGroupWireFinality `json:"from"` + To frostRetainedGroupWireFinality `json:"to"` +} + +type frostRetainedGroupHistoryPageRequest struct { + 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"` + 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"` + CheckpointAfter frostRetainedGroupWireCheckpointCursor `json:"checkpointAfter"` + Mutations []frostRetainedGroupWireMutation `json:"mutations"` + NextCursor string `json:"nextCursor"` + Complete bool `json:"complete"` + Receipt *frostRetainedGroupHistoryReceipt `json:"receipt"` +} + +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"` + OperatorAddress string `json:"operatorAddress"` + At frostRetainedGroupWireFinality `json:"at"` + OperatorID uint32 `json:"operatorID"` + Found bool `json:"found"` +} + +// 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( + identity FrostRetainedGroupHistoryIdentity, +) ([32]byte, error) { + if err := validateFrostRetainedGroupHistoryIdentity(identity); err != nil { + return [32]byte{}, err + } + return computeFrostRetainedGroupSourceEndpointFingerprint(identity), 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, + primaryTransport *FrostPrimaryEthereumTransport, + expectedChainID uint64, +) (*signedFrostRetainedGroupHistorySource, error) { + if ctx == nil { + return nil, fmt.Errorf("retained-group history context is nil") + } + validated, err := validateAndResolveFrostRetainedGroupSourceConfig( + ctx, + config, + primaryTransport, + ) + if err != nil { + return nil, err + } + 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 := + newFrostRetainedGroupAttestedHTTPClientWithSeparationPolicy( + validated.verifierEndpoint, + validated.identity.Verifier, + validated.rootCAs, + validated.requestTimeout, + separationPolicy, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot configure independent retained-group Ethereum verifier transport: [%w]", + err, + ) + } + rpcClient, err := rpc.DialOptions( + ctx, + validated.verifierEndpoint.canonical, + rpc.WithHTTPClient(rpcHTTPClient), + ) + 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, + } + exportHTTPClient, exportTransport, err := + newFrostRetainedGroupAttestedHTTPClientWithSeparationPolicy( + validated.exportEndpoint, + validated.identity.Export, + validated.rootCAs, + validated.requestTimeout, + separationPolicy, + ) + if err != nil { + verifier.Close() + rpcTransport.CloseIdleConnections() + return nil, fmt.Errorf( + "cannot configure retained-group export transport: [%w]", + err, + ) + } + source, err := newSignedFrostRetainedGroupHistorySource( + ctx, + validated.exportEndpoint.endpoint, + verifier, + exportHTTPClient, + expectedChainID, + validated.identity, + validated.identity.HistorySignerKeyHash, + validated.requestTimeout, + &frostRetainedGroupIndependenceMonitor{ + exportEndpoint: validated.exportEndpoint, + verifierEndpoint: validated.verifierEndpoint, + primaryTransport: primaryTransport, + }, + ) + if err != nil { + verifier.Close() + rpcTransport.CloseIdleConnections() + exportTransport.CloseIdleConnections() + return nil, err + } + source.httpTransports = []*http.Transport{exportTransport, rpcTransport} + return source, nil +} + +func newSignedFrostRetainedGroupHistorySource( + ctx context.Context, + exportEndpoint *url.URL, + verifier frostRetainedGroupEthereumVerifier, + httpClient frostRetainedGroupHTTPClient, + chainID uint64, + identity FrostRetainedGroupHistoryIdentity, + signerHash [32]byte, + requestTimeout time.Duration, + independenceMonitor *frostRetainedGroupIndependenceMonitor, +) (*signedFrostRetainedGroupHistorySource, error) { + if exportEndpoint == nil || verifier == nil || httpClient == nil || chainID == 0 || + 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) + 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, + independenceMonitor: independenceMonitor, + chainID: chainID, + identity: identity, + trustedSignerKeyHash: signerHash, + maximumPages: frostRetainedGroupMaximumPages, + maximumMutations: frostRetainedGroupMaximumMutations, + 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) + } + return source, nil +} + +func (source *signedFrostRetainedGroupHistorySource) Close() { + if source == nil { + return + } + if source.verifier != nil { + source.verifier.Close() + } + for _, transport := range source.httpTransports { + if transport != nil { + transport.CloseIdleConnections() + } + } +} + +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) { + return context.WithTimeout(ctx, source.requestTimeout) +} + +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") + } + if err := source.independenceMonitor.verify(ctx); err != nil { + return FrostPreSignFinality{}, err + } + requestContext, cancel := source.requestContext(ctx) + defer cancel() + header, err := source.verifier.HeaderByNumber( + requestContext, + 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") + } + requestContext, cancel := source.requestContext(ctx) + defer cancel() + header, err := source.verifier.HeaderByHash( + requestContext, + common.Hash(point.BlockHash), + ) + 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") + } + 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") + } + return nil +} + +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") + } + 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") + } + 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 + } + 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 + } + 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, + BindingHash: frostActivationHex32(evidence.bindingHash), + From: frostRetainedGroupFinalityToWire(from), + To: frostRetainedGroupFinalityToWire(to), + } + queryHash, err := frostRetainedGroupDomainHash( + frostRetainedGroupHistoryQueryDomain, + query, + ) + if err != nil { + return nil, err + } + + mutations := make([]FrostRetainedGroupMutation, 0) + mutationHashes := make([][32]byte, 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 + 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{ + BindingHash: frostActivationHex32(evidence.bindingHash), + Query: query, + CheckpointAfter: frostRetainedGroupWireCheckpointCursor{ + Sequence: checkpointAfter.Sequence, + CertificateHash: frostActivationHex32( + checkpointAfter.CertificateHash, + ), + }, + Cursor: cursor, + } + payload := &frostRetainedGroupHistoryPagePayload{} + 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, + from, + to, + cursor, + pageIndex, + previousPageHash, + snapshotID, + descriptorSetHash, + checkpointAfter, + ) + 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 { + 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) + } + 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) + mutationHashes = append( + mutationHashes, + sha256.Sum256(canonicalMutation), + ) + if uint64(len(mutations)) > source.maximumMutations { + 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)) || + payload.Receipt.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) + if err != nil { + return nil, fmt.Errorf("retained-group history receipt root is invalid") + } + computedRoot := frostRetainedGroupHistoryRootFromHashes( + evidence.bindingHash, + queryHash, + mutationHashes, + ) + 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, + HistoryRoot: receiptRoot, + CheckpointAfter: checkpointAfter, + Checkpoints: checkpoints, + CheckpointChainRoot: checkpointChainRoot, + CheckpointTipHash: checkpointTipHash, + CheckpointComplete: payload.Receipt.CheckpointComplete, + Complete: true, + EmptyAtFrom: true, + DescriptorSetHash: descriptorSetHash, + } + if err := validateCompleteFrostRetainedGroupHistory( + history, + evidence.liftPolicy, + ); 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 + 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{ + 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, + checkpointAfter FrostRetainedGroupCheckpointCursor, +) ([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) { + 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") + } + 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) + 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") + } + 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 uint64(len(payload.Mutations)) > source.maximumMutations { + 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") + } + resolveContext, cancel := context.WithTimeout(ctx, source.maximumReadDuration) + defer cancel() + ctx = resolveContext + 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, + BindingHash: frostActivationHex32(evidence.bindingHash), + 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.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 { + 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{}, +) (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) + if err != nil { + return 0, err + } + endpoint := *source.exportEndpoint + endpoint.Path = path.Join(endpoint.Path, operation) + request, err := http.NewRequestWithContext( + requestContext, + http.MethodPost, + endpoint.String(), + bytes.NewReader(requestBody), + ) + if err != nil { + 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 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, &frostRetainedGroupHistoryStatusError{ + statusCode: response.StatusCode, + } + } + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err != nil || mediaType != "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 0, err + } + if len(data) == 0 || len(data) > frostRetainedGroupMaximumResponseBytes { + return 0, fmt.Errorf("retained-group export response size is invalid") + } + envelope := &frostRetainedGroupSignedEnvelope{} + if err := decodeStrictFrostActivationJSON(data, envelope); err != nil { + return 0, fmt.Errorf("cannot decode retained-group signed envelope: [%w]", err) + } + if err := source.verifySignedEnvelope(envelope, responsePayload); err != nil { + return 0, err + } + return uint64(len(data)), nil +} + +func (source *signedFrostRetainedGroupHistorySource) verifySignedEnvelope( + envelope *frostRetainedGroupSignedEnvelope, + target interface{}, +) error { + evidence, evidenceErr := source.activationEvidence() + if envelope == nil || evidenceErr != nil || + 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") + } + 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 := 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") + } + 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 := decodeCanonicalFrostRetainedGroupBase64( + 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(canonical, target); err != nil { + return fmt.Errorf("cannot decode retained-group signed payload: [%w]", err) + } + return nil +} + +func (source *signedFrostRetainedGroupHistorySource) validWireIdentity( + identity frostRetainedGroupWireIdentity, +) bool { + parsed, err := frostRetainedGroupIdentityFromWire(identity) + return err == nil && parsed == source.identity +} + +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( + bindingHash [32]byte, + 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( + 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++ { + count[7-i] = byte(uint64(len(mutationHashes)) >> (i * 8)) + } + hasher.Write(count) + for _, mutationHash := range mutationHashes { + hasher.Write(mutationHash[:]) + } + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result +} + +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 + } + liftCertificateHash, err := parseFrostActivationHex32( + mutation.LiftCertificateHash, + ) + if err != nil { + return FrostRetainedGroupMutation{}, err + } + liftCertificate, err := frostRetainedGroupLiftCertificateFromWire( + mutation.LiftCertificate, + ) + 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...) + for _, operatorID := range operatorIDs { + if operatorID == 0 { + return FrostRetainedGroupMutation{}, fmt.Errorf("retained-group mutation has a zero operator ID") + } + } + 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, + LiftCertificateHash: liftCertificateHash, + LiftCertificate: liftCertificate, + 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), + 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") + } + 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 + } + 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 +} + +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..4049a2ca39 --- /dev/null +++ b/pkg/tbtc/frost_retained_group_history_source_test.go @@ -0,0 +1,2732 @@ +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" + "errors" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "strings" + "sync" + "testing" + "time" + + 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" +) + +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 + storage map[common.Address]map[common.Hash][]byte + sortitionPool common.Address + operator common.Address + operatorAt uint64 + operatorID uint32 + 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) { + 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) 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() + 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) FilterLogs( + context.Context, + ethereum.FilterQuery, +) ([]types.Log, error) { + return nil, nil +} + +func (verifier *frostRetainedGroupHistoryTestVerifier) CodeAtHash( + _ context.Context, + address common.Address, + 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") + } + return append([]byte{}, code...), nil +} + +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, + blockHash common.Hash, +) ([]byte, error) { + 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[:]) { + 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 + 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( + responseWriter http.ResponseWriter, + request *http.Request, +) { + if request.Method != http.MethodPost { + 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(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(bytes.NewReader(requestBody)).Decode(&operatorRequest); err != nil { + http.Error(responseWriter, "request", http.StatusBadRequest) + return + } + payload = export.operatorResponder(operatorRequest) + default: + http.NotFound(responseWriter, request) + return + } + if payload == nil { + request.Body = io.NopCloser(bytes.NewReader(requestBody)) + export.writeAttestedResponse( + responseWriter, + request, + http.StatusServiceUnavailable, + "text/plain", + []byte("missing\n"), + ) + 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/v3", + BindingHash: frostActivationHex32(export.bindingHash), + Payload: json.RawMessage(canonical), + PayloadSHA256: frostActivationHex32(payloadHash), + SignerPublicKeySPKI: base64.StdEncoding.EncodeToString(export.publicKeyDER), + SignatureAlgorithm: "ed25519", + Signature: base64.StdEncoding.EncodeToString(signature), + } + 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) + } +} + +type frostRetainedGroupHistorySourceFixture struct { + t *testing.T + verifier *frostRetainedGroupHistoryTestVerifier + export *frostRetainedGroupHistoryTestExport + server *httptest.Server + source *signedFrostRetainedGroupHistorySource + identity FrostRetainedGroupHistoryIdentity + profile FrostPreSignActivationProfile + runtimeManifest FrostPreSignActivationRuntimeManifest + from FrostPreSignFinality + to FrostPreSignFinality + descriptorSetHash [32]byte + snapshotID [32]byte + mutations []FrostRetainedGroupMutation + dkgFullMembers []uint32 + dkgMisbehaved []uint8 + 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 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 { + 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) + } + 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{ + 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, runtimeManifest := frostRetainedGroupHistoryTestProfile( + t, + bridgeCode, + registryCode, + 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), + finalized: headers[10], + headers: headers, + 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, + 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, + 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) + } + 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{ + 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) + } + source, err := newSignedFrostRetainedGroupHistorySource( + context.Background(), + endpoint, + verifier, + exportHTTPClient, + 1, + identity, + identity.HistorySignerKeyHash, + frostRetainedGroupDefaultTimeout, + &frostRetainedGroupIndependenceMonitor{ + exportEndpoint: resolvedEndpoint, + verifierEndpoint: resolvedVerifier, + primaryTransport: &testFrostPrimaryEthereumIndependenceVerifier{}, + }, + ) + if err != nil { + t.Fatal(err) + } + source.httpTransports = []*http.Transport{exportTransport} + descriptorSetHash := [32]byte{0x42} + if err := source.BindFrostRetainedGroupActivationEvidence( + profile, + runtimeManifest, + ); err != nil { + t.Fatal(err) + } + export.bindingHash = source.evidence.bindingHash + t.Cleanup(source.Close) + fixture := &frostRetainedGroupHistorySourceFixture{ + t: t, + verifier: verifier, + export: export, + server: server, + 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, + snapshotID: [32]byte{0x53}, + } + fixture.mutations = frostRetainedGroupHistoryTestMutations(t, headers, source.evidence) + fixture.dkgFullMembers = append( + []uint32{}, + 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 +} + +func frostRetainedGroupHistoryTestProfile( + t *testing.T, + bridgeCode []byte, + registryCode []byte, + sortitionPoolCode []byte, + deploymentBlockHash common.Hash, +) (FrostPreSignActivationProfile, FrostPreSignActivationRuntimeManifest) { + t.Helper() + emptyLinkedLibraryDescriptorHash, err := + frostRetainedGroupLinkedLibraryInventoryHash( + []FrostPreSignLinkedLibraryEvidence{}, + ) + if err != nil { + t.Fatal(err) + } + profile := FrostPreSignActivationProfile{ + DomainChainID: [32]byte{31: 0x01}, + ActivationManifestHash: [32]byte{0x02}, + 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(), + } + 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() + linkedLibraryDescriptorSetHash, err := + frostRetainedGroupLinkedLibraryDescriptorSetHash(deployments) + 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, + 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, + AttestationSignerKeyHash: [32]byte{0x37}, + 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}, + LiftProtocolID: [32]byte{0x4b}, + TombstoneProtocolID: [32]byte{0x4c}, + CheckpointAuthorityThreshold: 2, + CheckpointAuthorities: checkpointAuthorities, + CheckpointMinimumSequence: 1, + CheckpointPredecessorHash: [32]byte{}, + LiftAuthorityThreshold: 2, + LiftAuthorities: liftAuthorities, + StoreID: "quarantine-test-store", + StoreFingerprint: [32]byte{0x49}, + ClusterFingerprint: [32]byte{0x4a}, + }, + } +} + +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) { + 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() + 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: append([]uint8{}, misbehaved...), + Signatures: []byte{0x01, 0x02}, + SigningMembersIndices: []*big.Int{big.NewInt(1)}, + Members: append([]uint32{}, fullMembers...), + MembersHash: activeMembersHash, + } + 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) + } + 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, + evidence, + admission.WalletID, + 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, + registryAddress, + []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, + registryAddress, + []common.Hash{ + evidence.registryABI.Events["DkgResultApproved"].ID, + common.Hash(admission.DkgResultHash), + common.BytesToHash(common.HexToAddress("0x2222222222222222222222222222222222222222").Bytes()), + }, + nil, + ) + creationLog := frostRetainedGroupHistoryTestLog( + admission.CreationPoint, + registryAddress, + []common.Hash{ + evidence.registryABI.Events["WalletCreated"].ID, + common.Hash(admission.WalletID), + common.Hash(admission.DkgResultHash), + }, + nil, + ) + registrationLog := frostRetainedGroupHistoryTestLog( + admission.BridgeRegistrationPoint, + bridgeAddress, + []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, + bridgeAddress, + []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, + bridgeAddress, + []common.Hash{ + evidence.bridgeABI.Events["WalletClosed"].ID, + {}, + frostRetainedGroupBytes20Topic(closed.WalletPublicKeyHash), + }, + nil, + ) + registryClosureLog := frostRetainedGroupHistoryTestLog( + registryClosure.Point, + registryAddress, + []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 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, + 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, + checkpointAfterWire frostRetainedGroupWireCheckpointCursor, +) []*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) + } + 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 + } + pages := make([]*frostRetainedGroupHistoryPagePayload, pageCount) + previousPageHash := [32]byte{} + identity := frostRetainedGroupIdentityToWire(fixture.identity) + 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, + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), + 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), + CheckpointAfter: checkpointAfterWire, + 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( + 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), + CheckpointAfter: checkpointAfterWire, + CheckpointCertificates: wireCheckpoints, + CheckpointChainRoot: frostActivationHex32( + frostRetainedGroupCheckpointChainRoot( + fixture.source.evidence.bindingHash, + checkpointAfter, + checkpointHashes, + ), + ), + CheckpointTipHash: frostActivationHex32(checkpointTipHash), + CheckpointComplete: checkpointComplete, + } + return pages +} + +func (fixture *frostRetainedGroupHistorySourceFixture) historyResponse( + request frostRetainedGroupHistoryPageRequest, +) interface{} { + if request.BindingHash != + frostActivationHex32(fixture.source.evidence.bindingHash) { + return nil + } + pages := fixture.historyPages(request.Query, request.CheckpointAfter) + 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, + BindingHash: frostActivationHex32( + fixture.source.evidence.bindingHash, + ), + Identity: frostRetainedGroupIdentityToWire(fixture.identity), + 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, + fixture.checkpointAfter(), + ) + 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_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, +) { + 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 + }, + "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 + }, + "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, + ) { + 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 { + 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, +) { + 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/v3", + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), + 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_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/v3", + 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, + fixture.checkpointAfter(), + ) + 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/v3", + 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), + 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/v2"}`) + 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/v3", + BindingHash: frostActivationHex32(fixture.source.evidence.bindingHash), + 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{} { + pages := fixture.historyPages( + request.Query, + request.CheckpointAfter, + ) + return pages[len(pages)-1] + } + _, 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) + } +} + +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, + fixture.checkpointAfter(), + ) + 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) { + fixture := newFrostRetainedGroupHistorySourceFixture(t) + fixture.export.corruptSignature = true + _, 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) + } + }) +} + +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, + fixture.checkpointAfter(), + ) + if err == nil || !strings.Contains(err.Error(), testCase.expected) { + t.Fatalf("expected %s rejection, got [%v]", name, err) + } + }) + } +} + +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, +) { + 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, + fixture.checkpointAfter(), + ) + 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, +) { + 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] + }, + } + 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, + fixture.checkpointAfter(), + ) + if err == nil { + t.Fatal("expected malformed exact history to be rejected") + } + }) + } +} + +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, + fixture.checkpointAfter(), + ) + 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, + fixture.checkpointAfter(), + ) + 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, +) { + 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}) + }, + "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}) + } + }, + "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, + fixture.checkpointAfter(), + ) + 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, + fixture.checkpointAfter(), + ) + 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, + fixture.checkpointAfter(), + ) + 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, + fixture.checkpointAfter(), + ) + if err == nil || !strings.Contains(err.Error(), "signed activation manifest") { + t.Fatalf("expected manifest code-drift rejection, got [%v]", err) + } +} + +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, +) { + 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) + 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, + fixture.checkpointAfter(), + ) + 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 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) { + 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 new file mode 100644 index 0000000000..6d1fbff0bd --- /dev/null +++ b/pkg/tbtc/frost_retained_group_journal.go @@ -0,0 +1,4526 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "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/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" + frostRetainedGroupJournalLockFile = ".lock" + frostRetainedGroupJournalMetadataFile = "metadata.json" + frostRetainedGroupJournalStateFile = "state.json" + frostRetainedGroupJournalBatchPrefix = "batch-" + frostRetainedGroupLiftCertificatePrefix = "lift-certificate-" + frostRetainedGroupJournalFileSuffix = ".json" + frostRetainedGroupJournalTempSuffix = ".tmp" + 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" + + 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" + 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" + 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-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 +) + +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. +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" +) + +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 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"` + 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 FrostRetainedGroupHistory struct { + 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 +// 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, + FrostRetainedGroupCheckpointCursor, + ) (*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 + SourceIdentity FrostRetainedGroupHistoryIdentity + MinimumGeneration uint64 +} + +type FrostRetainedGroupQuarantineJournalManifest struct { + ProtocolID [32]byte + LiftProtocolID [32]byte + TombstoneProtocolID [32]byte + CheckpointAuthorityThreshold uint64 + CheckpointAuthorities []FrostRetainedGroupAuthority + CheckpointMinimumSequence uint64 + CheckpointPredecessorHash [32]byte + 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", + 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{} { + 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, + ) + } + 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, + ) + 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 { + 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 { + 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 { + 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 { + Schema string `json:"schema"` + BindingHash [32]byte `json:"bindingHash"` + 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"` + BindingHash [32]byte `json:"bindingHash"` + 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"` + 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"` + ActiveRoot [32]byte `json:"activeRoot"` + TombstoneRoot [32]byte `json:"tombstoneRoot"` + Quarantines []frostRetainedGroupQuarantineState `json:"quarantines"` + Tombstones []frostRetainedGroupQuarantineTombstone `json:"tombstones"` +} + +type frostRetainedGroupQuarantineJournalBatch struct { + Schema string `json:"schema"` + BindingHash [32]byte `json:"bindingHash"` + 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 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"` +} + +// 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 + 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 + QuarantineActiveRoot [32]byte + QuarantineTombstoneRoot [32]byte + QuarantineCount uint64 + ActiveQuarantines []frostRetainedGroupActiveQuarantine + QuarantineTombstoneCount uint64 + CheckpointMinimumSequence uint64 + CheckpointPredecessorHash [32]byte + CheckpointSequence uint64 + CheckpointCertificateHash [32]byte + CheckpointHistoryRoot [32]byte + LocalSessionCount uint64 + Complete bool +} + +type frostOrphanedDKGReconcilerFunc func( + context.Context, + FrostPreSignFinality, + map[[32]byte]struct{}, +) error + +type frostRetainedGroupJournal struct { + 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 + orphanedDKGReconciler frostOrphanedDKGReconcilerFunc + persistFailureHook func(string) error + checkpointPersistFailureHook func(string) error + closed bool +} + +func newFrostRetainedGroupJournal( + directory string, + bindingHash [32]byte, + runtimeManifest FrostPreSignActivationRuntimeManifest, + source FrostRetainedGroupHistorySource, + walletRegistry *walletRegistry, + operatorAddress chain.Address, +) (*frostRetainedGroupJournal, error) { + 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, + ) + } + 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{} || + 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{} || + 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{} || + 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) + 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) + } + 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 + } + 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, + BindingHash: bindingHash, + StoreID: manifest.StoreID, + StoreFingerprint: manifest.StoreFingerprint, + ClusterFingerprint: manifest.ClusterFingerprint, + Checkpoint: manifest.Checkpoint, + DescriptorSetHash: manifest.DescriptorSetHash, + SourceTrustDomainID: manifest.SourceTrustDomainID, + SourceEndpointFingerprint: manifest.SourceEndpointFingerprint, + SourceOperatorFingerprint: manifest.SourceOperatorFingerprint, + SourceIdentity: manifest.SourceIdentity, + }, + quarantineMetadata: frostRetainedGroupQuarantineMetadata{ + 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, + Checkpoint: manifest.Checkpoint, + }, + minimumGeneration: manifest.MinimumGeneration, + quarantineMinimumGeneration: quarantineManifest.MinimumGeneration, + source: source, + walletRegistry: walletRegistry, + 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() + 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.Name() != frostRetainedGroupCheckpointDirectory) || + !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.checkpointLockFile, + 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 + frgj.checkpointLockFile = nil + return result +} + +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 { + 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) + 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.Schema == frostRetainedGroupJournalMetadataSchemaV1 || + stored.Schema == frostRetainedGroupJournalMetadataSchemaV2 || + stored.Schema == frostRetainedGroupJournalMetadataSchemaV3 { + return frostRetainedGroupLegacySchemaError("canonical metadata") + } + 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, + BindingHash: frgj.metadata.BindingHash, + 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 == frostRetainedGroupJournalStateSchemaV1 || + stored.Schema == frostRetainedGroupJournalStateSchemaV2 { + return frostRetainedGroupLegacySchemaError("canonical state") + } + 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) + } + } + if err := frgj.initializeQuarantine(); err != nil { + return err + } + return frgj.initializeCheckpointJournal() +} + +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 || + identity != frgj.metadata.SourceIdentity { + 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 { + 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) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + metadataExists := false + stateExists := false + batchNames := make([]string, 0) + 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 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.HasPrefix(name, frostRetainedGroupLiftCertificatePrefix) && + strings.HasSuffix(name, frostRetainedGroupJournalFileSuffix): + certificateNames = append(certificateNames, 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.Schema == frostRetainedGroupQuarantineMetadataV1 || + stored.Schema == frostRetainedGroupQuarantineMetadataV2 { + return frostRetainedGroupLegacySchemaError("quarantine metadata") + } + 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 || len(certificateNames) != 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) + } + } + 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)), + ActiveRoot: emptyActiveRoot, + TombstoneRoot: emptyTombstoneRoot, + Quarantines: []frostRetainedGroupQuarantineState{}, + Tombstones: []frostRetainedGroupQuarantineTombstone{}, + } + 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 == frostRetainedGroupQuarantineStateV1 || + stored.Schema == frostRetainedGroupQuarantineStateV2 { + return frostRetainedGroupLegacySchemaError("quarantine state") + } + 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) + } + wireBatch := frostRetainedGroupWireQuarantineJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + name, + &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 := 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 + 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 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( + "prior FROST retained-group %s store schema is not safely migratable; "+ + "the signed activation manifest must provision a new empty v3 store identity", + component, + ) +} + +func validateFrostRetainedGroupBatch( + batch frostRetainedGroupJournalBatch, + prior frostRetainedGroupJournalState, +) error { + if batch.Schema == frostRetainedGroupJournalBatchSchemaV1 || + batch.Schema == frostRetainedGroupJournalBatchSchemaV2 { + 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{} { + 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 == frostRetainedGroupQuarantineBatchV1 || + batch.Schema == frostRetainedGroupQuarantineBatchV2 { + 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{} { + return fmt.Errorf("quarantine batch header is invalid") + } + declared := batch.Checksum + batch.Checksum = [32]byte{} + payload, err := frostRetainedGroupQuarantineBatchCanonicalValue(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 { + // 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 || + 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 { + if err := validateFrostRetainedGroupJournalFileName(name); err != nil { + return err + } + 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 { + if err := validateFrostRetainedGroupJournalFileName(name); err != nil { + return err + } + 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 + } + // 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. + // + // 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]", + name, + len(envelopeBytes), + frostRetainedGroupJournalMaximumFile, + ) + } + + 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) + } + } + + temporary, temporaryName, err := createFrostRetainedGroupJournalTemporaryFileAt( + directoryDescriptor, + directory, + name, + ) + if err != nil { + return err + } + remove := true + defer func() { + if remove { + _ = unix.Unlinkat(directoryDescriptor, temporaryName, 0) + } + }() + 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 := unix.Linkat( + directoryDescriptor, + temporaryName, + directoryDescriptor, + name, + 0, + ); err != nil { + return fmt.Errorf("cannot publish immutable journal file [%s]: [%w]", name, err) + } + if err := unix.Unlinkat(directoryDescriptor, temporaryName, 0); err != nil { + return err + } + remove = false + return directoryFile.Sync() + } + if err := unix.Renameat( + directoryDescriptor, + temporaryName, + directoryDescriptor, + name, + ); err != nil { + return err + } + remove = false + 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, 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), + 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) + } + 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 +} + +// 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, + 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( + state *frostRetainedGroupJournalState, + mutations []FrostRetainedGroupMutation, +) error { + 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{} || + 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 { + 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.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") + } + 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") + } + 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, + } + publicKeyHashes[mutation.WalletPublicKeyHash] = mutation.WalletID + 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, + policy frostRetainedGroupQuarantineLiftPolicy, +) 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 { + 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[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 { + 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.WalletID == [32]byte{} || + mutation.EvidenceHash == [32]byte{} || + strings.TrimSpace(mutation.Reason) == "" || + 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{ + 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, 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") + } + 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 + } + } + 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].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 +} + +// 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, +) 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, +) 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)) + hasher.Write(state.Root[:]) + hasher.Write(leaf[:]) + copy(state.Root[:], hasher.Sum(nil)) + state.Generation++ + return nil +} + +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"` + 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...) + if mutations[index].LiftCertificate != nil { + certificate := *mutations[index].LiftCertificate + certificate.Signatures = append( + []FrostRetainedGroupQuarantineLiftSignature{}, + mutations[index].LiftCertificate.Signatures..., + ) + result[index].LiftCertificate = &certificate + } + } + 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 +} + +// 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, +) (*frostRetainedGroupJournalSnapshot, error) { + 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 { + 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 || + 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 { + 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) + } + } + checkpointAfter := FrostRetainedGroupCheckpointCursor{ + Sequence: frgj.checkpointState.Sequence, + CertificateHash: frgj.checkpointState.CertificateHash, + } + checkpointCursor := checkpointAfter + var history *FrostRetainedGroupHistory + checkpointHashes := make([][32]byte, 0) + checkpointCertificates := make( + []FrostRetainedGroupCheckpointCertificate, + 0, + ) + checkpointRecoveryComplete := false + for checkpointPage := 0; checkpointPage < + frostRetainedGroupCheckpointPagesPerReconciliation; 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 history == nil { + return nil, fmt.Errorf( + "FROST retained-group history source returned no checkpoint page", + ) + } + 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) { + 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, + BindingHash: frgj.metadata.BindingHash, + 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...) + } + if frgj.checkpointPersistFailureHook != nil { + if err := frgj.checkpointPersistFailureHook( + "after-canonical-before-quarantine", + ); err != nil { + return nil, err + } + } + 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, + 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, + Sequence: frgj.quarantineState.BatchSequence + 1, + From: frgj.quarantineState.CurrentPoint, + To: target, + PriorBatchRoot: frgj.quarantineState.BatchRoot, + Mutations: quarantineSuffix, + } + checksumPayload, err := frostRetainedGroupQuarantineBatchCanonicalValue( + 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, + ) + wireBatch, err := frostRetainedGroupQuarantineBatchToWire(batch) + if err != nil { + return nil, err + } + if err := persistFrostRetainedGroupEnvelopeAt( + frgj.quarantineDirectory, + frostRetainedGroupBatchFileName(batch.Sequence), + &wireBatch, + 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...) + } + 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 + } + 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, 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, withCheckpointRecoveryProgress(fmt.Errorf( + "independent finalized head changed inconsistently during journal replay", + )) + } + if afterHead.BlockNumber == target.BlockNumber && afterHead.BlockHash != target.BlockHash { + 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, withCheckpointRecoveryProgress(fmt.Errorf( + "cannot verify independent finalized head after journal replay: [%w]", + err, + )) + } + if err := frgj.source.VerifyPoint(ctx, target); err != nil { + return nil, withCheckpointRecoveryProgress(fmt.Errorf( + "challenged FROST retained-group point changed during replay: [%w]", + err, + )) + } + if !history.CheckpointComplete { + if !checkpointRecoveryAdvanced { + return nil, fmt.Errorf( + "incomplete FROST checkpoint recovery did not advance the durable head", + ) + } + 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, + target, + 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 + } + 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") + } + // 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{} || + frgj.quarantineState.ActiveRoot == [32]byte{} || + 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, + 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, + QuarantineActiveRoot: frgj.quarantineState.ActiveRoot, + QuarantineTombstoneRoot: frgj.quarantineState.TombstoneRoot, + QuarantineCount: frostRetainedGroupActiveQuarantineCount(frgj.quarantineState), + 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 +} + +func validateCompleteFrostRetainedGroupHistory( + history *FrostRetainedGroupHistory, + liftPolicy frostRetainedGroupQuarantineLiftPolicy, +) 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 || + 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) + } + 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, + 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) + } + 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..., + ) + result.Tombstones = append( + []frostRetainedGroupQuarantineTombstone{}, + state.Tombstones..., + ) + return result +} + +type frostLocalSessionSnapshot struct { + WalletID [32]byte + WalletPublicKeyHash [20]byte + KeyGroup string + 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() + 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 { + 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 { + 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") + } + } + 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") + } + 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..fca4af122a --- /dev/null +++ b/pkg/tbtc/frost_retained_group_journal_test.go @@ -0,0 +1,4453 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "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 + finalizedHeadErr error + 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( + context.Context, +) (FrostRetainedGroupHistoryIdentity, error) { + return jhs.identity, nil +} + +func (jhs *journalHistorySource) FinalizedHead( + context.Context, +) (FrostPreSignFinality, error) { + if jhs.finalizedHeadErr != nil { + return FrostPreSignFinality{}, jhs.finalizedHeadErr + } + 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, + checkpointAfter FrostRetainedGroupCheckpointCursor, +) (*FrostRetainedGroupHistory, error) { + mutations := make([]FrostRetainedGroupMutation, 0) + for _, mutation := range jhs.mutations { + if mutation.Point.BlockNumber <= to.BlockNumber { + 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), + 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 +} + +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 + 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 { + 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()} + 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, + signingGroupOperators: append([]chain.Address{}, operatorAddrs...), + }, + signingGroupMemberIndex: group.MemberIndex(7), + signerMaterial: &frostsigning.NativeSignerMaterial{ + Format: frostsigning.NativeSignerMaterialFormatFrostTBTCSignerV1, + Payload: signerMaterialPayload, + }, + } + registry := &walletRegistry{ + walletCache: map[string]*walletCacheValue{ + "wallet": { + walletPublicKeyHash: walletPKH, + walletID: walletID, + signers: []*signer{localSigner}, + }, + }, + } + sourceIdentity := testFrostRetainedGroupCompleteIdentity() + manifest := FrostRetainedGroupCanonicalJournalManifest{ + StoreID: "journal-store-uuid", + StoreFingerprint: [32]byte{0x31}, + ClusterFingerprint: [32]byte{0x32}, + Checkpoint: checkpoint, + DescriptorSetHash: [32]byte{0x33}, + SourceTrustDomainID: sourceIdentity.TrustDomainID, + SourceEndpointFingerprint: sourceIdentity.EndpointFingerprint, + SourceOperatorFingerprint: sourceIdentity.OperatorFingerprint, + SourceIdentity: sourceIdentity, + MinimumGeneration: 1, + } + checkpointAuthorities := make([]FrostRetainedGroupAuthority, 3) + checkpointPrivateKeys := make([]ed25519.PrivateKey, 3) + checkpointPublicKeySPKIs := make([]string, 3) + for index := range checkpointAuthorities { + 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) + 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}, + LiftProtocolID: [32]byte{0x45}, + TombstoneProtocolID: [32]byte{0x46}, + CheckpointAuthorityThreshold: 2, + CheckpointAuthorities: checkpointAuthorities, + CheckpointMinimumSequence: 1, + CheckpointPredecessorHash: [32]byte{}, + 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: sourceIdentity, + 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}, + 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}, + } + 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, + 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) { + 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) + } + + 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 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, +) *frostRetainedGroupJournal { + t.Helper() + journal, err := newFrostRetainedGroupJournal( + directory, + fixture.bindingHash, + fixture.runtime, + fixture.source, + fixture.registry, + fixture.localOperator, + ) + if err != nil { + t.Fatal(err) + } + return journal +} + +func (fixture *journalTestFixture) openJournalError( + directory string, +) error { + journal, err := newFrostRetainedGroupJournal( + directory, + fixture.bindingHash, + fixture.runtime, + fixture.source, + fixture.registry, + fixture.localOperator, + ) + if journal != nil { + _ = journal.close() + } + 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, +) { + 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] = 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) + } + 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_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_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, +) { + 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 = "0f7a902e61f4d2e47ab936440b865f693c403c263d17aeb9298a515830557d4a" + const expectedChainRoot = "a4bb6bd09d47673ef2476afcf6318c7508430623a20011feae3338f3c2302ec6" + 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) + const previousAggregateRecoveryLimit = 4096 + count := previousAggregateRecoveryLimit + 2 + cursor := FrostRetainedGroupCheckpointCursor{ + Sequence: fixture.quarantine.CheckpointMinimumSequence - 1, + CertificateHash: fixture.quarantine.CheckpointPredecessorHash, + } + var proofFloor 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 == count-1-frostRetainedGroupMaximumHandshakeAncestry { + proofFloor = cursor + } + } + fixture.source.head = FrostPreSignFinality{ + BlockNumber: target.BlockNumber + 1, + 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) + snapshot, err := journal.reconcile(context.Background(), target) + 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) || + restarted.checkpointState.Sequence != uint64(count) || + snapshot.CheckpointCertificateHash != cursor.CertificateHash { + t.Fatalf( + "multi-page recovery stopped at the wrong checkpoint: %+v", + snapshot, + ) + } + ancestry, err := restarted.checkpointAncestryFrom(proofFloor) + if err != nil { + t.Fatal(err) + } + if len(ancestry) != frostRetainedGroupMaximumHandshakeAncestry+1 { + t.Fatalf( + "long-lived ancestry proof was truncated at [%d] certificates", + len(ancestry), + ) + } + 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) + } +} + +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{ + 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.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, +) { + 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_RejectsAuthenticatedPriorSchemaFixtures( + t *testing.T, +) { + 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": { + 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": { + 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": { + 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": { + 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": { + 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 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) + } + + 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, + ) + } + }) + } + } +} + +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 := frostRetainedGroupWireQuarantineJournalBatch{} + if err := readFrostRetainedGroupEnvelopeAt( + directory, + batchName, + &batch, + ); err != nil { + t.Fatal(err) + } + batch.BindingHash = frostActivationHex32([32]byte{0xff}) + batch.Checksum = frostActivationHex32([32]byte{}) + payload, err := frostRetainedGroupCanonicalValue(batch) + if err != nil { + t.Fatal(err) + } + batch.Checksum = frostActivationHex32(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, +) { + 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 := 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) + 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.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_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, +) { + 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( + 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 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("checkpoint rejects a conflicting valid certificate rewrite", 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 + _, err := restarted.reconcile( + context.Background(), + fixture.later, + ) + 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.quarantineState.Tombstones) != 0 { + t.Fatal("conflicting certificate rewrite changed durable state") + } + }) +} + +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, +) { + 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 TestApplyFrostRetainedGroupMutations_AllowsRepeatedStakeWeightedSeats( + 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 { + 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, +) { + 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}}, + Mutations: make( + []FrostRetainedGroupMutation, + frostRetainedGroupMaximumMutations+1, + ), + } + err = validateCompleteFrostRetainedGroupHistory(history, policy) + 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, + 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.bindingHash, + fixture.runtime, + 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.bindingHash, + fixture.runtime, + 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.bindingHash, + fixture.runtime, + fixture.source, + fixture.registry, + fixture.localOperator, + ); err == nil || !strings.Contains(err.Error(), "unsafe entry") { + t.Fatalf("expected symlink rejection, got [%v]", err) + } +} + +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_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) + 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.bindingHash, + fixture.runtime, + 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.bindingHash, + fixture.runtime, + 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 +} + +// 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, + ) + } +} + +// 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) + } +} +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) + } +} + +// 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) + } +} 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..271d62304f --- /dev/null +++ b/pkg/tbtc/frost_retained_key_group_binding.go @@ -0,0 +1,372 @@ +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 + } + 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, + 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..c8ad3c1a7f --- /dev/null +++ b/pkg/tbtc/frost_retained_key_group_binding_test.go @@ -0,0 +1,292 @@ +package tbtc + +import ( + "crypto/ecdsa" + "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" +) + +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 { + privateKeyBytes := make([]byte, 32) + privateKeyBytes[len(privateKeyBytes)-1] = 1 + _, publicKey := btcec2.PrivKeyFromBytes(privateKeyBytes) + return publicKey.ToECDSA() +} + +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/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/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..017d6c9335 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" @@ -65,6 +66,15 @@ type node struct { netProvider net.Provider walletRegistry *walletRegistry + frostPreSignAuthorizationBackend FrostPreSignAuthorizationBackend + frostPreSignActivationProfile FrostPreSignActivationProfile + frostDurableSessionStoreBinding *frostDurableSessionStoreBinding + frostProductionSignerReadiness frostProductionSignerReadinessVerifier + frostNativeSignerAnchorAdmission *frostNativeSignerAnchorAdmissionController + bitcoinBroadcastOutbox *bitcoinBroadcastOutbox + frostRetainedGroupJournal *frostRetainedGroupJournal + 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,9 +241,703 @@ 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 == "" { + return nil, fmt.Errorf( + "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( + "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) + } + 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) + } + 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) + 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 + var trustRecoverySelector *signing.NativeTBTCSignerStateAnchorTrustRecoveryRequired + 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 { + 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 + // 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, + ) + } + 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{ + Endpoint: config.FrostNativeSignerAnchorURL, + RequestTimeout: config. + FrostNativeSignerAnchorRequestTimeout, + ClientPrivateKey: clientPrivateKey, + OnlinePublicKeySPKI: onlineKeySPKI, + Identity: anchorManifest.Identity, + }, + verifiedTrustFloor, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot initialize authenticated native signer anchor client: [%w]", + err, + ) + } + exactTrustHeadReplay := + isFrostNativeSignerAnchorTrustExactHeadReplay( + priorTrustCertificateHead, + expectedTrustCertificateHead, + ) + var trustTransitionTarget *FrostNativeSignerAnchorTrustTransitionTarget + if !exactTrustHeadReplay { + 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( + "cannot install FROST native signer anchor trust transition: [%w]", + err, + ) + } + if err := validateFrostNativeSignerAnchorTrustTransitionResult( + trustTransitionResult, + trustTransitionTarget, + finalTrustCertificate, + expectedTrustCertificateHead, + expectedTrustHead, + priorTrustCertificateHead, + transitionCertificateChain, + trustTransitionRecoveryReplay, + ); 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, + signing.RecoverNativeTBTCSignerStateWitnessCheckpoint, + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot bind authenticated native signer anchor: [%w]", + 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(), + ) + if err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot reconcile startup native signer anchor: [%w]", + err, + ) + } + if err := validateFrostNativeSignerAnchorReconciledTransitionTarget( + startupSignerTip, + trustTransitionTarget, + ); err != nil { + _ = outbox.close() + return nil, err + } + if err := signing.InstallNativeTBTCSignerStateAnchorBarrier( + signing.NativeTBTCSignerStateAnchorBarrierConfig{ + 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() + return nil, fmt.Errorf( + "cannot install native signer protocol-output barrier: [%w]", + err, + ) + } + inventoryBinding, err := newFrostNativeSignerInventoryBinding( + storeBinding, + anchorBinding, + signing.ReadNativeTBTCSignerRetainedKeyPackageInventory, + signing.ReadNativeTBTCSignerStateAnchorTrustHead, + expectedTrustHead, + ) + 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( + "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, + ); err != nil { + _ = outbox.close() + return nil, fmt.Errorf( + "cannot bind retained-group evidence to the authenticated activation manifest: [%w]", + 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, + retainedGroupBindingHash, + runtimeManifest, + 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 + 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, + 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", + ) + } + // 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 { + _ = 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, + runtimeManifest, + pointVerifier, + storeBinding, + outbox, + journal, + readiness, + ) + if err != nil { + _ = journal.close() + _ = outbox.close() + return nil, fmt.Errorf("cannot initialize FROST activation handshake: [%w]", err) + } + node.frostPreSignAuthorizationBackend = backend + node.frostPreSignActivationProfile = verifiedActivationProfile + node.frostDurableSessionStoreBinding = storeBinding + node.frostProductionSignerReadiness = observedReadiness + node.frostNativeSignerAnchorAdmission = anchorAdmission + node.bitcoinBroadcastOutbox = outbox + node.frostActivationHandshakeExporter = exporter + } + 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) } @@ -533,6 +1237,70 @@ 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.frostDurableSessionStoreBinding, + n.frostProductionSignerReadiness, + n.frostNativeSignerAnchorAdmission, + 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 + + // 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 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 + // 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)), + gate.maximumAttempts, + ); exceeded { + executorLogger.Warnf("%s", warning) + } + } + } // Wire metrics recorder if available if n.performanceMetrics != nil { @@ -544,6 +1312,78 @@ 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 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 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. +// +// 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 two numbers so it can be exercised without +// standing up a wallet, a gate, or a native build. +func frostPreSignLocalSeatCeilingWarning( + localSeatCount uint64, + maximumSigningAttempts uint64, +) (string, bool) { + // 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( + 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 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 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, + maximumSigningAttempts, + ), true + } + + return fmt.Sprintf( + "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, + localSeatCount, + admissibleSeats, + ), 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 @@ -638,6 +1478,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 +1647,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 { @@ -1471,13 +2323,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, ) } @@ -1495,16 +2347,48 @@ func (n *node) archiveClosedWallets() error { } if !isRegistered && n.frostWalletRegistryAvailable() { - 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", - walletID, - walletPublicKeyHash, - ) - continue + isFrostWallet, err := n.walletRegistry. + isFrostWalletByPublicKeyHash(walletPublicKeyHash) + if err != nil { + return fmt.Errorf( + "could not classify local wallet with public key hash "+ + "[0x%x]: [%v]", + walletPublicKeyHash, + err, + ) + } + 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 + } } archiveWallet = !isRegistered @@ -1718,8 +2602,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_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 517c72eb1b..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, + ) } } @@ -855,6 +865,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..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" @@ -47,23 +48,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 +145,7 @@ func newRedemptionAction( signingExecutor, waitForBlockFn, ) + transactionExecutor.action = ActionRedemption feeDistribution := withRedemptionTotalFee(proposal.RedemptionTxFee.Int64()) @@ -267,30 +252,25 @@ func (ra *redemptionAction) execute() error { ) } - _, _, _, redemptionTxMaxTotalFee, _, _, _, err := + _, _, _, txMaxTotalFee, _, _, _, 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]", + "error while obtaining redemption parameters: [%v]", err, ) } - - err = validateRedemptionTransactionFee( + if err := validateRedemptionTransactionTotalFee( unsignedRedemptionTx, - redemptionTxMaxTotalFee, - ) - if err != nil { + txMaxTotalFee, + ); err != nil { if ra.metricsRecorder != nil { ra.metricsRecorder.IncrementCounter(clientinfo.MetricRedemptionExecutionsFailedTotal, 1) } - return fmt.Errorf( - "error while validating redemption transaction fee: [%v]", - err, - ) + return fmt.Errorf("invalid redemption transaction fee: [%v]", err) } signTxLogger := ra.logger.With( @@ -305,6 +285,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 +535,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 @@ -607,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") + } } } @@ -625,25 +591,35 @@ func assembleRedemptionTransaction( return builder, nil } -func validateRedemptionTransactionFee( - transaction *bitcoin.TransactionBuilder, +func validateRedemptionTransactionTotalFee( + builder *bitcoin.TransactionBuilder, maxTotalFee uint64, ) error { - actualFee := transaction.TotalInputsValue() - for _, output := range transaction.UnsignedTransaction().Outputs { - actualFee -= output.Value + if builder == nil { + return fmt.Errorf("redemption transaction builder is nil") } - if actualFee < 0 { - return fmt.Errorf( - "redemption transaction fee is negative: [%v]", - actualFee, - ) + 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 fee [%v] exceeds maximum total fee [%v]", + "redemption transaction total fee [%d] exceeds maximum [%d]", actualFee, maxTotalFee, ) diff --git a/pkg/tbtc/redemption_test.go b/pkg/tbtc/redemption_test.go index 3d4ee79ff4..cbc8f61dae 100644 --- a/pkg/tbtc/redemption_test.go +++ b/pkg/tbtc/redemption_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/big" + "strings" "testing" "time" @@ -11,7 +12,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,69 +179,71 @@ func TestRedemptionAction_Execute(t *testing.T) { } } -func TestRedemptionAction_RejectsTransactionFeeAboveMaximumBeforeSigning( - t *testing.T, -) { - const mainUtxoValue = int64(24372) +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(111)) + redeemingWallet := generateWallet(big.NewInt(1)) walletPublicKey := redeemingWallet.publicKey walletPublicKeyHash := bitcoin.PublicKeyHash(walletPublicKey) walletScript, err := bitcoin.PayToWitnessPublicKeyHash(walletPublicKeyHash) if err != nil { t.Fatal(err) } - - fundingTx := &bitcoin.Transaction{ + fundingTransaction := &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, + Inputs: []*bitcoin.TransactionInput{{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, }, - }, + Sequence: 0xffffffff, + }}, + Outputs: []*bitcoin.TransactionOutput{{ + Value: 9293, + PublicKeyScript: walletScript, + }}, } - if err := bitcoinChain.BroadcastTransaction(fundingTx); err != nil { + if err := bitcoinChain.BroadcastTransaction( + fundingTransaction, + ); err != nil { t.Fatal(err) } - walletMainUtxo := &bitcoin.UnspentTransactionOutput{ Outpoint: &bitcoin.TransactionOutpoint{ - TransactionHash: fundingTx.Hash(), - OutputIndex: 0, + TransactionHash: fundingTransaction.Hash(), }, - Value: mainUtxoValue, + Value: 9293, } - 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, + RequestedAmount: 10000, + TreasuryFee: 1000, + TxMaxFee: 1000, RequestedAt: time.Now(), } - hostChain.setPendingRedemptionRequest(walletPublicKeyHash, request) - + hostChain.setPendingRedemptionRequest( + walletPublicKeyHash, + request, + ) proposal := &RedemptionProposal{ RedeemersOutputScripts: []bitcoin.Script{redeemerScript}, - RedemptionTxFee: big.NewInt(110), + RedemptionTxFee: big.NewInt(1000), } if err := hostChain.setRedemptionProposalValidationResult( walletPublicKeyHash, @@ -250,36 +252,41 @@ func TestRedemptionAction_RejectsTransactionFeeAboveMaximumBeforeSigning( ); err != nil { t.Fatal(err) } - hostChain.setWallet(walletPublicKeyHash, &WalletChainData{ MainUtxoHash: hostChain.ComputeMainUtxoHash(walletMainUtxo), }) - hostChain.setRedemptionParameters(0, 0, 0, 121, 0, nil, 0) - + hostChain.setRedemptionParameters( + 0, + 0, + 0, + 1292, + 0, + big.NewInt(0), + 0, + ) + signingExecutor := &failIfCalledRedemptionSigningExecutor{} action := newRedemptionAction( logger.With(), hostChain, bitcoinChain, redeemingWallet, - newMockWalletSigningExecutor(), + signingExecutor, proposal, 100, 100+redemptionProposalValidityBlocks, - func(ctx context.Context, blockHeight uint64) error { return nil }, + func(context.Context, 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]]", + 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) { @@ -372,139 +379,66 @@ 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, - }, - ) +func TestAssembleRedemptionTransaction_DustChangePolicy(t *testing.T) { + scenarios, err := test.LoadRedemptionTestScenarios() if err != nil { t.Fatal(err) } + scenario := scenarios[0] - 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 + tests := map[string]struct { + changeValue int64 + expectedOutputCount int }{ - // 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]", + "zero change omitted": { + changeValue: 0, + expectedOutputCount: 1, }, - // 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, + "P2WPKH below dust omitted": { + changeValue: 293, + expectedOutputCount: 1, }, - // 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, + "P2WPKH at dust threshold retained": { + changeValue: 294, + expectedOutputCount: 2, }, } - for testName, test := range tests { - t.Run(testName, func(t *testing.T) { + for name, testCase := range tests { + t.Run(name, 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(), - }, + if err := bitcoinChain.BroadcastTransaction( + scenario.InputTransaction, + ); err != nil { + t.Fatal(err) } - feeDistribution := func(requests []*RedemptionRequest) []int64 { - return []int64{test.feeShare} + 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, - walletPublicKey, - walletMainUtxo, - requests, - feeDistribution, + 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 { @@ -512,86 +446,139 @@ func TestAssembleRedemptionTransaction_SubDustChange(t *testing.T) { } 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), + if len(outputs) != testCase.expectedOutputCount { + t.Fatalf( + "unexpected output count [%d]", + len(outputs), ) - - // 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, + } + if len(outputs) == 2 && + outputs[1].Value != testCase.changeValue { + t.Fatalf( + "unexpected change value [%d]", + outputs[1].Value, ) } + }) + } +} - actualFee := builder.TotalInputsValue() - totalOutputsValue +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, + } - // 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( + 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, - "implied transaction fee", - int(test.expectedImpliedFee), - int(actualFee), + bitcoinChain, + walletPublicKey, + 9000+testCase.changeValue, + ) + builder, err := assembleRedemptionTransaction( + bitcoinChain, + walletPublicKey, + mainUtxo, + []*RedemptionRequest{request}, + func([]*RedemptionRequest) []int64 { + return []int64{1000} + }, + RedemptionChangeLast, ) - - 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), - ) + 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 diff --git a/pkg/tbtc/registry.go b/pkg/tbtc/registry.go index 8cd59a8caa..07d09e4d23 100644 --- a/pkg/tbtc/registry.go +++ b/pkg/tbtc/registry.go @@ -31,6 +31,27 @@ 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 + + // 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 // provided wallet public key. calculateWalletIdFunc CalculateWalletIdFunc @@ -53,6 +74,22 @@ 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, + ) + } + frostDKGRetirementBoundaries, err := + walletStorage.loadFrostDKGRetirementBoundaries() + if err != nil { + return nil, fmt.Errorf( + "could not load durable FROST DKG retirement boundaries: [%w]", + err, + ) + } // Pre-populate the wallet cache using the wallet storage. walletCache := make(map[string]*walletCacheValue) @@ -85,6 +122,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 +157,11 @@ func newWalletRegistry( } return &walletRegistry{ - walletCache: walletCache, - walletStorage: walletStorage, - calculateWalletIdFunc: calculateWalletIdFunc, + walletCache: walletCache, + walletStorage: walletStorage, + retainedFrostKeyGroups: retainedFrostKeyGroups, + frostDKGRetirementBoundaries: frostDKGRetirementBoundaries, + calculateWalletIdFunc: calculateWalletIdFunc, }, nil } @@ -121,41 +181,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 } @@ -193,6 +282,41 @@ 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 +} + +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) { @@ -220,12 +344,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 +362,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 +470,11 @@ func (ws *walletStorage) loadSigners() map[string][]*signer { go func() { for descriptor := range descriptorsChan { + if descriptor.Directory() == + frostRetainedKeyGroupBindingDirectory || + descriptor.Directory() == frostDKGAttemptDirectory { + continue + } content, err := descriptor.Content() if err != nil { logger.Errorf( diff --git a/pkg/tbtc/signing.go b/pkg/tbtc/signing.go index 9bf8092a50..356e3795a2 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,14 @@ func (se *signingExecutor) usesSchnorrSignatures() bool { return false } +func (se *signingExecutor) frostPreSignGate() frostPreSignAuthorizationGate { + return se.preSignAuthorizationGate +} + +func (se *signingExecutor) bitcoinOutbox() *bitcoinBroadcastOutbox { + return se.broadcastOutbox +} + // 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 +366,9 @@ func (se *signingExecutor) signBatchWithTaprootMerkleRoots( taprootMerkleRoots, startBlock, nil, + nil, + nil, + nil, ) } @@ -305,6 +394,53 @@ func (se *signingExecutor) signBatchWithTaprootTransaction( taprootMerkleRoots, startBlock, 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, + taprootMerkleRoots []*[32]byte, + startBlock uint64, + 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") + } + 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") + } + if admitInput == nil { + return nil, fmt.Errorf("native signer anchor input admission is nil") + } + + return se.signBatchWithTaprootPolicy( + ctx, + messages, + taprootMerkleRoots, + startBlock, + unsignedTx, + &authorizationID, + authorizationGuard, + admitInput, ) } @@ -314,7 +450,24 @@ func (se *signingExecutor) signBatchWithTaprootPolicy( taprootMerkleRoots []*[32]byte, startBlock uint64, 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{} || + admitInput == nil) { + 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,28 +540,29 @@ func (se *signingExecutor) signBatchWithTaprootPolicy( taprootMerkleRoot = taprootMerkleRoots[i] } - policyBoundRoastSID, err := bindTaprootPolicyArtifactForSigning( - message, - taprootMerkleRoot, - signingStartBlock, - roastKeyGroupID, - unsignedTx, - i, - ) - if err != nil { - return nil, fmt.Errorf( - "cannot bind input [%d] to the native signer policy artifact: [%w]", - i, - err, - ) + 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, + ) + } } - - signature, _, endBlock, err := se.signWithTaprootMerkleRootForSession( + signature, endBlock, err := se.signBatchInputUnderAnchorAdmission( ctx, + i, message, taprootMerkleRoot, signingStartBlock, - policyBoundRoastSID, + roastKeyGroupID, + unsignedTx, + authorizationID, + authorizationGuard, + admitInput, ) if err != nil { // Error metrics are recorded in the sign() method for all error paths. @@ -428,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 @@ -451,6 +701,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 +755,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 +787,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,8 +877,40 @@ 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, + ) + } + // 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, + ) + } + aggregateMemoSession = session } + defer aggregateMemoSession.Release() for _, currentSigner := range se.signers { go func(signer *signer) { @@ -590,6 +931,9 @@ func (se *signingExecutor) signWithTaprootMerkleRootForSessionAndIntent( se.broadcastChannel, se.membershipValidator, ) + if !se.usesSchnorrSignatures() { + doneCheck.useLegacySignatureWireFormat(wallet.publicKey) + } retryLoop := newSigningRetryLoop( signingLogger, @@ -649,13 +993,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 +1078,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, ), @@ -830,6 +1177,7 @@ func (se *signingExecutor) signWithTaprootMerkleRootForSessionAndIntent( // 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/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 } 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") + } +} 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..c7e4457c21 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" @@ -121,6 +122,74 @@ 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 + // 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 `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. + 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 + // 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 } // Initialize kicks off the TBTC by initializing internal state, ensuring @@ -220,11 +289,51 @@ 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 // pre-params side effects. + if node.bitcoinBroadcastOutbox != nil { + if err := node.bitcoinBroadcastOutbox.start(ctx); err != nil { + return fmt.Errorf( + "cannot replay durable Bitcoin broadcast outbox before coordination: [%w]", + err, + ) + } + if err := node.frostActivationHandshakeExporter.start(ctx); err != nil { + return fmt.Errorf( + "cannot start FROST activation handshake exporter: [%w]", + err, + ) + } + go func() { + <-ctx.Done() + closeFrostResources() + }() + } + err = node.runCoordinationLayer(ctx) if err != nil { return fmt.Errorf("cannot run coordination layer: [%w]", err) @@ -259,6 +368,21 @@ 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) + // 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) @@ -532,6 +656,7 @@ func Initialize( }() }) + initializationComplete = true return nil } diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index d69db8db2b..ee81b10d9f 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -315,6 +315,34 @@ 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, + messages []*big.Int, + taprootMerkleRoots []*[32]byte, + startBlock uint64, + unsignedTx *bitcoin.TransactionBuilder, + authorizationID [32]byte, + authorizationGuard func(context.Context) error, + admitInput func(context.Context) (func(), error), + ) ([]*frost.Signature, error) +} + +type frostTransactionSafetyProvider interface { + frostPreSignGate() frostPreSignAuthorizationGate + bitcoinOutbox() *bitcoinBroadcastOutbox +} + // walletTransactionExecutor is a component allowing to sign and broadcast // wallet Bitcoin transactions. type walletTransactionExecutor struct { @@ -324,6 +352,124 @@ 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. 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 + 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 +483,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 +548,187 @@ 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", + ) + } + + 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 authorization != nil { + defer authorization.releaseAnchorReservation() + } + if err == nil { + err = wte.preSignAuthorizationGate.revalidate( + authorizationCtx, + authorization, + ) + } + if err == nil { + err = authorizationCtx.Err() + } + cancelAuthorizationCtx() + if err != nil { + return nil, fmt.Errorf( + "FROST pre-sign authorization failed before nonce generation: [%w]", + err, + ) + } + + // 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( - "taproot tweaked signing requires signer support", + "FROST authorization finalized at/after signing timeout block [%d]", + signingTimeoutBlock, ) } - signatures, err = tweakedSigningExecutor.signBatchWithTaprootMerkleRoots( + // 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") + + // 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, taprootMerkleRoots, - signingStartBlock, + effectiveSigningStartBlock, + 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 + } 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 +759,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 +1228,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..6fd07032de 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,232 @@ 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_UsesSharedFinalityBlockAndPersistsBeforeReturn( + 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] != 500 { + t.Fatalf( + "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) + } + + 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 +1783,15 @@ 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 + currentBlockCalls int + authorizedCalls int + signatureCalls int + authorizedStarts []uint64 + authorizationIDs [][32]byte } // nonPolicyBoundTaprootSigningExecutor returns valid Schnorr signature bytes @@ -1548,6 +1815,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 +1858,101 @@ 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, + taprootMerkleRoots []*[32]byte, + startBlock uint64, + 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") + } + if authorizationGuard == nil { + return nil, errors.New("nil authorization guard") + } + 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) + 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) { + dsseft.currentBlockCalls++ + 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 +2021,57 @@ 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, + admitInput func(context.Context) (func(), 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 + } + if err := exerciseTestPerInputAnchorAdmission( + ctx, + len(messages), + admitInput, + ); 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(