diff --git a/cmd/start.go b/cmd/start.go index b07860fe3b..f5631c0345 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -101,6 +101,8 @@ type startDeps struct { perfMetrics *clientinfo.PerformanceMetrics, minActiveOutpointConfirmations uint, bridgeCovenantFraudDefenseConfirmed bool, + eip712ChainID uint64, + eip712Salt [32]byte, ) (covenantsigner.Engine, error) initializeSigner func( ctx context.Context, @@ -242,6 +244,13 @@ func startWithDeps(cmd *cobra.Command, deps startDeps) error { btcChain, ) + eip712Salt, err := covenantsigner.ResolveEIP712DomainSalt( + clientConfig.CovenantSigner.EIP712Salt, + ) + if err != nil { + return fmt.Errorf("error resolving covenant signer EIP-712 salt: [%v]", err) + } + covenantSignerEngine, err := deps.initializeTbtc( ctx, tbtcChain, @@ -256,6 +265,8 @@ func startWithDeps(cmd *cobra.Command, deps startDeps) error { perfMetrics, // Pass the existing performance metrics instance to avoid duplicate registrations clientConfig.CovenantSigner.MinActiveOutpointConfirmations, clientConfig.CovenantSigner.BridgeCovenantFraudDefenseConfirmed, + clientConfig.CovenantSigner.EIP712ChainID, + eip712Salt, ) if err != nil { return fmt.Errorf("error initializing TBTC: [%v]", err) diff --git a/pkg/covenantsigner/approval_vectors_generator_test.go b/pkg/covenantsigner/approval_vectors_generator_test.go new file mode 100644 index 0000000000..345b69138f --- /dev/null +++ b/pkg/covenantsigner/approval_vectors_generator_test.go @@ -0,0 +1,135 @@ +package covenantsigner + +import ( + "encoding/hex" + "encoding/json" + "os" + "strings" + "testing" +) + +const approvalContractVectorsPath = "testdata/covenant_recovery_approval_vectors_v2.json" + +// TestGenerateCovenantRecoveryApprovalVectorsV2 regenerates the canonical +// approval-contract vectors from the same production code the runtime uses: +// artifactApprovalDigest for the v2 domain-wrapped approval digest and +// requestDigest (which normalizes first) for the request digest. Every protocol +// change that moves either digest — a new approval or certificate version, a +// changed EIP-712 domain, a new normalization rule — therefore has a +// reproducible way to refresh the fixture instead of hand-edited hashes. +// +// Writes are guarded so the fixture cannot drift silently: +// +// UPDATE_GOLDEN=1 go test ./pkg/covenantsigner \ +// -run '^TestGenerateCovenantRecoveryApprovalVectorsV2$' -count=1 +// +// Without UPDATE_GOLDEN the test verifies the checked-in derived values instead +// of rewriting them, so an unregenerated fixture fails here as well as in the +// vector assertion tests. +func TestGenerateCovenantRecoveryApprovalVectorsV2(t *testing.T) { + data, err := os.ReadFile(approvalContractVectorsPath) + if err != nil { + t.Fatal(err) + } + + current := approvalContractVectorsFile{} + if err := strictUnmarshal(data, ¤t); err != nil { + t.Fatal(err) + } + + regenerated := approvalContractVectorsFile{ + Version: current.Version, + Scope: current.Scope, + Vectors: make(map[string]approvalContractVector, len(current.Vectors)), + } + + for key, vector := range current.Vectors { + request := RouteSubmitRequest{} + if err := strictUnmarshal(vector.CanonicalSubmitRequest, &request); err != nil { + t.Fatalf("vector %s: %v", key, err) + } + if request.ArtifactApprovals == nil { + t.Fatalf("vector %s: artifact approvals are required", key) + } + + digestBytes, err := artifactApprovalDigest( + request.ArtifactApprovals.Payload, + testEIP712ChainID, + testEIP712Salt, + ) + if err != nil { + t.Fatalf("vector %s: %v", key, err) + } + approvalDigest := "0x" + hex.EncodeToString(digestBytes) + + // The certificate commits to the artifact approval it was issued over, so + // the certificate's approvalDigest is the same v2 domain-wrapped digest. + // Rebinding it here keeps the two in lockstep across a domain change. + if request.SignerApproval != nil { + request.SignerApproval.ApprovalDigest = approvalDigest + } + + canonicalSubmitRequest, err := json.Marshal(request) + if err != nil { + t.Fatalf("vector %s: %v", key, err) + } + + expectedRequestDigest, err := requestDigest(request, validationOptions{}) + if err != nil { + t.Fatalf("vector %s: %v", key, err) + } + + regenerated.Vectors[key] = approvalContractVector{ + CanonicalSubmitRequest: canonicalSubmitRequest, + ExpectedApprovalDigest: approvalDigest, + ExpectedRequestDigest: expectedRequestDigest, + } + } + + if os.Getenv("UPDATE_GOLDEN") != "1" { + for key, vector := range regenerated.Vectors { + existing, ok := current.Vectors[key] + if !ok { + t.Fatalf("vector %s is missing from %s", key, approvalContractVectorsPath) + } + if !strings.EqualFold( + existing.ExpectedApprovalDigest, + vector.ExpectedApprovalDigest, + ) { + t.Errorf( + "vector %s approval digest is stale\nchecked in: %s\nregenerated: %s\nrerun with UPDATE_GOLDEN=1", + key, + existing.ExpectedApprovalDigest, + vector.ExpectedApprovalDigest, + ) + } + if !strings.EqualFold( + existing.ExpectedRequestDigest, + vector.ExpectedRequestDigest, + ) { + t.Errorf( + "vector %s request digest is stale\nchecked in: %s\nregenerated: %s\nrerun with UPDATE_GOLDEN=1", + key, + existing.ExpectedRequestDigest, + vector.ExpectedRequestDigest, + ) + } + } + + return + } + + encoded, err := json.MarshalIndent(regenerated, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + approvalContractVectorsPath, + append(encoded, '\n'), + 0644, + ); err != nil { + t.Fatal(err) + } + + t.Logf("regenerated %s", approvalContractVectorsPath) +} diff --git a/pkg/covenantsigner/config.go b/pkg/covenantsigner/config.go index f8d470f94b..2573035136 100644 --- a/pkg/covenantsigner/config.go +++ b/pkg/covenantsigner/config.go @@ -49,4 +49,13 @@ type Config struct { // When set, the store acquires an exclusive file lock to prevent concurrent // process corruption. When empty, file locking is skipped. DataDir string `mapstructure:"dataDir"` + // EIP712ChainID pins the chainId of the EIP-712 domain used to wrap the v2 + // artifact approval digest. It must equal the chain the depositor's wallet + // signs on (eth_signTypedData_v4 enforces the active chain matches). Set + // this to the covenant's Ethereum chain (e.g. 1 mainnet, 11155111 Sepolia). + EIP712ChainID uint64 `mapstructure:"eip712ChainId"` + // EIP712Salt optionally overrides the EIP-712 domain salt (32-byte hex). + // When empty, a fixed program-namespace salt is used. It must match the + // client (wallet / covenant-manager / dashboard) domain construction. + EIP712Salt string `mapstructure:"eip712Salt"` } diff --git a/pkg/covenantsigner/covenantsigner_test.go b/pkg/covenantsigner/covenantsigner_test.go index 7d4576701f..0ea4847f8e 100644 --- a/pkg/covenantsigner/covenantsigner_test.go +++ b/pkg/covenantsigner/covenantsigner_test.go @@ -243,7 +243,7 @@ func loadApprovalContractVector( ) (RouteSubmitRequest, string, string) { t.Helper() - data, err := os.ReadFile("testdata/covenant_recovery_approval_vectors_v1.json") + data, err := os.ReadFile("testdata/covenant_recovery_approval_vectors_v2.json") if err != nil { t.Fatal(err) } @@ -252,10 +252,10 @@ func loadApprovalContractVector( if err := strictUnmarshal(data, &vectors); err != nil { t.Fatal(err) } - if vectors.Version != 1 { + if vectors.Version != 2 { t.Fatalf("unexpected vector version: %d", vectors.Version) } - if vectors.Scope != "covenant_recovery_approval_contract_v1" { + if vectors.Scope != "covenant_recovery_approval_contract_v2" { t.Fatalf("unexpected vector scope: %s", vectors.Scope) } @@ -296,6 +296,17 @@ const ( testCustodianPrivateKeyHex = "0x3333333333333333333333333333333333333333333333333333333333333333" ) +// testEIP712ChainID and testEIP712Salt are the EIP-712 domain params used across +// tests. They are the zero value so that every existing validationOptions{} and +// NewService (which default these to zero) stays consistent with signatures and +// pinned digests. The domain-wrap code path is still fully exercised; realistic +// wallet compatibility (chainId + salt) is proven separately by the real-wallet +// signature vector test. +var ( + testEIP712ChainID uint64 + testEIP712Salt [32]byte +) + var ( testDepositorPrivateKey = mustDeterministicTestPrivateKey(testDepositorPrivateKeyHex) testSignerPrivateKey = mustDeterministicTestPrivateKey(testSignerPrivateKeyHex) @@ -368,7 +379,7 @@ func mustArtifactApprovalSignature( privateKey *btcec.PrivateKey, payload ArtifactApprovalPayload, ) string { - digest, err := artifactApprovalDigest(payload) + digest, err := artifactApprovalDigest(payload, testEIP712ChainID, testEIP712Salt) if err != nil { panic(err) } @@ -595,7 +606,7 @@ func validSignerApproval( panic("artifact approvals are required") } - digest, err := artifactApprovalDigest(artifactApprovals.Payload) + digest, err := artifactApprovalDigest(artifactApprovals.Payload, testEIP712ChainID, testEIP712Salt) if err != nil { panic(err) } @@ -3220,6 +3231,46 @@ func TestNewServiceRejectsDuplicateDepositorTrustRootScope(t *testing.T) { } } +func TestNewServiceRejectsMixedEthAddressPresenceForSameReserve(t *testing.T) { + handle := newMemoryHandle() + + // Same (route, reserve) but different networks: one pins an ethAddress, the + // other does not. Allowing this mix would let a request steer verification + // to the secp-only sibling scope via its network value, downgrading an + // operator's intended wallet-signed enforcement. + withEth := testDepositorTrustRoot(TemplateSelfV1) + withEth.Network = "regtest" + withEth.EthAddress = "0x000000000000000000000000000000000000dEaD" + + withoutEth := testDepositorTrustRoot(TemplateSelfV1) + withoutEth.Network = "testnet" + + _, err := NewService( + handle, + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{withEth, withoutEth}), + ) + if err == nil || !strings.Contains( + err.Error(), + "must set ethAddress on all network entries or on none", + ) { + t.Fatalf("expected mixed ethAddress presence error, got %v", err) + } +} + +func TestNormalizeEthAddressRejectsZeroAddress(t *testing.T) { + _, err := normalizeEthAddress( + "depositorTrustRoots[0].ethAddress", + "0x0000000000000000000000000000000000000000", + ) + if err == nil || !strings.Contains( + err.Error(), + "must not be the zero ETH address", + ) { + t.Fatalf("expected zero ETH address rejection, got %v", err) + } +} + func TestNewServiceRejectsInvalidCustodianTrustRootPublicKey(t *testing.T) { handle := newMemoryHandle() @@ -3363,17 +3414,17 @@ func TestRequestDigestRejectsArtifactApprovalsWithoutMigrationTransactionPlan(t } } -func TestArtifactApprovalDigestMatchesPhase1Contract(t *testing.T) { +func TestArtifactApprovalDigestMatchesV2Contract(t *testing.T) { expectedDigests := map[TemplateID]string{ - TemplateQcV1: "0x4e1c72624e85c41d8d8a050d75704dc881ec6cd2dcfe1d240052887feef87ad8", - TemplateSelfV1: "0x960d7082d6eac550d7647d8fbeb90781e6cbd001b4d433e6635aa447dd937e79", + TemplateQcV1: "0xc8246daca36f0116377210140056949b23c37b3de3a5c48ec8d125405a9f05fe", + TemplateSelfV1: "0x063735af147351025209ba54a606b38598d67e60848d463bbd4bcfcbdf3506c7", } for _, route := range []TemplateID{TemplateQcV1, TemplateSelfV1} { t.Run(string(route), func(t *testing.T) { request := canonicalArtifactApprovalRequest(route) - digest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + digest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload, testEIP712ChainID, testEIP712Salt) if err != nil { t.Fatal(err) } @@ -3391,7 +3442,7 @@ func TestApprovalContractVectorsMatchExpectedRequestDigests(t *testing.T) { t.Run(vectorKey, func(t *testing.T) { request, expectedApprovalDigest, expectedDigest := loadApprovalContractVector(t, vectorKey) - digestBytes, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + digestBytes, err := artifactApprovalDigest(request.ArtifactApprovals.Payload, testEIP712ChainID, testEIP712Salt) if err != nil { t.Fatal(err) } @@ -3992,7 +4043,7 @@ func TestServiceAcceptsSignerApprovalCertificateWithEndBlockAboveUint32Range(t * request := structuredSignerApprovalRequest(TemplateSelfV1) request.SignerApproval.EndBlock = &endBlock - normalized, err := normalizeSignerApprovalCertificate(request) + normalized, err := normalizeSignerApprovalCertificate(request, testEIP712ChainID, testEIP712Salt) if err != nil { t.Fatalf("expected EndBlock above math.MaxUint32 to normalize, got %v", err) } @@ -4740,7 +4791,7 @@ func TestNormalizeSignerApprovalCertificateRejectsV1CertificateVersion(t *testin request := structuredSignerApprovalRequest(TemplateSelfV1) request.SignerApproval.CertificateVersion = 1 - _, err := normalizeSignerApprovalCertificate(request) + _, err := normalizeSignerApprovalCertificate(request, testEIP712ChainID, testEIP712Salt) if err == nil || !strings.Contains(err.Error(), "request.signerApproval.certificateVersion must equal 2") { t.Fatalf("expected v1 certificate version rejection, got %v", err) } diff --git a/pkg/covenantsigner/eip712_wallet_vector_test.go b/pkg/covenantsigner/eip712_wallet_vector_test.go new file mode 100644 index 0000000000..62e79a3d4a --- /dev/null +++ b/pkg/covenantsigner/eip712_wallet_vector_test.go @@ -0,0 +1,132 @@ +package covenantsigner + +import ( + "encoding/hex" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/crypto" +) + +// realWalletVector pins a real wallet-produced signature over a v2 domain-wrapped +// artifact approval digest, proving eth_signTypedData_v4 compatibility and +// locking the v-byte (27/28) and low-S handling of the ecrecover path. +// +// digestHex is NOT self-derived: it was independently computed by the eth_account +// (0.13.7) EIP-712 encoder — the same encoding a wallet's eth_signTypedData_v4 +// uses — for the domain/types/message below, and asserted equal to keep-core's +// artifactApprovalDigest. This cross-implementation equality is what proves the +// domain wrap is wallet-correct; the self-signed signature below only locks the +// v-byte/low-S handling. Reproduce with (chainId Sepolia 11155111, default salt): +// +// from eth_account.messages import encode_typed_data +// from eth_utils import keccak +// salt = keccak(text="tBTC Covenant Artifact Approval Domain v2") +// route = keccak(text="self_v1") +// typed = {"types": { +// "EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"}, +// {"name":"chainId","type":"uint256"},{"name":"salt","type":"bytes32"}], +// "ArtifactApproval":[{"name":"approvalVersion","type":"uint8"},{"name":"route","type":"bytes32"}, +// {"name":"scriptTemplateId","type":"bytes32"},{"name":"destinationCommitmentHash","type":"bytes32"}, +// {"name":"planCommitmentHash","type":"bytes32"}]}, +// "primaryType":"ArtifactApproval", +// "domain":{"name":"tBTC Covenant Artifact Approval","version":"2","chainId":11155111,"salt":salt}, +// "message":{"approvalVersion":2,"route":route,"scriptTemplateId":route, +// "destinationCommitmentHash":bytes.fromhex("913b...cdf9"),"planCommitmentHash":bytes.fromhex("c14b...c969")}} +// m = encode_typed_data(full_message=typed); digest = keccak(b"\x19\x01"+m.header+m.body) +// +// Note the dApp must pre-hash the route/scriptTemplateId identifiers to bytes32 +// (keccak of the string) to reproduce this digest, matching the bytes32 fields. +// If the digest formula, domain, or version changes, this pinned value no longer +// matches keep-core AND must be re-derived externally, not simply copied. +var realWalletVector = struct { + privateKeyHex string + address string + chainID uint64 + payload ArtifactApprovalPayload + digestHex string + signatureHex string +}{ + privateKeyHex: "0x4646464646464646464646464646464646464646464646464646464646464646", + address: "0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F", + chainID: 11155111, + payload: ArtifactApprovalPayload{ + ApprovalVersion: artifactApprovalVersion, + Route: TemplateSelfV1, + ScriptTemplateID: TemplateSelfV1, + DestinationCommitmentHash: "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", + PlanCommitmentHash: "0xc14b6b7c58211ceaee8f57a39c07481d9835ef959dbd6a02908312db4cf3c969", + }, + digestHex: "0x8561df74bc316a8838316fcbb477b693fc17a8f7ba9c3f40520d461c43cb2705", + signatureHex: "0xccfd9522f1e5c6db9add5808b9338d9bed3badb4d790204ded545250fdadd48525ec39ec4eb46433c2681b8cca583e56cf3ad91082270364d74cf4afef79da7e1b", +} + +func TestArtifactApprovalRealWalletSignatureVector(t *testing.T) { + // The digest keep-core computes must match the pinned wallet-signed digest. + digest, err := artifactApprovalDigest( + realWalletVector.payload, + realWalletVector.chainID, + defaultArtifactApprovalDomainSalt, + ) + if err != nil { + t.Fatal(err) + } + if got := "0x" + hex.EncodeToString(digest); got != realWalletVector.digestHex { + t.Fatalf("digest drift: expected %s, got %s", realWalletVector.digestHex, got) + } + + // The pinned wallet signature (v in {27,28}) must verify against the address. + if err := verifyEthSignature( + "depositor", + realWalletVector.address, + digest, + realWalletVector.signatureHex, + ); err != nil { + t.Fatalf("pinned wallet signature must verify, got %v", err) + } + + // The equivalent {0,1} recovery-id form must also verify. + rawSignature, err := hex.DecodeString(realWalletVector.signatureHex[2:]) + if err != nil { + t.Fatal(err) + } + legacyForm := make([]byte, 65) + copy(legacyForm, rawSignature) + legacyForm[64] -= 27 + if err := verifyEthSignature( + "depositor", + realWalletVector.address, + digest, + "0x"+hex.EncodeToString(legacyForm), + ); err != nil { + t.Fatalf("legacy recovery-id form must verify, got %v", err) + } + + // A different pinned address must be rejected (ecrecover-and-compare). + if err := verifyEthSignature( + "depositor", + "0x000000000000000000000000000000000000dead", + digest, + realWalletVector.signatureHex, + ); err == nil { + t.Fatal("expected verification against a wrong address to fail") + } + + // A high-S signature (non-canonical) must be rejected per EIP-2. + highS := make([]byte, 65) + copy(highS, rawSignature) + order := crypto.S256().Params().N + s := new(big.Int).SetBytes(rawSignature[32:64]) + highSValue := new(big.Int).Sub(order, s) + var sBytes [32]byte + highSValue.FillBytes(sBytes[:]) + copy(highS[32:64], sBytes[:]) + if err := verifyEthSignature( + "depositor", + realWalletVector.address, + digest, + "0x"+hex.EncodeToString(highS), + ); err == nil { + t.Fatal("expected high-S signature to be rejected") + } +} diff --git a/pkg/covenantsigner/eth_identity_integration_test.go b/pkg/covenantsigner/eth_identity_integration_test.go new file mode 100644 index 0000000000..c250c2d7cf --- /dev/null +++ b/pkg/covenantsigner/eth_identity_integration_test.go @@ -0,0 +1,218 @@ +package covenantsigner + +import ( + "context" + "encoding/hex" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/crypto" +) + +// testDepositorEthPrivateKeyHex is a deterministic test-only Ethereum key whose +// address is pinned as a depositor ETH identity in the integration tests below. +const testDepositorEthPrivateKeyHex = "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + +// mustEthArtifactApprovalSignature signs the v2 domain-wrapped approval digest +// with an Ethereum key, returning the 65-byte r‖s‖v hex a wallet emits (v in +// {27,28}). It uses the zero test EIP-712 domain, matching a Service built +// without WithEIP712Domain. +func mustEthArtifactApprovalSignature( + t *testing.T, + privateKeyHex string, + payload ArtifactApprovalPayload, +) string { + t.Helper() + + privateKey, err := crypto.HexToECDSA(privateKeyHex) + if err != nil { + t.Fatal(err) + } + digest, err := artifactApprovalDigest(payload, testEIP712ChainID, testEIP712Salt) + if err != nil { + t.Fatal(err) + } + signature, err := crypto.Sign(digest, privateKey) + if err != nil { + t.Fatal(err) + } + signature[64] += 27 + return "0x" + hex.EncodeToString(signature) +} + +// ethSignedSelfV1Request builds a valid self_v1 submit request whose depositor +// artifact approval is signed by the given Ethereum key instead of the +// secp256k1 script key, keeping the legacy artifactSignatures consistent. +func ethSignedSelfV1Request(t *testing.T, ethPrivateKeyHex string) RouteSubmitRequest { + t.Helper() + + request := baseRequest(TemplateSelfV1) + request.ArtifactApprovals.Approvals[0].Signature = mustEthArtifactApprovalSignature( + t, + ethPrivateKeyHex, + request.ArtifactApprovals.Payload, + ) + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) + return request +} + +func TestServiceAcceptsSelfV1WithPinnedDepositorEthIdentity(t *testing.T) { + privateKey, err := crypto.HexToECDSA(testDepositorEthPrivateKeyHex) + if err != nil { + t.Fatal(err) + } + ethAddress := crypto.PubkeyToAddress(privateKey.PublicKey).Hex() + + trustRoot := testDepositorTrustRoot(TemplateSelfV1) + trustRoot.EthAddress = ethAddress + + service, err := NewService( + newMemoryHandle(), + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{trustRoot}), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_self_eth_identity_match", + Stage: StageSignerCoordination, + Request: ethSignedSelfV1Request(t, testDepositorEthPrivateKeyHex), + }) + if err != nil { + t.Fatalf("expected ETH-signed approval to be accepted, got %v", err) + } +} + +// TestServiceAcceptsRedeemWithPinnedDepositorEthIdentity is the headline flow of +// vba-dashboard#172: a cooperative REDEEM whose depositor artifact approval is +// signed by a connected ETH wallet (eth_signTypedData_v4-shaped) and verified via +// the pinned depositor ETH identity. +func TestServiceAcceptsRedeemWithPinnedDepositorEthIdentity(t *testing.T) { + privateKey, err := crypto.HexToECDSA(testDepositorEthPrivateKeyHex) + if err != nil { + t.Fatal(err) + } + ethAddress := crypto.PubkeyToAddress(privateKey.PublicKey).Hex() + + trustRoot := testDepositorTrustRoot(TemplateSelfV1) + trustRoot.EthAddress = ethAddress + + service, err := NewService( + newMemoryHandle(), + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{trustRoot}), + ) + if err != nil { + t.Fatal(err) + } + + request := redeemSelfV1Request(t) + request.ArtifactApprovals.Approvals[0].Signature = mustEthArtifactApprovalSignature( + t, + testDepositorEthPrivateKeyHex, + request.ArtifactApprovals.Payload, + ) + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) + + if _, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_redeem_eth_identity", + Stage: StageSignerCoordination, + Request: request, + }); err != nil { + t.Fatalf("expected wallet-signed redeem to be accepted, got %v", err) + } +} + +func TestServiceRejectsSelfV1WithWrongDepositorEthIdentity(t *testing.T) { + // Pin a different ETH address than the one that signed the approval. + trustRoot := testDepositorTrustRoot(TemplateSelfV1) + trustRoot.EthAddress = "0x000000000000000000000000000000000000dEaD" + + service, err := NewService( + newMemoryHandle(), + &scriptedEngine{}, + WithDepositorTrustRoots([]DepositorTrustRoot{trustRoot}), + ) + if err != nil { + t.Fatal(err) + } + + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_self_eth_identity_mismatch", + Stage: StageSignerCoordination, + Request: ethSignedSelfV1Request(t, testDepositorEthPrivateKeyHex), + }) + if err == nil || !strings.Contains(err.Error(), "depositor ETH address") { + t.Fatalf("expected depositor ETH address mismatch error, got %v", err) + } +} + +// TestServicePollAcceptsEthSignedSelfV1Approval guards against a regression +// where a wallet-signed (ETH-identity) approval accepted at Submit is later +// rejected at every Poll: Poll's re-validation must reuse the depositor ETH +// address pinned at submit time rather than silently falling back to the +// secp256k1 script-key check, which cannot verify an ETH-style signature. +func TestServicePollAcceptsEthSignedSelfV1Approval(t *testing.T) { + privateKey, err := crypto.HexToECDSA(testDepositorEthPrivateKeyHex) + if err != nil { + t.Fatal(err) + } + ethAddress := crypto.PubkeyToAddress(privateKey.PublicKey).Hex() + + trustRoot := testDepositorTrustRoot(TemplateSelfV1) + trustRoot.EthAddress = ethAddress + + service, err := NewService( + newMemoryHandle(), + &scriptedEngine{ + submit: func(*Job) (*Transition, error) { + return &Transition{State: JobStatePending, Detail: "queued"}, nil + }, + poll: func(*Job) (*Transition, error) { + return &Transition{ + State: JobStateArtifactReady, + Detail: "artifact ready", + PSBTHash: "0x090a", + TransactionHex: "0x0b0c", + }, nil + }, + }, + WithDepositorTrustRoots([]DepositorTrustRoot{trustRoot}), + ) + if err != nil { + t.Fatal(err) + } + + request := ethSignedSelfV1Request(t, testDepositorEthPrivateKeyHex) + + submitResult, err := service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: "ors_self_eth_poll", + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatalf("expected ETH-signed approval to be accepted at submit, got %v", err) + } + + pollResult, err := service.Poll(context.Background(), TemplateSelfV1, SignerPollInput{ + RouteRequestID: "ors_self_eth_poll", + RequestID: submitResult.RequestID, + Stage: StageSignerCoordination, + Request: request, + }) + if err != nil { + t.Fatalf("expected ETH-signed approval to also be accepted at poll, got %v", err) + } + + if pollResult.Status != StepStatusReady { + t.Fatalf("expected READY, got %s", pollResult.Status) + } +} diff --git a/pkg/covenantsigner/redeem_renew_test.go b/pkg/covenantsigner/redeem_renew_test.go new file mode 100644 index 0000000000..f3585d50a7 --- /dev/null +++ b/pkg/covenantsigner/redeem_renew_test.go @@ -0,0 +1,236 @@ +package covenantsigner + +import ( + "context" + "strings" + "testing" +) + +func validRedeemDestination() *RedeemDestinationReservation { + reservation := &RedeemDestinationReservation{ + ReservationID: "crdr_12345678", + Reserve: "0x1111111111111111111111111111111111111111", + Epoch: 12, + Route: ReservationRouteRedeem, + Revealer: "0x2222222222222222222222222222222222222222", + Vault: "0x3333333333333333333333333333333333333333", + Network: "regtest", + Status: ReservationStatusReserved, + OutputScript: "0x0014bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + OutputValueSats: 998000, + } + reservation.OutputScriptHash, _ = computeDepositScriptHash(reservation.OutputScript) + reservation.DestinationCommitmentHash, _ = computeRedeemCommitmentHash(reservation) + return reservation +} + +func validRenewDestination() *RenewDestinationReservation { + reservation := &RenewDestinationReservation{ + ReservationID: "crnr_12345678", + Reserve: "0x1111111111111111111111111111111111111111", + Epoch: 12, + Route: ReservationRouteRenew, + Revealer: "0x2222222222222222222222222222222222222222", + Vault: "0x3333333333333333333333333333333333333333", + Network: "regtest", + Status: ReservationStatusReserved, + NextCovenantScript: "0x0014cccccccccccccccccccccccccccccccccccccccc", + NextMaturityHeight: 987654, + OutputValueSats: 998000, + } + reservation.NextCovenantScriptHash, _ = computeDepositScriptHash(reservation.NextCovenantScript) + reservation.DestinationCommitmentHash, _ = computeRenewCommitmentHash(reservation) + return reservation +} + +// rebuildApprovals recomputes the plan commitment and artifact approvals after a +// request's destination/plan were mutated, keeping the request internally +// consistent (approval payload, signature, and legacy signatures). +func rebuildApprovals(request *RouteSubmitRequest) { + request.MigrationTransactionPlan.PlanCommitmentHash, _ = + computeMigrationTransactionPlanCommitmentHash(*request, request.MigrationTransactionPlan) + request.ArtifactApprovals = validArtifactApprovals(*request) + request.ArtifactSignatures = canonicalArtifactSignatures( + request.Route, + request.ArtifactApprovals, + ) +} + +func redeemSelfV1Request(t *testing.T) RouteSubmitRequest { + t.Helper() + request := baseRequest(TemplateSelfV1) + dest := validRedeemDestination() + request.Action = CovenantActionRedeem + request.MigrationDestination = nil + request.RedeemDestination = dest + request.DestinationCommitmentHash = dest.DestinationCommitmentHash + request.MigrationTransactionPlan.DestinationValueSats = dest.OutputValueSats + rebuildApprovals(&request) + return request +} + +func renewSelfV1Request(t *testing.T) RouteSubmitRequest { + t.Helper() + request := baseRequest(TemplateSelfV1) + dest := validRenewDestination() + request.Action = CovenantActionRenew + request.MigrationDestination = nil + request.RenewDestination = dest + request.DestinationCommitmentHash = dest.DestinationCommitmentHash + request.MigrationTransactionPlan.DestinationValueSats = dest.OutputValueSats + rebuildApprovals(&request) + return request +} + +func submitRedeemRenew(t *testing.T, id string, request RouteSubmitRequest) error { + t.Helper() + service, err := NewService(newMemoryHandle(), &scriptedEngine{}) + if err != nil { + t.Fatal(err) + } + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: id, + Stage: StageSignerCoordination, + Request: request, + }) + return err +} + +func TestServiceAcceptsRedeemSelfV1(t *testing.T) { + if err := submitRedeemRenew(t, "ors_redeem_ok", redeemSelfV1Request(t)); err != nil { + t.Fatalf("expected redeem request to be accepted, got %v", err) + } +} + +func TestServiceAcceptsRenewSelfV1(t *testing.T) { + if err := submitRedeemRenew(t, "ors_renew_ok", renewSelfV1Request(t)); err != nil { + t.Fatalf("expected renew request to be accepted, got %v", err) + } +} + +// submitRedeemRenewWithMigrationPlanQuoteTrustRoots mirrors submitRedeemRenew +// but configures the service with migrationPlanQuoteTrustRoots, matching a +// realistic production deployment that also verifies MIGRATION plan quotes. +// REDEEM/RENEW requests have no plan-quote field and must remain unaffected by +// that configuration. +func submitRedeemRenewWithMigrationPlanQuoteTrustRoots( + t *testing.T, + id string, + request RouteSubmitRequest, +) error { + t.Helper() + service, err := NewService( + newMemoryHandle(), + &scriptedEngine{}, + WithMigrationPlanQuoteTrustRoots([]MigrationPlanQuoteTrustRoot{ + testMigrationPlanQuoteTrustRoot, + }), + ) + if err != nil { + t.Fatal(err) + } + _, err = service.Submit(context.Background(), TemplateSelfV1, SignerSubmitInput{ + RouteRequestID: id, + Stage: StageSignerCoordination, + Request: request, + }) + return err +} + +func TestServiceAcceptsRedeemSelfV1WithMigrationPlanQuoteTrustRootsConfigured(t *testing.T) { + err := submitRedeemRenewWithMigrationPlanQuoteTrustRoots( + t, "ors_redeem_trust_roots_configured", redeemSelfV1Request(t), + ) + if err != nil { + t.Fatalf( + "expected redeem request to be accepted when migrationPlanQuoteTrustRoots are configured, got %v", + err, + ) + } +} + +func TestServiceAcceptsRenewSelfV1WithMigrationPlanQuoteTrustRootsConfigured(t *testing.T) { + err := submitRedeemRenewWithMigrationPlanQuoteTrustRoots( + t, "ors_renew_trust_roots_configured", renewSelfV1Request(t), + ) + if err != nil { + t.Fatalf( + "expected renew request to be accepted when migrationPlanQuoteTrustRoots are configured, got %v", + err, + ) + } +} + +func TestServiceRejectsRedeemWithCommitmentMismatch(t *testing.T) { + request := redeemSelfV1Request(t) + // Tamper the output script without recomputing the commitment: the recomputed + // canonical commitment no longer matches the pinned one. + request.RedeemDestination.OutputScript = "0x0014dddddddddddddddddddddddddddddddddddddddd" + request.RedeemDestination.OutputScriptHash, _ = + computeDepositScriptHash(request.RedeemDestination.OutputScript) + + err := submitRedeemRenew(t, "ors_redeem_commitment_mismatch", request) + if err == nil || !strings.Contains(err.Error(), "canonical reservation artifact") { + t.Fatalf("expected redeem commitment mismatch error, got %v", err) + } +} + +func TestServiceRejectsRedeemWithValueMismatch(t *testing.T) { + request := redeemSelfV1Request(t) + // The built transaction would pay a value different from the committed one. + request.MigrationTransactionPlan.DestinationValueSats = + request.RedeemDestination.OutputValueSats - 1 + rebuildApprovals(&request) + + err := submitRedeemRenew(t, "ors_redeem_value_mismatch", request) + if err == nil || !strings.Contains(err.Error(), "destinationValueSats") { + t.Fatalf("expected redeem value mismatch error, got %v", err) + } +} + +func TestServiceRejectsCrossActionDestination(t *testing.T) { + // A REDEEM request must not also carry a migration destination. + request := redeemSelfV1Request(t) + request.MigrationDestination = validMigrationDestination() + + err := submitRedeemRenew(t, "ors_redeem_cross_action", request) + if err == nil || !strings.Contains(err.Error(), "must be omitted unless action is MIGRATION") { + t.Fatalf("expected cross-action destination error, got %v", err) + } +} + +func TestServiceRejectsRedeemActionWithoutRedeemDestination(t *testing.T) { + request := redeemSelfV1Request(t) + request.RedeemDestination = nil + + err := submitRedeemRenew(t, "ors_redeem_missing_dest", request) + if err == nil || !strings.Contains(err.Error(), "request.redeemDestination is required") { + t.Fatalf("expected missing redeem destination error, got %v", err) + } +} + +func TestRedeemRenewCommitmentHashesAreDeterministic(t *testing.T) { + redeem := validRedeemDestination() + again, err := computeRedeemCommitmentHash(redeem) + if err != nil { + t.Fatal(err) + } + if again != redeem.DestinationCommitmentHash { + t.Fatalf("redeem commitment not deterministic: %s vs %s", again, redeem.DestinationCommitmentHash) + } + + renew := validRenewDestination() + againRenew, err := computeRenewCommitmentHash(renew) + if err != nil { + t.Fatal(err) + } + if againRenew != renew.DestinationCommitmentHash { + t.Fatalf("renew commitment not deterministic: %s vs %s", againRenew, renew.DestinationCommitmentHash) + } + + // Redeem and renew commitments over the same identity must differ (distinct + // route + destination fields). + if redeem.DestinationCommitmentHash == renew.DestinationCommitmentHash { + t.Fatal("redeem and renew commitments must not collide") + } +} diff --git a/pkg/covenantsigner/server.go b/pkg/covenantsigner/server.go index 9ac35c19c4..a0c4f89b94 100644 --- a/pkg/covenantsigner/server.go +++ b/pkg/covenantsigner/server.go @@ -56,6 +56,11 @@ func Initialize( ) } + eip712DomainOption, err := WithEIP712Domain(config.EIP712ChainID, config.EIP712Salt) + if err != nil { + return nil, false, err + } + service, err := NewService( handle, engine, @@ -64,6 +69,7 @@ func Initialize( WithDepositorTrustRoots(config.DepositorTrustRoots), WithCustodianTrustRoots(config.CustodianTrustRoots), WithCurrentBlockProvider(engine), + eip712DomainOption, ) if err != nil { return nil, false, err @@ -89,6 +95,9 @@ func Initialize( if err := validateRequiredApprovalTrustRoots(config, service, !isLoopback); err != nil { return nil, false, err } + if err := validateEIP712ChainIDForEthTrustRoots(service); err != nil { + return nil, false, err + } if service.signerApprovalVerifier == nil { hasTrustRoots := len(service.depositorTrustRoots) > 0 || len(service.custodianTrustRoots) > 0 || @@ -255,6 +264,27 @@ func validateRequiredApprovalTrustRoots( return nil } +// validateEIP712ChainIDForEthTrustRoots fails startup loudly when a depositor ETH +// identity is pinned but no EIP-712 chainId is configured. chainId 0 would +// silently produce a domain no real wallet signs against (eth_signTypedData_v4 +// binds the wallet's active chain), so approvals would fail closed with no +// diagnostic. +func validateEIP712ChainIDForEthTrustRoots(service *Service) error { + if service.eip712ChainID != 0 { + return nil + } + for _, trustRoot := range service.depositorTrustRoots { + if trustRoot.EthAddress != "" { + return fmt.Errorf( + "covenant signer depositorTrustRoots pin an ethAddress but " + + "covenantSigner.eip712ChainId is unset; set it to the covenant's " + + "Ethereum chainId so wallet-signed approvals can verify", + ) + } + } + return nil +} + func hasDepositorTrustRootForRoute( trustRoots []DepositorTrustRoot, route TemplateID, diff --git a/pkg/covenantsigner/service.go b/pkg/covenantsigner/service.go index 80328265a5..73a48be8ef 100644 --- a/pkg/covenantsigner/service.go +++ b/pkg/covenantsigner/service.go @@ -27,6 +27,8 @@ type Service struct { migrationPlanQuoteTrustRoots []MigrationPlanQuoteTrustRoot depositorTrustRoots []DepositorTrustRoot custodianTrustRoots []CustodianTrustRoot + eip712ChainID uint64 + eip712Salt [32]byte } type ServiceOption func(*Service) @@ -61,6 +63,33 @@ func WithCustodianTrustRoots( } } +// WithEIP712Domain pins the EIP-712 domain (chainId + salt) used to compute the +// v2 domain-wrapped artifact approval digest. An empty saltHex falls back to the +// default program-namespace salt. The chainId and salt must match the client +// (wallet / covenant-manager / dashboard) domain construction. +func WithEIP712Domain(chainID uint64, saltHex string) (ServiceOption, error) { + salt, err := ResolveEIP712DomainSalt(saltHex) + if err != nil { + return nil, err + } + + return func(service *Service) { + service.eip712ChainID = chainID + service.eip712Salt = salt + }, nil +} + +// ResolveEIP712DomainSalt resolves the EIP-712 domain salt from its configured +// hex form, defaulting to the fixed program-namespace salt when empty. It is the +// single source of truth shared by the signer service and the tBTC engine so +// both compute identical approval digests. +func ResolveEIP712DomainSalt(saltHex string) ([32]byte, error) { + if trimmed := strings.TrimSpace(saltHex); trimmed != "" { + return decodeBytes32HexString("eip712Salt", trimmed) + } + return defaultArtifactApprovalDomainSalt, nil +} + func WithCurrentBlockProvider(engine Engine) ServiceOption { var provider func() (uint64, error) if cbp, ok := engine.(CurrentBlockHeightProvider); ok { @@ -364,6 +393,8 @@ func (s *Service) loadPollJob(route TemplateID, input SignerPollInput) (*Job, er input.Request, validationOptions{ policyIndependentDigest: true, + eip712ChainID: s.eip712ChainID, + eip712Salt: s.eip712Salt, }, ) if err != nil { @@ -394,6 +425,7 @@ func (s *Service) createOrDedup( input SignerSubmitInput, normalizedRequest RouteSubmitRequest, requestDigest string, + depositorEthAddress string, ) (*Job, *StepResult, error) { s.mutex.Lock() defer s.mutex.Unlock() @@ -431,17 +463,18 @@ func (s *Service) createOrDedup( now := s.now() job := &Job{ - RequestID: requestID, - RouteRequestID: input.RouteRequestID, - Route: route, - IdempotencyKey: input.Request.IdempotencyKey, - FacadeRequestID: input.Request.FacadeRequestID, - RequestDigest: requestDigest, - State: JobStateSubmitted, - Detail: "accepted for covenant signing", - CreatedAt: now.Format(time.RFC3339Nano), - UpdatedAt: now.Format(time.RFC3339Nano), - Request: normalizedRequest, + RequestID: requestID, + RouteRequestID: input.RouteRequestID, + Route: route, + IdempotencyKey: input.Request.IdempotencyKey, + FacadeRequestID: input.Request.FacadeRequestID, + RequestDigest: requestDigest, + DepositorEthAddress: depositorEthAddress, + State: JobStateSubmitted, + Detail: "accepted for covenant signing", + CreatedAt: now.Format(time.RFC3339Nano), + UpdatedAt: now.Format(time.RFC3339Nano), + Request: normalizedRequest, } if err := s.store.Put(job); err != nil { @@ -465,6 +498,8 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm migrationPlanQuoteVerificationNow: s.now(), signerApprovalVerifier: s.signerApprovalVerifier, currentBlock: currentBlock, + eip712ChainID: s.eip712ChainID, + eip712Salt: s.eip712Salt, } if err := validateSubmitInput(route, input, submitValidationOptions); err != nil { return StepResult{}, err @@ -477,6 +512,8 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm depositorTrustRoots: s.depositorTrustRoots, custodianTrustRoots: s.custodianTrustRoots, signerApprovalVerifier: s.signerApprovalVerifier, + eip712ChainID: s.eip712ChainID, + eip712Salt: s.eip712Salt, }, ) if err != nil { @@ -488,7 +525,15 @@ func (s *Service) Submit(ctx context.Context, route TemplateID, input SignerSubm return StepResult{}, err } - job, existingResult, err := s.createOrDedup(route, input, normalizedRequest, requestDigest) + // Pin the depositor's ETH identity (if any) to the durable job record now, + // at submit time, using the exact same resolution validateSubmitInput just + // used to decide whether this request's approval verified. Poll's + // re-validation is policy-independent (see policyIndependentDigest) and + // must not re-resolve depositorTrustRoots - which could change after + // submit - so it reuses this pinned snapshot instead. + depositorEthAddress := resolveExpectedDepositorEthAddress(input.Request, s.depositorTrustRoots) + + job, existingResult, err := s.createOrDedup(route, input, normalizedRequest, requestDigest, depositorEthAddress) if err != nil { return StepResult{}, err } @@ -581,12 +626,30 @@ func (s *Service) Poll(ctx context.Context, route TemplateID, input SignerPollIn if err != nil { return StepResult{}, err } + + // Look up the depositor ETH address pinned on the job at submit time, if + // any, so the signature re-verification below can use it. This must come + // from the durable job record rather than from live depositorTrustRoots + // config: policyIndependentDigest re-validation is deliberately isolated + // from config that could have changed since submit, and unlike the + // secp256k1 depositor key, the ETH identity has no equivalent field + // embedded in the resubmitted request for Poll to read back directly. + var pinnedDepositorEthAddress string + if storedJob, ok, err := s.store.GetByRequestID(input.RequestID); err != nil { + return StepResult{}, err + } else if ok && storedJob.Route == route && storedJob.RouteRequestID == input.RouteRequestID { + pinnedDepositorEthAddress = storedJob.DepositorEthAddress + } + if err := validatePollInput( route, input, validationOptions{ - policyIndependentDigest: true, - currentBlock: currentBlock, + policyIndependentDigest: true, + currentBlock: currentBlock, + pinnedDepositorEthAddress: pinnedDepositorEthAddress, + eip712ChainID: s.eip712ChainID, + eip712Salt: s.eip712Salt, }, ); err != nil { return StepResult{}, err diff --git a/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v2.json similarity index 87% rename from pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json rename to pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v2.json index f637e9d861..48523794a6 100644 --- a/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v1.json +++ b/pkg/covenantsigner/testdata/covenant_recovery_approval_vectors_v2.json @@ -1,14 +1,13 @@ { - "version": 1, - "scope": "covenant_recovery_approval_contract_v1", + "version": 2, + "scope": "covenant_recovery_approval_contract_v2", "vectors": { "qc_v1": { - "expectedApprovalDigest": "0xa6ffb42318a8e8b3b9669324ee5ad393133afcc9cc81044739cbaa77d5fa34c9", "canonicalSubmitRequest": { "facadeRequestId": "rf_vector_qc_v1", "idempotencyKey": "idem-qc-vector-v1", - "route": "qc_v1", "requestType": "reconstruct", + "route": "qc_v1", "strategy": "0x1111111111111111111111111111111111111111", "reserve": "0x2222222222222222222222222222222222222222", "epoch": 12, @@ -45,7 +44,7 @@ }, "artifactApprovals": { "payload": { - "approvalVersion": 1, + "approvalVersion": 2, "route": "qc_v1", "scriptTemplateId": "qc_v1", "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", @@ -65,12 +64,19 @@ "signerApproval": { "certificateVersion": 2, "signatureAlgorithm": "tecdsa-secp256k1", - "approvalDigest": "0xa6ffb42318a8e8b3b9669324ee5ad393133afcc9cc81044739cbaa77d5fa34c9", + "approvalDigest": "0xb6fca0f533f54f4918502215bd82d10d06a073ca6c7c0f869cc90fb43ad0e602", "walletPublicKey": "0x04d140d1eedb94f53ce43e0f4d68e8e0de6d6f2a444ef98f2a0e6c0f7fca02ef7dc4cb14e7b0f7c23787c93ca4d978f312c64379f38d9f52f86d1a89f0f8572f9f", "signerSetHash": "0xabababababababababababababababababababababababababababababababab", "signature": "0x5050", - "activeMembers": [1, 2, 3], - "inactiveMembers": [4, 5], + "activeMembers": [ + 1, + 2, + 3 + ], + "inactiveMembers": [ + 4, + 5 + ], "endBlock": 123 }, "artifactSignatures": [ @@ -92,15 +98,15 @@ "custodianRequired": true } }, - "expectedRequestDigest": "0x81e134d0bf0d1a7d41ec2c32d4ea555a77558f06bbe0941d0519e52a1132e28c" + "expectedApprovalDigest": "0xb6fca0f533f54f4918502215bd82d10d06a073ca6c7c0f869cc90fb43ad0e602", + "expectedRequestDigest": "0x70eba111184d4eb43a2eb9ca7a6c4219eca4a13fe63d41fce5b4e45c894953c4" }, "self_v1": { - "expectedApprovalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", "canonicalSubmitRequest": { "facadeRequestId": "rf_vector_self_v1", "idempotencyKey": "idem-self-vector-v1", - "route": "self_v1", "requestType": "reconstruct", + "route": "self_v1", "strategy": "0x1111111111111111111111111111111111111111", "reserve": "0x2222222222222222222222222222222222222222", "epoch": 12, @@ -137,7 +143,7 @@ }, "artifactApprovals": { "payload": { - "approvalVersion": 1, + "approvalVersion": 2, "route": "self_v1", "scriptTemplateId": "self_v1", "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", @@ -153,12 +159,19 @@ "signerApproval": { "certificateVersion": 2, "signatureAlgorithm": "tecdsa-secp256k1", - "approvalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", + "approvalDigest": "0x76d32f60185526f68eb132094599ddaae8d592efc4eff0f2d008c5386350041d", "walletPublicKey": "0x04d140d1eedb94f53ce43e0f4d68e8e0de6d6f2a444ef98f2a0e6c0f7fca02ef7dc4cb14e7b0f7c23787c93ca4d978f312c64379f38d9f52f86d1a89f0f8572f9f", "signerSetHash": "0xabababababababababababababababababababababababababababababababab", "signature": "0x5050", - "activeMembers": [1, 2, 3], - "inactiveMembers": [4, 5], + "activeMembers": [ + 1, + 2, + 3 + ], + "inactiveMembers": [ + 4, + 5 + ], "endBlock": 123 }, "artifactSignatures": [ @@ -177,15 +190,15 @@ "custodianRequired": false } }, - "expectedRequestDigest": "0x5eb10d5df3818f646b79f1feee1decd34949a688ca44f47c52d2a7b5d3aaf5d1" + "expectedApprovalDigest": "0x76d32f60185526f68eb132094599ddaae8d592efc4eff0f2d008c5386350041d", + "expectedRequestDigest": "0x0e043ce0fdead8dce09a75014558e12e1416c1386100b79a5bdbe227b2e1ddbc" }, "self_v1_presign": { - "expectedApprovalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", "canonicalSubmitRequest": { "facadeRequestId": "rf_vector_self_v1_presign", "idempotencyKey": "idem-self-presign-vector-v1", - "route": "self_v1", "requestType": "presign_self_v1", + "route": "self_v1", "strategy": "0x1111111111111111111111111111111111111111", "reserve": "0x2222222222222222222222222222222222222222", "epoch": 12, @@ -222,7 +235,7 @@ }, "artifactApprovals": { "payload": { - "approvalVersion": 1, + "approvalVersion": 2, "route": "self_v1", "scriptTemplateId": "self_v1", "destinationCommitmentHash": "0x913b832b3736a29966fd53f8a733a7587b150d3dfacb1c2d54994c1d3e56cdf9", @@ -238,12 +251,19 @@ "signerApproval": { "certificateVersion": 2, "signatureAlgorithm": "tecdsa-secp256k1", - "approvalDigest": "0x4820468d065bc627dabac7860ef473ed28806d6352ba3459ff4edfb81e6bb752", + "approvalDigest": "0x76d32f60185526f68eb132094599ddaae8d592efc4eff0f2d008c5386350041d", "walletPublicKey": "0x04d140d1eedb94f53ce43e0f4d68e8e0de6d6f2a444ef98f2a0e6c0f7fca02ef7dc4cb14e7b0f7c23787c93ca4d978f312c64379f38d9f52f86d1a89f0f8572f9f", "signerSetHash": "0xabababababababababababababababababababababababababababababababab", "signature": "0x5050", - "activeMembers": [1, 2, 3], - "inactiveMembers": [4, 5], + "activeMembers": [ + 1, + 2, + 3 + ], + "inactiveMembers": [ + 4, + 5 + ], "endBlock": 123 }, "artifactSignatures": [ @@ -262,7 +282,8 @@ "custodianRequired": false } }, - "expectedRequestDigest": "0x9c53f8df7ffbeece1ba9652bca402ce51561a94083355b3f1809c087d1598e97" + "expectedApprovalDigest": "0x76d32f60185526f68eb132094599ddaae8d592efc4eff0f2d008c5386350041d", + "expectedRequestDigest": "0x2fd19995dcccf23591dc1340fb0dc3735255ed7d0876ea76236e7473cbc344c0" } } } diff --git a/pkg/covenantsigner/types.go b/pkg/covenantsigner/types.go index d847cccdab..5b26e08ee2 100644 --- a/pkg/covenantsigner/types.go +++ b/pkg/covenantsigner/types.go @@ -48,8 +48,30 @@ type ReservationRoute string const ( ReservationRouteMigration ReservationRoute = "MIGRATION" + ReservationRouteRedeem ReservationRoute = "REDEEM" + ReservationRouteRenew ReservationRoute = "RENEW" ) +// CovenantAction discriminates the covenant lifecycle action a submit request +// performs. It selects which destination/plan the request carries and which +// transaction output the signer builds. An empty Action defaults to MIGRATION. +type CovenantAction string + +const ( + CovenantActionMigration CovenantAction = "MIGRATION" + CovenantActionRedeem CovenantAction = "REDEEM" + CovenantActionRenew CovenantAction = "RENEW" +) + +// ResolvedAction returns the request's action, defaulting an empty value to +// MIGRATION. +func (r RouteSubmitRequest) ResolvedAction() CovenantAction { + if r.Action == "" { + return CovenantActionMigration + } + return r.Action +} + type ReservationStatus string const ( @@ -158,6 +180,12 @@ type DepositorTrustRoot struct { Reserve string `json:"reserve" mapstructure:"reserve"` Network string `json:"network" mapstructure:"network"` PublicKey string `json:"publicKey" mapstructure:"publicKey"` + // EthAddress optionally pins the depositor's Ethereum identity (20-byte + // address). When set, the v2 artifact approval's depositor signature is + // verified against it via ecrecover-and-compare, enabling wallet-signed + // (eth_signTypedData_v4) approvals. When empty, verification falls back to + // the secp256k1 PublicKey above. + EthAddress string `json:"ethAddress" mapstructure:"ethAddress"` } type CustodianTrustRoot struct { @@ -214,6 +242,7 @@ type RouteSubmitRequest struct { IdempotencyKey string `json:"idempotencyKey"` RequestType RequestType `json:"requestType"` Route TemplateID `json:"route"` + Action CovenantAction `json:"action,omitempty"` Strategy string `json:"strategy"` Reserve string `json:"reserve"` Epoch uint64 `json:"epoch"` @@ -221,6 +250,8 @@ type RouteSubmitRequest struct { ActiveOutpoint CovenantOutpoint `json:"activeOutpoint"` DestinationCommitmentHash string `json:"destinationCommitmentHash"` MigrationDestination *MigrationDestinationReservation `json:"migrationDestination,omitempty"` + RedeemDestination *RedeemDestinationReservation `json:"redeemDestination,omitempty"` + RenewDestination *RenewDestinationReservation `json:"renewDestination,omitempty"` MigrationPlanQuote *MigrationDestinationPlanQuote `json:"migrationPlanQuote,omitempty"` MigrationTransactionPlan *MigrationTransactionPlan `json:"migrationTransactionPlan,omitempty"` ArtifactApprovals *ArtifactApprovalEnvelope `json:"artifactApprovals,omitempty"` @@ -231,6 +262,45 @@ type RouteSubmitRequest struct { Signing SigningRequirements `json:"signing"` } +// RedeemDestinationReservation is the cooperative-REDEEM destination: a payout +// output the signer pays directly. Its commitment binds the covenant identity +// (reserve/epoch/route/revealer/vault/network) to the payout (output scriptPubKey +// + value), so the depositor's artifact approval authorizes exactly that payout. +type RedeemDestinationReservation struct { + ReservationID string `json:"reservationId,omitempty"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route ReservationRoute `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + Status ReservationStatus `json:"status"` + OutputScript string `json:"outputScript"` + OutputScriptHash string `json:"outputScriptHash"` + OutputValueSats uint64 `json:"outputValueSats"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` +} + +// RenewDestinationReservation is the cooperative-RENEW destination: the value is +// re-locked into the next-epoch covenant output. Its commitment binds the +// covenant identity to the next covenant scriptPubKey, its maturity height, and +// the re-locked value. +type RenewDestinationReservation struct { + ReservationID string `json:"reservationId,omitempty"` + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route ReservationRoute `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + Status ReservationStatus `json:"status"` + NextCovenantScript string `json:"nextCovenantScript"` + NextCovenantScriptHash string `json:"nextCovenantScriptHash"` + NextMaturityHeight uint64 `json:"nextMaturityHeight"` + OutputValueSats uint64 `json:"outputValueSats"` + DestinationCommitmentHash string `json:"destinationCommitmentHash"` +} + type SignerSubmitInput struct { RouteRequestID string `json:"routeRequestId"` Request RouteSubmitRequest `json:"request"` @@ -255,23 +325,32 @@ type StepResult struct { } type Job struct { - RequestID string `json:"requestId"` - RouteRequestID string `json:"routeRequestId"` - Route TemplateID `json:"route"` - IdempotencyKey string `json:"idempotencyKey"` - FacadeRequestID string `json:"facadeRequestId"` - RequestDigest string `json:"requestDigest"` - State JobState `json:"state"` - Detail string `json:"detail,omitempty"` - Reason FailureReason `json:"reason,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - CompletedAt string `json:"completedAt,omitempty"` - FailedAt string `json:"failedAt,omitempty"` - Request RouteSubmitRequest `json:"request"` - PSBTHash string `json:"psbtHash,omitempty"` - TransactionHex string `json:"transactionHex,omitempty"` - Handoff map[string]any `json:"handoff,omitempty"` + RequestID string `json:"requestId"` + RouteRequestID string `json:"routeRequestId"` + Route TemplateID `json:"route"` + IdempotencyKey string `json:"idempotencyKey"` + FacadeRequestID string `json:"facadeRequestId"` + RequestDigest string `json:"requestDigest"` + // DepositorEthAddress is the depositor's ETH identity resolved from + // depositorTrustRoots at submit time, when one was configured for this + // request's trust-root scope; empty means the depositor's artifact + // approval was (and continues to be) verified against the secp256k1 + // script-template key instead. Poll re-validation is policy-independent + // (it must not depend on depositorTrustRoots possibly having changed + // since submit), so it reuses this pinned snapshot rather than + // re-resolving trust roots on every poll. + DepositorEthAddress string `json:"depositorEthAddress,omitempty"` + State JobState `json:"state"` + Detail string `json:"detail,omitempty"` + Reason FailureReason `json:"reason,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + CompletedAt string `json:"completedAt,omitempty"` + FailedAt string `json:"failedAt,omitempty"` + Request RouteSubmitRequest `json:"request"` + PSBTHash string `json:"psbtHash,omitempty"` + TransactionHex string `json:"transactionHex,omitempty"` + Handoff map[string]any `json:"handoff,omitempty"` } type SelfV1Template struct { diff --git a/pkg/covenantsigner/validation.go b/pkg/covenantsigner/validation.go index 47e4943814..c69e3c1be4 100644 --- a/pkg/covenantsigner/validation.go +++ b/pkg/covenantsigner/validation.go @@ -16,10 +16,14 @@ import ( ) const ( - canonicalCovenantInputSequence uint32 = 0xFFFFFFFD - canonicalAnchorValueSats uint64 = 330 - migrationTransactionPlanVersion uint32 = 1 - artifactApprovalVersion uint32 = 1 + canonicalCovenantInputSequence uint32 = 0xFFFFFFFD + canonicalAnchorValueSats uint64 = 330 + migrationTransactionPlanVersion uint32 = 1 + // artifactApprovalVersion and signerApprovalCertificateVersion version two + // different nested objects: the EIP-712 domain-wrapped artifact approval and + // the threshold-signed signer approval certificate respectively. They are + // bumped independently; neither value supersedes the other. + artifactApprovalVersion uint32 = 2 signerApprovalCertificateVersion uint32 = 2 migrationPlanQuoteVersion uint32 = 1 migrationPlanQuoteSignatureVersion uint32 = 1 @@ -30,6 +34,13 @@ const ( migrationPlanQuoteSigningDomain = "migration-plan-quote-v1:" signerApprovalSignatureAlgorithm = "tecdsa-secp256k1" covenantSignerRequestDigestDomain = "covenant-signer-request-v1:" + // artifactApprovalDomainName and artifactApprovalDomainVersion are the + // EIP-712 domain identity for the v2 domain-wrapped artifact approval. They + // are part of the signed contract and must match the values a wallet's + // eth_signTypedData_v4 domain carries; keep them aligned with the client + // (covenant-manager / dashboard / verification kit) domain construction. + artifactApprovalDomainName = "tBTC Covenant Artifact Approval" + artifactApprovalDomainVersion = "2" ) var artifactApprovalTypeHash = crypto.Keccak256Hash([]byte( @@ -41,6 +52,27 @@ var artifactApprovalTypeHash = crypto.Keccak256Hash([]byte( "bytes32 planCommitmentHash)", )) +// eip712DomainTypeHash is the EIP-712 typehash of the domain separator used to +// wrap the artifact approval struct hash. The domain omits verifyingContract +// (the approval is verified off-chain, not by a contract) and instead pins a +// program salt alongside name, version, and chainId. +var eip712DomainTypeHash = crypto.Keccak256Hash([]byte( + "EIP712Domain(string name,string version,uint256 chainId,bytes32 salt)", +)) + +// defaultArtifactApprovalDomainSalt is the fixed program-namespace salt used +// when the signer config does not pin an explicit salt. This salt does NOT by +// itself separate approvals across covenant deployments: scriptTemplateId only +// carries the route type (qc_v1/self_v1), which is identical across deployments, +// and the domain omits verifyingContract. Cross-deployment separation instead +// comes from the globally-unique reserve/vault bound inside +// destinationCommitmentHash plus the signer's trust-root scoping. Operators +// running multiple deployments on the same chainId that could share a reserve +// should pin a per-deployment eip712Salt to guarantee domain separation. +var defaultArtifactApprovalDomainSalt = [32]byte(crypto.Keccak256Hash( + []byte("tBTC Covenant Artifact Approval Domain v2"), +)) + var canonicalTimestampPattern = regexp.MustCompile( `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$`, ) @@ -80,6 +112,21 @@ type validationOptions struct { signerApprovalVerifier SignerApprovalVerifier policyIndependentDigest bool currentBlock *uint64 + // pinnedDepositorEthAddress, when non-empty, is used directly as the + // depositor's ETH identity for artifact-approval verification instead of + // resolving it from depositorTrustRoots. Poll's re-validation sets this + // from the job's DepositorEthAddress (resolved once at submit time and + // persisted on the durable job record) because policyIndependentDigest + // re-validation must not depend on depositorTrustRoots possibly having + // changed since submit, and the ETH identity - unlike the secp256k1 + // depositor key - has no equivalent field embedded in the request itself + // for Poll to fall back to reading directly. + pinnedDepositorEthAddress string + // eip712ChainID and eip712Salt define the EIP-712 domain the artifact + // approval digest is wrapped with. They must be identical across Submit, + // Poll, and normalization so recomputed digests match stored ones. + eip712ChainID uint64 + eip712Salt [32]byte } // requestDigest accepts raw requests because Poll validates equivalence against @@ -210,6 +257,8 @@ func normalizeRouteSubmitRequest( normalizedArtifactApprovals, normalizedSignerApproval, normalizedArtifactSignatures, err := normalizeArtifactApprovals( request.Route, request, + options.eip712ChainID, + options.eip712Salt, ) if err != nil { return RouteSubmitRequest{}, err @@ -220,12 +269,19 @@ func normalizeRouteSubmitRequest( return RouteSubmitRequest{}, err } - normalizedMigrationPlanQuote, err := normalizeMigrationPlanQuote( - request, - options, - ) - if err != nil { - return RouteSubmitRequest{}, err + // The migration plan quote authority path applies only to MIGRATION; REDEEM + // and RENEW requests carry no plan-quote field and must default to nil + // instead of being rejected by a deployment that also verifies MIGRATION + // plan quotes (mirrors the gating in validateCommonRequest). + var normalizedMigrationPlanQuote *MigrationDestinationPlanQuote + if request.ResolvedAction() == CovenantActionMigration { + normalizedMigrationPlanQuote, err = normalizeMigrationPlanQuote( + request, + options, + ) + if err != nil { + return RouteSubmitRequest{}, err + } } normalizedRequestType, err := normalizeRequestType(request.Route, request.RequestType) if err != nil { @@ -251,8 +307,11 @@ func normalizeRouteSubmitRequest( return normalizeLowerHex(request.ActiveOutpoint.ScriptHash) }(), }, + Action: request.Action, DestinationCommitmentHash: normalizeLowerHex(request.DestinationCommitmentHash), MigrationDestination: normalizeMigrationDestination(request.MigrationDestination), + RedeemDestination: normalizeRedeemDestination(request.RedeemDestination), + RenewDestination: normalizeRenewDestination(request.RenewDestination), MigrationPlanQuote: normalizedMigrationPlanQuote, MigrationTransactionPlan: normalizeMigrationTransactionPlan(request.MigrationTransactionPlan), ArtifactApprovals: normalizedArtifactApprovals, @@ -313,20 +372,23 @@ func validateCommonRequest( if err := validateHexString("request.destinationCommitmentHash", request.DestinationCommitmentHash); err != nil { return err } - // This intentionally creates a deployment ordering constraint: the - // orchestrator must supply the concrete migration destination artifact - // before this signer version can accept requests. - if err := validateMigrationDestination(request, request.MigrationDestination); err != nil { + // Validate the destination for the request's action (MIGRATION/REDEEM/RENEW). + // For MIGRATION this preserves the deployment ordering constraint: the + // orchestrator must supply the concrete destination artifact before this + // signer version can accept requests. + if err := validateActionDestination(request); err != nil { return err } - // This intentionally creates the next deployment ordering constraint: the - // orchestrator must supply the canonical migration transaction plan before - // this signer version can accept requests. + // The migration transaction plan carries the shared output/anchor/fee/ + // sequence/locktime parameters used to build the transaction for every action. if err := validateMigrationTransactionPlan(request, request.MigrationTransactionPlan); err != nil { return err } - if _, err := normalizeMigrationPlanQuote(request, options); err != nil { - return err + // The migration plan quote authority path applies only to MIGRATION. + if request.ResolvedAction() == CovenantActionMigration { + if _, err := normalizeMigrationPlanQuote(request, options); err != nil { + return err + } } if request.ArtifactApprovals == nil { return &inputError{"request.artifactApprovals is required"} @@ -336,7 +398,7 @@ func validateCommonRequest( "request.signerApproval is required when the signer approval verifier is configured", } } - if err := validateArtifactApprovals(route, request); err != nil { + if err := validateArtifactApprovals(route, request, options.eip712ChainID, options.eip712Salt); err != nil { return err } @@ -360,7 +422,8 @@ func validateCommonRequest( } depositorPublicKey := template.DepositorPublicKey - if len(options.depositorTrustRoots) > 0 && !options.policyIndependentDigest { + depositorEthAddress := options.pinnedDepositorEthAddress + if depositorEthAddress == "" && len(options.depositorTrustRoots) > 0 && !options.policyIndependentDigest { expectedDepositorPublicKey, ok := resolveExpectedDepositorPublicKey( request, options.depositorTrustRoots, @@ -376,12 +439,19 @@ func validateCommonRequest( } } depositorPublicKey = expectedDepositorPublicKey + depositorEthAddress = resolveExpectedDepositorEthAddress( + request, + options.depositorTrustRoots, + ) } if err := validateArtifactApprovalAuthenticity( request, depositorPublicKey, + depositorEthAddress, "", + options.eip712ChainID, + options.eip712Salt, ); err != nil { return err } @@ -407,7 +477,8 @@ func validateCommonRequest( } depositorPublicKey := template.DepositorPublicKey - if len(options.depositorTrustRoots) > 0 && !options.policyIndependentDigest { + depositorEthAddress := options.pinnedDepositorEthAddress + if depositorEthAddress == "" && len(options.depositorTrustRoots) > 0 && !options.policyIndependentDigest { expectedDepositorPublicKey, ok := resolveExpectedDepositorPublicKey( request, options.depositorTrustRoots, @@ -423,6 +494,10 @@ func validateCommonRequest( } } depositorPublicKey = expectedDepositorPublicKey + depositorEthAddress = resolveExpectedDepositorEthAddress( + request, + options.depositorTrustRoots, + ) } custodianPublicKey := template.CustodianPublicKey @@ -447,7 +522,10 @@ func validateCommonRequest( if err := validateArtifactApprovalAuthenticity( request, depositorPublicKey, + depositorEthAddress, custodianPublicKey, + options.eip712ChainID, + options.eip712Salt, ); err != nil { return err } diff --git a/pkg/covenantsigner/validation_approval.go b/pkg/covenantsigner/validation_approval.go index e4d19454d4..3f04d9db20 100644 --- a/pkg/covenantsigner/validation_approval.go +++ b/pkg/covenantsigner/validation_approval.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/btcsuite/btcd/btcec" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" ) @@ -49,6 +50,8 @@ func normalizeSignerApprovalMemberIndexes( func normalizeSignerApprovalCertificate( request RouteSubmitRequest, + chainID uint64, + salt [32]byte, ) (*SignerApprovalCertificate, error) { if request.SignerApproval == nil { return nil, nil @@ -114,7 +117,11 @@ func normalizeSignerApprovalCertificate( return nil, err } - expectedApprovalDigest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + expectedApprovalDigest, err := artifactApprovalDigest( + request.ArtifactApprovals.Payload, + chainID, + salt, + ) if err != nil { return nil, err } @@ -196,6 +203,14 @@ func abiEncodeUint32Word(value uint32) [32]byte { return encoded } +// abiEncodeUint64Word right-aligns a uint64 into a 32-byte word, the ABI +// encoding of a uint256 whose value fits in 64 bits (e.g. an EIP-712 chainId). +func abiEncodeUint64Word(value uint64) [32]byte { + var encoded [32]byte + binary.BigEndian.PutUint64(encoded[24:], value) + return encoded +} + func keccakTemplateIdentifier(id TemplateID) [32]byte { hash := crypto.Keccak256Hash([]byte(id)) @@ -205,10 +220,9 @@ func keccakTemplateIdentifier(id TemplateID) [32]byte { return encoded } -// artifactApprovalDigest pins the current phase-1 approval payload contract to -// a deterministic EIP-712-compatible struct hash, without yet committing to a -// chain-specific domain separator. -func artifactApprovalDigest(payload ArtifactApprovalPayload) ([]byte, error) { +// artifactApprovalStructHash computes the EIP-712 hashStruct of the +// ArtifactApproval message. It is the inner half of the domain-wrapped digest. +func artifactApprovalStructHash(payload ArtifactApprovalPayload) ([]byte, error) { destinationCommitmentHash, err := decodeBytes32HexString( "request.artifactApprovals.payload.destinationCommitmentHash", payload.DestinationCommitmentHash, @@ -237,15 +251,63 @@ func artifactApprovalDigest(payload ArtifactApprovalPayload) ([]byte, error) { copy(encoded[128:160], destinationCommitmentHash[:]) copy(encoded[160:192], planCommitmentHash[:]) - digest := crypto.Keccak256Hash(encoded) + return crypto.Keccak256(encoded), nil +} + +// artifactApprovalDomainSeparator computes the EIP-712 domain separator for the +// artifact approval (name, version, chainId, salt; no verifyingContract). +func artifactApprovalDomainSeparator(chainID uint64, salt [32]byte) [32]byte { + encoded := make([]byte, 32*5) + chainIDWord := abiEncodeUint64Word(chainID) + nameHash := crypto.Keccak256Hash([]byte(artifactApprovalDomainName)) + versionHash := crypto.Keccak256Hash([]byte(artifactApprovalDomainVersion)) + + copy(encoded[0:32], eip712DomainTypeHash.Bytes()) + copy(encoded[32:64], nameHash.Bytes()) + copy(encoded[64:96], versionHash.Bytes()) + copy(encoded[96:128], chainIDWord[:]) + copy(encoded[128:160], salt[:]) + + var separator [32]byte + copy(separator[:], crypto.Keccak256(encoded)) + return separator +} + +// artifactApprovalDigest computes the domain-wrapped EIP-712 v4 digest that a +// wallet's eth_signTypedData_v4 signature is produced over: +// keccak256(0x1901 ‖ domainSeparator ‖ hashStruct(message)). The digest is +// chain-specific: chainID and salt define the domain and must match the client. +func artifactApprovalDigest( + payload ArtifactApprovalPayload, + chainID uint64, + salt [32]byte, +) ([]byte, error) { + structHash, err := artifactApprovalStructHash(payload) + if err != nil { + return nil, err + } + + domainSeparator := artifactApprovalDomainSeparator(chainID, salt) + + prefixed := make([]byte, 0, 2+32+32) + prefixed = append(prefixed, 0x19, 0x01) + prefixed = append(prefixed, domainSeparator[:]...) + prefixed = append(prefixed, structHash...) + + digest := crypto.Keccak256Hash(prefixed) return digest.Bytes(), nil } -// ComputeArtifactApprovalDigest exposes the current phase-1 approval payload -// digest contract to cross-package verifiers that need to bind -// signerApproval.approvalDigest to request.artifactApprovals.payload. -func ComputeArtifactApprovalDigest(payload ArtifactApprovalPayload) ([]byte, error) { - return artifactApprovalDigest(payload) +// ComputeArtifactApprovalDigest exposes the v2 domain-wrapped approval digest to +// cross-package verifiers that need to bind signerApproval.approvalDigest to +// request.artifactApprovals.payload. chainID and salt must match the signer's +// configured EIP-712 domain. +func ComputeArtifactApprovalDigest( + payload ArtifactApprovalPayload, + chainID uint64, + salt [32]byte, +) ([]byte, error) { + return artifactApprovalDigest(payload, chainID, salt) } func parseCompressedSecp256k1PublicKey( @@ -339,6 +401,62 @@ func verifySecp256k1Signature( return &inputError{fmt.Sprintf("%s does not verify against the required public key", name)} } +// verifyEthSignature verifies a wallet-produced ECDSA signature over the digest +// against a pinned depositor ETH address (ecrecover-and-compare). It accepts the +// 65-byte r‖s‖v shape that eth_signTypedData_v4 returns (v in {27,28}, also +// tolerating {0,1}) and enforces low-S per EIP-2. +func verifyEthSignature( + name string, + ethAddress string, + digest []byte, + signature string, +) error { + rawSignature, err := hex.DecodeString(strings.TrimPrefix(signature, "0x")) + if err != nil { + return &inputError{fmt.Sprintf("%s must be valid hex", name)} + } + if len(rawSignature) != 65 { + return &inputError{ + fmt.Sprintf("%s must be a 65-byte secp256k1 signature", name), + } + } + if !isLowSSecp256k1(new(big.Int).SetBytes(rawSignature[32:64])) { + return &inputError{ + fmt.Sprintf("%s must be a low-S secp256k1 signature", name), + } + } + + // crypto.SigToPub expects the recovery id in {0,1}; wallets emit {27,28}. + normalized := make([]byte, 65) + copy(normalized, rawSignature) + switch normalized[64] { + case 27, 28: + normalized[64] -= 27 + case 0, 1: + // already normalized + default: + return &inputError{ + fmt.Sprintf("%s has an invalid recovery id", name), + } + } + + publicKey, err := crypto.SigToPub(digest, normalized) + if err != nil { + return &inputError{ + fmt.Sprintf("%s does not recover to a valid public key", name), + } + } + + recovered := crypto.PubkeyToAddress(*publicKey) + if recovered != common.HexToAddress(ethAddress) { + return &inputError{ + fmt.Sprintf("%s does not verify against the required depositor ETH address", name), + } + } + + return nil +} + func validateArtifactSignatures(signatures []string) ([]string, error) { if len(signatures) == 0 { return nil, &inputError{"request.artifactSignatures must not be empty"} @@ -375,16 +493,23 @@ func requiredStructuredArtifactApprovalRoles(route TemplateID) ([]ArtifactApprov } } -func validateArtifactApprovals(route TemplateID, request RouteSubmitRequest) error { - _, _, _, err := normalizeArtifactApprovals(route, request) +func validateArtifactApprovals( + route TemplateID, + request RouteSubmitRequest, + chainID uint64, + salt [32]byte, +) error { + _, _, _, err := normalizeArtifactApprovals(route, request, chainID, salt) return err } func normalizeArtifactApprovals( route TemplateID, request RouteSubmitRequest, + chainID uint64, + salt [32]byte, ) (*ArtifactApprovalEnvelope, *SignerApprovalCertificate, []string, error) { - normalizedSignerApproval, err := normalizeSignerApprovalCertificate(request) + normalizedSignerApproval, err := normalizeSignerApprovalCertificate(request, chainID, salt) if err != nil { return nil, nil, nil, err } @@ -402,7 +527,7 @@ func normalizeArtifactApprovals( } if request.ArtifactApprovals.Payload.ApprovalVersion != artifactApprovalVersion { - return nil, nil, nil, &inputError{"request.artifactApprovals.payload.approvalVersion must equal 1"} + return nil, nil, nil, &inputError{"request.artifactApprovals.payload.approvalVersion must equal 2"} } if request.ArtifactApprovals.Payload.Route != route { return nil, nil, nil, &inputError{"request.artifactApprovals.payload.route must match request.route"} @@ -528,22 +653,38 @@ func normalizeArtifactApprovals( return normalizedApprovals, normalizedSignerApproval, derivedLegacySignatures, nil } +// validateArtifactApprovalAuthenticity verifies each artifact approval signature +// against the payload digest. The depositor approval is verified against a +// pinned depositor ETH identity via ecrecover-and-compare when depositorEthAddress +// is set (the v2 wallet-signed path); otherwise it falls back to the +// script-template secp256k1 depositor key. The custodian approval always uses the +// secp256k1 custodian key. func validateArtifactApprovalAuthenticity( request RouteSubmitRequest, depositorPublicKey string, + depositorEthAddress string, custodianPublicKey string, + chainID uint64, + salt [32]byte, ) error { - payloadDigest, err := artifactApprovalDigest(request.ArtifactApprovals.Payload) + payloadDigest, err := artifactApprovalDigest( + request.ArtifactApprovals.Payload, + chainID, + salt, + ) if err != nil { return err } - depositorKey, err := parseCompressedSecp256k1PublicKey( - "request.scriptTemplate.depositorPublicKey", - depositorPublicKey, - ) - if err != nil { - return err + var depositorKey *btcec.PublicKey + if depositorEthAddress == "" { + depositorKey, err = parseCompressedSecp256k1PublicKey( + "request.scriptTemplate.depositorPublicKey", + depositorPublicKey, + ) + if err != nil { + return err + } } var custodianKey *btcec.PublicKey @@ -565,7 +706,16 @@ func validateArtifactApprovalAuthenticity( switch approval.Role { case ArtifactApprovalRoleDepositor: - if err := verifySecp256k1Signature( + if depositorEthAddress != "" { + if err := verifyEthSignature( + signaturePath, + depositorEthAddress, + payloadDigest, + approval.Signature, + ); err != nil { + return err + } + } else if err := verifySecp256k1Signature( signaturePath, depositorKey, payloadDigest, @@ -587,6 +737,16 @@ func validateArtifactApprovalAuthenticity( ); err != nil { return err } + default: + // normalizeArtifactApprovals already rejects unrecognized roles + // before this loop runs; fail closed here as defense in depth so an + // unexpected role can never be silently skipped without verification. + return &inputError{ + fmt.Sprintf( + "request.artifactApprovals.approvals[%d].role is not a recognized role", + i, + ), + } } } @@ -637,6 +797,26 @@ func resolveExpectedDepositorPublicKey( return "", false } +// resolveExpectedDepositorEthAddress returns the pinned depositor ETH address +// for the request scope, if one is configured. An empty return means no ETH +// identity is pinned and approval verification falls back to the secp256k1 +// depositor key. +func resolveExpectedDepositorEthAddress( + request RouteSubmitRequest, + trustRoots []DepositorTrustRoot, +) string { + route, reserve, network := trustRootLookupScope(request) + for _, trustRoot := range trustRoots { + if trustRoot.Route == route && + trustRoot.Reserve == reserve && + trustRoot.Network == network { + return trustRoot.EthAddress + } + } + + return "" +} + func resolveExpectedCustodianPublicKey( request RouteSubmitRequest, trustRoots []CustodianTrustRoot, diff --git a/pkg/covenantsigner/validation_quote.go b/pkg/covenantsigner/validation_quote.go index df94658063..d60c6e58ef 100644 --- a/pkg/covenantsigner/validation_quote.go +++ b/pkg/covenantsigner/validation_quote.go @@ -145,7 +145,7 @@ func normalizeScopedTrustRoots[T any]( func normalizeDepositorTrustRoots( trustRoots []DepositorTrustRoot, ) ([]DepositorTrustRoot, error) { - return normalizeScopedTrustRoots( + normalized, err := normalizeScopedTrustRoots( "depositorTrustRoots", trustRoots, func(t DepositorTrustRoot) (TemplateID, string, string, string) { @@ -158,6 +158,74 @@ func normalizeDepositorTrustRoots( } }, ) + if err != nil { + return nil, err + } + + // normalizeScopedTrustRoots preserves input order, so entries are + // index-aligned with trustRoots. Attach the optional pinned depositor ETH + // address (enables ecrecover-based v2 approval verification). + for i := range normalized { + ethAddress := strings.TrimSpace(trustRoots[i].EthAddress) + if ethAddress == "" { + continue + } + normalizedEth, err := normalizeEthAddress( + fmt.Sprintf("depositorTrustRoots[%d].ethAddress", i), + ethAddress, + ) + if err != nil { + return nil, err + } + normalized[i].EthAddress = normalizedEth + } + + // Prevent a silent ETH->secp verification downgrade: within a single + // (route, reserve) pair, ethAddress must be set on every network entry or on + // none of them. A mix would let a request steer verification to the + // secp-only sibling scope through its (partially caller-influenced) network + // value, bypassing an operator's intended wallet-signed enforcement. + ethPresenceByPair := make(map[string]bool, len(normalized)) + for i := range normalized { + pairKey := string(normalized[i].Route) + "|" + normalized[i].Reserve + hasEth := normalized[i].EthAddress != "" + if existing, ok := ethPresenceByPair[pairKey]; ok { + if existing != hasEth { + return nil, &inputError{ + fmt.Sprintf( + "depositorTrustRoots for route %s reserve %s must set ethAddress on all network entries or on none", + normalized[i].Route, + normalized[i].Reserve, + ), + } + } + } else { + ethPresenceByPair[pairKey] = hasEth + } + } + + return normalized, nil +} + +// normalizeEthAddress validates a 20-byte hex Ethereum address and returns it in +// lowercase 0x-prefixed form. +func normalizeEthAddress(name, value string) (string, error) { + trimmed := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), "0x") + raw, err := hex.DecodeString(trimmed) + if err != nil || len(raw) != 20 { + return "", &inputError{ + fmt.Sprintf("%s must be a 20-byte hex ETH address", name), + } + } + // Reject the zero address: an operator misconfiguring it would make the + // ecrecover-based approval check compare against 0x0, silently weakening the + // depositor binding to whatever recovers to the zero address. + if strings.Trim(trimmed, "0") == "" { + return "", &inputError{ + fmt.Sprintf("%s must not be the zero ETH address", name), + } + } + return "0x" + trimmed, nil } func normalizeCustodianTrustRoots( @@ -179,12 +247,19 @@ func normalizeCustodianTrustRoots( } func trustRootLookupScope(request RouteSubmitRequest) (TemplateID, string, string) { + // The trust-root scope network comes from the destination reservation for the + // request's action (migration/redeem/renew). network := "" - if request.MigrationDestination != nil { - network = strings.ToLower(strings.TrimSpace(request.MigrationDestination.Network)) + switch { + case request.MigrationDestination != nil: + network = request.MigrationDestination.Network + case request.RedeemDestination != nil: + network = request.RedeemDestination.Network + case request.RenewDestination != nil: + network = request.RenewDestination.Network } - return request.Route, normalizeLowerHex(request.Reserve), network + return request.Route, normalizeLowerHex(request.Reserve), strings.ToLower(strings.TrimSpace(network)) } func migrationPlanQuoteSigningPayloadBytes( diff --git a/pkg/covenantsigner/validation_template.go b/pkg/covenantsigner/validation_template.go index 1095b0ce74..52cc2bb4eb 100644 --- a/pkg/covenantsigner/validation_template.go +++ b/pkg/covenantsigner/validation_template.go @@ -76,6 +76,82 @@ func computeDestinationCommitmentHash( return "0x" + hex.EncodeToString(sum[:]), nil } +// redeemCommitmentPayload is the canonical-JSON preimage of a REDEEM destination +// commitment. Field order is hash-significant and must stay aligned with the +// client (covenant-manager / dashboard / verification kit) object literal. +type redeemCommitmentPayload struct { + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route string `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + OutputScriptHash string `json:"outputScriptHash"` + OutputValueSats uint64 `json:"outputValueSats"` +} + +// renewCommitmentPayload is the canonical-JSON preimage of a RENEW destination +// commitment. Field order is hash-significant and must stay aligned with the +// client object literal. +type renewCommitmentPayload struct { + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route string `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + NextCovenantScriptHash string `json:"nextCovenantScriptHash"` + NextMaturityHeight uint64 `json:"nextMaturityHeight"` + OutputValueSats uint64 `json:"outputValueSats"` +} + +// computeRedeemCommitmentHash is the REDEEM analogue of +// computeDestinationCommitmentHash: SHA-256 over the canonical-JSON preimage. +func computeRedeemCommitmentHash( + reservation *RedeemDestinationReservation, +) (string, error) { + payload, err := canonicaljson.Marshal(redeemCommitmentPayload{ + Reserve: normalizeLowerHex(reservation.Reserve), + Epoch: reservation.Epoch, + Route: string(reservation.Route), + Revealer: normalizeLowerHex(reservation.Revealer), + Vault: normalizeLowerHex(reservation.Vault), + Network: strings.TrimSpace(reservation.Network), + OutputScriptHash: normalizeLowerHex(reservation.OutputScriptHash), + OutputValueSats: reservation.OutputValueSats, + }) + if err != nil { + return "", err + } + + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]), nil +} + +// computeRenewCommitmentHash is the RENEW analogue of +// computeDestinationCommitmentHash: SHA-256 over the canonical-JSON preimage. +func computeRenewCommitmentHash( + reservation *RenewDestinationReservation, +) (string, error) { + payload, err := canonicaljson.Marshal(renewCommitmentPayload{ + Reserve: normalizeLowerHex(reservation.Reserve), + Epoch: reservation.Epoch, + Route: string(reservation.Route), + Revealer: normalizeLowerHex(reservation.Revealer), + Vault: normalizeLowerHex(reservation.Vault), + Network: strings.TrimSpace(reservation.Network), + NextCovenantScriptHash: normalizeLowerHex(reservation.NextCovenantScriptHash), + NextMaturityHeight: reservation.NextMaturityHeight, + OutputValueSats: reservation.OutputValueSats, + }) + if err != nil { + return "", err + } + + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]), nil +} + func computeMigrationTransactionPlanCommitmentHash( request RouteSubmitRequest, plan *MigrationTransactionPlan, @@ -174,6 +250,185 @@ func validateMigrationDestination( return nil } +// validateActionDestination dispatches destination validation on the request's +// covenant action and rejects destinations that do not belong to that action. +func validateActionDestination(request RouteSubmitRequest) error { + action := request.ResolvedAction() + if action != CovenantActionMigration && request.MigrationDestination != nil { + return &inputError{"request.migrationDestination must be omitted unless action is MIGRATION"} + } + if action != CovenantActionRedeem && request.RedeemDestination != nil { + return &inputError{"request.redeemDestination must be omitted unless action is REDEEM"} + } + if action != CovenantActionRenew && request.RenewDestination != nil { + return &inputError{"request.renewDestination must be omitted unless action is RENEW"} + } + + switch action { + case CovenantActionMigration: + return validateMigrationDestination(request, request.MigrationDestination) + case CovenantActionRedeem: + return validateRedeemDestination(request, request.RedeemDestination) + case CovenantActionRenew: + return validateRenewDestination(request, request.RenewDestination) + default: + return &inputError{"request.action is not supported"} + } +} + +func validateRedeemDestination( + request RouteSubmitRequest, + reservation *RedeemDestinationReservation, +) error { + if reservation == nil { + return &inputError{"request.redeemDestination is required"} + } + if reservation.Route != ReservationRouteRedeem { + return &inputError{"request.redeemDestination.route must be REDEEM"} + } + if reservation.Status != ReservationStatusReserved && + reservation.Status != ReservationStatusCommittedToEpoch { + return &inputError{"request.redeemDestination.status must be RESERVED or COMMITTED_TO_EPOCH"} + } + if err := validateAddressString("request.redeemDestination.reserve", reservation.Reserve); err != nil { + return err + } + if err := validateAddressString("request.redeemDestination.revealer", reservation.Revealer); err != nil { + return err + } + if err := validateAddressString("request.redeemDestination.vault", reservation.Vault); err != nil { + return err + } + if strings.TrimSpace(reservation.Network) == "" { + return &inputError{"request.redeemDestination.network is required"} + } + if err := validateHexString("request.redeemDestination.outputScript", reservation.OutputScript); err != nil { + return err + } + if err := validateHexString("request.redeemDestination.outputScriptHash", reservation.OutputScriptHash); err != nil { + return err + } + if err := validateHexString("request.redeemDestination.destinationCommitmentHash", reservation.DestinationCommitmentHash); err != nil { + return err + } + if reservation.OutputValueSats == 0 { + return &inputError{"request.redeemDestination.outputValueSats must be greater than zero"} + } + if request.Epoch != reservation.Epoch { + return &inputError{"request.redeemDestination.epoch does not match request.epoch"} + } + if normalizeLowerHex(request.Reserve) != normalizeLowerHex(reservation.Reserve) { + return &inputError{"request.redeemDestination.reserve does not match request.reserve"} + } + if normalizeLowerHex(request.DestinationCommitmentHash) != normalizeLowerHex(reservation.DestinationCommitmentHash) { + return &inputError{"request.redeemDestination.destinationCommitmentHash does not match request.destinationCommitmentHash"} + } + + outputScriptHash, err := computeDepositScriptHash(reservation.OutputScript) + if err != nil { + return &inputError{"request.redeemDestination.outputScript is not valid hex"} + } + if normalizeLowerHex(reservation.OutputScriptHash) != outputScriptHash { + return &inputError{"request.redeemDestination.outputScriptHash does not match outputScript"} + } + + commitmentHash, err := computeRedeemCommitmentHash(reservation) + if err != nil { + return err + } + if normalizeLowerHex(reservation.DestinationCommitmentHash) != commitmentHash { + return &inputError{"request.redeemDestination.destinationCommitmentHash does not match canonical reservation artifact"} + } + + // The built transaction pays MigrationTransactionPlan.DestinationValueSats; + // bind it to the committed output value so the signed output cannot diverge + // from what the depositor's artifact approval authorized. + if request.MigrationTransactionPlan != nil && + request.MigrationTransactionPlan.DestinationValueSats != reservation.OutputValueSats { + return &inputError{"request.redeemDestination.outputValueSats does not match request.migrationTransactionPlan.destinationValueSats"} + } + + return nil +} + +func validateRenewDestination( + request RouteSubmitRequest, + reservation *RenewDestinationReservation, +) error { + if reservation == nil { + return &inputError{"request.renewDestination is required"} + } + if reservation.Route != ReservationRouteRenew { + return &inputError{"request.renewDestination.route must be RENEW"} + } + if reservation.Status != ReservationStatusReserved && + reservation.Status != ReservationStatusCommittedToEpoch { + return &inputError{"request.renewDestination.status must be RESERVED or COMMITTED_TO_EPOCH"} + } + if err := validateAddressString("request.renewDestination.reserve", reservation.Reserve); err != nil { + return err + } + if err := validateAddressString("request.renewDestination.revealer", reservation.Revealer); err != nil { + return err + } + if err := validateAddressString("request.renewDestination.vault", reservation.Vault); err != nil { + return err + } + if strings.TrimSpace(reservation.Network) == "" { + return &inputError{"request.renewDestination.network is required"} + } + if err := validateHexString("request.renewDestination.nextCovenantScript", reservation.NextCovenantScript); err != nil { + return err + } + if err := validateHexString("request.renewDestination.nextCovenantScriptHash", reservation.NextCovenantScriptHash); err != nil { + return err + } + if err := validateHexString("request.renewDestination.destinationCommitmentHash", reservation.DestinationCommitmentHash); err != nil { + return err + } + if reservation.NextMaturityHeight == 0 { + return &inputError{"request.renewDestination.nextMaturityHeight must be greater than zero"} + } + if reservation.OutputValueSats == 0 { + return &inputError{"request.renewDestination.outputValueSats must be greater than zero"} + } + if request.Epoch != reservation.Epoch { + return &inputError{"request.renewDestination.epoch does not match request.epoch"} + } + if normalizeLowerHex(request.Reserve) != normalizeLowerHex(reservation.Reserve) { + return &inputError{"request.renewDestination.reserve does not match request.reserve"} + } + if normalizeLowerHex(request.DestinationCommitmentHash) != normalizeLowerHex(reservation.DestinationCommitmentHash) { + return &inputError{"request.renewDestination.destinationCommitmentHash does not match request.destinationCommitmentHash"} + } + + nextCovenantScriptHash, err := computeDepositScriptHash(reservation.NextCovenantScript) + if err != nil { + return &inputError{"request.renewDestination.nextCovenantScript is not valid hex"} + } + if normalizeLowerHex(reservation.NextCovenantScriptHash) != nextCovenantScriptHash { + return &inputError{"request.renewDestination.nextCovenantScriptHash does not match nextCovenantScript"} + } + + commitmentHash, err := computeRenewCommitmentHash(reservation) + if err != nil { + return err + } + if normalizeLowerHex(reservation.DestinationCommitmentHash) != commitmentHash { + return &inputError{"request.renewDestination.destinationCommitmentHash does not match canonical reservation artifact"} + } + + // The built transaction pays MigrationTransactionPlan.DestinationValueSats; + // bind it to the committed re-locked value so the signed output cannot diverge + // from what the depositor's artifact approval authorized. + if request.MigrationTransactionPlan != nil && + request.MigrationTransactionPlan.DestinationValueSats != reservation.OutputValueSats { + return &inputError{"request.renewDestination.outputValueSats does not match request.migrationTransactionPlan.destinationValueSats"} + } + + return nil +} + func validateMigrationTransactionPlan( request RouteSubmitRequest, plan *MigrationTransactionPlan, @@ -254,6 +509,53 @@ func normalizeMigrationDestination( } } +func normalizeRedeemDestination( + destination *RedeemDestinationReservation, +) *RedeemDestinationReservation { + if destination == nil { + return nil + } + + return &RedeemDestinationReservation{ + ReservationID: strings.TrimSpace(destination.ReservationID), + Reserve: normalizeLowerHex(destination.Reserve), + Epoch: destination.Epoch, + Route: destination.Route, + Revealer: normalizeLowerHex(destination.Revealer), + Vault: normalizeLowerHex(destination.Vault), + Network: strings.TrimSpace(destination.Network), + Status: destination.Status, + OutputScript: normalizeLowerHex(destination.OutputScript), + OutputScriptHash: normalizeLowerHex(destination.OutputScriptHash), + OutputValueSats: destination.OutputValueSats, + DestinationCommitmentHash: normalizeLowerHex(destination.DestinationCommitmentHash), + } +} + +func normalizeRenewDestination( + destination *RenewDestinationReservation, +) *RenewDestinationReservation { + if destination == nil { + return nil + } + + return &RenewDestinationReservation{ + ReservationID: strings.TrimSpace(destination.ReservationID), + Reserve: normalizeLowerHex(destination.Reserve), + Epoch: destination.Epoch, + Route: destination.Route, + Revealer: normalizeLowerHex(destination.Revealer), + Vault: normalizeLowerHex(destination.Vault), + Network: strings.TrimSpace(destination.Network), + Status: destination.Status, + NextCovenantScript: normalizeLowerHex(destination.NextCovenantScript), + NextCovenantScriptHash: normalizeLowerHex(destination.NextCovenantScriptHash), + NextMaturityHeight: destination.NextMaturityHeight, + OutputValueSats: destination.OutputValueSats, + DestinationCommitmentHash: normalizeLowerHex(destination.DestinationCommitmentHash), + } +} + func normalizeMigrationTransactionPlan( plan *MigrationTransactionPlan, ) *MigrationTransactionPlan { diff --git a/pkg/tbtc/covenant_signer.go b/pkg/tbtc/covenant_signer.go index 2aa7e1921a..0830f4df76 100644 --- a/pkg/tbtc/covenant_signer.go +++ b/pkg/tbtc/covenant_signer.go @@ -30,6 +30,11 @@ type covenantSignerEngine struct { // SIGHASH_ALL signature over a covenant active UTXO is otherwise a valid, // undefeatable tBTC fraud proof against the signing wallet. bridgeFraudDefenseConfirmed bool + // eip712ChainID and eip712Salt define the EIP-712 domain used to recompute + // the v2 artifact approval digest during signer approval verification. They + // must match the covenant signer service's configured domain. + eip712ChainID uint64 + eip712Salt [32]byte } // Compile-time assertions that covenantSignerEngine satisfies the full @@ -81,6 +86,8 @@ func newCovenantSignerEngine( node *node, minConfirmations uint, bridgeFraudDefenseConfirmed bool, + eip712ChainID uint64, + eip712Salt [32]byte, ) covenantsigner.Engine { if minConfirmations == 0 { minConfirmations = defaultMinActiveOutpointConfirmations @@ -90,6 +97,8 @@ func newCovenantSignerEngine( node: node, minimumActiveOutpointConfirmations: minConfirmations, bridgeFraudDefenseConfirmed: bridgeFraudDefenseConfirmed, + eip712ChainID: eip712ChainID, + eip712Salt: eip712Salt, } } @@ -109,6 +118,8 @@ func (cse *covenantSignerEngine) VerifySignerApproval( expectedApprovalDigest, err := covenantsigner.ComputeArtifactApprovalDigest( request.ArtifactApprovals.Payload, + cse.eip712ChainID, + cse.eip712Salt, ) if err != nil { return covenantsigner.NewInputError( @@ -220,11 +231,22 @@ func (cse *covenantSignerEngine) VerifySignerApproval( } // isCovenantSigningEligibleState reports whether a wallet in the given state is -// eligible to receive covenant signatures. Covenant migrations are only -// expected for live wallets, so covenant signing fails closed for every other -// state (including closed and terminated wallets that the closure path intends -// to deauthorize). If covenant signing must be allowed for another state in the -// future, add it here explicitly together with justification and tests. +// eligible to receive covenant signatures. The rule is uniform across every +// covenant action - migration, redeem, and renew: all three are only expected +// for live wallets, so covenant signing fails closed for every other state +// (including closed and terminated wallets that the closure path intends to +// deauthorize). +// +// Redeem and renew are deliberately held to the same live-only rule rather than +// being widened on the intuition that a cooperative payout should still be +// possible while a wallet winds down. Nothing in a signer approval certificate +// binds the wallet's state - the signer set hash covers only wallet identity, +// members hash, and threshold - so a certificate issued while the wallet was +// live stays verifiable after closure, and widening any action here makes that +// certificate replayable against a wallet the protocol already deauthorized. +// Allowing another action/state pair therefore requires an explicit protocol +// decision about why certificate reuse past that point is safe; add it here +// with that justification and matching tests. func isCovenantSigningEligibleState(state WalletState) bool { return state == StateLive } @@ -833,31 +855,75 @@ func (cse *covenantSignerEngine) buildQcV1SignerHandoff( }, nil } +// covenantDestinationOutputScript returns the destination output scriptPubKey +// for the request's covenant action. The redeem payout script and the renew +// next-covenant script are already output scripts and are paid directly, but a +// migration's deposit script is not: it is wrapped into its P2WSH scriptPubKey +// first. Validation has already recompute-and-compared each of these against the +// action's destination commitment, so the built output is exactly what the +// depositor's artifact approval authorized. +func covenantDestinationOutputScript( + request covenantsigner.RouteSubmitRequest, +) (bitcoin.Script, error) { + switch request.ResolvedAction() { + case covenantsigner.CovenantActionMigration: + if request.MigrationDestination == nil { + return nil, fmt.Errorf("migration destination is required") + } + script, err := decodePrefixedHex(request.MigrationDestination.DepositScript) + if err != nil { + return nil, fmt.Errorf("migration destination deposit script is invalid") + } + if len(script) == 0 { + return nil, fmt.Errorf("migration destination deposit script must not be empty") + } + // MigrationDestination.DepositScript is the plain tBTC deposit script, not + // a ready-made output script. The Bitcoin funding output must pay to its + // P2WSH script hash (OP_0 ), which is how the tBTC + // Bridge rebuilds and verifies the funding output in + // revealDepositWithExtraData. Using the plain deposit script directly as + // the output script would make the migration deposit unrevealable to the + // Bridge. + scriptPubKey, err := payToWitnessScriptHash(script) + if err != nil { + return nil, fmt.Errorf("cannot build migration destination locking script: %v", err) + } + return scriptPubKey, nil + case covenantsigner.CovenantActionRedeem: + if request.RedeemDestination == nil { + return nil, fmt.Errorf("redeem destination is required") + } + script, err := decodePrefixedHex(request.RedeemDestination.OutputScript) + if err != nil { + return nil, fmt.Errorf("redeem destination output script is invalid") + } + return script, nil + case covenantsigner.CovenantActionRenew: + if request.RenewDestination == nil { + return nil, fmt.Errorf("renew destination is required") + } + script, err := decodePrefixedHex(request.RenewDestination.NextCovenantScript) + if err != nil { + return nil, fmt.Errorf("renew destination next covenant script is invalid") + } + return script, nil + default: + return nil, fmt.Errorf("unsupported covenant action %q", request.ResolvedAction()) + } +} + func (cse *covenantSignerEngine) buildCovenantTransactionBuilder( request covenantsigner.RouteSubmitRequest, activeUtxo *bitcoin.UnspentTransactionOutput, witnessScript bitcoin.Script, ) (*bitcoin.TransactionBuilder, error) { - destinationDepositScript, err := decodePrefixedHex(request.MigrationDestination.DepositScript) - if err != nil { - return nil, fmt.Errorf("migration destination deposit script is invalid") - } - if len(destinationDepositScript) == 0 { - return nil, fmt.Errorf("migration destination deposit script must not be empty") - } - // MigrationDestination.DepositScript is the plain tBTC deposit script, not a - // ready-made output script. The Bitcoin funding output must pay to its P2WSH - // script hash (OP_0 ), which is how the tBTC Bridge - // rebuilds and verifies the funding output in revealDepositWithExtraData. - // Using the plain deposit script directly as the output script would make - // the migration deposit unrevealable to the Bridge. - destinationScriptPubKey, err := payToWitnessScriptHash(destinationDepositScript) + destinationScriptPubKey, err := covenantDestinationOutputScript(request) if err != nil { - return nil, fmt.Errorf("cannot build migration destination locking script: %v", err) + return nil, err } destinationValue, err := toBitcoinOutputValue( request.MigrationTransactionPlan.DestinationValueSats, - "migration destination value", + "covenant destination value", ) if err != nil { return nil, err diff --git a/pkg/tbtc/covenant_signer_redeem_renew_test.go b/pkg/tbtc/covenant_signer_redeem_renew_test.go new file mode 100644 index 0000000000..797de284cc --- /dev/null +++ b/pkg/tbtc/covenant_signer_redeem_renew_test.go @@ -0,0 +1,471 @@ +package tbtc + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "testing" + + "github.com/btcsuite/btcd/btcec" + "github.com/keep-network/keep-core/pkg/bitcoin" + "github.com/keep-network/keep-core/pkg/covenantsigner" +) + +// covenantActionScaffold holds the common self_v1 signing setup shared by the +// REDEEM and RENEW transaction-output binding tests. +type covenantActionScaffold struct { + service *covenantsigner.Service + node *node + walletPublicKey *ecdsa.PublicKey + depositorPrivateKey *btcec.PrivateKey + request covenantsigner.RouteSubmitRequest + reserve string + revealer string + vault string + epoch uint64 + destinationValueSats uint64 +} + +// newCovenantActionScaffold builds a valid self_v1 request shell (node, service, +// template, active UTXO, transaction plan) with no destination attached. The +// caller sets request.Action + the matching destination + DestinationCommitmentHash, +// then finalizes via applyTestMigrationTransactionPlanCommitment / +// applyTestArtifactApprovals before submitting. +func newCovenantActionScaffold(t *testing.T) covenantActionScaffold { + t.Helper() + + node, bitcoinChain, walletPublicKey := setupCovenantSignerTestNode(t) + + service, err := covenantsigner.NewService( + newCovenantSignerMemoryHandle(), + newCovenantSignerEngine(node, 0, true, testEIP712ChainID, testEIP712Salt), + ) + if err != nil { + t.Fatal(err) + } + + depositorPrivateKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), bytes.Repeat([]byte{0x42}, 32)) + depositorPublicKey := depositorPrivateKey.PubKey().SerializeCompressed() + signerPublicKey := (*btcec.PublicKey)(walletPublicKey).SerializeCompressed() + + template := &covenantsigner.SelfV1Template{ + Template: covenantsigner.TemplateSelfV1, + DepositorPublicKey: "0x" + hex.EncodeToString(depositorPublicKey), + SignerPublicKey: "0x" + hex.EncodeToString(signerPublicKey), + Delta2: 4320, + } + templateJSON, err := json.Marshal(template) + if err != nil { + t.Fatal(err) + } + + maturityHeight := uint64(912345) + witnessScript, err := buildSelfV1WitnessScript(template, maturityHeight) + if err != nil { + t.Fatal(err) + } + witnessScriptHash := bitcoin.WitnessScriptHash(witnessScript) + activeScriptPubKey, err := bitcoin.PayToWitnessScriptHash(witnessScriptHash) + if err != nil { + t.Fatal(err) + } + + const ( + inputValueSats = uint64(1_000_000) + destinationValueSats = uint64(998_000) + anchorValueSats = uint64(330) + feeSats = uint64(1_670) + ) + + prevTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + {Outpoint: &bitcoin.TransactionOutpoint{}, Sequence: 0xffffffff}, + }, + Outputs: []*bitcoin.TransactionOutput{ + {Value: int64(inputValueSats), PublicKeyScript: activeScriptPubKey}, + }, + Locktime: 0, + } + bitcoinChain.transactions = append(bitcoinChain.transactions, prevTransaction) + bitcoinChain.setTransactionConfirmations(prevTransaction.Hash(), 6) + + activeScriptHash := sha256.Sum256(activeScriptPubKey) + reserve := "0x1111111111111111111111111111111111111111" + revealer := "0x2222222222222222222222222222222222222222" + vault := "0x3333333333333333333333333333333333333333" + + request := covenantsigner.RouteSubmitRequest{ + FacadeRequestID: "rf_action_1", + IdempotencyKey: "idem_action_1", + RequestType: covenantsigner.RequestTypeReconstruct, + Route: covenantsigner.TemplateSelfV1, + Strategy: "0x1234", + Reserve: reserve, + Epoch: 12, + MaturityHeight: maturityHeight, + ActiveOutpoint: covenantsigner.CovenantOutpoint{ + TxID: "0x" + prevTransaction.Hash().Hex(bitcoin.ReversedByteOrder), + Vout: 0, + ScriptHash: "0x" + hex.EncodeToString(activeScriptHash[:]), + }, + MigrationTransactionPlan: &covenantsigner.MigrationTransactionPlan{ + InputValueSats: inputValueSats, + DestinationValueSats: destinationValueSats, + AnchorValueSats: anchorValueSats, + FeeSats: feeSats, + InputSequence: 0xfffffffd, + LockTime: uint32(maturityHeight), + }, + Artifacts: map[covenantsigner.RecoveryPathID]covenantsigner.ArtifactRecord{}, + ScriptTemplate: templateJSON, + Signing: covenantsigner.SigningRequirements{ + SignerRequired: true, + CustodianRequired: false, + }, + } + + return covenantActionScaffold{ + service: service, + node: node, + walletPublicKey: walletPublicKey, + depositorPrivateKey: depositorPrivateKey, + request: request, + reserve: reserve, + revealer: revealer, + vault: vault, + epoch: 12, + destinationValueSats: destinationValueSats, + } +} + +// payoutScript is a distinct P2WPKH scriptPubKey used as the redeem/renew output. +func payoutScript(t *testing.T, fill byte) bitcoin.Script { + t.Helper() + var hash [20]byte + for i := range hash { + hash[i] = fill + } + script, err := bitcoin.PayToWitnessPublicKeyHash(hash) + if err != nil { + t.Fatal(err) + } + return script +} + +type testRedeemCommitmentPayload struct { + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route string `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + OutputScriptHash string `json:"outputScriptHash"` + OutputValueSats uint64 `json:"outputValueSats"` +} + +func testRedeemCommitmentHash(t *testing.T, r *covenantsigner.RedeemDestinationReservation) string { + t.Helper() + payload, err := json.Marshal(testRedeemCommitmentPayload{ + Reserve: strings.ToLower(r.Reserve), + Epoch: r.Epoch, + Route: string(r.Route), + Revealer: strings.ToLower(r.Revealer), + Vault: strings.ToLower(r.Vault), + Network: strings.TrimSpace(r.Network), + OutputScriptHash: strings.ToLower(r.OutputScriptHash), + OutputValueSats: r.OutputValueSats, + }) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]) +} + +type testRenewCommitmentPayload struct { + Reserve string `json:"reserve"` + Epoch uint64 `json:"epoch"` + Route string `json:"route"` + Revealer string `json:"revealer"` + Vault string `json:"vault"` + Network string `json:"network"` + NextCovenantScriptHash string `json:"nextCovenantScriptHash"` + NextMaturityHeight uint64 `json:"nextMaturityHeight"` + OutputValueSats uint64 `json:"outputValueSats"` +} + +func testRenewCommitmentHash(t *testing.T, r *covenantsigner.RenewDestinationReservation) string { + t.Helper() + payload, err := json.Marshal(testRenewCommitmentPayload{ + Reserve: strings.ToLower(r.Reserve), + Epoch: r.Epoch, + Route: string(r.Route), + Revealer: strings.ToLower(r.Revealer), + Vault: strings.ToLower(r.Vault), + Network: strings.TrimSpace(r.Network), + NextCovenantScriptHash: strings.ToLower(r.NextCovenantScriptHash), + NextMaturityHeight: r.NextMaturityHeight, + OutputValueSats: r.OutputValueSats, + }) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(payload) + return "0x" + hex.EncodeToString(sum[:]) +} + +// submitAndDecode finalizes plan+approvals, submits, and returns the signed +// transaction's outputs for assertion. +func submitAndDecode(t *testing.T, s covenantActionScaffold, id string) *bitcoin.Transaction { + t.Helper() + + applyTestMigrationTransactionPlanCommitment(t, &s.request) + applyTestArtifactApprovals(t, s.node, s.walletPublicKey, &s.request, s.depositorPrivateKey, nil) + + result, err := s.service.Submit(context.Background(), covenantsigner.TemplateSelfV1, covenantsigner.SignerSubmitInput{ + RouteRequestID: id, + Stage: covenantsigner.StageSignerCoordination, + Request: s.request, + }) + if err != nil { + t.Fatal(err) + } + if result.Status != covenantsigner.StepStatusReady { + t.Fatalf("expected READY, got %s (%s)", result.Status, result.Detail) + } + + transactionBytes, err := hex.DecodeString(strings.TrimPrefix(result.TransactionHex, "0x")) + if err != nil { + t.Fatal(err) + } + transaction := &bitcoin.Transaction{} + if err := transaction.Deserialize(transactionBytes); err != nil { + t.Fatal(err) + } + return transaction +} + +func TestCovenantSignerEngine_SubmitRedeemPaysCommittedOutput(t *testing.T) { + s := newCovenantActionScaffold(t) + outputScript := payoutScript(t, 0xbb) + + dest := &covenantsigner.RedeemDestinationReservation{ + ReservationID: "crdr_1", + Reserve: s.reserve, + Epoch: s.epoch, + Route: covenantsigner.ReservationRouteRedeem, + Revealer: s.revealer, + Vault: s.vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusReserved, + OutputScript: "0x" + hex.EncodeToString(outputScript), + OutputScriptHash: testDepositScriptHash(t, outputScript), + OutputValueSats: s.destinationValueSats, + } + dest.DestinationCommitmentHash = testRedeemCommitmentHash(t, dest) + + s.request.Action = covenantsigner.CovenantActionRedeem + s.request.RedeemDestination = dest + s.request.DestinationCommitmentHash = dest.DestinationCommitmentHash + + transaction := submitAndDecode(t, s, "ors_redeem_tx") + + if len(transaction.Outputs) != 2 { + t.Fatalf("unexpected output count: %d", len(transaction.Outputs)) + } + if transaction.Outputs[0].Value != int64(s.destinationValueSats) { + t.Fatalf("redeem output value: got %d, want %d", transaction.Outputs[0].Value, s.destinationValueSats) + } + if !bytes.Equal(transaction.Outputs[0].PublicKeyScript, outputScript) { + t.Fatal("redeem output script does not match the committed payout script") + } +} + +func TestCovenantSignerEngine_SubmitRenewPaysCommittedOutput(t *testing.T) { + s := newCovenantActionScaffold(t) + nextScript := payoutScript(t, 0xcc) + + dest := &covenantsigner.RenewDestinationReservation{ + ReservationID: "crnr_1", + Reserve: s.reserve, + Epoch: s.epoch, + Route: covenantsigner.ReservationRouteRenew, + Revealer: s.revealer, + Vault: s.vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusReserved, + NextCovenantScript: "0x" + hex.EncodeToString(nextScript), + NextCovenantScriptHash: testDepositScriptHash(t, nextScript), + NextMaturityHeight: 987654, + OutputValueSats: s.destinationValueSats, + } + dest.DestinationCommitmentHash = testRenewCommitmentHash(t, dest) + + s.request.Action = covenantsigner.CovenantActionRenew + s.request.RenewDestination = dest + s.request.DestinationCommitmentHash = dest.DestinationCommitmentHash + + transaction := submitAndDecode(t, s, "ors_renew_tx") + + if len(transaction.Outputs) != 2 { + t.Fatalf("unexpected output count: %d", len(transaction.Outputs)) + } + if transaction.Outputs[0].Value != int64(s.destinationValueSats) { + t.Fatalf("renew output value: got %d, want %d", transaction.Outputs[0].Value, s.destinationValueSats) + } + if !bytes.Equal(transaction.Outputs[0].PublicKeyScript, nextScript) { + t.Fatal("renew output script does not match the committed next-covenant script") + } +} + +// nonLiveCovenantSigningStates enumerates every wallet state other than +// StateLive. Listing them explicitly (rather than deriving them) means a newly +// added WalletState does not silently escape the matrix below: it stays absent +// until someone decides, and records here, whether covenant signing may occur +// in it. +var nonLiveCovenantSigningStates = []WalletState{ + StateUnknown, + StateMovingFunds, + StateClosing, + StateClosed, + StateTerminated, +} + +// approvedRedeemActionRequest returns a REDEEM request that is valid in every +// respect except, potentially, the wallet's on-chain state. +func approvedRedeemActionRequest( + t *testing.T, + s covenantActionScaffold, +) covenantsigner.RouteSubmitRequest { + t.Helper() + + outputScript := payoutScript(t, 0xbb) + dest := &covenantsigner.RedeemDestinationReservation{ + ReservationID: "crdr_state_matrix", + Reserve: s.reserve, + Epoch: s.epoch, + Route: covenantsigner.ReservationRouteRedeem, + Revealer: s.revealer, + Vault: s.vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusReserved, + OutputScript: "0x" + hex.EncodeToString(outputScript), + OutputScriptHash: testDepositScriptHash(t, outputScript), + OutputValueSats: s.destinationValueSats, + } + dest.DestinationCommitmentHash = testRedeemCommitmentHash(t, dest) + + s.request.Action = covenantsigner.CovenantActionRedeem + s.request.RedeemDestination = dest + s.request.DestinationCommitmentHash = dest.DestinationCommitmentHash + + applyTestMigrationTransactionPlanCommitment(t, &s.request) + applyTestArtifactApprovals(t, s.node, s.walletPublicKey, &s.request, s.depositorPrivateKey, nil) + + return s.request +} + +// approvedRenewActionRequest returns a RENEW request that is valid in every +// respect except, potentially, the wallet's on-chain state. +func approvedRenewActionRequest( + t *testing.T, + s covenantActionScaffold, +) covenantsigner.RouteSubmitRequest { + t.Helper() + + nextScript := payoutScript(t, 0xcc) + dest := &covenantsigner.RenewDestinationReservation{ + ReservationID: "crnr_state_matrix", + Reserve: s.reserve, + Epoch: s.epoch, + Route: covenantsigner.ReservationRouteRenew, + Revealer: s.revealer, + Vault: s.vault, + Network: "regtest", + Status: covenantsigner.ReservationStatusReserved, + NextCovenantScript: "0x" + hex.EncodeToString(nextScript), + NextCovenantScriptHash: testDepositScriptHash(t, nextScript), + NextMaturityHeight: 987654, + OutputValueSats: s.destinationValueSats, + } + dest.DestinationCommitmentHash = testRenewCommitmentHash(t, dest) + + s.request.Action = covenantsigner.CovenantActionRenew + s.request.RenewDestination = dest + s.request.DestinationCommitmentHash = dest.DestinationCommitmentHash + + applyTestMigrationTransactionPlanCommitment(t, &s.request) + applyTestArtifactApprovals(t, s.node, s.walletPublicKey, &s.request, s.depositorPrivateKey, nil) + + return s.request +} + +// TestCovenantSignerEngine_VerifySignerApprovalWalletStateMatrixForActions pins +// the action x wallet-state authorization matrix for the cooperative actions. +// REDEEM and RENEW are held to the same live-only rule as MIGRATION: a signer +// approval certificate binds wallet identity, members hash, and threshold, but +// nothing about wallet state, so a certificate issued while the wallet was live +// would otherwise stay replayable against a wallet the protocol has since +// deauthorized. Widening any cell here is a protocol decision, and this matrix +// is what makes such a widening deliberate rather than incidental. +func TestCovenantSignerEngine_VerifySignerApprovalWalletStateMatrixForActions(t *testing.T) { + actions := []struct { + name string + build func(*testing.T, covenantActionScaffold) covenantsigner.RouteSubmitRequest + }{ + {"REDEEM", approvedRedeemActionRequest}, + {"RENEW", approvedRenewActionRequest}, + } + + for _, action := range actions { + t.Run(action.name, func(t *testing.T) { + s := newCovenantActionScaffold(t) + request := action.build(t, s) + + localChain, ok := s.node.chain.(*localChain) + if !ok { + t.Fatal("expected local chain implementation") + } + + walletPublicKeyHash := bitcoin.PublicKeyHash(s.walletPublicKey) + live, err := localChain.GetWallet(walletPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + cse := &covenantSignerEngine{node: s.node} + + // Control: while the wallet is live the request is otherwise valid, so + // a rejection below is attributable to the wallet state alone. + if err := cse.VerifySignerApproval(request); err != nil { + t.Fatalf("expected a live wallet to be accepted, got: %v", err) + } + + for _, state := range nonLiveCovenantSigningStates { + t.Run(state.String(), func(t *testing.T) { + mutated := *live + mutated.State = state + localChain.setWallet(walletPublicKeyHash, &mutated) + defer localChain.setWallet(walletPublicKeyHash, live) + + err := cse.VerifySignerApproval(request) + if err == nil { + t.Fatalf( + "expected %s to be rejected for a wallet in state %v", + action.name, + state, + ) + } + if !strings.Contains(err.Error(), "not eligible for covenant signing") { + t.Fatalf("unexpected error for state %v: %v", state, err) + } + }) + } + }) + } +} diff --git a/pkg/tbtc/covenant_signer_test.go b/pkg/tbtc/covenant_signer_test.go index 67ef7a6846..03db8e047f 100644 --- a/pkg/tbtc/covenant_signer_test.go +++ b/pkg/tbtc/covenant_signer_test.go @@ -83,7 +83,7 @@ func TestCovenantSignerEngine_SubmitSelfV1Ready(t *testing.T) { service, err := covenantsigner.NewService( newCovenantSignerMemoryHandle(), - newCovenantSignerEngine(node, 0, true), + newCovenantSignerEngine(node, 0, true, testEIP712ChainID, testEIP712Salt), ) if err != nil { t.Fatal(err) @@ -330,7 +330,7 @@ func TestCovenantSignerEngine_SubmitQcV1HandoffReady(t *testing.T) { service, err := covenantsigner.NewService( newCovenantSignerMemoryHandle(), - newCovenantSignerEngine(node, 0, true), + newCovenantSignerEngine(node, 0, true, testEIP712ChainID, testEIP712Salt), ) if err != nil { t.Fatal(err) @@ -629,7 +629,7 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsInvalidBeta(t *testing.T) { service, err := covenantsigner.NewService( newCovenantSignerMemoryHandle(), - newCovenantSignerEngine(node, 0, true), + newCovenantSignerEngine(node, 0, true, testEIP712ChainID, testEIP712Salt), ) if err != nil { t.Fatal(err) @@ -745,7 +745,7 @@ func TestCovenantSignerEngine_SubmitQcV1RejectsScriptHashMismatch(t *testing.T) service, err := covenantsigner.NewService( newCovenantSignerMemoryHandle(), - newCovenantSignerEngine(node, 0, true), + newCovenantSignerEngine(node, 0, true, testEIP712ChainID, testEIP712Salt), ) if err != nil { t.Fatal(err) @@ -891,7 +891,7 @@ func TestCovenantSignerEngine_SubmitSelfV1RejectsZeroMaturityHeight(t *testing.T service, err := covenantsigner.NewService( newCovenantSignerMemoryHandle(), - newCovenantSignerEngine(node, 0, true), + newCovenantSignerEngine(node, 0, true, testEIP712ChainID, testEIP712Salt), ) if err != nil { t.Fatal(err) @@ -1144,7 +1144,7 @@ func TestCovenantSignerEngine_OnSubmitFailsClosedWithoutBridgeFraudDefense(t *te // Construct the engine with the default (fail-closed) configuration: the // bridge covenant fraud-defense path is not confirmed deployed. - engine := newCovenantSignerEngine(node, 0, false) + engine := newCovenantSignerEngine(node, 0, false, testEIP712ChainID, testEIP712Salt) for _, route := range []covenantsigner.TemplateID{ covenantsigner.TemplateSelfV1, @@ -1367,6 +1367,17 @@ var testArtifactApprovalTypeHash = crypto.Keccak256Hash([]byte( "bytes32 planCommitmentHash)", )) +var testEIP712DomainTypeHash = crypto.Keccak256Hash([]byte( + "EIP712Domain(string name,string version,uint256 chainId,bytes32 salt)", +)) + +// testEIP712ChainID and testEIP712Salt are the zero EIP-712 domain used across +// tbtc covenant tests, matching engines constructed with (0, zero salt). +var ( + testEIP712ChainID uint64 + testEIP712Salt [32]byte +) + func testArtifactApprovalDigest( t *testing.T, payload covenantsigner.ArtifactApprovalPayload, @@ -1421,8 +1432,25 @@ func testArtifactApprovalDigest( copy(encoded[96:128], scriptTemplateIdentifier[:]) copy(encoded[128:160], destinationCommitmentHash[:]) copy(encoded[160:192], planCommitmentHash[:]) - - digest := crypto.Keccak256Hash(encoded) + structHash := crypto.Keccak256Hash(encoded) + + // Domain-wrap the struct hash: keccak256(0x1901 ‖ domainSeparator ‖ structHash). + var chainIDWord [32]byte + binary.BigEndian.PutUint64(chainIDWord[24:], testEIP712ChainID) + domainEncoded := make([]byte, 32*5) + copy(domainEncoded[0:32], testEIP712DomainTypeHash.Bytes()) + copy(domainEncoded[32:64], crypto.Keccak256Hash([]byte("tBTC Covenant Artifact Approval")).Bytes()) + copy(domainEncoded[64:96], crypto.Keccak256Hash([]byte("2")).Bytes()) + copy(domainEncoded[96:128], chainIDWord[:]) + copy(domainEncoded[128:160], testEIP712Salt[:]) + domainSeparator := crypto.Keccak256Hash(domainEncoded) + + prefixed := make([]byte, 0, 2+32+32) + prefixed = append(prefixed, 0x19, 0x01) + prefixed = append(prefixed, domainSeparator.Bytes()...) + prefixed = append(prefixed, structHash.Bytes()...) + + digest := crypto.Keccak256Hash(prefixed) return digest.Bytes() } @@ -1452,7 +1480,7 @@ func applyTestArtifactApprovals( t.Helper() payload := covenantsigner.ArtifactApprovalPayload{ - ApprovalVersion: 1, + ApprovalVersion: 2, Route: request.Route, ScriptTemplateID: request.Route, DestinationCommitmentHash: request.DestinationCommitmentHash, @@ -1587,7 +1615,7 @@ func TestCovenantSignerEngine_SubmitRejectsUnsupportedRoute(t *testing.T) { func TestCovenantSignerEngine_CurrentBlockHeightUsesNodeHostChain(t *testing.T) { node, _, _ := setupCovenantSignerTestNode(t) - engine := newCovenantSignerEngine(node, 0, true) + engine := newCovenantSignerEngine(node, 0, true, testEIP712ChainID, testEIP712Salt) cse, ok := engine.(*covenantSignerEngine) if !ok { t.Fatal("expected engine to be *covenantSignerEngine") @@ -1622,7 +1650,7 @@ func TestCovenantSignerEngine_CurrentBlockHeightUsesNodeHostChain(t *testing.T) func TestNewCovenantSignerEngine_DefaultMinConfirmations(t *testing.T) { node, _, _ := setupCovenantSignerTestNode(t) - engine := newCovenantSignerEngine(node, 0, true) + engine := newCovenantSignerEngine(node, 0, true, testEIP712ChainID, testEIP712Salt) cse, ok := engine.(*covenantSignerEngine) if !ok { @@ -1640,7 +1668,7 @@ func TestNewCovenantSignerEngine_DefaultMinConfirmations(t *testing.T) { func TestNewCovenantSignerEngine_ExplicitMinConfirmations(t *testing.T) { node, _, _ := setupCovenantSignerTestNode(t) - engine := newCovenantSignerEngine(node, 3, true) + engine := newCovenantSignerEngine(node, 3, true, testEIP712ChainID, testEIP712Salt) cse, ok := engine.(*covenantSignerEngine) if !ok { diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index e746dc1f90..c1f0bbca75 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -86,6 +86,8 @@ func Initialize( perfMetrics *clientinfo.PerformanceMetrics, minActiveOutpointConfirmations uint, bridgeCovenantFraudDefenseConfirmed bool, + eip712ChainID uint64, + eip712Salt [32]byte, ) (covenantsigner.Engine, error) { groupParameters := &GroupParameters{ GroupSize: 100, @@ -308,6 +310,8 @@ func Initialize( node, minActiveOutpointConfirmations, bridgeCovenantFraudDefenseConfirmed, + eip712ChainID, + eip712Salt, ), nil }