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 }