diff --git a/txnbuild/transaction.go b/txnbuild/transaction.go index b32d73c7e5..ac8654b1e8 100644 --- a/txnbuild/transaction.go +++ b/txnbuild/transaction.go @@ -1040,6 +1040,10 @@ func NewFeeBumpTransaction(params FeeBumpTransactionParams) (*FeeBumpTransaction // 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, memo *MemoID) (*Transaction, error) { + return BuildChallengeTxWithClientDomain(serverSignerSecret, clientAccountID, webAuthDomain, homeDomain, network, timebound, memo, nil, nil) +} + +func BuildChallengeTxWithClientDomain(serverSignerSecret, clientAccountID, webAuthDomain, homeDomain, network string, timebound time.Duration, memo *MemoID, clientDomain, clientDomainAccountID *string) (*Transaction, error) { if timebound < time.Second { return nil, errors.New("provided timebound must be at least 1s (300s is recommended)") } @@ -1104,6 +1108,20 @@ func BuildChallengeTx(serverSignerSecret, clientAccountID, webAuthDomain, homeDo if memo != nil { txParams.Memo = memo } + + if clientDomain != nil && clientDomainAccountID != nil { + clientAccountId, addressToAccountIdErr := xdr.AddressToAccountId(*clientDomainAccountID) + if addressToAccountIdErr != nil { + return nil, errors.Wrapf(addressToAccountIdErr, "%s is not a valid account id or muxed account", *clientDomainAccountID) + } + + txParams.Operations = append(txParams.Operations, &ManageData{ + SourceAccount: clientAccountId.Address(), + Name: "client_domain", + Value: []byte(*clientDomain), + }) + } + tx, err := NewTransaction(txParams) if err != nil { return nil, err @@ -1157,6 +1175,10 @@ func generateRandomNonce(n int) ([]byte, error) { // 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) { + return ReadChallengeTxWithClientDomain(challengeTx, serverAccountID, network, webAuthDomain, homeDomains, nil, nil) +} + +func ReadChallengeTxWithClientDomain(challengeTx, serverAccountID, network, webAuthDomain string, homeDomains []string, clientDomain, clientDomainAccountID *string) (tx *Transaction, clientAccountID string, matchedHomeDomain string, memo *MemoID, err error) { parsed, err := TransactionFromXDR(challengeTx) if err != nil { return tx, clientAccountID, matchedHomeDomain, memo, errors.Wrap(err, "could not parse challenge") @@ -1267,6 +1289,20 @@ func ReadChallengeTx(challengeTx, serverAccountID, network, webAuthDomain string if !bytes.Equal(op.Value, []byte(webAuthDomain)) { return tx, clientAccountID, matchedHomeDomain, memo, errors.Errorf("web auth domain operation value is %q but expect %q", string(op.Value), webAuthDomain) } + case "client_domain": + if op.SourceAccount == serverAccountID { + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("client domain operation must not be server source account") + } + if clientDomain != nil && !bytes.Equal(op.Value, []byte(*clientDomain)) { + return tx, clientAccountID, matchedHomeDomain, memo, errors.Errorf("client domain operation value is %q but expect %q", string(op.Value), *clientDomain) + } + if clientDomainAccountID == nil { + return tx, clientAccountID, matchedHomeDomain, memo, errors.Errorf("client domain account id is required") + } + err = verifyTxSignature(tx, network, *clientDomainAccountID) + if err != nil { + return tx, clientAccountID, matchedHomeDomain, memo, errors.New("client domain operation is present but txn not signed by client") + } default: // verify unknown subsequent operations are manage data ops with source account set to server account if op.SourceAccount != serverAccountID { diff --git a/txnbuild/transaction_test.go b/txnbuild/transaction_test.go index 800b0346fb..8f5f7b08c5 100644 --- a/txnbuild/transaction_test.go +++ b/txnbuild/transaction_test.go @@ -1157,6 +1157,39 @@ func TestBuildChallengeTx(t *testing.T) { } } +func TestBuildChallengeTxWithClientDomain(t *testing.T) { + kp0 := newKeypair0() + kp1 := newKeypair1().Address() + clientDomain := "clientdomain.stellar.org" + { + // 1 minute timebound + tx, err := BuildChallengeTxWithClientDomain(kp0.Seed(), kp0.Address(), "testwebauth.stellar.org", "testanchor.stellar.org", network.TestNetworkPassphrase, time.Minute, nil, &clientDomain, &kp1) + assert.NoError(t, err) + txeBase64, err := tx.Base64() + assert.NoError(t, err) + var txXDR xdr.TransactionEnvelope + err = xdr.SafeUnmarshalBase64(txeBase64, &txXDR) + assert.NoError(t, err) + assert.Equal(t, int64(0), txXDR.SeqNum(), "sequence number should be 0") + assert.Equal(t, uint32(300), txXDR.Fee(), "Fee should be 300") + assert.Equal(t, 3, len(txXDR.Operations()), "number operations should be 3") + timeDiff := txXDR.TimeBounds().MaxTime - txXDR.TimeBounds().MinTime + assert.Equal(t, int64(60), int64(timeDiff), "time difference should be 60 seconds") + op := txXDR.Operations()[0] + assert.Equal(t, xdr.OperationTypeManageData, op.Body.Type, "operation type should be manage data") + assert.Equal(t, xdr.String64("testanchor.stellar.org auth"), op.Body.ManageDataOp.DataName, "DataName should be 'testanchor.stellar.org auth'") + assert.Equal(t, 64, len(*op.Body.ManageDataOp.DataValue), "DataValue should be 64 bytes") + webAuthOp := txXDR.Operations()[1] + assert.Equal(t, xdr.OperationTypeManageData, webAuthOp.Body.Type, "operation type should be manage data") + assert.Equal(t, xdr.String64("web_auth_domain"), webAuthOp.Body.ManageDataOp.DataName, "DataName should be 'web_auth_domain'") + assert.Equal(t, "testwebauth.stellar.org", string(*webAuthOp.Body.ManageDataOp.DataValue), "DataValue should be 'testwebauth.stellar.org'") + clientDomainOp := txXDR.Operations()[2] + assert.Equal(t, xdr.OperationTypeManageData, clientDomainOp.Body.Type, "operation type should be manage data") + assert.Equal(t, xdr.String64("client_domain"), clientDomainOp.Body.ManageDataOp.DataName, "DataName should be 'client_domain'") + assert.Equal(t, "clientdomain.stellar.org", string(*clientDomainOp.Body.ManageDataOp.DataValue), "DataValue should be 'clientdomain.stellar.org'") + } +} + func TestHashHex(t *testing.T) { kp0 := newKeypair0() sourceAccount := NewSimpleAccount(kp0.Address(), int64(9605939170639897)) @@ -3037,6 +3070,173 @@ func TestReadChallengeTx_invalidWebAuthDomain(t *testing.T) { assert.EqualError(t, err, `web auth domain operation value is "testwebauth.example.org" but expect "testwebauth.stellar.org"`) } +func TestReadChallengeTxWithClientDomain_valid(t *testing.T) { + serverKP := newKeypair0() + clientKP := newKeypair1() + clientDomainKP := newKeypair2() + clientDomainKPAddress := clientDomainKP.Address() + clientDomain := "clientdomain.stellar.org" + txSource := NewSimpleAccount(serverKP.Address(), -1) + op := ManageData{ + SourceAccount: clientKP.Address(), + Name: "testanchor.stellar.org auth", + Value: []byte(base64.StdEncoding.EncodeToString(make([]byte, 48))), + } + webAuthDomainOp := ManageData{ + SourceAccount: serverKP.Address(), + Name: "web_auth_domain", + Value: []byte("testwebauth.stellar.org"), + } + clientDomainOp := ManageData{ + SourceAccount: clientDomainKPAddress, + Name: "client_domain", + Value: []byte(clientDomain), + } + + tx, err := NewTransaction( + TransactionParams{ + SourceAccount: &txSource, + IncrementSequenceNum: true, + Operations: []Operation{&op, &webAuthDomainOp, &clientDomainOp}, + BaseFee: MinBaseFee, + Preconditions: Preconditions{TimeBounds: NewTimeout(1000)}, + }, + ) + assert.NoError(t, err) + + tx, err = tx.Sign(network.TestNetworkPassphrase, serverKP, clientKP, clientDomainKP) + assert.NoError(t, err) + tx64, err := tx.Base64() + require.NoError(t, err) + readTx, readClientAccountID, _, _, err := ReadChallengeTxWithClientDomain(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}, &clientDomain, &clientDomainKPAddress) + assert.Equal(t, tx, readTx) + assert.Equal(t, clientKP.Address(), readClientAccountID) + assert.NoError(t, err) +} + +func TestReadChallengeTxWithClientDomain_invalidNotSignedByClientDomainID(t *testing.T) { + serverKP := newKeypair0() + clientKP := newKeypair1() + clientDomainKP := newKeypair2() + clientDomainKPAddress := clientDomainKP.Address() + clientDomain := "clientdomain.stellar.org" + txSource := NewSimpleAccount(serverKP.Address(), -1) + op := ManageData{ + SourceAccount: clientKP.Address(), + Name: "testanchor.stellar.org auth", + Value: []byte(base64.StdEncoding.EncodeToString(make([]byte, 48))), + } + webAuthDomainOp := ManageData{ + SourceAccount: serverKP.Address(), + Name: "web_auth_domain", + Value: []byte("testwebauth.stellar.org"), + } + clientDomainOp := ManageData{ + SourceAccount: clientDomainKPAddress, + Name: "client_domain", + Value: []byte(clientDomain), + } + tx, err := NewTransaction( + TransactionParams{ + SourceAccount: &txSource, + IncrementSequenceNum: true, + Operations: []Operation{&op, &webAuthDomainOp, &clientDomainOp}, + BaseFee: MinBaseFee, + Preconditions: Preconditions{TimeBounds: NewTimeout(1000)}, + }, + ) + assert.NoError(t, err) + + tx, err = tx.Sign(network.TestNetworkPassphrase, serverKP, clientKP) + assert.NoError(t, err) + tx64, err := tx.Base64() + require.NoError(t, err) + readTx, readClientAccountID, _, _, err := ReadChallengeTxWithClientDomain(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}, &clientDomain, &clientDomainKPAddress) + assert.Equal(t, tx, readTx) + assert.Equal(t, clientKP.Address(), readClientAccountID) + assert.EqualError(t, err, "client domain operation is present but txn not signed by client") +} + +func TestReadChallengeTxWithClientDomain_invalidClientDomainSourceAccount(t *testing.T) { + serverKP := newKeypair0() + clientKP := newKeypair1() + clientDomainKP := newKeypair2() + clientDomainKPAddress := clientDomainKP.Address() + clientDomain := "clientdomain.stellar.org" + txSource := NewSimpleAccount(serverKP.Address(), -1) + op1 := ManageData{ + SourceAccount: clientKP.Address(), + Name: "testanchor.stellar.org auth", + Value: []byte(base64.StdEncoding.EncodeToString(make([]byte, 48))), + } + webAuthDomainOp := ManageData{ + SourceAccount: serverKP.Address(), + Name: "web_auth_domain", + Value: []byte("testwebauth.stellar.org"), + } + clientDomainOp := ManageData{ + SourceAccount: serverKP.Address(), + Name: "client_domain", + Value: []byte(clientDomain), + } + tx, err := NewTransaction( + TransactionParams{ + SourceAccount: &txSource, + IncrementSequenceNum: true, + Operations: []Operation{&op1, &webAuthDomainOp, &clientDomainOp}, + BaseFee: MinBaseFee, + Preconditions: Preconditions{TimeBounds: NewTimeout(300)}, + }, + ) + assert.NoError(t, err) + tx, err = tx.Sign(network.TestNetworkPassphrase, serverKP) + assert.NoError(t, err) + tx64, err := tx.Base64() + require.NoError(t, err) + _, _, _, _, err = ReadChallengeTxWithClientDomain(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}, &clientDomain, &clientDomainKPAddress) + assert.EqualError(t, err, `client domain operation must not be server source account`) +} + +func TestReadChallengeTxWithClientDomain_invalidClientDomain(t *testing.T) { + serverKP := newKeypair0() + clientKP := newKeypair1() + clientDomainKP := newKeypair2() + clientDomainKPAddress := clientDomainKP.Address() + clientDomain := "clientdomain.stellar.org" + txSource := NewSimpleAccount(serverKP.Address(), -1) + op1 := ManageData{ + SourceAccount: clientKP.Address(), + Name: "testanchor.stellar.org auth", + Value: []byte(base64.StdEncoding.EncodeToString(make([]byte, 48))), + } + webAuthDomainOp := ManageData{ + SourceAccount: serverKP.Address(), + Name: "web_auth_domain", + Value: []byte("testwebauth.stellar.org"), + } + clientDomainOp := ManageData{ + SourceAccount: clientDomainKPAddress, + Name: "client_domain", + Value: []byte("clientdomain.example.org"), + } + tx, err := NewTransaction( + TransactionParams{ + SourceAccount: &txSource, + IncrementSequenceNum: true, + Operations: []Operation{&op1, &webAuthDomainOp, &clientDomainOp}, + BaseFee: MinBaseFee, + Preconditions: Preconditions{TimeBounds: NewTimeout(300)}, + }, + ) + assert.NoError(t, err) + tx, err = tx.Sign(network.TestNetworkPassphrase, serverKP) + assert.NoError(t, err) + tx64, err := tx.Base64() + require.NoError(t, err) + _, _, _, _, err = ReadChallengeTxWithClientDomain(tx64, serverKP.Address(), network.TestNetworkPassphrase, "testwebauth.stellar.org", []string{"testanchor.stellar.org"}, &clientDomain, &clientDomainKPAddress) + assert.EqualError(t, err, `client domain operation value is "clientdomain.example.org" but expect "clientdomain.stellar.org"`) +} + func TestVerifyChallengeTxThreshold_invalidServer(t *testing.T) { serverKP := newKeypair0() clientKP := newKeypair1()