diff --git a/walletunlocker/service.go b/walletunlocker/service.go index 5b62afd7..b80daca1 100644 --- a/walletunlocker/service.go +++ b/walletunlocker/service.go @@ -1,8 +1,11 @@ package walletunlocker import ( + "crypto/rand" "fmt" + "time" + "github.com/lightningnetwork/lnd/aezeed" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet/btcwallet" "github.com/lightningnetwork/lnd/macaroons" @@ -11,13 +14,28 @@ import ( "golang.org/x/net/context" ) +// WalletInitMsg is a message sent to the UnlockerService when a user wishes to +// set up the internal wallet for the first time. The user MUST provide a +// passphrase, but is also able to provide their own source of entropy. If +// provided, then this source of entropy will be used to generate the wallet's +// HD seed. Otherwise, the wallet will generate one itself. +type WalletInitMsg struct { + // Passphrase is the passphrase that will be used to encrypt the wallet + // itself. This MUST be at least 8 characters. + Passphrase []byte + + // WalletSeed is the deciphered cipher seed that the wallet should use + // to initialize itself. + WalletSeed *aezeed.CipherSeed +} + // UnlockerService implements the WalletUnlocker service used to provide lnd -// with a password for wallet encryption at startup. +// with a password for wallet encryption at startup. Additionally, during +// initial setup, users can provide their own source of entropy which will be +// used to generate the seed that's ultimately used within the wallet. type UnlockerService struct { - // CreatePasswords is a channel where passwords provided by the rpc - // client to be used to initially create and encrypt a wallet will be - // sent. - CreatePasswords chan []byte + // InitMsgs is a channel that carries all wallet init messages. + InitMsgs chan *WalletInitMsg // UnlockPasswords is a channel where passwords provided by the rpc // client to be used to unlock and decrypt an existing wallet will be @@ -32,42 +50,141 @@ type UnlockerService struct { // New creates and returns a new UnlockerService. func New(authSvc *macaroons.Service, chainDir string, params *chaincfg.Params) *UnlockerService { + return &UnlockerService{ - CreatePasswords: make(chan []byte, 1), + InitMsgs: make(chan *WalletInitMsg, 1), UnlockPasswords: make(chan []byte, 1), chainDir: chainDir, netParams: params, } } -// CreateWallet will read the password provided in the CreateWalletRequest and -// send it over the CreatePasswords channel in case no wallet already exist in -// the chain's wallet database directory. -func (u *UnlockerService) CreateWallet(ctx context.Context, - in *lnrpc.CreateWalletRequest) (*lnrpc.CreateWalletResponse, error) { +// GenSeed is the first method that should be used to instantiate a new lnd +// instance. This method allows a caller to generate a new aezeed cipher seed +// given an optional passphrase. If provided, the passphrase will be necessary +// to decrypt the cipherseed to expose the internal wallet seed. +// +// 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, + in *lnrpc.GenSeedRequest) (*lnrpc.GenSeedResponse, error) { - // Require the provided password to have a length of at - // least 8 characters. - password := in.Password + // Before we start, we'll ensure that the wallet hasn't already created + // so we don't show a *new* seed to the user if one already exists. + netDir := btcwallet.NetworkDir(u.chainDir, u.netParams) + loader := wallet.NewLoader(u.netParams, netDir) + walletExists, err := loader.WalletExists() + if err != nil { + return nil, err + } + if walletExists { + return nil, fmt.Errorf("wallet already exists") + } + + var entropy [aezeed.EntropySize]byte + + switch { + // If the user provided any entropy, then we'll make sure it's sized + // properly. + case len(in.SeedEntropy) != 0 && len(in.SeedEntropy) != aezeed.EntropySize: + return nil, fmt.Errorf("incorrect entropy length: expected "+ + "16 bytes, instead got %v bytes", len(in.SeedEntropy)) + + // If the user provided the correct number of bytes, then we'll copy it + // over into our buffer for usage. + case len(in.SeedEntropy) == aezeed.EntropySize: + copy(entropy[:], in.SeedEntropy[:]) + + // Otherwise, we'll generate a fresh new set of bytes to use as entropy + // to generate the seed. + default: + if _, err := rand.Read(entropy[:]); err != nil { + return nil, err + } + } + + // Now that we have our set of entropy, we'll create a new cipher seed + // instance. + // + // TODO(roasbeef): should use current keychain version here + cipherSeed, err := aezeed.New(0, &entropy, time.Now()) + if err != nil { + return nil, err + } + + // With our raw cipher seed obtained, we'll convert it into an encoded + // mnemonic using the user specified pass phrase. + mnemonic, err := cipherSeed.ToMnemonic(in.AezeedPassphrase) + if err != nil { + return nil, err + } + + // Additionally, we'll also obtain the raw enciphered cipher seed as + // well to return to the user. + encipheredSeed, err := cipherSeed.Encipher(in.AezeedPassphrase) + if err != nil { + return nil, err + } + + return &lnrpc.GenSeedResponse{ + CipherSeedMnemonic: []string(mnemonic[:]), + EncipheredSeed: encipheredSeed[:], + }, nil +} + +// InitWallet is used when lnd is starting up for the first time to fully +// initialize the daemon and its internal wallet. At the very least a wallet +// password must be provided. This will be used to encrypt sensitive material +// on disk. +// +// In the case of a recovery scenario, the user can also specify their aezeed +// mnemonic and passphrase. If set, then the daemon will use this prior state +// to initialize its internal wallet. +// +// Alternatively, this can be used along with the GenSeed RPC to obtain a +// seed, then present it to the user. Once it has been verified by the user, +// the seed can be fed into this RPC in order to commit the new wallet. +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. + password := in.WalletPassword if len(password) < 8 { return nil, fmt.Errorf("password must have " + "at least 8 characters") } + // We'll then open up the directory that will be used to store the + // wallet's files so we can check if the wallet already exists. netDir := btcwallet.NetworkDir(u.chainDir, u.netParams) loader := wallet.NewLoader(u.netParams, netDir) - // Check if wallet already exists. walletExists, err := loader.WalletExists() if err != nil { return nil, err } + // If the wallet already exists, then we'll exit early as we can't + // create the wallet if it already exists! if walletExists { - // Cannot create wallet if it already exists! return nil, fmt.Errorf("wallet already exists") } + // At this point, we know that the wallet doesn't already exist. So + // we'll map the user provided aezeed and passphrase into a decoded + // cipher seed instance. + var mnemonic aezeed.Mnemonic + copy(mnemonic[:], in.CipherSeedMnemonic[:]) + + // If we're unable to map it back into the ciphertext, then either the + // mnemonic is wrong, or the passphrase is wrong. + cipherSeed, err := mnemonic.ToCipherSeed(in.AezeedPassphrase) + if err != nil { + return nil, err + } + // Attempt to create a password for the macaroon service. if u.authSvc != nil { err = u.authSvc.CreateUnlock(&password) @@ -77,11 +194,17 @@ func (u *UnlockerService) CreateWallet(ctx context.Context, } } - // We send the password over the CreatePasswords channel, such that it - // can be used by lnd to open or create the wallet. - u.CreatePasswords <- password + // With the cipher seed deciphered, and the auth service created, we'll + // now send over the wallet password and the seed. This will allow the + // daemon to initialize itself and startup. + initMsg := &WalletInitMsg{ + Passphrase: password, + WalletSeed: cipherSeed, + } - return &lnrpc.CreateWalletResponse{}, nil + u.InitMsgs <- initMsg + + return &lnrpc.InitWalletResponse{}, nil } // UnlockWallet sends the password provided by the incoming UnlockWalletRequest @@ -105,15 +228,15 @@ func (u *UnlockerService) UnlockWallet(ctx context.Context, } // Try opening the existing wallet with the provided password. - _, err = loader.OpenExistingWallet(in.Password, false) + _, err = loader.OpenExistingWallet(in.WalletPassword, false) if err != nil { - // Could not open wallet, most likely this means that - // provided password was incorrect. + // Could not open wallet, most likely this means that provided + // password was incorrect. return nil, err } - // We successfully opened the wallet, but we'll need to unload - // it to make sure lnd can open it later. + // We successfully opened the wallet, but we'll need to unload it to + // make sure lnd can open it later. if err := loader.UnloadWallet(); err != nil { // TODO: not return error here? return nil, err @@ -121,7 +244,7 @@ func (u *UnlockerService) UnlockWallet(ctx context.Context, // Attempt to create a password for the macaroon service. if u.authSvc != nil { - err = u.authSvc.CreateUnlock(&in.Password) + err = u.authSvc.CreateUnlock(&in.WalletPassword) if err != nil { return nil, fmt.Errorf("unable to create/unlock "+ "macaroon store: %v", err) @@ -131,7 +254,7 @@ func (u *UnlockerService) UnlockWallet(ctx context.Context, // At this point we was able to open the existing wallet with the // provided password. We send the password over the UnlockPasswords // channel, such that it can be used by lnd to open the wallet. - u.UnlockPasswords <- in.Password + u.UnlockPasswords <- in.WalletPassword return &lnrpc.UnlockWalletResponse{}, nil } diff --git a/walletunlocker/service_test.go b/walletunlocker/service_test.go index d6e7eb8d..4e2514a2 100644 --- a/walletunlocker/service_test.go +++ b/walletunlocker/service_test.go @@ -4,9 +4,11 @@ import ( "bytes" "io/ioutil" "os" + "strings" "testing" "time" + "github.com/lightningnetwork/lnd/aezeed" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet/btcwallet" "github.com/lightningnetwork/lnd/walletunlocker" @@ -23,6 +25,13 @@ var ( testPassword = []byte("test-password") testSeed = []byte("test-seed-123456789") + testEntropy = [aezeed.EntropySize]byte{ + 0x81, 0xb6, 0x37, 0xd8, + 0x63, 0x59, 0xe6, 0x96, + 0x0d, 0xe7, 0x95, 0xe4, + 0x1e, 0x0b, 0x4c, 0xfd, + } + testNetParams = &chaincfg.MainNetParams ) @@ -39,10 +48,127 @@ func createTestWallet(t *testing.T, dir string, netParams *chaincfg.Params) { } } -// TestCreateWallet checks that CreateWallet correctly returns a password that -// can be used for creating a wallet if no wallet exists from before, and -// returns an error when it already exists. -func TestCreateWallet(t *testing.T) { +// 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) { + t.Parallel() + + // First, we'll create a new test directory and unlocker service for + // that directory. + testDir, err := ioutil.TempDir("", "testcreate") + if err != nil { + t.Fatalf("unable to create temp directory: %v", err) + } + defer func() { + os.RemoveAll(testDir) + }() + service := walletunlocker.New(nil, testDir, testNetParams) + + // Now that the service has been created, we'll ask it to generate a + // new seed for us given a test passphrase. + aezeedPass := []byte("kek") + genSeedReq := &lnrpc.GenSeedRequest{ + AezeedPassphrase: aezeedPass, + SeedEntropy: testEntropy[:], + } + + ctx := context.Background() + seedResp, err := service.GenSeed(ctx, genSeedReq) + if err != nil { + t.Fatalf("unable to generate seed: %v", err) + } + + // We should then be able to take the generated mnemonic, and properly + // decipher both it. + var mnemonic aezeed.Mnemonic + copy(mnemonic[:], seedResp.CipherSeedMnemonic[:]) + _, err = mnemonic.ToCipherSeed(aezeedPass) + if err != nil { + t.Fatalf("unable to decipher cipher seed: %v", err) + } +} + +// TestGenSeedInvalidEntropy tests that the gen seed method generates a valid +// cipher seed mnemonic pass phrase even when the user doesn't provide its own +// source of entropy. +func TestGenSeedGenerateEntropy(t *testing.T) { + t.Parallel() + + // First, we'll create a new test directory and unlocker service for + // that directory. + testDir, err := ioutil.TempDir("", "testcreate") + if err != nil { + t.Fatalf("unable to create temp directory: %v", err) + } + defer func() { + os.RemoveAll(testDir) + }() + service := walletunlocker.New(nil, testDir, testNetParams) + + // 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 + aezeedPass := []byte("kek") + genSeedReq := &lnrpc.GenSeedRequest{ + AezeedPassphrase: aezeedPass, + } + + ctx := context.Background() + seedResp, err := service.GenSeed(ctx, genSeedReq) + if err != nil { + t.Fatalf("unable to generate seed: %v", err) + } + + // We should then be able to take the generated mnemonic, and properly + // decipher both it. + var mnemonic aezeed.Mnemonic + copy(mnemonic[:], seedResp.CipherSeedMnemonic[:]) + _, err = mnemonic.ToCipherSeed(aezeedPass) + if err != nil { + t.Fatalf("unable to decipher cipher seed: %v", err) + } +} + +// TestGenSeedInvalidEntropy tests that if a user attempt to create a seed with +// the wrong number of bytes for the initial entropy, then the proper error is +// returned. +func TestGenSeedInvalidEntropy(t *testing.T) { + t.Parallel() + + // First, we'll create a new test directory and unlocker service for + // that directory. + testDir, err := ioutil.TempDir("", "testcreate") + if err != nil { + t.Fatalf("unable to create temp directory: %v", err) + } + defer func() { + os.RemoveAll(testDir) + }() + service := walletunlocker.New(nil, testDir, testNetParams) + + // 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 + // invalid set of entropy that's 55 bytes, instead of 15 bytes. + aezeedPass := []byte("kek") + genSeedReq := &lnrpc.GenSeedRequest{ + AezeedPassphrase: aezeedPass, + SeedEntropy: bytes.Repeat([]byte("a"), 55), + } + + // We should get an error now since the entropy source was invalid. + ctx := context.Background() + _, err = service.GenSeed(ctx, genSeedReq) + if err == nil { + t.Fatalf("seed creation should've failed") + } + + if !strings.Contains(err.Error(), "incorrect entropy length") { + t.Fatalf("wrong error, expected incorrect entropy length") + } +} + +// TestInitWallet tests that the user is able to properly initialize the wallet +// given an existing cipher seed passphrase. +func TestInitWallet(t *testing.T) { t.Parallel() // testDir is empty, meaning wallet was not created from before. @@ -57,22 +183,60 @@ func TestCreateWallet(t *testing.T) { // Create new UnlockerService. service := walletunlocker.New(nil, testDir, testNetParams) - ctx := context.Background() - req := &lnrpc.CreateWalletRequest{ - Password: testPassword, - } - _, err = service.CreateWallet(ctx, req) + // Once we have the unlocker service created, we'll now instantiate a + // new cipher seed instance. + cipherSeed, err := aezeed.New(0, &testEntropy, time.Now()) if err != nil { - t.Fatalf("CreateWallet call failed: %v", err) + t.Fatalf("unable to create seed: %v", err) } - // Password should be sent over the channel. + // With the new seed created, we'll convert it into a mnemonic phrase + // that we'll send over to initialize the wallet. + pass := []byte("test") + mnemonic, err := cipherSeed.ToMnemonic(pass) + if err != nil { + t.Fatalf("unable to create mnemonic: %v", err) + } + + // 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 + // seed, then send over the initialization information over the init + // channel. + ctx := context.Background() + req := &lnrpc.InitWalletRequest{ + WalletPassword: testPassword, + CipherSeedMnemonic: []string(mnemonic[:]), + AezeedPassphrase: pass, + } + _, err = service.InitWallet(ctx, req) + if err != nil { + t.Fatalf("InitWallet call failed: %v", err) + } + + // The same user passphrase, and also the plaintext cipher seed + // should be sent over and match exactly. select { - case pw := <-service.CreatePasswords: - if !bytes.Equal(pw, testPassword) { - t.Fatalf("expected to receive password %x, got %x", - testPassword, pw) + case msg := <-service.InitMsgs: + if !bytes.Equal(msg.Passphrase, testPassword) { + t.Fatalf("expected to receive password %x, "+ + "got %x", testPassword, msg.Passphrase) } + if msg.WalletSeed.InternalVersion != cipherSeed.InternalVersion { + t.Fatalf("mismatched versions: expected %v, "+ + "got %v", cipherSeed.InternalVersion, + msg.WalletSeed.InternalVersion) + } + if msg.WalletSeed.Birthday != cipherSeed.Birthday { + t.Fatalf("mismatched birthday: expected %v, "+ + "got %v", cipherSeed.Birthday, + msg.WalletSeed.Birthday) + } + if msg.WalletSeed.Entropy != cipherSeed.Entropy { + t.Fatalf("mismatched versions: expected %x, "+ + "got %x", cipherSeed.Entropy[:], + msg.WalletSeed.Entropy[:]) + } + case <-time.After(3 * time.Second): t.Fatalf("password not received") } @@ -80,11 +244,50 @@ func TestCreateWallet(t *testing.T) { // Create a wallet in testDir. createTestWallet(t, testDir, testNetParams) - // Now calling CreateWallet should fail, since a wallet already exists - // in the directory. - _, err = service.CreateWallet(ctx, req) + // Now calling InitWallet should fail, since a wallet already exists in + // the directory. + _, err = service.InitWallet(ctx, req) if err == nil { - t.Fatalf("CreateWallet did not fail as expected") + t.Fatalf("InitWallet did not fail as expected") + } + + // Similarly, if we try to do GenSeed again, we should get an error as + // the wallet already exists. + _, err = service.GenSeed(ctx, &lnrpc.GenSeedRequest{}) + if err == nil { + t.Fatalf("seed generation should have failed") + } +} + +// TestInitWalletInvalidCipherSeed tests that if we attempt to create a wallet +// with an invalid cipher seed, then we'll receive an error. +func TestCreateWalletInvalidEntropy(t *testing.T) { + t.Parallel() + + // testDir is empty, meaning wallet was not created from before. + testDir, err := ioutil.TempDir("", "testcreate") + if err != nil { + t.Fatalf("unable to create temp directory: %v", err) + } + defer func() { + os.RemoveAll(testDir) + }() + + // Create new UnlockerService. + service := walletunlocker.New(nil, testDir, testNetParams) + + // We'll attempt to init the wallet with an invalid cipher seed and + // passphrase. + req := &lnrpc.InitWalletRequest{ + WalletPassword: testPassword, + CipherSeedMnemonic: []string{"invalid", "seed"}, + AezeedPassphrase: []byte("fake pass"), + } + + ctx := context.Background() + _, err = service.InitWallet(ctx, req) + if err == nil { + t.Fatalf("wallet creation should have failed") } } @@ -108,7 +311,7 @@ func TestUnlockWallet(t *testing.T) { ctx := context.Background() req := &lnrpc.UnlockWalletRequest{ - Password: testPassword, + WalletPassword: testPassword, } // Should fail to unlock non-existing wallet. @@ -122,7 +325,7 @@ func TestUnlockWallet(t *testing.T) { // Try unlocking this wallet with the wrong passphrase. wrongReq := &lnrpc.UnlockWalletRequest{ - Password: []byte("wrong-ofc"), + WalletPassword: []byte("wrong-ofc"), } _, err = service.UnlockWallet(ctx, wrongReq) if err == nil { @@ -145,5 +348,4 @@ func TestUnlockWallet(t *testing.T) { case <-time.After(3 * time.Second): t.Fatalf("password not received") } - }