diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml index 40add276be..64f1060c32 100644 --- a/.github/workflows/build-images.yaml +++ b/.github/workflows/build-images.yaml @@ -5,11 +5,13 @@ on: push: branches: - master + - project-gf tags: - 'v*' pull_request: branches: - master + - project-gf # cancel build action if superseded by new commit on same branch concurrency: @@ -51,7 +53,7 @@ jobs: images: nutsfoundation/nuts-node tags: | # generate 'master' tag for the master branch - type=ref,event=branch,enable={{is_default_branch}},prefix= + type=ref,event=branch,enable=true,prefix= # generate 5.2.1 tag type=semver,pattern={{version}} flavor: | diff --git a/Dockerfile b/Dockerfile index 5ce0e91253..5b64c51fcd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,10 @@ FROM alpine:3.23.3 RUN apk update \ && apk add --no-cache \ tzdata \ - curl + curl \ + ca-certificates +COPY pki/cacerts/* /usr/local/share/ca-certificates/ +RUN update-ca-certificates COPY --from=builder /opt/nuts/nuts /usr/bin/nuts HEALTHCHECK --start-period=30s --timeout=5s --interval=10s \ diff --git a/LSPxNuts_README.md b/LSPxNuts_README.md new file mode 100644 index 0000000000..169f44b691 --- /dev/null +++ b/LSPxNuts_README.md @@ -0,0 +1,16 @@ +# LSPxNuts Proof of Concept + +This is a branch that for the Proof of Concept of the LSPxNuts project. + +It adds or alters the following functionality versus the mainstream Nuts node: + +- OAuth2 `vp_bearer` token exchange: read presentation definition from local definitions instead of fetching it from the remote authorization server. + LSP doesn't support presentation definitions, meaning that we need to look it up locally. +- Add support for JWT bearer grant type. If the server supports this, it uses this grant type instead of the Nuts-specific vp_token-bearer grant type. +- Add CA certificates of Sectigo (root CA, OV and EV intermediate CA) to Docker image's OS CA bundle, because they're used by AORTA-LSP. +- Fix marshalling of Verifiable Presentations in JWT format; `type` was marshalled as JSON-LD (single-entry-array was replaced by string) +- Add `policy_id` field to access token request to specify the Presentation Definition that should be used. + The `scope` can then be specified as whatever the use case requires (e.g. SMART on FHIR-esque scopes). +- Relax `did:x509` key usage check: the certificate from UZI smart cards that is used to sign credentials, doesn't have `serverAuth` key usage, only `digitalSignature`. + This broke, since we didn't specify the key usage, but `x509.Verify()` expects key usage `serverAuth` to be present by default. +- Add support for `RS256` (RSA 2048) signatures, since that's what UZI smart cards produce. \ No newline at end of file diff --git a/README.rst b/README.rst index 0f4b18cfdd..b0c69c3fb6 100644 --- a/README.rst +++ b/README.rst @@ -217,14 +217,17 @@ The following options can be configured on the server: storage.session.redis.sentinel.username Username for authenticating to Redis Sentinels. storage.session.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis session servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). storage.sql.connection Connection string for the SQL database. If not set it, defaults to a SQLite database stored inside the configured data directory. Note: using SQLite is not recommended in production environments. If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL'). + storage.sql.rdsiam.dbuser Database username for IAM authentication. If not specified, the username from the connection string will be used. The database user must be created with IAM authentication enabled. storage.sql.rdsiam.enabled false Enable AWS RDS IAM authentication for the SQL database connection. When enabled, the node will use temporary IAM tokens instead of passwords. Requires the connection string to be a PostgreSQL or MySQL RDS endpoint without a password. storage.sql.rdsiam.region AWS region where the RDS instance is located (e.g., 'us-east-1). Required when RDS IAM authentication is enabled. - storage.sql.rdsiam.dbuser Database username for IAM authentication. If not specified, the username from the connection string will be used. The database user must be created with IAM authentication enabled. - storage.sql.rdsiam.tokenrefreshinterval 14m0s Interval at which to refresh the IAM authentication token. RDS tokens are valid for 15 minutes, so the default is 14 minutes to ensure tokens are refreshed before expiry. Specified as Golang duration (e.g. 10m, 1h). + storage.sql.rdsiam.tokenrefreshinterval 14m0s Interval at which to refresh the IAM authentication token. RDS tokens are valid for 15 minutes, so set this to ensure tokens are refreshed before expiry. Specified as Golang duration (e.g. 10m, 1h). **Tracing** tracing.endpoint OTLP collector endpoint for OpenTelemetry tracing (e.g., 'localhost:4318'). When empty, tracing is disabled. tracing.insecure false Disable TLS for the OTLP connection. tracing.servicename Service name reported to the tracing backend. Defaults to 'nuts-node'. + **VCR** + vcr.dezi.allowedjku [] List of allowed JKU URLs for fetching Dezi attestation keys. If not set, defaults to production (https://auth.dezi.nl/dezi/jwks.json), and in non-strict mode also acceptance (https://acceptatie.auth.dezi.nl/dezi/jwks.json). + vcr.verifier.revocation.maxage 15m0s Max age of revocation information. If the revocation information is older than this, it will be refreshed from the issuer. If set to 0 or negative, revocation information will always be refreshed. **policy** policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 339d4b0086..e474bba650 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -29,7 +29,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/core/to" "html/template" "net/http" "net/url" @@ -37,6 +36,9 @@ import ( "strings" "time" + "github.com/nuts-foundation/nuts-node/core/to" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" @@ -729,31 +731,48 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS return nil, err } - tokenCache := r.accessTokenCache() - cacheKey := accessTokenRequestCacheKey(request) - if request.Params.CacheControl == nil || *request.Params.CacheControl != "no-cache" { - // try to retrieve token from cache - tokenCacheResult := new(TokenResponse) - err = tokenCache.Get(cacheKey, tokenCacheResult) - if err == nil { - // adjust tokenCacheResult.ExpiresIn to the remaining time - expiresAt := time.Unix(int64(*tokenCacheResult.ExpiresAt), 0) - tokenCacheResult.ExpiresIn = to.Ptr(int(time.Until(expiresAt).Seconds())) - return RequestServiceAccessToken200JSONResponse(*tokenCacheResult), nil - } else if !errors.Is(err, storage.ErrNotFound) { - // only log error, don't fail - log.Logger().WithError(err).Warnf("Failed to retrieve access token from cache: %s", err.Error()) - } - } + // PROJECT-GF: Disabled for testing credential revocation + //tokenCache := r.accessTokenCache() + //cacheKey := accessTokenRequestCacheKey(request) + //if request.Params.CacheControl == nil || *request.Params.CacheControl != "no-cache" { + // // try to retrieve token from cache + // tokenCacheResult := new(TokenResponse) + // err = tokenCache.Get(cacheKey, tokenCacheResult) + // if err == nil { + // // adjust tokenCacheResult.ExpiresIn to the remaining time + // expiresAt := time.Unix(int64(*tokenCacheResult.ExpiresAt), 0) + // tokenCacheResult.ExpiresIn = to.Ptr(int(time.Until(expiresAt).Seconds())) + // return RequestServiceAccessToken200JSONResponse(*tokenCacheResult), nil + // } else if !errors.Is(err, storage.ErrNotFound) { + // // only log error, don't fail + // log.Logger().WithError(err).Warnf("Failed to retrieve access token from cache: %s", err.Error()) + // } + //} var credentials []VerifiableCredential if request.Body.Credentials != nil { credentials = *request.Body.Credentials } + + idTokenCredentialIdx := -1 + if request.Body.IdToken != nil { + idTokenCredential, err := credential.CreateDeziUserCredential(*request.Body.IdToken) + if err != nil { + return nil, core.InvalidInputError("failed to create id_token credential: %w", err) + } + credentials = append(credentials, *idTokenCredential) + idTokenCredentialIdx = len(credentials) - 1 + } + // assert that self-asserted credentials do not contain an issuer or credentialSubject.id. These values must be set // by the nuts-node to build the correct wallet for a DID. See https://github.com/nuts-foundation/nuts-node/issues/3696 - // As a sideeffect it is no longer possible to pass signed credentials to this API. - for _, cred := range credentials { + // As a side effect it is no longer possible to pass signed credentials to this API. + for i, cred := range credentials { + // But not for id_token credentials, these are externally signed, meaning they have an issuer + if i == idTokenCredentialIdx { + continue + } + var credentialSubject []map[string]interface{} if err := cred.UnmarshalCredentialSubject(&credentialSubject); err != nil { // extremely unlikely @@ -774,7 +793,11 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS useDPoP = false } clientID := r.subjectToBaseURL(request.SubjectID) - tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials) + var policyId string + if request.Body.PolicyId != nil { + policyId = *request.Body.PolicyId + } + tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, policyId, useDPoP, credentials) if err != nil { // this can be an internal server error, a 400 oauth error or a 412 precondition failed if the wallet does not contain the required credentials return nil, err @@ -785,12 +808,13 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS } tokenResult.ExpiresAt = to.Ptr(int(time.Now().Add(ttl).Unix())) // we reduce the ttl by accessTokenCacheOffset to make sure the token is expired when the cache expires - ttl -= accessTokenCacheOffset - err = tokenCache.Put(cacheKey, tokenResult, storage.WithTTL(ttl)) - if err != nil { - // only log error, don't fail - log.Logger().WithError(err).Warnf("Failed to cache access token: %s", err.Error()) - } + // PROJECT-GF: Disabled for testing credential revocation + //ttl -= accessTokenCacheOffset + //err = tokenCache.Put(cacheKey, tokenResult, storage.WithTTL(ttl)) + //if err != nil { + // // only log error, don't fail + // log.Logger().WithError(err).Warnf("Failed to cache access token: %s", err.Error()) + //} return RequestServiceAccessToken200JSONResponse(*tokenResult), nil } diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 35efca16da..34bfe88c35 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -877,6 +877,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { } t.Run("ok - bypass cache (client uses Cache-Control: no-cache)", func(t *testing.T) { + t.Skip("PROJECT-GF: Disabled for testing credential revocation") ctx := newTestClient(t) response := &oauth.TokenResponse{ AccessToken: "token", @@ -886,7 +887,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} request.Params.CacheControl = to.Ptr("no-cache") // Initial call to populate cache - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(response, nil).Times(2) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(response, nil).Times(2) token, err := ctx.client.RequestServiceAccessToken(nil, request) // Test call to check cache is bypassed @@ -907,13 +908,14 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { TokenType: "Bearer", ExpiresIn: to.Ptr(900), } - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(response, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(response, nil) token, err := ctx.client.RequestServiceAccessToken(nil, request) require.NoError(t, err) t.Run("is cached", func(t *testing.T) { + t.Skip("PROJECT-GF: Disabled for testing credential revocation") cachedToken, err := ctx.client.RequestServiceAccessToken(nil, request) require.NoError(t, err) @@ -925,6 +927,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { }) t.Run("check expires_at reduction", func(t *testing.T) { + t.Skip("PROJECT-GF: Disabled for testing credential revocation") // get current cached value and adjust ExpiresAt cacheKey := accessTokenRequestCacheKey(request) var cachedTokenResponse TokenResponse @@ -944,9 +947,10 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { }) t.Run("cache expired", func(t *testing.T) { + t.Skip("PROJECT-GF: Disabled for testing credential revocation") cacheKey := accessTokenRequestCacheKey(request) _ = ctx.client.accessTokenCache().Delete(cacheKey) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) otherToken, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -963,7 +967,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { Scope: "first second", TokenType: &tokenTypeBearer, } - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil).Return(&oauth.TokenResponse{}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", false, nil).Return(&oauth.TokenResponse{}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -972,7 +976,36 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { t.Run("ok with expired cache by ttl", func(t *testing.T) { ctx := newTestClient(t) request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) + + _, err := ctx.client.RequestServiceAccessToken(nil, request) + + require.NoError(t, err) + assert.False(t, ctx.client.accessTokenCache().Exists(accessTokenRequestCacheKey(request))) + }) + t.Run("with Dezi id_token", func(t *testing.T) { + ctx := newTestClient(t) + idToken := "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ" + request := RequestServiceAccessTokenRequestObject{ + SubjectID: holderSubjectID, + Body: &RequestServiceAccessTokenJSONRequestBody{ + AuthorizationServer: verifierURL.String(), + Scope: "first second", + IdToken: to.Ptr(idToken), + }, + } + + // Expect that the id_token is converted to a Dezi credential and passed to RequestRFC021AccessToken + ctx.iamClient.EXPECT().RequestRFC021AccessToken( + nil, + holderClientID, + holderSubjectID, + verifierURL.String(), + "first second", + "", // policyId + true, + gomock.Any(), // The id_token is converted to a DeziUserCredential + ).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -981,7 +1014,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { }) t.Run("error - no matching credentials", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(nil, pe.ErrNoCredentials) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(nil, pe.ErrNoCredentials) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -990,6 +1023,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { assert.Equal(t, http.StatusPreconditionFailed, statusCodeFrom(err)) }) t.Run("broken cache", func(t *testing.T) { + t.Skip("PROJECT-GF: Disabled for testing credential revocation") ctx := newTestClient(t) mockStorage := storage.NewMockEngine(ctx.ctrl) errorSessionDatabase := storage.NewErrorSessionDatabase(assert.AnError) @@ -997,8 +1031,8 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { ctx.client.storageEngine = mockStorage request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) token1, err := ctx.client.RequestServiceAccessToken(nil, request) require.NoError(t, err) @@ -1023,7 +1057,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { {ID: to.Ptr(ssi.MustParseURI("not empty"))}, } request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials).Return(response, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, *body.Credentials).Return(response, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 5dbe21544d..b106c58fa6 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -146,6 +146,17 @@ type ServiceAccessTokenRequest struct { // - proof/signature (MUST be omitted; integrity protection is covered by the VP's proof/signature) Credentials *[]VerifiableCredential `json:"credentials,omitempty"` + // IdToken An optional ID Token (JWT) that represents the end-user. + // This ID token is included in the Verifiable Presentation that is used to request the access token. + // It currently only supports Dezi ID tokens. + IdToken *string `json:"id_token,omitempty"` + + // PolicyId (Optional) The ID of the policy to use when requesting the access token. + // If set the presentation definition is resolved from the policy with this ID. + // This allows you to specify scopes that don't resolve to a presentation definition automatically. + // If not set, the scope is used to resolve the presentation definition. + PolicyId *string `json:"policy_id,omitempty"` + // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` diff --git a/auth/auth.go b/auth/auth.go index f135335c01..6244e478f6 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -21,7 +21,13 @@ package auth import ( "crypto/tls" "errors" + "net/url" + "path" + "slices" + "time" + "github.com/nuts-foundation/nuts-node/auth/client/iam" + "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/didjwk" "github.com/nuts-foundation/nuts-node/vdr/didkey" @@ -30,10 +36,6 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/nuts-foundation/nuts-node/vdr/didx509" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "net/url" - "path" - "slices" - "time" "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/auth/services/notary" @@ -58,6 +60,7 @@ type Auth struct { relyingParty oauth.RelyingParty contractNotary services.ContractNotary serviceResolver didman.CompoundServiceResolver + policyBackend policy.PDPBackend keyStore crypto.KeyStore vcr vcr.VCR pkiProvider pki.Provider @@ -100,12 +103,13 @@ func (auth *Auth) ContractNotary() services.ContractNotary { // NewAuthInstance accepts a Config with several Nuts Engines and returns an instance of Auth func NewAuthInstance(config Config, vdrInstance vdr.VDR, subjectManager didsubject.Manager, vcr vcr.VCR, keyStore crypto.KeyStore, - serviceResolver didman.CompoundServiceResolver, jsonldManager jsonld.JSONLD, pkiProvider pki.Provider) *Auth { + serviceResolver didman.CompoundServiceResolver, jsonldManager jsonld.JSONLD, pkiProvider pki.Provider, policyBackend policy.PDPBackend) *Auth { return &Auth{ config: config, jsonldManager: jsonldManager, vdrInstance: vdrInstance, subjectManager: subjectManager, + policyBackend: policyBackend, keyStore: keyStore, vcr: vcr, pkiProvider: pkiProvider, @@ -126,7 +130,7 @@ func (auth *Auth) RelyingParty() oauth.RelyingParty { func (auth *Auth) IAMClient() iam.Client { keyResolver := resolver.DIDKeyResolver{Resolver: auth.vdrInstance.Resolver()} - return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.strictMode, auth.httpClientTimeout) + return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.policyBackend, auth.strictMode, auth.httpClientTimeout) } // Configure the Auth struct by creating a validator and create an Irma server diff --git a/auth/auth_test.go b/auth/auth_test.go index 968ea61ef8..baf0fe4840 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -19,6 +19,8 @@ package auth import ( + "testing" + "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/jsonld" @@ -28,7 +30,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "testing" ) func TestAuth_Configure(t *testing.T) { @@ -47,7 +48,7 @@ func TestAuth_Configure(t *testing.T) { vdrInstance := vdr.NewMockVDR(ctrl) vdrInstance.EXPECT().Resolver().AnyTimes() - i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock) + i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock, nil) require.NoError(t, i.Configure(tlsServerConfig)) }) @@ -61,7 +62,7 @@ func TestAuth_Configure(t *testing.T) { vdrInstance := vdr.NewMockVDR(ctrl) vdrInstance.EXPECT().Resolver().AnyTimes() - i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock) + i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock, nil) require.NoError(t, i.Configure(tlsServerConfig)) }) @@ -119,7 +120,7 @@ func TestAuth_IAMClient(t *testing.T) { vdrInstance := vdr.NewMockVDR(ctrl) vdrInstance.EXPECT().Resolver().AnyTimes() - i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, jsonld.NewTestJSONLDManager(t), pkiMock) + i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, jsonld.NewTestJSONLDManager(t), pkiMock, nil) assert.NotNil(t, i.IAMClient()) }) diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 0020fea550..d998349310 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -24,16 +24,17 @@ import ( "encoding/json" "errors" "fmt" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" - "github.com/nuts-foundation/nuts-node/crypto" - "github.com/nuts-foundation/nuts-node/vdr/resolver" "io" "net/http" "net/url" "strings" "time" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -205,11 +206,22 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, data return token, oauthError } + // TODO: Remove this when Itzos fixed their Token Response + type LenientTokenResponse struct { + AccessToken string `json:"access_token"` + DPoPKid *string `json:"dpop_kid,omitempty"` + ExpiresAt *any `json:"expires_at,omitempty"` + ExpiresIn *any `json:"expires_in,omitempty"` + TokenType string `json:"token_type"` + Scope *string `json:"scope,omitempty"` + } + var responseData []byte if responseData, err = io.ReadAll(response.Body); err != nil { return token, fmt.Errorf("unable to read response: %w", err) } - if err = json.Unmarshal(responseData, &token); err != nil { + var lenientToken LenientTokenResponse + if err = json.Unmarshal(responseData, &lenientToken); err != nil { // Cut off the response body to 100 characters max to prevent logging of large responses responseBodyString := string(responseData) if len(responseBodyString) > core.HttpResponseBodyLogClipAt { @@ -217,9 +229,43 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, data } return token, fmt.Errorf("unable to unmarshal response: %w, %s", err, responseBodyString) } + token.AccessToken = lenientToken.AccessToken + token.DPoPKid = lenientToken.DPoPKid + token.TokenType = lenientToken.TokenType + token.Scope = lenientToken.Scope + token.ExpiresAt, err = toInt(lenientToken.ExpiresAt) + if err != nil { + return token, fmt.Errorf("unable to parse expires_at: %w", err) + } + token.ExpiresIn, err = toInt(lenientToken.ExpiresIn) + if err != nil { + return token, fmt.Errorf("unable to parse expires_in: %w", err) + } + return token, nil } +func toInt(value *any) (*int, error) { + // handle expires_in which can be int or string + if value == nil { + return nil, nil + } + switch v := (*value).(type) { + case float64: + intValue := int(v) + return &intValue, nil + case string: + var intValue int + _, err := fmt.Sscanf(v, "%d", &intValue) + if err != nil { + return nil, fmt.Errorf("unable to parse string to int: %w", err) + } + return &intValue, nil + default: + return nil, fmt.Errorf("unable to parse value of type %T to int", v) + } +} + // PostError posts an OAuth error to the redirect URL and returns the redirect URL with the error as query parameter. func (hb HTTPClient) PostError(ctx context.Context, err oauth.OAuth2Error, verifierCallbackURL url.URL) (string, error) { // initiate http client, create a POST request with x-www-form-urlencoded body and send it to the redirect URL diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 5016708d9d..3065de01f2 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -20,6 +20,7 @@ package iam import ( "context" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -44,7 +45,7 @@ type Client interface { // PresentationDefinition returns the presentation definition from the given endpoint. PresentationDefinition(ctx context.Context, endpoint string) (*pe.PresentationDefinition, error) // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server using Nuts RFC021. - RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, + RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, policyId string, useDPoP bool, credentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer. diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index add1a059cd..1d49452ebb 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -194,18 +194,18 @@ func (mr *MockClientMockRecorder) RequestObjectByPost(ctx, requestURI, walletMet } // RequestRFC021AccessToken mocks base method. -func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { +func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes, policyId string, useDPoP bool, credentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials) + ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials) ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // RequestRFC021AccessToken indicates an expected call of RequestRFC021AccessToken. -func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials any) *gomock.Call { +func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials) } // VerifiableCredentials mocks base method. diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 0f9e370601..d3f54e62b2 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -24,16 +24,18 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/http/client" - "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vdr/didsubject" - "github.com/piprate/json-gold/ld" "maps" "net/http" "net/url" "slices" "time" + "github.com/nuts-foundation/nuts-node/http/client" + "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/piprate/json-gold/ld" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/log" @@ -60,11 +62,12 @@ type OpenID4VPClient struct { wallet holder.Wallet ldDocumentLoader ld.DocumentLoader subjectManager didsubject.Manager + policyBackend policy.PDPBackend } // NewClient returns an implementation of Holder func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectManager didsubject.Manager, jwtSigner nutsCrypto.JWTSigner, - ldDocumentLoader ld.DocumentLoader, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient { + ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient { return &OpenID4VPClient{ httpClient: HTTPClient{ strictMode: strictMode, @@ -77,6 +80,7 @@ func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectMa subjectManager: subjectManager, strictMode: strictMode, wallet: wallet, + policyBackend: policyBackend, } } @@ -235,24 +239,44 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd } func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, - useDPoP bool, additionalCredentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { + policyId string, useDPoP bool, additionalCredentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { iamClient := c.httpClient metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { return nil, err } - // get the presentation definition from the verifier - parsedURL, err := core.ParsePublicURL(metadata.PresentationDefinitionEndpoint, c.strictMode) - if err != nil { - return nil, err + // if no policyId is provided, use the scopes as policyId + if policyId == "" { + policyId = scopes } - presentationDefinitionURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{ - "scope": scopes, - }) - presentationDefinition, err := c.PresentationDefinition(ctx, presentationDefinitionURL.String()) - if err != nil { + // LSPxNuts: get the presentation definition from local definitions, if available + var presentationDefinition *pe.PresentationDefinition + presentationDefinitionMap, err := c.policyBackend.PresentationDefinitions(ctx, policyId) + if errors.Is(err, policy.ErrNotFound) { + // not found locally, get from verifier + // get the presentation definition from the verifier + parsedURL, err := core.ParsePublicURL(metadata.PresentationDefinitionEndpoint, c.strictMode) + if err != nil { + return nil, err + } + presentationDefinitionURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{ + "scope": policyId, + }) + presentationDefinition, err = c.PresentationDefinition(ctx, presentationDefinitionURL.String()) + if err != nil { + return nil, err + } + } else if err != nil { return nil, err + } else { + // found locally + if len(presentationDefinitionMap) != 1 { + return nil, fmt.Errorf("expected exactly one presentation definition for policy/scope '%s', found %d", policyId, len(presentationDefinitionMap)) + } + for _, pd := range presentationDefinitionMap { + presentationDefinition = &pd + } } params := holder.BuildParams{ @@ -309,10 +333,16 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID presentationSubmission, _ := json.Marshal(submission) data := url.Values{} data.Set(oauth.ClientIDParam, clientID) - data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType) data.Set(oauth.AssertionParam, assertion) - data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) data.Set(oauth.ScopeParam, scopes) + if slices.Contains(metadata.GrantTypesSupported, oauth.JWTBearerGrantType) { + // use JWT bearer grant type (e.g. authenticating at LSP GtK) + data.Set(oauth.GrantTypeParam, oauth.JWTBearerGrantType) + } else { + // use VP token grant type (as per Nuts RFC021) as default and fallback + data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType) + data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) + } // create DPoP header var dpopHeader string diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index e5c4ef6840..28f39b372b 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -24,22 +24,24 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/http/client" - test2 "github.com/nuts-foundation/nuts-node/test" - "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vdr/didsubject" "net/http" "net/http/httptest" "net/url" "testing" "time" + "github.com/nuts-foundation/nuts-node/http/client" + test2 "github.com/nuts-foundation/nuts-node/test" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vdr/didsubject" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/policy" http2 "github.com/nuts-foundation/nuts-node/test/http" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -252,20 +254,36 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) assert.NoError(t, err) require.NotNil(t, response) assert.Equal(t, "token", response.AccessToken) assert.Equal(t, "bearer", response.TokenType) }) + t.Run("ok with policy ID that differs from scope", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), "some-policy").Return(nil, policy.ErrNotFound) + + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "some-policy", false, nil) + + assert.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "token", response.AccessToken) + assert.Equal(t, "bearer", response.TokenType) + assert.Equal(t, "first second", *response.Scope) + }) t.Run("no DID fulfills the Presentation Definition", func(t *testing.T) { ctx := createClientServerTestContext(t) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) assert.ErrorIs(t, err, pe.ErrNoCredentials) assert.Nil(t, response) @@ -274,8 +292,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.authzServerMetadata.DIDMethodsSupported = []string{"other"} ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrPreconditionFailed) @@ -285,6 +304,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { t.Run("with additional credentials", func(t *testing.T) { ctx := createClientServerTestContext(t) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) credentials := []vc.VerifiableCredential{ { Context: []ssi.URI{ @@ -312,7 +332,55 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return createdVP, &pe.PresentationSubmission{}, nil }) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, credentials) + + assert.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "token", response.AccessToken) + assert.Equal(t, "bearer", response.TokenType) + }) + t.Run("with Dezi credential", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) + + // Create a Dezi credential from an id_token + idToken := "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ" + deziCredential, err := credential.CreateDeziUserCredential(idToken) + require.NoError(t, err) + + credentials := []vc.VerifiableCredential{*deziCredential} + + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + // Assert Dezi credentials are NOT self-attested (they have an issuer) + require.Len(t, additionalCredentials, 2) + require.Len(t, additionalCredentials[primaryWalletDID], 1) + // Dezi credentials have their own issuer, not the wallet DID + assert.Equal(t, "https://abonnee.dezi.nl", additionalCredentials[primaryWalletDID][0].Issuer.String()) + assert.Contains(t, additionalCredentials[primaryWalletDID][0].Type, ssi.MustParseURI("DeziUserCredential")) + require.Len(t, additionalCredentials[secondaryWalletDID], 1) + assert.Equal(t, "https://abonnee.dezi.nl", additionalCredentials[secondaryWalletDID][0].Issuer.String()) + assert.Contains(t, additionalCredentials[secondaryWalletDID][0].Type, ssi.MustParseURI("DeziUserCredential")) + return createdVP, &pe.PresentationSubmission{}, nil + }) + + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, credentials) + + assert.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "token", response.AccessToken) + assert.Equal(t, "bearer", response.TokenType) + }) + t.Run("grant_type urn:ietf:params:oauth:grant-type:jwt-bearer", func(t *testing.T) { + ctx := createClientServerTestContext(t) + // Set the authorization server to support JWT Bearer grant type + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JWTBearerGrantType} + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) + + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -325,14 +393,27 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), primaryKID).Return("dpop", nil) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", true, nil) assert.NoError(t, err) require.NotNil(t, response) assert.Equal(t, "token", response.AccessToken) assert.Equal(t, "bearer", response.TokenType) }) + t.Run("with Presentation Definition from local policy backend", func(t *testing.T) { + ctx := createClientServerTestContext(t) + pd := pe.PresentationDefinition{Name: "pd-id"} + ctx.clientTestContext.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pd, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), pd, gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) + assert.NoError(t, err) + require.NotNil(t, response) + }) t.Run("error - access denied", func(t *testing.T) { oauthError := oauth.OAuth2Error{ Code: "invalid_scope", @@ -347,8 +428,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { } ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) oauthError, ok := err.(oauth.OAuth2Error) @@ -357,6 +439,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }) t.Run("error - failed to get presentation definition", func(t *testing.T) { ctx := createClientServerTestContext(t) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) ctx.presentationDefinition = func(writer http.ResponseWriter) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusBadRequest) @@ -365,7 +448,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) assert.True(t, errors.As(err, &oauth.OAuth2Error{})) @@ -375,7 +458,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.metadata = nil - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidClientCall) @@ -383,13 +466,14 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }) t.Run("error - faulty presentation definition", func(t *testing.T) { ctx := createClientServerTestContext(t) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) ctx.presentationDefinition = func(writer http.ResponseWriter) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) _, _ = writer.Write([]byte("{")) } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrBadGateway) @@ -397,10 +481,11 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }) t.Run("error - failed to build vp", func(t *testing.T) { ctx := createClientServerTestContext(t) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, assert.AnError) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) assert.Error(t, err) }) @@ -477,6 +562,7 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon tlsConfig = &tls.Config{} } tlsConfig.InsecureSkipVerify = true + policyBackend := policy.NewMockPDPBackend(ctrl) return &clientTestContext{ audit: audit.TestContext(), @@ -488,13 +574,15 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon strictMode: false, httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), }, - jwtSigner: jwtSigner, - keyResolver: keyResolver, + jwtSigner: jwtSigner, + keyResolver: keyResolver, + policyBackend: policyBackend, }, jwtSigner: jwtSigner, keyResolver: keyResolver, wallet: wallet, subjectManager: subjectManager, + policyBackend: policyBackend, } } @@ -506,6 +594,7 @@ type clientTestContext struct { keyResolver *resolver.MockKeyResolver wallet *holder.MockWallet subjectManager *didsubject.MockManager + policyBackend *policy.MockPDPBackend } type clientServerTestContext struct { diff --git a/auth/oauth/openid.go b/auth/oauth/openid.go index 97572de798..ec6803a07b 100644 --- a/auth/oauth/openid.go +++ b/auth/oauth/openid.go @@ -24,7 +24,7 @@ import ( // proofTypeValuesSupported contains a list of supported cipher suites for ldp_vc & ldp_vp presentation formats // Recommended list of options https://w3c-ccg.github.io/ld-cryptosuite-registry/ -var proofTypeValuesSupported = []string{"JsonWebSignature2020"} +var proofTypeValuesSupported = []string{"JsonWebSignature2020", "DeziIDJWT"} // DefaultOpenIDSupportedFormats returns the OpenID formats supported by the Nuts node and is used in the // - Authorization Server's metadata field `vp_formats_supported` diff --git a/auth/oauth/types.go b/auth/oauth/types.go index c0a6d769d2..f7f09c4703 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -21,9 +21,10 @@ package oauth import ( "encoding/json" + "net/url" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/nuts-node/core" - "net/url" ) // this file contains constants, variables and helper functions for OAuth related code @@ -205,6 +206,8 @@ const ( PreAuthorizedCodeGrantType = "urn:ietf:params:oauth:grant-type:pre-authorized_code" // VpTokenGrantType is the grant_type for the vp_token-bearer grant type. (RFC021) VpTokenGrantType = "vp_token-bearer" + // JWTBearerGrantType is the grant_type for the jwt-bearer grant type. (RFC7523) + JWTBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" ) // response types diff --git a/auth/services/oauth/authz_server_test.go b/auth/services/oauth/authz_server_test.go index bb0cbc0771..335a85f810 100644 --- a/auth/services/oauth/authz_server_test.go +++ b/auth/services/oauth/authz_server_test.go @@ -27,11 +27,12 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/audit" - "github.com/nuts-foundation/nuts-node/vdr/resolver" "testing" "time" + "github.com/nuts-foundation/nuts-node/audit" + "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/v2/jwt" @@ -574,6 +575,7 @@ func TestService_parseAndValidateJwtBearerToken(t *testing.T) { }) t.Run("wrong signing algorithm", func(t *testing.T) { + t.Skip("LSPxNuts: enabled RS256 support") t.Setenv("GODEBUG", "rsa1024min=0") // minimum key-length has changed to 1024 -> https://pkg.go.dev/crypto/rsa#hdr-Minimum_key_size privateKey, err := rsa.GenerateKey(rand.Reader, 512) require.NoError(t, err) diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go index e78160a86c..50fede79b6 100644 --- a/auth/services/oauth/relying_party.go +++ b/auth/services/oauth/relying_party.go @@ -22,11 +22,12 @@ import ( "context" "crypto/tls" "fmt" - "github.com/nuts-foundation/nuts-node/pki" "net/url" "strings" "time" + "github.com/nuts-foundation/nuts-node/pki" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth/api/auth/v1/client" diff --git a/auth/test.go b/auth/test.go index 4142046cdf..448668ad5c 100644 --- a/auth/test.go +++ b/auth/test.go @@ -19,9 +19,11 @@ package auth import ( + "testing" + + "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/didsubject" - "testing" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/pki" @@ -44,5 +46,5 @@ func testInstance(t *testing.T, cfg Config) *Auth { vdrInstance := vdr.NewMockVDR(ctrl) vdrInstance.EXPECT().Resolver().AnyTimes() subjectManager := didsubject.NewMockManager(ctrl) - return NewAuthInstance(cfg, vdrInstance, subjectManager, vcrInstance, cryptoInstance, nil, nil, pkiMock) + return NewAuthInstance(cfg, vdrInstance, subjectManager, vcrInstance, cryptoInstance, nil, nil, pkiMock, policy.NewMockPDPBackend(ctrl)) } diff --git a/cmd/root.go b/cmd/root.go index b2737c9b3a..75cdc10897 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,13 +23,14 @@ import ( "context" "errors" "fmt" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "github.com/spf13/pflag" "io" "os" "runtime/pprof" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/nuts-foundation/nuts-node/auth" authAPIv1 "github.com/nuts-foundation/nuts-node/auth/api/auth/v1" authIAMAPI "github.com/nuts-foundation/nuts-node/auth/api/iam" @@ -201,11 +202,11 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { credentialInstance := vcr.NewVCRInstance(cryptoInstance, vdrInstance, networkInstance, jsonld, eventManager, storageInstance, pkiInstance) didmanInstance := didman.NewDidmanInstance(vdrInstance, credentialInstance, jsonld) discoveryInstance := discovery.New(storageInstance, credentialInstance, vdrInstance, vdrInstance) - authInstance := auth.NewAuthInstance(auth.DefaultConfig(), vdrInstance, vdrInstance, credentialInstance, cryptoInstance, didmanInstance, jsonld, pkiInstance) + policyInstance := policy.New() + authInstance := auth.NewAuthInstance(auth.DefaultConfig(), vdrInstance, vdrInstance, credentialInstance, cryptoInstance, didmanInstance, jsonld, pkiInstance, policyInstance) statusEngine := status.NewStatusEngine(system) metricsEngine := core.NewMetricsEngine() goldenHammer := golden_hammer.New(vdrInstance, didmanInstance) - policyInstance := policy.New() // Register HTTP routes didKeyResolver := resolver.DIDKeyResolver{Resolver: vdrInstance.Resolver()} diff --git a/core/tls.go b/core/tls.go index 3ef10babaf..e36fe6a0d5 100644 --- a/core/tls.go +++ b/core/tls.go @@ -71,6 +71,14 @@ type TrustStore struct { certificates []*x509.Certificate } +// PinCertificate adds the given certificate to the trust store as root certificate, without checking whether it forms a valid chain to some root. +// This is useful for trusting pinned certificates. +func (store *TrustStore) PinCertificate(certificate *x509.Certificate) { + store.certificates = append(store.certificates, certificate) + store.RootCAs = append(store.RootCAs, certificate) + store.CertPool.AddCert(certificate) +} + // Certificates returns a copy of the certificates within the CertPool func (store *TrustStore) Certificates() []*x509.Certificate { return store.certificates[:] diff --git a/crypto/jwx/algorithm.go b/crypto/jwx/algorithm.go index 4f89d7684e..771d2829e2 100644 --- a/crypto/jwx/algorithm.go +++ b/crypto/jwx/algorithm.go @@ -27,7 +27,7 @@ import ( // 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} +var SupportedAlgorithms = []jwa.SignatureAlgorithm{jwa.ES256, jwa.EdDSA, jwa.ES384, jwa.ES512, jwa.RS256, jwa.PS256, jwa.PS384, jwa.PS512} const DefaultRsaEncryptionAlgorithm = jwa.RSA_OAEP_256 const DefaultEcEncryptionAlgorithm = jwa.ECDH_ES_A256KW diff --git a/crypto/jwx_test.go b/crypto/jwx_test.go index b42e45a5dc..6dac69e449 100644 --- a/crypto/jwx_test.go +++ b/crypto/jwx_test.go @@ -29,13 +29,14 @@ import ( "encoding/json" "errors" "fmt" + "io" + "testing" + "time" + "github.com/nuts-foundation/nuts-node/crypto/jwx" "github.com/nuts-foundation/nuts-node/crypto/storage/spi" "github.com/nuts-foundation/nuts-node/storage/orm" "go.uber.org/mock/gomock" - "io" - "testing" - "time" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwe" @@ -120,6 +121,7 @@ func TestSignJWT(t *testing.T) { func TestParseJWT(t *testing.T) { t.Run("unsupported algorithm", func(t *testing.T) { + t.Skip("LSPxNuts: enabled RS256 support") rsaKey := test.GenerateRSAKey() token := jwt.New() signature, _ := jwt.Sign(token, jwt.WithKey(jwa.RS256, rsaKey)) @@ -581,7 +583,7 @@ func TestCrypto_convertHeaders(t *testing.T) { func Test_isAlgorithmSupported(t *testing.T) { assert.True(t, jwx.IsAlgorithmSupported(jwa.PS256)) - assert.False(t, jwx.IsAlgorithmSupported(jwa.RS256)) + assert.True(t, jwx.IsAlgorithmSupported(jwa.RS256)) assert.False(t, jwx.IsAlgorithmSupported("")) } diff --git a/development/lspxnuts/certs/localhost-chain.pem b/development/lspxnuts/certs/localhost-chain.pem new file mode 100644 index 0000000000..95ba04353b --- /dev/null +++ b/development/lspxnuts/certs/localhost-chain.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIC9jCCAd6gAwIBAgIURFCqPrL3QQdBNOqkwmXWNgx9pdQwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNDExMTExNDE1MTha +Fw0zNDExMDkxNDE1MThaMBsxGTAXBgNVBAMMEEZha2UgVVpJIFJvb3QgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDT5J8gKdyMJNi3cuAmJ+MILrMu +wrKyTRYhjUUFHHn5rcVaHN0hzB6v5t74Nt40xUXRNaomDcclBIOlwt8f62JA2p/j +83ENfdLrXvUu9NMThkqZwZ9dzRwK7l3UZBq8NTQUO74W4M2qx8nrXq31eWogxUUI +Fc1XORh5ecebeL5mUb2E6UlmDmNgm2fGeSmmis8zieI+KKYOhi/hYtyeixrg7rxP +4v0VRrEstcWAetRgXWQX0ElAxs0Vrsy6/vv3pEtXhx8wb2wi2xY14d9Ih8HdeNI+ ++3wIbZz6WVM3fD5QFHV2EZBH+soo0pfKj2tHsaDz3FPMuMzILt6U6PT4ALIdAgMB +AAGjMjAwMA8GA1UdEwQIMAYBAf8CAQAwHQYDVR0OBBYEFJuxz0XwN7PdeMhyJfcf +m7py1BK9MA0GCSqGSIb3DQEBCwUAA4IBAQAhlpkz68x2dGpOLX3FzAb8Ee+Y2OV+ +RWFpsME9ZVDU06JETPfPCj02PH82lgUnc4jeR81rPSsIt2ssqm2S4zb02Nip595c +AqCKvmBfEc9hPPW2ugpNxT8ZRU4LKrqpV4nJ6nBvDqmGuH5uq9Ng9l9SnM3eKmdZ +tJKc+ZNAPKxVAiueLTdr6W2UbmKoZARQQ0JLkFnZOxnUkr8pQfxUzEIUkHg2dWaa +I/4wo4Pni7xXggFoPDpVztu/iP33XBLqXJwxxHXhq9nc9JU/kEXDt7j8EgoyJo7J +jSKcjpRfpGkE5gqqB4Sa8wAsAPUK3jRreuytllAtQUZRbCtHbxclc9yA +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDjTCCAnWgAwIBAgIUFTPO+pUk32QWsYyLYdlLTmlRWWgwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNTEyMDIwNzEwMDVa +Fw0yNjEyMDIwNzEwMDVaMEMxEjAQBgNVBAMMCWxvY2FsaG9zdDEQMA4GA1UECgwH +VGVzdE9yZzEPMA0GA1UEBwwGQXJuaGVtMQowCAYDVQQFEwExMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtltFIFPm4KjsJPNWIiq54j18TNl6Ouy1Y0P1 +ugXA8BCAYjNjJAq/o0g3XM2Qsp8l2UVUXwHHtFptMpkQleVOKPOMkuw4UopC8VIO +CQ2uwKEsxhapmFoxV1+SOnJRJxnA+C6ju8btW1vSTZSyEorzwXb5oyMAV02Kst2A +PfsSQkPNU1mB+cHZ9CEOG3gUXbQ5Q8UAwwr9TS2R7qFomjYM781W1GvmIdO7a+4m +Gk4Eiy0GZkV7EhPbpOkOmWReF5TXiqDgCoWSyG3CE1xmFbnbivIHG8cBMlrj82G8 +8UD4BWJcb2edt3PF7fxSW4ulMaBL7zE89s9VsmDylmYo/Zc3HQIDAQABo4GgMIGd +MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjA8BgNVHREENTAzoDEGA1UF +BaAqDCgyLjE2LjUyOC4xLjEwMDcuOTkuMjExMC0xLTEtUy0yLTAwLjAwMC0zMB0G +A1UdDgQWBBRMg47vcZ1skdvIXwh8FC/rv7MfTjAfBgNVHSMEGDAWgBSbsc9F8Dez +3XjIciX3H5u6ctQSvTANBgkqhkiG9w0BAQsFAAOCAQEACL8lz57c7UfGCV4btMuP +72qrkxSj5Ii+nrreUGc4uxR3G8FpBE++0W4PMK/wNp8IfvKFweujHH1DigQLKhRI +bHrFnsJdkZ7h/LtTEzxti/0OMLQ9J8DaZ9myPEdkO5Qn7zsoanyjzmNwCGXKJg2d +0cOxsO5Gys6wAkqkS2YsLO/kKI6IUTNvxyoziSap8kvKwIrAP2vAgTWCECT9fKJS +05kx7WFciN+STw6hkxEQNeStteAEgfXuLnrwQSeCPljrQSTNOYMy9B1uEs06C5QP +VHWmT43b9/YWEogxRQKJdM4toIvpFTGM8PonOpWyS77z6Ltnglaq8b5+QNjykYv7 +fw== +-----END CERTIFICATE----- diff --git a/development/lspxnuts/certs/localhost.key b/development/lspxnuts/certs/localhost.key new file mode 100644 index 0000000000..645c1a451e --- /dev/null +++ b/development/lspxnuts/certs/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQC2W0UgU+bgqOwk +81YiKrniPXxM2Xo67LVjQ/W6BcDwEIBiM2MkCr+jSDdczZCynyXZRVRfAce0Wm0y +mRCV5U4o84yS7DhSikLxUg4JDa7AoSzGFqmYWjFXX5I6clEnGcD4LqO7xu1bW9JN +lLISivPBdvmjIwBXTYqy3YA9+xJCQ81TWYH5wdn0IQ4beBRdtDlDxQDDCv1NLZHu +oWiaNgzvzVbUa+Yh07tr7iYaTgSLLQZmRXsSE9uk6Q6ZZF4XlNeKoOAKhZLIbcIT +XGYVuduK8gcbxwEyWuPzYbzxQPgFYlxvZ523c8Xt/FJbi6UxoEvvMTz2z1WyYPKW +Zij9lzcdAgMBAAECgf8UTRTlBHIvkJ65fl2YcClBhpbP92YkKTYIVwiELR/Nmgiw +5geje47aHrALJNd3C0Crb4x1Bz20VlzRxTiTd3O8G2EK+kFK7xmExB3L5DoQN+FE +LEG1NFVJ5Nnip9dhAvz4pDiWLw89nHtNJ8CrT8zTPOuNvdfL4FYQk5gzTkA6ICHA +7NWx9bd/OXS9SWbxQiLi+BeWh4EHmC8U7ZD3HZMTDXnTerebL2Wdtjt3n6atX3/M +gctFBx6L5spBcDpsrcyXPuJSzBrUL83XRCC9SpO/sWpqI5R7mIvOPIqiSXZcENeh +h2zvRHFoo29olkagiTSAzJE8OAhQq8iZlmtotdECgYEA9KNaZw/0LWS3YMZZ5oJ9 +F/lVtXrEfKKoDfG4ftietgNRYmhSmyb/pzAKSll0t9MqCVUH18UTWVm6Unp0vPB0 +kaySBKKBLWysYxApVvn4Nmy6+A0KHDmDJdJsMxZHme/ApNOGjpA+30QlOMZk0aey +5d7X9r5ETAVtDm1lGLItz8kCgYEAvtNqGdUxRaZlm7FA1nb3i8c+FN/i3nO9+6sQ +ASu/Zy6144vwe51jxS1wctiZW/sp0xoq1rXmjxmXgFLHWoZGrzv4oGZjWz4wdTh2 +tTvrtrj7MgVDrfvBBt+LHzrUkqmW67x5QOIbDYCwFKtoicGDHdpqmz70bC1ey0Ub +1fBr3rUCgYBETZ+eCuxICEjS8k6Dd4dpvCncA6z8h4WYbxbuA5k8hGyipzH5M8hJ +a7ZTz+owsPqZpG4OJm4iklTdVmdloVVKnv4d4Slj/2WaOxbvu9c7itwhCbL68mvV +kYy4Ls5LAo+s9YoqH8gOGj6yPWJEzye52qA9uh3jg9hRIOYLISR9UQKBgB+1baoB +PQC/1555Y7a/af72CqDZWw9v2B/bmvs208VHg73d4QYJbyyykj7jMwiPwbFsZbXr +3/XjYMNX/fxS16gCpRuyJ8xflxnDWiZfYJmqP0NekJJ2hOqpdqqn0e7U81kUpmlb +qPcjbR7iJKrPVwQ86P4HBgJ7v4azYx63ppUJAoGBAIGkZj8Klu4p5wEtM+n5ZY0B +Gm6mW77ukIF+M1U94CazxB1XXiQhPCnbLst6NkLLAYKb4Lkzu/ivlDYPXL18oNKZ +e0xBSnzMgExEnuJvwYNHPOF08MDKuSuCu2A0LLGB/CZoFMpvW0sHJlO2h3AY9nZR +TIh5VU9wuNH8zI0VvkRo +-----END PRIVATE KEY----- diff --git a/development/lspxnuts/certs/localhost.pem b/development/lspxnuts/certs/localhost.pem new file mode 100644 index 0000000000..216f8dd292 --- /dev/null +++ b/development/lspxnuts/certs/localhost.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDjTCCAnWgAwIBAgIUFTPO+pUk32QWsYyLYdlLTmlRWWgwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNTEyMDIwNzEwMDVa +Fw0yNjEyMDIwNzEwMDVaMEMxEjAQBgNVBAMMCWxvY2FsaG9zdDEQMA4GA1UECgwH +VGVzdE9yZzEPMA0GA1UEBwwGQXJuaGVtMQowCAYDVQQFEwExMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtltFIFPm4KjsJPNWIiq54j18TNl6Ouy1Y0P1 +ugXA8BCAYjNjJAq/o0g3XM2Qsp8l2UVUXwHHtFptMpkQleVOKPOMkuw4UopC8VIO +CQ2uwKEsxhapmFoxV1+SOnJRJxnA+C6ju8btW1vSTZSyEorzwXb5oyMAV02Kst2A +PfsSQkPNU1mB+cHZ9CEOG3gUXbQ5Q8UAwwr9TS2R7qFomjYM781W1GvmIdO7a+4m +Gk4Eiy0GZkV7EhPbpOkOmWReF5TXiqDgCoWSyG3CE1xmFbnbivIHG8cBMlrj82G8 +8UD4BWJcb2edt3PF7fxSW4ulMaBL7zE89s9VsmDylmYo/Zc3HQIDAQABo4GgMIGd +MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjA8BgNVHREENTAzoDEGA1UF +BaAqDCgyLjE2LjUyOC4xLjEwMDcuOTkuMjExMC0xLTEtUy0yLTAwLjAwMC0zMB0G +A1UdDgQWBBRMg47vcZ1skdvIXwh8FC/rv7MfTjAfBgNVHSMEGDAWgBSbsc9F8Dez +3XjIciX3H5u6ctQSvTANBgkqhkiG9w0BAQsFAAOCAQEACL8lz57c7UfGCV4btMuP +72qrkxSj5Ii+nrreUGc4uxR3G8FpBE++0W4PMK/wNp8IfvKFweujHH1DigQLKhRI +bHrFnsJdkZ7h/LtTEzxti/0OMLQ9J8DaZ9myPEdkO5Qn7zsoanyjzmNwCGXKJg2d +0cOxsO5Gys6wAkqkS2YsLO/kKI6IUTNvxyoziSap8kvKwIrAP2vAgTWCECT9fKJS +05kx7WFciN+STw6hkxEQNeStteAEgfXuLnrwQSeCPljrQSTNOYMy9B1uEs06C5QP +VHWmT43b9/YWEogxRQKJdM4toIvpFTGM8PonOpWyS77z6Ltnglaq8b5+QNjykYv7 +fw== +-----END CERTIFICATE----- diff --git a/development/lspxnuts/docker-compose.yml b/development/lspxnuts/docker-compose.yml new file mode 100644 index 0000000000..8b8a824e2b --- /dev/null +++ b/development/lspxnuts/docker-compose.yml @@ -0,0 +1,25 @@ +services: + nutsnode: + image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:lspxnuts}" + #pull_policy: always + ports: + - "18081:8081" + environment: + NUTS_STRICTMODE: false + NUTS_URL: "https://nuts.nl" + NUTS_AUTH_CONTRACTVALIDATORS: dummy + NUTS_HTTP_INTERNAL_ADDRESS: ":8081" + NUTS_POLICY_DIRECTORY: /opt/nuts/policies + volumes: + - ./policies:/opt/nuts/policies:ro + healthcheck: + interval: 1s # Make test run quicker by checking health status more often + + nutsadmin: + image: "nutsfoundation/nuts-admin:main" + environment: + - NUTS_NODE_ADDRESS=http://nutsnode:8081 + ports: + - "1405:1305" + depends_on: + - nutsnode diff --git a/development/lspxnuts/policies/lspxnuts.json b/development/lspxnuts/policies/lspxnuts.json new file mode 100644 index 0000000000..6c2ab9e157 --- /dev/null +++ b/development/lspxnuts/policies/lspxnuts.json @@ -0,0 +1,225 @@ +{ + "nuts-lsp": { + "organization": { + "format": { + "ldp_vc": { + "proof_type": [ + "JsonWebSignature2020" + ] + }, + "jwt_vc": { + "alg": [ + "PS256", + "RS256" + ] + }, + "jwt_vp": { + "alg": [ + "ES256" + ] + } + }, + "id": "pd_any_care_organization", + "name": "Care organization", + "purpose": "Finding a care organization", + "input_descriptors": [ + { + "id": "id_uzicert_uracredential", + "name": "Care organization identity from fake UZI-server certificate", + "purpose": "Finding a care organization for authorizing access to medical metadata.", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "X509Credential" + } + }, + { + "path": [ + "$.issuer" + ], + "purpose": "We can only accept credentials from a trusted issuer", + "filter": { + "type": "string", + "pattern": "^did:x509:0:sha256:GwlhBZuEFlSHXSRUXQuTs3_YpQxAahColwJJj35US1A[\\w\\-.:%]*$" + } + }, + { + "id": "organization_id", + "path": [ + "$.credentialSubject[0].id", + "$.credentialSubject.id" + ], + "filter": { + "type": "string", + "pattern": "^did:web:" + } + }, + { + "id": "organization_name", + "path": [ + "$.credentialSubject[0].subject.O", + "$.credentialSubject.subject.O" + ], + "filter": { + "type": "string" + } + }, + { + "id": "organization_ura", + "path": [ + "$.credentialSubject[0].san.otherName", + "$.credentialSubject.san.otherName" + ], + "filter": { + "type": "string", + "pattern": "^[0-9.]+-\\d+-\\d+-S-(\\d+)-00\\.000-\\d+$" + } + }, + { + "id": "organization_city", + "path": [ + "$.credentialSubject[0].subject.L", + "$.credentialSubject.subject.L" + ], + "filter": { + "type": "string" + } + } + ] + } + }, + { + "id": "id_patient_enrollment", + "name": "The patient enrollment credential", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "PatientEnrollmentCredential" + } + }, + { + "path": [ + "$.issuer" + ], + "purpose": "We can only accept credentials from a trusted issuer", + "filter": { + "type": "string", + "pattern": "^did:x509:0:sha256:KY3NR_y2OphPtJev5NxWhxJ7A-4bNta8OTRnalCbIv4[\\w\\-.:%]*$" + } + }, + { + "id": "organization_id", + "path": [ + "$.credentialSubject[0].id", + "$.credentialSubject.id" + ], + "filter": { + "type": "string", + "pattern": "^did:web:" + } + }, + { + "id": "patient_id", + "path": [ + "$.credentialSubject[0].patientId", + "$.credentialSubject.patientId" + ], + "filter": { + "type": "string" + } + }, + { + "id": "registered_by", + "path": [ + "$.credentialSubject[0].registeredBy", + "$.credentialSubject.registeredBy" + ], + "filter": { + "type": "string" + } + } + ] + } + }, + { + "id": "id_healthcare_professional_delegation", + "name": "The healthcare professional delegation credential", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "HealthCareProfessionalDelegationCredential" + } + }, + { + "path": [ + "$.issuer" + ], + "purpose": "We can only accept credentials from a trusted issuer", + "filter": { + "type": "string", + "pattern": "^did:x509:0:sha256:KY3NR_y2OphPtJev5NxWhxJ7A-4bNta8OTRnalCbIv4[\\w\\-.:%]*$" + } + }, + { + "id": "organization_id", + "path": [ + "$.credentialSubject[0].id", + "$.credentialSubject.id" + ], + "filter": { + "type": "string", + "pattern": "^did:web:" + } + }, + { + "id": "registered_by", + "path": [ + "$.credentialSubject[0].registeredBy", + "$.credentialSubject.registeredBy" + ], + "filter": { + "type": "string" + } + }, + { + "id": "role_code", + "path": [ + "$.credentialSubject[0].roleCode", + "$.credentialSubject.roleCode" + ], + "filter": { + "type": "string" + } + }, + { + "id": "authorization_rule", + "path": [ + "$.credentialSubject[0].authorizationRule", + "$.credentialSubject.authorizationRule" + ], + "filter": { + "type": "string" + } + } + ] + } + } + ] + } + } +} \ No newline at end of file diff --git a/development/lspxnuts/setup.sh b/development/lspxnuts/setup.sh new file mode 100755 index 0000000000..5b9c33c23b --- /dev/null +++ b/development/lspxnuts/setup.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash + +set -e + +# Configuration +NUTS_NODE_URL="http://localhost:18081" +SUBJECT_NAME="${SUBJECT_NAME:-testsubject}" +CERT_CHAIN="./certs/localhost-chain.pem" +CERT_KEY="./certs/localhost.key" +ISSUER_CN="${ISSUER_CN:-CN=Fake UZI Root CA}" + +echo "======================================" +echo "LSPxNuts Setup Script" +echo "======================================" +echo "" + +echo "------------------------------------" +echo "Creating Nuts subject..." +echo "------------------------------------" +#REQUEST="{\"subject\":\"${SUBJECT_NAME}\"}" +REQUEST="{}" +RESPONSE=$(echo $REQUEST | curl -s -X POST --data-binary @- ${NUTS_NODE_URL}/internal/vdr/v2/subject --header "Content-Type: application/json") + +# Extract DID from response +SUBJECT_NAME=$(echo $RESPONSE | jq -r '.subject') +DID=$(echo $RESPONSE | jq -r '.documents[0].id') + +if [ -z "$DID" ] || [ "$DID" = "null" ]; then + echo "ERROR: Failed to create subject or extract DID" + echo "Response: $RESPONSE" + exit 1 +fi + +echo "✓ Subject created successfully" +echo " Subject: ${SUBJECT_NAME}" +echo " DID: ${DID}" +echo "" + +echo "------------------------------------" +echo "Issuing X509Credential..." +echo "------------------------------------" + +# Check if certificate files exist +if [ ! -f "$CERT_CHAIN" ]; then + echo "ERROR: Certificate chain not found at $CERT_CHAIN" + exit 1 +fi + +if [ ! -f "$CERT_KEY" ]; then + echo "ERROR: Certificate key not found at $CERT_KEY" + exit 1 +fi + +# Issue X509 credential using go-didx509-toolkit Docker image +CREDENTIAL=$(docker run \ + --rm \ + -v "$(pwd)/${CERT_CHAIN}:/cert-chain.pem:ro" \ + -v "$(pwd)/${CERT_KEY}:/cert-key.key:ro" \ + nutsfoundation/go-didx509-toolkit:main \ + vc "/cert-chain.pem" "/cert-key.key" "${ISSUER_CN}" "${DID}") + +if [ -z "$CREDENTIAL" ]; then + echo "ERROR: Failed to generate X509Credential" + exit 1 +fi + +echo "✓ X509Credential generated" +echo "" + +echo "------------------------------------" +echo "Loading credential into wallet..." +echo "------------------------------------" + +# Store credential in wallet +HTTP_CODE=$(echo "\"${CREDENTIAL}\"" | curl -s -o /dev/null -w "%{http_code}" \ + -X POST --data-binary @- \ + ${NUTS_NODE_URL}/internal/vcr/v2/holder/${SUBJECT_NAME}/vc \ + -H "Content-Type:application/json") + +if [ "$HTTP_CODE" -eq 204 ]; then + echo "✓ X509Credential successfully stored in wallet" +else + echo "ERROR: Failed to load X509Credential in wallet (HTTP $HTTP_CODE)" + exit 1 +fi + +echo "" +echo "------------------------------------" +echo "Issuing MandaatCredential..." +echo "------------------------------------" + +# Issue a self-issued MandaatCredential +MANDAAT_REQUEST=$(cat <&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +echo "---------------------------------------" +echo "Perform OAuth 2.0 rfc021 flow..." +echo "---------------------------------------" + +# Run generate-jwt.sh, and read the input into a var, clean newlines +IDTOKEN=$(./generate-jwt.sh | tr -d '\n') + +REQUEST=$( +cat << EOF +{ + "authorization_server": "https://nodeA/oauth2/vendorA", + "token_type": "bearer", + "scope": "test", + "id_token": "$IDTOKEN" +} +EOF +) +# Request access token +RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:18081/internal/auth/v2/vendorA/request-service-access-token -H "Content-Type: application/json") +if echo $RESPONSE | grep -q "access_token"; then + ACCESS_TOKEN=$(echo $RESPONSE | jq -r .access_token) +else + echo "FAILED: Could not get access token from node-A" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +echo Access token: $ACCESS_TOKEN + +echo "------------------------------------" +echo "Introspect access token..." +echo "------------------------------------" +RESPONSE=$(curl -X POST -s --data "token=$ACCESS_TOKEN" http://localhost:18081/internal/auth/v2/accesstoken/introspect) +echo Introspection response: $RESPONSE + +# Check that it contains the following claims from the Dezi token: +# Token contains: +# - "abonnee_nummer":"90000380" -> organization_ura_dezi +# - "dezi_nummer":"900022159" -> user_uzi +# - "voorletters":"J." -> user_initials +# - "achternaam":"90017362" -> user_surname +# - "voorvoegsel":null -> user_surname_prefix (empty) +# - "rol_code":"92.000" -> user_role +if [ "$(echo $RESPONSE | jq -r .organization_ura_dezi)" != "90000380" ]; then + echo "FAILED: organization_ura_dezi invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_initials)" != "J." ]; then + echo "FAILED: user_initials invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_role)" != "92.000" ]; then + echo "FAILED: user_role invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_surname)" != "90017362" ]; then + echo "FAILED: user_surname invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +# voorvoegsel is null in the token, so user_surname_prefix should be empty or not present +USER_SURNAME_PREFIX=$(echo $RESPONSE | jq -r .user_surname_prefix) +if [ "$USER_SURNAME_PREFIX" != "" ] && [ "$USER_SURNAME_PREFIX" != "null" ]; then + echo "FAILED: user_surname_prefix should be empty, got: $USER_SURNAME_PREFIX" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_uzi)" != "900022159" ]; then + echo "FAILED: user_uzi invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +echo "------------------------------------" +echo "Stopping Docker containers..." +echo "------------------------------------" +docker compose down \ No newline at end of file diff --git a/go.mod b/go.mod index 8f87ee33be..98b1ae6d81 100644 --- a/go.mod +++ b/go.mod @@ -9,14 +9,14 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/PaesslerAG/jsonpath v0.1.2-0.20230323094847-3484786d6f97 github.com/alicebob/miniredis/v2 v2.35.0 - github.com/avast/retry-go/v4 v4.7.0 + github.com/avast/retry-go/v4 v4.6.1 github.com/cbroglie/mustache v1.4.0 github.com/chromedp/chromedp v0.14.2 github.com/dlclark/regexp2 v1.11.5 github.com/go-redis/redismock/v9 v9.2.0 github.com/goodsign/monday v1.0.2 github.com/google/uuid v1.6.0 - github.com/hashicorp/vault/api v1.22.0 + github.com/hashicorp/vault/api v1.20.0 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.1 @@ -31,7 +31,7 @@ require ( github.com/nats-io/nats-server/v2 v2.11.12 github.com/nats-io/nats.go v1.49.0 github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b - github.com/nuts-foundation/go-did v0.17.0 + github.com/nuts-foundation/go-did v0.18.0 github.com/nuts-foundation/go-leia/v4 v4.2.0 github.com/nuts-foundation/go-stoabs v1.11.0 github.com/nuts-foundation/sqlite v1.0.0 @@ -113,11 +113,11 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect - github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect - github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/go-sockaddr v1.0.2 // indirect github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -191,7 +191,7 @@ require ( golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect gopkg.in/Regis24GmbH/go-diacritics.v2 v2.0.3 // indirect - gorm.io/gorm v1.31.1 + gorm.io/gorm v1.30.2 modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.47.0 @@ -204,7 +204,7 @@ require ( github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.17 github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf github.com/daangn/minimemcached v1.2.0 - github.com/eko/gocache/lib/v4 v4.2.3 + github.com/eko/gocache/lib/v4 v4.2.0 github.com/eko/gocache/store/go_cache/v4 v4.2.2 github.com/eko/gocache/store/memcache/v4 v4.2.4 github.com/eko/gocache/store/redis/v4 v4.2.2 diff --git a/go.sum b/go.sum index bc5626e393..702717a413 100644 --- a/go.sum +++ b/go.sum @@ -45,8 +45,9 @@ github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op h1:Ucf+QxEKMbPogR github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio= -github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= +github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= @@ -81,6 +82,7 @@ github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= @@ -144,16 +146,17 @@ github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ= github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q= github.com/eknkc/basex v1.0.1 h1:TcyAkqh4oJXgV3WYyL4KEfCMk9W8oJCpmx1bo+jVgKY= github.com/eknkc/basex v1.0.1/go.mod h1:k/F/exNEHFdbs3ZHuasoP2E7zeWwZblG84Y7Z59vQRo= -github.com/eko/gocache/lib/v4 v4.2.3 h1:s78TFqEGAH3SbzP4N40D755JYT/aaGFKEPrsUtC1chU= -github.com/eko/gocache/lib/v4 v4.2.3/go.mod h1:Zus8mwmaPu1VYOzfomb+Dvx2wV7fT5jDRbHYtQM6MEY= +github.com/eko/gocache/lib/v4 v4.2.0 h1:MNykyi5Xw+5Wu3+PUrvtOCaKSZM1nUSVftbzmeC7Yuw= +github.com/eko/gocache/lib/v4 v4.2.0/go.mod h1:7ViVmbU+CzDHzRpmB4SXKyyzyuJ8A3UW3/cszpcqB4M= github.com/eko/gocache/store/go_cache/v4 v4.2.2 h1:tAI9nl6TLoJyKG1ujF0CS0n/IgTEMl+NivxtR5R3/hw= github.com/eko/gocache/store/go_cache/v4 v4.2.2/go.mod h1:T9zkHokzr8K9EiC7RfMbDg6HSwaV6rv3UdcNu13SGcA= github.com/eko/gocache/store/memcache/v4 v4.2.4 h1:k8bLwU7F3caEO43eC2dHZR0X+KsoF/aQfpsRgFap6Io= github.com/eko/gocache/store/memcache/v4 v4.2.4/go.mod h1:DxmvRb6CIb8GKnNFgn+A+hhAEeFZRxThLo9+v3Z6TMs= github.com/eko/gocache/store/redis/v4 v4.2.2 h1:Thw31fzGuH3WzJywsdbMivOmP550D6JS7GDHhvCJPA0= github.com/eko/gocache/store/redis/v4 v4.2.2/go.mod h1:LaTxLKx9TG/YUEybQvPMij++D7PBTIJ4+pzvk0ykz0w= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -189,8 +192,8 @@ github.com/go-redsync/redsync/v4 v4.13.0 h1:49X6GJfnbLGaIpBBREM/zA4uIMDXKAh1NDkv github.com/go-redsync/redsync/v4 v4.13.0/go.mod h1:HMW4Q224GZQz6x1Xc7040Yfgacukdzu7ifTDAKiyErQ= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= -github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= +github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= @@ -245,26 +248,28 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= -github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= -github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= -github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= -github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= -github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= -github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= -github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/hashicorp/vault/api v1.20.0 h1:KQMHElgudOsr+IbJgmbjHnCTxEpKs9LnozA1D3nozU4= +github.com/hashicorp/vault/api v1.20.0/go.mod h1:GZ4pcjfzoOWpkJ3ijHNpEoAxKEsBJnVljyTe3jM2Sms= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -335,8 +340,10 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 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/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= @@ -355,10 +362,13 @@ github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYs github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -396,8 +406,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.17.0 h1:nLmMiiKjIJwgZsfJ98ywATiCb9VHomnb3r86oWHdILw= -github.com/nuts-foundation/go-did v0.17.0/go.mod h1:8VLZhVjkFH9VgGu//3y7ICowwItpym3NWkOih1Ka1fw= +github.com/nuts-foundation/go-did v0.18.0 h1:IB0X8PrzDulpR1zAgDpaHfwoSjJpIhx5u1Tg8I2nnb8= +github.com/nuts-foundation/go-did v0.18.0/go.mod h1:4od1gAmCi9HjHTQGEvHC8pLeuXdXACxidAcdA52YScc= github.com/nuts-foundation/go-leia/v4 v4.2.0 h1:o/bgYVCyTgsfgtaKmlrcUaJ2z4NwetERC98SUWwYajM= github.com/nuts-foundation/go-leia/v4 v4.2.0/go.mod h1:Gw6bXqJLOAmHSiXJJYbVoj+Mowp/PoBRywO0ZPsVzA0= github.com/nuts-foundation/go-stoabs v1.11.0 h1:q18jVruPdFcVhodDrnKuhq/24i0pUC/YXgzJS0glKUU= @@ -427,6 +437,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEnkRx9k= github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= @@ -461,6 +472,7 @@ github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc= github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis= @@ -657,6 +669,7 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -771,8 +784,8 @@ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXD gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI= gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= -gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= -gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/gorm v1.30.2 h1:f7bevlVoVe4Byu3pmbWPVHnPsLoWaMjEb7/clyr9Ivs= +gorm.io/gorm v1.30.2/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= diff --git a/pki/cacerts/Sectigo Public Server Authentication CA EV R36.crt b/pki/cacerts/Sectigo Public Server Authentication CA EV R36.crt new file mode 100644 index 0000000000..c53f9c8a70 --- /dev/null +++ b/pki/cacerts/Sectigo Public Server Authentication CA EV R36.crt @@ -0,0 +1,36 @@ +-----BEGIN CERTIFICATE----- +MIIGSzCCBDOgAwIBAgIQbU98rTNTd8jG4AHd4uLIjjANBgkqhkiG9w0BAQwFADBf +MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD +Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw +HhcNMjEwMzIyMDAwMDAwWhcNMzYwMzIxMjM1OTU5WjBgMQswCQYDVQQGEwJHQjEY +MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFB1Ymxp +YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0EgRVYgUjM2MIIBojANBgkqhkiG9w0B +AQEFAAOCAY8AMIIBigKCAYEApduwxkQH5noeb0k4yRXK47Fd+hVkH8twKWQF3FNm +HHShQrOH7S0L3p8Aaf8P7OqojwyZgyqD5o/Sb95N45P/NfmL+lfAOkQ1Bpvz0OcZ +VmwYhMWFYJe8parkz5w9Bk8mn/AtN65hIQF0NqBI6F+23/jzhlpl3E0zKkkUJSfT +dgvwaJvTiPgLQ7CpYyLJvMpgdPHf+nnvA5YJjhae6olh6BRllvRhzKOq0gd60BrO +Aos9QyOfYhUDG3eQovMA9fyiI2qNclCp6DY8hqt55lERoBS/Whza8Cx1bh6q8rTP +nno2uI6FZb2UavIYblyGDamXAW/jv3qsMB42xz/p2mUHg0wQzn/8KAiyu2ZbDs7E +OOwYx0V2ViNsCXGR/GCmJsUNPmhEEXBclOj6qF7rFf7nkST2bokSt9VF1NB1JgRl +djZin1+v2Vm88UEgFxerQPPrPjAEMqDx44vKl22sGWnl+jOyxf/H59DHKbIezrIY +rJpSnA3WEVaiXs1oIRy1ZziNAgMBAAGjggGAMIIBfDAfBgNVHSMEGDAWgBRWc1hk +lfmSGrASKgRieaFAFYghSTAdBgNVHQ4EFgQUmC1eHo/rVPS5/1WVrUzHfqSYrnsw +DgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYI +KwYBBQUHAwEGCCsGAQUFBwMCMBoGA1UdIAQTMBEwBgYEVR0gADAHBgVngQwBATBU +BgNVHR8ETTBLMEmgR6BFhkNodHRwOi8vY3JsLnNlY3RpZ28uY29tL1NlY3RpZ29Q +dWJsaWNTZXJ2ZXJBdXRoZW50aWNhdGlvblJvb3RSNDYuY3JsMIGEBggrBgEFBQcB +AQR4MHYwTwYIKwYBBQUHMAKGQ2h0dHA6Ly9jcnQuc2VjdGlnby5jb20vU2VjdGln +b1B1YmxpY1NlcnZlckF1dGhlbnRpY2F0aW9uUm9vdFI0Ni5wN2MwIwYIKwYBBQUH +MAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMA0GCSqGSIb3DQEBDAUAA4ICAQA2 +LRCehSXiyw56alKt2aqDwDLEDmyu74lUmDG4rI1Jw3+ivLz+OK3NpE466a8yXu7b +f326wkrrOCF2SVu3ItNjXqsgwXdIM9tapVEKjsEwbjcPUcxNeqzSpLSgHZIVhjJJ ++QtdEL2n1NhDxqewlN+ZKWWBLGB0LA/S7SvCl9s7rsppo97uv6J3h//q4b2SgzHu +KUu80Hofpjp2bILGHy4CqzKzyg3wn9sKFcX6Ufkcea/tuiWhIMPkiQCv5tXsRj+H +n3CZql+wRPbjhNqncCkzrwOBrt7Gov46EHhqMUP/Euf8gSw7X+CzWEUJ0Q+Vn8VL +ZDkiKcMSYMJI+6Trc5tVn9LQLI6tTzwagLhgcVtFpom3PVtEJRPN/d05L0ck7S1b +xPaXcQhKUqdzFd09pt6fLx2vMmylBKXjKosSxjB12KRzLvI2CUUVdpl7OTFA32Fk ++zQC2gJPGmPPCzRMRYfVOEIZxbG0nI9lpYZidLkQ/EyTtJ3JVj7cb3KRe/e1s+Xj +rnHqbUwoizicfogtcKm5giwj74f+Rpr0RRy4xum1bC662fNHRJaTt5ErVR6gHjHM +Zhp+tOeOvohq5XxIMCKuetnwcPeBsOzUBzmUPoxQNRVXEYbwV2rS3t2jJ0Zh/Dnn +O96biaAsEnxbQyu4fvMx6D4J4Zbkc7Zp75T3GE/UjQ== +-----END CERTIFICATE----- diff --git a/pki/cacerts/Sectigo Public Server Authentication CA OV R36.crt b/pki/cacerts/Sectigo Public Server Authentication CA OV R36.crt new file mode 100644 index 0000000000..4440a7378d --- /dev/null +++ b/pki/cacerts/Sectigo Public Server Authentication CA OV R36.crt @@ -0,0 +1,36 @@ +-----BEGIN CERTIFICATE----- +MIIGTDCCBDSgAwIBAgIQLBo8dulD3d3/GRsxiQrtcTANBgkqhkiG9w0BAQwFADBf +MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD +Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw +HhcNMjEwMzIyMDAwMDAwWhcNMzYwMzIxMjM1OTU5WjBgMQswCQYDVQQGEwJHQjEY +MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFB1Ymxp +YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0EgT1YgUjM2MIIBojANBgkqhkiG9w0B +AQEFAAOCAY8AMIIBigKCAYEApkMtJ3R06jo0fceI0M52B7K+TyMeGcv2BQ5AVc3j +lYt76TvHIu/nNe22W/RJXX9rWUD/2GE6GF5x0V4bsY7K3IeJ8E7+KzG/TGboySfD +u+F52jqQBbY62ofhYjMeiAbLI02+FqwHeM8uIrUtcX8b2RCxF358TB0NHVccAXZc +FYgZndZCeXxjuca7pJJ20LLUnXtgXcjAE1vY4WvbReW0W6mkeZyNGdmpTcFs5Y+s +yy6LtE5Zocji9J9NlNnReox2RWVyEXpA1ChZ4gqN+ZpVSIQ0HBorVFbBKyhdZyEX +gZgNSNtBRwxqwIzJePJhYd4ZUhO1vk+/uP3nwDk0p95q/j7naXNCSvESnrHPypaB +WRK066nKfPRPi9m9kIOhMdYfS8giFRTcdgL24Ycilj7ecAK9Trh0VbjwouJ4WH+x +bt47u68ZFCD/ac55I0DNHkCpaPruj6e9Rmr7K46wZDAYXuEAqB7tGG/jd6JAA+H2 +O44CV98NRsU213f1kScIZntNAgMBAAGjggGBMIIBfTAfBgNVHSMEGDAWgBRWc1hk +lfmSGrASKgRieaFAFYghSTAdBgNVHQ4EFgQU42Z0u3BojSxdTg6mSo+bNyKcgpIw +DgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYI +KwYBBQUHAwEGCCsGAQUFBwMCMBsGA1UdIAQUMBIwBgYEVR0gADAIBgZngQwBAgIw +VAYDVR0fBE0wSzBJoEegRYZDaHR0cDovL2NybC5zZWN0aWdvLmNvbS9TZWN0aWdv +UHVibGljU2VydmVyQXV0aGVudGljYXRpb25Sb290UjQ2LmNybDCBhAYIKwYBBQUH +AQEEeDB2ME8GCCsGAQUFBzAChkNodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3Rp +Z29QdWJsaWNTZXJ2ZXJBdXRoZW50aWNhdGlvblJvb3RSNDYucDdjMCMGCCsGAQUF +BzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEA +BZXWDHWC3cubb/e1I1kzi8lPFiK/ZUoH09ufmVOrc5ObYH/XKkWUexSPqRkwKFKr +7r8OuG+p7VNB8rifX6uopqKAgsvZtZsq7iAFw04To6vNcxeBt1Eush3cQ4b8nbQR +MQLChgEAqwhuXp9P48T4QEBSksYav7+aFjNySsLYlPzNqVM3RNwvBdvp6vgDtGwc +xlKQZVuuNVIaoYyls8swhxDeSHKpRdxRauTLZ+pl+wGvy0pnrLEJGSz9mOEmfbod +e/XopR2NGqaHJ6bIjyxPu6UtyQGI26En7UAEozACrHz06Nx2jTAY9E6NeB6XuobE +wLK025ZRmvglcURG1BrV24tGHHTgxCe8M3oGlpUSMTKQ2dkgljZVYt+gKdFtWELZ +MuRdi+X3XsrR8LFz+aLUiDRfQqhmw3RxjIyVKvvu9UPYY1nsvxYmFnUSeM+2q1z/ +iPUry+xDY9MC6+IhleKT094VKdFVp7LXH42+wvU+17lRolQ2mK2N/nBLVBwaIhib +QXw4VYKwB86Bc6eS6iqsc94KEgD/U4VsjmgfhK+Xp4NM+VYzTTa3QeV3p8xOM0cw +q1p8oZFA+OBcz3FYWpDIe5j0NWKlw9hXsTyPY/HeZUV59akskSOSRSmDfe8wJDPX +58uB9/7lud0G3x0pxQAcffP0ayKavNwDTw4UfJ34cEw= +-----END CERTIFICATE----- diff --git a/pki/cacerts/Sectigo Public Server Authentication Root R46.crt b/pki/cacerts/Sectigo Public Server Authentication Root R46.crt new file mode 100644 index 0000000000..71afc161d9 --- /dev/null +++ b/pki/cacerts/Sectigo Public Server Authentication Root R46.crt @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFijCCA3KgAwIBAgIQdY39i658BwD6qSWn4cetFDANBgkqhkiG9w0BAQwFADBf +MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD +Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw +HhcNMjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5WjBfMQswCQYDVQQGEwJHQjEY +MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1Ymxp +YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQCTvtU2UnXYASOgHEdCSe5jtrch/cSV1UgrJnwUUxDa +ef0rty2k1Cz66jLdScK5vQ9IPXtamFSvnl0xdE8H/FAh3aTPaE8bEmNtJZlMKpnz +SDBh+oF8HqcIStw+KxwfGExxqjWMrfhu6DtK2eWUAtaJhBOqbchPM8xQljeSM9xf +iOefVNlI8JhD1mb9nxc4Q8UBUQvX4yMPFF1bFOdLvt30yNoDN9HWOaEhUTCDsG3X +ME6WW5HwcCSrv0WBZEMNvSE6Lzzpng3LILVCJ8zab5vuZDCQOc2TZYEhMbUjUDM3 +IuM47fgxMMxF/mL50V0yeUKH32rMVhlATc6qu/m1dkmU8Sf4kaWD5QazYw6A3OAS +VYCmO2a0OYctyPDQ0RTp5A1NDvZdV3LFOxxHVp3i1fuBYYzMTYCQNFu31xR13NgE +SJ/AwSiItOkcyqex8Va3e0lMWeUgFaiEAin6OJRpmkkGj80feRQXEgyDet4fsZfu ++Zd4KKTIRJLpfSYFplhym3kT2BFfrsU4YjRosoYwjviQYZ4ybPUHNs2iTG7sijbt +8uaZFURww3y8nDnAtOFr94MlI1fZEoDlSfB1D++N6xybVCi0ITz8fAr/73trdf+L +HaAZBav6+CuBQug4urv7qv094PPK306Xlynt8xhW6aWWrL3DkJiy4Pmi1KZHQ3xt +zwIDAQABo0IwQDAdBgNVHQ4EFgQUVnNYZJX5khqwEioEYnmhQBWIIUkwDgYDVR0P +AQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAC9c +mTz8Bl6MlC5w6tIyMY208FHVvArzZJ8HXtXBc2hkeqK5Duj5XYUtqDdFqij0lgVQ +YKlJfp/imTYpE0RHap1VIDzYm/EDMrraQKFz6oOht0SmDpkBm+S8f74TlH7Kph52 +gDY9hAaLMyZlbcp+nv4fjFg4exqDsQ+8FxG75gbMY/qB8oFM2gsQa6H61SilzwZA +Fv97fRheORKkU55+MkIQpiGRqRxOF3yEvJ+M0ejf5lG5Nkc/kLnHvALcWxxPDkjB +JYOcCj+esQMzEhonrPcibCTRAUH4WAP+JWgiH5paPHxsnnVI84HxZmduTILA7rpX +DhjvLpr3Etiga+kFpaHpaPi8TD8SHkXoUsCjvxInebnMMTzD9joiFgOgyY9mpFui +TdaBJQbpdqQACj7LzTWb4OE4y2BThihCQRxEV+ioratF4yUQvNs+ZUH7G6aXD+u5 +dHn5HrwdVw1Hr8Mvn4dGp+smWg9WY7ViYG4A++MnESLn/pmPNPW56MORcr3Ywx65 +LvKRRFHQV80MNNVIIb/bE/FmJUNS0nAiNs2fxBx1IK1jcmMGDw4nztJqDby1ORrp +0XZ60Vzk50lJLVU3aPAaOpg+VBeHVOmmJ1CJeyAvP/+/oYtKR5j/K3tJPsMpRmAY +QqszKbrAKbkTidOIijlBO8n9pu0f9GBj39ItVQGL +-----END CERTIFICATE----- diff --git a/vcr/cmd/cmd.go b/vcr/cmd/cmd.go index 01db27af37..5a0d8ffc5e 100644 --- a/vcr/cmd/cmd.go +++ b/vcr/cmd/cmd.go @@ -21,11 +21,12 @@ package cmd import ( "encoding/json" "fmt" + "strings" + "time" + "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/spf13/pflag" - "strings" - "time" "github.com/nuts-foundation/nuts-node/core" api "github.com/nuts-foundation/nuts-node/vcr/api/vcr/v2" @@ -40,6 +41,8 @@ func FlagSet() *pflag.FlagSet { flagSet.String("vcr.openid4vci.definitionsdir", defs.OpenID4VCI.DefinitionsDIR, "Directory with the additional credential definitions the node could issue (experimental, may change without notice).") flagSet.Bool("vcr.openid4vci.enabled", defs.OpenID4VCI.Enabled, "Enable issuing and receiving credentials over OpenID4VCI.") flagSet.Duration("vcr.openid4vci.timeout", time.Second*30, "Time-out for OpenID4VCI HTTP client operations.") + flagSet.Duration("vcr.verifier.revocation.maxage", time.Minute*15, "Max age of revocation information. If the revocation information is older than this, it will be refreshed from the issuer. If set to 0 or negative, revocation information will always be refreshed.") + flagSet.StringSlice("vcr.dezi.allowedjku", defs.Dezi.AllowedJKU, "List of allowed JKU URLs for fetching Dezi attestation keys. If not set, defaults to production (https://auth.dezi.nl/dezi/jwks.json), and in non-strict mode also acceptance (https://acceptatie.auth.dezi.nl/dezi/jwks.json).") return flagSet } diff --git a/vcr/config.go b/vcr/config.go index 614ddf7291..b87952e338 100644 --- a/vcr/config.go +++ b/vcr/config.go @@ -20,8 +20,9 @@ package vcr import ( - "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "time" + + "github.com/nuts-foundation/nuts-node/vcr/openid4vci" ) // ModuleName is the name of this module. @@ -31,6 +32,23 @@ const ModuleName = "VCR" type Config struct { // OpenID4VCI holds the config for the OpenID4VCI credential issuer and wallet OpenID4VCI openid4vci.Config `koanf:"openid4vci"` + Verifier VerifierConfig `koanf:"verifier"` + Dezi DeziConfig `koanf:"dezi"` +} + +type VerifierConfig struct { + Revocation VerifierRevocationConfig `koanf:"revocation"` +} + +type VerifierRevocationConfig struct { + MaxAge time.Duration `koanf:"maxage"` +} + +type DeziConfig struct { + // AllowedJKU contains the list of JKU URLs from which Dezi attestation keys are allowed to be fetched. + // If not configured, defaults to production environment (https://auth.dezi.nl/dezi/jwks.json). + // In non-strict mode, acceptance environment is also allowed (https://acceptatie.auth.dezi.nl/dezi/jwks.json). + AllowedJKU []string `koanf:"allowedjku"` } // DefaultConfig returns a fresh Config filled with default values diff --git a/vcr/credential/Koppelvlakspecificatie_2024-DEZI-Online+koppelvlak+1_+platformleverancier.pdf b/vcr/credential/Koppelvlakspecificatie_2024-DEZI-Online+koppelvlak+1_+platformleverancier.pdf new file mode 100644 index 0000000000..aa7c8bb645 Binary files /dev/null and b/vcr/credential/Koppelvlakspecificatie_2024-DEZI-Online+koppelvlak+1_+platformleverancier.pdf differ diff --git a/vcr/credential/cert.pem b/vcr/credential/cert.pem new file mode 100644 index 0000000000..2d67b94830 --- /dev/null +++ b/vcr/credential/cert.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID1jCCAb4CCQD0FlvJsbcrvTANBgkqhkiG9w0BAQsFADAoMQswCQYDVQQDDAJV +UzEZMBcGA1UEAwwQaW5nZS02LXV6aXBvYy1jYTAeFw0yMzEyMDExMjMzMzBaFw0y +NTA0MTQxMjMzMzBaMDIxCzAJBgNVBAYTAlVTMSMwIQYDVQQDDBpubC11emlwb2Mt +cGhwLWxhcmF2ZWwtZGVtbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AKbCCGYa70/EeKgpsCtBtGRiHYRrodVActlrOMHEd26hjRvctLfNiyt0QyBFlzCv +0VDXR779eoSQ6mgOaDc71kb/sGWqn8LdQ74JtY5gI5qG7n3RX3EQZLEtb16jzYdN +K1Nf2oF+KMWkvyc/V9R5e267rN2iRIGBSJQ1ffcxDqTfrMVlchV2fgVT7YO47Snj +L1wC+FxqxSG757Nz8yeyPgr2Zk1oiaztxPcXWFUiNIFZoJS9iW7HM6rCm8Z7/mRc +4Bndl/pnFe25kfhOg9JIUMo1or9ml6CIszRoZ/hS8vB9Gn6WTKNBaH110zJz8ysd +6qs8ZJBaDbkJgI6L6Vm/wt8CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAizxBfaAa +DNQN9hXKFJePz7dTPLwHY/ZjC49dQrtyWzfgr1LKi4o1ixjkdg2FXU9t+xMuWgxX +FdbjOJLdcQYWEYb+W7KmkglIcP8bOkRDWfplgskpaTogRK095S1CuMM9v0bKC/ts +dHb3UfqW4U4Zko38/Ue9fRF8ra2p71QFs8+nt/BAwCkzrNLzaxMY8//TiFU+ZEYL +zPIQBjKaYB8yVh0Wh3qaieB2BzKUan+Eysh2bUc9TplQykIdk4z6T+FO5KTj5eVk +6zpHflWWCT61y15mu3xAEb83rOf+zFpoNGiDssiko0OeLK7Flqh7HuCP26NNnwsb +VGwkg60pDu+ASG2am3TPif3JpI7skzABFw4vbvPUpIk6Im3ycC98GyXowQujI0ZT +16dXfh1E38psRUeO5o+uxY6MUPXNSioYZ0mf3BARLahN41rqxKXz5ML1DSZnIOZK +F3peSggaZoRi1h0r6W14WEcYvxdHDkVR6M1qW0i7YeIBk6kaXEkwCmFz3hk5w9an +WJDjnMqSRgRsFVcIL/Ezi/Elubk21f4LHTEQmsjzzd1G+d09fjdI6JrhYMftGuYZ +4jOZZWpzoMH1TiZZ+JkBdyRwEdbqzW+v+/0BZQy6HRaZlombcOmS9MSjFRDTyUGW +D9F1eUIqKct0yyJPPXH3lDkzqqtX4DLcopo= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/vcr/credential/dezi.go b/vcr/credential/dezi.go new file mode 100644 index 0000000000..b887b1e996 --- /dev/null +++ b/vcr/credential/dezi.go @@ -0,0 +1,490 @@ +package credential + +import ( + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "slices" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core" +) + +const DeziIDJWT07ProofType = "DeziIDJWT07" +const DeziIDJWT2024ProofType = "DeziIDJWT2024" + +func DeziIDJWTProofTypes() []string { + return []string{DeziIDJWT07ProofType, DeziIDJWT2024ProofType} +} + +type DeziIDTokenSubject struct { + Identifier string `json:"identifier"` + Name string `json:"name,omitempty"` + Employee HealthcareWorker `json:"employee"` +} + +func (d DeziIDTokenSubject) MarshalJSON() ([]byte, error) { + type Alias DeziIDTokenSubject + aux := struct { + Alias + Type string `json:"@type"` + }{ + Alias: Alias(d), + Type: "DeziIDTokenSubject", + } + return json.Marshal(aux) +} + +type HealthcareWorker struct { + Identifier string `json:"identifier"` + Initials string `json:"initials"` + SurnamePrefix string `json:"surnamePrefix"` + Surname string `json:"surname"` + Role string `json:"role,omitempty"` + RoleRegistry string `json:"role_registry,omitempty"` + RoleName string `json:"role_name,omitempty"` +} + +func (d HealthcareWorker) MarshalJSON() ([]byte, error) { + type Alias HealthcareWorker + aux := struct { + Alias + Type string `json:"@type"` + }{ + Alias: Alias(d), + Type: "HealthcareWorker", + } + return json.Marshal(aux) +} + +// CreateDeziUserCredential creates a Verifiable Credential from a Dezi id_token JWT. It supports the following spec versions: +// - april 2024 +// - 15 jan 2026/v0.7: https://www.dezi.nl/documenten/2024/04/24/koppelvlakspecificatie-dezi-voor-platform--en-softwareleveranciers +func CreateDeziUserCredential(idTokenSerialized string) (*vc.VerifiableCredential, error) { + // Parse without signature or time validation - those are validated elsewhere + idToken, err := jwt.Parse([]byte(idTokenSerialized), jwt.WithVerify(false), jwt.WithValidate(false)) + if err != nil { + return nil, fmt.Errorf("parsing id_token: %w", err) + } + + subject, version, err := extractDeziIDTokenSubject(idToken) + if err != nil { + return nil, err + } + + // Determine proof type based on version + var proofTypeName string + switch version { + case "2024": + proofTypeName = DeziIDJWT2024ProofType + case "0.7": + proofTypeName = DeziIDJWT07ProofType + default: + return nil, fmt.Errorf("unsupported Dezi id_token version: %s", version) + } + + credentialMap := map[string]any{ + "@context": []any{ + "https://www.w3.org/2018/credentials/v1", + // TODO: Create JSON-LD context? + }, + "type": []string{"VerifiableCredential", "DeziUserCredential"}, + "id": idToken.JwtID(), + "issuer": idToken.Issuer(), + "issuanceDate": idToken.NotBefore().Format(time.RFC3339Nano), + "expirationDate": idToken.Expiration().Format(time.RFC3339Nano), + "credentialSubject": subject, + "proof": deziProofType{ + Type: proofTypeName, + JWT: idTokenSerialized, + }, + } + data, _ := json.Marshal(credentialMap) + return vc.ParseVerifiableCredential(string(data)) +} + +var _ Validator = DeziUserCredentialValidator{} + +type DeziUserCredentialValidator struct { + trustStore *core.TrustStore + // AllowedJKU is a list of allowed jku URLs for fetching JWK Sets (for v0.7 tokens), used to verify Dezi attestations. + AllowedJKU []string +} + +func (d DeziUserCredentialValidator) Validate(credential vc.VerifiableCredential) error { + _, version, err := parseDeziProofType(credential) + if err != nil { + return err + } + switch version { + case "2024": + return deziIDToken2024CredentialValidator{ + clock: time.Now, + trustStore: d.trustStore, + }.Validate(credential) + case "0.7": + return deziIDToken07CredentialValidator{ + clock: time.Now, + allowedJKU: d.AllowedJKU, + }.Validate(credential) + default: + return fmt.Errorf("%w: unsupported Dezi id_token version: %s", errValidation, version) + } +} + +var _ Validator = deziIDToken2024CredentialValidator{} + +// deziIDToken2024CredentialValidator validates DeziIDTokenCredential, +// according to spec of april 2024 (uses x5c in JWT payload instead of jku header) +type deziIDToken2024CredentialValidator struct { + clock func() time.Time + trustStore *core.TrustStore +} + +func (d deziIDToken2024CredentialValidator) Validate(credential vc.VerifiableCredential) error { + proof, _, err := parseDeziProofType(credential) + if err != nil { + return fmt.Errorf("%w: %w", errValidation, err) + } + + idToken, err := d.validateIDToken(credential, proof.JWT) + if err != nil { + return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err) + } + + // Validate that token timestamps match credential dates + if !idToken.NotBefore().Equal(credential.IssuanceDate) { + return errors.New("id_token 'nbf' does not match credential 'issuanceDate'") + } + if !idToken.Expiration().Equal(*credential.ExpirationDate) { + return errors.New("id_token 'exp' does not match credential 'expirationDate'") + } + + // Validate that the + + return (defaultCredentialValidator{}).Validate(credential) +} + +func (d deziIDToken2024CredentialValidator) validateIDToken(credential vc.VerifiableCredential, serialized string) (jwt.Token, error) { + // Parse without verification first to extract x5c from payload + token, err := jwt.Parse([]byte(serialized), jwt.WithVerify(false), jwt.WithValidate(false)) + if err != nil { + return nil, fmt.Errorf("parse JWT: %w", err) + } + + // After signature has been validated, token can be considered a valid JWT + err = d.validateSignature(token, err, serialized) + if err != nil { + return nil, fmt.Errorf("signature: %w", err) + } + return token, nil +} + +func (d deziIDToken2024CredentialValidator) validateSignature(token jwt.Token, err error, serialized string) error { + // Extract x5c claim from payload (not header - this is non-standard but per 2024 spec) + x5cRaw, ok := token.Get("x5c") + if !ok { + return errors.New("missing 'x5c' claim in JWT payload") + } + + var x5c []any + switch v := x5cRaw.(type) { + case []any: + x5c = v + case string: + x5c = []any{v} + default: + return errors.New("'x5c' claim must be either a string or an array of strings") + } + + // Parse the certificate chain + var certChain [][]byte + for i, certData := range x5c { + certStr, ok := certData.(string) + if !ok { + return fmt.Errorf("'x5c[%d]' must be a string", i) + } + // x5c contains base64-encoded DER certificates + certBytes, err := base64.StdEncoding.DecodeString(certStr) + if err != nil { + return fmt.Errorf("decode 'x5c[%d]': %w", i, err) + } + certChain = append(certChain, certBytes) + } + + if len(certChain) == 0 { + return errors.New("'x5c' certificate chain is empty") + } + + // Parse the leaf certificate (first in chain) + leafCert, err := x509.ParseCertificate(certChain[0]) + if err != nil { + return fmt.Errorf("parse signing certificate: %w", err) + } + + _, err = leafCert.Verify(x509.VerifyOptions{ + Roots: core.NewCertPool(d.trustStore.RootCAs), + CurrentTime: d.clock(), + Intermediates: core.NewCertPool(d.trustStore.IntermediateCAs), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, // TODO: use more specific key usage if possible + }) + if err != nil { + return fmt.Errorf("verify Dezi certificate chain: %w", err) + } + + // Verify the JWT signature using the leaf certificate's public key + _, err = jwt.Parse([]byte(serialized), jwt.WithKey(jwa.RS256, leafCert.PublicKey), jwt.WithValidate(true), jwt.WithClock(jwt.ClockFunc(d.clock))) + if err != nil { + return err + } + return nil +} + +// deziIDToken07CredentialValidator validates DeziUserCredential, +// according to v0.7 spec of 15-01-2026 (https://www.dezi.nl/documenten/2025/12/15/koppelvlakspecificatie-dezi-voor-platform--en-softwareleveranciers) +type deziIDToken07CredentialValidator struct { + clock func() time.Time + httpClient *http.Client // Optional HTTP client for fetching JWK Set (for testing) + allowedJKU []string // List of allowed jku URLs +} + +func (d deziIDToken07CredentialValidator) Validate(credential vc.VerifiableCredential) error { + proof, _, err := parseDeziProofType(credential) + if err != nil { + return fmt.Errorf("%w: %w", errValidation, err) + } + if err := d.validateDeziToken(credential, proof.JWT); err != nil { + return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err) + } + return (defaultCredentialValidator{}).Validate(credential) +} + +func (d deziIDToken07CredentialValidator) validateDeziToken(credential vc.VerifiableCredential, serialized string) error { + // Parse and verify the JWT + // - WithVerifyAuto(nil, ...) uses default jwk.Fetch and automatically fetches the JWK Set from the jku header URL + // - WithFetchWhitelist allows fetching from any https:// URL (Dezi endpoints) + // - WithHTTPClient allows using a custom HTTP client (for testing with self-signed certs) + fetchOptions := []jwk.FetchOption{jwk.WithFetchWhitelist(jwk.WhitelistFunc(func(requestedURL string) bool { + return slices.Contains(d.allowedJKU, requestedURL) + }))} + if d.httpClient != nil { + fetchOptions = append(fetchOptions, jwk.WithHTTPClient(d.httpClient)) + } + + // TODO: Only allow specific domains for the jku + // TODO: make sure it's signed with a jku + token, err := jwt.Parse( + []byte(serialized), + jwt.WithVerifyAuto(nil, fetchOptions...), + jwt.WithClock(jwt.ClockFunc(d.clock)), + ) + if err != nil { + return fmt.Errorf("failed to verify JWT signature: %w", err) + } + + // Validate that token timestamps match credential dates + if !token.NotBefore().Equal(credential.IssuanceDate) { + return errors.New("'nbf' does not match credential 'issuanceDate'") + } + if !token.Expiration().Equal(*credential.ExpirationDate) { + return errors.New("'exp' does not match credential 'expirationDate'") + } + + var credentialSubject []DeziIDTokenSubject + if err = credential.UnmarshalCredentialSubject(&credentialSubject); err != nil { + return fmt.Errorf("invalid credential subject format: %w", err) + } + if len(credentialSubject) != 1 { + return fmt.Errorf("expected exactly one credential subject, got %d", len(credentialSubject)) + } + + subjectFromToken, _, err := extractDeziIDTokenSubject(token) + if err != nil { + return fmt.Errorf("invalid id_token claims: %w", err) + } + if !reflect.DeepEqual(credentialSubject[0], subjectFromToken) { + return errors.New("credential subject does not match id_token claims") + } + + // TODO: check id_token revocation + return nil +} + +type deziProofType struct { + Type string `json:"type"` + JWT string `json:"jwt"` +} + +func parseDeziProofType(credential vc.VerifiableCredential) (*deziProofType, string, error) { + var proofs []deziProofType + if err := credential.UnmarshalProofValue(&proofs); err != nil { + return nil, "", fmt.Errorf("invalid proof format: %w", err) + } + if len(proofs) != 1 { + return nil, "", fmt.Errorf("expected exactly one proof, got %d", len(proofs)) + } + proof := &proofs[0] + + // Derive version from proof type + var version string + switch proof.Type { + case "DeziIDJWT2024": + version = "2024" + case "DeziIDJWT07": + version = "0.7" + default: + return nil, "", fmt.Errorf("invalid proof type: expected 'DeziIDJWT2024' or 'DeziIDJWT07', got '%s'", proof.Type) + } + + return proof, version, nil +} + +// extractDeziIDTokenSubject extracts and validates the subject information from a Dezi id_token JWT. +// It returns the DeziIDTokenSubject, the detected version ("2024" or "0.7"), and any error encountered. +func extractDeziIDTokenSubject(idToken jwt.Token) (DeziIDTokenSubject, string, error) { + // Check if this is v0.7 format (has abonnee_nummer) or 2024 format (has relations) + var version string + { + _, hasRelations := idToken.Get("relations") + if hasRelations { + version = "2024" + } else { + version = "0.7" + } + } + + switch version { + case "0.7": + return extractDezi07Subject(idToken) + case "2024": + return extractDezi2024Subject(idToken) + default: + return DeziIDTokenSubject{}, "", fmt.Errorf("unsupported Dezi id_token version: %s", version) + } +} + +// extractDezi07Subject extracts the subject from a v0.7 Dezi id_token +func extractDezi07Subject(idToken jwt.Token) (DeziIDTokenSubject, string, error) { + getString := func(claim string) string { + value, ok := idToken.Get(claim) + if !ok { + return "" + } + result, _ := value.(string) + return result + } + + orgURA := getString("abonnee_nummer") + if orgURA == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'abonnee_nummer' claim") + } + orgName := getString("abonnee_naam") + if orgName == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'abonnee_naam' claim") + } + + userID := getString("dezi_nummer") + if userID == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'dezi_nummer' claim") + } + initials := getString("voorletters") + if initials == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'voorletters' claim") + } + surname := getString("achternaam") + if surname == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'achternaam' claim") + } + surnamePrefix := getString("voorvoegsel") // Can be null/empty in v0.7 + + role := getString("rol_code") + roleRegistry := getString("rol_code_bron") + roleName := getString("rol_naam") + + return DeziIDTokenSubject{ + Identifier: orgURA, + Name: orgName, + Employee: HealthcareWorker{ + Identifier: userID, + Initials: initials, + SurnamePrefix: surnamePrefix, + Surname: surname, + Role: role, + RoleRegistry: roleRegistry, + RoleName: roleName, + }, + }, "0.7", nil +} + +// extractDezi2024Subject extracts the subject from a 2024 Dezi id_token +func extractDezi2024Subject(idToken jwt.Token) (DeziIDTokenSubject, string, error) { + getString := func(claim string) string { + value, ok := idToken.Get(claim) + if !ok { + return "" + } + result, _ := value.(string) + return result + } + + relationsRaw, _ := idToken.Get("relations") + relations, ok := relationsRaw.([]any) + if !ok || len(relations) != 1 { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") + } + relation, ok := relations[0].(map[string]any) + if !ok { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") + } + + orgURA, ok := relation["ura"].(string) + if !ok || orgURA == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token 'relations[0].ura' claim invalid or missing (expected non-empty string)") + } + orgName, _ := relation["entity_name"].(string) + + userID := getString("dezi_nummer") + if userID == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'dezi_nummer' claim") + } + initials := getString("initials") + if initials == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'initials' claim") + } + surname := getString("surname") + if surname == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'surname' claim") + } + surnamePrefix := getString("surname_prefix") + if surnamePrefix == "" { + return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'surname_prefix' claim") + } + + // In 2024 format, roles is an array - we'll take the first role for now + // TODO: Clarify how to handle multiple roles + var role string + rolesAny, ok := relation["roles"].([]any) + if ok && len(rolesAny) > 0 { + role, _ = rolesAny[0].(string) + } + + return DeziIDTokenSubject{ + Identifier: orgURA, + Name: orgName, + Employee: HealthcareWorker{ + Identifier: userID, + Initials: initials, + SurnamePrefix: surnamePrefix, + Surname: surname, + Role: role, + }, + }, "2024", nil +} diff --git a/vcr/credential/dezi_test.go b/vcr/credential/dezi_test.go new file mode 100644 index 0000000000..d320d66f16 --- /dev/null +++ b/vcr/credential/dezi_test.go @@ -0,0 +1,409 @@ +package credential + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/core/to" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubbedRoundTripper is a test helper that returns a mock JWK Set for any HTTP request +type stubbedRoundTripper struct { + keySets map[string]jwk.Set +} + +func (s *stubbedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + keySet, ok := s.keySets[req.URL.String()] + if !ok { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewReader([]byte(`{"error": "not found"}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + } + + // Marshal the key set to JSON + jwksJSON, err := json.Marshal(keySet) + if err != nil { + return nil, err + } + + // Return a mock HTTP response with the JWK Set + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(jwksJSON)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil +} + +func TestCreateDeziIDToken(t *testing.T) { + t.Run("version 0.7", func(t *testing.T) { + const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjMyNWRlOWFiLTQzMzAtNGMwMS04MjRlLWQ5YmQwYzM3Y2NhMCIsImprdSI6Imh0dHBzOi8vW2V4dGVybiBlbmRwb2ludF0vandrcy5qc29uIiwidHlwIjoiSldUIn0.eyJqdGkiOiI2MWIxZmFmYy00ZWM3LTQ0ODktYTI4MC04ZDBhNTBhM2Q1YTkiLCJpc3MiOiJhYm9ubmVlLmRlemkubmwiLCJleHAiOjE3NDAxMzExNzYsIm5iZiI6MTczMjE4MjM3NiwianNvbl9zY2hlbWEiOiJodHRwczovL3d3dy5kZXppLm5sL2pzb25fc2NoZW1hcy92ZXJrbGFyaW5nX3YxLmpzb24iLCJsb2FfZGV6aSI6Imh0dHA6Ly9laWRhcy5ldXJvcGUuZXUvTG9BL2hpZ2giLCJ2ZXJrbGFyaW5nX2lkIjoiODUzOWY3NWQtNjM0Yy00N2RiLWJiNDEtMjg3OTFkZmQxZjhkIiwiZGV6aV9udW1tZXIiOiIxMjM0NTY3ODkiLCJ2b29ybGV0dGVycyI6IkEuQi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IlpvcmdtZWRld2Vya2VyIiwiYWJvbm5lZV9udW1tZXIiOiI4NzY1NDMyMSIsImFib25uZWVfbmFhbSI6IlpvcmdhYW5iaWVkZXIiLCJyb2xfY29kZSI6IjAxLjAwMCIsInJvbF9uYWFtIjoiQXJ0cyIsInJvbF9jb2RlX2Jyb24iOiJodHRwOi8vd3d3LmRlemkubmwvcm9sX2NvZGVfYnJvbi9iaWciLCJyZXZvY2F0aWVfY29udHJvbGVfdXJpIjoiaHR0cHM6Ly9hdXRoLmRlemkubmwvcmV2b2NhdGllLXN0YXR1cy92MS92ZXJrbGFyaW5nLzg1MzlmNzVkLTYzNGMtNDdkYi1iYjQxLTI4NzkxZGZkMWY4ZCJ9.vegszRMWJjE-SBpfPO9lxN_fEY814ezsXRYhLXorPq3j_B_wlv4A92saasdEWrTALbl9Shux0i6JvkbouqvZ_oJpOUfJxWFGFfGGCuiMhiz4k1zm665i98e2xTqFzqjQySu_gup3wYm24FmnzbHxy02RzM3pXvQCsk_jIfQ1YcUZmNmXa5hR4DEn4Z9STLHd2HwyL6IKafEGl-R_kgbAnArSHQvuLw0Fpx62QD0tr5d3PbzPirBdkuy4G1l0umb69EjZMZ5MyIl8Y_irhQ9IFomAeSlU_zZp6UojVIOnCY2gL5EMc_8B1PDC6R_C--quGoh14jiSOJAeYSf_9ETjgQ" + + actual, err := CreateDeziUserCredential(input) + require.NoError(t, err) + + require.Len(t, actual.CredentialSubject, 1) + subject := actual.CredentialSubject[0] + employee := subject["employee"].(map[string]interface{}) + assert.Equal(t, "87654321", subject["identifier"]) + assert.Equal(t, "Zorgaanbieder", subject["name"]) + assert.Equal(t, "123456789", employee["identifier"]) + assert.Equal(t, "A.B.", employee["initials"]) + assert.Equal(t, "Zorgmedewerker", employee["surname"]) + assert.Equal(t, "", employee["surnamePrefix"]) // voorvoegsel is null in this token + assert.Equal(t, "01.000", employee["role"]) + assert.Equal(t, "http://www.dezi.nl/rol_code_bron/big", employee["role_registry"]) + assert.Equal(t, "Arts", employee["role_name"]) + + t.Run("from online test environment", func(t *testing.T) { + // Payload: + // { + // "json_schema": "https://www.dezi.nl/json_schemas/v1/verklaring.json", + // "loa_dezi": "http://eidas.europa.eu/LoA/high", + // "jti": "f410b255-6b07-4182-ac5c-c41f02bd3995", + // "verklaring_id": "0e970fcb-530c-482e-ba28-47b461d4dcb5", + // "dezi_nummer": "900022159", + // "voorletters": "J.", + // "voorvoegsel": null, + // "achternaam": "90017362", + // "abonnee_nummer": "90000380", + // "abonnee_naam": "Tést Zorginstelling 01", + // "rol_code": "92.000", + // "rol_naam": "Mondhygiënist", + // "rol_code_bron": "http://www.dezi.nl/rol_bron/big", + // "status_uri": "https://acceptatie.auth.dezi.nl/status/v1/verklaring/0e970fcb-530c-482e-ba28-47b461d4dcb5", + // "nbf": 1772665200, + // "exp": 1780610400, + // "iss": "https://abonnee.dezi.nl" + //} + const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ" + + actual, err := CreateDeziUserCredential(input) + require.NoError(t, err) + + require.Len(t, actual.CredentialSubject, 1) + subject := actual.CredentialSubject[0] + employee := subject["employee"].(map[string]interface{}) + assert.Equal(t, "90000380", subject["identifier"]) + assert.Equal(t, "Tést Zorginstelling 01", subject["name"]) + assert.Equal(t, "900022159", employee["identifier"]) + assert.Equal(t, "J.", employee["initials"]) + assert.Equal(t, "90017362", employee["surname"]) + assert.Equal(t, "", employee["surnamePrefix"]) // voorvoegsel is null in this token + assert.Equal(t, "92.000", employee["role"]) + assert.Equal(t, "http://www.dezi.nl/rol_bron/big", employee["role_registry"]) + assert.Equal(t, "Mondhygiënist", employee["role_name"]) + }) + }) +} + +func TestDeziIDToken07CredentialValidator(t *testing.T) { + iat := time.Unix(1732182376, 0) + exp := time.Unix(1740131176, 0) + validAt := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC) + + signingKeyCert, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + require.NoError(t, err) + + publicKeyJWK, err := jwk.FromRaw(signingKeyCert.Leaf.PublicKey) + require.NoError(t, err) + require.NoError(t, publicKeyJWK.Set(jwk.KeyIDKey, "1")) + correctKeySet := jwk.NewSet() + require.NoError(t, correctKeySet.AddKey(publicKeyJWK)) + // KeySet taken from https://acceptatie.auth.dezi.nl/dezi/jwks.json, copied to make the test deterministic + accKeySet := jwk.NewSet() + err = json.Unmarshal([]byte(`{ + "keys" : [ + { + "kty": "RSA", + "kid": "ae46829d-c8e8-48a0-bd6a-21b8a07b8cb2", + "x5c": [ + "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" + ], + "x5t": "mlPsZptNN2Bo8A8A6keBROJ6Q_U", + "x5t#S256": "UHZTsA9YMQnGRd24MZLxZabWczwuZn1PE9iV7j-oDm8", + "n": "4ObWRH19nPSyFsuKIQ_HG3FrlAqoBiij4mAYsAl7EWduHCGj92jkkGE4z6CNPgcdVK3J2WllhyKj7kDf1aZoCvfkVrQHpS_GnEBHME-5Vo3a8Z-1AfVxxSbVLlXFu793tx83U_mB8PVxHhzf6pW449fjZrSNc0cnluXoYRFgNGxD0hlL5JahMuOoWGpKJ5XVZp6bZjbIuHc2rC589THQl1N1V11QcpoCnQsFkX92JTtgtDl-jehrqr_P2-EXRhAZl59MAk6BAZXBJWDFY_gbjYW3j4q-ITBG5iGc8tYK3JxOCdK4K3Ql3QoNEptU32ET1zrRux5D5MRiC09MKoJ4bQ", + "e": "AQAB" + } +]}`), &accKeySet) + require.NoError(t, err) + + wrongKeySet := jwk.NewSet() + wrongKey, _ := jwk.FromRaw([]byte("wrong-secret-key-data")) + wrongKey.Set(jwk.KeyIDKey, "wrong-kid") + wrongKeySet.AddKey(wrongKey) + + tests := []struct { + name string + deziAttestation string + keySet jwk.Set + clock *time.Time + modifyCred func(*vc.VerifiableCredential) + allowedJKU []string + wantErr string + }{ + { + name: "ok", + keySet: correctKeySet, + }, + { + name: "from test environment", + deziAttestation: "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ", + clock: to.Ptr(time.Date(2026, 3, 11, 8, 0, 0, 0, time.UTC)), + }, + { + name: "wrong exp", + keySet: correctKeySet, + modifyCred: func(c *vc.VerifiableCredential) { + wrongExp := exp.Add(time.Hour) + c.ExpirationDate = &wrongExp + }, + wantErr: "'exp' does not match credential 'expirationDate'", + }, + { + name: "wrong nbf", + keySet: correctKeySet, + modifyCred: func(c *vc.VerifiableCredential) { + c.IssuanceDate = iat.Add(-time.Hour) + }, + wantErr: "'nbf' does not match credential 'issuanceDate'", + }, + { + name: "invalid signature", + keySet: wrongKeySet, + wantErr: "failed to verify JWT signature", + }, + { + name: "JWK set endpoint unreachable", + keySet: nil, + wantErr: "failed to verify JWT signature", + }, + { + name: "token claims differ from credential subject", + keySet: correctKeySet, + modifyCred: func(c *vc.VerifiableCredential) { + c.CredentialSubject[0]["identifier"] = "different-identifier" + }, + wantErr: "credential subject does not match id_token claims", + }, + { + name: "JKU not allowed", + allowedJKU: []string{"https://example.com/other"}, + wantErr: "rejected by whitelist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deziAttestation := tt.deziAttestation + if tt.deziAttestation == "" { + tokenBytes, err := CreateTestDezi07IDToken(iat, exp, signingKeyCert.PrivateKey) + require.NoError(t, err) + deziAttestation = string(tokenBytes) + } + + cred, err := CreateDeziUserCredential(deziAttestation) + require.NoError(t, err) + + if tt.modifyCred != nil { + tt.modifyCred(cred) + } + + validator := deziIDToken07CredentialValidator{ + clock: func() time.Time { return validAt }, + httpClient: &http.Client{Transport: &stubbedRoundTripper{keySets: map[string]jwk.Set{ + "https://acceptatie.auth.dezi.nl/dezi/jwks.json": accKeySet, + "https://example.com/jwks.json": tt.keySet, + }}}, + allowedJKU: []string{ + "https://acceptatie.auth.dezi.nl/dezi/jwks.json", + "https://example.com/jwks.json", + }, + } + if tt.clock != nil { + validator.clock = func() time.Time { return *tt.clock } + } + if tt.allowedJKU != nil { + validator.allowedJKU = tt.allowedJKU + } + + err = validator.Validate(*cred) + if tt.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestDeziIDToken2024CredentialValidator(t *testing.T) { + t.Skip("TODO: implement or remove") + const exampleToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IjFNY2p3cjgxMGpOVUZHVHR6T21MeTRTNnN5cVJ1aVZ1YVM0UmZyWmZwOEk9IiwieDV0Ijoibk4xTVdBeFRZTUgxOE45cFBWMlVIYlVZVDVOWTByT19TaHQyLWZVWF9nOCJ9.eyJhdWQiOiIwMDZmYmYzNC1hODBiLTRjODEtYjZlOS01OTM2MDA2NzVmYjIiLCJleHAiOjE3MDE5MzM2OTcsImluaXRpYWxzIjoiQi5CLiIsImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0OjgwMDYiLCJqc29uX3NjaGVtYSI6Imh0dHBzOi8vbG9jYWxob3N0OjgwMDYvanNvbl9zY2hlbWEuanNvbiIsImxvYV9hdXRobiI6Imh0dHA6Ly9laWRhcy5ldXJvcGEuZXUvTG9BL2hpZ2giLCJsb2FfdXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsIm5iZiI6MTcwMTkzMzYyNywicmVsYXRpb25zIjpbeyJlbnRpdHlfbmFtZSI6IlpvcmdhYW5iaWVkZXIiLCJyb2xlcyI6WyIwMS4wNDEiLCIzMC4wMDAiLCIwMS4wMTAiLCIwMS4wMTEiXSwidXJhIjoiODc2NTQzMjEifV0sInN1cm5hbWUiOiJKYW5zZW4iLCJzdXJuYW1lX3ByZWZpeCI6InZhbiBkZXIiLCJ1emlfaWQiOiI5MDAwMDAwMDkiLCJ4NWMiOiJNSUlEMWpDQ0FiNENDUUQwRmx2SnNiY3J2VEFOQmdrcWhraUc5dzBCQVFzRkFEQW9NUXN3Q1FZRFZRUUREQUpWXG5VekVaTUJjR0ExVUVBd3dRYVc1blpTMDJMWFY2YVhCdll5MWpZVEFlRncweU16RXlNREV4TWpNek16QmFGdzB5XG5OVEEwTVRReE1qTXpNekJhTURJeEN6QUpCZ05WQkFZVEFsVlRNU013SVFZRFZRUUREQnB1YkMxMWVtbHdiMk10XG5jR2h3TFd4aGNtRjJaV3d0WkdWdGJ6Q0NBU0l3RFFZSktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCXG5BS2JDQ0dZYTcwL0VlS2dwc0N0QnRHUmlIWVJyb2RWQWN0bHJPTUhFZDI2aGpSdmN0TGZOaXl0MFF5QkZsekN2XG4wVkRYUjc3OWVvU1E2bWdPYURjNzFrYi9zR1dxbjhMZFE3NEp0WTVnSTVxRzduM1JYM0VRWkxFdGIxNmp6WWROXG5LMU5mMm9GK0tNV2t2eWMvVjlSNWUyNjdyTjJpUklHQlNKUTFmZmN4RHFUZnJNVmxjaFYyZmdWVDdZTzQ3U25qXG5MMXdDK0Z4cXhTRzc1N056OHlleVBncjJaazFvaWF6dHhQY1hXRlVpTklGWm9KUzlpVzdITTZyQ204WjcvbVJjXG40Qm5kbC9wbkZlMjVrZmhPZzlKSVVNbzFvcjltbDZDSXN6Um9aL2hTOHZCOUduNldUS05CYUgxMTB6Sno4eXNkXG42cXM4WkpCYURia0pnSTZMNlZtL3d0OENBd0VBQVRBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQWdFQWl6eEJmYUFhXG5ETlFOOWhYS0ZKZVB6N2RUUEx3SFkvWmpDNDlkUXJ0eVd6ZmdyMUxLaTRvMWl4amtkZzJGWFU5dCt4TXVXZ3hYXG5GZGJqT0pMZGNRWVdFWWIrVzdLbWtnbEljUDhiT2tSRFdmcGxnc2twYVRvZ1JLMDk1UzFDdU1NOXYwYktDL3RzXG5kSGIzVWZxVzRVNFprbzM4L1VlOWZSRjhyYTJwNzFRRnM4K250L0JBd0NrenJOTHpheE1ZOC8vVGlGVStaRVlMXG56UElRQmpLYVlCOHlWaDBXaDNxYWllQjJCektVYW4rRXlzaDJiVWM5VHBsUXlrSWRrNHo2VCtGTzVLVGo1ZVZrXG42enBIZmxXV0NUNjF5MTVtdTN4QUViODNyT2YrekZwb05HaURzc2lrbzBPZUxLN0ZscWg3SHVDUDI2Tk5ud3NiXG5WR3drZzYwcER1K0FTRzJhbTNUUGlmM0pwSTdza3pBQkZ3NHZidlBVcElrNkltM3ljQzk4R3lYb3dRdWpJMFpUXG4xNmRYZmgxRTM4cHNSVWVPNW8rdXhZNk1VUFhOU2lvWVowbWYzQkFSTGFoTjQxcnF4S1h6NU1MMURTWm5JT1pLXG5GM3BlU2dnYVpvUmkxaDByNlcxNFdFY1l2eGRIRGtWUjZNMXFXMGk3WWVJQms2a2FYRWt3Q21GejNoazV3OWFuXG5XSkRqbk1xU1JnUnNGVmNJTC9FemkvRWx1YmsyMWY0TEhURVFtc2p6emQxRytkMDlmamRJNkpyaFlNZnRHdVlaXG40ak9aWldwem9NSDFUaVpaK0prQmR5UndFZGJxelcrdisvMEJaUXk2SFJhWmxvbWJjT21TOU1TakZSRFR5VUdXXG5EOUYxZVVJcUtjdDB5eUpQUFhIM2xEa3pxcXRYNERMY29wbz0ifQ.VvzIXZ8FCIwxvP3Wc4kLvIgQChJZAhS-DcKKvkiZg677w-ZRciIFCWUH5oXLqG-emyV4f87tIoWnp4TY3gGFNljNrtlTVCv3zXaTCxHwzL6q2QCs1liBus2uPv0kjBtzeve2G5_Owst3ndeUcwLJPnTIoYRLvbjjaPkFTg49K5ZTpN8E9dl7Gimwgv_rZ1fOH7XrAwlTY-jF34wsR_K17wHI5Zp237_HcAPqnMI8P3U7u74Vu-3mqCePubVBDnT4bGcd4flZCFH-LTDhew9BO4cBkBxafAev7OB5A9qGOKEtRynTDAOkazyb8_qwJAGnyCAVxBQ4VFRB1-cE576TLQ` + const exampleCertificateDERBase64 = `MIID1jCCAb4CCQD0FlvJsbcrvTANBgkqhkiG9w0BAQsFADAoMQswCQYDVQQDDAJV +UzEZMBcGA1UEAwwQaW5nZS02LXV6aXBvYy1jYTAeFw0yMzEyMDExMjMzMzBaFw0y +NTA0MTQxMjMzMzBaMDIxCzAJBgNVBAYTAlVTMSMwIQYDVQQDDBpubC11emlwb2Mt +cGhwLWxhcmF2ZWwtZGVtbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AKbCCGYa70/EeKgpsCtBtGRiHYRrodVActlrOMHEd26hjRvctLfNiyt0QyBFlzCv +0VDXR779eoSQ6mgOaDc71kb/sGWqn8LdQ74JtY5gI5qG7n3RX3EQZLEtb16jzYdN +K1Nf2oF+KMWkvyc/V9R5e267rN2iRIGBSJQ1ffcxDqTfrMVlchV2fgVT7YO47Snj +L1wC+FxqxSG757Nz8yeyPgr2Zk1oiaztxPcXWFUiNIFZoJS9iW7HM6rCm8Z7/mRc +4Bndl/pnFe25kfhOg9JIUMo1or9ml6CIszRoZ/hS8vB9Gn6WTKNBaH110zJz8ysd +6qs8ZJBaDbkJgI6L6Vm/wt8CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAizxBfaAa +DNQN9hXKFJePz7dTPLwHY/ZjC49dQrtyWzfgr1LKi4o1ixjkdg2FXU9t+xMuWgxX +FdbjOJLdcQYWEYb+W7KmkglIcP8bOkRDWfplgskpaTogRK095S1CuMM9v0bKC/ts +dHb3UfqW4U4Zko38/Ue9fRF8ra2p71QFs8+nt/BAwCkzrNLzaxMY8//TiFU+ZEYL +zPIQBjKaYB8yVh0Wh3qaieB2BzKUan+Eysh2bUc9TplQykIdk4z6T+FO5KTj5eVk +6zpHflWWCT61y15mu3xAEb83rOf+zFpoNGiDssiko0OeLK7Flqh7HuCP26NNnwsb +VGwkg60pDu+ASG2am3TPif3JpI7skzABFw4vbvPUpIk6Im3ycC98GyXowQujI0ZT +16dXfh1E38psRUeO5o+uxY6MUPXNSioYZ0mf3BARLahN41rqxKXz5ML1DSZnIOZK +F3peSggaZoRi1h0r6W14WEcYvxdHDkVR6M1qW0i7YeIBk6kaXEkwCmFz3hk5w9an +WJDjnMqSRgRsFVcIL/Ezi/Elubk21f4LHTEQmsjzzd1G+d09fjdI6JrhYMftGuYZ +4jOZZWpzoMH1TiZZ+JkBdyRwEdbqzW+v+/0BZQy6HRaZlombcOmS9MSjFRDTyUGW +D9F1eUIqKct0yyJPPXH3lDkzqqtX4DLcopo=` + certificateDER, err := base64.StdEncoding.DecodeString(exampleCertificateDERBase64) + require.NoError(t, err) + exampleCertificate, err := x509.ParseCertificate(certificateDER) + require.NoError(t, err) + // Setup trust store with the Dezi certificate as root CA + exampleTrustStore := core.BuildTrustStore([]*x509.Certificate{}) + exampleTrustStore.RootCAs = append(exampleTrustStore.RootCAs, exampleCertificate) + + // Load a signing key pair for creating test tokens + // Note: In real scenarios, the signing key would match the cert in x5c + signingKeyCert, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + require.NoError(t, err) + signingKeyCert.Leaf, err = x509.ParseCertificate(signingKeyCert.Certificate[0]) + require.NoError(t, err) + trustStore := core.BuildTrustStore([]*x509.Certificate{}) + trustStore.RootCAs = append(trustStore.RootCAs, signingKeyCert.Leaf) + + // Use a validation time within the Dezi certificate validity period + validAt := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC) + + // createToken returns a factory function that creates a JWT with the given x5c value + // If x5cValue is a *x509.Certificate, it encodes that certificate in x5c + // If x5cValue is any other type (string, []string, nil), it's used directly as the x5c claim + createToken := func(x5cValue any, nbf *time.Time, exp *time.Time) func(t *testing.T) []byte { + if nbf == nil { + nbf = new(time.Time) + *nbf = time.Unix(1732182376, 0) // Nov 21, 2024 + } + if exp == nil { + exp = new(time.Time) + *exp = time.Unix(1740131176, 0) // Feb 21, 2025 + } + return func(t *testing.T) []byte { + token := jwt.New() + claims := map[string]any{ + jwt.AudienceKey: "006fbf34-a80b-4c81-b6e9-593600675fb2", + jwt.ExpirationKey: exp.Unix(), + jwt.NotBeforeKey: nbf.Unix(), + jwt.IssuerKey: "https://max.proeftuin.Dezi-online.rdobeheer.nl", + jwt.JwtIDKey: "test-jwt-id", + "dezi_nummer": "900000009", + "initials": "B.B.", + "surname": "Jansen", + "surname_prefix": "van der", + "relations": []map[string]interface{}{ + {"entity_name": "Zorgaanbieder", "roles": []string{"01.041"}, "ura": "87654321"}, + }, + } + + // Handle x5c based on type + if cert, ok := x5cValue.(*x509.Certificate); ok { + // Encode the provided certificate in x5c + x5cArray := []string{base64.StdEncoding.EncodeToString(cert.Raw)} + claims["x5c"] = x5cArray + } else if x5cValue != nil { + // Use x5cValue directly (for testing invalid formats) + claims["x5c"] = x5cValue + } + // If x5cValue is nil, don't add x5c claim (for testing missing x5c) + + for k, v := range claims { + require.NoError(t, token.Set(k, v)) + } + signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, signingKeyCert.PrivateKey)) + require.NoError(t, err) + return signed + } + } + + tests := []struct { + name string + createToken func(t *testing.T) []byte + modifyCred func(*vc.VerifiableCredential) + trustStore *core.TrustStore + wantErr string + }{ + { + name: "ok", + createToken: func(t *testing.T) []byte { + return []byte(exampleToken) + }, + trustStore: exampleTrustStore, + }, + { + name: "missing x5c", + createToken: createToken(nil, nil, nil), + wantErr: "missing 'x5c' claim", + }, + { + name: "invalid certificate", + createToken: createToken([]string{"invalid-base64!!!"}, nil, nil), + wantErr: "decode 'x5c", + }, + { + name: "credential's nbf does not match token's nbf", + createToken: createToken([]string{base64.StdEncoding.EncodeToString(signingKeyCert.Leaf.Raw)}, nil, nil), + modifyCred: func(c *vc.VerifiableCredential) { + c.IssuanceDate = time.Date(2024, 11, 1, 0, 0, 0, 0, time.UTC) + }, + wantErr: "'nbf' does not match credential 'issuanceDate'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tokenBytes := tt.createToken(t) + cred, err := CreateDeziUserCredential(string(tokenBytes)) + require.NoError(t, err) + + if tt.modifyCred != nil { + tt.modifyCred(cred) + } + validator := deziIDToken2024CredentialValidator{ + clock: func() time.Time { return validAt }, + trustStore: trustStore, + } + if tt.trustStore != nil { + validator.trustStore = tt.trustStore + } + + err = validator.Validate(*cred) + if tt.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/vcr/credential/koppelvlakspecificatie_07-dezi-voor-platform-en-softwareleveranciers-v0-7.pdf b/vcr/credential/koppelvlakspecificatie_07-dezi-voor-platform-en-softwareleveranciers-v0-7.pdf new file mode 100644 index 0000000000..6cc845d2f7 Binary files /dev/null and b/vcr/credential/koppelvlakspecificatie_07-dezi-voor-platform-en-softwareleveranciers-v0-7.pdf differ diff --git a/vcr/credential/resolver.go b/vcr/credential/resolver.go index 182eda33e7..45b9a8b7d2 100644 --- a/vcr/credential/resolver.go +++ b/vcr/credential/resolver.go @@ -22,6 +22,7 @@ package credential import ( "errors" "fmt" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/crypto" @@ -29,6 +30,8 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/signature/proof" ) +var DefaultDeziUserCredentialValidator = DeziUserCredentialValidator{} + // FindValidator finds the Validator the provided credential based on its Type // When no additional type is provided, it returns the default validator func FindValidator(credential vc.VerifiableCredential, pkiValidator pki.Validator) Validator { @@ -41,6 +44,11 @@ func FindValidator(credential vc.VerifiableCredential, pkiValidator pki.Validato return nutsAuthorizationCredentialValidator{} case X509CredentialType: return x509CredentialValidator{pkiValidator: pkiValidator} + case DeziUserCredentialTypeURI.String(): + // TODO: This is an ugly pattern, and FindValidator() should probably be moved to the Verifier, but that's a big refactor. + // As long as it's non-production/PoC code, this is fine. + // Make nice when merging to master. + return DefaultDeziUserCredentialValidator } } } diff --git a/vcr/credential/test.go b/vcr/credential/test.go new file mode 100644 index 0000000000..bfd11a341d --- /dev/null +++ b/vcr/credential/test.go @@ -0,0 +1,91 @@ +package credential + +import ( + "crypto" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +func CreateTestDezi07IDToken(issuedAt time.Time, validUntil time.Time, key crypto.PrivateKey) ([]byte, error) { + claims := map[string]any{ + jwt.JwtIDKey: "test-jwt-id-07", + jwt.ExpirationKey: validUntil.Unix(), + jwt.NotBeforeKey: issuedAt.Unix(), + jwt.IssuerKey: "abonnee.dezi.nl", + "json_schema": "https://www.dezi.nl/json_schemas/verklaring_v1.json", + "loa_dezi": "http://eidas.europa.eu/LoA/high", + "verklaring_id": "test-verklaring-id", + // v0.7 format claims + "dezi_nummer": "123456789", + "voorletters": "A.B.", + "voorvoegsel": "van der", + "achternaam": "Zorgmedewerker", + "abonnee_nummer": "87654321", + "abonnee_naam": "Zorgaanbieder", + "rol_code": "01.000", + "rol_naam": "Arts", + "rol_code_bron": "http://www.dezi.nl/rol_code_bron/big", + } + token := jwt.New() + for name, value := range claims { + if err := token.Set(name, value); err != nil { + return nil, err + } + } + + headers := jws.NewHeaders() + for k, v := range map[string]any{ + "alg": "RS256", + "kid": "1", + "jku": "https://example.com/jwks.json", + } { + if err := headers.Set(k, v); err != nil { + return nil, err + } + } + return jwt.Sign(token, jwt.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(headers))) +} + +func CreateTestDezi2024IDToken(issuedAt time.Time, validUntil time.Time, key crypto.PrivateKey) ([]byte, error) { + claims := map[string]any{ + jwt.AudienceKey: "006fbf34-a80b-4c81-b6e9-593600675fb2", + jwt.ExpirationKey: validUntil.Unix(), + jwt.NotBeforeKey: issuedAt.Unix(), + jwt.IssuerKey: "https://max.proeftuin.Dezi-online.rdobeheer.nl", + "initials": "B.B.", + "surname": "Jansen", + "surname_prefix": "van der", + "Dezi_id": "900000009", + "json_schema": "https://max.proeftuin.Dezi-online.rdobeheer.nl/json_schema.json", + "loa_authn": "http://eidas.europa.eu/LoA/high", + "loa_Dezi": "http://eidas.europa.eu/LoA/high", + "relations": []map[string]interface{}{ + { + "entity_name": "Zorgaanbieder", + "roles": []string{"01.041", "30.000", "01.010", "01.011"}, + "ura": "87654321", + }, + }, + } + token := jwt.New() + for name, value := range claims { + if err := token.Set(name, value); err != nil { + return nil, err + } + } + + headers := jws.NewHeaders() + for k, v := range map[string]any{ + "alg": "RS256", + "kid": "1", + "jku": "https://example.com/jwks.json", + } { + if err := headers.Set(k, v); err != nil { + return nil, err + } + } + return jwt.Sign(token, jwt.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(headers))) +} diff --git a/vcr/credential/types.go b/vcr/credential/types.go index 87da9fefeb..b7bb5b9283 100644 --- a/vcr/credential/types.go +++ b/vcr/credential/types.go @@ -39,6 +39,8 @@ var ( NutsOrganizationCredentialTypeURI, _ = ssi.ParseURI(NutsOrganizationCredentialType) // NutsAuthorizationCredentialTypeURI is the VC type for a NutsAuthorizationCredentialType as URI NutsAuthorizationCredentialTypeURI, _ = ssi.ParseURI(NutsAuthorizationCredentialType) + // DeziUserCredentialTypeURI is the VC type for a DeziUserCredential + DeziUserCredentialTypeURI = ssi.MustParseURI("DeziUserCredential") // NutsV1ContextURI is the nuts V1 json-ld context as URI NutsV1ContextURI = ssi.MustParseURI(NutsV1Context) ) diff --git a/vcr/credential/util.go b/vcr/credential/util.go index 41643548f7..0895718898 100644 --- a/vcr/credential/util.go +++ b/vcr/credential/util.go @@ -20,18 +20,20 @@ package credential import ( "errors" + "slices" + "time" + "github.com/google/uuid" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" - "slices" - "time" ) // ResolveSubjectDID resolves the subject DID from the given credentials. +// It skips credentials that don't have a credentialSubject.id (e.g., DeziUserCredential). // It returns an error if: // - the credentials do not have the same subject DID. -// - the credentials do not have a subject DID. +// - none of the credentials have a subject DID. func ResolveSubjectDID(credentials ...vc.VerifiableCredential) (*did.DID, error) { var subjectID did.DID for _, credential := range credentials { @@ -114,10 +116,20 @@ func PresentationExpirationDate(presentation vc.VerifiablePresentation) *time.Ti // AutoCorrectSelfAttestedCredential sets the required fields for a self-attested credential. // These are provided through the API, and for convenience we set the required fields, if not already set. -// It only does this for unsigned JSON-LD credentials. DO NOT USE THIS WITH JWT_VC CREDENTIALS. +// It only does this for unsigned JSON-LD credentials and DeziUserCredentials (derived proof). DO NOT USE THIS WITH JWT_VC CREDENTIALS. func AutoCorrectSelfAttestedCredential(credential vc.VerifiableCredential, requester did.DID) vc.VerifiableCredential { if len(credential.Proof) > 0 { - return credential + proofs, _ := credential.Proofs() + requiresCorrection := false + for _, p := range proofs { + if slices.Contains(DeziIDJWTProofTypes(), string(p.Type)) { + requiresCorrection = true + break + } + } + if !requiresCorrection { + return credential + } } if credential.ID == nil { credential.ID, _ = ssi.ParseURI(uuid.NewString()) diff --git a/vcr/credential/validator.go b/vcr/credential/validator.go index ac2b481dae..56be3476b3 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -25,6 +25,9 @@ import ( "encoding/json" "errors" "fmt" + "net/url" + "strings" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -33,8 +36,6 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/revocation" "github.com/nuts-foundation/nuts-node/vdr/didx509" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "net/url" - "strings" ) // Validator is the interface specific VC verification. diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go index 182f434d41..75803fb1c8 100644 --- a/vcr/holder/presenter.go +++ b/vcr/holder/presenter.go @@ -23,22 +23,23 @@ import ( "encoding/json" "errors" "fmt" + "os" + "strings" + "time" + "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vcr/log" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/signature" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/piprate/json-gold/ld" - "strings" - "time" ) type presenter struct { @@ -109,57 +110,55 @@ func (p presenter) buildPresentation(ctx context.Context, signerDID *did.DID, cr return nil, fmt.Errorf("unable to resolve assertion key for signing VP (did=%s): %w", *signerDID, err) } + var vp *vc.VerifiablePresentation switch options.Format { case JWTPresentationFormat: - return p.buildJWTPresentation(ctx, *signerDID, credentials, options, kid) + vp, err = p.buildJWTPresentation(ctx, *signerDID, credentials, options, kid) + if err != nil { + return nil, err + } case "": fallthrough case JSONLDPresentationFormat: - return p.buildJSONLDPresentation(ctx, *signerDID, credentials, options, kid) + vp, err = p.buildJSONLDPresentation(ctx, *signerDID, credentials, options, kid) + if err != nil { + return nil, err + } default: return nil, fmt.Errorf("unsupported presentation proof format: %s", options.Format) } + + tmpFile, err := os.CreateTemp(os.TempDir(), "vp-*.txt") + if err != nil { + return nil, fmt.Errorf("unable to create temp file for VP debug output: %w", err) + } + defer tmpFile.Close() + _, err = tmpFile.WriteString(vp.Raw()) + if err != nil { + return nil, fmt.Errorf("unable to write VP debug output to temp file: %w", err) + } + log.Logger().Infof("Created VP stored in temp file: %s", tmpFile.Name()) + return vp, nil } // buildJWTPresentation builds a JWT presentation according to https://www.w3.org/TR/vc-data-model/#json-web-token func (p presenter) buildJWTPresentation(ctx context.Context, subjectDID did.DID, credentials []vc.VerifiableCredential, options PresentationOptions, keyID string) (*vc.VerifiablePresentation, error) { - headers := map[string]interface{}{ - jws.TypeKey: "JWT", - } - id := did.DIDURL{DID: subjectDID} - id.Fragment = strings.ToLower(uuid.NewString()) - claims := map[string]interface{}{ - jwt.SubjectKey: subjectDID.String(), - jwt.JwtIDKey: id.String(), - "vp": vc.VerifiablePresentation{ - Context: append([]ssi.URI{VerifiableCredentialLDContextV1}, options.AdditionalContexts...), - Type: append([]ssi.URI{VerifiablePresentationLDType}, options.AdditionalTypes...), - Holder: options.Holder, - VerifiableCredential: credentials, - }, - } - if options.ProofOptions.Nonce != nil { - claims["nonce"] = *options.ProofOptions.Nonce - } - if options.ProofOptions.Domain != nil { - claims[jwt.AudienceKey] = *options.ProofOptions.Domain - } - if options.ProofOptions.Created.IsZero() { - claims[jwt.NotBeforeKey] = time.Now().Unix() - } else { - claims[jwt.NotBeforeKey] = int(options.ProofOptions.Created.Unix()) - } + exp := options.ProofOptions.Created.Add(1 * time.Hour) if options.ProofOptions.Expires != nil { - claims[jwt.ExpirationKey] = int(options.ProofOptions.Expires.Unix()) - } - for claimName, value := range options.ProofOptions.AdditionalProperties { - claims[claimName] = value - } - token, err := p.signer.SignJWT(ctx, claims, headers, keyID) - if err != nil { - return nil, fmt.Errorf("unable to sign JWT presentation: %w", err) - } - return vc.ParseVerifiablePresentation(token) + exp = *options.ProofOptions.Expires + } + return vc.CreateJWTVerifiablePresentation(ctx, subjectDID.URI(), credentials, vc.PresentationOptions{ + AdditionalContexts: options.AdditionalContexts, + AdditionalTypes: options.AdditionalTypes, + AdditionalProofProperties: options.ProofOptions.AdditionalProperties, + Holder: options.Holder, + Nonce: options.ProofOptions.Nonce, + Audience: options.ProofOptions.Domain, + IssuedAt: &options.ProofOptions.Created, + ExpiresAt: exp, + }, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) { + return p.signer.SignJWT(ctx, claims, headers, keyID) + }) } func (p presenter) buildJSONLDPresentation(ctx context.Context, subjectDID did.DID, credentials []vc.VerifiableCredential, options PresentationOptions, keyID string) (*vc.VerifiablePresentation, error) { diff --git a/vcr/holder/presenter_test.go b/vcr/holder/presenter_test.go index efc47e70b8..1f334b9a9d 100644 --- a/vcr/holder/presenter_test.go +++ b/vcr/holder/presenter_test.go @@ -20,6 +20,9 @@ package holder import ( "context" + "testing" + "time" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -39,8 +42,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "testing" - "time" ) func TestPresenter_buildPresentation(t *testing.T) { @@ -142,7 +143,7 @@ func TestPresenter_buildPresentation(t *testing.T) { }) }) t.Run("JWT", func(t *testing.T) { - options := PresentationOptions{Format: JWTPresentationFormat} + options := PresentationOptions{Format: JWTPresentationFormat, ProofOptions: proof.ProofOptions{Created: time.Now()}} t.Run("ok - one VC", func(t *testing.T) { ctrl := gomock.NewController(t) @@ -163,6 +164,7 @@ func TestPresenter_buildPresentation(t *testing.T) { nonce, _ := result.JWT().Get("nonce") assert.Empty(t, nonce) }) + t.Run("ok - multiple VCs", func(t *testing.T) { ctrl := gomock.NewController(t) diff --git a/vcr/holder/sql_wallet.go b/vcr/holder/sql_wallet.go index c66b6b92da..64d2035ec2 100644 --- a/vcr/holder/sql_wallet.go +++ b/vcr/holder/sql_wallet.go @@ -20,7 +20,6 @@ package holder import ( "context" - "errors" "fmt" "time" @@ -128,13 +127,15 @@ func (h sqlWallet) List(_ context.Context, holderDID did.DID) ([]vc.VerifiableCr // now validate credentials and remove invalid ones validCredentials := make([]vc.VerifiableCredential, 0, len(credentials)) for _, credential := range credentials { + validCredentials = append(validCredentials, credential) + // TODO: Disabled for now in project GF, because we want to actively demo with expired credentials. // we only want to check expiration and revocation status - if err = h.verifier.Verify(credential, true, false, nil); err == nil { - validCredentials = append(validCredentials, credential) - } else if !errors.Is(err, types.ErrCredentialNotValidAtTime) && !errors.Is(err, types.ErrRevoked) { - // a possible technical error has occurred that should be logged. - log.Logger().WithError(err).WithField(core.LogFieldCredentialID, credential.ID).Warn("unable to verify credential") - } + //if err = h.verifier.Verify(credential, true, false, nil); err == nil { + // validCredentials = append(validCredentials, credential) + //} else if !errors.Is(err, types.ErrCredentialNotValidAtTime) && !errors.Is(err, types.ErrRevoked) { + // // a possible technical error has occurred that should be logged. + // log.Logger().WithError(err).WithField(core.LogFieldCredentialID, credential.ID).Warn("unable to verify credential") + //} } return validCredentials, nil diff --git a/vcr/holder/sql_wallet_test.go b/vcr/holder/sql_wallet_test.go index 75d4767538..6ba791771f 100644 --- a/vcr/holder/sql_wallet_test.go +++ b/vcr/holder/sql_wallet_test.go @@ -202,6 +202,7 @@ func Test_sqlWallet_List(t *testing.T) { assert.Equal(t, expected.ID.String(), list[0].ID.String()) }) t.Run("expired credential", func(t *testing.T) { + t.Skip("TODO: Disabled for now in project GF, because we want to actively demo with expired credentials.") resetStore(t, storageEngine.GetSQLDatabase()) sut := NewSQLWallet(nil, nil, testVerifier{err: types.ErrCredentialNotValidAtTime}, nil, storageEngine) expected := createCredential(vdr.TestMethodDIDA.String()) @@ -213,6 +214,7 @@ func Test_sqlWallet_List(t *testing.T) { require.Len(t, list, 0) }) t.Run("other error", func(t *testing.T) { + t.Skip("TODO: Disabled for now in project GF, because we want to actively demo with expired credentials.") captureLogs := audit.CaptureLogs(t, logrus.StandardLogger()) resetStore(t, storageEngine.GetSQLDatabase()) sut := NewSQLWallet(nil, nil, testVerifier{err: assert.AnError}, nil, storageEngine) @@ -241,6 +243,7 @@ func Test_sqlWallet_SearchCredential(t *testing.T) { assert.Empty(t, list) }) t.Run("returns all credentials including expired/revoked", func(t *testing.T) { + t.Skip("TODO: Disabled for now in project GF, because we want to actively demo with expired credentials.") resetStore(t, storageEngine.GetSQLDatabase()) // SearchCredential should not filter by validity, so we pass a testVerifier that would filter sut := NewSQLWallet(nil, nil, testVerifier{err: types.ErrCredentialNotValidAtTime}, nil, storageEngine) diff --git a/vcr/issuer/issuer_test.go b/vcr/issuer/issuer_test.go index 2cfc4d2272..c69fbd1f41 100644 --- a/vcr/issuer/issuer_test.go +++ b/vcr/issuer/issuer_test.go @@ -1106,7 +1106,7 @@ func TestIssuer_StatusList(t *testing.T) { func newTestStatusList2021(t testing.TB, db *gorm.DB, signingKey crypto.PublicKey, dids ...did.DID) *revocation.StatusList2021 { storage.AddDIDtoSQLDB(t, db, dids...) - cs := revocation.NewStatusList2021(db, nil, "https://example.com") + cs := revocation.NewStatusList2021(db, nil, "https://example.com", 15*time.Minute) cs.Sign = func(_ context.Context, unsignedCredential vc.VerifiableCredential, kid string) (*vc.VerifiableCredential, error) { unsignedCredential.ID, _ = ssi.ParseURI("test-credential") bs, err := json.Marshal(unsignedCredential) diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index 9c220c3bee..8e1f4669dd 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -24,11 +24,12 @@ import ( "crypto/rand" "embed" "encoding/json" + "strings" + "testing" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core/to" vcrTest "github.com/nuts-foundation/nuts-node/vcr/test" - "strings" - "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwt" diff --git a/vcr/pe/presentation_submission.go b/vcr/pe/presentation_submission.go index 1d78ed137f..2adb9391c9 100644 --- a/vcr/pe/presentation_submission.go +++ b/vcr/pe/presentation_submission.go @@ -22,13 +22,14 @@ import ( "encoding/json" "errors" "fmt" + "strings" + "github.com/PaesslerAG/jsonpath" "github.com/google/uuid" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/vcr/credential" v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2" - "strings" ) // ParsePresentationSubmission validates the given JSON and parses it into a PresentationSubmission. @@ -133,11 +134,15 @@ func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmis } // the verifiableCredential property in Verifiable Presentations can be a single VC or an array of VCs when represented in JSON. - // go-did always marshals a single VC as a single VC for JSON-LD VPs. So we might need to fix the mapping paths. - - // todo the check below actually depends on the format of the credential and not the format of the VP + // go-did always marshals a single VC as a single VC for JSON-LD VPs. So we need to fix the mapping paths. if len(signInstruction.Mappings) == 1 { - signInstruction.Mappings[0].Path = "$.verifiableCredential" + if format == vc.JWTPresentationProofFormat { + // JWT VP always has an array of VCs + signInstruction.Mappings[0].Path = "$.verifiableCredential[0]" + } else { + // JSON-LD VP with single VC has single VC in verifiableCredential + signInstruction.Mappings[0].Path = "$.verifiableCredential" + } } // Just 1 VP, no nesting needed diff --git a/vcr/pe/presentation_submission_test.go b/vcr/pe/presentation_submission_test.go index 5388e234ef..b85e60afb9 100644 --- a/vcr/pe/presentation_submission_test.go +++ b/vcr/pe/presentation_submission_test.go @@ -20,6 +20,9 @@ package pe import ( "encoding/json" + "testing" + "time" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -27,8 +30,6 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" - "time" ) func TestParsePresentationSubmission(t *testing.T) { @@ -189,7 +190,7 @@ func TestPresentationSubmissionBuilder_Build(t *testing.T) { assert.Len(t, signInstruction.VerifiableCredentials, 1) assert.Equal(t, holder1, signInstruction.Holder) require.Len(t, submission.DescriptorMap, 1) - assert.Equal(t, "$.verifiableCredential", submission.DescriptorMap[0].Path) + assert.Equal(t, "$.verifiableCredential[0]", submission.DescriptorMap[0].Path) }) }) } diff --git a/vcr/revocation/statuslist2021_verifier.go b/vcr/revocation/statuslist2021_verifier.go index 47f514229f..f7841c34a1 100644 --- a/vcr/revocation/statuslist2021_verifier.go +++ b/vcr/revocation/statuslist2021_verifier.go @@ -25,7 +25,6 @@ import ( "io" "net/http" "strconv" - "time" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/core" @@ -34,9 +33,6 @@ import ( "gorm.io/gorm/clause" ) -// maxAgeExternal is the maximum age of external StatusList2021Credentials. If older than this we try to refresh. -const maxAgeExternal = 15 * time.Minute - // Verify StatusList2021 returns a types.ErrRevoked when the credentialStatus contains a 'StatusList2021Entry' that can be resolved and lists the credential as 'revoked' // Other credentialStatus type/statusPurpose are ignored. Verification may fail with other non-standardized errors. func (cs *StatusList2021) Verify(credentialToVerify vc.VerifiableCredential) error { @@ -106,6 +102,7 @@ func (cs *StatusList2021) Verify(credentialToVerify vc.VerifiableCredential) err func (cs *StatusList2021) statusList(statusListCredential string) (*credentialRecord, error) { cr, err := cs.loadCredential(statusListCredential) if err != nil { + log.Logger().WithError(err).Warnf("Failed to load StatusList2021Credential from database, fetching from issuer (url=%s)", statusListCredential) // assume any error means we don't have the credential, so try fetching remote return cs.update(statusListCredential) } @@ -115,23 +112,11 @@ func (cs *StatusList2021) statusList(statusListCredential string) (*credentialRe return cr, nil } - // TODO: renewal criteria need to be reconsidered if we add other purposes. A 'suspension' may have been canceled - // renew expired certificates - if (cr.Expires != nil && time.Unix(*cr.Expires, 0).Before(time.Now())) || // expired - time.Unix(cr.CreatedAt, 0).Add(maxAgeExternal).Before(time.Now()) { // older than 15 min - crUpdated, err := cs.update(statusListCredential) - if err == nil { - return crUpdated, nil - } - // use known StatusList2021Credential if we can't fetch a new one, even if it is older/expired - if cr.Expires != nil && time.Unix(*cr.Expires, 0).Before(time.Now()) { - // log warning if using expired StatusList2021Credential - log.Logger().WithError(err).WithField(core.LogFieldCredentialSubject, statusListCredential). - Info("Validating credentialStatus using expired StatusList2021Credential") - } + // PROJECT-GF: for demo purposes, we always update the statuslist credentials, so we can demo revocation. + crUpdated, err := cs.update(statusListCredential) + if err == nil { + return crUpdated, nil } - - // return credentialRecord, which could be outdated but is the best information available. return cr, nil } diff --git a/vcr/revocation/statuslist2021_verifier_test.go b/vcr/revocation/statuslist2021_verifier_test.go index dfe9eb6f3f..3d11c9592b 100644 --- a/vcr/revocation/statuslist2021_verifier_test.go +++ b/vcr/revocation/statuslist2021_verifier_test.go @@ -132,6 +132,7 @@ func TestStatusList2021_statusList(t *testing.T) { return *cr, cir } t.Run("ok - known credential", func(t *testing.T) { + t.Skip("PROJECT-GF: for demo purposes, we always update the statuslist credentials, so we can demo revocation.") cs, entry, _ := testSetup(t, false) cs.client = nil // panics if attempts to update expectedCR, _ := makeRecords(entry.StatusListCredential) @@ -180,7 +181,7 @@ func TestStatusList2021_statusList(t *testing.T) { t.Run("ok - exceeded max age", func(t *testing.T) { cs, _, ts := testSetup(t, false) cr, cir := makeRecords(ts.URL) - cr.CreatedAt = time.Now().Add(-2 * maxAgeExternal).Unix() + cr.CreatedAt = time.Now().Add(-2 * cs.maxAge).Unix() require.NoError(t, cs.db.Create(&cr).Error) actualCR, err := cs.statusList(cir.SubjectID) diff --git a/vcr/revocation/types.go b/vcr/revocation/types.go index 4bde9dd649..a7d05f977f 100644 --- a/vcr/revocation/types.go +++ b/vcr/revocation/types.go @@ -119,12 +119,14 @@ type StatusList2021 struct { VerifySignature VerifySignFn // injected by verifier Sign SignFn // injected by issuer, context must contain an audit log ResolveKey ResolveKeyFn // injected by issuer + // maxAge is the maximum age of external StatusList2021Credentials. If older than this we try to refresh. + maxAge time.Duration } // NewStatusList2021 returns a StatusList2021 without a Sign or VerifySignature method. // The URL in the credential will be constructed as follows using the given base URL: /statuslist// -func NewStatusList2021(db *gorm.DB, client core.HTTPRequestDoer, baseURL string) *StatusList2021 { - return &StatusList2021{client: client, db: db, baseURL: baseURL} +func NewStatusList2021(db *gorm.DB, client core.HTTPRequestDoer, baseURL string, maxAgeForRefresh time.Duration) *StatusList2021 { + return &StatusList2021{client: client, db: db, baseURL: baseURL, maxAge: maxAgeForRefresh} } // StatusList2021Entry is the "credentialStatus" property used by issuers to enable VerifiableCredential status information. diff --git a/vcr/revocation/types_test.go b/vcr/revocation/types_test.go index e0a642b5ea..a12ec51672 100644 --- a/vcr/revocation/types_test.go +++ b/vcr/revocation/types_test.go @@ -35,7 +35,7 @@ import ( // newTestStatusList2021 returns a StatusList2021 that does not Sign or VerifySignature, with a SQLite db containing the dids, and no http-client. func newTestStatusList2021(t testing.TB, dids ...did.DID) *StatusList2021 { - cs := NewStatusList2021(storage.NewTestStorageEngine(t).GetSQLDatabase(), nil, "https://example.com") + cs := NewStatusList2021(storage.NewTestStorageEngine(t).GetSQLDatabase(), nil, "https://example.com", 15*time.Minute) cs.Sign = noopSign cs.ResolveKey = noopResolveKey cs.VerifySignature = noopSignVerify diff --git a/vcr/vcr.go b/vcr/vcr.go index 6cf9876b89..0d6cd08723 100644 --- a/vcr/vcr.go +++ b/vcr/vcr.go @@ -24,6 +24,12 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" + "net/http" + "path" + "strings" + "time" + "github.com/nuts-foundation/go-leia/v4" "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/pki" @@ -32,11 +38,6 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/revocation" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "io/fs" - "net/http" - "path" - "strings" - "time" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" @@ -165,6 +166,23 @@ func (c *vcr) Configure(config core.ServerConfig) error { // copy strictmode for openid4vci usage c.strictmode = config.Strictmode + // Configure Dezi JKU allowlist + { + var allowedJKU []string + if len(c.config.Dezi.AllowedJKU) > 0 { + // Use configured values + allowedJKU = c.config.Dezi.AllowedJKU + } else { + // Default behavior: production URL always allowed + allowedJKU = []string{"https://auth.dezi.nl/dezi/jwks.json"} + if !c.strictmode { + // In non-strict mode, also allow acceptance environment + allowedJKU = append(allowedJKU, "https://acceptatie.auth.dezi.nl/dezi/jwks.json") + } + } + credential.DefaultDeziUserCredentialValidator = credential.DeziUserCredentialValidator{AllowedJKU: allowedJKU} + } + // create issuer store (to revoke) issuerStorePath := path.Join(c.datadir, "vcr", "issued-credentials.db") issuerBackupStore, err := c.storageClient.GetProvider(ModuleName).GetKVStore("backup-issued-credentials", storage.PersistentStorageClass) @@ -229,7 +247,7 @@ func (c *vcr) Configure(config core.ServerConfig) error { networkPublisher = issuer.NewNetworkPublisher(c.network, didResolver, c.keyStore) } - status := revocation.NewStatusList2021(c.storageClient.GetSQLDatabase(), client.NewWithCache(config.HTTPClient.Timeout), config.URL) + status := revocation.NewStatusList2021(c.storageClient.GetSQLDatabase(), client.NewWithCache(config.HTTPClient.Timeout), config.URL, c.config.Verifier.Revocation.MaxAge) c.issuer = issuer.NewIssuer(c.issuerStore, c, networkPublisher, openidHandlerFn, didResolver, c.keyStore, c.jsonldManager, c.trustConfig, status) c.verifier = verifier.NewVerifier(c.verifierStore, didResolver, c.keyResolver, c.jsonldManager, c.trustConfig, status, c.pkiProvider) diff --git a/vcr/vcr_test.go b/vcr/vcr_test.go index d4833943a9..bbc42b7467 100644 --- a/vcr/vcr_test.go +++ b/vcr/vcr_test.go @@ -43,6 +43,7 @@ import ( "github.com/nuts-foundation/nuts-node/events" "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/verifier" "go.etcd.io/bbolt" @@ -108,6 +109,73 @@ func TestVCR_Configure(t *testing.T) { assert.ErrorContains(t, err, "http request error: strictmode is enabled, but request is not over HTTPS") }) + t.Run("Dezi AllowedJKU configuration", func(t *testing.T) { + t.Run("uses configured AllowedJKU", func(t *testing.T) { + testDirectory := io.TestDirectory(t) + ctrl := gomock.NewController(t) + vdrInstance := vdr.NewMockVDR(ctrl) + vdrInstance.EXPECT().Resolver().AnyTimes() + pkiProvider := pki.NewMockProvider(ctrl) + pkiProvider.EXPECT().CreateTLSConfig(gomock.Any()).Return(nil, nil).AnyTimes() + networkInstance := network.NewMockTransactions(ctrl) + networkInstance.EXPECT().Disabled().AnyTimes() + instance := NewVCRInstance(nil, vdrInstance, networkInstance, jsonld.NewTestJSONLDManager(t), nil, storage.NewTestStorageEngine(t), pkiProvider).(*vcr) + + // Configure custom AllowedJKU + instance.config.Dezi.AllowedJKU = []string{"https://custom.dezi.nl/jwks.json"} + + err := instance.Configure(core.TestServerConfig(func(config *core.ServerConfig) { + config.Datadir = testDirectory + })) + + require.NoError(t, err) + // Verify that the configured value is used + assert.Equal(t, []string{"https://custom.dezi.nl/jwks.json"}, credential.DefaultDeziUserCredentialValidator.AllowedJKU) + }) + t.Run("default: production only in strict mode", func(t *testing.T) { + testDirectory := io.TestDirectory(t) + ctrl := gomock.NewController(t) + vdrInstance := vdr.NewMockVDR(ctrl) + vdrInstance.EXPECT().Resolver().AnyTimes() + pkiProvider := pki.NewMockProvider(ctrl) + pkiProvider.EXPECT().CreateTLSConfig(gomock.Any()).Return(nil, nil).AnyTimes() + networkInstance := network.NewMockTransactions(ctrl) + networkInstance.EXPECT().Disabled().AnyTimes() + instance := NewVCRInstance(nil, vdrInstance, networkInstance, jsonld.NewTestJSONLDManager(t), nil, storage.NewTestStorageEngine(t), pkiProvider).(*vcr) + + err := instance.Configure(core.TestServerConfig(func(config *core.ServerConfig) { + config.Datadir = testDirectory + config.Strictmode = true + })) + + require.NoError(t, err) + // Verify that only production is allowed + assert.Equal(t, []string{"https://auth.dezi.nl/dezi/jwks.json"}, credential.DefaultDeziUserCredentialValidator.AllowedJKU) + }) + t.Run("default: production and acceptance in non-strict mode", func(t *testing.T) { + testDirectory := io.TestDirectory(t) + ctrl := gomock.NewController(t) + vdrInstance := vdr.NewMockVDR(ctrl) + vdrInstance.EXPECT().Resolver().AnyTimes() + pkiProvider := pki.NewMockProvider(ctrl) + pkiProvider.EXPECT().CreateTLSConfig(gomock.Any()).Return(nil, nil).AnyTimes() + networkInstance := network.NewMockTransactions(ctrl) + networkInstance.EXPECT().Disabled().AnyTimes() + instance := NewVCRInstance(nil, vdrInstance, networkInstance, jsonld.NewTestJSONLDManager(t), nil, storage.NewTestStorageEngine(t), pkiProvider).(*vcr) + + err := instance.Configure(core.TestServerConfig(func(config *core.ServerConfig) { + config.Datadir = testDirectory + config.Strictmode = false + })) + + require.NoError(t, err) + // Verify that both production and acceptance are allowed + assert.Equal(t, []string{ + "https://auth.dezi.nl/dezi/jwks.json", + "https://acceptatie.auth.dezi.nl/dezi/jwks.json", + }, credential.DefaultDeziUserCredentialValidator.AllowedJKU) + }) + }) } func TestVCR_Start(t *testing.T) { diff --git a/vcr/verifier/verifier.go b/vcr/verifier/verifier.go index 59f833327e..3ff19e471f 100644 --- a/vcr/verifier/verifier.go +++ b/vcr/verifier/verifier.go @@ -152,6 +152,10 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus } // Check signature + // DeziUserCredential: signature is verified by Dezi attestation ("verklaring") inside the credential. Signature verification is skipped here. + if credentialToVerify.IsType(credential.DeziUserCredentialTypeURI) { + checkSignature = false + } if checkSignature { issuerDID, _ := did.ParseDID(credentialToVerify.Issuer.String()) metadata := resolver.ResolveMetadata{ResolveTime: validAt, AllowDeactivated: false} diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 3466d519e4..5fe6754dd9 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -31,7 +31,6 @@ import ( "testing" "time" - "github.com/nuts-foundation/nuts-node/core/to" "github.com/nuts-foundation/nuts-node/storage/orm" "github.com/nuts-foundation/nuts-node/test/pki" @@ -40,6 +39,7 @@ import ( ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core/to" "github.com/nuts-foundation/nuts-node/crypto/storage/spi" "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/storage" @@ -156,7 +156,7 @@ func TestVerifier_Verify(t *testing.T) { ctx := newMockContext(t) ctx.store.EXPECT().GetRevocations(gomock.Any()).Return([]*credential.Revocation{}, ErrNotFound).AnyTimes() db := storage.NewTestStorageEngine(t).GetSQLDatabase() - ctx.verifier.credentialStatus = revocation.NewStatusList2021(db, ts.Client(), "https://example.com") + ctx.verifier.credentialStatus = revocation.NewStatusList2021(db, ts.Client(), "https://example.com", 15*time.Minute) ctx.verifier.credentialStatus.(*revocation.StatusList2021).VerifySignature = func(_ vc.VerifiableCredential, _ *time.Time) error { return nil } // don't check signatures on 'downloaded' StatusList2021Credentials ctx.verifier.credentialStatus.(*revocation.StatusList2021).Sign = func(_ context.Context, unsignedCredential vc.VerifiableCredential, _ string) (*vc.VerifiableCredential, error) { bs, err := json.Marshal(unsignedCredential) @@ -307,7 +307,7 @@ func TestVerifier_Verify(t *testing.T) { assert.EqualError(t, err, "verifiable credential must list at most 2 types") }) - t.Run("verify x509", func(t *testing.T) { + t.Run("X509Credential", func(t *testing.T) { ura := "312312312" certs, keys, err := pki.BuildCertChain(nil, ura, nil) chain := pki.CertsToChain(certs) @@ -855,7 +855,7 @@ func newMockContext(t *testing.T) mockContext { trustConfig := trust.NewConfig(path.Join(io.TestDirectory(t), "trust.yaml")) db := orm.NewTestDatabase(t) - verifier := NewVerifier(verifierStore, didResolver, keyResolver, jsonldManager, trustConfig, revocation.NewStatusList2021(db, nil, ""), nil).(*verifier) + verifier := NewVerifier(verifierStore, didResolver, keyResolver, jsonldManager, trustConfig, revocation.NewStatusList2021(db, nil, "", time.Minute*15), nil).(*verifier) return mockContext{ ctrl: ctrl, diff --git a/vdr/didx509/resolver.go b/vdr/didx509/resolver.go index 38af9a321b..02b44126ce 100644 --- a/vdr/didx509/resolver.go +++ b/vdr/didx509/resolver.go @@ -23,11 +23,12 @@ import ( "crypto/x509" "errors" "fmt" + "strings" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "strings" ) const ( @@ -108,6 +109,7 @@ func (r Resolver) Resolve(id did.DID, metadata *resolver.ResolveMetadata) (*did. validatedChains, err := validationCert.Verify(x509.VerifyOptions{ Intermediates: core.NewCertPool(trustStore.IntermediateCAs), Roots: core.NewCertPool(trustStore.RootCAs), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, }) if err != nil { return nil, nil, fmt.Errorf("did:509 certificate chain validation failed: %w", err) diff --git a/vdr/didx509/resolver_test.go b/vdr/didx509/resolver_test.go index a9c56b3c50..f16beff4a8 100644 --- a/vdr/didx509/resolver_test.go +++ b/vdr/didx509/resolver_test.go @@ -23,6 +23,9 @@ import ( "crypto/x509" "encoding/base64" "fmt" + "strings" + "testing" + "github.com/lestrrat-go/jwx/v2/cert" "github.com/minio/sha256-simd" "github.com/nuts-foundation/go-did/did" @@ -30,8 +33,6 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "strings" - "testing" ) func TestManager_Resolve(t *testing.T) { @@ -190,6 +191,26 @@ func TestManager_Resolve(t *testing.T) { _, _, err = didResolver.Resolve(rootDID, &metadata) require.ErrorContains(t, err, "did:509 certificate chain validation failed: x509: certificate signed by unknown authority") }) + t.Run("did:x509 from UZI card", func(t *testing.T) { + certsBase64 := []string{ + "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", + } + chain := new(cert.Chain) + for _, certB64 := range certsBase64 { + err := chain.Add([]byte(certB64)) + require.NoError(t, err) + } + uziDID := did.MustParseDID("did:x509:0:sha256:KY3NR_y2OphPtJev5NxWhxJ7A-4bNta8OTRnalCbIv4::subject:O:T%C3%A9st%20Zorginstelling%2001::san:otherName:2.16.528.1.1007.99.217-1-900030757-Z-90000380-01.015-00000000") + metadata := resolver.ResolveMetadata{} + metadata.JwtProtectedHeaders = make(map[string]interface{}) + metadata.JwtProtectedHeaders[X509CertChainHeader] = chain + _, _, err = didResolver.Resolve(uziDID, &metadata) + require.NoError(t, err) + + }) t.Run("x5c contains extra certs", func(t *testing.T) { metadata := resolver.ResolveMetadata{ JwtProtectedHeaders: map[string]interface{}{