Skip to content

Commit 2238551

Browse files
authored
Refactor JWKS into its own module (#249)
### Description Refactor JWKS into its own module. ### Type of change * [ ] New feature * [ ] Feature improvement * [ ] Bug fix * [ ] Documentation * [x] Cleanup / refactoring * [ ] Other (please explain) ### How is this change tested ? * [x] Unit tests * [ ] Manual tests (explain) * [ ] Tests are not needed
1 parent 13d2867 commit 2238551

File tree

7 files changed

+616
-0
lines changed

7 files changed

+616
-0
lines changed

.github/workflows/pr.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ jobs:
5656
run: go test -race -timeout=5m -failfast ./...
5757
- name: Run govulncheck
5858
run: go install golang.org/x/vuln/cmd/govulncheck@latest && govulncheck ./...
59+
- name Test jwks
60+
run: |
61+
cd ./jwks
62+
go build .
63+
go vet .
64+
go test .
5965
- name: Build examples
6066
run: |
6167
for dir in $(find examples/ -name go.mod); do

jwks/go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module github.com/c2FmZQ/tlsproxy/jwks
2+
3+
go 1.25.5
4+
5+
require github.com/hashicorp/go-retryablehttp v0.7.8
6+
7+
require github.com/hashicorp/go-cleanhttp v0.5.2 // indirect

jwks/go.sum

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
2+
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
3+
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
4+
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
5+
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
6+
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
7+
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
8+
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
9+
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
10+
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
11+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
12+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
13+
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
14+
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

jwks/jwks.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// MIT License
2+
//
3+
// Copyright (c) 2024 TTBT Enterprises LLC
4+
// Copyright (c) 2024 Robin Thellend <rthellend@rthellend.com>
5+
//
6+
// Permission is hereby granted, free of charge, to any person obtaining a copy
7+
// of this software and associated documentation files (the "Software"), to deal
8+
// in the Software without restriction, including without limitation the rights
9+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
// copies of the Software, and to permit persons to whom the Software is
11+
// furnished to do so, subject to the following conditions:
12+
//
13+
// The above copyright notice and this permission notice shall be included in all
14+
// copies or substantial portions of the Software.
15+
//
16+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
// SOFTWARE.
23+
24+
// Package jwks implements a JSON Web Key Set (JWKS) management system.
25+
package jwks
26+
27+
import (
28+
"crypto"
29+
"crypto/ecdsa"
30+
"crypto/ed25519"
31+
"crypto/elliptic"
32+
"crypto/rsa"
33+
"crypto/sha256"
34+
"crypto/x509"
35+
"encoding/base64"
36+
"encoding/hex"
37+
"fmt"
38+
"math/big"
39+
)
40+
41+
// JWKS is a JSON Web Key Set.
42+
type JWKS struct {
43+
Keys []JWK `json:"keys"`
44+
}
45+
46+
// JWK is a JSON Web Key.
47+
type JWK struct {
48+
Type string `json:"kty"`
49+
Use string `json:"use"`
50+
ID string `json:"kid"`
51+
Alg string `json:"alg"`
52+
// EC
53+
Curve string `json:"crv,omitempty"`
54+
X string `json:"x,omitempty"`
55+
Y string `json:"y,omitempty"`
56+
// RSA
57+
N string `json:"n,omitempty"`
58+
E string `json:"e,omitempty"`
59+
}
60+
61+
// PublicKey returns the crypto.PublicKey from the JWK.
62+
func (k JWK) PublicKey() (crypto.PublicKey, error) {
63+
switch k.Type {
64+
case "EC":
65+
var curve elliptic.Curve
66+
switch k.Curve {
67+
case "P-256":
68+
curve = elliptic.P256()
69+
case "P-384":
70+
curve = elliptic.P384()
71+
case "P-521":
72+
curve = elliptic.P521()
73+
default:
74+
return nil, fmt.Errorf("unsupported EC curve %q", k.Curve)
75+
}
76+
x, err := base64.RawURLEncoding.DecodeString(k.X)
77+
if err != nil {
78+
return nil, err
79+
}
80+
y, err := base64.RawURLEncoding.DecodeString(k.Y)
81+
if err != nil {
82+
return nil, err
83+
}
84+
return &ecdsa.PublicKey{Curve: curve, X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)}, nil
85+
case "RSA":
86+
n, err := base64.RawURLEncoding.DecodeString(k.N)
87+
if err != nil {
88+
return nil, err
89+
}
90+
eBytes, err := base64.RawURLEncoding.DecodeString(k.E)
91+
if err != nil {
92+
return nil, err
93+
}
94+
return &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(eBytes).Int64())}, nil
95+
case "OKP": // EdDSA
96+
x, err := base64.RawURLEncoding.DecodeString(k.X)
97+
if err != nil {
98+
return nil, err
99+
}
100+
return ed25519.PublicKey(x), nil
101+
default:
102+
return nil, fmt.Errorf("unknown key type %q", k.Type)
103+
}
104+
}
105+
106+
// PublicKeyToJWK converts a crypto.PublicKey to a JWK.
107+
func PublicKeyToJWK(pub crypto.PublicKey) *JWK {
108+
var jwk JWK
109+
switch pub := pub.(type) {
110+
case *ecdsa.PublicKey:
111+
var alg, crv string
112+
switch pub.Curve {
113+
case elliptic.P256():
114+
alg = "ES256"
115+
crv = "P-256"
116+
case elliptic.P384():
117+
alg = "ES384"
118+
crv = "P-384"
119+
case elliptic.P521():
120+
alg = "ES512"
121+
crv = "P-521"
122+
default:
123+
return nil
124+
}
125+
size := (pub.Curve.Params().BitSize + 7) / 8
126+
xBytes := make([]byte, size)
127+
yBytes := make([]byte, size)
128+
pub.X.FillBytes(xBytes)
129+
pub.Y.FillBytes(yBytes)
130+
jwk = JWK{
131+
Type: "EC",
132+
Use: "sig",
133+
Alg: alg,
134+
Curve: crv,
135+
X: base64.RawURLEncoding.EncodeToString(xBytes),
136+
Y: base64.RawURLEncoding.EncodeToString(yBytes),
137+
}
138+
case ed25519.PublicKey:
139+
jwk = JWK{
140+
Type: "OKP",
141+
Use: "sig",
142+
Alg: "EdDSA",
143+
Curve: "Ed25519",
144+
X: base64.RawURLEncoding.EncodeToString(pub),
145+
}
146+
case *rsa.PublicKey:
147+
jwk = JWK{
148+
Type: "RSA",
149+
Use: "sig",
150+
Alg: "RS256",
151+
N: base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
152+
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
153+
}
154+
default:
155+
return nil
156+
}
157+
// Calculate Key ID (kid)
158+
b, err := x509.MarshalPKIXPublicKey(pub)
159+
if err == nil {
160+
sum := sha256.Sum256(b)
161+
jwk.ID = hex.EncodeToString(sum[:])[:16]
162+
}
163+
return &jwk
164+
}
165+
166+
// New returns a new JWKS from the provided public keys.
167+
// Note: RSA keys default to alg:"RS256", which may not be correct, and might need to be updated.
168+
func New(keys []crypto.PublicKey) *JWKS {
169+
var out JWKS
170+
for _, pub := range keys {
171+
if k := PublicKeyToJWK(pub); k != nil {
172+
out.Keys = append(out.Keys, *k)
173+
}
174+
}
175+
return &out
176+
}

jwks/jwks_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// MIT License
2+
//
3+
// Copyright (c) 2024 TTBT Enterprises LLC
4+
// Copyright (c) 2024 Robin Thellend <rthellend@rthellend.com>
5+
//
6+
// Permission is hereby granted, free of charge, to any person obtaining a copy
7+
// of this software and associated documentation files (the "Software"), to deal
8+
// in the Software without restriction, including without limitation the rights
9+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
// copies of the Software, and to permit persons to whom the Software is
11+
// furnished to do so, subject to the following conditions:
12+
//
13+
// The above copyright notice and this permission notice shall be included in all
14+
// copies or substantial portions of the Software.
15+
//
16+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
// SOFTWARE.
23+
24+
package jwks
25+
26+
import (
27+
"crypto"
28+
"crypto/ecdsa"
29+
"crypto/ed25519"
30+
"crypto/elliptic"
31+
"crypto/rand"
32+
"crypto/rsa"
33+
"encoding/json"
34+
"testing"
35+
)
36+
37+
func TestJWKS(t *testing.T) {
38+
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
39+
if err != nil {
40+
t.Fatalf("ecdsa.GenerateKey: %v", err)
41+
}
42+
ecKey384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
43+
if err != nil {
44+
t.Fatalf("ecdsa.GenerateKey(P384): %v", err)
45+
}
46+
ecKey521, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
47+
if err != nil {
48+
t.Fatalf("ecdsa.GenerateKey(P521): %v", err)
49+
}
50+
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
51+
if err != nil {
52+
t.Fatalf("rsa.GenerateKey: %v", err)
53+
}
54+
edPub, _, err := ed25519.GenerateKey(rand.Reader)
55+
if err != nil {
56+
t.Fatalf("ed25519.GenerateKey: %v", err)
57+
}
58+
59+
ks := New([]crypto.PublicKey{
60+
&ecKey.PublicKey,
61+
&ecKey384.PublicKey,
62+
&ecKey521.PublicKey,
63+
&rsaKey.PublicKey,
64+
edPub,
65+
})
66+
if len(ks.Keys) != 5 {
67+
t.Errorf("len(ks.Keys) = %d, want 5", len(ks.Keys))
68+
}
69+
if ks.Keys[0].Type != "EC" || ks.Keys[0].Curve != "P-256" {
70+
t.Errorf("ks.Keys[0] = %v, want EC/P-256", ks.Keys[0])
71+
}
72+
if ks.Keys[1].Type != "EC" || ks.Keys[1].Curve != "P-384" {
73+
t.Errorf("ks.Keys[1] = %v, want EC/P-384", ks.Keys[1])
74+
}
75+
if ks.Keys[2].Type != "EC" || ks.Keys[2].Curve != "P-521" {
76+
t.Errorf("ks.Keys[2] = %v, want EC/P-521", ks.Keys[2])
77+
}
78+
if ks.Keys[3].Type != "RSA" {
79+
t.Errorf("ks.Keys[3].Type = %q, want RSA", ks.Keys[3].Type)
80+
}
81+
if ks.Keys[4].Type != "OKP" {
82+
t.Errorf("ks.Keys[4].Type = %q, want OKP", ks.Keys[4].Type)
83+
}
84+
85+
b, err := json.Marshal(ks)
86+
if err != nil {
87+
t.Fatalf("json.Marshal: %v", err)
88+
}
89+
var got JWKS
90+
if err := json.Unmarshal(b, &got); err != nil {
91+
t.Fatalf("json.Unmarshal: %v", err)
92+
}
93+
if len(got.Keys) != 5 {
94+
t.Errorf("len(got.Keys) = %d, want 5", len(got.Keys))
95+
}
96+
}

0 commit comments

Comments
 (0)