diff --git a/btc/bip39.go b/btc/bip39.go index f9579ba..b368176 100644 --- a/btc/bip39.go +++ b/btc/bip39.go @@ -6,28 +6,42 @@ import ( "encoding/hex" "errors" "fmt" - "os" - "strings" - "syscall" - "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcutil/hdkeychain" "github.com/guggero/chantools/bip39" "golang.org/x/crypto/pbkdf2" "golang.org/x/crypto/ssh/terminal" + "os" + "strings" + "syscall" +) + +const ( + BIP39MnemonicEnvName = "SEED_MNEMONIC" + BIP39PassphraseEnvName = "SEED_PASSPHRASE" ) func ReadMnemonicFromTerminal(params *chaincfg.Params) (*hdkeychain.ExtendedKey, error) { - // We'll now prompt the user to enter in their 12 to 24 word mnemonic. - fmt.Printf("Input your 12 to 24 word mnemonic separated by spaces: ") + var err error reader := bufio.NewReader(os.Stdin) - mnemonicStr, err := reader.ReadString('\n') - if err != nil { - return nil, err + + // To automate things with chantools, we also offer reading the seed + // from environment variables. + mnemonicStr := strings.TrimSpace(os.Getenv(BIP39MnemonicEnvName)) + + if mnemonicStr == "" { + // If there's no value in the environment, we'll now prompt the + //user to enter in their 12 to 24 word mnemonic. + fmt.Printf("Input your 12 to 24 word mnemonic separated by " + + "spaces: ") + mnemonicStr, err = reader.ReadString('\n') + if err != nil { + return nil, err + } + fmt.Println() } - fmt.Println() // We'll trim off extra spaces, and ensure the mnemonic is all // lower case. @@ -40,62 +54,87 @@ func ReadMnemonicFromTerminal(params *chaincfg.Params) (*hdkeychain.ExtendedKey, "must be between 12 and 24 words") } - // Additionally, the user may have a passphrase, that will also - // need to be provided so the daemon can properly decipher the - // cipher seed. - fmt.Printf("Input your cipher seed passphrase (press enter if " + - "your seed doesn't have a passphrase): ") - passphrase, err := terminal.ReadPassword(int(syscall.Stdin)) // nolint - if err != nil { - return nil, err - } - fmt.Println() + // Additionally, the user may have a passphrase, that will also need to + // be provided so the daemon can properly decipher the cipher seed. + // Try the environment variable first. + passphrase := strings.TrimSpace(os.Getenv(BIP39PassphraseEnvName)) - // Check that the mnemonic is valid. - _, err = bip39.EntropyFromMnemonic(mnemonicStr) - if err != nil { - return nil, err - } + // Because we cannot differentiate between an empty and a non-existent + // environment variable, we need a special character that indicates that + // no passphrase should be used. We use a single dash (-) for that as + // that would be too short for a passphrase anyway. + var ( + passphraseBytes []byte + seed []byte + choice string + ) + switch { + // The user indicated in the environment variable that no passphrase + // should be used. We don't set any value. + case passphrase == "-": - var seed []byte - fmt.Printf("Please choose passphrase mode:\n" + - " 0 - Default BIP39\n" + - " 1 - Passphrase to hex\n" + - " 2 - Digital Bitbox (extra round of PBKDF2)\n" + - "\n" + - "Choice [default 0]: ") - choice, err := reader.ReadString('\n') - if err != nil { - return nil, err + // The environment variable didn't contain anything, we'll read the + // passphrase from the terminal. + case passphrase == "": + // Additionally, the user may have a passphrase, that will also + // need to be provided so the daemon can properly decipher the + // cipher seed. + fmt.Printf("Input your cipher seed passphrase (press enter " + + "if your seed doesn't have a passphrase): ") + passphraseBytes, err = terminal.ReadPassword( + int(syscall.Stdin), // nolint + ) + if err != nil { + return nil, err + } + fmt.Println() + + // Check that the mnemonic is valid. + _, err = bip39.EntropyFromMnemonic(mnemonicStr) + if err != nil { + return nil, err + } + + fmt.Printf("Please choose passphrase mode:\n" + + " 0 - Default BIP39\n" + + " 1 - Passphrase to hex\n" + + " 2 - Digital Bitbox (extra round of PBKDF2)\n" + + "\n" + + "Choice [default 0]: ") + choice, err = reader.ReadString('\n') + if err != nil { + return nil, err + } + fmt.Println() + + // There was a password in the environment, just convert it to bytes. + default: + passphraseBytes = []byte(passphrase) } - fmt.Println() switch strings.TrimSpace(choice) { case "", "0": seed = pbkdf2.Key( []byte(mnemonicStr), append( - []byte("mnemonic"), passphrase..., + []byte("mnemonic"), passphraseBytes..., ), 2048, 64, sha512.New, ) case "1": - passphrase = []byte(hex.EncodeToString(passphrase)) + p := []byte(hex.EncodeToString(passphraseBytes)) seed = pbkdf2.Key( - []byte(mnemonicStr), append( - []byte("mnemonic"), passphrase..., - ), 2048, 64, sha512.New, + []byte(mnemonicStr), append([]byte("mnemonic"), p...), + 2048, 64, sha512.New, ) case "2": - passphrase = pbkdf2.Key( - passphrase, []byte("Digital Bitbox"), 20480, 64, + p := hex.EncodeToString(pbkdf2.Key( + passphraseBytes, []byte("Digital Bitbox"), 20480, 64, sha512.New, - ) - passphrase = []byte(hex.EncodeToString(passphrase)) + )) seed = pbkdf2.Key( - []byte(mnemonicStr), append( - []byte("mnemonic"), passphrase..., - ), 2048, 64, sha512.New, + []byte(mnemonicStr), append([]byte("mnemonic"), p...), + 2048, 64, sha512.New, ) default: diff --git a/cmd/chantools/chanbackup_test.go b/cmd/chantools/chanbackup_test.go new file mode 100644 index 0000000..c16d48d --- /dev/null +++ b/cmd/chantools/chanbackup_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + backupContent = "FundingOutpoint: (string) (len=66) \"10279f626196340" + + "58b6133cb7ac6c1693a8e6df7caa91c6263ca3d0bf704ad4d:0\"" +) + +func TestChanBackupAndDumpBackup(t *testing.T) { + h := newHarness(t) + + // Create a channel backup from a channel DB file. + makeBackup := &chanBackupCommand{ + ChannelDB: h.testdataFile("channel.db"), + MultiFile: h.tempFile("extracted.backup"), + rootKey: &rootKey{RootKey: rootKeyAezeed}, + } + + err := makeBackup.Execute(nil, nil) + require.NoError(t, err) + + // Decrypt and dump the channel backup file. + dumpBackup := &dumpBackupCommand{ + MultiFile: makeBackup.MultiFile, + rootKey: &rootKey{RootKey: rootKeyAezeed}, + } + + err = dumpBackup.Execute(nil, nil) + require.NoError(t, err) + + h.assertLogContains(backupContent) +} diff --git a/cmd/chantools/compactdb.go b/cmd/chantools/compactdb.go index 2e3c200..dbb763f 100644 --- a/cmd/chantools/compactdb.go +++ b/cmd/chantools/compactdb.go @@ -64,10 +64,14 @@ func (c *compactDBCommand) Execute(_ *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("error opening source DB: %v", err) } + defer func() { _ = src.Close() }() + dst, err := c.openDB(c.DestDB, false) if err != nil { return fmt.Errorf("error opening destination DB: %v", err) } + defer func() { _ = dst.Close() }() + err = c.compact(dst, src) if err != nil { return fmt.Errorf("error compacting DB: %v", err) diff --git a/cmd/chantools/compactdb_test.go b/cmd/chantools/compactdb_test.go new file mode 100644 index 0000000..7d44d71 --- /dev/null +++ b/cmd/chantools/compactdb_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCompactDBAndDumpChannels(t *testing.T) { + h := newHarness(t) + + // Compact the test DB. + compact := &compactDBCommand{ + SourceDB: h.testdataFile("channel.db"), + DestDB: h.tempFile("compacted.db"), + } + + err := compact.Execute(nil, nil) + require.NoError(t, err) + + require.FileExists(t, compact.DestDB) + + // Compacting small DBs actually increases the size slightly. But we + // just want to make sure the contents match. + require.GreaterOrEqual( + t, h.fileSize(compact.DestDB), h.fileSize(compact.SourceDB), + ) + + // Compare the content of the source and destination DB by looking at + // the logged dump. + dump := &dumpChannelsCommand{ + ChannelDB: compact.SourceDB, + } + h.clearLog() + err = dump.Execute(nil, nil) + require.NoError(t, err) + sourceDump := h.getLog() + + h.clearLog() + dump.ChannelDB = compact.DestDB + err = dump.Execute(nil, nil) + require.NoError(t, err) + destDump := h.getLog() + + h.assertLogEqual(sourceDump, destDump) +} diff --git a/cmd/chantools/derivekey.go b/cmd/chantools/derivekey.go index 2e962ad..3c7a837 100644 --- a/cmd/chantools/derivekey.go +++ b/cmd/chantools/derivekey.go @@ -2,15 +2,24 @@ package main import ( "fmt" - "github.com/btcsuite/btcutil" "github.com/btcsuite/btcutil/hdkeychain" "github.com/guggero/chantools/lnd" "github.com/spf13/cobra" ) +const deriveKeyFormat = ` +Path: %s +Network: %s +Public key: %x +Extended public key (xpub): %v +Address: %v +Legacy address: %v +Private key (WIF): %s +Extended private key (xprv): %s +` + type deriveKeyCommand struct { - BIP39 bool Path string Neuter bool @@ -29,11 +38,6 @@ derivation path from the root key and prints it to the console.`, --path "m/1017'/0'/5'/0/0'" --neuter`, RunE: cc.Execute, } - cc.cmd.Flags().BoolVar( - &cc.BIP39, "bip39", false, "read a classic BIP39 seed and "+ - "passphrase from the terminal instead of asking for "+ - "lnd seed format or providing the --rootkey flag", - ) cc.cmd.Flags().StringVar( &cc.Path, "path", "", "BIP32 derivation path to derive; must "+ "start with \"m/\"", @@ -60,7 +64,6 @@ func (c *deriveKeyCommand) Execute(_ *cobra.Command, _ []string) error { func deriveKey(extendedKey *hdkeychain.ExtendedKey, path string, neuter bool) error { - fmt.Printf("Deriving path %s for network %s.\n", path, chainParams.Name) child, pubKey, wif, err := lnd.DeriveKey(extendedKey, path, chainParams) if err != nil { return fmt.Errorf("could not derive keys: %v", err) @@ -69,8 +72,6 @@ func deriveKey(extendedKey *hdkeychain.ExtendedKey, path string, if err != nil { return fmt.Errorf("could not neuter child key: %v", err) } - fmt.Printf("\nPublic key: %x\n", pubKey.SerializeCompressed()) - fmt.Printf("Extended public key (xpub): %s\n", neutered.String()) // Print the address too. hash160 := btcutil.Hash160(pubKey.SerializeCompressed()) @@ -84,13 +85,21 @@ func deriveKey(extendedKey *hdkeychain.ExtendedKey, path string, if err != nil { return fmt.Errorf("could not create address: %v", err) } - fmt.Printf("Address: %s\n", addrP2WKH) - fmt.Printf("Legacy address: %s\n", addrP2PKH) + privKey, xPriv := "n/a", "n/a" if !neuter { - fmt.Printf("\nPrivate key (WIF): %s\n", wif.String()) - fmt.Printf("Extended private key (xprv): %s\n", child.String()) + privKey, xPriv = wif.String(), child.String() } + result := fmt.Sprintf( + deriveKeyFormat, path, chainParams.Name, + pubKey.SerializeCompressed(), neutered, addrP2WKH, addrP2PKH, + privKey, xPriv, + ) + fmt.Printf(result) + + // For the tests, also log as trace level which is disabled by default. + log.Tracef(result) + return nil } diff --git a/cmd/chantools/derivekey_test.go b/cmd/chantools/derivekey_test.go new file mode 100644 index 0000000..3512c33 --- /dev/null +++ b/cmd/chantools/derivekey_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "github.com/guggero/chantools/btc" + "github.com/guggero/chantools/lnd" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + testPath = "m/123'/45'/67'/8/9" + keyContent = "bcrt1qnl5qfvpfcmj7y56nugpermluu46x79sfz0ku70" + keyContentBIP39 = "bcrt1q3pae32m7jdqm5ulf80yc3n59xy4s4xm5a28ekr" +) + +func TestDeriveKey(t *testing.T) { + h := newHarness(t) + + // Derive a specific key from the serialized root key. + derive := &deriveKeyCommand{ + Path: testPath, + rootKey: &rootKey{RootKey: rootKeyAezeed}, + } + + err := derive.Execute(nil, nil) + require.NoError(t, err) + + h.assertLogContains(keyContent) +} + +func TestDeriveKeyAezeedNoPassphrase(t *testing.T) { + h := newHarness(t) + + // Derive a specific key from the serialized root key. + derive := &deriveKeyCommand{ + Path: testPath, + rootKey: &rootKey{}, + } + + err := os.Setenv(lnd.MnemonicEnvName, seedAezeedNoPassphrase) + require.NoError(t, err) + err = os.Setenv(lnd.PassphraseEnvName, "-") + require.NoError(t, err) + + err = derive.Execute(nil, nil) + require.NoError(t, err) + + h.assertLogContains(keyContent) +} + +func TestDeriveKeyAezeedWithPassphrase(t *testing.T) { + h := newHarness(t) + + // Derive a specific key from the serialized root key. + derive := &deriveKeyCommand{ + Path: testPath, + rootKey: &rootKey{}, + } + + err := os.Setenv(lnd.MnemonicEnvName, seedAezeedWithPassphrase) + require.NoError(t, err) + err = os.Setenv(lnd.PassphraseEnvName, testPassPhrase) + require.NoError(t, err) + + err = derive.Execute(nil, nil) + require.NoError(t, err) + + h.assertLogContains(keyContent) +} + +func TestDeriveKeySeedBip39(t *testing.T) { + h := newHarness(t) + + // Derive a specific key from the serialized root key. + derive := &deriveKeyCommand{ + Path: testPath, + rootKey: &rootKey{BIP39: true}, + } + + err := os.Setenv(btc.BIP39MnemonicEnvName, seedBip39) + require.NoError(t, err) + err = os.Setenv(btc.BIP39PassphraseEnvName, "-") + require.NoError(t, err) + + err = derive.Execute(nil, nil) + require.NoError(t, err) + + h.assertLogContains(keyContentBIP39) +} diff --git a/cmd/chantools/doc.go b/cmd/chantools/doc.go index b136908..59b2973 100644 --- a/cmd/chantools/doc.go +++ b/cmd/chantools/doc.go @@ -7,8 +7,8 @@ import ( func newDocCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "doc", - Short: "Generate the markdown documentation of all commands", + Use: "doc", + Short: "Generate the markdown documentation of all commands", Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { return doc.GenMarkdownTree(rootCmd, "./doc") @@ -16,4 +16,4 @@ func newDocCommand() *cobra.Command { } return cmd -} \ No newline at end of file +} diff --git a/cmd/chantools/dumpbackup.go b/cmd/chantools/dumpbackup.go index a7bd147..8da3ce3 100644 --- a/cmd/chantools/dumpbackup.go +++ b/cmd/chantools/dumpbackup.go @@ -64,9 +64,14 @@ func dumpChannelBackup(multiFile *chanbackup.MultiFile, if err != nil { return fmt.Errorf("could not extract multi file: %v", err) } - spew.Dump(dump.BackupMulti{ + content := dump.BackupMulti{ Version: multi.Version, StaticBackups: dump.BackupDump(multi, chainParams), - }) + } + spew.Dump(content) + + // For the tests, also log as trace level which is disabled by default. + log.Tracef(spew.Sdump(content)) + return nil } diff --git a/cmd/chantools/dumpbackup_test.go b/cmd/chantools/dumpbackup_test.go new file mode 100644 index 0000000..fdf9a63 --- /dev/null +++ b/cmd/chantools/dumpbackup_test.go @@ -0,0 +1,4 @@ +package main + +// This file is empty for now, the dumpbackup command is covered by the test in +// chanbackup_test.go. diff --git a/cmd/chantools/dumpchannels.go b/cmd/chantools/dumpchannels.go index 3e8185d..4f6ed67 100644 --- a/cmd/chantools/dumpchannels.go +++ b/cmd/chantools/dumpchannels.go @@ -50,6 +50,7 @@ func (c *dumpChannelsCommand) Execute(_ *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("error opening rescue DB: %v", err) } + defer func() { _ = db.Close() }() if c.Closed { return dumpClosedChannelInfo(db) @@ -69,6 +70,10 @@ func dumpOpenChannelInfo(chanDb *channeldb.DB) error { } spew.Dump(dumpChannels) + + // For the tests, also log as trace level which is disabled by default. + log.Tracef(spew.Sdump(dumpChannels)) + return nil } @@ -84,5 +89,9 @@ func dumpClosedChannelInfo(chanDb *channeldb.DB) error { } spew.Dump(dumpChannels) + + // For the tests, also log as trace level which is disabled by default. + log.Tracef(spew.Sdump(dumpChannels)) + return nil } diff --git a/cmd/chantools/dumpchannels_test.go b/cmd/chantools/dumpchannels_test.go new file mode 100644 index 0000000..b8fbad5 --- /dev/null +++ b/cmd/chantools/dumpchannels_test.go @@ -0,0 +1,4 @@ +package main + +// This file is empty for now, the dumpchannels command is covered by the test +// in compactdb_test.go. diff --git a/cmd/chantools/genimportscript.go b/cmd/chantools/genimportscript.go index ccbb3a6..f9d297a 100644 --- a/cmd/chantools/genimportscript.go +++ b/cmd/chantools/genimportscript.go @@ -96,7 +96,7 @@ func (c *genImportScriptCommand) Execute(_ *cobra.Command, _ []string) error { return fmt.Errorf("error reading root key: %v", err) } - // The btcwallet gives the birthday a slack of 48 hours, let's do the + // The btcwallet gives the birthday a slack of 48 hours, let's do the // same. if !birthday.IsZero() { c.RescanFrom = btc.SeedBirthdayToBlock( diff --git a/cmd/chantools/removechannel.go b/cmd/chantools/removechannel.go index dae7b86..db8fbff 100644 --- a/cmd/chantools/removechannel.go +++ b/cmd/chantools/removechannel.go @@ -26,7 +26,7 @@ func newRemoveChannelCommand() *cobra.Command { Short: "Remove a single channel from the given channel DB", Example: `chantools --channeldb ~/.lnd/data/graph/mainnet/channel.db \ --channel 3149764effbe82718b280de425277e5e7b245a4573aa4a0203ac12cee1c37816:0`, - RunE: cc.Execute, + RunE: cc.Execute, } cc.cmd.Flags().StringVar( &cc.ChannelDB, "channeldb", "", "lnd channel.backup file to "+ diff --git a/cmd/chantools/root.go b/cmd/chantools/root.go index 00b7a3e..cf9b853 100644 --- a/cmd/chantools/root.go +++ b/cmd/chantools/root.go @@ -5,7 +5,7 @@ import ( "bytes" "encoding/json" "fmt" - "github.com/btcsuite/btcutil/hdkeychain" + "github.com/guggero/chantools/btc" "io/ioutil" "os" "strings" @@ -14,6 +14,7 @@ import ( "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btclog" + "github.com/btcsuite/btcutil/hdkeychain" "github.com/guggero/chantools/dataformat" "github.com/guggero/chantools/lnd" "github.com/lightningnetwork/lnd/build" @@ -46,7 +47,7 @@ var rootCmd = &cobra.Command{ funds locked in lnd channels in case lnd itself cannot run properly anymore. Complete documentation is available at https://github.com/guggero/chantools/.`, Version: fmt.Sprintf("v%s, commit %s", version, Commit), - PreRun: func(cmd *cobra.Command, args []string) { + PersistentPreRun: func(cmd *cobra.Command, args []string) { switch { case Testnet: chainParams = &chaincfg.TestNet3Params @@ -109,6 +110,7 @@ func main() { type rootKey struct { RootKey string + BIP39 bool } func newRootKey(cmd *cobra.Command, desc string) *rootKey { @@ -118,6 +120,11 @@ func newRootKey(cmd *cobra.Command, desc string) *rootKey { "to use for "+desc+"; leave empty to prompt for "+ "lnd 24 word aezeed", ) + cmd.Flags().BoolVar( + &r.BIP39, "bip39", false, "read a classic BIP39 seed and "+ + "passphrase from the terminal instead of asking for "+ + "lnd seed format or providing the --rootkey flag", + ) return r } @@ -136,6 +143,10 @@ func (r *rootKey) readWithBirthday() (*hdkeychain.ExtendedKey, time.Time, extendedKey, err := hdkeychain.NewKeyFromString(r.RootKey) return extendedKey, time.Unix(0, 0), err + case r.BIP39: + extendedKey, err := btc.ReadMnemonicFromTerminal(chainParams) + return extendedKey, time.Unix(0, 0), err + default: return lnd.ReadAezeed(chainParams) } @@ -249,7 +260,7 @@ func setupLogging() { if err != nil { panic(err) } - err = build.ParseAndSetDebugLevels("trace", logWriter) + err = build.ParseAndSetDebugLevels("debug", logWriter) if err != nil { panic(err) } diff --git a/cmd/chantools/root_test.go b/cmd/chantools/root_test.go new file mode 100644 index 0000000..afb8ba6 --- /dev/null +++ b/cmd/chantools/root_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "path" + "regexp" + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btclog" + "github.com/lightningnetwork/lnd/chanbackup" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/stretchr/testify/require" +) + +const ( + seedAezeedNoPassphrase = "abandon kangaroo tribe spell brass entry " + + "argue buzz muffin total rug title autumn wish use bubble " + + "alarm rent machine hockey fork slam gaze tobacco" + seedAezeedWithPassphrase = "able pause keen exhibit duck olympic " + + "foot donor hire omit earth ribbon rotate cruise door orbit " + + "nephew mixture machine hockey fork scorpion shell door" + testPassPhrase = "testnet3" + seedBip39 = "uncover bargain diesel boss local host over divide " + + "orient cradle good crumble" + + rootKeyAezeed = "tprv8ZgxMBicQKsPejNXQLJKe3dBBs9Zrt53EZrsBzVLQ8rZji3" + + "hVb3wcoRvgrjvTmjPG2ixoGUUkCyC6yBEy9T5gbLdvD2a5VmJbcFd5Q9pkAs" + rootKeyBip39 = "tprv8ZgxMBicQKsPdoVEZRN2MyzEgxGTqJepzhMc66b26zL1siLi" + + "WRQAGh9rAgPPJuQeHWWpgcDcS45yi6KBTFeGkQMEb2RNTrP11evJcB4UVSh" + rootKeyBip39Passphrase = "" +) + +var ( + datePattern = regexp.MustCompile( + "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3} ", + ) + addressPattern = regexp.MustCompile("\\(0x[0-9a-f]{10}\\)") +) + +type harness struct { + t *testing.T + logBuffer *bytes.Buffer + logger btclog.Logger + tempDir string +} + +func newHarness(t *testing.T) *harness { + buf := &bytes.Buffer{} + logBackend := btclog.NewBackend(buf) + tempDir, err := ioutil.TempDir("", "chantools") + require.NoError(t, err) + + h := &harness{ + t: t, + logBuffer: buf, + logger: logBackend.Logger("CHAN"), + tempDir: tempDir, + } + + h.logger.SetLevel(btclog.LevelTrace) + log = h.logger + channeldb.UseLogger(h.logger) + chanbackup.UseLogger(h.logger) + + os.Clearenv() + chainParams = &chaincfg.RegressionNetParams + + return h +} + +func (h *harness) getLog() string { + return h.logBuffer.String() +} + +func (h *harness) clearLog() { + h.logBuffer.Reset() +} + +func (h *harness) assertLogContains(format string, args ...interface{}) { + h.t.Helper() + + require.Contains(h.t, h.logBuffer.String(), fmt.Sprintf(format, args...)) +} + +func (h *harness) assertLogEqual(a, b string) { + // Remove all timestamps and all memory addresses from dumps as those + // are always different. + a = datePattern.ReplaceAllString(a, "") + a = addressPattern.ReplaceAllString(a, "") + + b = datePattern.ReplaceAllString(b, "") + b = addressPattern.ReplaceAllString(b, "") + + require.Equal(h.t, a, b) +} + +func (h *harness) testdataFile(name string) string { + workingDir, err := os.Getwd() + require.NoError(h.t, err) + + return path.Join(workingDir, "testdata", name) +} + +func (h *harness) tempFile(name string) string { + return path.Join(h.tempDir, name) +} + +func (h *harness) fileSize(name string) int64 { + stat, err := os.Stat(name) + require.NoError(h.t, err) + + return stat.Size() +} diff --git a/cmd/chantools/showrootkey.go b/cmd/chantools/showrootkey.go index 6a553ec..d2894ec 100644 --- a/cmd/chantools/showrootkey.go +++ b/cmd/chantools/showrootkey.go @@ -3,14 +3,14 @@ package main import ( "fmt" - "github.com/btcsuite/btcutil/hdkeychain" - "github.com/guggero/chantools/btc" "github.com/spf13/cobra" ) -type showRootKeyCommand struct { - BIP39 bool +const showRootKeyFormat = ` +Your BIP32 HD root key is: %v +` +type showRootKeyCommand struct { rootKey *rootKey cmd *cobra.Command } @@ -27,11 +27,6 @@ commands of this tool.`, Example: `chantools showrootkey`, RunE: cc.Execute, } - cc.cmd.Flags().BoolVar( - &cc.BIP39, "bip39", false, "read a classic BIP39 seed and "+ - "passphrase from the terminal instead of asking for "+ - "lnd seed format or providing the --rootkey flag", - ) cc.rootKey = newRootKey(cc.cmd, "decrypting the backup") @@ -39,23 +34,16 @@ commands of this tool.`, } func (c *showRootKeyCommand) Execute(_ *cobra.Command, _ []string) error { - var ( - extendedKey *hdkeychain.ExtendedKey - err error - ) - - // Check that root key is valid or fall back to terminal input. - switch { - case c.BIP39: - extendedKey, err = btc.ReadMnemonicFromTerminal(chainParams) - - default: - extendedKey, err = c.rootKey.read() - } + extendedKey, err := c.rootKey.read() if err != nil { return fmt.Errorf("error reading root key: %v", err) } - fmt.Printf("\nYour BIP32 HD root key is: %s\n", extendedKey.String()) + result := fmt.Sprintf(showRootKeyFormat, extendedKey) + fmt.Printf(result) + + // For the tests, also log as trace level which is disabled by default. + log.Tracef(result) + return nil } diff --git a/cmd/chantools/showrootkey_test.go b/cmd/chantools/showrootkey_test.go new file mode 100644 index 0000000..fb7b892 --- /dev/null +++ b/cmd/chantools/showrootkey_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "github.com/guggero/chantools/btc" + "os" + "testing" + + "github.com/guggero/chantools/lnd" + "github.com/stretchr/testify/require" +) + +func TestShowRootKey(t *testing.T) { + h := newHarness(t) + + // Derive the root key from the aezeed. + show := &showRootKeyCommand{ + rootKey: &rootKey{}, + } + + err := os.Setenv(lnd.MnemonicEnvName, seedAezeedNoPassphrase) + require.NoError(t, err) + err = os.Setenv(lnd.PassphraseEnvName, "-") + require.NoError(t, err) + + err = show.Execute(nil, nil) + require.NoError(t, err) + + h.assertLogContains(rootKeyAezeed) +} + +func TestShowRootKeyBIP39(t *testing.T) { + h := newHarness(t) + + // Derive the root key from the BIP39 seed. + show := &showRootKeyCommand{ + rootKey: &rootKey{BIP39: true}, + } + + err := os.Setenv(btc.BIP39MnemonicEnvName, seedBip39) + require.NoError(t, err) + err = os.Setenv(btc.BIP39PassphraseEnvName, "-") + require.NoError(t, err) + + err = show.Execute(nil, nil) + require.NoError(t, err) + + h.assertLogContains(rootKeyBip39) +} + +func TestShowRootKeyBIP39WithPassphre(t *testing.T) { + h := newHarness(t) + + // Derive the root key from the BIP39 seed. + show := &showRootKeyCommand{ + rootKey: &rootKey{BIP39: true}, + } + + err := os.Setenv(btc.BIP39MnemonicEnvName, seedBip39) + require.NoError(t, err) + err = os.Setenv(btc.BIP39PassphraseEnvName, testPassPhrase) + require.NoError(t, err) + + err = show.Execute(nil, nil) + require.NoError(t, err) + + h.assertLogContains(rootKeyBip39Passphrase) +} \ No newline at end of file diff --git a/cmd/chantools/testdata/channel.db b/cmd/chantools/testdata/channel.db new file mode 100644 index 0000000..54d39f5 Binary files /dev/null and b/cmd/chantools/testdata/channel.db differ diff --git a/cmd/chantools/testdata/wallet.db b/cmd/chantools/testdata/wallet.db new file mode 100644 index 0000000..bc600b9 Binary files /dev/null and b/cmd/chantools/testdata/wallet.db differ diff --git a/cmd/chantools/vanitygen.go b/cmd/chantools/vanitygen.go index 21c3aba..68c46f3 100644 --- a/cmd/chantools/vanitygen.go +++ b/cmd/chantools/vanitygen.go @@ -51,7 +51,7 @@ phone] `, Example: `chantools vanitygen --prefix 022222 --threads 8`, - RunE: cc.Execute, + RunE: cc.Execute, } cc.cmd.Flags().StringVar( &cc.Prefix, "prefix", "", "hex encoded prefix to find in node "+ diff --git a/cmd/chantools/walletinfo.go b/cmd/chantools/walletinfo.go index 8e8c47f..dbcdd29 100644 --- a/cmd/chantools/walletinfo.go +++ b/cmd/chantools/walletinfo.go @@ -1,11 +1,8 @@ package main import ( - "encoding/hex" "fmt" "os" - "os/user" - "path/filepath" "strings" "github.com/btcsuite/btcd/btcec" @@ -15,6 +12,7 @@ import ( "github.com/btcsuite/btcwallet/walletdb" "github.com/guggero/chantools/lnd" "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lncfg" "github.com/lightningnetwork/lnd/lnwallet" "github.com/spf13/cobra" @@ -27,6 +25,19 @@ import ( const ( passwordEnvName = "WALLET_PASSWORD" + + walletInfoFormat = ` +Identity Pubkey: %x +BIP32 HD extended root key: %s +Wallet scopes: +%s +` + + keyScopeformat = ` +Scope: m/%d'/%d' + Number of internal %s addresses: %d + Number of external %s addresses: %d +` ) var ( @@ -126,13 +137,14 @@ func (c *walletInfoCommand) Execute(_ *cobra.Command, _ []string) error { // Try to load and open the wallet. db, err := walletdb.Open( - "bdb", cleanAndExpandPath(c.WalletDB), false, + "bdb", lncfg.CleanAndExpandPath(c.WalletDB), false, lnd.DefaultOpenTimeout, ) if err != nil { return fmt.Errorf("error opening wallet database: %v", err) } - defer closeWalletDb(db) + defer func() { _ = db.Close() }() + w, err := wallet.Open(db, publicWalletPw, openCallbacks, chainParams, 0) if err != nil { return err @@ -147,21 +159,33 @@ func (c *walletInfoCommand) Execute(_ *cobra.Command, _ []string) error { } // Print the wallet info and if requested the root key. - err = walletInfo(w) + identityKey, scopeInfo, err := walletInfo(w) if err != nil { return err } + rootKey := "n/a" if c.WithRootKey { masterHDPrivKey, err := decryptRootKey(db, privateWalletPw) if err != nil { return err } - fmt.Printf("BIP32 HD extended root key: %s\n", masterHDPrivKey) + rootKey = string(masterHDPrivKey) } + + result := fmt.Sprintf( + walletInfoFormat, identityKey.SerializeCompressed(), rootKey, + scopeInfo, + ) + + fmt.Printf(result) + + // For the tests, also log as trace level which is disabled by default. + log.Tracef(result) + return nil } -func walletInfo(w *wallet.Wallet) error { +func walletInfo(w *wallet.Wallet) (*btcec.PublicKey, string, error) { keyRing := keychain.NewBtcWalletKeyRing(w, chainParams.HDCoinType) idPrivKey, err := keyRing.DerivePrivKey(keychain.KeyDescriptor{ KeyLocator: keychain.KeyLocator{ @@ -170,47 +194,48 @@ func walletInfo(w *wallet.Wallet) error { }, }) if err != nil { - return fmt.Errorf("unable to open key ring for coin type %d: "+ - "%v", chainParams.HDCoinType, err) + return nil, "", fmt.Errorf("unable to open key ring for coin "+ + "type %d: %v", chainParams.HDCoinType, err) } - idPrivKey.Curve = btcec.S256() - fmt.Printf( - "Identity Pubkey: %s\n", - hex.EncodeToString(idPrivKey.PubKey().SerializeCompressed()), - ) - // Print information about the different addresses in use. - printScopeInfo( - "np2wkh", w, - w.Manager.ScopesForExternalAddrType( + // Collect information about the different addresses in use. + scopeNp2wkh, err := printScopeInfo( + "np2wkh", w, w.Manager.ScopesForExternalAddrType( waddrmgr.NestedWitnessPubKey, ), ) - printScopeInfo( - "p2wkh", w, - w.Manager.ScopesForExternalAddrType( + if err != nil { + return nil, "", err + } + scopeP2wkh, err := printScopeInfo( + "p2wkh", w, w.Manager.ScopesForExternalAddrType( waddrmgr.WitnessPubKey, ), ) - return nil + if err != nil { + return nil, "", err + } + + return idPrivKey.PubKey(), scopeNp2wkh + scopeP2wkh, nil } -func printScopeInfo(name string, w *wallet.Wallet, scopes []waddrmgr.KeyScope) { +func printScopeInfo(name string, w *wallet.Wallet, + scopes []waddrmgr.KeyScope) (string, error) { + + scopeInfo := "" for _, scope := range scopes { props, err := w.AccountProperties(scope, defaultAccount) if err != nil { - fmt.Printf("Error fetching account properties: %v", err) + return "", fmt.Errorf("error fetching account "+ + "properties: %v", err) } - fmt.Printf("Scope: %s\n", scope.String()) - fmt.Printf( - " Number of internal (change) %s addresses: %d\n", - name, props.InternalKeyCount, - ) - fmt.Printf( - " Number of external %s addresses: %d\n", name, - props.ExternalKeyCount, + scopeInfo += fmt.Sprintf( + keyScopeformat, scope.Purpose, scope.Coin, name, + props.InternalKeyCount, name, props.ExternalKeyCount, ) } + + return scopeInfo, nil } func decryptRootKey(db walletdb.DB, privPassphrase []byte) ([]byte, error) { @@ -222,18 +247,14 @@ func decryptRootKey(db walletdb.DB, privPassphrase []byte) ([]byte, error) { err := walletdb.View(db, func(tx walletdb.ReadTx) error { ns := tx.ReadBucket(waddrmgrNamespaceKey) if ns == nil { - return fmt.Errorf( - "namespace '%s' does not exist", - waddrmgrNamespaceKey, - ) + return fmt.Errorf("namespace '%s' does not exist", + waddrmgrNamespaceKey) } mainBucket := ns.NestedReadBucket(mainBucketName) if mainBucket == nil { - return fmt.Errorf( - "bucket '%s' does not exist", - mainBucketName, - ) + return fmt.Errorf("bucket '%s' does not exist", + mainBucketName) } val := mainBucket.Get(masterPrivKeyName) @@ -251,6 +272,7 @@ func decryptRootKey(db walletdb.DB, privPassphrase []byte) ([]byte, error) { masterHDPrivEnc = make([]byte, len(val)) copy(masterHDPrivEnc, val) } + return nil }) if err != nil { @@ -276,36 +298,3 @@ func decryptRootKey(db walletdb.DB, privPassphrase []byte) ([]byte, error) { copy(cryptoKeyPriv[:], cryptoKeyPrivBytes) return cryptoKeyPriv.Decrypt(masterHDPrivEnc) } - -func closeWalletDb(db walletdb.DB) { - err := db.Close() - if err != nil { - fmt.Printf("Error closing database: %v", err) - } -} - -// cleanAndExpandPath expands environment variables and leading ~ in the -// passed path, cleans the result, and returns it. -// This function is taken from https://github.com/btcsuite/btcd -func cleanAndExpandPath(path string) string { - if path == "" { - return "" - } - - // Expand initial ~ to OS specific home directory. - if strings.HasPrefix(path, "~") { - var homeDir string - u, err := user.Current() - if err == nil { - homeDir = u.HomeDir - } else { - homeDir = os.Getenv("HOME") - } - - path = strings.Replace(path, "~", homeDir, 1) - } - - // NOTE: The os.ExpandEnv doesn't work with Windows-style %VARIABLE%, - // but the variables can still be expanded via POSIX-style $VARIABLE. - return filepath.Clean(os.ExpandEnv(path)) -} diff --git a/cmd/chantools/walletinfo_test.go b/cmd/chantools/walletinfo_test.go new file mode 100644 index 0000000..0800abd --- /dev/null +++ b/cmd/chantools/walletinfo_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + walletContent = "03b99ab108e39e9e4cf565c1b706480180a70a4fdc4828e44c50" + + "4530c056be5b5f" +) + +func TestWalletInfo(t *testing.T) { + h := newHarness(t) + + // Dump the wallet information. + info := &walletInfoCommand{ + WalletDB: h.testdataFile("wallet.db"), + WithRootKey: true, + } + + err := os.Setenv(passwordEnvName, testPassPhrase) + require.NoError(t, err) + + err = info.Execute(nil, nil) + require.NoError(t, err) + + h.assertLogContains(walletContent) + h.assertLogContains(rootKeyAezeed) +} diff --git a/go.mod b/go.mod index 9918da0..3faea38 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/ltcsuite/ltcd v0.0.0-20191228044241-92166e412499 // indirect github.com/miekg/dns v1.1.26 // indirect github.com/spf13/cobra v1.1.1 + github.com/stretchr/testify v1.6.1 go.etcd.io/bbolt v1.3.5-0.20200615073812-232d8fc87f50 golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 ) diff --git a/go.sum b/go.sum index c569685..e8954e5 100644 --- a/go.sum +++ b/go.sum @@ -568,6 +568,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= @@ -858,6 +859,7 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/lnd/aezeed.go b/lnd/aezeed.go index d0f1f74..8413566 100644 --- a/lnd/aezeed.go +++ b/lnd/aezeed.go @@ -16,8 +16,8 @@ import ( ) const ( - memonicEnvName = "AEZEED_MNEMONIC" - passphraseEnvName = "AEZEED_PASSPHRASE" + MnemonicEnvName = "AEZEED_MNEMONIC" + PassphraseEnvName = "AEZEED_PASSPHRASE" ) var ( @@ -30,7 +30,7 @@ func ReadAezeed(params *chaincfg.Params) (*hdkeychain.ExtendedKey, time.Time, // To automate things with chantools, we also offer reading the seed // from environment variables. - mnemonicStr := strings.TrimSpace(os.Getenv(memonicEnvName)) + mnemonicStr := strings.TrimSpace(os.Getenv(MnemonicEnvName)) // If nothing is set in the environment, read the seed from the // terminal. @@ -70,7 +70,7 @@ func ReadAezeed(params *chaincfg.Params) (*hdkeychain.ExtendedKey, time.Time, // Additionally, the user may have a passphrase, that will also need to // be provided so the daemon can properly decipher the cipher seed. // Try the environment variable first. - passphrase := strings.TrimSpace(os.Getenv(passphraseEnvName)) + passphrase := strings.TrimSpace(os.Getenv(PassphraseEnvName)) // Because we cannot differentiate between an empty and a non-existent // environment variable, we need a special character that indicates that