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).
This commit is contained in:
Elle Mouton 2025-02-27 08:50:31 +02:00
parent 2717aa0bc2
commit 03712e6067
No known key found for this signature in database
GPG key ID: D7D916376026F177
3 changed files with 144 additions and 17 deletions

View file

@ -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
}

View file

@ -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)
})
})
})
}
}

View file

@ -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