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/jwt-generator/go.mod b/jwt-generator/go.mod deleted file mode 100644 index 2974a51..0000000 --- a/jwt-generator/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module github.com/dappnode/validator-monitoring/jwt-generator - -go 1.22.3 - -require github.com/golang-jwt/jwt/v5 v5.2.1 diff --git a/jwt-generator/go.sum b/jwt-generator/go.sum deleted file mode 100644 index f56d3e6..0000000 --- a/jwt-generator/go.sum +++ /dev/null @@ -1,2 +0,0 @@ -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= diff --git a/jwt-generator/main.go b/jwt-generator/main.go deleted file mode 100644 index 193687b..0000000 --- a/jwt-generator/main.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "os" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -func main() { - // Define flags for the command-line input - privateKeyPath := flag.String("private-key", "private.pem", "Path to the RSA private key file") - subject := flag.String("sub", "user@example.com", "Subject claim for the JWT") - expiration := flag.String("exp", "24h", "Expiration duration for the JWT (e.g., '24h' for 24 hours)") - kid := flag.String("kid", "key1", "Key ID (kid) for the JWT") - flag.Parse() - - // Read the private key file - privateKeyData, err := os.ReadFile(*privateKeyPath) - if err != nil { - log.Fatalf("Failed to read private key file: %v", err) - } - - // Parse the RSA private key - privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyData) - if err != nil { - log.Fatalf("Failed to parse private key: %v", err) - } - - // Parse the expiration duration - duration, err := time.ParseDuration(*expiration) - if err != nil { - log.Fatalf("Failed to parse expiration duration: %v", err) - } - - // Create a new token object, specifying signing method and claims - token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ - "sub": *subject, - "exp": time.Now().Add(duration).Unix(), - }) - - // 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 { - log.Fatalf("Failed to sign token: %v", err) - } - - // Output the token - fmt.Println(tokenString) -} 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/internal/api/handlers/getSignatures.go b/listener/internal/api/handlers/getSignatures.go index 0931098..2e82052 100644 --- a/listener/internal/api/handlers/getSignatures.go +++ b/listener/internal/api/handlers/getSignatures.go @@ -12,17 +12,19 @@ import ( ) func GetSignatures(w http.ResponseWriter, r *http.Request, dbCollection *mongo.Collection) { - // Get roles from the context - roles, ok := r.Context().Value(middleware.RolesKey).([]string) - if !ok || len(roles) == 0 { - http.Error(w, "Roles not found in context", http.StatusUnauthorized) + // 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 roles + // Query MongoDB for documents with tags matching the context tags var results []bson.M filter := bson.M{ - "tag": bson.M{"$in": roles}, + "tag": bson.M{"$in": tags}, } cursor, err := dbCollection.Find(context.Background(), filter) if err != nil { diff --git a/listener/internal/api/middleware/jwtMiddleware.go b/listener/internal/api/middleware/jwtMiddleware.go index ca46cab..a9317f2 100644 --- a/listener/internal/api/middleware/jwtMiddleware.go +++ b/listener/internal/api/middleware/jwtMiddleware.go @@ -11,14 +11,14 @@ import ( "github.com/golang-jwt/jwt/v5" ) -type PublicKeyEntry struct { +type KeyId struct { PublicKey string `json:"publicKey"` - Roles []string `json:"roles"` + Tags []string `json:"tags"` } type contextKey string -const RolesKey contextKey = "roles" +const TagsKey contextKey = "tags" // JWTMiddleware dynamically checks tokens against public keys loaded from a JSON file func JWTMiddleware(next http.Handler) http.Handler { @@ -37,10 +37,10 @@ func JWTMiddleware(next http.Handler) http.Handler { tokenString := parts[1] - // Load public keys from JSON file - publicKeys, err := loadPublicKeys("/app/jwt/users.json") + // Load all key ids from the whitelist JSON data file + keyIds, err := loadKeyIds("/app/jwt/users.json") if err != nil { - http.Error(w, fmt.Sprintf("Failed to load public keys: %v", err), http.StatusInternalServerError) + http.Error(w, fmt.Sprintf("Internal server error: %v", err), http.StatusInternalServerError) return } @@ -52,11 +52,11 @@ func JWTMiddleware(next http.Handler) http.Handler { kid, ok := token.Header["kid"].(string) if !ok { - return nil, fmt.Errorf("kid not found in token header") + 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 := publicKeys[kid] + entry, exists := keyIds[kid] if !exists { return nil, fmt.Errorf("public key not found for kid: %s", kid) } @@ -70,32 +70,39 @@ func JWTMiddleware(next http.Handler) http.Handler { return } - // Extract the kid and find the associated roles + // 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 := publicKeys[kid] + entry, exists := keyIds[kid] if !exists { http.Error(w, "public key not found for kid", http.StatusUnauthorized) return } - // Store roles in context - ctx := context.WithValue(r.Context(), RolesKey, entry.Roles) + // 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 loadPublicKeys(filePath string) (map[string]PublicKeyEntry, error) { +func loadKeyIds(filePath string) (map[string]KeyId, error) { data, err := os.ReadFile(filePath) if err != nil { return nil, err } - var keys map[string]PublicKeyEntry + var keys map[string]KeyId if err := json.Unmarshal(data, &keys); err != nil { return nil, err }