Merge pull request #1137 from guggero/macaroon-tests
macaroons: add unit tests and documentation
This commit is contained in:
commit
e422883069
@ -36,7 +36,8 @@ legitimate user.
|
|||||||
A macaroon is delegated by adding restrictions (called caveats) and an
|
A macaroon is delegated by adding restrictions (called caveats) and an
|
||||||
authentication code similar to a signature (technically an HMAC) to it. The
|
authentication code similar to a signature (technically an HMAC) to it. The
|
||||||
technical method of doing this is outside the scope of this overview
|
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:
|
user must remember several things:
|
||||||
|
|
||||||
* Sharing a macaroon allows anyone in possession of that macaroon to use it to
|
* Sharing a macaroon allows anyone in possession of that macaroon to use it to
|
||||||
|
5
lnd.go
5
lnd.go
@ -791,8 +791,9 @@ func genCertPair(certFile, keyFile string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// genMacaroons generates a pair of macaroon files; one admin-level and one
|
// genMacaroons generates three macaroon files; one admin-level, one
|
||||||
// read-only. These can also be used to generate more granular macaroons.
|
// 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,
|
func genMacaroons(ctx context.Context, svc *macaroons.Service,
|
||||||
admFile, roFile, invoiceFile string) error {
|
admFile, roFile, invoiceFile string) error {
|
||||||
|
|
||||||
|
89
macaroons/README.md
Normal file
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.
|
108
macaroons/constraints_test.go
Normal file
108
macaroons/constraints_test.go
Normal file
@ -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() {
|
for _, info := range c.Info() {
|
||||||
if info.Name == name && info.Prefix == "std" {
|
if info.Name == name &&
|
||||||
|
info.Prefix == "" &&
|
||||||
|
info.Namespace == "std" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
136
macaroons/service_test.go
Normal file
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
|
// invoicePermissions is a slice of all the entities that allows a user
|
||||||
// to only access calls that are related to invoices, so: streaming
|
// 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{
|
invoicePermissions = []bakery.Op{
|
||||||
{
|
{
|
||||||
Entity: "invoices",
|
Entity: "invoices",
|
||||||
|
Loading…
Reference in New Issue
Block a user