lightning-terminal/accounts/interceptor.go
Elle Mouton 8527969031
accounts: remove accountFromMacaroon helper
We only use it in one place and we can just handle the unwrap of the
option there.
2025-05-21 14:14:12 +02:00

255 lines
7.4 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 := IDFromCaveats(mac.Caveats())
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.
accountID, err := acctID.UnwrapOrErr(
fmt.Errorf("expected account ID in macaroon caveat"),
)
if err != nil {
return mid.RPCErr(req, err)
}
acct, err := s.Account(ctx, accountID)
if err != nil {
return mid.RPCErrString(
req, "error getting account %x: %v", accountID[:], 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
}
// 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
}