Skip to content

Commit bb52987

Browse files
committed
feat(openid4vci): verify signed_metadata in issuer metadata
When the credential issuer's metadata contains signed_metadata (v1.0 Section 12.2.3), verify the JWT signature using the issuer's key from the DID document. Validates typ header, required claims (sub, iat), and compares metadata claims against the unsigned metadata. Rejects metadata if verification fails; proceeds without it if absent (field is OPTIONAL).
1 parent c5327c6 commit bb52987

4 files changed

Lines changed: 301 additions & 3 deletions

File tree

auth/client/iam/client.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ package iam
2121
import (
2222
"bytes"
2323
"context"
24+
stdcrypto "crypto"
2425
"encoding/json"
2526
"errors"
2627
"fmt"
@@ -282,7 +283,52 @@ func (hb HTTPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIs
282283
if err != nil {
283284
return nil, err
284285
}
285-
return &metadata, err
286+
if metadata.SignedMetadata != "" {
287+
if err = hb.verifySignedMetadata(ctx, &metadata); err != nil {
288+
return nil, fmt.Errorf("signed_metadata verification failed: %w", err)
289+
}
290+
}
291+
return &metadata, nil
292+
}
293+
294+
// verifySignedMetadata verifies the signed_metadata JWT against the issuer's key (v1.0 Section 12.2.3).
295+
// It validates the JWT signature, typ header, required claims (sub, iat), and compares
296+
// key metadata claims (credential_issuer, credential_endpoint) against the unsigned metadata.
297+
func (hb HTTPClient) verifySignedMetadata(ctx context.Context, metadata *oauth.OpenIDCredentialIssuerMetadata) error {
298+
// Verify typ header to prevent JWT type confusion attacks
299+
typ, err := crypto.JWTTyp(metadata.SignedMetadata)
300+
if err != nil {
301+
return fmt.Errorf("invalid JWT: %w", err)
302+
}
303+
if typ != "openidvci-issuer-metadata+jwt" {
304+
return fmt.Errorf("typ header must be openidvci-issuer-metadata+jwt, got %q", typ)
305+
}
306+
// Parse, verify signature, and validate standard claims using shared infrastructure
307+
token, err := crypto.ParseJWT(metadata.SignedMetadata, func(kid string) (stdcrypto.PublicKey, error) {
308+
return hb.keyResolver.ResolveKeyByID(kid, nil, resolver.AssertionMethod)
309+
}, jwt.WithValidate(true), jwt.WithAcceptableSkew(5*time.Second))
310+
if err != nil {
311+
return fmt.Errorf("invalid JWT: %w", err)
312+
}
313+
// sub is REQUIRED, must match credential_issuer. iss is OPTIONAL per spec.
314+
if token.Subject() != metadata.CredentialIssuer {
315+
return fmt.Errorf("sub %q does not match credential_issuer %q", token.Subject(), metadata.CredentialIssuer)
316+
}
317+
if token.IssuedAt().IsZero() {
318+
return fmt.Errorf("iat claim is required")
319+
}
320+
// Compare metadata claims from JWT payload against unsigned metadata
321+
claims, err := token.AsMap(ctx)
322+
if err != nil {
323+
return fmt.Errorf("failed to extract claims: %w", err)
324+
}
325+
if ci, _ := claims["credential_issuer"].(string); ci != metadata.CredentialIssuer {
326+
return fmt.Errorf("credential_issuer claim %q does not match metadata %q", ci, metadata.CredentialIssuer)
327+
}
328+
if ce, _ := claims["credential_endpoint"].(string); ce != "" && ce != metadata.CredentialEndpoint {
329+
return fmt.Errorf("credential_endpoint claim %q does not match metadata %q", ce, metadata.CredentialEndpoint)
330+
}
331+
return nil
286332
}
287333

288334
func (hb HTTPClient) OpenIDConfiguration(ctx context.Context, issuerURL string) (*oauth.OpenIDConfiguration, error) {

auth/client/iam/openid4vp_test.go

Lines changed: 240 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ package iam
2020

2121
import (
2222
"context"
23+
"crypto/ecdsa"
2324
"crypto/tls"
2425
"encoding/json"
2526
"errors"
2627
"fmt"
28+
"github.com/lestrrat-go/jwx/v2/jwa"
29+
"github.com/lestrrat-go/jwx/v2/jws"
30+
"github.com/lestrrat-go/jwx/v2/jwt"
2731
"github.com/nuts-foundation/nuts-node/http/client"
2832
test2 "github.com/nuts-foundation/nuts-node/test"
2933
"github.com/nuts-foundation/nuts-node/vcr/credential"
@@ -40,6 +44,7 @@ import (
4044
"github.com/nuts-foundation/nuts-node/audit"
4145
"github.com/nuts-foundation/nuts-node/auth/oauth"
4246
"github.com/nuts-foundation/nuts-node/crypto"
47+
cryptoTest "github.com/nuts-foundation/nuts-node/crypto/test"
4348
http2 "github.com/nuts-foundation/nuts-node/test/http"
4449
"github.com/nuts-foundation/nuts-node/vcr/holder"
4550
"github.com/nuts-foundation/nuts-node/vcr/openid4vci"
@@ -486,8 +491,9 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon
486491
wallet: wallet,
487492
subjectManager: subjectManager,
488493
httpClient: HTTPClient{
489-
strictMode: false,
490-
httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig),
494+
strictMode: false,
495+
httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig),
496+
keyResolver: keyResolver,
491497
},
492498
jwtSigner: jwtSigner,
493499
keyResolver: keyResolver,
@@ -697,6 +703,238 @@ func TestIAMClient_OpenIdCredentialIssuerMetadata(t *testing.T) {
697703
assert.Nil(t, response)
698704
assert.EqualError(t, err, "failed to retrieve Openid credential issuer metadata: server returned HTTP 404 (expected: 200)")
699705
})
706+
t.Run("ok - signed_metadata is verified", func(t *testing.T) {
707+
ctx := createClientServerTestContext(t)
708+
ecKey := cryptoTest.GenerateECKey()
709+
kid := "did:web:example.com#key-1"
710+
signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{
711+
"credential_issuer": "https://issuer.example.com",
712+
"credential_endpoint": "https://issuer.example.com/credential",
713+
})
714+
issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{
715+
CredentialIssuer: "https://issuer.example.com",
716+
CredentialEndpoint: "https://issuer.example.com/credential",
717+
SignedMetadata: signedJWT,
718+
}
719+
ctx.openIDCredentialIssuerMetadata = issuerMetadata
720+
ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) {
721+
writer.Header().Add("Content-Type", "application/json")
722+
writer.WriteHeader(http.StatusOK)
723+
bytes, _ := json.Marshal(*issuerMetadata)
724+
_, _ = writer.Write(bytes)
725+
}
726+
ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil)
727+
728+
metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer")
729+
730+
require.NoError(t, err)
731+
require.NotNil(t, metadata)
732+
assert.Equal(t, "https://issuer.example.com", metadata.CredentialIssuer)
733+
})
734+
t.Run("error - signed_metadata JWT signature invalid", func(t *testing.T) {
735+
ctx := createClientServerTestContext(t)
736+
issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{
737+
CredentialIssuer: "https://issuer.example.com",
738+
CredentialEndpoint: "https://issuer.example.com/credential",
739+
SignedMetadata: "invalid.jwt.token",
740+
}
741+
ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) {
742+
writer.Header().Add("Content-Type", "application/json")
743+
writer.WriteHeader(http.StatusOK)
744+
bytes, _ := json.Marshal(*issuerMetadata)
745+
_, _ = writer.Write(bytes)
746+
}
747+
748+
metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer")
749+
750+
require.Error(t, err)
751+
assert.Nil(t, metadata)
752+
assert.ErrorContains(t, err, "signed_metadata verification failed")
753+
})
754+
t.Run("error - signed_metadata sub mismatch", func(t *testing.T) {
755+
ctx := createClientServerTestContext(t)
756+
ecKey := cryptoTest.GenerateECKey()
757+
kid := "did:web:example.com#key-1"
758+
signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{
759+
"credential_issuer": "https://other-issuer.example.com",
760+
"credential_endpoint": "https://issuer.example.com/credential",
761+
})
762+
issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{
763+
CredentialIssuer: "https://issuer.example.com",
764+
CredentialEndpoint: "https://issuer.example.com/credential",
765+
SignedMetadata: signedJWT,
766+
}
767+
ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) {
768+
writer.Header().Add("Content-Type", "application/json")
769+
writer.WriteHeader(http.StatusOK)
770+
bytes, _ := json.Marshal(*issuerMetadata)
771+
_, _ = writer.Write(bytes)
772+
}
773+
ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil)
774+
775+
metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer")
776+
777+
require.Error(t, err)
778+
assert.Nil(t, metadata)
779+
assert.ErrorContains(t, err, "sub")
780+
assert.ErrorContains(t, err, "does not match credential_issuer")
781+
})
782+
t.Run("error - signed_metadata credential_endpoint mismatch", func(t *testing.T) {
783+
ctx := createClientServerTestContext(t)
784+
ecKey := cryptoTest.GenerateECKey()
785+
kid := "did:web:example.com#key-1"
786+
signedJWT := createSignedMetadataJWT(t, ecKey, kid, map[string]interface{}{
787+
"credential_issuer": "https://issuer.example.com",
788+
"credential_endpoint": "https://evil.example.com/credential",
789+
})
790+
issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{
791+
CredentialIssuer: "https://issuer.example.com",
792+
CredentialEndpoint: "https://issuer.example.com/credential",
793+
SignedMetadata: signedJWT,
794+
}
795+
ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) {
796+
writer.Header().Add("Content-Type", "application/json")
797+
writer.WriteHeader(http.StatusOK)
798+
bytes, _ := json.Marshal(*issuerMetadata)
799+
_, _ = writer.Write(bytes)
800+
}
801+
ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil)
802+
803+
metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer")
804+
805+
require.Error(t, err)
806+
assert.Nil(t, metadata)
807+
assert.ErrorContains(t, err, "credential_endpoint claim")
808+
assert.ErrorContains(t, err, "does not match metadata")
809+
})
810+
t.Run("error - signed_metadata wrong typ header", func(t *testing.T) {
811+
ctx := createClientServerTestContext(t)
812+
ecKey := cryptoTest.GenerateECKey()
813+
kid := "did:web:example.com#key-1"
814+
signedJWT := createSignedMetadataJWTCustom(t, ecKey, kid, "jwt", map[string]interface{}{
815+
"iss": "https://issuer.example.com",
816+
"sub": "https://issuer.example.com",
817+
"credential_issuer": "https://issuer.example.com",
818+
"credential_endpoint": "https://issuer.example.com/credential",
819+
}, true, true)
820+
issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{
821+
CredentialIssuer: "https://issuer.example.com",
822+
CredentialEndpoint: "https://issuer.example.com/credential",
823+
SignedMetadata: signedJWT,
824+
}
825+
ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) {
826+
writer.Header().Add("Content-Type", "application/json")
827+
writer.WriteHeader(http.StatusOK)
828+
bytes, _ := json.Marshal(*issuerMetadata)
829+
_, _ = writer.Write(bytes)
830+
}
831+
832+
metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer")
833+
834+
require.Error(t, err)
835+
assert.Nil(t, metadata)
836+
assert.ErrorContains(t, err, "typ header must be openidvci-issuer-metadata+jwt")
837+
})
838+
t.Run("error - signed_metadata missing sub", func(t *testing.T) {
839+
ctx := createClientServerTestContext(t)
840+
ecKey := cryptoTest.GenerateECKey()
841+
kid := "did:web:example.com#key-1"
842+
signedJWT := createSignedMetadataJWTCustom(t, ecKey, kid, "openidvci-issuer-metadata+jwt", map[string]interface{}{
843+
"iss": "https://issuer.example.com",
844+
"credential_issuer": "https://issuer.example.com",
845+
"credential_endpoint": "https://issuer.example.com/credential",
846+
}, true, true)
847+
issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{
848+
CredentialIssuer: "https://issuer.example.com",
849+
CredentialEndpoint: "https://issuer.example.com/credential",
850+
SignedMetadata: signedJWT,
851+
}
852+
ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) {
853+
writer.Header().Add("Content-Type", "application/json")
854+
writer.WriteHeader(http.StatusOK)
855+
bytes, _ := json.Marshal(*issuerMetadata)
856+
_, _ = writer.Write(bytes)
857+
}
858+
ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil)
859+
860+
metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer")
861+
862+
require.Error(t, err)
863+
assert.Nil(t, metadata)
864+
assert.ErrorContains(t, err, "sub")
865+
assert.ErrorContains(t, err, "does not match credential_issuer")
866+
})
867+
t.Run("error - signed_metadata missing iat", func(t *testing.T) {
868+
ctx := createClientServerTestContext(t)
869+
ecKey := cryptoTest.GenerateECKey()
870+
kid := "did:web:example.com#key-1"
871+
signedJWT := createSignedMetadataJWTCustom(t, ecKey, kid, "openidvci-issuer-metadata+jwt", map[string]interface{}{
872+
"sub": "https://issuer.example.com",
873+
"credential_issuer": "https://issuer.example.com",
874+
"credential_endpoint": "https://issuer.example.com/credential",
875+
}, false, false)
876+
issuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{
877+
CredentialIssuer: "https://issuer.example.com",
878+
CredentialEndpoint: "https://issuer.example.com/credential",
879+
SignedMetadata: signedJWT,
880+
}
881+
ctx.credentialIssuerMetadata = func(writer http.ResponseWriter) {
882+
writer.Header().Add("Content-Type", "application/json")
883+
writer.WriteHeader(http.StatusOK)
884+
bytes, _ := json.Marshal(*issuerMetadata)
885+
_, _ = writer.Write(bytes)
886+
}
887+
ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(ecKey.Public(), nil)
888+
889+
metadata, err := ctx.client.OpenIdCredentialIssuerMetadata(context.Background(), ctx.tlsServer.URL+"/issuer")
890+
891+
require.Error(t, err)
892+
assert.Nil(t, metadata)
893+
assert.ErrorContains(t, err, "iat claim is required")
894+
})
895+
}
896+
897+
func createSignedMetadataJWT(t *testing.T, key *ecdsa.PrivateKey, kid string, claims map[string]interface{}) string {
898+
t.Helper()
899+
token := jwt.New()
900+
for k, v := range claims {
901+
require.NoError(t, token.Set(k, v))
902+
}
903+
if iss, ok := claims["credential_issuer"].(string); ok {
904+
require.NoError(t, token.Set(jwt.IssuerKey, iss))
905+
require.NoError(t, token.Set(jwt.SubjectKey, iss))
906+
}
907+
require.NoError(t, token.Set(jwt.IssuedAtKey, time.Now().Unix()))
908+
require.NoError(t, token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour).Unix()))
909+
hdrs := jws.NewHeaders()
910+
require.NoError(t, hdrs.Set(jws.KeyIDKey, kid))
911+
require.NoError(t, hdrs.Set(jws.TypeKey, "openidvci-issuer-metadata+jwt"))
912+
signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, key, jws.WithProtectedHeaders(hdrs)))
913+
require.NoError(t, err)
914+
return string(signed)
915+
}
916+
917+
// createSignedMetadataJWTCustom allows overriding specific JWT fields for negative tests.
918+
func createSignedMetadataJWTCustom(t *testing.T, key *ecdsa.PrivateKey, kid string, typ string, claims map[string]interface{}, setIat bool, setExp bool) string {
919+
t.Helper()
920+
token := jwt.New()
921+
for k, v := range claims {
922+
require.NoError(t, token.Set(k, v))
923+
}
924+
if setIat {
925+
require.NoError(t, token.Set(jwt.IssuedAtKey, time.Now().Unix()))
926+
}
927+
if setExp {
928+
require.NoError(t, token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour).Unix()))
929+
}
930+
hdrs := jws.NewHeaders()
931+
require.NoError(t, hdrs.Set(jws.KeyIDKey, kid))
932+
if typ != "" {
933+
require.NoError(t, hdrs.Set(jws.TypeKey, typ))
934+
}
935+
signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, key, jws.WithProtectedHeaders(hdrs)))
936+
require.NoError(t, err)
937+
return string(signed)
700938
}
701939

