diff --git a/Makefile b/Makefile index 56abbc84..5b1b328c 100644 --- a/Makefile +++ b/Makefile @@ -137,6 +137,11 @@ setup: deps db-up CANTON_MASTER_KEY := $(or $(CANTON_MASTER_KEY),$(shell openssl rand -base64 32)) export CANTON_MASTER_KEY +# JWT signing key for read-endpoint auth: a base64-encoded RSA PEM (single line so +# it survives env substitution into a YAML scalar). Generated per run unless set. +JWT_PRIVATE_KEY := $(or $(JWT_PRIVATE_KEY),$(shell openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null | openssl base64 -A)) +export JWT_PRIVATE_KEY + build-dars: ./scripts/setup/build-dars.sh diff --git a/config.e2e-local.yaml b/config.e2e-local.yaml index bfbd0d93..c6f780b4 100644 --- a/config.e2e-local.yaml +++ b/config.e2e-local.yaml @@ -94,10 +94,6 @@ eth_rpc: chain_id: 31337 request_timeout: "30s" -jwks: - url: "" - issuer: "" - monitoring: enabled: false diff --git a/docker-compose.yaml b/docker-compose.yaml index 3dc5e14d..7d33016b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -171,6 +171,9 @@ services: CANTON_AUTH_CLIENT_ID: "${CANTON_AUTH_CLIENT_ID:-local-test-client}" CANTON_AUTH_CLIENT_SECRET: "${CANTON_AUTH_CLIENT_SECRET:-local-test-secret}" ETHEREUM_RELAYER_PRIVATE_KEY: "${ETHEREUM_RELAYER_PRIVATE_KEY:-ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80}" + # Needed because bootstrap also loads the api-server config, which now + # includes an auth block with private_key: "${JWT_PRIVATE_KEY}". + JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY}" volumes: - ./contracts/ethereum-wayfinder/broadcast:/app/broadcast:ro - config_state:/app/state @@ -298,6 +301,9 @@ services: CANTON_AUTH_CLIENT_ID: "${CANTON_AUTH_CLIENT_ID:-local-test-client}" CANTON_AUTH_CLIENT_SECRET: "${CANTON_AUTH_CLIENT_SECRET:-local-test-secret}" CANTON_MASTER_KEY: "${CANTON_MASTER_KEY}" # Generate with: openssl rand -base64 32 + # Base64-encoded RSA PEM for read-endpoint JWT signing. Generate with: + # openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 | openssl base64 -A + JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY}" SKIP_CANTON_SIG_VERIFY: "${SKIP_CANTON_SIG_VERIFY:-false}" # Set to "true" for local testing # Admin API: enabled on the api-server only. The bootstrap loads the same # config but leaves ADMIN_API_ENABLED unset, so admin stays off there and 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/app/api/server.go b/pkg/app/api/server.go index 0c4d4b90..e37deb0b 100644 --- a/pkg/app/api/server.go +++ b/pkg/app/api/server.go @@ -14,6 +14,9 @@ import ( sharedmetrics "github.com/chainsafe/canton-middleware/internal/metrics" apphttp "github.com/chainsafe/canton-middleware/pkg/app/http" + "github.com/chainsafe/canton-middleware/pkg/auth/jwt" + authservice "github.com/chainsafe/canton-middleware/pkg/auth/service" + nonceprovider "github.com/chainsafe/canton-middleware/pkg/auth/service/nonce_provider" canton "github.com/chainsafe/canton-middleware/pkg/cantonsdk/client" cantontkn "github.com/chainsafe/canton-middleware/pkg/cantonsdk/token" "github.com/chainsafe/canton-middleware/pkg/config" @@ -160,17 +163,16 @@ func (s *Server) Run() error { ) } - // Admin config is optional (nil when the `admin` block is omitted). The token - // is resolved by the config loader (api_key: "${ADMIN_API_KEY}") and validated - // as required when enabled, so setupRouter can read it straight off the value. - var adminCfg config.AdminAPI - if cfg.Admin != nil { - adminCfg = *cfg.Admin + loginSvc, readAuth, err := s.buildReadAuth(userStore, logger) + if err != nil { + stop() + _ = g.Wait() + return err } router := s.setupRouter( svcs.evmStore, wl, cantonClient, svcs.tokenService, svcs.regSvc, svcs.transferSvc, - adminCfg, metrics, logger, + loginSvc, readAuth, metrics, logger, ) s.registerServers(g, gCtx, router, logger) @@ -309,6 +311,49 @@ func initServices( }, nil } +// buildReadAuth constructs the SIWE login handler and the middleware that guards +// the read endpoints. +// passthrough is a no-op middleware used when read authentication is disabled. +func passthrough(next http.Handler) http.Handler { return next } + +// buildReadAuth constructs the SIWE login service and the middleware guarding the +// read endpoints. Auth is optional: when the `auth` config block is absent it +// returns a nil service (login/JWKS routes are not mounted) and a passthrough +// middleware, so the read endpoints fall back to resolving the caller from the +// ?address= query parameter and are not access-controlled. +func (s *Server) buildReadAuth( + userStore authservice.UserLookup, + logger *zap.Logger, +) (authservice.Service, func(http.Handler) http.Handler, error) { + if s.cfg.Auth == nil { + logger.Warn("read-endpoint authentication is DISABLED: no `auth` config block; " + + "transfer read endpoints and /profile resolve the caller from ?address= and are not access-controlled") + return nil, passthrough, nil + } + + cfg := s.cfg.Auth + + key, err := jwt.ParseRSAPrivateKey(cfg.PrivateKey) + if err != nil { + return nil, nil, fmt.Errorf("invalid JWT signing key: %w", err) + } + + nonces := nonceprovider.NewInMemory(cfg.NonceTTL) + issuer := jwt.NewIssuer(key, cfg.KeyID, cfg.Issuer, cfg.Audience, cfg.TokenTTL) + verifier := jwt.NewSIWEVerifier(cfg.Domain, cfg.URI, cfg.ChainID) + loginSvc := authservice.NewLog(authservice.New(verifier, issuer, nonces, userStore), logger) + + validator := jwt.NewValidatorWithKey(issuer.KeyID(), issuer.PublicKey(), cfg.Issuer) + readAuthMW := jwt.RequireAuth(validator, cfg.Audience) + + logger.Info("read-endpoint authentication enabled", + zap.String("issuer", cfg.Issuer), + zap.String("audience", cfg.Audience), + zap.Duration("token_ttl", cfg.TokenTTL), + ) + return loginSvc, readAuthMW, nil +} + func (s *Server) getMasterKey() ([]byte, error) { masterKeyStr := os.Getenv(s.cfg.KeyManagement.MasterKeyEnv) if masterKeyStr == "" { @@ -383,7 +428,8 @@ func (s *Server) setupRouter( tokenService *token.Service, userService userservice.Service, transferSvc transfer.Service, - adminCfg config.AdminAPI, + loginSvc authservice.Service, + readAuth func(http.Handler) http.Handler, metrics *apphttp.HTTPMetrics, logger *zap.Logger, ) chi.Router { @@ -406,16 +452,24 @@ func (s *Server) setupRouter( // Supported tokens metadata token.RegisterRoutes(r, tokenService, logger) - // Registration endpoints - userservice.RegisterRoutes(r, userService, logger) + // SIWE login + JWKS endpoints (only when read auth is configured). + if loginSvc != nil { + authservice.RegisterRoutes(r, loginSvc, logger) + } + + // Registration endpoints (registration is self-authenticating; /profile is + // guarded by readAuth). + userservice.RegisterRoutes(r, userService, readAuth, logger) - // Admin endpoints (whitelist management), gated by a static bearer token. - if adminCfg.Enabled { - whitelist.RegisterAdminRoutes(r, wl, adminCfg.APIKey, logger) + // Admin endpoints (whitelist management), gated by a static bearer token. The + // admin block is optional (nil when omitted); the api_key is resolved and + // validated by the config loader (api_key: "${ADMIN_API_KEY}"). + if s.cfg.Admin != nil && s.cfg.Admin.Enabled { + whitelist.RegisterAdminRoutes(r, wl, s.cfg.Admin.APIKey, logger) } - // Non-custodial transfer endpoints (prepare/execute) - transfer.RegisterRoutes(r, transferSvc, logger) + // Non-custodial transfer endpoints (prepare/execute); read endpoints guarded by readAuth. + transfer.RegisterRoutes(r, transferSvc, readAuth, logger) registryHandler := registry.NewHandler(cantonClient.Token, logger) r.Handle("/registry/transfer-instruction/v1/transfer-factory", registryHandler) diff --git a/pkg/auth/config.go b/pkg/auth/config.go new file mode 100644 index 00000000..4040e7f2 --- /dev/null +++ b/pkg/auth/config.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import "time" + +// Config configures Sign-In with Ethereum (EIP-4361) login and the JWTs it issues +// for read endpoints. It is always required; read endpoints are gated by a bearer +// token. The matching public key is published at /.well-known/jwks.json so other +// services (e.g. the indexer) can validate tokens without a shared secret. +type Config struct { + // PrivateKey is the RSA signing key as a base64-encoded PEM (PKCS#1 or PKCS#8). + // Supply it via env substitution — private_key: "${JWT_PRIVATE_KEY}" — where the + // env holds `base64 < key.pem`. It is base64-encoded so the (multi-line) PEM + // survives being expanded into a single YAML scalar. + PrivateKey string `yaml:"private_key" validate:"required"` + // KeyID is the JWKS "kid" advertised for the signing key. + KeyID string `yaml:"kid" validate:"required" default:"default"` + // Issuer is the JWT "iss" claim (and the value validators check). + Issuer string `yaml:"issuer" validate:"required" default:"canton-middleware"` + // Audience is the JWT "aud" claim. + Audience string `yaml:"audience" validate:"required" default:"canton-middleware-api"` + // TokenTTL is how long an issued JWT stays valid. + TokenTTL time.Duration `yaml:"token_ttl" validate:"required,gt=0" default:"30m"` + // NonceTTL is how long a login nonce stays valid before it must be re-fetched. + NonceTTL time.Duration `yaml:"nonce_ttl" validate:"required,gt=0" default:"5m"` + // Domain is the EIP-4361 domain the SIWE message must bind to (e.g. "app.example.com"). + Domain string `yaml:"domain" validate:"required"` + // URI is the EIP-4361 uri the SIWE message must bind to (e.g. "https://app.example.com"). + URI string `yaml:"uri" validate:"required"` + // ChainID is the EIP-155 chain id the SIWE message must declare. + ChainID int `yaml:"chain_id" validate:"required,gt=0"` +} 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..1b5eb3f9 --- /dev/null +++ b/pkg/auth/jwt/validator.go @@ -0,0 +1,159 @@ +// 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 + 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. +func (v *Validator) getKey(kid string) (any, error) { + 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) + } + + v.keysMu.Lock() + defer v.keysMu.Unlock() + for _, key := range set.Keys { + if key.Kty != "RSA" { + continue + } + pubKey, err := parseRSAPublicKey(key.N, key.E) + if err != nil { + continue // skip invalid keys + } + v.keys[key.Kid] = pubKey + } + 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") + } +} diff --git a/pkg/auth/service/http.go b/pkg/auth/service/http.go new file mode 100644 index 00000000..4c35b826 --- /dev/null +++ b/pkg/auth/service/http.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "go.uber.org/zap" + + 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" +) + +const maxRequestBodyBytes = 1 << 20 // 1MB + +type httpHandler struct { + svc Service + logger *zap.Logger +} + +// RegisterRoutes mounts the login and JWKS endpoints on r. +func RegisterRoutes(r chi.Router, svc Service, logger *zap.Logger) { + h := &httpHandler{svc: svc, logger: logger} + + r.Get("/auth/nonce", apphttp.HandleError(h.nonce)) + r.Post("/auth/login", apphttp.HandleError(h.login)) + r.Get("/.well-known/jwks.json", apphttp.HandleError(h.jwks)) + + logger.Info("SIWE login enabled", zap.String("path", "/auth/login")) +} + +// nonce issues a login nonce for the caller's address (GET /auth/nonce?address=). +// The nonce is keyed by address so repeat requests reuse the same live value, which +// stops an unauthenticated caller from churning the store; the address is the one +// the client will put in its SIWE message and is not a secret. +func (h *httpHandler) nonce(w http.ResponseWriter, r *http.Request) error { + address := strings.TrimSpace(r.URL.Query().Get("address")) + if !auth.ValidateEVMAddress(address) { + return apperrors.BadRequestError(nil, "address query parameter must be a 0x-prefixed 40-hex-char EVM address") + } + + nonce, err := h.svc.Nonce(auth.NormalizeAddress(address)) + if err != nil { + h.logger.Warn("nonce issuance rejected", zap.Error(err)) + return apperrors.GeneralError(err) + } + + writeJSON(w, http.StatusOK, &auth.NonceResponse{Nonce: nonce}) + return nil +} + +func (h *httpHandler) login(w http.ResponseWriter, r *http.Request) error { + var req auth.LoginRequest + if err := readJSON(r, &req); err != nil { + return err + } + if req.Message == "" || req.Signature == "" { + return apperrors.BadRequestError(nil, "message and signature are required") + } + + res, err := h.svc.Login(r.Context(), req.Message, req.Signature) + if err != nil { + return err + } + + writeJSON(w, http.StatusOK, res) + return nil +} + +func (h *httpHandler) jwks(w http.ResponseWriter, _ *http.Request) error { + writeJSON(w, http.StatusOK, h.svc.JWKS()) + return nil +} + +func readJSON(r *http.Request, dst any) error { + dec := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodyBytes)) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + return apperrors.BadRequestError(err, "invalid JSON") + } + return nil +} + +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} diff --git a/pkg/auth/service/http_test.go b/pkg/auth/service/http_test.go new file mode 100644 index 00000000..a94f4a63 --- /dev/null +++ b/pkg/auth/service/http_test.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/mock" + "go.uber.org/zap" + + apperrors "github.com/chainsafe/canton-middleware/pkg/app/errors" + "github.com/chainsafe/canton-middleware/pkg/auth" + "github.com/chainsafe/canton-middleware/pkg/auth/jwt" + "github.com/chainsafe/canton-middleware/pkg/auth/service/mocks" +) + +func newLoginTestServer(svc Service) http.Handler { + r := chi.NewRouter() + RegisterRoutes(r, svc, zap.NewNop()) + return r +} + +func TestNonceHTTP_ReturnsNonce(t *testing.T) { + svc := mocks.NewService(t) + svc.EXPECT().Nonce(mock.Anything).Return("the-nonce", nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/auth/nonce?address=0x0000000000000000000000000000000000000001", nil) + newLoginTestServer(svc).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var got auth.NonceResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Nonce != "the-nonce" { + t.Fatalf("nonce = %q, want the-nonce", got.Nonce) + } +} + +func TestNonceHTTP_MissingOrInvalidAddress_Returns400(t *testing.T) { + svc := mocks.NewService(t) // Nonce must not be called + + for _, q := range []string{"/auth/nonce", "/auth/nonce?address=not-an-address"} { + rec := httptest.NewRecorder() + newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, q, nil)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("%s: status = %d, want 400", q, rec.Code) + } + } +} + +func TestLoginHTTP_Success(t *testing.T) { + svc := mocks.NewService(t) + svc.EXPECT().Login(mock.Anything, "msg", "sig"). + Return(&auth.LoginResponse{Token: "tok", ExpiresAt: 12345}, nil) + + body, _ := json.Marshal(auth.LoginRequest{Message: "msg", Signature: "sig"}) + rec := httptest.NewRecorder() + newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader(string(body)))) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + var got auth.LoginResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Token != "tok" || got.ExpiresAt != 12345 { + t.Fatalf("response = %+v, want {tok 12345}", got) + } +} + +func TestLoginHTTP_InvalidJSON_Returns400(t *testing.T) { + svc := mocks.NewService(t) // Login must not be called + rec := httptest.NewRecorder() + newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader("{not json"))) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestLoginHTTP_MissingFields_Returns400(t *testing.T) { + svc := mocks.NewService(t) // Login must not be called + body, _ := json.Marshal(auth.LoginRequest{Message: "msg"}) // signature missing + rec := httptest.NewRecorder() + newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader(string(body)))) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestLoginHTTP_ServiceUnauthorized_Returns401(t *testing.T) { + svc := mocks.NewService(t) + svc.EXPECT().Login(mock.Anything, "msg", "sig"). + Return(nil, apperrors.UnAuthorizedError(nil, "invalid sign-in message or signature")) + + body, _ := json.Marshal(auth.LoginRequest{Message: "msg", Signature: "sig"}) + rec := httptest.NewRecorder() + newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader(string(body)))) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestJWKSHTTP_ServesKeySet(t *testing.T) { + svc := mocks.NewService(t) + svc.EXPECT().JWKS().Return(jwt.JWKS{Keys: []jwt.JWK{{Kid: "kid-1", Kty: "RSA"}}}) + + rec := httptest.NewRecorder() + newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var got jwt.JWKS + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Keys) != 1 || got.Keys[0].Kid != "kid-1" { + t.Fatalf("jwks = %+v, want one key kid-1", got) + } +} diff --git a/pkg/auth/service/log.go b/pkg/auth/service/log.go new file mode 100644 index 00000000..9fee9773 --- /dev/null +++ b/pkg/auth/service/log.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/chainsafe/canton-middleware/pkg/auth" + "github.com/chainsafe/canton-middleware/pkg/auth/jwt" +) + +const loginServiceName = "LoginService" + +// logService wraps Service with logging of the login call. Nonce and JWKS are +// trivial accessors and pass through unlogged; Login is the meaningful operation, +// so its entry/exit, duration, and errors are recorded (never the message or +// signature, which are sensitive). +type logService struct { + svc Service + logger *zap.Logger +} + +// NewLog creates a logging decorator for the login Service. +func NewLog(svc Service, logger *zap.Logger) Service { + return &logService{svc: svc, logger: logger} +} + +func (ls *logService) Nonce(address string) (string, error) { return ls.svc.Nonce(address) } + +func (ls *logService) JWKS() jwt.JWKS { return ls.svc.JWKS() } + +func (ls *logService) Login(ctx context.Context, message, signature string) (resp *auth.LoginResponse, err error) { + start := time.Now() + + ls.logger.Info("Login started", + zap.String("service", loginServiceName), + zap.String("method", "Login"), + ) + + defer func() { + duration := time.Since(start) + + if err != nil { + ls.logger.Error("Login failed", + zap.String("service", loginServiceName), + zap.String("method", "Login"), + zap.Duration("duration", duration), + zap.Error(err), + ) + } else { + ls.logger.Info("Login completed", + zap.String("service", loginServiceName), + zap.String("method", "Login"), + zap.Duration("duration", duration), + zap.Int64("expires_at", resp.ExpiresAt), + ) + } + }() + + return ls.svc.Login(ctx, message, signature) +} diff --git a/pkg/auth/service/login.go b/pkg/auth/service/login.go new file mode 100644 index 00000000..ecc8aa7b --- /dev/null +++ b/pkg/auth/service/login.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package service implements the Sign-In with Ethereum (EIP-4361) login flow: it +// issues single-use nonces, verifies signed SIWE messages, and mints the JWTs that +// authenticate read endpoints. The cryptographic primitives (JWT issuer, SIWE +// verifier, JWKS) live in pkg/auth/jwt and the nonce store in +// pkg/auth/service/nonce_provider; this package orchestrates them and binds the +// authenticated address to a registered user's Canton party. +package service + +import ( + "context" + "errors" + "time" + + "github.com/ethereum/go-ethereum/common" + + apperrors "github.com/chainsafe/canton-middleware/pkg/app/errors" + "github.com/chainsafe/canton-middleware/pkg/auth" + "github.com/chainsafe/canton-middleware/pkg/auth/jwt" + "github.com/chainsafe/canton-middleware/pkg/user" +) + +// The login service depends only on the narrow interfaces below, declared at the +// consumer so they can be mocked in isolation. Concrete implementations come from +// pkg/auth/jwt (Verifier, Issuer), pkg/auth/service/nonce_provider (NonceStore), +// and pkg/userstore (UserLookup). + +//go:generate mockery --name Service --output mocks --outpkg mocks --filename mock_service.go --with-expecter +//go:generate mockery --name Verifier --output mocks --outpkg mocks --filename mock_verifier.go --with-expecter +//go:generate mockery --name Issuer --output mocks --outpkg mocks --filename mock_issuer.go --with-expecter +//go:generate mockery --name NonceStore --output mocks --outpkg mocks --filename mock_nonce_store.go --with-expecter +//go:generate mockery --name UserLookup --output mocks --outpkg mocks --filename mock_user_lookup.go --with-expecter + +// UserLookup resolves a registered user by EVM address so login can bind the token +// to the user's Canton party. Satisfied by *userstore.Store. +type UserLookup interface { + GetUserByEVMAddress(ctx context.Context, evmAddress string) (*user.User, error) +} + +// NonceStore issues and consumes single-use login nonces keyed by address. Consume +// must be atomic so a given nonce satisfies at most one login. Implementations live +// under nonce_provider (e.g. nonceprovider.InMemory). +type NonceStore interface { + // Issue returns a nonce for address, valid for the store's TTL. Calling it again + // for an address that still holds a live nonce returns the same value. It may + // return an error if the store is at capacity. + Issue(address string) (string, error) + // Consume returns true exactly once for a live, previously-issued nonce and + // removes it; false for unknown, expired, or already-consumed nonces. + Consume(nonce string) bool +} + +// Verifier validates a signed SIWE login message and returns the recovered EVM +// address and the message nonce. Satisfied by *jwt.SIWEVerifier. +type Verifier interface { + Verify(message, signature string) (common.Address, string, error) +} + +// Issuer mints session JWTs and publishes the signing key set. Satisfied by +// *jwt.Issuer. +type Issuer interface { + Issue(evmAddress, cantonPartyID string) (string, time.Time, error) + JWKS() jwt.JWKS +} + +// Service orchestrates the SIWE login flow. +type Service interface { + // Nonce issues a single-use login nonce for the given EVM address. + Nonce(address string) (string, error) + // JWKS returns the public signing key set for token validation by other services. + JWKS() jwt.JWKS + // Login verifies a signed SIWE message and, if the recovered address belongs to + // a registered user, issues a JWT bound to that user's Canton party. + Login(ctx context.Context, message, signature string) (*auth.LoginResponse, error) +} + +type loginService struct { + verifier Verifier + issuer Issuer + nonces NonceStore + users UserLookup +} + +// New builds a login Service from its collaborators. +func New( + verifier Verifier, + issuer Issuer, + nonces NonceStore, + users UserLookup, +) Service { + return &loginService{ + verifier: verifier, + issuer: issuer, + nonces: nonces, + users: users, + } +} + +func (s *loginService) Nonce(address string) (string, error) { return s.nonces.Issue(address) } + +func (s *loginService) JWKS() jwt.JWKS { return s.issuer.JWKS() } + +func (s *loginService) Login(ctx context.Context, message, signature string) (*auth.LoginResponse, error) { + addr, nonce, err := s.verifier.Verify(message, signature) + if err != nil { + return nil, apperrors.UnAuthorizedError(err, "invalid sign-in message or signature") + } + // Enforce single-use only after the signature checks out, so a bad signature + // cannot burn a victim's outstanding nonce. + if !s.nonces.Consume(nonce) { + return nil, apperrors.UnAuthorizedError(nil, "nonce is unknown, expired, or already used") + } + evmAddress := auth.NormalizeAddress(addr.Hex()) + + usr, err := s.users.GetUserByEVMAddress(ctx, evmAddress) + if err != nil && !errors.Is(err, user.ErrUserNotFound) { + return nil, err + } + if usr == nil { + return nil, apperrors.UnAuthorizedError(nil, "address is not registered") + } + if usr.CantonPartyID == "" { + return nil, apperrors.UnAuthorizedError(nil, "user has no Canton party id") + } + + token, exp, err := s.issuer.Issue(evmAddress, usr.CantonPartyID) + if err != nil { + return nil, apperrors.GeneralError(err) + } + + return &auth.LoginResponse{Token: token, ExpiresAt: exp.Unix()}, nil +} diff --git a/pkg/auth/service/login_test.go b/pkg/auth/service/login_test.go new file mode 100644 index 00000000..b25c988f --- /dev/null +++ b/pkg/auth/service/login_test.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + + apperrors "github.com/chainsafe/canton-middleware/pkg/app/errors" + "github.com/chainsafe/canton-middleware/pkg/auth" + "github.com/chainsafe/canton-middleware/pkg/auth/jwt" + "github.com/chainsafe/canton-middleware/pkg/auth/service/mocks" + "github.com/chainsafe/canton-middleware/pkg/user" +) + +const ( + loginMessage = "localhost wants you to sign in..." + loginSig = "0xsignature" + loginNonce = "nonce0001" +) + +// testAddr is an arbitrary EVM address; loginAddr is its checksummed form, which is +// what the service normalizes to before the store lookup and token issuance. +var ( + testAddr = common.HexToAddress("0x00000000000000000000000000000000000000ff") + loginAddr = auth.NormalizeAddress(testAddr.Hex()) +) + +func newLoginDeps(t *testing.T) (*mocks.Verifier, *mocks.Issuer, *mocks.NonceStore, *mocks.UserLookup) { + t.Helper() + return mocks.NewVerifier(t), mocks.NewIssuer(t), mocks.NewNonceStore(t), mocks.NewUserLookup(t) +} + +func TestLogin_Success(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + exp := time.Unix(1_700_000_000, 0) + + v.EXPECT().Verify(loginMessage, loginSig).Return(testAddr, loginNonce, nil) + n.EXPECT().Consume(loginNonce).Return(true) + u.EXPECT().GetUserByEVMAddress(mock.Anything, loginAddr). + Return(&user.User{EVMAddress: loginAddr, CantonPartyID: "party::abc"}, nil) + iss.EXPECT().Issue(loginAddr, "party::abc").Return("the-token", exp, nil) + + res, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.Token != "the-token" { + t.Fatalf("token = %q, want the-token", res.Token) + } + if res.ExpiresAt != exp.Unix() { + t.Fatalf("expires_at = %d, want %d", res.ExpiresAt, exp.Unix()) + } +} + +func TestLogin_InvalidSignature(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + // Verification fails; nothing else must be called — in particular the nonce must + // not be consumed on a bad signature (the mocks fail the test on any extra call). + v.EXPECT().Verify(loginMessage, loginSig).Return(common.Address{}, "", errors.New("bad signature")) + + _, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + requireUnauthorized(t, err) +} + +func TestLogin_NonceRejected(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + v.EXPECT().Verify(loginMessage, loginSig).Return(testAddr, loginNonce, nil) + n.EXPECT().Consume(loginNonce).Return(false) // reused / expired / unknown + + _, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + requireUnauthorized(t, err) +} + +func TestLogin_UnregisteredAddress(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + v.EXPECT().Verify(loginMessage, loginSig).Return(testAddr, loginNonce, nil) + n.EXPECT().Consume(loginNonce).Return(true) + u.EXPECT().GetUserByEVMAddress(mock.Anything, loginAddr).Return(nil, user.ErrUserNotFound) + + _, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + requireUnauthorized(t, err) +} + +func TestLogin_MissingCantonParty(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + v.EXPECT().Verify(loginMessage, loginSig).Return(testAddr, loginNonce, nil) + n.EXPECT().Consume(loginNonce).Return(true) + u.EXPECT().GetUserByEVMAddress(mock.Anything, loginAddr). + Return(&user.User{EVMAddress: loginAddr}, nil) // CantonPartyID empty + + _, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + requireUnauthorized(t, err) +} + +func TestLogin_StoreError(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + storeErr := errors.New("db unavailable") + v.EXPECT().Verify(loginMessage, loginSig).Return(testAddr, loginNonce, nil) + n.EXPECT().Consume(loginNonce).Return(true) + u.EXPECT().GetUserByEVMAddress(mock.Anything, loginAddr).Return(nil, storeErr) + + _, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + if !errors.Is(err, storeErr) { + t.Fatalf("expected wrapped store error, got %v", err) + } +} + +func TestLogin_IssueError(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + v.EXPECT().Verify(loginMessage, loginSig).Return(testAddr, loginNonce, nil) + n.EXPECT().Consume(loginNonce).Return(true) + u.EXPECT().GetUserByEVMAddress(mock.Anything, loginAddr). + Return(&user.User{CantonPartyID: "party::abc"}, nil) + iss.EXPECT().Issue(loginAddr, "party::abc").Return("", time.Time{}, errors.New("sign failure")) + + _, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + if !apperrors.Is(err, apperrors.CategoryGeneralError) { + t.Fatalf("expected CategoryGeneralError, got %v", err) + } +} + +func TestNonce_Delegates(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + n.EXPECT().Issue(loginAddr).Return("fresh-nonce", nil) + + got, err := New(v, iss, n, u).Nonce(loginAddr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "fresh-nonce" { + t.Fatalf("Nonce() = %q, want fresh-nonce", got) + } +} + +func TestJWKS_Delegates(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + want := jwt.JWKS{Keys: []jwt.JWK{{Kid: "kid-1", Kty: "RSA"}}} + iss.EXPECT().JWKS().Return(want) + + got := New(v, iss, n, u).JWKS() + if len(got.Keys) != 1 || got.Keys[0].Kid != "kid-1" { + t.Fatalf("JWKS() = %+v, want %+v", got, want) + } +} + +func requireUnauthorized(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected an error, got nil") + } + if !apperrors.Is(err, apperrors.CategoryUnauthorized) { + t.Fatalf("expected CategoryUnauthorized, got %v", err) + } +} diff --git a/pkg/auth/service/mocks/mock_issuer.go b/pkg/auth/service/mocks/mock_issuer.go new file mode 100644 index 00000000..28f0cf44 --- /dev/null +++ b/pkg/auth/service/mocks/mock_issuer.go @@ -0,0 +1,146 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + jwt "github.com/chainsafe/canton-middleware/pkg/auth/jwt" + mock "github.com/stretchr/testify/mock" + + time "time" +) + +// Issuer is an autogenerated mock type for the Issuer type +type Issuer struct { + mock.Mock +} + +type Issuer_Expecter struct { + mock *mock.Mock +} + +func (_m *Issuer) EXPECT() *Issuer_Expecter { + return &Issuer_Expecter{mock: &_m.Mock} +} + +// Issue provides a mock function with given fields: evmAddress, cantonPartyID +func (_m *Issuer) Issue(evmAddress string, cantonPartyID string) (string, time.Time, error) { + ret := _m.Called(evmAddress, cantonPartyID) + + if len(ret) == 0 { + panic("no return value specified for Issue") + } + + var r0 string + var r1 time.Time + var r2 error + if rf, ok := ret.Get(0).(func(string, string) (string, time.Time, error)); ok { + return rf(evmAddress, cantonPartyID) + } + if rf, ok := ret.Get(0).(func(string, string) string); ok { + r0 = rf(evmAddress, cantonPartyID) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string, string) time.Time); ok { + r1 = rf(evmAddress, cantonPartyID) + } else { + r1 = ret.Get(1).(time.Time) + } + + if rf, ok := ret.Get(2).(func(string, string) error); ok { + r2 = rf(evmAddress, cantonPartyID) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// Issuer_Issue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Issue' +type Issuer_Issue_Call struct { + *mock.Call +} + +// Issue is a helper method to define mock.On call +// - evmAddress string +// - cantonPartyID string +func (_e *Issuer_Expecter) Issue(evmAddress interface{}, cantonPartyID interface{}) *Issuer_Issue_Call { + return &Issuer_Issue_Call{Call: _e.mock.On("Issue", evmAddress, cantonPartyID)} +} + +func (_c *Issuer_Issue_Call) Run(run func(evmAddress string, cantonPartyID string)) *Issuer_Issue_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *Issuer_Issue_Call) Return(_a0 string, _a1 time.Time, _a2 error) *Issuer_Issue_Call { + _c.Call.Return(_a0, _a1, _a2) + return _c +} + +func (_c *Issuer_Issue_Call) RunAndReturn(run func(string, string) (string, time.Time, error)) *Issuer_Issue_Call { + _c.Call.Return(run) + return _c +} + +// JWKS provides a mock function with no fields +func (_m *Issuer) JWKS() jwt.JWKS { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for JWKS") + } + + var r0 jwt.JWKS + if rf, ok := ret.Get(0).(func() jwt.JWKS); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(jwt.JWKS) + } + + return r0 +} + +// Issuer_JWKS_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JWKS' +type Issuer_JWKS_Call struct { + *mock.Call +} + +// JWKS is a helper method to define mock.On call +func (_e *Issuer_Expecter) JWKS() *Issuer_JWKS_Call { + return &Issuer_JWKS_Call{Call: _e.mock.On("JWKS")} +} + +func (_c *Issuer_JWKS_Call) Run(run func()) *Issuer_JWKS_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Issuer_JWKS_Call) Return(_a0 jwt.JWKS) *Issuer_JWKS_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Issuer_JWKS_Call) RunAndReturn(run func() jwt.JWKS) *Issuer_JWKS_Call { + _c.Call.Return(run) + return _c +} + +// NewIssuer creates a new instance of Issuer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewIssuer(t interface { + mock.TestingT + Cleanup(func()) +}) *Issuer { + mock := &Issuer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/auth/service/mocks/mock_nonce_store.go b/pkg/auth/service/mocks/mock_nonce_store.go new file mode 100644 index 00000000..efc819b6 --- /dev/null +++ b/pkg/auth/service/mocks/mock_nonce_store.go @@ -0,0 +1,134 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// NonceStore is an autogenerated mock type for the NonceStore type +type NonceStore struct { + mock.Mock +} + +type NonceStore_Expecter struct { + mock *mock.Mock +} + +func (_m *NonceStore) EXPECT() *NonceStore_Expecter { + return &NonceStore_Expecter{mock: &_m.Mock} +} + +// Consume provides a mock function with given fields: nonce +func (_m *NonceStore) Consume(nonce string) bool { + ret := _m.Called(nonce) + + if len(ret) == 0 { + panic("no return value specified for Consume") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(nonce) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// NonceStore_Consume_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Consume' +type NonceStore_Consume_Call struct { + *mock.Call +} + +// Consume is a helper method to define mock.On call +// - nonce string +func (_e *NonceStore_Expecter) Consume(nonce interface{}) *NonceStore_Consume_Call { + return &NonceStore_Consume_Call{Call: _e.mock.On("Consume", nonce)} +} + +func (_c *NonceStore_Consume_Call) Run(run func(nonce string)) *NonceStore_Consume_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *NonceStore_Consume_Call) Return(_a0 bool) *NonceStore_Consume_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *NonceStore_Consume_Call) RunAndReturn(run func(string) bool) *NonceStore_Consume_Call { + _c.Call.Return(run) + return _c +} + +// Issue provides a mock function with given fields: address +func (_m *NonceStore) Issue(address string) (string, error) { + ret := _m.Called(address) + + if len(ret) == 0 { + panic("no return value specified for Issue") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(address) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(address) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(address) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NonceStore_Issue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Issue' +type NonceStore_Issue_Call struct { + *mock.Call +} + +// Issue is a helper method to define mock.On call +// - address string +func (_e *NonceStore_Expecter) Issue(address interface{}) *NonceStore_Issue_Call { + return &NonceStore_Issue_Call{Call: _e.mock.On("Issue", address)} +} + +func (_c *NonceStore_Issue_Call) Run(run func(address string)) *NonceStore_Issue_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *NonceStore_Issue_Call) Return(_a0 string, _a1 error) *NonceStore_Issue_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *NonceStore_Issue_Call) RunAndReturn(run func(string) (string, error)) *NonceStore_Issue_Call { + _c.Call.Return(run) + return _c +} + +// NewNonceStore creates a new instance of NonceStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewNonceStore(t interface { + mock.TestingT + Cleanup(func()) +}) *NonceStore { + mock := &NonceStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/auth/service/mocks/mock_service.go b/pkg/auth/service/mocks/mock_service.go new file mode 100644 index 00000000..06bbe72c --- /dev/null +++ b/pkg/auth/service/mocks/mock_service.go @@ -0,0 +1,201 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + context "context" + + auth "github.com/chainsafe/canton-middleware/pkg/auth" + + jwt "github.com/chainsafe/canton-middleware/pkg/auth/jwt" + + mock "github.com/stretchr/testify/mock" +) + +// Service is an autogenerated mock type for the Service type +type Service struct { + mock.Mock +} + +type Service_Expecter struct { + mock *mock.Mock +} + +func (_m *Service) EXPECT() *Service_Expecter { + return &Service_Expecter{mock: &_m.Mock} +} + +// JWKS provides a mock function with no fields +func (_m *Service) JWKS() jwt.JWKS { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for JWKS") + } + + var r0 jwt.JWKS + if rf, ok := ret.Get(0).(func() jwt.JWKS); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(jwt.JWKS) + } + + return r0 +} + +// Service_JWKS_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JWKS' +type Service_JWKS_Call struct { + *mock.Call +} + +// JWKS is a helper method to define mock.On call +func (_e *Service_Expecter) JWKS() *Service_JWKS_Call { + return &Service_JWKS_Call{Call: _e.mock.On("JWKS")} +} + +func (_c *Service_JWKS_Call) Run(run func()) *Service_JWKS_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Service_JWKS_Call) Return(_a0 jwt.JWKS) *Service_JWKS_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Service_JWKS_Call) RunAndReturn(run func() jwt.JWKS) *Service_JWKS_Call { + _c.Call.Return(run) + return _c +} + +// Login provides a mock function with given fields: ctx, message, signature +func (_m *Service) Login(ctx context.Context, message string, signature string) (*auth.LoginResponse, error) { + ret := _m.Called(ctx, message, signature) + + if len(ret) == 0 { + panic("no return value specified for Login") + } + + var r0 *auth.LoginResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*auth.LoginResponse, error)); ok { + return rf(ctx, message, signature) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *auth.LoginResponse); ok { + r0 = rf(ctx, message, signature) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*auth.LoginResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, message, signature) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Service_Login_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Login' +type Service_Login_Call struct { + *mock.Call +} + +// Login is a helper method to define mock.On call +// - ctx context.Context +// - message string +// - signature string +func (_e *Service_Expecter) Login(ctx interface{}, message interface{}, signature interface{}) *Service_Login_Call { + return &Service_Login_Call{Call: _e.mock.On("Login", ctx, message, signature)} +} + +func (_c *Service_Login_Call) Run(run func(ctx context.Context, message string, signature string)) *Service_Login_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *Service_Login_Call) Return(_a0 *auth.LoginResponse, _a1 error) *Service_Login_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Service_Login_Call) RunAndReturn(run func(context.Context, string, string) (*auth.LoginResponse, error)) *Service_Login_Call { + _c.Call.Return(run) + return _c +} + +// Nonce provides a mock function with given fields: address +func (_m *Service) Nonce(address string) (string, error) { + ret := _m.Called(address) + + if len(ret) == 0 { + panic("no return value specified for Nonce") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(address) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(address) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(address) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Service_Nonce_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Nonce' +type Service_Nonce_Call struct { + *mock.Call +} + +// Nonce is a helper method to define mock.On call +// - address string +func (_e *Service_Expecter) Nonce(address interface{}) *Service_Nonce_Call { + return &Service_Nonce_Call{Call: _e.mock.On("Nonce", address)} +} + +func (_c *Service_Nonce_Call) Run(run func(address string)) *Service_Nonce_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Service_Nonce_Call) Return(_a0 string, _a1 error) *Service_Nonce_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Service_Nonce_Call) RunAndReturn(run func(string) (string, error)) *Service_Nonce_Call { + _c.Call.Return(run) + return _c +} + +// NewService creates a new instance of Service. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewService(t interface { + mock.TestingT + Cleanup(func()) +}) *Service { + mock := &Service{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/auth/service/mocks/mock_user_lookup.go b/pkg/auth/service/mocks/mock_user_lookup.go new file mode 100644 index 00000000..f8833db4 --- /dev/null +++ b/pkg/auth/service/mocks/mock_user_lookup.go @@ -0,0 +1,97 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + user "github.com/chainsafe/canton-middleware/pkg/user" +) + +// UserLookup is an autogenerated mock type for the UserLookup type +type UserLookup struct { + mock.Mock +} + +type UserLookup_Expecter struct { + mock *mock.Mock +} + +func (_m *UserLookup) EXPECT() *UserLookup_Expecter { + return &UserLookup_Expecter{mock: &_m.Mock} +} + +// GetUserByEVMAddress provides a mock function with given fields: ctx, evmAddress +func (_m *UserLookup) GetUserByEVMAddress(ctx context.Context, evmAddress string) (*user.User, error) { + ret := _m.Called(ctx, evmAddress) + + if len(ret) == 0 { + panic("no return value specified for GetUserByEVMAddress") + } + + var r0 *user.User + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*user.User, error)); ok { + return rf(ctx, evmAddress) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *user.User); ok { + r0 = rf(ctx, evmAddress) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*user.User) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, evmAddress) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// UserLookup_GetUserByEVMAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserByEVMAddress' +type UserLookup_GetUserByEVMAddress_Call struct { + *mock.Call +} + +// GetUserByEVMAddress is a helper method to define mock.On call +// - ctx context.Context +// - evmAddress string +func (_e *UserLookup_Expecter) GetUserByEVMAddress(ctx interface{}, evmAddress interface{}) *UserLookup_GetUserByEVMAddress_Call { + return &UserLookup_GetUserByEVMAddress_Call{Call: _e.mock.On("GetUserByEVMAddress", ctx, evmAddress)} +} + +func (_c *UserLookup_GetUserByEVMAddress_Call) Run(run func(ctx context.Context, evmAddress string)) *UserLookup_GetUserByEVMAddress_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *UserLookup_GetUserByEVMAddress_Call) Return(_a0 *user.User, _a1 error) *UserLookup_GetUserByEVMAddress_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *UserLookup_GetUserByEVMAddress_Call) RunAndReturn(run func(context.Context, string) (*user.User, error)) *UserLookup_GetUserByEVMAddress_Call { + _c.Call.Return(run) + return _c +} + +// NewUserLookup creates a new instance of UserLookup. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewUserLookup(t interface { + mock.TestingT + Cleanup(func()) +}) *UserLookup { + mock := &UserLookup{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/auth/service/mocks/mock_verifier.go b/pkg/auth/service/mocks/mock_verifier.go new file mode 100644 index 00000000..47bc442a --- /dev/null +++ b/pkg/auth/service/mocks/mock_verifier.go @@ -0,0 +1,101 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + common "github.com/ethereum/go-ethereum/common" + mock "github.com/stretchr/testify/mock" +) + +// Verifier is an autogenerated mock type for the Verifier type +type Verifier struct { + mock.Mock +} + +type Verifier_Expecter struct { + mock *mock.Mock +} + +func (_m *Verifier) EXPECT() *Verifier_Expecter { + return &Verifier_Expecter{mock: &_m.Mock} +} + +// Verify provides a mock function with given fields: message, signature +func (_m *Verifier) Verify(message string, signature string) (common.Address, string, error) { + ret := _m.Called(message, signature) + + if len(ret) == 0 { + panic("no return value specified for Verify") + } + + var r0 common.Address + var r1 string + var r2 error + if rf, ok := ret.Get(0).(func(string, string) (common.Address, string, error)); ok { + return rf(message, signature) + } + if rf, ok := ret.Get(0).(func(string, string) common.Address); ok { + r0 = rf(message, signature) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(common.Address) + } + } + + if rf, ok := ret.Get(1).(func(string, string) string); ok { + r1 = rf(message, signature) + } else { + r1 = ret.Get(1).(string) + } + + if rf, ok := ret.Get(2).(func(string, string) error); ok { + r2 = rf(message, signature) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// Verifier_Verify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Verify' +type Verifier_Verify_Call struct { + *mock.Call +} + +// Verify is a helper method to define mock.On call +// - message string +// - signature string +func (_e *Verifier_Expecter) Verify(message interface{}, signature interface{}) *Verifier_Verify_Call { + return &Verifier_Verify_Call{Call: _e.mock.On("Verify", message, signature)} +} + +func (_c *Verifier_Verify_Call) Run(run func(message string, signature string)) *Verifier_Verify_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *Verifier_Verify_Call) Return(_a0 common.Address, _a1 string, _a2 error) *Verifier_Verify_Call { + _c.Call.Return(_a0, _a1, _a2) + return _c +} + +func (_c *Verifier_Verify_Call) RunAndReturn(run func(string, string) (common.Address, string, error)) *Verifier_Verify_Call { + _c.Call.Return(run) + return _c +} + +// NewVerifier creates a new instance of Verifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewVerifier(t interface { + mock.TestingT + Cleanup(func()) +}) *Verifier { + mock := &Verifier{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/auth/service/nonce_provider/in_memory.go b/pkg/auth/service/nonce_provider/in_memory.go new file mode 100644 index 00000000..79c7cb11 --- /dev/null +++ b/pkg/auth/service/nonce_provider/in_memory.go @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package nonceprovider contains implementations of the login nonce store. +package nonceprovider + +import ( + "errors" + "sync" + "time" + + siwe "github.com/spruceid/siwe-go" +) + +// maxAddresses caps how many distinct addresses can hold a live nonce at once. +// GET /auth/nonce is unauthenticated, so without a bound an attacker could flood +// the store and exhaust memory. Because each address maps to at most one live +// nonce (repeat requests reuse it), the map only grows with distinct addresses, +// and when the cap is hit the store rejects new issuance rather than evicting a +// live nonce — so an ongoing flood can never invalidate a nonce a real user is +// mid-login with. The cap is far above any legitimate concurrent-login volume. +const maxAddresses = 1 << 16 // 65536 + +// ErrStoreFull is returned by Issue when the store is at capacity and no expired +// entries can be reclaimed. It signals transient overload, not a client error. +var ErrStoreFull = errors.New("nonce store is at capacity") + +type entry struct { + nonce string + expiry time.Time +} + +// InMemory is an in-process nonce store suitable for a single api-server replica. +// Nonces are keyed by the requesting address so a given address holds at most one +// live nonce. Nonces are short-lived, so losing them on restart is harmless; run +// more than one replica and this must be replaced with a shared (e.g. Postgres) store. +type InMemory struct { + mu sync.Mutex + byAddr map[string]entry // address -> its live nonce + expiry + byNonce map[string]string // nonce -> address (reverse index for Consume) + ttl time.Duration + max int + nextSweep time.Time + now func() time.Time +} + +// NewInMemory creates an InMemory nonce store whose nonces expire after ttl. +func NewInMemory(ttl time.Duration) *InMemory { + return &InMemory{ + byAddr: make(map[string]entry), + byNonce: make(map[string]string), + ttl: ttl, + max: maxAddresses, + now: time.Now, + } +} + +// Issue returns a nonce for address. If the address already holds a live nonce it +// is returned unchanged, so repeat requests (retries, an attacker replaying the +// same address) do not grow the store. Otherwise a fresh nonce is minted. It +// purges expired entries on a fixed cadence and, when the store is full of live +// entries, returns ErrStoreFull instead of evicting another address's live nonce. +func (s *InMemory) Issue(address string) (string, error) { + now := s.now() + + s.mu.Lock() + defer s.mu.Unlock() + + if now.After(s.nextSweep) { + s.purgeExpired(now) + s.nextSweep = now.Add(s.ttl) + } + + // Reuse the address's current nonce while it is still valid. + if e, ok := s.byAddr[address]; ok && now.Before(e.expiry) { + return e.nonce, nil + } + + // No live nonce for this address: we are about to add (or replace) an entry. + // If we would grow past the cap, try reclaiming expired entries first, then + // refuse — never drop another address's live nonce. + if _, exists := s.byAddr[address]; !exists && len(s.byAddr) >= s.max { + s.purgeExpired(now) + if len(s.byAddr) >= s.max { + return "", ErrStoreFull + } + } + + // Drop any stale nonce this address held so its reverse index does not leak. + if old, ok := s.byAddr[address]; ok { + delete(s.byNonce, old.nonce) + } + + nonce := siwe.GenerateNonce() + s.byAddr[address] = entry{nonce: nonce, expiry: now.Add(s.ttl)} + s.byNonce[nonce] = address + return nonce, nil +} + +// Consume removes the nonce and reports whether it was live at the moment of use. +func (s *InMemory) Consume(nonce string) bool { + now := s.now() + + s.mu.Lock() + defer s.mu.Unlock() + + address, ok := s.byNonce[nonce] + if !ok { + return false + } + e := s.byAddr[address] + delete(s.byNonce, nonce) + delete(s.byAddr, address) + return now.Before(e.expiry) +} + +// purgeExpired removes all entries whose expiry has passed. Callers must hold s.mu. +func (s *InMemory) purgeExpired(now time.Time) { + for address, e := range s.byAddr { + if now.After(e.expiry) { + delete(s.byAddr, address) + delete(s.byNonce, e.nonce) + } + } +} diff --git a/pkg/auth/service/nonce_provider/in_memory_test.go b/pkg/auth/service/nonce_provider/in_memory_test.go new file mode 100644 index 00000000..256934a2 --- /dev/null +++ b/pkg/auth/service/nonce_provider/in_memory_test.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 + +package nonceprovider + +import ( + "errors" + "strconv" + "testing" + "time" +) + +func TestInMemory_SingleUse(t *testing.T) { + store := NewInMemory(time.Minute) + nonce, err := store.Issue("0xabc") + if err != nil { + t.Fatalf("issue: %v", err) + } + + if !store.Consume(nonce) { + t.Fatal("first consume should succeed") + } + if store.Consume(nonce) { + t.Fatal("second consume of same nonce must fail") + } + if store.Consume("never-issued") { + t.Fatal("consuming an unknown nonce must fail") + } +} + +func TestInMemory_ReusesLiveNoncePerAddress(t *testing.T) { + store := NewInMemory(time.Minute) + + first, err := store.Issue("0xabc") + if err != nil { + t.Fatalf("issue: %v", err) + } + second, err := store.Issue("0xabc") + if err != nil { + t.Fatalf("re-issue: %v", err) + } + if first != second { + t.Fatalf("expected the same live nonce to be reused, got %q then %q", first, second) + } + + // A different address gets its own nonce. + other, _ := store.Issue("0xdef") + if other == first { + t.Fatal("different addresses must get different nonces") + } +} + +func TestInMemory_Expiry(t *testing.T) { + store := NewInMemory(time.Minute) + base := time.Unix(1_700_000_000, 0) + store.now = func() time.Time { return base } + + nonce, _ := store.Issue("0xabc") + + // Advance past the TTL before consuming. + store.now = func() time.Time { return base.Add(2 * time.Minute) } + if store.Consume(nonce) { + t.Fatal("expired nonce must not be consumable") + } +} + +func TestInMemory_RejectsWhenFull_WithoutEvictingLive(t *testing.T) { + store := NewInMemory(time.Hour) // long TTL so nothing expires during the test + store.max = 4 + + issued := make([]string, 0, store.max) + for i := 0; i < store.max; i++ { + n, err := store.Issue("addr-" + strconv.Itoa(i)) + if err != nil { + t.Fatalf("issue %d: %v", i, err) + } + issued = append(issued, n) + } + + // A new distinct address is rejected — never at the cost of a live nonce. + if _, err := store.Issue("addr-overflow"); !errors.Is(err, ErrStoreFull) { + t.Fatalf("expected ErrStoreFull, got %v", err) + } + + // Every previously-issued nonce is still valid (none were evicted). + for i, n := range issued { + if !store.Consume(n) { + t.Fatalf("nonce %d was evicted but should have survived", i) + } + } +} + +func TestInMemory_ReclaimsExpiredBeforeRejecting(t *testing.T) { + store := NewInMemory(time.Minute) + base := time.Unix(1_700_000_000, 0) + store.now = func() time.Time { return base } + store.max = 2 + + _, _ = store.Issue("a") + _, _ = store.Issue("b") // now full + + // After the TTL, those entries are reclaimable, so a new address succeeds. + store.now = func() time.Time { return base.Add(2 * time.Minute) } + if _, err := store.Issue("c"); err != nil { + t.Fatalf("expected expired entries to be reclaimed, got %v", err) + } +} diff --git a/pkg/auth/types.go b/pkg/auth/types.go new file mode 100644 index 00000000..8c17b356 --- /dev/null +++ b/pkg/auth/types.go @@ -0,0 +1,15 @@ +package auth + +type NonceResponse struct { + Nonce string `json:"nonce"` +} + +type LoginRequest struct { + Message string `json:"message"` + Signature string `json:"signature"` +} + +type LoginResponse struct { + Token string `json:"token"` + ExpiresAt int64 `json:"expires_at"` // unix seconds +} diff --git a/pkg/config/config.go b/pkg/config/config.go index a861adad..8dc285fd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/chainsafe/canton-middleware/pkg/app/http" + "github.com/chainsafe/canton-middleware/pkg/auth" canton "github.com/chainsafe/canton-middleware/pkg/cantonsdk/client" "github.com/chainsafe/canton-middleware/pkg/cantonsdk/ledger" "github.com/chainsafe/canton-middleware/pkg/custodial" @@ -74,7 +75,7 @@ type APIServer struct { Token *token.Config `yaml:"token" validate:"required"` TokenProvider *TokenProviderConfig `yaml:"token_provider" default:"-"` // omit → defaults to canton mode EthRPC *ethrpc.Config `yaml:"eth_rpc" validate:"required"` - JWKS *JWKS `yaml:"jwks" default:"-"` // nil by default (feature disabled) + Auth *auth.Config `yaml:"auth" default:"-"` // nil disables read-endpoint auth (SIWE login + JWT) AcceptWorker *custodial.AcceptWorkerConfig `yaml:"accept_worker" default:"-"` // nil disables the worker Monitoring *Monitoring `yaml:"monitoring" validate:"required"` Logging *log.Config `yaml:"logging" validate:"required"` @@ -114,12 +115,6 @@ type Monitoring struct { HealthCheckURL string `yaml:"health_check_url" default:"/health"` } -// JWKS contains JWKS configuration for JWT validation -type JWKS struct { - URL string `yaml:"url" default:""` - Issuer string `yaml:"issuer" default:""` -} - // KeyManagement contains settings for custodial Canton key management type KeyManagement struct { // MasterKeyEnv is the environment variable name containing the master encryption key (base64) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 4fa82b10..b424b9e5 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -143,8 +143,8 @@ func TestLoadAPIServer_AppliesDefaults(t *testing.T) { t.Fatalf("key_management.key_derivation default mismatch: got %q", cfg.KeyManagement.KeyDerivation) } - if cfg.JWKS != nil { - t.Fatal("jwks should default to nil when omitted") + if cfg.Auth != nil { + t.Fatal("auth should default to nil when omitted") } if cfg.Canton.Bridge != nil { t.Fatal("canton.bridge should default to nil when omitted") @@ -422,4 +422,5 @@ func setDefaultConfigEnv(t *testing.T) { t.Setenv("CANTON_USDCX_ISSUER_PARTY", "usdcx-issuer::1220test") t.Setenv("CANTON_USDCX_REGISTRY_URL", "http://usdcx-registry:8090") t.Setenv("ADMIN_API_KEY", "test-admin-key") + t.Setenv("JWT_PRIVATE_KEY", "dummy-base64-key") } diff --git a/pkg/config/defaults/config.api-server.docker.yaml b/pkg/config/defaults/config.api-server.docker.yaml index b5f9dffa..10653234 100644 --- a/pkg/config/defaults/config.api-server.docker.yaml +++ b/pkg/config/defaults/config.api-server.docker.yaml @@ -98,10 +98,19 @@ eth_rpc: chain_id: 31337 # Anvil local chain ID request_timeout: "30s" -# JWKS endpoint for JWT validation (optional - if not using EVM signatures) -jwks: - url: "" - issuer: "" +# Read-endpoint authentication (SIWE login + RS256 JWT). domain/uri/chain_id are +# localhost here so the e2e suite can sign a matching EIP-4361 message. The signing +# key is a base64-encoded PEM supplied via ${JWT_PRIVATE_KEY}. +auth: + private_key: "${JWT_PRIVATE_KEY}" + kid: "default" + issuer: "canton-middleware" + audience: "canton-middleware-api" + token_ttl: "6h" + nonce_ttl: "5m" + domain: "localhost" + uri: "http://localhost" + chain_id: 31337 monitoring: enabled: true diff --git a/pkg/config/defaults/config.api-server.local-devnet.yaml b/pkg/config/defaults/config.api-server.local-devnet.yaml index 0512eaed..ed6a4968 100644 --- a/pkg/config/defaults/config.api-server.local-devnet.yaml +++ b/pkg/config/defaults/config.api-server.local-devnet.yaml @@ -91,9 +91,17 @@ eth_rpc: chain_id: 1155111101 # Custom: Sepolia (11155111) + 01 suffix for Canton local request_timeout: "60s" -jwks: - url: "" - issuer: "" +# The signing key is a base64-encoded PEM supplied via ${JWT_PRIVATE_KEY} (base64 < key.pem). +auth: + private_key: "${JWT_PRIVATE_KEY}" + kid: "default" + issuer: "canton-middleware" + audience: "canton-middleware-api" + token_ttl: "6h" + nonce_ttl: "1m" + domain: "dapp-dev1.01.chainsafe.dev" + uri: "https://dapp-dev1.01.chainsafe.dev" + chain_id: 1155111101 monitoring: enabled: true diff --git a/pkg/config/defaults/config.api-server.mainnet.yaml b/pkg/config/defaults/config.api-server.mainnet.yaml index f8413487..78dc7066 100644 --- a/pkg/config/defaults/config.api-server.mainnet.yaml +++ b/pkg/config/defaults/config.api-server.mainnet.yaml @@ -73,9 +73,18 @@ eth_rpc: chain_id: 1337 request_timeout: "60s" -jwks: - url: "" - issuer: "" +# The signing key is a base64-encoded PEM supplied via ${JWT_PRIVATE_KEY} (base64 < key.pem). +# domain/uri must match the production dapp origin. +auth: + private_key: "${JWT_PRIVATE_KEY}" + kid: "default" + issuer: "canton-middleware" + audience: "canton-middleware-api" + token_ttl: "6h" + nonce_ttl: "1m" + domain: "evm-middleware.chainsafe.io" + uri: "https://evm-middleware.chainsafe.io" + chain_id: 1337 monitoring: enabled: true diff --git a/pkg/transfer/http.go b/pkg/transfer/http.go index 2aadc87f..853fee84 100644 --- a/pkg/transfer/http.go +++ b/pkg/transfer/http.go @@ -38,7 +38,11 @@ type httpHandler struct { } // RegisterRoutes registers the non-custodial prepare/execute transfer endpoints. -func RegisterRoutes(r chi.Router, svc Service, logger *zap.Logger) { +// readAuth guards the read (list) endpoints. When auth is enabled it authenticates +// the caller and puts their identity in the request context; when disabled it is a +// passthrough and the handlers fall back to the ?address= query parameter. It must +// be non-nil. +func RegisterRoutes(r chi.Router, svc Service, readAuth func(http.Handler) http.Handler, logger *zap.Logger) { h := &httpHandler{svc: svc, logger: logger} r.Post("/api/v2/transfer/prepare", apphttp.HandleError(h.prepare)) @@ -48,9 +52,12 @@ func RegisterRoutes(r chi.Router, svc Service, logger *zap.Logger) { // middleware holds the custodial user's Canton key and signs server-side. r.Post("/api/v2/transfer/custodial", apphttp.HandleError(h.sendCustodial)) - r.Get("/api/v2/transfer/incoming", apphttp.HandleError(h.listIncoming)) - r.Get("/api/v2/transfer/outgoing", apphttp.HandleError(h.listOutgoing)) - r.Get("/api/v2/transfer/completed", apphttp.HandleError(h.listCompleted)) + // Read endpoints return one caller's data. With auth enabled the caller is the + // bearer-token identity; with auth disabled they fall back to ?address=. + read := r.With(readAuth) + read.Get("/api/v2/transfer/incoming", apphttp.HandleError(h.listIncoming)) + read.Get("/api/v2/transfer/outgoing", apphttp.HandleError(h.listOutgoing)) + read.Get("/api/v2/transfer/completed", apphttp.HandleError(h.listCompleted)) r.Post("/api/v2/transfer/incoming/{contractID}/prepare", apphttp.HandleError(h.prepareAccept)) r.Post("/api/v2/transfer/incoming/{contractID}/execute", apphttp.HandleError(h.executeAccept)) @@ -149,23 +156,15 @@ func (h *httpHandler) execute(w http.ResponseWriter, r *http.Request) error { return nil } -// listIncoming is intentionally unauthenticated for now: callers pass the EVM -// address as a query parameter and receive that user's pending offers. The -// endpoint is read-only and exposes only data already visible to the receiver -// party on-ledger, so dropping the signature requirement does not leak anything -// new — it just lets clients (and tests) poll incoming offers without prior -// signing-key access. Sensitive fields (party IDs) are truncated server-side. +// listIncoming returns the caller's pending offers. Identity comes from the bearer token when auth is enabled, else from ?address=. // // Pagination is page/limit based to match the indexer envelope so each request // translates to exactly one indexer round-trip — no in-process buffering of all // offers for a receiver. func (h *httpHandler) listIncoming(w http.ResponseWriter, r *http.Request) error { - evmAddr := strings.TrimSpace(r.URL.Query().Get("address")) - if evmAddr == "" { - return apperrors.BadRequestError(nil, "address query parameter is required") - } - if !auth.ValidateEVMAddress(evmAddr) { - return apperrors.BadRequestError(nil, "invalid address: must be a 0x-prefixed 40-hex-char EVM address") + evmAddr, err := callerAddress(r) + if err != nil { + return err } p, err := parseListPagination(r) @@ -173,7 +172,7 @@ func (h *httpHandler) listIncoming(w http.ResponseWriter, r *http.Request) error return err } - resp, err := h.svc.ListIncoming(r.Context(), auth.NormalizeAddress(evmAddr), p) + resp, err := h.svc.ListIncoming(r.Context(), evmAddr, p) if err != nil { return err } @@ -182,16 +181,12 @@ func (h *httpHandler) listIncoming(w http.ResponseWriter, r *http.Request) error return nil } -// listOutgoing returns the queried address's outbound TransferOffers. Like -// listIncoming it is unauthenticated and takes the EVM address as a query param; +// listOutgoing returns the caller's outbound TransferOffers; // ?status= filters by pending|expired|accepted|canceled|rejected|all (default all). func (h *httpHandler) listOutgoing(w http.ResponseWriter, r *http.Request) error { - evmAddr := strings.TrimSpace(r.URL.Query().Get("address")) - if evmAddr == "" { - return apperrors.BadRequestError(nil, "address query parameter is required") - } - if !auth.ValidateEVMAddress(evmAddr) { - return apperrors.BadRequestError(nil, "invalid address: must be a 0x-prefixed 40-hex-char EVM address") + evmAddr, err := callerAddress(r) + if err != nil { + return err } status, err := parseOutgoingStatus(r) @@ -203,7 +198,7 @@ func (h *httpHandler) listOutgoing(w http.ResponseWriter, r *http.Request) error return err } - resp, err := h.svc.ListOutgoing(r.Context(), auth.NormalizeAddress(evmAddr), status, p) + resp, err := h.svc.ListOutgoing(r.Context(), evmAddr, status, p) if err != nil { return err } @@ -212,15 +207,11 @@ func (h *httpHandler) listOutgoing(w http.ResponseWriter, r *http.Request) error return nil } -// listCompleted returns the queried address's settled transfers across all tokens. -// Unauthenticated, EVM address as a query param, party IDs truncated. +// listCompleted returns the caller's settled transfers across all tokens. func (h *httpHandler) listCompleted(w http.ResponseWriter, r *http.Request) error { - evmAddr := strings.TrimSpace(r.URL.Query().Get("address")) - if evmAddr == "" { - return apperrors.BadRequestError(nil, "address query parameter is required") - } - if !auth.ValidateEVMAddress(evmAddr) { - return apperrors.BadRequestError(nil, "invalid address: must be a 0x-prefixed 40-hex-char EVM address") + evmAddr, err := callerAddress(r) + if err != nil { + return err } p, err := parseListPagination(r) @@ -228,7 +219,7 @@ func (h *httpHandler) listCompleted(w http.ResponseWriter, r *http.Request) erro return err } - resp, err := h.svc.ListCompleted(r.Context(), auth.NormalizeAddress(evmAddr), p) + resp, err := h.svc.ListCompleted(r.Context(), evmAddr, p) if err != nil { return err } @@ -237,6 +228,25 @@ func (h *httpHandler) listCompleted(w http.ResponseWriter, r *http.Request) erro return nil } +// callerAddress resolves the EVM address whose data the request may access. +// +// When read authentication is enabled, the auth middleware has placed the +// authenticated address (from the JWT, already normalized) in the request context, +// and it is used verbatim — a caller can only read their own data. When auth is +// disabled, the middleware is a passthrough and the address falls back to the +// ?address= query parameter (not access-controlled — the disabled-auth posture). +func callerAddress(r *http.Request) (string, error) { + if addr, ok := auth.EVMAddressFromContext(r.Context()); ok && addr != "" { + return addr, nil + } + + addr := strings.TrimSpace(r.URL.Query().Get("address")) + if !auth.ValidateEVMAddress(addr) { + return "", apperrors.BadRequestError(nil, "address query parameter is required: must be a 0x-prefixed 40-hex-char EVM address") + } + return auth.NormalizeAddress(addr), nil +} + // parseOutgoingStatus maps ?status= to a transfer status filter for the outgoing // endpoint. Empty or "all" means no status filter. "accepted" is accepted as a // backward-compatible alias for "completed". diff --git a/pkg/transfer/http_auth_test.go b/pkg/transfer/http_auth_test.go new file mode 100644 index 00000000..2e800fa7 --- /dev/null +++ b/pkg/transfer/http_auth_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +package transfer + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "go.uber.org/zap" + + "github.com/chainsafe/canton-middleware/pkg/auth" + "github.com/chainsafe/canton-middleware/pkg/indexer" +) + +// stubService embeds Service so it satisfies the interface; only the method under +// test is implemented. Any other call panics, which is the desired signal in a test. +type stubService struct { + Service + gotAddr string +} + +func (s *stubService) ListIncoming(_ context.Context, evmAddr string, _ indexer.Pagination) (*IncomingTransfersList, error) { + s.gotAddr = evmAddr + return &IncomingTransfersList{}, nil +} + +func authAs(evmAddress string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := auth.WithAuthInfo(r.Context(), &auth.AuthInfo{ + EVMAddress: evmAddress, + CantonParty: "party::test", + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// TestListIncoming_UsesTokenIdentityNotQuery verifies the read endpoint derives the +// address from the authenticated context and ignores any ?address= query param. +func TestListIncoming_UsesTokenIdentityNotQuery(t *testing.T) { + // The address in context is already checksummed at token issuance; the handler + // passes it through unchanged. + authed := auth.NormalizeAddress("0x000000000000000000000000000000000000dead") + svc := &stubService{} + + r := chi.NewRouter() + RegisterRoutes(r, svc, authAs(authed), zap.NewNop()) + + rec := httptest.NewRecorder() + // A different address in the query must be ignored. + req := httptest.NewRequest(http.MethodGet, "/api/v2/transfer/incoming?address=0x0000000000000000000000000000000000000001", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if svc.gotAddr != authed { + t.Fatalf("service received %q, want authenticated address %q", svc.gotAddr, authed) + } +} + +// passthrough is the middleware used when read auth is disabled. +func passthrough(next http.Handler) http.Handler { return next } + +// TestListIncoming_AuthDisabled_UsesQueryAddress verifies that with auth disabled +// (passthrough middleware, no context identity) the handler falls back to ?address=. +func TestListIncoming_AuthDisabled_UsesQueryAddress(t *testing.T) { + want := auth.NormalizeAddress("0x0000000000000000000000000000000000000001") + svc := &stubService{} + + r := chi.NewRouter() + RegisterRoutes(r, svc, passthrough, zap.NewNop()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/transfer/incoming?address="+want, nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if svc.gotAddr != want { + t.Fatalf("service received %q, want %q", svc.gotAddr, want) + } +} + +// TestListIncoming_AuthDisabled_MissingAddress400 verifies that with auth disabled +// and no ?address= there is no caller to resolve, so it's a 400. +func TestListIncoming_AuthDisabled_MissingAddress400(t *testing.T) { + svc := &stubService{} + + r := chi.NewRouter() + RegisterRoutes(r, svc, passthrough, zap.NewNop()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/transfer/incoming", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if svc.gotAddr != "" { + t.Fatal("service must not be called without a resolvable caller") + } +} diff --git a/pkg/user/service/http.go b/pkg/user/service/http.go index 4324e4d8..4224fadf 100644 --- a/pkg/user/service/http.go +++ b/pkg/user/service/http.go @@ -6,12 +6,14 @@ import ( "encoding/json" "io" "net/http" + "strings" "github.com/go-chi/chi/v5" "go.uber.org/zap" 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" "github.com/chainsafe/canton-middleware/pkg/user" ) @@ -24,7 +26,7 @@ type HTTP struct { } // RegisterRoutes registers HTTP endpoints for registration service on the given chi router -func RegisterRoutes(r chi.Router, service Service, logger *zap.Logger) { +func RegisterRoutes(r chi.Router, service Service, readAuth func(http.Handler) http.Handler, logger *zap.Logger) { h := &HTTP{ service: service, logger: logger, @@ -32,7 +34,7 @@ func RegisterRoutes(r chi.Router, service Service, logger *zap.Logger) { r.Post("/register", apphttp.HandleError(h.register)) r.Post("/register/prepare-topology", apphttp.HandleError(h.prepareTopology)) - r.Get("/profile", apphttp.HandleError(h.getUser)) + r.With(readAuth).Get("/profile", apphttp.HandleError(h.getUser)) } // register handles HTTP requests @@ -118,24 +120,20 @@ func (h *HTTP) prepareTopology(w http.ResponseWriter, r *http.Request) error { return nil } -// getUser handles GET /user?address=0x... and returns the registered user profile. -// The caller must provide an EIP-191 signature over the message via X-Signature and -// X-Message headers. Credentials are kept out of query params to avoid leaking them -// into server access logs, CDN logs, and browser history. -// Returns 404 if the address is not registered. +// getUser handles GET /profile and returns the caller's registered profile. With +// read auth enabled the caller's address comes from the bearer token (context); +// with auth disabled it falls back to the ?address= query parameter. func (h *HTTP) getUser(w http.ResponseWriter, r *http.Request) error { - address := r.URL.Query().Get("address") - if address == "" { - return apperrors.BadRequestError(nil, "address query parameter required") - } - - signature := r.Header.Get("X-Signature") - message := r.Header.Get("X-Message") - if signature == "" || message == "" { - return apperrors.UnAuthorizedError(nil, "X-Signature and X-Message headers required") + address, ok := auth.EVMAddressFromContext(r.Context()) + if !ok || address == "" { + address = strings.TrimSpace(r.URL.Query().Get("address")) + if !auth.ValidateEVMAddress(address) { + return apperrors.BadRequestError(nil, "address query parameter is required") + } + address = auth.NormalizeAddress(address) } - resp, err := h.service.GetUser(r.Context(), address, message, signature) + resp, err := h.service.GetUser(r.Context(), address) if err != nil { return err } diff --git a/pkg/user/service/http_test.go b/pkg/user/service/http_test.go index fcedf4f7..bedcb3a3 100644 --- a/pkg/user/service/http_test.go +++ b/pkg/user/service/http_test.go @@ -14,16 +14,39 @@ import ( "go.uber.org/zap" apperrors "github.com/chainsafe/canton-middleware/pkg/app/errors" + "github.com/chainsafe/canton-middleware/pkg/auth" "github.com/chainsafe/canton-middleware/pkg/user" "github.com/chainsafe/canton-middleware/pkg/user/service/mocks" ) func newRegisterTestServer(svc Service) http.Handler { + return newRegisterTestServerWithAuth(svc, passthroughAuth) +} + +func newRegisterTestServerWithAuth(svc Service, readAuth func(http.Handler) http.Handler) http.Handler { r := chi.NewRouter() - RegisterRoutes(r, svc, zap.NewNop()) + RegisterRoutes(r, svc, readAuth, zap.NewNop()) return r } +// passthroughAuth forwards the request untouched, simulating a request that +// reached the handler without an authenticated identity in context. +func passthroughAuth(next http.Handler) http.Handler { return next } + +// authAs returns middleware that injects a fixed authenticated EVM address, +// simulating a validated bearer token. +func authAs(evmAddress string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := auth.WithAuthInfo(r.Context(), &auth.AuthInfo{ + EVMAddress: evmAddress, + CantonParty: "party::test", + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + func TestRegisterHTTP_InvalidJSON_ReturnsBadRequest(t *testing.T) { svc := mocks.NewService(t) handler := newRegisterTestServer(svc) @@ -147,13 +170,13 @@ func TestRegisterHTTP_CantonNative_ResponseCheck(t *testing.T) { } } -func TestGetUserHTTP_MissingAddress_ReturnsBadRequest(t *testing.T) { +func TestGetUserHTTP_AuthDisabled_MissingAddress_Returns400(t *testing.T) { svc := mocks.NewService(t) - handler := newRegisterTestServer(svc) + // passthroughAuth = auth disabled: no context identity and no ?address=, so + // there is no caller to resolve — a 400, and GetUser is never called. + handler := newRegisterTestServerWithAuth(svc, passthroughAuth) req := httptest.NewRequest(http.MethodGet, "/profile", nil) - req.Header.Set("X-Signature", "0xsig") - req.Header.Set("X-Message", "login:0xabc:1234567890") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -162,40 +185,29 @@ func TestGetUserHTTP_MissingAddress_ReturnsBadRequest(t *testing.T) { } } -func TestGetUserHTTP_MissingHeaders_ReturnsUnauthorized(t *testing.T) { +func TestGetUserHTTP_AuthDisabled_UsesQueryAddress(t *testing.T) { svc := mocks.NewService(t) - handler := newRegisterTestServer(svc) + svc.EXPECT().GetUser(mock.Anything, "0x0000000000000000000000000000000000000001"). + Return(&user.User{EVMAddress: "0x0000000000000000000000000000000000000001"}, nil) + handler := newRegisterTestServerWithAuth(svc, passthroughAuth) - req := httptest.NewRequest(http.MethodGet, "/profile?address=0xabc", nil) - // no X-Signature / X-Message headers + req := httptest.NewRequest(http.MethodGet, "/profile?address=0x0000000000000000000000000000000000000001", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) - } - - var got struct { - Error string `json:"error"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("failed to decode response JSON: %v", err) - } - if got.Error != "X-Signature and X-Message headers required" { - t.Fatalf("unexpected error %q", got.Error) + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) } } -func TestGetUserHTTP_ValidHeaders_ReturnsUser(t *testing.T) { +func TestGetUserHTTP_Authenticated_ReturnsUser(t *testing.T) { svc := mocks.NewService(t) svc.EXPECT(). - GetUser(mock.Anything, "0xabc", "login:0xabc:1234567890", "0xsig"). + GetUser(mock.Anything, "0xabc"). Return(&user.User{EVMAddress: "0xabc", CantonParty: "party::xyz"}, nil) - handler := newRegisterTestServer(svc) + handler := newRegisterTestServerWithAuth(svc, authAs("0xabc")) - req := httptest.NewRequest(http.MethodGet, "/profile?address=0xabc", nil) - req.Header.Set("X-Signature", "0xsig") - req.Header.Set("X-Message", "login:0xabc:1234567890") + req := httptest.NewRequest(http.MethodGet, "/profile", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -215,13 +227,11 @@ func TestGetUserHTTP_ValidHeaders_ReturnsUser(t *testing.T) { func TestGetUserHTTP_ServiceReturnsNotFound_Returns404(t *testing.T) { svc := mocks.NewService(t) svc.EXPECT(). - GetUser(mock.Anything, "0xabc", "login:0xabc:1234567890", "0xsig"). + GetUser(mock.Anything, "0xabc"). Return(nil, apperrors.ResourceNotFoundError(nil, "user not found")) - handler := newRegisterTestServer(svc) + handler := newRegisterTestServerWithAuth(svc, authAs("0xabc")) - req := httptest.NewRequest(http.MethodGet, "/profile?address=0xabc", nil) - req.Header.Set("X-Signature", "0xsig") - req.Header.Set("X-Message", "login:0xabc:1234567890") + req := httptest.NewRequest(http.MethodGet, "/profile", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -239,31 +249,3 @@ func TestGetUserHTTP_ServiceReturnsNotFound_Returns404(t *testing.T) { t.Fatalf("expected error %q, got %q", "user not found", got.Error) } } - -func TestGetUserHTTP_ServiceReturnsUnauthorized_Returns401(t *testing.T) { - svc := mocks.NewService(t) - svc.EXPECT(). - GetUser(mock.Anything, "0xabc", "login:0xabc:1234567890", "0xsig"). - Return(nil, apperrors.UnAuthorizedError(nil, "invalid signature")) - handler := newRegisterTestServer(svc) - - req := httptest.NewRequest(http.MethodGet, "/profile?address=0xabc", nil) - req.Header.Set("X-Signature", "0xsig") - req.Header.Set("X-Message", "login:0xabc:1234567890") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusUnauthorized { - t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) - } - - var got struct { - Error string `json:"error"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("failed to decode response JSON: %v", err) - } - if got.Error != "invalid signature" { - t.Fatalf("expected error %q, got %q", "invalid signature", got.Error) - } -} diff --git a/pkg/user/service/log.go b/pkg/user/service/log.go index 90f4425a..3fdfa71c 100644 --- a/pkg/user/service/log.go +++ b/pkg/user/service/log.go @@ -165,7 +165,7 @@ func (ls *logService) PrepareExternalRegistration( return ls.svc.PrepareExternalRegistration(ctx, req) } -func (ls *logService) GetUser(ctx context.Context, evmAddress, msg, sig string) (usr *user.User, err error) { +func (ls *logService) GetUser(ctx context.Context, evmAddress string) (usr *user.User, err error) { start := time.Now() ls.logger.Info("GetUser started", @@ -195,7 +195,7 @@ func (ls *logService) GetUser(ctx context.Context, evmAddress, msg, sig string) } }() - return ls.svc.GetUser(ctx, evmAddress, msg, sig) + return ls.svc.GetUser(ctx, evmAddress) } // Helper functions for sensitive data redaction diff --git a/pkg/user/service/mocks/mock_service.go b/pkg/user/service/mocks/mock_service.go index 826fc519..c2d45baa 100644 --- a/pkg/user/service/mocks/mock_service.go +++ b/pkg/user/service/mocks/mock_service.go @@ -23,9 +23,9 @@ func (_m *Service) EXPECT() *Service_Expecter { return &Service_Expecter{mock: &_m.Mock} } -// GetUser provides a mock function with given fields: ctx, evmAddress, msg, sig -func (_m *Service) GetUser(ctx context.Context, evmAddress string, msg string, sig string) (*user.User, error) { - ret := _m.Called(ctx, evmAddress, msg, sig) +// GetUser provides a mock function with given fields: ctx, evmAddress +func (_m *Service) GetUser(ctx context.Context, evmAddress string) (*user.User, error) { + ret := _m.Called(ctx, evmAddress) if len(ret) == 0 { panic("no return value specified for GetUser") @@ -33,19 +33,19 @@ func (_m *Service) GetUser(ctx context.Context, evmAddress string, msg string, s var r0 *user.User var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (*user.User, error)); ok { - return rf(ctx, evmAddress, msg, sig) + if rf, ok := ret.Get(0).(func(context.Context, string) (*user.User, error)); ok { + return rf(ctx, evmAddress) } - if rf, ok := ret.Get(0).(func(context.Context, string, string, string) *user.User); ok { - r0 = rf(ctx, evmAddress, msg, sig) + if rf, ok := ret.Get(0).(func(context.Context, string) *user.User); ok { + r0 = rf(ctx, evmAddress) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*user.User) } } - if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { - r1 = rf(ctx, evmAddress, msg, sig) + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, evmAddress) } else { r1 = ret.Error(1) } @@ -61,15 +61,13 @@ type Service_GetUser_Call struct { // GetUser is a helper method to define mock.On call // - ctx context.Context // - evmAddress string -// - msg string -// - sig string -func (_e *Service_Expecter) GetUser(ctx interface{}, evmAddress interface{}, msg interface{}, sig interface{}) *Service_GetUser_Call { - return &Service_GetUser_Call{Call: _e.mock.On("GetUser", ctx, evmAddress, msg, sig)} +func (_e *Service_Expecter) GetUser(ctx interface{}, evmAddress interface{}) *Service_GetUser_Call { + return &Service_GetUser_Call{Call: _e.mock.On("GetUser", ctx, evmAddress)} } -func (_c *Service_GetUser_Call) Run(run func(ctx context.Context, evmAddress string, msg string, sig string)) *Service_GetUser_Call { +func (_c *Service_GetUser_Call) Run(run func(ctx context.Context, evmAddress string)) *Service_GetUser_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string)) + run(args[0].(context.Context), args[1].(string)) }) return _c } @@ -79,7 +77,7 @@ func (_c *Service_GetUser_Call) Return(_a0 *user.User, _a1 error) *Service_GetUs return _c } -func (_c *Service_GetUser_Call) RunAndReturn(run func(context.Context, string, string, string) (*user.User, error)) *Service_GetUser_Call { +func (_c *Service_GetUser_Call) RunAndReturn(run func(context.Context, string) (*user.User, error)) *Service_GetUser_Call { _c.Call.Return(run) return _c } diff --git a/pkg/user/service/service.go b/pkg/user/service/service.go index 15ef79fd..be36a21a 100644 --- a/pkg/user/service/service.go +++ b/pkg/user/service/service.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "strings" - "time" "go.uber.org/zap" "google.golang.org/grpc/codes" @@ -35,10 +34,6 @@ const ( // cantonKeySize is the required size for Canton private keys (32 bytes for secp256k1) cantonKeySize = 32 - - // loginMessageMaxAge is the maximum age accepted for a GET /profile login message. - // Must match SESSION_MAX_AGE_MS in the dapp's session.ts. - loginMessageMaxAge = 24 * time.Hour ) var ( @@ -68,7 +63,7 @@ type Service interface { RegisterWeb3User(ctx context.Context, req *user.RegisterRequest) (*user.RegisterResponse, error) RegisterCantonNativeUser(ctx context.Context, req *user.RegisterRequest) (*user.RegisterResponse, error) PrepareExternalRegistration(ctx context.Context, req *user.RegisterRequest) (*user.PrepareTopologyResponse, error) - GetUser(ctx context.Context, evmAddress, msg, sig string) (*user.User, error) + GetUser(ctx context.Context, evmAddress string) (*user.User, error) } type registrationService struct { @@ -464,21 +459,11 @@ func (s *registrationService) PrepareExternalRegistration( }, nil } -func (s *registrationService) GetUser(ctx context.Context, evmAddress, msg, sig string) (*user.User, error) { +// GetUser returns the profile for evmAddress. The caller's identity is +// established by the auth middleware (bearer token) before this is reached, so no +// signature is verified here; evmAddress is taken from the authenticated context. +func (s *registrationService) GetUser(ctx context.Context, evmAddress string) (*user.User, error) { evmAddress = auth.NormalizeAddress(evmAddress) - recoveredAddr, err := auth.VerifyEIP191Signature(msg, sig) - if err != nil { - return nil, apperrors.UnAuthorizedError(err, "invalid signature") - } - if !strings.EqualFold(recoveredAddr.Hex(), evmAddress) { - return nil, apperrors.UnAuthorizedError(nil, "signature does not match address") - } - if !strings.HasPrefix(strings.ToLower(msg), "login:"+strings.ToLower(evmAddress)+":") { - return nil, apperrors.UnAuthorizedError(nil, "message must be of form login:
:") - } - if err = auth.ValidateTimedMessage(msg, loginMessageMaxAge); err != nil { - return nil, apperrors.UnAuthorizedError(err, "message expired or malformed") - } usr, err := s.store.GetUserByEVMAddress(ctx, evmAddress) if err != nil && !errors.Is(err, user.ErrUserNotFound) { diff --git a/pkg/user/service/service_test.go b/pkg/user/service/service_test.go index 83cb2c56..f33a521f 100644 --- a/pkg/user/service/service_test.go +++ b/pkg/user/service/service_test.go @@ -9,7 +9,6 @@ import ( "fmt" "strings" "testing" - "time" "github.com/ethereum/go-ethereum/crypto" "go.uber.org/zap" @@ -197,41 +196,21 @@ func TestRegistrationService_RegisterCantonNativeUser_PartyAlreadyRegistered(t * } // signLoginMessage creates a valid timed EIP-191 login message and signature. -// offsetFromNow shifts the embedded timestamp by the given duration (use a negative -// value to simulate an expired message). -func signLoginMessage(t *testing.T, offsetFromNow time.Duration) (evmAddress, message, signature string) { - t.Helper() - - privateKey, err := crypto.GenerateKey() - if err != nil { - t.Fatalf("GenerateKey() failed: %v", err) - } - - ts := time.Now().Add(offsetFromNow).Unix() - addr := auth.NormalizeAddress(crypto.PubkeyToAddress(privateKey.PublicKey).Hex()) - message = fmt.Sprintf("login:%s:%d", strings.ToLower(addr), ts) - - prefixed := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message) - hash := crypto.Keccak256Hash([]byte(prefixed)) - - sig, err := crypto.Sign(hash.Bytes(), privateKey) - if err != nil { - t.Fatalf("Sign() failed: %v", err) - } - - return addr, message, "0x" + hex.EncodeToString(sig) -} +// GetUser trusts the EVM address supplied by the caller: the auth middleware has +// already established identity from the bearer token before the service is reached, +// so these tests exercise the lookup and error mapping, not signature verification +// (which now lives in pkg/auth/jwt). func TestGetUser_Success(t *testing.T) { ctx := context.Background() - evmAddress, message, signature := signLoginMessage(t, 0) + evmAddress := auth.NormalizeAddress("0x000000000000000000000000000000000000dEaD") expected := &user.User{EVMAddress: evmAddress, CantonParty: "party::abc"} storeMock := mocks.NewStore(t) storeMock.EXPECT().GetUserByEVMAddress(ctx, evmAddress).Return(expected, nil).Once() svc := NewService(storeMock, nil, nil, zap.NewNop(), false, stubChecker{allow: true}, nil) - got, err := svc.GetUser(ctx, evmAddress, message, signature) + got, err := svc.GetUser(ctx, evmAddress) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -240,45 +219,15 @@ func TestGetUser_Success(t *testing.T) { } } -func TestGetUser_ExpiredMessage(t *testing.T) { - ctx := context.Background() - // Timestamp 25 hours in the past — beyond the 24-hour loginMessageMaxAge. - evmAddress, message, signature := signLoginMessage(t, -25*time.Hour) - - svc := NewService(nil, nil, nil, zap.NewNop(), false, nil, nil) - _, err := svc.GetUser(ctx, evmAddress, message, signature) - if err == nil { - t.Fatal("expected unauthorized error for expired message, got nil") - } - if !apperrors.Is(err, apperrors.CategoryUnauthorized) { - t.Fatalf("expected CategoryUnauthorized, got %v", err) - } -} - -func TestGetUser_WrongAddress(t *testing.T) { - ctx := context.Background() - _, message, signature := signLoginMessage(t, 0) - otherAddress := "0x000000000000000000000000000000000000dEaD" - - svc := NewService(nil, nil, nil, zap.NewNop(), false, nil, nil) - _, err := svc.GetUser(ctx, otherAddress, message, signature) - if err == nil { - t.Fatal("expected unauthorized error for mismatched address, got nil") - } - if !apperrors.Is(err, apperrors.CategoryUnauthorized) { - t.Fatalf("expected CategoryUnauthorized, got %v", err) - } -} - func TestGetUser_UserNotFound(t *testing.T) { ctx := context.Background() - evmAddress, message, signature := signLoginMessage(t, 0) + evmAddress := auth.NormalizeAddress("0x000000000000000000000000000000000000dEaD") storeMock := mocks.NewStore(t) storeMock.EXPECT().GetUserByEVMAddress(ctx, evmAddress).Return(nil, user.ErrUserNotFound).Once() svc := NewService(storeMock, nil, nil, zap.NewNop(), false, stubChecker{allow: true}, nil) - _, err := svc.GetUser(ctx, evmAddress, message, signature) + _, err := svc.GetUser(ctx, evmAddress) if err == nil { t.Fatal("expected not-found error, got nil") } @@ -287,58 +236,16 @@ func TestGetUser_UserNotFound(t *testing.T) { } } -func TestGetUser_InvalidSignature(t *testing.T) { - ctx := context.Background() - - svc := NewService(nil, nil, nil, zap.NewNop(), false, nil, nil) - _, err := svc.GetUser(ctx, "0xdeadbeef", "some message", "not-a-valid-signature") - if err == nil { - t.Fatal("expected unauthorized error for invalid signature, got nil") - } - if !apperrors.Is(err, apperrors.CategoryUnauthorized) { - t.Fatalf("expected CategoryUnauthorized, got %v", err) - } -} - -func TestGetUser_InvalidMessageFormat_ReturnsUnauthorized(t *testing.T) { - ctx := context.Background() - - privateKey, err := crypto.GenerateKey() - if err != nil { - t.Fatalf("GenerateKey() failed: %v", err) - } - addr := auth.NormalizeAddress(crypto.PubkeyToAddress(privateKey.PublicKey).Hex()) - - // Sign a message with the wrong operation prefix (transfer instead of login). - msg := fmt.Sprintf("transfer:%s:%d", strings.ToLower(addr), time.Now().Unix()) - prefixed := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(msg), msg) - hash := crypto.Keccak256Hash([]byte(prefixed)) - sig, err := crypto.Sign(hash.Bytes(), privateKey) - if err != nil { - t.Fatalf("Sign() failed: %v", err) - } - hexSig := "0x" + hex.EncodeToString(sig) - - svc := NewService(nil, nil, nil, zap.NewNop(), false, nil, nil) - _, err = svc.GetUser(ctx, addr, msg, hexSig) - if err == nil { - t.Fatal("expected unauthorized error for wrong message prefix, got nil") - } - if !apperrors.Is(err, apperrors.CategoryUnauthorized) { - t.Fatalf("expected CategoryUnauthorized, got %v", err) - } -} - func TestGetUser_StoreError(t *testing.T) { ctx := context.Background() - evmAddress, message, signature := signLoginMessage(t, 0) + evmAddress := auth.NormalizeAddress("0x000000000000000000000000000000000000dEaD") storeErr := errors.New("connection refused") storeMock := mocks.NewStore(t) storeMock.EXPECT().GetUserByEVMAddress(ctx, evmAddress).Return(nil, storeErr).Once() svc := NewService(storeMock, nil, nil, zap.NewNop(), false, stubChecker{allow: true}, nil) - _, err := svc.GetUser(ctx, evmAddress, message, signature) + _, err := svc.GetUser(ctx, evmAddress) if err == nil { t.Fatal("expected error, got nil") } diff --git a/tests/e2e/devstack/shim/apiserver.go b/tests/e2e/devstack/shim/apiserver.go index 7162f772..a8664dda 100644 --- a/tests/e2e/devstack/shim/apiserver.go +++ b/tests/e2e/devstack/shim/apiserver.go @@ -10,13 +10,16 @@ import ( "net/url" "os" "strconv" + "sync" "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" + siwe "github.com/spruceid/siwe-go" + "github.com/chainsafe/canton-middleware/pkg/auth" "github.com/chainsafe/canton-middleware/pkg/ethereum/contracts" "github.com/chainsafe/canton-middleware/pkg/registry" "github.com/chainsafe/canton-middleware/pkg/transfer" @@ -26,6 +29,15 @@ import ( "github.com/chainsafe/canton-middleware/tests/e2e/devstack/util" ) +// SIWE login parameters. They MUST match the api-server's auth config +// (pkg/config/defaults/config.api-server.docker.yaml) or /auth/login rejects the +// signed message. +const ( + siweDomain = "localhost" + siweURI = "http://localhost" + siweChainID = 31337 +) + // defaultAdminAPIKey is the admin bearer token used when ADMIN_API_KEY is not // set in the environment. It MUST match the default in the api-server's compose // environment (docker-compose.yaml: ADMIN_API_KEY: "${ADMIN_API_KEY:-...}") so @@ -49,6 +61,11 @@ var _ stack.APIServer = (*APIServerShim)(nil) type APIServerShim struct { httpClient evm *ethclient.Client + + // tokens caches one SIWE-issued JWT per account address so the read endpoints + // (which require a bearer token when auth is enabled) reuse a single login. + mu sync.Mutex + tokens map[common.Address]string } // NewAPIServer dials the api-server REST endpoint and its /eth JSON-RPC @@ -64,10 +81,60 @@ func NewAPIServer(ctx context.Context, manifest *stack.ServiceManifest) (*APISer endpoint: manifest.APIHTTP, client: &http.Client{Timeout: 30 * time.Second}, }, - evm: ethclient.NewClient(rpcClient), + evm: ethclient.NewClient(rpcClient), + tokens: make(map[common.Address]string), }, nil } +// Login performs the SIWE (EIP-4361) login flow for account and returns a JWT: +// fetch a nonce, build and EIP-191-sign the message, then exchange it at +// /auth/login. The account must already be registered. The token is cached, so +// the read endpoints authenticate transparently after the first call. +func (a *APIServerShim) Login(ctx context.Context, account *stack.Account) (string, error) { + var nr auth.NonceResponse + q := url.Values{"address": []string{account.Address.Hex()}} + if err := a.get(ctx, "/auth/nonce", q, &nr); err != nil { + return "", fmt.Errorf("fetch nonce: %w", err) + } + + // Build the message with the same library the server parses with, so the text + // is a guaranteed-parseable EIP-4361 message. + msg, err := siwe.InitMessage(siweDomain, account.Address.Hex(), siweURI, nr.Nonce, map[string]any{ + "chainId": siweChainID, + "issuedAt": time.Now().UTC().Format(time.RFC3339), + }) + if err != nil { + return "", fmt.Errorf("build SIWE message: %w", err) + } + raw := msg.String() + + sig, err := util.SignEIP191(account.PrivateKey, raw) + if err != nil { + return "", fmt.Errorf("sign SIWE message: %w", err) + } + + var lr auth.LoginResponse + if err := a.post(ctx, "/auth/login", "", "", auth.LoginRequest{Message: raw, Signature: sig}, &lr); err != nil { + return "", fmt.Errorf("login: %w", err) + } + + a.mu.Lock() + a.tokens[account.Address] = lr.Token + a.mu.Unlock() + return lr.Token, nil +} + +// ensureToken returns a cached JWT for account, logging in on first use. +func (a *APIServerShim) ensureToken(ctx context.Context, account *stack.Account) (string, error) { + a.mu.Lock() + tok := a.tokens[account.Address] + a.mu.Unlock() + if tok != "" { + return tok, nil + } + return a.Login(ctx, account) +} + func (a *APIServerShim) Endpoint() string { return a.endpoint } func (a *APIServerShim) RPC() *ethclient.Client { return a.evm } func (a *APIServerShim) Close() { a.evm.Close() } @@ -204,15 +271,19 @@ func (a *APIServerShim) TransferFactory(ctx context.Context) (*registry.Transfer return &resp, nil } -// ListIncomingTransfers sends GET /api/v2/transfer/incoming?address=. The -// endpoint is unauthenticated; account is used only to derive the query parameter. +// ListIncomingTransfers sends GET /api/v2/transfer/incoming as account. The caller +// is taken from account's SIWE-issued bearer token, so it returns only account's +// pending offers. func (a *APIServerShim) ListIncomingTransfers( ctx context.Context, account *stack.Account, ) (*transfer.IncomingTransfersList, error) { - q := url.Values{"address": []string{account.Address.Hex()}} + token, err := a.ensureToken(ctx, account) + if err != nil { + return nil, err + } var resp transfer.IncomingTransfersList - if err := a.get(ctx, "/api/v2/transfer/incoming", q, &resp); err != nil { + if err := a.getBearer(ctx, "/api/v2/transfer/incoming", token, &resp); err != nil { return nil, err } return &resp, nil @@ -259,34 +330,41 @@ func (a *APIServerShim) WithdrawCustodial( return &resp, nil } -// ListOutgoingTransfers sends GET /api/v2/transfer/outgoing?address=&status=. -// The endpoint is unauthenticated; account is used only to derive the address query -// parameter. An empty status omits the filter (server defaults to all). +// ListOutgoingTransfers sends GET /api/v2/transfer/outgoing[?status=] as +// account (caller from its bearer token). An empty status omits the filter (server +// defaults to all). func (a *APIServerShim) ListOutgoingTransfers( ctx context.Context, account *stack.Account, status string, ) (*transfer.OutgoingTransfersList, error) { - q := url.Values{"address": []string{account.Address.Hex()}} + token, err := a.ensureToken(ctx, account) + if err != nil { + return nil, err + } + path := "/api/v2/transfer/outgoing" if status != "" { - q.Set("status", status) + path += "?" + url.Values{"status": []string{status}}.Encode() } var resp transfer.OutgoingTransfersList - if err := a.get(ctx, "/api/v2/transfer/outgoing", q, &resp); err != nil { + if err := a.getBearer(ctx, path, token, &resp); err != nil { return nil, err } return &resp, nil } -// ListCompletedTransfers sends GET /api/v2/transfer/completed?address=. -// The endpoint is unauthenticated; account is used only to derive the query parameter. +// ListCompletedTransfers sends GET /api/v2/transfer/completed as account (caller +// from its bearer token). func (a *APIServerShim) ListCompletedTransfers( ctx context.Context, account *stack.Account, ) (*transfer.CompletedTransfersList, error) { - q := url.Values{"address": []string{account.Address.Hex()}} + token, err := a.ensureToken(ctx, account) + if err != nil { + return nil, err + } var resp transfer.CompletedTransfersList - if err := a.get(ctx, "/api/v2/transfer/completed", q, &resp); err != nil { + if err := a.getBearer(ctx, "/api/v2/transfer/completed", token, &resp); err != nil { return nil, err } return &resp, nil diff --git a/tests/e2e/devstack/stack/interfaces.go b/tests/e2e/devstack/stack/interfaces.go index a602b65f..2f978443 100644 --- a/tests/e2e/devstack/stack/interfaces.go +++ b/tests/e2e/devstack/stack/interfaces.go @@ -201,19 +201,19 @@ type APIServer interface { WithdrawCustodial(ctx context.Context, account *Account, contractID string) (*transfer.ExecuteResponse, error) // ListIncomingTransfers returns pending inbound TransferOffer details for the - // given account via GET /api/v2/transfer/incoming?address=…. The endpoint is - // unauthenticated; account is used only to derive the query parameter. + // given account via GET /api/v2/transfer/incoming. The caller is authenticated + // with account's SIWE-issued bearer token, so it returns only account's data. ListIncomingTransfers(ctx context.Context, account *Account) (*transfer.IncomingTransfersList, error) // ListOutgoingTransfers returns the account's outbound transfers via - // GET /api/v2/transfer/outgoing?address=…&status=…. status filters by - // pending|expired|completed|all (empty string = all). Unauthenticated. + // GET /api/v2/transfer/outgoing[?status=…]. status filters by + // pending|expired|completed|all (empty string = all). Authenticated as account. ListOutgoingTransfers( ctx context.Context, account *Account, status string, ) (*transfer.OutgoingTransfersList, error) // ListCompletedTransfers returns the account's settled transfers across all - // tokens via GET /api/v2/transfer/completed?address=…. Unauthenticated. + // tokens via GET /api/v2/transfer/completed. Authenticated as account. ListCompletedTransfers(ctx context.Context, account *Account) (*transfer.CompletedTransfersList, error) // PrepareAcceptTransfer prepares a non-custodial accept of an inbound offer