diff --git a/go.mod b/go.mod index e017bd3d..321e5a11 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/lib/pq v1.12.3 github.com/prometheus/client_golang v1.20.5 github.com/shopspring/decimal v1.4.0 + github.com/spruceid/siwe-go v0.2.1 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.40.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 @@ -45,8 +46,9 @@ require ( github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dchest/uniuri v1.2.0 // indirect github.com/deckarep/golang-set/v2 v2.6.0 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.5.1+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect @@ -87,6 +89,7 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect + github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect diff --git a/go.sum b/go.sum index 4a964cfe..27ebd01a 100644 --- a/go.sum +++ b/go.sum @@ -64,12 +64,14 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= +github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= +github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc= github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0= github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -267,6 +269,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433 h1:mLbKGKe5gDGHE8uJLYMmA/fkp/htaXEMl2Hj0k4xfYE= +github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -284,6 +288,8 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spruceid/siwe-go v0.2.1 h1:BroySys6CyUzeyNppTseEOT/w56xTdOfcmECTI7rnuc= +github.com/spruceid/siwe-go v0.2.1/go.mod h1:MHpHbptGsM3lHth2L8quhZ9ipiwST8zsJH1CjWpeO1k= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= diff --git a/pkg/auth/jwt.go b/pkg/auth/jwt.go deleted file mode 100644 index 7fea447a..00000000 --- a/pkg/auth/jwt.go +++ /dev/null @@ -1,199 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package auth - -import ( - "context" - "crypto/rsa" - "encoding/base64" - "encoding/json" - "fmt" - "math/big" - "net/http" - "sync" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -// JWTValidator validates JWT tokens using JWKS -type JWTValidator struct { - jwksURL string - issuer string - keys map[string]interface{} - keysMu sync.RWMutex - client *http.Client -} - -// JWKS represents a JSON Web Key Set -type JWKS struct { - Keys []JWK `json:"keys"` -} - -// JWK represents a JSON Web Key -type JWK struct { - Kid string `json:"kid"` - Kty string `json:"kty"` - Alg string `json:"alg"` - Use string `json:"use"` - N string `json:"n"` - E string `json:"e"` -} - -// NewJWTValidator creates a new JWT validator -func NewJWTValidator(jwksURL, issuer string) *JWTValidator { - return &JWTValidator{ - jwksURL: jwksURL, - issuer: issuer, - keys: make(map[string]interface{}), - client: &http.Client{ - Timeout: 10 * time.Second, - }, - } -} - -// ValidateToken validates a JWT token and returns the claims -func (v *JWTValidator) ValidateToken(tokenString string) (jwt.MapClaims, error) { - // Parse the token - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - // Validate the algorithm - if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - - // Get the key ID - kid, ok := token.Header["kid"].(string) - if !ok { - return nil, fmt.Errorf("missing kid in token header") - } - - // Get the key - key, err := v.getKey(kid) - if err != nil { - return nil, err - } - return key, nil - }) - - if err != nil { - return nil, fmt.Errorf("failed to parse token: %w", err) - } - - if !token.Valid { - return nil, fmt.Errorf("invalid token") - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return nil, fmt.Errorf("invalid claims type") - } - - // Validate issuer if configured - if v.issuer != "" { - iss, ok := claims["iss"].(string) - if !ok || iss != v.issuer { - return nil, fmt.Errorf("invalid issuer") - } - } - - return claims, nil -} - -// getKey retrieves a key by ID, refreshing from JWKS if needed -func (v *JWTValidator) getKey(kid string) (interface{}, error) { - v.keysMu.RLock() - key, exists := v.keys[kid] - v.keysMu.RUnlock() - - if exists { - return key, nil - } - - // Refresh keys from JWKS - if err := v.refreshKeys(); err != nil { - return nil, err - } - - v.keysMu.RLock() - key, exists = v.keys[kid] - v.keysMu.RUnlock() - - if !exists { - return nil, fmt.Errorf("key not found: %s", kid) - } - - return key, nil -} - -// refreshKeys fetches and parses the JWKS -func (v *JWTValidator) refreshKeys() error { - if v.jwksURL == "" { - return fmt.Errorf("JWKS URL not configured") - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.jwksURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - resp, err := v.client.Do(req) - if err != nil { - return fmt.Errorf("failed to fetch JWKS: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("JWKS endpoint returned status %d", resp.StatusCode) - } - - var jwks JWKS - if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { - return fmt.Errorf("failed to decode JWKS: %w", err) - } - - v.keysMu.Lock() - defer v.keysMu.Unlock() - - for _, key := range jwks.Keys { - if key.Kty == "RSA" { - pubKey, err := parseRSAPublicKey(key.N, key.E) - if err != nil { - continue // Skip invalid keys - } - v.keys[key.Kid] = pubKey - } - } - - return nil -} - -// parseRSAPublicKey parses RSA public key components from base64url-encoded strings -func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) { - // Decode base64url-encoded modulus (n) - nBytes, err := base64.RawURLEncoding.DecodeString(nStr) - if err != nil { - return nil, fmt.Errorf("failed to decode modulus: %w", err) - } - - // Decode base64url-encoded exponent (e) - eBytes, err := base64.RawURLEncoding.DecodeString(eStr) - if err != nil { - return nil, fmt.Errorf("failed to decode exponent: %w", err) - } - - // Convert to big.Int for modulus - n := new(big.Int).SetBytes(nBytes) - - // Convert exponent bytes to int - e := int(new(big.Int).SetBytes(eBytes).Int64()) - - return &rsa.PublicKey{N: n, E: e}, nil -} - -// IsConfigured returns true if JWKS validation is configured -func (v *JWTValidator) IsConfigured() bool { - return v.jwksURL != "" -} diff --git a/pkg/auth/jwt/helpers_test.go b/pkg/auth/jwt/helpers_test.go new file mode 100644 index 00000000..5ed6c937 --- /dev/null +++ b/pkg/auth/jwt/helpers_test.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "encoding/hex" + "fmt" + "maps" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + gojwt "github.com/golang-jwt/jwt/v5" + siwe "github.com/spruceid/siwe-go" +) + +const ( + testDomain = "localhost" + testURI = "http://localhost" + testChainID = 31337 + testIssuer = "canton-middleware" + testAud = "canton-middleware-api" +) + +func newTestKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate RSA key: %v", err) + } + return key +} + +// signSIWE builds an EIP-4361 message for the given nonce (with optional overrides +// such as expirationTime), signs it with a freshly generated EOA key, and returns +// the raw message, its 0x-prefixed signature, and the signer's checksummed address. +func signSIWE(t *testing.T, nonce string, opts map[string]any) (raw, signature string, addr common.Address) { + t.Helper() + + priv, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate EOA key: %v", err) + } + addr = crypto.PubkeyToAddress(priv.PublicKey) + + options := map[string]any{ + "chainId": testChainID, + "issuedAt": time.Now().UTC().Format(time.RFC3339), + } + maps.Copy(options, opts) + + msg, err := siwe.InitMessage(testDomain, addr.Hex(), testURI, nonce, options) + if err != nil { + t.Fatalf("init SIWE message: %v", err) + } + return msg.String(), signPersonal(t, priv, msg.String()), addr +} + +// signPersonal produces an EIP-191 personal_sign signature over message. +func signPersonal(t *testing.T, priv *ecdsa.PrivateKey, message string) string { + t.Helper() + prefixed := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message) + hash := crypto.Keccak256Hash([]byte(prefixed)) + sig, err := crypto.Sign(hash.Bytes(), priv) + if err != nil { + t.Fatalf("sign: %v", err) + } + return "0x" + hex.EncodeToString(sig) +} + +// mintRS256 signs claims as an RS256 JWT with the given key and kid header. It lets +// validator tests craft tokens with arbitrary claims without going through Issuer. +func mintRS256(t *testing.T, key *rsa.PrivateKey, kid string, claims gojwt.MapClaims) string { + t.Helper() + token := gojwt.NewWithClaims(gojwt.SigningMethodRS256, claims) + token.Header["kid"] = kid + signed, err := token.SignedString(key) + if err != nil { + t.Fatalf("sign RS256 token: %v", err) + } + return signed +} diff --git a/pkg/auth/jwt/issuer.go b/pkg/auth/jwt/issuer.go new file mode 100644 index 00000000..52b794f1 --- /dev/null +++ b/pkg/auth/jwt/issuer.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package jwt provides the authentication primitives behind the api-server's +// Sign-In-with-Ethereum (EIP-4361) login flow and the JWT sessions it issues. + +package jwt + +import ( + "crypto/rsa" + "fmt" + "time" + + gojwt "github.com/golang-jwt/jwt/v5" + siwe "github.com/spruceid/siwe-go" +) + +// Custom JWT claims carried alongside the registered claims. +const ( + // EVMAddressClaim carries the authenticated EVM address. + EVMAddressClaim = "evm_address" + // CantonPartyClaim carries the user's Canton party id. It mirrors the standard + // "sub" claim but is named explicitly so external verifiers (e.g. the indexer) + // can read the party without relying on "sub" semantics. + CantonPartyClaim = "canton_party_id" +) + +// Claims are the claims minted at login. The Canton party id is both the subject +// and an explicit canton_party_id claim; the EVM address rides alongside so handlers +// can resolve either identity from the token. +type Claims struct { + EVMAddress string `json:"evm_address"` + CantonPartyID string `json:"canton_party_id"` + gojwt.RegisteredClaims +} + +// Issuer mints RS256 JWTs for authenticated users. +type Issuer struct { + key *rsa.PrivateKey + kid string + issuer string + audience string + ttl time.Duration + now func() time.Time +} + +// NewIssuer creates an Issuer that signs with key, advertising it under kid. +func NewIssuer(key *rsa.PrivateKey, kid, issuer, audience string, ttl time.Duration) *Issuer { + return &Issuer{ + key: key, + kid: kid, + issuer: issuer, + audience: audience, + ttl: ttl, + now: time.Now, + } +} + +// Issue mints a signed token for the (evmAddress, cantonPartyID) identity and +// returns it alongside its expiry time. +func (i *Issuer) Issue(evmAddress, cantonPartyID string) (string, time.Time, error) { + now := i.now() + exp := now.Add(i.ttl) + + claims := Claims{ + EVMAddress: evmAddress, + CantonPartyID: cantonPartyID, + RegisteredClaims: gojwt.RegisteredClaims{ + Subject: cantonPartyID, + Issuer: i.issuer, + Audience: gojwt.ClaimStrings{i.audience}, + IssuedAt: gojwt.NewNumericDate(now), + ExpiresAt: gojwt.NewNumericDate(exp), + ID: siwe.GenerateNonce(), + }, + } + + token := gojwt.NewWithClaims(gojwt.SigningMethodRS256, claims) + token.Header["kid"] = i.kid + + signed, err := token.SignedString(i.key) + if err != nil { + return "", time.Time{}, fmt.Errorf("sign token: %w", err) + } + return signed, exp, nil +} + +// PublicKey returns the public half of the signing key, for JWKS publication and +// in-process validation. +func (i *Issuer) PublicKey() *rsa.PublicKey { return &i.key.PublicKey } + +// KeyID returns the kid advertised for the signing key. +func (i *Issuer) KeyID() string { return i.kid } + +// JWKS returns the public signing key as a JSON Web Key Set for publication. +func (i *Issuer) JWKS() JWKS { return marshalJWKS(i.kid, &i.key.PublicKey) } diff --git a/pkg/auth/jwt/issuer_test.go b/pkg/auth/jwt/issuer_test.go new file mode 100644 index 00000000..7f8769a7 --- /dev/null +++ b/pkg/auth/jwt/issuer_test.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import ( + "testing" + "time" + + gojwt "github.com/golang-jwt/jwt/v5" +) + +func TestIssuer_IssueSetsClaims(t *testing.T) { + key := newTestKey(t) + issuer := NewIssuer(key, "kid-1", testIssuer, testAud, time.Hour) + + before := time.Now() + token, exp, err := issuer.Issue("0xABC", "party::xyz") + if err != nil { + t.Fatalf("issue: %v", err) + } + if exp.Before(before.Add(time.Hour - time.Minute)) { + t.Fatalf("expiry %v is not ~1h out", exp) + } + + // Parse independently with the issuer's public key (no Validator involved) to + // assert the token's structure and claims. + parsed, err := gojwt.Parse(token, func(*gojwt.Token) (any, error) { return &key.PublicKey, nil }) + if err != nil || !parsed.Valid { + t.Fatalf("parse issued token: valid=%v err=%v", parsed.Valid, err) + } + if kid, _ := parsed.Header["kid"].(string); kid != "kid-1" { + t.Fatalf("kid header = %q, want kid-1", kid) + } + + claims := parsed.Claims.(gojwt.MapClaims) + if claims["sub"] != "party::xyz" { + t.Fatalf("sub = %v, want party::xyz", claims["sub"]) + } + if claims[CantonPartyClaim] != "party::xyz" { + t.Fatalf("%s = %v, want party::xyz", CantonPartyClaim, claims[CantonPartyClaim]) + } + if claims[EVMAddressClaim] != "0xABC" { + t.Fatalf("%s = %v, want 0xABC", EVMAddressClaim, claims[EVMAddressClaim]) + } + if claims["iss"] != testIssuer { + t.Fatalf("iss = %v, want %s", claims["iss"], testIssuer) + } + // aud is serialized as a JSON array by jwt.ClaimStrings and parses back as such. + aud, ok := claims["aud"].([]any) + if !ok || len(aud) != 1 || aud[0] != testAud { + t.Fatalf("aud = %v, want [%s]", claims["aud"], testAud) + } + if _, ok := claims["jti"]; !ok { + t.Fatal("expected a jti claim") + } +} + +func TestIssuer_JWKSDescribesSigningKey(t *testing.T) { + key := newTestKey(t) + issuer := NewIssuer(key, "kid-1", testIssuer, testAud, time.Hour) + + set := issuer.JWKS() + if len(set.Keys) != 1 { + t.Fatalf("JWKS has %d keys, want 1", len(set.Keys)) + } + k := set.Keys[0] + if k.Kid != "kid-1" || k.Kty != "RSA" || k.Alg != "RS256" || k.Use != "sig" { + t.Fatalf("unexpected JWK metadata: %+v", k) + } + if k.N == "" || k.E == "" { + t.Fatal("JWK modulus/exponent must be populated") + } + + // The published key must reconstruct to the issuer's actual public key. + pub, err := parseRSAPublicKey(k.N, k.E) + if err != nil { + t.Fatalf("parse published key: %v", err) + } + if pub.N.Cmp(key.PublicKey.N) != 0 || pub.E != key.PublicKey.E { + t.Fatal("published JWKS key does not match the signing key") + } +} diff --git a/pkg/auth/jwt/jwks.go b/pkg/auth/jwt/jwks.go new file mode 100644 index 00000000..08293c13 --- /dev/null +++ b/pkg/auth/jwt/jwks.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import ( + "crypto/rsa" + "encoding/base64" + "fmt" + "math/big" +) + +// JWK is a single JSON Web Key (RSA public key) per RFC 7517. +type JWK struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Alg string `json:"alg"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` +} + +// JWKS is a JSON Web Key Set, the document served at /.well-known/jwks.json. +type JWKS struct { + Keys []JWK `json:"keys"` +} + +// marshalJWKS renders an RSA public key as a single-key JWKS document. It is the +// inverse of parseRSAPublicKey: modulus and exponent are base64url-encoded (no +// padding) per RFC 7518. +func marshalJWKS(kid string, pub *rsa.PublicKey) JWKS { + eBytes := big.NewInt(int64(pub.E)).Bytes() + return JWKS{Keys: []JWK{{ + Kty: "RSA", + Use: "sig", + Alg: "RS256", + Kid: kid, + N: base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(eBytes), + }}} +} + +// parseRSAPublicKey reconstructs an RSA public key from base64url-encoded modulus +// and exponent (the "n" and "e" members of a JWK). +func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(nStr) + if err != nil { + return nil, fmt.Errorf("decode modulus: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(eStr) + if err != nil { + return nil, fmt.Errorf("decode exponent: %w", err) + } + + return &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: int(new(big.Int).SetBytes(eBytes).Int64()), + }, nil +} diff --git a/pkg/auth/jwt/jwks_test.go b/pkg/auth/jwt/jwks_test.go new file mode 100644 index 00000000..9a482abf --- /dev/null +++ b/pkg/auth/jwt/jwks_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import "testing" + +func TestMarshalJWKS_RoundTrip(t *testing.T) { + key := newTestKey(t) + + set := marshalJWKS("kid-1", &key.PublicKey) + if len(set.Keys) != 1 { + t.Fatalf("got %d keys, want 1", len(set.Keys)) + } + jwk := set.Keys[0] + if jwk.Kid != "kid-1" || jwk.Kty != "RSA" || jwk.Alg != "RS256" || jwk.Use != "sig" { + t.Fatalf("unexpected JWK metadata: %+v", jwk) + } + + pub, err := parseRSAPublicKey(jwk.N, jwk.E) + if err != nil { + t.Fatalf("parseRSAPublicKey: %v", err) + } + if pub.N.Cmp(key.PublicKey.N) != 0 { + t.Fatal("modulus did not round-trip") + } + if pub.E != key.PublicKey.E { + t.Fatalf("exponent = %d, want %d", pub.E, key.PublicKey.E) + } +} + +func TestParseRSAPublicKey_RejectsBadBase64(t *testing.T) { + if _, err := parseRSAPublicKey("!!!not-base64!!!", "AQAB"); err == nil { + t.Fatal("expected error for invalid modulus encoding") + } +} diff --git a/pkg/auth/jwt/keys.go b/pkg/auth/jwt/keys.go new file mode 100644 index 00000000..74b36204 --- /dev/null +++ b/pkg/auth/jwt/keys.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" +) + +// ParseRSAPrivateKey decodes a PEM-encoded RSA private key in either PKCS#1 +// ("RSA PRIVATE KEY") or PKCS#8 ("PRIVATE KEY") form. +func ParseRSAPrivateKey(pemString string) (*rsa.PrivateKey, error) { + pemBytes, err := base64.StdEncoding.DecodeString(pemString) + if err != nil { + return nil, fmt.Errorf("JWT signing key is not valid base64: %w", err) + } + + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("no PEM block found in key") + } + + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse RSA private key: %w", err) + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("key is %T, want RSA", parsed) + } + return key, nil +} diff --git a/pkg/auth/jwt/keys_test.go b/pkg/auth/jwt/keys_test.go new file mode 100644 index 00000000..402ec30f --- /dev/null +++ b/pkg/auth/jwt/keys_test.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "testing" +) + +// b64PEM renders a PEM block and base64-encodes it, matching how the signing key +// is supplied to ParseRSAPrivateKey (base64 so the multi-line PEM survives env +// substitution into a single YAML scalar). +func b64PEM(block *pem.Block) string { + return base64.StdEncoding.EncodeToString(pem.EncodeToMemory(block)) +} + +func TestParseRSAPrivateKey_PKCS1(t *testing.T) { + key := newTestKey(t) + block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} + + got, err := ParseRSAPrivateKey(b64PEM(block)) + if err != nil { + t.Fatalf("parse PKCS#1: %v", err) + } + if got.N.Cmp(key.N) != 0 { + t.Fatal("parsed key does not match original") + } +} + +func TestParseRSAPrivateKey_PKCS8(t *testing.T) { + key := newTestKey(t) + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatalf("marshal PKCS#8: %v", err) + } + block := &pem.Block{Type: "PRIVATE KEY", Bytes: der} + + got, err := ParseRSAPrivateKey(b64PEM(block)) + if err != nil { + t.Fatalf("parse PKCS#8: %v", err) + } + if got.N.Cmp(key.N) != 0 { + t.Fatal("parsed key does not match original") + } +} + +func TestParseRSAPrivateKey_NotBase64(t *testing.T) { + if _, err := ParseRSAPrivateKey("!!! not base64 !!!"); err == nil { + t.Fatal("expected error for non-base64 input") + } +} + +func TestParseRSAPrivateKey_NoPEM(t *testing.T) { + if _, err := ParseRSAPrivateKey(base64.StdEncoding.EncodeToString([]byte("not a pem block"))); err == nil { + t.Fatal("expected error for input without a PEM block") + } +} + +func TestParseRSAPrivateKey_NonRSA(t *testing.T) { + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate EC key: %v", err) + } + der, err := x509.MarshalPKCS8PrivateKey(ecKey) + if err != nil { + t.Fatalf("marshal EC PKCS#8: %v", err) + } + block := &pem.Block{Type: "PRIVATE KEY", Bytes: der} + + if _, err := ParseRSAPrivateKey(b64PEM(block)); err == nil { + t.Fatal("expected error for a non-RSA key") + } +} diff --git a/pkg/auth/jwt/middleware.go b/pkg/auth/jwt/middleware.go new file mode 100644 index 00000000..a1c07a3d --- /dev/null +++ b/pkg/auth/jwt/middleware.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import ( + "net/http" + "slices" + "strings" + + gojwt "github.com/golang-jwt/jwt/v5" + + apperrors "github.com/chainsafe/canton-middleware/pkg/app/errors" + apphttp "github.com/chainsafe/canton-middleware/pkg/app/http" + "github.com/chainsafe/canton-middleware/pkg/auth" +) + +// TokenValidator verifies a bearer token and returns its claims. Satisfied by +// Validator. +type TokenValidator interface { + ValidateToken(token string) (gojwt.MapClaims, error) +} + +// RequireAuth returns middleware that rejects requests without a valid bearer token +// for the expected audience and populates the request context with the +// authenticated identity (EVM address + Canton party) from the token claims. +func RequireAuth(validator TokenValidator, audience string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + info, err := authenticate(validator, audience, r) + if err != nil { + apphttp.DefaultErrorHandler(w, err) + return + } + ctx := auth.WithAuthInfo(r.Context(), info) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func authenticate(validator TokenValidator, audience string, r *http.Request) (*auth.AuthInfo, error) { + const prefix = "Bearer " + header := r.Header.Get("Authorization") + // RFC 7235: the auth-scheme token is case-insensitive, so accept "bearer" too. + if len(header) < len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { + return nil, apperrors.UnAuthorizedError(nil, "bearer token required") + } + + token := strings.TrimSpace(header[len(prefix):]) + claims, err := validator.ValidateToken(token) + if err != nil { + return nil, apperrors.UnAuthorizedError(err, "invalid or expired token") + } + + if !hasAudience(claims, audience) { + return nil, apperrors.UnAuthorizedError(nil, "token audience mismatch") + } + + evmAddress, _ := claims[EVMAddressClaim].(string) + party, _ := claims["sub"].(string) + if evmAddress == "" || party == "" { + return nil, apperrors.UnAuthorizedError(nil, "token missing identity claims") + } + + return &auth.AuthInfo{EVMAddress: evmAddress, CantonParty: party}, nil +} + +// hasAudience reports whether the token's aud claim contains want. The claim may be +// a single string or an array of strings per RFC 7519. +func hasAudience(claims gojwt.MapClaims, want string) bool { + switch aud := claims["aud"].(type) { + case string: + return aud == want + case []any: + for _, a := range aud { + if s, ok := a.(string); ok && s == want { + return true + } + } + case []string: + return slices.Contains(aud, want) + } + return false +} diff --git a/pkg/auth/jwt/middleware_test.go b/pkg/auth/jwt/middleware_test.go new file mode 100644 index 00000000..d55640af --- /dev/null +++ b/pkg/auth/jwt/middleware_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/chainsafe/canton-middleware/pkg/auth" +) + +func TestRequireAuth_RejectsMissingAndAcceptsValid(t *testing.T) { + issuer := NewIssuer(newTestKey(t), "kid-1", testIssuer, testAud, time.Hour) + validator := NewValidatorWithKey(issuer.KeyID(), issuer.PublicKey(), testIssuer) + + var seenAddr string + protected := RequireAuth(validator, testAud)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenAddr, _ = auth.EVMAddressFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + // No token -> 401. + rec := httptest.NewRecorder() + protected.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("missing token status = %d, want 401", rec.Code) + } + + // Valid token -> 200, identity populated. + token, _, _ := issuer.Issue("0xdead", "party::dead") + rec = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + protected.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("valid token status = %d, want 200", rec.Code) + } + if seenAddr != "0xdead" { + t.Fatalf("context evm address = %q, want 0xdead", seenAddr) + } +} + +func TestRequireAuth_WrongAudienceRejected(t *testing.T) { + issuer := NewIssuer(newTestKey(t), "kid-1", testIssuer, "some-other-aud", time.Hour) + validator := NewValidatorWithKey(issuer.KeyID(), issuer.PublicKey(), testIssuer) + + protected := RequireAuth(validator, testAud)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + token, _, _ := issuer.Issue("0xdead", "party::dead") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+token) + protected.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("audience mismatch status = %d, want 401", rec.Code) + } +} diff --git a/pkg/auth/jwt/validator.go b/pkg/auth/jwt/validator.go new file mode 100644 index 00000000..64f76de9 --- /dev/null +++ b/pkg/auth/jwt/validator.go @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import ( + "context" + "crypto/rsa" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + gojwt "github.com/golang-jwt/jwt/v5" +) + +const jwksFetchTimeout = 10 * time.Second + +// Validator verifies RS256 JWTs. Keys come either from a single in-process public +// key (NewValidatorWithKey) or from a remote JWKS endpoint fetched on demand and +// cached by key id (NewValidator). +type Validator struct { + jwksURL string + issuer string + keys map[string]any + keysMu sync.RWMutex + refreshMu sync.Mutex // serializes JWKS refreshes so a burst of misses triggers one fetch + client *http.Client +} + +// NewValidator creates a Validator that fetches signing keys from a JWKS endpoint. +// If the issuer is non-empty, tokens must carry a matching "iss" claim. +func NewValidator(jwksURL, issuer string) *Validator { + return &Validator{ + jwksURL: jwksURL, + issuer: issuer, + keys: make(map[string]any), + client: &http.Client{Timeout: jwksFetchTimeout}, + } +} + +// NewValidatorWithKey creates a Validator that trusts a single in-process RSA +// public key, keyed by kid, and performs no network fetch. The token issuer's own +// process uses this to validate the tokens it mints; external services validate the +// same tokens via the published JWKS URL using NewValidator. +func NewValidatorWithKey(kid string, pub *rsa.PublicKey, issuer string) *Validator { + return &Validator{ + issuer: issuer, + keys: map[string]any{kid: pub}, + } +} + +// ValidateToken parses and verifies a token, returning its claims. It enforces the +// RS256 signing method, a known key id, standard time claims, and (when configured) +// the expected issuer. +func (v *Validator) ValidateToken(tokenString string) (gojwt.MapClaims, error) { + token, err := gojwt.Parse(tokenString, func(token *gojwt.Token) (any, error) { + if _, ok := token.Method.(*gojwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, fmt.Errorf("missing kid in token header") + } + return v.getKey(kid) + }) + if err != nil { + return nil, fmt.Errorf("parse token: %w", err) + } + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + claims, ok := token.Claims.(gojwt.MapClaims) + if !ok { + return nil, fmt.Errorf("invalid claims type") + } + + if v.issuer != "" { + iss, ok := claims["iss"].(string) + if !ok || iss != v.issuer { + return nil, fmt.Errorf("invalid issuer") + } + } + + return claims, nil +} + +// IsConfigured reports whether the validator can obtain keys (in-process or JWKS). +func (v *Validator) IsConfigured() bool { + v.keysMu.RLock() + defer v.keysMu.RUnlock() + return v.jwksURL != "" || len(v.keys) > 0 +} + +// getKey returns the cached key for kid, refreshing from JWKS once on a miss. +// refreshMu serializes the refresh so concurrent misses trigger a single fetch; +// after acquiring it we re-check the cache in case another goroutine just filled it. +func (v *Validator) getKey(kid string) (any, error) { + v.keysMu.RLock() + key, exists := v.keys[kid] + v.keysMu.RUnlock() + if exists { + return key, nil + } + + v.refreshMu.Lock() + defer v.refreshMu.Unlock() + + v.keysMu.RLock() + key, exists = v.keys[kid] + v.keysMu.RUnlock() + if exists { + return key, nil + } + + if err := v.refreshKeys(); err != nil { + return nil, err + } + + v.keysMu.RLock() + key, exists = v.keys[kid] + v.keysMu.RUnlock() + if !exists { + return nil, fmt.Errorf("key not found: %s", kid) + } + return key, nil +} + +// refreshKeys fetches and caches the RSA keys from the JWKS endpoint. +func (v *Validator) refreshKeys() error { + if v.jwksURL == "" { + return fmt.Errorf("JWKS URL not configured") + } + + ctx, cancel := context.WithTimeout(context.Background(), jwksFetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.jwksURL, nil) + if err != nil { + return fmt.Errorf("create JWKS request: %w", err) + } + + resp, err := v.client.Do(req) + if err != nil { + return fmt.Errorf("fetch JWKS: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("JWKS endpoint returned status %d", resp.StatusCode) + } + + var set JWKS + if err := json.NewDecoder(resp.Body).Decode(&set); err != nil { + return fmt.Errorf("decode JWKS: %w", err) + } + + // Replace the cache wholesale (rather than merging) so keys that were rotated + // out of the JWKS stop being trusted instead of lingering forever. + newKeys := make(map[string]any, len(set.Keys)) + for _, key := range set.Keys { + if key.Kty != "RSA" { + continue + } + pubKey, err := parseRSAPublicKey(key.N, key.E) + if err != nil { + continue // skip invalid keys + } + newKeys[key.Kid] = pubKey + } + + v.keysMu.Lock() + v.keys = newKeys + v.keysMu.Unlock() + return nil +} diff --git a/pkg/auth/jwt/validator_test.go b/pkg/auth/jwt/validator_test.go new file mode 100644 index 00000000..6850f83e --- /dev/null +++ b/pkg/auth/jwt/validator_test.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + gojwt "github.com/golang-jwt/jwt/v5" +) + +func validClaims() gojwt.MapClaims { + return gojwt.MapClaims{ + "iss": testIssuer, + "sub": "party::xyz", + "exp": time.Now().Add(time.Hour).Unix(), + } +} + +func TestValidator_InProcessKey_Accepts(t *testing.T) { + key := newTestKey(t) + token := mintRS256(t, key, "kid-1", validClaims()) + + claims, err := NewValidatorWithKey("kid-1", &key.PublicKey, testIssuer).ValidateToken(token) + if err != nil { + t.Fatalf("validate: %v", err) + } + if claims["sub"] != "party::xyz" { + t.Fatalf("sub = %v, want party::xyz", claims["sub"]) + } +} + +func TestValidator_RejectsWrongAlgorithm(t *testing.T) { + key := newTestKey(t) + // HS256 token — the validator must reject non-RSA methods (alg-confusion guard). + hs := gojwt.NewWithClaims(gojwt.SigningMethodHS256, validClaims()) + hs.Header["kid"] = "kid-1" + signed, err := hs.SignedString([]byte("secret")) + if err != nil { + t.Fatalf("sign HS256: %v", err) + } + + if _, err := NewValidatorWithKey("kid-1", &key.PublicKey, testIssuer).ValidateToken(signed); err == nil { + t.Fatal("HS256 token must be rejected") + } +} + +func TestValidator_RejectsUnknownKid(t *testing.T) { + key := newTestKey(t) + token := mintRS256(t, key, "other-kid", validClaims()) + + if _, err := NewValidatorWithKey("kid-1", &key.PublicKey, testIssuer).ValidateToken(token); err == nil { + t.Fatal("token with unknown kid must be rejected") + } +} + +func TestValidator_RejectsWrongIssuer(t *testing.T) { + key := newTestKey(t) + claims := validClaims() + claims["iss"] = "someone-else" + token := mintRS256(t, key, "kid-1", claims) + + if _, err := NewValidatorWithKey("kid-1", &key.PublicKey, testIssuer).ValidateToken(token); err == nil { + t.Fatal("token with wrong issuer must be rejected") + } +} + +func TestValidator_RejectsExpiredToken(t *testing.T) { + key := newTestKey(t) + claims := validClaims() + claims["exp"] = time.Now().Add(-time.Hour).Unix() + token := mintRS256(t, key, "kid-1", claims) + + if _, err := NewValidatorWithKey("kid-1", &key.PublicKey, testIssuer).ValidateToken(token); err == nil { + t.Fatal("expired token must be rejected") + } +} + +func TestValidator_FetchesKeysFromJWKS(t *testing.T) { + key := newTestKey(t) + set := marshalJWKS("kid-remote", &key.PublicKey) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(set) + })) + defer srv.Close() + + token := mintRS256(t, key, "kid-remote", validClaims()) + + claims, err := NewValidator(srv.URL, testIssuer).ValidateToken(token) + if err != nil { + t.Fatalf("validate via JWKS: %v", err) + } + if claims["sub"] != "party::xyz" { + t.Fatalf("sub = %v, want party::xyz", claims["sub"]) + } +} + +func TestValidator_IsConfigured(t *testing.T) { + key := newTestKey(t) + if !NewValidatorWithKey("kid-1", &key.PublicKey, testIssuer).IsConfigured() { + t.Fatal("in-process validator should report configured") + } + if !NewValidator("http://jwks.example/keys", testIssuer).IsConfigured() { + t.Fatal("JWKS validator should report configured") + } + if (&Validator{}).IsConfigured() { + t.Fatal("empty validator should report not configured") + } +} diff --git a/pkg/auth/jwt/verifier.go b/pkg/auth/jwt/verifier.go new file mode 100644 index 00000000..169b063c --- /dev/null +++ b/pkg/auth/jwt/verifier.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import ( + "fmt" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + siwe "github.com/spruceid/siwe-go" +) + +// eip191SigLen is the byte length of a secp256k1 recoverable signature (r||s||v). +const eip191SigLen = 65 + +// SIWEVerifier validates EIP-4361 login messages against the server's expected +// domain, uri, and chain id, and recovers the signer. It does not track nonces: it +// returns the message's nonce so the caller can enforce single use against its own +// store, keeping this a stateless primitive. +type SIWEVerifier struct { + domain string + uri string + chainID int + now func() time.Time +} + +// NewSIWEVerifier binds a verifier to the expected domain/uri/chain. +func NewSIWEVerifier(domain, uri string, chainID int) *SIWEVerifier { + return &SIWEVerifier{ + domain: domain, + uri: uri, + chainID: chainID, + now: time.Now, + } +} + +// Verify parses and validates a SIWE message and its signature. On success it +// returns the recovered checksummed EVM address and the message's nonce; the caller +// is responsible for enforcing nonce single use. It performs no state mutation, so a +// failed verification leaves any nonce untouched. +func (v *SIWEVerifier) Verify(rawMessage, signature string) (addr common.Address, nonce string, err error) { + msg, err := siwe.ParseMessage(rawMessage) + if err != nil { + return common.Address{}, "", fmt.Errorf("parse SIWE message: %w", err) + } + + if got := msg.GetChainID(); got != v.chainID { + return common.Address{}, "", fmt.Errorf("unexpected chain id: got %d, want %d", got, v.chainID) + } + if got := msg.GetURI(); got.String() != v.uri { + return common.Address{}, "", fmt.Errorf("unexpected uri: got %q, want %q", got.String(), v.uri) + } + + // Guard the signature length before handing it to siwe-go: its VerifyEIP191 + // indexes sigBytes[64] with no bounds check and panics on a signature shorter + // than 65 bytes. Reject malformed signatures as an auth failure instead. + sigBytes, decErr := hexutil.Decode(signature) + if decErr != nil || len(sigBytes) != eip191SigLen { + return common.Address{}, "", fmt.Errorf("signature must be a 0x-prefixed 65-byte hex string") + } + + // Verify signature, domain, and time window (expiration / not-before). Nonce + // uniqueness is enforced by the caller against its store, not here. + now := v.now() + if _, err := msg.Verify(signature, &v.domain, nil, &now); err != nil { + return common.Address{}, "", fmt.Errorf("verify SIWE signature: %w", err) + } + + return msg.GetAddress(), msg.GetNonce(), nil +} diff --git a/pkg/auth/jwt/verifier_test.go b/pkg/auth/jwt/verifier_test.go new file mode 100644 index 00000000..ad052ba8 --- /dev/null +++ b/pkg/auth/jwt/verifier_test.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 + +package jwt + +import ( + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" +) + +func TestSIWEVerifier_Success(t *testing.T) { + verifier := NewSIWEVerifier(testDomain, testURI, testChainID) + + raw, sig, addr := signSIWE(t, "testnonce0001", nil) + + gotAddr, gotNonce, err := verifier.Verify(raw, sig) + if err != nil { + t.Fatalf("verify: %v", err) + } + if gotAddr != addr { + t.Fatalf("recovered %s, want %s", gotAddr, addr) + } + if gotNonce != "testnonce0001" { + t.Fatalf("returned nonce %q, want testnonce0001", gotNonce) + } +} + +func TestSIWEVerifier_WrongDomainRejected(t *testing.T) { + verifier := NewSIWEVerifier("evil.example.com", testURI, testChainID) + + raw, sig, _ := signSIWE(t, "testnonce0001", nil) + if _, _, err := verifier.Verify(raw, sig); err == nil { + t.Fatal("domain mismatch must be rejected") + } +} + +func TestSIWEVerifier_WrongChainRejected(t *testing.T) { + verifier := NewSIWEVerifier(testDomain, testURI, 1) // expects mainnet + + raw, sig, _ := signSIWE(t, "testnonce0001", nil) // signs with testChainID + if _, _, err := verifier.Verify(raw, sig); err == nil { + t.Fatal("chain id mismatch must be rejected") + } +} + +func TestSIWEVerifier_WrongURIRejected(t *testing.T) { + verifier := NewSIWEVerifier(testDomain, "http://other.example", testChainID) + + raw, sig, _ := signSIWE(t, "testnonce0001", nil) + if _, _, err := verifier.Verify(raw, sig); err == nil { + t.Fatal("uri mismatch must be rejected") + } +} + +func TestSIWEVerifier_ExpiredMessageRejected(t *testing.T) { + verifier := NewSIWEVerifier(testDomain, testURI, testChainID) + + raw, sig, _ := signSIWE(t, "testnonce0001", map[string]any{ + "expirationTime": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339), + }) + if _, _, err := verifier.Verify(raw, sig); err == nil { + t.Fatal("expired SIWE message must be rejected") + } +} + +func TestSIWEVerifier_TamperedSignatureRejected(t *testing.T) { + verifier := NewSIWEVerifier(testDomain, testURI, testChainID) + + raw, _, _ := signSIWE(t, "testnonce0001", nil) + + // A signature from a different signer over the same message. + other, _ := crypto.GenerateKey() + badSig := signPersonal(t, other, raw) + + if _, _, err := verifier.Verify(raw, badSig); err == nil { + t.Fatal("signature from a non-matching signer must be rejected") + } +} + +func TestSIWEVerifier_MalformedSignatureRejected(t *testing.T) { + verifier := NewSIWEVerifier(testDomain, testURI, testChainID) + + raw, _, _ := signSIWE(t, "testnonce0001", nil) + + // A too-short signature must be rejected as an auth failure, not panic + // (siwe-go's VerifyEIP191 indexes sigBytes[64] with no bounds check). + if _, _, err := verifier.Verify(raw, "0x00"); err == nil { + t.Fatal("malformed (short) signature must be rejected") + } +}