-
Notifications
You must be signed in to change notification settings - Fork 504
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
exp/services/webauth: add SEP-10 v1.2.0 implementation (#2074)
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
1 parent
4372947
commit e18ff04
Showing
16 changed files
with
1,268 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
Oops, something went wrong.