Skip to content

[sql-18] sessions: tightly couple sessions & accounts #989

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 63 additions & 9 deletions accounts/interceptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand Down Expand Up @@ -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 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 &accountID, nil

return fn.Some(accountID), nil
}
78 changes: 78 additions & 0 deletions accounts/interceptor_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
}
}
1 change: 1 addition & 0 deletions itest/litd_accounts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ func testAccountRestrictionsLNC(ctxm context.Context, t *harnessTest,
AccountId: accountID,
})
require.NoError(t.t, err)
require.Equal(t.t, accountID, sessResp.Session.AccountId)

// Try the LNC connection now.
connectPhrase := strings.Split(
Expand Down
16 changes: 16 additions & 0 deletions session/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (

"github.com/btcsuite/btcd/btcec/v2"
"github.com/lightninglabs/lightning-node-connect/mailbox"
"github.com/lightninglabs/lightning-terminal/accounts"
"github.com/lightninglabs/lightning-terminal/macaroons"
"github.com/lightningnetwork/lnd/fn"
"gopkg.in/macaroon-bakery.v2/bakery"
"gopkg.in/macaroon.v2"
)
Expand Down Expand Up @@ -117,6 +119,9 @@ type Session struct {
// group of sessions. If this is the very first session in the group
// then this will be the same as ID.
GroupID ID

// AccountID is an optional account that the session has been linked to.
AccountID fn.Option[accounts.AccountID]
}

// buildSession creates a new session with the given user-defined parameters.
Expand Down Expand Up @@ -163,6 +168,7 @@ func buildSession(id ID, localPrivKey *btcec.PrivateKey, label string, typ Type,
PrivacyFlags: opts.privacyFlags,
GroupID: groupID,
MacaroonRecipe: opts.macaroonRecipe,
AccountID: opts.accountID,
}

if len(opts.featureConfig) != 0 {
Expand Down Expand Up @@ -196,6 +202,9 @@ type sessionOptions struct {
// macaroonRecipe holds the permissions and caveats that should be used
// to bake the macaroon to be used with this session.
macaroonRecipe *MacaroonRecipe

// accountID is an optional account that the session has been linked to.
accountID fn.Option[accounts.AccountID]
}

// defaultSessionOptions returns a new sessionOptions struct with default
Expand Down Expand Up @@ -258,6 +267,13 @@ func WithMacaroonRecipe(caveats []macaroon.Caveat, perms []bakery.Op) Option {
}
}

// WithAccount can be used to link the session to an account.
func WithAccount(id accounts.AccountID) Option {
return func(o *sessionOptions) {
o.accountID = fn.Some(id)
}
}

// IDToGroupIndex defines an interface for the session ID to group ID index.
type IDToGroupIndex interface {
// GetGroupID will return the group ID for the given session ID.
Expand Down
21 changes: 18 additions & 3 deletions session/kvdb_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/lightninglabs/lightning-terminal/accounts"
"github.com/lightningnetwork/lnd/clock"
"go.etcd.io/bbolt"
)
Expand Down Expand Up @@ -82,13 +83,17 @@ type BoltStore struct {
*bbolt.DB

clock clock.Clock

accounts accounts.Store
}

// A compile-time check to ensure that BoltStore implements the Store interface.
var _ Store = (*BoltStore)(nil)

// NewDB creates a new bolt database that can be found at the given directory.
func NewDB(dir, fileName string, clock clock.Clock) (*BoltStore, error) {
func NewDB(dir, fileName string, clock clock.Clock,
store accounts.Store) (*BoltStore, error) {

firstInit := false
path := filepath.Join(dir, fileName)

Expand All @@ -112,8 +117,9 @@ func NewDB(dir, fileName string, clock clock.Clock) (*BoltStore, error) {
}

return &BoltStore{
DB: db,
clock: clock,
DB: db,
clock: clock,
accounts: store,
}, nil
}

Expand Down Expand Up @@ -211,6 +217,15 @@ func (db *BoltStore) NewSession(ctx context.Context, label string, typ Type,

sessionKey := getSessionKey(session)

// If an account is being linked, we first need to check that
// it exists.
session.AccountID.WhenSome(func(account accounts.AccountID) {
_, err = db.accounts.Account(ctx, account)
})
if err != nil {
return err
}

if len(sessionBucket.Get(sessionKey)) != 0 {
return fmt.Errorf("session with local public key(%x) "+
"already exists",
Expand Down
Loading
Loading