From 614d09dd44f83b8ff199319184138045c008587a Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Sat, 3 Aug 2024 11:39:18 +0200 Subject: [PATCH 1/3] cln: add CLN funding key derivation --- cln/derivation.go | 59 ++++++++++++++++++++++++++++++++++++++++++ cln/derivation_test.go | 37 ++++++++++++++++++++++++++ 2 files changed, 96 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..dade986 --- /dev/null +++ b/cln/derivation.go @@ -0,0 +1,59 @@ +package cln + +import ( + "crypto/sha256" + "encoding/binary" + + "github.com/btcsuite/btcd/btcec/v2" + "golang.org/x/crypto/hkdf" +) + +var ( + InfoPeerSeed = []byte("peer seed") + InfoPerPeer = []byte("per-peer seed") + InfoCLightning = []byte("c-lightning") +) + +// FundingKey derives a CLN channel funding key for the given peer and channel +// number (incrementing database index). +func FundingKey(hsmSecret [32]byte, peerPubKey *btcec.PublicKey, + channelNum uint64) (*btcec.PublicKey, error) { + + channelBase, err := HkdfSha256(hsmSecret[:], nil, InfoPeerSeed) + if err != nil { + return nil, err + } + + peerAndChannel := make([]byte, 33+8) + copy(peerAndChannel[:33], peerPubKey.SerializeCompressed()) + binary.LittleEndian.PutUint64(peerAndChannel[33:], channelNum) + + channelSeed, err := HkdfSha256( + channelBase[:], peerAndChannel[:], InfoPerPeer, + ) + if err != nil { + return nil, err + } + + fundingKey, err := HkdfSha256(channelSeed[:], nil, InfoCLightning) + if err != nil { + return nil, err + } + + _, pubKey := btcec.PrivKeyFromBytes(fundingKey[:]) + return pubKey, 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) { + expander := hkdf.New(sha256.New, key, salt, info) + 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..37a5349 --- /dev/null +++ b/cln/derivation_test.go @@ -0,0 +1,37 @@ +package cln + +import ( + "encoding/hex" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "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, + } + peerPubKeyBytes, _ = hex.DecodeString( + "02678187ca43e6a6f62f9185be98a933bf485313061e6a05578bbd83c54e" + + "88d460", + ) + peerPubKey, _ = btcec.ParsePubKey(peerPubKeyBytes) + + expectedFundingKeyBytes, _ = hex.DecodeString( + "0326a2171c97673cc8cd7a04a043f0224c59591fc8c9de320a48f7c9b68a" + + "b0ae2b", + ) +) + +func TestFundingKey(t *testing.T) { + fundingKey, err := FundingKey(hsmSecret, peerPubKey, 1) + require.NoError(t, err) + + require.Equal( + t, expectedFundingKeyBytes, fundingKey.SerializeCompressed(), + ) +} From f3edda7c7f7e337fb75fa13d5c60f97b6649d2eb Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Sat, 3 Aug 2024 12:16:24 +0200 Subject: [PATCH 2/3] cln: add CLN node key derivation --- cln/derivation.go | 13 +++++++++++++ cln/derivation_test.go | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/cln/derivation.go b/cln/derivation.go index dade986..3b9e70f 100644 --- a/cln/derivation.go +++ b/cln/derivation.go @@ -9,11 +9,24 @@ import ( ) 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, error) { + salt := make([]byte, 4) + privKeyBytes, err := HkdfSha256(hsmSecret[:], salt, InfoNodeID) + if err != nil { + return nil, err + } + + _, pubKey := btcec.PrivKeyFromBytes(privKeyBytes[:]) + return pubKey, nil +} + // FundingKey derives a CLN channel funding key for the given peer and channel // number (incrementing database index). func FundingKey(hsmSecret [32]byte, peerPubKey *btcec.PublicKey, diff --git a/cln/derivation_test.go b/cln/derivation_test.go index 37a5349..d6e2b25 100644 --- a/cln/derivation_test.go +++ b/cln/derivation_test.go @@ -15,6 +15,11 @@ var ( 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", @@ -27,6 +32,13 @@ var ( ) ) +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 := FundingKey(hsmSecret, peerPubKey, 1) require.NoError(t, err) From f4b1923fb4ab20628100a0941bfab34370f5922a Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Sat, 3 Aug 2024 12:39:23 +0200 Subject: [PATCH 3/3] zombierecovery: make preparekeys CLN compatible --- cmd/chantools/zombierecovery_makeoffer.go | 21 +-- .../zombierecovery_makeoffer_test.go | 50 ++++--- cmd/chantools/zombierecovery_preparekeys.go | 136 +++++++++++++++--- 3 files changed, 150 insertions(+), 57 deletions(-) diff --git a/cmd/chantools/zombierecovery_makeoffer.go b/cmd/chantools/zombierecovery_makeoffer.go index a59cd11..07ab36e 100644 --- a/cmd/chantools/zombierecovery_makeoffer.go +++ b/cmd/chantools/zombierecovery_makeoffer.go @@ -181,22 +181,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, @@ -275,6 +259,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..9a95871 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..56cdd00 100644 --- a/cmd/chantools/zombierecovery_preparekeys.go +++ b/cmd/chantools/zombierecovery_preparekeys.go @@ -9,8 +9,10 @@ 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/spf13/cobra" ) @@ -25,6 +27,8 @@ type zombieRecoveryPrepareKeysCommand struct { NumKeys uint32 + HsmSecret string + rootKey *rootKey cmd *cobra.Command } @@ -58,6 +62,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 +77,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,19 +103,51 @@ func (c *zombieRecoveryPrepareKeysCommand) Execute(_ *cobra.Command, return errors.New("invalid match file, node info missing") } + // Derive the keys for the node type, depending on the input flags. + var pubKeyStr string + switch { + case c.HsmSecret != "": + pubKeyStr, err = c.clnDeriveKeys(&match) + default: + pubKeyStr, err = c.lndDeriveKeys(&match) + } + if err != nil { + return err + } + + // Write the result back into a new file. + matchBytes, err := json.MarshalIndent(match, "", " ") + if err != nil { + return err + } + + fileName := fmt.Sprintf("results/preparedkeys-%s-%s.json", + time.Now().Format("2006-01-02"), pubKeyStr) + log.Infof("Writing result to %s", fileName) + return os.WriteFile(fileName, matchBytes, 0644) +} + +func (c *zombieRecoveryPrepareKeysCommand) lndDeriveKeys(match *match) (string, + error) { + + extendedKey, err := c.rootKey.read() + if err != nil { + return "", fmt.Errorf("error reading root key: %w", err) + } + _, pubKey, _, err := lnd.DeriveKey( extendedKey, lnd.IdentityPath(chainParams), chainParams, ) if err != nil { - return fmt.Errorf("error deriving identity pubkey: %w", err) + return "", fmt.Errorf("error deriving identity pubkey: %w", err) } pubKeyStr := hex.EncodeToString(pubKey.SerializeCompressed()) var nodeInfo *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: @@ -126,7 +163,7 @@ func (c *zombieRecoveryPrepareKeysCommand) Execute(_ *cobra.Command, matchChannel := match.Channels[idx] addr, err := lnd.ParseAddress(matchChannel.Address, chainParams) if err != nil { - return fmt.Errorf("error parsing channel funding "+ + return "", fmt.Errorf("error parsing channel funding "+ "address '%s': %w", matchChannel.Address, err) } @@ -136,14 +173,14 @@ func (c *zombieRecoveryPrepareKeysCommand) Execute(_ *cobra.Command, matchChannel.ChanPoint, ) if err != nil { - return fmt.Errorf("error parsing channel "+ + return "", fmt.Errorf("error parsing channel "+ "point %s: %w", matchChannel.ChanPoint, err) } var randomness [32]byte if _, err := rand.Read(randomness[:]); err != nil { - return err + return "", err } nonces, err := lnd.GenerateMuSig2Nonces( @@ -151,8 +188,8 @@ func (c *zombieRecoveryPrepareKeysCommand) Execute(_ *cobra.Command, 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( @@ -171,8 +208,8 @@ func (c *zombieRecoveryPrepareKeysCommand) Execute(_ *cobra.Command, chainParams, ) if err != nil { - return fmt.Errorf("error deriving multisig pubkey: %w", - err) + return "", fmt.Errorf("error deriving multisig "+ + "pubkey: %w", err) } nodeInfo.MultisigKeys = append( @@ -182,14 +219,67 @@ func (c *zombieRecoveryPrepareKeysCommand) Execute(_ *cobra.Command, } nodeInfo.PayoutAddr = c.PayoutAddr - // Write the result back into a new file. - matchBytes, err := json.MarshalIndent(match, "", " ") + return pubKeyStr, nil +} + +func (c *zombieRecoveryPrepareKeysCommand) clnDeriveKeys(match *match) (string, + error) { + + secretBytes, err := hex.DecodeString(c.HsmSecret) if err != nil { - return err + return "", fmt.Errorf("error decoding HSM secret: %w", err) } - fileName := fmt.Sprintf("results/preparedkeys-%s-%s.json", - time.Now().Format("2006-01-02"), pubKeyStr) - log.Infof("Writing result to %s", fileName) - return os.WriteFile(fileName, matchBytes, 0644) + var hsmSecret [32]byte + copy(hsmSecret[:], secretBytes) + + nodePubKey, err := cln.NodeKey(hsmSecret) + if err != nil { + return "", fmt.Errorf("error deriving node pubkey: %w", err) + } + + pubKeyStr := hex.EncodeToString(nodePubKey.SerializeCompressed()) + 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, + c.MatchFile) + + case match.Node1.PubKey == pubKeyStr: + ourNodeInfo = match.Node1 + theirNodeInfo = match.Node2 + + default: + 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) + } + + // Derive all 2500 keys now, this might take a while. + for index := range c.NumKeys { + pubKey, err := cln.FundingKey( + hsmSecret, theirNodeKey, uint64(index), + ) + if err != nil { + return "", fmt.Errorf("error deriving multisig "+ + "pubkey: %w", err) + } + + ourNodeInfo.MultisigKeys = append( + ourNodeInfo.MultisigKeys, + hex.EncodeToString(pubKey.SerializeCompressed()), + ) + } + ourNodeInfo.PayoutAddr = c.PayoutAddr + + return pubKeyStr, nil }