Merge pull request #9172 from guggero/initwallet-mac-root-key

cmd: allow deterministic macaroon derivation with `lncli`
This commit is contained in:
Oliver Gugger authored and GitHub committed 2024-10-15 12:09:02 +02:00
commit 5f86e2529b
6 files changed
+289 -29

No files matched your search

+67 -25
View File
@@ -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
}
+52 -4
View File
@@ -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
+40
View File
@@ -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
@@ -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
+68
View File
@@ -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
}
+58
View File
@@ -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)
}