Skip to content

Commit

Permalink
move jwt generator & update readme
Browse files Browse the repository at this point in the history
  • Loading branch information
Marketen committed May 24, 2024
1 parent 606ea6b commit c54b5ca
Show file tree
Hide file tree
Showing 7 changed files with 160 additions and 99 deletions.
71 changes: 55 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/Consensys/web3signer/pull/982>). The request has the following format:

```json
{
Expand All @@ -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=<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 token>
```

#### 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:

Expand All @@ -35,30 +74,30 @@ 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"`
}
```

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 <https://ethereum.github.io/beacon-APIs/#/Beacon/postStateValidators>:
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:

Expand Down Expand Up @@ -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 <http://ui.dappnode:8080>

## Environment variables

Expand Down
5 changes: 0 additions & 5 deletions jwt-generator/go.mod

This file was deleted.

2 changes: 0 additions & 2 deletions jwt-generator/go.sum

This file was deleted.

56 changes: 0 additions & 56 deletions jwt-generator/main.go

This file was deleted.

76 changes: 76 additions & 0 deletions listener/cmd/jwt-generator/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
14 changes: 8 additions & 6 deletions listener/internal/api/handlers/getSignatures.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 21 additions & 14 deletions listener/internal/api/middleware/jwtMiddleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand All @@ -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)
}
Expand All @@ -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
}
Expand Down

0 comments on commit c54b5ca

Please sign in to comment.