From e8ad9e0092097b81e5c9a09d99586b7c9eb6ad0c Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Mon, 12 May 2025 07:19:13 +0200 Subject: [PATCH 1/5] mod: bump to rebased version of MuSig2 lib --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 76f57f4..c1f9a84 100644 --- a/go.mod +++ b/go.mod @@ -212,4 +212,4 @@ require ( // taproot-assets dependency to function properly. replace google.golang.org/protobuf => github.com/lightninglabs/protobuf-go-hex-display v1.33.0-hex-display -replace github.com/btcsuite/btcd/btcutil/psbt => github.com/guggero/btcd/btcutil/psbt v0.0.0-20240615145141-63f97ed9872a +replace github.com/btcsuite/btcd/btcutil/psbt => github.com/guggero/btcd/btcutil/psbt v0.0.0-20250512051801-35a277511ec5 diff --git a/go.sum b/go.sum index 4a1ee1e..942fb14 100644 --- a/go.sum +++ b/go.sum @@ -342,8 +342,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= -github.com/guggero/btcd/btcutil/psbt v0.0.0-20240615145141-63f97ed9872a h1:8TM7i6cMdvNZWtw6eVVP5wDOvVbc9Cjf/ZIc3+APo34= -github.com/guggero/btcd/btcutil/psbt v0.0.0-20240615145141-63f97ed9872a/go.mod h1:7+GB/GHXQM8xCb9q1A5sHDT3LNgrK7fZofPddOAgc3U= +github.com/guggero/btcd/btcutil/psbt v0.0.0-20250512051801-35a277511ec5 h1:YrUjMjcp6rE+VRWaZDxpKeU6EM4+2e4LpOphmsVoA+E= +github.com/guggero/btcd/btcutil/psbt v0.0.0-20250512051801-35a277511ec5/go.mod h1:XcL+sQuIjRePOZ91XTgpS5MZFcLEK4D2hzHmlqKuPwQ= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= From 3d432f7bb9b2eb3c87745d78ee73f537366d5755 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Sat, 3 Aug 2024 11:39:18 +0200 Subject: [PATCH 2/5] cln: add CLN key derivation --- cln/derivation.go | 127 +++++++++++++++++++++++++++++++++++++++++ cln/derivation_test.go | 111 +++++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 cln/derivation.go create mode 100644 cln/derivation_test.go diff --git a/cln/derivation.go b/cln/derivation.go new file mode 100644 index 0000000..f55bbf1 --- /dev/null +++ b/cln/derivation.go @@ -0,0 +1,127 @@ +package cln + +import ( + "crypto/sha256" + "encoding/binary" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/keychain" + "golang.org/x/crypto/hkdf" +) + +const ( + KeyOffsetFunding = 0 + KeyOffsetRevocation = 1 + KeyOffsetHtlc = 2 + KeyOffsetPayment = 3 + KeyOffsetDelayed = 4 +) + +var ( + InfoNodeID = []byte("nodeid") + InfoPeerSeed = []byte("peer seed") + InfoPerPeer = []byte("per-peer seed") + InfoCLightning = []byte("c-lightning") +) + +// NodeKey derives a CLN node key from the given HSM secret. +func NodeKey(hsmSecret [32]byte) (*btcec.PublicKey, *btcec.PrivateKey, error) { + salt := make([]byte, 4) + privKeyBytes, err := HkdfSha256(hsmSecret[:], salt, InfoNodeID) + if err != nil { + return nil, nil, err + } + + privKey, pubKey := btcec.PrivKeyFromBytes(privKeyBytes[:]) + return pubKey, privKey, nil +} + +// DeriveKeyPair derives a channel key pair from the given HSM secret, and the +// key descriptor. The public key in the key descriptor is used as the peer's +// public key, the family is converted to the CLN key type, and the index is +// used as the channel's database index. +func DeriveKeyPair(hsmSecret [32]byte, + desc *keychain.KeyDescriptor) (*btcec.PublicKey, *btcec.PrivateKey, + error) { + + var offset int + switch desc.Family { + case keychain.KeyFamilyMultiSig: + offset = KeyOffsetFunding + + case keychain.KeyFamilyRevocationBase: + offset = KeyOffsetRevocation + + case keychain.KeyFamilyHtlcBase: + offset = KeyOffsetHtlc + + case keychain.KeyFamilyPaymentBase: + offset = KeyOffsetPayment + + case keychain.KeyFamilyDelayBase: + offset = KeyOffsetDelayed + + case keychain.KeyFamilyNodeKey: + return NodeKey(hsmSecret) + + default: + return nil, nil, fmt.Errorf("unsupported key family for CLN: "+ + "%v", desc.Family) + } + + channelBase, err := HkdfSha256(hsmSecret[:], nil, InfoPeerSeed) + if err != nil { + return nil, nil, err + } + + peerAndChannel := make([]byte, 33+8) + copy(peerAndChannel[:33], desc.PubKey.SerializeCompressed()) + binary.LittleEndian.PutUint64(peerAndChannel[33:], uint64(desc.Index)) + + channelSeed, err := HkdfSha256( + channelBase[:], peerAndChannel[:], InfoPerPeer, + ) + if err != nil { + return nil, nil, err + } + + fundingKey, err := HkdfSha256WithSkip( + channelSeed[:], nil, InfoCLightning, offset*32, + ) + if err != nil { + return nil, nil, err + } + + privKey, pubKey := btcec.PrivKeyFromBytes(fundingKey[:]) + return pubKey, privKey, nil +} + +// HkdfSha256 derives a 32-byte key from the given input key material, salt, and +// info using the HKDF-SHA256 key derivation function. +func HkdfSha256(key, salt, info []byte) ([32]byte, error) { + return HkdfSha256WithSkip(key, salt, info, 0) +} + +// HkdfSha256WithSkip derives a 32-byte key from the given input key material, +// salt, and info using the HKDF-SHA256 key derivation function and skips the +// first `skip` bytes of the output. +func HkdfSha256WithSkip(key, salt, info []byte, skip int) ([32]byte, error) { + expander := hkdf.New(sha256.New, key, salt, info) + + if skip > 0 { + skippedBytes := make([]byte, skip) + _, err := expander.Read(skippedBytes) + if err != nil { + return [32]byte{}, err + } + } + + var outputKey [32]byte + _, err := expander.Read(outputKey[:]) + if err != nil { + return [32]byte{}, err + } + + return outputKey, nil +} diff --git a/cln/derivation_test.go b/cln/derivation_test.go new file mode 100644 index 0000000..f7a472c --- /dev/null +++ b/cln/derivation_test.go @@ -0,0 +1,111 @@ +package cln + +import ( + "encoding/hex" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +var ( + hsmSecret = [32]byte{ + 0x3f, 0x0a, 0x06, 0xc6, 0x38, 0x5b, 0x74, 0x93, + 0xf7, 0x5a, 0xa0, 0x08, 0x9f, 0x31, 0x6a, 0x13, + 0xbf, 0x72, 0xbe, 0xb4, 0x30, 0xe5, 0x9e, 0x71, + 0xb5, 0xac, 0x5a, 0x73, 0x58, 0x1a, 0x62, 0x70, + } + nodeKeyBytes, _ = hex.DecodeString( + "035149629152c1bee83f1e148a51400b5f24bf3e2ca53384dd801418446e" + + "1f53fe", + ) + + peerPubKeyBytes, _ = hex.DecodeString( + "02678187ca43e6a6f62f9185be98a933bf485313061e6a05578bbd83c54e" + + "88d460", + ) + peerPubKey, _ = btcec.ParsePubKey(peerPubKeyBytes) + + expectedFundingKeyBytes, _ = hex.DecodeString( + "0326a2171c97673cc8cd7a04a043f0224c59591fc8c9de320a48f7c9b68a" + + "b0ae2b", + ) +) + +func TestNodeKey(t *testing.T) { + nodeKey, _, err := NodeKey(hsmSecret) + require.NoError(t, err) + + require.Equal(t, nodeKeyBytes, nodeKey.SerializeCompressed()) +} + +func TestFundingKey(t *testing.T) { + fundingKey, _, err := DeriveKeyPair(hsmSecret, &keychain.KeyDescriptor{ + PubKey: peerPubKey, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyMultiSig, + Index: 1, + }, + }) + require.NoError(t, err) + + require.Equal( + t, expectedFundingKeyBytes, fundingKey.SerializeCompressed(), + ) +} + +func TestPaymentBasePointSecret(t *testing.T) { + hsmSecret2, _ := hex.DecodeString( + "665b09e6fc86391f0141d957eb14ec30f8f8a58a876842792474cacc2448" + + "9456", + ) + + basePointPeerBytes, _ := hex.DecodeString( + "0350aeef9f33a157953d3c3c1ef464bdf421204461959524e52e530c17f1" + + "66f541", + ) + + expectedPaymentBasePointBytes, _ := hex.DecodeString( + "0339c93ca896829672510f8a4e51caef4b5f6a26f880acf5a120725a7f02" + + "7b56b4", + ) + + var hsmSecret [32]byte + copy(hsmSecret[:], hsmSecret2) + + basepointPeer, err := btcec.ParsePubKey(basePointPeerBytes) + require.NoError(t, err) + + nk, _, err := NodeKey(hsmSecret) + require.NoError(t, err) + + t.Logf("Node key: %x", nk.SerializeCompressed()) + + fk, _, err := DeriveKeyPair(hsmSecret, &keychain.KeyDescriptor{ + PubKey: basepointPeer, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyMultiSig, + Index: 1, + }, + }) + require.NoError(t, err) + + t.Logf("Funding key: %x", fk.SerializeCompressed()) + + paymentBasePoint, _, err := DeriveKeyPair( + hsmSecret, &keychain.KeyDescriptor{ + PubKey: basepointPeer, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyPaymentBase, + Index: 1, + }, + }, + ) + require.NoError(t, err) + + require.Equal( + t, expectedPaymentBasePointBytes, + paymentBasePoint.SerializeCompressed(), + ) +} From 5c4b3a2bce30512781a812faa2ec514301001ec4 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Sun, 8 Jun 2025 09:22:52 +0200 Subject: [PATCH 3/5] cln+lnd: implement signer interface --- cln/signer.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++ lnd/signer.go | 68 +++++++++++++++++++++++++-- 2 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 cln/signer.go diff --git a/cln/signer.go b/cln/signer.go new file mode 100644 index 0000000..e09b31e --- /dev/null +++ b/cln/signer.go @@ -0,0 +1,128 @@ +package cln + +import ( + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/wallet" + "github.com/lightninglabs/chantools/lnd" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" +) + +type Signer struct { + HsmSecret [32]byte +} + +func (s *Signer) SignOutputRaw(tx *wire.MsgTx, + signDesc *input.SignDescriptor) (input.Signature, error) { + + // First attempt to fetch the private key which corresponds to the + // specified public key. + privKey, err := s.FetchPrivateKey(&signDesc.KeyDesc) + if err != nil { + return nil, err + } + + return lnd.SignOutputRawWithPrivateKey(tx, signDesc, privKey) +} + +func (s *Signer) FetchPrivateKey( + descriptor *keychain.KeyDescriptor) (*btcec.PrivateKey, error) { + + _, privKey, err := DeriveKeyPair(s.HsmSecret, descriptor) + return privKey, err +} + +func (s *Signer) FindMultisigKey(targetPubkey, peerPubKey *btcec.PublicKey, + maxNumKeys uint32) (*keychain.KeyDescriptor, error) { + + // Loop through the local multisig keys to find the target key. + for index := range maxNumKeys { + privKey, err := s.FetchPrivateKey(&keychain.KeyDescriptor{ + PubKey: peerPubKey, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyMultiSig, + Index: index, + }, + }) + if err != nil { + return nil, fmt.Errorf("error deriving funding "+ + "private key: %w", err) + } + + currentPubkey := privKey.PubKey() + if !targetPubkey.IsEqual(currentPubkey) { + continue + } + + return &keychain.KeyDescriptor{ + PubKey: peerPubKey, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyMultiSig, + Index: index, + }, + }, nil + } + + return nil, errors.New("no matching pubkeys found") +} + +func (s *Signer) AddPartialSignature(packet *psbt.Packet, + keyDesc keychain.KeyDescriptor, utxo *wire.TxOut, witnessScript []byte, + inputIndex int) error { + + // Now we add our partial signature. + prevOutFetcher := wallet.PsbtPrevOutputFetcher(packet) + signDesc := &input.SignDescriptor{ + KeyDesc: keyDesc, + WitnessScript: witnessScript, + Output: utxo, + InputIndex: inputIndex, + HashType: txscript.SigHashAll, + PrevOutputFetcher: prevOutFetcher, + SigHashes: txscript.NewTxSigHashes( + packet.UnsignedTx, prevOutFetcher, + ), + } + ourSigRaw, err := s.SignOutputRaw(packet.UnsignedTx, signDesc) + if err != nil { + return fmt.Errorf("error signing with our key: %w", err) + } + ourSig := append(ourSigRaw.Serialize(), byte(txscript.SigHashAll)) + + // Because of the way we derive keys in CLN, the public key in the key + // descriptor is the peer's public key, not our own. So we need to + // derive our own public key from the private key. + ourPrivKey, err := s.FetchPrivateKey(&keyDesc) + if err != nil { + return fmt.Errorf("error fetching private key for descriptor "+ + "%v: %w", keyDesc, err) + } + ourPubKey := ourPrivKey.PubKey() + + // Great, we were able to create our sig, let's add it to the PSBT. + updater, err := psbt.NewUpdater(packet) + if err != nil { + return fmt.Errorf("error creating PSBT updater: %w", err) + } + status, err := updater.Sign( + inputIndex, ourSig, ourPubKey.SerializeCompressed(), nil, + witnessScript, + ) + if err != nil { + return fmt.Errorf("error adding signature to PSBT: %w", err) + } + if status != 0 { + return fmt.Errorf("unexpected status for signature update, "+ + "got %d wanted 0", status) + } + + return nil +} + +var _ lnd.ChannelSigner = (*Signer)(nil) diff --git a/lnd/signer.go b/lnd/signer.go index 083b635..0139383 100644 --- a/lnd/signer.go +++ b/lnd/signer.go @@ -20,6 +20,21 @@ import ( "github.com/lightningnetwork/lnd/keychain" ) +type ChannelSigner interface { + SignOutputRaw(tx *wire.MsgTx, + signDesc *input.SignDescriptor) (input.Signature, error) + + FetchPrivateKey(descriptor *keychain.KeyDescriptor) ( + *btcec.PrivateKey, error) + + FindMultisigKey(targetPubkey, peerPubKey *btcec.PublicKey, + maxNumKeys uint32) (*keychain.KeyDescriptor, error) + + AddPartialSignature(packet *psbt.Packet, + keyDesc keychain.KeyDescriptor, utxo *wire.TxOut, + witnessScript []byte, inputIndex int) error +} + type Signer struct { *input.MusigSessionManager @@ -37,10 +52,10 @@ func (s *Signer) SignOutputRaw(tx *wire.MsgTx, return nil, err } - return s.SignOutputRawWithPrivateKey(tx, signDesc, privKey) + return SignOutputRawWithPrivateKey(tx, signDesc, privKey) } -func (s *Signer) SignOutputRawWithPrivateKey(tx *wire.MsgTx, +func SignOutputRawWithPrivateKey(tx *wire.MsgTx, signDesc *input.SignDescriptor, privKey *secp256k1.PrivateKey) (input.Signature, error) { @@ -140,6 +155,51 @@ func (s *Signer) FetchPrivateKey(descriptor *keychain.KeyDescriptor) ( return key.ECPrivKey() } +func (s *Signer) FindMultisigKey(targetPubkey, _ *btcec.PublicKey, + maxNumKeys uint32) (*keychain.KeyDescriptor, error) { + + // First, we need to derive the correct branch from the local root key. + multisigBranch, err := DeriveChildren(s.ExtendedKey, []uint32{ + HardenedKeyStart + uint32(keychain.BIP0043Purpose), + HardenedKeyStart + s.ChainParams.HDCoinType, + HardenedKeyStart + uint32(keychain.KeyFamilyMultiSig), + 0, + }) + if err != nil { + return nil, fmt.Errorf("could not derive local multisig key: "+ + "%w", err) + } + + // Loop through the local multisig keys to find the target key. + for index := range maxNumKeys { + currentKey, err := multisigBranch.DeriveNonStandard(index) + if err != nil { + return nil, fmt.Errorf("error deriving child key: %w", + err) + } + + currentPubkey, err := currentKey.ECPubKey() + if err != nil { + return nil, fmt.Errorf("error deriving public key: %w", + err) + } + + if !targetPubkey.IsEqual(currentPubkey) { + continue + } + + return &keychain.KeyDescriptor{ + PubKey: currentPubkey, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyMultiSig, + Index: index, + }, + }, nil + } + + return nil, errors.New("no matching pubkeys found") +} + func (s *Signer) AddPartialSignature(packet *psbt.Packet, keyDesc keychain.KeyDescriptor, utxo *wire.TxOut, witnessScript []byte, inputIndex int) error { @@ -199,7 +259,7 @@ func (s *Signer) AddPartialSignatureForPrivateKey(packet *psbt.Packet, packet.UnsignedTx, prevOutFetcher, ), } - ourSigRaw, err := s.SignOutputRawWithPrivateKey( + ourSigRaw, err := SignOutputRawWithPrivateKey( packet.UnsignedTx, signDesc, privateKey, ) if err != nil { @@ -258,7 +318,7 @@ func (s *Signer) AddTaprootSignature(packet *psbt.Packet, inputIndex int, signDesc.TapTweak = pIn.TaprootMerkleRoot } - ourSigRaw, err := s.SignOutputRawWithPrivateKey( + ourSigRaw, err := SignOutputRawWithPrivateKey( packet.UnsignedTx, signDesc, privateKey, ) if err != nil { From 53513571a25b39ad5812283f90bdd42b498fe54c Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Sat, 3 Aug 2024 12:39:23 +0200 Subject: [PATCH 4/5] zombierecovery: make preparekeys CLN compatible --- cmd/chantools/zombierecovery_makeoffer.go | 21 +--- .../zombierecovery_makeoffer_test.go | 50 +++++--- cmd/chantools/zombierecovery_preparekeys.go | 115 +++++++++++++----- doc/chantools_zombierecovery_preparekeys.md | 1 + 4 files changed, 125 insertions(+), 62 deletions(-) diff --git a/cmd/chantools/zombierecovery_makeoffer.go b/cmd/chantools/zombierecovery_makeoffer.go index cfd9791..fcf2220 100644 --- a/cmd/chantools/zombierecovery_makeoffer.go +++ b/cmd/chantools/zombierecovery_makeoffer.go @@ -183,22 +183,6 @@ func (c *zombieRecoveryMakeOfferCommand) Execute(_ *cobra.Command, } } - // If we're only matching, we can stop here. - if c.MatchOnly { - ourPubKeys, err := parseKeys(keys1.Node1.MultisigKeys) - if err != nil { - return fmt.Errorf("error parsing their keys: %w", err) - } - - theirPubKeys, err := parseKeys(keys2.Node2.MultisigKeys) - if err != nil { - return fmt.Errorf("error parsing our keys: %w", err) - } - return matchKeys( - keys1.Channels, ourPubKeys, theirPubKeys, chainParams, - ) - } - // Make sure one of the nodes is ours. _, pubKey, _, err := lnd.DeriveKey( extendedKey, lnd.IdentityPath(chainParams), chainParams, @@ -277,6 +261,11 @@ func (c *zombieRecoveryMakeOfferCommand) Execute(_ *cobra.Command, return err } + // If we're only matching, we can stop here. + if c.MatchOnly { + return nil + } + // Let's prepare the PSBT. packet, err := psbt.NewFromUnsignedTx(wire.NewMsgTx(2)) if err != nil { diff --git a/cmd/chantools/zombierecovery_makeoffer_test.go b/cmd/chantools/zombierecovery_makeoffer_test.go index 1c250ae..44ae2cd 100644 --- a/cmd/chantools/zombierecovery_makeoffer_test.go +++ b/cmd/chantools/zombierecovery_makeoffer_test.go @@ -9,23 +9,37 @@ import ( "github.com/stretchr/testify/require" ) -var ( - key1Bytes, _ = hex.DecodeString( - "0201943d78d61c8ad50ba57164830f536c156d8d89d979448bef3e67f564" + - "ea0ab6", - ) - key1, _ = btcec.ParsePubKey(key1Bytes) - key2Bytes, _ = hex.DecodeString( - "038b88de18064024e9da4dfc9c804283b3077a265dcd73ad3615b50badcb" + - "debd5b", - ) - key2, _ = btcec.ParsePubKey(key2Bytes) - addr = "bc1qp5jnhnavt32fjwhnf5ttpvvym7e0syp79q5l9skz545q62d8u2uq05" + - "ul63" -) - func TestMatchScript(t *testing.T) { - ok, _, _, err := matchScript(addr, key1, key2, &chaincfg.MainNetParams) - require.NoError(t, err) - require.True(t, ok) + testCases := []struct { + key1 string + key2 string + addr string + params *chaincfg.Params + }{{ + key1: "0201943d78d61c8ad50ba57164830f536c156d8d89d979448bef3e67f564ea0ab6", + key2: "038b88de18064024e9da4dfc9c804283b3077a265dcd73ad3615b50badcbdebd5b", + addr: "bc1qp5jnhnavt32fjwhnf5ttpvvym7e0syp79q5l9skz545q62d8u2uq05ul63", + params: &chaincfg.MainNetParams, + }, { + key1: "03585d8e760bd0925da67d9c22a69dcad9f51f90a39f9a681971268555975ea30d", + key2: "0326a2171c97673cc8cd7a04a043f0224c59591fc8c9de320a48f7c9b68ab0ae2b", + addr: "bcrt1qhcn39q6jc0krkh9va230y2z6q96zadt8fhxw3erv92fzlrw83cyq40nwek", + params: &chaincfg.RegressionNetParams, + }} + + for _, tc := range testCases { + key1Bytes, err := hex.DecodeString(tc.key1) + require.NoError(t, err) + key1, err := btcec.ParsePubKey(key1Bytes) + require.NoError(t, err) + + key2Bytes, err := hex.DecodeString(tc.key2) + require.NoError(t, err) + key2, err := btcec.ParsePubKey(key2Bytes) + require.NoError(t, err) + + ok, _, _, err := matchScript(tc.addr, key1, key2, tc.params) + require.NoError(t, err) + require.True(t, ok) + } } diff --git a/cmd/chantools/zombierecovery_preparekeys.go b/cmd/chantools/zombierecovery_preparekeys.go index fc5c058..1e64b32 100644 --- a/cmd/chantools/zombierecovery_preparekeys.go +++ b/cmd/chantools/zombierecovery_preparekeys.go @@ -9,9 +9,12 @@ import ( "os" "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/chantools/cln" "github.com/lightninglabs/chantools/lnd" + "github.com/lightningnetwork/lnd/keychain" "github.com/spf13/cobra" ) @@ -25,6 +28,8 @@ type zombieRecoveryPrepareKeysCommand struct { NumKeys uint32 + HsmSecret string + rootKey *rootKey cmd *cobra.Command } @@ -58,6 +63,12 @@ correct ones for the matched channels.`, &cc.NumKeys, "num_keys", numMultisigKeys, "the number of "+ "multisig keys to derive", ) + cc.cmd.Flags().StringVar( + &cc.HsmSecret, "hsm_secret", "", "the hex encoded HSM secret "+ + "to use for deriving the multisig keys for a CLN "+ + "node; obtain by running 'xxd -p -c32 "+ + "~/.lightning/bitcoin/hsm_secret'", + ) cc.rootKey = newRootKey(cc.cmd, "deriving the multisig keys") @@ -67,12 +78,7 @@ correct ones for the matched channels.`, func (c *zombieRecoveryPrepareKeysCommand) Execute(_ *cobra.Command, _ []string) error { - extendedKey, err := c.rootKey.read() - if err != nil { - return fmt.Errorf("error reading root key: %w", err) - } - - err = lnd.CheckAddress( + err := lnd.CheckAddress( c.PayoutAddr, chainParams, false, "payout", lnd.AddrTypeP2WKH, lnd.AddrTypeP2TR, ) @@ -98,26 +104,68 @@ func (c *zombieRecoveryPrepareKeysCommand) Execute(_ *cobra.Command, return errors.New("invalid match file, node info missing") } - _, pubKey, _, err := lnd.DeriveKey( - extendedKey, lnd.IdentityPath(chainParams), chainParams, - ) + // Derive the keys for the node type, depending on the input flags. + var signer lnd.ChannelSigner + switch { + case c.HsmSecret != "": + secretBytes, err := hex.DecodeString(c.HsmSecret) + if err != nil { + return fmt.Errorf("error decoding HSM secret: %w", err) + } + + var hsmSecret [32]byte + copy(hsmSecret[:], secretBytes) + + signer = &cln.Signer{ + HsmSecret: hsmSecret, + } + + default: + extendedKey, err := c.rootKey.read() + if err != nil { + return fmt.Errorf("error reading root key: %w", err) + } + signer = &lnd.Signer{ + ExtendedKey: extendedKey, + ChainParams: chainParams, + } + } + + nodePrivKey, err := signer.FetchPrivateKey(&keychain.KeyDescriptor{ + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyNodeKey, + }, + }) if err != nil { - return fmt.Errorf("error deriving identity pubkey: %w", err) + return fmt.Errorf("error deriving identity private key: %w", + err) } + pubKey := nodePrivKey.PubKey() pubKeyStr := hex.EncodeToString(pubKey.SerializeCompressed()) - var nodeInfo *nodeInfo + var ourNodeInfo, theirNodeInfo *nodeInfo switch { case match.Node1.PubKey != pubKeyStr && match.Node2.PubKey != pubKeyStr: - return fmt.Errorf("derived pubkey %s from seed but that key "+ - "was not found in the match file %s", pubKeyStr, + return fmt.Errorf("derived pubkey %s from seed but that "+ + "key was not found in the match file %s", pubKeyStr, c.MatchFile) case match.Node1.PubKey == pubKeyStr: - nodeInfo = match.Node1 + ourNodeInfo = match.Node1 + theirNodeInfo = match.Node2 default: - nodeInfo = match.Node2 + ourNodeInfo = match.Node2 + theirNodeInfo = match.Node1 + } + + theirNodeKeyBytes, err := hex.DecodeString(theirNodeInfo.PubKey) + if err != nil { + return fmt.Errorf("error decoding peer pubkey: %w", err) + } + theirNodeKey, err := btcec.ParsePubKey(theirNodeKeyBytes) + if err != nil { + return fmt.Errorf("error parsing peer pubkey: %w", err) } // If there are any Simple Taproot channels, we need to generate some @@ -132,6 +180,12 @@ func (c *zombieRecoveryPrepareKeysCommand) Execute(_ *cobra.Command, _, isP2TR := addr.(*btcutil.AddressTaproot) if isP2TR { + lndSigner, ok := signer.(*lnd.Signer) + if !ok { + return fmt.Errorf("taproot channels not " + + "supported for CLN ") + } + chanPoint, err := wire.NewOutPointFromString( matchChannel.ChanPoint, ) @@ -147,12 +201,13 @@ func (c *zombieRecoveryPrepareKeysCommand) Execute(_ *cobra.Command, } nonces, err := lnd.GenerateMuSig2Nonces( - extendedKey, randomness, chanPoint, chainParams, + lndSigner.ExtendedKey, randomness, chanPoint, + chainParams, nil, ) if err != nil { - return fmt.Errorf("error generating MuSig2 "+ - "nonces: %w", err) + return fmt.Errorf("error generating "+ + "MuSig2 nonces: %w", err) } matchChannel.MuSig2NonceRandomness = hex.EncodeToString( @@ -166,21 +221,25 @@ func (c *zombieRecoveryPrepareKeysCommand) Execute(_ *cobra.Command, // Derive all 2500 keys now, this might take a while. for index := range c.NumKeys { - _, pubKey, _, err := lnd.DeriveKey( - extendedKey, lnd.MultisigPath(chainParams, int(index)), - chainParams, - ) + privKey, err := signer.FetchPrivateKey(&keychain.KeyDescriptor{ + PubKey: theirNodeKey, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyMultiSig, + Index: index, + }, + }) if err != nil { - return fmt.Errorf("error deriving multisig pubkey: %w", - err) + return fmt.Errorf("error deriving funding private "+ + "key: %w", err) } - nodeInfo.MultisigKeys = append( - nodeInfo.MultisigKeys, - hex.EncodeToString(pubKey.SerializeCompressed()), + fundingPubKey := privKey.PubKey() + ourNodeInfo.MultisigKeys = append( + ourNodeInfo.MultisigKeys, + hex.EncodeToString(fundingPubKey.SerializeCompressed()), ) } - nodeInfo.PayoutAddr = c.PayoutAddr + ourNodeInfo.PayoutAddr = c.PayoutAddr // Write the result back into a new file. matchBytes, err := json.MarshalIndent(match, "", " ") diff --git a/doc/chantools_zombierecovery_preparekeys.md b/doc/chantools_zombierecovery_preparekeys.md index c967141..2379d74 100644 --- a/doc/chantools_zombierecovery_preparekeys.md +++ b/doc/chantools_zombierecovery_preparekeys.md @@ -27,6 +27,7 @@ chantools zombierecovery preparekeys \ ``` --bip39 read a classic BIP39 seed and passphrase from the terminal instead of asking for lnd seed format or providing the --rootkey flag -h, --help help for preparekeys + --hsm_secret string the hex encoded HSM secret to use for deriving the multisig keys for a CLN node; obtain by running 'xxd -p -c32 ~/.lightning/bitcoin/hsm_secret' --match_file string the match JSON file that was sent to both nodes by the match maker --num_keys uint32 the number of multisig keys to derive (default 2500) --payout_addr string the address where this node's rescued funds should be sent to, must be a P2WPKH (native SegWit) or P2TR (Taproot) address From 02b0551c58d8a191a7737f87c2fd4da79590b92b Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Sun, 8 Jun 2025 09:37:21 +0200 Subject: [PATCH 5/5] zombierecovery: make signoffer CLN compatible --- cmd/chantools/signrescuefunding.go | 61 ++----------- cmd/chantools/zombierecovery_signoffer.go | 102 +++++++++++++++------- doc/chantools_zombierecovery_signoffer.md | 12 +-- 3 files changed, 87 insertions(+), 88 deletions(-) diff --git a/cmd/chantools/signrescuefunding.go b/cmd/chantools/signrescuefunding.go index ebc093f..5579e91 100644 --- a/cmd/chantools/signrescuefunding.go +++ b/cmd/chantools/signrescuefunding.go @@ -6,10 +6,8 @@ import ( "fmt" "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/lightninglabs/chantools/lnd" - "github.com/lightningnetwork/lnd/keychain" "github.com/spf13/cobra" ) @@ -69,24 +67,10 @@ func (c *signRescueFundingCommand) Execute(_ *cobra.Command, _ []string) error { return fmt.Errorf("error decoding PSBT: %w", err) } - return signRescueFunding(extendedKey, packet, signer) + return signRescueFunding(packet, signer) } -func signRescueFunding(rootKey *hdkeychain.ExtendedKey, - packet *psbt.Packet, signer *lnd.Signer) error { - - // First, we need to derive the correct branch from the local root key. - localMultisig, err := lnd.DeriveChildren(rootKey, []uint32{ - lnd.HardenedKeyStart + uint32(keychain.BIP0043Purpose), - lnd.HardenedKeyStart + chainParams.HDCoinType, - lnd.HardenedKeyStart + uint32(keychain.KeyFamilyMultiSig), - 0, - }) - if err != nil { - return fmt.Errorf("could not derive local multisig key: %w", - err) - } - +func signRescueFunding(packet *psbt.Packet, signer *lnd.Signer) error { // Now let's check that the packet has the expected proprietary key with // our pubkey that we need to sign with. if len(packet.Inputs) != 1 { @@ -110,8 +94,12 @@ func signRescueFunding(rootKey *hdkeychain.ExtendedKey, } // Now we can look up the local key and check the PSBT further, then - // add our signature. - localKeyDesc, err := findLocalMultisigKey(localMultisig, targetKey) + // add our signature. This is NOT CLN compatible, as we'd need to + // add the peer's public key as a command argument to pass into + // FindMultisigKey. + localKeyDesc, err := signer.FindMultisigKey( + targetKey, nil, MaxChannelLookup, + ) if err != nil { return fmt.Errorf("could not find local multisig key: %w", err) } @@ -153,36 +141,3 @@ func signRescueFunding(rootKey *hdkeychain.ExtendedKey, return nil } - -func findLocalMultisigKey(multisigBranch *hdkeychain.ExtendedKey, - targetPubkey *btcec.PublicKey) (*keychain.KeyDescriptor, error) { - - // Loop through the local multisig keys to find the target key. - for index := range uint32(MaxChannelLookup) { - currentKey, err := multisigBranch.DeriveNonStandard(index) - if err != nil { - return nil, fmt.Errorf("error deriving child key: %w", - err) - } - - currentPubkey, err := currentKey.ECPubKey() - if err != nil { - return nil, fmt.Errorf("error deriving public key: %w", - err) - } - - if !targetPubkey.IsEqual(currentPubkey) { - continue - } - - return &keychain.KeyDescriptor{ - PubKey: currentPubkey, - KeyLocator: keychain.KeyLocator{ - Family: keychain.KeyFamilyMultiSig, - Index: index, - }, - }, nil - } - - return nil, errors.New("no matching pubkeys found") -} diff --git a/cmd/chantools/zombierecovery_signoffer.go b/cmd/chantools/zombierecovery_signoffer.go index d433ee3..224e7ec 100644 --- a/cmd/chantools/zombierecovery_signoffer.go +++ b/cmd/chantools/zombierecovery_signoffer.go @@ -3,22 +3,25 @@ package main import ( "bufio" "bytes" + "encoding/hex" "errors" "fmt" "os" "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/chantools/cln" "github.com/lightninglabs/chantools/lnd" - "github.com/lightningnetwork/lnd/keychain" "github.com/spf13/cobra" ) type zombieRecoverySignOfferCommand struct { Psbt string + HsmSecret string + RemotePeer string + rootKey *rootKey cmd *cobra.Command } @@ -40,6 +43,17 @@ peer to recover funds from one or more channels.`, &cc.Psbt, "psbt", "", "the base64 encoded PSBT that the other "+ "party sent as an offer to rescue funds", ) + cc.cmd.Flags().StringVar( + &cc.HsmSecret, "hsm_secret", "", "the hex encoded HSM secret "+ + "to use for deriving the multisig keys for a CLN "+ + "node; obtain by running 'xxd -p -c32 "+ + "~/.lightning/bitcoin/hsm_secret'", + ) + cc.cmd.Flags().StringVar( + &cc.RemotePeer, "remote_peer", "", "the hex encoded remote "+ + "peer node identity key, only required when running "+ + "'signoffer' on the CLN side", + ) cc.rootKey = newRootKey(cc.cmd, "signing the offer") @@ -49,16 +63,6 @@ peer to recover funds from one or more channels.`, func (c *zombieRecoverySignOfferCommand) Execute(_ *cobra.Command, _ []string) error { - extendedKey, err := c.rootKey.read() - if err != nil { - return fmt.Errorf("error reading root key: %w", err) - } - - signer := &lnd.Signer{ - ExtendedKey: extendedKey, - ChainParams: chainParams, - } - // Decode the PSBT. packet, err := psbt.NewFromRawBytes( bytes.NewReader([]byte(c.Psbt)), true, @@ -67,24 +71,56 @@ func (c *zombieRecoverySignOfferCommand) Execute(_ *cobra.Command, return fmt.Errorf("error decoding PSBT: %w", err) } - return signOffer(extendedKey, packet, signer) -} + var ( + signer lnd.ChannelSigner + remoteNode *btcec.PublicKey + ) + switch { + case c.HsmSecret != "": + secretBytes, err := hex.DecodeString(c.HsmSecret) + if err != nil { + return fmt.Errorf("error decoding HSM secret: %w", err) + } -func signOffer(rootKey *hdkeychain.ExtendedKey, - packet *psbt.Packet, signer *lnd.Signer) error { + var hsmSecret [32]byte + copy(hsmSecret[:], secretBytes) - // First, we need to derive the correct branch from the local root key. - localMultisig, err := lnd.DeriveChildren(rootKey, []uint32{ - lnd.HardenedKeyStart + uint32(keychain.BIP0043Purpose), - lnd.HardenedKeyStart + chainParams.HDCoinType, - lnd.HardenedKeyStart + uint32(keychain.KeyFamilyMultiSig), - 0, - }) - if err != nil { - return fmt.Errorf("could not derive local multisig key: %w", - err) + if c.RemotePeer == "" { + return errors.New("remote peer pubkey is required " + + "when using the HSM secret") + } + + remoteNodeBytes, err := hex.DecodeString(c.RemotePeer) + if err != nil { + return fmt.Errorf("error decoding peer pubkey: %w", err) + } + remoteNode, err = btcec.ParsePubKey(remoteNodeBytes) + if err != nil { + return fmt.Errorf("error parsing peer pubkey: %w", err) + } + + signer = &cln.Signer{ + HsmSecret: hsmSecret, + } + + default: + extendedKey, err := c.rootKey.read() + if err != nil { + return fmt.Errorf("error reading root key: %w", err) + } + + signer = &lnd.Signer{ + ExtendedKey: extendedKey, + ChainParams: chainParams, + } } + return signOffer(packet, signer, remoteNode) +} + +func signOffer(packet *psbt.Packet, signer lnd.ChannelSigner, + peerPubKey *btcec.PublicKey) error { + // Now let's check that the packet has the expected proprietary key with // our pubkey that we need to sign with. if len(packet.Inputs) == 0 { @@ -144,8 +180,8 @@ func signOffer(rootKey *hdkeychain.ExtendedKey, // Now we can look up the local key and check the PSBT further, // then add our signature. - localKeyDesc, err := findLocalMultisigKey( - localMultisig, targetKey, + localKeyDesc, err := signer.FindMultisigKey( + targetKey, peerPubKey, MaxChannelLookup, ) if err != nil { return fmt.Errorf("could not find local multisig key: "+ @@ -155,8 +191,14 @@ func signOffer(rootKey *hdkeychain.ExtendedKey, // If this is a Simple Taproot channel, we need to generate a // partial MuSig2 signature instead. if len(packet.Inputs[idx].MuSig2PartialSigs) > 0 { + lndSigner, ok := signer.(*lnd.Signer) + if !ok { + return errors.New("taproot channels not yet " + + "supported for CLN") + } + err = muSig2PartialSign( - signer, localKeyDesc, packet, idx, + lndSigner, localKeyDesc, packet, idx, ) if err != nil { return fmt.Errorf("error adding partial "+ @@ -187,7 +229,7 @@ func signOffer(rootKey *hdkeychain.ExtendedKey, // We're almost done. Now we just need to make sure we can finalize and // extract the final TX. - err = psbt.MaybeFinalizeAll(packet) + err := psbt.MaybeFinalizeAll(packet) if err != nil { return fmt.Errorf("error finalizing PSBT: %w", err) } diff --git a/doc/chantools_zombierecovery_signoffer.md b/doc/chantools_zombierecovery_signoffer.md index 90bb9e4..2a86be5 100644 --- a/doc/chantools_zombierecovery_signoffer.md +++ b/doc/chantools_zombierecovery_signoffer.md @@ -21,11 +21,13 @@ chantools zombierecovery signoffer \ ### Options ``` - --bip39 read a classic BIP39 seed and passphrase from the terminal instead of asking for lnd seed format or providing the --rootkey flag - -h, --help help for signoffer - --psbt string the base64 encoded PSBT that the other party sent as an offer to rescue funds - --rootkey string BIP32 HD root key of the wallet to use for signing the offer; leave empty to prompt for lnd 24 word aezeed - --walletdb string read the seed/master root key to use for signing the offer from an lnd wallet.db file instead of asking for a seed or providing the --rootkey flag + --bip39 read a classic BIP39 seed and passphrase from the terminal instead of asking for lnd seed format or providing the --rootkey flag + -h, --help help for signoffer + --hsm_secret string the hex encoded HSM secret to use for deriving the multisig keys for a CLN node; obtain by running 'xxd -p -c32 ~/.lightning/bitcoin/hsm_secret' + --psbt string the base64 encoded PSBT that the other party sent as an offer to rescue funds + --remote_peer string the hex encoded remote peer node identity key, only required when running 'signoffer' on the CLN side + --rootkey string BIP32 HD root key of the wallet to use for signing the offer; leave empty to prompt for lnd 24 word aezeed + --walletdb string read the seed/master root key to use for signing the offer from an lnd wallet.db file instead of asking for a seed or providing the --rootkey flag ``` ### Options inherited from parent commands