macaroons: add super macaroon helpers

Introduce helper functions in the macaroons package to manage super
macaroon files and permissions. Add SuperMacaroonExists,
MacaroonMatchesPermissions, and BakeAndWriteSuperMacaroon.
This commit is contained in:
cyberguru1 2026-06-13 18:24:33 -05:00
parent e093948a35
commit 3975e83006
No known key found for this signature in database
GPG key ID: F0FB5ECF1A8786E6
2 changed files with 250 additions and 0 deletions

View file

@ -6,8 +6,12 @@ import (
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt"
"os"
"strings"
"github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc"
"google.golang.org/protobuf/proto"
"gopkg.in/macaroon-bakery.v2/bakery" "gopkg.in/macaroon-bakery.v2/bakery"
"gopkg.in/macaroon.v2" "gopkg.in/macaroon.v2"
) )
@ -118,3 +122,127 @@ func BakeSuperMacaroon(ctx context.Context, lnd lnrpc.LightningClient,
return hex.EncodeToString(macBytes), err return hex.EncodeToString(macBytes), err
} }
// SuperMacaroonExists determines whether a macaroon file exists at the given
// path.
func SuperMacaroonExists(path string) bool {
if _, err := os.Stat(path); err != nil {
return false
}
return true
}
// MacaroonMatchesPermissions checks if the macaroon at the given path contains
// exactly the expected permissions (no more and no less).
func MacaroonMatchesPermissions(path string,
expectedPerms []bakery.Op) (bool, error) {
macBytes, err := os.ReadFile(path)
if err != nil {
return false, err
}
mac := &macaroon.Macaroon{}
if err := mac.UnmarshalBinary(macBytes); err != nil {
return false, err
}
rawID := mac.Id()
if len(rawID) == 0 || rawID[0] != byte(bakery.LatestVersion) {
return false, errors.New("invalid macaroon version")
}
decodedID := &lnrpc.MacaroonId{}
if err := proto.Unmarshal(rawID[1:], decodedID); err != nil {
return false, err
}
// Map expected permissions for easy lookup: entity -> action -> true.
expectedMap := make(map[string]map[string]bool)
for _, op := range expectedPerms {
if expectedMap[op.Entity] == nil {
expectedMap[op.Entity] = make(map[string]bool)
}
expectedMap[op.Entity][op.Action] = true
}
// Map actual permissions from decoded macaroon ID:
// entity -> action -> true.
actualMap := make(map[string]map[string]bool)
for _, op := range decodedID.Ops {
if op == nil {
continue
}
if actualMap[op.Entity] == nil {
actualMap[op.Entity] = make(map[string]bool)
}
for _, action := range op.Actions {
actualMap[op.Entity][action] = true
}
}
// Compare the mapped sets for exact equality.
if len(expectedMap) != len(actualMap) {
return false, nil
}
for entity, actions := range expectedMap {
actualActions, ok := actualMap[entity]
if !ok || len(actions) != len(actualActions) {
return false, nil
}
for action := range actions {
if !actualActions[action] {
return false, nil
}
}
}
return true, nil
}
// BakeAndWriteSuperMacaroon bakes a super macaroon and writes it to disk.
func BakeAndWriteSuperMacaroon(ctx context.Context, lnd lnrpc.LightningClient,
path string, perms []bakery.Op) error {
var suffixBytes [4]byte
rootKeyID := NewSuperMacaroonRootKeyID(suffixBytes)
superMacHex, err := BakeSuperMacaroon(
ctx, lnd, rootKeyID, perms, nil,
)
if err != nil {
return fmt.Errorf("unable to bake super macaroon: %w", err)
}
superMacBytes, err := hex.DecodeString(superMacHex)
if err != nil {
return fmt.Errorf("unable to decode baked "+
"super macaroon: %w", err)
}
if err := os.WriteFile(path, superMacBytes, 0600); err != nil {
return fmt.Errorf("unable to write super macaroon to %v: %w",
path, err)
}
return nil
}
// HasMacaroonSuffix checks that the super macaroon path is not empty and ends
// with the expected suffix.
func HasMacaroonSuffix(path string) error {
if path == "" {
return fmt.Errorf("super-macaroon-path cannot be empty")
}
if !strings.HasSuffix(path, ".macaroon") {
return fmt.Errorf("super-macaroon-path must end with the " +
".macaroon suffix")
}
return nil
}

