Skip to content

Commit c4d5753

Browse files
author
Gusted
committed
jwtx: Add [pfx]KEYS_ACCEPTED configuration to enable seamless key rotations, add [oauth2] JWT_KEYS_ACCEPTED (#12307)
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12307 Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org> Reviewed-by: Gusted <gusted@noreply.codeberg.org>
2 parents dc8d994 + 2521a84 commit c4d5753

16 files changed

Lines changed: 1239 additions & 72 deletions

File tree

.deadcode-out

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ forgejo.org/modules/json
131131
StdJSON.NewDecoder
132132
StdJSON.Indent
133133

134+
forgejo.org/modules/jwtx
135+
savePublicKey
136+
NewVerifier
137+
Verifier.Parse
138+
134139
forgejo.org/modules/log
135140
eventWriterBuffer.Close
136141
eventWriterBuffer.Write

custom/conf/app.example.ini

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,14 @@ ENABLED = true
557557
;; Alternative location to specify OAuth2 authentication secret. You cannot specify both this and JWT_SECRET, and must pick one
558558
;JWT_SECRET_URI = file:/etc/gitea/oauth2_jwt_secret
559559
;;
560+
;; Additional keys accepted for validation. Useful for key rotation. Format:
561+
;; <alg>:[<base64>|<uri>] ...
562+
;; alg: see JWT_SIGNING_ALGORITHM
563+
;; uri: file://<path>
564+
;; example: JWT_SIGNING_ALGORITHM = RS256:file:module/pub/*.pem HS256:file:module/old.key
565+
;; there is no default
566+
;JWT_SIGNING_ALGORITHM =
567+
;;
560568
;; Lifetime of an OAuth2 access token in seconds
561569
;ACCESS_TOKEN_EXPIRATION_TIME = 3600
562570
;;

modules/jwtx/signingkey.go

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ type SigningKeyCfg struct {
3535
}
3636

3737
type KeyCfg struct {
38-
Signing *SigningKeyCfg
39-
// more later
38+
Signing *SigningKeyCfg
39+
Verification []*VerificationKeyCfg
4040
}
4141

4242
// ErrInvalidAlgorithmType represents an invalid algorithm error.
@@ -61,6 +61,7 @@ type SigningKey interface {
6161
SignKey() any
6262
VerifyKey() any
6363
ToJWK() (map[string]string, error)
64+
ID() string
6465
PreProcessToken(*jwt.Token)
6566
// convenience: jwt.NewWithClaims + PreProcessToken + SignedString
6667
JWT(jwt.Claims, ...jwt.TokenOption) (string, error)
@@ -94,6 +95,10 @@ func (key hmacSigningKey) ToJWK() (map[string]string, error) {
9495
}, nil
9596
}
9697

98+
func (key hmacSigningKey) ID() string {
99+
return ""
100+
}
101+
97102
func (key hmacSigningKey) PreProcessToken(*jwt.Token) {}
98103

99104
func (key hmacSigningKey) JWT(claims jwt.Claims, opts ...jwt.TokenOption) (string, error) {
@@ -147,6 +152,10 @@ func (key rsaSigningKey) ToJWK() (map[string]string, error) {
147152
}, nil
148153
}
149154

155+
func (key rsaSigningKey) ID() string {
156+
return key.id
157+
}
158+
150159
func (key rsaSigningKey) PreProcessToken(token *jwt.Token) {
151160
token.Header["kid"] = key.id
152161
}
@@ -202,6 +211,10 @@ func (key eddsaSigningKey) ToJWK() (map[string]string, error) {
202211
}, nil
203212
}
204213

214+
func (key eddsaSigningKey) ID() string {
215+
return key.id
216+
}
217+
205218
func (key eddsaSigningKey) PreProcessToken(token *jwt.Token) {
206219
token.Header["kid"] = key.id
207220
}
@@ -258,6 +271,10 @@ func (key ecdsaSigningKey) ToJWK() (map[string]string, error) {
258271
}, nil
259272
}
260273

274+
func (key ecdsaSigningKey) ID() string {
275+
return key.id
276+
}
277+
261278
func (key ecdsaSigningKey) PreProcessToken(token *jwt.Token) {
262279
token.Header["kid"] = key.id
263280
}
@@ -381,7 +398,7 @@ func createAsymmetricKey(keyPath, algorithm string) error {
381398
return pem.Encode(f, privateKeyPEM)
382399
}
383400

384-
func loadAsymmetricKey(keyPath string) (any, error) {
401+
func loadPrivateKey(keyPath string) (any, error) {
385402
bytes, err := os.ReadFile(keyPath)
386403
if err != nil {
387404
return nil, err
@@ -410,7 +427,36 @@ func loadOrCreateAsymmetricKey(keyPath, algorithm string) (any, error) {
410427
return nil, fmt.Errorf("Error generating private key %s: %v", keyPath, err)
411428
}
412429
}
413-
return loadAsymmetricKey(keyPath)
430+
return loadPrivateKey(keyPath)
431+
}
432+
433+
// save the public key for a private key
434+
func savePublicKey(keyPath string, signingKey SigningKey) error {
435+
if signingKey.IsSymmetric() {
436+
return fmt.Errorf("Saving symmatric key deliberately not supported (path: \"%s\")", keyPath)
437+
}
438+
bytes, err := x509.MarshalPKIXPublicKey(signingKey.VerifyKey())
439+
if err != nil {
440+
return err
441+
}
442+
443+
publicKeyPEM := &pem.Block{Type: "PUBLIC KEY", Bytes: bytes}
444+
445+
if err := os.MkdirAll(filepath.Dir(keyPath), os.ModePerm); err != nil {
446+
return err
447+
}
448+
449+
f, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
450+
if err != nil {
451+
return err
452+
}
453+
defer func() {
454+
if err = f.Close(); err != nil {
455+
log.Error("Close: %v", err)
456+
}
457+
}()
458+
459+
return pem.Encode(f, publicKeyPEM)
414460
}
415461

416462
// InitSigningKey creates a signing key from SigningKeyCfg
@@ -563,3 +609,33 @@ func ParseJWKToPublicKey(jwk map[string]any) (any, error) {
563609
return nil, fmt.Errorf("unsupported key type in JWK: %s", kty)
564610
}
565611
}
612+
613+
// Init Signing Key and Verifier
614+
func InitWithParser(keyCfgP **KeyCfg, parser *jwt.Parser) (SigningKey, *Verifier, error) {
615+
keyCfg := *keyCfgP
616+
*keyCfgP = nil
617+
618+
signingKey, err := InitSigningKey(&keyCfg.Signing)
619+
if err != nil {
620+
return nil, nil, err
621+
}
622+
623+
// we always add the signing key to the verifier (we accept tokens which
624+
// we issue). No idea if there could be future cases where we do not
625+
// want this, but for now antything else would be very un-POLA
626+
verifier := NewVerifierWithParser(parser)
627+
err = verifier.AddKey(signingKey)
628+
if err != nil {
629+
return nil, nil, err
630+
}
631+
err = verifier.AddVerificationKeyCfg(&keyCfg.Verification)
632+
if err != nil {
633+
return nil, nil, err
634+
}
635+
636+
return signingKey, verifier, nil
637+
}
638+
639+
func Init(keyCfgP **KeyCfg) (SigningKey, *Verifier, error) {
640+
return InitWithParser(keyCfgP, jwt.NewParser())
641+
}

modules/jwtx/signingkey_test.go

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import (
1313
"path/filepath"
1414
"testing"
1515

16+
"forgejo.org/modules/generate"
17+
1618
"github.com/golang-jwt/jwt/v5"
1719
"github.com/stretchr/testify/assert"
1820
"github.com/stretchr/testify/require"
1921
)
2022

21-
func testSignVerify(t *testing.T, signKey, verifyKey SigningKey) {
23+
func testSignVerify(t *testing.T, signKey SigningKey, verifyKey VerificationKey) {
2224
t.Helper()
2325
// test sign and verify
2426
claimsIn := jwt.RegisteredClaims{
@@ -34,9 +36,15 @@ func testSignVerify(t *testing.T, signKey, verifyKey SigningKey) {
3436
assert.NotNil(t, valToken.Method)
3537
assert.Equal(t, signKey.SigningMethod().Alg(), valToken.Method.Alg())
3638
assert.Equal(t, verifyKey.SigningMethod().Alg(), valToken.Method.Alg())
39+
40+
// asymmetric keys generate JWT with a kid, symmetric not
3741
kid, ok := valToken.Header["kid"]
38-
assert.True(t, ok)
39-
assert.NotNil(t, kid)
42+
if signKey.IsSymmetric() {
43+
assert.False(t, ok)
44+
} else {
45+
assert.True(t, ok)
46+
assert.NotNil(t, kid)
47+
}
4048

4149
return verifyKey.VerifyKey(), nil
4250
})
@@ -68,17 +76,53 @@ func TestLoadOrCreateAsymmetricKey(t *testing.T) {
6876
useKey := func(t *testing.T, keyPath, algorithm string) {
6977
t.Helper()
7078
// duplicates loadKey() to some extent, but uses SigningKey
71-
cfg := &SigningKeyCfg{
79+
assert.NotEmpty(t, keyPath)
80+
scfg := &SigningKeyCfg{
7281
Algorithm: algorithm,
7382
PrivateKeyPath: &keyPath,
7483
}
7584

76-
key, err := InitSigningKey(&cfg)
85+
// load the signing key via the settings interface
86+
key, err := InitSigningKey(&scfg)
7787
require.NoError(t, err)
7888
assert.NotNil(t, key)
79-
assert.Nil(t, cfg)
89+
assert.Nil(t, scfg)
90+
assert.NotEmpty(t, key.ID())
91+
assert.Nil(t, scfg)
8092

8193
testSignVerify(t, key, key)
94+
95+
// load the signing key file as a verification key
96+
assert.NotEmpty(t, keyPath)
97+
vcfg := &VerificationKeyCfg{
98+
Algorithm: algorithm,
99+
PublicKeyPath: &keyPath,
100+
}
101+
102+
// load the verification key via the settings interface
103+
vkey, err := InitVerificationKey(&vcfg)
104+
require.NoError(t, err)
105+
assert.NotNil(t, key)
106+
assert.Equal(t, key.ID(), vkey.ID())
107+
assert.Nil(t, scfg)
108+
109+
testSignVerify(t, key, vkey)
110+
111+
// save the public key, then load it and test
112+
pKeyPath := keyPath + ".pub"
113+
err = savePublicKey(pKeyPath, key)
114+
require.NoError(t, err)
115+
vcfg = &VerificationKeyCfg{
116+
Algorithm: algorithm,
117+
PublicKeyPath: &pKeyPath,
118+
}
119+
vkey, err = InitVerificationKey(&vcfg)
120+
require.NoError(t, err)
121+
assert.NotNil(t, vkey)
122+
assert.Equal(t, key.ID(), vkey.ID())
123+
assert.Nil(t, vcfg)
124+
125+
testSignVerify(t, key, vkey)
82126
}
83127
t.Run("RSA-2048", func(t *testing.T) {
84128
keyPath := filepath.Join(t.TempDir(), "jwt-rsa-2048.priv")
@@ -184,3 +228,39 @@ func TestCannotCreatePrivateKey(t *testing.T) {
184228
require.Error(t, err)
185229
require.ErrorContains(t, err, "Error generating private key")
186230
}
231+
232+
// test symmetic algorithms used via the SigningKey and VerificationKey
233+
// interfaces
234+
func TestSymmetricKey(t *testing.T) {
235+
algorithms := []string{"HS256", "HS384", "HS512"}
236+
for _, algorithm := range algorithms {
237+
t.Run(algorithm, func(t *testing.T) {
238+
secret, _ := generate.NewJwtSecret()
239+
assert.NotEmpty(t, secret)
240+
241+
// init the signing key via the settings interface
242+
scfg := &SigningKeyCfg{
243+
Algorithm: algorithm,
244+
SecretBytes: &secret,
245+
}
246+
skey, err := InitSigningKey(&scfg)
247+
require.NoError(t, err)
248+
assert.NotNil(t, skey)
249+
assert.Nil(t, scfg)
250+
251+
testSignVerify(t, skey, skey)
252+
253+
// init the same key as a VerificationKey
254+
vcfg := &VerificationKeyCfg{
255+
Algorithm: algorithm,
256+
SecretBytes: &secret,
257+
}
258+
vkey, err := InitVerificationKey(&vcfg)
259+
require.NoError(t, err)
260+
assert.NotNil(t, vkey)
261+
assert.Nil(t, vcfg)
262+
263+
testSignVerify(t, skey, vkey)
264+
})
265+
}
266+
}

0 commit comments

Comments
 (0)