diff --git a/docs/macaroons.md b/docs/macaroons.md index eb5bc507..8b83b81b 100644 --- a/docs/macaroons.md +++ b/docs/macaroons.md @@ -36,7 +36,8 @@ legitimate user. A macaroon is delegated by adding restrictions (called caveats) and an authentication code similar to a signature (technically an HMAC) to it. The technical method of doing this is outside the scope of this overview -documentation, but the macaroon paper linked above describes it quite well. The +documentation, but the [README in the macaroons package](../macaroons/README.md) +or the macaroon paper linked above describe it in more detail. The user must remember several things: * Sharing a macaroon allows anyone in possession of that macaroon to use it to diff --git a/lnd.go b/lnd.go index 0ca432f6..93e4905f 100644 --- a/lnd.go +++ b/lnd.go @@ -791,8 +791,9 @@ func genCertPair(certFile, keyFile string) error { return nil } -// genMacaroons generates a pair of macaroon files; one admin-level and one -// read-only. These can also be used to generate more granular macaroons. +// 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. func genMacaroons(ctx context.Context, svc *macaroons.Service, admFile, roFile, invoiceFile string) error { diff --git a/macaroons/README.md b/macaroons/README.md new file mode 100644 index 00000000..87ab93bb --- /dev/null +++ b/macaroons/README.md @@ -0,0 +1,89 @@ +# macaroons + +This is a more detailed, technical description of how macaroons work and how +authentication and authorization is implemented in `lnd`. + +For a more high-level overview see +[macaroons.md in the docs](../docs/macaroons.md). + +## Root key + +At startup, if the option `--no-macaroons` is **not** used, a Bolt DB key/value +store named `data/macaroons.db` is created with a bucket named `macrootkeys`. +In this DB the following two key/value pairs are stored: + +* Key `0`: the encrypted root key (32 bytes). + * If the root key does not exist yet, 32 bytes of pseudo-random data is + generated and used. +* Key `enckey`: the parameters used to derive a secret encryption key from a + passphrase. + * The following parameters are stored: `

