|
1 | 1 | package services |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
4 | 5 | "context" |
5 | 6 | "crypto/rand" |
6 | 7 | "encoding/base64" |
@@ -272,8 +273,160 @@ func (s *sep45Service) CreateChallenge(ctx context.Context, req SEP45ChallengeRe |
272 | 273 | }, nil |
273 | 274 | } |
274 | 275 |
|
| 276 | +type webAuthVerifyArgs struct { |
| 277 | + raw map[string]string // Useful for comparing arguments |
| 278 | + clientAccount string |
| 279 | + clientContractID xdr.ContractId |
| 280 | + homeDomain string |
| 281 | + clientDomain string |
| 282 | + clientDomainAccount string |
| 283 | +} |
| 284 | + |
275 | 285 | func (s *sep45Service) ValidateChallenge(ctx context.Context, req SEP45ValidationRequest) (*SEP45ValidationResponse, error) { |
276 | | - return nil, fmt.Errorf("challenge validation is not implemented") |
| 286 | + encodedEntries := strings.TrimSpace(req.AuthorizationEntries) |
| 287 | + if encodedEntries == "" { |
| 288 | + return nil, fmt.Errorf("authorization_entries is required") |
| 289 | + } |
| 290 | + |
| 291 | + rawEntries, err := base64.StdEncoding.DecodeString(encodedEntries) |
| 292 | + if err != nil { |
| 293 | + return nil, fmt.Errorf("decoding authorization entries: %w", err) |
| 294 | + } |
| 295 | + |
| 296 | + var entries xdr.SorobanAuthorizationEntries |
| 297 | + if err := entries.UnmarshalBinary(rawEntries); err != nil { |
| 298 | + return nil, fmt.Errorf("unmarshalling authorization entries: %w", err) |
| 299 | + } |
| 300 | + if len(entries) == 0 { |
| 301 | + return nil, fmt.Errorf("authorization entries cannot be empty") |
| 302 | + } |
| 303 | + |
| 304 | + webAuthDomain := strings.TrimSpace(s.getWebAuthDomain(ctx)) |
| 305 | + if webAuthDomain == "" { |
| 306 | + return nil, fmt.Errorf("unable to determine web_auth_domain") |
| 307 | + } |
| 308 | + |
| 309 | + var ( |
| 310 | + argsXDR xdr.ScVec |
| 311 | + parsedArgs *webAuthVerifyArgs |
| 312 | + serverEntryVerified bool |
| 313 | + clientEntryFound bool |
| 314 | + clientDomainFound bool |
| 315 | + ) |
| 316 | + |
| 317 | + for _, entry := range entries { |
| 318 | + contractFn, err := s.ensureWebAuthInvocation(entry) |
| 319 | + if err != nil { |
| 320 | + return nil, err |
| 321 | + } |
| 322 | + |
| 323 | + // Extract the invocation arguments and make sure they are valid and consistent across entries |
| 324 | + argsMap, err := utils.ExtractArgsMap(contractFn.Args) |
| 325 | + if err != nil { |
| 326 | + return nil, fmt.Errorf("extracting authorization arguments: %w", err) |
| 327 | + } |
| 328 | + if parsedArgs == nil { |
| 329 | + argsXDR = contractFn.Args |
| 330 | + parsedArgs, err = s.buildChallengeArgs(argsMap, webAuthDomain) |
| 331 | + if err != nil { |
| 332 | + return nil, err |
| 333 | + } |
| 334 | + } else if err := compareArgs(argsMap, parsedArgs.raw); err != nil { |
| 335 | + return nil, err |
| 336 | + } |
| 337 | + |
| 338 | + // Check that we have the expected authorization entries |
| 339 | + addr := entry.Credentials.Address.Address |
| 340 | + switch addr.Type { |
| 341 | + case xdr.ScAddressTypeScAddressTypeAccount: |
| 342 | + if addr.AccountId == nil { |
| 343 | + return nil, fmt.Errorf("authorization entry missing account id") |
| 344 | + } |
| 345 | + // If the account matches the server signing key, we can verify the signature now |
| 346 | + accountAddress := addr.AccountId.Address() |
| 347 | + if accountAddress == s.signingKP.Address() { |
| 348 | + if err := s.verifyServerAuthEntry(entry); err != nil { |
| 349 | + return nil, err |
| 350 | + } |
| 351 | + serverEntryVerified = true |
| 352 | + } else if parsedArgs != nil && parsedArgs.clientDomainAccount != "" && accountAddress == parsedArgs.clientDomainAccount { |
| 353 | + clientDomainFound = true |
| 354 | + } |
| 355 | + case xdr.ScAddressTypeScAddressTypeContract: |
| 356 | + if addr.ContractId == nil { |
| 357 | + return nil, fmt.Errorf("authorization entry missing contract id") |
| 358 | + } |
| 359 | + if parsedArgs != nil && *addr.ContractId == parsedArgs.clientContractID { |
| 360 | + clientEntryFound = true |
| 361 | + } |
| 362 | + default: |
| 363 | + return nil, fmt.Errorf("unsupported authorization address type: %d", addr.Type) |
| 364 | + } |
| 365 | + } |
| 366 | + |
| 367 | + if parsedArgs == nil { |
| 368 | + return nil, fmt.Errorf("missing authorization arguments") |
| 369 | + } |
| 370 | + if !serverEntryVerified { |
| 371 | + return nil, fmt.Errorf("missing signed server authorization entry") |
| 372 | + } |
| 373 | + if !clientEntryFound { |
| 374 | + return nil, fmt.Errorf("missing client account authorization entry") |
| 375 | + } |
| 376 | + if parsedArgs.clientDomainAccount != "" && !clientDomainFound { |
| 377 | + return nil, fmt.Errorf("missing client domain authorization entry") |
| 378 | + } |
| 379 | + if len(argsXDR) == 0 { |
| 380 | + return nil, fmt.Errorf("unable to rebuild invocation arguments") |
| 381 | + } |
| 382 | + |
| 383 | + contractID := s.contractID |
| 384 | + hostFunction := xdr.HostFunction{ |
| 385 | + Type: xdr.HostFunctionTypeHostFunctionTypeInvokeContract, |
| 386 | + InvokeContract: &xdr.InvokeContractArgs{ |
| 387 | + ContractAddress: xdr.ScAddress{ |
| 388 | + Type: xdr.ScAddressTypeScAddressTypeContract, |
| 389 | + ContractId: &contractID, |
| 390 | + }, |
| 391 | + FunctionName: "web_auth_verify", |
| 392 | + Args: argsXDR, |
| 393 | + }, |
| 394 | + } |
| 395 | + |
| 396 | + authEntries := make([]xdr.SorobanAuthorizationEntry, len(entries)) |
| 397 | + copy(authEntries, entries) |
| 398 | + |
| 399 | + txParams := txnbuild.TransactionParams{ |
| 400 | + SourceAccount: &txnbuild.SimpleAccount{ |
| 401 | + AccountID: keypair.MustRandom().Address(), |
| 402 | + Sequence: 0, |
| 403 | + }, |
| 404 | + BaseFee: int64(txnbuild.MinBaseFee), |
| 405 | + Preconditions: txnbuild.Preconditions{ |
| 406 | + TimeBounds: txnbuild.NewTimeout(300), |
| 407 | + }, |
| 408 | + Operations: []txnbuild.Operation{&txnbuild.InvokeHostFunction{ |
| 409 | + SourceAccount: s.signingKP.Address(), |
| 410 | + HostFunction: hostFunction, |
| 411 | + Auth: authEntries, |
| 412 | + }}, |
| 413 | + } |
| 414 | + |
| 415 | + tx, err := txnbuild.NewTransaction(txParams) |
| 416 | + if err != nil { |
| 417 | + return nil, fmt.Errorf("building transaction: %w", err) |
| 418 | + } |
| 419 | + |
| 420 | + txB64, err := tx.Base64() |
| 421 | + if err != nil { |
| 422 | + return nil, fmt.Errorf("encoding transaction: %w", err) |
| 423 | + } |
| 424 | + |
| 425 | + if _, simErr := s.rpcClient.SimulateTransaction(ctx, protocol.SimulateTransactionRequest{Transaction: txB64}); simErr != nil { |
| 426 | + return nil, fmt.Errorf("simulating transaction: %w", simErr) |
| 427 | + } |
| 428 | + |
| 429 | + return nil, fmt.Errorf("sep45 jwt generation not implemented") |
277 | 430 | } |
278 | 431 |
|
279 | 432 | func (s *sep45Service) signServerAuthEntry(ctx context.Context, result *stellar.SimulationResult) (xdr.SorobanAuthorizationEntries, error) { |
@@ -340,6 +493,187 @@ func generateNonce() (string, error) { |
340 | 493 | return fmt.Sprintf("%d", binary.BigEndian.Uint32(buf[:])), nil |
341 | 494 | } |
342 | 495 |
|
| 496 | +func (s *sep45Service) ensureWebAuthInvocation(entry xdr.SorobanAuthorizationEntry) (*xdr.InvokeContractArgs, error) { |
| 497 | + if entry.Credentials.Type != xdr.SorobanCredentialsTypeSorobanCredentialsAddress || entry.Credentials.Address == nil { |
| 498 | + return nil, fmt.Errorf("authorization entry missing address credentials") |
| 499 | + } |
| 500 | + if len(entry.RootInvocation.SubInvocations) > 0 { |
| 501 | + return nil, fmt.Errorf("authorization entries cannot contain sub-invocations") |
| 502 | + } |
| 503 | + if entry.RootInvocation.Function.Type != xdr.SorobanAuthorizedFunctionTypeSorobanAuthorizedFunctionTypeContractFn || entry.RootInvocation.Function.ContractFn == nil { |
| 504 | + return nil, fmt.Errorf("authorization entry must invoke contract function") |
| 505 | + } |
| 506 | + |
| 507 | + contractFn := entry.RootInvocation.Function.ContractFn |
| 508 | + if contractFn.ContractAddress.Type != xdr.ScAddressTypeScAddressTypeContract || contractFn.ContractAddress.ContractId == nil { |
| 509 | + return nil, fmt.Errorf("authorization entry missing contract address") |
| 510 | + } |
| 511 | + if *contractFn.ContractAddress.ContractId != s.contractID { |
| 512 | + return nil, fmt.Errorf("authorization entry targets unexpected contract") |
| 513 | + } |
| 514 | + if contractFn.FunctionName != "web_auth_verify" { |
| 515 | + return nil, fmt.Errorf("authorization entry must call web_auth_verify") |
| 516 | + } |
| 517 | + return contractFn, nil |
| 518 | +} |
| 519 | + |
| 520 | +func compareArgs(current, expected map[string]string) error { |
| 521 | + if len(current) != len(expected) { |
| 522 | + return fmt.Errorf("authorization entry arguments mismatch") |
| 523 | + } |
| 524 | + for k, v := range expected { |
| 525 | + if current[k] != v { |
| 526 | + return fmt.Errorf("authorization entry arguments mismatch") |
| 527 | + } |
| 528 | + } |
| 529 | + return nil |
| 530 | +} |
| 531 | + |
| 532 | +func (s *sep45Service) buildChallengeArgs(args map[string]string, webAuthDomain string) (*webAuthVerifyArgs, error) { |
| 533 | + clientAccount := strings.TrimSpace(args["account"]) |
| 534 | + if clientAccount == "" { |
| 535 | + return nil, fmt.Errorf("account argument is required") |
| 536 | + } |
| 537 | + rawContractID, err := strkey.Decode(strkey.VersionByteContract, clientAccount) |
| 538 | + if err != nil { |
| 539 | + return nil, fmt.Errorf("account must be a valid contract address: %w", err) |
| 540 | + } |
| 541 | + var contractID xdr.ContractId |
| 542 | + copy(contractID[:], rawContractID) |
| 543 | + |
| 544 | + homeDomain := strings.TrimSpace(args["home_domain"]) |
| 545 | + if homeDomain == "" { |
| 546 | + return nil, fmt.Errorf("home_domain is required") |
| 547 | + } |
| 548 | + if !s.isValidHomeDomain(homeDomain) { |
| 549 | + return nil, fmt.Errorf("invalid home_domain must match %s", s.getBaseDomain()) |
| 550 | + } |
| 551 | + |
| 552 | + challengeWebAuthDomain := strings.TrimSpace(args["web_auth_domain"]) |
| 553 | + if challengeWebAuthDomain == "" { |
| 554 | + return nil, fmt.Errorf("web_auth_domain is required") |
| 555 | + } |
| 556 | + if !strings.EqualFold(challengeWebAuthDomain, webAuthDomain) { |
| 557 | + return nil, fmt.Errorf("web_auth_domain must equal %s", webAuthDomain) |
| 558 | + } |
| 559 | + |
| 560 | + webAuthDomainAccount := strings.TrimSpace(args["web_auth_domain_account"]) |
| 561 | + if webAuthDomainAccount == "" { |
| 562 | + return nil, fmt.Errorf("web_auth_domain_account is required") |
| 563 | + } |
| 564 | + if !strkey.IsValidEd25519PublicKey(webAuthDomainAccount) { |
| 565 | + return nil, fmt.Errorf("web_auth_domain_account must be a valid Stellar account") |
| 566 | + } |
| 567 | + if webAuthDomainAccount != s.signingKP.Address() { |
| 568 | + return nil, fmt.Errorf("web_auth_domain_account must match server signing key") |
| 569 | + } |
| 570 | + |
| 571 | + clientDomain := strings.TrimSpace(args["client_domain"]) |
| 572 | + clientDomainAccount := strings.TrimSpace(args["client_domain_account"]) |
| 573 | + if clientDomainAccount != "" && clientDomain == "" { |
| 574 | + return nil, fmt.Errorf("client_domain is required when client_domain_account is provided") |
| 575 | + } |
| 576 | + if clientDomain != "" { |
| 577 | + if clientDomainAccount == "" { |
| 578 | + return nil, fmt.Errorf("client_domain_account is required when client_domain is provided") |
| 579 | + } |
| 580 | + if !strkey.IsValidEd25519PublicKey(clientDomainAccount) { |
| 581 | + return nil, fmt.Errorf("client_domain_account must be a valid Stellar account") |
| 582 | + } |
| 583 | + } else if s.clientAttributionRequired { |
| 584 | + return nil, fmt.Errorf("client_domain is required") |
| 585 | + } |
| 586 | + |
| 587 | + if strings.TrimSpace(args["nonce"]) == "" { |
| 588 | + return nil, fmt.Errorf("nonce is required") |
| 589 | + } |
| 590 | + |
| 591 | + return &webAuthVerifyArgs{ |
| 592 | + raw: args, |
| 593 | + clientAccount: clientAccount, |
| 594 | + clientContractID: contractID, |
| 595 | + homeDomain: homeDomain, |
| 596 | + clientDomain: clientDomain, |
| 597 | + clientDomainAccount: clientDomainAccount, |
| 598 | + }, nil |
| 599 | +} |
| 600 | + |
| 601 | +func (s *sep45Service) verifyServerAuthEntry(entry xdr.SorobanAuthorizationEntry) error { |
| 602 | + if entry.Credentials.Address == nil { |
| 603 | + return fmt.Errorf("server authorization entry missing address credentials") |
| 604 | + } |
| 605 | + sigVal := entry.Credentials.Address.Signature |
| 606 | + if sigVal.Type != xdr.ScValTypeScvVec { |
| 607 | + return fmt.Errorf("server authorization entry missing signature") |
| 608 | + } |
| 609 | + expiration := uint32(entry.Credentials.Address.SignatureExpirationLedger) |
| 610 | + if expiration == 0 { |
| 611 | + return fmt.Errorf("server authorization entry missing expiration ledger") |
| 612 | + } |
| 613 | + |
| 614 | + publicKey, signature, err := extractSignature(&sigVal) |
| 615 | + if err != nil { |
| 616 | + return err |
| 617 | + } |
| 618 | + if !bytes.Equal(publicKey, s.signingPKBytes) { |
| 619 | + return fmt.Errorf("server authorization entry signed by unexpected key") |
| 620 | + } |
| 621 | + |
| 622 | + payload, err := utils.BuildAuthorizationPayload(entry, s.networkPassphrase) |
| 623 | + if err != nil { |
| 624 | + return fmt.Errorf("building authorization payload: %w", err) |
| 625 | + } |
| 626 | + |
| 627 | + // We could also verify that the signature expiration ledger is not |
| 628 | + // expired yet so we can return early but this is also checked during the transaction simulation |
| 629 | + if err := s.signingKP.Verify(payload[:], signature); err != nil { |
| 630 | + return fmt.Errorf("server authorization entry signature invalid: %w", err) |
| 631 | + } |
| 632 | + return nil |
| 633 | +} |
| 634 | + |
| 635 | +func extractSignature(sigVal *xdr.ScVal) ([]byte, []byte, error) { |
| 636 | + vec, ok := sigVal.GetVec() |
| 637 | + if !ok || vec == nil || len(*vec) == 0 { |
| 638 | + return nil, nil, fmt.Errorf("signature must be a vector") |
| 639 | + } |
| 640 | + sigMapVal := (*vec)[0] |
| 641 | + entries, ok := sigMapVal.GetMap() |
| 642 | + if !ok || entries == nil { |
| 643 | + return nil, nil, fmt.Errorf("signature must be a map") |
| 644 | + } |
| 645 | + |
| 646 | + var publicKey []byte |
| 647 | + var signature []byte |
| 648 | + for _, entry := range *entries { |
| 649 | + key, ok := entry.Key.GetSym() |
| 650 | + if !ok { |
| 651 | + continue |
| 652 | + } |
| 653 | + switch string(key) { |
| 654 | + case "public_key": |
| 655 | + bytesVal, ok := entry.Val.GetBytes() |
| 656 | + if !ok { |
| 657 | + return nil, nil, fmt.Errorf("signature public key must be bytes") |
| 658 | + } |
| 659 | + publicKey = append([]byte(nil), bytesVal...) |
| 660 | + case "signature": |
| 661 | + bytesVal, ok := entry.Val.GetBytes() |
| 662 | + if !ok { |
| 663 | + return nil, nil, fmt.Errorf("signature bytes missing") |
| 664 | + } |
| 665 | + signature = append([]byte(nil), bytesVal...) |
| 666 | + } |
| 667 | + } |
| 668 | + if len(publicKey) == 0 { |
| 669 | + return nil, nil, fmt.Errorf("signature missing public key") |
| 670 | + } |
| 671 | + if len(signature) == 0 { |
| 672 | + return nil, nil, fmt.Errorf("signature missing value") |
| 673 | + } |
| 674 | + return publicKey, signature, nil |
| 675 | +} |
| 676 | + |
343 | 677 | // TODO(philip): Below methods are shared with sep10_service.go so they can be moved to a common utility package later. |
344 | 678 |
|
345 | 679 | func (s *sep45Service) getWebAuthDomain(ctx context.Context) string { |
|
0 commit comments