diff --git a/cmd/lncli/main.go b/cmd/lncli/main.go index 0336f966..a83206b9 100644 --- a/cmd/lncli/main.go +++ b/cmd/lncli/main.go @@ -39,7 +39,7 @@ func fatal(err error) { } func getWalletUnlockerClient(ctx *cli.Context) (lnrpc.WalletUnlockerClient, func()) { - conn := getClientConn(ctx) + conn := getClientConn(ctx, true) cleanUp := func() { conn.Close() @@ -49,7 +49,7 @@ func getWalletUnlockerClient(ctx *cli.Context) (lnrpc.WalletUnlockerClient, func } func getClient(ctx *cli.Context) (lnrpc.LightningClient, func()) { - conn := getClientConn(ctx) + conn := getClientConn(ctx, false) cleanUp := func() { conn.Close() @@ -58,7 +58,7 @@ func getClient(ctx *cli.Context) (lnrpc.LightningClient, func()) { return lnrpc.NewLightningClient(conn), cleanUp } -func getClientConn(ctx *cli.Context) *grpc.ClientConn { +func getClientConn(ctx *cli.Context, skipMacaroons bool) *grpc.ClientConn { // Load the specified TLS certificate and build transport credentials // with it. tlsCertPath := cleanAndExpandPath(ctx.GlobalString("tlscertpath")) @@ -72,8 +72,9 @@ func getClientConn(ctx *cli.Context) *grpc.ClientConn { grpc.WithTransportCredentials(creds), } - // Only process macaroon credentials if --no-macaroons isn't set. - if !ctx.GlobalBool("no-macaroons") { + // Only process macaroon credentials if --no-macaroons isn't set and + // if we're not skipping macaroon processing. + if !ctx.GlobalBool("no-macaroons") && !skipMacaroons { // Load the specified macaroon file. macPath := cleanAndExpandPath(ctx.GlobalString("macaroonpath")) macBytes, err := ioutil.ReadFile(macPath) diff --git a/lnd.go b/lnd.go index f7e32b92..f11cde02 100644 --- a/lnd.go +++ b/lnd.go @@ -154,28 +154,6 @@ func lndMain() error { ctx, cancel := context.WithCancel(ctx) defer cancel() - var macaroonService *bakery.Bakery - if !cfg.NoMacaroons { - // Create the macaroon authentication/authorization service. - macaroonService, err = macaroons.NewService(macaroonDatabaseDir, - macaroons.IPLockChecker) - if err != nil { - srvrLog.Errorf("unable to create macaroon service: %v", err) - return err - } - - // Create macaroon files for lncli to use if they don't exist. - if !fileExists(cfg.AdminMacPath) && !fileExists(cfg.ReadMacPath) { - err = genMacaroons(ctx, macaroonService, - cfg.AdminMacPath, cfg.ReadMacPath) - if err != nil { - ltndLog.Errorf("unable to create macaroon "+ - "files: %v", err) - return err - } - } - } - // Ensure we create TLS key and certificate if they don't exist if !fileExists(cfg.TLSCertPath) && !fileExists(cfg.TLSKeyPath) { if err := genCertPair(cfg.TLSCertPath, cfg.TLSKeyPath); err != nil { @@ -200,6 +178,18 @@ func lndMain() error { } proxyOpts := []grpc.DialOption{grpc.WithTransportCredentials(cCreds)} + var macaroonService *macaroons.Service + if !cfg.NoMacaroons { + // Create the macaroon authentication/authorization service. + macaroonService, err = macaroons.NewService(macaroonDatabaseDir, + macaroons.IPLockChecker) + if err != nil { + srvrLog.Errorf("unable to create macaroon service: %v", err) + return err + } + defer macaroonService.Close() + } + // We wait until the user provides a password over RPC. In case lnd is // started with the --noencryptwallet flag, we use the default password // "hello" for wallet encryption. @@ -215,6 +205,27 @@ func lndMain() error { } } + if !cfg.NoMacaroons { + // 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 && err != macaroons.ErrAlreadyUnlocked { + srvrLog.Error(err) + return err + } + // Create macaroon files for lncli to use if they don't exist. + if !fileExists(cfg.AdminMacPath) && !fileExists(cfg.ReadMacPath) { + err = genMacaroons(ctx, macaroonService, + cfg.AdminMacPath, cfg.ReadMacPath) + if err != nil { + ltndLog.Errorf("unable to create macaroon "+ + "files: %v", err) + return err + } + } + } + // With the information parsed from the configuration, create valid // instances of the pertinent interfaces required to operate the // Lightning Network Daemon. @@ -389,10 +400,10 @@ func lndMain() error { // Check macaroon authentication if macaroons aren't disabled. if macaroonService != nil { serverOpts = append(serverOpts, - grpc.UnaryInterceptor(macaroons.UnaryServerInterceptor( - macaroonService, permissions)), - grpc.StreamInterceptor(macaroons.StreamServerInterceptor( - macaroonService, permissions)), + grpc.UnaryInterceptor(macaroonService. + UnaryServerInterceptor(permissions)), + grpc.StreamInterceptor(macaroonService. + StreamServerInterceptor(permissions)), ) } @@ -672,7 +683,7 @@ func genCertPair(certFile, keyFile string) error { // genMacaroons generates a pair of macaroon files; one admin-level and one // read-only. These can also be used to generate more granular macaroons. -func genMacaroons(ctx context.Context, svc *bakery.Bakery, admFile, +func genMacaroons(ctx context.Context, svc *macaroons.Service, admFile, roFile string) error { // Generate the read-only macaroon and write it to a file. @@ -712,7 +723,7 @@ func genMacaroons(ctx context.Context, svc *bakery.Bakery, admFile, // the user to this RPC server. func waitForWalletPassword(grpcEndpoints, restEndpoints []string, serverOpts []grpc.ServerOption, proxyOpts []grpc.DialOption, - tlsConf *tls.Config, macaroonService *bakery.Bakery) ([]byte, []byte, error) { + tlsConf *tls.Config, macaroonService *macaroons.Service) ([]byte, []byte, error) { // Set up a new PasswordService, which will listen // for passwords provided over RPC. diff --git a/macaroons/auth.go b/macaroons/auth.go index e088197e..64472a2c 100644 --- a/macaroons/auth.go +++ b/macaroons/auth.go @@ -2,13 +2,9 @@ package macaroons import ( "encoding/hex" - "fmt" - - "google.golang.org/grpc/metadata" "golang.org/x/net/context" - "gopkg.in/macaroon-bakery.v2/bakery" macaroon "gopkg.in/macaroon.v2" ) @@ -47,40 +43,3 @@ func NewMacaroonCredential(m *macaroon.Macaroon) MacaroonCredential { ms.Macaroon = m.Clone() return ms } - -// ValidateMacaroon validates the capabilities of a given request given a -// bakery service, context, and uri. Within the passed context.Context, we -// expect a macaroon to be encoded as request metadata using the key -// "macaroon". -func ValidateMacaroon(ctx context.Context, requiredPermissions []bakery.Op, - svc *bakery.Bakery) error { - - // Get macaroon bytes from context and unmarshal into macaroon. - md, ok := metadata.FromIncomingContext(ctx) - if !ok { - return fmt.Errorf("unable to get metadata from context") - } - if len(md["macaroon"]) != 1 { - return fmt.Errorf("expected 1 macaroon, got %d", - len(md["macaroon"])) - } - - // With the macaroon obtained, we'll now decode the hex-string - // encoding, then unmarshal it from binary into its concrete struct - // representation. - macBytes, err := hex.DecodeString(md["macaroon"][0]) - if err != nil { - return err - } - mac := &macaroon.Macaroon{} - err = mac.UnmarshalBinary(macBytes) - if err != nil { - return err - } - - // Check the method being called against the permitted operation and - // the expiration time and IP address and return the result. - authChecker := svc.Checker.Auth(macaroon.Slice{mac}) - _, err = authChecker.Allow(ctx, requiredPermissions...) - return err -} diff --git a/macaroons/service.go b/macaroons/service.go index 6d6b1706..f809a103 100644 --- a/macaroons/service.go +++ b/macaroons/service.go @@ -1,13 +1,16 @@ package macaroons import ( + "encoding/hex" "fmt" "path" "google.golang.org/grpc" + "google.golang.org/grpc/metadata" "gopkg.in/macaroon-bakery.v2/bakery" "gopkg.in/macaroon-bakery.v2/bakery/checkers" + macaroon "gopkg.in/macaroon.v2" "golang.org/x/net/context" @@ -20,6 +23,15 @@ var ( dbFilename = "macaroons.db" ) +// Service encapsulates bakery.Bakery and adds a Close() method that zeroes the +// root key service encryption keys, as well as utility methods to validate a +// macaroon against the bakery and gRPC middleware for macaroon-based auth. +type Service struct { + bakery.Bakery + + rks *RootKeyStorage +} + // NewService returns a service backed by the macaroon Bolt DB stored in the // passed directory. The `checks` argument can be any of the `Checker` type // functions defined in this package, or a custom checker if desired. This @@ -27,7 +39,7 @@ var ( // listing the same checker more than once is not harmful. Default checkers, // such as those for `allow`, `time-before`, `declared`, and `error` caveats // are registered automatically and don't need to be added. -func NewService(dir string, checks ...Checker) (*bakery.Bakery, error) { +func NewService(dir string, checks ...Checker) (*Service, error) { // Open the database that we'll use to store the primary macaroon key, // and all generated macaroons+caveats. macaroonDB, err := bolt.Open(path.Join(dir, dbFilename), 0600, @@ -62,7 +74,7 @@ func NewService(dir string, checks ...Checker) (*bakery.Bakery, error) { } } - return svc, nil + return &Service{*svc, rootKeyStore}, nil } // isRegistered checks to see if the required checker has already been @@ -83,7 +95,7 @@ func isRegistered(c *checkers.Checker, name string) bool { // UnaryServerInterceptor is a GRPC interceptor that checks whether the // request is authorized by the included macaroons. -func UnaryServerInterceptor(svc *bakery.Bakery, +func (svc *Service) UnaryServerInterceptor( permissionMap map[string][]bakery.Op) grpc.UnaryServerInterceptor { return func(ctx context.Context, req interface{}, @@ -95,8 +107,7 @@ func UnaryServerInterceptor(svc *bakery.Bakery, "required for method", info.FullMethod) } - err := ValidateMacaroon(ctx, permissionMap[info.FullMethod], - svc) + err := svc.ValidateMacaroon(ctx, permissionMap[info.FullMethod]) if err != nil { return nil, err } @@ -107,7 +118,7 @@ func UnaryServerInterceptor(svc *bakery.Bakery, // StreamServerInterceptor is a GRPC interceptor that checks whether the // request is authorized by the included macaroons. -func StreamServerInterceptor(svc *bakery.Bakery, +func (svc *Service) StreamServerInterceptor( permissionMap map[string][]bakery.Op) grpc.StreamServerInterceptor { return func(srv interface{}, ss grpc.ServerStream, @@ -118,8 +129,8 @@ func StreamServerInterceptor(svc *bakery.Bakery, "for method", info.FullMethod) } - err := ValidateMacaroon(ss.Context(), - permissionMap[info.FullMethod], svc) + err := svc.ValidateMacaroon(ss.Context(), + permissionMap[info.FullMethod]) if err != nil { return err } @@ -127,3 +138,52 @@ func StreamServerInterceptor(svc *bakery.Bakery, return handler(srv, ss) } } + +// ValidateMacaroon validates the capabilities of a given request given a +// bakery service, context, and uri. Within the passed context.Context, we +// expect a macaroon to be encoded as request metadata using the key +// "macaroon". +func (svc *Service) ValidateMacaroon(ctx context.Context, + requiredPermissions []bakery.Op) error { + + // Get macaroon bytes from context and unmarshal into macaroon. + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return fmt.Errorf("unable to get metadata from context") + } + if len(md["macaroon"]) != 1 { + return fmt.Errorf("expected 1 macaroon, got %d", + len(md["macaroon"])) + } + + // With the macaroon obtained, we'll now decode the hex-string + // encoding, then unmarshal it from binary into its concrete struct + // representation. + macBytes, err := hex.DecodeString(md["macaroon"][0]) + if err != nil { + return err + } + mac := &macaroon.Macaroon{} + err = mac.UnmarshalBinary(macBytes) + if err != nil { + return err + } + + // Check the method being called against the permitted operation and + // the expiration time and IP address and return the result. + authChecker := svc.Checker.Auth(macaroon.Slice{mac}) + _, err = authChecker.Allow(ctx, requiredPermissions...) + return err +} + +// Close closes the database that underlies the RootKeyStore and zeroes the +// encryption keys. +func (svc *Service) Close() error { + return svc.rks.Close() +} + +// CreateUnlock calls the underlying root key store's CreateUnlock and returns +// the result. +func (svc *Service) CreateUnlock(password *[]byte) error { + return svc.rks.CreateUnlock(password) +} diff --git a/macaroons/store.go b/macaroons/store.go index d57c2ba9..8993fefd 100644 --- a/macaroons/store.go +++ b/macaroons/store.go @@ -8,6 +8,8 @@ import ( "golang.org/x/net/context" "github.com/boltdb/bolt" + + "github.com/roasbeef/btcwallet/snacl" ) const ( @@ -25,13 +27,28 @@ var ( // TODO(aakselrod): Add support for key rotation. defaultRootKeyID = []byte("0") - // macaroonBucketName is the name of the macaroon store bucket. - macaroonBucketName = []byte("macaroons") + // encryptedKeyID is the name of the database key that stores the + // encryption key, encrypted with a salted + hashed password. The + // format is 32 bytes of salt, and the rest is encrypted key. + encryptedKeyID = []byte("enckey") + + // ErrAlreadyUnlocked specifies that the store has already been + // unlocked. + ErrAlreadyUnlocked = fmt.Errorf("macaroon store already unlocked") + + // ErrStoreLocked specifies that the store needs to be unlocked with + // a password. + ErrStoreLocked = fmt.Errorf("macaroon store is locked") + + // ErrPasswordRequired specifies that a nil password has been passed. + ErrPasswordRequired = fmt.Errorf("a non-nil password is required") ) // RootKeyStorage implements the bakery.RootKeyStorage interface. type RootKeyStorage struct { *bolt.DB + + encKey *snacl.SecretKey } // NewRootKeyStorage creates a RootKeyStorage instance. @@ -47,11 +64,65 @@ func NewRootKeyStorage(db *bolt.DB) (*RootKeyStorage, error) { } // Return the DB wrapped in a RootKeyStorage object. - return &RootKeyStorage{db}, nil + return &RootKeyStorage{db, nil}, nil +} + +// CreateUnlock sets an encryption key if one is not already set, otherwise it +// checks if the password is correct for the stored encryption key. +func (r *RootKeyStorage) CreateUnlock(password *[]byte) error { + // Check if we've already unlocked the store; return an error if so. + if r.encKey != nil { + return ErrAlreadyUnlocked + } + + // Check if a nil password has been passed; return an error if so. + if password == nil { + return ErrPasswordRequired + } + + return r.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(rootKeyBucketName) + dbKey := bucket.Get(encryptedKeyID) + if len(dbKey) > 0 { + // We've already stored a key, so try to unlock with + // the password. + encKey := &snacl.SecretKey{} + err := encKey.Unmarshal(dbKey) + if err != nil { + return err + } + + err = encKey.DeriveKey(password) + if err != nil { + return err + } + + r.encKey = encKey + return nil + } + + // We haven't yet stored a key, so create a new one. + encKey, err := snacl.NewSecretKey(password, snacl.DefaultN, + snacl.DefaultR, snacl.DefaultP) + if err != nil { + return err + } + + err = bucket.Put(encryptedKeyID, encKey.Marshal()) + if err != nil { + return err + } + + r.encKey = encKey + return nil + }) } // Get implements the Get method for the bakery.RootKeyStorage interface. func (r *RootKeyStorage) Get(_ context.Context, id []byte) ([]byte, error) { + if r.encKey == nil { + return nil, ErrStoreLocked + } var rootKey []byte err := r.View(func(tx *bolt.Tx) error { dbKey := tx.Bucket(rootKeyBucketName).Get(id) @@ -60,8 +131,13 @@ func (r *RootKeyStorage) Get(_ context.Context, id []byte) ([]byte, error) { string(id)) } - rootKey = make([]byte, len(dbKey)) - copy(rootKey[:], dbKey) + decKey, err := r.encKey.Decrypt(dbKey) + if err != nil { + return err + } + + rootKey = make([]byte, len(decKey)) + copy(rootKey[:], decKey) return nil }) if err != nil { @@ -75,23 +151,40 @@ func (r *RootKeyStorage) Get(_ context.Context, id []byte) ([]byte, error) { // interface. // TODO(aakselrod): Add support for key rotation. func (r *RootKeyStorage) RootKey(_ context.Context) ([]byte, []byte, error) { + if r.encKey == nil { + return nil, nil, ErrStoreLocked + } var rootKey []byte id := defaultRootKeyID err := r.Update(func(tx *bolt.Tx) error { ns := tx.Bucket(rootKeyBucketName) - rootKey = ns.Get(id) + dbKey := ns.Get(id) - // If there's no root key stored in the bucket yet, create one. - if len(rootKey) != 0 { + // If there's a root key stored in the bucket, decrypt it and + // return it. + if len(dbKey) != 0 { + decKey, err := r.encKey.Decrypt(dbKey) + if err != nil { + return err + } + + rootKey = make([]byte, len(decKey)) + copy(rootKey[:], decKey[:]) return nil } - // Create a RootKeyLen-byte root key. + // Otherwise, create a RootKeyLen-byte root key, encrypt it, + // and store it in the bucket. rootKey = make([]byte, RootKeyLen) if _, err := io.ReadFull(rand.Reader, rootKey[:]); err != nil { return err } - return ns.Put(id, rootKey) + + encKey, err := r.encKey.Encrypt(rootKey) + if err != nil { + return err + } + return ns.Put(id, encKey) }) if err != nil { return nil, nil, err @@ -99,3 +192,10 @@ func (r *RootKeyStorage) RootKey(_ context.Context) ([]byte, []byte, error) { return rootKey, id, nil } + +// Close closes the underlying database and zeroes the encryption key stored +// in memory. +func (r *RootKeyStorage) Close() error { + r.encKey.Zero() + return r.DB.Close() +} diff --git a/macaroons/store_test.go b/macaroons/store_test.go new file mode 100644 index 00000000..8fb677d2 --- /dev/null +++ b/macaroons/store_test.go @@ -0,0 +1,137 @@ +package macaroons_test + +import ( + "bytes" + "io/ioutil" + "os" + "path" + "testing" + + "github.com/boltdb/bolt" + + "github.com/lightningnetwork/lnd/macaroons" + + "github.com/roasbeef/btcwallet/snacl" +) + +func TestStore(t *testing.T) { + tempDir, err := ioutil.TempDir("", "macaroonstore-") + if err != nil { + t.Fatalf("Error creating temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + db, err := bolt.Open(path.Join(tempDir, "weks.db"), 0600, + bolt.DefaultOptions) + if err != nil { + t.Fatalf("Error opening store DB: %v", err) + } + + store, err := macaroons.NewRootKeyStorage(db) + if err != nil { + db.Close() + t.Fatalf("Error creating root key store: %v", err) + } + defer store.Close() + + key, id, err := store.RootKey(nil) + if err != macaroons.ErrStoreLocked { + t.Fatalf("Received %v instead of ErrStoreLocked", err) + } + + key, err = store.Get(nil, nil) + if err != macaroons.ErrStoreLocked { + t.Fatalf("Received %v instead of ErrStoreLocked", err) + } + + pw := []byte("weks") + err = store.CreateUnlock(&pw) + if err != nil { + t.Fatalf("Error creating store encryption key: %v", err) + } + + key, id, err = store.RootKey(nil) + if err != nil { + t.Fatalf("Error getting root key from store: %v", err) + } + rootID := id + + key2, err := store.Get(nil, id) + if err != nil { + t.Fatalf("Error getting key with ID %s: %v", string(id), err) + } + if !bytes.Equal(key, key2) { + t.Fatalf("Root key doesn't match: expected %v, got %v", + key, key2) + } + + badpw := []byte("badweks") + err = store.CreateUnlock(&badpw) + if err != macaroons.ErrAlreadyUnlocked { + t.Fatalf("Received %v instead of ErrAlreadyUnlocked", err) + } + + store.Close() + // Between here and the re-opening of the store, it's possible to get + // a double-close, but that's not such a big deal since the tests will + // fail anyway in that case. + db, err = bolt.Open(path.Join(tempDir, "weks.db"), 0600, + bolt.DefaultOptions) + if err != nil { + t.Fatalf("Error opening store DB: %v", err) + } + + store, err = macaroons.NewRootKeyStorage(db) + if err != nil { + db.Close() + t.Fatalf("Error creating root key store: %v", err) + } + + err = store.CreateUnlock(&badpw) + if err != snacl.ErrInvalidPassword { + t.Fatalf("Received %v instead of ErrInvalidPassword", err) + } + + err = store.CreateUnlock(nil) + if err != macaroons.ErrPasswordRequired { + t.Fatalf("Received %v instead of ErrPasswordRequired", err) + } + + key, id, err = store.RootKey(nil) + if err != macaroons.ErrStoreLocked { + t.Fatalf("Received %v instead of ErrStoreLocked", err) + } + + key, err = store.Get(nil, nil) + if err != macaroons.ErrStoreLocked { + t.Fatalf("Received %v instead of ErrStoreLocked", err) + } + + err = store.CreateUnlock(&pw) + if err != nil { + t.Fatalf("Error unlocking root key store: %v", err) + } + + key, err = store.Get(nil, rootID) + if err != nil { + t.Fatalf("Error getting key with ID %s: %v", + string(rootID), err) + } + if !bytes.Equal(key, key2) { + t.Fatalf("Root key doesn't match: expected %v, got %v", + key2, key) + } + + key, id, err = store.RootKey(nil) + if err != nil { + t.Fatalf("Error getting root key from store: %v", err) + } + if !bytes.Equal(key, key2) { + t.Fatalf("Root key doesn't match: expected %v, got %v", + key2, key) + } + if !bytes.Equal(rootID, id) { + t.Fatalf("Root ID doesn't match: expected %v, got %v", + rootID, id) + } +} diff --git a/walletunlocker/service.go b/walletunlocker/service.go index 34780518..5b62afd7 100644 --- a/walletunlocker/service.go +++ b/walletunlocker/service.go @@ -5,10 +5,10 @@ import ( "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet/btcwallet" + "github.com/lightningnetwork/lnd/macaroons" "github.com/roasbeef/btcd/chaincfg" "github.com/roasbeef/btcwallet/wallet" "golang.org/x/net/context" - "gopkg.in/macaroon-bakery.v2/bakery" ) // UnlockerService implements the WalletUnlocker service used to provide lnd @@ -26,10 +26,11 @@ type UnlockerService struct { chainDir string netParams *chaincfg.Params + authSvc *macaroons.Service } // New creates and returns a new UnlockerService. -func New(authSvc *bakery.Bakery, chainDir string, +func New(authSvc *macaroons.Service, chainDir string, params *chaincfg.Params) *UnlockerService { return &UnlockerService{ CreatePasswords: make(chan []byte, 1), @@ -67,6 +68,15 @@ func (u *UnlockerService) CreateWallet(ctx context.Context, return nil, fmt.Errorf("wallet already exists") } + // Attempt to create a password for the macaroon service. + if u.authSvc != nil { + err = u.authSvc.CreateUnlock(&password) + if err != nil { + return nil, fmt.Errorf("unable to create/unlock "+ + "macaroon store: %v", err) + } + } + // 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 @@ -109,6 +119,15 @@ func (u *UnlockerService) UnlockWallet(ctx context.Context, return nil, err } + // Attempt to create a password for the macaroon service. + if u.authSvc != nil { + err = u.authSvc.CreateUnlock(&in.Password) + if err != nil { + return nil, fmt.Errorf("unable to create/unlock "+ + "macaroon store: %v", err) + } + } + // 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.