` + * `salt`: 32 byte of random data used as salt for the `scrypt` key + derivation. + * `digest`: sha256 hashed key derived from the `scrypt` operation. Is used + to verify if the password is correct. + * `N`, `P`, `R`: Parameters used for the `scrypt` operation. + * The root key is symmetrically encrypted with the derived secret key, using + the `secretbox` method of the library + [btcsuite/golangcrypto](https://github.com/btcsuite/golangcrypto). + * If the option `--noencryptwallet` is used, then the default passphrase + `hello` is used to encrypt the root key. + +## Generated macaroons + +With the root key set up, `lnd` continues with creating three macaroon files: + +* `invoice.macaroon`: Grants read and write access to all invoice related gRPC + commands (like generating an address or adding an invoice). Can be used for a + web shop application for example. Paying an invoice is not possible, even if + the name might suggest it. The permission `offchain` is needed to pay an + invoice which is currently only granted in the admin macaroon. +* `readonly.macaroon`: Grants read-only access to all gRPC commands. Could be + given to a monitoring application for example. +* `admin.macaroon`: Grants full read and write access to all gRPC commands. + This is used by the `lncli` client. + +These three macaroons all have the location field set to `lnd` and have no +conditions/first party caveats or third party caveats set. + +The access restrictions are implemented with a list of entity/action pairs that +is mapped to the gRPC functions by the `rpcserver.go`. +For example, the permissions for the `invoice.macaroon` looks like this: + +```go + // invoicePermissions is a slice of all the entities that allows a user + // to only access calls that are related to invoices, so: streaming + // RPCs, generating, and listening invoices. + invoicePermissions = []bakery.Op{ + { + Entity: "invoices", + Action: "read", + }, + { + Entity: "invoices", + Action: "write", + }, + { + Entity: "address", + Action: "read", + }, + { + Entity: "address", + Action: "write", + }, + } +``` + +## Constraints / First party caveats + +There are currently two constraints implemented that can be used by `lncli` to +restrict the macaroon it uses to communicate with the gRPC interface. These can +be found in `constraints.go`: + +* `TimeoutConstraint`: Set a timeout in seconds after which the macaroon is no + longer valid. + This constraint can be set by adding the parameter `--macaroontimeout xy` to + the `lncli` command. +* `IPLockConstraint`: Locks the macaroon to a specific IP address. + This constraint can be set by adding the parameter `--macaroonip a.b.c.d` to + the `lncli` command. diff --git a/macaroons/constraints_test.go b/macaroons/constraints_test.go new file mode 100644 index 00000000..694d7841 --- /dev/null +++ b/macaroons/constraints_test.go @@ -0,0 +1,108 @@ +package macaroons_test + +import ( + "testing" + "github.com/lightningnetwork/lnd/macaroons" + "gopkg.in/macaroon.v2" + "time" + "strings" +) + +var ( + testRootKey = []byte("dummyRootKey") + testId = []byte("dummyId") + testLocation = "lnd" + testVersion = macaroon.LatestVersion + expectedTimeCaveatSubstring = "time-before " + string(time.Now().Year()) +) + +func createDummyMacaroon(t *testing.T) *macaroon.Macaroon { + dummyMacaroon, err := macaroon.New(testRootKey, testId, + testLocation, testVersion) + if err != nil { + t.Fatalf("Error creating initial macaroon: %v", err) + } + return dummyMacaroon +} + +// TestAddConstraints tests that constraints can be added to an existing +// macaroon and therefore tighten its restrictions. +func TestAddConstraints(t *testing.T) { + // We need a dummy macaroon to start with. Create one without + // a bakery, because we mock everything anyway. + initialMac := createDummyMacaroon(t) + + // Now add a constraint and make sure we have a cloned macaroon + // with the constraint applied instead of a mutated initial one. + newMac, err := macaroons.AddConstraints(initialMac, + macaroons.TimeoutConstraint(1)) + if err != nil { + t.Fatalf("Error adding constraint: %v", err) + } + if &newMac == &initialMac { + t.Fatalf("Initial macaroon has been changed, something " + + "went wrong!") + } + + // Finally, test that the constraint has been added. + if len(initialMac.Caveats()) == len(newMac.Caveats()) { + t.Fatalf("No caveat has been added to the macaroon when " + + "constraint was applied") + } +} + +// TestTimeoutConstraint tests that a caveat for the lifetime of +// a macaroon is created. +func TestTimeoutConstraint(t *testing.T) { + // Get a configured version of the constraint function. + constraintFunc := macaroons.TimeoutConstraint(3) + + // Now we need a dummy macaroon that we can apply the constraint + // function to. + testMacaroon := createDummyMacaroon(t) + err := constraintFunc(testMacaroon) + if err != nil { + t.Fatalf("Error applying timeout constraint: %v", err) + } + + // Finally, check that the created caveat has an + // acceptable value + if strings.HasPrefix(string(testMacaroon.Caveats()[0].Id), + expectedTimeCaveatSubstring) { + t.Fatalf("Added caveat '%s' does not meet the expectations!", + testMacaroon.Caveats()[0].Id) + } +} + +// TestTimeoutConstraint tests that a caveat for the lifetime of +// a macaroon is created. +func TestIpLockConstraint(t *testing.T) { + // Get a configured version of the constraint function. + constraintFunc := macaroons.IPLockConstraint("127.0.0.1") + + // Now we need a dummy macaroon that we can apply the constraint + // function to. + testMacaroon := createDummyMacaroon(t) + err := constraintFunc(testMacaroon) + if err != nil { + t.Fatalf("Error applying timeout constraint: %v", err) + } + + // Finally, check that the created caveat has an + // acceptable value + if string(testMacaroon.Caveats()[0].Id) != "ipaddr 127.0.0.1" { + t.Fatalf("Added caveat '%s' does not meet the expectations!", + testMacaroon.Caveats()[0].Id) + } +} + +// TestIPLockBadIP tests that an IP constraint cannot be added if the +// provided string is not a valid IP address. +func TestIPLockBadIP(t *testing.T) { + constraintFunc := macaroons.IPLockConstraint("127.0.0/800"); + testMacaroon := createDummyMacaroon(t) + err := constraintFunc(testMacaroon) + if err == nil { + t.Fatalf("IPLockConstraint with bad IP should fail.") + } +} diff --git a/macaroons/service.go b/macaroons/service.go index 77363f6c..87ebc31a 100644 --- a/macaroons/service.go +++ b/macaroons/service.go @@ -85,7 +85,9 @@ func isRegistered(c *checkers.Checker, name string) bool { } for _, info := range c.Info() { - if info.Name == name && info.Prefix == "std" { + if info.Name == name && + info.Prefix == "" && + info.Namespace == "std" { return true } } diff --git a/macaroons/service_test.go b/macaroons/service_test.go new file mode 100644 index 00000000..599bcb88 --- /dev/null +++ b/macaroons/service_test.go @@ -0,0 +1,136 @@ +package macaroons_test + +import ( + "testing" + "path" + "os" + "context" + "io/ioutil" + "encoding/hex" + + "github.com/coreos/bbolt" + "github.com/lightningnetwork/lnd/macaroons" + "gopkg.in/macaroon-bakery.v2/bakery/checkers" + "gopkg.in/macaroon-bakery.v2/bakery" + "google.golang.org/grpc/metadata" +) + +var ( + testOperation = bakery.Op{ + Entity: "testEntity", + Action: "read", + } + defaultPw = []byte("hello") +) + +// setupTestRootKeyStorage creates a dummy root key storage by +// creating a temporary macaroons.db and initializing it with the +// default password of 'hello'. Only the path to the temporary +// DB file is returned, because the service will open the file +// and read the store on its own. +func setupTestRootKeyStorage(t *testing.T) string { + tempDir, err := ioutil.TempDir("", "macaroonstore-") + if err != nil { + t.Fatalf("Error creating temp dir: %v", err) + } + db, err := bolt.Open(path.Join(tempDir, "macaroons.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() + err = store.CreateUnlock(&defaultPw) + return tempDir +} + +// TestNewService tests the creation of the macaroon service. +func TestNewService(t *testing.T) { + // First, initialize a dummy DB file with a store that the service + // can read from. Make sure the file is removed in the end. + tempDir := setupTestRootKeyStorage(t) + defer os.RemoveAll(tempDir) + + // Second, create the new service instance, unlock it and pass in a + // checker that we expect it to add to the bakery. + service, err := macaroons.NewService(tempDir, macaroons.IPLockChecker) + defer service.Close() + if err != nil { + t.Fatalf("Error creating new service: %v", err) + } + err = service.CreateUnlock(&defaultPw) + if err != nil { + t.Fatalf("Error unlocking root key storage: %v", err) + } + + // Third, check if the created service can bake macaroons. + macaroon, err := service.Oven.NewMacaroon(nil, bakery.LatestVersion, + nil, testOperation) + if err != nil { + t.Fatalf("Error creating macaroon from service: %v", err) + } + if macaroon.Namespace().String() != "std:" { + t.Fatalf("The created macaroon has an invalid namespace: %s", + macaroon.Namespace().String()) + } + + // Finally, check if the service has been initialized correctly and + // the checker has been added. + var checkerFound = false + checker := service.Checker.FirstPartyCaveatChecker.(*checkers.Checker) + for _, info := range checker.Info() { + if info.Name == "ipaddr" && + info.Prefix == "" && + info.Namespace == "std" { + checkerFound = true + } + } + if !checkerFound { + t.Fatalf("Checker '%s' not found in service.", "ipaddr") + } +} + +// TestValidateMacaroon tests the validation of a macaroon that is in an +// incoming context. +func TestValidateMacaroon(t *testing.T) { + // First, initialize the service and unlock it. + tempDir := setupTestRootKeyStorage(t) + defer os.RemoveAll(tempDir) + service, err := macaroons.NewService(tempDir, macaroons.IPLockChecker) + defer service.Close() + if err != nil { + t.Fatalf("Error creating new service: %v", err) + } + err = service.CreateUnlock(&defaultPw) + if err != nil { + t.Fatalf("Error unlocking root key storage: %v", err) + } + + // Then, create a new macaroon that we can serialize. + macaroon, err := service.Oven.NewMacaroon(nil, bakery.LatestVersion, + nil, testOperation) + if err != nil { + t.Fatalf("Error creating macaroon from service: %v", err) + } + macaroonBinary, err := macaroon.M().MarshalBinary() + if err != nil { + t.Fatalf("Error serializing macaroon: %v", err) + } + + // Because the macaroons are always passed in a context, we need to + // mock one that has just the serialized macaroon as a value. + md := metadata.New(map[string]string{ + "macaroon": hex.EncodeToString(macaroonBinary), + }) + mockContext := metadata.NewIncomingContext(context.Background(), md) + + // Finally, validate the macaroon against the required permissions. + err = service.ValidateMacaroon(mockContext, []bakery.Op{testOperation}) + if err != nil { + t.Fatalf("Error validating the macaroon: %v", err) + } +} diff --git a/rpcserver.go b/rpcserver.go index f22f091f..6f0d01e1 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -110,7 +110,7 @@ var ( // invoicePermissions is a slice of all the entities that allows a user // to only access calls that are related to invoices, so: streaming - // RPC's, generating, and listening invoices. + // RPCs, generating, and listening invoices. invoicePermissions = []bakery.Op{ { Entity: "invoices",