From 1ead5ba38ab0e8a5d90d99a4513b06da741d6403 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Mon, 20 Jul 2026 17:40:04 -0500 Subject: [PATCH] psbt: reject nil taproot pointer fields In this commit, we extend taproot PSBT serialization and finalization to reject nil script spend signatures, leaf scripts, and BIP32 derivations before any sort or dereference. We return wrapped ErrInvalidPsbtFormat errors so callers can identify malformed packet state without parsing strings. We also make FindLeafScript reject nil inputs and leaf entries. This keeps both the direct finalizer and MaybeFinalize paths from panicking on malformed in-memory packets. --- psbt/finalizer.go | 15 ++++- psbt/partial_input.go | 16 ++++- psbt/partial_output.go | 7 +++ psbt/psbt_test.go | 138 +++++++++++++++++++++++++++++++++++++---- psbt/utils.go | 11 +++- 5 files changed, 171 insertions(+), 16 deletions(-) diff --git a/psbt/finalizer.go b/psbt/finalizer.go index fa1d8f2b..a2a1e2d2 100644 --- a/psbt/finalizer.go +++ b/psbt/finalizer.go @@ -55,6 +55,10 @@ func isFinalizableWitnessInput(pInput *PInput) bool { // For each of the script spend signatures we need a // corresponding tap script leaf with the control block. for _, sig := range pInput.TaprootScriptSpendSig { + if sig == nil { + return false + } + _, err := FindLeafScript(pInput, sig.LeafHash) if err != nil { return false @@ -516,6 +520,13 @@ func finalizeTaprootInput(p *Packet, inIndex int) error { pInput = &p.Inputs[inIndex] ) + for idx, scriptSpendSig := range pInput.TaprootScriptSpendSig { + if scriptSpendSig == nil { + return fmt.Errorf("nil taproot script spend signature "+ + "at index %d: %w", idx, ErrInvalidPsbtFormat) + } + } + // What spend path did we take? switch { // Key spend path. @@ -547,8 +558,8 @@ func finalizeTaprootInput(p *Packet, inIndex int) error { targetLeafHash := pInput.TaprootScriptSpendSig[0].LeafHash leafScript, err := FindLeafScript(pInput, targetLeafHash) if err != nil { - return fmt.Errorf("control block for script spend " + - "signature not found") + return fmt.Errorf("control block for script spend "+ + "signature not found: %w", err) } // The witness stack will contain all signatures, followed by diff --git a/psbt/partial_input.go b/psbt/partial_input.go index 9c1c99c6..0dc57959 100644 --- a/psbt/partial_input.go +++ b/psbt/partial_input.go @@ -495,6 +495,13 @@ func (pi *PInput) serialize(w io.Writer) error { } } + for idx, scriptSpend := range pi.TaprootScriptSpendSig { + if scriptSpend == nil { + return fmt.Errorf("nil taproot script spend "+ + "signature at index %d: %w", idx, + ErrInvalidPsbtFormat) + } + } sort.Slice(pi.TaprootScriptSpendSig, func(i, j int) bool { return pi.TaprootScriptSpendSig[i].SortBefore( pi.TaprootScriptSpendSig[j], @@ -518,7 +525,8 @@ func (pi *PInput) serialize(w io.Writer) error { for idx, leafScript := range pi.TaprootLeafScript { if leafScript == nil { - return fmt.Errorf("nil taproot leaf script at index %d", idx) + return fmt.Errorf("nil taproot leaf script at "+ + "index %d: %w", idx, ErrInvalidPsbtFormat) } } sort.Slice(pi.TaprootLeafScript, func(i, j int) bool { @@ -538,6 +546,12 @@ func (pi *PInput) serialize(w io.Writer) error { } } + for idx, derivation := range pi.TaprootBip32Derivation { + if derivation == nil { + return fmt.Errorf("nil taproot BIP32 derivation at "+ + "index %d: %w", idx, ErrInvalidPsbtFormat) + } + } sort.Slice(pi.TaprootBip32Derivation, func(i, j int) bool { return pi.TaprootBip32Derivation[i].SortBefore( pi.TaprootBip32Derivation[j], diff --git a/psbt/partial_output.go b/psbt/partial_output.go index 94b5d33f..93233e0e 100644 --- a/psbt/partial_output.go +++ b/psbt/partial_output.go @@ -2,6 +2,7 @@ package psbt import ( "bytes" + "fmt" "io" "sort" @@ -225,6 +226,12 @@ func (po *POutput) serialize(w io.Writer) error { } } + for idx, derivation := range po.TaprootBip32Derivation { + if derivation == nil { + return fmt.Errorf("nil taproot BIP32 derivation at "+ + "index %d: %w", idx, ErrInvalidPsbtFormat) + } + } sort.Slice(po.TaprootBip32Derivation, func(i, j int) bool { return po.TaprootBip32Derivation[i].SortBefore( po.TaprootBip32Derivation[j], diff --git a/psbt/psbt_test.go b/psbt/psbt_test.go index c2907dbc..1f36a97c 100644 --- a/psbt/psbt_test.go +++ b/psbt/psbt_test.go @@ -1338,24 +1338,138 @@ func TestFromUnsigned(t *testing.T) { } } -func TestB64EncodeRejectsNilTaprootLeafScript(t *testing.T) { - tx := wire.NewMsgTx(2) - tx.AddTxIn(&wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: chainhash.Hash{}, - Index: 0, +func TestB64EncodeRejectsNilTaprootFields(t *testing.T) { + testCases := []struct { + name string + expectedErr string + setNil func(*Packet) + }{ + { + name: "input script spend signature", + expectedErr: "nil taproot script spend signature at index 0", + setNil: func(packet *Packet) { + packet.Inputs[0].TaprootScriptSpendSig = + []*TaprootScriptSpendSig{nil} + }, }, - }) - tx.AddTxOut(wire.NewTxOut(1, []byte{txscript.OP_TRUE})) + { + name: "input leaf script", + expectedErr: "nil taproot leaf script at index 0", + setNil: func(packet *Packet) { + packet.Inputs[0].TaprootLeafScript = + []*TaprootTapLeafScript{nil} + }, + }, + { + name: "input BIP32 derivation", + expectedErr: "nil taproot BIP32 derivation at index 0", + setNil: func(packet *Packet) { + packet.Inputs[0].TaprootBip32Derivation = + []*TaprootBip32Derivation{nil} + }, + }, + { + name: "output BIP32 derivation", + expectedErr: "nil taproot BIP32 derivation at index 0", + setNil: func(packet *Packet) { + packet.Outputs[0].TaprootBip32Derivation = + []*TaprootBip32Derivation{nil} + }, + }, + } - packet, err := NewFromUnsignedTx(tx) - require.NoError(t, err) + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{}, + }) + tx.AddTxOut(wire.NewTxOut( + 1, []byte{txscript.OP_TRUE}, + )) - packet.Inputs[0].TaprootLeafScript = []*TaprootTapLeafScript{nil} - _, err = packet.B64Encode() + packet, err := NewFromUnsignedTx(tx) + require.NoError(t, err) + testCase.setNil(packet) + + _, err = packet.B64Encode() + require.ErrorIs(t, err, ErrInvalidPsbtFormat) + require.ErrorContains(t, err, testCase.expectedErr) + }) + } +} + +func TestFindLeafScriptRejectsNilLeaf(t *testing.T) { + _, err := FindLeafScript(nil, make([]byte, chainhash.HashSize)) + require.ErrorIs(t, err, ErrInvalidPsbtFormat) + require.ErrorContains(t, err, "nil PSBT input") + + input := &PInput{ + TaprootLeafScript: []*TaprootTapLeafScript{nil}, + } + + _, err = FindLeafScript(input, make([]byte, chainhash.HashSize)) + require.ErrorIs(t, err, ErrInvalidPsbtFormat) require.ErrorContains(t, err, "nil taproot leaf script at index 0") } +func TestFinalizeRejectsNilTaprootFields(t *testing.T) { + newPacket := func() *Packet { + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{}, + }) + tx.AddTxOut(wire.NewTxOut( + 1, []byte{txscript.OP_TRUE}, + )) + + packet, err := NewFromUnsignedTx(tx) + require.NoError(t, err) + packet.Inputs[0].WitnessUtxo = wire.NewTxOut( + 1, append( + []byte{txscript.OP_1, txscript.OP_DATA_32}, + make([]byte, 32)..., + ), + ) + + return packet + } + + t.Run("script spend signature", func(t *testing.T) { + packet := newPacket() + packet.Inputs[0].TaprootScriptSpendSig = + []*TaprootScriptSpendSig{nil} + + finalized, err := MaybeFinalize(packet, 0) + require.False(t, finalized) + require.ErrorIs(t, err, ErrNotFinalizable) + + err = Finalize(packet, 0) + require.ErrorIs(t, err, ErrInvalidPsbtFormat) + require.ErrorContains( + t, err, "nil taproot script spend signature at index 0", + ) + }) + + t.Run("leaf script", func(t *testing.T) { + packet := newPacket() + packet.Inputs[0].TaprootScriptSpendSig = + []*TaprootScriptSpendSig{{ + LeafHash: make([]byte, chainhash.HashSize), + }} + packet.Inputs[0].TaprootLeafScript = + []*TaprootTapLeafScript{nil} + + finalized, err := MaybeFinalize(packet, 0) + require.False(t, finalized) + require.ErrorIs(t, err, ErrNotFinalizable) + + err = Finalize(packet, 0) + require.ErrorIs(t, err, ErrInvalidPsbtFormat) + require.ErrorContains(t, err, "nil taproot leaf script at index 0") + }) +} + func TestNonWitnessToWitness(t *testing.T) { // We'll start with a PSBT produced by Core for which // the first input is signed and we'll provided the signatures for diff --git a/psbt/utils.go b/psbt/utils.go index 2c880e2b..6053bca5 100644 --- a/psbt/utils.go +++ b/psbt/utils.go @@ -464,7 +464,16 @@ func NewFromSignedTx(tx *wire.MsgTx) (*Packet, [][]byte, func FindLeafScript(pInput *PInput, targetLeafHash []byte) (*TaprootTapLeafScript, error) { - for _, leaf := range pInput.TaprootLeafScript { + if pInput == nil { + return nil, fmt.Errorf("nil PSBT input: %w", ErrInvalidPsbtFormat) + } + + for idx, leaf := range pInput.TaprootLeafScript { + if leaf == nil { + return nil, fmt.Errorf("nil taproot leaf script at index "+ + "%d: %w", idx, ErrInvalidPsbtFormat) + } + leafHash := txscript.TapLeaf{ LeafVersion: leaf.LeafVersion, Script: leaf.Script,