Skip to content

Commit b87d9b6

Browse files
authored
jwt validation v1 (#57)
* jwt validation v1
1 parent e6c7191 commit b87d9b6

File tree

15 files changed

+331
-25
lines changed

15 files changed

+331
-25
lines changed

.env.example

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ MAX_ENTRIES_PER_BSON=
77
BEACON_NODE_URL_MAINNET=
88
BEACON_NODE_URL_HOLESKY=
99
BEACON_NODE_URL_GNOSIS=
10-
BEACON_NODE_URL_LUKSO=
10+
BEACON_NODE_URL_LUKSO=
11+
JWT_USERS_FILE=

.gitignore

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
.env
22
./listener/tmp/*
3-
./listener/bin/*
3+
./listener/bin/*
4+
jwt
5+
private.pem
6+
public.pem

README.md

+55-16
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
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.
66

7+
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.
8+
79
In dappnode, the signature request and validation flow is as follows:
810

9-
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:
11+
**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:
1012

1113
```json
1214
{
@@ -16,17 +18,54 @@ In dappnode, the signature request and validation flow is as follows:
1618
}
1719
```
1820

19-
2. The web3signer answers with the `PROOF_OF_VALIDATION` signature. Its important to notice that the order of the items in the JSON matters.
21+
**2.** The web3signer answers with the `PROOF_OF_VALIDATION` signature. Its important to notice that the order of the items in the JSON matters.
2022

21-
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.
23+
**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.
2224

23-
## API
25+
##  API
2426

2527
- `/signatures?network=<network>`:
2628
- `POST`: TODO
2729
- `GET`: TODO
2830

29-
## Validation
31+
### Authentication
32+
33+
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:
34+
35+
```text
36+
Bearer <JWT token>
37+
```
38+
39+
#### JWT requirements
40+
41+
To access the `GET /signatures` endpoint, the JWT must meet the following criteria:
42+
43+
- **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.
44+
45+
As a nice to have, the JWT can also include the following claims as part of the payload:
46+
47+
- **Expiration time** (`exp`): The expiration time of the token, in Unix time. If no `exp` is provided, the token will be valid indefinitely.
48+
- **Subject** (`sub`): Additional information about the user or entity behind the token. (e.g. an email address)
49+
50+
#### Generating the JWT
51+
52+
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.
53+
A keypair in PEM format can be generated using OpenSSL:
54+
55+
```sh
56+
openssl genrsa -out private.pem 2048
57+
openssl rsa -in private.pem -pubout -out public.pem
58+
```
59+
60+
Once you have the private key, you can generate a JWT token using the `jwt-generator` tool:
61+
62+
```sh
63+
./jwt-generator --private-key=path/to/private.pem --kid=your_kid_here --exp=24h --output=path/to/output.jwt
64+
```
65+
66+
Only JWT tokens with whitelisted "kid" and pubkey will be accepted. Please contact the dappnode team for more information on this.
67+
68+
##  Validation
3069

3170
The process of validating the request and the signature follows the next steps:
3271

@@ -35,30 +74,30 @@ The process of validating the request and the signature follows the next steps:
3574

3675
```go
3776
type SignatureRequest struct {
38-
Payload string `json:"payload"`
39-
Pubkey string `json:"pubkey"`
40-
Signature string `json:"signature"`
41-
Tag Tag `json:"tag"`
77+
Payload string `json:"payload"`
78+
Pubkey string `json:"pubkey"`
79+
Signature string `json:"signature"`
80+
Tag Tag `json:"tag"`
4281
}
4382
```
4483

4584
The payload must be encoded in base64 and must have the following format:
4685

4786
```go
4887
type DecodedPayload struct {
49-
Type string `json:"type"`
50-
Platform string `json:"platform"`
51-
Timestamp string `json:"timestamp"`
88+
Type string `json:"type"`
89+
Platform string `json:"platform"`
90+
Timestamp string `json:"timestamp"`
5291
}
5392
```
5493

55-
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:
94+
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>:
5695
3.1 The signatures from the validators that are not in this status will be discarded.
5796
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.
5897
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.
5998
5. Only valid signatures will be stored in the database.
6099

61-
## Crons
100+
##  Crons
62101

63102
There are 2 cron to ensure the system is working properly:
64103

@@ -88,12 +127,12 @@ bson.M{
88127
"timestamp": req.DecodedPayload.Timestamp,
89128
},
90129
},
91-
}
130+
}
92131
```
93132

94133
**Mongo db UI**
95134

96-
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
135+
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>
97136

98137
## Environment variables
99138

docker-compose.dev.yml

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ services:
1111
volumes:
1212
- ./listener/cmd:/app/cmd
1313
- ./listener/internal:/app/internal
14+
- ./jwt:/app/jwt
1415
networks:
1516
dncore_network:
1617
aliases:

docker-compose.yml

+3-1
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ services:
1414
BEACON_NODE_URL_LUKSO: ${BEACON_NODE_URL_LUKSO}
1515
BEACON_NODE_URL_GNOSIS: ${BEACON_NODE_URL_GNOSIS}
1616
MAX_ENTRIES_PER_BSON: ${MAX_ENTRIES_PER_BSON}
17+
JWT_USERS_FILE: ${JWT_USERS_FILE}
1718
depends_on:
1819
- mongo
1920
container_name: listener
2021
restart: always
21-
22+
volumes:
23+
- ./jwt:/app/jwt ## listener expects /app/jwt to exist, careful when changing this path
2224
ui:
2325
build:
2426
context: ui

jwt/users.json.example

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"stader": {
3+
"publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq81M9pHCZEExzJFgWEXK\navIs0AexsLlP6CIGkbvfe/GX+kIjP28kkXYGCJlUuVhGYa8wU2mBYeXTbtvi9OR9\ndmKTOzsl3QzIKVd5BqXqbTmQxGp0S6ShujK6LHTOELxwYhFKulx2ls2DSyXhqOGx\nyh0Gm/3H7CiCgNHMJWUUiy5Xyp71vtimzDM+OniUVQE/ZjPg5WG+cM536Ms8XcK1\nNIN0z8ovgAibHqw8jEljxM89Sn9XD3mQo8kBTG+3dLsjUbHZDiJZogNgeXsOrM7m\nh3YtIwMvr5YEWUR7ON7ST5Wrwx14uF6YDE0yo6nb/cmmSSUJ/cdX36dNK3dGrYhB\nywIDAQAB\n-----END PUBLIC KEY-----",
4+
"tags": ["solo"]
5+
}
6+
}

listener/cmd/jwt-generator/main.go

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"os"
7+
"time"
8+
9+
"github.com/dappnode/validator-monitoring/listener/internal/logger"
10+
11+
"github.com/golang-jwt/jwt/v5"
12+
)
13+
14+
func main() {
15+
// Define flags for the command-line input
16+
privateKeyPath := flag.String("private-key", "", "Path to the RSA private key file (mandatory)")
17+
subject := flag.String("sub", "", "Subject claim for the JWT (optional)")
18+
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.")
19+
kid := flag.String("kid", "", "Key ID (kid) for the JWT (mandatory)")
20+
outputFilePath := flag.String("output", "token.jwt", "Output file path for the JWT. Defaults to ./token.jwt")
21+
22+
flag.Parse()
23+
24+
// Check for mandatory parameters
25+
if *kid == "" || *privateKeyPath == "" {
26+
logger.Fatal("Key ID (kid) and private key path must be provided")
27+
}
28+
29+
// Read the private key file
30+
privateKeyData, err := os.ReadFile(*privateKeyPath)
31+
if err != nil {
32+
logger.Fatal(fmt.Sprintf("Failed to read private key file: %v", err))
33+
}
34+
35+
// Parse the RSA private key
36+
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyData)
37+
if err != nil {
38+
logger.Fatal(fmt.Sprintf("Failed to parse private key: %v", err))
39+
}
40+
41+
// Prepare the claims for the JWT. These are optional
42+
claims := jwt.MapClaims{}
43+
if *subject != "" {
44+
claims["sub"] = *subject
45+
}
46+
if *expiration != "" {
47+
duration, err := time.ParseDuration(*expiration)
48+
if err != nil {
49+
logger.Fatal(fmt.Sprintf("Failed to parse expiration duration: %v", err))
50+
}
51+
claims["exp"] = time.Now().Add(duration).Unix()
52+
}
53+
54+
// Create a new token object, specifying signing method and claims
55+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
56+
57+
// Set the key ID (kid) in the token header
58+
token.Header["kid"] = *kid
59+
60+
// Sign the token with the private key
61+
tokenString, err := token.SignedString(privateKey)
62+
if err != nil {
63+
logger.Fatal(fmt.Sprintf("Failed to sign token: %v", err))
64+
}
65+
66+
// Output the token to the console
67+
fmt.Println("JWT generated successfully:")
68+
fmt.Println(tokenString)
69+
70+
// Save the token to a file
71+
err = os.WriteFile(*outputFilePath, []byte(tokenString), 0644)
72+
if err != nil {
73+
logger.Fatal(fmt.Sprintf("Failed to write the JWT to file: %v", err))
74+
}
75+
fmt.Println("JWT saved to file:", *outputFilePath)
76+
}

listener/cmd/listener/main.go

+1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ func main() {
5252
dbCollection,
5353
config.BeaconNodeURLs,
5454
config.MaxEntriesPerBson,
55+
config.JWTUsersFilePath,
5556
)
5657

5758
// Start the API server in a goroutine. Needs to be in a goroutine to allow for the cron job to run,

listener/go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/dappnode/validator-monitoring/listener
33
go 1.22.0
44

55
require (
6+
github.com/golang-jwt/jwt/v5 v5.2.1
67
github.com/gorilla/mux v1.8.1
78
github.com/herumi/bls-eth-go-binary v1.35.0
89
github.com/robfig/cron v1.2.0

listener/go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
22
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
4+
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
35
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
46
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
57
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=

listener/internal/api/api.go

+4-2
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,18 @@ type httpApi struct {
1818
dbCollection *mongo.Collection
1919
beaconNodeUrls map[types.Network]string
2020
maxEntriesPerBson int
21+
jwtUsersFilePath string
2122
}
2223

2324
// create a new api instance
24-
func NewApi(port string, dbClient *mongo.Client, dbCollection *mongo.Collection, beaconNodeUrls map[types.Network]string, maxEntriesPerBson int) *httpApi {
25+
func NewApi(port string, dbClient *mongo.Client, dbCollection *mongo.Collection, beaconNodeUrls map[types.Network]string, maxEntriesPerBson int, jwtUsersFilePath string) *httpApi {
2526
return &httpApi{
2627
port: port,
2728
dbClient: dbClient,
2829
dbCollection: dbCollection,
2930
beaconNodeUrls: beaconNodeUrls,
3031
maxEntriesPerBson: maxEntriesPerBson,
32+
jwtUsersFilePath: jwtUsersFilePath,
3133
}
3234
}
3335

@@ -41,7 +43,7 @@ func (s *httpApi) Start() {
4143

4244
s.server = &http.Server{
4345
Addr: ":" + s.port,
44-
Handler: routes.SetupRouter(s.dbCollection, s.beaconNodeUrls, s.maxEntriesPerBson),
46+
Handler: routes.SetupRouter(s.dbCollection, s.beaconNodeUrls, s.maxEntriesPerBson, s.jwtUsersFilePath),
4547
}
4648

4749
// ListenAndServe returns ErrServerClosed to indicate that the server has been shut down when the server is closed gracefully. We need to
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package handlers
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
9+
"github.com/dappnode/validator-monitoring/listener/internal/api/middleware"
10+
"github.com/dappnode/validator-monitoring/listener/internal/logger"
11+
"go.mongodb.org/mongo-driver/bson"
12+
"go.mongodb.org/mongo-driver/mongo"
13+
)
14+
15+
func GetSignatures(w http.ResponseWriter, r *http.Request, dbCollection *mongo.Collection) {
16+
logger.Debug("Received new GET '/signatures' request")
17+
// Get tags from the context
18+
tags, ok := r.Context().Value(middleware.TagsKey).([]string)
19+
// middlewware already checks that tags is not empty. If something fails here, it is
20+
// because middleware didnt pass context correctly
21+
if !ok || len(tags) == 0 {
22+
http.Error(w, "Internal server error", http.StatusInternalServerError)
23+
return
24+
}
25+
26+
// Query MongoDB for documents with tags matching the context tags
27+
var results []bson.M
28+
filter := bson.M{
29+
"tag": bson.M{"$in": tags},
30+
}
31+
cursor, err := dbCollection.Find(context.Background(), filter)
32+
if err != nil {
33+
http.Error(w, fmt.Sprintf("Failed to query MongoDB: %v", err), http.StatusInternalServerError)
34+
return
35+
}
36+
defer cursor.Close(context.Background())
37+
38+
if err := cursor.All(context.Background(), &results); err != nil {
39+
http.Error(w, fmt.Sprintf("Failed to read cursor: %v", err), http.StatusInternalServerError)
40+
return
41+
}
42+
43+
// Return the results as JSON
44+
w.Header().Set("Content-Type", "application/json")
45+
if err := json.NewEncoder(w).Encode(results); err != nil {
46+
http.Error(w, fmt.Sprintf("Failed to encode results: %v", err), http.StatusInternalServerError)
47+
}
48+
}

0 commit comments

Comments
 (0)