702940
func TestIAMClient_RequestNonce(t *testing.T) {

auth/oauth/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,8 @@ type OpenIDCredentialIssuerMetadata struct {
423423
AuthorizationServers []string `json:"authorization_servers,omitempty"`
424424
CredentialConfigurationsSupported map[string]map[string]interface{} `json:"credential_configurations_supported,omitempty"`
425425
Display []map[string]string `json:"display,omitempty"`
426+
// SignedMetadata is a JWT containing signed issuer metadata for trust verification (v1.0 Section 12.2.3).
427+
SignedMetadata string `json:"signed_metadata,omitempty"`
426428
}
427429

428430
// OpenIDConfiguration represents the OpenID configuration

crypto/jwx.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,18 @@ func JWTKidAlg(tokenString string) (string, jwa.SignatureAlgorithm, error) {
165165
return hdrs.KeyID(), hdrs.Algorithm(), nil
166166
}
167167

168+
// JWTTyp parses a JWT without validation and returns the 'typ' header.
169+
func JWTTyp(tokenString string) (string, error) {
170+
j, err := jws.ParseString(tokenString)
171+
if err != nil {
172+
return "", err
173+
}
174+
if len(j.Signatures()) != 1 {
175+
return "", errors.New("incorrect number of signatures in JWT")
176+
}
177+
return j.Signatures()[0].ProtectedHeaders().Type(), nil
178+
}
179+
168180
// PublicKeyFunc defines a function that resolves a public key based on a kid
169181
type PublicKeyFunc func(kid string) (crypto.PublicKey, error)
170182

0 commit comments

Comments
 (0)