diff --git a/lnd.go b/lnd.go index 2a211bee..cfec4683 100644 --- a/lnd.go +++ b/lnd.go @@ -879,16 +879,25 @@ func waitForWalletPassword(grpcEndpoints, restEndpoints []string, serverOpts []grpc.ServerOption, proxyOpts []grpc.DialOption, tlsConf *tls.Config) (*WalletUnlockParams, error) { - // Set up a new PasswordService, which will listen - // for passwords provided over RPC. + // Set up a new PasswordService, which will listen for passwords + // provided over RPC. grpcServer := grpc.NewServer(serverOpts...) chainConfig := cfg.Bitcoin if registeredChains.PrimaryChain() == litecoinChain { chainConfig = cfg.Litecoin } + + // The macaroon files are passed to the wallet unlocker since they are + // also encrypted with the wallet's password. These files will be + // deleted within it and recreated when successfully changing the + // wallet's password. + macaroonFiles := []string{ + filepath.Join(macaroonDatabaseDir, macaroons.DBFilename), + cfg.AdminMacPath, cfg.ReadMacPath, cfg.InvoiceMacPath, + } pwService := walletunlocker.New( - chainConfig.ChainDir, activeNetParams.Params, + chainConfig.ChainDir, activeNetParams.Params, macaroonFiles, ) lnrpc.RegisterWalletUnlockerServer(grpcServer, pwService) @@ -951,9 +960,10 @@ func waitForWalletPassword(grpcEndpoints, restEndpoints []string, wg.Wait() // Wait for user to provide the password. - ltndLog.Infof("Waiting for wallet encryption password. " + - "Use `lncli create` to create wallet, or " + - "`lncli unlock` to unlock already created wallet.") + ltndLog.Infof("Waiting for wallet encryption password. Use `lncli " + + "create` to create a wallet, `lncli unlock` to unlock an " + + "existing wallet, or `lncli changepassword` to change the " + + "password of an existing wallet and unlock it.") // We currently don't distinguish between getting a password to be used // for creation or unlocking, as a new wallet db will be created if diff --git a/walletunlocker/service.go b/walletunlocker/service.go index fa5750c3..d0966a7a 100644 --- a/walletunlocker/service.go +++ b/walletunlocker/service.go @@ -2,11 +2,14 @@ package walletunlocker import ( "crypto/rand" + "errors" "fmt" + "os" "time" "github.com/lightningnetwork/lnd/aezeed" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/btcwallet" "github.com/roasbeef/btcd/chaincfg" "github.com/roasbeef/btcwallet/wallet" @@ -64,17 +67,21 @@ type UnlockerService struct { // sent. UnlockMsgs chan *WalletUnlockMsg - chainDir string - netParams *chaincfg.Params + chainDir string + netParams *chaincfg.Params + macaroonFiles []string } // New creates and returns a new UnlockerService. -func New(chainDir string, params *chaincfg.Params) *UnlockerService { +func New(chainDir string, params *chaincfg.Params, + macaroonFiles []string) *UnlockerService { + return &UnlockerService{ - InitMsgs: make(chan *WalletInitMsg, 1), - UnlockMsgs: make(chan *WalletUnlockMsg, 1), - chainDir: chainDir, - netParams: params, + InitMsgs: make(chan *WalletInitMsg, 1), + UnlockMsgs: make(chan *WalletUnlockMsg, 1), + chainDir: chainDir, + netParams: params, + macaroonFiles: macaroonFiles, } } @@ -168,12 +175,10 @@ func (u *UnlockerService) GenSeed(ctx context.Context, func (u *UnlockerService) InitWallet(ctx context.Context, in *lnrpc.InitWalletRequest) (*lnrpc.InitWalletResponse, error) { - // Require the provided password to have a length of at least 8 - // characters. + // Make sure the password meets our constraints. password := in.WalletPassword - if len(password) < 8 { - return nil, fmt.Errorf("password must have " + - "at least 8 characters") + if err := validatePassword(password); err != nil { + return nil, err } // Require that the recovery window be non-negative. @@ -276,3 +281,85 @@ func (u *UnlockerService) UnlockWallet(ctx context.Context, return &lnrpc.UnlockWalletResponse{}, nil } + +// ChangePassword changes the password of the wallet and sends the new password +// across the UnlockPasswords channel to automatically unlock the wallet if +// successful. +func (u *UnlockerService) ChangePassword(ctx context.Context, + in *lnrpc.ChangePasswordRequest) (*lnrpc.ChangePasswordResponse, error) { + + netDir := btcwallet.NetworkDir(u.chainDir, u.netParams) + loader := wallet.NewLoader(u.netParams, netDir, 0) + + // First, we'll make sure the wallet exists for the specific chain and + // network. + walletExists, err := loader.WalletExists() + if err != nil { + return nil, err + } + + if !walletExists { + return nil, errors.New("wallet not found") + } + + publicPw := in.CurrentPassword + privatePw := in.CurrentPassword + + // If the current password is blank, we'll assume the user is coming + // from a --noencryptwallet state, so we'll use the default passwords. + if len(in.CurrentPassword) == 0 { + publicPw = lnwallet.DefaultPublicPassphrase + privatePw = lnwallet.DefaultPrivatePassphrase + } + + // Make sure the new password meets our constraints. + if err := validatePassword(in.NewPassword); err != nil { + return nil, err + } + + // Load the existing wallet in order to proceed with the password change. + w, err := loader.OpenExistingWallet(publicPw, false) + if err != nil { + return nil, err + } + // Unload the wallet to allow lnd to open it later on. + defer loader.UnloadWallet() + + // Since the macaroon database is also encrypted with the wallet's + // password, we'll remove all of the macaroon files so that they're + // re-generated at startup using the new password. We'll make sure to do + // this after unlocking the wallet to ensure macaroon files don't get + // deleted with incorrect password attempts. + for _, file := range u.macaroonFiles { + if err := os.Remove(file); err != nil { + return nil, err + } + } + + // Attempt to change both the public and private passphrases for the + // wallet. This will be done atomically in order to prevent one + // passphrase change from being successful and not the other. + err = w.ChangePassphrases( + publicPw, in.NewPassword, privatePw, in.NewPassword, + ) + if err != nil { + return nil, fmt.Errorf("unable to change wallet passphrase: "+ + "%v", err) + } + + // Finally, send the new password across the UnlockPasswords channel to + // automatically unlock the wallet. + u.UnlockMsgs <- &WalletUnlockMsg{Passphrase: in.NewPassword} + + return &lnrpc.ChangePasswordResponse{}, nil +} + +// validatePassword assures the password meets all of our constraints. +func validatePassword(password []byte) error { + // Passwords should have a length of at least 8 characters. + if len(password) < 8 { + return errors.New("password must have at least 8 characters") + } + + return nil +} diff --git a/walletunlocker/service_test.go b/walletunlocker/service_test.go index b8ccf04e..c5ab819a 100644 --- a/walletunlocker/service_test.go +++ b/walletunlocker/service_test.go @@ -64,10 +64,9 @@ func TestGenSeed(t *testing.T) { if err != nil { t.Fatalf("unable to create temp directory: %v", err) } - defer func() { - os.RemoveAll(testDir) - }() - service := walletunlocker.New(testDir, testNetParams) + defer os.RemoveAll(testDir) + + service := walletunlocker.New(testDir, testNetParams, nil) // Now that the service has been created, we'll ask it to generate a // new seed for us given a test passphrase. @@ -108,7 +107,7 @@ func TestGenSeedGenerateEntropy(t *testing.T) { defer func() { os.RemoveAll(testDir) }() - service := walletunlocker.New(testDir, testNetParams) + service := walletunlocker.New(testDir, testNetParams, nil) // Now that the service has been created, we'll ask it to generate a // new seed for us given a test passphrase. Note that we don't actually @@ -148,7 +147,7 @@ func TestGenSeedInvalidEntropy(t *testing.T) { defer func() { os.RemoveAll(testDir) }() - service := walletunlocker.New(testDir, testNetParams) + service := walletunlocker.New(testDir, testNetParams, nil) // Now that the service has been created, we'll ask it to generate a // new seed for us given a test passphrase. However, we'll be using an @@ -186,7 +185,7 @@ func TestInitWallet(t *testing.T) { }() // Create new UnlockerService. - service := walletunlocker.New(testDir, testNetParams) + service := walletunlocker.New(testDir, testNetParams, nil) // Once we have the unlocker service created, we'll now instantiate a // new cipher seed instance. @@ -287,7 +286,7 @@ func TestCreateWalletInvalidEntropy(t *testing.T) { }() // Create new UnlockerService. - service := walletunlocker.New(testDir, testNetParams) + service := walletunlocker.New(testDir, testNetParams, nil) // We'll attempt to init the wallet with an invalid cipher seed and // passphrase. @@ -320,7 +319,7 @@ func TestUnlockWallet(t *testing.T) { }() // Create new UnlockerService. - service := walletunlocker.New(testDir, testNetParams) + service := walletunlocker.New(testDir, testNetParams, nil) ctx := context.Background() req := &lnrpc.UnlockWalletRequest{ @@ -368,3 +367,101 @@ func TestUnlockWallet(t *testing.T) { t.Fatalf("password not received") } } + +// TestChangeWalletPassword tests that we can successfully change the wallet's +// password needed to unlock it. +func TestChangeWalletPassword(t *testing.T) { + t.Parallel() + + // testDir is empty, meaning wallet was not created from before. + testDir, err := ioutil.TempDir("", "testchangepassword") + if err != nil { + t.Fatalf("unable to create temp directory: %v", err) + } + defer os.RemoveAll(testDir) + + // Create some files that will act as macaroon files that should be + // deleted after a password change is successful. + var tempFiles []string + for i := 0; i < 3; i++ { + file, err := ioutil.TempFile(testDir, "") + if err != nil { + t.Fatalf("unable to create temp file: %v", err) + } + tempFiles = append(tempFiles, file.Name()) + file.Close() + } + + // Create a new UnlockerService with our temp files. + service := walletunlocker.New(testDir, testNetParams, tempFiles) + + ctx := context.Background() + newPassword := []byte("hunter2???") + + req := &lnrpc.ChangePasswordRequest{ + CurrentPassword: testPassword, + NewPassword: newPassword, + } + + // Changing the password to a non-existing wallet should fail. + _, err = service.ChangePassword(ctx, req) + if err == nil { + t.Fatal("expected call to ChangePassword to fail") + } + + // Create a wallet to test changing the password. + createTestWallet(t, testDir, testNetParams) + + // Attempting to change the wallet's password using an incorrect + // current password should fail. + wrongReq := &lnrpc.ChangePasswordRequest{ + CurrentPassword: []byte("wrong-ofc"), + NewPassword: newPassword, + } + _, err = service.ChangePassword(ctx, wrongReq) + if err == nil { + t.Fatal("expected call to ChangePassword to fail") + } + + // The files should still exist after an unsuccessful attempt to change + // the wallet's password. + for _, tempFile := range tempFiles { + if _, err := os.Stat(tempFile); os.IsNotExist(err) { + t.Fatal("file does not exist but it should") + } + } + + // Attempting to change the wallet's password using an invalid + // new password should fail. + wrongReq.NewPassword = []byte("8") + _, err = service.ChangePassword(ctx, wrongReq) + if err == nil { + t.Fatal("expected call to ChangePassword to fail") + } + + // When providing the correct wallet's current password and a new + // password that meets the length requirement, the password change + // should succeed. + _, err = service.ChangePassword(ctx, req) + if err != nil { + t.Fatalf("unable to change wallet's password: %v", err) + } + + // The files should no longer exist. + for _, tempFile := range tempFiles { + if _, err := os.Open(tempFile); err == nil { + t.Fatal("file exists but it shouldn't") + } + } + + // The new password should be sent over the channel. + select { + case unlockMsg := <-service.UnlockMsgs: + if !bytes.Equal(unlockMsg.Passphrase, newPassword) { + t.Fatalf("expected to receive password %x, got %x", + testPassword, unlockMsg.Passphrase) + } + case <-time.After(3 * time.Second): + t.Fatalf("password not received") + } +}