-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
auth: add signature middleware (#13)
What SignatureMiddleware validates if the API caller has ownership of the configured Stellar public key that signed the request. This adds authentication when requesting the resources. Why Security reasons.
- Loading branch information
1 parent
db5b9b0
commit 2b5e625
Showing
15 changed files
with
792 additions
and
23 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
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,26 @@ | ||
package utils | ||
|
||
import ( | ||
"fmt" | ||
|
||
"github.com/spf13/viper" | ||
"github.com/stellar/go/keypair" | ||
"github.com/stellar/go/support/config" | ||
) | ||
|
||
func SetConfigOptionStellarPublicKey(co *config.ConfigOption) error { | ||
publicKey := viper.GetString(co.Name) | ||
|
||
kp, err := keypair.ParseAddress(publicKey) | ||
if err != nil { | ||
return fmt.Errorf("validating public key in %s: %w", co.Name, err) | ||
} | ||
|
||
key, ok := co.ConfigKey.(*string) | ||
if !ok { | ||
return fmt.Errorf("the expected type for the config key in %s is a string, but a %T was provided instead", co.Name, co.ConfigKey) | ||
} | ||
*key = kp.Address() | ||
|
||
return nil | ||
} |
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,126 @@ | ||
package utils | ||
|
||
import ( | ||
"go/types" | ||
"os" | ||
"strings" | ||
"testing" | ||
|
||
"github.com/spf13/cobra" | ||
"github.com/stellar/go/support/config" | ||
"github.com/stellar/wallet-backend/internal/utils" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
// customSetterTestCase is a test case to test a custom_set_value function. | ||
type customSetterTestCase[T any] struct { | ||
name string | ||
args []string | ||
envValue string | ||
wantErrContains string | ||
wantResult T | ||
} | ||
|
||
// customSetterTester tests a custom_set_value function, according with the customSetterTestCase provided. | ||
func customSetterTester[T any](t *testing.T, tc customSetterTestCase[T], co config.ConfigOption) { | ||
t.Helper() | ||
ClearTestEnvironment(t) | ||
if tc.envValue != "" { | ||
envName := strings.ToUpper(co.Name) | ||
envName = strings.ReplaceAll(envName, "-", "_") | ||
t.Setenv(envName, tc.envValue) | ||
} | ||
|
||
// start the CLI command | ||
testCmd := cobra.Command{ | ||
RunE: func(cmd *cobra.Command, args []string) error { | ||
co.Require() | ||
return co.SetValue() | ||
}, | ||
} | ||
// mock the command line output | ||
buf := new(strings.Builder) | ||
testCmd.SetOut(buf) | ||
|
||
// Initialize the command for the given option | ||
err := co.Init(&testCmd) | ||
require.NoError(t, err) | ||
|
||
// execute command line | ||
if len(tc.args) > 0 { | ||
testCmd.SetArgs(tc.args) | ||
} | ||
err = testCmd.Execute() | ||
|
||
// check the result | ||
if tc.wantErrContains != "" { | ||
assert.Error(t, err) | ||
assert.Contains(t, err.Error(), tc.wantErrContains) | ||
} else { | ||
assert.NoError(t, err) | ||
} | ||
|
||
if !utils.IsEmpty(tc.wantResult) { | ||
destPointer := utils.UnwrapInterfaceToPointer[T](co.ConfigKey) | ||
assert.Equal(t, tc.wantResult, *destPointer) | ||
} | ||
} | ||
|
||
// clearTestEnvironment removes all envs from the test environment. It's useful | ||
// to make tests independent from the localhost environment variables. | ||
func ClearTestEnvironment(t *testing.T) { | ||
t.Helper() | ||
|
||
// remove all envs from tghe test environment | ||
for _, env := range os.Environ() { | ||
key := env[:strings.Index(env, "=")] | ||
t.Setenv(key, "") | ||
} | ||
} | ||
|
||
func TestSetConfigOptionStellarPublicKey(t *testing.T) { | ||
opts := struct{ sep10SigningPublicKey string }{} | ||
|
||
co := config.ConfigOption{ | ||
Name: "wallet-signing-key", | ||
OptType: types.String, | ||
CustomSetValue: SetConfigOptionStellarPublicKey, | ||
ConfigKey: &opts.sep10SigningPublicKey, | ||
} | ||
expectedPublicKey := "GAX46JJZ3NPUM2EUBTTGFM6ITDF7IGAFNBSVWDONPYZJREHFPP2I5U7S" | ||
|
||
testCases := []customSetterTestCase[string]{ | ||
{ | ||
name: "returns an error if the public key is empty", | ||
wantErrContains: "validating public key in wallet-signing-key: strkey is 0 bytes long; minimum valid length is 5", | ||
}, | ||
{ | ||
name: "returns an error if the public key is invalid", | ||
args: []string{"--wallet-signing-key", "invalid_public_key"}, | ||
wantErrContains: "validating public key in wallet-signing-key: base32 decode failed: illegal base32 data at input byte 18", | ||
}, | ||
{ | ||
name: "returns an error if the public key is invalid (private key instead)", | ||
args: []string{"--wallet-signing-key", "SDISQRUPIHAO5WIIGY4QRDCINZSA44TX3OIIUK3C63NUKN5DABKEQ276"}, | ||
wantErrContains: "validating public key in wallet-signing-key: invalid version byte", | ||
}, | ||
{ | ||
name: "handles Stellar public key through the CLI flag", | ||
args: []string{"--wallet-signing-key", "GAX46JJZ3NPUM2EUBTTGFM6ITDF7IGAFNBSVWDONPYZJREHFPP2I5U7S"}, | ||
wantResult: expectedPublicKey, | ||
}, | ||
{ | ||
name: "handles Stellar public key through the ENV vars", | ||
envValue: "GAX46JJZ3NPUM2EUBTTGFM6ITDF7IGAFNBSVWDONPYZJREHFPP2I5U7S", | ||
wantResult: expectedPublicKey, | ||
}, | ||
} | ||
|
||
for _, tc := range testCases { | ||
t.Run(tc.name, func(t *testing.T) { | ||
opts.sep10SigningPublicKey = "" | ||
customSetterTester(t, tc, co) | ||
}) | ||
} | ||
} |
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
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,18 @@ | ||
package auth | ||
|
||
import ( | ||
"context" | ||
|
||
"github.com/stretchr/testify/mock" | ||
) | ||
|
||
type MockSignatureVerifier struct { | ||
mock.Mock | ||
} | ||
|
||
var _ SignatureVerifier = (*MockSignatureVerifier)(nil) | ||
|
||
func (sv *MockSignatureVerifier) VerifySignature(ctx context.Context, signatureHeaderContent string, reqBody []byte) error { | ||
args := sv.Called(ctx, signatureHeaderContent, reqBody) | ||
return args.Error(0) | ||
} |
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,153 @@ | ||
package auth | ||
|
||
import ( | ||
"context" | ||
"encoding/base64" | ||
"errors" | ||
"fmt" | ||
"net/url" | ||
"regexp" | ||
"strconv" | ||
"strings" | ||
"time" | ||
|
||
"github.com/stellar/go/keypair" | ||
"github.com/stellar/go/strkey" | ||
"github.com/stellar/go/support/log" | ||
) | ||
|
||
type SignatureVerifier interface { | ||
VerifySignature(ctx context.Context, signatureHeaderContent string, rawReqBody []byte) error | ||
} | ||
|
||
var ( | ||
ErrStellarSignatureNotVerified = errors.New("neither Signature nor X-Stellar-Signature header could be verified") | ||
) | ||
|
||
type ErrInvalidTimestampFormat struct { | ||
TimestampString string | ||
timestampValueError bool | ||
} | ||
|
||
func (e ErrInvalidTimestampFormat) Error() string { | ||
if e.timestampValueError { | ||
return fmt.Sprintf("signature format different than expected. expected unix seconds, got: %s", e.TimestampString) | ||
} | ||
return fmt.Sprintf("malformed timestamp: %s", e.TimestampString) | ||
} | ||
|
||
type ErrExpiredSignatureTimestamp struct { | ||
ExpiredSignatureTimestamp time.Time | ||
CheckTime time.Time | ||
} | ||
|
||
func (e ErrExpiredSignatureTimestamp) Error() string { | ||
return fmt.Sprintf("signature timestamp has expired. sig timestamp: %s, check time %s", e.ExpiredSignatureTimestamp.Format(time.RFC3339), e.CheckTime.Format(time.RFC3339)) | ||
} | ||
|
||
type StellarSignatureVerifier struct { | ||
ServerHostname string | ||
WalletSigningKey string | ||
} | ||
|
||
var _ SignatureVerifier = (*StellarSignatureVerifier)(nil) | ||
|
||
// VerifySignature verifies the Signature or X-Stellar-Signature content and checks if the signature is signed for a known caller. | ||
func (sv *StellarSignatureVerifier) VerifySignature(ctx context.Context, signatureHeaderContent string, rawReqBody []byte) error { | ||
t, s, err := ExtractTimestampedSignature(signatureHeaderContent) | ||
if err != nil { | ||
log.Ctx(ctx).Error(err) | ||
return ErrStellarSignatureNotVerified | ||
} | ||
|
||
// 2 seconds | ||
err = VerifyGracePeriodSeconds(t, 2*time.Second) | ||
if err != nil { | ||
log.Ctx(ctx).Error(err) | ||
return ErrStellarSignatureNotVerified | ||
} | ||
|
||
signatureBytes, err := base64.StdEncoding.DecodeString(s) | ||
if err != nil { | ||
log.Ctx(ctx).Errorf("unable to decode signature value %s: %s", s, err.Error()) | ||
return ErrStellarSignatureNotVerified | ||
} | ||
|
||
payload := t + "." + sv.ServerHostname + "." + string(rawReqBody) | ||
|
||
// TODO: perhaps add possibility to have more than one signing key. | ||
kp, err := keypair.ParseAddress(sv.WalletSigningKey) | ||
if err != nil { | ||
return fmt.Errorf("parsing wallet signing key %s: %w", sv.WalletSigningKey, err) | ||
} | ||
|
||
err = kp.Verify([]byte(payload), signatureBytes) | ||
if err != nil { | ||
log.Ctx(ctx).Errorf("unable to verify the signature: %s", err.Error()) | ||
return ErrStellarSignatureNotVerified | ||
} | ||
|
||
return nil | ||
} | ||
|
||
func ExtractTimestampedSignature(signatureHeaderContent string) (t string, s string, err error) { | ||
parts := strings.SplitN(signatureHeaderContent, ",", 2) | ||
if len(parts) != 2 { | ||
return "", "", fmt.Errorf("malformed header: %s", signatureHeaderContent) | ||
} | ||
|
||
tHeaderContent := parts[0] | ||
timestampParts := strings.SplitN(tHeaderContent, "=", 2) | ||
if len(timestampParts) != 2 || strings.TrimSpace(timestampParts[0]) != "t" { | ||
return "", "", &ErrInvalidTimestampFormat{TimestampString: tHeaderContent} | ||
} | ||
t = strings.TrimSpace(timestampParts[1]) | ||
|
||
sHeaderContent := parts[1] | ||
signatureParts := strings.SplitN(sHeaderContent, "=", 2) | ||
if len(signatureParts) != 2 || strings.TrimSpace(signatureParts[0]) != "s" { | ||
return "", "", fmt.Errorf("malformed signature: %s", signatureParts) | ||
} | ||
s = strings.TrimSpace(signatureParts[1]) | ||
|
||
return t, s, nil | ||
} | ||
|
||
func VerifyGracePeriodSeconds(timestampString string, gracePeriod time.Duration) error { | ||
// Note: from Nov 20th, 2286 this RegEx will fail because of an extra digit | ||
if ok, _ := regexp.MatchString(`^\d{10}$`, timestampString); !ok { | ||
return &ErrInvalidTimestampFormat{TimestampString: timestampString, timestampValueError: true} | ||
} | ||
|
||
timestampUnix, err := strconv.ParseInt(timestampString, 10, 64) | ||
if err != nil { | ||
return fmt.Errorf("unable to parse timestamp value %s: %v", timestampString, err) | ||
} | ||
|
||
return verifyGracePeriod(time.Unix(timestampUnix, 0), gracePeriod) | ||
} | ||
|
||
func verifyGracePeriod(timestamp time.Time, gracePeriod time.Duration) error { | ||
now := time.Now() | ||
if !timestamp.Add(gracePeriod).After(now) { | ||
return &ErrExpiredSignatureTimestamp{ExpiredSignatureTimestamp: timestamp, CheckTime: now} | ||
} | ||
|
||
return nil | ||
} | ||
|
||
func NewStellarSignatureVerifier(serverHostName, walletSigningKey string) (*StellarSignatureVerifier, error) { | ||
if !strkey.IsValidEd25519PublicKey(walletSigningKey) { | ||
return nil, fmt.Errorf("invalid wallet signing key") | ||
} | ||
|
||
u, err := url.ParseRequestURI(serverHostName) | ||
if err != nil { | ||
return nil, fmt.Errorf("invalid server hostname: %w", err) | ||
} | ||
|
||
return &StellarSignatureVerifier{ | ||
ServerHostname: u.Hostname(), | ||
WalletSigningKey: walletSigningKey, | ||
}, nil | ||
} |
Oops, something went wrong.