From 03712e60674e9d61142412bce37d873abea8307d Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 27 Feb 2025 08:50:31 +0200 Subject: [PATCH] accounts: add an IDFromCaveates helper And use that from the existing accountFromMacaroon helper (which will then test the new helper by proxy). We add this helper so that we can use it later on from the sessions package where we want to extract an account ID from a caveat (we wont have a full macaroon available). --- accounts/interceptor.go | 76 ++++++++++++++++++++++++++++++----- accounts/interceptor_test.go | 78 ++++++++++++++++++++++++++++++++++++ session_rpcserver.go | 7 +--- 3 files changed, 144 insertions(+), 17 deletions(-) create mode 100644 accounts/interceptor_test.go diff --git a/accounts/interceptor.go b/accounts/interceptor.go index 079f4ba0..56e9908e 100644 --- a/accounts/interceptor.go +++ b/accounts/interceptor.go @@ -5,11 +5,14 @@ import ( "encoding/hex" "errors" "fmt" + "strings" mid "github.com/lightninglabs/lightning-terminal/rpcmiddleware" + "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/macaroons" "google.golang.org/protobuf/proto" + "gopkg.in/macaroon-bakery.v2/bakery/checkers" "gopkg.in/macaroon.v2" ) @@ -23,6 +26,15 @@ const ( accountMiddlewareName = "lit-account" ) +var ( + // caveatPrefix is the prefix that is used for custom caveats that are + // used by the account system. This prefix is used to identify the + // custom caveat and extract the condition (the AccountID) from it. + caveatPrefix = []byte(fmt.Sprintf( + "%s %s ", macaroons.CondLndCustom, CondAccount, + )) +) + // Name returns the name of the interceptor. func (s *InterceptorService) Name() string { return accountMiddlewareName @@ -199,22 +211,64 @@ func parseRPCMessage(msg *lnrpc.RPCMessage) (proto.Message, error) { // accountFromMacaroon attempts to extract an account ID from the custom account // caveat in the macaroon. func accountFromMacaroon(mac *macaroon.Macaroon) (*AccountID, error) { - // Extract the account caveat from the macaroon. - macaroonAccount := macaroons.GetCustomCaveatCondition(mac, CondAccount) - if macaroonAccount == "" { - // There is no condition that locks the macaroon to an account, - // so there is nothing to check. + if mac == nil { return nil, nil } - // The macaroon is indeed locked to an account. Fetch the account and - // validate its balance. - accountIDBytes, err := hex.DecodeString(macaroonAccount) + // Extract the account caveat from the macaroon. + accountID, err := IDFromCaveats(mac.Caveats()) if err != nil { return nil, err } - var accountID AccountID - copy(accountID[:], accountIDBytes) - return &accountID, nil + var id *AccountID + accountID.WhenSome(func(aID AccountID) { + id = &aID + }) + + return id, nil +} + +// CaveatFromID creates a custom caveat that can be used to bind a macaroon to +// a certain account. +func CaveatFromID(id AccountID) macaroon.Caveat { + condition := checkers.Condition(macaroons.CondLndCustom, fmt.Sprintf( + "%s %x", CondAccount, id[:], + )) + + return macaroon.Caveat{Id: []byte(condition)} +} + +// IDFromCaveats attempts to extract an AccountID from the given set of caveats +// by looking for the custom caveat that binds a macaroon to a certain account. +func IDFromCaveats(caveats []macaroon.Caveat) (fn.Option[AccountID], error) { + var accountIDStr string + for _, caveat := range caveats { + // The caveat id has a format of + // "lnd-custom [custom-caveat-name] [custom-caveat-condition]" + // and we only want the condition part. If we match the prefix + // part we return the condition that comes after the prefix. + _, after, found := strings.Cut( + string(caveat.Id), string(caveatPrefix), + ) + if !found { + continue + } + + accountIDStr = after + } + + if accountIDStr == "" { + return fn.None[AccountID](), nil + } + + var accountID AccountID + accountIDBytes, err := hex.DecodeString(accountIDStr) + if err != nil { + return fn.None[AccountID](), err + } + + copy(accountID[:], accountIDBytes) + + return fn.Some(accountID), nil } diff --git a/accounts/interceptor_test.go b/accounts/interceptor_test.go new file mode 100644 index 00000000..08e549b3 --- /dev/null +++ b/accounts/interceptor_test.go @@ -0,0 +1,78 @@ +package accounts + +import ( + "fmt" + "testing" + + "github.com/lightningnetwork/lnd/fn" + "github.com/lightningnetwork/lnd/macaroons" + "github.com/stretchr/testify/require" + "gopkg.in/macaroon-bakery.v2/bakery/checkers" + "gopkg.in/macaroon.v2" +) + +// TestAccountIDCaveatEmbedding tests that the account ID can be embedded in a +// macaroon caveat and extracted from it. +func TestAccountIDCaveatEmbedding(t *testing.T) { + badCondition := checkers.Condition(macaroons.CondLndCustom, fmt.Sprintf( + "%s %s", CondAccount, "invalid hex", + )) + + tests := []struct { + name string + caveats []macaroon.Caveat + expectedErr string + expectedAcct fn.Option[AccountID] + }{ + { + name: "valid account ID, single caveat", + caveats: []macaroon.Caveat{ + CaveatFromID(AccountID{1, 2, 3, 4, 5}), + }, + expectedAcct: fn.Some(AccountID{1, 2, 3, 4, 5}), + }, + { + name: "valid account ID, single multiple caveats", + caveats: []macaroon.Caveat{ + {Id: []byte("some other caveat")}, + CaveatFromID(AccountID{1, 2, 3, 4, 5}), + {Id: []byte("another one")}, + }, + expectedAcct: fn.Some(AccountID{1, 2, 3, 4, 5}), + }, + { + name: "invalid account ID", + caveats: []macaroon.Caveat{ + {Id: []byte(badCondition)}, + }, + expectedErr: "encoding/hex: invalid", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + acct, err := IDFromCaveats(test.caveats) + if test.expectedErr != "" { + require.ErrorContains(t, err, test.expectedErr) + + return + } + require.NoError(t, err) + + if test.expectedAcct.IsNone() { + require.True(t, acct.IsNone()) + + return + } + require.True(t, acct.IsSome()) + + test.expectedAcct.WhenSome(func(id AccountID) { + acct.WhenSome(func(acct AccountID) { + require.Equal(t, id, acct) + }) + }) + }) + } +} diff --git a/session_rpcserver.go b/session_rpcserver.go index c185a3a9..b3aaca7b 100644 --- a/session_rpcserver.go +++ b/session_rpcserver.go @@ -235,12 +235,7 @@ func (s *sessionRpcServer) AddSession(ctx context.Context, return nil, fmt.Errorf("invalid account ID: %v", err) } - cav := checkers.Condition(macaroons.CondLndCustom, fmt.Sprintf( - "%s %x", accounts.CondAccount, id[:], - )) - caveats = append(caveats, macaroon.Caveat{ - Id: []byte(cav), - }) + caveats = append(caveats, accounts.CaveatFromID(*id)) // For the custom macaroon type, we use the custom permissions specified // in the request. For the time being, the caveats list will be empty