Skip to content
Closed
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
4 changes: 0 additions & 4 deletions config.e2e-local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,6 @@ eth_rpc:
chain_id: 31337
request_timeout: "30s"

jwks:
url: ""
issuer: ""

monitoring:
enabled: false

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
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
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 @@ -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 == "" {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions pkg/auth/config.go
Original file line number Diff line number Diff line change
@@ -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"`
}
Loading
Loading