lightning-terminal/accounts/interceptor.go
Elle Mouton 03712e6067
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).
2025-03-04 13:43:13 +02:00

274 lines
7.8 KiB
Go

package accounts
import (
"context"
"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"
)
const (
// CondAccount is the custom caveat condition that binds a macaroon to a
// certain account.
CondAccount = "account"
// accountMiddlewareName is the name that is used for the account system
// when registering it to lnd as an RPC middleware.
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
}
// ReadOnly returns true if this interceptor should be registered in read-only
// mode. In read-only mode no custom caveat name can be specified.
func (s *InterceptorService) ReadOnly() bool {
return false
}
// CustomCaveatName returns the name of the custom caveat that is expected to be
// handled by this interceptor. Cannot be specified in read-only mode.
func (s *InterceptorService) CustomCaveatName() string {
return CondAccount
}
// Intercept processes an RPC middleware interception request and returns the
// interception result which either accepts or rejects the intercepted message.
func (s *InterceptorService) Intercept(ctx context.Context,
req *lnrpc.RPCMiddlewareRequest) (*lnrpc.RPCMiddlewareResponse, error) {
// We only allow a single request or response to be handled at the same
// time. This should already be serialized by the RPC stream itself, but
// with the lock we prevent a new request to be handled before we finish
// handling the previous one.
s.requestMtx.Lock()
defer s.requestMtx.Unlock()
// If the account service is not running, we reject all requests.
// Note that this is by no means a guarantee that the account service
// will be running throughout processing the request, but at least we
// can stop requests early if the service was already disabled when the
// request came in.
if !s.IsRunning() {
return mid.RPCErrString(
req, "the account service has been stopped",
)
}
mac := &macaroon.Macaroon{}
err := mac.UnmarshalBinary(req.RawMacaroon)
if err != nil {
return mid.RPCErrString(req, "error parsing macaroon: %v", err)
}
acctID, err := accountFromMacaroon(mac)
if err != nil {
return mid.RPCErrString(
req, "error parsing account from macaroon: %v", err,
)
}
// No account lock in the macaroon, something's weird. The interceptor
// wouldn't have been triggered if there was no caveat, so we do expect
// a macaroon here.
if acctID == nil {
return mid.RPCErrString(req, "expected account ID in "+
"macaroon caveat")
}
acct, err := s.Account(ctx, *acctID)
if err != nil {
return mid.RPCErrString(
req, "error getting account %x: %v", acctID[:], err,
)
}
log.Debugf("Account auth intercepted, ID=%x, balance_sat=%d, "+
"expired=%v", acct.ID[:], acct.CurrentBalanceSats(),
acct.HasExpired())
if acct.HasExpired() {
return mid.RPCErrString(
req, "account %x has expired", acct.ID[:],
)
}
// We now add the account and request ID to the incoming context to give
// each checker access to them if required.
ctx = AddAccountToContext(ctx, acct)
ctx = AddRequestIDToContext(ctx, req.RequestId)
switch r := req.InterceptType.(type) {
// In the authentication phase we just check that the account hasn't
// expired yet (which we already did). This is only be used for
// establishing streams, so we don't see a request yet.
case *lnrpc.RPCMiddlewareRequest_StreamAuth:
return mid.RPCOk(req)
// Parse incoming requests and act on them.
case *lnrpc.RPCMiddlewareRequest_Request:
msg, err := parseRPCMessage(r.Request)
if err != nil {
return mid.RPCErr(req, err)
}
return mid.RPCErr(req, s.checkers.checkIncomingRequest(
ctx, r.Request.MethodFullUri, msg,
))
// Parse and possibly manipulate outgoing responses.
case *lnrpc.RPCMiddlewareRequest_Response:
if r.Response.IsError {
parsedErr := mid.ParseResponseErr(r.Response.Serialized)
replacementErr, err := s.checkers.handleErrorResponse(
ctx, r.Response.MethodFullUri, parsedErr,
)
if err != nil {
return mid.RPCErr(req, err)
}
// No error occurred but the response error should be
// replaced with the given custom error. Wrap it in the
// correct RPC response of the interceptor now.
if replacementErr != nil {
return mid.RPCErrReplacement(
req, replacementErr,
)
}
// No error and no replacement, just return an empty
// response of the correct type.
return mid.RPCOk(req)
}
msg, err := parseRPCMessage(r.Response)
if err != nil {
return mid.RPCErr(req, err)
}
replacement, err := s.checkers.replaceOutgoingResponse(
ctx, r.Response.MethodFullUri, msg,
)
if err != nil {
return mid.RPCErr(req, err)
}
// No error occurred but the response should be replaced with
// the given custom response. Wrap it in the correct RPC
// response of the interceptor now.
if replacement != nil {
return mid.RPCReplacement(req, replacement)
}
// No error and no replacement, just return an empty response of
// the correct type.
return mid.RPCOk(req)
default:
return mid.RPCErrString(req, "invalid intercept type: %v", r)
}
}
// parseRPCMessage parses a raw RPC message into the original protobuf message
// type.
func parseRPCMessage(msg *lnrpc.RPCMessage) (proto.Message, error) {
// Are we intercepting an error message being returned?
if msg.TypeName == "error" {
return nil, errors.New(string(msg.Serialized))
}
// No, it's a normal message.
parsedMsg, err := mid.ParseProtobuf(msg.TypeName, msg.Serialized)
if err != nil {
return nil, fmt.Errorf("error parsing proto of type %v: %w",
msg.TypeName, err)
}
return parsedMsg, nil
}
// accountFromMacaroon attempts to extract an account ID from the custom account
// caveat in the macaroon.
func accountFromMacaroon(mac *macaroon.Macaroon) (*AccountID, error) {
if mac == nil {
return nil, nil
}
// 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 fn.Some(accountID), nil
}