diff --git a/lnd.go b/lnd.go index 238ad535..464d71de 100644 --- a/lnd.go +++ b/lnd.go @@ -410,7 +410,8 @@ func Main(cfg *Config, lisCfg ListenerCfg, shutdownChan <-chan struct{}) error { if !cfg.NoMacaroons { // Create the macaroon authentication/authorization service. macaroonService, err = macaroons.NewService( - cfg.networkDir, "lnd", false, macaroons.IPLockChecker, + cfg.networkDir, "lnd", walletInitParams.StatelessInit, + macaroons.IPLockChecker, ) if err != nil { err := fmt.Errorf("unable to set up macaroon "+ @@ -421,17 +422,42 @@ func Main(cfg *Config, lisCfg ListenerCfg, shutdownChan <-chan struct{}) error { defer macaroonService.Close() // Try to unlock the macaroon store with the private password. + // Ignore ErrAlreadyUnlocked since it could be unlocked by the + // wallet unlocker. err = macaroonService.CreateUnlock(&privateWalletPw) - if err != nil { + if err != nil && err != macaroons.ErrAlreadyUnlocked { err := fmt.Errorf("unable to unlock macaroons: %v", err) ltndLog.Error(err) return err } - // Create macaroon files for lncli to use if they don't exist. - if !fileExists(cfg.AdminMacPath) && !fileExists(cfg.ReadMacPath) && + // In case we actually needed to unlock the wallet, we now need + // to create an instance of the admin macaroon and send it to + // the unlocker so it can forward it to the user. In no seed + // backup mode, there's nobody listening on the channel and we'd + // block here forever. + if !cfg.NoSeedBackup { + adminMacBytes, err := bakeMacaroon( + ctx, macaroonService, adminPermissions(), + ) + if err != nil { + return err + } + + // The channel is buffered by one element so writing + // should not block here. + walletInitParams.MacResponseChan <- adminMacBytes + } + + // If the user requested a stateless initialization, no macaroon + // files should be created. + if !walletInitParams.StatelessInit && + !fileExists(cfg.AdminMacPath) && + !fileExists(cfg.ReadMacPath) && !fileExists(cfg.InvoiceMacPath) { + // Create macaroon files for lncli to use if they don't + // exist. err = genMacaroons( ctx, macaroonService, cfg.AdminMacPath, cfg.ReadMacPath, cfg.InvoiceMacPath, @@ -443,6 +469,28 @@ func Main(cfg *Config, lisCfg ListenerCfg, shutdownChan <-chan struct{}) error { return err } } + + // As a security service to the user, if they requested + // stateless initialization and there are macaroon files on disk + // we log a warning. + if walletInitParams.StatelessInit { + msg := "Found %s macaroon on disk (%s) even though " + + "--stateless_init was requested. Unencrypted " + + "state is accessible by the host system. You " + + "should change the password and use " + + "--new_mac_root_key with --stateless_init to " + + "clean up and invalidate old macaroons." + + if fileExists(cfg.AdminMacPath) { + ltndLog.Warnf(msg, "admin", cfg.AdminMacPath) + } + if fileExists(cfg.ReadMacPath) { + ltndLog.Warnf(msg, "readonly", cfg.ReadMacPath) + } + if fileExists(cfg.InvoiceMacPath) { + ltndLog.Warnf(msg, "invoice", cfg.InvoiceMacPath) + } + } } // Now we're definitely done with the unlocker, shut it down so we can @@ -950,6 +998,21 @@ func fileExists(name string) bool { return true } +// bakeMacaroon creates a new macaroon with newest version and the given +// permissions then returns it binary serialized. +func bakeMacaroon(ctx context.Context, svc *macaroons.Service, + permissions []bakery.Op) ([]byte, error) { + + mac, err := svc.NewMacaroon( + ctx, macaroons.DefaultRootKeyID, permissions..., + ) + if err != nil { + return nil, err + } + + return mac.M().MarshalBinary() +} + // genMacaroons generates three macaroon files; one admin-level, one for // invoice access and one read-only. These can also be used to generate more // granular macaroons. @@ -960,57 +1023,48 @@ func genMacaroons(ctx context.Context, svc *macaroons.Service, // access invoice related calls. This is useful for merchants and other // services to allow an isolated instance that can only query and // modify invoices. - invoiceMac, err := svc.NewMacaroon( - ctx, macaroons.DefaultRootKeyID, invoicePermissions..., - ) - if err != nil { - return err - } - invoiceMacBytes, err := invoiceMac.M().MarshalBinary() + invoiceMacBytes, err := bakeMacaroon(ctx, svc, invoicePermissions) if err != nil { return err } err = ioutil.WriteFile(invoiceFile, invoiceMacBytes, 0644) if err != nil { - os.Remove(invoiceFile) + _ = os.Remove(invoiceFile) return err } // Generate the read-only macaroon and write it to a file. - roMacaroon, err := svc.NewMacaroon( - ctx, macaroons.DefaultRootKeyID, readPermissions..., - ) - if err != nil { - return err - } - roBytes, err := roMacaroon.M().MarshalBinary() + roBytes, err := bakeMacaroon(ctx, svc, readPermissions) if err != nil { return err } if err = ioutil.WriteFile(roFile, roBytes, 0644); err != nil { - os.Remove(admFile) + _ = os.Remove(roFile) return err } // Generate the admin macaroon and write it to a file. - adminPermissions := append(readPermissions, writePermissions...) - admMacaroon, err := svc.NewMacaroon( - ctx, macaroons.DefaultRootKeyID, adminPermissions..., - ) - if err != nil { - return err - } - admBytes, err := admMacaroon.M().MarshalBinary() + admBytes, err := bakeMacaroon(ctx, svc, adminPermissions()) if err != nil { return err } if err = ioutil.WriteFile(admFile, admBytes, 0600); err != nil { + _ = os.Remove(admFile) return err } return nil } +// adminPermissions returns a list of all permissions in a safe way that doesn't +// modify any of the source lists. +func adminPermissions() []bakery.Op { + admin := make([]bakery.Op, len(readPermissions)+len(writePermissions)) + copy(admin[:len(readPermissions)], readPermissions) + copy(admin[len(readPermissions):], writePermissions) + return admin +} + // WalletUnlockParams holds the variables used to parameterize the unlocking of // lnd's wallet after it has already been created. type WalletUnlockParams struct { @@ -1039,6 +1093,15 @@ type WalletUnlockParams struct { // UnloadWallet is a function for unloading the wallet, which should // be called on shutdown. UnloadWallet func() error + + // StatelessInit signals that the user requested the daemon to be + // initialized stateless, which means no unencrypted macaroons should be + // written to disk. + StatelessInit bool + + // MacResponseChan is the channel for sending back the admin macaroon to + // the WalletUnlocker service. + MacResponseChan chan []byte } // waitForWalletPassword will spin up gRPC and REST endpoints for the @@ -1054,12 +1117,10 @@ func waitForWalletPassword(cfg *Config, restEndpoints []net.Addr, 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. + // The macaroonFiles are passed to the wallet unlocker so they can be + // deleted and recreated in case the root macaroon key is also changed + // during the change password operation. macaroonFiles := []string{ - filepath.Join(cfg.networkDir, macaroons.DBFilename), cfg.AdminMacPath, cfg.ReadMacPath, cfg.InvoiceMacPath, } pwService := walletunlocker.New( @@ -1074,6 +1135,10 @@ func waitForWalletPassword(cfg *Config, restEndpoints []net.Addr, var shutdownFuncs []func() shutdown := func() { + // Make sure nothing blocks on reading on the macaroon channel, + // otherwise the GracefulStop below will never return. + close(pwService.MacResponseChan) + for _, shutdownFn := range shutdownFuncs { shutdownFn() } @@ -1204,23 +1269,27 @@ func waitForWalletPassword(cfg *Config, restEndpoints []net.Addr, } return &WalletUnlockParams{ - Password: password, - Birthday: birthday, - RecoveryWindow: recoveryWindow, - Wallet: newWallet, - ChansToRestore: initMsg.ChanBackups, - UnloadWallet: loader.UnloadWallet, + Password: password, + Birthday: birthday, + RecoveryWindow: recoveryWindow, + Wallet: newWallet, + ChansToRestore: initMsg.ChanBackups, + UnloadWallet: loader.UnloadWallet, + StatelessInit: initMsg.StatelessInit, + MacResponseChan: pwService.MacResponseChan, }, shutdown, nil // The wallet has already been created in the past, and is simply being // unlocked. So we'll just return these passphrases. case unlockMsg := <-pwService.UnlockMsgs: return &WalletUnlockParams{ - Password: unlockMsg.Passphrase, - RecoveryWindow: unlockMsg.RecoveryWindow, - Wallet: unlockMsg.Wallet, - ChansToRestore: unlockMsg.ChanBackups, - UnloadWallet: unlockMsg.UnloadWallet, + Password: unlockMsg.Passphrase, + RecoveryWindow: unlockMsg.RecoveryWindow, + Wallet: unlockMsg.Wallet, + ChansToRestore: unlockMsg.ChanBackups, + UnloadWallet: unlockMsg.UnloadWallet, + StatelessInit: unlockMsg.StatelessInit, + MacResponseChan: pwService.MacResponseChan, }, shutdown, nil case <-signal.ShutdownChannel(): diff --git a/walletunlocker/service.go b/walletunlocker/service.go index 39a66701..bea2e758 100644 --- a/walletunlocker/service.go +++ b/walletunlocker/service.go @@ -16,6 +16,13 @@ import ( "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/btcwallet" + "github.com/lightningnetwork/lnd/macaroons" +) + +var ( + // ErrUnlockTimeout signals that we did not get the expected unlock + // message before the timeout occurred. + ErrUnlockTimeout = errors.New("got no unlock message before timeout") ) // ChannelsToRecover wraps any set of packed (serialized+encrypted) channel @@ -54,6 +61,11 @@ type WalletInitMsg struct { // ChanBackups a set of static channel backups that should be received // after the wallet has been initialized. ChanBackups ChannelsToRecover + + // StatelessInit signals that the user requested the daemon to be + // initialized stateless, which means no unencrypted macaroons should be + // written to disk. + StatelessInit bool } // WalletUnlockMsg is a message sent by the UnlockerService when a user wishes @@ -85,6 +97,11 @@ type WalletUnlockMsg struct { // UnloadWallet is a function for unloading the wallet, which should // be called on shutdown. UnloadWallet func() error + + // StatelessInit signals that the user requested the daemon to be + // initialized stateless, which means no unencrypted macaroons should be + // written to disk. + StatelessInit bool } // UnlockerService implements the WalletUnlocker service used to provide lnd @@ -100,10 +117,18 @@ type UnlockerService struct { // sent. UnlockMsgs chan *WalletUnlockMsg + // MacResponseChan is the channel for sending back the admin macaroon to + // the WalletUnlocker service. + MacResponseChan chan []byte + chainDir string noFreelistSync bool netParams *chaincfg.Params - macaroonFiles []string + + // macaroonFiles is the path to the three generated macaroons with + // different access permissions. These might not exist in a stateless + // initialization of lnd. + macaroonFiles []string } // New creates and returns a new UnlockerService. @@ -111,11 +136,15 @@ func New(chainDir string, params *chaincfg.Params, noFreelistSync bool, macaroonFiles []string) *UnlockerService { return &UnlockerService{ - InitMsgs: make(chan *WalletInitMsg, 1), - UnlockMsgs: make(chan *WalletUnlockMsg, 1), - chainDir: chainDir, - netParams: params, - macaroonFiles: macaroonFiles, + InitMsgs: make(chan *WalletInitMsg, 1), + UnlockMsgs: make(chan *WalletUnlockMsg, 1), + + // Make sure we buffer the channel is buffered so the main lnd + // goroutine isn't blocking on writing to it. + MacResponseChan: make(chan []byte, 1), + chainDir: chainDir, + netParams: params, + macaroonFiles: macaroonFiles, } } @@ -127,7 +156,7 @@ func New(chainDir string, params *chaincfg.Params, noFreelistSync bool, // Once the cipherseed is obtained and verified by the user, the InitWallet // method should be used to commit the newly generated seed, and create the // wallet. -func (u *UnlockerService) GenSeed(ctx context.Context, +func (u *UnlockerService) GenSeed(_ context.Context, in *lnrpc.GenSeedRequest) (*lnrpc.GenSeedResponse, error) { // Before we start, we'll ensure that the wallet hasn't already created @@ -297,6 +326,7 @@ func (u *UnlockerService) InitWallet(ctx context.Context, Passphrase: password, WalletSeed: cipherSeed, RecoveryWindow: uint32(recoveryWindow), + StatelessInit: in.StatelessInit, } // Before we return the unlock payload, we'll check if we can extract @@ -306,9 +336,25 @@ func (u *UnlockerService) InitWallet(ctx context.Context, initMsg.ChanBackups = *chansToRestore } - u.InitMsgs <- initMsg + // Deliver the initialization message back to the main daemon. + select { + case u.InitMsgs <- initMsg: + // We need to read from the channel to let the daemon continue + // its work and to get the admin macaroon. Once the response + // arrives, we directly forward it to the client. + select { + case adminMac := <-u.MacResponseChan: + return &lnrpc.InitWalletResponse{ + AdminMacaroon: adminMac, + }, nil - return &lnrpc.InitWalletResponse{}, nil + case <-ctx.Done(): + return nil, ErrUnlockTimeout + } + + case <-ctx.Done(): + return nil, ErrUnlockTimeout + } } // UnlockWallet sends the password provided by the incoming UnlockWalletRequest @@ -351,6 +397,7 @@ func (u *UnlockerService) UnlockWallet(ctx context.Context, RecoveryWindow: recoveryWindow, Wallet: unlockedWallet, UnloadWallet: loader.UnloadWallet, + StatelessInit: in.StatelessInit, } // Before we return the unlock payload, we'll check if we can extract @@ -360,12 +407,25 @@ func (u *UnlockerService) UnlockWallet(ctx context.Context, walletUnlockMsg.ChanBackups = *chansToRestore } - // At this point we was able to open the existing wallet with the + // At this point we were able to open the existing wallet with the // provided password. We send the password over the UnlockMsgs // channel, such that it can be used by lnd to open the wallet. - u.UnlockMsgs <- walletUnlockMsg + select { + case u.UnlockMsgs <- walletUnlockMsg: + // We need to read from the channel to let the daemon continue + // its work. But we don't need the returned macaroon for this + // operation, so we read it but then discard it. + select { + case <-u.MacResponseChan: + return &lnrpc.UnlockWalletResponse{}, nil - return &lnrpc.UnlockWalletResponse{}, nil + case <-ctx.Done(): + return nil, ErrUnlockTimeout + } + + case <-ctx.Done(): + return nil, ErrUnlockTimeout + } } // ChangePassword changes the password of the wallet and sends the new password @@ -408,18 +468,32 @@ func (u *UnlockerService) ChangePassword(ctx context.Context, 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 { - err := os.Remove(file) - if err != nil && !os.IsNotExist(err) { - return nil, err + // Now that we've opened the wallet, we need to close it in case of an + // error. But not if we succeed, then the caller must close it. + orderlyReturn := false + defer func() { + if !orderlyReturn { + _ = loader.UnloadWallet() + } + }() + + // Before we actually change the password, we need to check if all flags + // were set correctly. The content of the previously generated macaroon + // files will become invalid after we generate a new root key. So we try + // to delete them here and they will be recreated during normal startup + // later. If they are missing, this is only an error if the + // stateless_init flag was not set. + if in.NewMacaroonRootKey || in.StatelessInit { + for _, file := range u.macaroonFiles { + err := os.Remove(file) + if err != nil && !in.StatelessInit { + return nil, fmt.Errorf("could not remove "+ + "macaroon file: %v. if the wallet "+ + "was initialized stateless please "+ + "add the --stateless_init "+ + "flag", err) + } } } @@ -434,11 +508,86 @@ func (u *UnlockerService) ChangePassword(ctx context.Context, "%v", err) } + // The next step is to load the macaroon database, change the password + // then close it again. + // Attempt to open the macaroon DB, unlock it and then change + // the passphrase. + macaroonService, err := macaroons.NewService( + netDir, "lnd", in.StatelessInit, + ) + if err != nil { + return nil, err + } + + err = macaroonService.CreateUnlock(&privatePw) + if err != nil { + closeErr := macaroonService.Close() + if closeErr != nil { + return nil, fmt.Errorf("could not create unlock: %v "+ + "--> follow-up error when closing: %v", err, + closeErr) + } + return nil, err + } + err = macaroonService.ChangePassword(privatePw, in.NewPassword) + if err != nil { + closeErr := macaroonService.Close() + if closeErr != nil { + return nil, fmt.Errorf("could not change password: %v "+ + "--> follow-up error when closing: %v", err, + closeErr) + } + return nil, err + } + + // If requested by the user, attempt to replace the existing + // macaroon root key with a new one. + if in.NewMacaroonRootKey { + err = macaroonService.GenerateNewRootKey() + if err != nil { + closeErr := macaroonService.Close() + if closeErr != nil { + return nil, fmt.Errorf("could not generate "+ + "new root key: %v --> follow-up error "+ + "when closing: %v", err, closeErr) + } + return nil, err + } + } + + err = macaroonService.Close() + if err != nil { + return nil, fmt.Errorf("could not close macaroon service: %v", + err) + } + // Finally, send the new password across the UnlockPasswords channel to // automatically unlock the wallet. - u.UnlockMsgs <- &WalletUnlockMsg{Passphrase: in.NewPassword} + walletUnlockMsg := &WalletUnlockMsg{ + Passphrase: in.NewPassword, + Wallet: w, + StatelessInit: in.StatelessInit, + UnloadWallet: loader.UnloadWallet, + } + select { + case u.UnlockMsgs <- walletUnlockMsg: + // We need to read from the channel to let the daemon continue + // its work and to get the admin macaroon. Once the response + // arrives, we directly forward it to the client. + orderlyReturn = true + select { + case adminMac := <-u.MacResponseChan: + return &lnrpc.ChangePasswordResponse{ + AdminMacaroon: adminMac, + }, nil - return &lnrpc.ChangePasswordResponse{}, nil + case <-ctx.Done(): + return nil, ErrUnlockTimeout + } + + case <-ctx.Done(): + return nil, ErrUnlockTimeout + } } // ValidatePassword assures the password meets all of our constraints. diff --git a/walletunlocker/service_test.go b/walletunlocker/service_test.go index 1ed2ae5d..b32f9af8 100644 --- a/walletunlocker/service_test.go +++ b/walletunlocker/service_test.go @@ -3,8 +3,10 @@ package walletunlocker_test import ( "bytes" "context" + "fmt" "io/ioutil" "os" + "path" "testing" "time" @@ -13,8 +15,10 @@ import ( "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet" "github.com/lightningnetwork/lnd/aezeed" + "github.com/lightningnetwork/lnd/channeldb/kvdb" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/btcwallet" "github.com/lightningnetwork/lnd/macaroons" "github.com/lightningnetwork/lnd/walletunlocker" @@ -24,6 +28,7 @@ import ( var ( testPassword = []byte("test-password") testSeed = []byte("test-seed-123456789") + testMac = []byte("fakemacaroon") testEntropy = [aezeed.EntropySize]byte{ 0x81, 0xb6, 0x37, 0xd8, @@ -44,6 +49,12 @@ var ( ) func createTestWallet(t *testing.T, dir string, netParams *chaincfg.Params) { + createTestWalletWithPw(t, testPassword, testPassword, dir, netParams) +} + +func createTestWalletWithPw(t *testing.T, pubPw, privPw []byte, dir string, + netParams *chaincfg.Params) { + // Instruct waddrmgr to use the cranked down scrypt parameters when // creating new wallet encryption keys. fastScrypt := waddrmgr.FastScryptOptions @@ -60,13 +71,66 @@ func createTestWallet(t *testing.T, dir string, netParams *chaincfg.Params) { netDir := btcwallet.NetworkDir(dir, netParams) loader := wallet.NewLoader(netParams, netDir, true, 0) _, err := loader.CreateNewWallet( - testPassword, testPassword, testSeed, time.Time{}, + pubPw, privPw, testSeed, time.Time{}, ) require.NoError(t, err) err = loader.UnloadWallet() require.NoError(t, err) } +func createSeedAndMnemonic(t *testing.T, + pass []byte) (*aezeed.CipherSeed, aezeed.Mnemonic) { + cipherSeed, err := aezeed.New( + keychain.KeyDerivationVersion, &testEntropy, time.Now(), + ) + require.NoError(t, err) + + // With the new seed created, we'll convert it into a mnemonic phrase + // that we'll send over to initialize the wallet. + mnemonic, err := cipherSeed.ToMnemonic(pass) + require.NoError(t, err) + return cipherSeed, mnemonic +} + +// openOrCreateTestMacStore opens or creates a bbolt DB and then initializes a +// root key storage for that DB and then unlocks it, creating a root key in the +// process. +func openOrCreateTestMacStore(tempDir string, pw *[]byte, + netParams *chaincfg.Params) (*macaroons.RootKeyStorage, error) { + + netDir := btcwallet.NetworkDir(tempDir, netParams) + err := os.MkdirAll(netDir, 0700) + if err != nil { + return nil, err + } + db, err := kvdb.Create( + kvdb.BoltBackendName, path.Join(netDir, macaroons.DBFilename), + true, + ) + if err != nil { + return nil, err + } + + store, err := macaroons.NewRootKeyStorage(db) + if err != nil { + _ = db.Close() + return nil, err + } + + err = store.CreateUnlock(pw) + if err != nil { + _ = store.Close() + return nil, err + } + _, _, err = store.RootKey(defaultRootKeyIDContext) + if err != nil { + _ = store.Close() + return nil, err + } + + return store, nil +} + // TestGenSeedUserEntropy tests that the gen seed method generates a valid // cipher seed mnemonic phrase and user provided source of entropy. func TestGenSeed(t *testing.T) { @@ -183,17 +247,9 @@ func TestInitWallet(t *testing.T) { service := walletunlocker.New(testDir, testNetParams, true, nil) // Once we have the unlocker service created, we'll now instantiate a - // new cipher seed instance. - cipherSeed, err := aezeed.New( - keychain.KeyDerivationVersion, &testEntropy, time.Now(), - ) - require.NoError(t, err) - - // With the new seed created, we'll convert it into a mnemonic phrase - // that we'll send over to initialize the wallet. + // new cipher seed and its mnemonic. pass := []byte("test") - mnemonic, err := cipherSeed.ToMnemonic(pass) - require.NoError(t, err) + cipherSeed, mnemonic := createSeedAndMnemonic(t, pass) // Now that we have all the necessary items, we'll now issue the Init // command to the wallet. This should check the validity of the cipher @@ -205,13 +261,29 @@ func TestInitWallet(t *testing.T) { CipherSeedMnemonic: mnemonic[:], AezeedPassphrase: pass, RecoveryWindow: int32(testRecoveryWindow), + StatelessInit: true, } - _, err = service.InitWallet(ctx, req) - require.NoError(t, err) + errChan := make(chan error, 1) + go func() { + response, err := service.InitWallet(ctx, req) + if err != nil { + errChan <- err + return + } + + if !bytes.Equal(response.AdminMacaroon, testMac) { + errChan <- fmt.Errorf("mismatched macaroon: "+ + "expected %x, got %x", testMac, + response.AdminMacaroon) + } + }() // The same user passphrase, and also the plaintext cipher seed // should be sent over and match exactly. select { + case err := <-errChan: + t.Fatalf("InitWallet call failed: %v", err) + case msg := <-service.InitMsgs: msgSeed := msg.WalletSeed require.Equal(t, testPassword, msg.Passphrase) @@ -221,6 +293,11 @@ func TestInitWallet(t *testing.T) { require.Equal(t, cipherSeed.Birthday, msgSeed.Birthday) require.Equal(t, cipherSeed.Entropy, msgSeed.Entropy) require.Equal(t, testRecoveryWindow, msg.RecoveryWindow) + require.Equal(t, true, msg.StatelessInit) + + // Send a fake macaroon that should be returned in the response + // in the async code above. + service.MacResponseChan <- testMac case <-time.After(defaultTestTimeout): t.Fatalf("password not received") @@ -288,6 +365,7 @@ func TestUnlockWallet(t *testing.T) { req := &lnrpc.UnlockWalletRequest{ WalletPassword: testPassword, RecoveryWindow: int32(testRecoveryWindow), + StatelessInit: true, } // Should fail to unlock non-existing wallet. @@ -305,23 +383,39 @@ func TestUnlockWallet(t *testing.T) { require.Error(t, err) // With the correct password, we should be able to unlock the wallet. - _, err = service.UnlockWallet(ctx, req) - require.NoError(t, err) + errChan := make(chan error, 1) + go func() { + // With the correct password, we should be able to unlock the + // wallet. + _, err := service.UnlockWallet(ctx, req) + if err != nil { + errChan <- err + } + }() // Password and recovery window should be sent over the channel. select { + case err := <-errChan: + t.Fatalf("UnlockWallet call failed: %v", err) + case unlockMsg := <-service.UnlockMsgs: require.Equal(t, testPassword, unlockMsg.Passphrase) require.Equal(t, testRecoveryWindow, unlockMsg.RecoveryWindow) + require.Equal(t, true, unlockMsg.StatelessInit) + + // Send a fake macaroon that should be returned in the response + // in the async code above. + service.MacResponseChan <- testMac case <-time.After(defaultTestTimeout): 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) { +// TestChangeWalletPasswordNewRootkey tests that we can successfully change the +// wallet's password needed to unlock it and rotate the root key for the +// macaroons in the same process. +func TestChangeWalletPasswordNewRootkey(t *testing.T) { t.Parallel() // testDir is empty, meaning wallet was not created from before. @@ -331,8 +425,18 @@ func TestChangeWalletPassword(t *testing.T) { _ = os.RemoveAll(testDir) }() + // Changing the password of the wallet will also try to change the + // password of the macaroon DB. We create a default DB here but close it + // immediately so the service does not fail when trying to open it. + store, err := openOrCreateTestMacStore( + testDir, &testPassword, testNetParams, + ) + require.NoError(t, err) + require.NoError(t, store.Close()) + // Create some files that will act as macaroon files that should be - // deleted after a password change is successful. + // deleted after a password change is successful with a new root key + // requested. var tempFiles []string for i := 0; i < 3; i++ { file, err := ioutil.TempFile(testDir, "") @@ -350,8 +454,9 @@ func TestChangeWalletPassword(t *testing.T) { newPassword := []byte("hunter2???") req := &lnrpc.ChangePasswordRequest{ - CurrentPassword: testPassword, - NewPassword: newPassword, + CurrentPassword: testPassword, + NewPassword: newPassword, + NewMacaroonRootKey: true, } // Changing the password to a non-existing wallet should fail. @@ -387,8 +492,24 @@ func TestChangeWalletPassword(t *testing.T) { // 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) - require.NoError(t, err) + errChan := make(chan error, 1) + go doChangePassword(service, testDir, req, errChan) + + // The new password should be sent over the channel. + select { + case err := <-errChan: + t.Fatalf("ChangePassword call failed: %v", err) + + case unlockMsg := <-service.UnlockMsgs: + require.Equal(t, newPassword, unlockMsg.Passphrase) + + // Send a fake macaroon that should be returned in the response + // in the async code above. + service.MacResponseChan <- testMac + + case <-time.After(defaultTestTimeout): + t.Fatalf("password not received") + } // The files should no longer exist. for _, tempFile := range tempFiles { @@ -396,13 +517,146 @@ func TestChangeWalletPassword(t *testing.T) { t.Fatal("file exists but it shouldn't") } } +} - // The new password should be sent over the channel. +// TestChangeWalletPasswordStateless checks that trying to change the password +// of an existing wallet that was initialized stateless works when when the +// --stateless_init flat is set. Also checks that if no password is given, +// the default password is used. +func TestChangeWalletPasswordStateless(t *testing.T) { + t.Parallel() + + // testDir is empty, meaning wallet was not created from before. + testDir, err := ioutil.TempDir("", "testchangepasswordstateless") + require.NoError(t, err) + defer func() { + _ = os.RemoveAll(testDir) + }() + + // Changing the password of the wallet will also try to change the + // password of the macaroon DB. We create a default DB here but close it + // immediately so the service does not fail when trying to open it. + store, err := openOrCreateTestMacStore( + testDir, &lnwallet.DefaultPrivatePassphrase, testNetParams, + ) + require.NoError(t, err) + require.NoError(t, store.Close()) + + // Create a temp file that will act as the macaroon DB file that will + // be deleted by changing the password. + tmpFile, err := ioutil.TempFile(testDir, "") + require.NoError(t, err) + tempMacFile := tmpFile.Name() + err = tmpFile.Close() + require.NoError(t, err) + + // Create a file name that does not exist that will be used as a + // macaroon file reference. The fact that the file does not exist should + // not throw an error when --stateless_init is used. + nonExistingFile := path.Join(testDir, "does-not-exist") + + // Create a new UnlockerService with our temp files. + service := walletunlocker.New(testDir, testNetParams, true, []string{ + tempMacFile, nonExistingFile, + }) + + // Create a wallet we can try to unlock. We use the default password + // so we can check that the unlocker service defaults to this when + // we give it an empty CurrentPassword to indicate we come from a + // --noencryptwallet state. + createTestWalletWithPw( + t, lnwallet.DefaultPublicPassphrase, + lnwallet.DefaultPrivatePassphrase, testDir, testNetParams, + ) + + // We make sure that we get a proper error message if we forget to + // add the --stateless_init flag but the macaroon files don't exist. + badReq := &lnrpc.ChangePasswordRequest{ + NewPassword: testPassword, + NewMacaroonRootKey: true, + } + ctx := context.Background() + _, err = service.ChangePassword(ctx, badReq) + require.Error(t, err) + + // Prepare the correct request we are going to send to the unlocker + // service. We don't provide a current password to indicate there + // was none set before. + req := &lnrpc.ChangePasswordRequest{ + NewPassword: testPassword, + StatelessInit: true, + NewMacaroonRootKey: true, + } + + // Since we indicated the wallet was initialized stateless, the service + // will block until it receives the macaroon through the channel + // provided in the message in UnlockMsgs. So we need to call the service + // async and then wait for the unlock message to arrive so we can send + // back a fake macaroon. + errChan := make(chan error, 1) + go doChangePassword(service, testDir, req, errChan) + + // Password and recovery window should be sent over the channel. select { + case err := <-errChan: + t.Fatalf("ChangePassword call failed: %v", err) + case unlockMsg := <-service.UnlockMsgs: - require.Equal(t, newPassword, unlockMsg.Passphrase) + require.Equal(t, testPassword, unlockMsg.Passphrase) + + // Send a fake macaroon that should be returned in the response + // in the async code above. + service.MacResponseChan <- testMac case <-time.After(defaultTestTimeout): t.Fatalf("password not received") } } + +func doChangePassword(service *walletunlocker.UnlockerService, testDir string, + req *lnrpc.ChangePasswordRequest, errChan chan error) { + + // When providing the correct wallet's current password and a + // new password that meets the length requirement, the password + // change should succeed. + ctx := context.Background() + response, err := service.ChangePassword(ctx, req) + if err != nil { + errChan <- fmt.Errorf("could not change password: %v", err) + return + } + + if !bytes.Equal(response.AdminMacaroon, testMac) { + errChan <- fmt.Errorf("mismatched macaroon: expected "+ + "%x, got %x", testMac, response.AdminMacaroon) + } + + // Close the macaroon DB and try to open it and read the root + // key with the new password. + store, err := openOrCreateTestMacStore( + testDir, &testPassword, testNetParams, + ) + if err != nil { + errChan <- fmt.Errorf("could not create test store: %v", err) + return + } + _, _, err = store.RootKey(defaultRootKeyIDContext) + if err != nil { + errChan <- fmt.Errorf("could not get root key: %v", err) + return + } + + // Do cleanup now. Since we are in a go func, the defer at the + // top of the outer would not work, because it would delete + // the directory before we could check the content in here. + err = store.Close() + if err != nil { + errChan <- fmt.Errorf("could not close store: %v", err) + return + } + err = os.RemoveAll(testDir) + if err != nil { + errChan <- err + return + } +}