DEV Community

ElioenaiFerrari
ElioenaiFerrari

Posted on

4

Rotation keys in Golang

The idea here is to rotate the ed25519 keys for the signatures of our tokens. In this way we achieve greater security and unpredictability with asymmetric signature.

First, let's assemble our containers.

// Dockerfile
FROM golang:1.20-alpine

WORKDIR /app
COPY go.* .
RUN go mod tidy
COPY . .
RUN go build -buildvcs=false -ldflags '-s -w' -o ./bin ./cmd/guardian

EXPOSE 4000

CMD [ "./bin/guardian" ]
Enter fullscreen mode Exit fullscreen mode
// docker-compose.yml
version: '3'

networks:
  guardian:
    driver: bridge

services:
  cache:
    image: redis:7.0-alpine
    hostname: cache.guardian.local
    restart: always
    ports:
      - '6379:6379'
    networks:
      - guardian
  app:
    platform: linux/amd64
    build: .
    hostname: app.guardian.local
    depends_on:
      - cache
    networks:
      - guardian
    ports:
      - 4000:4000
Enter fullscreen mode Exit fullscreen mode

The key server module will be responsible for setting the last key id, generating the tokens with the last generated key, and rotating the keys in the given interval.
The kid is the identifier of the key in question. Every time we generate a token we will use the last key stored in redis. Our ks.Start() worker in the main file will rotate keys every 5 minutes.

// internal/key-server/key-server.go
package keyserver

import (
    "context"
    "crypto/ed25519"
    "crypto/rand"
    "crypto/x509"
    "errors"
    "time"

    "github.com/golang-jwt/jwt"
    gonanoid "github.com/matoous/go-nanoid/v2"
    "github.com/redis/go-redis/v9"
)

type KeyServer struct {
    cache *redis.Client
}

func New(cache *redis.Client) *KeyServer {
    return &KeyServer{
        cache: cache,
    }
}

func (ks *KeyServer) NewSignedToken(ctx context.Context, data map[string]interface{}) (string, jwt.Claims, error) {
    lastKID, err := ks.GetLastKID(ctx)
    if err != nil {
        return "", nil, err
    }

    lastKey, err := ks.GetKeyByKID(ctx, lastKID)
    if err != nil {
        return "", nil, err
    }

    claims := jwt.MapClaims{
        "exp": time.Now().Add(15 * time.Minute).Unix(),
        "iss": "guardian",
        "kid": lastKID,
        "dat": data,
    }
    token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
    signedToken, err := token.SignedString(lastKey)

    return signedToken, claims, err
}

func (ks *KeyServer) DecodeSignedToken(ctx context.Context, signedToken string) (*jwt.Token, jwt.Claims, error) {
    claims := jwt.MapClaims{}
    token, err := jwt.ParseWithClaims(signedToken, &claims, func(t *jwt.Token) (interface{}, error) {
        claims, ok := t.Claims.(*jwt.MapClaims)
        if !ok {
            return nil, errors.New("invalid token")
        }

        key, err := ks.GetKeyByKID(ctx, (*claims)["kid"].(string))
        if err != nil {
            return nil, err
        }

        return key.Public(), nil
    })

    if err != nil {
        return nil, nil, err
    }

    return token, claims, nil
}

func (ks *KeyServer) SetLastKID(ctx context.Context, kid string) error {
    return ks.cache.Set(ctx, "last-kid", kid, time.Minute*15).Err()
}

func (ks *KeyServer) SetLastKey(ctx context.Context, kid string, pvk ed25519.PrivateKey) error {
    b, err := x509.MarshalPKCS8PrivateKey(pvk)
    if err != nil {
        return err
    }

    if err := ks.cache.Set(ctx, kid, b, time.Minute*15).Err(); err != nil {
        return err
    }

    return nil
}

func (ks *KeyServer) GetKeyByKID(ctx context.Context, kid string) (ed25519.PrivateKey, error) {
    key, err := ks.cache.Get(ctx, kid).Result()
    if err != nil {
        return nil, err
    }

    pvk, err := x509.ParsePKCS8PrivateKey([]byte(key))
    if err != nil {
        return nil, err
    }

    return pvk.(ed25519.PrivateKey), nil
}

func (ks *KeyServer) GetLastKID(ctx context.Context) (string, error) {
    lastKID, err := ks.cache.Get(ctx, "last-kid").Result()
    if err != nil {
        return "", err
    }

    return lastKID, nil
}

func (ks *KeyServer) Rotate(ctx context.Context) error {
    kid, err := gonanoid.New(8)
    if err != nil {
        return err
    }

    _, pvk, err := ed25519.GenerateKey(rand.Reader)
    if err != nil {
        return err
    }

    if err := ks.SetLastKID(ctx, kid); err != nil {
        return err
    }

    if err := ks.SetLastKey(ctx, kid, pvk); err != nil {
        return err
    }

    return nil
}

