mirror of https://github.com/lightninglabs/loop
lsat: add unary interceptor and simple store
parent
49cbe9aa63
commit
8cecae501c
@ -0,0 +1,200 @@
|
||||
package lsat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/lightninglabs/loop/lndclient"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/macaroons"
|
||||
"github.com/lightningnetwork/lnd/zpay32"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
// GRPCErrCode is the error code we receive from a gRPC call if the
|
||||
// server expects a payment.
|
||||
GRPCErrCode = codes.Internal
|
||||
|
||||
// GRPCErrMessage is the error message we receive from a gRPC call in
|
||||
// conjunction with the GRPCErrCode to signal the client that a payment
|
||||
// is required to access the service.
|
||||
GRPCErrMessage = "payment required"
|
||||
|
||||
// AuthHeader is is the HTTP response header that contains the payment
|
||||
// challenge.
|
||||
AuthHeader = "WWW-Authenticate"
|
||||
|
||||
// MaxRoutingFee is the maximum routing fee in satoshis that we are
|
||||
// going to pay to acquire an LSAT token.
|
||||
// TODO(guggero): make this configurable
|
||||
MaxRoutingFeeSats = 10
|
||||
)
|
||||
|
||||
var (
|
||||
// authHeaderRegex is the regular expression the payment challenge must
|
||||
// match for us to be able to parse the macaroon and invoice.
|
||||
authHeaderRegex = regexp.MustCompile(
|
||||
"LSAT macaroon='(.*?)' invoice='(.*?)'",
|
||||
)
|
||||
)
|
||||
|
||||
// Interceptor is a gRPC client interceptor that can handle LSAT authentication
|
||||
// challenges with embedded payment requests. It uses a connection to lnd to
|
||||
// automatically pay for an authentication token.
|
||||
type Interceptor struct {
|
||||
lnd *lndclient.LndServices
|
||||
store Store
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
// NewInterceptor creates a new gRPC client interceptor that uses the provided
|
||||
// lnd connection to automatically acquire and pay for LSAT tokens, unless the
|
||||
// indicated store already contains a usable token.
|
||||
func NewInterceptor(lnd *lndclient.LndServices, store Store) *Interceptor {
|
||||
return &Interceptor{
|
||||
lnd: lnd,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// UnaryInterceptor is an interceptor method that can be used directly by gRPC
|
||||
// for unary calls. If the store contains a token, it is attached as credentials
|
||||
// to every call before patching it through. The response error is also
|
||||
// intercepted for every call. If there is an error returned and it is
|
||||
// indicating a payment challenge, a token is acquired and paid for
|
||||
// automatically. The original request is then repeated back to the server, now
|
||||
// with the new token attached.
|
||||
func (i *Interceptor) UnaryInterceptor(ctx context.Context, method string,
|
||||
req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker,
|
||||
opts ...grpc.CallOption) error {
|
||||
|
||||
// To avoid paying for a token twice if two parallel requests are
|
||||
// happening, we require an exclusive lock here.
|
||||
i.lock.Lock()
|
||||
defer i.lock.Unlock()
|
||||
|
||||
addLsatCredentials := func(token *Token) error {
|
||||
macaroon, err := token.PaidMacaroon()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts = append(opts, grpc.PerRPCCredentials(
|
||||
macaroons.NewMacaroonCredential(macaroon),
|
||||
))
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we already have a token, let's append it.
|
||||
if i.store.HasToken() {
|
||||
lsat, err := i.store.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = addLsatCredentials(lsat); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// We need a way to extract the response headers sent by the
|
||||
// server. This can only be done through the experimental
|
||||
// grpc.Trailer call option.
|
||||
// We execute the request and inspect the error. If it's the
|
||||
// LSAT specific payment required error, we might execute the
|
||||
// same method again later with the paid LSAT token.
|
||||
trailerMetadata := &metadata.MD{}
|
||||
opts = append(opts, grpc.Trailer(trailerMetadata))
|
||||
err := invoker(ctx, method, req, reply, cc, opts...)
|
||||
|
||||
// Only handle the LSAT error message that comes in the form of
|
||||
// a gRPC status error.
|
||||
if isPaymentRequired(err) {
|
||||
lsat, err := i.payLsatToken(ctx, trailerMetadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = addLsatCredentials(lsat); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Execute the same request again, now with the LSAT
|
||||
// token added as an RPC credential.
|
||||
return invoker(ctx, method, req, reply, cc, opts...)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// payLsatToken reads the payment challenge from the response metadata and tries
|
||||
// to pay the invoice encoded in them, returning a paid LSAT token if
|
||||
// successful.
|
||||
func (i *Interceptor) payLsatToken(ctx context.Context, md *metadata.MD) (
|
||||
*Token, error) {
|
||||
|
||||
// First parse the authentication header that was stored in the
|
||||
// metadata.
|
||||
authHeader := md.Get(AuthHeader)
|
||||
if len(authHeader) == 0 {
|
||||
return nil, fmt.Errorf("auth header not found in response")
|
||||
}
|
||||
matches := authHeaderRegex.FindStringSubmatch(authHeader[0])
|
||||
if len(matches) != 3 {
|
||||
return nil, fmt.Errorf("invalid auth header "+
|
||||
"format: %s", authHeader[0])
|
||||
}
|
||||
|
||||
// Decode the base64 macaroon and the invoice so we can store the
|
||||
// information in our store later.
|
||||
macBase64, invoiceStr := matches[1], matches[2]
|
||||
macBytes, err := base64.StdEncoding.DecodeString(macBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode of macaroon failed: "+
|
||||
"%v", err)
|
||||
}
|
||||
invoice, err := zpay32.Decode(invoiceStr, i.lnd.ChainParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to decode invoice: %v", err)
|
||||
}
|
||||
|
||||
// Pay invoice now and wait for the result to arrive or the main context
|
||||
// being canceled.
|
||||
// TODO(guggero): Store payment information so we can track the payment
|
||||
// later in case the client shuts down while the payment is in flight.
|
||||
respChan := i.lnd.Client.PayInvoice(
|
||||
ctx, invoiceStr, MaxRoutingFeeSats, nil,
|
||||
)
|
||||
select {
|
||||
case result := <-respChan:
|
||||
if result.Err != nil {
|
||||
return nil, result.Err
|
||||
}
|
||||
token, err := NewToken(
|
||||
macBytes, invoice.PaymentHash, result.Preimage,
|
||||
lnwire.NewMSatFromSatoshis(result.PaidAmt),
|
||||
lnwire.NewMSatFromSatoshis(result.PaidFee),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to create token: %v",
|
||||
err)
|
||||
}
|
||||
return token, i.store.StoreToken(token)
|
||||
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("context canceled")
|
||||
}
|
||||
}
|
||||
|
||||
// isPaymentRequired inspects an error to find out if it's the specific gRPC
|
||||
// error returned by the server to indicate a payment is required to access the
|
||||
// service.
|
||||
func isPaymentRequired(err error) bool {
|
||||
statusErr, ok := status.FromError(err)
|
||||
return ok &&
|
||||
statusErr.Message() == GRPCErrMessage &&
|
||||
statusErr.Code() == GRPCErrCode
|
||||
}
|
@ -0,0 +1,100 @@
|
||||
package lsat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoToken is the error returned when the store doesn't contain a
|
||||
// token yet.
|
||||
ErrNoToken = fmt.Errorf("no token in store")
|
||||
|
||||
storeFileName = "lsat.token"
|
||||
)
|
||||
|
||||
// Store is an interface that allows users to store and retrieve an LSAT token.
|
||||
type Store interface {
|
||||
// HasToken returns true if the store contains a token.
|
||||
HasToken() bool
|
||||
|
||||
// Token returns the token that is contained in the store or an error
|
||||
// if there is none.
|
||||
Token() (*Token, error)
|
||||
|
||||
// StoreToken saves a token to the store, overwriting any old token if
|
||||
// there is one.
|
||||
StoreToken(*Token) error
|
||||
}
|
||||
|
||||
// FileStore is an implementation of the Store interface that uses a single file
|
||||
// to save the serialized token.
|
||||
type FileStore struct {
|
||||
fileName string
|
||||
}
|
||||
|
||||
// A compile-time flag to ensure that FileStore implements the Store interface.
|
||||
var _ Store = (*FileStore)(nil)
|
||||
|
||||
// NewFileStore creates a new file based token store, creating its file in the
|
||||
// provided directory. If the directory does not exist, it will be created.
|
||||
func NewFileStore(storeDir string) (*FileStore, error) {
|
||||
// If the target path for the token store doesn't exist, then we'll
|
||||
// create it now before we proceed.
|
||||
if !fileExists(storeDir) {
|
||||
if err := os.MkdirAll(storeDir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &FileStore{
|
||||
fileName: filepath.Join(storeDir, storeFileName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HasToken returns true if the store contains a token.
|
||||
//
|
||||
// NOTE: This is part of the Store interface.
|
||||
func (f *FileStore) HasToken() bool {
|
||||
return fileExists(f.fileName)
|
||||
}
|
||||
|
||||
// Token returns the token that is contained in the store or an error if there
|
||||
// is none.
|
||||
//
|
||||
// NOTE: This is part of the Store interface.
|
||||
func (f *FileStore) Token() (*Token, error) {
|
||||
if !f.HasToken() {
|
||||
return nil, ErrNoToken
|
||||
}
|
||||
bytes, err := ioutil.ReadFile(f.fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return deserializeToken(bytes)
|
||||
}
|
||||
|
||||
// StoreToken saves a token to the store, overwriting any old token if there is
|
||||
// one.
|
||||
//
|
||||
// NOTE: This is part of the Store interface.
|
||||
func (f *FileStore) StoreToken(token *Token) error {
|
||||
bytes, err := serializeToken(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ioutil.WriteFile(f.fileName, bytes, 0600)
|
||||
}
|
||||
|
||||
// fileExists returns true if the file exists, and false otherwise.
|
||||
func fileExists(path string) bool {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
@ -0,0 +1,183 @@
|
||||
package lsat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/lightningnetwork/lnd/lntypes"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"gopkg.in/macaroon.v2"
|
||||
)
|
||||
|
||||
// Token is the main type to store an LSAT token in.
|
||||
type Token struct {
|
||||
// PaymentHash is the hash of the LSAT invoice that needs to be paid.
|
||||
// Knowing the preimage to this hash is seen as proof of payment by the
|
||||
// authentication server.
|
||||
PaymentHash lntypes.Hash
|
||||
|
||||
// Preimage is the proof of payment indicating that the token has been
|
||||
// paid for if set.
|
||||
Preimage lntypes.Preimage
|
||||
|
||||
// AmountPaid is the total amount in msat that the user paid to get the
|
||||
// token. This does not include routing fees.
|
||||
AmountPaid lnwire.MilliSatoshi
|
||||
|
||||
// RoutingFeePaid is the total amount in msat that the user paid in
|
||||
// routing fee to get the token.
|
||||
RoutingFeePaid lnwire.MilliSatoshi
|
||||
|
||||
// TimeCreated is the moment when this token was created.
|
||||
TimeCreated time.Time
|
||||
|
||||
// baseMac is the base macaroon in its original form as baked by the
|
||||
// authentication server. No client side caveats have been added to it
|
||||
// yet.
|
||||
baseMac *macaroon.Macaroon
|
||||
}
|
||||
|
||||
// NewToken creates a new token from the given base macaroon and payment
|
||||
// information.
|
||||
func NewToken(baseMac []byte, paymentHash *[32]byte, preimage lntypes.Preimage,
|
||||
amountPaid, routingFeePaid lnwire.MilliSatoshi) (*Token, error) {
|
||||
|
||||
token, err := tokenFromChallenge(baseMac, paymentHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token.Preimage = preimage
|
||||
token.AmountPaid = amountPaid
|
||||
token.RoutingFeePaid = routingFeePaid
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// tokenFromChallenge parses the parts that are present in the challenge part
|
||||
// of the LSAT auth protocol which is the macaroon and the payment hash.
|
||||
func tokenFromChallenge(baseMac []byte, paymentHash *[32]byte) (*Token, error) {
|
||||
// First, validate that the macaroon is valid and can be unmarshaled.
|
||||
mac := &macaroon.Macaroon{}
|
||||
err := mac.UnmarshalBinary(baseMac)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to unmarshal macaroon: %v", err)
|
||||
}
|
||||
|
||||
token := &Token{
|
||||
TimeCreated: time.Now(),
|
||||
baseMac: mac,
|
||||
}
|
||||
hash, err := lntypes.MakeHash(paymentHash[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token.PaymentHash = hash
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// BaseMacaroon returns the base macaroon as received from the authentication
|
||||
// server.
|
||||
func (t *Token) BaseMacaroon() *macaroon.Macaroon {
|
||||
return t.baseMac.Clone()
|
||||
}
|
||||
|
||||
// PaidMacaroon returns the base macaroon with the proof of payment (preimage)
|
||||
// added as a first-party-caveat.
|
||||
func (t *Token) PaidMacaroon() (*macaroon.Macaroon, error) {
|
||||
mac := t.BaseMacaroon()
|
||||
err := AddFirstPartyCaveats(
|
||||
mac, NewCaveat(PreimageKey, t.Preimage.String()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mac, nil
|
||||
}
|
||||
|
||||
// serializeToken returns a byte-serialized representation of the token.
|
||||
func serializeToken(t *Token) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
|
||||
baseMacBytes, err := t.baseMac.MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
macLen := uint32(len(baseMacBytes))
|
||||
if err := binary.Write(&b, byteOrder, macLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := binary.Write(&b, byteOrder, baseMacBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := binary.Write(&b, byteOrder, t.PaymentHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := binary.Write(&b, byteOrder, t.Preimage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := binary.Write(&b, byteOrder, t.AmountPaid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := binary.Write(&b, byteOrder, t.RoutingFeePaid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeUnix := t.TimeCreated.UnixNano()
|
||||
if err := binary.Write(&b, byteOrder, timeUnix); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
// deserializeToken constructs a token by reading it from a byte slice.
|
||||
func deserializeToken(value []byte) (*Token, error) {
|
||||
r := bytes.NewReader(value)
|
||||
|
||||
var macLen uint32
|
||||
if err := binary.Read(r, byteOrder, &macLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
macBytes := make([]byte, macLen)
|
||||
if err := binary.Read(r, byteOrder, &macBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var paymentHash [lntypes.HashSize]byte
|
||||
if err := binary.Read(r, byteOrder, &paymentHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := tokenFromChallenge(macBytes, &paymentHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := binary.Read(r, byteOrder, &token.Preimage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := binary.Read(r, byteOrder, &token.AmountPaid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := binary.Read(r, byteOrder, &token.RoutingFeePaid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var unixNano int64
|
||||
if err := binary.Read(r, byteOrder, &unixNano); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token.TimeCreated = time.Unix(0, unixNano)
|
||||
|
||||
return token, nil
|
||||
}
|
Loading…
Reference in New Issue