github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/stellar/stellarsvc/anchor.go (about)

     1  package stellarsvc
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/base64"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  
    11  	"net/http"
    12  	"net/url"
    13  	"path"
    14  	"strings"
    15  	"time"
    16  
    17  	"github.com/keybase/client/go/libkb"
    18  	"github.com/keybase/client/go/protocol/stellar1"
    19  	"github.com/keybase/stellarnet"
    20  	"github.com/stellar/go/keypair"
    21  	"github.com/stellar/go/network"
    22  	"github.com/stellar/go/xdr"
    23  	"golang.org/x/net/publicsuffix"
    24  )
    25  
    26  // ErrAnchor is returned by some error paths and includes
    27  // a code to make it easier to figure out which error
    28  // it refers to.
    29  type ErrAnchor struct {
    30  	Code    int
    31  	Message string
    32  }
    33  
    34  // Error returns an error message string.
    35  func (e ErrAnchor) Error() string {
    36  	return e.Message
    37  }
    38  
    39  const (
    40  	ErrAnchorCodeBadStatus = 100
    41  )
    42  
    43  // anchorInteractor is used to interact with the sep6 transfer server for an asset.
    44  type anchorInteractor struct {
    45  	accountID      stellar1.AccountID
    46  	secretKey      *stellar1.SecretKey
    47  	asset          stellar1.Asset
    48  	authToken      string
    49  	httpGetClient  func(mctx libkb.MetaContext, url, authToken string) (code int, body []byte, err error)
    50  	httpPostClient func(mctx libkb.MetaContext, url, authToken string, data url.Values) (code int, body []byte, err error)
    51  }
    52  
    53  // newAnchorInteractor creates an anchorInteractor for an account to interact
    54  // with an asset.
    55  func newAnchorInteractor(accountID stellar1.AccountID, secretKey *stellar1.SecretKey, asset stellar1.Asset) *anchorInteractor {
    56  	ai := &anchorInteractor{
    57  		accountID:      accountID,
    58  		asset:          asset,
    59  		httpGetClient:  httpGet,
    60  		httpPostClient: httpPost,
    61  	}
    62  
    63  	if ai.asset.AuthEndpoint != "" {
    64  		// we will need secretKey to sign authentication challenges.
    65  		ai.secretKey = secretKey
    66  	}
    67  
    68  	return ai
    69  }
    70  
    71  // Deposit runs the deposit action for accountID on the transfer server for asset.
    72  func (a *anchorInteractor) Deposit(mctx libkb.MetaContext) (stellar1.AssetActionResultLocal, error) {
    73  	if err := a.checkAssetForDeposit(mctx); err != nil {
    74  		return stellar1.AssetActionResultLocal{}, err
    75  	}
    76  	if a.asset.UseSep24 {
    77  		return a.depositSep24(mctx)
    78  	}
    79  	return a.depositSep6(mctx)
    80  }
    81  
    82  func (a *anchorInteractor) depositSep6(mctx libkb.MetaContext) (stellar1.AssetActionResultLocal, error) {
    83  	u, err := a.checkURL(mctx, "deposit")
    84  	if err != nil {
    85  		return stellar1.AssetActionResultLocal{}, err
    86  	}
    87  
    88  	v := url.Values{}
    89  	v.Set("account", a.accountID.String())
    90  	v.Set("asset_code", a.asset.Code)
    91  	u.RawQuery = v.Encode()
    92  
    93  	var okResponse okDepositResponse
    94  	return a.get(mctx, u, &okResponse)
    95  }
    96  
    97  func (a *anchorInteractor) depositSep24(mctx libkb.MetaContext) (stellar1.AssetActionResultLocal, error) {
    98  	u, err := a.checkURL(mctx, "/transactions/deposit/interactive")
    99  	if err != nil {
   100  		return stellar1.AssetActionResultLocal{}, err
   101  	}
   102  
   103  	v := url.Values{}
   104  	v.Set("account", a.accountID.String())
   105  	v.Set("asset_code", a.asset.Code)
   106  
   107  	return a.postSep24(mctx, u, v)
   108  }
   109  
   110  // Withdraw runs the withdraw action for accountID on the transfer server for asset.
   111  func (a *anchorInteractor) Withdraw(mctx libkb.MetaContext) (stellar1.AssetActionResultLocal, error) {
   112  	if err := a.checkAssetForWithdraw(mctx); err != nil {
   113  		return stellar1.AssetActionResultLocal{}, err
   114  	}
   115  	if a.asset.UseSep24 {
   116  		return a.withdrawSep24(mctx)
   117  	}
   118  	return a.withdrawSep6(mctx)
   119  }
   120  
   121  func (a *anchorInteractor) withdrawSep6(mctx libkb.MetaContext) (stellar1.AssetActionResultLocal, error) {
   122  	u, err := a.checkURL(mctx, "withdraw")
   123  	if err != nil {
   124  		return stellar1.AssetActionResultLocal{}, err
   125  	}
   126  
   127  	v := url.Values{}
   128  	v.Set("asset_code", a.asset.Code)
   129  	v.Set("account", a.accountID.String())
   130  	if a.asset.WithdrawType != "" {
   131  		// this is supposed to be optional, but a lot of anchors require it
   132  		// if they all change to optional, we can not return this from stellard
   133  		// and it won't get set
   134  		v.Set("type", a.asset.WithdrawType)
   135  	}
   136  	u.RawQuery = v.Encode()
   137  
   138  	var okResponse okWithdrawResponse
   139  	return a.get(mctx, u, &okResponse)
   140  }
   141  
   142  func (a *anchorInteractor) withdrawSep24(mctx libkb.MetaContext) (stellar1.AssetActionResultLocal, error) {
   143  	u, err := a.checkURL(mctx, "/transactions/withdraw/interactive")
   144  	if err != nil {
   145  		return stellar1.AssetActionResultLocal{}, err
   146  	}
   147  
   148  	v := url.Values{}
   149  	v.Set("asset_code", a.asset.Code)
   150  	v.Set("account", a.accountID.String())
   151  
   152  	return a.postSep24(mctx, u, v)
   153  }
   154  
   155  // checkAssetForDeposit sanity-checks the asset to make sure it is verified
   156  // and looks like the transfer server actions are supported.
   157  func (a *anchorInteractor) checkAssetForDeposit(mctx libkb.MetaContext) error {
   158  	if err := a.checkAssetCommon(mctx); err != nil {
   159  		return err
   160  	}
   161  	if !a.asset.ShowDepositButton {
   162  		return errors.New("deposit not enabled for asset")
   163  	}
   164  	if a.asset.DepositReqAuth {
   165  		if a.asset.AuthEndpoint == "" {
   166  			return errors.New("deposit requires auth, but no auth endpoint")
   167  		}
   168  
   169  		// asset requires sep10 authentication token
   170  		if err := a.getAuthToken(mctx); err != nil {
   171  			return err
   172  		}
   173  	}
   174  
   175  	return nil
   176  }
   177  
   178  // checkAssetForWithdraw sanity-checks the asset to make sure it is verified
   179  // and looks like the transfer server actions are supported.
   180  func (a *anchorInteractor) checkAssetForWithdraw(mctx libkb.MetaContext) error {
   181  	if err := a.checkAssetCommon(mctx); err != nil {
   182  		return err
   183  	}
   184  	if !a.asset.ShowWithdrawButton {
   185  		return errors.New("withdraw not enabled for asset")
   186  	}
   187  	if a.asset.WithdrawReqAuth {
   188  		if a.asset.AuthEndpoint == "" {
   189  			return errors.New("withdraw requires auth, but no auth endpoint")
   190  		}
   191  
   192  		// asset requires sep10 authentication token
   193  		if err := a.getAuthToken(mctx); err != nil {
   194  			return err
   195  		}
   196  	}
   197  
   198  	return nil
   199  }
   200  
   201  // checkAssetCommon sanity-checks the asset to make sure it is verified
   202  // and has a transfer server.
   203  func (a *anchorInteractor) checkAssetCommon(mctx libkb.MetaContext) error {
   204  	if a.asset.VerifiedDomain == "" {
   205  		return errors.New("asset is unverified")
   206  	}
   207  	if a.asset.TransferServer == "" {
   208  		return errors.New("asset has no transfer server")
   209  	}
   210  
   211  	return nil
   212  }
   213  
   214  // checkURL creates the URL with the transfer server value and a path
   215  // and checks it for validity and same domain as the asset.
   216  func (a *anchorInteractor) checkURL(mctx libkb.MetaContext, action string) (*url.URL, error) {
   217  	u, err := url.ParseRequestURI(a.asset.TransferServer)
   218  	if err != nil {
   219  		return nil, err
   220  	}
   221  	u.Path = path.Join(u.Path, action)
   222  
   223  	if u.Scheme != "https" {
   224  		return nil, errors.New("transfer server URL is not https")
   225  	}
   226  
   227  	if u.RawQuery != "" {
   228  		return nil, errors.New("transfer server URL has a query")
   229  	}
   230  
   231  	return u, nil
   232  }
   233  
   234  type okDepositResponse struct {
   235  	How        string      `json:"how"`
   236  	ETA        int         `json:"int"`
   237  	MinAmount  float64     `json:"min_amount"`
   238  	MaxAmount  float64     `json:"max_amount"`
   239  	FeeFixed   float64     `json:"fee_fixed"`
   240  	FeePercent float64     `json:"fee_percent"`
   241  	ExtraInfo  interface{} `json:"extra_info"`
   242  }
   243  
   244  // this will never happen, but:
   245  func (r *okDepositResponse) String() string {
   246  	return fmt.Sprintf("Deposit request approved by anchor.  %s: %v", r.How, r.ExtraInfo)
   247  }
   248  
   249  type okWithdrawResponse struct {
   250  	AccountID  string  `json:"account_id"`
   251  	MemoType   string  `json:"memo_type"` // text, id or hash
   252  	Memo       string  `json:"memo"`
   253  	ETA        int     `json:"int"`
   254  	MinAmount  float64 `json:"min_amount"`
   255  	MaxAmount  float64 `json:"max_amount"`
   256  	FeeFixed   float64 `json:"fee_fixed"`
   257  	FeePercent float64 `json:"fee_percent"`
   258  }
   259  
   260  // this will never happen, but:
   261  func (r *okWithdrawResponse) String() string {
   262  	return fmt.Sprintf("Withdraw request approved by anchor.  Send token to %s.  Memo: %s (%s)", r.AccountID, r.Memo, r.MemoType)
   263  }
   264  
   265  type forbiddenResponse struct {
   266  	Type  string `json:"type"`
   267  	URL   string `json:"url"`
   268  	ID    string `json:"id"`
   269  	Error string `json:"error"`
   270  }
   271  
   272  type okSep24Response struct {
   273  	Type string `json:"type"`
   274  	URL  string `json:"url"`
   275  	ID   string `json:"id"`
   276  }
   277  
   278  func (r *okSep24Response) String() string {
   279  	return r.URL
   280  }
   281  
   282  // get performs the http GET requests and parses the result.
   283  func (a *anchorInteractor) get(mctx libkb.MetaContext, u *url.URL, okResponse fmt.Stringer) (stellar1.AssetActionResultLocal, error) {
   284  	mctx.Debug("performing http GET to %s", u)
   285  	code, body, err := a.httpGetClient(mctx, u.String(), a.authToken)
   286  	if err != nil {
   287  		mctx.Debug("GET failed: %s", err)
   288  		return stellar1.AssetActionResultLocal{}, err
   289  	}
   290  	switch code {
   291  	case http.StatusOK:
   292  		if err := json.Unmarshal(body, okResponse); err != nil {
   293  			mctx.Debug("json unmarshal of 200 response failed: %s", err)
   294  			return stellar1.AssetActionResultLocal{}, err
   295  		}
   296  		msg := okResponse.String()
   297  		return stellar1.AssetActionResultLocal{MessageFromAnchor: &msg}, nil
   298  	case http.StatusForbidden:
   299  		var resp forbiddenResponse
   300  		if err := json.Unmarshal(body, &resp); err != nil {
   301  			mctx.Debug("json unmarshal of 403 response failed: %s", err)
   302  			return stellar1.AssetActionResultLocal{}, err
   303  		}
   304  		if resp.Error != "" {
   305  			return stellar1.AssetActionResultLocal{}, fmt.Errorf("Error from anchor: %s", resp.Error)
   306  		}
   307  		if resp.Type == "interactive_customer_info_needed" {
   308  			parsed, err := url.Parse(resp.URL)
   309  			if err != nil {
   310  				mctx.Debug("invalid URL received from anchor: %s", resp.URL)
   311  				return stellar1.AssetActionResultLocal{}, errors.New("invalid URL received from anchor")
   312  			}
   313  			if !a.domainMatches(parsed.Host) {
   314  				mctx.Debug("response URL on a different domain than asset domain: %s vs. %s", resp.URL, a.asset.VerifiedDomain)
   315  				return stellar1.AssetActionResultLocal{}, errors.New("anchor requesting opening a different domain")
   316  			}
   317  			return stellar1.AssetActionResultLocal{ExternalUrl: &resp.URL}, nil
   318  		}
   319  		mctx.Debug("unhandled anchor response for %s: %+v", u, resp)
   320  		return stellar1.AssetActionResultLocal{}, errors.New("unhandled asset anchor http response")
   321  	default:
   322  		mctx.Debug("unhandled anchor response code for %s: %d", u, code)
   323  		mctx.Debug("unhandled anchor response body for %s: %s", u, string(body))
   324  		return stellar1.AssetActionResultLocal{},
   325  			ErrAnchor{
   326  				Code:    ErrAnchorCodeBadStatus,
   327  				Message: fmt.Sprintf("Unknown asset anchor HTTP response code %d", code),
   328  			}
   329  	}
   330  }
   331  
   332  // postSep24 performs the http POST request for interactive deposit/withdraw and
   333  // parses the result.
   334  func (a *anchorInteractor) postSep24(mctx libkb.MetaContext, u *url.URL, data url.Values) (stellar1.AssetActionResultLocal, error) {
   335  	mctx.Debug("performing http POST (sep24) to %s", u)
   336  	code, body, err := a.httpPostClient(mctx, u.String(), a.authToken, data)
   337  	if err != nil {
   338  		mctx.Debug("POST failed: %s", err)
   339  		return stellar1.AssetActionResultLocal{}, err
   340  	}
   341  
   342  	switch code {
   343  	case http.StatusOK:
   344  		var resp okSep24Response
   345  		if err := json.Unmarshal(body, &resp); err != nil {
   346  			mctx.Debug("json unmarshal of 200 response failed: %s", err)
   347  			return stellar1.AssetActionResultLocal{}, err
   348  		}
   349  		if resp.Type == "interactive_customer_info_needed" {
   350  			_, err = url.Parse(resp.URL)
   351  			if err != nil {
   352  				mctx.Debug("invalid URL received from anchor: %s", resp.URL)
   353  				return stellar1.AssetActionResultLocal{}, errors.New("invalid URL received from anchor")
   354  			}
   355  			return stellar1.AssetActionResultLocal{ExternalUrl: &resp.URL}, nil
   356  		}
   357  		mctx.Debug("unhandled anchor response for %s: %+v", u, resp)
   358  		return stellar1.AssetActionResultLocal{}, errors.New("unhandled asset anchor http response")
   359  	default:
   360  		mctx.Debug("unhandled anchor response code for %s: %d", u, code)
   361  		mctx.Debug("unhandled anchor response body for %s: %s", u, string(body))
   362  		return stellar1.AssetActionResultLocal{},
   363  			ErrAnchor{
   364  				Code:    ErrAnchorCodeBadStatus,
   365  				Message: fmt.Sprintf("Unknown asset anchor HTTP response code %d", code),
   366  			}
   367  	}
   368  }
   369  
   370  // httpGet is the live version of httpGetClient that is used
   371  // by default.
   372  func httpGet(mctx libkb.MetaContext, url, authToken string) (int, []byte, error) {
   373  	client := http.Client{
   374  		Timeout: 10 * time.Second,
   375  		Transport: libkb.NewInstrumentedRoundTripper(mctx.G(),
   376  			func(*http.Request) string { return "GET StellarAnchor" },
   377  			http.DefaultTransport.(*http.Transport)),
   378  	}
   379  	req, err := http.NewRequest(http.MethodGet, url, nil)
   380  	if err != nil {
   381  		return 0, nil, err
   382  	}
   383  
   384  	if authToken != "" {
   385  		req.Header.Add("Authorization", "Bearer "+authToken)
   386  	}
   387  
   388  	res, err := client.Do(req.WithContext(mctx.Ctx()))
   389  	if err != nil {
   390  		return 0, nil, err
   391  	}
   392  	defer res.Body.Close()
   393  
   394  	body, err := io.ReadAll(res.Body)
   395  	if err != nil {
   396  		return 0, nil, err
   397  	}
   398  
   399  	return res.StatusCode, body, nil
   400  }
   401  
   402  // httpPost is the live version of httpPostClient that is used
   403  // by default.
   404  func httpPost(mctx libkb.MetaContext, url, authToken string, data url.Values) (int, []byte, error) {
   405  	client := http.Client{
   406  		Timeout: 10 * time.Second,
   407  		Transport: libkb.NewInstrumentedRoundTripper(mctx.G(),
   408  			func(*http.Request) string { return "POST StellarAnchor" },
   409  			http.DefaultTransport.(*http.Transport)),
   410  	}
   411  	req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(data.Encode()))
   412  	if err != nil {
   413  		return 0, nil, err
   414  	}
   415  	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   416  
   417  	if authToken != "" {
   418  		req.Header.Add("Authorization", "Bearer "+authToken)
   419  	}
   420  
   421  	res, err := client.Do(req.WithContext(mctx.Ctx()))
   422  	if err != nil {
   423  		return 0, nil, err
   424  	}
   425  	defer res.Body.Close()
   426  
   427  	body, err := io.ReadAll(res.Body)
   428  	if err != nil {
   429  		return 0, nil, err
   430  	}
   431  
   432  	return res.StatusCode, body, nil
   433  }
   434  
   435  func (a *anchorInteractor) domainMatches(url string) bool {
   436  	urlTLD, err := publicsuffix.EffectiveTLDPlusOne(strings.ToLower(url))
   437  	if err != nil {
   438  		return false
   439  	}
   440  	assetTLD, err := publicsuffix.EffectiveTLDPlusOne(strings.ToLower(a.asset.VerifiedDomain))
   441  	if err != nil {
   442  		return false
   443  	}
   444  	return urlTLD == assetTLD
   445  }
   446  
   447  func (a *anchorInteractor) getAuthToken(mctx libkb.MetaContext) error {
   448  	u, err := url.ParseRequestURI(a.asset.AuthEndpoint)
   449  	if err != nil {
   450  		return err
   451  	}
   452  	if u.Scheme != "https" {
   453  		return errors.New("auth endpoint is not https")
   454  	}
   455  
   456  	v := url.Values{}
   457  	v.Set("account", a.accountID.String())
   458  	u.RawQuery = v.Encode()
   459  
   460  	code, body, err := a.httpGetClient(mctx, u.String(), a.authToken)
   461  	if err != nil {
   462  		return err
   463  	}
   464  	if code != http.StatusOK {
   465  		return errors.New("auth endpoint GET error")
   466  	}
   467  	var res map[string]string
   468  	if err := json.Unmarshal(body, &res); err != nil {
   469  		return err
   470  	}
   471  	tx, ok := res["transaction"]
   472  	if !ok {
   473  		return errors.New("auth endpoint response did not contain a tx challenge")
   474  	}
   475  	var unpacked xdr.TransactionEnvelope
   476  	err = xdr.SafeUnmarshalBase64(tx, &unpacked)
   477  	if err != nil {
   478  		return err
   479  	}
   480  
   481  	if unpacked.Tx.SeqNum != 0 {
   482  		return errors.New("invalid tx challenge: seqno not zero")
   483  	}
   484  
   485  	// TODO:
   486  	// sourceAccount := stellar1.AccountID(unpacked.Tx.SourceAccount.Address())
   487  	// sourceAccount is supposed to be the same as SIGNING_KEY in stellar.toml.
   488  	// we don't get that value currently...
   489  
   490  	if err := stellarnet.VerifyEnvelope(unpacked); err != nil {
   491  		return err
   492  	}
   493  
   494  	if len(unpacked.Tx.Operations) != 1 {
   495  		return errors.New("invalid tx challenge: invalid number of operations")
   496  	}
   497  
   498  	op := unpacked.Tx.Operations[0]
   499  	if op.Body.Type != xdr.OperationTypeManageData {
   500  		return errors.New("invalid tx challenge: invalid operation type")
   501  	}
   502  
   503  	// ok, we've checked all we can check...go ahead and sign this tx.
   504  	if a.secretKey == nil {
   505  		return errors.New("no secret key, cannot sign the tx challenge")
   506  	}
   507  	hash, err := network.HashTransaction(&unpacked.Tx, stellarnet.NetworkPassphrase())
   508  	if err != nil {
   509  		return err
   510  	}
   511  
   512  	kp, err := keypair.Parse(a.secretKey.SecureNoLogString())
   513  	if err != nil {
   514  		return err
   515  	}
   516  	sig, err := kp.SignDecorated(hash[:])
   517  	if err != nil {
   518  		return err
   519  	}
   520  	unpacked.Signatures = append(unpacked.Signatures, sig)
   521  
   522  	var buf bytes.Buffer
   523  	_, err = xdr.Marshal(&buf, unpacked)
   524  	if err != nil {
   525  		return fmt.Errorf("marshaling envelope with signature returened an error: %s", err)
   526  	}
   527  	signed := base64.StdEncoding.EncodeToString(buf.Bytes())
   528  
   529  	data := url.Values{}
   530  	data.Set("transaction", signed)
   531  	code, body, err = a.httpPostClient(mctx, a.asset.AuthEndpoint, "", data)
   532  	if err != nil {
   533  		return err
   534  	}
   535  	if code != http.StatusOK {
   536  		return errors.New("challenge post didn't reutrn OK/200")
   537  	}
   538  
   539  	var tokres map[string]string
   540  	if err := json.Unmarshal(body, &tokres); err != nil {
   541  		return err
   542  	}
   543  	token, ok := tokres["token"]
   544  	if ok {
   545  		a.authToken = token
   546  	} else {
   547  		msg, ok := tokres["error"]
   548  		if ok {
   549  			return fmt.Errorf("challenge post returned an error: %s", msg)
   550  		}
   551  		return errors.New("invalid response from challenge post")
   552  	}
   553  
   554  	return nil
   555  }