diff --git a/.env.example b/.env.example index 1d8ab25..4a96de9 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,5 @@ MAX_ENTRIES_PER_BSON= BEACON_NODE_URL_MAINNET= BEACON_NODE_URL_HOLESKY= BEACON_NODE_URL_GNOSIS= -BEACON_NODE_URL_LUKSO= \ No newline at end of file +BEACON_NODE_URL_LUKSO= +JWT_USERS_FILE= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 06f54b6..89eea8f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .env ./listener/tmp/* -./listener/bin/* \ No newline at end of file +./listener/bin/* +jwt +private.pem +public.pem \ No newline at end of file diff --git a/README.md b/README.md index 4629073..0b228bd 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,11 @@ This repository contains the code for the validator monitoring system. The system is designed to listen signatures request from different networks validate them and store the results in a database. +It also contains a simple JWT generator that can be used to easily generate the JWT token necessary to access some API endpoints. More on this on the [API](#api) section. + In dappnode, the signature request and validation flow is as follows: -1. The staking brain sends the signature request of type `PROOF_OF_VALIDATION` to the web3signer (see https://github.com/Consensys/web3signer/pull/982). The request has the following format: +**1.** The staking brain sends the signature request of type `PROOF_OF_VALIDATION` to the web3signer (see ). The request has the following format: ```json { @@ -16,17 +18,54 @@ In dappnode, the signature request and validation flow is as follows: } ``` -2. The web3signer answers with the `PROOF_OF_VALIDATION` signature. Its important to notice that the order of the items in the JSON matters. +**2.** The web3signer answers with the `PROOF_OF_VALIDATION` signature. Its important to notice that the order of the items in the JSON matters. -3. The staking brain sends back all the `PROOF_OF_VALIDATION` signatures to the the signatures monitoring system. The listener will validate the requests, the validators and the signatures and finally store the result into a mongo db. +**3.** The staking brain sends back all the `PROOF_OF_VALIDATION` signatures to the the signatures monitoring system. The listener will validate the requests, the validators and the signatures and finally store the result into a mongo db. -## API +##  API - `/signatures?network=`: - `POST`: TODO - `GET`: TODO -## Validation +### Authentication + +The `GET /signatures` endpoint is protected by a JWT token, which must be included in the HTTPS request. This token should be passed in the Authorization header using the Bearer schema. The expected format is: + +```text +Bearer +``` + +#### JWT requirements + +To access the `GET /signatures` endpoint, the JWT must meet the following criteria: + +- **Key ID** (`kid`): The JWT must include a kid claim in the header. It will be used to identify which public key to use to verify the signature. + +As a nice to have, the JWT can also include the following claims as part of the payload: + +- **Expiration time** (`exp`): The expiration time of the token, in Unix time. If no `exp` is provided, the token will be valid indefinitely. +- **Subject** (`sub`): Additional information about the user or entity behind the token. (e.g. an email address) + +#### Generating the JWT + +To generate a JWT token, you can use the `jwt-generator` tool included in this repository. The tool requires an RSA private key in PEM format to sign the token. +A keypair in PEM format can be generated using OpenSSL: + +```sh + openssl genrsa -out private.pem 2048 + openssl rsa -in private.pem -pubout -out public.pem +``` + +Once you have the private key, you can generate a JWT token using the `jwt-generator` tool: + +```sh + ./jwt-generator --private-key=path/to/private.pem --kid=your_kid_here --exp=24h --output=path/to/output.jwt +``` + +Only JWT tokens with whitelisted "kid" and pubkey will be accepted. Please contact the dappnode team for more information on this. + +##  Validation The process of validating the request and the signature follows the next steps: @@ -35,10 +74,10 @@ The process of validating the request and the signature follows the next steps: ```go type SignatureRequest struct { - Payload string `json:"payload"` - Pubkey string `json:"pubkey"` - Signature string `json:"signature"` - Tag Tag `json:"tag"` + Payload string `json:"payload"` + Pubkey string `json:"pubkey"` + Signature string `json:"signature"` + Tag Tag `json:"tag"` } ``` @@ -46,19 +85,19 @@ The payload must be encoded in base64 and must have the following format: ```go type DecodedPayload struct { - Type string `json:"type"` - Platform string `json:"platform"` - Timestamp string `json:"timestamp"` + Type string `json:"type"` + Platform string `json:"platform"` + Timestamp string `json:"timestamp"` } ``` -3. The validators must be in status "active_on_going" according to a standard beacon node API, see https://ethereum.github.io/beacon-APIs/#/Beacon/postStateValidators: +3. The validators must be in status "active_on_going" according to a standard beacon node API, see : 3.1 The signatures from the validators that are not in this status will be discarded. 3.2 If in the moment of querying the beacon node to get the validator status the beacon node is down the signature will be accepted storing the validator status as "unknown" for later validation. 4. Only the signatures that have passed the previous steps will be validated. The validation of the signature will be done using the pubkey from the request. 5. Only valid signatures will be stored in the database. -## Crons +##  Crons There are 2 cron to ensure the system is working properly: @@ -88,12 +127,12 @@ bson.M{ "timestamp": req.DecodedPayload.Timestamp, }, }, - } + } ``` **Mongo db UI** -There is a express mongo db UI that can be accessed at `http://localhost:8080`. If its running in dev mode and the compose dev was deployed on a dappnode environment then it can be access through http://ui.dappnode:8080 +There is a express mongo db UI that can be accessed at `http://localhost:8080`. If its running in dev mode and the compose dev was deployed on a dappnode environment then it can be access through ## Environment variables diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0c4ebf0..c921a1b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -11,6 +11,7 @@ services: volumes: - ./listener/cmd:/app/cmd - ./listener/internal:/app/internal + - ./jwt:/app/jwt networks: dncore_network: aliases: diff --git a/docker-compose.yml b/docker-compose.yml index b27dfa5..31b78c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,11 +14,13 @@ services: BEACON_NODE_URL_LUKSO: ${BEACON_NODE_URL_LUKSO} BEACON_NODE_URL_GNOSIS: ${BEACON_NODE_URL_GNOSIS} MAX_ENTRIES_PER_BSON: ${MAX_ENTRIES_PER_BSON} + JWT_USERS_FILE: ${JWT_USERS_FILE} depends_on: - mongo container_name: listener restart: always - + volumes: + - ./jwt:/app/jwt ## listener expects /app/jwt to exist, careful when changing this path ui: build: context: ui diff --git a/jwt/users.json.example b/jwt/users.json.example new file mode 100644 index 0000000..74b84d5 --- /dev/null +++ b/jwt/users.json.example @@ -0,0 +1,6 @@ +{ + "stader": { + "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq81M9pHCZEExzJFgWEXK\navIs0AexsLlP6CIGkbvfe/GX+kIjP28kkXYGCJlUuVhGYa8wU2mBYeXTbtvi9OR9\ndmKTOzsl3QzIKVd5BqXqbTmQxGp0S6ShujK6LHTOELxwYhFKulx2ls2DSyXhqOGx\nyh0Gm/3H7CiCgNHMJWUUiy5Xyp71vtimzDM+OniUVQE/ZjPg5WG+cM536Ms8XcK1\nNIN0z8ovgAibHqw8jEljxM89Sn9XD3mQo8kBTG+3dLsjUbHZDiJZogNgeXsOrM7m\nh3YtIwMvr5YEWUR7ON7ST5Wrwx14uF6YDE0yo6nb/cmmSSUJ/cdX36dNK3dGrYhB\nywIDAQAB\n-----END PUBLIC KEY-----", + "tags": ["solo"] + } +} diff --git a/listener/cmd/jwt-generator/main.go b/listener/cmd/jwt-generator/main.go new file mode 100644 index 0000000..507e237 --- /dev/null +++ b/listener/cmd/jwt-generator/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "flag" + "fmt" + "os" + "time" + + "github.com/dappnode/validator-monitoring/listener/internal/logger" + + "github.com/golang-jwt/jwt/v5" +) + +func main() { + // Define flags for the command-line input + privateKeyPath := flag.String("private-key", "", "Path to the RSA private key file (mandatory)") + subject := flag.String("sub", "", "Subject claim for the JWT (optional)") + expiration := flag.String("exp", "", "Expiration duration for the JWT in hours (optional, e.g., '24h' for 24 hours). If no value is provided, the generated token will not expire.") + kid := flag.String("kid", "", "Key ID (kid) for the JWT (mandatory)") + outputFilePath := flag.String("output", "token.jwt", "Output file path for the JWT. Defaults to ./token.jwt") + + flag.Parse() + + // Check for mandatory parameters + if *kid == "" || *privateKeyPath == "" { + logger.Fatal("Key ID (kid) and private key path must be provided") + } + + // Read the private key file + privateKeyData, err := os.ReadFile(*privateKeyPath) + if err != nil { + logger.Fatal(fmt.Sprintf("Failed to read private key file: %v", err)) + } + + // Parse the RSA private key + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyData) + if err != nil { + logger.Fatal(fmt.Sprintf("Failed to parse private key: %v", err)) + } + + // Prepare the claims for the JWT. These are optional + claims := jwt.MapClaims{} + if *subject != "" { + claims["sub"] = *subject + } + if *expiration != "" { + duration, err := time.ParseDuration(*expiration) + if err != nil { + logger.Fatal(fmt.Sprintf("Failed to parse expiration duration: %v", err)) + } + claims["exp"] = time.Now().Add(duration).Unix() + } + + // Create a new token object, specifying signing method and claims + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + + // Set the key ID (kid) in the token header + token.Header["kid"] = *kid + + // Sign the token with the private key + tokenString, err := token.SignedString(privateKey) + if err != nil { + logger.Fatal(fmt.Sprintf("Failed to sign token: %v", err)) + } + + // Output the token to the console + fmt.Println("JWT generated successfully:") + fmt.Println(tokenString) + + // Save the token to a file + err = os.WriteFile(*outputFilePath, []byte(tokenString), 0644) + if err != nil { + logger.Fatal(fmt.Sprintf("Failed to write the JWT to file: %v", err)) + } + fmt.Println("JWT saved to file:", *outputFilePath) +} diff --git a/listener/cmd/listener/main.go b/listener/cmd/listener/main.go index 67af453..5ddd2c1 100644 --- a/listener/cmd/listener/main.go +++ b/listener/cmd/listener/main.go @@ -52,6 +52,7 @@ func main() { dbCollection, config.BeaconNodeURLs, config.MaxEntriesPerBson, + config.JWTUsersFilePath, ) // Start the API server in a goroutine. Needs to be in a goroutine to allow for the cron job to run, diff --git a/listener/go.mod b/listener/go.mod index 5fa4032..3d41fea 100644 --- a/listener/go.mod +++ b/listener/go.mod @@ -3,6 +3,7 @@ module github.com/dappnode/validator-monitoring/listener go 1.22.0 require ( + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/gorilla/mux v1.8.1 github.com/herumi/bls-eth-go-binary v1.35.0 github.com/robfig/cron v1.2.0 diff --git a/listener/go.sum b/listener/go.sum index 1041429..b272ac8 100644 --- a/listener/go.sum +++ b/listener/go.sum @@ -1,5 +1,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= diff --git a/listener/internal/api/api.go b/listener/internal/api/api.go index a8adef9..f29ca9b 100644 --- a/listener/internal/api/api.go +++ b/listener/internal/api/api.go @@ -18,16 +18,18 @@ type httpApi struct { dbCollection *mongo.Collection beaconNodeUrls map[types.Network]string maxEntriesPerBson int + jwtUsersFilePath string } // create a new api instance -func NewApi(port string, dbClient *mongo.Client, dbCollection *mongo.Collection, beaconNodeUrls map[types.Network]string, maxEntriesPerBson int) *httpApi { +func NewApi(port string, dbClient *mongo.Client, dbCollection *mongo.Collection, beaconNodeUrls map[types.Network]string, maxEntriesPerBson int, jwtUsersFilePath string) *httpApi { return &httpApi{ port: port, dbClient: dbClient, dbCollection: dbCollection, beaconNodeUrls: beaconNodeUrls, maxEntriesPerBson: maxEntriesPerBson, + jwtUsersFilePath: jwtUsersFilePath, } } @@ -41,7 +43,7 @@ func (s *httpApi) Start() { s.server = &http.Server{ Addr: ":" + s.port, - Handler: routes.SetupRouter(s.dbCollection, s.beaconNodeUrls, s.maxEntriesPerBson), + Handler: routes.SetupRouter(s.dbCollection, s.beaconNodeUrls, s.maxEntriesPerBson, s.jwtUsersFilePath), } // ListenAndServe returns ErrServerClosed to indicate that the server has been shut down when the server is closed gracefully. We need to diff --git a/listener/internal/api/handlers/getSignatures.go b/listener/internal/api/handlers/getSignatures.go new file mode 100644 index 0000000..9c1b1e3 --- /dev/null +++ b/listener/internal/api/handlers/getSignatures.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/dappnode/validator-monitoring/listener/internal/api/middleware" + "github.com/dappnode/validator-monitoring/listener/internal/logger" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +func GetSignatures(w http.ResponseWriter, r *http.Request, dbCollection *mongo.Collection) { + logger.Debug("Received new GET '/signatures' request") + // Get tags from the context + tags, ok := r.Context().Value(middleware.TagsKey).([]string) + // middlewware already checks that tags is not empty. If something fails here, it is + // because middleware didnt pass context correctly + if !ok || len(tags) == 0 { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Query MongoDB for documents with tags matching the context tags + var results []bson.M + filter := bson.M{ + "tag": bson.M{"$in": tags}, + } + cursor, err := dbCollection.Find(context.Background(), filter) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to query MongoDB: %v", err), http.StatusInternalServerError) + return + } + defer cursor.Close(context.Background()) + + if err := cursor.All(context.Background(), &results); err != nil { + http.Error(w, fmt.Sprintf("Failed to read cursor: %v", err), http.StatusInternalServerError) + return + } + + // Return the results as JSON + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(results); err != nil { + http.Error(w, fmt.Sprintf("Failed to encode results: %v", err), http.StatusInternalServerError) + } +} diff --git a/listener/internal/api/middleware/jwtMiddleware.go b/listener/internal/api/middleware/jwtMiddleware.go new file mode 100644 index 0000000..65f918d --- /dev/null +++ b/listener/internal/api/middleware/jwtMiddleware.go @@ -0,0 +1,111 @@ +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +type KeyId struct { + PublicKey string `json:"publicKey"` + Tags []string `json:"tags"` +} + +type contextKey string + +const TagsKey contextKey = "tags" + +// JWTMiddleware dynamically checks tokens against public keys loaded from a JSON file +func JWTMiddleware(next http.Handler, jwtUsersFilePath string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Authorization header is required", http.StatusUnauthorized) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + http.Error(w, "Authorization header format must be Bearer {token}", http.StatusUnauthorized) + return + } + + tokenString := parts[1] + + // Load all key ids from the whitelist JSON data file + keyIds, err := loadKeyIds(jwtUsersFilePath) + if err != nil { + http.Error(w, fmt.Sprintf("Internal server error: %v", err), http.StatusInternalServerError) + return + } + + // Parse and verify the token + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.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("kid not found in token header, generate a new token with a 'kid'") + } + + // Load the public key for the given kid + entry, exists := keyIds[kid] + if !exists { + return nil, fmt.Errorf("public key not found for kid: %s", kid) + } + + // Return the public key for signature verification + return jwt.ParseRSAPublicKeyFromPEM([]byte(entry.PublicKey)) + }) + + if err != nil || !token.Valid { + http.Error(w, fmt.Sprintf("Invalid token or claims: %v", err), http.StatusUnauthorized) + return + } + + // Extract the kid and find the associated tags. We have to do this again because the token is parsed in a separate function. + kid, ok := token.Header["kid"].(string) + if !ok { + http.Error(w, "kid not found in token header", http.StatusUnauthorized) + return + } + + entry, exists := keyIds[kid] + if !exists { + http.Error(w, "public key not found for kid", http.StatusUnauthorized) + return + } + + // If the key id is found, but no tags are associated with it, it means the key is not authorized to access + // any signature. This should never happen. + if len(entry.Tags) == 0 { + http.Error(w, "no authorized tags found for given kid", http.StatusUnauthorized) + return + } + + // Store tags in context. We will use this in the handler to query MongoDB + ctx := context.WithValue(r.Context(), TagsKey, entry.Tags) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func loadKeyIds(filePath string) (map[string]KeyId, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + var keys map[string]KeyId + if err := json.Unmarshal(data, &keys); err != nil { + return nil, err + } + + return keys, nil +} diff --git a/listener/internal/api/routes/routes.go b/listener/internal/api/routes/routes.go index 5e0516c..d06cf6e 100644 --- a/listener/internal/api/routes/routes.go +++ b/listener/internal/api/routes/routes.go @@ -4,12 +4,13 @@ import ( "net/http" "github.com/dappnode/validator-monitoring/listener/internal/api/handlers" + "github.com/dappnode/validator-monitoring/listener/internal/api/middleware" "github.com/dappnode/validator-monitoring/listener/internal/api/types" "github.com/gorilla/mux" "go.mongodb.org/mongo-driver/mongo" ) -func SetupRouter(dbCollection *mongo.Collection, beaconNodeUrls map[types.Network]string, maxEntriesPerBson int) *mux.Router { +func SetupRouter(dbCollection *mongo.Collection, beaconNodeUrls map[types.Network]string, maxEntriesPerBson int, jwtUsersFilePath string) *mux.Router { r := mux.NewRouter() // Define routes @@ -19,8 +20,10 @@ func SetupRouter(dbCollection *mongo.Collection, beaconNodeUrls map[types.Networ handlers.PostSignatures(w, r, dbCollection, beaconNodeUrls, maxEntriesPerBson) }).Methods(http.MethodPost) - // Middlewares - // r.Use(corsmiddleware())) + // this method uses JWTmiddleware as auth + r.Handle("/signatures", middleware.JWTMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlers.GetSignatures(w, r, dbCollection) + }), jwtUsersFilePath)).Methods(http.MethodGet) return r } diff --git a/listener/internal/config/getConfig.go b/listener/internal/config/getConfig.go index 2af8557..7f1c30b 100644 --- a/listener/internal/config/getConfig.go +++ b/listener/internal/config/getConfig.go @@ -21,6 +21,7 @@ type Config struct { BeaconNodeURLs map[types.Network]string // Max number of entries allowed per BSON document MaxEntriesPerBson int + JWTUsersFilePath string } func GetConfig() (*Config, error) { @@ -68,7 +69,14 @@ func GetConfig() (*Config, error) { return nil, fmt.Errorf("MAX_ENTRIES_PER_BSON is not a valid integer") } - // print all envs beauty with newlines + jwtUsersFileName := os.Getenv("JWT_USERS_FILE") + if jwtUsersFileName == "" { + return nil, fmt.Errorf("JWT_USERS_FILE is not set") + } + // we are hardcoding /app/jwt inside the container. This is because docker-compose has a bind mount hardcoded + // to that same path (./jwt:/app/jwt). Any changes here should be reflected in docker-compose.yml + jwtUsersFilePath := "/app/jwt/" + jwtUsersFileName + logger.Info("LOG_LEVEL: " + logLevel) logger.Info("API_PORT: " + apiPort) logger.Info("MONGO_DB_URI: " + mongoDBURI) @@ -77,6 +85,7 @@ func GetConfig() (*Config, error) { logger.Info("BEACON_NODE_URL_GNOSIS: " + beaconGnosis) logger.Info("BEACON_NODE_URL_LUKSO: " + beaconLukso) logger.Info("MAX_ENTRIES_PER_BSON: " + maxEntriesPerBsonStr) + logger.Info("JWT_USERS_FILE_PATH: " + jwtUsersFilePath) beaconNodeURLs := map[types.Network]string{ types.Mainnet: beaconMainnet, @@ -91,5 +100,6 @@ func GetConfig() (*Config, error) { LogLevel: logLevel, BeaconNodeURLs: beaconNodeURLs, MaxEntriesPerBson: MaxEntriesPerBson, + JWTUsersFilePath: jwtUsersFilePath, }, nil }