diff --git a/go.mod b/go.mod index 33af079464..6e1a8d41c6 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pion/datachannel v1.5.10 // indirect diff --git a/pkg/bitcoin/script.go b/pkg/bitcoin/script.go index b6bdf144dc..405ea6ec08 100644 --- a/pkg/bitcoin/script.go +++ b/pkg/bitcoin/script.go @@ -19,6 +19,7 @@ const ( P2WPKHScript P2SHScript P2WSHScript + P2TRScript ) func (st ScriptType) String() string { @@ -31,6 +32,8 @@ func (st ScriptType) String() string { return "P2SH" case P2WSHScript: return "P2WSH" + case P2TRScript: + return "P2TR" default: return "NonStandard" } @@ -147,8 +150,25 @@ func PayToScriptHash(scriptHash [20]byte) (Script, error) { Script() } +// PayToTaproot constructs a P2TR script for the provided 32-byte x-only +// Taproot output key. The function assumes the provided output key is valid. +// +// The argument must be the final Taproot output key committed to by the +// scriptPubKey. This helper does not derive a BIP-341/BIP-86 tweak from an +// internal key. +func PayToTaproot(outputKey [32]byte) (Script, error) { + return txscript.NewScriptBuilder(). + AddOp(txscript.OP_1). + AddData(outputKey[:]). + Script() +} + // GetScriptType gets the ScriptType of the given Script. func GetScriptType(script Script) ScriptType { + if isPayToTaproot(script) { + return P2TRScript + } + switch txscript.GetScriptClass(script) { case txscript.PubKeyHashTy: return P2PKHScript @@ -163,6 +183,12 @@ func GetScriptType(script Script) ScriptType { } } +func isPayToTaproot(script Script) bool { + return len(script) == 34 && + script[0] == txscript.OP_1 && + script[1] == txscript.OP_DATA_32 +} + // ExtractPublicKeyHash extracts the public key hash from a P2WPKH or P2PKH // script. func ExtractPublicKeyHash(script Script) ([20]byte, error) { @@ -189,3 +215,15 @@ func ExtractPublicKeyHash(script Script) ([20]byte, error) { return publicKeyHash, nil } + +// ExtractTaprootKey extracts the x-only output key from a P2TR script. +func ExtractTaprootKey(script Script) ([32]byte, error) { + if GetScriptType(script) != P2TRScript { + return [32]byte{}, fmt.Errorf("not a P2TR script") + } + + var outputKey [32]byte + copy(outputKey[:], script[2:]) + + return outputKey, nil +} diff --git a/pkg/bitcoin/script_test.go b/pkg/bitcoin/script_test.go index 9de1d69869..4af00b1998 100644 --- a/pkg/bitcoin/script_test.go +++ b/pkg/bitcoin/script_test.go @@ -334,6 +334,32 @@ func TestPayToScriptHash(t *testing.T) { testutils.AssertBytesEqual(t, expectedResult, result[:]) } +func TestPayToTaproot(t *testing.T) { + outputKeyBytes, err := hex.DecodeString( + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + ) + if err != nil { + t.Fatal(err) + } + + var outputKey [32]byte + copy(outputKey[:], outputKeyBytes) + + result, err := PayToTaproot(outputKey) + if err != nil { + t.Fatal(err) + } + + expectedResult, err := hex.DecodeString( + "51201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + ) + if err != nil { + t.Fatal(err) + } + + testutils.AssertBytesEqual(t, expectedResult, result[:]) +} + func TestGetScriptType(t *testing.T) { fromHex := func(hexString string) []byte { bytes, err := hex.DecodeString(hexString) @@ -363,6 +389,10 @@ func TestGetScriptType(t *testing.T) { script: fromHex("002086a303cdd2e2eab1d1679f1a813835dc5a1b65321077cdccaf08f98cbf04ca96"), expectedType: P2WSHScript, }, + "p2tr script": { + script: fromHex("51201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"), + expectedType: P2TRScript, + }, "non-standard script": { script: fromHex( "14934b98637ca318a4d6e7ca6ffd1690b8e77df6377508f9f0c90d0003" + @@ -387,6 +417,59 @@ func TestGetScriptType(t *testing.T) { } } +func TestExtractTaprootKey(t *testing.T) { + fromHex := func(hexString string) []byte { + bytes, err := hex.DecodeString(hexString) + if err != nil { + t.Fatal(err) + } + return bytes + } + + var outputKey [32]byte + copy( + outputKey[:], + fromHex("1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"), + ) + + var tests = map[string]struct { + script Script + expectedOutputKey [32]byte + expectedErr error + }{ + "P2TR script": { + script: fromHex("51201b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f"), + expectedOutputKey: outputKey, + }, + "other script": { + script: fromHex("00148db50eb52063ea9d98b3eac91489a90f738986f6"), + expectedErr: fmt.Errorf("not a P2TR script"), + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + actualOutputKey, err := ExtractTaprootKey(test.script) + + if !reflect.DeepEqual(test.expectedErr, err) { + t.Errorf( + "unexpected error\nexpected: %+v\nactual: %+v\n", + test.expectedErr, + err, + ) + } + + if test.expectedOutputKey != actualOutputKey { + t.Errorf( + "unexpected taproot output key\nexpected: 0x%x\nactual: 0x%x\n", + test.expectedOutputKey, + actualOutputKey, + ) + } + }) + } +} + func TestExtractPublicKeyHash(t *testing.T) { fromHex := func(hexString string) []byte { bytes, err := hex.DecodeString(hexString) diff --git a/pkg/bitcoin/transaction_builder.go b/pkg/bitcoin/transaction_builder.go index db8855af44..6e79933175 100644 --- a/pkg/bitcoin/transaction_builder.go +++ b/pkg/bitcoin/transaction_builder.go @@ -1,12 +1,16 @@ package bitcoin import ( + "bytes" "crypto/ecdsa" + "crypto/sha256" + "encoding/binary" "encoding/hex" "fmt" "math/big" "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -33,8 +37,41 @@ func NewTransactionBuilder(chain Chain) *TransactionBuilder { } } +// HasTaprootKeyPathInputs returns true if the builder has at least one P2TR +// input intended to be spent using the Taproot key path. +func (tb *TransactionBuilder) HasTaprootKeyPathInputs() bool { + for _, sigHashArgs := range tb.sigHashArgs { + if sigHashArgs.scriptType == P2TRScript { + return true + } + } + + return false +} + +// HasOnlyTaprootKeyPathInputs returns true if every input in the builder is a +// P2TR input intended to be spent using the Taproot key path. +func (tb *TransactionBuilder) HasOnlyTaprootKeyPathInputs() bool { + if len(tb.sigHashArgs) == 0 { + return false + } + + for _, sigHashArgs := range tb.sigHashArgs { + if sigHashArgs.scriptType != P2TRScript { + return false + } + } + + return true +} + // AddPublicKeyHashInput adds an unsigned input pointing to a UTXO locked // using a P2PKH or P2WPKH script. +// +// For backward compatibility with wallet-action construction that discovers +// the input script type from the chain, this method also accepts P2TR direct +// key-path inputs. New Taproot-specific code should prefer +// AddTaprootKeyPathInput to make that spend policy explicit. func (tb *TransactionBuilder) AddPublicKeyHashInput( utxo *UnspentTransactionOutput, ) error { @@ -47,25 +84,65 @@ func (tb *TransactionBuilder) AddPublicKeyHashInput( ) } - class := txscript.GetScriptClass(utxoScript) - isPublicKeyHashScript := class == txscript.PubKeyHashTy || - class == txscript.WitnessV0PubKeyHashTy - if !isPublicKeyHashScript { + scriptType := GetScriptType(utxoScript) + isDirectKeySpendScript := scriptType == P2PKHScript || + scriptType == P2WPKHScript || + scriptType == P2TRScript + if !isDirectKeySpendScript { + return fmt.Errorf( + "UTXO pointed by the input is not P2PKH/P2WPKH/P2TR", + ) + } + + return tb.addDirectKeySpendInput(utxo, utxoScript, scriptType) +} + +// AddTaprootKeyPathInput adds an unsigned input pointing to a UTXO locked +// using a P2TR script and intended to be spent using the Taproot key path. +// +// The script's x-only key is treated as the final Taproot output key. The +// builder does not apply a BIP-341/BIP-86 tap tweak during signing; callers +// must ensure the FROST signer can produce signatures for the exact output key +// committed to by the scriptPubKey. +func (tb *TransactionBuilder) AddTaprootKeyPathInput( + utxo *UnspentTransactionOutput, +) error { + utxoScript, err := tb.getScript(utxo) + if err != nil { + return fmt.Errorf( + "cannot get locking script for UTXO pointed "+ + "by the input: [%v]", + err, + ) + } + + scriptType := GetScriptType(utxoScript) + if scriptType != P2TRScript { return fmt.Errorf( - "UTXO pointed by the input is not P2PKH/P2WPKH", + "UTXO pointed by the input is not P2TR", ) } - // The UTXO was locked using a P2PKH/P2WPKH script so, the scriptCode - // required to build the sighash is equivalent to that script. Worth - // noting that the P2WPKH script is actually converted to the P2PKH script - // when used as a scriptCode, according to BIP-0143. For reference see, + return tb.addDirectKeySpendInput(utxo, utxoScript, scriptType) +} + +func (tb *TransactionBuilder) addDirectKeySpendInput( + utxo *UnspentTransactionOutput, + utxoScript Script, + scriptType ScriptType, +) error { + // The UTXO was locked using a direct key-spend script, so the scriptCode + // required to build the sighash is equivalent to that script. Worth noting + // that the P2WPKH script is actually converted to the P2PKH script when + // used as a scriptCode, according to BIP-0143. For reference see, // https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki#specification. // That conversion is handled within the `txscript.CalcWitnessSigHash` call. sigHashArgs := &inputSigHashArgs{ - value: utxo.Value, - scriptCode: utxoScript, - witness: txscript.IsWitnessProgram(utxoScript), + value: utxo.Value, + publicKeyScript: utxoScript, + scriptCode: utxoScript, + scriptType: scriptType, + witness: scriptType == P2WPKHScript || scriptType == P2TRScript, } hash := chainhash.Hash(utxo.Outpoint.TransactionHash) @@ -96,10 +173,10 @@ func (tb *TransactionBuilder) AddScriptHashInput( ) } - class := txscript.GetScriptClass(utxoScript) - isPublicKeyHashScript := class == txscript.ScriptHashTy || - class == txscript.WitnessV0ScriptHashTy - if !isPublicKeyHashScript { + scriptType := GetScriptType(utxoScript) + isScriptHashScript := scriptType == P2SHScript || + scriptType == P2WSHScript + if !isScriptHashScript { return fmt.Errorf( "UTXO pointed by the input is not P2SH/P2WSH", ) @@ -109,9 +186,11 @@ func (tb *TransactionBuilder) AddScriptHashInput( // to build the sighash is equivalent to the plain-text redeem script whose // hash is included in the P2SH/P2WSH script. sigHashArgs := &inputSigHashArgs{ - value: utxo.Value, - scriptCode: redeemScript, - witness: txscript.IsWitnessProgram(utxoScript), + value: utxo.Value, + publicKeyScript: utxoScript, + scriptCode: redeemScript, + scriptType: scriptType, + witness: scriptType == P2WSHScript, } hash := chainhash.Hash(utxo.Outpoint.TransactionHash) @@ -180,13 +259,34 @@ func (tb *TransactionBuilder) ComputeSignatureHashes() ([]*big.Int, error) { // sighash fragments can be pre-computed upfront and reused. witnessSigHashFragments := txscript.NewTxSigHashes(tb.internal.MsgTx) + var taprootSigHashMidstate *taprootSignatureHashMidstate + if tb.HasTaprootKeyPathInputs() { + var err error + taprootSigHashMidstate, err = tb.taprootSignatureHashMidstate( + tb.internal.MsgTx, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot calculate taproot sighash midstate: [%v]", + err, + ) + } + } + for i := range tb.internal.TxIn { sigHashArgs := tb.sigHashArgs[i] var sigHashBytes []byte var err error - if sigHashArgs.witness { + switch sigHashArgs.scriptType { + case P2TRScript: + sigHashBytes, err = tb.calcTaprootKeyPathSignatureHash( + tb.internal.MsgTx, + i, + taprootSigHashMidstate, + ) + case P2WPKHScript, P2WSHScript: sigHashBytes, err = txscript.CalcWitnessSigHash( sigHashArgs.scriptCode, witnessSigHashFragments, @@ -195,7 +295,7 @@ func (tb *TransactionBuilder) ComputeSignatureHashes() ([]*big.Int, error) { i, sigHashArgs.value, ) - } else { + default: sigHashBytes, err = txscript.CalcSignatureHash( sigHashArgs.scriptCode, txscript.SigHashAll, @@ -247,6 +347,14 @@ func (tb *TransactionBuilder) AddSignatures( for i, input := range tb.internal.TxIn { signature := signatures[i] + sigHashArgs := tb.sigHashArgs[i] + + if sigHashArgs.scriptType == P2TRScript { + return nil, fmt.Errorf( + "input [%v] is P2TR; use AddTaprootKeyPathSignatures", + i, + ) + } // Make a sanity check to avoid producing crap transactions. if !ecdsa.Verify( @@ -266,8 +374,6 @@ func (tb *TransactionBuilder) AddSignatures( signature.PublicKey, ).SerializeCompressed() - sigHashArgs := tb.sigHashArgs[i] - if sigHashArgs.witness { witness := wire.TxWitness{ signatureBytes, @@ -310,6 +416,81 @@ func (tb *TransactionBuilder) AddSignatures( return tb.internal.toTransaction(), nil } +// SchnorrSignatureContainer is a helper type holding a serialized 64-byte +// BIP-340 Schnorr signature. +type SchnorrSignatureContainer struct { + Signature [64]byte +} + +// AddTaprootKeyPathSignatures adds Schnorr signature data for P2TR key-path +// transaction inputs and returns a signed Transaction instance. +func (tb *TransactionBuilder) AddTaprootKeyPathSignatures( + signatures []*SchnorrSignatureContainer, +) (*Transaction, error) { + if len(tb.sigHashes) == 0 { + return nil, fmt.Errorf("signature hashes must be computed first") + } + + if len(signatures) != len(tb.internal.TxIn) { + return nil, fmt.Errorf("wrong signatures count") + } + + if !tb.HasOnlyTaprootKeyPathInputs() { + return nil, fmt.Errorf( + "taproot key-path signatures require all inputs to be P2TR", + ) + } + + for i, input := range tb.internal.TxIn { + signature := signatures[i] + if signature == nil { + return nil, fmt.Errorf("signature for input [%v] is nil", i) + } + + signatureBytes := make([]byte, len(signature.Signature)) + copy(signatureBytes, signature.Signature[:]) + + taprootKey, err := ExtractTaprootKey(tb.sigHashArgs[i].publicKeyScript) + if err != nil { + return nil, fmt.Errorf( + "cannot extract taproot key for input [%v]: [%v]", + i, + err, + ) + } + + taprootPublicKey, err := schnorr.ParsePubKey(taprootKey[:]) + if err != nil { + return nil, fmt.Errorf( + "cannot parse taproot key for input [%v]: [%v]", + i, + err, + ) + } + + taprootSignature, err := schnorr.ParseSignature(signatureBytes) + if err != nil { + return nil, fmt.Errorf( + "cannot parse taproot key-path signature for input [%v]: [%v]", + i, + err, + ) + } + + sigHashBytes := tb.sigHashes[i].FillBytes(make([]byte, sha256.Size)) + if !taprootSignature.Verify(sigHashBytes, taprootPublicKey) { + return nil, fmt.Errorf( + "invalid taproot key-path signature for input [%v]", + i, + ) + } + + input.Witness = wire.TxWitness{signatureBytes} + } + + return tb.internal.toTransaction(), nil +} + // TotalInputsValue returns the total value of transaction inputs. func (tb *TransactionBuilder) TotalInputsValue() int64 { totalInputsValue := int64(0) @@ -480,15 +661,245 @@ func (tb *TransactionBuilder) UnsignedTransactionIO() ( return inputs, outputs, nil } +func (tb *TransactionBuilder) calcTaprootKeyPathSignatureHash( + tx *wire.MsgTx, + inputIndex int, + midstate *taprootSignatureHashMidstate, +) ([]byte, error) { + if tx == nil { + return nil, fmt.Errorf("transaction is nil") + } + if midstate == nil { + return nil, fmt.Errorf("taproot sighash midstate is nil") + } + + if inputIndex < 0 || inputIndex >= len(tx.TxIn) { + return nil, fmt.Errorf( + "input index [%d] out of range for [%d] inputs", + inputIndex, + len(tx.TxIn), + ) + } + + if len(tx.TxIn) != len(tb.sigHashArgs) { + return nil, fmt.Errorf( + "input metadata mismatch: [%d] tx inputs, [%d] sighash args", + len(tx.TxIn), + len(tb.sigHashArgs), + ) + } + + var sigMsg bytes.Buffer + + // BIP-341 defines the final digest as tagged_hash("TapSighash", + // 0x00 || SigMsg(0x00, 0)). The first byte is the epoch and the second + // byte is SIGHASH_DEFAULT. + sigMsg.WriteByte(0x00) + sigMsg.WriteByte(0x00) + + if err := binary.Write(&sigMsg, binary.LittleEndian, tx.Version); err != nil { + return nil, err + } + if err := binary.Write(&sigMsg, binary.LittleEndian, tx.LockTime); err != nil { + return nil, err + } + + sigMsg.Write(midstate.hashPrevOuts[:]) + sigMsg.Write(midstate.hashInputAmounts[:]) + sigMsg.Write(midstate.hashInputScripts[:]) + sigMsg.Write(midstate.hashSequences[:]) + sigMsg.Write(midstate.hashOutputs[:]) + + // Key-path spends use ext_flag=0 and this implementation does not attach + // a Taproot annex, so spend_type is 0. + sigMsg.WriteByte(0x00) + + if err := binary.Write( + &sigMsg, + binary.LittleEndian, + uint32(inputIndex), + ); err != nil { + return nil, err + } + + hash := chainhash.TaggedHash([]byte("TapSighash"), sigMsg.Bytes()) + return hash.CloneBytes(), nil +} + +type taprootSignatureHashMidstate struct { + hashPrevOuts [chainhash.HashSize]byte + hashInputAmounts [chainhash.HashSize]byte + hashInputScripts [chainhash.HashSize]byte + hashSequences [chainhash.HashSize]byte + hashOutputs [chainhash.HashSize]byte +} + +func (tb *TransactionBuilder) taprootSignatureHashMidstate( + tx *wire.MsgTx, +) (*taprootSignatureHashMidstate, error) { + if tx == nil { + return nil, fmt.Errorf("transaction is nil") + } + + if len(tx.TxIn) != len(tb.sigHashArgs) { + return nil, fmt.Errorf( + "input metadata mismatch: [%d] tx inputs, [%d] sighash args", + len(tx.TxIn), + len(tb.sigHashArgs), + ) + } + + hashPrevOuts, err := tb.taprootHashPrevOuts(tx) + if err != nil { + return nil, err + } + + hashInputAmounts, err := tb.taprootHashInputAmounts() + if err != nil { + return nil, err + } + + hashInputScripts, err := tb.taprootHashInputScripts() + if err != nil { + return nil, err + } + + hashSequences, err := tb.taprootHashSequences(tx) + if err != nil { + return nil, err + } + + hashOutputs, err := tb.taprootHashOutputs(tx) + if err != nil { + return nil, err + } + + return &taprootSignatureHashMidstate{ + hashPrevOuts: hashPrevOuts, + hashInputAmounts: hashInputAmounts, + hashInputScripts: hashInputScripts, + hashSequences: hashSequences, + hashOutputs: hashOutputs, + }, nil +} + +func (tb *TransactionBuilder) taprootHashPrevOuts( + tx *wire.MsgTx, +) ([chainhash.HashSize]byte, error) { + var buffer bytes.Buffer + for _, input := range tx.TxIn { + if err := writeOutPoint(&buffer, &input.PreviousOutPoint); err != nil { + return [chainhash.HashSize]byte{}, err + } + } + + return chainhash.HashH(buffer.Bytes()), nil +} + +func (tb *TransactionBuilder) taprootHashInputAmounts() ( + [chainhash.HashSize]byte, + error, +) { + var buffer bytes.Buffer + for i, sigHashArgs := range tb.sigHashArgs { + if sigHashArgs.value < 0 { + return [chainhash.HashSize]byte{}, fmt.Errorf( + "input [%d] value is negative", + i, + ) + } + + if err := binary.Write( + &buffer, + binary.LittleEndian, + uint64(sigHashArgs.value), + ); err != nil { + return [chainhash.HashSize]byte{}, err + } + } + + return chainhash.HashH(buffer.Bytes()), nil +} + +func (tb *TransactionBuilder) taprootHashInputScripts() ( + [chainhash.HashSize]byte, + error, +) { + var buffer bytes.Buffer + for i, sigHashArgs := range tb.sigHashArgs { + if err := wire.WriteVarBytes( + &buffer, + 0, + sigHashArgs.publicKeyScript, + ); err != nil { + return [chainhash.HashSize]byte{}, fmt.Errorf( + "cannot write public key script for input [%d]: [%v]", + i, + err, + ) + } + } + + return chainhash.HashH(buffer.Bytes()), nil +} + +func (tb *TransactionBuilder) taprootHashSequences( + tx *wire.MsgTx, +) ([chainhash.HashSize]byte, error) { + var buffer bytes.Buffer + for _, input := range tx.TxIn { + if err := binary.Write( + &buffer, + binary.LittleEndian, + input.Sequence, + ); err != nil { + return [chainhash.HashSize]byte{}, err + } + } + + return chainhash.HashH(buffer.Bytes()), nil +} + +func (tb *TransactionBuilder) taprootHashOutputs( + tx *wire.MsgTx, +) ([chainhash.HashSize]byte, error) { + var buffer bytes.Buffer + for i, output := range tx.TxOut { + if err := wire.WriteTxOut(&buffer, 0, 0, output); err != nil { + return [chainhash.HashSize]byte{}, fmt.Errorf( + "cannot write output [%d]: [%v]", + i, + err, + ) + } + } + + return chainhash.HashH(buffer.Bytes()), nil +} + +func writeOutPoint(buffer *bytes.Buffer, outpoint *wire.OutPoint) error { + if _, err := buffer.Write(outpoint.Hash[:]); err != nil { + return err + } + + return binary.Write(buffer, binary.LittleEndian, outpoint.Index) +} + // inputSigHashArgs is a helper structure holding some arguments required to // compute a sighash for the given input. type inputSigHashArgs struct { // value denotes the satoshi value of the UTXO pointed by the given input. value int64 + // publicKeyScript is the locking script of the UTXO pointed by the given + // input. + publicKeyScript []byte // scriptCode is a component of the input's sighash and is the script that // is actually executed while unlocking the given UTXO. The scriptCode // depends on the script type that was used to lock the given UTXO. scriptCode []byte + // scriptType denotes the locking script type of the UTXO pointed by the + // given input. + scriptType ScriptType // witness denotes whether the given input point's to a UTXO locked using // a witness script. witness bool diff --git a/pkg/bitcoin/transaction_builder_test.go b/pkg/bitcoin/transaction_builder_test.go index 2a455db7c7..1a09ced228 100644 --- a/pkg/bitcoin/transaction_builder_test.go +++ b/pkg/bitcoin/transaction_builder_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" + btcec2 "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/keep-network/keep-core/internal/testutils" @@ -113,6 +115,89 @@ func TestTransactionBuilder_AddPublicKeyHashInput(t *testing.T) { } } +func TestTransactionBuilder_AddPublicKeyHashInput_AcceptsTaprootKeyPathInputForBackwardCompatibility( + t *testing.T, +) { + localChain := newLocalChain() + builder := NewTransactionBuilder(localChain) + + var taprootOutputKey [32]byte + copy( + taprootOutputKey[:], + hexToSlice( + t, + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + ), + ) + + lockingScript, err := PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatal(err) + } + + inputTransaction := &Transaction{ + Version: 1, + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash{0x01}, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*TransactionOutput{ + { + Value: 100000, + PublicKeyScript: lockingScript, + }, + }, + Locktime: 0, + } + + if err := localChain.addTransaction(inputTransaction); err != nil { + t.Fatal(err) + } + + inputTransactionUtxo := &UnspentTransactionOutput{ + Outpoint: &TransactionOutpoint{ + TransactionHash: inputTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + } + + if err := builder.AddPublicKeyHashInput(inputTransactionUtxo); err != nil { + t.Fatal(err) + } + + if !builder.HasTaprootKeyPathInputs() { + t.Fatal("expected builder to have taproot key-path inputs") + } + if !builder.HasOnlyTaprootKeyPathInputs() { + t.Fatal("expected builder to have only taproot key-path inputs") + } + + assertSigHashArgs( + t, + &inputSigHashArgs{ + value: inputTransactionUtxo.Value, + publicKeyScript: lockingScript, + scriptCode: lockingScript, + scriptType: P2TRScript, + witness: true, + }, + builder.sigHashArgs[0], + ) + assertInternalInput(t, builder, 0, &TransactionInput{ + Outpoint: inputTransactionUtxo.Outpoint, + SignatureScript: nil, + Witness: nil, + Sequence: 0xffffffff, + }) +} + func TestTransactionBuilder_AddInputReturnsErrorForOutOfRangeOutputIndex( t *testing.T, ) { @@ -247,6 +332,363 @@ func TestTransactionBuilder_AddOutput(t *testing.T) { assertInternalOutput(t, builder, 0, output) } +func TestTransactionBuilder_AddTaprootKeyPathSignatures(t *testing.T) { + localChain := newLocalChain() + builder := NewTransactionBuilder(localChain) + + privateKeyBytes := hexToSlice( + t, + "0101010101010101010101010101010101010101010101010101010101010101", + ) + privateKey, publicKey := btcec2.PrivKeyFromBytes(privateKeyBytes) + + var taprootOutputKey [32]byte + copy(taprootOutputKey[:], schnorr.SerializePubKey(publicKey)) + + inputScript, err := PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatal(err) + } + + var outputPublicKeyHash [20]byte + copy( + outputPublicKeyHash[:], + hexToSlice(t, "0202020202020202020202020202020202020202"), + ) + outputScript, err := PayToWitnessPublicKeyHash(outputPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + previousTransaction := &Transaction{ + Version: 1, + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash{ + 0x10, 0x11, 0x12, 0x13, + 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, + 0x1c, 0x1d, 0x1e, 0x1f, + 0x20, 0x21, 0x22, 0x23, + 0x24, 0x25, 0x26, 0x27, + 0x28, 0x29, 0x2a, 0x2b, + 0x2c, 0x2d, 0x2e, 0x2f, + }, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*TransactionOutput{ + { + Value: 100000, + PublicKeyScript: inputScript, + }, + }, + Locktime: 0, + } + + if err := localChain.addTransaction(previousTransaction); err != nil { + t.Fatal(err) + } + + err = builder.AddTaprootKeyPathInput(&UnspentTransactionOutput{ + Outpoint: &TransactionOutpoint{ + TransactionHash: previousTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }) + if err != nil { + t.Fatal(err) + } + + builder.AddOutput(&TransactionOutput{ + Value: 90000, + PublicKeyScript: outputScript, + }) + + if !builder.HasTaprootKeyPathInputs() { + t.Fatal("expected builder to have taproot key-path inputs") + } + if !builder.HasOnlyTaprootKeyPathInputs() { + t.Fatal("expected builder to have only taproot key-path inputs") + } + + sigHashes, err := builder.ComputeSignatureHashes() + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual(t, "signature hashes count", 1, len(sigHashes)) + + expectedSigHash := hexToSlice( + t, + "96653d19d603d309d22cfe2ccd0ba445e40629dea18d46108caa601055ec4318", + ) + // This vector was generated with btcd v0.23.4's BIP-341 + // CalcTaprootSignatureHash implementation and independently + // cross-checked by reviewers. + sigHashBytes := sigHashes[0].FillBytes(make([]byte, 32)) + testutils.AssertBytesEqual(t, expectedSigHash, sigHashBytes) + + signature, err := schnorr.Sign(privateKey, sigHashBytes) + if err != nil { + t.Fatal(err) + } + signatureBytes := signature.Serialize() + + expectedSignature := hexToSlice( + t, + "5e847a0c22486f3b89ff80edd5afaf4be550aa411a0a7e28cff19d2b5924d77102bbf9a0a51100f4fdfc8435d0e8ff0f61dfdeccd464b78c553b1b4414ac0877", + ) + testutils.AssertBytesEqual(t, expectedSignature, signatureBytes) + + var signatureContainer [64]byte + copy(signatureContainer[:], signatureBytes) + + transaction, err := builder.AddTaprootKeyPathSignatures( + []*SchnorrSignatureContainer{ + { + Signature: signatureContainer, + }, + }, + ) + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual( + t, + "transaction inputs count", + 1, + len(transaction.Inputs), + ) + testutils.AssertIntsEqual( + t, + "taproot witness elements count", + 1, + len(transaction.Inputs[0].Witness), + ) + testutils.AssertBytesEqual( + t, + expectedSignature, + transaction.Inputs[0].Witness[0], + ) + testutils.AssertBytesEqual(t, nil, transaction.Inputs[0].SignatureScript) +} + +func TestTransactionBuilder_AddTaprootKeyPathSignatures_MultipleInputs( + t *testing.T, +) { + localChain := newLocalChain() + builder := NewTransactionBuilder(localChain) + + privateKeyBytes := hexToSlice( + t, + "0101010101010101010101010101010101010101010101010101010101010101", + ) + privateKey, publicKey := btcec2.PrivKeyFromBytes(privateKeyBytes) + + var taprootOutputKey [32]byte + copy(taprootOutputKey[:], schnorr.SerializePubKey(publicKey)) + + inputScript, err := PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatal(err) + } + + var outputPublicKeyHash [20]byte + copy( + outputPublicKeyHash[:], + hexToSlice(t, "0202020202020202020202020202020202020202"), + ) + outputScript, err := PayToWitnessPublicKeyHash(outputPublicKeyHash) + if err != nil { + t.Fatal(err) + } + + inputValues := []int64{100000, 110000} + for i, value := range inputValues { + previousTransaction := &Transaction{ + Version: 1, + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash{byte(0x20 + i)}, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*TransactionOutput{ + { + Value: value, + PublicKeyScript: inputScript, + }, + }, + Locktime: 0, + } + + if err := localChain.addTransaction(previousTransaction); err != nil { + t.Fatal(err) + } + + err = builder.AddTaprootKeyPathInput(&UnspentTransactionOutput{ + Outpoint: &TransactionOutpoint{ + TransactionHash: previousTransaction.Hash(), + OutputIndex: 0, + }, + Value: value, + }) + if err != nil { + t.Fatal(err) + } + } + + builder.AddOutput(&TransactionOutput{ + Value: 209000, + PublicKeyScript: outputScript, + }) + + sigHashes, err := builder.ComputeSignatureHashes() + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual(t, "signature hashes count", 2, len(sigHashes)) + if sigHashes[0].Cmp(sigHashes[1]) == 0 { + t.Fatal("expected distinct taproot signature hashes") + } + + signatures := make([]*SchnorrSignatureContainer, len(sigHashes)) + expectedSignatures := make([][]byte, len(sigHashes)) + for i, sigHash := range sigHashes { + signature, err := schnorr.Sign( + privateKey, + sigHash.FillBytes(make([]byte, 32)), + ) + if err != nil { + t.Fatal(err) + } + + signatureBytes := signature.Serialize() + expectedSignatures[i] = signatureBytes + + var signatureContainer [64]byte + copy(signatureContainer[:], signatureBytes) + signatures[i] = &SchnorrSignatureContainer{ + Signature: signatureContainer, + } + } + + transaction, err := builder.AddTaprootKeyPathSignatures(signatures) + if err != nil { + t.Fatal(err) + } + + testutils.AssertIntsEqual( + t, + "transaction inputs count", + 2, + len(transaction.Inputs), + ) + for i, input := range transaction.Inputs { + testutils.AssertIntsEqual( + t, + fmt.Sprintf("taproot witness elements count for input [%d]", i), + 1, + len(input.Witness), + ) + testutils.AssertBytesEqual(t, expectedSignatures[i], input.Witness[0]) + testutils.AssertBytesEqual(t, nil, input.SignatureScript) + } +} + +func TestTransactionBuilder_AddSignaturesRejectsTaprootInput(t *testing.T) { + localChain := newLocalChain() + builder := NewTransactionBuilder(localChain) + + var taprootOutputKey [32]byte + copy( + taprootOutputKey[:], + hexToSlice( + t, + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + ), + ) + inputScript, err := PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatal(err) + } + + previousTransaction := &Transaction{ + Version: 1, + Inputs: []*TransactionInput{ + { + Outpoint: &TransactionOutpoint{ + TransactionHash: Hash{0x01}, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*TransactionOutput{ + { + Value: 100000, + PublicKeyScript: inputScript, + }, + }, + Locktime: 0, + } + + if err := localChain.addTransaction(previousTransaction); err != nil { + t.Fatal(err) + } + + err = builder.AddTaprootKeyPathInput(&UnspentTransactionOutput{ + Outpoint: &TransactionOutpoint{ + TransactionHash: previousTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }) + if err != nil { + t.Fatal(err) + } + + var outputPublicKeyHash [20]byte + outputScript, err := PayToWitnessPublicKeyHash(outputPublicKeyHash) + if err != nil { + t.Fatal(err) + } + builder.AddOutput(&TransactionOutput{ + Value: 90000, + PublicKeyScript: outputScript, + }) + + if _, err := builder.ComputeSignatureHashes(); err != nil { + t.Fatal(err) + } + + _, err = builder.AddSignatures([]*SignatureContainer{ + { + R: big.NewInt(1), + S: big.NewInt(1), + }, + }) + if err == nil { + t.Fatal("expected AddSignatures to reject a taproot input") + } + if !strings.Contains(err.Error(), "use AddTaprootKeyPathSignatures") { + t.Fatalf("unexpected error: [%v]", err) + } +} + func TestTransactionBuilder_ReplaceUnsignedTransaction(t *testing.T) { builder := NewTransactionBuilder(nil) @@ -856,6 +1298,23 @@ func assertSigHashArgs(t *testing.T, expected, actual *inputSigHashArgs) { actual.scriptCode, ) + if expected.publicKeyScript != nil { + testutils.AssertBytesEqual( + t, + expected.publicKeyScript, + actual.publicKeyScript, + ) + } + + if expected.scriptType != NonStandardScript { + testutils.AssertIntsEqual( + t, + "sighash args script type", + int(expected.scriptType), + int(actual.scriptType), + ) + } + testutils.AssertBoolsEqual( t, "sighash args witness flag", diff --git a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go index bc3cc3393c..659d842aaf 100644 --- a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go +++ b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go @@ -12,6 +12,7 @@ import ( "fmt" "strings" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/frost/roast/attempt" @@ -684,10 +685,12 @@ func executeBuildTaggedTBTCSignerBootstrapCoarseRoundWithSignature( ) } - messageBytes := request.Message.Bytes() - if len(messageBytes) == 0 { - messageBytes = []byte{0} + messageDigest, err := messageDigestFromBigInt(request.Message) + if err != nil { + return nil, fmt.Errorf("invalid request message digest: [%v]", err) } + messageBytes := make([]byte, len(messageDigest)) + copy(messageBytes, messageDigest[:]) if request.MemberIndex == 0 { return nil, fmt.Errorf("request member index is zero") @@ -779,14 +782,21 @@ func decodeBuildTaggedTBTCSignerSignature(signature []byte) (*frost.Signature, e return nil, fmt.Errorf("signature is empty") } - // Unmarshal validates signature wire format (length + split into R/S) only. - // Cryptographic validity is enforced by downstream Schnorr verification at - // submission time. + // Unmarshal validates length and splits the wire value into R/S. The + // tbtc-signer material carries a key-group handle rather than the x-only + // output key, so this layer can only enforce canonical Schnorr encoding. + // Key-bound verification happens downstream when the wallet output key is + // available. result := &frost.Signature{} if err := result.Unmarshal(signature); err != nil { return nil, fmt.Errorf("invalid frost signature bytes: [%w]", err) } + serialized := result.Serialize() + if _, err := schnorr.ParseSignature(serialized[:]); err != nil { + return nil, fmt.Errorf("non-canonical BIP-340 signature bytes: [%w]", err) + } + return result, nil } diff --git a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go index 5f00b47cca..213931092f 100644 --- a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go +++ b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native_test.go @@ -283,6 +283,18 @@ func buildTaggedTBTCSignerValidTestSignature(seed byte) []byte { return signature } +func TestDecodeBuildTaggedTBTCSignerSignatureRejectsNonCanonicalBIP340( + t *testing.T, +) { + _, err := decodeBuildTaggedTBTCSignerSignature(bytes.Repeat([]byte{0xff}, 64)) + if err == nil { + t.Fatal("expected non-canonical BIP-340 signature bytes to be rejected") + } + if !strings.Contains(err.Error(), "non-canonical BIP-340 signature bytes") { + t.Fatalf("unexpected error: [%v]", err) + } +} + func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_ValidatesRequest( t *testing.T, ) { diff --git a/pkg/frost/signing/native_frost_protocol_frost_native.go b/pkg/frost/signing/native_frost_protocol_frost_native.go index afad26e97e..e97b00dd99 100644 --- a/pkg/frost/signing/native_frost_protocol_frost_native.go +++ b/pkg/frost/signing/native_frost_protocol_frost_native.go @@ -5,11 +5,13 @@ package signing import ( "bytes" "context" + "encoding/hex" "encoding/json" "errors" "fmt" "sort" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/ipfs/go-log/v2" "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/frost/roast/attempt" @@ -304,10 +306,12 @@ func executeNativeFROSTSigning( ) } - messageBytes := request.Message.Bytes() - if len(messageBytes) == 0 { - messageBytes = []byte{0} + messageDigest, err := messageDigestFromBigInt(request.Message) + if err != nil { + return nil, fmt.Errorf("invalid request message digest: [%v]", err) } + messageBytes := make([]byte, len(messageDigest)) + copy(messageBytes, messageDigest[:]) ownNonces, ownCommitment, err := engine.GenerateNoncesAndCommitments( signerMaterial.KeyPackage, @@ -494,6 +498,16 @@ func executeNativeFROSTSigning( err, ) } + if err := verifyNativeFROSTBIP340Signature( + signature, + messageDigest, + signerMaterial.PublicKeyPackage, + ); err != nil { + return nil, fmt.Errorf( + "native FROST aggregation returned non-verifiable BIP-340 signature: [%w]", + err, + ) + } if logger != nil { logger.Debugf( @@ -506,6 +520,49 @@ func executeNativeFROSTSigning( return signature, nil } +func verifyNativeFROSTBIP340Signature( + signature *frost.Signature, + messageDigest [attempt.MessageDigestLength]byte, + publicKeyPackage *NativeFROSTPublicKeyPackage, +) error { + if signature == nil { + return fmt.Errorf("signature is nil") + } + + if publicKeyPackage == nil { + return fmt.Errorf("public key package is nil") + } + + publicKeyBytes, err := hex.DecodeString(publicKeyPackage.VerifyingKey) + if err != nil { + return fmt.Errorf("cannot decode verifying key: [%w]", err) + } + if len(publicKeyBytes) != frost.OutputKeySize { + return fmt.Errorf( + "unexpected verifying key length [%d], expected [%d]", + len(publicKeyBytes), + frost.OutputKeySize, + ) + } + + publicKey, err := schnorr.ParsePubKey(publicKeyBytes) + if err != nil { + return fmt.Errorf("cannot parse BIP-340 verifying key: [%w]", err) + } + + signatureBytes := signature.Serialize() + parsedSignature, err := schnorr.ParseSignature(signatureBytes[:]) + if err != nil { + return fmt.Errorf("cannot parse BIP-340 signature: [%w]", err) + } + + if !parsedSignature.Verify(messageDigest[:], publicKey) { + return fmt.Errorf("signature verification failed") + } + + return nil +} + func includedMembersFromRequest( request *NativeExecutionFFISigningRequest, ) (map[group.MemberIndex]struct{}, []group.MemberIndex, error) { diff --git a/pkg/frost/signing/native_frost_protocol_frost_native_test.go b/pkg/frost/signing/native_frost_protocol_frost_native_test.go index 48c0ecab54..cc2f1d3f62 100644 --- a/pkg/frost/signing/native_frost_protocol_frost_native_test.go +++ b/pkg/frost/signing/native_frost_protocol_frost_native_test.go @@ -5,23 +5,31 @@ package signing import ( "context" "crypto/sha256" - "crypto/sha512" + "encoding/hex" "encoding/json" "errors" "fmt" "math/big" - "sort" "strings" "sync" "testing" "time" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/keep-network/keep-core/pkg/frost" "github.com/keep-network/keep-core/pkg/net" "github.com/keep-network/keep-core/pkg/net/local" "github.com/keep-network/keep-core/pkg/protocol/group" ) +var deterministicNativeFROSTSigningPrivateKeyBytesForTest = [32]byte{ + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, +} + type deterministicNativeFROSTSigningEngine struct{} func (dnfse *deterministicNativeFROSTSigningEngine) GenerateNoncesAndCommitments( @@ -64,20 +72,14 @@ func (dnfse *deterministicNativeFROSTSigningEngine) NewSigningPackage( return nil, fmt.Errorf("commitments are empty") } - serialized := append([]byte{}, message...) for _, commitment := range commitments { if commitment == nil { return nil, fmt.Errorf("commitment is nil") } - - serialized = append(serialized, []byte(commitment.Identifier)...) - serialized = append(serialized, commitment.Data...) } - packageDigest := sha256.Sum256(serialized) - return &NativeFROSTSigningPackage{ - Data: packageDigest[:], + Data: append([]byte{}, message...), }, nil } @@ -128,26 +130,29 @@ func (dnfse *deterministicNativeFROSTSigningEngine) Aggregate( return nil, fmt.Errorf("signature shares are empty") } - orderedSignatureShares := append([]*NativeFROSTSignatureShare{}, signatureShares...) - sort.Slice(orderedSignatureShares, func(i, j int) bool { - return orderedSignatureShares[i].Identifier < orderedSignatureShares[j].Identifier - }) - - serialized := append([]byte{}, signingPackage.Data...) - for _, signatureShare := range orderedSignatureShares { + for _, signatureShare := range signatureShares { if signatureShare == nil { return nil, fmt.Errorf("signature share is nil") } + } - serialized = append(serialized, []byte(signatureShare.Identifier)...) - serialized = append(serialized, signatureShare.Data...) + privateKey, _ := btcec.PrivKeyFromBytes( + deterministicNativeFROSTSigningPrivateKeyBytesForTest[:], + ) + signature, err := schnorr.Sign(privateKey, signingPackage.Data) + if err != nil { + return nil, err } - serialized = append(serialized, []byte(publicKeyPackage.VerifyingKey)...) + return signature.Serialize(), nil +} - signatureDigest := sha512.Sum512(serialized) +func deterministicNativeFROSTSigningVerifyingKeyForTest() string { + _, publicKey := btcec.PrivKeyFromBytes( + deterministicNativeFROSTSigningPrivateKeyBytesForTest[:], + ) - return signatureDigest[:], nil + return hex.EncodeToString(schnorr.SerializePubKey(publicKey)) } type recordingNativeFROSTSigningEngine struct { @@ -317,6 +322,32 @@ func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_Nati ) } } + + assertNativeFROSTSignatureVerifiesBIP340( + t, + results[0].signature, + requests[0], + ) +} + +func TestVerifyNativeFROSTBIP340SignatureRejectsInvalidAggregate( + t *testing.T, +) { + messageDigest, err := messageDigestFromBigInt(bigOneForTest()) + if err != nil { + t.Fatalf("unexpected message digest error: [%v]", err) + } + + err = verifyNativeFROSTBIP340Signature( + &frost.Signature{}, + messageDigest, + &NativeFROSTPublicKeyPackage{ + VerifyingKey: deterministicNativeFROSTSigningVerifyingKeyForTest(), + }, + ) + if err == nil { + t.Fatal("expected invalid BIP-340 aggregate signature to be rejected") + } } func TestBuildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive_Sign_NativeFROSTPath_AttemptVariationUsesCohortSelections( @@ -586,7 +617,7 @@ func newNativeFROSTSigningRequestWithSessionForTest( KeyPackage: keyPackage, PublicKeyPackage: &NativeFROSTPublicKeyPackage{ VerifyingShares: verifyingShares, - VerifyingKey: "verifying-key", + VerifyingKey: deterministicNativeFROSTSigningVerifyingKeyForTest(), }, }) if err != nil { @@ -615,3 +646,38 @@ func newNativeFROSTSigningRequestWithSessionForTest( func bigOneForTest() *big.Int { return big.NewInt(1) } + +func assertNativeFROSTSignatureVerifiesBIP340( + t *testing.T, + signature *frost.Signature, + request *NativeExecutionFFISigningRequest, +) { + t.Helper() + + messageDigest, err := messageDigestFromBigInt(request.Message) + if err != nil { + t.Fatalf("unexpected message digest error: [%v]", err) + } + + publicKeyBytes, err := hex.DecodeString( + deterministicNativeFROSTSigningVerifyingKeyForTest(), + ) + if err != nil { + t.Fatalf("unexpected verifying key decode error: [%v]", err) + } + + publicKey, err := schnorr.ParsePubKey(publicKeyBytes) + if err != nil { + t.Fatalf("unexpected verifying key parse error: [%v]", err) + } + + signatureBytes := signature.Serialize() + parsedSignature, err := schnorr.ParseSignature(signatureBytes[:]) + if err != nil { + t.Fatalf("unexpected signature parse error: [%v]", err) + } + + if !parsedSignature.Verify(messageDigest[:], publicKey) { + t.Fatal("expected native FROST aggregate signature to verify as BIP-340") + } +} diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index e041d16e71..8e24cf7100 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -384,6 +384,13 @@ func (wte *walletTransactionExecutor) signTransaction( ) } + if unsignedTx.HasTaprootKeyPathInputs() && + !unsignedTx.HasOnlyTaprootKeyPathInputs() { + return nil, fmt.Errorf( + "cannot apply FROST signatures to mixed taproot and legacy inputs", + ) + } + signTxLogger.Infof("signing transaction's sig hashes") signingCtx, cancelSigningCtx := withCancelOnBlock( @@ -407,6 +414,31 @@ func (wte *walletTransactionExecutor) signTransaction( signTxLogger.Infof("applying transaction's signatures") + if unsignedTx.HasTaprootKeyPathInputs() { + containers := make( + []*bitcoin.SchnorrSignatureContainer, + len(signatures), + ) + for i, signature := range signatures { + containers[i] = &bitcoin.SchnorrSignatureContainer{ + Signature: signature.Serialize(), + } + } + + tx, err := unsignedTx.AddTaprootKeyPathSignatures(containers) + if err != nil { + return nil, fmt.Errorf( + "error while applying transaction's taproot key-path "+ + "signatures: [%v]", + err, + ) + } + + signTxLogger.Infof("transaction created successfully") + + return tx, nil + } + containers := make([]*bitcoin.SignatureContainer, len(signatures)) for i, signature := range signatures { containers[i] = &bitcoin.SignatureContainer{ diff --git a/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go b/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go index c935c9c42b..dd6e081951 100644 --- a/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go +++ b/pkg/tbtc/wallet_sign_transaction_build_taproot_tx_test.go @@ -12,6 +12,8 @@ import ( "strings" "testing" + btcec2 "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/keep-network/keep-core/pkg/bitcoin" "github.com/keep-network/keep-core/pkg/frost" frostsigning "github.com/keep-network/keep-core/pkg/frost/signing" @@ -892,6 +894,274 @@ func TestWalletTransactionExecutor_SignTransaction_RejectsNativeUnsignedTransact } } +func TestWalletTransactionExecutor_SignTransaction_AppliesTaprootKeyPathSignatures( + t *testing.T, +) { + originalBuildTaprootTxViaNativeSignerFn := buildTaprootTxViaNativeSignerFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = originalBuildTaprootTxViaNativeSignerFn + }) + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return "", nil + } + + privateKeyBytes := mustDecodeHex( + t, + "0101010101010101010101010101010101010101010101010101010101010101", + ) + privateKey, publicKey := btcec2.PrivKeyFromBytes(privateKeyBytes) + + var taprootOutputKey [32]byte + copy(taprootOutputKey[:], schnorr.SerializePubKey(publicKey)) + + inputScript, err := bitcoin.PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatalf("cannot create taproot input script: [%v]", err) + } + + var outputPublicKeyHash [20]byte + copy( + outputPublicKeyHash[:], + mustDecodeHex(t, "0202020202020202020202020202020202020202"), + ) + outputScript, err := bitcoin.PayToWitnessPublicKeyHash(outputPublicKeyHash) + if err != nil { + t.Fatalf("cannot create output script: [%v]", err) + } + + localBitcoinChain := newLocalBitcoinChain() + fundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{ + 0x10, 0x11, 0x12, 0x13, + 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, + 0x1c, 0x1d, 0x1e, 0x1f, + 0x20, 0x21, 0x22, 0x23, + 0x24, 0x25, 0x26, 0x27, + 0x28, 0x29, 0x2a, 0x2b, + 0x2c, 0x2d, 0x2e, 0x2f, + }, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: inputScript, + }, + }, + Locktime: 0, + } + if err := localBitcoinChain.BroadcastTransaction(fundingTransaction); err != nil { + t.Fatalf("cannot broadcast funding transaction: [%v]", err) + } + + unsignedTx := bitcoin.NewTransactionBuilder(localBitcoinChain) + if err := unsignedTx.AddTaprootKeyPathInput( + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: fundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }, + ); err != nil { + t.Fatalf("cannot add taproot input: [%v]", err) + } + unsignedTx.AddOutput(&bitcoin.TransactionOutput{ + Value: 90000, + PublicKeyScript: outputScript, + }) + + wte := &walletTransactionExecutor{ + executingWallet: generateWallet(big.NewInt(111)), + signingExecutor: &deterministicSchnorrSigningExecutorForTaproot{ + privateKey: privateKey, + }, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + + tx, err := wte.signTransaction(&warningCaptureLogger{}, unsignedTx, 0, 0) + if err != nil { + t.Fatalf("unexpected signTransaction error: [%v]", err) + } + + expectedSignature := mustDecodeHex( + t, + "5e847a0c22486f3b89ff80edd5afaf4be550aa411a0a7e28cff19d2b5924d77102bbf9a0a51100f4fdfc8435d0e8ff0f61dfdeccd464b78c553b1b4414ac0877", + ) + + if len(tx.Inputs) != 1 { + t.Fatalf("unexpected input count: [%d]", len(tx.Inputs)) + } + if len(tx.Inputs[0].Witness) != 1 { + t.Fatalf("unexpected taproot witness: [%x]", tx.Inputs[0].Witness) + } + if !bytes.Equal(expectedSignature, tx.Inputs[0].Witness[0]) { + t.Fatalf( + "unexpected taproot witness signature\nexpected: [%x]\nactual: [%x]", + expectedSignature, + tx.Inputs[0].Witness[0], + ) + } + if len(tx.Inputs[0].SignatureScript) != 0 { + t.Fatalf( + "unexpected signature script for taproot input: [%x]", + tx.Inputs[0].SignatureScript, + ) + } +} + +func TestWalletTransactionExecutor_SignTransaction_RejectsMixedTaprootAndLegacyInputsBeforeSigning( + t *testing.T, +) { + originalBuildTaprootTxViaNativeSignerFn := buildTaprootTxViaNativeSignerFn + t.Cleanup(func() { + buildTaprootTxViaNativeSignerFn = originalBuildTaprootTxViaNativeSignerFn + }) + buildTaprootTxViaNativeSignerFn = func( + unsignedTx *bitcoin.TransactionBuilder, + ) (string, error) { + return "", nil + } + + var taprootOutputKey [32]byte + copy( + taprootOutputKey[:], + mustDecodeHex( + t, + "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + ), + ) + taprootInputScript, err := bitcoin.PayToTaproot(taprootOutputKey) + if err != nil { + t.Fatalf("cannot create taproot input script: [%v]", err) + } + + var witnessPublicKeyHash [20]byte + copy( + witnessPublicKeyHash[:], + mustDecodeHex(t, "0202020202020202020202020202020202020202"), + ) + witnessInputScript, err := bitcoin.PayToWitnessPublicKeyHash( + witnessPublicKeyHash, + ) + if err != nil { + t.Fatalf("cannot create witness input script: [%v]", err) + } + + localBitcoinChain := newLocalBitcoinChain() + taprootFundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x01}, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 100000, + PublicKeyScript: taprootInputScript, + }, + }, + Locktime: 0, + } + if err := localBitcoinChain.BroadcastTransaction( + taprootFundingTransaction, + ); err != nil { + t.Fatalf("cannot broadcast taproot funding transaction: [%v]", err) + } + + legacyFundingTransaction := &bitcoin.Transaction{ + Version: 1, + Inputs: []*bitcoin.TransactionInput{ + { + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: bitcoin.Hash{0x02}, + OutputIndex: 0, + }, + SignatureScript: []byte{0x51}, + Sequence: 0xffffffff, + }, + }, + Outputs: []*bitcoin.TransactionOutput{ + { + Value: 50000, + PublicKeyScript: witnessInputScript, + }, + }, + Locktime: 0, + } + if err := localBitcoinChain.BroadcastTransaction( + legacyFundingTransaction, + ); err != nil { + t.Fatalf("cannot broadcast legacy funding transaction: [%v]", err) + } + + unsignedTx := bitcoin.NewTransactionBuilder(localBitcoinChain) + if err := unsignedTx.AddTaprootKeyPathInput( + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: taprootFundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 100000, + }, + ); err != nil { + t.Fatalf("cannot add taproot input: [%v]", err) + } + if err := unsignedTx.AddPublicKeyHashInput( + &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: legacyFundingTransaction.Hash(), + OutputIndex: 0, + }, + Value: 50000, + }, + ); err != nil { + t.Fatalf("cannot add legacy input: [%v]", err) + } + unsignedTx.AddOutput(&bitcoin.TransactionOutput{ + Value: 140000, + PublicKeyScript: witnessInputScript, + }) + + wte := &walletTransactionExecutor{ + executingWallet: generateWallet(big.NewInt(111)), + signingExecutor: &unexpectedSigningExecutorForBuildTaprootTxError{}, + waitForBlockFn: func(ctx context.Context, block uint64) error { + return nil + }, + } + + tx, err := wte.signTransaction(&warningCaptureLogger{}, unsignedTx, 0, 0) + if err == nil { + t.Fatal("expected mixed taproot and legacy signing error") + } + if tx != nil { + t.Fatal("expected no signed transaction") + } + if !strings.Contains(err.Error(), "mixed taproot and legacy inputs") { + t.Fatalf("unexpected error: [%v]", err) + } +} + func buildTaprootTxSubstitutionFixture( t *testing.T, ) ( @@ -1075,6 +1345,37 @@ func (desefbts *deterministicECDSASigningExecutorForBuildTaprootTxSubstitution) return signatures, nil } +type deterministicSchnorrSigningExecutorForTaproot struct { + privateKey *btcec2.PrivateKey +} + +func (dsseft *deterministicSchnorrSigningExecutorForTaproot) signBatch( + ctx context.Context, + messages []*big.Int, + startBlock uint64, +) ([]*frost.Signature, error) { + signatures := make([]*frost.Signature, 0, len(messages)) + + for _, message := range messages { + signature, err := schnorr.Sign( + dsseft.privateKey, + message.FillBytes(make([]byte, 32)), + ) + if err != nil { + return nil, err + } + + serialized := signature.Serialize() + frostSignature := &frost.Signature{} + copy(frostSignature.R[:], serialized[:32]) + copy(frostSignature.S[:], serialized[32:]) + + signatures = append(signatures, frostSignature) + } + + return signatures, nil +} + type unexpectedSigningExecutorForBuildTaprootTxError struct{} func (usefbte *unexpectedSigningExecutorForBuildTaprootTxError) signBatch(