diff --git a/exp/services/webauth/internal/serve/challenge.go b/exp/services/webauth/internal/serve/challenge.go index a01458215d..dccfd06494 100644 --- a/exp/services/webauth/internal/serve/challenge.go +++ b/exp/services/webauth/internal/serve/challenge.go @@ -2,6 +2,7 @@ package serve import ( "net/http" + "strconv" "strings" "time" @@ -33,7 +34,9 @@ func (h challengeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { queryValues := r.URL.Query() account := queryValues.Get("account") - if !strkey.IsValidEd25519PublicKey(account) { + isStellarAccount := strkey.IsValidEd25519PublicKey(account) + isMuxedAccount := strkey.IsValidMuxedAccountEd25519PublicKey(account) + if !isStellarAccount && !isMuxedAccount { badRequest.Render(w) return } @@ -57,6 +60,18 @@ func (h challengeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { homeDomain = h.HomeDomains[0] } + var memo *txnbuild.MemoID + memoParam := queryValues.Get("memo") + if memoParam != "" { + memoInt, err := strconv.ParseUint(memoParam, 10, 64) + if err != nil { + badRequest.Render(w) + return + } + memoId := txnbuild.MemoID(memoInt) + memo = &memoId + } + tx, err := txnbuild.BuildChallengeTx( h.SigningKey.Seed(), account, @@ -64,10 +79,11 @@ func (h challengeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { homeDomain, h.NetworkPassphrase, h.ChallengeExpiresIn, + memo, ) if err != nil { h.Logger.Ctx(ctx).WithStack(err).Error(err) - serverError.Render(w) + badRequest.Render(w) return } diff --git a/exp/services/webauth/internal/serve/challenge_test.go b/exp/services/webauth/internal/serve/challenge_test.go index 9391224763..cd5deb2451 100644 --- a/exp/services/webauth/internal/serve/challenge_test.go +++ b/exp/services/webauth/internal/serve/challenge_test.go @@ -11,7 +11,9 @@ import ( "github.com/stellar/go/keypair" "github.com/stellar/go/network" + "github.com/stellar/go/strkey" supportlog "github.com/stellar/go/support/log" + "github.com/stellar/go/txnbuild" "github.com/stellar/go/xdr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -185,3 +187,107 @@ func TestChallenge_invalidHomeDomain(t *testing.T) { require.NoError(t, err) assert.JSONEq(t, `{"error":"The request was invalid in some way."}`, string(body)) } + +func TestChallengeWithMemo(t *testing.T) { + serverKey := keypair.MustRandom() + account := keypair.MustRandom() + + h := challengeHandler{ + Logger: supportlog.DefaultLogger, + NetworkPassphrase: network.TestNetworkPassphrase, + SigningKey: serverKey, + ChallengeExpiresIn: time.Minute, + Domain: "webauthdomain", + HomeDomains: []string{"testdomain"}, + } + + r := httptest.NewRequest("GET", "/?account="+account.Address()+"&memo=1", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + resp := w.Result() + + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type")) + + res := struct { + Transaction string `json:"transaction"` + NetworkPassphrase string `json:"network_passphrase"` + }{} + err := json.NewDecoder(resp.Body).Decode(&res) + require.NoError(t, err) + + var tx xdr.TransactionEnvelope + err = xdr.SafeUnmarshalBase64(res.Transaction, &tx) + require.NoError(t, err) + + memo, err := txnbuild.MemoID(1).ToXDR() + require.NoError(t, err) + require.Equal(t, tx.Memo(), memo) +} + +func TestChallengeWithBadMemo(t *testing.T) { + serverKey := keypair.MustRandom() + account := keypair.MustRandom() + + h := challengeHandler{ + Logger: supportlog.DefaultLogger, + NetworkPassphrase: network.TestNetworkPassphrase, + SigningKey: serverKey, + ChallengeExpiresIn: time.Minute, + Domain: "webauthdomain", + HomeDomains: []string{"testdomain"}, + } + + r := httptest.NewRequest("GET", "/?account="+account.Address()+"&memo=test", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + resp := w.Result() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type")) + + body, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"error":"The request was invalid in some way."}`, string(body)) +} + +func TestChallengeWithMuxedAccount(t *testing.T) { + serverKey := keypair.MustRandom() + account := keypair.MustRandom() + + muxedAccount := strkey.MuxedAccount{} + muxedAccount.SetAccountID(account.Address()) + muxedAccount.SetID(1) + muxedAccountAddress, err := muxedAccount.Address() + require.NoError(t, err) + + h := challengeHandler{ + Logger: supportlog.DefaultLogger, + NetworkPassphrase: network.TestNetworkPassphrase, + SigningKey: serverKey, + ChallengeExpiresIn: time.Minute, + Domain: "webauthdomain", + HomeDomains: []string{"testdomain"}, + } + + r := httptest.NewRequest("GET", "/?account="+muxedAccountAddress, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + resp := w.Result() + + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type")) + + res := struct { + Transaction string `json:"transaction"` + NetworkPassphrase string `json:"network_passphrase"` + }{} + err = json.NewDecoder(resp.Body).Decode(&res) + require.NoError(t, err) + + var tx xdr.TransactionEnvelope + err = xdr.SafeUnmarshalBase64(res.Transaction, &tx) + require.NoError(t, err) + + require.Equal(t, tx.Operations()[0].SourceAccount.Address(), muxedAccountAddress) +} diff --git a/exp/services/webauth/internal/serve/token.go b/exp/services/webauth/internal/serve/token.go index 56db5f269f..8b8a722526 100644 --- a/exp/services/webauth/internal/serve/token.go +++ b/exp/services/webauth/internal/serve/token.go @@ -2,6 +2,7 @@ package serve import ( "net/http" + "strconv" "strings" "time" @@ -11,6 +12,7 @@ import ( supportlog "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/httpjson" "github.com/stellar/go/txnbuild" + "github.com/stellar/go/xdr" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" ) @@ -52,9 +54,10 @@ func (h tokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { clientAccountID string signingAddress *keypair.FromAddress homeDomain string + memo *txnbuild.MemoID ) for _, s := range h.SigningAddresses { - tx, clientAccountID, homeDomain, err = txnbuild.ReadChallengeTx(req.Transaction, s.Address(), h.NetworkPassphrase, h.Domain, h.HomeDomains) + tx, clientAccountID, homeDomain, memo, err = txnbuild.ReadChallengeTx(req.Transaction, s.Address(), h.NetworkPassphrase, h.Domain, h.HomeDomains) if err == nil { signingAddress = s break @@ -80,10 +83,20 @@ func (h tokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { WithField("tx", hash). WithField("account", clientAccountID). WithField("serversigner", signingAddress.Address()). - WithField("homedomain", homeDomain) + WithField("homedomain", homeDomain). + WithField("memo", memo) l.Info("Start verifying challenge transaction.") + muxedAccount, err := xdr.AddressToMuxedAccount(clientAccountID) + if err != nil { + badRequest.Render(w) + return + } + if muxedAccount.Type == xdr.CryptoKeyTypeKeyTypeMuxedEd25519 { + clientAccountID = muxedAccount.ToAccountId().Address() + } + var clientAccountExists bool clientAccount, err := h.HorizonClient.AccountDetail(horizonclient.AccountRequest{AccountID: clientAccountID}) switch { @@ -140,10 +153,20 @@ func (h tokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + var sub string + if muxedAccount.Type == xdr.CryptoKeyTypeKeyTypeEd25519 { + sub = clientAccountID + if memo != nil { + sub += ":" + strconv.FormatUint(uint64(*memo), 10) + } + } else { + sub = muxedAccount.Address() + } + issuedAt := time.Unix(tx.Timebounds().MinTime, 0) claims := jwt.Claims{ Issuer: h.JWTIssuer, - Subject: clientAccountID, + Subject: sub, IssuedAt: jwt.NewNumericDate(issuedAt), Expiry: jwt.NewNumericDate(issuedAt.Add(h.JWTExpiresIn)), } diff --git a/exp/services/webauth/internal/serve/token_test.go b/exp/services/webauth/internal/serve/token_test.go index 7aa41d4b78..1f120ed7c1 100644 --- a/exp/services/webauth/internal/serve/token_test.go +++ b/exp/services/webauth/internal/serve/token_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "strings" "testing" "time" @@ -18,6 +19,7 @@ import ( "github.com/stellar/go/keypair" "github.com/stellar/go/network" "github.com/stellar/go/protocols/horizon" + "github.com/stellar/go/strkey" supportlog "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/problem" "github.com/stellar/go/txnbuild" @@ -46,6 +48,7 @@ func TestToken_formInputSuccess(t *testing.T) { homeDomain, network.TestNetworkPassphrase, time.Minute, + nil, ) require.NoError(t, err) @@ -146,6 +149,7 @@ func TestToken_formInputSuccess_jwtHeaderAndPayloadAreDeterministic(t *testing.T homeDomain, network.TestNetworkPassphrase, time.Minute, + nil, ) require.NoError(t, err) @@ -255,6 +259,7 @@ func TestToken_jsonInputSuccess(t *testing.T) { homeDomain, network.TestNetworkPassphrase, time.Minute, + nil, ) require.NoError(t, err) @@ -412,6 +417,7 @@ func TestToken_jsonInputValidRotatingServerSigners(t *testing.T) { homeDomain, network.TestNetworkPassphrase, time.Minute, + nil, ) require.NoError(t, err) @@ -497,6 +503,7 @@ func TestToken_jsonInputValidMultipleSigners(t *testing.T) { homeDomain, network.TestNetworkPassphrase, time.Minute, + nil, ) require.NoError(t, err) @@ -605,6 +612,7 @@ func TestToken_jsonInputNotEnoughWeight(t *testing.T) { homeDomain, network.TestNetworkPassphrase, time.Minute, + nil, ) require.NoError(t, err) @@ -691,6 +699,7 @@ func TestToken_jsonInputUnrecognizedSigner(t *testing.T) { homeDomain, network.TestNetworkPassphrase, time.Minute, + nil, ) require.NoError(t, err) @@ -777,6 +786,7 @@ func TestToken_jsonInputAccountNotExistSuccess(t *testing.T) { homeDomain, network.TestNetworkPassphrase, time.Minute, + nil, ) require.NoError(t, err) @@ -881,6 +891,7 @@ func TestToken_jsonInputAccountNotExistFail(t *testing.T) { homeDomain, network.TestNetworkPassphrase, time.Minute, + nil, ) require.NoError(t, err) @@ -963,6 +974,7 @@ func TestToken_jsonInputAccountNotExistNotAllowed(t *testing.T) { homeDomain, network.TestNetworkPassphrase, time.Minute, + nil, ) require.NoError(t, err) @@ -1047,6 +1059,7 @@ func TestToken_jsonInputUnrecognizedServerSigner(t *testing.T) { homeDomain, network.TestNetworkPassphrase, time.Minute, + nil, ) require.NoError(t, err) @@ -1248,6 +1261,7 @@ func TestToken_jsonInputInvalidWebAuthDomainFail(t *testing.T) { homeDomain, network.TestNetworkPassphrase, time.Minute, + nil, ) require.NoError(t, err) @@ -1309,3 +1323,217 @@ func TestToken_jsonInputInvalidWebAuthDomainFail(t *testing.T) { assert.JSONEq(t, `{"error":"The request was invalid in some way."}`, string(respBodyBytes)) } + +func TestToken_successWithIdMemo(t *testing.T) { + serverKey := keypair.MustRandom() + t.Logf("Server signing key: %s", serverKey.Address()) + + jwtPrivateKey, err := jwtkey.GenerateKey() + require.NoError(t, err) + jwk := jose.JSONWebKey{Key: jwtPrivateKey, Algorithm: string(jose.ES256)} + + account := keypair.MustRandom() + t.Logf("Client account: %s", account.Address()) + + domain := "webauth.example.com" + homeDomain := "example.com" + + memo := txnbuild.MemoID(1) + tx, err := txnbuild.BuildChallengeTx( + serverKey.Seed(), + account.Address(), + domain, + homeDomain, + network.TestNetworkPassphrase, + time.Minute, + &memo, + ) + require.NoError(t, err) + + chTx, err := tx.Base64() + require.NoError(t, err) + t.Logf("Tx: %s", chTx) + + tx, err = tx.Sign(network.TestNetworkPassphrase, account) + require.NoError(t, err) + txSigned, err := tx.Base64() + require.NoError(t, err) + t.Logf("Signed: %s", txSigned) + + horizonClient := &horizonclient.MockClient{} + horizonClient. + On("AccountDetail", horizonclient.AccountRequest{AccountID: account.Address()}). + Return( + horizon.Account{ + Thresholds: horizon.AccountThresholds{ + LowThreshold: 1, + MedThreshold: 10, + HighThreshold: 100, + }, + Signers: []horizon.Signer{ + { + Key: account.Address(), + Weight: 100, + }, + }}, + nil, + ) + + h := tokenHandler{ + Logger: supportlog.DefaultLogger, + HorizonClient: horizonClient, + NetworkPassphrase: network.TestNetworkPassphrase, + SigningAddresses: []*keypair.FromAddress{serverKey.FromAddress()}, + JWK: jwk, + JWTIssuer: "https://example.com", + JWTExpiresIn: time.Minute, + Domain: domain, + HomeDomains: []string{homeDomain}, + } + + body := struct { + Transaction string `json:"transaction"` + }{ + Transaction: txSigned, + } + bodyBytes, err := json.Marshal(body) + require.NoError(t, err) + r := httptest.NewRequest("POST", "/", bytes.NewReader(bodyBytes)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + resp := w.Result() + + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type")) + + res := struct { + Token string `json:"token"` + }{} + err = json.NewDecoder(resp.Body).Decode(&res) + require.NoError(t, err) + + t.Logf("JWT: %s", res.Token) + + token, err := jwt.Parse(res.Token, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return &jwtPrivateKey.PublicKey, nil + }) + require.NoError(t, err) + + claims := token.Claims.(jwt.MapClaims) + + require.Equal(t, account.Address()+":"+strconv.FormatUint(uint64(memo), 10), claims["sub"]) +} + +func TestToken_successWithMuxedAccount(t *testing.T) { + serverKey := keypair.MustRandom() + t.Logf("Server signing key: %s", serverKey.Address()) + + jwtPrivateKey, err := jwtkey.GenerateKey() + require.NoError(t, err) + jwk := jose.JSONWebKey{Key: jwtPrivateKey, Algorithm: string(jose.ES256)} + + account := keypair.MustRandom() + t.Logf("Stellar account: %s", account.Address()) + + muxedAccount := strkey.MuxedAccount{} + muxedAccount.SetAccountID(account.Address()) + muxedAccount.SetID(1) + muxedAccountAddress, err := muxedAccount.Address() + require.NoError(t, err) + t.Logf("Muxed account: %s", muxedAccountAddress) + + domain := "webauth.example.com" + homeDomain := "example.com" + + tx, err := txnbuild.BuildChallengeTx( + serverKey.Seed(), + muxedAccountAddress, + domain, + homeDomain, + network.TestNetworkPassphrase, + time.Minute, + nil, + ) + require.NoError(t, err) + + chTx, err := tx.Base64() + require.NoError(t, err) + t.Logf("Tx: %s", chTx) + + tx, err = tx.Sign(network.TestNetworkPassphrase, account) + require.NoError(t, err) + txSigned, err := tx.Base64() + require.NoError(t, err) + t.Logf("Signed: %s", txSigned) + + horizonClient := &horizonclient.MockClient{} + horizonClient. + On("AccountDetail", horizonclient.AccountRequest{AccountID: account.Address()}). + Return( + horizon.Account{ + Thresholds: horizon.AccountThresholds{ + LowThreshold: 1, + MedThreshold: 10, + HighThreshold: 100, + }, + Signers: []horizon.Signer{ + { + Key: account.Address(), + Weight: 100, + }, + }}, + nil, + ) + + h := tokenHandler{ + Logger: supportlog.DefaultLogger, + HorizonClient: horizonClient, + NetworkPassphrase: network.TestNetworkPassphrase, + SigningAddresses: []*keypair.FromAddress{serverKey.FromAddress()}, + JWK: jwk, + JWTIssuer: "https://example.com", + JWTExpiresIn: time.Minute, + Domain: domain, + HomeDomains: []string{homeDomain}, + } + + body := struct { + Transaction string `json:"transaction"` + }{ + Transaction: txSigned, + } + bodyBytes, err := json.Marshal(body) + require.NoError(t, err) + r := httptest.NewRequest("POST", "/", bytes.NewReader(bodyBytes)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + resp := w.Result() + + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type")) + + res := struct { + Token string `json:"token"` + }{} + err = json.NewDecoder(resp.Body).Decode(&res) + require.NoError(t, err) + + t.Logf("JWT: %s", res.Token) + + token, err := jwt.Parse(res.Token, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return &jwtPrivateKey.PublicKey, nil + }) + require.NoError(t, err) + + claims := token.Claims.(jwt.MapClaims) + + require.Equal(t, muxedAccountAddress, claims["sub"]) +} diff --git a/txnbuild/CHANGELOG.md b/txnbuild/CHANGELOG.md index d7ed9c8cd1..0e3f776a49 100644 --- a/txnbuild/CHANGELOG.md +++ b/txnbuild/CHANGELOG.md @@ -6,6 +6,16 @@ file. This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Breaking changes + +* Muxed accounts and ID memos can be used in the `BuildChallengeTx()` and `ReadChallengeTx()` SEP-10 utilitiy functions to identify users of shared Stellar accounts. ([#4746](https://github.com/stellar/go/pull/4746)) + * `BuildChallengeTx()`: + * Muxed account addresses can be passed as the `clientAccountID`. + * Adds an additional parameter of type `*txnbuild.MemoID`. Memos cannot be specified if the `clientAccoutID` id a muxed account address. + * `ReadChallengeTx()`: + * Muxed account addresses may be returned as the `clientAccountID`. + * Adds an additional return value of type `*txnbuild.MemoID`. + ## [10.0.0](https://github.com/stellar/go/releases/tag/horizonclient-v9.0.0) - 2022-04-18 diff --git a/txnbuild/example_test.go b/txnbuild/example_test.go index 3aab258ab9..26b5d95187 100644 --- a/txnbuild/example_test.go +++ b/txnbuild/example_test.go @@ -760,7 +760,7 @@ func ExampleBuildChallengeTx() { webAuthDomain := "webauthdomain.example.org" timebound := time.Duration(5 * time.Minute) - tx, err := BuildChallengeTx(serverSignerSeed, clientAccountID, webAuthDomain, anchorName, network.TestNetworkPassphrase, timebound) + tx, err := BuildChallengeTx(serverSignerSeed, clientAccountID, webAuthDomain, anchorName, network.TestNetworkPassphrase, timebound, nil) check(err) txeBase64, err := tx.Base64() diff --git a/txnbuild/transaction.go b/txnbuild/transaction.go index bd3e784f55..0b8bf7a4bc 100644 --- a/txnbuild/transaction.go +++ b/txnbuild/transaction.go @@ -998,8 +998,9 @@ func NewFeeBumpTransaction(params FeeBumpTransactionParams) (*FeeBumpTransaction // BuildChallengeTx is a factory method that creates a valid SEP 10 challenge, for use in web authentication. // "timebound" is the time duration the transaction should be valid for, and must be greater than 1s (300s is recommended). +// Muxed accounts or ID memos can be provided to identity a user of a shared Stellar account. // More details on SEP 10: https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md -func BuildChallengeTx(serverSignerSecret, clientAccountID, webAuthDomain, homeDomain, network string, timebound time.Duration) (*Transaction, error) { +func BuildChallengeTx(serverSignerSecret, clientAccountID, webAuthDomain, homeDomain, network string, timebound time.Duration, memo *MemoID) (*Transaction, error) { if timebound < time.Second { return nil, errors.New("provided timebound must be at least 1s (300s is recommended)") } @@ -1021,7 +1022,11 @@ func BuildChallengeTx(serverSignerSecret, clientAccountID, webAuthDomain, homeDo } if _, err = xdr.AddressToAccountId(clientAccountID); err != nil { - return nil, errors.Wrapf(err, "%s is not a valid account id", clientAccountID) + if _, err = xdr.AddressToMuxedAccount(clientAccountID); err != nil { + return nil, errors.Wrapf(err, "%s is not a valid account id or muxed account", clientAccountID) + } else if memo != nil { + return nil, errors.New("memos are not valid for challenge transactions with a muxed client account") + } } // represent server signing account as SimpleAccount @@ -1035,29 +1040,32 @@ func BuildChallengeTx(serverSignerSecret, clientAccountID, webAuthDomain, homeDo // Create a SEP 10 compatible response. See // https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md#response - tx, err := NewTransaction( - TransactionParams{ - SourceAccount: &sa, - IncrementSequenceNum: false, - Operations: []Operation{ - &ManageData{ - SourceAccount: clientAccountID, - Name: homeDomain + " auth", - Value: []byte(randomNonceToString), - }, - &ManageData{ - SourceAccount: serverKP.Address(), - Name: "web_auth_domain", - Value: []byte(webAuthDomain), - }, + txParams := TransactionParams{ + SourceAccount: &sa, + IncrementSequenceNum: false, + Operations: []Operation{ + &ManageData{ + SourceAccount: clientAccountID, + Name: homeDomain + " auth", + Value: []byte(randomNonceToString), }, - BaseFee: MinBaseFee, - Memo: nil, - Preconditions: Preconditions{ - TimeBounds: NewTimebounds(currentTime.Unix(), maxTime.Unix()), + &ManageData{ + SourceAccount: serverKP.Address(), + Name: "web_auth_domain", + Value: []byte(webAuthDomain), }, }, - ) + BaseFee: MinBaseFee, + Preconditions: Preconditions{ + TimeBounds: NewTimebounds(currentTime.Unix(), maxTime.Unix()), + }, + } + // Do not replace this if-then-assign block by assigning `memo` within the `TransactionParams` + // struct above. Doing so will cause errors as described here: https://go.dev/doc/faq#nil_error + if memo != nil { + txParams.Memo = memo + } + tx, err := NewTransaction(txParams) if err != nil { return nil, err } @@ -1105,57 +1113,61 @@ func generateRandomNonce(n int) ([]byte, error) { // one of the following functions to completely verify the transaction: // - VerifyChallengeTxThreshold // - VerifyChallengeTxSigners -func ReadChallengeTx(challengeTx, serverAccountID, network, webAuthDomain string, homeDomains []string) (tx *Transaction, clientAccountID string, matchedHomeDomain string, err error) { +// +// The returned clientAccountID may be a Stellar account (G...) or Muxed account (M...) address. If +// the address is muxed, or if the memo returned is non-nil, the challenge transaction +// is being used to authenticate a user of a shared Stellar account. +func ReadChallengeTx(challengeTx, serverAccountID, network, webAuthDomain string, homeDomains []string) (tx *Transaction, clientAccountID string, matchedHomeDomain string, memo *MemoID, err error) { parsed, err := TransactionFromXDR(challengeTx) if err != nil { - return tx, clientAccountID, matchedHomeDomain, errors.Wrap(err, "could not parse challenge") + return tx, clientAccountID, matchedHomeDomain, memo, errors.Wrap(err, "could not parse challenge") } var isSimple bool tx, isSimple = parsed.Transaction() if !isSimple { - return tx, clientAccountID, matchedHomeDomain, errors.New("challenge cannot be a fee bump transaction") + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("challenge cannot be a fee bump transaction") } // Enforce no muxed accounts (at least until we understand their impact) if tx.envelope.SourceAccount().Type == xdr.CryptoKeyTypeKeyTypeMuxedEd25519 { err = errors.New("invalid source account: only valid Ed25519 accounts are allowed in challenge transactions") - return tx, clientAccountID, matchedHomeDomain, err + return tx, clientAccountID, matchedHomeDomain, memo, err } // verify transaction source if tx.SourceAccount().AccountID != serverAccountID { - return tx, clientAccountID, matchedHomeDomain, errors.New("transaction source account is not equal to server's account") + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("transaction source account is not equal to server's account") } // verify sequence number if tx.SourceAccount().Sequence != 0 { - return tx, clientAccountID, matchedHomeDomain, errors.New("transaction sequence number must be 0") + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("transaction sequence number must be 0") } // verify timebounds if tx.Timebounds().MaxTime == TimeoutInfinite { - return tx, clientAccountID, matchedHomeDomain, errors.New("transaction requires non-infinite timebounds") + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("transaction requires non-infinite timebounds") } // Apply a grace period to the challenge MinTime to account for clock drift between the server and client var gracePeriod int64 = 5 * 60 // seconds currentTime := time.Now().UTC().Unix() if currentTime+gracePeriod < tx.Timebounds().MinTime || currentTime > tx.Timebounds().MaxTime { - return tx, clientAccountID, matchedHomeDomain, errors.Errorf("transaction is not within range of the specified timebounds (currentTime=%d, MinTime=%d, MaxTime=%d)", + return tx, clientAccountID, matchedHomeDomain, memo, errors.Errorf("transaction is not within range of the specified timebounds (currentTime=%d, MinTime=%d, MaxTime=%d)", currentTime, tx.Timebounds().MinTime, tx.Timebounds().MaxTime) } // verify operation operations := tx.Operations() if len(operations) < 1 { - return tx, clientAccountID, matchedHomeDomain, errors.New("transaction requires at least one manage_data operation") + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("transaction requires at least one manage_data operation") } op, ok := operations[0].(*ManageData) if !ok { - return tx, clientAccountID, matchedHomeDomain, errors.New("operation type should be manage_data") + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("operation type should be manage_data") } if op.SourceAccount == "" { - return tx, clientAccountID, matchedHomeDomain, errors.New("operation should have a source account") + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("operation should have a source account") } for _, homeDomain := range homeDomains { if op.Name == homeDomain+" auth" { @@ -1164,60 +1176,72 @@ func ReadChallengeTx(challengeTx, serverAccountID, network, webAuthDomain string } } if matchedHomeDomain == "" { - return tx, clientAccountID, matchedHomeDomain, errors.Errorf("operation key does not match any homeDomains passed (key=%q, homeDomains=%v)", op.Name, homeDomains) + return tx, clientAccountID, matchedHomeDomain, memo, errors.Errorf("operation key does not match any homeDomains passed (key=%q, homeDomains=%v)", op.Name, homeDomains) } clientAccountID = op.SourceAccount - rawOperations := tx.envelope.Operations() - if len(rawOperations) > 0 && rawOperations[0].SourceAccount.Type == xdr.CryptoKeyTypeKeyTypeMuxedEd25519 { - err = errors.New("invalid operation source account: only valid Ed25519 accounts are allowed in challenge transactions") - return tx, clientAccountID, matchedHomeDomain, err + firstOpSourceAccountType := tx.envelope.Operations()[0].SourceAccount.Type + if firstOpSourceAccountType != xdr.CryptoKeyTypeKeyTypeMuxedEd25519 && firstOpSourceAccountType != xdr.CryptoKeyTypeKeyTypeEd25519 { + err = errors.New("invalid source account for first operation: only valid Ed25519 or muxed accounts are valid") + return tx, clientAccountID, matchedHomeDomain, memo, err + } + if tx.Memo() != nil { + if firstOpSourceAccountType == xdr.CryptoKeyTypeKeyTypeMuxedEd25519 { + err = errors.New("memos are not valid for challenge transactions with a muxed client account") + return tx, clientAccountID, matchedHomeDomain, memo, err + } + var txMemo MemoID + if txMemo, ok = (tx.Memo()).(MemoID); !ok { + err = errors.New("invalid memo, only ID memos are permitted") + return tx, clientAccountID, matchedHomeDomain, memo, err + } + memo = &txMemo } // verify manage data value nonceB64 := string(op.Value) if len(nonceB64) != 64 { - return tx, clientAccountID, matchedHomeDomain, errors.New("random nonce encoded as base64 should be 64 bytes long") + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("random nonce encoded as base64 should be 64 bytes long") } nonceBytes, err := base64.StdEncoding.DecodeString(nonceB64) if err != nil { - return tx, clientAccountID, matchedHomeDomain, errors.Wrap(err, "failed to decode random nonce provided in manage_data operation") + return tx, clientAccountID, matchedHomeDomain, memo, errors.Wrap(err, "failed to decode random nonce provided in manage_data operation") } if len(nonceBytes) != 48 { - return tx, clientAccountID, matchedHomeDomain, errors.New("random nonce before encoding as base64 should be 48 bytes long") + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("random nonce before encoding as base64 should be 48 bytes long") } // verify subsequent operations are manage data ops and known, or unknown with source account set to server account for _, op := range operations[1:] { op, ok := op.(*ManageData) if !ok { - return tx, clientAccountID, matchedHomeDomain, errors.New("operation type should be manage_data") + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("operation type should be manage_data") } if op.SourceAccount == "" { - return tx, clientAccountID, matchedHomeDomain, errors.New("operation should have a source account") + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("operation should have a source account") } switch op.Name { case "web_auth_domain": if op.SourceAccount != serverAccountID { - return tx, clientAccountID, matchedHomeDomain, errors.New("web auth domain operation must have server source account") + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("web auth domain operation must have server source account") } if !bytes.Equal(op.Value, []byte(webAuthDomain)) { - return tx, clientAccountID, matchedHomeDomain, errors.Errorf("web auth domain operation value is %q but expect %q", string(op.Value), webAuthDomain) + return tx, clientAccountID, matchedHomeDomain, memo, errors.Errorf("web auth domain operation value is %q but expect %q", string(op.Value), webAuthDomain) } default: // verify unknown subsequent operations are manage data ops with source account set to server account if op.SourceAccount != serverAccountID { - return tx, clientAccountID, matchedHomeDomain, errors.New("subsequent operations are unrecognized") + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("subsequent operations are unrecognized") } } } err = verifyTxSignature(tx, network, serverAccountID) if err != nil { - return tx, clientAccountID, matchedHomeDomain, err + return tx, clientAccountID, matchedHomeDomain, memo, err } - return tx, clientAccountID, matchedHomeDomain, nil + return tx, clientAccountID, matchedHomeDomain, memo, nil } // VerifyChallengeTxThreshold verifies that for a SEP 10 challenge transaction @@ -1289,7 +1313,7 @@ func VerifyChallengeTxThreshold(challengeTx, serverAccountID, network, webAuthDo // server account or one of the signers provided in the arguments. func VerifyChallengeTxSigners(challengeTx, serverAccountID, network, webAuthDomain string, homeDomains []string, signers ...string) ([]string, error) { // Read the transaction which validates its structure. - tx, _, _, err := ReadChallengeTx(challengeTx, serverAccountID, network, webAuthDomain, homeDomains) + tx, _, _, _, err := ReadChallengeTx(challengeTx, serverAccountID, network, webAuthDomain, homeDomains) if err != nil { return nil, err } diff --git a/txnbuild/transaction_challenge_example_test.go b/txnbuild/transaction_challenge_example_test.go index a183aa44f4..a0db63064a 100644 --- a/txnbuild/transaction_challenge_example_test.go +++ b/txnbuild/transaction_challenge_example_test.go @@ -37,7 +37,7 @@ func ExampleVerifyChallengeTxThreshold() { // Server builds challenge transaction var challengeTx string { - tx, err := txnbuild.BuildChallengeTx(serverAccount.Seed(), clientAccount.Address(), "webauthdomain.stellar.org", "test", network.TestNetworkPassphrase, time.Minute) + tx, err := txnbuild.BuildChallengeTx(serverAccount.Seed(), clientAccount.Address(), "webauthdomain.stellar.org", "test", network.TestNetworkPassphrase, time.Minute, nil) if err != nil { fmt.Println("Error:", err) return @@ -52,7 +52,7 @@ func ExampleVerifyChallengeTxThreshold() { // Client reads and signs challenge transaction var signedChallengeTx string { - tx, txClientAccountID, _, err := txnbuild.ReadChallengeTx(challengeTx, serverAccount.Address(), network.TestNetworkPassphrase, "webauthdomain.stellar.org", []string{"test"}) + tx, txClientAccountID, _, _, err := txnbuild.ReadChallengeTx(challengeTx, serverAccount.Address(), network.TestNetworkPassphrase, "webauthdomain.stellar.org", []string{"test"}) if err != nil { fmt.Println("Error:", err) return @@ -75,10 +75,13 @@ func ExampleVerifyChallengeTxThreshold() { // Server verifies signed challenge transaction { - _, txClientAccountID, _, err := txnbuild.ReadChallengeTx(challengeTx, serverAccount.Address(), network.TestNetworkPassphrase, "webauthdomain.stellar.org", []string{"test"}) + _, txClientAccountID, _, memo, err := txnbuild.ReadChallengeTx(challengeTx, serverAccount.Address(), network.TestNetworkPassphrase, "webauthdomain.stellar.org", []string{"test"}) if err != nil { fmt.Println("Error:", err) return + } else if memo != nil { + fmt.Println("Expected memo to be nil, got: ", memo) + return } // Server gets account diff --git a/txnbuild/transaction_test.go b/txnbuild/transaction_test.go index b813dd8b95..f8fce12e9c 100644 --- a/txnbuild/transaction_test.go +++ b/txnbuild/transaction_test.go @@ -1073,7 +1073,7 @@ func TestBuildChallengeTx(t *testing.T) { { // 1 minute timebound - tx, err := BuildChallengeTx(kp0.Seed(), kp0.Address(), "testwebauth.stellar.org", "testanchor.stellar.org", network.TestNetworkPassphrase, time.Minute) + tx, err := BuildChallengeTx(kp0.Seed(), kp0.Address(), "testwebauth.stellar.org", "testanchor.stellar.org", network.TestNetworkPassphrase, time.Minute, nil) assert.NoError(t, err) txeBase64, err := tx.Base64() assert.NoError(t, err) @@ -1097,7 +1097,7 @@ func TestBuildChallengeTx(t *testing.T) { { // 5 minutes timebound - tx, err := BuildChallengeTx(kp0.Seed(), kp0.Address(), "testwebauth.stellar.org", "testanchor.stellar.org", network.TestNetworkPassphrase, time.Duration(5*time.Minute)) + tx, err := BuildChallengeTx(kp0.Seed(), kp0.Address(), "testwebauth.stellar.org", "testanchor.stellar.org", network.TestNetworkPassphrase, time.Duration(5*time.Minute), nil) assert.NoError(t, err) txeBase64, err := tx.Base64() assert.NoError(t, err) @@ -1120,8 +1120,37 @@ func TestBuildChallengeTx(t *testing.T) { assert.Equal(t, "testwebauth.stellar.org", string(*webAuthOp.Body.ManageDataOp.DataValue), "DataValue should be 'testwebauth.stellar.org'") } + // transaction with invalid clientAccountID + { + _, err := BuildChallengeTx(kp0.Seed(), "test", "testwebauth.stellar.org", "testanchor.stellar.org", network.TestNetworkPassphrase, time.Minute, nil) + require.EqualError(t, err, "test is not a valid account id or muxed account: invalid address length") + } + + // transaction with memo + { + var memo MemoID = MemoID(1) + tx, err := BuildChallengeTx(kp0.Seed(), kp0.Address(), "testwebauth.stellar.org", "testanchor.stellar.org", network.TestNetworkPassphrase, time.Minute, &memo) + assert.NoError(t, err) + assert.Equal(t, tx.Memo(), &memo) + } + + // transaction with muxed account + { + muxedAccount := "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLK" + tx, err := BuildChallengeTx(kp0.Seed(), muxedAccount, "testwebauth.stellar.org", "testanchor.stellar.org", network.TestNetworkPassphrase, time.Minute, nil) + assert.NoError(t, err) + assert.Equal(t, tx.operations[0].GetSourceAccount(), muxedAccount) + } + + // transaction with memo and muxed account + { + var memo MemoID = MemoID(1) + _, err := BuildChallengeTx(kp0.Seed(), "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLK", "testwebauth.stellar.org", "testanchor.stellar.org", network.TestNetworkPassphrase, time.Minute, &memo) + assert.EqualError(t, err, "memos are not valid for challenge transactions with a muxed client account") + } + //transaction with infinite timebound - _, err := BuildChallengeTx(kp0.Seed(), kp0.Address(), "webauthdomain", "sdf", network.TestNetworkPassphrase, 0) + _, err := BuildChallengeTx(kp0.Seed(), kp0.Address(), "webauthdomain", "sdf", network.TestNetworkPassphrase, 0, nil) if assert.Error(t, err) { assert.Contains(t, err.Error(), "provided timebound must be at least 1s (300s is recommended)") } @@ -1878,7 +1907,7 @@ func TestReadChallengeTx_validSignedByServerAndClient(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, clientKP.Address(), readClientAccountID) assert.NoError(t, err) @@ -1913,7 +1942,7 @@ func TestReadChallengeTx_validSignedByServer(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, clientKP.Address(), readClientAccountID) assert.NoError(t, err) @@ -1946,7 +1975,7 @@ func TestReadChallengeTx_invalidNotSignedByServer(t *testing.T) { tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, clientKP.Address(), readClientAccountID) assert.EqualError(t, err, "transaction not signed by "+serverKP.Address()) @@ -1982,7 +2011,7 @@ func TestReadChallengeTx_invalidCorrupted(t *testing.T) { tx64, err := tx.Base64() require.NoError(t, err) tx64 = strings.ReplaceAll(tx64, "A", "B") - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Nil(t, readTx) assert.Equal(t, "", readClientAccountID) assert.EqualError( @@ -2022,7 +2051,7 @@ func TestReadChallengeTx_invalidServerAccountIDMismatch(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, "", readClientAccountID) assert.EqualError(t, err, "transaction source account is not equal to server's account") @@ -2057,7 +2086,7 @@ func TestReadChallengeTx_invalidSeqNoNotZero(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, "", readClientAccountID) assert.EqualError(t, err, "transaction sequence number must be 0") @@ -2092,7 +2121,7 @@ func TestReadChallengeTx_invalidTimeboundsInfinite(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, "", readClientAccountID) assert.EqualError(t, err, "transaction requires non-infinite timebounds") @@ -2127,7 +2156,7 @@ func TestReadChallengeTx_invalidTimeboundsOutsideRange(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, "", readClientAccountID) assert.Error(t, err) @@ -2164,7 +2193,7 @@ func TestReadChallengeTx_validTimeboundsWithGracePeriod(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, clientKP.Address(), readClientAccountID) assert.NoError(t, err) @@ -2200,7 +2229,7 @@ func TestReadChallengeTx_invalidTimeboundsWithGracePeriod(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, "", readClientAccountID) assert.Error(t, err) @@ -2230,7 +2259,7 @@ func TestReadChallengeTx_invalidOperationWrongType(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, "", readClientAccountID) assert.EqualError(t, err, "operation type should be manage_data") @@ -2258,7 +2287,7 @@ func TestReadChallengeTx_invalidOperationNoSourceAccount(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + _, _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.EqualError(t, err, "operation should have a source account") } @@ -2291,7 +2320,7 @@ func TestReadChallengeTx_invalidDataValueWrongEncodedLength(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, clientKP.Address(), readClientAccountID) assert.EqualError(t, err, "random nonce encoded as base64 should be 64 bytes long") @@ -2326,7 +2355,7 @@ func TestReadChallengeTx_invalidDataValueCorruptBase64(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, clientKP.Address(), readClientAccountID) assert.EqualError(t, err, "failed to decode random nonce provided in manage_data operation: illegal base64 data at input byte 37") @@ -2362,7 +2391,7 @@ func TestReadChallengeTx_invalidDataValueWrongByteLength(t *testing.T) { tx64, err := tx.Base64() assert.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, clientKP.Address(), readClientAccountID) assert.EqualError(t, err, "random nonce before encoding as base64 should be 48 bytes long") @@ -2377,6 +2406,7 @@ func TestReadChallengeTx_acceptsV0AndV1Transactions(t *testing.T) { "testanchor.stellar.org", network.TestNetworkPassphrase, time.Hour, + nil, ) assert.NoError(t, err) @@ -2391,7 +2421,7 @@ func TestReadChallengeTx_acceptsV0AndV1Transactions(t *testing.T) { assert.NoError(t, err) for _, challenge := range []string{v1Challenge, v0Challenge} { - parsedTx, clientAccountID, _, err := ReadChallengeTx( + parsedTx, clientAccountID, _, _, err := ReadChallengeTx( challenge, kp0.Address(), network.TestNetworkPassphrase, @@ -2417,6 +2447,7 @@ func TestReadChallengeTx_forbidsFeeBumpTransactions(t *testing.T) { "testanchor.stellar.org", network.TestNetworkPassphrase, time.Hour, + nil, ) assert.NoError(t, err) @@ -2435,7 +2466,7 @@ func TestReadChallengeTx_forbidsFeeBumpTransactions(t *testing.T) { challenge, err := feeBumpTx.Base64() assert.NoError(t, err) - _, _, _, err = ReadChallengeTx( + _, _, _, _, err = ReadChallengeTx( challenge, kp0.Address(), network.TestNetworkPassphrase, @@ -2445,19 +2476,48 @@ func TestReadChallengeTx_forbidsFeeBumpTransactions(t *testing.T) { assert.EqualError(t, err, "challenge cannot be a fee bump transaction") } -func TestReadChallengeTx_forbidsMuxedAccounts(t *testing.T) { +func TestReadChallengeTx_allowsMuxedAccountsForClientAccountId(t *testing.T) { kp0 := newKeypair0() + kp1 := newKeypair1() + aid := xdr.MustAddress(kp1.Address()) + muxedAccount := xdr.MuxedAccount{ + Type: xdr.CryptoKeyTypeKeyTypeMuxedEd25519, + Med25519: &xdr.MuxedAccountMed25519{ + Id: 0xcafebabe, + Ed25519: *aid.Ed25519, + }, + } tx, err := BuildChallengeTx( kp0.Seed(), - kp0.Address(), + muxedAccount.Address(), "testwebauth.stellar.org", "testanchor.stellar.org", network.TestNetworkPassphrase, time.Hour, + nil, ) + assert.NoError(t, err) - env := tx.ToXDR() + challenge, err := marshallBase64(tx.ToXDR(), tx.Signatures()) assert.NoError(t, err) + + tx, _, _, _, err = ReadChallengeTx( + challenge, + kp0.Address(), + network.TestNetworkPassphrase, + "testwebauth.stellar.org", + []string{"testanchor.stellar.org"}, + ) + assert.NoError(t, err) + assert.Equal(t, tx.envelope.Operations()[0].SourceAccount, &muxedAccount) +} + +func TestReadChallengeTransaction_forbidsMuxedTxSourceAccount(t *testing.T) { + kp0 := newKeypair0() + kp1 := newKeypair1() + homeDomain := "testanchor.stellar.org" + webAuthDomain := "testwebauth.stellar.org" + aid := xdr.MustAddress(kp0.Address()) muxedAccount := xdr.MuxedAccount{ Type: xdr.CryptoKeyTypeKeyTypeMuxedEd25519, @@ -2466,20 +2526,174 @@ func TestReadChallengeTx_forbidsMuxedAccounts(t *testing.T) { Ed25519: *aid.Ed25519, }, } - *env.V1.Tx.Operations[0].SourceAccount = muxedAccount + serverAccount := SimpleAccount{ + AccountID: muxedAccount.Address(), + Sequence: 0, + } + randomNonce, _ := generateRandomNonce(48) + randomNonceToString := base64.StdEncoding.EncodeToString(randomNonce) + currentTime := time.Now().UTC() + maxTime := currentTime.Add(300) - challenge, err := marshallBase64(env, env.Signatures()) + tx, err := NewTransaction( + TransactionParams{ + SourceAccount: &serverAccount, + IncrementSequenceNum: false, + Operations: []Operation{ + &ManageData{ + SourceAccount: kp1.Address(), + Name: homeDomain + " auth", + Value: []byte(randomNonceToString), + }, + &ManageData{ + SourceAccount: serverAccount.GetAccountID(), + Name: "web_auth_domain", + Value: []byte(webAuthDomain), + }, + }, + BaseFee: MinBaseFee, + Memo: MemoID(1), + Preconditions: Preconditions{ + TimeBounds: NewTimebounds(currentTime.Unix(), maxTime.Unix()), + }, + }, + ) assert.NoError(t, err) - _, _, _, err = ReadChallengeTx( + tx, err = tx.Sign(network.TestNetworkPassphrase, kp0) + assert.NoError(t, err) + + challenge, err := marshallBase64(tx.ToXDR(), tx.Signatures()) + assert.NoError(t, err) + + _, _, _, _, err = ReadChallengeTx( challenge, kp0.Address(), network.TestNetworkPassphrase, - "testwebauth.stellar.org", - []string{"testanchor.stellar.org"}, + webAuthDomain, + []string{homeDomain}, + ) + assert.EqualError(t, err, "invalid source account: only valid Ed25519 accounts are allowed in challenge transactions") +} + +func TestReadChallengeTransaction_forbidsMemoWithMuxedClientAccount(t *testing.T) { + kp0 := newKeypair0() + kp1 := newKeypair1() + homeDomain := "testanchor.stellar.org" + webAuthDomain := "testwebauth.stellar.org" + + serverAccount := SimpleAccount{ + AccountID: kp0.Address(), + Sequence: 0, + } + aid := xdr.MustAddress(kp1.Address()) + muxedAccount := xdr.MuxedAccount{ + Type: xdr.CryptoKeyTypeKeyTypeMuxedEd25519, + Med25519: &xdr.MuxedAccountMed25519{ + Id: 0xcafebabe, + Ed25519: *aid.Ed25519, + }, + } + randomNonce, _ := generateRandomNonce(48) + randomNonceToString := base64.StdEncoding.EncodeToString(randomNonce) + currentTime := time.Now().UTC() + maxTime := currentTime.Add(300) + + tx, err := NewTransaction( + TransactionParams{ + SourceAccount: &serverAccount, + IncrementSequenceNum: false, + Operations: []Operation{ + &ManageData{ + SourceAccount: muxedAccount.Address(), + Name: homeDomain + " auth", + Value: []byte(randomNonceToString), + }, + &ManageData{ + SourceAccount: serverAccount.GetAccountID(), + Name: "web_auth_domain", + Value: []byte(webAuthDomain), + }, + }, + BaseFee: MinBaseFee, + Memo: MemoID(1), + Preconditions: Preconditions{ + TimeBounds: NewTimebounds(currentTime.Unix(), maxTime.Unix()), + }, + }, + ) + assert.NoError(t, err) + + tx, err = tx.Sign(network.TestNetworkPassphrase, kp0) + assert.NoError(t, err) + + challenge, err := marshallBase64(tx.ToXDR(), tx.Signatures()) + assert.NoError(t, err) + + _, _, _, _, err = ReadChallengeTx( + challenge, + kp0.Address(), + network.TestNetworkPassphrase, + webAuthDomain, + []string{homeDomain}, + ) + assert.EqualError(t, err, "memos are not valid for challenge transactions with a muxed client account") +} + +func TestReadChallengeTransaction_forbidsNonIdMemo(t *testing.T) { + kp0 := newKeypair0() + kp1 := newKeypair1() + homeDomain := "testanchor.stellar.org" + webAuthDomain := "testwebauth.stellar.org" + + serverAccount := SimpleAccount{ + AccountID: kp0.Address(), + Sequence: 0, + } + randomNonce, _ := generateRandomNonce(48) + randomNonceToString := base64.StdEncoding.EncodeToString(randomNonce) + currentTime := time.Now().UTC() + maxTime := currentTime.Add(300) + + tx, err := NewTransaction( + TransactionParams{ + SourceAccount: &serverAccount, + IncrementSequenceNum: false, + Operations: []Operation{ + &ManageData{ + SourceAccount: kp1.Address(), + Name: homeDomain + " auth", + Value: []byte(randomNonceToString), + }, + &ManageData{ + SourceAccount: serverAccount.GetAccountID(), + Name: "web_auth_domain", + Value: []byte(webAuthDomain), + }, + }, + BaseFee: MinBaseFee, + Memo: MemoText("test"), + Preconditions: Preconditions{ + TimeBounds: NewTimebounds(currentTime.Unix(), maxTime.Unix()), + }, + }, + ) + assert.NoError(t, err) + + tx, err = tx.Sign(network.TestNetworkPassphrase, kp0) + assert.NoError(t, err) + + challenge, err := marshallBase64(tx.ToXDR(), tx.Signatures()) + assert.NoError(t, err) + + _, _, _, _, err = ReadChallengeTx( + challenge, + kp0.Address(), + network.TestNetworkPassphrase, + webAuthDomain, + []string{homeDomain}, ) - errorMessage := "only valid Ed25519 accounts are allowed in challenge transactions" - assert.Contains(t, err.Error(), errorMessage) + assert.EqualError(t, err, "invalid memo, only ID memos are permitted") } func TestReadChallengeTx_doesVerifyHomeDomainFailure(t *testing.T) { @@ -2511,7 +2725,7 @@ func TestReadChallengeTx_doesVerifyHomeDomainFailure(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"willfail"}) + _, _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"willfail"}) assert.EqualError(t, err, "operation key does not match any homeDomains passed (key=\"testanchor.stellar.org auth\", homeDomains=[willfail])") } @@ -2544,7 +2758,7 @@ func TestReadChallengeTx_doesVerifyHomeDomainSuccess(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + _, _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, nil, err) } @@ -2582,7 +2796,7 @@ func TestReadChallengeTx_allowsAdditionalManageDataOpsWithSourceAccountSetToServ assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, clientKP.Address(), readClientAccountID) assert.NoError(t, err) @@ -2622,7 +2836,7 @@ func TestReadChallengeTx_disallowsAdditionalManageDataOpsWithoutSourceAccountSet assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + _, _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.EqualError(t, err, "subsequent operations are unrecognized") } @@ -2659,7 +2873,7 @@ func TestReadChallengeTx_disallowsAdditionalOpsOfOtherTypes(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - readTx, readClientAccountID, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + readTx, readClientAccountID, _, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.Equal(t, tx, readTx) assert.Equal(t, clientKP.Address(), readClientAccountID) assert.EqualError(t, err, "operation type should be manage_data") @@ -2693,7 +2907,7 @@ func TestReadChallengeTx_matchesHomeDomain(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - _, _, matchedHomeDomain, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + _, _, matchedHomeDomain, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) require.NoError(t, err) assert.Equal(t, matchedHomeDomain, "testanchor.stellar.org") } @@ -2726,7 +2940,7 @@ func TestReadChallengeTx_doesNotMatchHomeDomain(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - _, _, matchedHomeDomain, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"not", "going", "to", "match"}) + _, _, matchedHomeDomain, _, err := ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"not", "going", "to", "match"}) assert.Equal(t, matchedHomeDomain, "") assert.EqualError(t, err, "operation key does not match any homeDomains passed (key=\"testanchor.stellar.org auth\", homeDomains=[not going to match])") } @@ -2754,7 +2968,7 @@ func TestReadChallengeTx_validWhenWebAuthDomainMissing(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + _, _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.NoError(t, err) } @@ -2786,7 +3000,7 @@ func TestReadChallengeTx_invalidWebAuthDomainSourceAccount(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + _, _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.EqualError(t, err, `web auth domain operation must have server source account`) } @@ -2818,7 +3032,7 @@ func TestReadChallengeTx_invalidWebAuthDomain(t *testing.T) { assert.NoError(t, err) tx64, err := tx.Base64() require.NoError(t, err) - _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) + _, _, _, _, err = ReadChallengeTx(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}) assert.EqualError(t, err, `web auth domain operation value is "testwebauth.example.org" but expect "testwebauth.stellar.org"`) }