Skip to content

Commit

Permalink
{txnbuild, webauth}: support the SEP-10 V2 (#2967)
Browse files Browse the repository at this point in the history
resolves #2968

Makes the same changes made to the JS SDK.

Co-authored-by: Howard Tinghao Chen <[email protected]>
  • Loading branch information
JakeUrban and howardtw authored Sep 16, 2020
1 parent 9d2b70e commit 5e82f16
Show file tree
Hide file tree
Showing 11 changed files with 401 additions and 85 deletions.
6 changes: 4 additions & 2 deletions exp/services/webauth/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# webauth

This is a [SEP-10] Web Authentication implementation based on SEP-10 v1.3.0
This is a [SEP-10] Web Authentication implementation based on SEP-10 v2.0.0
that requires a user to prove they possess a signing key(s) that meets the high
threshold for an account, i.e. they have the ability to perform any high
threshold operation on the given account. If an account does not exist it may
Expand Down Expand Up @@ -42,6 +42,7 @@ Usage:
Flags:
--allow-accounts-that-do-not-exist Allow accounts that do not exist (ALLOW_ACCOUNTS_THAT_DO_NOT_EXIST)
--auth-home-domain string Home domain(s) of the service(s) requiring SEP-10 authentication comma separated (first domain is the default domain) (AUTH_HOME_DOMAIN)
--challenge-expires-in int The time period in seconds after which the challenge transaction expires (CHALLENGE_EXPIRES_IN) (default 300)
--horizon-url string Horizon URL used for looking up account details (HORIZON_URL) (default "https://horizon-testnet.stellar.org/")
--jwk string JSON Web Key (JWK) used for signing JWTs (if the key is an asymmetric key that has separate public and private key, the JWK must contain the private key) (JWK)
Expand All @@ -50,6 +51,7 @@ Flags:
--network-passphrase string Network passphrase of the Stellar network transactions should be signed for (NETWORK_PASSPHRASE) (default "Test SDF Network ; September 2015")
--port int Port to listen and serve on (PORT) (default 8000)
--signing-key string Stellar signing key(s) used for signing transactions comma separated (first key is used for signing, others used for verifying challenges) (SIGNING_KEY)
--stellar-toml-domain string Domain where stellar.toml is served. The private key counterpart of the SIGNING_KEY specified in the stellar.toml file has to be provided via signing-key (STELLAR_TOML_DOMAIN)
```

[SEP-10]: https://github.com/stellar/stellar-protocol/blob/2be91ce8d8032ca9b2f368800d06b9fba346a147/ecosystem/sep-0010.md
[SEP-10]: https://github.com/stellar/stellar-protocol/blob/28c636b4ef5074ca0c3d46bbe9bf0f3f38095233/ecosystem/sep-0010.md
14 changes: 14 additions & 0 deletions exp/services/webauth/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ func (c *ServeCommand) Command() *cobra.Command {
ConfigKey: &opts.SigningKeys,
Required: true,
},
{
Name: "stellar-toml-domain",
Usage: "Domain where stellar.toml is served. The private key counterpart of the SIGNING_KEY specified in the stellar.toml file has to be provided via signing-key",
OptType: types.String,
ConfigKey: &opts.StellarTOMLDomain,
Required: true,
},
{
Name: "auth-home-domain",
Usage: "Home domain(s) of the service(s) requiring SEP-10 authentication comma separated (first domain is the default domain)",
OptType: types.String,
ConfigKey: &opts.AuthHomeDomains,
Required: true,
},
{
Name: "challenge-expires-in",
Usage: "The time period in seconds after which the challenge transaction expires",
Expand Down
30 changes: 26 additions & 4 deletions exp/services/webauth/internal/serve/challenge.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package serve

import (
"net/http"
"strings"
"time"

"github.com/stellar/go/keypair"
Expand All @@ -15,10 +16,10 @@ import (
// requests for a new challenge transaction.
type challengeHandler struct {
Logger *supportlog.Entry
ServerName string
NetworkPassphrase string
SigningKey *keypair.Full
ChallengeExpiresIn time.Duration
HomeDomains []string
}

type challengeResponse struct {
Expand All @@ -28,17 +29,37 @@ type challengeResponse struct {

func (h challengeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
queryValues := r.URL.Query()

account := r.URL.Query().Get("account")
account := queryValues.Get("account")
if !strkey.IsValidEd25519PublicKey(account) {
badRequest.Render(w)
return
}

homeDomain := queryValues.Get("home_domain")
if homeDomain != "" {
// In some cases the full stop (period) character is used at the end of a FQDN.
homeDomain = strings.TrimSuffix(homeDomain, ".")
matched := false
for _, supportedDomain := range h.HomeDomains {
if homeDomain == supportedDomain {
matched = true
break
}
}
if !matched {
badRequest.Render(w)
return
}
} else {
homeDomain = h.HomeDomains[0]
}

tx, err := txnbuild.BuildChallengeTx(
h.SigningKey.Seed(),
account,
h.ServerName,
homeDomain,
h.NetworkPassphrase,
h.ChallengeExpiresIn,
)
Expand All @@ -58,7 +79,8 @@ func (h challengeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
l := h.Logger.Ctx(ctx).
WithField("tx", hash).
WithField("account", account).
WithField("serversigner", h.SigningKey.Address())
WithField("serversigner", h.SigningKey.Address()).
WithField("homedomain", homeDomain)

l.Info("Generated challenge transaction for account.")

Expand Down
89 changes: 83 additions & 6 deletions exp/services/webauth/internal/serve/challenge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package serve

import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
Expand All @@ -22,10 +23,10 @@ func TestChallenge(t *testing.T) {

h := challengeHandler{
Logger: supportlog.DefaultLogger,
ServerName: "testserver",
NetworkPassphrase: network.TestNetworkPassphrase,
SigningKey: serverKey,
ChallengeExpiresIn: time.Minute,
HomeDomains: []string{"testdomain"},
}

r := httptest.NewRequest("GET", "/?account="+account.Address(), nil)
Expand Down Expand Up @@ -56,7 +57,7 @@ func TestChallenge(t *testing.T) {
opSourceAccount := tx.Operations()[0].SourceAccount.ToAccountId()
assert.Equal(t, account.Address(), opSourceAccount.Address())
assert.Equal(t, xdr.OperationTypeManageData, tx.Operations()[0].Body.Type)
assert.Regexp(t, "^testserver auth", tx.Operations()[0].Body.ManageDataOp.DataName)
assert.Regexp(t, "^testdomain auth", tx.Operations()[0].Body.ManageDataOp.DataName)

hash, err := network.HashTransactionInEnvelope(tx, res.NetworkPassphrase)
require.NoError(t, err)
Expand All @@ -65,8 +66,60 @@ func TestChallenge(t *testing.T) {
assert.Equal(t, network.TestNetworkPassphrase, res.NetworkPassphrase)
}

func TestChallengeNoAccount(t *testing.T) {
h := challengeHandler{}
func TestChallenge_anotherHomeDomain(t *testing.T) {
serverKey := keypair.MustRandom()
account := keypair.MustRandom()
anotherDomain := "anotherdomain"

h := challengeHandler{
Logger: supportlog.DefaultLogger,
NetworkPassphrase: network.TestNetworkPassphrase,
SigningKey: serverKey,
ChallengeExpiresIn: time.Minute,
HomeDomains: []string{"testdomain", anotherDomain},
}

r := httptest.NewRequest("GET", fmt.Sprintf("/?account=%s&home_domain=%s", account.Address(), anotherDomain), 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)

assert.Len(t, tx.Signatures(), 1)
sourceAccount := tx.SourceAccount().ToAccountId()
assert.Equal(t, serverKey.Address(), sourceAccount.Address())
assert.Equal(t, tx.SeqNum(), int64(0))
assert.Equal(t, time.Unix(int64(tx.TimeBounds().MaxTime), 0).Sub(time.Unix(int64(tx.TimeBounds().MinTime), 0)), time.Minute)
assert.Len(t, tx.Operations(), 1)
opSourceAccount := tx.Operations()[0].SourceAccount.ToAccountId()
assert.Equal(t, account.Address(), opSourceAccount.Address())
assert.Equal(t, xdr.OperationTypeManageData, tx.Operations()[0].Body.Type)
assert.Regexp(t, "^anotherdomain auth", tx.Operations()[0].Body.ManageDataOp.DataName)

hash, err := network.HashTransactionInEnvelope(tx, res.NetworkPassphrase)
require.NoError(t, err)
assert.NoError(t, serverKey.FromAddress().Verify(hash[:], tx.V0.Signatures[0].Signature))

assert.Equal(t, network.TestNetworkPassphrase, res.NetworkPassphrase)
}

func TestChallenge_noAccount(t *testing.T) {
h := challengeHandler{
SigningKey: keypair.MustRandom(),
}

r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
Expand All @@ -81,8 +134,10 @@ func TestChallengeNoAccount(t *testing.T) {
assert.JSONEq(t, `{"error":"The request was invalid in some way."}`, string(body))
}

func TestChallengeInvalidAccount(t *testing.T) {
h := challengeHandler{}
func TestChallenge_invalidAccount(t *testing.T) {
h := challengeHandler{
SigningKey: keypair.MustRandom(),
}

r := httptest.NewRequest("GET", "/?account=GREATACCOUNT", nil)
w := httptest.NewRecorder()
Expand All @@ -96,3 +151,25 @@ func TestChallengeInvalidAccount(t *testing.T) {
require.NoError(t, err)
assert.JSONEq(t, `{"error":"The request was invalid in some way."}`, string(body))
}

func TestChallenge_invalidHomeDomain(t *testing.T) {
account := keypair.MustRandom()
anotherDomain := "anotherdomain"

h := challengeHandler{
SigningKey: keypair.MustRandom(),
HomeDomains: []string{"testdomain"},
}

r := httptest.NewRequest("GET", fmt.Sprintf("/?account=%s&home_domain=%s", account.Address(), anotherDomain), 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))
}
70 changes: 65 additions & 5 deletions exp/services/webauth/internal/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package serve
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/BurntSushi/toml"
"github.com/stellar/go/clients/horizonclient"
"github.com/stellar/go/keypair"
"github.com/stellar/go/support/errors"
Expand All @@ -16,12 +18,16 @@ import (
"gopkg.in/square/go-jose.v2"
)

const stellarTomlMaxSize = 100 * 1024

type Options struct {
Logger *supportlog.Entry
HorizonURL string
Port int
NetworkPassphrase string
SigningKeys string
StellarTOMLDomain string
AuthHomeDomains string
ChallengeExpiresIn time.Duration
JWK string
JWTIssuer string
Expand All @@ -48,18 +54,41 @@ func Serve(opts Options) {
}

func handler(opts Options) (http.Handler, error) {
signingKeys := []*keypair.Full{}
signingAddresses := []*keypair.FromAddress{}
for i, signingKeyStr := range strings.Split(opts.SigningKeys, ",") {
var signingKeyFull *keypair.Full
signingKeyStrs := strings.Split(opts.SigningKeys, ",")
signingAddresses := make([]*keypair.FromAddress, 0, len(signingKeyStrs))

for i, signingKeyStr := range signingKeyStrs {
signingKey, err := keypair.ParseFull(signingKeyStr)
if err != nil {
return nil, errors.Wrap(err, "parsing signing key seed")
}
signingKeys = append(signingKeys, signingKey)

// Only the first key is used for signing. The rest is for verifying challenge transactions, if any.
if i == 0 {
var signingKeyPub string
signingKeyPub, err = getStellarTOMLSigningKey(opts.StellarTOMLDomain)
if err != nil {
opts.Logger.Errorf("Error reading SIGNING_KEY from domain %s: %v", opts.StellarTOMLDomain, err)
}

if err == nil && signingKey.Address() != signingKeyPub {
opts.Logger.Error("The configured signing key does not match the private key counterpart of the SIGNING_KEY in the stellar.toml file.")
}

signingKeyFull = signingKey
}
signingAddresses = append(signingAddresses, signingKey.FromAddress())
opts.Logger.Info("Signing key ", i, ": ", signingKey.Address())
}

homeDomains := strings.Split(opts.AuthHomeDomains, ",")
trimmedHomeDomains := make([]string, 0, len(homeDomains))
for _, homeDomain := range homeDomains {
// In some cases the full stop (period) character is used at the end of a FQDN.
trimmedHomeDomains = append(trimmedHomeDomains, strings.TrimSuffix(homeDomain, "."))
}

jwk := jose.JSONWebKey{}
err := json.Unmarshal([]byte(opts.JWK), &jwk)
if err != nil {
Expand Down Expand Up @@ -88,8 +117,9 @@ func handler(opts Options) (http.Handler, error) {
mux.Get("/", challengeHandler{
Logger: opts.Logger,
NetworkPassphrase: opts.NetworkPassphrase,
SigningKey: signingKeys[0],
SigningKey: signingKeyFull,
ChallengeExpiresIn: opts.ChallengeExpiresIn,
HomeDomains: trimmedHomeDomains,
}.ServeHTTP)
mux.Post("/", tokenHandler{
Logger: opts.Logger,
Expand All @@ -100,7 +130,37 @@ func handler(opts Options) (http.Handler, error) {
JWTIssuer: opts.JWTIssuer,
JWTExpiresIn: opts.JWTExpiresIn,
AllowAccountsThatDoNotExist: opts.AllowAccountsThatDoNotExist,
HomeDomains: trimmedHomeDomains,
}.ServeHTTP)

return mux, nil
}

func getStellarTOMLSigningKey(domain string) (string, error) {
var signingKeyTOML struct {
SigningKey string `toml:"SIGNING_KEY"`
}

httpClient := &http.Client{
Timeout: 5 * time.Second,
}

domain = strings.TrimRight(domain, "./")
resp, err := httpClient.Get(fmt.Sprintf("https://%s/.well-known/stellar.toml", domain))
if err != nil {
return "", errors.Wrap(err, "sending http request")
}
defer resp.Body.Close()

if resp.StatusCode/100 != 2 {
return "", errors.New("http request failed with non-200 status code")
}

safeResBody := io.LimitReader(resp.Body, stellarTomlMaxSize)
_, err = toml.DecodeReader(safeResBody, &signingKeyTOML)
if err != nil {
return "", errors.Wrap(err, "decoding signing key")
}

return signingKeyTOML.SigningKey, nil
}
Loading

0 comments on commit 5e82f16

Please sign in to comment.