modules/jwtx: Add verificationkey.go, savePublicKey() function

This commit momentarily adds dead code, which for now is only used in
tests. The actual use case is going to be added in a follow up, but this
commit is still kept separate for clarity.
This commit is contained in:
Nils Goroll 2026-02-09 16:07:12 +01:00
commit fa86259403
No known key found for this signature in database
GPG key ID: 1DCD8F57A3868BD7
3 changed files with 378 additions and 8 deletions

View file

@ -35,8 +35,8 @@ type SigningKeyCfg struct {
}
type KeyCfg struct {
Signing *SigningKeyCfg
// more later
Signing *SigningKeyCfg
Verification []*VerificationKeyCfg
}
// ErrInvalidAlgorithmType represents an invalid algorithm error.
@ -430,6 +430,35 @@ func loadOrCreateAsymmetricKey(keyPath, algorithm string) (any, error) {
return loadPrivateKey(keyPath)
}
// save the public key for a private key
func savePublicKey(keyPath string, signingKey SigningKey) error {
if signingKey.IsSymmetric() {
return fmt.Errorf("Saving symmatric key deliberately not supported (path: \"%s\")", keyPath)
}
bytes, err := x509.MarshalPKIXPublicKey(signingKey.VerifyKey())
if err != nil {
return err
}
publicKeyPEM := &pem.Block{Type: "PUBLIC KEY", Bytes: bytes}
if err := os.MkdirAll(filepath.Dir(keyPath), os.ModePerm); err != nil {
return err
}
f, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer func() {
if err = f.Close(); err != nil {
log.Error("Close: %v", err)
}
}()
return pem.Encode(f, publicKeyPEM)
}
// InitSigningKey creates a signing key from SigningKeyCfg
// cfgP is set to nil to mark that is has been processed
func InitSigningKey(cfgP **SigningKeyCfg) (SigningKey, error) {

View file

@ -13,12 +13,14 @@ import (
"path/filepath"
"testing"
"forgejo.org/modules/generate"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func testSignVerify(t *testing.T, signKey, verifyKey SigningKey) {
func testSignVerify(t *testing.T, signKey SigningKey, verifyKey VerificationKey) {
t.Helper()
// test sign and verify
claimsIn := jwt.RegisteredClaims{
@ -34,9 +36,15 @@ func testSignVerify(t *testing.T, signKey, verifyKey SigningKey) {
assert.NotNil(t, valToken.Method)
assert.Equal(t, signKey.SigningMethod().Alg(), valToken.Method.Alg())
assert.Equal(t, verifyKey.SigningMethod().Alg(), valToken.Method.Alg())
// asymmetric keys generate JWT with a kid, symmetric not
kid, ok := valToken.Header["kid"]
assert.True(t, ok)
assert.NotNil(t, kid)
if signKey.IsSymmetric() {
assert.False(t, ok)
} else {
assert.True(t, ok)
assert.NotNil(t, kid)
}
return verifyKey.VerifyKey(), nil
})
@ -68,18 +76,53 @@ func TestLoadOrCreateAsymmetricKey(t *testing.T) {
useKey := func(t *testing.T, keyPath, algorithm string) {
t.Helper()
// duplicates loadKey() to some extent, but uses SigningKey
cfg := &SigningKeyCfg{
assert.NotEmpty(t, keyPath)
scfg := &SigningKeyCfg{
Algorithm: algorithm,
PrivateKeyPath: &keyPath,
}
key, err := InitSigningKey(&cfg)
// load the signing key via the settings interface
key, err := InitSigningKey(&scfg)
require.NoError(t, err)
assert.NotNil(t, key)
assert.Nil(t, cfg)
assert.Nil(t, scfg)
assert.NotEmpty(t, key.ID())
assert.Nil(t, scfg)
testSignVerify(t, key, key)
// load the signing key file as a verification key
assert.NotEmpty(t, keyPath)
vcfg := &VerificationKeyCfg{
Algorithm: algorithm,
PublicKeyPath: &keyPath,
}
// load the verification key via the settings interface
vkey, err := InitVerificationKey(&vcfg)
require.NoError(t, err)
assert.NotNil(t, key)
assert.Equal(t, key.ID(), vkey.ID())
assert.Nil(t, scfg)
testSignVerify(t, key, vkey)
// save the public key, then load it and test
pKeyPath := keyPath + ".pub"
err = savePublicKey(pKeyPath, key)
require.NoError(t, err)
vcfg = &VerificationKeyCfg{
Algorithm: algorithm,
PublicKeyPath: &pKeyPath,
}
vkey, err = InitVerificationKey(&vcfg)
require.NoError(t, err)
assert.NotNil(t, vkey)
assert.Equal(t, key.ID(), vkey.ID())
assert.Nil(t, vcfg)
testSignVerify(t, key, vkey)
}
t.Run("RSA-2048", func(t *testing.T) {
keyPath := filepath.Join(t.TempDir(), "jwt-rsa-2048.priv")
@ -185,3 +228,39 @@ func TestCannotCreatePrivateKey(t *testing.T) {
require.Error(t, err)
require.ErrorContains(t, err, "Error generating private key")
}
// test symmetic algorithms used via the SigningKey and VerificationKey
// interfaces
func TestSymmetricKey(t *testing.T) {
algorithms := []string{"HS256", "HS384", "HS512"}
for _, algorithm := range algorithms {
t.Run(algorithm, func(t *testing.T) {
secret, _ := generate.NewJwtSecret()
assert.NotEmpty(t, secret)
// init the signing key via the settings interface
scfg := &SigningKeyCfg{
Algorithm: algorithm,
SecretBytes: &secret,
}
skey, err := InitSigningKey(&scfg)
require.NoError(t, err)
assert.NotNil(t, skey)
assert.Nil(t, scfg)
testSignVerify(t, skey, skey)
// init the same key as a VerificationKey
vcfg := &VerificationKeyCfg{
Algorithm: algorithm,
SecretBytes: &secret,
}
vkey, err := InitVerificationKey(&vcfg)
require.NoError(t, err)
assert.NotNil(t, vkey)
assert.Nil(t, vcfg)
testSignVerify(t, skey, vkey)
})
}
}

View file

@ -0,0 +1,262 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jwtx
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"os"
"forgejo.org/modules/util"
"github.com/golang-jwt/jwt/v5"
)
// The ...KeyCfg types are only used for handover from setting to signingkey
// see comment in setting/security.go
type VerificationKeyCfg struct {
Algorithm string
SecretBytes *[]byte
PublicKeyPath *string
}
// VerificationKey represents a algorithm/key pair to sign JWTs
type VerificationKey interface {
IsSymmetric() bool
SigningMethod() jwt.SigningMethod
VerifyKey() any
ID() string
// want? ToJWK() (map[string]string, error)
}
type hmacVerificationKey struct {
signingMethod jwt.SigningMethod
secret []byte
}
func (key hmacVerificationKey) IsSymmetric() bool {
return true
}
func (key hmacVerificationKey) SigningMethod() jwt.SigningMethod {
return key.signingMethod
}
func (key hmacVerificationKey) VerifyKey() any {
return key.secret
}
func (key hmacVerificationKey) ID() string { return "" }
type rsaVerificationKey struct {
signingMethod jwt.SigningMethod
key *rsa.PublicKey
id string
}
func newRSAVerificationKey(signingMethod jwt.SigningMethod, key *rsa.PublicKey) (rsaVerificationKey, error) {
kid, err := util.CreatePublicKeyFingerprint(key)
if err != nil {
return rsaVerificationKey{}, err
}
return rsaVerificationKey{
signingMethod,
key,
base64.RawURLEncoding.EncodeToString(kid),
}, nil
}
func (key rsaVerificationKey) IsSymmetric() bool {
return false
}
func (key rsaVerificationKey) SigningMethod() jwt.SigningMethod {
return key.signingMethod
}
func (key rsaVerificationKey) VerifyKey() any {
return key.key
}
func (key rsaVerificationKey) ID() string {
return key.id
}
type eddsaVerificationKey struct {
signingMethod jwt.SigningMethod
key ed25519.PublicKey
id string
}
func newEdDSAVerificationKey(signingMethod jwt.SigningMethod, key ed25519.PublicKey) (eddsaVerificationKey, error) {
kid, err := util.CreatePublicKeyFingerprint(key)
if err != nil {
return eddsaVerificationKey{}, err
}
return eddsaVerificationKey{
signingMethod,
key,
base64.RawURLEncoding.EncodeToString(kid),
}, nil
}
func (key eddsaVerificationKey) IsSymmetric() bool {
return false
}
func (key eddsaVerificationKey) SigningMethod() jwt.SigningMethod {
return key.signingMethod
}
func (key eddsaVerificationKey) VerifyKey() any {
return key.key
}
func (key eddsaVerificationKey) ID() string {
return key.id
}
type ecdsaVerificationKey struct {
signingMethod jwt.SigningMethod
key *ecdsa.PublicKey
id string
}
func newECDSAVerificationKey(signingMethod jwt.SigningMethod, key *ecdsa.PublicKey) (ecdsaVerificationKey, error) {
kid, err := util.CreatePublicKeyFingerprint(key)
if err != nil {
return ecdsaVerificationKey{}, err
}
return ecdsaVerificationKey{
signingMethod,
key,
base64.RawURLEncoding.EncodeToString(kid),
}, nil
}
func (key ecdsaVerificationKey) IsSymmetric() bool {
return false
}
func (key ecdsaVerificationKey) SigningMethod() jwt.SigningMethod {
return key.signingMethod
}
func (key ecdsaVerificationKey) VerifyKey() any {
return key.key
}
func (key ecdsaVerificationKey) ID() string {
return key.id
}
// CreateVerificationKey creates a signing key from an algorithm / key pair.
func CreateVerificationKey(algorithm string, key any) (VerificationKey, error) {
signingMethod := GetSigningMethod(algorithm)
if signingMethod == nil {
return nil, ErrInvalidAlgorithmType{algorithm}
}
switch signingMethod.(type) {
case *jwt.SigningMethodEd25519:
publicKey, ok := key.(ed25519.PublicKey)
if !ok {
return nil, jwt.ErrInvalidKeyType
}
return newEdDSAVerificationKey(signingMethod, publicKey)
case *jwt.SigningMethodECDSA:
publicKey, ok := key.(*ecdsa.PublicKey)
if !ok {
return nil, jwt.ErrInvalidKeyType
}
return newECDSAVerificationKey(signingMethod, publicKey)
case *jwt.SigningMethodRSA:
publicKey, ok := key.(*rsa.PublicKey)
if !ok {
return nil, jwt.ErrInvalidKeyType
}
return newRSAVerificationKey(signingMethod, publicKey)
default:
secret, ok := key.([]byte)
if !ok {
return nil, jwt.ErrInvalidKeyType
}
return hmacVerificationKey{signingMethod, secret}, nil
}
}
// loadPublicKey loads a public key
func loadPublicKey(keyPath string) (any, error) {
bytes, err := os.ReadFile(keyPath)
if err != nil {
return nil, err
}
block, _ := pem.Decode(bytes)
if block == nil {
return nil, fmt.Errorf("no valid PEM data found in %s", keyPath)
} else if block.Type != "PUBLIC KEY" {
return nil, fmt.Errorf("expected PUBLIC KEY, got %s in %s", block.Type, keyPath)
}
return x509.ParsePKIXPublicKey(block.Bytes)
}
// InitVerificationKey creates a signing key from VerificationKeyCfg
// cfgP is set to nil to mark that is has been processed
func InitVerificationKey(cfgP **VerificationKeyCfg) (VerificationKey, error) {
cfg := *cfgP
*cfgP = nil
var err error
var key VerificationKey
if IsValidSymmetricAlgorithm(cfg.Algorithm) {
key, err = CreateVerificationKey(cfg.Algorithm, *cfg.SecretBytes)
} else if IsValidAsymmetricAlgorithm(cfg.Algorithm) {
key, err = InitAsymmetricVerificationKey(*cfg.PublicKeyPath, cfg.Algorithm)
} else {
// should never happen, setting.loadVerificationKeyCfg() ensures
err = ErrInvalidAlgorithmType{Algorithm: cfg.Algorithm}
}
return key, err
}
// InitAsymmetricVerificationKey creates an asymmetric signing key from settings or creates a random key.
func InitAsymmetricVerificationKey(keyPath, algorithm string) (VerificationKey, error) {
var err error
var key any
if !IsValidAsymmetricAlgorithm(algorithm) {
return nil, ErrInvalidAlgorithmType{Algorithm: algorithm}
}
key, err = loadPublicKey(keyPath)
if err == nil {
return CreateVerificationKey(algorithm, key)
}
// We deliberately support loading a signing key as a verification key
// such that users can just move a private key to another location to use it
// as a verification key
key, err2 := loadPrivateKey(keyPath)
if err2 != nil {
return nil, fmt.Errorf("Neither public key nor private key: %w/%w", err, err2)
}
signingKey, err2 := CreateSigningKey(algorithm, key)
if err2 != nil {
return nil, err2
}
return signingKey.(VerificationKey), nil
}