func (ks *KeyServer) Start(ctx context.Context) error {
    ks.Rotate(ctx)

    for range time.Tick(time.Minute * 5) {
        ks.Rotate(ctx)
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

The POST /api/v1/tokens route will generate a token with the claims defined in the body sent (JSON). And the GET /api/v1/tokens route will only act as a validator for us to test our tokens.
The token is signed with a private ed2559 key and validated with the public key of that same key.

// cmd/guardian/main.go
package main

import (
    "context"
    "log"
    "net/http"

    keyserver "github.com/ElioenaiFerrari/guardian/internal/key-server"
    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/fiber/v2/middleware/cors"
    "github.com/gofiber/fiber/v2/middleware/logger"
    "github.com/gofiber/fiber/v2/middleware/recover"
    "github.com/redis/go-redis/v9"
)

func main() {
    cache := redis.NewClient(&redis.Options{
        Addr:     "cache.guardian.local:6379",
        DB:       0,
        Password: "",
    })
    ks := keyserver.New(cache)
    ctx := context.Background()

    go ks.Start(ctx)

    app := fiber.New()
    app.Use(logger.New())
    app.Use(recover.New())
    app.Use(cors.New())

    v1 := app.Group("/api/v1")
    v1.Post("/tokens", func(c *fiber.Ctx) error {
        var data map[string]interface{}

        if err := c.BodyParser(&data); err != nil {
            return fiber.NewError(http.StatusBadRequest, err.Error())
        }

        token, claims, err := ks.NewSignedToken(ctx, data)
        if err != nil {
            return fiber.NewError(http.StatusInternalServerError, err.Error())
        }

        return c.Status(http.StatusCreated).JSON(fiber.Map{
            "token":  token,
            "claims": claims,
        })
    })

    v1.Get("/tokens", func(c *fiber.Ctx) error {
        signedToken := c.Get("Authorization")
        if signedToken == "" {
            return fiber.NewError(http.StatusUnauthorized, "unauthorized")
        }

        token, claims, err := ks.DecodeSignedToken(ctx, signedToken)
        if err != nil {
            return fiber.NewError(http.StatusUnauthorized, err.Error())
        }

        return c.Status(http.StatusCreated).JSON(fiber.Map{
            "token":  token,
            "claims": claims,
        })
    })

    log.Fatal(app.Listen(":4000"))
}
Enter fullscreen mode Exit fullscreen mode

Let's start our server.

docker-compose up --build
Enter fullscreen mode Exit fullscreen mode

Examples:

  • POST request
curl --location 'http://localhost:4000/api/v1/tokens' \
--header 'Content-Type: application/json' \
--data '{
    "sub": "123123123"
}'
Enter fullscreen mode Exit fullscreen mode
  • POST response
{
    "claims": {
        "dat": {
            "sub": "123123123"
        },
        "exp": 1678807427,
        "iss": "guardian",
        "kid": "rwqgN9dI"
    },
    "token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJkYXQiOnsic3ViIjoiMTIzMTIzMTIzIn0sImV4cCI6MTY3ODgwNzQyNywiaXNzIjoiZ3VhcmRpYW4iLCJraWQiOiJyd3FnTjlkSSJ9.8yUZ-0XSZk5UMITSUZLX2SXIs5MIIXCID4X4IBxWy_N9WHXCBk0v6o0urB5r7Sr8vz7T_Z_2JVsNU4j681PMAw"
}
Enter fullscreen mode Exit fullscreen mode
  • GET request
curl --location 'http://localhost:4000/api/v1/tokens' \
--header 'Authorization: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJkYXQiOnsic3ViIjoiMTIzMTIzMTIzIn0sImV4cCI6MTY3ODgwNzQyNywiaXNzIjoiZ3VhcmRpYW4iLCJraWQiOiJyd3FnTjlkSSJ9.8yUZ-0XSZk5UMITSUZLX2SXIs5MIIXCID4X4IBxWy_N9WHXCBk0v6o0urB5r7Sr8vz7T_Z_2JVsNU4j681PMAw'
Enter fullscreen mode Exit fullscreen mode
  • GET response
{
    "claims": {
        "dat": {
            "sub": "123123123"
        },
        "exp": 1678807427,
        "iss": "guardian",
        "kid": "rwqgN9dI"
    },
    "token": {
        "Raw": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJkYXQiOnsic3ViIjoiMTIzMTIzMTIzIn0sImV4cCI6MTY3ODgwNzQyNywiaXNzIjoiZ3VhcmRpYW4iLCJraWQiOiJyd3FnTjlkSSJ9.8yUZ-0XSZk5UMITSUZLX2SXIs5MIIXCID4X4IBxWy_N9WHXCBk0v6o0urB5r7Sr8vz7T_Z_2JVsNU4j681PMAw",
        "Method": {},
        "Header": {
            "alg": "EdDSA",
            "typ": "JWT"
        },
        "Claims": {
            "dat": {
                "sub": "123123123"
            },
            "exp": 1678807427,
            "iss": "guardian",
            "kid": "rwqgN9dI"
        },
        "Signature": "8yUZ-0XSZk5UMITSUZLX2SXIs5MIIXCID4X4IBxWy_N9WHXCBk0v6o0urB5r7Sr8vz7T_Z_2JVsNU4j681PMAw",
        "Valid": true
    }
}
Enter fullscreen mode Exit fullscreen mode

Give me a star in repository and contribute to our group. Also look at my portfolio.

Quickstart image

Django MongoDB Backend Quickstart! A Step-by-Step Tutorial

Get up and running with the new Django MongoDB Backend Python library! This tutorial covers creating a Django application, connecting it to MongoDB Atlas, performing CRUD operations, and configuring the Django admin for MongoDB.

Watch full video →

Top comments (0)

Quickstart image

Django MongoDB Backend Quickstart! A Step-by-Step Tutorial

Get up and running with the new Django MongoDB Backend Python library! This tutorial covers creating a Django application, connecting it to MongoDB Atlas, performing CRUD operations, and configuring the Django admin for MongoDB.

Watch full video →

👋 Kindness is contagious

Explore a trove of insights in this engaging article, celebrated within our welcoming DEV Community. Developers from every background are invited to join and enhance our shared wisdom.

A genuine "thank you" can truly uplift someone’s day. Feel free to express your gratitude in the comments below!

On DEV, our collective exchange of knowledge lightens the road ahead and strengthens our community bonds. Found something valuable here? A small thank you to the author can make a big difference.

Okay