From 8cecae501c5f6ad208664c92f8d0fcc5e8e7082a Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Fri, 8 Nov 2019 10:37:51 +0100 Subject: [PATCH] lsat: add unary interceptor and simple store --- client.go | 7 +- lsat/interceptor.go | 200 ++++++++++++++++++++++++++++++++++++++++++ lsat/store.go | 100 +++++++++++++++++++++ lsat/token.go | 183 ++++++++++++++++++++++++++++++++++++++ swap_server_client.go | 20 +++-- 5 files changed, 504 insertions(+), 6 deletions(-) create mode 100644 lsat/interceptor.go create mode 100644 lsat/store.go create mode 100644 lsat/token.go diff --git a/client.go b/client.go index 4d1b9df..5214e12 100644 --- a/client.go +++ b/client.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcutil" "github.com/lightninglabs/loop/lndclient" "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/lsat" "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/sweep" "github.com/lightningnetwork/lnd/lntypes" @@ -78,9 +79,13 @@ func NewClient(dbDir string, serverAddress string, insecure bool, if err != nil { return nil, nil, err } + lsatStore, err := lsat.NewFileStore(dbDir) + if err != nil { + return nil, nil, err + } swapServerClient, err := newSwapServerClient( - serverAddress, insecure, tlsPathServer, + serverAddress, insecure, tlsPathServer, lsatStore, lnd, ) if err != nil { return nil, nil, err diff --git a/lsat/interceptor.go b/lsat/interceptor.go new file mode 100644 index 0000000..839c5cb --- /dev/null +++ b/lsat/interceptor.go @@ -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 +} diff --git a/lsat/store.go b/lsat/store.go new file mode 100644 index 0000000..4ae5c64 --- /dev/null +++ b/lsat/store.go @@ -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 +} diff --git a/lsat/token.go b/lsat/token.go new file mode 100644 index 0000000..d7f7377 --- /dev/null +++ b/lsat/token.go @@ -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 +} diff --git a/swap_server_client.go b/swap_server_client.go index 4d328a9..07a4425 100644 --- a/swap_server_client.go +++ b/swap_server_client.go @@ -10,7 +10,9 @@ import ( "github.com/btcsuite/btcd/btcec" "github.com/btcsuite/btcutil" + "github.com/lightninglabs/loop/lndclient" "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/lsat" "github.com/lightningnetwork/lnd/lntypes" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -48,10 +50,16 @@ type grpcSwapServerClient struct { var _ swapServerClient = (*grpcSwapServerClient)(nil) -func newSwapServerClient(address string, insecure bool, tlsPath string) ( +func newSwapServerClient(address string, insecure bool, tlsPath string, + lsatStore lsat.Store, lnd *lndclient.LndServices) ( *grpcSwapServerClient, error) { - serverConn, err := getSwapServerConn(address, insecure, tlsPath) + // Create the server connection with the interceptor that will handle + // the LSAT protocol for us. + clientInterceptor := lsat.NewInterceptor(lnd, lsatStore) + serverConn, err := getSwapServerConn( + address, insecure, tlsPath, clientInterceptor, + ) if err != nil { return nil, err } @@ -226,11 +234,13 @@ func (s *grpcSwapServerClient) Close() { } // getSwapServerConn returns a connection to the swap server. -func getSwapServerConn(address string, insecure bool, tlsPath string) ( - *grpc.ClientConn, error) { +func getSwapServerConn(address string, insecure bool, tlsPath string, + interceptor *lsat.Interceptor) (*grpc.ClientConn, error) { // Create a dial options array. - opts := []grpc.DialOption{} + opts := []grpc.DialOption{grpc.WithUnaryInterceptor( + interceptor.UnaryInterceptor, + )} // There are three options to connect to a swap server, either insecure, // using a self-signed certificate or with a certificate signed by a