View file

@ -1,9 +1,13 @@
package macaroons package macaroons
import ( import (
"encoding/hex"
"os"
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gopkg.in/macaroon-bakery.v2/bakery"
) )
var ( var (
@ -43,3 +47,121 @@ func TestIsSuperMacaroon(t *testing.T) {
require.True(t, IsSuperMacaroon(testMacHex)) require.True(t, IsSuperMacaroon(testMacHex))
} }
// TestSuperMacaroonHelpers tests that SuperMacaroonExists and
// MacaroonMatchesPermissions behave correctly.
func TestSuperMacaroonHelpers(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
path := filepath.Join(tempDir, "test.macaroon")
// Verify that it doesn't exist yet.
require.False(t, SuperMacaroonExists(path))
// Write the test macaroon.
macBytes, err := hex.DecodeString(testMacHex)
require.NoError(t, err)
err = os.WriteFile(path, macBytes, 0600)
require.NoError(t, err)
// Now it should exist.
require.True(t, SuperMacaroonExists(path))
// The macaroon matches this expected list of permissions.
expectedPerms := []bakery.Op{
{Entity: "account", Action: "read"},
{Entity: "auction", Action: "read"},
{Entity: "audit", Action: "read"},
{Entity: "auth", Action: "read"},
{Entity: "info", Action: "read"},
{Entity: "insights", Action: "read"},
{Entity: "invoices", Action: "read"},
{Entity: "loop", Action: "in"},
{Entity: "loop", Action: "out"},
{Entity: "macaroon", Action: "read"},
{Entity: "message", Action: "read"},
{Entity: "offchain", Action: "read"},
{Entity: "onchain", Action: "read"},
{Entity: "order", Action: "read"},
{Entity: "peers", Action: "read"},
{Entity: "rates", Action: "read"},
{Entity: "recommendation", Action: "read"},
{Entity: "report", Action: "read"},
{Entity: "suggestions", Action: "read"},
{Entity: "swap", Action: "read"},
{Entity: "terms", Action: "read"},
}
matches, err := MacaroonMatchesPermissions(path, expectedPerms)
require.NoError(t, err)
require.True(t, matches)
// A subset of permissions should NOT match exactly.
matches, err = MacaroonMatchesPermissions(path, expectedPerms[:5])
require.NoError(t, err)
require.False(t, matches)
// Extra/different permissions should NOT match exactly.
differentPerms := append(
expectedPerms,
bakery.Op{Entity: "invalid", Action: "write"},
)
matches, err = MacaroonMatchesPermissions(path, differentPerms)
require.NoError(t, err)
require.False(t, matches)
}
// TestHasMacaroonSuffix tests that HasMacaroonSuffix correctly
// checks the suffix of the super macaroon path.
func TestHasMacaroonSuffix(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
wantErr bool
errStr string
}{
{
name: "empty path",
path: "",
wantErr: true,
errStr: "super-macaroon-path cannot be empty",
},
{
name: "valid path",
path: "/tmp/test.macaroon",
wantErr: false,
},
{
name: "invalid path - missing suffix",
path: "/tmp/test.mac",
wantErr: true,
errStr: "super-macaroon-path must end " +
"with the .macaroon suffix",
},
{
name: "invalid path - wrong suffix",
path: "/tmp/test.macaroon.tmp",
wantErr: true,
errStr: "super-macaroon-path must end " +
"with the .macaroon suffix",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := HasMacaroonSuffix(tt.path)
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), tt.errStr)
} else {
require.NoError(t, err)
}
})
}
}