diff --git a/cmd/commands/cmd_macaroon.go b/cmd/commands/cmd_macaroon.go index 149c7db453..15c29380a7 100644 --- a/cmd/commands/cmd_macaroon.go +++ b/cmd/commands/cmd_macaroon.go @@ -10,6 +10,7 @@ import ( "strings" "unicode" + "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/lncfg" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/macaroons" @@ -38,6 +39,12 @@ var ( Usage: "the condition of the custom caveat to add, can be " + "empty if custom caveat doesn't need a value", } + bakeFromRootKeyFlag = cli.StringFlag{ + Name: "root_key", + Usage: "if the root key is known, it can be passed directly " + + "as a hex encoded string, turning the command into " + + "an offline operation", + } ) var bakeMacaroonCommand = cli.Command{ @@ -48,7 +55,7 @@ var bakeMacaroonCommand = cli.Command{ ArgsUsage: "[--save_to=] [--timeout=] [--ip_address=] " + "[--custom_caveat_name= [--custom_caveat_condition=]] " + "[--root_key_id=] [--allow_external_permissions] " + - "permissions...", + "[--root_key=] permissions...", Description: ` Bake a new macaroon that grants the provided permissions and optionally adds restrictions (timeout, IP address) to it. @@ -74,6 +81,12 @@ var bakeMacaroonCommand = cli.Command{ To get a list of all available URIs and permissions, use the "lncli listpermissions" command. + + If the root key is known (for example because "lncli create" was used + with a custom --mac_root_key value), it can be passed directly as a + hex encoded string using the --root_key flag. This turns the command + into an offline operation and the macaroon will be created without + calling into the server's RPC endpoint. `, Flags: []cli.Flag{ cli.StringFlag{ @@ -95,14 +108,13 @@ var bakeMacaroonCommand = cli.Command{ Usage: "whether permissions lnd is not familiar with " + "are allowed", }, + bakeFromRootKeyFlag, }, Action: actionDecorator(bakeMacaroon), } func bakeMacaroon(ctx *cli.Context) error { ctxc := getContext() - client, cleanUp := getClient(ctx) - defer cleanUp() // Show command help if no arguments. if ctx.NArg() == 0 { @@ -154,36 +166,66 @@ func bakeMacaroon(ctx *cli.Context) error { ) } - // Now we have gathered all the input we need and can do the actual - // RPC call. - req := &lnrpc.BakeMacaroonRequest{ - Permissions: parsedPermissions, - RootKeyId: rootKeyID, - AllowExternalPermissions: ctx.Bool("allow_external_permissions"), - } - resp, err := client.BakeMacaroon(ctxc, req) - if err != nil { - return err - } + var rawMacaroon *macaroon.Macaroon + switch { + case ctx.IsSet(bakeFromRootKeyFlag.Name): + macRootKey, err := hex.DecodeString( + ctx.String(bakeFromRootKeyFlag.Name), + ) + if err != nil { + return fmt.Errorf("unable to parse macaroon root key: "+ + "%w", err) + } - // Now we should have gotten a valid macaroon. Unmarshal it so we can - // add first-party caveats (if necessary) to it. - macBytes, err := hex.DecodeString(resp.Macaroon) - if err != nil { - return err - } - unmarshalMac := &macaroon.Macaroon{} - if err = unmarshalMac.UnmarshalBinary(macBytes); err != nil { - return err + ops := fn.Map(func(p *lnrpc.MacaroonPermission) bakery.Op { + return bakery.Op{ + Entity: p.Entity, + Action: p.Action, + } + }, parsedPermissions) + + rawMacaroon, err = macaroons.BakeFromRootKey(macRootKey, ops) + if err != nil { + return fmt.Errorf("unable to bake macaroon: %w", err) + } + + default: + client, cleanUp := getClient(ctx) + defer cleanUp() + + // Now we have gathered all the input we need and can do the + // actual RPC call. + req := &lnrpc.BakeMacaroonRequest{ + Permissions: parsedPermissions, + RootKeyId: rootKeyID, + AllowExternalPermissions: ctx.Bool( + "allow_external_permissions", + ), + } + resp, err := client.BakeMacaroon(ctxc, req) + if err != nil { + return err + } + + // Now we should have gotten a valid macaroon. Unmarshal it so + // we can add first-party caveats (if necessary) to it. + macBytes, err := hex.DecodeString(resp.Macaroon) + if err != nil { + return err + } + rawMacaroon = &macaroon.Macaroon{} + if err = rawMacaroon.UnmarshalBinary(macBytes); err != nil { + return err + } } // Now apply the desired constraints to the macaroon. This will always // create a new macaroon object, even if no constraints are added. - constrainedMac, err := applyMacaroonConstraints(ctx, unmarshalMac) + constrainedMac, err := applyMacaroonConstraints(ctx, rawMacaroon) if err != nil { return err } - macBytes, err = constrainedMac.MarshalBinary() + macBytes, err := constrainedMac.MarshalBinary() if err != nil { return err } diff --git a/cmd/commands/cmd_walletunlocker.go b/cmd/commands/cmd_walletunlocker.go index a0a286047b..49a14b0e6c 100644 --- a/cmd/commands/cmd_walletunlocker.go +++ b/cmd/commands/cmd_walletunlocker.go @@ -12,6 +12,7 @@ import ( "github.com/lightningnetwork/lnd/lncfg" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" + "github.com/lightningnetwork/lnd/macaroons" "github.com/lightningnetwork/lnd/walletunlocker" "github.com/urfave/cli" ) @@ -26,6 +27,13 @@ var ( Name: "save_to", Usage: "save returned admin macaroon to this file", } + macRootKeyFlag = cli.StringFlag{ + Name: "mac_root_key", + Usage: "macaroon root key to use when initializing the " + + "macaroon store; allows for deterministic macaroon " + + "generation; if not set, a random one will be " + + "created", + } ) var createCommand = cli.Command{ @@ -81,6 +89,7 @@ var createCommand = cli.Command{ }, statelessInitFlag, saveToFlag, + macRootKeyFlag, }, Action: actionDecorator(create), } @@ -261,6 +270,7 @@ mnemonicCheck: extendedRootKey string extendedRootKeyBirthday uint64 recoveryWindow int32 + macRootKey []byte ) switch { // Use an existing cipher seed mnemonic in the aezeed format. @@ -366,6 +376,23 @@ mnemonicCheck: printCipherSeedWords(cipherSeedMnemonic) } + // Parse the macaroon root key if it was specified by the user. + if ctx.IsSet(macRootKeyFlag.Name) { + macRootKey, err = hex.DecodeString( + ctx.String(macRootKeyFlag.Name), + ) + if err != nil { + return fmt.Errorf("unable to parse macaroon root key: "+ + "%w", err) + } + + if len(macRootKey) != macaroons.RootKeyLen { + return fmt.Errorf("macaroon root key must be exactly "+ + "%v bytes, got %v", macaroons.RootKeyLen, + len(macRootKey)) + } + } + // With either the user's prior cipher seed, or a newly generated one, // we'll go ahead and initialize the wallet. req := &lnrpc.InitWalletRequest{ @@ -377,6 +404,7 @@ mnemonicCheck: RecoveryWindow: recoveryWindow, ChannelBackups: chanBackups, StatelessInit: statelessInit, + MacaroonRootKey: macRootKey, } response, err := client.InitWallet(ctxc, req) if err != nil { @@ -687,6 +715,7 @@ var createWatchOnlyCommand = cli.Command{ Flags: []cli.Flag{ statelessInitFlag, saveToFlag, + macRootKeyFlag, }, Action: actionDecorator(createWatchOnly), } @@ -764,11 +793,30 @@ func createWatchOnly(ctx *cli.Context) error { } } + // Parse the macaroon root key if it was specified by the user. + var macRootKey []byte + if ctx.IsSet(macRootKeyFlag.Name) { + macRootKey, err = hex.DecodeString( + ctx.String(macRootKeyFlag.Name), + ) + if err != nil { + return fmt.Errorf("unable to parse macaroon root key: "+ + "%w", err) + } + + if len(macRootKey) != macaroons.RootKeyLen { + return fmt.Errorf("macaroon root key must be exactly "+ + "%v bytes, got %v", macaroons.RootKeyLen, + len(macRootKey)) + } + } + initResp, err := client.InitWallet(ctxc, &lnrpc.InitWalletRequest{ - WalletPassword: walletPassword, - WatchOnly: rpcResp, - RecoveryWindow: recoveryWindow, - StatelessInit: statelessInit, + WalletPassword: walletPassword, + WatchOnly: rpcResp, + RecoveryWindow: recoveryWindow, + StatelessInit: statelessInit, + MacaroonRootKey: macRootKey, }) if err != nil { return err diff --git a/docs/macaroons.md b/docs/macaroons.md index c30eb2cf32..5cc0150b75 100644 --- a/docs/macaroons.md +++ b/docs/macaroons.md @@ -158,6 +158,46 @@ Examples: $ lncli --macaroonpath=/safe/location/admin.macaroon getinfo ``` +## Using deterministic/pre-generated macaroons + +All macaroons are derived from a secret root key (by default from the root key +with the ID `"0"`). That root key is randomly generated when the macaroon store +is first initialized (when the wallet is created) and is therefore not +deterministic by default. + +It can be useful to use a deterministic (or pre-generated) root key, which is +why the `InitWallet` RPC (or the `lncli create` or `lncli createwatchonly` +counterparts) allows a root key to be specified. + +Using a pre-generated root key can be useful for scenarios like: +* Testing: If a node is always initialized with the same root key for each test + run, then macaroons generated in one test run can be re-used in another run + and don't need to be re-derived. +* Remote signing setup: When using a remote signing setup where there are two + related `lnd` nodes (e.g. a watch-only and a signer pair), it can be useful + to generate a valid macaroon _before_ any of the nodes are even started up. + +**Example**: + +The following example shows how a valid macaroon can be generated before even +starting a node: + +```shell +# Randomly generate a 32-byte long secret root key and encode it as hex. +ROOT_KEY=$(cat /dev/urandom | head -c32 | xxd -p -c32) + +# Derive a read-only macaroon from that root key. +# NOTE: When using the --root_key flag, the `lncli bakemacaroon` command is +# fully offline and does not need to connect to any lnd node. +lncli bakemacaroon --root_key $ROOT_KEY --save_to /tmp/info.macaroon info:read + +# Create the lnd node now, using the same root key. +lncli create --mac_root_key $ROOT_KEY + +# Use the pre-generated macaroon for a call. +lncli --macaroonpath /tmp/info.macaroon getinfo +``` + ## Using Macaroons with GRPC clients When interacting with `lnd` using the GRPC interface, the macaroons are encoded diff --git a/docs/release-notes/release-notes-0.19.0.md b/docs/release-notes/release-notes-0.19.0.md index 204d25a54d..9d73311898 100644 --- a/docs/release-notes/release-notes-0.19.0.md +++ b/docs/release-notes/release-notes-0.19.0.md @@ -46,6 +46,10 @@ ## lncli Additions +* [A pre-generated macaroon root key can now be specified in `lncli create` and + `lncli createwatchonly`](https://github.com/lightningnetwork/lnd/pull/9172) to + allow for deterministic macaroon generation. + # Improvements ## Functional Updates diff --git a/macaroons/bake.go b/macaroons/bake.go new file mode 100644 index 0000000000..29904d1515 --- /dev/null +++ b/macaroons/bake.go @@ -0,0 +1,68 @@ +package macaroons + +import ( + "bytes" + "fmt" + + "golang.org/x/net/context" + "gopkg.in/macaroon-bakery.v2/bakery" + "gopkg.in/macaroon.v2" +) + +// inMemoryRootKeyStore is a simple implementation of bakery.RootKeyStore that +// stores a single root key in memory. +type inMemoryRootKeyStore struct { + rootKey []byte +} + +// A compile-time check to ensure that inMemoryRootKeyStore implements +// bakery.RootKeyStore. +var _ bakery.RootKeyStore = (*inMemoryRootKeyStore)(nil) + +// Get returns the root key for the given id. If the item is not there, it +// returns ErrNotFound. +func (s *inMemoryRootKeyStore) Get(_ context.Context, id []byte) ([]byte, + error) { + + if !bytes.Equal(id, DefaultRootKeyID) { + return nil, bakery.ErrNotFound + } + + return s.rootKey, nil +} + +// RootKey returns the root key to be used for making a new macaroon, and an id +// that can be used to look it up later with the Get method. +func (s *inMemoryRootKeyStore) RootKey(context.Context) ([]byte, []byte, + error) { + + return s.rootKey, DefaultRootKeyID, nil +} + +// BakeFromRootKey creates a new macaroon that is derived from the given root +// key and permissions. +func BakeFromRootKey(rootKey []byte, + permissions []bakery.Op) (*macaroon.Macaroon, error) { + + if len(rootKey) != RootKeyLen { + return nil, fmt.Errorf("root key must be %d bytes, is %d", + RootKeyLen, len(rootKey)) + } + + rootKeyStore := &inMemoryRootKeyStore{ + rootKey: rootKey, + } + + service, err := NewService(rootKeyStore, "lnd", false) + if err != nil { + return nil, fmt.Errorf("unable to create service: %w", err) + } + + ctx := context.Background() + mac, err := service.NewMacaroon(ctx, DefaultRootKeyID, permissions...) + if err != nil { + return nil, fmt.Errorf("unable to create macaroon: %w", err) + } + + return mac.M(), nil +} diff --git a/macaroons/bake_test.go b/macaroons/bake_test.go new file mode 100644 index 0000000000..20c22b647b --- /dev/null +++ b/macaroons/bake_test.go @@ -0,0 +1,58 @@ +package macaroons_test + +import ( + "context" + "encoding/hex" + "testing" + + "github.com/lightningnetwork/lnd/macaroons" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" + "gopkg.in/macaroon-bakery.v2/bakery" +) + +// TestBakeFromRootKey tests that a macaroon can be baked from a root key +// directly without needing to create a store or service first. +func TestBakeFromRootKey(t *testing.T) { + // Create a test store and unlock it. + _, store := newTestStore(t) + + pw := []byte("weks") + err := store.CreateUnlock(&pw) + require.NoError(t, err) + + // Force the store to create a new random root key. + key, id, err := store.RootKey(defaultRootKeyIDContext) + require.NoError(t, err) + require.Len(t, key, 32) + + tmpKey, err := store.Get(defaultRootKeyIDContext, id) + require.NoError(t, err) + require.Equal(t, key, tmpKey) + + // Create a service that uses the root key store. + service, err := macaroons.NewService(store, "lnd", false) + require.NoError(t, err, "Error creating new service") + defer func() { + require.NoError(t, service.Close()) + }() + + // Call the BakeFromRootKey function that derives a macaroon directly + // from the root key. + perms := []bakery.Op{{Entity: "foo", Action: "bar"}} + mac, err := macaroons.BakeFromRootKey(key, perms) + require.NoError(t, err) + + macaroonBytes, err := mac.MarshalBinary() + require.NoError(t, err) + + md := metadata.New(map[string]string{ + "macaroon": hex.EncodeToString(macaroonBytes), + }) + macCtx := metadata.NewIncomingContext(context.Background(), md) + + // The macaroon should be valid for the service, since the root key was + // the same. + err = service.ValidateMacaroon(macCtx, nil, "baz") + require.NoError(t, err) +}