Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
84 changes: 69 additions & 15 deletions pkg/app/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -308,6 +310,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 == "" {
Expand Down Expand Up @@ -382,7 +427,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 {
Expand All @@ -405,16 +451,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)
Expand Down
15 changes: 14 additions & 1 deletion pkg/config/defaults/config.api-server.docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,20 @@ eth_rpc:
chain_id: 31337 # Anvil local chain ID
request_timeout: "30s"

# JWKS endpoint for JWT validation (optional - if not using EVM signatures)
# 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
server:
Expand Down
12 changes: 12 additions & 0 deletions pkg/config/defaults/config.api-server.local-devnet.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ eth_rpc:
chain_id: 1155111101 # Custom: Sepolia (11155111) + 01 suffix for Canton local
request_timeout: "60s"

# 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
server:
Expand Down
13 changes: 13 additions & 0 deletions pkg/config/defaults/config.api-server.mainnet.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ eth_rpc:
chain_id: 1337
request_timeout: "60s"

# 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
server:
Expand Down
90 changes: 55 additions & 35 deletions pkg/transfer/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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))

Expand Down Expand Up @@ -149,31 +156,23 @@ 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)
if err != nil {
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
}
Expand All @@ -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)
Expand All @@ -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
}
Expand All @@ -212,23 +207,19 @@ 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)
if err != nil {
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
}
Expand All @@ -237,6 +228,35 @@ 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. A ?address= that
// does not match the token is rejected with a 403 rather than silently ignored, so a
// client wrongly targeting another address fails loudly instead of quietly receiving
// its 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 != "" {
// Normalize here too: the transfer service looks the address up verbatim
// and does not normalize, so both branches must yield a canonical address.
authenticated := auth.NormalizeAddress(addr)
if q := strings.TrimSpace(r.URL.Query().Get("address")); q != "" &&
(!auth.ValidateEVMAddress(q) || auth.NormalizeAddress(q) != authenticated) {
return "", apperrors.ForbiddenError(nil, "address query parameter does not match the authenticated identity")
}
return authenticated, nil
}
Comment thread
sadiq1971 marked this conversation as resolved.

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".
Expand Down
Loading
Loading