From 2a85bbb480627a74012bebc82c0724d953a31290 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 2 Jul 2026 13:17:11 +0200 Subject: [PATCH 01/12] fix: unwrap error chain when resolving HTTP status code GetHTTPStatusCode only did a direct type assertion for HTTPStatusCodeError, so any fmt.Errorf("...: %w", err) wrap between an error's origin and the Echo error handler silently discarded a deliberately-set status code, falling back to 500. This affected RequestServiceAccessToken when the remote presentation definition endpoint returns an OAuth2 error (e.g. invalid_scope), among other paths, per #2943. --- core/echo_errors.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/echo_errors.go b/core/echo_errors.go index 6eb8a25f7d..39dde1ff3c 100644 --- a/core/echo_errors.go +++ b/core/echo_errors.go @@ -193,7 +193,7 @@ func ResolveStatusCode(err error, mapping map[error]int) int { // - from handler // - if none of the above criteria match, HTTP 500 Internal Server Error is returned. func GetHTTPStatusCode(err error, ctx echo.Context) int { - if predefined, ok := err.(HTTPStatusCodeError); ok { + if predefined, ok := errors.AsType[HTTPStatusCodeError](err); ok { return predefined.StatusCode() } if predefined, ok := err.(*echo.HTTPError); ok { From f931a5be84fc48b0d15f92f5be7417809310a904 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Fri, 3 Jul 2026 10:43:06 +0200 Subject: [PATCH 02/12] chore: migrate from lestrrat-go/jwx v2 (EOL) to v3 jwx v2 is deprecated (EOL as of v2.1.7); migrate all direct usage to v3. - Rewrite imports v2 -> v3 across the codebase. - jwa algorithm constants -> function calls (jwa.ES256 -> jwa.ES256()). - Adapt Get()/accessor call sites to v3 signatures ((T, bool) returns). - jwk.FromRaw -> jwk.Import, (jwk.Key).Raw -> jwk.Export. - Replace the removed raw jws.NewVerifier with jwsbb.Verify for detached signature verification (crypto, vcr JSON-LD proof). - didkey resolver: use crypto/ecdh for X25519 (jwx/x25519 removed in v3). - Faithfully replace token.AsMap/PrivateClaims with JSON-based extraction so null-valued claims are still tolerated (v3 per-claim Get errors on null). - azure keyvault: validate key type before jwk.Export for a clear error. - Point go-did at its jwx-v3 branch (pending a tagged go-did release). Assisted by AI --- auth/api/iam/api.go | 9 +- auth/api/iam/api_test.go | 8 +- auth/api/iam/dpop.go | 9 +- auth/api/iam/dpop_test.go | 14 ++- auth/api/iam/jar.go | 15 ++- auth/api/iam/jar_test.go | 14 +-- auth/api/iam/metadata.go | 10 +- auth/api/iam/openid4vci.go | 2 +- auth/api/iam/openid4vp.go | 7 +- auth/api/iam/openid4vp_test.go | 2 +- auth/api/iam/s2s_vptoken.go | 3 +- auth/api/iam/s2s_vptoken_test.go | 4 +- auth/api/iam/user_test.go | 6 +- auth/api/iam/validation.go | 2 +- auth/client/iam/client.go | 17 ++- auth/client/iam/client_test.go | 11 +- auth/oauth/types.go | 2 +- auth/oauth/types_test.go | 2 +- auth/services/irma/validator.go | 2 +- auth/services/oauth/authz_server.go | 69 +++++++---- auth/services/oauth/authz_server_test.go | 39 +++--- auth/services/oauth/relying_party.go | 2 +- crypto/api/v1/api.go | 2 +- crypto/dpop/dpop.go | 60 ++++++---- crypto/dpop/dpop_test.go | 57 ++++----- crypto/dpop_test.go | 15 +-- crypto/jwt_profile.go | 7 +- crypto/jwx.go | 113 ++++++++++-------- crypto/jwx/algorithm.go | 12 +- crypto/jwx/jwx_es256k.go | 2 +- crypto/jwx/jwx_es256k_test.go | 4 +- crypto/jwx_test.go | 111 +++++++++-------- crypto/memory.go | 10 +- crypto/memory_test.go | 6 +- crypto/storage/azure/keyvault.go | 10 +- crypto/storage/azure/keyvault_test.go | 19 ++- crypto/storage/spi/interface.go | 12 +- crypto/storage/spi/interface_test.go | 6 +- discovery/client_test.go | 2 +- discovery/module.go | 14 +-- discovery/module_test.go | 2 +- discovery/store.go | 3 +- discovery/store_test.go | 2 +- discovery/test.go | 15 +-- go.mod | 20 ++-- go.sum | 40 +++---- http/engine_test.go | 10 +- http/tokenV2/authorized_keys.go | 4 +- http/tokenV2/middleware.go | 64 +++++----- http/tokenV2/middleware_test.go | 101 ++++++---------- http/user/session.go | 2 +- http/user/session_test.go | 4 +- network/dag/parser.go | 40 ++++--- network/dag/parser_test.go | 75 +++++++----- network/dag/signing.go | 6 +- network/dag/signing_test.go | 2 +- network/dag/test.go | 4 +- network/dag/transaction.go | 6 +- network/dag/verifier.go | 13 +- network/dag/verifier_test.go | 8 +- .../v2/transactionlist_handler_test.go | 2 +- pki/denylist.go | 16 +-- pki/denylist_test.go | 12 +- test/pki/test.go | 2 +- vcr/credential/resolver_test.go | 12 +- vcr/credential/util.go | 7 +- vcr/credential/util_test.go | 2 +- vcr/credential/validator.go | 2 +- vcr/credential/validator_test.go | 17 ++- vcr/holder/presenter_test.go | 29 +++-- vcr/issuer/issuer_test.go | 12 +- vcr/issuer/openid.go | 13 +- vcr/pe/presentation_definition.go | 7 +- vcr/pe/presentation_definition_test.go | 14 ++- vcr/pe/util.go | 8 +- vcr/pe/util_test.go | 4 +- vcr/signature/json_web_signature.go | 2 +- vcr/signature/json_web_signature_test.go | 15 ++- vcr/signature/proof/jsonld.go | 5 +- vcr/signature/proof/jsonld_test.go | 2 +- vcr/store_test.go | 3 +- vcr/test/credentials.go | 12 +- vcr/test/test.go | 12 +- vcr/verifier/signature_verifier.go | 2 +- vcr/verifier/signature_verifier_test.go | 34 ++++-- vcr/verifier/verifier_test.go | 15 +-- vdr/didjwk/resolver.go | 6 +- vdr/didjwk/resolver_test.go | 2 +- vdr/didkey/resolver.go | 6 +- vdr/didnuts/ambassador.go | 6 +- vdr/didnuts/ambassador_test.go | 18 +-- vdr/didnuts/manager.go | 6 +- vdr/didnuts/manager_test.go | 18 +-- vdr/didnuts/validators.go | 4 +- vdr/didx509/resolver_test.go | 2 +- vdr/didx509/x509_utils.go | 2 +- vdr/didx509/x509_utils_test.go | 36 +++--- vdr/resolver/did.go | 2 +- vdr/resolver/did_test.go | 2 +- vdr/vdr_test.go | 4 +- 100 files changed, 814 insertions(+), 693 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 6386a3bc22..6943c3d1bd 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -39,8 +39,8 @@ import ( "github.com/nuts-foundation/nuts-node/core/to" "github.com/labstack/echo/v4" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth" @@ -402,7 +402,8 @@ func (r Wrapper) introspectAccessToken(input string) (*ExtendedTokenIntrospectio // SHA256 hashing won't fail. var cnf *Cnf if token.DPoP != nil { - hash, _ := token.DPoP.Headers.JWK().Thumbprint(crypto.SHA256) + key, _ := token.DPoP.Headers.JWK() + hash, _ := key.Thumbprint(crypto.SHA256) base64Hash := base64.RawURLEncoding.EncodeToString(hash) cnf = &Cnf{Jkt: base64Hash} } @@ -653,7 +654,7 @@ func (r Wrapper) OpenIDConfiguration(ctx context.Context, request OpenIDConfigur } } // create JWK and add to set - jwkKey, err := jwk.FromRaw(key) + jwkKey, err := jwk.Import(key) if err != nil { return nil, oauth.OAuth2Error{ Code: oauth.ServerError, diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 01a1e031af..41c39b3782 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -33,9 +33,9 @@ import ( "time" "github.com/labstack/echo/v4" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -1538,7 +1538,7 @@ func createIssuerCredential(issuerDID did.DID, holderDID did.DID) *vc.Verifiable for key, val := range claims { request.Set(key, val) } - sign, err := jwt.Sign(request, jwt.WithKey(jwa.ES256, privateKey, jws.WithProtectedHeaders(hdrs))) + sign, err := jwt.Sign(request, jwt.WithKey(jwa.ES256(), privateKey, jws.WithProtectedHeaders(hdrs))) return string(sign), err } diff --git a/auth/api/iam/dpop.go b/auth/api/iam/dpop.go index 6de6e379bb..b690a7c45a 100644 --- a/auth/api/iam/dpop.go +++ b/auth/api/iam/dpop.go @@ -76,8 +76,8 @@ func (r Wrapper) ValidateDPoPProof(_ context.Context, request ValidateDPoPProofR return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil } // check if ath claim matches hash of access_token - ath, ok := dpopToken.Token.Get(dpop.ATHKey) - if !ok { + var ath string + if err := dpopToken.Token.Get(dpop.ATHKey, &ath); err != nil { reason := "missing ath claim" return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil } @@ -87,13 +87,14 @@ func (r Wrapper) ValidateDPoPProof(_ context.Context, request ValidateDPoPProofR return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil } // check if the jti is already used, if not add it to the store for the duration of the access token lifetime + jti, _ := dpopToken.Token.JwtID() var target struct{} - if err := r.useNonceOnceStore().Get(dpopToken.Token.JwtID(), &target); err != nil { + if err := r.useNonceOnceStore().Get(jti, &target); err != nil { if !errors.Is(err, storage.ErrNotFound) { log.Logger().WithError(err).Error("ValidateDPoPProof: failed to retrieve jti usage state") return nil, err } - if err := r.useNonceOnceStore().Put(dpopToken.Token.JwtID(), target); err != nil { + if err := r.useNonceOnceStore().Put(jti, target); err != nil { log.Logger().WithError(err).Error("ValidateDPoPProof: failed to store jti usage state") return nil, err } diff --git a/auth/api/iam/dpop_test.go b/auth/api/iam/dpop_test.go index 1b55d9b252..1c9f54c160 100644 --- a/auth/api/iam/dpop_test.go +++ b/auth/api/iam/dpop_test.go @@ -29,7 +29,7 @@ import ( "net/http" "testing" - "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v3/jwa" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -172,7 +172,7 @@ func TestWrapper_ValidateDPoPProof(t *testing.T) { require.NoError(t, err) require.IsType(t, ValidateDPoPProof200JSONResponse{}, resp) assert.False(t, resp.(ValidateDPoPProof200JSONResponse).Valid) - assert.Equal(t, "failed to parse DPoP header: invalid DPoP token\ninvalid compact serialization format: invalid number of segments", *resp.(ValidateDPoPProof200JSONResponse).Reason) + assert.Equal(t, "failed to parse DPoP header: invalid DPoP token\njws.ParseString: failed to parse string: jws.Parse: failed to parse compact format: jws.Parse: invalid compact serialization format: jwsbb: invalid number of segments", *resp.(ValidateDPoPProof200JSONResponse).Reason) }) t.Run("invalid accestoken", func(t *testing.T) { ctx := newTestClient(t) @@ -188,7 +188,8 @@ func TestWrapper_ValidateDPoPProof(t *testing.T) { }) t.Run("already used once", func(t *testing.T) { ctx := newTestClient(t) - _ = ctx.client.useNonceOnceStore().Put(dpopProof.Token.JwtID(), struct{}{}) + jti, _ := dpopProof.Token.JwtID() + _ = ctx.client.useNonceOnceStore().Put(jti, struct{}{}) resp, err := ctx.client.ValidateDPoPProof(nil, request) @@ -229,9 +230,10 @@ func newSignedTestDPoP() (*dpop.DPoP, *dpop.DPoP, string) { withProof := newTestDPoP() keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) _ = withProof.GenerateProof("token") - _, _ = withProof.Sign("kid", keyPair, jwa.ES256) - _, _ = dpopToken.Sign("kid", keyPair, jwa.ES256) - thumbprintBytes, _ := dpopToken.Headers.JWK().Thumbprint(crypto2.SHA256) + _, _ = withProof.Sign("kid", keyPair, jwa.ES256()) + _, _ = dpopToken.Sign("kid", keyPair, jwa.ES256()) + jwkKey, _ := dpopToken.Headers.JWK() + thumbprintBytes, _ := jwkKey.Thumbprint(crypto2.SHA256) thumbprint := base64.RawURLEncoding.EncodeToString(thumbprintBytes) return dpopToken, withProof, thumbprint } diff --git a/auth/api/iam/jar.go b/auth/api/iam/jar.go index e634ed9c32..f97beac496 100644 --- a/auth/api/iam/jar.go +++ b/auth/api/iam/jar.go @@ -22,9 +22,10 @@ import ( "bytes" "context" "crypto" + "encoding/json" "errors" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -183,8 +184,12 @@ func (j jar) validate(ctx context.Context, rawToken string, clientId string) (oa if err != nil { return nil, oauth.OAuth2Error{Code: oauth.InvalidRequestObject, Description: "request signature validation failed", InternalError: err} } - claimsAsMap, err := token.AsMap(ctx) - if err != nil { + // Convert the token's claims to a map via its JSON representation. This mirrors jwx v2's + // token.AsMap: it preserves all claims including null-valued ones (a per-claim Get loop + // errors on null values in v3). + claimsAsMap := make(map[string]interface{}) + claimsJSON, _ := json.Marshal(token) + if err := json.Unmarshal(claimsJSON, &claimsAsMap); err != nil { // very unlikely return nil, oauth.OAuth2Error{Code: oauth.InvalidRequestObject, Description: "invalid request parameter", InternalError: err} } @@ -213,7 +218,7 @@ func compareThumbprint(configurationKey jwk.Key, publicKey crypto.PublicKey) err if err != nil { return err } - signerKey, err := jwk.FromRaw(publicKey) + signerKey, err := jwk.Import(publicKey) if err != nil { return err } diff --git a/auth/api/iam/jar_test.go b/auth/api/iam/jar_test.go index 17b5741e2d..ef039ec8ac 100644 --- a/auth/api/iam/jar_test.go +++ b/auth/api/iam/jar_test.go @@ -23,15 +23,15 @@ import ( "crypto" "errors" "fmt" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/nuts-node/crypto/storage/spi" "github.com/nuts-foundation/nuts-node/test" "testing" "time" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/client/iam" @@ -126,7 +126,7 @@ func TestJar_Parse(t *testing.T) { // setup did document and keys privateKey, _ := spi.GenerateKeyPair() kid := fmt.Sprintf("%s#%s", holderDID.String(), "key") - jwkKey, _ := jwk.FromRaw(privateKey.Public()) + jwkKey, _ := jwk.Import(privateKey.Public()) jwkKey.Set(jwk.KeyIDKey, kid) jwkSet := jwk.NewSet() _ = jwkSet.AddKey(jwkKey) @@ -329,7 +329,7 @@ func TestJar_Parse(t *testing.T) { t.Run("error - openID configuration key mismatch", func(t *testing.T) { ctx := newJarTestCtx(t) alternateKey, _ := spi.GenerateKeyPair() - jwkKey, _ := jwk.FromRaw(alternateKey.Public()) + jwkKey, _ := jwk.Import(alternateKey.Public()) jwkKey.Set(jwk.KeyIDKey, kid) jwkSet := jwk.NewSet() _ = jwkSet.AddKey(jwkKey) @@ -358,7 +358,7 @@ func createSignedRequestObject(t testing.TB, kid string, privateKey crypto.Priva } headers := jws.NewHeaders() require.NoError(t, headers.Set(jws.KeyIDKey, kid)) - return jwt.Sign(request, jwt.WithKey(jwa.ES256, privateKey, jws.WithProtectedHeaders(headers))) + return jwt.Sign(request, jwt.WithKey(jwa.ES256(), privateKey, jws.WithProtectedHeaders(headers))) } type testJarCtx struct { diff --git a/auth/api/iam/metadata.go b/auth/api/iam/metadata.go index ec58fac866..578119813b 100644 --- a/auth/api/iam/metadata.go +++ b/auth/api/iam/metadata.go @@ -19,12 +19,12 @@ package iam import ( - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "net/url" "strings" "time" - "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v3/jwa" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/core/to" @@ -67,10 +67,10 @@ func staticAuthorizationServerMetadata() oauth.AuthorizationServerMetadata { ClientIdSchemesSupported: clientIdSchemesSupported, ResponseTypesSupported: []string{oauth.VPTokenResponseType}, VPFormatsSupported: map[string]map[string][]string{ - "jwt_vp_json": {"alg_values_supported": []string{string(jwa.ES256)}}, - "jwt_vc_json": {"alg_values_supported": []string{string(jwa.ES256)}}, + "jwt_vp_json": {"alg_values_supported": []string{jwa.ES256().String()}}, + "jwt_vc_json": {"alg_values_supported": []string{jwa.ES256().String()}}, }, - RequestObjectSigningAlgValuesSupported: []string{string(jwa.ES256)}, + RequestObjectSigningAlgValuesSupported: []string{jwa.ES256().String()}, } } diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index c5817da2c8..453bc4183c 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -27,7 +27,7 @@ import ( "net/url" "time" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 8db40857bb..1afcfcf460 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -34,8 +34,8 @@ import ( "strings" "time" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -598,8 +598,7 @@ func extractChallenge(presentation vc.VerifiablePresentation) (string, error) { var nonce string switch presentation.Format() { case vc.JWTPresentationProofFormat: - nonceRaw, _ := presentation.JWT().Get("nonce") - nonce, _ = nonceRaw.(string) + _ = presentation.JWT().Get("nonce", &nonce) case vc.JSONLDPresentationProofFormat: proof, err := credential.ParseLDProof(presentation) if err != nil { diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index 71e5fb4b17..90df21a7af 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -31,7 +31,7 @@ import ( "github.com/nuts-foundation/nuts-node/http/user" "github.com/nuts-foundation/nuts-node/vcr/verifier" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index 34212badbf..c60a879926 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -226,8 +226,7 @@ func extractNonce(presentation vc.VerifiablePresentation) (string, error) { var nonce string switch presentation.Format() { case vc.JWTPresentationProofFormat: - nonceRaw, _ := presentation.JWT().Get("nonce") - nonce, _ = nonceRaw.(string) + _ = presentation.JWT().Get("nonce", &nonce) case vc.JSONLDPresentationProofFormat: proof, err := credential.ParseLDProof(presentation) if err != nil { diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index bb07c86ae4..f76e1e5c1a 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -32,7 +32,7 @@ import ( "testing" "time" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -182,7 +182,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { ctx := newTestClient(t) resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, "[true, false]") - assert.EqualError(t, err, "invalid_request - assertion parameter is invalid: unable to parse PEX envelope as verifiable presentation: invalid JWT") + assert.EqualError(t, err, "invalid_request - assertion parameter is invalid: unable to parse PEX envelope as verifiable presentation: jwt.Parse: failed to parse token: unknown payload type (payload is not JWT?)") assert.Nil(t, resp) }) t.Run("not all VPs have the same credential subject ID", func(t *testing.T) { diff --git a/auth/api/iam/user_test.go b/auth/api/iam/user_test.go index 1edc14d76c..6c9307005c 100644 --- a/auth/api/iam/user_test.go +++ b/auth/api/iam/user_test.go @@ -28,8 +28,8 @@ import ( "testing" "time" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -128,7 +128,7 @@ func TestWrapper_handleUserLanding(t *testing.T) { sessionKey, err := jwk.ParseKey(userSession.Wallet.JWK) require.NoError(t, err) assert.NotEmpty(t, sessionKey.KeyID) - assert.Equal(t, jwa.EC, sessionKey.KeyType()) + assert.Equal(t, jwa.EC(), sessionKey.KeyType()) // check for details of issued NutsEmployeeCredential assert.Equal(t, "NutsEmployeeCredential", employeeCredentialTemplate.Type[0].String()) employeeCredentialSubject := employeeCredentialTemplate.CredentialSubject[0] diff --git a/auth/api/iam/validation.go b/auth/api/iam/validation.go index 81c3c66596..901abe3d35 100644 --- a/auth/api/iam/validation.go +++ b/auth/api/iam/validation.go @@ -54,7 +54,7 @@ func (r Wrapper) validatePresentationAudience(presentation vc.VerifiablePresenta var audience []string switch presentation.Format() { case vc.JWTPresentationProofFormat: - audience = presentation.JWT().Audience() + audience, _ = presentation.JWT().Audience() case vc.JSONLDPresentationProofFormat: proof, err := credential.ParseLDProof(presentation) if err != nil { diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 3112ede9bf..46c37ea368 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -29,8 +29,8 @@ import ( "strings" "time" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -264,12 +264,17 @@ func (hb HTTPClient) OpenIDConfiguration(ctx context.Context, issuerURL string) if err != nil { return nil, fmt.Errorf("unable to parse response: %w", err) } - claims, err := token.AsMap(ctx) - if err != nil { + // Convert the token's claims to a map via its JSON representation. This mirrors jwx v2's + // token.AsMap: it preserves all claims including null-valued ones (a per-claim Get loop + // errors on null values in v3). + claims := make(map[string]interface{}) + claimsJSON, _ := json.Marshal(token) + if err = json.Unmarshal(claimsJSON, &claims); err != nil { return nil, fmt.Errorf("unable to parse response: %w", err) } // hack, broken iat - claims["iat"] = token.IssuedAt().Unix() + iat, _ := token.IssuedAt() + claims["iat"] = iat.Unix() asJSON, _ := json.Marshal(claims) if err = json.Unmarshal(asJSON, &configuration); err != nil { return nil, fmt.Errorf("unable to unmarshal response: %w", err) @@ -279,7 +284,7 @@ func (hb HTTPClient) OpenIDConfiguration(ctx context.Context, issuerURL string) func (hb HTTPClient) KeyProvider() jws.KeyProviderFunc { return func(context context.Context, keySink jws.KeySink, signature *jws.Signature, message *jws.Message) error { - keyID := signature.ProtectedHeaders().KeyID() + keyID, _ := signature.ProtectedHeaders().KeyID() publicKey, err := hb.keyResolver.ResolveKeyByID(keyID, nil, resolver.AssertionMethod) if err != nil { return fmt.Errorf("failed to resolve key (kid=%s): %w", keyID, err) diff --git a/auth/client/iam/client_test.go b/auth/client/iam/client_test.go index 560715c822..9b68693851 100644 --- a/auth/client/iam/client_test.go +++ b/auth/client/iam/client_test.go @@ -24,7 +24,8 @@ import ( "crypto/ecdsa" "encoding/json" "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/audit" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" @@ -261,6 +262,7 @@ func TestHTTPClient_OpenIDConfiguration(t *testing.T) { ctx := context.Background() configuration := oauth.OpenIDConfiguration{ Issuer: "issuer", + JWKs: jwk.NewSet(), } // create jwt @@ -269,6 +271,9 @@ func TestHTTPClient_OpenIDConfiguration(t *testing.T) { claims := make(map[string]interface{}) asJson, _ := json.Marshal(configuration) _ = json.Unmarshal(asJson, &claims) + // jwx v3 rejects a token whose "exp" is set to the zero value (epoch) as expired; + // the marshaled zero-value OpenIDConfiguration includes "exp":0, so drop it to keep the token valid. + delete(claims, "exp") alg, _ := nutsCrypto.SignatureAlgorithm(testKey.Public()) headers := map[string]interface{}{jws.AlgorithmKey: alg, jws.KeyIDKey: "test"} token, err := nutsCrypto.SignJWT(audit.TestContext(), testKey, alg, claims, headers) @@ -316,7 +321,7 @@ func TestHTTPClient_OpenIDConfiguration(t *testing.T) { require.Error(t, err) require.Nil(t, response) - assert.EqualError(t, err, "unable to parse response: failed to parse jws: invalid byte sequence") + assert.EqualError(t, err, "unable to parse response: jwt.Parse: failed to parse token: jws.Verify: failed to parse jws: jws.Parse: failed to parse compact format: jws.Parse: invalid compact serialization format: jwsbb: invalid number of segments") }) t.Run("error - unknown key", func(t *testing.T) { otherClient := &HTTPClient{ @@ -330,7 +335,7 @@ func TestHTTPClient_OpenIDConfiguration(t *testing.T) { require.Error(t, err) require.Nil(t, response) - assert.EqualError(t, err, "unable to parse response: could not verify message using any of the signatures or keys") + assert.EqualError(t, err, "unable to parse response: jwt.Parse: failed to parse token: jws.Verify: could not verify message using any of the signatures or keys: jws.Verify: failed to verify signature #1 with key *ecdsa.PublicKey: invalid ECDSA signature\njws.Verify: signature #1: tried 1 key(s) but none verified successfully") }) } diff --git a/auth/oauth/types.go b/auth/oauth/types.go index 380909c0ec..94cfc09b33 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -21,7 +21,7 @@ package oauth import ( "encoding/json" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/nuts-node/core" "net/url" ) diff --git a/auth/oauth/types_test.go b/auth/oauth/types_test.go index e0932a7991..fb528b61b4 100644 --- a/auth/oauth/types_test.go +++ b/auth/oauth/types_test.go @@ -120,7 +120,7 @@ func TestOpenIDConfiguration_UnmarshalJSON(t *testing.T) { "key_ops": ["verify"], "kid": "key1", "kty": "RSA", - "n": "pnXBOusEANuug6ewezb9J_", + "n": "wCSGKw5VuZSGYmSL8G6AVHZBNfxrdZArTSdQsDCo33vdneJhaccynjUMrh6BHpDlJIatbQlMkvDNu3bWllBmXtLx4in6z-bGzaE9XIv0Wq2jYwKyx0Jbf4XD9Qn5LMtt9G5Pbcjilemr2I_J1CwS7pfiUMqsiBMmfc0Gdih2sFkjVNrS_4ZPII2JjFGH1Sn-X0kYm8c7XBYZdXRdoTN6ABXgRXfvdbvxfmlddAVyuUoCiaMDv28hSAjznr93QShnxHO4XVefuPIiRhHCPtsppnVWY_hDyLVPLeqA-Xd3CuQCxHRnU6hPmuWHwq_bOcAs7NKrG1ubFSM60RB5jSSepQ", "use": "sig" } ] diff --git a/auth/services/irma/validator.go b/auth/services/irma/validator.go index 1ba86593a0..a3aaf8440b 100644 --- a/auth/services/irma/validator.go +++ b/auth/services/irma/validator.go @@ -26,7 +26,7 @@ import ( "strings" "time" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/contract" diff --git a/auth/services/oauth/authz_server.go b/auth/services/oauth/authz_server.go index 469d68be66..99dcf9fd9e 100644 --- a/auth/services/oauth/authz_server.go +++ b/auth/services/oauth/authz_server.go @@ -26,7 +26,7 @@ import ( "fmt" "time" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" vc2 "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/contract" @@ -88,9 +88,9 @@ type organizationIdentity struct { } func (c validationContext) userIdentity() (*vc2.VerifiablePresentation, error) { - claim, ok := c.jwtBearerToken.Get(userIdentityClaim) + var claim interface{} // If no credentials then OK - if !ok || claim == nil { + if err := c.jwtBearerToken.Get(userIdentityClaim, &claim); err != nil || claim == nil { return nil, nil } @@ -104,8 +104,8 @@ func (c validationContext) userIdentity() (*vc2.VerifiablePresentation, error) { } func (c validationContext) stringVal(claim string) *string { - val, ok := c.jwtBearerToken.Get(claim) - if !ok { + var val interface{} + if err := c.jwtBearerToken.Get(claim, &val); err != nil { return nil } stringVal, ok := val.(string) @@ -118,10 +118,10 @@ func (c validationContext) stringVal(claim string) *string { func (c validationContext) verifiableCredentials() ([]vc2.VerifiableCredential, error) { vcs := make([]vc2.VerifiableCredential, 0) - claim, ok := c.jwtBearerToken.Get(vcClaim) + var claim interface{} // If no credentials then OK - if !ok || claim == nil { + if err := c.jwtBearerToken.Get(vcClaim, &claim); err != nil || claim == nil { return vcs, nil } @@ -228,8 +228,8 @@ func (s *authzServer) CreateAccessToken(ctx context.Context, request services.Cr if err != nil { var requesterDID, authorizerDID string if validationCtx.jwtBearerToken != nil { - requesterDID = validationCtx.jwtBearerToken.Issuer() - authorizerDID = validationCtx.jwtBearerToken.Subject() + requesterDID, _ = validationCtx.jwtBearerToken.Issuer() + authorizerDID, _ = validationCtx.jwtBearerToken.Subject() } log.Logger(). WithField(core.LogFieldRequesterDID, requesterDID). @@ -336,18 +336,20 @@ func (s *authzServer) validatePurposeOfUse(context *validationContext) error { // check if the aud service identifier matches the oauth endpoint of the requested service func (s *authzServer) validateAudience(context *validationContext) error { - if len(context.jwtBearerToken.Audience()) != 1 { + audience, _ := context.jwtBearerToken.Audience() + if len(audience) != 1 { return errors.New("aud does not contain a single URI") } // parsing is already done in a previous check - subject, _ := did.ParseDID(context.jwtBearerToken.Subject()) + sub, _ := context.jwtBearerToken.Subject() + subject, _ := did.ParseDID(sub) endpointURL, err := s.serviceResolver.GetCompoundServiceEndpoint(*subject, context.purposeOfUse, services.OAuthEndpointType, true) if err != nil { return err } - if context.jwtBearerToken.Audience()[0] != endpointURL { + if audience[0] != endpointURL { return errors.New("aud does not contain correct endpoint URL") } return nil @@ -357,13 +359,14 @@ func (s *authzServer) validateAudience(context *validationContext) error { // - the signing key (KID) must be present as assertionMethod in the issuer's DID. // - the requester name/city which must match the login contract. func (s *authzServer) validateIssuer(vContext *validationContext) error { - if requester, err := did.ParseDID(vContext.jwtBearerToken.Issuer()); err != nil { + iss, _ := vContext.jwtBearerToken.Issuer() + if requester, err := did.ParseDID(iss); err != nil { return fmt.Errorf(errInvalidIssuerFmt, err) } else { vContext.requester = requester } - validationTime := vContext.jwtBearerToken.IssuedAt() + validationTime, _ := vContext.jwtBearerToken.IssuedAt() metadata := &resolver.ResolveMetadata{ ResolveTime: &validationTime, } @@ -372,7 +375,7 @@ func (s *authzServer) validateIssuer(vContext *validationContext) error { } searchTerms := []vcr.SearchTerm{ - {IRIPath: jsonld.CredentialSubjectPath, Value: vContext.jwtBearerToken.Issuer()}, + {IRIPath: jsonld.CredentialSubjectPath, Value: iss}, {IRIPath: jsonld.OrganizationNamePath, Type: vcr.NotNil}, {IRIPath: jsonld.OrganizationCityPath, Type: vcr.NotNil}, } @@ -408,17 +411,18 @@ func (s *authzServer) validateIssuer(vContext *validationContext) error { // check if the authorizer is registered by this vendor, according to RFC003 §5.2.1.8 func (s *authzServer) validateSubject(ctx context.Context, validationCtx *validationContext) error { - if validationCtx.jwtBearerToken.Subject() == "" { + sub, _ := validationCtx.jwtBearerToken.Subject() + if sub == "" { return fmt.Errorf(errInvalidSubjectFmt, errors.New("missing")) } - subject, err := did.ParseDID(validationCtx.jwtBearerToken.Subject()) + subject, err := did.ParseDID(sub) if err != nil { return fmt.Errorf(errInvalidSubjectFmt, err) } validationCtx.authorizer = subject - iat := validationCtx.jwtBearerToken.IssuedAt() + iat, _ := validationCtx.jwtBearerToken.IssuedAt() signingKeyID, _, err := s.keyResolver.ResolveKey(*subject, &iat, resolver.NutsSigningKeyType) if err != nil { return err @@ -459,9 +463,9 @@ func (s *authzServer) validateAuthorizationCredentials(context *validationContex return nil } - iat := context.jwtBearerToken.IssuedAt() - iss := context.jwtBearerToken.Issuer() - sub := context.jwtBearerToken.Subject() + iat, _ := context.jwtBearerToken.IssuedAt() + iss, _ := context.jwtBearerToken.Issuer() + sub, _ := context.jwtBearerToken.Subject() for _, authCred := range vcs { // first check if the VC is valid and if the signature is correct @@ -519,14 +523,27 @@ func (s *authzServer) IntrospectAccessToken(ctx context.Context, accessToken str result := &services.NutsAccessToken{} - if err := result.FromMap(token.PrivateClaims()); err != nil { + // Extract the private (non-registered) claims via the token's JSON representation. This mirrors + // jwx v2's token.PrivateClaims: it preserves null-valued claims (a per-claim Get loop errors on + // null values in v3). + privateClaims := make(map[string]interface{}) + claimsJSON, _ := json.Marshal(token) + if err := json.Unmarshal(claimsJSON, &privateClaims); err != nil { + return nil, err + } + for _, k := range []string{jwt.IssuerKey, jwt.SubjectKey, jwt.AudienceKey, jwt.ExpirationKey, jwt.NotBeforeKey, jwt.IssuedAtKey, jwt.JwtIDKey} { + delete(privateClaims, k) + } + if err := result.FromMap(privateClaims); err != nil { return nil, err } - result.Subject = token.Subject() - result.Issuer = token.Issuer() - result.IssuedAt = token.IssuedAt().Unix() - result.Expiration = token.Expiration().Unix() + result.Subject, _ = token.Subject() + result.Issuer, _ = token.Issuer() + iat, _ := token.IssuedAt() + result.IssuedAt = iat.Unix() + exp, _ := token.Expiration() + result.Expiration = exp.Unix() return result, nil } diff --git a/auth/services/oauth/authz_server_test.go b/auth/services/oauth/authz_server_test.go index 7e1141dacd..c9c8ab71ff 100644 --- a/auth/services/oauth/authz_server_test.go +++ b/auth/services/oauth/authz_server_test.go @@ -31,9 +31,9 @@ import ( "testing" "time" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -517,7 +517,7 @@ func TestService_validateAuthorizationCredentials(t *testing.T) { err := ctx.oauthService.validateAuthorizationCredentials(tokenCtx) - assert.EqualError(t, err, "invalid jwt.vcs: cannot unmarshal authorization credential: invalid JWT") + assert.EqualError(t, err, "invalid jwt.vcs: cannot unmarshal authorization credential: jwt.Parse: failed to parse token: unknown payload type (payload is not JWT?)") }) t.Run("error - jwt.iss <> credentialSubject.ID mismatch", func(t *testing.T) { @@ -563,14 +563,14 @@ func TestService_parseAndValidateJwtBearerToken(t *testing.T) { } err := ctx.oauthService.parseAndValidateJwtBearerToken(tokenCtx) assert.Nil(t, tokenCtx.jwtBearerToken) - assert.Equal(t, "invalid compact serialization format: invalid number of segments", err.Error()) + assert.Equal(t, "jws.ParseString: failed to parse string: jws.Parse: failed to parse compact format: jws.Parse: invalid compact serialization format: jwsbb: invalid number of segments", err.Error()) tokenCtx2 := &validationContext{ rawJwtBearerToken: "123.456.787", } err = ctx.oauthService.parseAndValidateJwtBearerToken(tokenCtx2) assert.Nil(t, tokenCtx.jwtBearerToken) - assert.Equal(t, "failed to parse JOSE headers: invalid character '×' looking for beginning of value", err.Error()) + assert.Equal(t, "jws.ParseString: failed to parse string: jws.Parse: failed to parse compact format: failed to parse JOSE headers: invalid character '×' looking for beginning of value", err.Error()) }) t.Run("wrong signing algorithm", func(t *testing.T) { @@ -582,7 +582,7 @@ func TestService_parseAndValidateJwtBearerToken(t *testing.T) { token := jwt.New() hdrs := jws.NewHeaders() hdrs.Set(jws.KeyIDKey, keyID) - signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.HS256, secret, jws.WithProtectedHeaders(hdrs))) + signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.HS256(), secret, jws.WithProtectedHeaders(hdrs))) require.NoError(t, err) tokenCtx := &validationContext{ @@ -601,7 +601,8 @@ func TestService_parseAndValidateJwtBearerToken(t *testing.T) { err := ctx.oauthService.parseAndValidateJwtBearerToken(tokenCtx) assert.NoError(t, err) - assert.Equal(t, requesterDID.String(), tokenCtx.jwtBearerToken.Issuer()) + iss, _ := tokenCtx.jwtBearerToken.Issuer() + assert.Equal(t, requesterDID.String(), iss) assert.Equal(t, requesterSigningKeyID, tokenCtx.kid) }) @@ -633,7 +634,7 @@ func TestService_parseAndValidateJwtBearerToken(t *testing.T) { ctx.keyResolver.EXPECT().ResolveKeyByID(requesterSigningKeyID, nil, resolver.NutsSigningKeyType).Return(requesterSigningKey.PublicKey, nil) err := ctx.oauthService.parseAndValidateJwtBearerToken(tokenCtx) - assert.EqualError(t, err, "\"exp\" not satisfied") + assert.EqualError(t, err, "jwt.ParseString: failed to parse string: jwt.Validate: validation failed: \"exp\" not satisfied: token is expired") }) } @@ -695,10 +696,14 @@ func TestService_IntrospectAccessToken(t *testing.T) { require.NoError(t, err) require.NotNil(t, claims) - assert.Equal(t, tokenCtx.jwtBearerToken.Subject(), claims.Subject) - assert.Equal(t, tokenCtx.jwtBearerToken.Issuer(), claims.Issuer) - assert.Equal(t, tokenCtx.jwtBearerToken.IssuedAt().Unix(), claims.IssuedAt) - assert.Equal(t, tokenCtx.jwtBearerToken.Expiration().Unix(), claims.Expiration) + subject, _ := tokenCtx.jwtBearerToken.Subject() + issuer, _ := tokenCtx.jwtBearerToken.Issuer() + issuedAt, _ := tokenCtx.jwtBearerToken.IssuedAt() + expiration, _ := tokenCtx.jwtBearerToken.Expiration() + assert.Equal(t, subject, claims.Subject) + assert.Equal(t, issuer, claims.Issuer) + assert.Equal(t, issuedAt.Unix(), claims.IssuedAt) + assert.Equal(t, expiration.Unix(), claims.Expiration) assert.Equal(t, expectedService, claims.Service) }) @@ -716,7 +721,7 @@ func TestService_IntrospectAccessToken(t *testing.T) { // Signature verification should fail claims, err := ctx.oauthService.IntrospectAccessToken(ctx.audit, tokenCtx.rawJwtBearerToken) - require.EqualError(t, err, "could not verify message using any of the signatures or keys") + require.EqualError(t, err, "jwt.ParseString: failed to parse string: signature verification failed for ES256: invalid ECDSA signature") require.Nil(t, claims) }) @@ -877,7 +882,7 @@ func TestService_IntrospectAccessToken(t *testing.T) { signTokenWithKeyAndHeaders(tokenCtx, authorizerSigningKey, authorizerSigningKeyID, map[string]interface{}{"typ": "at+jwt"}) _, err := ctx.oauthService.IntrospectAccessToken(ctx.audit, tokenCtx.rawJwtBearerToken) - assert.ErrorContains(t, err, `"exp" not satisfied: required claim not found`) + assert.ErrorContains(t, err, `required claim "exp" is missing`) }) t.Run("rejects token without iat claim", func(t *testing.T) { @@ -900,7 +905,7 @@ func TestService_IntrospectAccessToken(t *testing.T) { signTokenWithKeyAndHeaders(tokenCtx, authorizerSigningKey, authorizerSigningKeyID, map[string]interface{}{"typ": "at+jwt"}) _, err := ctx.oauthService.IntrospectAccessToken(ctx.audit, tokenCtx.rawJwtBearerToken) - assert.ErrorContains(t, err, `"iat" not satisfied: required claim not found`) + assert.ErrorContains(t, err, `required claim "iat" is missing`) }) t.Run("rejects token with excessive lifetime", func(t *testing.T) { @@ -1093,7 +1098,7 @@ func signTokenWithKeyAndHeaders(context *validationContext, key *ecdsa.PrivateKe panic(err) } } - signedToken, err := jwt.Sign(context.jwtBearerToken, jwt.WithKey(jwa.ES256, key, jws.WithProtectedHeaders(hdrs))) + signedToken, err := jwt.Sign(context.jwtBearerToken, jwt.WithKey(jwa.ES256(), key, jws.WithProtectedHeaders(hdrs))) if err != nil { panic(err) } diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go index e78160a86c..c3091d19c3 100644 --- a/auth/services/oauth/relying_party.go +++ b/auth/services/oauth/relying_party.go @@ -27,7 +27,7 @@ import ( "strings" "time" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth/api/auth/v1/client" "github.com/nuts-foundation/nuts-node/auth/oauth" diff --git a/crypto/api/v1/api.go b/crypto/api/v1/api.go index 99b0706b8a..49dcc18884 100644 --- a/crypto/api/v1/api.go +++ b/crypto/api/v1/api.go @@ -28,7 +28,7 @@ import ( "time" "github.com/labstack/echo/v4" - "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v3/jws" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/audit" diff --git a/crypto/dpop/dpop.go b/crypto/dpop/dpop.go index 3f8ad8b932..cf09e2fa05 100644 --- a/crypto/dpop/dpop.go +++ b/crypto/dpop/dpop.go @@ -33,10 +33,10 @@ import ( "strings" "time" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/nuts-node/crypto/hash" "github.com/nuts-foundation/nuts-node/crypto/jwx" ) @@ -99,7 +99,7 @@ func (t *DPoP) Sign(kid string, key crypto.Signer, alg jwa.SignatureAlgorithm) ( if t.raw != "" { return "", errors.New("already signed") } - publicKeyJWK, err := jwk.FromRaw(key.Public()) + publicKeyJWK, err := jwk.Import(key.Public()) if err != nil { return "", err } @@ -137,53 +137,58 @@ func Parse(s string) (*DPoP, error) { return nil, fmt.Errorf("%w: invalid number of signatures", ErrInvalidDPoP) } headers := message.Signatures()[0].ProtectedHeaders() - if !slices.Contains(jwx.SupportedAlgorithms, headers.Algorithm()) { - return nil, fmt.Errorf("%w: invalid alg: %s", ErrInvalidDPoP, headers.Algorithm()) + alg, _ := headers.Algorithm() + if !slices.Contains(jwx.SupportedAlgorithms, alg) { + return nil, fmt.Errorf("%w: invalid alg: %s", ErrInvalidDPoP, alg) } - if headers.Type() != "dpop+jwt" { - return nil, fmt.Errorf("%w: invalid type: %s", ErrInvalidDPoP, headers.Type()) + if typ, _ := headers.Type(); typ != "dpop+jwt" { + return nil, fmt.Errorf("%w: invalid type: %s", ErrInvalidDPoP, typ) } - if headers.JWK() == nil { + headerJWK, ok := headers.JWK() + if !ok || headerJWK == nil { return nil, fmt.Errorf("%w: missing jwk header", ErrInvalidDPoP) } - if jwkIsPrivateKey(headers.JWK()) { + if jwkIsPrivateKey(headerJWK) { return nil, fmt.Errorf("%w: invalid jwk header", ErrInvalidDPoP) } - token, err := jwt.ParseString(s, jwt.WithKey(headers.Algorithm(), headers.JWK())) + token, err := jwt.ParseString(s, jwt.WithKey(alg, headerJWK)) if err != nil { return nil, errors.Join(ErrInvalidDPoP, err) } - if token.IssuedAt().IsZero() { + if iat, ok := token.IssuedAt(); !ok || iat.IsZero() { return nil, fmt.Errorf("%w: missing iat claim", ErrInvalidDPoP) } - if v, ok := token.Get(HTUKey); !ok || v == "" { + var htu string + if err := token.Get(HTUKey, &htu); err != nil || htu == "" { return nil, fmt.Errorf("%w: missing htu claim", ErrInvalidDPoP) } - if v, ok := token.Get(HTMKey); !ok || v == "" { + var htm string + if err := token.Get(HTMKey, &htm); err != nil || htm == "" { return nil, fmt.Errorf("%w: missing htm claim", ErrInvalidDPoP) } - if token.JwtID() == "" { + jti, _ := token.JwtID() + if jti == "" { return nil, fmt.Errorf("%w: missing jti claim", ErrInvalidDPoP) } - if len(token.JwtID()) > maxJtiLength { + if len(jti) > maxJtiLength { return nil, fmt.Errorf("%w: jti claim too long", ErrInvalidDPoP) } return &DPoP{raw: s, Token: token, Headers: headers}, nil } -func jwkIsPrivateKey(jwk jwk.Key) bool { +func jwkIsPrivateKey(key jwk.Key) bool { // we try to parse it as different private keys, if there's no error, it's a private key var rsaPrivateKey rsa.PrivateKey - if err := jwk.Raw(&rsaPrivateKey); err == nil { + if err := jwk.Export(key, &rsaPrivateKey); err == nil { return true } var ecPrivateKey ecdsa.PrivateKey - if err := jwk.Raw(&ecPrivateKey); err == nil { + if err := jwk.Export(key, &ecPrivateKey); err == nil { return true } var edPrivateKey ed25519.PrivateKey - if err := jwk.Raw(&edPrivateKey); err == nil { + if err := jwk.Export(key, &edPrivateKey); err == nil { return true } return false @@ -191,16 +196,18 @@ func jwkIsPrivateKey(jwk jwk.Key) bool { // HTU returns the htu claim of the DPoP token func (t DPoP) HTU() string { - if v, ok := t.Token.Get(HTUKey); ok { - return v.(string) + var htu string + if err := t.Token.Get(HTUKey, &htu); err == nil { + return htu } return "" } // HTM returns the htm claim of the DPoP token func (t DPoP) HTM() string { - if v, ok := t.Token.Get(HTMKey); ok { - return v.(string) + var htm string + if err := t.Token.Get(HTMKey, &htm); err == nil { + return htm } return "" } @@ -209,7 +216,8 @@ func (t DPoP) HTM() string { // for the url, the port is stripped. // If there is a mismatch, the reason is returned in an error. func (t DPoP) Match(jkt string, method string, url string) (bool, error) { - tp, _ := t.Headers.JWK().Thumbprint(crypto.SHA256) + key, _ := t.Headers.JWK() + tp, _ := key.Thumbprint(crypto.SHA256) base64tp := base64.RawURLEncoding.EncodeToString(tp) if base64tp != jkt { diff --git a/crypto/dpop/dpop_test.go b/crypto/dpop/dpop_test.go index 908fb8c7de..151321e179 100644 --- a/crypto/dpop/dpop_test.go +++ b/crypto/dpop/dpop_test.go @@ -24,13 +24,13 @@ import ( "crypto/elliptic" "crypto/rand" "encoding/base64" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "net/http" "testing" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -42,12 +42,13 @@ func TestNewDPoP(t *testing.T) { token := New(*request) require.NotNil(t, token) - assert.Equal(t, DPopType, token.Headers.Type()) + headerType, _ := token.Headers.Type() + assert.Equal(t, DPopType, headerType) assert.Equal(t, "POST", token.HTM()) assert.Equal(t, "https://server.example.com/token", token.HTU()) // check if jti is set - jti, ok := token.Token.Get(jwt.JwtIDKey) - require.True(t, ok) + var jti string + require.NoError(t, token.Token.Get(jwt.JwtIDKey, &jti)) assert.NotEmpty(t, jti) }) } @@ -59,14 +60,14 @@ func TestDPoP_Proof(t *testing.T) { token := New(*request) token.GenerateProof("token") - ath, ok := token.Token.Get(ATHKey) - require.True(t, ok) + var ath string + require.NoError(t, token.Token.Get(ATHKey, &ath)) assert.Equal(t, "PEaenWxYddN6Q_NT1PiOYfz4EsZu7jRXRlpAsNpBU-A", ath) }) } func TestDPoP_Sign(t *testing.T) { - const alg = jwa.ES256 + var alg = jwa.ES256() keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) request, _ := http.NewRequest("POST", "https://server.example.com/token", nil) @@ -80,9 +81,10 @@ func TestDPoP_Sign(t *testing.T) { // check if jwk header is set and if the private part of the is omitted message, err := jws.ParseString(tokenString) require.NoError(t, err) - actualJWK, ok := message.Signatures()[0].ProtectedHeaders().Get(jws.JWKKey) - require.True(t, ok) - assert.Equal(t, alg, actualJWK.(jwk.Key).Algorithm()) + var actualJWK jwk.Key + require.NoError(t, message.Signatures()[0].ProtectedHeaders().Get(jws.JWKKey, &actualJWK)) + actualAlg, _ := actualJWK.Algorithm() + assert.Equal(t, alg, actualAlg) assert.Equal(t, "kid", token.Kid) }) t.Run("already signed", func(t *testing.T) { @@ -97,10 +99,10 @@ func TestDPoP_Sign(t *testing.T) { } func TestParseDPoP(t *testing.T) { - const alg = jwa.ES256 + var alg = jwa.ES256() keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - jwkKey, _ := jwk.FromRaw(keyPair) - _ = jwkKey.Set(jwk.AlgorithmKey, jwa.ES256) + jwkKey, _ := jwk.Import(keyPair) + _ = jwkKey.Set(jwk.AlgorithmKey, jwa.ES256()) pkey, _ := jwkKey.PublicKey() request, _ := http.NewRequest("GET", "https://server.example.com/token", nil) @@ -112,7 +114,8 @@ func TestParseDPoP(t *testing.T) { token, err := Parse(dpopString) require.NoError(t, err) - assert.Equal(t, pkey, token.Headers.JWK()) + headerJWK, _ := token.Headers.JWK() + assert.Equal(t, pkey, headerJWK) assert.Equal(t, "GET", token.HTM()) assert.Equal(t, "https://server.example.com/token", token.HTU()) }) @@ -120,7 +123,7 @@ func TestParseDPoP(t *testing.T) { _, err := Parse("invalid") require.Error(t, err) - assert.EqualError(t, err, "invalid DPoP token\ninvalid compact serialization format: invalid number of segments") + assert.EqualError(t, err, "invalid DPoP token\njws.ParseString: failed to parse string: jws.Parse: failed to parse compact format: jws.Parse: invalid compact serialization format: jwsbb: invalid number of segments") }) t.Run("unsupported algorithm", func(t *testing.T) { customJwt := jwt.New() @@ -147,7 +150,7 @@ func TestParseDPoP(t *testing.T) { altHeaders := jws.NewHeaders() altHeaders.Set(jws.TypeKey, DPopType) - tokenBytes, _ := jwt.Sign(altToken, jwt.WithKey(jwa.SignatureAlgorithm(jwkKey.Algorithm().String()), jwkKey, jws.WithProtectedHeaders(altHeaders))) + tokenBytes, _ := jwt.Sign(altToken, jwt.WithKey(alg, jwkKey, jws.WithProtectedHeaders(altHeaders))) _, err := Parse(string(tokenBytes)) @@ -160,7 +163,7 @@ func TestParseDPoP(t *testing.T) { altHeaders.Set(jws.TypeKey, DPopType) altHeaders.Set(jws.JWKKey, jwkKey) - tokenBytes, _ := jwt.Sign(altToken, jwt.WithKey(jwa.SignatureAlgorithm(jwkKey.Algorithm().String()), jwkKey, jws.WithProtectedHeaders(altHeaders))) + tokenBytes, _ := jwt.Sign(altToken, jwt.WithKey(alg, jwkKey, jws.WithProtectedHeaders(altHeaders))) _, err := Parse(string(tokenBytes)) @@ -174,7 +177,7 @@ func TestParseDPoP(t *testing.T) { _, err := Parse(dpopString + "0") require.Error(t, err) - assert.EqualError(t, err, "invalid DPoP token\ncould not verify message using any of the signatures or keys") + assert.EqualError(t, err, "invalid DPoP token\njwt.ParseString: failed to parse string: signature verification failed for ES256: dsig.VerifyECDSA: failed to unpack ECDSA signature: invalid signature length for curve \"P-256\"") }) t.Run("missing iat claim", func(t *testing.T) { dpopToken := New(*request) @@ -232,11 +235,11 @@ func TestParseDPoP(t *testing.T) { } func TestDPoP_Match(t *testing.T) { - const alg = jwa.ES256 + var alg = jwa.ES256() accessToken := "token" keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - jwkKey, _ := jwk.FromRaw(keyPair) - _ = jwkKey.Set(jwk.AlgorithmKey, jwa.ES256) + jwkKey, _ := jwk.Import(keyPair) + _ = jwkKey.Set(jwk.AlgorithmKey, jwa.ES256()) thumbprint, _ := jwkKey.Thumbprint(crypto.SHA256) thumbprintString := base64.RawURLEncoding.EncodeToString(thumbprint) request, _ := http.NewRequest("POST", "https://server.example.com/token", nil) @@ -317,10 +320,10 @@ func TestDPoP_Match(t *testing.T) { } func TestDPoP_marshalling(t *testing.T) { - const alg = jwa.ES256 + var alg = jwa.ES256() keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - jwkKey, _ := jwk.FromRaw(keyPair) - _ = jwkKey.Set(jwk.AlgorithmKey, jwa.ES256) + jwkKey, _ := jwk.Import(keyPair) + _ = jwkKey.Set(jwk.AlgorithmKey, jwa.ES256()) request, _ := http.NewRequest("POST", "https://server.example.com/token", nil) t.Run("marshal", func(t *testing.T) { diff --git a/crypto/dpop_test.go b/crypto/dpop_test.go index 69b2f74427..cc866f3c10 100644 --- a/crypto/dpop_test.go +++ b/crypto/dpop_test.go @@ -23,8 +23,8 @@ import ( "net/http" "testing" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/crypto/dpop" "github.com/nuts-foundation/nuts-node/crypto/hash" @@ -36,8 +36,8 @@ func TestDPOP(t *testing.T) { client := createCrypto(t) kid := "kid" _, pubKey := newKeyReference(t, client, kid) - keyAsJWK, _ := jwk.FromRaw(pubKey) - _ = keyAsJWK.Set(jwk.AlgorithmKey, jwa.ES256) + keyAsJWK, _ := jwk.Import(pubKey) + _ = keyAsJWK.Set(jwk.AlgorithmKey, jwa.ES256()) request, _ := http.NewRequest("POST", "https://server.example.com/token", nil) t.Run("creates valid DPoP token", func(t *testing.T) { @@ -48,7 +48,8 @@ func TestDPOP(t *testing.T) { token, err = dpop.Parse(tokenString) require.NoError(t, err) - assert.Equal(t, keyAsJWK, token.Headers.JWK()) + headerJWK, _ := token.Headers.JWK() + assert.Equal(t, keyAsJWK, headerJWK) assert.Equal(t, "POST", token.HTM()) assert.Equal(t, "https://server.example.com/token", token.HTU()) }) @@ -65,8 +66,8 @@ func TestDPOP(t *testing.T) { proof, err := dpop.Parse(proofString) require.NoError(t, err) - ath, ok := proof.Token.Get(dpop.ATHKey) - require.True(t, ok) + var ath string + require.NoError(t, proof.Token.Get(dpop.ATHKey, &ath)) assert.Equal(t, hashString, ath) }) } diff --git a/crypto/jwt_profile.go b/crypto/jwt_profile.go index 4eae45cb6c..19c95dd3cd 100644 --- a/crypto/jwt_profile.go +++ b/crypto/jwt_profile.go @@ -21,7 +21,7 @@ import ( "fmt" "time" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" ) @@ -77,8 +77,9 @@ func IssuerKidValidator(token jwt.Token, headers map[string]interface{}) error { if err != nil { return fmt.Errorf("invalid kid header: %w", err) } - if parsed.DID.String() != token.Issuer() { - return fmt.Errorf("token issuer (%s) does not match signing key DID (%s)", token.Issuer(), parsed.DID.String()) + iss, _ := token.Issuer() + if parsed.DID.String() != iss { + return fmt.Errorf("token issuer (%s) does not match signing key DID (%s)", iss, parsed.DID.String()) } return nil } diff --git a/crypto/jwx.go b/crypto/jwx.go index 959cc82010..4ca5768efb 100644 --- a/crypto/jwx.go +++ b/crypto/jwx.go @@ -30,11 +30,12 @@ import ( "maps" "time" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwe" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwe" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jws/jwsbb" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/mr-tron/base58" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/crypto/jwx" @@ -49,11 +50,11 @@ func GenerateJWK() (jwk.Key, error) { if err != nil { return nil, nil } - result, err := jwk.FromRaw(keyPair) + result, err := jwk.Import(keyPair) if err != nil { return nil, err } - return result, result.Set(jwk.AlgorithmKey, jwa.ES256) + return result, result.Set(jwk.AlgorithmKey, jwa.ES256()) } // SignJWT creates a JWT from the given claims and signs it with the given key. @@ -101,7 +102,7 @@ func (client *Crypto) DecryptJWE(ctx context.Context, message string) (body []by } protectedHeaders := msg.ProtectedHeaders() - kid := protectedHeaders.KeyID() + kid, _ := protectedHeaders.KeyID() if len(kid) == 0 { return nil, nil, errors.New("kid header not found") } @@ -112,17 +113,22 @@ func (client *Crypto) DecryptJWE(ctx context.Context, message string) (body []by audit.Log(ctx, log.Logger(), audit.CryptoDecryptJWEEvent).Infof("Decrypting a JWE with kid: %s", kid) - keyJWK, err := jwk.FromRaw(privateKey) + keyJWK, err := jwk.Import(privateKey) if err != nil { return nil, nil, fmt.Errorf("keys stored in '%s' do not support JWE decryption", client.backend.Name()) } - body, err = jwe.Decrypt([]byte(message), jwe.WithKey(protectedHeaders.Algorithm(), keyJWK)) + alg, _ := protectedHeaders.Algorithm() + body, err = jwe.Decrypt([]byte(message), jwe.WithKey(alg, keyJWK)) if err != nil { return nil, nil, err } - headers, err = msg.ProtectedHeaders().AsMap(ctx) - if err != nil { - return nil, nil, err + headers = make(map[string]interface{}) + for _, k := range protectedHeaders.Keys() { + var v interface{} + if err = protectedHeaders.Get(k, &v); err != nil { + return nil, nil, err + } + headers[k] = v } return body, headers, err } @@ -144,7 +150,7 @@ func SignJWT(ctx context.Context, key crypto.Signer, alg jwa.SignatureAlgorithm, return "", fmt.Errorf("invalid JWT headers: %w", err) } - sig, err = jwt.Sign(t, jwt.WithKey(jwa.SignatureAlgorithm(alg.String()), key, jws.WithProtectedHeaders(hdr))) + sig, err = jwt.Sign(t, jwt.WithKey(alg, key, jws.WithProtectedHeaders(hdr))) token = string(sig) return @@ -154,16 +160,18 @@ func SignJWT(ctx context.Context, key crypto.Signer, alg jwa.SignatureAlgorithm, func JWTKidAlg(tokenString string) (string, jwa.SignatureAlgorithm, error) { j, err := jws.ParseString(tokenString) if err != nil { - return "", "", err + return "", jwa.SignatureAlgorithm{}, err } if len(j.Signatures()) != 1 { - return "", "", errors.New("incorrect number of signatures in JWT") + return "", jwa.SignatureAlgorithm{}, errors.New("incorrect number of signatures in JWT") } sig := j.Signatures()[0] hdrs := sig.ProtectedHeaders() - return hdrs.KeyID(), hdrs.Algorithm(), nil + kid, _ := hdrs.KeyID() + alg, _ := hdrs.Algorithm() + return kid, alg, nil } // PublicKeyFunc defines a function that resolves a public key based on a kid @@ -242,8 +250,8 @@ func ParseJWT(tokenString string, f PublicKeyFunc, profile *JWTProfile, at *time // Check required claims are present and non-empty. // Mirrors the jwx error format ("" not satisfied: ...) so callers see one consistent style. for _, claim := range profile.RequiredClaims { - val, ok := token.Get(claim) - if !ok { + var val interface{} + if err := token.Get(claim, &val); err != nil { return nil, fmt.Errorf(`%q not satisfied: required claim not found`, claim) } if s, isStr := val.(string); isStr && s == "" { @@ -277,17 +285,12 @@ func ParseJWS(token []byte, f PublicKeyFunc) (payload []byte, err error) { for i := range signatures { signature := signatures[i] // Get and check the algorithm - alg := signature.ProtectedHeaders().Algorithm() + alg, _ := signature.ProtectedHeaders().Algorithm() if !jwx.IsAlgorithmSupported(alg) { return nil, fmt.Errorf("token signing algorithm is not supported: %s", alg) } - // Get the verifier for the algorithm - verifier, err := jws.NewVerifier(alg) - if err != nil { - return nil, err - } // Get the key id, and get the associated key - kid := signature.ProtectedHeaders().KeyID() + kid, _ := signature.ProtectedHeaders().KeyID() key, err := f(kid) if err != nil { return nil, err @@ -298,7 +301,7 @@ func ParseJWS(token []byte, f PublicKeyFunc) (payload []byte, err error) { for _, part := range parts { payload = append(payload, part...) } - err = verifier.Verify(payload, signature.Signature(), key) + err = jwsbb.Verify(key, alg.String(), payload, signature.Signature()) if err != nil { return nil, err } @@ -316,7 +319,7 @@ func SignJWS(ctx context.Context, payload []byte, protectedHeaders map[string]in return "", fmt.Errorf("unable to set header %s: %w", key, err) } } - if headers.JWK() != nil { + if headerJWK, ok := headers.JWK(); ok { // 'kid' has been logged, use 'jwk' to sign _ = headers.Remove(jwk.KeyIDKey) @@ -324,7 +327,7 @@ func SignJWS(ctx context.Context, payload []byte, protectedHeaders map[string]in // we want to make sure the `jwk` header (if present) does not (accidentally) contain a private key. // That would lead to the node leaking its private key material in the resulting JWS which would be very, very bad. var jwkAsPrivateKey crypto.Signer - if err := headers.JWK().Raw(&jwkAsPrivateKey); err == nil { + if err := jwk.Export(headerJWK, &jwkAsPrivateKey); err == nil { // `err != nil` is good in this case, because that means the key is not assignable to crypto.Signer, // which is the interface implemented by all private key types. return "", errors.New("refusing to sign JWS with private key in JWK header") @@ -357,19 +360,19 @@ func EncryptJWE(payload []byte, protectedHeaders map[string]interface{}, publicK if publicKey == nil { return "", errors.New("no publicKey provided") } - json, err := json.Marshal(protectedHeaders) + headersJSON, err := json.Marshal(protectedHeaders) if err != nil { return "", err } headers := jwe.NewHeaders() - err = headers.UnmarshalJSON(json) + err = json.Unmarshal(headersJSON, headers) if err != nil { return "", err } // Figure out the KeyEncryptionAlgorithm, give prevalence to the headers var alg jwa.KeyEncryptionAlgorithm - if len(headers.Algorithm().String()) > 0 { - alg = headers.Algorithm() + if hdrAlg, ok := headers.Algorithm(); ok && len(hdrAlg.String()) > 0 { + alg = hdrAlg } else { alg, err = encryptionAlgorithm(publicKey) if err != nil { @@ -379,14 +382,15 @@ func EncryptJWE(payload []byte, protectedHeaders map[string]interface{}, publicK // Figure out the KeyEncryptionAlgorithm, give prevalence to the headers enc := jwx.DefaultContentEncryptionAlgorithm - if len(headers.ContentEncryption().String()) > 0 { - enc = headers.ContentEncryption() + if hdrEnc, ok := headers.ContentEncryption(); ok && len(hdrEnc.String()) > 0 { + enc = hdrEnc } + compression, _ := headers.Compression() options := []jwe.EncryptOption{ jwe.WithProtectedHeaders(headers), jwe.WithContentEncryption(enc), jwe.WithKey(alg, publicKey), - jwe.WithCompress(headers.Compression()), // "" means no compression + jwe.WithCompress(compression), // "" means no compression } encoded, err := jwe.Encrypt(payload, options...) @@ -405,10 +409,13 @@ func ExtractProtectedHeaders(jwt string) (map[string]interface{}, error) { if len(message.Signatures()) != 1 { return nil, ErrorInvalidNumberOfSignatures } - var err error - headers, err = message.Signatures()[0].ProtectedHeaders().AsMap(context.Background()) - if err != nil { - return nil, err + protectedHeaders := message.Signatures()[0].ProtectedHeaders() + for _, k := range protectedHeaders.Keys() { + var v interface{} + if err := protectedHeaders.Get(k, &v); err != nil { + return nil, err + } + headers[k] = v } } } @@ -444,24 +451,24 @@ func convertHeaders(headers map[string]interface{}) (jws.Headers, error) { func signingAlg(key crypto.PublicKey) (jwa.SignatureAlgorithm, error) { switch k := key.(type) { case *rsa.PublicKey: - return jwa.PS256, nil + return jwa.PS256(), nil case *ecdsa.PublicKey: return ecAlgUsingPublicKey(*k) case ed25519.PublicKey: - return jwa.EdDSA, nil + return jwa.EdDSA(), nil default: - return "", fmt.Errorf(`could not determine signature algorithm for key type '%T'`, key) + return jwa.SignatureAlgorithm{}, fmt.Errorf(`could not determine signature algorithm for key type '%T'`, key) } } func ecAlgUsingPublicKey(key ecdsa.PublicKey) (alg jwa.SignatureAlgorithm, err error) { switch key.Params().BitSize { case 256: - alg = jwa.ES256 + alg = jwa.ES256() case 384: - alg = jwa.ES384 + alg = jwa.ES384() case 521: - alg = jwa.ES512 + alg = jwa.ES512() default: err = jwx.ErrUnsupportedSigningKey } @@ -471,7 +478,7 @@ func ecAlgUsingPublicKey(key ecdsa.PublicKey) (alg jwa.SignatureAlgorithm, err e // SignatureAlgorithm determines the jwa.SigningAlgorithm for ec/rsa/ed25519 keys. func SignatureAlgorithm(key crypto.PublicKey) (jwa.SignatureAlgorithm, error) { if key == nil { - return "", errors.New("no key provided") + return jwa.SignatureAlgorithm{}, errors.New("no key provided") } var ptr interface{} @@ -490,19 +497,19 @@ func SignatureAlgorithm(key crypto.PublicKey) (jwa.SignatureAlgorithm, error) { switch k := ptr.(type) { case *rsa.PrivateKey: - return jwa.PS256, nil + return jwa.PS256(), nil case *rsa.PublicKey: - return jwa.PS256, nil + return jwa.PS256(), nil case *ecdsa.PrivateKey: return ecAlgUsingPublicKey(k.PublicKey) case *ecdsa.PublicKey: return ecAlgUsingPublicKey(*k) case ed25519.PrivateKey: - return jwa.EdDSA, nil + return jwa.EdDSA(), nil case ed25519.PublicKey: - return jwa.EdDSA, nil + return jwa.EdDSA(), nil default: - return "", fmt.Errorf(`could not determine signature algorithm for key type '%T'`, key) + return jwa.SignatureAlgorithm{}, fmt.Errorf(`could not determine signature algorithm for key type '%T'`, key) } } @@ -514,7 +521,7 @@ func encryptionAlgorithm(key crypto.PublicKey) (jwa.KeyEncryptionAlgorithm, erro case *ecdsa.PublicKey: return jwx.DefaultEcEncryptionAlgorithm, nil default: - return "", fmt.Errorf("could not determine encryption algorithm for key type '%T'", key) + return jwa.KeyEncryptionAlgorithm{}, fmt.Errorf("could not determine encryption algorithm for key type '%T'", key) } } diff --git a/crypto/jwx/algorithm.go b/crypto/jwx/algorithm.go index bbf5eb54c9..c17a46574f 100644 --- a/crypto/jwx/algorithm.go +++ b/crypto/jwx/algorithm.go @@ -21,17 +21,17 @@ package jwx import ( "errors" - "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v3/jwa" ) // ErrUnsupportedSigningKey is returned when an unsupported private key is used to sign. Currently only ecdsa and rsa keys are supported var ErrUnsupportedSigningKey = errors.New("signing key algorithm not supported") -var SupportedAlgorithms = []jwa.SignatureAlgorithm{jwa.ES256, jwa.EdDSA, jwa.ES384, jwa.ES512, jwa.PS256, jwa.PS384, jwa.PS512, jwa.RS256} +var SupportedAlgorithms = []jwa.SignatureAlgorithm{jwa.ES256(), jwa.EdDSA(), jwa.ES384(), jwa.ES512(), jwa.PS256(), jwa.PS384(), jwa.PS512(), jwa.RS256()} -const DefaultRsaEncryptionAlgorithm = jwa.RSA_OAEP_256 -const DefaultEcEncryptionAlgorithm = jwa.ECDH_ES_A256KW -const DefaultContentEncryptionAlgorithm = jwa.A256GCM +var DefaultRsaEncryptionAlgorithm = jwa.RSA_OAEP_256() +var DefaultEcEncryptionAlgorithm = jwa.ECDH_ES_A256KW() +var DefaultContentEncryptionAlgorithm = jwa.A256GCM() func IsAlgorithmSupported(alg jwa.SignatureAlgorithm) bool { for _, curr := range SupportedAlgorithms { @@ -51,7 +51,7 @@ func AddSupportedAlgorithm(alg jwa.SignatureAlgorithm) bool { func SupportedAlgorithmsAsStrings() []string { var result []string for _, alg := range SupportedAlgorithms { - result = append(result, string(alg)) + result = append(result, alg.String()) } return result } diff --git a/crypto/jwx/jwx_es256k.go b/crypto/jwx/jwx_es256k.go index d8a495a790..bc0637aeb6 100644 --- a/crypto/jwx/jwx_es256k.go +++ b/crypto/jwx/jwx_es256k.go @@ -20,7 +20,7 @@ package jwx -import "github.com/lestrrat-go/jwx/v2/jwa" +import "github.com/lestrrat-go/jwx/v3/jwa" func init() { AddSupportedAlgorithm(jwa.ES256K) diff --git a/crypto/jwx/jwx_es256k_test.go b/crypto/jwx/jwx_es256k_test.go index cfc99dff88..5888e68fe1 100644 --- a/crypto/jwx/jwx_es256k_test.go +++ b/crypto/jwx/jwx_es256k_test.go @@ -22,8 +22,8 @@ package jwx import ( "crypto" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/nuts-node/crypto/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/crypto/jwx_test.go b/crypto/jwx_test.go index 6866906fb2..a5216f8280 100644 --- a/crypto/jwx_test.go +++ b/crypto/jwx_test.go @@ -37,11 +37,11 @@ import ( "testing" "time" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwe" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwe" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/mr-tron/base58" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/crypto/test" @@ -60,7 +60,7 @@ func TestSignJWT(t *testing.T) { claims := map[string]interface{}{"iss": "nuts"} t.Run("creates valid JWT using rsa keys", func(t *testing.T) { rsaKey := test.GenerateRSAKey() - tokenString, err := SignJWT(audit.TestContext(), rsaKey, jwa.PS256, claims, nil) + tokenString, err := SignJWT(audit.TestContext(), rsaKey, jwa.PS256(), claims, nil) assert.Nil(t, err) @@ -70,7 +70,8 @@ func TestSignJWT(t *testing.T) { require.NoError(t, err) - assert.Equal(t, "nuts", token.Issuer()) + issuer, _ := token.Issuer() + assert.Equal(t, "nuts", issuer) }) t.Run("creates valid JWT using ec keys", func(t *testing.T) { @@ -79,7 +80,7 @@ func TestSignJWT(t *testing.T) { p521, _ := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) keys := []*ecdsa.PrivateKey{p256, p384, p521} - algs := []jwa.SignatureAlgorithm{jwa.ES256, jwa.ES384, jwa.ES512} + algs := []jwa.SignatureAlgorithm{jwa.ES256(), jwa.ES384(), jwa.ES512()} for i, ecKey := range keys { name := fmt.Sprintf("using %s", ecKey.Params().Name) @@ -93,26 +94,28 @@ func TestSignJWT(t *testing.T) { }, nil, nil) require.NoError(t, err) - assert.Equal(t, "nuts", token.Issuer()) + issuer, _ := token.Issuer() + assert.Equal(t, "nuts", issuer) }) } }) t.Run("sets correct headers", func(t *testing.T) { ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - tokenString, err := SignJWT(audit.TestContext(), ecKey, jwa.ES256, claims, nil) + tokenString, err := SignJWT(audit.TestContext(), ecKey, jwa.ES256(), claims, nil) require.NoError(t, err) msg, err := jws.ParseString(tokenString) require.NoError(t, err) hdrs := msg.Signatures()[0].ProtectedHeaders() - alg, _ := hdrs.Get(jwk.AlgorithmKey) - assert.Equal(t, jwa.ES256, alg) + var alg jwa.SignatureAlgorithm + require.NoError(t, hdrs.Get(jwk.AlgorithmKey, &alg)) + assert.Equal(t, jwa.ES256(), alg) }) t.Run("invalid claim", func(t *testing.T) { - tokenString, err := SignJWT(audit.TestContext(), nil, jwa.ES256, map[string]interface{}{jwt.IssuedAtKey: "foobar"}, nil) + tokenString, err := SignJWT(audit.TestContext(), nil, jwa.ES256(), map[string]interface{}{jwt.IssuedAtKey: "foobar"}, nil) assert.Empty(t, tokenString) assert.EqualError(t, err, "invalid value for iat key: failed to accept string \"foobar\": value is not number of seconds since the epoch, and attempt to parse it as RFC3339 timestamp failed: parsing time \"foobar\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"foobar\" as \"2006\"") }) @@ -122,7 +125,7 @@ func TestParseJWT(t *testing.T) { t.Run("unsupported algorithm", func(t *testing.T) { secret := []byte("test-hmac-secret") token := jwt.New() - signature, _ := jwt.Sign(token, jwt.WithKey(jwa.HS256, secret)) + signature, _ := jwt.Sign(token, jwt.WithKey(jwa.HS256(), secret)) parsedToken, err := ParseJWT(string(signature), func(_ string) (crypto.PublicKey, error) { return secret, nil }, nil, nil) @@ -136,7 +139,7 @@ func TestParseJWT(t *testing.T) { token := jwt.New() err := token.Set(jwt.IssuedAtKey, time.Now().Add(4*time.Second).Unix()) assert.NoError(t, err) - signature, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256, ecKey)) + signature, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256(), ecKey)) parsedToken, err := ParseJWT(string(signature), func(_ string) (crypto.PublicKey, error) { return ecKey.Public(), nil }, nil, nil) @@ -151,7 +154,7 @@ func TestParseJWT(t *testing.T) { token := jwt.New() err := token.Set(jwt.IssuedAtKey, time.Now().Add(4*time.Second).Unix()) assert.NoError(t, err) - signature, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256, ecKey)) + signature, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256(), ecKey)) _, err = ParseJWT(string(signature), func(_ string) (crypto.PublicKey, error) { return ecKey.Public(), nil }, (&JWTProfile{}).WithClockSkew(1*time.Second), nil) @@ -162,14 +165,14 @@ func TestParseJWT(t *testing.T) { authenticKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) attackerKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) token := jwt.New() - validToken, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256, authenticKey)) + validToken, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256(), authenticKey)) parsedToken, err := ParseJWT(string(validToken), func(_ string) (crypto.PublicKey, error) { return attackerKey.Public(), nil }, nil, nil) assert.Nil(t, parsedToken) - assert.EqualError(t, err, "could not verify message using any of the signatures or keys") + assert.EqualError(t, err, "jwt.ParseString: failed to parse string: signature verification failed for ES256: invalid ECDSA signature") }) } @@ -192,7 +195,8 @@ func TestCrypto_SignJWT(t *testing.T) { require.NoError(t, err) - assert.Equal(t, "nuts", token.Issuer()) + issuer, _ := token.Issuer() + assert.Equal(t, "nuts", issuer) assert.Equal(t, kid, actualKID) }) t.Run("creates valid JWT using external key", func(t *testing.T) { @@ -217,7 +221,8 @@ func TestCrypto_SignJWT(t *testing.T) { require.NoError(t, err) - assert.Equal(t, "nuts", token.Issuer()) + issuer, _ := token.Issuer() + assert.Equal(t, "nuts", issuer) assert.Equal(t, kid, actualKID) }) t.Run("writes audit logs", func(t *testing.T) { @@ -299,7 +304,7 @@ func TestCrypto_SignJWS(t *testing.T) { auditLogs := audit.CaptureAuditLogs(t) key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - publicKeyAsJWK, _ := jwk.FromRaw(key.Public()) + publicKeyAsJWK, _ := jwk.Import(key.Public()) hdrs := map[string]interface{}{ "kid": kid, "jwk": publicKeyAsJWK, @@ -311,7 +316,8 @@ func TestCrypto_SignJWS(t *testing.T) { auditLogs.AssertContains(t, ModuleName, "SignJWS", audit.TestActor, "Signing a JWS with key: kid") // kid is not in headers msg, err := jws.Parse([]byte(signature)) - assert.Empty(t, msg.Signatures()[0].ProtectedHeaders().KeyID()) + kid, _ := msg.Signatures()[0].ProtectedHeaders().KeyID() + assert.Empty(t, kid) }) t.Run("returns error for not found", func(t *testing.T) { @@ -375,7 +381,7 @@ func TestCrypto_EncryptJWE(t *testing.T) { privateKey, _, err := client.getPrivateKey(context.Background(), kid) require.NoError(t, err) - token, err := jwe.Decrypt([]byte(tokenString), jwe.WithKey(jwa.ECDH_ES, privateKey)) + token, err := jwe.Decrypt([]byte(tokenString), jwe.WithKey(jwa.ECDH_ES(), privateKey)) require.NoError(t, err) var body = make(map[string]interface{}) @@ -507,19 +513,21 @@ func TestSignJWS(t *testing.T) { sig := message.Signatures() assert.Len(t, sig, 1, "there must be one signature in the parsed message") - fooValue, _ := sig[0].ProtectedHeaders().Get("foo") - assert.Equal(t, "bar", fooValue.(string), + var fooValue string + _ = sig[0].ProtectedHeaders().Get("foo", &fooValue) + assert.Equal(t, "bar", fooValue, "the protected headers must contain the 'foo' key with 'bar' value") // Sanity check: verify signature - actualPayload, err := jws.Verify([]byte(signature), jws.WithKey(sig[0].ProtectedHeaders().Algorithm(), key.Public())) + sigAlg, _ := sig[0].ProtectedHeaders().Algorithm() + actualPayload, err := jws.Verify([]byte(signature), jws.WithKey(sigAlg, key.Public())) require.NoError(t, err, "the signature could not be validated") assert.Equal(t, payload, actualPayload) }) t.Run("public key in JWK header is allowed", func(t *testing.T) { payload := []byte{1, 2, 3} - publicKeyAsJWK, _ := jwk.FromRaw(key.Public()) + publicKeyAsJWK, _ := jwk.Import(key.Public()) hdrs := map[string]interface{}{"jwk": publicKeyAsJWK} signature, err := SignJWS(audit.TestContext(), payload, hdrs, key, false) assert.NoError(t, err) @@ -532,13 +540,13 @@ func TestSignJWS(t *testing.T) { payload := []byte{'.'} signature, err := SignJWS(audit.TestContext(), payload, hdrs, key, false) - assert.EqualError(t, err, "unable to sign JWS failed to generate signature for signer #0 (alg=ES256): payload must not contain a \".\"") + assert.EqualError(t, err, "unable to sign JWS jws.Sign: failed to populate message: failed to build signature 0: compact serialization with b64=false requires payload to contain no \".\" characters per RFC 7797 §5.2; use jws.WithDetachedPayload to keep the payload out of the wire format") assert.Empty(t, signature) }) t.Run("private key in JWK header is not allowed", func(t *testing.T) { payload := []byte{1, 2, 3} - privateKeyAsJWK, _ := jwk.FromRaw(key) + privateKeyAsJWK, _ := jwk.Import(key) hdrs := map[string]interface{}{"jwk": privateKeyAsJWK} signature, err := SignJWS(audit.TestContext(), payload, hdrs, key, false) assert.EqualError(t, err, "refusing to sign JWS with private key in JWK header") @@ -568,7 +576,7 @@ func TestCrypto_convertHeaders(t *testing.T) { t.Run("nil headers", func(t *testing.T) { jwtHeader, err := convertHeaders(nil) require.NoError(t, err) - assert.Len(t, jwtHeader.PrivateParams(), 0) + assert.Len(t, jwtHeader.Keys(), 0) }) t.Run("ok", func(t *testing.T) { @@ -577,7 +585,8 @@ func TestCrypto_convertHeaders(t *testing.T) { } jwtHeader, err := convertHeaders(rawHeaders) - v, _ := jwtHeader.Get("key") + var v string + require.NoError(t, jwtHeader.Get("key", &v)) require.NoError(t, err) assert.Equal(t, "value", v) }) @@ -594,9 +603,9 @@ func TestCrypto_convertHeaders(t *testing.T) { } func Test_isAlgorithmSupported(t *testing.T) { - assert.True(t, jwx.IsAlgorithmSupported(jwa.PS256)) - assert.True(t, jwx.IsAlgorithmSupported(jwa.RS256)) - assert.False(t, jwx.IsAlgorithmSupported("")) + assert.True(t, jwx.IsAlgorithmSupported(jwa.PS256())) + assert.True(t, jwx.IsAlgorithmSupported(jwa.RS256())) + assert.False(t, jwx.IsAlgorithmSupported(jwa.SignatureAlgorithm{})) } func TestSignatureAlgorithm(t *testing.T) { @@ -624,16 +633,16 @@ func TestSignatureAlgorithm(t *testing.T) { key interface{} alg jwa.SignatureAlgorithm }{ - {"EC private key as pointer", ecKey256, jwa.ES256}, - {"EC private key", *ecKey256, jwa.ES256}, - {"EC public key as pointer", &ecKey384.PublicKey, jwa.ES384}, - {"EC public key", ecKey521.PublicKey, jwa.ES512}, - {"RSA private key as pointer", rsaKey, jwa.PS256}, - {"RSA private key", *rsaKey, jwa.PS256}, - {"RSA public key as pointer", &rsaKey.PublicKey, jwa.PS256}, - {"RSA public key", rsaKey.PublicKey, jwa.PS256}, - {"ED25519 private key", pEDKey, jwa.EdDSA}, - {"ED25519 public key", sEDKey, jwa.EdDSA}, + {"EC private key as pointer", ecKey256, jwa.ES256()}, + {"EC private key", *ecKey256, jwa.ES256()}, + {"EC public key as pointer", &ecKey384.PublicKey, jwa.ES384()}, + {"EC public key", ecKey521.PublicKey, jwa.ES512()}, + {"RSA private key as pointer", rsaKey, jwa.PS256()}, + {"RSA private key", *rsaKey, jwa.PS256()}, + {"RSA public key as pointer", &rsaKey.PublicKey, jwa.PS256()}, + {"RSA public key", rsaKey.PublicKey, jwa.PS256()}, + {"ED25519 private key", pEDKey, jwa.EdDSA()}, + {"ED25519 public key", sEDKey, jwa.EdDSA()}, } for _, test := range tests { @@ -695,33 +704,33 @@ func Test_signingAlg(t *testing.T) { key, _ := rsa.GenerateKey(rand.Reader, 1024) alg, err := signingAlg(key.Public()) require.NoError(t, err) - assert.Equal(t, jwa.PS256, alg) + assert.Equal(t, jwa.PS256(), alg) }) t.Run("EC", func(t *testing.T) { t.Run("P256", func(t *testing.T) { key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) alg, err := signingAlg(key.Public()) require.NoError(t, err) - assert.Equal(t, jwa.ES256, alg) + assert.Equal(t, jwa.ES256(), alg) }) t.Run("P384", func(t *testing.T) { key, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) alg, err := signingAlg(key.Public()) require.NoError(t, err) - assert.Equal(t, jwa.ES384, alg) + assert.Equal(t, jwa.ES384(), alg) }) t.Run("P521", func(t *testing.T) { key, _ := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) alg, err := signingAlg(key.Public()) require.NoError(t, err) - assert.Equal(t, jwa.ES512, alg) + assert.Equal(t, jwa.ES512(), alg) }) }) t.Run("ED25519", func(t *testing.T) { key, _, _ := ed25519.GenerateKey(rand.Reader) alg, err := signingAlg(key) require.NoError(t, err) - assert.Equal(t, jwa.EdDSA, alg) + assert.Equal(t, jwa.EdDSA(), alg) }) t.Run("unsupported key", func(t *testing.T) { _, err := signingAlg(nil) @@ -740,7 +749,7 @@ func TestExtractProtectedHeaders(t *testing.T) { if err != nil { return "", err } - sign, err := jws.Sign(marshal, jws.WithKey(jwa.ES256, jwk)) + sign, err := jws.Sign(marshal, jws.WithKey(jwa.ES256(), jwk)) if err != nil { return "", err } @@ -755,7 +764,7 @@ func TestExtractProtectedHeaders(t *testing.T) { if err != nil { return "", err } - sign, err := jws.Sign(marshal, jws.WithKey(jwa.ES256, jwk), jws.WithKey(jwa.ES256, jwk), jws.WithJSON()) + sign, err := jws.Sign(marshal, jws.WithKey(jwa.ES256(), jwk), jws.WithKey(jwa.ES256(), jwk), jws.WithJSON()) if err != nil { return "", err } diff --git a/crypto/memory.go b/crypto/memory.go index 56d93ab951..c3d5bd8086 100644 --- a/crypto/memory.go +++ b/crypto/memory.go @@ -24,7 +24,7 @@ import ( "errors" "maps" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/nuts-node/crypto/dpop" ) @@ -42,11 +42,11 @@ func (m MemoryJWTSigner) SignJWT(ctx context.Context, claims map[string]interfac headersLocal := make(map[string]interface{}) maps.Copy(headersLocal, headers) - if kid != m.Key.KeyID() { + if keyID, _ := m.Key.KeyID(); kid != keyID { return "", ErrPrivateKeyNotFound } var signer crypto.Signer - if err := m.Key.Raw(&signer); err != nil { + if err := jwk.Export(m.Key, &signer); err != nil { return "", err } alg, err := signingAlg(signer.Public()) @@ -60,10 +60,10 @@ func (m MemoryJWTSigner) SignJWT(ctx context.Context, claims map[string]interfac func (m MemoryJWTSigner) SignJWS(ctx context.Context, payload []byte, headers map[string]interface{}, kid string, detached bool) (string, error) { var signer crypto.Signer - if err := m.Key.Raw(&signer); err != nil { + if err := jwk.Export(m.Key, &signer); err != nil { return "", err } - if kid != m.Key.KeyID() { + if keyID, _ := m.Key.KeyID(); kid != keyID { return "", ErrPrivateKeyNotFound } headers["kid"] = kid diff --git a/crypto/memory_test.go b/crypto/memory_test.go index ea2ea172a4..5171d06574 100644 --- a/crypto/memory_test.go +++ b/crypto/memory_test.go @@ -25,13 +25,13 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "testing" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/stretchr/testify/assert" ) func TestMemoryKeyStore_SignJWT(t *testing.T) { pk, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - privateKeyJWK, _ := jwk.FromRaw(pk) + privateKeyJWK, _ := jwk.Import(pk) privateKeyJWK.Set(jwk.KeyIDKey, "123") alg, _ := ecAlgUsingPublicKey(pk.PublicKey) privateKeyJWK.Set(jwk.AlgorithmKey, alg) @@ -53,7 +53,7 @@ func TestMemoryKeyStore_SignJWT(t *testing.T) { func TestMemoryJWTSigner_SignJWS(t *testing.T) { pk, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - privateKeyJWK, _ := jwk.FromRaw(pk) + privateKeyJWK, _ := jwk.Import(pk) privateKeyJWK.Set(jwk.KeyIDKey, "123") alg, _ := ecAlgUsingPublicKey(pk.PublicKey) privateKeyJWK.Set(jwk.AlgorithmKey, alg) diff --git a/crypto/storage/azure/keyvault.go b/crypto/storage/azure/keyvault.go index 7b0e233c49..1a49517e1d 100644 --- a/crypto/storage/azure/keyvault.go +++ b/crypto/storage/azure/keyvault.go @@ -33,7 +33,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto/log" "github.com/nuts-foundation/nuts-node/crypto/storage/spi" @@ -209,14 +209,14 @@ func parseKey(key *azkeys.JSONWebKey) (publicKey crypto.PublicKey, keyType azkey err = fmt.Errorf("unable to parse key from Azure Key Vault as JWK: %w", err) return } - if err = keyAsJWK.Raw(&publicKey); err != nil { - err = fmt.Errorf("unable to convert key from Azure Key Vault Key to crypto.PublicKey: %w", err) - return - } if !(*key.Kty == azkeys.KeyTypeEC || *key.Kty == azkeys.KeyTypeECHSM) || *key.Crv != azkeys.CurveNameP256 { err = errors.New("only ES256 keys are supported") return } + if err = jwk.Export(keyAsJWK, &publicKey); err != nil { + err = fmt.Errorf("unable to convert key from Azure Key Vault Key to crypto.PublicKey: %w", err) + return + } keyType = azkeys.SignatureAlgorithmES256 if key.KID == nil { err = errors.New("missing KID in key") diff --git a/crypto/storage/azure/keyvault_test.go b/crypto/storage/azure/keyvault_test.go index 37faf224b7..e2b7d5b73a 100644 --- a/crypto/storage/azure/keyvault_test.go +++ b/crypto/storage/azure/keyvault_test.go @@ -21,6 +21,7 @@ package azure import ( "context" "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/sha256" @@ -37,7 +38,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/nuts-node/crypto/storage/spi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -112,8 +113,8 @@ func Test_Keyvault_GetPrivateKey(t *testing.T) { }) t.Run("unsupported key type", func(t *testing.T) { // Generate an RSA key to return - privateKey, _ := rsa.GenerateKey(rand.Reader, 1024) - privateKeyAsJWK, err := jwk.FromRaw(privateKey.PublicKey) + privateKey, _ := rsa.GenerateKey(rand.Reader, 2048) + privateKeyAsJWK, err := jwk.Import(privateKey.PublicKey) require.NoError(t, err) privateKeyJWKAsBytes_, _ := json.Marshal(privateKeyAsJWK) var jsonWebKey azkeys.JSONWebKey @@ -241,12 +242,20 @@ func Test_azureSigningKey_Sign(t *testing.T) { func keyBundle() azkeys.KeyBundle { id := azkeys.ID("https://myvaultname.vault.azure.net/keys/did-web-example-com-0/b86c2e6ad9054f4abf69cc185b99aa60") + // jwx v3 validates that the X/Y coordinates have the correct length for the curve, + // so use a real P-256 key's coordinates instead of arbitrary bytes. + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + ecdhPub, _ := key.PublicKey.ECDH() + // Uncompressed point encoding: 0x04 || X (32 bytes) || Y (32 bytes). + point := ecdhPub.Bytes() + x := point[1:33] + y := point[33:65] return azkeys.KeyBundle{ Key: &azkeys.JSONWebKey{ Kty: to.Ptr(azkeys.KeyTypeEC), Crv: to.Ptr(azkeys.CurveNameP256), - X: []byte{1, 2, 3}, - Y: []byte{4, 5, 6}, + X: x, + Y: y, KID: &id, }, } diff --git a/crypto/storage/spi/interface.go b/crypto/storage/spi/interface.go index 0652c96eee..746b572d4f 100644 --- a/crypto/storage/spi/interface.go +++ b/crypto/storage/spi/interface.go @@ -29,7 +29,7 @@ import ( "fmt" "regexp" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/nuts-node/core" ) @@ -77,9 +77,13 @@ type PublicKeyEntry struct { // FromJWK fills the publicKeyEntry with key material from the given key func (pke *PublicKeyEntry) FromJWK(key jwk.Key) error { - asMap, err := key.AsMap(context.Background()) - if err != nil { - return err + asMap := make(map[string]interface{}) + for _, k := range key.Keys() { + var v interface{} + if err := key.Get(k, &v); err != nil { + return err + } + asMap[k] = v } pke.Key = asMap pke.parsedJWK = key diff --git a/crypto/storage/spi/interface_test.go b/crypto/storage/spi/interface_test.go index 7747afd91c..8671d6ada7 100644 --- a/crypto/storage/spi/interface_test.go +++ b/crypto/storage/spi/interface_test.go @@ -22,7 +22,7 @@ import ( "crypto/elliptic" "crypto/rand" "errors" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "testing" @@ -38,13 +38,13 @@ func TestPublicKeyEntry_UnmarshalJSON(t *testing.T) { t.Run("error - invalid publicKeyJwk format", func(t *testing.T) { err := (&PublicKeyEntry{}).UnmarshalJSON([]byte("{\"publicKeyJwk\":{}}")) - assert.EqualError(t, err, "could not parse publicKeyEntry: invalid publickeyJwk: invalid key type from JSON ()") + assert.EqualError(t, err, "could not parse publicKeyEntry: invalid publickeyJwk: jwk.ParseKey: invalid key type from JSON ()") }) } func TestPublicKeyEntry_FromJWK(t *testing.T) { privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - pk, _ := jwk.FromRaw(privateKey) + pk, _ := jwk.Import(privateKey) entry := PublicKeyEntry{} err := entry.FromJWK(pk) diff --git a/discovery/client_test.go b/discovery/client_test.go index cbebab6add..3322c0876e 100644 --- a/discovery/client_test.go +++ b/discovery/client_test.go @@ -21,7 +21,7 @@ package discovery import ( "context" "errors" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" diff --git a/discovery/module.go b/discovery/module.go index bba9412042..ef8174af80 100644 --- a/discovery/module.go +++ b/discovery/module.go @@ -236,10 +236,11 @@ func (m *Module) verifyRegistration(definition ServiceDefinition, presentation v return errors.Join(ErrInvalidPresentation, errPresentationWithoutID) } // Make sure the presentation is intended for this service - if err := validateAudience(definition, presentation.JWT().Audience()); err != nil { + audience, _ := presentation.JWT().Audience() + if err := validateAudience(definition, audience); err != nil { return err } - expiration := presentation.JWT().Expiration() + expiration, _ := presentation.JWT().Expiration() if expiration.IsZero() { return errors.Join(ErrInvalidPresentation, errPresentationWithoutExpiration) } @@ -275,7 +276,7 @@ func (m *Module) verifyRegistration(definition ServiceDefinition, presentation v func (m *Module) validateRegistration(definition ServiceDefinition, presentation vc.VerifiablePresentation) error { // VP can't be valid longer than the credentialRecord it contains - expiration := presentation.JWT().Expiration() + expiration, _ := presentation.JWT().Expiration() for _, cred := range presentation.VerifiableCredential { if cred.ExpirationDate != nil && expiration.After(*cred.ExpirationDate) { return errPresentationValidityExceedsCredentials @@ -306,9 +307,8 @@ func (m *Module) validateRetraction(serviceID string, presentation vc.Verifiable // RFC022 §3.4: it MUST contain a retract_jti JWT claim, containing the jti of the presentation to retract. // Check that the retraction refers to an existing presentation. // If not, it might've already been removed due to expiry or superseded by a newer presentation. - retractJTIRaw, _ := presentation.JWT().Get("retract_jti") - retractJTI, ok := retractJTIRaw.(string) - if !ok || retractJTI == "" { + var retractJTI string + if err := presentation.JWT().Get("retract_jti", &retractJTI); err != nil || retractJTI == "" { return errInvalidRetractionJTIClaim } signerDID, _ := credential.PresentationSigner(presentation) // checked before @@ -317,7 +317,7 @@ func (m *Module) validateRetraction(serviceID string, presentation vc.Verifiable return err } if !exists { - if _, ok = m.serverDefinitions[serviceID]; ok { // only throw an error if acting as server for this service, see https://github.com/nuts-foundation/nuts-node/issues/3691 + if _, ok := m.serverDefinitions[serviceID]; ok { // only throw an error if acting as server for this service, see https://github.com/nuts-foundation/nuts-node/issues/3691 return errRetractionReferencesUnknownPresentation } log.Logger().Warnf("Ignored retraction (ID=%s) signed by (did=%s) for (service=%s) that references a VP (retractedJTI=%s) that does not exist (anymore).", presentation.ID, signerDID.String(), serviceID, retractJTI) diff --git a/discovery/module_test.go b/discovery/module_test.go index 9078611bf6..752e431e11 100644 --- a/discovery/module_test.go +++ b/discovery/module_test.go @@ -22,7 +22,7 @@ import ( "context" "encoding/json" "errors" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/core" diff --git a/discovery/store.go b/discovery/store.go index be65f92113..e440a082a0 100644 --- a/discovery/store.go +++ b/discovery/store.go @@ -210,6 +210,7 @@ func storePresentation(tx *gorm.DB, serviceID string, timestamp int, presentatio return nil, err } + presentationExpiration, _ := presentation.JWT().Expiration() newPresentation := presentationRecord{ ID: uuid.NewString(), ServiceID: serviceID, @@ -217,7 +218,7 @@ func storePresentation(tx *gorm.DB, serviceID string, timestamp int, presentatio LamportTimestamp: timestamp, PresentationID: presentation.ID.String(), PresentationRaw: presentation.Raw(), - PresentationExpiration: presentation.JWT().Expiration().Unix(), + PresentationExpiration: presentationExpiration.Unix(), } credentialStore := store.CredentialStore{} diff --git a/discovery/store_test.go b/discovery/store_test.go index d1b73ef23b..8239aa0a80 100644 --- a/discovery/store_test.go +++ b/discovery/store_test.go @@ -19,7 +19,7 @@ package discovery import ( - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/core/to" diff --git a/discovery/test.go b/discovery/test.go index 15d913939e..f016014862 100644 --- a/discovery/test.go +++ b/discovery/test.go @@ -26,10 +26,10 @@ import ( "encoding/json" "fmt" "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -264,13 +264,13 @@ func signJWT(subjectDID did.DID, claims map[string]interface{}, headers map[stri if signingKey == nil { return "", fmt.Errorf("key not found for DID: %s", subjectDID) } - subjectKeyJWK, err := jwk.FromRaw(signingKey) + subjectKeyJWK, err := jwk.Import(signingKey) if err != nil { return "", nil } keyID := did.DIDURL{DID: subjectDID} keyID.Fragment = "0" - if err := subjectKeyJWK.Set(jwk.AlgorithmKey, jwa.ES256); err != nil { + if err := subjectKeyJWK.Set(jwk.AlgorithmKey, jwa.ES256()); err != nil { return "", err } if err := subjectKeyJWK.Set(jwk.KeyIDKey, keyID.String()); err != nil { @@ -290,7 +290,8 @@ func signJWT(subjectDID did.DID, claims map[string]interface{}, headers map[stri return "", err } } - bytes, err := jwt.Sign(token, jwt.WithKey(subjectKeyJWK.Algorithm(), subjectKeyJWK, jws.WithProtectedHeaders(hdr))) + alg, _ := subjectKeyJWK.Algorithm() + bytes, err := jwt.Sign(token, jwt.WithKey(alg, subjectKeyJWK, jws.WithProtectedHeaders(hdr))) return string(bytes), err } diff --git a/go.mod b/go.mod index 2637f69568..f3c9069d2e 100644 --- a/go.mod +++ b/go.mod @@ -24,14 +24,13 @@ require ( github.com/knadh/koanf/providers/structs v1.0.0 github.com/knadh/koanf/v2 v2.3.5 github.com/labstack/echo/v4 v4.15.4 - github.com/lestrrat-go/jwx/v2 v2.1.6 github.com/mdp/qrterminal/v3 v3.2.1 github.com/mr-tron/base58 v1.3.0 github.com/multiformats/go-multicodec v0.10.0 github.com/nats-io/nats-server/v2 v2.14.2 github.com/nats-io/nats.go v1.52.0 github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b - github.com/nuts-foundation/go-did v0.21.0 + github.com/nuts-foundation/go-did v0.21.1-0.20260703074457-d8459a02af42 github.com/nuts-foundation/go-leia/v4 v4.3.0 github.com/nuts-foundation/go-stoabs v1.11.1 github.com/nuts-foundation/sqlite v1.0.0 @@ -86,7 +85,7 @@ require ( github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/edsrzf/mmap-go v1.1.0 // indirect @@ -105,7 +104,7 @@ require ( github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect - github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect @@ -132,9 +131,6 @@ require ( github.com/labstack/gommon v0.5.0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc v1.0.6 // indirect - github.com/lestrrat-go/iter v1.0.2 // indirect - github.com/lestrrat-go/option v1.0.1 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/mfridman/interpolate v0.0.2 // indirect @@ -149,7 +145,7 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect - github.com/multiformats/go-multibase v0.2.0 // indirect + github.com/multiformats/go-multibase v0.3.0 // indirect github.com/multiformats/go-multihash v0.0.11 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/jwt/v2 v2.8.2 // indirect @@ -208,6 +204,7 @@ require ( github.com/eko/gocache/store/go_cache/v4 v4.2.5 github.com/eko/gocache/store/memcache/v4 v4.2.4 github.com/eko/gocache/store/redis/v4 v4.2.6 + github.com/lestrrat-go/jwx/v3 v3.1.1 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/uptrace/opentelemetry-go-extra/otelgorm v0.3.2 go.opentelemetry.io/contrib/bridges/otellogrus v0.19.0 @@ -245,12 +242,13 @@ require ( github.com/google/go-tpm v0.9.8 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect - github.com/lestrrat-go/httprc/v3 v3.0.0 // indirect - github.com/lestrrat-go/jwx/v3 v3.0.10 // indirect + github.com/lestrrat-go/dsig v1.2.1 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/rs/zerolog v1.26.1 // indirect github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect - github.com/valyala/fastjson v1.6.4 // indirect + github.com/valyala/fastjson v1.6.10 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect go.opentelemetry.io/otel/log v0.20.0 // indirect diff --git a/go.sum b/go.sum index ea76d57e11..9f234b9724 100644 --- a/go.sum +++ b/go.sum @@ -125,8 +125,8 @@ github.com/daangn/minimemcached v1.2.1/go.mod h1:ewcvvKcPuzp5tQjELLUXDZJtb3L1Uqx github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dgraph-io/badger/v4 v4.6.0 h1:acOwfOOZ4p1dPRnYzvkVm7rUk2Y21TgPVepCy5dJdFQ= github.com/dgraph-io/badger/v4 v4.6.0/go.mod h1:KSJ5VTuZNC3Sd+YhvVjk2nYua9UZnnTr/SkXvdtiPgI= github.com/dgraph-io/ristretto/v2 v2.1.0 h1:59LjpOJLNDULHh8MC4UaegN52lC4JnO2dITsie/Pa8I= @@ -199,8 +199,8 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -323,20 +323,16 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc= +github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= -github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= -github.com/lestrrat-go/httprc/v3 v3.0.0 h1:nZUx/zFg5uc2rhlu1L1DidGr5Sj02JbXvGSpnY4LMrc= -github.com/lestrrat-go/httprc/v3 v3.0.0/go.mod h1:k2U1QIiyVqAKtkffbg+cUmsyiPGQsb9aAfNQiNFuQ9Q= -github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= -github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= -github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= -github.com/lestrrat-go/jwx/v3 v3.0.10 h1:XuoCBhZBncRIjMQ32HdEc76rH0xK/Qv2wq5TBouYJDw= -github.com/lestrrat-go/jwx/v3 v3.0.10/go.mod h1:kNMedLgTpHvPJkK5EMVa1JFz+UVyY2dMmZKu3qjl/Pk= -github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= -github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= +github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= +github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= @@ -376,8 +372,8 @@ github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aG github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= -github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= -github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multibase v0.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68= +github.com/multiformats/go-multibase v0.3.0/go.mod h1:MoBLQPCkRTOL3eveIPO81860j2AQY8JwcnNlRkGRUfI= github.com/multiformats/go-multicodec v0.10.0 h1:UpP223cig/Cx8J76jWt91njpK3GTAO1w02sdcjZDSuc= github.com/multiformats/go-multicodec v0.10.0/go.mod h1:wg88pM+s2kZJEQfRCKBNU+g32F5aWBEjyFHXvZLTcLI= github.com/multiformats/go-multihash v0.0.11 h1:yEyBxwoR/7vBM5NfLVXRnpQNVLrMhpS6MRb7Z/1pnzc= @@ -400,8 +396,8 @@ github.com/nightlyone/lockfile v1.0.0 h1:RHep2cFKK4PonZJDdEl4GmkabuhbsRMgk/k3uAm github.com/nightlyone/lockfile v1.0.0/go.mod h1:rywoIealpdNse2r832aiD9jRk8ErCatROs6LzC841CI= github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b h1:80icUxWHwE1MrIOOEK5rxrtyKOgZeq5Iu1IjAEkggTY= github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b/go.mod h1:6YUioYirD6/8IahZkoS4Ypc8xbeJW76Xdk1QKcziNTM= -github.com/nuts-foundation/go-did v0.21.0 h1:jQAr6O8afgVp88Rkwnfl1m5E0b0L0KTkSqM5iDnq0fw= -github.com/nuts-foundation/go-did v0.21.0/go.mod h1:4od1gAmCi9HjHTQGEvHC8pLeuXdXACxidAcdA52YScc= +github.com/nuts-foundation/go-did v0.21.1-0.20260703074457-d8459a02af42 h1:OZwIKM1P32o9KifNfkqdBAFwwhWuPpOA3bftInCsDAQ= +github.com/nuts-foundation/go-did v0.21.1-0.20260703074457-d8459a02af42/go.mod h1:M6d7KaJLf3Ipl+ttRANc207S5MT9Kq5F0so1fPad/I0= github.com/nuts-foundation/go-leia/v4 v4.3.0 h1:R0qGISIeg2q/PCQTC9cuoBtA6cFu4WBV2DbmSOWKZyM= github.com/nuts-foundation/go-leia/v4 v4.3.0/go.mod h1:Gw6bXqJLOAmHSiXJJYbVoj+Mowp/PoBRywO0ZPsVzA0= github.com/nuts-foundation/go-stoabs v1.11.1 h1:ZQOeRKzC1+AfW6Ve5kBJMo+zYhHLBUI4KrLWKYkxoeI= @@ -537,8 +533,8 @@ github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2/go.mod h1:O8bHQfyinKwTX github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= -github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= diff --git a/http/engine_test.go b/http/engine_test.go index 31d36d5e11..2ba12c8292 100644 --- a/http/engine_test.go +++ b/http/engine_test.go @@ -32,9 +32,9 @@ import ( "github.com/google/uuid" "github.com/labstack/echo/v4" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/http/log" "github.com/nuts-foundation/nuts-node/test" @@ -366,7 +366,7 @@ func generateEd25519TestKey(t *testing.T) (jwk.Key, *jwt.Serializer, []byte) { sshAuthKey := fmt.Sprintf("%v %v random@test.local", sshPub.Type(), b64.StdEncoding.EncodeToString(sshPub.Marshal())) // Convert the base key type to a jwk type - jwkKey, err := jwk.FromRaw(priv) + jwkKey, err := jwk.Import(priv) require.NoError(t, err) // Set the key ID for the jwk to be the public key fingerprint @@ -374,7 +374,7 @@ func generateEd25519TestKey(t *testing.T) (jwk.Key, *jwt.Serializer, []byte) { require.NoError(t, err) // Create a serializer configured to use the generated key - serializer := jwt.NewSerializer().Sign(jwt.WithKey(jwa.EdDSA, jwkKey)) + serializer := jwt.NewSerializer().Sign(jwt.WithKey(jwa.EdDSA(), jwkKey)) t.Logf("authorized_key = %v", sshAuthKey) diff --git a/http/tokenV2/authorized_keys.go b/http/tokenV2/authorized_keys.go index 839d6b2bc9..84c126af50 100644 --- a/http/tokenV2/authorized_keys.go +++ b/http/tokenV2/authorized_keys.go @@ -31,7 +31,7 @@ import ( "github.com/nuts-foundation/nuts-node/http/log" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" ) // minimumRSAKeySize defines the minimum length in bits of RSA keys @@ -80,7 +80,7 @@ func jwkFromSSHKey(key ssh.PublicKey) (jwk.Key, error) { } // Use the crypto/* key type to create the jwk key type - converted, err := jwk.FromRaw(cryptoPublicKey) + converted, err := jwk.Import(cryptoPublicKey) if err != nil { return nil, err } diff --git a/http/tokenV2/middleware.go b/http/tokenV2/middleware.go index dbe0bd8846..ab731e5bbb 100644 --- a/http/tokenV2/middleware.go +++ b/http/tokenV2/middleware.go @@ -29,9 +29,9 @@ import ( "github.com/google/uuid" "github.com/labstack/echo/v4" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/http/log" @@ -162,7 +162,7 @@ func (m middlewareImpl) checkConnectionAuthorization(context echo.Context, next } // Ensure the issuer, the person issuing the JWT, matches the registered username for this key - if authorizedKey.comment != token.Issuer() { + if iss, _ := token.Issuer(); authorizedKey.comment != iss { return unauthorizedError(context, fmt.Errorf("expected issuer (%s) does not match iss", authorizedKey.comment)) } @@ -177,11 +177,14 @@ func (m middlewareImpl) checkConnectionAuthorization(context echo.Context, next // accessGranted allows a connection to be handled func accessGranted(authKey authorizedKey, context echo.Context, token jwt.Token, next echo.HandlerFunc) error { // Create an audit log entry about this access granted event - auditLog := auditLogger(context, token.Subject(), audit.AccessGrantedEvent) - auditLog.Infof("Access granted to user '%v' with JWT %s issued to %s by %s", authKey.comment, token.JwtID(), token.Subject(), token.Issuer()) + sub, _ := token.Subject() + jti, _ := token.JwtID() + iss, _ := token.Issuer() + auditLog := auditLogger(context, sub, audit.AccessGrantedEvent) + auditLog.Infof("Access granted to user '%v' with JWT %s issued to %s by %s", authKey.comment, jti, sub, iss) // Set the username from authorized_keys as the username in the context - context.Set(core.UserContextKey, token.Issuer()) + context.Set(core.UserContextKey, iss) // Call the next handler/middleware, probably serving some content/processing the API request return next(context) @@ -210,28 +213,28 @@ func credentialIsSecure(credential string) error { secureSignatureCount := 0 for _, signature := range message.Signatures() { // Reject credentials signed with insecure algorithms - algorithm := signature.ProtectedHeaders().Algorithm() + algorithm, _ := signature.ProtectedHeaders().Algorithm() if !acceptableSignatureAlgorithm(algorithm) { return fmt.Errorf("signing algorithm %v is not permitted", algorithm) } // Reject credentials trying to provide the public key directly in the header - if signature.ProtectedHeaders().JWK() != nil { + if _, ok := signature.ProtectedHeaders().JWK(); ok { return errors.New("embedding JWK in signature is forbidden") } // Reject credentials trying to provide the public key URL in the header - if signature.ProtectedHeaders().JWKSetURL() != "" { + if jku, ok := signature.ProtectedHeaders().JWKSetURL(); ok && jku != "" { return errors.New("embedding JWKSetURL in signature is forbidden") } // Reject credentials trying to provide an x509 certificate chain in the header - if signature.ProtectedHeaders().X509CertChain() != nil { + if _, ok := signature.ProtectedHeaders().X509CertChain(); ok { return errors.New("embedding X509CertChain in signature is forbidden") } // Reject credentials trying to provide an x509 chain URL in the header - if signature.ProtectedHeaders().X509URL() != "" { + if x5u, ok := signature.ProtectedHeaders().X509URL(); ok && x5u != "" { return errors.New("embedding X509URL in signature is forbidden") } @@ -255,12 +258,10 @@ func mandatoryJWTFields() []string { // tokenJTI returns the JWT's JTI as a string func tokenJTI(token jwt.Token) string { - // Retrieve the JwtID from the token, but it comes out as an interface{} - if jtiIface, ok := token.Get(jwt.JwtIDKey); ok { - // Convert the interface{} to a string so the typed value can be returned - if jtiStr, ok := jtiIface.(string); ok { - return jtiStr - } + // Retrieve the JwtID from the token as a string + var jti string + if err := token.Get(jwt.JwtIDKey, &jti); err == nil { + return jti } // Simply return an empty string if either the jti field wasn't present or it wasn't a string @@ -274,7 +275,8 @@ func tokenJTI(token jwt.Token) string { func bestPracticesCheck(token jwt.Token) error { // Ensure the mandatory fields are present for _, field := range mandatoryJWTFields() { - if _, ok := token.Get(field); !ok { + var v interface{} + if err := token.Get(field, &v); err != nil { return fmt.Errorf("missing field: %v", field) } } @@ -285,25 +287,29 @@ func bestPracticesCheck(token jwt.Token) error { return fmt.Errorf("token jti is not a valid uuid: %w", err) } + nbf, _ := token.NotBefore() + exp, _ := token.Expiration() + iat, _ := token.IssuedAt() + // Ensure the expiration is no more than 24.5 hours after NotBefore - maxExpirationAfterNotBefore := token.NotBefore().Add(time.Minute * time.Duration(1470)) - if token.Expiration().After(maxExpirationAfterNotBefore) { + maxExpirationAfterNotBefore := nbf.Add(time.Minute * time.Duration(1470)) + if exp.After(maxExpirationAfterNotBefore) { return errors.New("token expires too long after nbf") } // Ensure the expiration is no more than 24.5 hours after IssuedAt - maxExpirationAfterIssuedAt := token.IssuedAt().Add(time.Minute * time.Duration(1470)) - if token.Expiration().After(maxExpirationAfterIssuedAt) { + maxExpirationAfterIssuedAt := iat.Add(time.Minute * time.Duration(1470)) + if exp.After(maxExpirationAfterIssuedAt) { return errors.New("token expires too long after iat") } // Ensure the IssuedAt is <= the NotBefore date - if token.IssuedAt().After(token.NotBefore()) { + if iat.After(nbf) { return errors.New("token nbf occurs before iat") } // Ensure the subject field is non-empty - if token.Subject() == "" { + if sub, _ := token.Subject(); sub == "" { return errors.New("sub must not be empty") } @@ -319,7 +325,7 @@ func bestPracticesCheck(token jwt.Token) error { func acceptableSignatureAlgorithm(algorithm jwa.SignatureAlgorithm) bool { switch algorithm { // The following algorithms are supported for elliptic curve keys - case jwa.ES256, jwa.ES384, jwa.ES512: + case jwa.ES256(), jwa.ES384(), jwa.ES512(): return true // The RS512/PS512 algorithms are supported for RSA keys, but less secure @@ -342,11 +348,11 @@ func acceptableSignatureAlgorithm(algorithm jwa.SignatureAlgorithm) bool { // the ssh-agent protocol. It seems the ssh ecosystem has generally moved // beyond RSA support in favor of trying to maximize security within the // scope of RSA use. Perhaps we could consider doing the same. - case jwa.RS512, jwa.PS512: + case jwa.RS512(), jwa.PS512(): return true // Edwards curve signatures are considered secure and are therefore supported - case jwa.EdDSA: + case jwa.EdDSA(): return true // Explicitly reject messages signed by the "none" algorithm. This @@ -355,7 +361,7 @@ func acceptableSignatureAlgorithm(algorithm jwa.SignatureAlgorithm) bool { // approach into a blacklist approach in the future. Not to mention this // going wrong would result in a catastrophic security hole, so it's worth // having a special case for it. - case jwa.NoSignature: + case jwa.NoSignature(): return false // Only explicitly allowed signing algorithms are acceptable diff --git a/http/tokenV2/middleware_test.go b/http/tokenV2/middleware_test.go index a2316272e3..a4805e8b84 100644 --- a/http/tokenV2/middleware_test.go +++ b/http/tokenV2/middleware_test.go @@ -40,9 +40,9 @@ import ( "github.com/labstack/echo/v4" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/google/uuid" @@ -70,7 +70,7 @@ func generateEd25519TestKey(t *testing.T) (jwk.Key, *jwt.Serializer, []byte) { sshAuthKey := fmt.Sprintf("%v %v %v", sshPub.Type(), b64.StdEncoding.EncodeToString(sshPub.Marshal()), validUser) // Convert the base key type to a jwk type - jwkKey, err := jwk.FromRaw(priv) + jwkKey, err := jwk.Import(priv) require.NoError(t, err) // Set the key ID for the jwk to be the public key fingerprint @@ -78,7 +78,7 @@ func generateEd25519TestKey(t *testing.T) (jwk.Key, *jwt.Serializer, []byte) { require.NoError(t, err) // Create a serializer configured to use the generated key - serializer := jwt.NewSerializer().Sign(jwt.WithKey(jwa.EdDSA, jwkKey)) + serializer := jwt.NewSerializer().Sign(jwt.WithKey(jwa.EdDSA(), jwkKey)) t.Logf("authorized_key = %v", sshAuthKey) @@ -98,7 +98,7 @@ func generateECDSATestKey(t *testing.T, curve elliptic.Curve, signingAlgorithm j sshAuthKey := fmt.Sprintf("%v %v %v", sshPub.Type(), b64.StdEncoding.EncodeToString(sshPub.Marshal()), validUser) // Convert the base key type to a jwk type - jwkKey, err := jwk.FromRaw(priv) + jwkKey, err := jwk.Import(priv) require.NoError(t, err) // Set the key ID for the jwk to be the public key fingerprint @@ -126,7 +126,7 @@ func generateRSATestKey(t *testing.T, bits int, signingAlgorithm jwa.SignatureAl sshAuthKey := fmt.Sprintf("%v %v %s", sshPub.Type(), b64.StdEncoding.EncodeToString(sshPub.Marshal()), validUser) // Convert the base key type to a jwk type - jwkKey, err := jwk.FromRaw(priv) + jwkKey, err := jwk.Import(priv) require.NoError(t, err) // Set the key ID for the jwk to be the public key fingerprint @@ -272,9 +272,9 @@ func TestAuditLogAccessGranted(t *testing.T) { assert.Equal(t, ok, recorder.Body.String()) // Ensure the audit logging is working - jwtID, _ := token.Get(jwt.JwtIDKey) - subject, _ := token.Get(jwt.SubjectKey) - issuer, _ := token.Get(jwt.IssuerKey) + jwtID, _ := token.JwtID() + subject, _ := token.Subject() + issuer, _ := token.Issuer() capturedAuditLog.AssertContains(t, "http", audit.AccessGrantedEvent, validUser, fmt.Sprintf("Access granted to user '%v' with JWT %s issued to %s by %s", validUser, jwtID, subject, issuer)) } @@ -445,7 +445,7 @@ func TestInvalidSingleAudience(t *testing.T) { // Call the handler, ensuring the appropriate error is returned err = handler(testCtx) require.Error(t, err) - assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "jwt.Validate: \"aud\" not satisfied") + assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "validation failed: \"aud\" not satisfied") } // TestValidIssProxiedSub ensures a valid JWT containing a subject mentioning a proxied username results in a 200 OK @@ -610,7 +610,7 @@ func TestValidJWTCaseInsensitiveBearer(t *testing.T) { // TestValidJWTECDSAES256 ensures a valid JWT signed by an ECDSA 256-bit key authorizes a request func TestValidJWTECDSAES256(t *testing.T) { // Generate a new test key and jwt serializer - _, serializer, authorizedKey := generateECDSATestKey(t, elliptic.P256(), jwa.ES256) + _, serializer, authorizedKey := generateECDSATestKey(t, elliptic.P256(), jwa.ES256()) // Create a new JWT token := validJWT(t) @@ -647,7 +647,7 @@ func TestValidJWTECDSAES256(t *testing.T) { // TestValidJWTECDSAES384 ensures a valid JWT signed by an ECDSA 384-bit key authorizes a request func TestValidJWTECDSAES384(t *testing.T) { // Generate a new test key and jwt serializer - _, serializer, authorizedKey := generateECDSATestKey(t, elliptic.P384(), jwa.ES384) + _, serializer, authorizedKey := generateECDSATestKey(t, elliptic.P384(), jwa.ES384()) // Create a new JWT token := validJWT(t) @@ -684,7 +684,7 @@ func TestValidJWTECDSAES384(t *testing.T) { // TestValidJWTECDSAES384 ensures a valid JWT signed by an ECDSA 384-bit key authorizes a request func TestValidJWTECDSAES512(t *testing.T) { // Generate a new test key and jwt serializer - _, serializer, authorizedKey := generateECDSATestKey(t, elliptic.P521(), jwa.ES512) + _, serializer, authorizedKey := generateECDSATestKey(t, elliptic.P521(), jwa.ES512()) // Create a new JWT token := validJWT(t) @@ -754,7 +754,7 @@ func TestWrongAudienceJWT(t *testing.T) { // Call the handler, ensuring the appropriate error is returned err = handler(testCtx) require.Error(t, err) - assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "jwt.Validate: \"aud\" not satisfied") + assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "validation failed: \"aud\" not satisfied") } // TestWrongKeyID ensures a JWT with the wrong kid from an authorized key causes 401 Unauthorized @@ -811,7 +811,7 @@ func TestCorrectKeyIDWithIncorrectSignature(t *testing.T) { token := validJWT(t) // Sign and serialize the JWT with the untrusted key, setting the key id to a trusted key - trustedKeyID, found := trustedKey.Get(jwk.KeyIDKey) + trustedKeyID, found := trustedKey.KeyID() require.True(t, found) require.NoError(t, untrustedKey.Set(jwk.KeyIDKey, trustedKeyID)) serialized, err := untrustedSerializer.Serialize(token) @@ -881,7 +881,7 @@ func TestExpiredJWT(t *testing.T) { // Call the handler, ensuring the appropriate error is returned err = handler(testCtx) assert.Error(t, err) - assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "jwt.Validate: \"exp\" not satisfied") + assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "validation failed: \"exp\" not satisfied") } // TestFutureIATJWT ensures a not yet valid (iat = future) JWT from an authorized key causes 401 Unauthorized @@ -924,7 +924,7 @@ func TestFutureIATJWT(t *testing.T) { // Call the handler, ensuring the appropriate error is returned err = handler(testCtx) require.Error(t, err) - assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "jwt.Validate: \"iat\" not satisfied") + assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "validation failed: \"iat\" not satisfied") } // TestFutureNBFJWT ensures a not yet valid (nbf = future) JWT from an authorized key causes 401 Unauthorized @@ -967,7 +967,7 @@ func TestFutureNBFJWT(t *testing.T) { // Call the handler, ensuring the appropriate error is returned err = handler(testCtx) require.Error(t, err) - assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "jwt.Validate: \"nbf\" not satisfied") + assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "validation failed: \"nbf\" not satisfied") } // TestUnauthorizedKey ensures a valid JWT signed by an unauthorized key is rejected with 401 Unauthorized @@ -1163,7 +1163,7 @@ func TestNonBearerToken(t *testing.T) { // The RS512 signing algorithm is a suitable alternative, or better yet do not use RSA keys at all. func TestInsecureRS256JWT(t *testing.T) { // Generate a new test key and jwt serializer - _, serializer, authorizedKey := generateRSATestKey(t, 4096, jwa.RS256) + _, serializer, authorizedKey := generateRSATestKey(t, 4096, jwa.RS256()) // Create a new JWT token := validJWT(t) @@ -1202,7 +1202,7 @@ func TestInsecureRS256JWT(t *testing.T) { // The RS512 signing algorithm is a suitable alternative, or better yet do not use RSA keys at all. func TestInsecureRS384JWT(t *testing.T) { // Generate a new test key and jwt serializer - _, serializer, authorizedKey := generateRSATestKey(t, 4096, jwa.RS384) + _, serializer, authorizedKey := generateRSATestKey(t, 4096, jwa.RS384()) // Create a new JWT token := validJWT(t) @@ -1237,55 +1237,26 @@ func TestInsecureRS384JWT(t *testing.T) { assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "insecure credential: signing algorithm RS384 is not permitted") } -// TestInsecure1024BitRS512JWT ensures a JWT signed securely with an RS512 signing algorithm but using an insecure 1024-bit RSA key is rejected with a 401 Unauthorized response +// TestInsecure1024BitRS512JWT ensures an insecure 1024-bit RSA key is rejected outright. +// jwx v3 refuses to import RSA keys smaller than 2048 bits, so such a key can never be used to +// sign a JWT nor be loaded into the authorized keys list. This is a stronger guarantee than the +// previous behaviour, where the key was rejected only because it never made it into the in-memory +// authorized keys list (see also authorized_keys_test.go, which rejects the key at that level). func TestInsecure1024BitRS512JWT(t *testing.T) { - // Generate a new test key and jwt serializer - _, serializer, authorizedKey := generateRSATestKey(t, 1024, jwa.RS512) - - // Create a new JWT - token := validJWT(t) - - // Sign and serialize the JWT - serialized, err := serializer.Serialize(token) - require.NoError(t, err) - t.Logf("jwt=%v", string(serialized)) - - // Create the middleware - middleware, err := New(nil, validHostname, []byte(authorizedKey)) + // Generate a new insecure 1024-bit RSA key + priv, err := rsa.GenerateKey(rand.Reader, 1024) require.NoError(t, err) - // Setup the handler such that if the middleware authorizes the request a 200 OK response is set - handler := middleware.Handler(statusOKHandler) - - // Create a test GET request - request, err := http.NewRequest("GET", "/", nil) - require.NoError(t, err) - - // Set the authorization header in the test request - header := fmt.Sprintf("Bearer %v", string(serialized)) - request.Header.Set("Authorization", header) - - // Setup a test context which wraps the test request and records the response - recorder := httptest.NewRecorder() - testCtx := echo.New().NewContext(request, recorder) - - // Call the handler, ensuring no error is returned - err = handler(testCtx) + // Ensure the insecure key cannot be imported into a jwk, which prevents it from ever being used + _, err = jwk.Import(priv) require.Error(t, err) - - // Note that this error may seem a bit strange, but the key is not authorized as 1024-bit RSA keys should never make their way - // into the authorized keys list in memory. This technically doesn't provide the best guarantee as to the reason for failure - // in this unit test but it is the best that can be done at the time and it is likely not desirable for the 1024-bit RSA key - // to be loaded into the list in memory anyways. We take a small chance here but it's an extreme corner case and as long as - // we are careful when modifying this test, this should be sufficient along with the tests in authorized_keys_test.go which - // ensure the keys are rejected at that level. - assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "credential not signed by an authorized key") + assert.Contains(t, err.Error(), "rsa modulus too small") } // TestSecureRS512JWT ensures a JWT signed securely with an RS512 signing algorithm and a 4096-bit RSA key is accepted with a 200 OK response func TestSecureRS512JWT(t *testing.T) { // Generate a new test key and jwt serializer - _, serializer, authorizedKey := generateRSATestKey(t, 4096, jwa.RS512) + _, serializer, authorizedKey := generateRSATestKey(t, 4096, jwa.RS512()) // Create a new JWT token := validJWT(t) @@ -1329,7 +1300,7 @@ func TestSecureRS512JWT(t *testing.T) { // at all. func TestPS256JWT(t *testing.T) { // Generate a new test key and jwt serializer - _, serializer, authorizedKey := generateRSATestKey(t, 4096, jwa.PS256) + _, serializer, authorizedKey := generateRSATestKey(t, 4096, jwa.PS256()) // Create a new JWT token := validJWT(t) @@ -1369,7 +1340,7 @@ func TestPS256JWT(t *testing.T) { // at all. func TestPS384JWT(t *testing.T) { // Generate a new test key and jwt serializer - _, serializer, authorizedKey := generateRSATestKey(t, 4096, jwa.PS384) + _, serializer, authorizedKey := generateRSATestKey(t, 4096, jwa.PS384()) // Create a new JWT token := validJWT(t) @@ -1407,7 +1378,7 @@ func TestPS384JWT(t *testing.T) { // TestSecurePS512JWT ensures a JWT signed securely with an RS512 signing algorithm and a 4096-bit RSA key is accepted with a 200 OK response func TestSecurePS512JWT(t *testing.T) { // Generate a new test key and jwt serializer - _, serializer, authorizedKey := generateRSATestKey(t, 4096, jwa.PS512) + _, serializer, authorizedKey := generateRSATestKey(t, 4096, jwa.PS512()) // Create a new JWT token := validJWT(t) @@ -1598,7 +1569,7 @@ func TestMissingAud(t *testing.T) { // Call the handler, ensuring the appropriate error is returned err = handler(testCtx) require.Error(t, err) - assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "jwt.Validate: claim \"aud\" not found") + assert.Contains(t, err.(*echo.HTTPError).Internal.Error(), "claim \"aud\" does not exist") } // TestMissingIss ensures a JWT with a missing issuer is rejected with 401 Unauthorized diff --git a/http/user/session.go b/http/user/session.go index 41dce47aac..1e81933e7a 100644 --- a/http/user/session.go +++ b/http/user/session.go @@ -26,7 +26,7 @@ import ( "fmt" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/log" diff --git a/http/user/session_test.go b/http/user/session_test.go index 871448cb40..3695402bd8 100644 --- a/http/user/session_test.go +++ b/http/user/session_test.go @@ -24,7 +24,7 @@ import ( "crypto/rand" "encoding/json" "github.com/labstack/echo/v4" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/storage" @@ -264,7 +264,7 @@ func TestMiddleware_createUserSessionCookie(t *testing.T) { func TestUserWallet_Key(t *testing.T) { t.Run("ok", func(t *testing.T) { pk, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - keyAsJWK, err := jwk.FromRaw(pk) + keyAsJWK, err := jwk.Import(pk) require.NoError(t, err) jwkAsJSON, _ := json.Marshal(keyAsJWK) wallet := Wallet{ diff --git a/network/dag/parser.go b/network/dag/parser.go index e1be15758f..1cf3464bba 100644 --- a/network/dag/parser.go +++ b/network/dag/parser.go @@ -23,9 +23,9 @@ import ( "fmt" "time" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" "github.com/nuts-foundation/nuts-node/crypto/hash" ) @@ -77,8 +77,9 @@ type transactionParseStep func(transaction *transaction, headers jws.Headers, me // parseSigningAlgorithm validates whether the signing algorithm is allowed func parseSigningAlgorithm(_ *transaction, headers jws.Headers, _ *jws.Message) error { - if !isAlgoAllowed(headers.Algorithm()) { - return transactionValidationError("signing algorithm not allowed: %s", headers.Algorithm()) + alg, _ := headers.Algorithm() + if !isAlgoAllowed(alg) { + return transactionValidationError("signing algorithm not allowed: %s", alg) } return nil } @@ -95,7 +96,7 @@ func parsePayload(transaction *transaction, _ jws.Headers, message *jws.Message) // parseContentType parses, validates and sets the transaction payload content type. func parseContentType(transaction *transaction, headers jws.Headers, _ *jws.Message) error { - contentType := headers.ContentType() + contentType, _ := headers.ContentType() if !ValidatePayloadType(contentType) { return transactionValidationError("%w", errInvalidPayloadType) } @@ -105,25 +106,27 @@ func parseContentType(transaction *transaction, headers jws.Headers, _ *jws.Mess // parseSignatureParams parses, validates and sets the transaction signing key (`jwk`) or key ID (`kid`). func parseSignatureParams(transaction *transaction, headers jws.Headers, _ *jws.Message) error { - if key, ok := headers.Get(jws.JWKKey); ok { - jwkKey := key.(jwk.Key) + var jwkKey jwk.Key + if err := headers.Get(jws.JWKKey, &jwkKey); err == nil { transaction.signingKey = jwkKey } // Get the keyID from the header (not to be confused with the keyID from the embedded key) - if kid, ok := headers.Get(jws.KeyIDKey); ok { - transaction.signingKeyID = kid.(string) + var kid string + if err := headers.Get(jws.KeyIDKey, &kid); err == nil { + transaction.signingKeyID = kid } // Check RFC004 3.1 kid and jwk constraints if (transaction.signingKey != nil && transaction.signingKeyID != "") || (transaction.signingKey == nil && transaction.signingKeyID == "") { return transactionValidationError("either `kid` or `jwk` header must be present (but not both)") } - transaction.signingAlgorithm = headers.Algorithm() + transaction.signingAlgorithm, _ = headers.Algorithm() return nil } // parseSigningTime parses, validates and sets the transaction signing time. func parseSigningTime(transaction *transaction, headers jws.Headers, _ *jws.Message) error { - if timeAsInterf, ok := headers.Get(signingTimeHeader); !ok { + var timeAsInterf interface{} + if err := headers.Get(signingTimeHeader, &timeAsInterf); err != nil { return transactionValidationError(missingHeaderErrFmt, signingTimeHeader) } else if timeAsFloat64, ok := timeAsInterf.(float64); !ok { return transactionValidationError(invalidHeaderErrFmt, signingTimeHeader) @@ -136,7 +139,8 @@ func parseSigningTime(transaction *transaction, headers jws.Headers, _ *jws.Mess // parseVersion parses, validates and sets the transaction format version. func parseVersion(transaction *transaction, headers jws.Headers, _ *jws.Message) error { var version Version - if versionAsInterf, ok := headers.Get(versionHeader); !ok { + var versionAsInterf interface{} + if err := headers.Get(versionHeader, &versionAsInterf); err != nil { return transactionValidationError(missingHeaderErrFmt, versionHeader) } else if versionAsFloat64, ok := versionAsInterf.(float64); !ok { return transactionValidationError(invalidHeaderErrFmt, versionHeader) @@ -159,7 +163,8 @@ func versionAllowed(version Version) bool { // parsePrevious parses, validates and sets the transaction prevs fields. func parsePrevious(transaction *transaction, headers jws.Headers, _ *jws.Message) error { - if prevsAsInterf, ok := headers.Get(previousHeader); !ok { + var prevsAsInterf interface{} + if err := headers.Get(previousHeader, &prevsAsInterf); err != nil { return transactionValidationError(missingHeaderErrFmt, previousHeader) } else if prevsAsSlice, ok := prevsAsInterf.([]interface{}); !ok { return transactionValidationError(invalidHeaderErrFmt, previousHeader) @@ -178,8 +183,8 @@ func parsePrevious(transaction *transaction, headers jws.Headers, _ *jws.Message } func parsePAL(transaction *transaction, headers jws.Headers, _ *jws.Message) error { - rawPal, ok := headers.Get(palHeader) - if !ok { + var rawPal interface{} + if err := headers.Get(palHeader, &rawPal); err != nil { return nil } palEncoded, ok := rawPal.([]interface{}) @@ -199,7 +204,8 @@ func parsePAL(transaction *transaction, headers jws.Headers, _ *jws.Message) err } func parseLamportClock(transaction *transaction, headers jws.Headers, _ *jws.Message) error { - if lcAsInterf, ok := headers.Get(lamportClockHeader); !ok { + var lcAsInterf interface{} + if err := headers.Get(lamportClockHeader, &lcAsInterf); err != nil { // won't happen since it's a critical header, but we need to check the cast anyway return transactionValidationError(missingHeaderErrFmt, lamportClockHeader) } else if lcAsFloat64, ok := lcAsInterf.(float64); !ok { diff --git a/network/dag/parser_test.go b/network/dag/parser_test.go index 3d84ed5377..bb74a18244 100644 --- a/network/dag/parser_test.go +++ b/network/dag/parser_test.go @@ -26,9 +26,9 @@ import ( "testing" "time" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" "github.com/nuts-foundation/nuts-node/crypto/hash" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -41,13 +41,13 @@ func TestParseTransaction(t *testing.T) { t.Run("v1", func(t *testing.T) { headers := makeJWSHeaders(key, "123", true) _ = headers.Set("pal", []string{base64.StdEncoding.EncodeToString([]byte{5, 6, 7})}) - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) require.NoError(t, err) var actualKey ecdsa.PublicKey - err = transaction.SigningKey().Raw(&actualKey) + err = jwk.Export(transaction.SigningKey(), &actualKey) require.NoError(t, err) assert.NotNil(t, transaction) @@ -58,7 +58,9 @@ func TestParseTransaction(t *testing.T) { assert.Equal(t, 1, int(transaction.Version())) assert.Equal(t, "foo/bar", transaction.PayloadType()) assert.Equal(t, time.UTC, transaction.SigningTime().Location()) - assert.Equal(t, headers.PrivateParams()[previousHeader].([]string)[0], transaction.Previous()[0].String()) + var previousHeaderValue []string + _ = headers.Get(previousHeader, &previousHeaderValue) + assert.Equal(t, previousHeaderValue[0], transaction.Previous()[0].String()) assert.Equal(t, transaction.PAL(), [][]byte{{5, 6, 7}}) assert.NotNil(t, transaction.Data()) assert.False(t, transaction.Ref().Empty()) @@ -68,13 +70,13 @@ func TestParseTransaction(t *testing.T) { _ = headers.Set("pal", []string{base64.StdEncoding.EncodeToString([]byte{5, 6, 7})}) _ = headers.Set(versionHeader, 2) _ = headers.Set(jws.CriticalKey, []string{signingTimeHeader, versionHeader, previousHeader, lamportClockHeader}) - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) require.NoError(t, err) var actualKey ecdsa.PublicKey - err = transaction.SigningKey().Raw(&actualKey) + err = jwk.Export(transaction.SigningKey(), &actualKey) require.NoError(t, err) assert.NotNil(t, transaction) @@ -85,7 +87,9 @@ func TestParseTransaction(t *testing.T) { assert.Equal(t, 2, int(transaction.Version())) assert.Equal(t, "foo/bar", transaction.PayloadType()) assert.Equal(t, time.UTC, transaction.SigningTime().Location()) - assert.Equal(t, headers.PrivateParams()[previousHeader].([]string)[0], transaction.Previous()[0].String()) + var previousHeaderValue []string + _ = headers.Get(previousHeader, &previousHeaderValue) + assert.Equal(t, previousHeaderValue[0], transaction.Previous()[0].String()) assert.Equal(t, transaction.PAL(), [][]byte{{5, 6, 7}}) assert.NotNil(t, transaction.Data()) assert.False(t, transaction.Ref().Empty()) @@ -93,18 +97,18 @@ func TestParseTransaction(t *testing.T) { t.Run("error - input not a JWS (compact serialization format)", func(t *testing.T) { tx, err := ParseTransaction([]byte("not a JWS")) assert.Nil(t, tx) - assert.EqualError(t, err, "unable to parse transaction: invalid compact serialization format: invalid number of segments") + assert.EqualError(t, err, "unable to parse transaction: jws.Parse: failed to parse compact format: jws.Parse: invalid compact serialization format: jwsbb: invalid number of segments") }) t.Run("error - input not a JWS (JSON serialization format)", func(t *testing.T) { tx, err := ParseTransaction([]byte("{}")) assert.Nil(t, tx) - assert.EqualError(t, err, "unable to parse transaction: failed to unmarshal jws message: required field \"signatures\" not present") + assert.EqualError(t, err, "unable to parse transaction: jws.Parse: failed to parse JSON format: failed to unmarshal jws message: required field \"signatures\" not present") }) t.Run("error - pal header has invalid type", func(t *testing.T) { headers := makeJWSHeaders(key, "123", false) _ = headers.Set("pal", 100) - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -113,8 +117,8 @@ func TestParseTransaction(t *testing.T) { }) t.Run("error - sigt header is missing", func(t *testing.T) { headers := makeJWSHeaders(key, "123", false) - delete(headers.PrivateParams(), signingTimeHeader) - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + _ = headers.Remove(signingTimeHeader) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -124,7 +128,7 @@ func TestParseTransaction(t *testing.T) { t.Run("error - invalid sigt header", func(t *testing.T) { headers := makeJWSHeaders(key, "123", false) headers.Set(signingTimeHeader, "not a date") - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -133,8 +137,8 @@ func TestParseTransaction(t *testing.T) { }) t.Run("error - vers header is missing", func(t *testing.T) { headers := makeJWSHeaders(key, "123", false) - delete(headers.PrivateParams(), versionHeader) - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + _ = headers.Remove(versionHeader) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -144,7 +148,7 @@ func TestParseTransaction(t *testing.T) { t.Run("error - both jwk and kid set", func(t *testing.T) { headers := makeJWSHeaders(key, "1234", true) headers.Set(jwk.KeyIDKey, "123") - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -153,7 +157,7 @@ func TestParseTransaction(t *testing.T) { }) t.Run("error - jwk/kid both not set", func(t *testing.T) { headers := makeJWSHeaders(nil, "", false) - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -162,8 +166,8 @@ func TestParseTransaction(t *testing.T) { }) t.Run("error - prevs header is missing", func(t *testing.T) { headers := makeJWSHeaders(key, "123", true) - delete(headers.PrivateParams(), previousHeader) - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + _ = headers.Remove(previousHeader) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -173,7 +177,7 @@ func TestParseTransaction(t *testing.T) { t.Run("error - invalid prevs (not an array)", func(t *testing.T) { headers := makeJWSHeaders(key, "123", true) headers.Set(previousHeader, 2) - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -183,7 +187,7 @@ func TestParseTransaction(t *testing.T) { t.Run("error - invalid prevs (invalid entry)", func(t *testing.T) { headers := makeJWSHeaders(key, "123", true) headers.Set(previousHeader, []string{"not a hash"}) - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -193,7 +197,7 @@ func TestParseTransaction(t *testing.T) { t.Run("error - invalid prevs (invalid entry, not a string)", func(t *testing.T) { headers := makeJWSHeaders(key, "123", true) headers.Set(previousHeader, []int{5}) - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -203,7 +207,7 @@ func TestParseTransaction(t *testing.T) { t.Run("error - cty header is invalid", func(t *testing.T) { headers := makeJWSHeaders(key, "", false) headers.Set(jws.ContentTypeKey, "") - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -213,7 +217,7 @@ func TestParseTransaction(t *testing.T) { t.Run("error - invalid version", func(t *testing.T) { headers := makeJWSHeaders(key, "123", true) headers.Set(versionHeader, "foobar") - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -223,7 +227,7 @@ func TestParseTransaction(t *testing.T) { t.Run("error - unsupported version", func(t *testing.T) { headers := makeJWSHeaders(key, "123", true) headers.Set(versionHeader, 3) - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -233,8 +237,8 @@ func TestParseTransaction(t *testing.T) { t.Run("error - invalid algorithm", func(t *testing.T) { key := generateRSAKey() headers := makeJWSHeaders(key, "", false) - headers.Set(jws.AlgorithmKey, jwa.RS256) - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + headers.Set(jws.AlgorithmKey, jwa.RS256()) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -244,7 +248,7 @@ func TestParseTransaction(t *testing.T) { t.Run("error - invalid lamport clock", func(t *testing.T) { headers := makeJWSHeaders(key, "123", true) headers.Set(lamportClockHeader, "a") - signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign(payloadAsBytes, jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -253,7 +257,7 @@ func TestParseTransaction(t *testing.T) { }) t.Run("error - invalid payload", func(t *testing.T) { headers := makeJWSHeaders(key, "123", true) - signature, _ := jws.Sign([]byte("not a valid hash"), jws.WithKey(headers.Algorithm(), key, jws.WithProtectedHeaders(headers))) + signature, _ := jws.Sign([]byte("not a valid hash"), jws.WithKey(algOf(headers), key, jws.WithProtectedHeaders(headers))) transaction, err := ParseTransaction(signature) @@ -262,10 +266,15 @@ func TestParseTransaction(t *testing.T) { }) } +func algOf(headers jws.Headers) jwa.SignatureAlgorithm { + alg, _ := headers.Algorithm() + return alg +} + func makeJWSHeaders(key crypto.Signer, kid string, embedKey bool) jws.Headers { prev, _ := hash.ParseHex("bedcd5bfb50af622be56c4aec7ac5da64745686b362afc7e615ea89b0705b8f8") headerMap := map[string]interface{}{ - jws.AlgorithmKey: jwa.ES256, + jws.AlgorithmKey: jwa.ES256(), jws.ContentTypeKey: "foo/bar", jws.CriticalKey: []string{signingTimeHeader, versionHeader, previousHeader, lamportClockHeader}, lamportClockHeader: 0, @@ -274,7 +283,7 @@ func makeJWSHeaders(key crypto.Signer, kid string, embedKey bool) jws.Headers { previousHeader: []string{prev.String()}, } if embedKey { - keyAsJWS, _ := jwk.FromRaw(key.Public()) + keyAsJWS, _ := jwk.Import(key.Public()) keyAsJWS.Set(jwk.KeyIDKey, kid) headerMap[jws.JWKKey] = keyAsJWS } else { diff --git a/network/dag/signing.go b/network/dag/signing.go index 717315bd0b..a623b0469e 100644 --- a/network/dag/signing.go +++ b/network/dag/signing.go @@ -25,8 +25,8 @@ import ( "fmt" "time" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" ) @@ -67,7 +67,7 @@ func (d transactionSigner) Sign(ctx context.Context, input UnsignedTransaction, var key jwk.Key var err error if d.key != nil { - key, err = jwk.FromRaw(d.key) + key, err = jwk.Import(d.key) if err != nil { return nil, fmt.Errorf(errSigningTransactionFmt, err) } diff --git a/network/dag/signing_test.go b/network/dag/signing_test.go index bc886031bf..5883391a0f 100644 --- a/network/dag/signing_test.go +++ b/network/dag/signing_test.go @@ -19,7 +19,7 @@ package dag import ( - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/nuts-node/audit" "github.com/stretchr/testify/require" "testing" diff --git a/network/dag/test.go b/network/dag/test.go index 478c112e48..7e5eab7277 100644 --- a/network/dag/test.go +++ b/network/dag/test.go @@ -23,7 +23,7 @@ import ( "crypto" "encoding/binary" "fmt" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/audit" "path" @@ -70,7 +70,7 @@ func CreateSignedTestTransaction(payloadNum uint32, signingTime time.Time, pal [ func jwkToCryptoPublicKey(jwkKey jwk.Key) crypto.PublicKey { jwkPublicKey, _ := jwkKey.PublicKey() var rawKey interface{} - if err := jwkPublicKey.Raw(&rawKey); err != nil { + if err := jwk.Export(jwkPublicKey, &rawKey); err != nil { panic(err) } diff --git a/network/dag/transaction.go b/network/dag/transaction.go index 39cdeaf0d1..55debee866 100644 --- a/network/dag/transaction.go +++ b/network/dag/transaction.go @@ -25,8 +25,8 @@ import ( "strings" "time" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/nuts-node/crypto/hash" ) @@ -45,7 +45,7 @@ const ( lamportClockHeader = "lc" ) -var allowedAlgos = []jwa.SignatureAlgorithm{jwa.ES256, jwa.ES384, jwa.ES512, jwa.PS256, jwa.PS384, jwa.PS512} +var allowedAlgos = []jwa.SignatureAlgorithm{jwa.ES256(), jwa.ES384(), jwa.ES512(), jwa.PS256(), jwa.PS384(), jwa.PS512()} var errInvalidPayloadType = errors.New("payload type must be formatted as MIME type") var errInvalidPrevs = errors.New("prevs contains an empty hash") diff --git a/network/dag/verifier.go b/network/dag/verifier.go index 1de2887cc5..3950610e4d 100644 --- a/network/dag/verifier.go +++ b/network/dag/verifier.go @@ -25,8 +25,9 @@ import ( "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" ) // ErrPreviousTransactionMissing indicates one or more of the previous transactions (which the transaction refers to) @@ -45,7 +46,7 @@ func NewTransactionSignatureVerifier(resolver resolver.NutsKeyResolver) Verifier return func(_ stoabs.ReadTx, transaction Transaction) error { var signingKey crypto2.PublicKey if transaction.SigningKey() != nil { - if err := transaction.SigningKey().Raw(&signingKey); err != nil { + if err := jwk.Export(transaction.SigningKey(), &signingKey); err != nil { return err } } else { @@ -57,7 +58,11 @@ func NewTransactionSignatureVerifier(resolver resolver.NutsKeyResolver) Verifier } // TODO: jws.Verify parses the JWS again, which we already did when parsing the transaction. If we want to optimize // this we need to implement a custom verifier. - _, err := jws.Verify(transaction.Data(), jws.WithKey(jwa.SignatureAlgorithm(transaction.SigningAlgorithm()), signingKey)) + alg, ok := jwa.LookupSignatureAlgorithm(transaction.SigningAlgorithm()) + if !ok { + return fmt.Errorf("unsupported signing algorithm: %s", transaction.SigningAlgorithm()) + } + _, err := jws.Verify(transaction.Data(), jws.WithKey(alg, signingKey)) return err } } diff --git a/network/dag/verifier_test.go b/network/dag/verifier_test.go index feda14b7ae..af6fbddd13 100644 --- a/network/dag/verifier_test.go +++ b/network/dag/verifier_test.go @@ -31,7 +31,7 @@ import ( "testing" "time" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/go-stoabs" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/crypto/hash" @@ -111,14 +111,14 @@ func TestTransactionSignatureVerifier(t *testing.T) { transaction, _, _ := CreateTestTransaction(1) expected, _ := ParseTransaction(transaction.Data()) err := NewTransactionSignatureVerifier(&staticKeyResolver{Key: attackerKey.Public()})(nil, expected) - assert.EqualError(t, err, "could not verify message using any of the signatures or keys") + assert.EqualError(t, err, "jws.Verify: could not verify message using any of the signatures or keys: jws.Verify: failed to verify signature #1 with key *ecdsa.PublicKey: invalid ECDSA signature\njws.Verify: signature #1: tried 1 key(s) but none verified successfully") }) t.Run("key type is incorrect", func(t *testing.T) { d, _, _ := CreateTestTransaction(1) tx := d.(*transaction) - tx.signingKey, _ = jwk.FromRaw([]byte("01234567890123456789012345678901234567890123456789ABCDEF")) + tx.signingKey, _ = jwk.Import([]byte("01234567890123456789012345678901234567890123456789ABCDEF")) err := NewTransactionSignatureVerifier(nil)(nil, tx) - assert.EqualError(t, err, "could not verify message using any of the signatures or keys") + assert.EqualError(t, err, "jwk.Export: no suitable exporter found for key type '*jwk.symmetricKey'") }) t.Run("unable to resolve key by hash", func(t *testing.T) { d := CreateSignedTestTransaction(1, time.Now(), nil, "foo/bar", false) diff --git a/network/transport/v2/transactionlist_handler_test.go b/network/transport/v2/transactionlist_handler_test.go index 0971900cbc..a3df827136 100644 --- a/network/transport/v2/transactionlist_handler_test.go +++ b/network/transport/v2/transactionlist_handler_test.go @@ -195,7 +195,7 @@ func TestProtocol_handleTransactionList(t *testing.T) { }, }}) - assert.EqualError(t, err, "received transaction is invalid: unable to parse transaction: invalid compact serialization format: invalid number of segments") + assert.EqualError(t, err, "received transaction is invalid: unable to parse transaction: jws.Parse: failed to parse compact format: jws.Parse: invalid compact serialization format: jwsbb: invalid number of segments") }) t.Run("error - unknown conversationID", func(t *testing.T) { diff --git a/pki/denylist.go b/pki/denylist.go index e1a3039fb1..548572eb2f 100644 --- a/pki/denylist.go +++ b/pki/denylist.go @@ -29,9 +29,9 @@ import ( "sync/atomic" "time" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" ) // denylistImpl implements arbitrary certificate rejection using issuer and serial number tuples @@ -182,7 +182,7 @@ func (b *denylistImpl) Update() error { } // Check the signature of the denylist - payload, err := jws.Verify(bytes, jws.WithKey(jwa.EdDSA, b.trustedKey)) + payload, err := jws.Verify(bytes, jws.WithKey(jwa.EdDSA(), b.trustedKey)) if err != nil { return fmt.Errorf("failed to verify denylist signature: %w", err) } @@ -251,11 +251,11 @@ func certKeyJWKThumbprint(cert *x509.Certificate) string { // Compute the fingerprint of the key _ = jwk.AssignKeyID(key) - // Retrieve the fingerprint, which annoyingly is an "any" return type - fingerprint, _ := key.Get(jwk.KeyIDKey) + // Retrieve the fingerprint + var fingerprint string + _ = key.Get(jwk.KeyIDKey, &fingerprint) - // Use fmt to "convert" the fingerprint to a string - return fmt.Sprintf("%s", fingerprint) + return fingerprint } // If something above failed, default to an empty string. This would happen if the JWK library could not diff --git a/pki/denylist_test.go b/pki/denylist_test.go index 2b6e9bea27..00fbf4c340 100644 --- a/pki/denylist_test.go +++ b/pki/denylist_test.go @@ -31,9 +31,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" ) // Do not use this public key for anything other than unit tests in denylist_test.go @@ -179,7 +179,7 @@ func encodeDenylist(t *testing.T, entries []denylistEntry) string { require.NoError(t, err) // Sign the denylist as a JWS Message - compactJWS, err := jws.Sign(payload, jws.WithKey(jwa.EdDSA, key)) + compactJWS, err := jws.Sign(payload, jws.WithKey(jwa.EdDSA(), key)) require.NoError(t, err) // Return the compact encoded JWS message @@ -201,7 +201,7 @@ func TestNewDenylist(t *testing.T) { URL: "example.com", TrustedSigner: "definitely not valid", }) - assert.EqualError(t, err, "failed to parse key: failed to parse PEM encoded key: failed to decode PEM data") + assert.EqualError(t, err, "failed to parse key: jwk.ParseKey: failed to decode X.509 encoded key: failed to decode PEM data") }) } @@ -255,7 +255,7 @@ func TestDenylistMissing(t *testing.T) { // Sign an invalid denylist as a JWS Message payload := []byte("invalid payload") - compactJWS, err := jws.Sign(payload, jws.WithKey(jwa.EdDSA, key)) + compactJWS, err := jws.Sign(payload, jws.WithKey(jwa.EdDSA(), key)) require.NoError(t, err) // Setup a denylist server diff --git a/test/pki/test.go b/test/pki/test.go index fd0ae4ba08..f8a9dda3e4 100644 --- a/test/pki/test.go +++ b/test/pki/test.go @@ -27,7 +27,7 @@ import ( _ "embed" "encoding/asn1" "encoding/base64" - "github.com/lestrrat-go/jwx/v2/cert" + "github.com/lestrrat-go/jwx/v3/cert" "github.com/nuts-foundation/nuts-node/test/io" "math/big" "net" diff --git a/vcr/credential/resolver_test.go b/vcr/credential/resolver_test.go index f2fb94f18a..504da44bd8 100644 --- a/vcr/credential/resolver_test.go +++ b/vcr/credential/resolver_test.go @@ -23,9 +23,9 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/nuts-foundation/nuts-node/vcr/test" @@ -73,7 +73,7 @@ func TestPresentationSigner(t *testing.T) { t.Run("ok", func(t *testing.T) { headers := jws.NewHeaders() headers.Set(jws.KeyIDKey, keyID.String()) - signedToken, err := jwt.Sign(jwt.New(), jwt.WithKey(jwa.ES256, privateKey, jws.WithProtectedHeaders(headers))) + signedToken, err := jwt.Sign(jwt.New(), jwt.WithKey(jwa.ES256(), privateKey, jws.WithProtectedHeaders(headers))) require.NoError(t, err) presentation, err := vc.ParseVerifiablePresentation(string(signedToken)) require.NoError(t, err) @@ -83,7 +83,7 @@ func TestPresentationSigner(t *testing.T) { assert.Equal(t, keyID.DID, *actual) }) t.Run("no kid header", func(t *testing.T) { - signedToken, err := jwt.Sign(jwt.New(), jwt.WithKey(jwa.ES256, privateKey)) + signedToken, err := jwt.Sign(jwt.New(), jwt.WithKey(jwa.ES256(), privateKey)) require.NoError(t, err) presentation, err := vc.ParseVerifiablePresentation(string(signedToken)) require.NoError(t, err) @@ -96,7 +96,7 @@ func TestPresentationSigner(t *testing.T) { t.Run("kid is not a did", func(t *testing.T) { headers := jws.NewHeaders() require.NoError(t, headers.Set(jws.KeyIDKey, "not a did")) - signedToken, err := jwt.Sign(jwt.New(), jwt.WithKey(jwa.ES256, privateKey, jws.WithProtectedHeaders(headers))) + signedToken, err := jwt.Sign(jwt.New(), jwt.WithKey(jwa.ES256(), privateKey, jws.WithProtectedHeaders(headers))) require.NoError(t, err) presentation, err := vc.ParseVerifiablePresentation(string(signedToken)) require.NoError(t, err) diff --git a/vcr/credential/util.go b/vcr/credential/util.go index 41643548f7..a24b8f4b51 100644 --- a/vcr/credential/util.go +++ b/vcr/credential/util.go @@ -74,8 +74,9 @@ func PresentationIssuanceDate(presentation vc.VerifiablePresentation) *time.Time switch presentation.Format() { case vc.JWTPresentationProofFormat: jwt := presentation.JWT() - if result = jwt.NotBefore(); result.IsZero() { - result = jwt.IssuedAt() + nbf, _ := jwt.NotBefore() + if result = nbf; result.IsZero() { + result, _ = jwt.IssuedAt() } case vc.JSONLDPresentationProofFormat: ldProof, err := ParseLDProof(presentation) @@ -98,7 +99,7 @@ func PresentationExpirationDate(presentation vc.VerifiablePresentation) *time.Ti var result time.Time switch presentation.Format() { case vc.JWTPresentationProofFormat: - result = presentation.JWT().Expiration() + result, _ = presentation.JWT().Expiration() case vc.JSONLDPresentationProofFormat: ldProof, err := ParseLDProof(presentation) if err != nil || ldProof.Expires == nil { diff --git a/vcr/credential/util_test.go b/vcr/credential/util_test.go index c84ccafa6d..06d016c7fb 100644 --- a/vcr/credential/util_test.go +++ b/vcr/credential/util_test.go @@ -19,7 +19,7 @@ package credential import ( - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" diff --git a/vcr/credential/validator.go b/vcr/credential/validator.go index ac2b481dae..8ae00f2cab 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -25,7 +25,7 @@ import ( "encoding/json" "errors" "fmt" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/crypto" diff --git a/vcr/credential/validator_test.go b/vcr/credential/validator_test.go index acc926e757..a6c2659fe7 100644 --- a/vcr/credential/validator_test.go +++ b/vcr/credential/validator_test.go @@ -25,7 +25,7 @@ import ( "testing" "time" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -575,12 +575,17 @@ func TestX509CredentialValidator_Validate(t *testing.T) { x509credential := test.ValidX509Credential(t, func(builder *jwt.Builder) *jwt.Builder { // Build new jwt.Builder without expiration token, _ := builder.Build() - vc, _ := token.Get("vc") + var vc interface{} + _ = token.Get("vc", &vc) + nbf, _ := token.NotBefore() + sub, _ := token.Subject() + iss, _ := token.Issuer() + jti, _ := token.JwtID() return jwt.NewBuilder(). - NotBefore(token.NotBefore()). - Subject(token.Subject()). - Issuer(token.Issuer()). - JwtID(token.JwtID()). + NotBefore(nbf). + Subject(sub). + Issuer(iss). + JwtID(jti). Claim("vc", vc) }) diff --git a/vcr/holder/presenter_test.go b/vcr/holder/presenter_test.go index 3c65a77e54..899945b428 100644 --- a/vcr/holder/presenter_test.go +++ b/vcr/holder/presenter_test.go @@ -161,7 +161,8 @@ func TestPresenter_buildPresentation(t *testing.T) { assert.NotEmpty(t, result.ID.Fragment, "id must have a fragment") assert.Equal(t, JWTPresentationFormat, result.Format()) assert.NotNil(t, result.JWT()) - nonce, _ := result.JWT().Get("nonce") + var nonce interface{} + _ = result.JWT().Get("nonce", &nonce) assert.Empty(t, nonce) t.Run("#3957: Verifiable Presentation type is marshalled incorrectly in JWT format", func(t *testing.T) { @@ -175,7 +176,9 @@ func TestPresenter_buildPresentation(t *testing.T) { assert.Contains(t, string(data), `"type":"VerifiablePresentation"`) }) }) - vpAsMap := result.JWT().PrivateClaims()["vp"].(map[string]any) + var vpClaim interface{} + _ = result.JWT().Get("vp", &vpClaim) + vpAsMap := vpClaim.(map[string]any) t.Run("make sure type now marshals as array", func(t *testing.T) { typeProp := vpAsMap["type"].([]any) assert.Equal(t, []any{"VerifiablePresentation"}, typeProp) @@ -204,8 +207,11 @@ func TestPresenter_buildPresentation(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - assert.Equal(t, testDID.String(), result.JWT().Issuer(), "holder must be carried in the iss claim") - vpAsMap := result.JWT().PrivateClaims()["vp"].(map[string]any) + iss, _ := result.JWT().Issuer() + assert.Equal(t, testDID.String(), iss, "holder must be carried in the iss claim") + var vpClaim interface{} + _ = result.JWT().Get("vp", &vpClaim) + vpAsMap := vpClaim.(map[string]any) assert.NotContains(t, vpAsMap, "holder", "non-standard vp.holder must not be set") }) @@ -255,12 +261,17 @@ func TestPresenter_buildPresentation(t *testing.T) { require.NotNil(t, result) assert.Equal(t, JWTPresentationFormat, result.Format()) assert.NotNil(t, result.JWT()) - assert.Equal(t, *options.ProofOptions.Expires, result.JWT().Expiration().Local()) - assert.Equal(t, options.ProofOptions.Created, result.JWT().NotBefore().Local()) - assert.Equal(t, []string{domain}, result.JWT().Audience()) - actualNonce, _ := result.JWT().Get("nonce") + expiration, _ := result.JWT().Expiration() + assert.Equal(t, *options.ProofOptions.Expires, expiration.Local()) + notBefore, _ := result.JWT().NotBefore() + assert.Equal(t, options.ProofOptions.Created, notBefore.Local()) + audience, _ := result.JWT().Audience() + assert.Equal(t, []string{domain}, audience) + var actualNonce interface{} + _ = result.JWT().Get("nonce", &actualNonce) assert.Equal(t, nonce, actualNonce) - actualCustomClaim, _ := result.JWT().Get("custom") + var actualCustomClaim interface{} + _ = result.JWT().Get("custom", &actualCustomClaim) assert.Equal(t, "claim", actualCustomClaim) }) }) diff --git a/vcr/issuer/issuer_test.go b/vcr/issuer/issuer_test.go index 2cfc4d2272..36a038fea9 100644 --- a/vcr/issuer/issuer_test.go +++ b/vcr/issuer/issuer_test.go @@ -143,10 +143,14 @@ func Test_issuer_buildAndSignVC(t *testing.T) { assert.Empty(t, result.Proof) // Assert JWT require.NotNil(t, result.JWT()) - assert.Equal(t, subjectDID, result.JWT().Subject()) - assert.Equal(t, result.IssuanceDate, result.JWT().NotBefore()) - assert.Equal(t, *result.ExpirationDate, result.JWT().Expiration()) - assert.Equal(t, result.ID.String(), result.JWT().JwtID()) + subject, _ := result.JWT().Subject() + assert.Equal(t, subjectDID, subject) + notBefore, _ := result.JWT().NotBefore() + assert.Equal(t, result.IssuanceDate, notBefore) + expiration, _ := result.JWT().Expiration() + assert.Equal(t, *result.ExpirationDate, expiration) + jwtID, _ := result.JWT().JwtID() + assert.Equal(t, result.ID.String(), jwtID) }) }) t.Run("credentialStatus", func(t *testing.T) { diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index c221083194..7af39befda 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -25,7 +25,7 @@ import ( "errors" "fmt" "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" @@ -333,8 +333,9 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o } // Validate audience + audience, _ := token.Audience() audienceMatches := false - for _, aud := range token.Audience() { + for _, aud := range audience { if aud == i.issuerIdentifierURL { audienceMatches = true break @@ -342,15 +343,15 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o } if !audienceMatches { return generateProofError(openid4vci.Error{ - Err: fmt.Errorf("audience doesn't match credential issuer (aud=%s)", token.Audience()), + Err: fmt.Errorf("audience doesn't match credential issuer (aud=%s)", audience), Code: openid4vci.InvalidProof, StatusCode: http.StatusBadRequest, }) } // given the JWT typ, the nonce is in the 'nonce' claim - nonce, ok := token.Get("nonce") - if !ok { + var nonce string + if err := token.Get("nonce", &nonce); err != nil { return generateProofError(openid4vci.Error{ Err: errors.New("missing nonce claim"), Code: openid4vci.InvalidProof, @@ -359,7 +360,7 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o } // check if the nonce matches the one we sent in the offer - flowFromNonce, err := i.store.FindByReference(ctx, cNonceRefType, nonce.(string)) + flowFromNonce, err := i.store.FindByReference(ctx, cNonceRefType, nonce) if err != nil { return err } diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index f204463f13..2610e332fa 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -22,8 +22,7 @@ import ( "encoding/json" "errors" "fmt" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v3/jws" v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2" "strings" @@ -350,7 +349,7 @@ func matchFormat(format *PresentationDefinitionClaimFormatDesignations, credenti case vc.JWTCredentialProofFormat: // Get signing algorithm used to sign the JWT message, _ := jws.ParseString(credential.Raw()) // can't really fail, JWT has been parsed before. - signingAlgorithm, _ := message.Signatures()[0].ProtectedHeaders().Get(jws.AlgorithmKey) + signingAlgorithm, _ := message.Signatures()[0].ProtectedHeaders().Algorithm() // Check that the signing algorithm is specified by the presentation definition if entry := asMap[vc.JWTCredentialProofFormat]; entry != nil { if len(message.Signatures()[0].Signature()) == 0 { @@ -359,7 +358,7 @@ func matchFormat(format *PresentationDefinitionClaimFormatDesignations, credenti } if supportedAlgorithms := entry[jws.AlgorithmKey]; supportedAlgorithms != nil { for _, supportedAlgorithm := range supportedAlgorithms { - if signingAlgorithm == jwa.SignatureAlgorithm(supportedAlgorithm) { + if signingAlgorithm.String() == supportedAlgorithm { return true } } diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index 0d8e19068d..71b64b8069 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -23,6 +23,7 @@ import ( "crypto/elliptic" "crypto/rand" "embed" + "encoding/base64" "encoding/json" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core/to" @@ -30,8 +31,8 @@ import ( "strings" "testing" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/vcr/pe/test" @@ -221,7 +222,7 @@ func TestMatch(t *testing.T) { t.Run("unsupported JOSE alg", func(t *testing.T) { token := jwt.New() privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, privateKey)) + signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateKey)) require.NoError(t, err) jwtCredential, err := vc.ParseVerifiableCredential(string(signedToken)) require.NoError(t, err) @@ -547,8 +548,13 @@ txJy6M1-lD7a5HTzanYTWBPAUHDZGyGKXdJw-W_x0IWChBzI8t3kpG253fg6V3tPgHeKXE94fz_QpYfg assert.False(t, match) }) t.Run("no proof in credential (self-attested)", func(t *testing.T) { + // A self-attested credential carries no signature. In jwx v3 an empty compact + // signature is only valid when the protected header declares "alg":"none", so + // build the unsigned credential with a none-alg header (jwx v2 tolerated dropping + // the signature from a signed header, jwx v3 does not). jwtCredNoProof := strings.Split(jwtCredential, ".") - credNoProof, err := vc.ParseVerifiableCredential(jwtCredNoProof[0] + "." + jwtCredNoProof[1] + ".") + noneHeader := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + credNoProof, err := vc.ParseVerifiableCredential(noneHeader + "." + jwtCredNoProof[1] + ".") require.NoError(t, err) match := matchFormat(&PresentationDefinitionClaimFormatDesignations{ "jwt_vc": {"alg": {"ES521"}}, diff --git a/vcr/pe/util.go b/vcr/pe/util.go index 2c8fd21ab1..41cb2bd645 100644 --- a/vcr/pe/util.go +++ b/vcr/pe/util.go @@ -21,7 +21,7 @@ package pe import ( "encoding/json" "fmt" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/vc" ) @@ -165,11 +165,13 @@ func vpAsInterface(presentation vc.VerifiablePresentation) (interface{}, error) } asMap := make(map[string]interface{}) // use the 'vp' claim as base Verifiable Presentation properties - innerVPAsMap, _ := token.PrivateClaims()["vp"].(map[string]interface{}) + var innerVP interface{} + _ = token.Get("vp", &innerVP) + innerVPAsMap, _ := innerVP.(map[string]interface{}) for key, value := range innerVPAsMap { asMap[key] = value } - if jti, ok := token.Get(jwt.JwtIDKey); ok { + if jti, ok := token.JwtID(); ok { asMap["id"] = jti } return asMap, nil diff --git a/vcr/pe/util_test.go b/vcr/pe/util_test.go index 999df47fac..d9579dd0e9 100644 --- a/vcr/pe/util_test.go +++ b/vcr/pe/util_test.go @@ -37,7 +37,7 @@ func TestParseEnvelope(t *testing.T) { }) t.Run("invalid JWT", func(t *testing.T) { envelope, err := ParseEnvelope([]byte(`eyINVALID`)) - assert.EqualError(t, err, "unable to parse PEX envelope as verifiable presentation: invalid JWT") + assert.EqualError(t, err, "unable to parse PEX envelope as verifiable presentation: jwt.Parse: failed to parse token: unknown payload type (payload is not JWT?)") assert.Nil(t, envelope) }) t.Run("JSON object", func(t *testing.T) { @@ -73,7 +73,7 @@ func TestParseEnvelope(t *testing.T) { }) t.Run("invalid format", func(t *testing.T) { envelope, err := ParseEnvelope([]byte(`true`)) - assert.EqualError(t, err, "unable to parse PEX envelope as verifiable presentation: invalid JWT") + assert.EqualError(t, err, "unable to parse PEX envelope as verifiable presentation: jwt.Parse: failed to parse token: unknown payload type (payload is not JWT?)") assert.Nil(t, envelope) }) } diff --git a/vcr/signature/json_web_signature.go b/vcr/signature/json_web_signature.go index b3eeceaed0..8d3bcc2033 100644 --- a/vcr/signature/json_web_signature.go +++ b/vcr/signature/json_web_signature.go @@ -21,7 +21,7 @@ package signature import ( "context" "fmt" - "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v3/jws" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/crypto/hash" diff --git a/vcr/signature/json_web_signature_test.go b/vcr/signature/json_web_signature_test.go index 541b4be01d..6f5591dde3 100644 --- a/vcr/signature/json_web_signature_test.go +++ b/vcr/signature/json_web_signature_test.go @@ -19,10 +19,9 @@ package signature import ( - "context" "encoding/hex" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jws" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/crypto" @@ -133,12 +132,18 @@ func TestJsonWebSignature2020_Sign(t *testing.T) { assert.Empty(t, msg.Payload(), "payload should be empty (detached)") require.Len(t, msg.Signatures(), 1) expectedHeaders := map[string]interface{}{ - "alg": jwa.ES256, + "alg": jwa.ES256(), "b64": false, "crit": []string{"b64"}, "kid": keyID, } - actualHeaders, _ := msg.Signatures()[0].ProtectedHeaders().AsMap(context.Background()) + protected := msg.Signatures()[0].ProtectedHeaders() + actualHeaders := make(map[string]interface{}) + for _, k := range protected.Keys() { + var v interface{} + _ = protected.Get(k, &v) + actualHeaders[k] = v + } assert.Equal(t, expectedHeaders, actualHeaders) }) } diff --git a/vcr/signature/proof/jsonld.go b/vcr/signature/proof/jsonld.go index 93f78a1fae..2b607fc641 100644 --- a/vcr/signature/proof/jsonld.go +++ b/vcr/signature/proof/jsonld.go @@ -29,7 +29,7 @@ import ( "strings" "time" - "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v3/jws/jwsbb" ssi "github.com/nuts-foundation/go-did" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vcr/signature" @@ -131,7 +131,6 @@ func (p LDProof) Verify(document Document, suite signature.Suite, key crypto.Pub return err } - jswVerifier, _ := jws.NewVerifier(alg) // the jws lib can't do this for us, so we concat hdr with payload for verification splittedJws := strings.Split(p.JWS, "..") if len(splittedJws) != 2 { @@ -142,7 +141,7 @@ func (p LDProof) Verify(document Document, suite signature.Suite, key crypto.Pub return fmt.Errorf("could not base64 decode signature: %w", err) } challenge := fmt.Sprintf("%s.%s", splittedJws[0], tbv) - if err = jswVerifier.Verify([]byte(challenge), sig, key); err != nil { + if err = jwsbb.Verify(key, alg.String(), []byte(challenge), sig); err != nil { return fmt.Errorf("invalid proof signature: %w", err) } return nil diff --git a/vcr/signature/proof/jsonld_test.go b/vcr/signature/proof/jsonld_test.go index 04ed3a940f..cc4d807e9d 100644 --- a/vcr/signature/proof/jsonld_test.go +++ b/vcr/signature/proof/jsonld_test.go @@ -153,7 +153,7 @@ func TestLDProof_Verify(t *testing.T) { // signature with some changed characters ldProof.JWS = "eyJhbGciOiJFZERTQSIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..MJ5GwWRMsadCyLNXU_flgJtsS32584MydBxBuyups_cM0sbU3abTEOMyUvmLNcKOwOBE1MfDoB1_YY425W3sAg" err = ldProof.Verify(signedDocument.DocumentWithoutProof(), signature.JSONWebSignature2020{ContextLoader: contextLoader}, pk) - assert.EqualError(t, err, "invalid proof signature: failed to match EdDSA signature") + assert.EqualError(t, err, "invalid proof signature: invalid EdDSA signature") }) } diff --git a/vcr/store_test.go b/vcr/store_test.go index ebdb9fc0b9..d6e10048f1 100644 --- a/vcr/store_test.go +++ b/vcr/store_test.go @@ -24,6 +24,7 @@ import ( "crypto/ecdsa" "crypto/sha1" "encoding/json" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/crypto/storage/spi" "github.com/nuts-foundation/nuts-node/vcr/test" @@ -48,7 +49,7 @@ func TestVcr_StoreCredential(t *testing.T) { pkeJSON, _ := os.ReadFile("test/public.json") json.Unmarshal(pkeJSON, &pke) var pk = new(ecdsa.PublicKey) - pke.JWK().Raw(pk) + jwk.Export(pke.JWK(), pk) t.Run("ok - not owned, do not store in wallet", func(t *testing.T) { ctx := newMockContext(t) diff --git a/vcr/test/credentials.go b/vcr/test/credentials.go index f45572c7cd..e405589000 100644 --- a/vcr/test/credentials.go +++ b/vcr/test/credentials.go @@ -27,10 +27,10 @@ import ( "encoding/base64" "encoding/json" "fmt" - "github.com/lestrrat-go/jwx/v2/cert" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/cert" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/test/pki" "github.com/nuts-foundation/nuts-node/vcr/assets" @@ -99,7 +99,7 @@ func JWTNutsOrganizationCredential(t *testing.T, subjectID did.DID) vc.Verifiabl }, "type": "NutsOrganizationCredential", })) - signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES384, privateKey)) + signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES384(), privateKey)) require.NoError(t, err) jwtVC, err := vc.ParseVerifiableCredential(string(signedToken)) require.NoError(t, err) @@ -180,7 +180,7 @@ func ValidX509Credential(t *testing.T, options ...credentialOption) vc.Verifiabl } token, err := builder.Build() require.NoError(t, err) - s, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, rootKey, jws.WithProtectedHeaders(headers))) + s, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), rootKey, jws.WithProtectedHeaders(headers))) require.NoError(t, err) credential, err := vc.ParseVerifiableCredential(string(s)) require.NoError(t, err) diff --git a/vcr/test/test.go b/vcr/test/test.go index 8b8c732cc7..1632519504 100644 --- a/vcr/test/test.go +++ b/vcr/test/test.go @@ -19,11 +19,10 @@ package test import ( - "context" "crypto" "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/lestrrat-go/jwx/v3/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -60,7 +59,12 @@ func CreateJWTPresentation(t *testing.T, subjectDID did.DID, tokenVisitor func(t keyStore := nutsCrypto.NewMemoryCryptoInstance(t) _, key, err := keyStore.New(audit.TestContext(), nutsCrypto.StringNamingFunc(kid)) require.NoError(t, err) - claims, err = unsignedToken.AsMap(context.Background()) + claims = make(map[string]interface{}) + for _, k := range unsignedToken.Keys() { + var v interface{} + require.NoError(t, unsignedToken.Get(k, &v)) + claims[k] = v + } signedToken, err := keyStore.SignJWT(audit.TestContext(), claims, headers, kid) result, err := vc.ParseVerifiablePresentation(signedToken) require.NoError(t, err) diff --git a/vcr/verifier/signature_verifier.go b/vcr/verifier/signature_verifier.go index b900269418..53cbcd6a0d 100644 --- a/vcr/verifier/signature_verifier.go +++ b/vcr/verifier/signature_verifier.go @@ -25,7 +25,7 @@ import ( "strings" "time" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/jsonld" diff --git a/vcr/verifier/signature_verifier_test.go b/vcr/verifier/signature_verifier_test.go index 38805b38ac..b91a7c084e 100644 --- a/vcr/verifier/signature_verifier_test.go +++ b/vcr/verifier/signature_verifier_test.go @@ -32,16 +32,17 @@ import ( "encoding/json" "errors" "os" + "strings" "testing" "time" "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/cert" - "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v3/cert" + "github.com/lestrrat-go/jwx/v3/jwa" testpki "github.com/nuts-foundation/nuts-node/test/pki" "github.com/nuts-foundation/nuts-node/vdr/didx509" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -64,7 +65,7 @@ func TestSignatureVerifier_VerifySignature(t *testing.T) { pkeJSON, _ := os.ReadFile("../test/public.json") json.Unmarshal(pkeJSON, &pke) var pk = new(ecdsa.PublicKey) - pke.JWK().Raw(pk) + jwk.Export(pke.JWK(), pk) t.Run("JSON-LD", func(t *testing.T) { t.Run("ok", func(t *testing.T) { @@ -119,7 +120,7 @@ func TestSignatureVerifier_VerifySignature(t *testing.T) { // Create did:jwk for issuer, and sign credential keyStore := nutsCrypto.NewMemoryCryptoInstance(t) kid, key, err := keyStore.New(audit.TestContext(), func(key crypto.PublicKey) (string, error) { - keyAsJWK, _ := jwk.FromRaw(key) + keyAsJWK, _ := jwk.Import(key) keyJSON, _ := json.Marshal(keyAsJWK) return "did:jwk:" + base64.RawStdEncoding.EncodeToString(keyJSON) + "#0", nil }) @@ -161,7 +162,7 @@ func TestSignatureVerifier_VerifySignature(t *testing.T) { err = sv.VerifySignature(*cred, nil) - assert.EqualError(t, err, "presentation(s) or credential(s) verification failed: unable to validate JWT signature: could not verify message using any of the signatures or keys") + assert.EqualError(t, err, "presentation(s) or credential(s) verification failed: unable to validate JWT signature: jwt.ParseString: failed to parse string: signature verification failed for ES256: invalid ECDSA signature") }) t.Run("expired token", func(t *testing.T) { // Credential taken from Sphereon Wallet, expires on Tue Oct 03 2023 @@ -175,7 +176,7 @@ func TestSignatureVerifier_VerifySignature(t *testing.T) { } err := sv.VerifySignature(*cred, nil) - assert.EqualError(t, err, "presentation(s) or credential(s) verification failed: unable to validate JWT signature: \"exp\" not satisfied") + assert.EqualError(t, err, "presentation(s) or credential(s) verification failed: unable to validate JWT signature: jwt.ParseString: failed to parse string: jwt.Validate: validation failed: \"exp\" not satisfied: token is expired") }) t.Run("without kid header, derived from issuer", func(t *testing.T) { // Credential taken from Sphereon Wallet @@ -195,16 +196,23 @@ func TestSignatureVerifier_VerifySignature(t *testing.T) { t.Run("no signature", func(t *testing.T) { // Credential taken from Sphereon Wallet const credentialJSON = `eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTYzMDE3MDgsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiZmlyc3ROYW1lIjoiSGVsbG8iLCJsYXN0TmFtZSI6IlNwaGVyZW9uIiwiZW1haWwiOiJzcGhlcmVvbkBleGFtcGxlLmNvbSIsInR5cGUiOiJTcGhlcmVvbiBHdWVzdCIsImlkIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEifX0sIkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiZXhwaXJhdGlvbkRhdGUiOiIyMDIzLTEwLTAzVDAyOjU1OjA4LjEzM1oiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJmaXJzdE5hbWUiOiJIZWxsbyIsImxhc3ROYW1lIjoiU3BoZXJlb24iLCJlbWFpbCI6InNwaGVyZW9uQGV4YW1wbGUuY29tIiwidHlwZSI6IlNwaGVyZW9uIEd1ZXN0IiwiaWQiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOa3NpTENKMWMyVWlPaUp6YVdjaUxDSnJkSGtpT2lKRlF5SXNJbU55ZGlJNkluTmxZM0F5TlRack1TSXNJbmdpT2lKak1WZFljemRYTTIxNWMyVlZaazVDY1hONFpGQlhRa2xIYUV0a05GUjZNRXhTTFVacU9FWk5XV0V3SWl3aWVTSTZJbGR0YTBOWWRURjNlWHBhWjBkT04xVjRUbUZ3Y0hGdVQxRmhUMnRYTWtOblQxTnVUMjk1VFVsVWRXTWlmUSJ9LCJpc3N1ZXIiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOaUlzSW5WelpTSTZJbk5wWnlJc0ltdDBlU0k2SWtWRElpd2lZM0oySWpvaVVDMHlOVFlpTENKNElqb2lWRWN5U0RKNE1tUlhXRTR6ZFVOeFduQnhSakY1YzBGUVVWWkVTa1ZPWDBndFEwMTBZbWRxWWkxT1p5SXNJbmtpT2lJNVRUaE9lR1F3VUU0eU1rMDViRkJFZUdSd1JIQnZWRXg2TVRWM1pubGFTbk0yV21oTFNWVktNek00SW4wIiwiaXNzdWFuY2VEYXRlIjoiMjAyMy0wOS0yOVQxMjozMTowOC4xMzNaIiwic3ViIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEiLCJuYmYiOjE2OTU5OTA2NjgsImlzcyI6ImRpZDpqd2s6ZXlKaGJHY2lPaUpGVXpJMU5pSXNJblZ6WlNJNkluTnBaeUlzSW10MGVTSTZJa1ZESWl3aVkzSjJJam9pVUMweU5UWWlMQ0o0SWpvaVZFY3lTREo0TW1SWFdFNHpkVU54V25CeFJqRjVjMEZRVVZaRVNrVk9YMGd0UTAxMFltZHFZaTFPWnlJc0lua2lPaUk1VFRoT2VHUXdVRTR5TWswNWJGQkVlR1J3UkhCdlZFeDZNVFYzWm5sYVNuTTJXbWhMU1ZWS016TTRJbjAifQ.` - cred, _ := vc.ParseVerifiableCredential(credentialJSON) + // A self-attested credential has no signature. In jwx v3 an empty compact signature + // is only accepted with an "alg":"none" protected header, so build the unsigned + // credential with a none-alg header (jwx v2 tolerated dropping the signature from a + // signed header, jwx v3 does not). Signature verification must still reject it. + parts := strings.Split(credentialJSON, ".") + noneHeader := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + cred, err := vc.ParseVerifiableCredential(noneHeader + "." + parts[1] + ".") + require.NoError(t, err) sv := signatureVerifier{ keyResolver: resolver.DIDKeyResolver{ Resolver: didjwk.NewResolver(), }, } - err := sv.VerifySignature(*cred, nil) + err = sv.VerifySignature(*cred, nil) - assert.EqualError(t, err, "presentation(s) or credential(s) verification failed: unable to validate JWT signature: could not verify message using any of the signatures or keys") + assert.EqualError(t, err, "presentation(s) or credential(s) verification failed: unable to validate JWT signature: token signing algorithm is not supported: none") }) }) @@ -233,7 +241,7 @@ func TestSignatureVerifier_VerifySignature(t *testing.T) { err := sv.VerifySignature(vc2, nil) - assert.ErrorContains(t, err, "failed to verify signature") + assert.ErrorContains(t, err, "invalid ECDSA signature") }) t.Run("error - wrong hashed proof", func(t *testing.T) { @@ -248,7 +256,7 @@ func TestSignatureVerifier_VerifySignature(t *testing.T) { err := sv.VerifySignature(vc2, nil) - assert.ErrorContains(t, err, "failed to verify signature") + assert.ErrorContains(t, err, "invalid ECDSA signature") }) t.Run("error - no proof", func(t *testing.T) { @@ -321,7 +329,7 @@ func buildX509Credential(chain *cert.Chain, signingCert *x509.Certificate, rootC claims["vc"] = *credential - token, err := nutsCrypto.SignJWT(audit.TestContext(), signingKey, jwa.PS512, claims, headers) + token, err := nutsCrypto.SignJWT(audit.TestContext(), signingKey, jwa.PS512(), claims, headers) if err != nil { return nil, err } diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 4d3bc4f7d9..51f01a2e4e 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -35,8 +35,8 @@ import ( "github.com/nuts-foundation/nuts-node/storage/orm" "github.com/nuts-foundation/nuts-node/test/pki" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -471,7 +471,7 @@ func Test_verifier_CheckAndStoreRevocation(t *testing.T) { otherKey, _ := spi.GenerateKeyPair() sut.keyResolver.EXPECT().ResolveKeyByID(revocation.Proof.VerificationMethod.String(), &metadata, resolver.NutsSigningKeyType).Return(otherKey, nil) err := sut.verifier.RegisterRevocation(revocation) - assert.EqualError(t, err, "unable to verify revocation signature: invalid proof signature: failed to verify signature using ecdsa") + assert.EqualError(t, err, "unable to verify revocation signature: invalid proof signature: invalid ECDSA signature") }) } @@ -488,7 +488,7 @@ func TestVerifier_VerifyVP(t *testing.T) { }`)) require.NoError(t, err) var publicKey crypto.PublicKey - require.NoError(t, key.Raw(&publicKey)) + require.NoError(t, jwk.Export(key, &publicKey)) const rawVP = `eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDpudXRzOkd2a3p4c2V6SHZFYzhuR2hnejZYbzNqYnFrSHdzd0xtV3czQ1l0Q203aEFXI2FiYy1tZXRob2QtMSIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTc2OTY3NDEsImlzcyI6ImRpZDpudXRzOkd2a3p4c2V6SHZFYzhuR2hnejZYbzNqYnFrSHdzd0xtV3czQ1l0Q203aEFXIiwibmJmIjoxNjk3NjEwMzQxLCJzdWIiOiJkaWQ6bnV0czpHdmt6eHNlekh2RWM4bkdoZ3o2WG8zamJxa0h3c3dMbVd3M0NZdENtN2hBVyIsInZwIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOiJWZXJpZmlhYmxlUHJlc2VudGF0aW9uIiwidmVyaWZpYWJsZUNyZWRlbnRpYWwiOnsiQGNvbnRleHQiOlsiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiLCJodHRwczovL251dHMubmwvY3JlZGVudGlhbHMvdjEiLCJodHRwczovL3czYy1jY2cuZ2l0aHViLmlvL2xkcy1qd3MyMDIwL2NvbnRleHRzL2xkcy1qd3MyMDIwLXYxLmpzb24iXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiY29tcGFueSI6eyJjaXR5IjoiSGVuZ2VsbyIsIm5hbWUiOiJEZSBiZXN0ZSB6b3JnIn0sImlkIjoiZGlkOm51dHM6R3ZrenhzZXpIdkVjOG5HaGd6NlhvM2picWtId3N3TG1XdzNDWXRDbTdoQVcifSwiaWQiOiJkaWQ6bnV0czo0dHpNYVdmcGl6VktlQThmc2NDM0pUZFdCYzNhc1VXV01qNWhVRkhkV1gzSCNmNDNiZWY0Zi0xYTc5LTQzNjQtOTJmMy0zZmM3NDNmYTlmMTkiLCJpc3N1YW5jZURhdGUiOiIyMDIxLTEyLTI0VDEzOjIxOjI5LjA4NzIwNSswMTowMCIsImlzc3VlciI6ImRpZDpudXRzOjR0ek1hV2ZwaXpWS2VBOGZzY0MzSlRkV0JjM2FzVVdXTWo1aFVGSGRXWDNIIiwicHJvb2YiOnsiY3JlYXRlZCI6IjIwMjEtMTItMjRUMTM6MjE6MjkuMDg3MjA1KzAxOjAwIiwiandzIjoiZXlKaGJHY2lPaUpGVXpJMU5pSXNJbUkyTkNJNlptRnNjMlVzSW1OeWFYUWlPbHNpWWpZMElsMTkuLmhQTTJHTGMxSzlkMkQ4U2J2ZTAwNHg5U3VtakxxYVhUaldoVWh2cVdSd3hmUldsd2ZwNWdIRFVZdVJvRWpoQ1hmTHQtX3Uta25DaFZtSzk4ME4zTEJ3IiwicHJvb2ZQdXJwb3NlIjoiTnV0c1NpZ25pbmdLZXlUeXBlIiwidHlwZSI6Ikpzb25XZWJTaWduYXR1cmUyMDIwIiwidmVyaWZpY2F0aW9uTWV0aG9kIjoiZGlkOm51dHM6R3ZrenhzZXpIdkVjOG5HaGd6NlhvM2picWtId3N3TG1XdzNDWXRDbTdoQVcjYWJjLW1ldGhvZC0xIn0sInR5cGUiOlsiQ29tcGFueUNyZWRlbnRpYWwiLCJWZXJpZmlhYmxlQ3JlZGVudGlhbCJdfX19.v3beJvGa3HeImU3VLvsrZjnHs0krKPaCdTEh-qHS7j26LIQYcMHhrLkIexrpPO5z0TKSDnKq5Jl10SWaJpLRIA` @@ -529,7 +529,8 @@ func TestVerifier_VerifyVP(t *testing.T) { } t.Run("ok - credential has no own proof", func(t *testing.T) { vp, key := test.CreateJWTPresentation(t, subjectDID, func(token jwt.Token) { - vpRaw, _ := token.Get("vp") + var vpRaw interface{} + _ = token.Get("vp", &vpRaw) castVP := vpRaw.(vc.VerifiablePresentation) castVP.Holder, _ = ssi.ParseURI(subjectDID.String()) _ = token.Set("vp", castVP) @@ -566,7 +567,7 @@ func TestVerifier_VerifyVP(t *testing.T) { validAt := time.Date(2023, 10, 21, 12, 0, 0, 0, time.UTC) vcs, err := ctx.verifier.VerifyVP(*presentation, false, false, &validAt) - assert.EqualError(t, err, "presentation(s) or credential(s) verification failed: unable to validate JWT signature: \"exp\" not satisfied") + assert.EqualError(t, err, "presentation(s) or credential(s) verification failed: unable to validate JWT signature: jwt.ParseString: failed to parse string: jwt.Validate: validation failed: \"exp\" not satisfied: token is expired") assert.Empty(t, vcs) }) t.Run("VP signer != VC credentialSubject.id", func(t *testing.T) { @@ -729,7 +730,7 @@ func TestVerifier_VerifyVP(t *testing.T) { vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) - assert.EqualError(t, err, "presentation(s) or credential(s) verification failed: invalid signature: invalid proof signature: failed to verify signature using ecdsa") + assert.EqualError(t, err, "presentation(s) or credential(s) verification failed: invalid signature: invalid proof signature: invalid ECDSA signature") assert.Empty(t, vcs) }) t.Run("error - signing key unknown", func(t *testing.T) { diff --git a/vdr/didjwk/resolver.go b/vdr/didjwk/resolver.go index dae339209d..7989067693 100644 --- a/vdr/didjwk/resolver.go +++ b/vdr/didjwk/resolver.go @@ -27,7 +27,7 @@ import ( godid "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" ) // MethodName is the name of this DID method. @@ -117,7 +117,7 @@ func rawPrivateKeyOf(key jwk.Key) (any, error) { // value is available. If the jwx/jwk library offers a way in the future to specifically get a private key from // a JWK this will be much simpler. var rawUnspecifiedKey any - if err := key.Raw(&rawUnspecifiedKey); err != nil { + if err := jwk.Export(key, &rawUnspecifiedKey); err != nil { return nil, fmt.Errorf("failed to get raw key: %w", err) } @@ -132,7 +132,7 @@ func rawPrivateKeyOf(key jwk.Key) (any, error) { // Get the raw public key, which is a golang crypto primitive, or nil. This will be compared next to the // rawUnspecifiedKey (unspecified as public or private) key to determine whether a private key can be returned. var rawPublicKey any - if err := publicKey.Raw(&rawPublicKey); err != nil { + if err := jwk.Export(publicKey, &rawPublicKey); err != nil { return nil, fmt.Errorf("failed to get raw public key: %w", err) } diff --git a/vdr/didjwk/resolver_test.go b/vdr/didjwk/resolver_test.go index 0be0e9aee1..f6186ff13c 100644 --- a/vdr/didjwk/resolver_test.go +++ b/vdr/didjwk/resolver_test.go @@ -87,7 +87,7 @@ func TestResolver_Resolve(t *testing.T) { t.Run("Invalid base64 data fails", failure(b64(canonicalJWK)+":invalid-base64-data", "illegal base64 data at input")) // Ensure a DID with invalid JSON fails - t.Run("base64 encoded non-JSON fails", failure(b64("__NOT_JSON__"), "failed to unmarshal JSON")) + t.Run("base64 encoded non-JSON fails", failure(b64("__NOT_JSON__"), "failed to unmarshal data")) // Ensure valid JSON as an invalid JWK fails t.Run("base64+JSON encoded non-JWK fails", failure(b64(validJSONInvalidJWK), "invalid key type from JSON")) diff --git a/vdr/didkey/resolver.go b/vdr/didkey/resolver.go index 1d84a5809c..e03235a105 100644 --- a/vdr/didkey/resolver.go +++ b/vdr/didkey/resolver.go @@ -21,6 +21,7 @@ package didkey import ( "bytes" "crypto" + "crypto/ecdh" "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" @@ -28,7 +29,6 @@ import ( "encoding/binary" "errors" "fmt" - "github.com/lestrrat-go/jwx/v2/x25519" "github.com/mr-tron/base58" "github.com/multiformats/go-multicodec" ssi "github.com/nuts-foundation/go-did" @@ -81,7 +81,9 @@ func (r Resolver) Resolve(id did.DID, _ *resolver.ResolveMetadata) (*did.Documen if keyLength != 32 { return nil, nil, errInvalidPublicKeyLength } - key = x25519.PublicKey(mcBytes) + if key, err = ecdh.X25519().NewPublicKey(mcBytes); err != nil { + return nil, nil, fmt.Errorf("did:key: invalid X25519 public key: %w", err) + } case multicodec.Ed25519Pub: if keyLength != 32 { return nil, nil, errInvalidPublicKeyLength diff --git a/vdr/didnuts/ambassador.go b/vdr/didnuts/ambassador.go index 837962b048..c25a1550ee 100644 --- a/vdr/didnuts/ambassador.go +++ b/vdr/didnuts/ambassador.go @@ -26,7 +26,7 @@ import ( "encoding/json" "errors" "fmt" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nats-io/nats.go" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-stoabs" @@ -210,7 +210,7 @@ func (n *ambassador) handleCreateDIDDocument(transaction dag.Transaction, propos } var rawKey crypto.PublicKey - err = transaction.SigningKey().Raw(&rawKey) + err = jwk.Export(transaction.SigningKey(), &rawKey) if err != nil { return err } @@ -283,7 +283,7 @@ func (n *ambassador) handleUpdateDIDDocument(transaction dag.Transaction, propos return fmt.Errorf("unable to resolve signingkey: %w", err) } - signingKey, err := jwk.FromRaw(pKey) + signingKey, err := jwk.Import(pKey) if err != nil { return fmt.Errorf("could not parse public key into jwk: %w", err) } diff --git a/vdr/didnuts/ambassador_test.go b/vdr/didnuts/ambassador_test.go index e762aa7987..0532e1ad98 100644 --- a/vdr/didnuts/ambassador_test.go +++ b/vdr/didnuts/ambassador_test.go @@ -30,7 +30,7 @@ import ( "time" "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nats-io/nats.go" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/audit" @@ -357,7 +357,7 @@ func TestAmbassador_handleCreateDIDDocument(t *testing.T) { t.Run("create nok - fails when DID does not matches signing key", func(t *testing.T) { ctx := newMockContext(t) pair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - signingKey, _ := jwk.FromRaw(pair.PublicKey) + signingKey, _ := jwk.Import(pair.PublicKey) _ = signingKey.Set(jwk.KeyIDKey, "kid123") tx := newTX() tx.signingKeyID = "" @@ -417,7 +417,7 @@ func TestAmbassador_handleUpdateDIDDocument(t *testing.T) { } var pKey crypto.PublicKey - _ = signingKey.Raw(&pKey) + _ = jwk.Export(signingKey, &pKey) ctx.didStore.EXPECT().Resolve(didDocument.ID, &resolver.ResolveMetadata{AllowDeactivated: true}).Return(&storedDocument, currentMetadata, nil) ctx.keyResolver.EXPECT().ResolvePublicKey(storedDocument.CapabilityInvocation[0].ID.String(), gomock.Any()).Return(pKey, nil) @@ -454,7 +454,7 @@ func TestAmbassador_handleUpdateDIDDocument(t *testing.T) { } var pKey crypto.PublicKey - _ = signingKey.Raw(&pKey) + _ = jwk.Export(signingKey, &pKey) ctx.didStore.EXPECT().Resolve(currentDoc.ID, &resolver.ResolveMetadata{AllowDeactivated: true, SourceTransaction: &prev}).Return(¤tDoc, currentMetadata, nil) ctx.keyResolver.EXPECT().ResolvePublicKey(currentDoc.CapabilityInvocation[0].ID.String(), gomock.Any()).Return(pKey, nil) @@ -489,7 +489,7 @@ func TestAmbassador_handleUpdateDIDDocument(t *testing.T) { } var pKey crypto.PublicKey - _ = signingKey.Raw(&pKey) + _ = jwk.Export(signingKey, &pKey) gomock.InOrder( ctx.didStore.EXPECT().Resolve(currentDoc.ID, gomock.Any()).Return(nil, nil, resolver.ErrNotFound), @@ -513,7 +513,7 @@ func TestAmbassador_handleUpdateDIDDocument(t *testing.T) { didDocumentController, controllerSigningKey := newDidDoc(t) var pKey crypto.PublicKey - _ = controllerSigningKey.Raw(&pKey) + _ = jwk.Export(controllerSigningKey, &pKey) // set the didDocument`s controller to the controller didDocument.Controller = []did.DID{didDocumentController.ID} @@ -570,7 +570,7 @@ func TestAmbassador_handleUpdateDIDDocument(t *testing.T) { // We still use the document signing key, not the one from the controller var pKey crypto.PublicKey - _ = documentSigningKey.Raw(&pKey) + _ = jwk.Export(documentSigningKey, &pKey) // set the didDocument`s controller to the controller didDocument.Controller = []did.DID{didDocumentController.ID} @@ -644,7 +644,7 @@ func Test_handleUpdateDIDDocument(t *testing.T) { func Test_checkTransactionIntegrity(t *testing.T) { signingTime := time.Now() pair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - signingKey, _ := jwk.FromRaw(pair.PublicKey) + signingKey, _ := jwk.Import(pair.PublicKey) _ = signingKey.Set(jwk.KeyIDKey, "kid123") payloadHash := hash.SHA256Sum([]byte("payload")) ref := hash.SHA256Sum([]byte("ref")) @@ -737,7 +737,7 @@ func newDidDoc(t *testing.T) (did.Document, jwk.Key) { require.NoError(t, err) publicKey, err := didDocument.VerificationMethod[0].PublicKey() require.NoError(t, err) - signingKey, _ := jwk.FromRaw(publicKey) + signingKey, _ := jwk.Import(publicKey) serviceID := did.MustParseDIDURL(didDocument.ID.String()) serviceID.Fragment = "1234" didDocument.Service = []did.Service{ diff --git a/vdr/didnuts/manager.go b/vdr/didnuts/manager.go index a2341a4d91..77824252c1 100644 --- a/vdr/didnuts/manager.go +++ b/vdr/didnuts/manager.go @@ -27,7 +27,7 @@ import ( "github.com/nuts-foundation/nuts-node/storage" "time" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core" @@ -106,7 +106,7 @@ func getKIDName(pKey crypto.PublicKey, idFunc func(key jwk.Key) (string, error)) // -------------------- // generate idString - jwKey, err := jwk.FromRaw(pKey) + jwKey, err := jwk.Import(pKey) if err != nil { return "", fmt.Errorf("could not generate kid: %w", err) } @@ -126,7 +126,7 @@ func getKIDName(pKey crypto.PublicKey, idFunc func(key jwk.Key) (string, error)) kid := &did.DIDURL{} kid.Method = MethodName kid.ID = idString - kid.Fragment = jwKey.KeyID() + kid.Fragment, _ = jwKey.KeyID() return kid.String(), nil } diff --git a/vdr/didnuts/manager_test.go b/vdr/didnuts/manager_test.go index bd17138ed0..948eb8b6f9 100644 --- a/vdr/didnuts/manager_test.go +++ b/vdr/didnuts/manager_test.go @@ -31,7 +31,7 @@ import ( "testing" "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/audit" @@ -189,8 +189,9 @@ func TestManager_GenerateDocument(t *testing.T) { nutsThumbprint, err := nutsCrypto.Thumbprint(asJWK) require.NoError(t, err) _ = jwk.AssignKeyID(asJWK, jwk.WithThumbprintHash(crypto.SHA256)) + asJWKKeyID, _ := asJWK.KeyID() assert.Equal(t, fmt.Sprintf("did:nuts:%s", nutsThumbprint), doc.DID.ID) - assert.Equal(t, fmt.Sprintf("did:nuts:%s#%s", nutsThumbprint, asJWK.KeyID()), verificationMethod.ID.String()) + assert.Equal(t, fmt.Sprintf("did:nuts:%s#%s", nutsThumbprint, asJWKKeyID), verificationMethod.ID.String()) t.Run("additional verification method", func(t *testing.T) { asDID := did.MustParseDID(doc.DID.ID) @@ -202,7 +203,8 @@ func TestManager_GenerateDocument(t *testing.T) { asJWK, err := verificationMethod.JWK() require.NoError(t, err) _ = jwk.AssignKeyID(asJWK, jwk.WithThumbprintHash(crypto.SHA256)) - assert.Equal(t, fmt.Sprintf("%s#%s", doc.DID.ID, asJWK.KeyID()), verificationMethod.ID.String()) + asJWKKeyID, _ := asJWK.KeyID() + assert.Equal(t, fmt.Sprintf("%s#%s", doc.DID.ID, asJWKKeyID), verificationMethod.ID.String()) }) }) } @@ -250,7 +252,7 @@ func Test_DIDKidNamingFunc(t *testing.T) { t.Run("nok - wrong key type", func(t *testing.T) { keyID, err := DIDKIDNamingFunc(unknownPublicKey{}) - assert.EqualError(t, err, "could not generate kid: invalid key type 'didnuts.unknownPublicKey' for jwk.New") + assert.EqualError(t, err, "could not generate kid: jwk.Import: failed to convert didnuts.unknownPublicKey to jwk.Key: no converters were able to convert") assert.Empty(t, keyID) }) } @@ -278,7 +280,7 @@ func jwkToPublicKey(t *testing.T, jwkStr string) (crypto.PublicKey, error) { require.NoError(t, err) key, _ := keySet.Key(0) var rawKey crypto.PublicKey - if err = key.Raw(&rawKey); err != nil { + if err = jwk.Export(key, &rawKey); err != nil { return nil, err } return rawKey, nil @@ -308,8 +310,9 @@ func TestManager_NewDocument(t *testing.T) { nutsThumbprint, err := nutsCrypto.Thumbprint(asJWK) require.NoError(t, err) _ = jwk.AssignKeyID(asJWK, jwk.WithThumbprintHash(crypto.SHA256)) + asJWKKeyID, _ := asJWK.KeyID() assert.Equal(t, fmt.Sprintf("did:nuts:%s", nutsThumbprint), doc.DID.ID) - assert.Equal(t, fmt.Sprintf("did:nuts:%s#%s", nutsThumbprint, asJWK.KeyID()), verificationMethod.ID.String()) + assert.Equal(t, fmt.Sprintf("did:nuts:%s#%s", nutsThumbprint, asJWKKeyID), verificationMethod.ID.String()) t.Run("additional verification method", func(t *testing.T) { asDID := did.MustParseDID(doc.DID.ID) @@ -321,7 +324,8 @@ func TestManager_NewDocument(t *testing.T) { asJWK, err := verificationMethod.JWK() require.NoError(t, err) _ = jwk.AssignKeyID(asJWK, jwk.WithThumbprintHash(crypto.SHA256)) - assert.Equal(t, fmt.Sprintf("%s#%s", doc.DID.ID, asJWK.KeyID()), verificationMethod.ID.String()) + asJWKKeyID, _ := asJWK.KeyID() + assert.Equal(t, fmt.Sprintf("%s#%s", doc.DID.ID, asJWKKeyID), verificationMethod.ID.String()) }) }) } diff --git a/vdr/didnuts/validators.go b/vdr/didnuts/validators.go index ed10fddd2b..d0bfbac6ff 100644 --- a/vdr/didnuts/validators.go +++ b/vdr/didnuts/validators.go @@ -22,7 +22,7 @@ import ( "encoding/json" "errors" "fmt" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/network/transport" @@ -79,7 +79,7 @@ func (v verificationMethodValidator) verifyThumbprint(method *did.VerificationMe return fmt.Errorf("unable to get JWK: %w", err) } _ = jwk.AssignKeyID(keyAsJWK) - if keyAsJWK.KeyID() != method.ID.Fragment { + if keyID, _ := keyAsJWK.KeyID(); keyID != method.ID.Fragment { return errors.New("key thumbprint does not match ID") } return nil diff --git a/vdr/didx509/resolver_test.go b/vdr/didx509/resolver_test.go index 408e60562f..ba827d6c99 100644 --- a/vdr/didx509/resolver_test.go +++ b/vdr/didx509/resolver_test.go @@ -23,7 +23,7 @@ import ( "crypto/x509" "encoding/base64" "fmt" - "github.com/lestrrat-go/jwx/v2/cert" + "github.com/lestrrat-go/jwx/v3/cert" "github.com/minio/sha256-simd" "github.com/nuts-foundation/go-did/did" testpki "github.com/nuts-foundation/nuts-node/test/pki" diff --git a/vdr/didx509/x509_utils.go b/vdr/didx509/x509_utils.go index b4be0cacd6..300ecdae39 100644 --- a/vdr/didx509/x509_utils.go +++ b/vdr/didx509/x509_utils.go @@ -29,7 +29,7 @@ import ( "encoding/base64" "errors" "fmt" - "github.com/lestrrat-go/jwx/v2/cert" + "github.com/lestrrat-go/jwx/v3/cert" "strings" ) diff --git a/vdr/didx509/x509_utils_test.go b/vdr/didx509/x509_utils_test.go index 41f98cfb19..64f70b87b5 100644 --- a/vdr/didx509/x509_utils_test.go +++ b/vdr/didx509/x509_utils_test.go @@ -26,7 +26,7 @@ import ( "encoding/base64" "errors" "fmt" - "github.com/lestrrat-go/jwx/v2/cert" + "github.com/lestrrat-go/jwx/v3/cert" "github.com/nuts-foundation/nuts-node/test/pki" "github.com/stretchr/testify/require" "slices" @@ -176,16 +176,6 @@ func TestFindCertificateByHash(t *testing.T) { // TestParseChain tests the parseChain function with cases that contain valid and invalid PEM encoded certificates. func TestParseChain(t *testing.T) { - newChain := func(pems [][]byte) *cert.Chain { - chain := cert.Chain{} - for _, pemCert := range pems { - err := chain.Add(pemCert) - if err != nil { - panic(err) - } - } - return &chain - } certs, _, _ := pki.BuildCertChain([]string{"123"}, "", nil) invalidCert := `Y29ycnVwdCBjZXJ0aWZpY2F0ZQo=` @@ -206,16 +196,6 @@ func TestParseChain(t *testing.T) { chain: pki.CertsToChain(certs), want: certs[:], // not critical for testing wantError: nil, - }, { - name: "invalid cert", - chain: newChain([][]byte{[]byte(invalidCert)}), - want: nil, - wantError: errors.New("x509: malformed certificate"), - }, { - name: "invalid base64", - chain: newChain([][]byte{[]byte(invalidBase64)}), - want: nil, - wantError: errors.New("illegal base64 data at input byte 5"), }, } @@ -233,6 +213,20 @@ func TestParseChain(t *testing.T) { } }) } + + // In jwx v3, cert.Chain.Add parses and validates each certificate eagerly, so + // malformed certificates are rejected at insertion time and can never reach + // parseChain. Assert that the malformed-cert handling still happens, now at Add. + t.Run("invalid cert", func(t *testing.T) { + chain := cert.Chain{} + err := chain.Add([]byte(invalidCert)) + require.ErrorContains(t, err, "x509: malformed certificate") + }) + t.Run("invalid base64", func(t *testing.T) { + chain := cert.Chain{} + err := chain.Add([]byte(invalidBase64)) + require.ErrorContains(t, err, "illegal base64 data at input byte 5") + }) } func leafCertFromCerts(certs []*x509.Certificate) *x509.Certificate { diff --git a/vdr/resolver/did.go b/vdr/resolver/did.go index 052bf8dd9f..09b7577e54 100644 --- a/vdr/resolver/did.go +++ b/vdr/resolver/did.go @@ -20,7 +20,7 @@ package resolver import ( "errors" - "github.com/lestrrat-go/jwx/v2/cert" + "github.com/lestrrat-go/jwx/v3/cert" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/crypto/hash" "sync" diff --git a/vdr/resolver/did_test.go b/vdr/resolver/did_test.go index 1db1c60254..bbad2a808a 100644 --- a/vdr/resolver/did_test.go +++ b/vdr/resolver/did_test.go @@ -23,7 +23,7 @@ import ( "crypto/elliptic" "crypto/rand" "errors" - "github.com/lestrrat-go/jwx/v2/cert" + "github.com/lestrrat-go/jwx/v3/cert" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/crypto/hash" diff --git a/vdr/vdr_test.go b/vdr/vdr_test.go index ce6c593695..5207d19711 100644 --- a/vdr/vdr_test.go +++ b/vdr/vdr_test.go @@ -25,7 +25,7 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" - "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v3/jwk" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/audit" @@ -265,7 +265,7 @@ func TestVDR_Configure(t *testing.T) { }) t.Run("it can resolve using did:jwk", func(t *testing.T) { privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - expectedJWK, err := jwk.FromRaw(privateKey.Public()) + expectedJWK, err := jwk.Import(privateKey.Public()) require.NoError(t, err) jwkBytes, _ := json.Marshal(expectedJWK) From e1db00712d7ba94f1d5a6e9539df5e8a805c5b1b Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Fri, 3 Jul 2026 12:31:27 +0200 Subject: [PATCH 03/12] refactor(jwx): centralize JWT claims-to-map into jwx.AsMap; check errors Addresses review feedback on the jwx v3 migration: - Add crypto/jwx.AsMap(joseObject) helper that converts a jwt.Token's claims to a map via JSON (preserving null-valued claims, unlike a per-field Get loop which errors on null). Replaces the duplicated marshal/unmarshal blocks in auth/client/iam, auth/api/iam/jar, auth/services/oauth, and the vcr test helper, and the equivalent loop in spi.FromJWK. The helper checks both the marshal and unmarshal errors, so those are no longer ignored. - Keep the per-field Get loop for JOSE *headers* (DecryptJWE, ExtractProtectedHeaders): a JSON round-trip flattens rich header values (e.g. the x5c certificate chain) and breaks callers that rely on their concrete Go types. - Remaining discarded returns are single-value accessors that returned no error/bool in v2 (e.g. token.Issuer()), so ignoring the new bool is faithful. Assisted by AI --- auth/api/iam/jar.go | 10 +++----- auth/client/iam/client.go | 9 +++---- auth/services/oauth/authz_server.go | 11 ++++---- crypto/jwx.go | 4 +++ crypto/jwx/claims.go | 40 +++++++++++++++++++++++++++++ crypto/storage/spi/interface.go | 11 +++----- vcr/test/test.go | 9 +++---- 7 files changed, 62 insertions(+), 32 deletions(-) create mode 100644 crypto/jwx/claims.go diff --git a/auth/api/iam/jar.go b/auth/api/iam/jar.go index f97beac496..175bce57a0 100644 --- a/auth/api/iam/jar.go +++ b/auth/api/iam/jar.go @@ -22,7 +22,6 @@ import ( "bytes" "context" "crypto" - "encoding/json" "errors" "github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwt" @@ -30,6 +29,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/oauth" cryptoNuts "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/crypto/jwx" "github.com/nuts-foundation/nuts-node/vdr/resolver" "net/url" "time" @@ -184,12 +184,8 @@ func (j jar) validate(ctx context.Context, rawToken string, clientId string) (oa if err != nil { return nil, oauth.OAuth2Error{Code: oauth.InvalidRequestObject, Description: "request signature validation failed", InternalError: err} } - // Convert the token's claims to a map via its JSON representation. This mirrors jwx v2's - // token.AsMap: it preserves all claims including null-valued ones (a per-claim Get loop - // errors on null values in v3). - claimsAsMap := make(map[string]interface{}) - claimsJSON, _ := json.Marshal(token) - if err := json.Unmarshal(claimsJSON, &claimsAsMap); err != nil { + claimsAsMap, err := jwx.AsMap(token) + if err != nil { // very unlikely return nil, oauth.OAuth2Error{Code: oauth.InvalidRequestObject, Description: "invalid request parameter", InternalError: err} } diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 46c37ea368..e2ae0c462a 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -32,6 +32,7 @@ import ( "github.com/lestrrat-go/jwx/v3/jws" "github.com/lestrrat-go/jwx/v3/jwt" "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/crypto/jwx" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/nuts-foundation/go-did/vc" @@ -264,12 +265,8 @@ func (hb HTTPClient) OpenIDConfiguration(ctx context.Context, issuerURL string) if err != nil { return nil, fmt.Errorf("unable to parse response: %w", err) } - // Convert the token's claims to a map via its JSON representation. This mirrors jwx v2's - // token.AsMap: it preserves all claims including null-valued ones (a per-claim Get loop - // errors on null values in v3). - claims := make(map[string]interface{}) - claimsJSON, _ := json.Marshal(token) - if err = json.Unmarshal(claimsJSON, &claims); err != nil { + claims, err := jwx.AsMap(token) + if err != nil { return nil, fmt.Errorf("unable to parse response: %w", err) } // hack, broken iat diff --git a/auth/services/oauth/authz_server.go b/auth/services/oauth/authz_server.go index 99dcf9fd9e..48c588fe9c 100644 --- a/auth/services/oauth/authz_server.go +++ b/auth/services/oauth/authz_server.go @@ -35,6 +35,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/core" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/crypto/jwx" "github.com/nuts-foundation/nuts-node/didman" "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/vcr" @@ -523,12 +524,10 @@ func (s *authzServer) IntrospectAccessToken(ctx context.Context, accessToken str result := &services.NutsAccessToken{} - // Extract the private (non-registered) claims via the token's JSON representation. This mirrors - // jwx v2's token.PrivateClaims: it preserves null-valued claims (a per-claim Get loop errors on - // null values in v3). - privateClaims := make(map[string]interface{}) - claimsJSON, _ := json.Marshal(token) - if err := json.Unmarshal(claimsJSON, &privateClaims); err != nil { + // Extract all claims, then drop the registered ones to mirror jwx v2's token.PrivateClaims. + // jwx.AsMap preserves null-valued claims (a per-claim Get loop errors on null values in v3). + privateClaims, err := jwx.AsMap(token) + if err != nil { return nil, err } for _, k := range []string{jwt.IssuerKey, jwt.SubjectKey, jwt.AudienceKey, jwt.ExpirationKey, jwt.NotBeforeKey, jwt.IssuedAtKey, jwt.JwtIDKey} { diff --git a/crypto/jwx.go b/crypto/jwx.go index 4ca5768efb..5af6d99bc0 100644 --- a/crypto/jwx.go +++ b/crypto/jwx.go @@ -122,6 +122,8 @@ func (client *Crypto) DecryptJWE(ctx context.Context, message string) (body []by if err != nil { return nil, nil, err } + // Iterate the headers with Get (rather than jwx.AsMap's JSON round-trip) so rich header + // values such as the x5c certificate chain keep their concrete Go types for callers. headers = make(map[string]interface{}) for _, k := range protectedHeaders.Keys() { var v interface{} @@ -410,6 +412,8 @@ func ExtractProtectedHeaders(jwt string) (map[string]interface{}, error) { return nil, ErrorInvalidNumberOfSignatures } protectedHeaders := message.Signatures()[0].ProtectedHeaders() + // Iterate with Get (rather than jwx.AsMap's JSON round-trip) so rich header values + // such as the x5c certificate chain keep their concrete Go types for callers. for _, k := range protectedHeaders.Keys() { var v interface{} if err := protectedHeaders.Get(k, &v); err != nil { diff --git a/crypto/jwx/claims.go b/crypto/jwx/claims.go new file mode 100644 index 0000000000..60888269ca --- /dev/null +++ b/crypto/jwx/claims.go @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package jwx + +import "encoding/json" + +// AsMap converts a JOSE object (a jwt.Token, jws/jwe Headers, or jwk.Key) to a map of its +// fields, keyed by their JSON member names. +// +// It replaces jwx v2's AsMap methods, which were removed in v3. Marshaling via JSON (rather +// than iterating keys and calling Get per field) is deliberate: v3's per-field Get returns an +// error for a null-valued member, whereas the JSON round-trip preserves null members as nil - +// matching v2's AsMap behaviour and avoiding rejection of otherwise-valid input. +func AsMap(joseObject any) (map[string]interface{}, error) { + data, err := json.Marshal(joseObject) + if err != nil { + return nil, err + } + result := make(map[string]interface{}) + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} diff --git a/crypto/storage/spi/interface.go b/crypto/storage/spi/interface.go index 746b572d4f..e840e8ff2e 100644 --- a/crypto/storage/spi/interface.go +++ b/crypto/storage/spi/interface.go @@ -31,6 +31,7 @@ import ( "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/crypto/jwx" ) // ErrNotFound indicates that the specified crypto storage entry couldn't be found. @@ -77,13 +78,9 @@ type PublicKeyEntry struct { // FromJWK fills the publicKeyEntry with key material from the given key func (pke *PublicKeyEntry) FromJWK(key jwk.Key) error { - asMap := make(map[string]interface{}) - for _, k := range key.Keys() { - var v interface{} - if err := key.Get(k, &v); err != nil { - return err - } - asMap[k] = v + asMap, err := jwx.AsMap(key) + if err != nil { + return err } pke.Key = asMap pke.parsedJWK = key diff --git a/vcr/test/test.go b/vcr/test/test.go index 1632519504..da142012c3 100644 --- a/vcr/test/test.go +++ b/vcr/test/test.go @@ -28,6 +28,7 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/crypto/jwx" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/stretchr/testify/require" "testing" @@ -59,12 +60,8 @@ func CreateJWTPresentation(t *testing.T, subjectDID did.DID, tokenVisitor func(t keyStore := nutsCrypto.NewMemoryCryptoInstance(t) _, key, err := keyStore.New(audit.TestContext(), nutsCrypto.StringNamingFunc(kid)) require.NoError(t, err) - claims = make(map[string]interface{}) - for _, k := range unsignedToken.Keys() { - var v interface{} - require.NoError(t, unsignedToken.Get(k, &v)) - claims[k] = v - } + claims, err = jwx.AsMap(unsignedToken) + require.NoError(t, err) signedToken, err := keyStore.SignJWT(audit.TestContext(), claims, headers, kid) result, err := vc.ParseVerifiablePresentation(signedToken) require.NoError(t, err) From 765c8e6c90c517d08d0030fd4e946528ba2473dd Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Tue, 7 Jul 2026 11:45:38 +0200 Subject: [PATCH 04/12] chore: pin go-did to tagged release v0.22.0 Replaces the interim jwx-v3-migration branch pseudo-version with the tagged go-did v0.22.0 release (jwx v3). Clears the merge blocker for the jwx v3 migration. Assisted by AI --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f3c9069d2e..c9ac12d0bd 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( github.com/nats-io/nats-server/v2 v2.14.2 github.com/nats-io/nats.go v1.52.0 github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b - github.com/nuts-foundation/go-did v0.21.1-0.20260703074457-d8459a02af42 + github.com/nuts-foundation/go-did v0.22.0 github.com/nuts-foundation/go-leia/v4 v4.3.0 github.com/nuts-foundation/go-stoabs v1.11.1 github.com/nuts-foundation/sqlite v1.0.0 diff --git a/go.sum b/go.sum index 9f234b9724..8e3a94064d 100644 --- a/go.sum +++ b/go.sum @@ -396,8 +396,8 @@ github.com/nightlyone/lockfile v1.0.0 h1:RHep2cFKK4PonZJDdEl4GmkabuhbsRMgk/k3uAm github.com/nightlyone/lockfile v1.0.0/go.mod h1:rywoIealpdNse2r832aiD9jRk8ErCatROs6LzC841CI= github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b h1:80icUxWHwE1MrIOOEK5rxrtyKOgZeq5Iu1IjAEkggTY= github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b/go.mod h1:6YUioYirD6/8IahZkoS4Ypc8xbeJW76Xdk1QKcziNTM= -github.com/nuts-foundation/go-did v0.21.1-0.20260703074457-d8459a02af42 h1:OZwIKM1P32o9KifNfkqdBAFwwhWuPpOA3bftInCsDAQ= -github.com/nuts-foundation/go-did v0.21.1-0.20260703074457-d8459a02af42/go.mod h1:M6d7KaJLf3Ipl+ttRANc207S5MT9Kq5F0so1fPad/I0= +github.com/nuts-foundation/go-did v0.22.0 h1:cjGbLv8S+MMlwlnuy9YD6wdBWDKguweCIem8sZ6neQw= +github.com/nuts-foundation/go-did v0.22.0/go.mod h1:M6d7KaJLf3Ipl+ttRANc207S5MT9Kq5F0so1fPad/I0= github.com/nuts-foundation/go-leia/v4 v4.3.0 h1:R0qGISIeg2q/PCQTC9cuoBtA6cFu4WBV2DbmSOWKZyM= github.com/nuts-foundation/go-leia/v4 v4.3.0/go.mod h1:Gw6bXqJLOAmHSiXJJYbVoj+Mowp/PoBRywO0ZPsVzA0= github.com/nuts-foundation/go-stoabs v1.11.1 h1:ZQOeRKzC1+AfW6Ve5kBJMo+zYhHLBUI4KrLWKYkxoeI= From ee0432e2dc97e31d53182b3de2163c14de66fde2 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 8 Jul 2026 13:21:42 +0200 Subject: [PATCH 05/12] fix(iam): handle []interface{} array claims in oauthParameters.get The jwx v3 migration extracts JAR request-object claims via jwx.AsMap (json.Marshal + Unmarshal into a map), which decodes JSON arrays as []interface{} rather than the []string that jwx v2's AsMap produced. oauthParameters.get only matched []string, so a single-value "aud" claim resolved to "" and audience validation failed (surfaced by the e2e OAuth flow: "invalid audience, expected: ..., was: "). Handle []interface{} single-element arrays too, fulfilling get's documented "array values with len == 1 are treated as single string values" contract regardless of whether the value came from query params ([]string) or JSON claims ([]interface{}). Add a unit test covering the accessor. Assisted by AI --- auth/api/iam/params.go | 7 ++++++ auth/api/iam/params_test.go | 44 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 auth/api/iam/params_test.go diff --git a/auth/api/iam/params.go b/auth/api/iam/params.go index 8c66ad3721..b68ad7972b 100644 --- a/auth/api/iam/params.go +++ b/auth/api/iam/params.go @@ -42,6 +42,13 @@ func (ssp oauthParameters) get(key string) string { if len(typedValue) == 1 { return typedValue[0] } + case []interface{}: + // JWT claims decoded from JSON (e.g. via jwx.AsMap) carry arrays as []interface{}. + if len(typedValue) == 1 { + if str, ok := typedValue[0].(string); ok { + return str + } + } } return "" } diff --git a/auth/api/iam/params_test.go b/auth/api/iam/params_test.go new file mode 100644 index 0000000000..6af6fb9a6f --- /dev/null +++ b/auth/api/iam/params_test.go @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package iam + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOauthParameters_get(t *testing.T) { + params := oauthParameters{ + "string": "value", + "stringSlice": []string{"value"}, + "interfaceSlice": []interface{}{"value"}, + "multiSlice": []interface{}{"a", "b"}, + "nonStringSlice": []interface{}{1}, + "number": 1, + } + assert.Equal(t, "value", params.get("string")) + assert.Equal(t, "value", params.get("stringSlice")) + // JWT claims decoded from JSON (e.g. the "aud" claim via jwx.AsMap) arrive as []interface{}. + assert.Equal(t, "value", params.get("interfaceSlice")) + assert.Empty(t, params.get("multiSlice")) + assert.Empty(t, params.get("nonStringSlice")) + assert.Empty(t, params.get("number")) + assert.Empty(t, params.get("absent")) +} From dba88531b3333af70905972f6e36abc5ba73d76a Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 10:24:45 +0200 Subject: [PATCH 06/12] refactor(jwx): use jws.VerifierFor over the low-level jwsbb.Verify For the raw/detached signature verification (ParseJWS and the JSON-LD proof verifier), use the jws-level jws.VerifierFor(alg) instead of reaching into the low-level jwsbb.Verify building block. jws.VerifierFor is the intended v3 replacement for the v2 jws.NewVerifier(alg) pattern (which no longer registers standard algorithms in v3). For standard algorithms it delegates to jwsbb.Verify anyway, so the result is identical, but it stays at the public jws API level and honours any verifier registered via jws.RegisterVerifier (jwsbb.Verify bypasses the registry by design). Assisted by AI --- crypto/jwx.go | 6 ++++-- vcr/signature/proof/jsonld.go | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crypto/jwx.go b/crypto/jwx.go index 5af6d99bc0..7479fb18d6 100644 --- a/crypto/jwx.go +++ b/crypto/jwx.go @@ -34,7 +34,6 @@ import ( "github.com/lestrrat-go/jwx/v3/jwe" "github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jws" - "github.com/lestrrat-go/jwx/v3/jws/jwsbb" "github.com/lestrrat-go/jwx/v3/jwt" "github.com/mr-tron/base58" "github.com/nuts-foundation/nuts-node/audit" @@ -303,10 +302,13 @@ func ParseJWS(token []byte, f PublicKeyFunc) (payload []byte, err error) { for _, part := range parts { payload = append(payload, part...) } - err = jwsbb.Verify(key, alg.String(), payload, signature.Signature()) + verifier, err := jws.VerifierFor(alg) if err != nil { return nil, err } + if err = verifier.Verify(key, payload, signature.Signature()); err != nil { + return nil, err + } } body = message.Payload() diff --git a/vcr/signature/proof/jsonld.go b/vcr/signature/proof/jsonld.go index 2b607fc641..b91f620afb 100644 --- a/vcr/signature/proof/jsonld.go +++ b/vcr/signature/proof/jsonld.go @@ -29,7 +29,7 @@ import ( "strings" "time" - "github.com/lestrrat-go/jwx/v3/jws/jwsbb" + "github.com/lestrrat-go/jwx/v3/jws" ssi "github.com/nuts-foundation/go-did" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vcr/signature" @@ -141,7 +141,11 @@ func (p LDProof) Verify(document Document, suite signature.Suite, key crypto.Pub return fmt.Errorf("could not base64 decode signature: %w", err) } challenge := fmt.Sprintf("%s.%s", splittedJws[0], tbv) - if err = jwsbb.Verify(key, alg.String(), []byte(challenge), sig); err != nil { + verifier, err := jws.VerifierFor(alg) + if err != nil { + return err + } + if err = verifier.Verify(key, []byte(challenge), sig); err != nil { return fmt.Errorf("invalid proof signature: %w", err) } return nil From 705e5da72f2b036aa848d5720038d3e475c2f115 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 10:53:08 +0200 Subject: [PATCH 07/12] fix(jwx): jwa.ES256K() function call under jwx_es256k build tag The es256k registration file is only compiled with -tags jwx_es256k, so the jwa constant->function change was missed during the v2->v3 migration. Assisted by AI --- crypto/jwx/jwx_es256k.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto/jwx/jwx_es256k.go b/crypto/jwx/jwx_es256k.go index bc0637aeb6..6ba2ba0cc3 100644 --- a/crypto/jwx/jwx_es256k.go +++ b/crypto/jwx/jwx_es256k.go @@ -23,5 +23,5 @@ package jwx import "github.com/lestrrat-go/jwx/v3/jwa" func init() { - AddSupportedAlgorithm(jwa.ES256K) + AddSupportedAlgorithm(jwa.ES256K()) } From e687d236903987d38e1451a36545c473dc83ca45 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 13:38:10 +0200 Subject: [PATCH 08/12] fix(jwx): tolerate null-valued JOSE header members; restore ES256K test Two follow-ups to the jwx v2->v3 migration: - Null-valued protected header members were rejected. The header extraction loops (DecryptJWE, ExtractProtectedHeaders) use per-field Get to preserve rich types like the x5c certificate chain, but v3's Get errors on a null value, so a JWS/JWE with a null-valued custom header was rejected where v2's Headers.AsMap returned nil. Add jwx.HeadersAsMap, which keeps concrete Go types and stores a null member as nil (the only failure mode of Get on an existing key into interface{}). Use it in both loops. - Move and fix the previously-uncompilable ES256K test. It lived in package jwx but called crypto.ParseJWT (an import cycle), passed the wrong argument count, and signed with a P-256 key for a secp256k1 algorithm. It never compiled because CI only builds -tags jwx_es256k, never tests it. Moved to package crypto and signed with a real secp256k1 key. Assisted by AI --- crypto/jwx.go | 22 ++---------- crypto/jwx/claims.go | 35 +++++++++++++++++-- crypto/jwx/claims_test.go | 54 +++++++++++++++++++++++++++++ crypto/{jwx => }/jwx_es256k_test.go | 33 +++++++++++------- 4 files changed, 109 insertions(+), 35 deletions(-) create mode 100644 crypto/jwx/claims_test.go rename crypto/{jwx => }/jwx_es256k_test.go (56%) diff --git a/crypto/jwx.go b/crypto/jwx.go index 7479fb18d6..c0e54be3c0 100644 --- a/crypto/jwx.go +++ b/crypto/jwx.go @@ -121,16 +121,7 @@ func (client *Crypto) DecryptJWE(ctx context.Context, message string) (body []by if err != nil { return nil, nil, err } - // Iterate the headers with Get (rather than jwx.AsMap's JSON round-trip) so rich header - // values such as the x5c certificate chain keep their concrete Go types for callers. - headers = make(map[string]interface{}) - for _, k := range protectedHeaders.Keys() { - var v interface{} - if err = protectedHeaders.Get(k, &v); err != nil { - return nil, nil, err - } - headers[k] = v - } + headers = jwx.HeadersAsMap(protectedHeaders) return body, headers, err } @@ -413,16 +404,7 @@ func ExtractProtectedHeaders(jwt string) (map[string]interface{}, error) { if len(message.Signatures()) != 1 { return nil, ErrorInvalidNumberOfSignatures } - protectedHeaders := message.Signatures()[0].ProtectedHeaders() - // Iterate with Get (rather than jwx.AsMap's JSON round-trip) so rich header values - // such as the x5c certificate chain keep their concrete Go types for callers. - for _, k := range protectedHeaders.Keys() { - var v interface{} - if err := protectedHeaders.Get(k, &v); err != nil { - return nil, err - } - headers[k] = v - } + headers = jwx.HeadersAsMap(message.Signatures()[0].ProtectedHeaders()) } } return headers, nil diff --git a/crypto/jwx/claims.go b/crypto/jwx/claims.go index 60888269ca..9723153792 100644 --- a/crypto/jwx/claims.go +++ b/crypto/jwx/claims.go @@ -20,13 +20,17 @@ package jwx import "encoding/json" -// AsMap converts a JOSE object (a jwt.Token, jws/jwe Headers, or jwk.Key) to a map of its -// fields, keyed by their JSON member names. +// AsMap converts a JOSE object (a jwt.Token or jwk.Key) to a map of its fields, keyed by their +// JSON member names. // // It replaces jwx v2's AsMap methods, which were removed in v3. Marshaling via JSON (rather // than iterating keys and calling Get per field) is deliberate: v3's per-field Get returns an // error for a null-valued member, whereas the JSON round-trip preserves null members as nil - // matching v2's AsMap behaviour and avoiding rejection of otherwise-valid input. +// +// Do NOT use this for jws/jwe protected headers: the JSON round-trip flattens rich header +// values (e.g. the x5c certificate chain) into plain JSON types, breaking callers that expect +// the concrete Go types. Use HeadersAsMap for headers. func AsMap(joseObject any) (map[string]interface{}, error) { data, err := json.Marshal(joseObject) if err != nil { @@ -38,3 +42,30 @@ func AsMap(joseObject any) (map[string]interface{}, error) { } return result, nil } + +// HeadersAsMap converts jws or jwe protected headers to a map of their members, keyed by JSON +// member name. +// +// Unlike AsMap it iterates the members with Get, preserving the concrete Go types of rich +// members such as the x5c certificate chain (a JSON round-trip would flatten those). It +// replaces jwx v2's Headers.AsMap: a null-valued member is stored as nil rather than causing an +// error, because v3's per-field Get fails on a null value and would otherwise reject an +// otherwise-valid header set. +func HeadersAsMap(headers interface { + Keys() []string + Get(string, any) error +}) map[string]interface{} { + result := make(map[string]interface{}) + for _, k := range headers.Keys() { + var v interface{} + if err := headers.Get(k, &v); err != nil { + // k comes from Keys() so the member exists, and the destination is interface{}, + // which accepts any non-nil value; the only failure mode is a null-valued member. + // Represent it as nil, matching v2's Headers.AsMap. + result[k] = nil + continue + } + result[k] = v + } + return result +} diff --git a/crypto/jwx/claims_test.go b/crypto/jwx/claims_test.go new file mode 100644 index 0000000000..fd888e5aa3 --- /dev/null +++ b/crypto/jwx/claims_test.go @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package jwx + +import ( + "testing" + + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHeadersAsMap(t *testing.T) { + t.Run("preserves members and represents a null-valued member as nil", func(t *testing.T) { + headers := jws.NewHeaders() + require.NoError(t, headers.Set("string-member", "value")) + require.NoError(t, headers.Set("null-member", nil)) + require.Contains(t, headers.Keys(), "null-member") // sanity: the null member is actually present + + m := HeadersAsMap(headers) + + assert.Equal(t, "value", m["string-member"]) + // A per-field Get errors on the null member; HeadersAsMap must instead store nil. + nilValue, present := m["null-member"] + assert.True(t, present, "null-valued member must be present in the map") + assert.Nil(t, nilValue) + }) + t.Run("keeps the concrete Go type of a rich member (like x5c)", func(t *testing.T) { + headers := jws.NewHeaders() + require.NoError(t, headers.Set("rich-member", []string{"a", "b"})) + + m := HeadersAsMap(headers) + + // A JSON round-trip (jwx.AsMap) would flatten this to []interface{}; HeadersAsMap keeps + // the concrete []string, which is what callers of rich headers such as x5c rely on. + assert.IsType(t, []string{}, m["rich-member"]) + }) +} diff --git a/crypto/jwx/jwx_es256k_test.go b/crypto/jwx_es256k_test.go similarity index 56% rename from crypto/jwx/jwx_es256k_test.go rename to crypto/jwx_es256k_test.go index 5888e68fe1..a18e866a2c 100644 --- a/crypto/jwx/jwx_es256k_test.go +++ b/crypto/jwx_es256k_test.go @@ -18,28 +18,35 @@ * */ -package jwx +package crypto import ( "crypto" + "crypto/ecdsa" + "crypto/rand" + "testing" + + "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwt" - "github.com/nuts-foundation/nuts-node/crypto/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" ) +// TestES256k verifies that, when built with the jwx_es256k tag, an ES256K-signed JWT +// round-trips through ParseJWT (i.e. ES256K is registered as a supported algorithm). func TestES256k(t *testing.T) { - t.Run("test ES256K", func(t *testing.T) { - ecKey := test.GenerateECKey() - token := jwt.New() - signature, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256K, ecKey)) - parsedToken, err := ParseJWT(string(signature), func(_ string) (crypto.PublicKey, error) { - return ecKey.Public(), nil - }, nil) - require.NoError(t, err) + // ES256K signs over the secp256k1 curve, so a P-256 key won't do. + ecKey, err := ecdsa.GenerateKey(secp256k1.S256(), rand.Reader) + require.NoError(t, err) + + token := jwt.New() + signature, err := jwt.Sign(token, jwt.WithKey(jwa.ES256K(), ecKey)) + require.NoError(t, err) - assert.NotNil(t, parsedToken) - }) + parsedToken, err := ParseJWT(string(signature), func(_ string) (crypto.PublicKey, error) { + return ecKey.Public(), nil + }, nil, nil) + require.NoError(t, err) + assert.NotNil(t, parsedToken) } From ed35e897cd6cfef8c9f530afcae4652459abc43d Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 13:55:52 +0200 Subject: [PATCH 09/12] refactor(jwx): rename AsMap to ClaimsAsMap, type it as jwt.Token AsMap was misleadingly generic: it took `any` but is really about a JWT token's claims. Rename to ClaimsAsMap and type the parameter as jwt.Token for compile-time safety and clearer intent. The one non-token caller (spi.FromJWK, a jwk.Key) is not "claims", so it now does its own json.Marshal/Unmarshal inline. A jwk.Key has no null-valued members, so it does not need the null-tolerant helper. Assisted by AI --- auth/api/iam/jar.go | 2 +- auth/api/iam/params.go | 2 +- auth/api/iam/params_test.go | 2 +- auth/client/iam/client.go | 2 +- auth/services/oauth/authz_server.go | 4 ++-- crypto/jwx/claims.go | 25 ++++++++++++++----------- crypto/storage/spi/interface.go | 7 +++++-- vcr/test/test.go | 2 +- 8 files changed, 26 insertions(+), 20 deletions(-) diff --git a/auth/api/iam/jar.go b/auth/api/iam/jar.go index 175bce57a0..50970afff6 100644 --- a/auth/api/iam/jar.go +++ b/auth/api/iam/jar.go @@ -184,7 +184,7 @@ func (j jar) validate(ctx context.Context, rawToken string, clientId string) (oa if err != nil { return nil, oauth.OAuth2Error{Code: oauth.InvalidRequestObject, Description: "request signature validation failed", InternalError: err} } - claimsAsMap, err := jwx.AsMap(token) + claimsAsMap, err := jwx.ClaimsAsMap(token) if err != nil { // very unlikely return nil, oauth.OAuth2Error{Code: oauth.InvalidRequestObject, Description: "invalid request parameter", InternalError: err} diff --git a/auth/api/iam/params.go b/auth/api/iam/params.go index b68ad7972b..181a1e0390 100644 --- a/auth/api/iam/params.go +++ b/auth/api/iam/params.go @@ -43,7 +43,7 @@ func (ssp oauthParameters) get(key string) string { return typedValue[0] } case []interface{}: - // JWT claims decoded from JSON (e.g. via jwx.AsMap) carry arrays as []interface{}. + // JWT claims decoded from JSON (e.g. via jwx.ClaimsAsMap) carry arrays as []interface{}. if len(typedValue) == 1 { if str, ok := typedValue[0].(string); ok { return str diff --git a/auth/api/iam/params_test.go b/auth/api/iam/params_test.go index 6af6fb9a6f..5035407b4b 100644 --- a/auth/api/iam/params_test.go +++ b/auth/api/iam/params_test.go @@ -35,7 +35,7 @@ func TestOauthParameters_get(t *testing.T) { } assert.Equal(t, "value", params.get("string")) assert.Equal(t, "value", params.get("stringSlice")) - // JWT claims decoded from JSON (e.g. the "aud" claim via jwx.AsMap) arrive as []interface{}. + // JWT claims decoded from JSON (e.g. the "aud" claim via jwx.ClaimsAsMap) arrive as []interface{}. assert.Equal(t, "value", params.get("interfaceSlice")) assert.Empty(t, params.get("multiSlice")) assert.Empty(t, params.get("nonStringSlice")) diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 485ea2b1bc..3494c827cb 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -261,7 +261,7 @@ func (hb HTTPClient) OpenIDConfiguration(ctx context.Context, issuerURL string) if err != nil { return nil, fmt.Errorf("unable to parse response: %w", err) } - claims, err := jwx.AsMap(token) + claims, err := jwx.ClaimsAsMap(token) if err != nil { return nil, fmt.Errorf("unable to parse response: %w", err) } diff --git a/auth/services/oauth/authz_server.go b/auth/services/oauth/authz_server.go index 48c588fe9c..76f5971451 100644 --- a/auth/services/oauth/authz_server.go +++ b/auth/services/oauth/authz_server.go @@ -525,8 +525,8 @@ func (s *authzServer) IntrospectAccessToken(ctx context.Context, accessToken str result := &services.NutsAccessToken{} // Extract all claims, then drop the registered ones to mirror jwx v2's token.PrivateClaims. - // jwx.AsMap preserves null-valued claims (a per-claim Get loop errors on null values in v3). - privateClaims, err := jwx.AsMap(token) + // jwx.ClaimsAsMap preserves null-valued claims (a per-claim Get loop errors on null values in v3). + privateClaims, err := jwx.ClaimsAsMap(token) if err != nil { return nil, err } diff --git a/crypto/jwx/claims.go b/crypto/jwx/claims.go index 9723153792..2077a77df7 100644 --- a/crypto/jwx/claims.go +++ b/crypto/jwx/claims.go @@ -18,21 +18,24 @@ package jwx -import "encoding/json" +import ( + "encoding/json" -// AsMap converts a JOSE object (a jwt.Token or jwk.Key) to a map of its fields, keyed by their -// JSON member names. + "github.com/lestrrat-go/jwx/v3/jwt" +) + +// ClaimsAsMap returns a JWT token's claims as a map, keyed by their JSON member names. // -// It replaces jwx v2's AsMap methods, which were removed in v3. Marshaling via JSON (rather -// than iterating keys and calling Get per field) is deliberate: v3's per-field Get returns an -// error for a null-valued member, whereas the JSON round-trip preserves null members as nil - -// matching v2's AsMap behaviour and avoiding rejection of otherwise-valid input. +// It replaces jwx v2's token.AsMap, which was removed in v3. Marshaling via JSON (rather than +// iterating keys and calling Get per claim) is deliberate: v3's per-field Get returns an error +// for a null-valued claim, whereas the JSON round-trip preserves null claims as nil - matching +// v2's AsMap behaviour and avoiding rejection of otherwise-valid tokens. // -// Do NOT use this for jws/jwe protected headers: the JSON round-trip flattens rich header +// For jws/jwe protected headers use HeadersAsMap instead: a JSON round-trip flattens rich header // values (e.g. the x5c certificate chain) into plain JSON types, breaking callers that expect -// the concrete Go types. Use HeadersAsMap for headers. -func AsMap(joseObject any) (map[string]interface{}, error) { - data, err := json.Marshal(joseObject) +// the concrete Go types. +func ClaimsAsMap(token jwt.Token) (map[string]interface{}, error) { + data, err := json.Marshal(token) if err != nil { return nil, err } diff --git a/crypto/storage/spi/interface.go b/crypto/storage/spi/interface.go index e840e8ff2e..de532e7c93 100644 --- a/crypto/storage/spi/interface.go +++ b/crypto/storage/spi/interface.go @@ -31,7 +31,6 @@ import ( "github.com/lestrrat-go/jwx/v3/jwk" "github.com/nuts-foundation/nuts-node/core" - "github.com/nuts-foundation/nuts-node/crypto/jwx" ) // ErrNotFound indicates that the specified crypto storage entry couldn't be found. @@ -78,10 +77,14 @@ type PublicKeyEntry struct { // FromJWK fills the publicKeyEntry with key material from the given key func (pke *PublicKeyEntry) FromJWK(key jwk.Key) error { - asMap, err := jwx.AsMap(key) + data, err := json.Marshal(key) if err != nil { return err } + asMap := make(map[string]interface{}) + if err := json.Unmarshal(data, &asMap); err != nil { + return err + } pke.Key = asMap pke.parsedJWK = key return nil diff --git a/vcr/test/test.go b/vcr/test/test.go index da142012c3..a27b4030ee 100644 --- a/vcr/test/test.go +++ b/vcr/test/test.go @@ -60,7 +60,7 @@ func CreateJWTPresentation(t *testing.T, subjectDID did.DID, tokenVisitor func(t keyStore := nutsCrypto.NewMemoryCryptoInstance(t) _, key, err := keyStore.New(audit.TestContext(), nutsCrypto.StringNamingFunc(kid)) require.NoError(t, err) - claims, err = jwx.AsMap(unsignedToken) + claims, err = jwx.ClaimsAsMap(unsignedToken) require.NoError(t, err) signedToken, err := keyStore.SignJWT(audit.TestContext(), claims, headers, kid) result, err := vc.ParseVerifiablePresentation(signedToken) From 41344036c3247ab5f6a24aba8d88de386ae303e4 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 14:01:43 +0200 Subject: [PATCH 10/12] refactor(jwx): make headersAsMap an unexported crypto-package helper Its only callers are DecryptJWE and ExtractProtectedHeaders in the crypto package, so move it out of the exported crypto/jwx API and unexport it. Drop its unit test. Assisted by AI --- crypto/jwx.go | 31 ++++++++++++++++++++-- crypto/jwx/claims.go | 29 +-------------------- crypto/jwx/claims_test.go | 54 --------------------------------------- 3 files changed, 30 insertions(+), 84 deletions(-) delete mode 100644 crypto/jwx/claims_test.go diff --git a/crypto/jwx.go b/crypto/jwx.go index c0e54be3c0..ea639d2485 100644 --- a/crypto/jwx.go +++ b/crypto/jwx.go @@ -121,7 +121,7 @@ func (client *Crypto) DecryptJWE(ctx context.Context, message string) (body []by if err != nil { return nil, nil, err } - headers = jwx.HeadersAsMap(protectedHeaders) + headers = headersAsMap(protectedHeaders) return body, headers, err } @@ -404,12 +404,39 @@ func ExtractProtectedHeaders(jwt string) (map[string]interface{}, error) { if len(message.Signatures()) != 1 { return nil, ErrorInvalidNumberOfSignatures } - headers = jwx.HeadersAsMap(message.Signatures()[0].ProtectedHeaders()) + headers = headersAsMap(message.Signatures()[0].ProtectedHeaders()) } } return headers, nil } +// headersAsMap converts jws or jwe protected headers to a map of their members, keyed by JSON +// member name. +// +// Unlike jwx.ClaimsAsMap it iterates the members with Get, preserving the concrete Go types of +// rich members such as the x5c certificate chain (a JSON round-trip would flatten those). It +// replaces jwx v2's Headers.AsMap: a null-valued member is stored as nil rather than causing an +// error, because v3's per-field Get fails on a null value and would otherwise reject an +// otherwise-valid header set. +func headersAsMap(headers interface { + Keys() []string + Get(string, any) error +}) map[string]interface{} { + result := make(map[string]interface{}) + for _, k := range headers.Keys() { + var v interface{} + if err := headers.Get(k, &v); err != nil { + // k comes from Keys() so the member exists, and the destination is interface{}, + // which accepts any non-nil value; the only failure mode is a null-valued member. + // Represent it as nil, matching v2's Headers.AsMap. + result[k] = nil + continue + } + result[k] = v + } + return result +} + func (client *Crypto) getPrivateKey(ctx context.Context, kid string) (crypto.Signer, string, error) { keyRef, err := client.findKeyReferenceByKid(ctx, kid) if err != nil { diff --git a/crypto/jwx/claims.go b/crypto/jwx/claims.go index 2077a77df7..8c32f847fa 100644 --- a/crypto/jwx/claims.go +++ b/crypto/jwx/claims.go @@ -31,7 +31,7 @@ import ( // for a null-valued claim, whereas the JSON round-trip preserves null claims as nil - matching // v2's AsMap behaviour and avoiding rejection of otherwise-valid tokens. // -// For jws/jwe protected headers use HeadersAsMap instead: a JSON round-trip flattens rich header +// This is unsuitable for jws/jwe protected headers: a JSON round-trip flattens rich header // values (e.g. the x5c certificate chain) into plain JSON types, breaking callers that expect // the concrete Go types. func ClaimsAsMap(token jwt.Token) (map[string]interface{}, error) { @@ -45,30 +45,3 @@ func ClaimsAsMap(token jwt.Token) (map[string]interface{}, error) { } return result, nil } - -// HeadersAsMap converts jws or jwe protected headers to a map of their members, keyed by JSON -// member name. -// -// Unlike AsMap it iterates the members with Get, preserving the concrete Go types of rich -// members such as the x5c certificate chain (a JSON round-trip would flatten those). It -// replaces jwx v2's Headers.AsMap: a null-valued member is stored as nil rather than causing an -// error, because v3's per-field Get fails on a null value and would otherwise reject an -// otherwise-valid header set. -func HeadersAsMap(headers interface { - Keys() []string - Get(string, any) error -}) map[string]interface{} { - result := make(map[string]interface{}) - for _, k := range headers.Keys() { - var v interface{} - if err := headers.Get(k, &v); err != nil { - // k comes from Keys() so the member exists, and the destination is interface{}, - // which accepts any non-nil value; the only failure mode is a null-valued member. - // Represent it as nil, matching v2's Headers.AsMap. - result[k] = nil - continue - } - result[k] = v - } - return result -} diff --git a/crypto/jwx/claims_test.go b/crypto/jwx/claims_test.go deleted file mode 100644 index fd888e5aa3..0000000000 --- a/crypto/jwx/claims_test.go +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (C) 2024 Nuts community - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package jwx - -import ( - "testing" - - "github.com/lestrrat-go/jwx/v3/jws" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestHeadersAsMap(t *testing.T) { - t.Run("preserves members and represents a null-valued member as nil", func(t *testing.T) { - headers := jws.NewHeaders() - require.NoError(t, headers.Set("string-member", "value")) - require.NoError(t, headers.Set("null-member", nil)) - require.Contains(t, headers.Keys(), "null-member") // sanity: the null member is actually present - - m := HeadersAsMap(headers) - - assert.Equal(t, "value", m["string-member"]) - // A per-field Get errors on the null member; HeadersAsMap must instead store nil. - nilValue, present := m["null-member"] - assert.True(t, present, "null-valued member must be present in the map") - assert.Nil(t, nilValue) - }) - t.Run("keeps the concrete Go type of a rich member (like x5c)", func(t *testing.T) { - headers := jws.NewHeaders() - require.NoError(t, headers.Set("rich-member", []string{"a", "b"})) - - m := HeadersAsMap(headers) - - // A JSON round-trip (jwx.AsMap) would flatten this to []interface{}; HeadersAsMap keeps - // the concrete []string, which is what callers of rich headers such as x5c rely on. - assert.IsType(t, []string{}, m["rich-member"]) - }) -} From 2d0b3b8b2329ee3bde9399e28516c7a40f3fb7e2 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 14:02:39 +0200 Subject: [PATCH 11/12] docs(jwx): drop headers caveat from ClaimsAsMap doc Assisted by AI --- crypto/jwx/claims.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crypto/jwx/claims.go b/crypto/jwx/claims.go index 8c32f847fa..3399d1ddf2 100644 --- a/crypto/jwx/claims.go +++ b/crypto/jwx/claims.go @@ -30,10 +30,6 @@ import ( // iterating keys and calling Get per claim) is deliberate: v3's per-field Get returns an error // for a null-valued claim, whereas the JSON round-trip preserves null claims as nil - matching // v2's AsMap behaviour and avoiding rejection of otherwise-valid tokens. -// -// This is unsuitable for jws/jwe protected headers: a JSON round-trip flattens rich header -// values (e.g. the x5c certificate chain) into plain JSON types, breaking callers that expect -// the concrete Go types. func ClaimsAsMap(token jwt.Token) (map[string]interface{}, error) { data, err := json.Marshal(token) if err != nil { From c38aba30847ff0713f1e498a6b9b89c54e33ac93 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 9 Jul 2026 14:43:26 +0200 Subject: [PATCH 12/12] JWK missing checks --- auth/api/iam/api.go | 5 ++++- crypto/dpop/dpop.go | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 6943c3d1bd..3ce7eb578e 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -402,7 +402,10 @@ func (r Wrapper) introspectAccessToken(input string) (*ExtendedTokenIntrospectio // SHA256 hashing won't fail. var cnf *Cnf if token.DPoP != nil { - key, _ := token.DPoP.Headers.JWK() + key, ok := token.DPoP.Headers.JWK() + if !ok { + return nil, errors.New("DPoP header is missing the jwk") + } hash, _ := key.Thumbprint(crypto.SHA256) base64Hash := base64.RawURLEncoding.EncodeToString(hash) cnf = &Cnf{Jkt: base64Hash} diff --git a/crypto/dpop/dpop.go b/crypto/dpop/dpop.go index cf09e2fa05..fb4e422419 100644 --- a/crypto/dpop/dpop.go +++ b/crypto/dpop/dpop.go @@ -216,7 +216,10 @@ func (t DPoP) HTM() string { // for the url, the port is stripped. // If there is a mismatch, the reason is returned in an error. func (t DPoP) Match(jkt string, method string, url string) (bool, error) { - key, _ := t.Headers.JWK() + key, ok := t.Headers.JWK() + if !ok { + return false, errors.New("missing jwk header") + } tp, _ := key.Thumbprint(crypto.SHA256) base64tp := base64.RawURLEncoding.EncodeToString(tp)