From 3975e83006f8d466bb089275283318f52cc9080e Mon Sep 17 00:00:00 2001 From: cyberguru1 Date: Sat, 13 Jun 2026 18:24:33 -0500 Subject: [PATCH] macaroons: add super macaroon helpers Introduce helper functions in the macaroons package to manage super macaroon files and permissions. Add SuperMacaroonExists, MacaroonMatchesPermissions, and BakeAndWriteSuperMacaroon. --- macaroons/super_mac.go | 128 ++++++++++++++++++++++++++++++++++++ macaroons/super_mac_test.go | 122 ++++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) diff --git a/macaroons/super_mac.go b/macaroons/super_mac.go index 32cad9ff..d103306e 100644 --- a/macaroons/super_mac.go +++ b/macaroons/super_mac.go @@ -6,8 +6,12 @@ import ( "encoding/binary" "encoding/hex" "errors" + "fmt" + "os" + "strings" "github.com/lightningnetwork/lnd/lnrpc" + "google.golang.org/protobuf/proto" "gopkg.in/macaroon-bakery.v2/bakery" "gopkg.in/macaroon.v2" ) @@ -118,3 +122,127 @@ func BakeSuperMacaroon(ctx context.Context, lnd lnrpc.LightningClient, 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 +} diff --git a/macaroons/super_mac_test.go b/macaroons/super_mac_test.go index d4b91aea..a751683f 100644 --- a/macaroons/super_mac_test.go +++ b/macaroons/super_mac_test.go @@ -1,9 +1,13 @@ package macaroons import ( + "encoding/hex" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/require" + "gopkg.in/macaroon-bakery.v2/bakery" ) var ( @@ -43,3 +47,121 @@ func TestIsSuperMacaroon(t *testing.T) { 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) + } + }) + } +}