Merge pull request #1137 from guggero/macaroon-tests

macaroons: add unit tests and documentation
This commit is contained in:
Olaoluwa Osuntokun 2018-05-18 16:39:37 -07:00 committed by GitHub
commit e422883069
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 342 additions and 5 deletions

@ -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

5
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 {

89
macaroons/README.md Normal file

@ -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><digest><N><R><P>`
* `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.

@ -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.")
}
}

@ -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
}
}

136
macaroons/service_test.go Normal file

@ -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)
}
}

@ -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",