diff --git a/aperture.go b/aperture.go index e885936..e3a215b 100644 --- a/aperture.go +++ b/aperture.go @@ -158,7 +158,7 @@ type Aperture struct { cfg *Config etcdClient *clientv3.Client - challenger *LndChallenger + challenger mint.Challenger httpsServer *http.Server torHTTPServer *http.Server proxy *proxy.Proxy @@ -205,22 +205,38 @@ func (a *Aperture) Start(errChan chan error) error { }, nil } + var checker auth.InvoiceChecker if !a.cfg.Authenticator.Disable { - a.challenger, err = NewLndChallenger( - a.cfg.Authenticator, genInvoiceReq, errChan, - ) - if err != nil { - return err - } - err = a.challenger.Start() - if err != nil { - return err + if a.cfg.Authenticator.LNURL != "" { + a.challenger, err = NewLNURLChallenger( + a.cfg.Authenticator.LNURL, + a.cfg.Authenticator.Network, + ) + if err != nil { + return err + } + + } else { + lndChallenger, err := NewLndChallenger( + a.cfg.Authenticator, genInvoiceReq, errChan, + ) + if err != nil { + return err + } + + err = lndChallenger.Start() + if err != nil { + return err + } + + a.challenger = lndChallenger + checker = lndChallenger } } // Create the proxy and connect it to lnd. a.proxy, a.proxyCleanup, err = createProxy( - a.cfg, a.challenger, a.etcdClient, + a.cfg, a.challenger, checker, a.etcdClient, ) if err != nil { return err @@ -319,7 +335,10 @@ func (a *Aperture) Stop() error { var returnErr error if a.challenger != nil { - a.challenger.Stop() + ch, ok := a.challenger.(*LndChallenger) + if ok { + ch.Stop() + } } // Stop everything that was started alongside the proxy, for example the @@ -624,15 +643,16 @@ func initTorListener(cfg *Config, etcd *clientv3.Client) (*tor.Controller, error } // createProxy creates the proxy with all the services it needs. -func createProxy(cfg *Config, challenger *LndChallenger, - etcdClient *clientv3.Client) (*proxy.Proxy, func(), error) { +func createProxy(cfg *Config, challenger mint.Challenger, + checker auth.InvoiceChecker, etcdClient *clientv3.Client) (*proxy.Proxy, + func(), error) { minter := mint.New(&mint.Config{ Challenger: challenger, Secrets: newSecretStore(etcdClient), ServiceLimiter: newStaticServiceLimiter(cfg.Services), }) - authenticator := auth.NewLsatAuthenticator(minter, challenger) + authenticator := auth.NewLsatAuthenticator(minter, checker) // By default the static file server only returns 404 answers for // security reasons. Serving files from the staticRoot directory has to diff --git a/config.go b/config.go index ed1c87b..cca0098 100644 --- a/config.go +++ b/config.go @@ -27,6 +27,10 @@ type EtcdConfig struct { } type AuthConfig struct { + Disable bool `long:"disable" description:"Whether to disable any auth."` + + Network string `long:"network" description:"The network the authenticator is using." choice:"regtest" choice:"simnet" choice:"testnet" choice:"mainnet"` + // LndHost is the hostname of the LND instance to connect to. LndHost string `long:"lndhost" description:"Hostname of the LND instance to connect to"` @@ -34,9 +38,8 @@ type AuthConfig struct { MacDir string `long:"macdir" description:"Directory containing LND instance's macaroons"` - Network string `long:"network" description:"The network LND is connected to." choice:"regtest" choice:"simnet" choice:"testnet" choice:"mainnet"` - - Disable bool `long:"disable" description:"Whether to disable LND auth."` + // LNURL is the lnurl that will be used to fetch invoices from. + LNURL string `long:"lnurl" description:"The LNURL to be used to query for invoices. If this is specified then the LND config should not be"` } func (a *AuthConfig) validate() error { @@ -45,6 +48,15 @@ func (a *AuthConfig) validate() error { return nil } + if a.LNURL != "" && a.LndHost != "" { + return errors.New("must use either LND or LNURL for " + + "authentication, not both") + } + + if a.LNURL != "" { + return nil + } + if a.LndHost == "" { return errors.New("lnd host required") } diff --git a/go.mod b/go.mod index 91b0047..9d743be 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/btcsuite/btcd/btcec/v2 v2.1.0 github.com/btcsuite/btcd/btcutil v1.1.0 github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f + github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d github.com/btcsuite/btcwallet/wtxmgr v1.5.0 github.com/fortytw2/leaktest v1.3.0 github.com/golang/protobuf v1.5.2 diff --git a/go.sum b/go.sum index b74fff1..eeabd1e 100644 --- a/go.sum +++ b/go.sum @@ -86,6 +86,7 @@ github.com/btcsuite/btcd/btcutil/psbt v1.1.0 h1:1LxDjz2ar4L2mrviBdxrzxesMMcAtj4n github.com/btcsuite/btcd/btcutil/psbt v1.1.0/go.mod h1:xMuACsIKDzcE3kWMxqK+aLrAWZ8bMdn7YjYEwNs5q8k= github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo= github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d h1:yJzD/yFppdVCf6ApMkVy8cUxV0XrxdP9rVf6D87/Mng= github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= github.com/btcsuite/btcwallet v0.14.0 h1:+Nwf0GkRUwDjd/F3i9HgrRhDp8RHZFbBZ3kQaZr6zD0= github.com/btcsuite/btcwallet v0.14.0/go.mod h1:KFR1x3ZH7c31i4qA34XIvcsnhrEBLK1SHli52lN8E54= diff --git a/lnurl_challenger.go b/lnurl_challenger.go new file mode 100644 index 0000000..2e8da02 --- /dev/null +++ b/lnurl_challenger.go @@ -0,0 +1,256 @@ +package aperture + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "html" + "io/ioutil" + "net/http" + "strings" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcutil/bech32" + "github.com/lightninglabs/aperture/mint" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/zpay32" +) + +const ( + // lnurlHRP is the human readable part of a bech32 LNURL string. + lnurlHRP = "lnurl" + + // payRequestTag is the tag expected in the response of the invoice + // request. + payRequestTag = "payRequest" +) + +// A compile-time flag to ensure the LNURLChallenger satisfies the +// mint.Challenger and auth.InvoiceChecker interface. +var _ mint.Challenger = (*LNURLChallenger)(nil) + +// LNURLChallenger uses LNURL for invoice retrieval. It will not do proper +// invoice checking. +type LNURLChallenger struct { + url string + network *chaincfg.Params +} + +// NewLNURLChallenger creates a new LNURLChallenger. +func NewLNURLChallenger(lnurl string, network string) (*LNURLChallenger, + error) { + + // Parse the network name to get the correct parameters. + var net *chaincfg.Params + switch network { + case "mainnet": + net = &chaincfg.MainNetParams + case "testnet": + net = &chaincfg.TestNet3Params + case "regtest": + net = &chaincfg.RegressionNetParams + default: + return nil, fmt.Errorf("unsupported network: %s", network) + } + + // Get the URL from the LNURL string. + url, err := parseLNURL(lnurl) + if err != nil { + return nil, err + } + + return &LNURLChallenger{ + url: url, + network: net, + }, nil +} + +// parseLNURL parses the given LNURL into the URL that should be queried when a +// new invoice is required. +func parseLNURL(lnurl string) (string, error) { + var ( + url string + err error + ) + switch { + // If the string starts with is "LNURL" then the string is just the + // bech32 encoding of the URL to use. + case strings.HasPrefix(lnurl, "LNURL"): + url, err = decodeLNURL(lnurl) + if err != nil { + return "", fmt.Errorf("error decoding LNURL: %v", err) + } + + // If the string prefix is "lightning:" then what follows should be the + // bech32 encoding of the URL to use. + case strings.HasPrefix(lnurl, "lightning:"): + url, err = decodeLNURL(strings.TrimPrefix(lnurl, "lightning:")) + if err != nil { + return "", fmt.Errorf("error decoding LNURL: %w", err) + } + + // If the string starts with "lnurlp" then this part just needs to be + // replaced with "https" inorder to reconstruct the URL to use. + case strings.HasPrefix(lnurl, "lnurlp"): + url = strings.Replace(lnurl, "lnurlp", "https", 1) + + // If the string contains an "@" symbol then this is a Lightning + // Address. + case strings.Contains(lnurl, "@"): + parts := strings.Split(lnurl, "@") + if len(parts) != 2 { + return "", fmt.Errorf("invalid LN address. Expected" + + "the form @") + } + + username, domain := parts[0], parts[1] + url = fmt.Sprintf( + "https://%s/.well-known/lnurlp/%s", domain, username, + ) + + default: + return "", fmt.Errorf("unsupported LNURL address") + } + + return url, nil +} + +// NewChallenge fetches a new invoice for the given price from the LNURL +// server. This is part of the +func (l *LNURLChallenger) NewChallenge(price int64) (string, lntypes.Hash, + error) { + + paymentRequest, paymentHash, err := l.fetchInvoice(price) + if err != nil { + return "", lntypes.Hash{}, err + } + + hash, err := lntypes.MakeHash(paymentHash) + if err != nil { + return "", lntypes.Hash{}, err + } + + return paymentRequest, hash, nil +} + +// fetchInvoice attempts to fetch an invoice from the LNURL server for the +// given price. It returns the invoice string and payment hash. +func (l *LNURLChallenger) fetchInvoice(price int64) (string, []byte, error) { + // Make a GET request to the decoded LNURL. + var payResp PayResponse + if err := get(l.url, &payResp); err != nil { + return "", nil, err + } + + // Ensure that the response has the correct tag. + if payResp.Tag != payRequestTag { + return "", nil, fmt.Errorf("incorrect tag received. "+ + "Expected %s, got %s", payRequestTag, payResp.Tag) + } + + // Check that the LNURL server accepts the given price. + if price < payResp.MinSendable || price > payResp.MaxSendable { + return "", nil, fmt.Errorf("price out of range for lnurl " + + "server min and max parameters") + } + + delim := "?" + if strings.Contains(payResp.Callback, "?") { + delim = "&" + } + getInvoiceReq := fmt.Sprintf( + "%s%samount=%d", payResp.Callback, delim, price, + ) + + // Now make a request to the callback URL with the parameters of the + // invoice we want. + var invoice InvoiceResponse + if err := get(getInvoiceReq, &invoice); err != nil { + return "", nil, err + } + + inv, err := zpay32.Decode(invoice.PayRequest, l.network) + if err != nil { + return "", nil, err + } + + // Ensure that the invoice description hash matches the metadata + // received before. + metaHash := sha256.Sum256([]byte(html.UnescapeString(payResp.Metadata))) + if !bytes.Equal(inv.DescriptionHash[:], metaHash[:]) { + return "", nil, fmt.Errorf("invalid invoice description " + + "hash received from the LNURL server") + } + + return invoice.PayRequest, inv.PaymentHash[:], nil +} + +// PayResponse is the structure of the JSON response expected from the initial +// query to the LNURL server. +type PayResponse struct { + // Callback is the URL from LN SERVICE which will accept the pay + // request parameters. + Callback string `json:"callback"` + + // MaxSendable is the max amount LN SERVICE is willing to receive. + MaxSendable int64 `json:"maxSendable"` + + // MinSendable is the min amount LN SERVICE is willing to receive, can + // not be less than 1 or more than MaxSendable. + MinSendable int64 `json:"minSendable"` + + // Metadata json which must be presented as raw string here, this is + // required to pass signature verification at a later step. + Metadata string `json:"metadata"` + + // Type of LNURL. + Tag string `json:"tag"` +} + +// InvoiceResponse is the structure of the JSON response we expect from the +// query to the Callback received in the PayResponse. +type InvoiceResponse struct { + // PayRequest is a bech32-serialized lightning invoice. + PayRequest string `json:"pr"` + + // Routes is an empty array. + Routes []string `json:"routes"` +} + +// get makes an HTTP get request to the given URL and attempts to unmarshal +// the response. +func get(url string, out interface{}) error { + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("GET request error: %v", err) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("could not read response body: %v", err) + } + defer resp.Body.Close() + + return json.Unmarshal(body, &out) +} + +// decodeLNURL does a bech32 decode of an LNURL string. +func decodeLNURL(lnurl string) (string, error) { + hrp, data, err := bech32.Decode(lnurl) + if err != nil { + return "", err + } + + if hrp != lnurlHRP { + return "", fmt.Errorf("incorrect hrp for LNURL. Expected "+ + "'%s', got '%s'", lnurlHRP, hrp) + } + + data, err = bech32.ConvertBits(data, 5, 8, false) + if err != nil { + return "", err + } + + return string(data), nil +} diff --git a/sample-conf.yaml b/sample-conf.yaml index 6493d5a..abc1c3a 100644 --- a/sample-conf.yaml +++ b/sample-conf.yaml @@ -34,6 +34,12 @@ authenticator: # The chain network the lnd is active on. network: "simnet" + # The LNURL to query for invoices. If this is set then LND config + # should not be set. If LNURL is used for invoice retrieval then + # aperture can _not_ check for the status of an invoice and will + # rely purely on the preimage-paymentHash match. + lnurl: "LNURL1DP68GURN8GHJ7URP0YHX2MRVV4KK7AT5DAHZUCM0D5HHQCTE28LWCH" + # Settings for the etcd instance which the proxy will use to reliably store and # retrieve token information. etcd: