All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 33s
Completes KEY-WP-0019. Client.Verify now delegates JWK-set parsing and RS256 signature checking to internal/jose, so the caller path and upstream provider verification share one implementation rather than two copies that drift. Its claim policy stays put: audience and nonce bindings belong to the caller. The existing authclient tests pass unchanged, which is the evidence the migration preserved behaviour. One deliberate strictness increase: a key set containing any malformed RSA signing key is refused outright rather than used alongside a good key. KeyCape's /jwks publishes a single key, so no current deployment is affected. Adds direct tests for internal/jose. It is now the single verifier behind both paths, and testing it only through its callers would leave duplicate key ids, crit headers, even exponents and undersized moduli covered by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
168 lines
5.3 KiB
Go
168 lines
5.3 KiB
Go
package jose_test
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/big"
|
|
"strings"
|
|
"testing"
|
|
|
|
"keycape/internal/jose"
|
|
)
|
|
|
|
// internal/jose is the single signature verifier behind both the caller CLI and
|
|
// upstream provider verification, so it is tested directly rather than only
|
|
// through its callers.
|
|
|
|
var testKey = func() *rsa.PrivateKey {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return key
|
|
}()
|
|
|
|
func jwks(kid string, key *rsa.PrivateKey) []byte {
|
|
return []byte(fmt.Sprintf(`{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":%q,"n":%q,"e":%q}]}`,
|
|
kid,
|
|
base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
|
|
base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes())))
|
|
}
|
|
|
|
func sign(t *testing.T, header, claims string, key *rsa.PrivateKey) string {
|
|
t.Helper()
|
|
input := base64.RawURLEncoding.EncodeToString([]byte(header)) + "." +
|
|
base64.RawURLEncoding.EncodeToString([]byte(claims))
|
|
digest := sha256.Sum256([]byte(input))
|
|
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return input + "." + base64.RawURLEncoding.EncodeToString(signature)
|
|
}
|
|
|
|
func TestVerifyAcceptsGenuineTokenAndReturnsClaims(t *testing.T) {
|
|
keys, err := jose.ParseJWKS(jwks("k1", testKey))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token := sign(t, `{"alg":"RS256","kid":"k1"}`, `{"sub":"alice","n":1}`, testKey)
|
|
claims, err := jose.Verify(token, keys)
|
|
if err != nil {
|
|
t.Fatalf("genuine token rejected: %v", err)
|
|
}
|
|
if claims["sub"] != "alice" {
|
|
t.Fatalf("claims not returned: %v", claims)
|
|
}
|
|
}
|
|
|
|
func TestVerifyRejections(t *testing.T) {
|
|
keys, err := jose.ParseJWKS(jwks("k1", testKey))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
other, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
genuine := sign(t, `{"alg":"RS256","kid":"k1"}`, `{"sub":"alice"}`, testKey)
|
|
parts := strings.Split(genuine, ".")
|
|
|
|
cases := map[string]string{
|
|
"wrong signing key": sign(t, `{"alg":"RS256","kid":"k1"}`, `{"sub":"alice"}`, other),
|
|
"unknown key id": sign(t, `{"alg":"RS256","kid":"k2"}`, `{"sub":"alice"}`, testKey),
|
|
"missing key id": sign(t, `{"alg":"RS256"}`, `{"sub":"alice"}`, testKey),
|
|
"alg none": sign(t, `{"alg":"none","kid":"k1"}`, `{"sub":"alice"}`, testKey),
|
|
"alg HS256": sign(t, `{"alg":"HS256","kid":"k1"}`, `{"sub":"alice"}`, testKey),
|
|
// A critical extension we do not understand must not be ignored.
|
|
"critical extension": sign(t, `{"alg":"RS256","kid":"k1","crit":["exp"]}`, `{"sub":"alice"}`, testKey),
|
|
"tampered payload": parts[0] + "." + base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"mallory"}`)) + "." + parts[2],
|
|
"two segments": parts[0] + "." + parts[1],
|
|
"payload not json": sign(t, `{"alg":"RS256","kid":"k1"}`, `not-json`, testKey),
|
|
"empty": "",
|
|
}
|
|
|
|
for name, token := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := jose.Verify(token, keys); err == nil {
|
|
t.Fatal("accepted")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseJWKSRejectsUnusableKeySets(t *testing.T) {
|
|
small, err := rsa.GenerateKey(rand.Reader, 1024)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cases := map[string][]byte{
|
|
"undersized modulus": jwks("k1", small),
|
|
"empty set": []byte(`{"keys":[]}`),
|
|
"no rsa signing key": []byte(`{"keys":[{"kty":"EC","kid":"k1","crv":"P-256"}]}`),
|
|
"missing key id": []byte(`{"keys":[{"kty":"RSA","n":"AQAB","e":"AQAB"}]}`),
|
|
"bad base64": []byte(`{"keys":[{"kty":"RSA","kid":"k1","n":"!!!","e":"AQAB"}]}`),
|
|
"even exponent": []byte(fmt.Sprintf(`{"keys":[{"kty":"RSA","kid":"k1","n":%q,"e":"AQAA"}]}`, base64.RawURLEncoding.EncodeToString(testKey.PublicKey.N.Bytes()))),
|
|
"not json": []byte(`nonsense`),
|
|
}
|
|
for name, raw := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := jose.ParseJWKS(raw); err == nil {
|
|
t.Fatal("accepted")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Two entries under one key id make key selection ambiguous, so the set is
|
|
// refused rather than resolved by order.
|
|
func TestParseJWKSRejectsDuplicateKeyIDs(t *testing.T) {
|
|
var first, second map[string]interface{}
|
|
if err := json.Unmarshal(jwks("k1", testKey), &first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(jwks("k1", testKey), &second); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
merged, err := json.Marshal(map[string]interface{}{
|
|
"keys": []interface{}{
|
|
first["keys"].([]interface{})[0],
|
|
second["keys"].([]interface{})[0],
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := jose.ParseJWKS(merged); err == nil {
|
|
t.Fatal("duplicate key id accepted")
|
|
}
|
|
}
|
|
|
|
// Keys for other algorithms alongside a usable RS256 key are ignored, not fatal.
|
|
func TestParseJWKSIgnoresIrrelevantKeys(t *testing.T) {
|
|
var usable map[string]interface{}
|
|
if err := json.Unmarshal(jwks("k1", testKey), &usable); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mixed, err := json.Marshal(map[string]interface{}{
|
|
"keys": []interface{}{
|
|
map[string]interface{}{"kty": "EC", "kid": "ec", "crv": "P-256"},
|
|
usable["keys"].([]interface{})[0],
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keys, err := jose.ParseJWKS(mixed)
|
|
if err != nil {
|
|
t.Fatalf("usable key set rejected: %v", err)
|
|
}
|
|
if _, ok := keys["k1"]; !ok || len(keys) != 1 {
|
|
t.Fatalf("unexpected key set: %v", keys)
|
|
}
|
|
}
|