diff --git a/accounts/interceptor.go b/accounts/interceptor.go index 079f4ba07..56e9908e9 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 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 } diff --git a/accounts/interceptor_test.go b/accounts/interceptor_test.go new file mode 100644 index 000000000..08e549b36 --- /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/itest/litd_accounts_test.go b/itest/litd_accounts_test.go index b678723f0..891f4fb3a 100644 --- a/itest/litd_accounts_test.go +++ b/itest/litd_accounts_test.go @@ -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( diff --git a/session/interface.go b/session/interface.go index 1ce3854f3..d1e25b648 100644 --- a/session/interface.go +++ b/session/interface.go @@ -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" ) @@ -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. @@ -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 { @@ -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 @@ -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. diff --git a/session/kvdb_store.go b/session/kvdb_store.go index 00524e3d8..829caff3a 100644 --- a/session/kvdb_store.go +++ b/session/kvdb_store.go @@ -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" ) @@ -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) @@ -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 } @@ -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", diff --git a/session/store_test.go b/session/store_test.go index a853a6133..846dc4e07 100644 --- a/session/store_test.go +++ b/session/store_test.go @@ -2,12 +2,18 @@ package session import ( "context" + "fmt" "testing" "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/lightning-terminal/accounts" "github.com/lightningnetwork/lnd/clock" + "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" ) var testTime = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) @@ -324,9 +330,49 @@ func TestStateShift(t *testing.T) { require.ErrorContains(t, err, "illegal session state transition") } +// TestLinkedAccount tests that linking a session to an account works as +// expected. +func TestLinkedAccount(t *testing.T) { + t.Parallel() + ctx := context.Background() + clock := clock.NewTestClock(testTime) + + accts := accounts.NewTestDB(t, clock) + db := NewTestDBWithAccounts(t, clock, accts) + + // Reserve a session. Link it to an account that does not yet exist. + // This should fail. + acctID := accounts.AccountID{1, 2, 3, 4} + _, err := reserveSession(db, "session 1", withAccount(acctID)) + require.ErrorIs(t, err, accounts.ErrAccNotFound) + + // Now, add a new account + acct, err := accts.NewAccount(ctx, 1234, clock.Now().Add(time.Hour), "") + require.NoError(t, err) + + // Reserve a session. Link it to the account that was just created. + // This should succeed. + + s1, err := reserveSession(db, "session 1", withAccount(acct.ID)) + require.NoError(t, err) + require.True(t, s1.AccountID.IsSome()) + s1.AccountID.WhenSome(func(id accounts.AccountID) { + require.Equal(t, acct.ID, id) + }) + + // Make sure that a fetched session includes the account ID. + s1, err = db.GetSessionByID(ctx, s1.ID) + require.NoError(t, err) + require.True(t, s1.AccountID.IsSome()) + s1.AccountID.WhenSome(func(id accounts.AccountID) { + require.Equal(t, acct.ID, id) + }) +} + type testSessionOpts struct { groupID *ID sessType Type + account fn.Option[accounts.AccountID] } func defaultTestSessOpts() *testSessionOpts { @@ -352,6 +398,12 @@ func withType(t Type) testSessionModifier { } } +func withAccount(alias accounts.AccountID) testSessionModifier { + return func(s *testSessionOpts) { + s.account = fn.Some(alias) + } +} + func reserveSession(db Store, label string, mods ...testSessionModifier) (*Session, error) { @@ -360,13 +412,35 @@ func reserveSession(db Store, label string, mod(opts) } - return db.NewSession( - context.Background(), label, opts.sessType, - time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC), - "foo.bar.baz:1234", + options := []Option{ WithDevServer(), WithPrivacy(PrivacyFlags{ClearPubkeys}), WithLinkedGroupID(opts.groupID), + } + + opts.account.WhenSome(func(id accounts.AccountID) { + // For now, we manually add the account caveat for bbolt + // compatibility. + accountCaveat := checkers.Condition( + macaroons.CondLndCustom, + fmt.Sprintf("%s %x", accounts.CondAccount, id[:]), + ) + + caveats := append(caveats, macaroon.Caveat{ + Id: []byte(accountCaveat), + }) + + options = append( + options, + WithAccount(id), + WithMacaroonRecipe(caveats, nil), + ) + }) + + return db.NewSession( + context.Background(), label, opts.sessType, + time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC), + "foo.bar.baz:1234", options..., ) } diff --git a/session/test_kvdb.go b/session/test_kvdb.go index 6f270d617..5f3c0671e 100644 --- a/session/test_kvdb.go +++ b/session/test_kvdb.go @@ -3,6 +3,7 @@ package session import ( "testing" + "github.com/lightninglabs/lightning-terminal/accounts" "github.com/lightningnetwork/lnd/clock" "github.com/stretchr/testify/require" ) @@ -17,7 +18,23 @@ func NewTestDB(t *testing.T, clock clock.Clock) *BoltStore { func NewTestDBFromPath(t *testing.T, dbPath string, clock clock.Clock) *BoltStore { - store, err := NewDB(dbPath, DBFilename, clock) + acctStore := accounts.NewTestDB(t, clock) + + return newDBFromPathWithAccounts(t, clock, dbPath, acctStore) +} + +// NewTestDBWithAccounts creates a new test session Store with access to an +// existing accounts DB. +func NewTestDBWithAccounts(t *testing.T, clock clock.Clock, + acctStore accounts.Store) *BoltStore { + + return newDBFromPathWithAccounts(t, clock, t.TempDir(), acctStore) +} + +func newDBFromPathWithAccounts(t *testing.T, clock clock.Clock, dbPath string, + acctStore accounts.Store) *BoltStore { + + store, err := NewDB(dbPath, DBFilename, clock, acctStore) require.NoError(t, err) t.Cleanup(func() { diff --git a/session/tlv.go b/session/tlv.go index 672e73caa..d8817c1cd 100644 --- a/session/tlv.go +++ b/session/tlv.go @@ -7,6 +7,7 @@ import ( "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/lightning-terminal/accounts" "github.com/lightningnetwork/lnd/tlv" "gopkg.in/macaroon-bakery.v2/bakery" "gopkg.in/macaroon.v2" @@ -278,6 +279,18 @@ func DeserializeSession(r io.Reader) (*Session, error) { session.GroupID = session.ID } + // For any sessions stored in the BBolt store, a coupled account (if + // any) is linked implicitly via the macaroon recipe caveat. So we + // need to extract it from there. + if session.MacaroonRecipe != nil { + session.AccountID, err = accounts.IDFromCaveats( + session.MacaroonRecipe.Caveats, + ) + if err != nil { + return nil, err + } + } + return session, nil } diff --git a/session_rpcserver.go b/session_rpcserver.go index c185a3a94..a007a9560 100644 --- a/session_rpcserver.go +++ b/session_rpcserver.go @@ -2,6 +2,7 @@ package terminal import ( "context" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -22,6 +23,7 @@ import ( "github.com/lightninglabs/lightning-terminal/perms" "github.com/lightninglabs/lightning-terminal/rules" "github.com/lightninglabs/lightning-terminal/session" + "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/macaroons" "google.golang.org/grpc" "gopkg.in/macaroon-bakery.v2/bakery" @@ -221,7 +223,10 @@ func (s *sessionRpcServer) AddSession(ctx context.Context, permissions[entity][action] = struct{}{} } - var caveats []macaroon.Caveat + var ( + caveats []macaroon.Caveat + accountID fn.Option[accounts.AccountID] + ) switch typ { // For the default session types we use empty caveats and permissions, // the macaroons are baked correctly when creating the session. @@ -235,12 +240,8 @@ 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)) + accountID = fn.Some(*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 @@ -325,6 +326,10 @@ func (s *sessionRpcServer) AddSession(ctx context.Context, sessOpts = append(sessOpts, session.WithDevServer()) } + accountID.WhenSome(func(id accounts.AccountID) { + sessOpts = append(sessOpts, session.WithAccount(id)) + }) + sess, err := s.cfg.db.NewSession( ctx, req.Label, typ, expiry, req.MailboxServerAddr, sessOpts..., @@ -1505,6 +1510,11 @@ func (s *sessionRpcServer) marshalRPCSession(sess *session.Session) ( } } + var accountID string + sess.AccountID.WhenSome(func(id accounts.AccountID) { + accountID = hex.EncodeToString(id[:]) + }) + return &litrpc.Session{ Id: sess.ID[:], Label: sess.Label, @@ -1524,6 +1534,7 @@ func (s *sessionRpcServer) marshalRPCSession(sess *session.Session) ( GroupId: sess.GroupID[:], FeatureConfigs: clientConfig, PrivacyFlags: sess.PrivacyFlags.Serialize(), + AccountId: accountID, }, nil } diff --git a/terminal.go b/terminal.go index e297d0fe1..b8141d4a3 100644 --- a/terminal.go +++ b/terminal.go @@ -449,7 +449,9 @@ func (g *LightningTerminal) start(ctx context.Context) error { g.ruleMgrs = rules.NewRuleManagerSet() // Create an instance of the local Terminal Connect session store DB. - g.sessionDB, err = session.NewDB(networkDir, session.DBFilename, clock) + g.sessionDB, err = session.NewDB( + networkDir, session.DBFilename, clock, g.accountsStore, + ) if err != nil { return fmt.Errorf("error creating session DB: %v", err) }