From 357b3933fbb2f3ca7b32d1d7775c231a947e7147 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Thu, 21 Nov 2024 14:03:56 +0100 Subject: [PATCH] tapchannel: tweak HTLC script key internal key to enforce uniqueness This commit tweaks the internal key of the asset-level script key with the HTLC index to enforce uniqueness of script keys across multiple HTLCs with the same payment hash and timeout (MPP shards of the same payment). The internal key we tweak is the revocation key. So we must also apply that same tweak when doing a revocation/breach sweep transaction. But since that functionality is not yet implemented, we'll just leave a TODO in a follow-up commit for that. Therefore, no tweak will currently need to be applied at _sign_ time. --- tapchannel/aux_leaf_signer.go | 101 ++++++++++ tapchannel/aux_leaf_signer_test.go | 289 +++++++++++++++++++++++++++++ tapchannel/commitment.go | 13 +- 3 files changed, 400 insertions(+), 3 deletions(-) diff --git a/tapchannel/aux_leaf_signer.go b/tapchannel/aux_leaf_signer.go index f728b1ec4..26e7c23b5 100644 --- a/tapchannel/aux_leaf_signer.go +++ b/tapchannel/aux_leaf_signer.go @@ -3,12 +3,14 @@ package tapchannel import ( "bytes" "fmt" + "math/big" "sync" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/lightninglabs/taproot-assets/address" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/commitment" @@ -751,3 +753,102 @@ func (v *schnorrSigValidator) validateSchnorrSig(virtualTx *wire.MsgTx, return nil } + +// HtlcIndexAsScriptKeyTweak converts the given HTLC index into a modulo N +// scalar that can be used to tweak the internal key of the HTLC script key on +// the asset level. The value of 1 is always added to the index to make sure +// this value is always non-zero. +func HtlcIndexAsScriptKeyTweak(index input.HtlcIndex) *secp256k1.ModNScalar { + // We need to avoid the tweak being zero, so we always add 1 to the + // index. Otherwise, we'd multiply G by zero. + index++ + + // If we wrapped around from math.MaxUint64 to 0, we need to make sure + // the tweak is 1 to not cause a multiplication by zero. This should + // never happen, as it would mean we have more than math.MaxUint64 + // updates in a channel, which exceeds the protocol's maximum. + if index == 0 { + return new(secp256k1.ModNScalar).SetInt(1) + } + + indexAsBytes := new(big.Int).SetUint64(index).Bytes() + indexAsScalar := new(secp256k1.ModNScalar) + _ = indexAsScalar.SetByteSlice(indexAsBytes) + + return indexAsScalar +} + +// TweakPubKeyWithIndex tweaks the given internal public key with the given +// HTLC index. The tweak is derived from the index in a way that never results +// in a zero tweak. The value of 1 is always added to the index to make sure +// this value is always non-zero. The public key is tweaked like this: +// +// tweakedKey = key + (index+1) * G +func TweakPubKeyWithIndex(pubKey *btcec.PublicKey, + index input.HtlcIndex) *btcec.PublicKey { + + // We need to operate on Jacobian points, which is just a different + // representation of the public key that allows us to do scalar + // multiplication. + var ( + pubKeyJacobian, tweakTimesG, tweakedKey btcec.JacobianPoint + ) + pubKey.AsJacobian(&pubKeyJacobian) + + // Derive the tweak from the HTLC index in a way that never results in + // a zero tweak. Then we multiply G by the tweak. + tweak := HtlcIndexAsScriptKeyTweak(index) + secp256k1.ScalarBaseMultNonConst(tweak, &tweakTimesG) + + // And finally we add the result to the key to get the tweaked key. + secp256k1.AddNonConst(&pubKeyJacobian, &tweakTimesG, &tweakedKey) + + // Convert the tweaked key back to an affine point and create a new + // taproot key from it. + tweakedKey.ToAffine() + return btcec.NewPublicKey(&tweakedKey.X, &tweakedKey.Y) +} + +// TweakHtlcTree tweaks the internal key of the given HTLC script tree with the +// given index, then returns the tweaked tree with the updated taproot key. +// The tapscript tree and tapscript root are not modified. +// The internal key is tweaked like this: +// +// tweakedInternalKey = internalKey + (index+1) * G +func TweakHtlcTree(tree input.ScriptTree, + index input.HtlcIndex) input.ScriptTree { + + // The tapscript tree and root are not modified, only the internal key + // is tweaked, which inherently modifies the taproot key. + tweakedInternalPubKey := TweakPubKeyWithIndex(tree.InternalKey, index) + newTaprootKey := txscript.ComputeTaprootOutputKey( + tweakedInternalPubKey, tree.TapscriptRoot, + ) + + return input.ScriptTree{ + InternalKey: tweakedInternalPubKey, + TaprootKey: newTaprootKey, + TapscriptTree: tree.TapscriptTree, + TapscriptRoot: tree.TapscriptRoot, + } +} + +// AddTweakWithIndex adds the given index to the given tweak. If the tweak is +// empty, the index is used as the tweak directly. The value of 1 is always +// added to the index to make sure this value is always non-zero. +func AddTweakWithIndex(maybeTweak []byte, index input.HtlcIndex) []byte { + indexTweak := HtlcIndexAsScriptKeyTweak(index) + + // If we don't already have a tweak, we just use the index as the tweak. + if len(maybeTweak) == 0 { + return fn.ByteSlice(indexTweak.Bytes()) + } + + // If we have a tweak, we need to parse/decode it as a scalar, then add + // the index as a scalar, and encode it back to a byte slice. + tweak := new(secp256k1.ModNScalar) + _ = tweak.SetByteSlice(maybeTweak) + newTweak := tweak.Add(indexTweak) + + return fn.ByteSlice(newTweak.Bytes()) +} diff --git a/tapchannel/aux_leaf_signer_test.go b/tapchannel/aux_leaf_signer_test.go index a2fe08656..39c36140e 100644 --- a/tapchannel/aux_leaf_signer_test.go +++ b/tapchannel/aux_leaf_signer_test.go @@ -3,13 +3,19 @@ package tapchannel import ( "bytes" "crypto/rand" + "encoding/binary" "encoding/hex" "fmt" + "math" + "math/big" "testing" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/txscript" + "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/internal/test" cmsg "github.com/lightninglabs/taproot-assets/tapchannelmsg" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnwire" @@ -63,6 +69,16 @@ func somePartialSigWithNonce(t *testing.T) lnwire.OptPartialSigWithNonceTLV { ](*sig)) } +func pubKeyFromUint64(num uint64) *btcec.PublicKey { + var ( + buf = make([]byte, 8) + scalar = new(secp256k1.ModNScalar) + ) + binary.BigEndian.PutUint64(buf, num) + _ = scalar.SetByteSlice(buf) + return secp256k1.NewPrivateKey(scalar).PubKey() +} + // TestMaxCommitSigMsgSize attempts to find values for the max number of asset // IDs we want to allow per channel and the resulting maximum number of HTLCs // that channel can allow. The maximum number of different asset IDs that can be @@ -163,3 +179,276 @@ func makeCommitSig(t *testing.T, numAssetIDs, numHTLCs int) *lnwire.CommitSig { return msg } + +// TestHtlcIndexAsScriptKeyTweak tests the HtlcIndexAsScriptKeyTweak function. +func TestHtlcIndexAsScriptKeyTweak(t *testing.T) { + var ( + buf = make([]byte, 8) + maxUint64MinusOne = new(secp256k1.ModNScalar) + maxUint64 = new(secp256k1.ModNScalar) + ) + binary.BigEndian.PutUint64(buf, math.MaxUint64-1) + _ = maxUint64MinusOne.SetByteSlice(buf) + + binary.BigEndian.PutUint64(buf, math.MaxUint64) + _ = maxUint64.SetByteSlice(buf) + + testCases := []struct { + name string + index uint64 + result *secp256k1.ModNScalar + }{ + { + name: "index 0", + index: 0, + result: new(secp256k1.ModNScalar).SetInt(1), + }, + { + name: "index math.MaxUint32-1", + index: math.MaxUint32 - 1, + result: new(secp256k1.ModNScalar).SetInt( + math.MaxUint32, + ), + }, + { + name: "index math.MaxUint64-2", + index: math.MaxUint64 - 2, + result: maxUint64MinusOne, + }, + { + name: "index math.MaxUint64-1", + index: math.MaxUint64 - 1, + result: maxUint64, + }, + { + name: "index math.MaxUint64, wraps around to 1", + index: math.MaxUint64, + result: new(secp256k1.ModNScalar).SetInt(1), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tweak := HtlcIndexAsScriptKeyTweak(tc.index) + require.Equal(t, tc.result, tweak) + }) + } +} + +// TestTweakPubKeyWithIndex tests the TweakPubKeyWithIndex function. +func TestTweakPubKeyWithIndex(t *testing.T) { + // We want a random number in the range of uint32 but will need it as + // an uint64 for the test cases. + randNum := uint64(test.RandInt[uint32]()) + startKey := pubKeyFromUint64(randNum) + + testCases := []struct { + name string + pubKey *btcec.PublicKey + index uint64 + result *btcec.PublicKey + }{ + { + name: "index 0", + pubKey: startKey, + index: 0, + result: pubKeyFromUint64(randNum + 1), + }, + { + name: "index 1", + pubKey: startKey, + index: 1, + result: pubKeyFromUint64(randNum + 2), + }, + { + name: "index 99", + pubKey: startKey, + index: 99, + result: pubKeyFromUint64(randNum + 100), + }, + { + name: "index math.MaxUint32-1", + pubKey: startKey, + index: math.MaxUint32 - 1, + result: pubKeyFromUint64(randNum + math.MaxUint32), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tweakedKey := TweakPubKeyWithIndex(tc.pubKey, tc.index) + require.Equal( + t, tc.result.SerializeCompressed(), + tweakedKey.SerializeCompressed(), + ) + }) + } +} + +// TestTweakHtlcTree tests the TweakHtlcTree function. +func TestTweakHtlcTree(t *testing.T) { + randTree := txscript.AssembleTaprootScriptTree( + test.RandTapLeaf(nil), test.RandTapLeaf(nil), + test.RandTapLeaf(nil), + ) + randRoot := randTree.RootNode.TapHash() + + // We want a random number in the range of uint32 but will need it as + // an uint64 for the test cases. + randNum := uint64(test.RandInt[uint32]()) + + makeTaprootKey := func(num uint64) *btcec.PublicKey { + return txscript.ComputeTaprootOutputKey( + pubKeyFromUint64(num), randRoot[:], + ) + } + startKey := pubKeyFromUint64(randNum) + startTaprootKey := makeTaprootKey(randNum) + + testCases := []struct { + name string + tree input.ScriptTree + index uint64 + result input.ScriptTree + }{ + { + name: "index 0", + tree: input.ScriptTree{ + InternalKey: startKey, + TaprootKey: startTaprootKey, + TapscriptTree: randTree, + TapscriptRoot: randRoot[:], + }, + index: 0, + result: input.ScriptTree{ + InternalKey: pubKeyFromUint64(randNum + 1), + TaprootKey: makeTaprootKey(randNum + 1), + TapscriptTree: randTree, + TapscriptRoot: randRoot[:], + }, + }, + { + name: "index 1", + tree: input.ScriptTree{ + InternalKey: startKey, + TaprootKey: startTaprootKey, + TapscriptTree: randTree, + TapscriptRoot: randRoot[:], + }, + index: 1, + result: input.ScriptTree{ + InternalKey: pubKeyFromUint64(randNum + 2), + TaprootKey: makeTaprootKey(randNum + 2), + TapscriptTree: randTree, + TapscriptRoot: randRoot[:], + }, + }, + { + name: "index 99", + tree: input.ScriptTree{ + InternalKey: startKey, + TaprootKey: startTaprootKey, + TapscriptTree: randTree, + TapscriptRoot: randRoot[:], + }, + index: 99, + result: input.ScriptTree{ + InternalKey: pubKeyFromUint64(randNum + 100), + TaprootKey: makeTaprootKey(randNum + 100), + TapscriptTree: randTree, + TapscriptRoot: randRoot[:], + }, + }, + { + name: "index math.MaxUint32-1", + tree: input.ScriptTree{ + InternalKey: startKey, + TaprootKey: startTaprootKey, + TapscriptTree: randTree, + TapscriptRoot: randRoot[:], + }, + index: math.MaxUint32 - 1, + result: input.ScriptTree{ + InternalKey: pubKeyFromUint64( + randNum + math.MaxUint32, + ), + TaprootKey: makeTaprootKey( + randNum + math.MaxUint32, + ), + TapscriptTree: randTree, + TapscriptRoot: randRoot[:], + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tweakedTree := TweakHtlcTree(tc.tree, tc.index) + require.Equal( + t, tc.result.InternalKey.SerializeCompressed(), + tweakedTree.InternalKey.SerializeCompressed(), + ) + require.Equal( + t, tc.result.TaprootKey.SerializeCompressed(), + tweakedTree.TaprootKey.SerializeCompressed(), + ) + require.Equal(t, tc.result, tweakedTree) + }) + } +} + +// TestAddTweakWithIndex tests the AddTweakWithIndex function. +func TestAddTweakWithIndex(t *testing.T) { + var ( + bufMaxUint64 = make([]byte, 8) + maxUint64 = new(secp256k1.ModNScalar) + ) + binary.BigEndian.PutUint64(bufMaxUint64, math.MaxUint64) + _ = maxUint64.SetByteSlice(bufMaxUint64) + maxUint64Double := new(secp256k1.ModNScalar). + Set(maxUint64).Add(maxUint64) + + testCases := []struct { + name string + tweak []byte + index uint64 + result *secp256k1.ModNScalar + }{ + { + name: "empty tweak, index 0", + index: 0, + result: new(secp256k1.ModNScalar).SetInt(1), + }, + { + name: "five as tweak, index 123", + tweak: []byte{0x05}, + index: 123, + result: new(secp256k1.ModNScalar).SetInt(129), + }, + { + name: "all zero tweak, index 123", + tweak: bytes.Repeat([]byte{0}, 32), + index: 123, + result: new(secp256k1.ModNScalar).SetInt(124), + }, + { + name: "tweak math.MaxUint64, index math.MaxUint64-1", + tweak: fn.ByteSlice(maxUint64.Bytes()), + index: math.MaxUint64 - 1, + result: maxUint64Double, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tweak := AddTweakWithIndex(tc.tweak, tc.index) + resultBytes := tc.result.Bytes() + resultBigInt := new(big.Int).SetBytes(resultBytes[:]) + tweakBigInt := new(big.Int).SetBytes(tweak) + + require.Equalf(t, resultBytes[:], tweak, "expected: "+ + "%s, got: %s", resultBigInt.String(), + tweakBigInt.String()) + }) + } +} diff --git a/tapchannel/commitment.go b/tapchannel/commitment.go index a7c3b117a..c4e5fef5d 100644 --- a/tapchannel/commitment.go +++ b/tapchannel/commitment.go @@ -756,6 +756,13 @@ func CreateAllocations(chanState lnwallet.AuxChanState, ourBalance, "allocation, HTLC is dust") } + // To ensure uniqueness of the script key across HTLCs with the + // same payment hash and timeout (which would be equal + // otherwise), we tweak the asset level internal key of the + // script key with the HTLC index. We'll ONLY use this for the + // asset level, NOT for the BTC level. + tweakedTree := TweakHtlcTree(htlcTree, htlc.HtlcIndex) + allocations = append(allocations, &Allocation{ Type: allocType, Amount: rfqmsg.Sum(htlc.AssetBalances), @@ -766,13 +773,13 @@ func CreateAllocations(chanState lnwallet.AuxChanState, ourBalance, NonAssetLeaves: sibling, ScriptKey: asset.ScriptKey{ PubKey: asset.NewScriptKey( - htlcTree.TaprootKey, + tweakedTree.TaprootKey, ).PubKey, TweakedScriptKey: &asset.TweakedScriptKey{ RawKey: keychain.KeyDescriptor{ - PubKey: htlcTree.InternalKey, + PubKey: tweakedTree.InternalKey, }, - Tweak: htlcTree.TapscriptRoot, + Tweak: tweakedTree.TapscriptRoot, }, }, SortTaprootKeyBytes: schnorr.SerializePubKey(