diff --git a/lnwallet/btcwallet/signer.go b/lnwallet/btcwallet/signer.go index cea9b977c..bf339b519 100644 --- a/lnwallet/btcwallet/signer.go +++ b/lnwallet/btcwallet/signer.go @@ -1,11 +1,14 @@ package btcwallet import ( + "fmt" + "github.com/btcsuite/btcd/btcec" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcutil" + "github.com/btcsuite/btcutil/hdkeychain" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/walletdb" "github.com/go-errors/errors" @@ -73,6 +76,133 @@ func deriveFromKeyLoc(scopedMgr *waddrmgr.ScopedKeyManager, return addr.(waddrmgr.ManagedPubKeyAddress).PrivKey() } +// deriveKeyByBIP32Path derives a key described by a BIP32 path. We expect the +// first three elements of the path to be hardened according to BIP44, so they +// must be a number >= 2^31. +func (b *BtcWallet) deriveKeyByBIP32Path(path []uint32) (*btcec.PrivateKey, + error) { + + // Make sure we get a full path with exactly 5 elements. A path is + // either custom purpose one with 4 dynamic and one static elements: + // m/1017'/coinType'/keyFamily'/0/index + // Or a default BIP49/89 one with 5 elements: + // m/purpose'/coinType'/account'/change/index + const expectedDerivationPathDepth = 5 + if len(path) != expectedDerivationPathDepth { + return nil, fmt.Errorf("invalid BIP32 derivation path, "+ + "expected path length %d, instead was %d", + expectedDerivationPathDepth, len(path)) + } + + // Assert that the first three parts of the path are actually hardened + // to avoid under-flowing the uint32 type. + if err := assertHardened(path[0], path[1], path[2]); err != nil { + return nil, fmt.Errorf("invalid BIP32 derivation path, "+ + "expected first three elements to be hardened: %w", err) + } + + purpose := path[0] - hdkeychain.HardenedKeyStart + coinType := path[1] - hdkeychain.HardenedKeyStart + account := path[2] - hdkeychain.HardenedKeyStart + change, index := path[3], path[4] + + // Is this a custom lnd internal purpose key? + switch purpose { + case keychain.BIP0043Purpose: + // Make sure it's for the same coin type as our wallet's + // keychain scope. + if coinType != b.chainKeyScope.Coin { + return nil, fmt.Errorf("invalid BIP32 derivation "+ + "path, expected coin type %d, instead was %d", + b.chainKeyScope.Coin, coinType) + } + + return b.deriveKeyByLocator(keychain.KeyLocator{ + Family: keychain.KeyFamily(account), + Index: index, + }) + + // Is it a standard, BIP defined purpose that the wallet understands? + case waddrmgr.KeyScopeBIP0044.Purpose, + waddrmgr.KeyScopeBIP0049Plus.Purpose, + waddrmgr.KeyScopeBIP0084.Purpose: + + // We're going to continue below the switch statement to avoid + // unnecessary indentation for this default case. + + // Currently, there is no way to import any other key scopes than the + // one custom purpose or three standard ones into lnd's wallet. So we + // shouldn't accept any other scopes to sign for. + default: + return nil, fmt.Errorf("invalid BIP32 derivation path, "+ + "unknown purpose %d", purpose) + } + + // Okay, we made sure it's a BIP49/84 key, so we need to derive it now. + // Interestingly, the btcwallet never actually uses a coin type other + // than 0 for those keys, so we need to make sure this behavior is + // replicated here. + if coinType != 0 { + return nil, fmt.Errorf("invalid BIP32 derivation path, coin " + + "type must be 0 for BIP49/84 btcwallet keys") + } + + // We only expect to be asked to sign with key scopes that we know + // about. So if the scope doesn't exist, we don't create it. + scope := waddrmgr.KeyScope{ + Purpose: purpose, + Coin: coinType, + } + scopedMgr, err := b.wallet.Manager.FetchScopedKeyManager(scope) + if err != nil { + return nil, fmt.Errorf("error fetching manager for scope %v: "+ + "%w", scope, err) + } + + // Let's see if we can hit the private key cache. + keyPath := waddrmgr.DerivationPath{ + InternalAccount: account, + Account: account, + Branch: change, + Index: index, + } + privKey, err := scopedMgr.DeriveFromKeyPathCache(keyPath) + if err == nil { + return privKey, nil + } + + // The key wasn't in the cache, let's fully derive it now. + err = walletdb.View(b.db, func(tx walletdb.ReadTx) error { + addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) + + addr, err := scopedMgr.DeriveFromKeyPath(addrmgrNs, keyPath) + if err != nil { + return fmt.Errorf("error deriving private key: %w", err) + } + + privKey, err = addr.(waddrmgr.ManagedPubKeyAddress).PrivKey() + return err + }) + if err != nil { + return nil, fmt.Errorf("error deriving key from path %#v: %w", + keyPath, err) + } + + return privKey, nil +} + +// assertHardened makes sure each given element is >= 2^31. +func assertHardened(elements ...uint32) error { + for idx, element := range elements { + if element < hdkeychain.HardenedKeyStart { + return fmt.Errorf("element at index %d is not hardened", + idx) + } + } + + return nil +} + // deriveKeyByLocator attempts to derive a key stored in the wallet given a // valid key locator. func (b *BtcWallet) deriveKeyByLocator( diff --git a/lnwallet/btcwallet/signer_test.go b/lnwallet/btcwallet/signer_test.go new file mode 100644 index 000000000..be15acb4d --- /dev/null +++ b/lnwallet/btcwallet/signer_test.go @@ -0,0 +1,241 @@ +package btcwallet + +import ( + "encoding/hex" + "io/ioutil" + "os" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/integration/rpctest" + "github.com/btcsuite/btcutil" + "github.com/btcsuite/btcutil/hdkeychain" + "github.com/btcsuite/btcwallet/chain" + "github.com/lightningnetwork/lnd/blockcache" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/require" +) + +var ( + // seedBytes is the raw entropy of the aezeed: + // able promote dizzy mixture sword myth share public find tattoo + // catalog cousin bulb unfair machine alarm cool large promote kick + // shop rug mean year + // Which corresponds to the master root key: + // xprv9s21ZrQH143K2KADjED57FvNbptdKLp4sqKzssegwEGKQMGoDkbyhUeCKe5m3A + // MU44z4vqkmGswwQVKrv599nFG16PPZDEkNrogwoDGeCmZ + seedBytes, _ = hex.DecodeString("4a7611b6979ba7c4bc5c5cd2239b2973") + + // firstAddress is the first address that we should get from the wallet, + // corresponding to the derivation path m/84'/0'/0'/0/0 (even on regtest + // which is a special case for the BIP49/84 addresses in btcwallet). + firstAddress = "bcrt1qgdlgjc5ede7fjv350wcjqat80m0zsmfaswsj9p" + + testCases = []struct { + name string + path []uint32 + err string + wif string + }{{ + name: "m/84'/0'/0'/0/0", + path: []uint32{ + hardenedKey(84), hardenedKey(0), hardenedKey(0), 0, 0, + }, + wif: "cPp3XUewCBQVg3pgwVWbtpzDwhWTQpHhu8saN3SdGRTkiLpu1R6h", + }, { + name: "m/84'/0'/0'/1/0", + path: []uint32{ + hardenedKey(84), hardenedKey(0), hardenedKey(0), 1, 0, + }, + wif: "cPUR1nFAeYAtSWSkKoWB6WbzRTbDSGdrGRmv1kVLRPyo7QXph2gt", + }, { + name: "m/84'/0'/0'/0/12345", + path: []uint32{ + hardenedKey(84), hardenedKey(0), hardenedKey(0), 0, + 12345, + }, + wif: "cQCdGxqKeGZKiC2uRYMAGenJHkDvajiPieT4Yg7k1BKawjKkywvz", + }, { + name: "m/49'/0'/0'/0/0", + path: []uint32{ + hardenedKey(49), hardenedKey(0), hardenedKey(0), 0, 0, + }, + wif: "cMwVK2bcTzivPZfcCH585rBGghqsJAP9MdVy8inRti1wZvLn5DvY", + }, { + name: "m/49'/0'/0'/1/0", + path: []uint32{ + hardenedKey(49), hardenedKey(0), hardenedKey(0), 1, 0, + }, + wif: "cNPW9bMtdc2YGBzWzSCXFN4excjrT34nZzGYtfkzkazUrt3dXuv7", + }, { + name: "m/49'/0'/0'/1/12345", + path: []uint32{ + hardenedKey(49), hardenedKey(0), hardenedKey(0), 1, + 12345, + }, + wif: "cNdJt2fSNUJYVSb8JFjhosPcQgNvJ92SjNeNpsf1gUwDVDv2KVRa", + }, { + name: "m/1017'/1'/0'/0/0", + path: []uint32{ + hardenedKey(1017), hardenedKey(1), hardenedKey(0), 0, 0, + }, + wif: "cPsCmbWQENgptj3eTiyd85QSAD1xqYKPM9jUkfvm7vgN3SoVPWSP", + }, { + name: "m/1017'/1'/6'/0/0", + path: []uint32{ + hardenedKey(1017), hardenedKey(1), hardenedKey(6), 0, 0, + }, + wif: "cPeQdpcGJmLqpdmvokh3DK9ZtjYAXxiw4p4ELNUWkWt6bMRqArEV", + }, { + name: "m/1017'/1'/7'/0/123", + path: []uint32{ + hardenedKey(1017), hardenedKey(1), hardenedKey(7), 0, + 123, + }, + wif: "cPcWZMqY4YErkcwjtFJaYoXkzd7bKxrfxAVzhDgy3n5BGH8CU8sn", + }, { + name: "m/84'/1'/0'/0/0", + path: []uint32{ + hardenedKey(84), hardenedKey(1), hardenedKey(0), 0, 0, + }, + err: "coin type must be 0 for BIP49/84 btcwallet keys", + }, { + name: "m/1017'/0'/0'/0/0", + path: []uint32{ + hardenedKey(1017), hardenedKey(0), hardenedKey(0), 0, 0, + }, + err: "expected coin type 1, instead was 0", + }, { + name: "m/84'/0'/1'/0/0", + path: []uint32{ + hardenedKey(84), hardenedKey(0), hardenedKey(1), 0, 0, + }, + err: "account 1 not found", + }, { + name: "m/49'/0'/1'/0/0", + path: []uint32{ + hardenedKey(49), hardenedKey(0), hardenedKey(1), 0, 0, + }, + err: "account 1 not found", + }, { + name: "non-hardened purpose m/84/0/0/0/0", + path: []uint32{84, 0, 0, 0, 0}, + err: "element at index 0 is not hardened", + }, { + name: "non-hardened account m/84'/0'/0/0/0", + path: []uint32{hardenedKey(84), hardenedKey(0), 0, 0, 0}, + err: "element at index 2 is not hardened", + }} +) + +// TestBip32KeyDerivation makes sure that private keys can be derived from a +// BIP32 key path correctly. +func TestBip32KeyDerivation(t *testing.T) { + netParams := &chaincfg.RegressionNetParams + w, cleanup := newTestWallet(t, netParams, seedBytes) + defer cleanup() + + // This is just a sanity check that the wallet was initialized + // correctly. We make sure the first derived address is the expected + // one. + firstDerivedAddr, err := w.NewAddress( + lnwallet.WitnessPubKey, false, lnwallet.DefaultAccountName, + ) + require.NoError(t, err) + require.Equal(t, firstAddress, firstDerivedAddr.String()) + + // Let's go through the test cases now that we know our wallet is ready. + for _, tc := range testCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + privKey, err := w.deriveKeyByBIP32Path(tc.path) + + if tc.err == "" { + require.NoError(t, err) + wif, err := btcutil.NewWIF( + privKey, netParams, true, + ) + require.NoError(t, err) + require.Equal(t, tc.wif, wif.String()) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tc.err) + } + }) + } +} + +func newTestWallet(t *testing.T, netParams *chaincfg.Params, + seedBytes []byte) (*BtcWallet, func()) { + + tempDir, err := ioutil.TempDir("", "lnwallet") + if err != nil { + _ = os.RemoveAll(tempDir) + t.Fatalf("creating temp dir failed: %v", err) + } + + chainBackend, backendCleanup := getChainBackend(t, netParams) + cleanup := func() { + _ = os.RemoveAll(tempDir) + backendCleanup() + } + + loaderOpt := LoaderWithLocalWalletDB(tempDir, false, time.Minute) + config := Config{ + PrivatePass: []byte("some-pass"), + HdSeed: seedBytes, + NetParams: netParams, + CoinType: netParams.HDCoinType, + ChainSource: chainBackend, + // wallet starts in recovery mode + RecoveryWindow: 2, + LoaderOptions: []LoaderOption{loaderOpt}, + } + blockCache := blockcache.NewBlockCache(10000) + w, err := New(config, blockCache) + if err != nil { + cleanup() + t.Fatalf("creating wallet failed: %v", err) + } + + err = w.Start() + if err != nil { + cleanup() + t.Fatalf("starting wallet failed: %v", err) + } + + return w, cleanup +} + +// getChainBackend returns a simple btcd based chain backend to back the wallet. +func getChainBackend(t *testing.T, netParams *chaincfg.Params) (chain.Interface, + func()) { + + miningNode, err := rpctest.New(netParams, nil, nil, "") + require.NoError(t, err) + require.NoError(t, miningNode.SetUp(true, 25)) + + // Next, mine enough blocks in order for SegWit and the CSV package + // soft-fork to activate on RegNet. + numBlocks := netParams.MinerConfirmationWindow * 2 + _, err = miningNode.Client.Generate(numBlocks) + require.NoError(t, err) + + rpcConfig := miningNode.RPCConfig() + chainClient, err := chain.NewRPCClient( + netParams, rpcConfig.Host, rpcConfig.User, rpcConfig.Pass, + rpcConfig.Certificates, false, 20, + ) + require.NoError(t, err) + + return chainClient, func() { + _ = miningNode.TearDown() + } +} + +// hardenedKey returns a key of a hardened derivation key path. +func hardenedKey(part uint32) uint32 { + return part + hdkeychain.HardenedKeyStart +}