Skip to content

Commit

Permalink
exp/services/webauth: add SEP-10 v1.2.0 implementation (#2074)
Browse files Browse the repository at this point in the history
Add SEP-10 web authentication implementation based on SEP-10 v1.2.0 that requires the master key have a high threshold for authentication to succeed.

We need a standalone server implementation of SEP-10 for the mobile-wallet and this provides a server supporting the absolute basics of the existing SEP-10 protocol.

The SEP-10 protocol doesn't define what threshold a server should require a signing master key to have on an account, but for the sake of demonstration and our use case it requires the high threshold. It could be configurable but isn't at the moment.

This implementation has been written with the proposal in mind that we are making to SEP-10 (stellar/stellar-protocol#489) also, and already sets up the test cases with where we expect multi-sig to go but has those tests set with expectations that are appropriate given the limitations of SEP-10 today.

This application is not polished which is why it is being added to the `exp` package and why this is a draft PR.
  • Loading branch information
leighmcculloch authored Dec 20, 2019
1 parent 4372947 commit e18ff04
Show file tree
Hide file tree
Showing 16 changed files with 1,268 additions and 0 deletions.
54 changes: 54 additions & 0 deletions exp/services/webauth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# webauth

This is a [SEP-10] Web Authentication implementation based on SEP-10 v1.2.0
that requires the master key have a high threshold for authentication to
succeed.

SEP-10 defines an endpoint for authenticating a user in possession of a Stellar
account using their Stellar account as credentials. This implementation is a
standalone microservice that implements the minimum requirements as defined by
the SEP-10 protocol and will be adapted as the protocol evolves.

This implementation is not polished and is still experimental.
Running this implementation in production is not recommended.

## Usage

````
$ webauth --help
SEP-10 Web Authentication Server
Usage:
webauth [command] [flags]
webauth [command]
Available Commands:
genjwtkey Generate a JWT ECDSA key
serve Run the SEP-10 Web Authentication server
Flags:
-h, --help help for webauth
Use "webauth [command] --help" for more information about a command.
```
## Usage: Serve
```
$ webauth serve --help
Run the SEP-10 Web Authentication server
Usage:
webauth serve [flags]
Flags:
--challenge-expires-in int The time period in seconds after which the challenge transaction expires (default 300)
--horizon-url string Horizon URL used for looking up account details (default "https://horizon-testnet.stellar.org/")
--jwt-expires-in int The time period in seconds after which the JWT expires (default 300)
--jwt-key string Base64 encoded ECDSA private key used for signing JWTs
--network-passphrase string Network passphrase of the Stellar network transactions should be signed for (default "Test SDF Network ; September 2015")
--port int Port to listen and serve on (default 8000)
--signing-key string Stellar signing key used for signing transactions
```
[SEP-10]: https://github.com/stellar/stellar-protocol/blob/2be91ce8d8032ca9b2f368800d06b9fba346a147/ecosystem/sep-0010.md
41 changes: 41 additions & 0 deletions exp/services/webauth/internal/commands/genjwtkey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package commands

import (
"github.com/spf13/cobra"
"github.com/stellar/go/exp/support/jwtkey"
supportlog "github.com/stellar/go/support/log"
)

type GenJWTKeyCommand struct {
Logger *supportlog.Entry
}

func (c *GenJWTKeyCommand) Command() *cobra.Command {
cmd := &cobra.Command{
Use: "genjwtkey",
Short: "Generate a JWT ECDSA key",
Run: func(_ *cobra.Command, _ []string) {
c.Run()
},
}
return cmd
}

func (c *GenJWTKeyCommand) Run() {
k, err := jwtkey.GenerateKey()
if err != nil {
c.Logger.Fatal(err)
}

if public, err := jwtkey.PublicKeyToString(&k.PublicKey); err == nil {
c.Logger.Print("Public:", public)
} else {
c.Logger.Print("Public:", err)
}

if private, err := jwtkey.PrivateKeyToString(k); err == nil {
c.Logger.Print("Private:", private)
} else {
c.Logger.Print("Private:", err)
}
}
95 changes: 95 additions & 0 deletions exp/services/webauth/internal/commands/serve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package commands

import (
"go/types"

"github.com/spf13/cobra"
"github.com/stellar/go/clients/horizonclient"
"github.com/stellar/go/exp/services/webauth/internal/serve"
"github.com/stellar/go/network"
"github.com/stellar/go/support/config"
supportlog "github.com/stellar/go/support/log"
)

type ServeCommand struct {
Logger *supportlog.Entry
}

func (c *ServeCommand) Command() *cobra.Command {
opts := serve.Options{
Logger: c.Logger,
}
configOpts := config.ConfigOptions{
{
Name: "port",
Usage: "Port to listen and serve on",
OptType: types.Int,
ConfigKey: &opts.Port,
FlagDefault: 8000,
Required: true,
},
{
Name: "horizon-url",
Usage: "Horizon URL used for looking up account details",
OptType: types.String,
ConfigKey: &opts.HorizonURL,
FlagDefault: horizonclient.DefaultTestNetClient.HorizonURL,
Required: true,
},
{
Name: "network-passphrase",
Usage: "Network passphrase of the Stellar network transactions should be signed for",
OptType: types.String,
ConfigKey: &opts.NetworkPassphrase,
FlagDefault: network.TestNetworkPassphrase,
Required: true,
},
{
Name: "signing-key",
Usage: "Stellar signing key used for signing transactions",
OptType: types.String,
ConfigKey: &opts.SigningKey,
Required: true,
},
{
Name: "challenge-expires-in",
Usage: "The time period in seconds after which the challenge transaction expires",
OptType: types.Int,
CustomSetValue: config.SetDuration,
ConfigKey: &opts.ChallengeExpiresIn,
FlagDefault: 300,
Required: true,
},
{
Name: "jwt-key",
Usage: "Base64 encoded ECDSA private key used for signing JWTs",
OptType: types.String,
ConfigKey: &opts.JWTPrivateKey,
Required: true,
},
{
Name: "jwt-expires-in",
Usage: "The time period in seconds after which the JWT expires",
OptType: types.Int,
CustomSetValue: config.SetDuration,
ConfigKey: &opts.JWTExpiresIn,
FlagDefault: 300,
Required: true,
},
}
cmd := &cobra.Command{
Use: "serve",
Short: "Run the SEP-10 Web Authentication server",
Run: func(_ *cobra.Command, _ []string) {
configOpts.Require()
configOpts.SetValues()
c.Run(opts)
},
}
configOpts.Init(cmd)
return cmd
}

func (c *ServeCommand) Run(opts serve.Options) {
serve.Serve(opts)
}
56 changes: 56 additions & 0 deletions exp/services/webauth/internal/serve/challenge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package serve

import (
"net/http"
"time"

"github.com/stellar/go/keypair"
"github.com/stellar/go/strkey"
supportlog "github.com/stellar/go/support/log"
"github.com/stellar/go/support/render/httpjson"
"github.com/stellar/go/txnbuild"
)

// ChallengeHandler implements the SEP-10 challenge endpoint and handles
// requests for a new challenge transaction.
type challengeHandler struct {
Logger *supportlog.Entry
ServerName string
NetworkPassphrase string
SigningKey *keypair.Full
ChallengeExpiresIn time.Duration
}

type challengeResponse struct {
Transaction string `json:"transaction"`
NetworkPassphrase string `json:"network_passphrase"`
}

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

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

tx, err := txnbuild.BuildChallengeTx(
h.SigningKey.Seed(),
account,
h.ServerName,
h.NetworkPassphrase,
h.ChallengeExpiresIn,
)
if err != nil {
h.Logger.Ctx(ctx).WithStack(err).Error(err)
serverError.Render(w)
return
}

res := challengeResponse{
Transaction: tx,
NetworkPassphrase: h.NetworkPassphrase,
}
httpjson.Render(w, res, httpjson.JSON)
}
96 changes: 96 additions & 0 deletions exp/services/webauth/internal/serve/challenge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package serve

import (
"encoding/json"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/stellar/go/keypair"
"github.com/stellar/go/network"
supportlog "github.com/stellar/go/support/log"
"github.com/stellar/go/xdr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestChallenge(t *testing.T) {
serverKey := keypair.MustRandom()
account := keypair.MustRandom()

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

r := httptest.NewRequest("GET", "/?account="+account.Address(), 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)
assert.Equal(t, serverKey.Address(), tx.Tx.SourceAccount.Address())
assert.Equal(t, tx.Tx.SeqNum, xdr.SequenceNumber(0))
assert.Equal(t, time.Unix(int64(tx.Tx.TimeBounds.MaxTime), 0).Sub(time.Unix(int64(tx.Tx.TimeBounds.MinTime), 0)), time.Minute)
assert.Len(t, tx.Tx.Operations, 1)
assert.Equal(t, account.Address(), tx.Tx.Operations[0].SourceAccount.Address())
assert.Equal(t, xdr.OperationTypeManageData, tx.Tx.Operations[0].Body.Type)
assert.Regexp(t, "^testserver auth", tx.Tx.Operations[0].Body.ManageDataOp.DataName)

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

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

func TestChallengeNoAccount(t *testing.T) {
h := challengeHandler{}

r := httptest.NewRequest("GET", "/", 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 TestChallengeInvalidAccount(t *testing.T) {
h := challengeHandler{}

r := httptest.NewRequest("GET", "/?account=GREATACCOUNT", 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))
}
45 changes: 45 additions & 0 deletions exp/services/webauth/internal/serve/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package serve

import (
"net/http"

"github.com/stellar/go/support/render/httpjson"
)

var serverError = errorResponse{
Status: http.StatusInternalServerError,
Error: "An error occurred while processing this request.",
}
var notFound = errorResponse{
Status: http.StatusNotFound,
Error: "The resource at the url requested was not found.",
}
var methodNotAllowed = errorResponse{
Status: http.StatusMethodNotAllowed,
Error: "The method is not allowed for resource at the url requested.",
}
var badRequest = errorResponse{
Status: http.StatusBadRequest,
Error: "The request was invalid in some way.",
}
var unauthorized = errorResponse{
Status: http.StatusUnauthorized,
Error: "The request could not be authenticated.",
}

type errorResponse struct {
Status int `json:"-"`
Error string `json:"error"`
}

func (e errorResponse) Render(w http.ResponseWriter) {
httpjson.RenderStatus(w, e.Status, e, httpjson.JSON)
}

type errorHandler struct {
Error errorResponse
}

func (h errorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.Error.Render(w)
}
Loading

0 comments on commit e18ff04

Please sign in to comment.