diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml index b86273dbe4..a7c453806c 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 878d9f0363..046c5656a5 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/auth/api/iam/api.go b/auth/api/iam/api.go index c3affbcf97..88ecfde7b2 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" @@ -750,9 +752,18 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS if request.Body.Credentials != nil { credentials = *request.Body.Credentials } + + if request.Body.IdToken != nil { + idTokenCredential, err := credential.CreateDeziIDTokenCredential(*request.Body.IdToken) + if err != nil { + return nil, core.InvalidInputError("failed to create id_token credential: %w", err) + } + credentials = append(credentials, *idTokenCredential) + } + // 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. + // As a side effect it is no longer possible to pass signed credentials to this API. for _, cred := range credentials { var credentialSubject []map[string]interface{} if err := cred.UnmarshalCredentialSubject(&credentialSubject); err != nil { @@ -774,7 +785,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 diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index ed973f375f..bb227c73d1 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -873,7 +873,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 @@ -894,7 +894,7 @@ 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) @@ -933,7 +933,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { t.Run("cache expired", func(t *testing.T) { 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) @@ -950,7 +950,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}) @@ -959,7 +959,7 @@ 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) @@ -968,7 +968,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}) @@ -984,8 +984,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) @@ -1010,7 +1010,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..27bed126f3 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -24,16 +24,18 @@ 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" + "github.com/nuts-foundation/nuts-node/policy" + 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" @@ -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) + + 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, false, nil) + 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,22 @@ 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("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 +360,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 +395,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 +406,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 +415,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 +425,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 +433,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 +448,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 +529,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 +541,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 +561,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/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 a14feab268..43898895c9 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" @@ -199,11 +200,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/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: +# - "organization_ura_dezi":"87654321" +# - "user_initials":"B.B." +# - "user_roles":["01.041","30.000","01.010","01.011"] +# - "user_surname":"Jansen" +# - "user_surname_prefix":"van der" +# - "user_uzi":"900000009" +if [ "$(echo $RESPONSE | jq -r .organization_ura_dezi)" != "87654321" ]; then + echo "FAILED: organization_ura_dezi invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_initials)" != "B.B." ]; then + echo "FAILED: user_initials invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +USER_ROLES=$(echo $RESPONSE | jq -r '.user_roles | sort | join(",")') +if [ "$USER_ROLES" != "01.010,01.011,01.041,30.000" ]; then + echo "FAILED: user_roles invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_surname)" != "Jansen" ]; then + echo "FAILED: user_surname invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_surname_prefix)" != "van der" ]; then + echo "FAILED: user_surname_prefix invalid" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi +if [ "$(echo $RESPONSE | jq -r .user_uzi)" != "900000009" ]; 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/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/credential/dezi.go b/vcr/credential/dezi.go new file mode 100644 index 0000000000..4a53006a4c --- /dev/null +++ b/vcr/credential/dezi.go @@ -0,0 +1,87 @@ +package credential + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/go-did/vc" +) + +func CreateDeziIDTokenCredential(idTokenSerialized string) (*vc.VerifiableCredential, error) { + idToken, err := jwt.Parse([]byte(idTokenSerialized), jwt.WithVerify(false), jwt.WithAcceptableSkew(time.Hour*24*365*10)) + if err != nil { + return nil, fmt.Errorf("parsing id_token: %w", err) + } + relationsRaw, _ := idToken.Get("relations") + relations, ok := relationsRaw.([]any) + if !ok || len(relations) != 1 { + return nil, 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 nil, fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)") + } + roles, ok := relation["roles"].([]any) + if !ok { + return nil, fmt.Errorf("id_token 'relations[0].roles' claim invalid or missing (expected array of strings)") + } + orgURA, ok := relation["ura"].(string) + if !ok || orgURA == "" { + return nil, fmt.Errorf("id_token 'relations[0].ura' claim invalid or missing (expected non-empty string)") + } + getString := func(claim string) string { + value, ok := idToken.Get(claim) + if !ok { + return "" + } + result, _ := value.(string) + return result + } + userID := getString("Dezi_id") + if userID == "" { + return nil, fmt.Errorf("id_token missing 'Dezi_id' claim") + } + initials := getString("initials") + if initials == "" { + return nil, fmt.Errorf("id_token missing 'initials' claim") + } + surname := getString("surname") + if surname == "" { + return nil, fmt.Errorf("id_token missing 'surname' claim") + } + surnamePrefix := getString("surname_prefix") + if surnamePrefix == "" { + return nil, fmt.Errorf("id_token missing 'surname_prefix' claim") + } + + credentialMap := map[string]any{ + "@context": []any{ + "https://www.w3.org/2018/credentials/v1", + // TODO: Create JSON-LD context? + }, + "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, + "issuanceDate": idToken.NotBefore().Format(time.RFC3339Nano), + "expirationDate": idToken.Expiration().Format(time.RFC3339Nano), + "credentialSubject": map[string]any{ + "@type": "DeziIDTokenSubject", + "identifier": orgURA, + "name": relation["entity_name"], + "employee": map[string]any{ + "@type": "HealthcareWorker", + "identifier": userID, + "initials": initials, + "surnamePrefix": surnamePrefix, + "surname": surname, + "roles": roles, + }, + }, + "proof": map[string]any{ + "type": "DeziIDJWT", + "jwt": idTokenSerialized, + }, + } + data, _ := json.Marshal(credentialMap) + return vc.ParseVerifiableCredential(string(data)) +} diff --git a/vcr/credential/dezi_test.go b/vcr/credential/dezi_test.go new file mode 100644 index 0000000000..ea9b970cab --- /dev/null +++ b/vcr/credential/dezi_test.go @@ -0,0 +1,28 @@ +package credential + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateDeziIDToken(t *testing.T) { + t.Run("ok", func(t *testing.T) { + const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6IlVhd3AwY2lRck1PSENadW04MlA2dkphNU8xND0iLCJ0eXAiOiJKV1QifQ.eyJEZXppX2lkIjoiOTAwMDAwMDA5IiwiYXVkIjpbIjAwNmZiZjM0LWE4MGItNGM4MS1iNmU5LTU5MzYwMDY3NWZiMiJdLCJleHAiOjE3MDE5MzM2OTcsImluaXRpYWxzIjoiQi5CLiIsImlzcyI6Imh0dHBzOi8vbWF4LnByb2VmdHVpbi5EZXppLW9ubGluZS5yZG9iZWhlZXIubmwiLCJqc29uX3NjaGVtYSI6Imh0dHBzOi8vbWF4LnByb2VmdHVpbi5EZXppLW9ubGluZS5yZG9iZWhlZXIubmwvanNvbl9zY2hlbWEuanNvbiIsImxvYV9EZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImxvYV9hdXRobiI6Imh0dHA6Ly9laWRhcy5ldXJvcGEuZXUvTG9BL2hpZ2giLCJuYmYiOjE3MDE5MzM2MjcsInJlbGF0aW9ucyI6W3siZW50aXR5X25hbWUiOiJab3JnYWFuYmllZGVyIiwicm9sZXMiOlsiMDEuMDQxIiwiMzAuMDAwIiwiMDEuMDEwIiwiMDEuMDExIl0sInVyYSI6Ijg3NjU0MzIxIn1dLCJzdXJuYW1lIjoiSmFuc2VuIiwic3VybmFtZV9wcmVmaXgiOiJ2YW4gZGVyIiwieDVjIjpbIk1JSURWRENDQWp5Z0F3SUJBZ0lVRFdHVVJUNDJGWFdZbWR2Y1ExQXJxSUQrQUFBd0RRWUpLb1pJaHZjTkFRRUxCUUF3RWpFUU1BNEdBMVVFQXd3SFVtOXZkQ0JEUVRBZUZ3MHlOVEV5TWpReE16SXdOVEZhRncweU9EQXpNamd4TXpJd05URmFNQlF4RWpBUUJnTlZCQU1NQ1d4dlkyRnNhRzl6ZERDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTFJzeFRzaDhRTTdGTkFVUnRlb2pGTUhFNjdSd2dsL3NvdmJnWWlNMVZXTVZGYW9MT0UzYWxzaE9HMkh6d2ZkNUI4Q1VYOFg0K0ZudnZnazkrd0FrMU9vTENTSFA0Ri9LVGk1ZTJwNjRWdllzQUlXWDVOMjBldE5NeWFKRVBIM0lHMHl0OTNZbllZWHF0Y2hvUzNOV01lZjdMUFhJNnlOUXQvNnp0dzg5Qm5vY21lRDVqNmtKaWVtY0krTWttYmE2cHU0TitKT2dXS05ENTlVZlVuUmlDRlVtUEVoZ3VxRzd6WmJROGM3bFVkN1hPRVc1eGZQNUtmNjZ5eUsxaitUb3plUHlmelJIMVBuUHBDaDRWa2lmM1o2elJtc2t6Z0h6RDZjWmpaZDY0b1MwMExGOUczNFlMMkNuRElOdHNaUnZWTW5icmdpelJBY1lXOG83WUc5TzRjQ0F3RUFBYU9CbnpDQm5EQWZCZ05WSFNNRUdEQVdnQlF6VUFRZ0l4ZXBuWjhrSUhRQ0tLdWVIVUhaQ1RBSkJnTlZIUk1FQWpBQU1Bc0dBMVVkRHdRRUF3SUU4REFkQmdOVkhTVUVGakFVQmdnckJnRUZCUWNEQVFZSUt3WUJCUVVIQXdJd0l3WURWUjBSQkJ3d0dvSUpiRzlqWVd4b2IzTjBnZ2R1ZFhSekxtNXNod1IvQUFBQk1CMEdBMVVkRGdRV0JCUUZBY016U1pCYWdUQWNwZ2wvcmllSmV0S3J3VEFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBSzEvRHAxaWk2RWdhRk9NUmJFbWVLMWJrS1dvNDQyYVZHTUdXQzQ0TzUwTm9nSzM1aFM2eFdEdDNYb3B5eUEzcUFKd2FDeDc3ckNkNkVleXpSSlFucXdPRktjUGlqVERkL1hQa25xQzhIUTlqSGVqeWpUY2VaZjFOVVpvU1NteStiS3duK080NHBzdkhmWkpMajN0OFViSkRVd0ZWVm5tL0NMVlh0Q0tETHhnUU9LMWdKUkgrMmxvTmZvRHR0REE3c29TMGJrL2VFQ0FML3Bsdm9meE1LUUxBV0ZobWhLUkhnWC9TSVdORWhkdkcxYlNGVG5HOFVtTmh1RGkrU1dnV3RLTnE4SW5mL3hTSlRyT09ycndFU3V4Wk50aEp5bUVyaWFrT084VEN6VE9icGRoNVFydHo3SmRGV3l1ZDZ0ZnU3VnNVbHRHSEpSeW4zVU5TZCtIYlJnPT0iXX0.alxktj1V-sQHq-bCPdD5GB3E8YqKHgglPEXuYoATXIbo0CBUWARMP8x_Q-gyGdJvU3kpLHVhQK5Y7Y6N6Tpkw46jZiDEVmBEWPltbqKOdCjYGiEie4VB1Sw0lvKwXsiKP_xMGAI6LQ8nrS9_y8fT9JsRWBtqEYR8wb84B727FJiY3SVwks1lnqljW2-qHlS-Z-ecdT7GF_7VBsz5a5UITqQSX0-Q7ccvoXGl8QFtqYjkG9D0oYWRu46l6AtCqyrIc90Iq44nIQf38U46Fohz_ED-J0xSFoiQoWOuiv7vzkdDiO4RHnaGVJeCAiesa3-TdUzE69ZSiPzx8AV_pTRfiw" + + actual, err := CreateDeziIDTokenCredential(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, "900000009", employee["identifier"]) + assert.Equal(t, "B.B.", employee["initials"]) + assert.Equal(t, "Jansen", employee["surname"]) + assert.Equal(t, "van der", employee["surnamePrefix"]) + assert.Equal(t, []any{"01.041", "30.000", "01.010", "01.011"}, employee["roles"]) + }) +} diff --git a/vcr/credential/resolver.go b/vcr/credential/resolver.go index 182eda33e7..db30514a5c 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" @@ -41,6 +42,8 @@ func FindValidator(credential vc.VerifiableCredential, pkiValidator pki.Validato return nutsAuthorizationCredentialValidator{} case X509CredentialType: return x509CredentialValidator{pkiValidator: pkiValidator} + case DeziIDTokenCredentialTypeURI.String(): + return deziIDTokenCredentialValidator{} } } } diff --git a/vcr/credential/test.go b/vcr/credential/test.go new file mode 100644 index 0000000000..ed69ef43a4 --- /dev/null +++ b/vcr/credential/test.go @@ -0,0 +1,55 @@ +package credential + +import ( + "crypto/sha1" + "crypto/tls" + "encoding/base64" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +func CreateTestDeziIDToken(issuedAt time.Time, validUntil time.Time) ([]byte, error) { + keyPair, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem") + if err != nil { + return nil, err + } + key, err := jwk.FromRaw(keyPair.PrivateKey) + if err != nil { + return nil, err + } + x5t := sha1.Sum(keyPair.Leaf.Raw) + claims := map[string]any{ + jwk.KeyIDKey: base64.StdEncoding.EncodeToString(x5t[:]), + jwk.X509CertThumbprintKey: base64.StdEncoding.EncodeToString(x5t[:]), + jwk.AlgorithmKey: "RS256", + 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", + "x5c": []string{base64.StdEncoding.EncodeToString(keyPair.Leaf.Raw)}, + "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 + } + } + return jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) +} diff --git a/vcr/credential/types.go b/vcr/credential/types.go index 87da9fefeb..beb77cbeab 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) + // DeziIDTokenCredentialTypeURI is the VC type for a DeziIDTokenCredential + DeziIDTokenCredentialTypeURI = ssi.MustParseURI("DeziIDTokenCredential") // 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..25e299f19e 100644 --- a/vcr/credential/util.go +++ b/vcr/credential/util.go @@ -20,12 +20,14 @@ 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" + "github.com/nuts-foundation/nuts-node/vcr/signature/proof" ) // ResolveSubjectDID resolves the subject DID from the given credentials. @@ -114,10 +116,22 @@ 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 DeziIDTokenCredentials (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 + var proof []proof.LDProof + _ = credential.UnmarshalProofValue(&proof) + isDeziTokenCredential := false + for _, p := range proof { + if p.Type == "DeziIDJWT" { + // derived proof, do the auto-correction + isDeziTokenCredential = true + break + } + } + if !isDeziTokenCredential { + 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..1e4fb5e75c 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. @@ -383,3 +384,54 @@ func validatePolicyAssertions(issuer did.DID, credential vc.VerifiableCredential return nil } + +// DeziIDTokenCredentialValidator validates DeziIDTokenCredential, according to (TODO: add spec). +type deziIDTokenCredentialValidator struct { +} + +func (d deziIDTokenCredentialValidator) Validate(credential vc.VerifiableCredential) error { + type proofType struct { + Type string `json:"type"` + JWT string `json:"jwt"` + } + proofs := []proofType{} + if err := credential.UnmarshalProofValue(&proofs); err != nil { + return fmt.Errorf("%w: invalid proof format: %w", errValidation, err) + } + if len(proofs) != 1 { + return fmt.Errorf("%w: expected exactly one proof, got %d", errValidation, len(proofs)) + } + proof := proofs[0] + if proof.Type != "DeziIDJWT" { + return fmt.Errorf("%w: invalid proof type: expected 'DeziIDJWT', got '%s'", errValidation, proof.Type) + } + 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 deziIDTokenCredentialValidator) validateDeziToken(credential vc.VerifiableCredential, serialized string) error { + //headers, err := crypto.ExtractProtectedHeaders(serialized) + //if err != nil { + // return fmt.Errorf("invalid JWT headers: %w", err) + //} + //chain := cert.Chain{} + //for i, s := range headers["x5c"].([]string) { + // + //} + // + //token, err := jwt.ParseString(serialized, jws.WithKeyProvider(jws.)) + //if err != nil { + // return err + //} + //// TODO: Verify deziToken signature + //if !token.NotBefore().Equal(credential.IssuanceDate) { + // return errors.New("id_token 'nbf' does not match credential 'issuanceDate'") + //} + //if !token.Expiration().Equal(*credential.ExpirationDate) { + // return errors.New("id_token 'exp' does not match credential 'expirationDate'") + //} + // TODO: implement rest of checks + return nil +} diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go index 182f434d41..a1744413cd 100644 --- a/vcr/holder/presenter.go +++ b/vcr/holder/presenter.go @@ -23,6 +23,10 @@ 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" @@ -32,13 +36,12 @@ import ( "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,16 +112,35 @@ 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 @@ -128,15 +150,16 @@ func (p presenter) buildJWTPresentation(ctx context.Context, subjectDID did.DID, } id := did.DIDURL{DID: subjectDID} id.Fragment = strings.ToLower(uuid.NewString()) + type VPAlias vc.VerifiablePresentation claims := map[string]interface{}{ jwt.SubjectKey: subjectDID.String(), jwt.JwtIDKey: id.String(), - "vp": vc.VerifiablePresentation{ + "vp": VPAlias(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 diff --git a/vcr/holder/presenter_test.go b/vcr/holder/presenter_test.go index efc47e70b8..f4bb6242d5 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) { @@ -162,6 +163,12 @@ func TestPresenter_buildPresentation(t *testing.T) { assert.NotNil(t, result.JWT()) nonce, _ := result.JWT().Get("nonce") assert.Empty(t, nonce) + + t.Run("type must be an array", func(t *testing.T) { + rawVP := result.JWT().PrivateClaims()["vp"].(map[string]any) + typeProp := rawVP["type"].([]any) + assert.Equal(t, []any{"VerifiablePresentation"}, typeProp) + }) }) 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 d837c273e0..f0e376f0f8 100644 --- a/vcr/holder/sql_wallet.go +++ b/vcr/holder/sql_wallet.go @@ -22,6 +22,8 @@ import ( "context" "errors" "fmt" + "time" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -39,7 +41,6 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/resolver" "gorm.io/gorm" "gorm.io/gorm/schema" - "time" ) type sqlWallet struct { diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index 9c220c3bee..9e4378aa54 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -24,11 +24,14 @@ import ( "crypto/rand" "embed" "encoding/json" + "strings" + "testing" + "time" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core/to" + "github.com/nuts-foundation/nuts-node/vcr/credential" 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" @@ -102,6 +105,207 @@ func TestParsePresentationDefinition(t *testing.T) { }) } +func TestDeziIDTokenCredential(t *testing.T) { + iat := time.Unix(1701933627, 0) + exp := time.Unix(1701933697, 0) + token, err := credential.CreateTestDeziIDToken(iat, exp) + require.NoError(t, err) + + // Create DeziIDTokenCredential using the helper function + credentialMap := map[string]any{ + "@context": []any{ + "https://www.w3.org/2018/credentials/v1", + }, + "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, + "issuanceDate": iat.Format("2006-01-02T15:04:05Z07:00"), + "expirationDate": exp.Format("2006-01-02T15:04:05Z07:00"), + "credentialSubject": map[string]any{ + "@type": "DeziIDTokenSubject", + "identifier": "87654321", + "name": "Zorgaanbieder", + "employee": map[string]any{ + "@type": "HealthcareWorker", + "identifier": "900000009", + "initials": "B.B.", + "surnamePrefix": "van der", + "surname": "Jansen", + "roles": []string{"01.041", "30.000"}, + }, + }, + "proof": map[string]any{ + "type": "DeziIDJWT", + "jwt": string(token), + }, + } + data, _ := json.Marshal(credentialMap) + cred, err := vc.ParseVerifiableCredential(string(data)) + require.NoError(t, err) + + t.Run("matching credential", func(t *testing.T) { + // Create a presentation definition that matches DeziIDTokenCredential + pd, err := ParsePresentationDefinition([]byte(`{ + "id": "pd_dezi_id_token_credential", + "name": "Dezi ID Token", + "purpose": "Request a Dezi ID Token credential", + "input_descriptors": [ + { + "id": "id_dezi_credential", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "DeziIDTokenCredential" + } + }, + { + "id": "employee_identifier", + "path": [ + "$.credentialSubject.employee.identifier" + ], + "filter": { + "type": "string" + } + }, + { + "id": "employee_initials", + "path": [ + "$.credentialSubject.employee.initials" + ], + "filter": { + "type": "string" + } + } + ] + } + } + ] + }`)) + require.NoError(t, err) + + // Test matching + credentials, mappingObjects, err := pd.Match([]vc.VerifiableCredential{*cred}) + + require.NoError(t, err) + require.Len(t, credentials, 1) + require.Len(t, mappingObjects, 1) + + // Test field resolution + credMap := map[string]vc.VerifiableCredential{ + "id_dezi_credential": *cred, + } + fieldValues, err := pd.ResolveConstraintsFields(credMap) + require.NoError(t, err) + require.Len(t, fieldValues, 2) + assert.Equal(t, "900000009", fieldValues["employee_identifier"]) + assert.Equal(t, "B.B.", fieldValues["employee_initials"]) + }) + + t.Run("non-matching credential type", func(t *testing.T) { + pd, err := ParsePresentationDefinition([]byte(`{ + "id": "pd_other_credential", + "input_descriptors": [ + { + "id": "other_credential", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "SomeOtherCredential" + } + } + ] + } + } + ] + }`)) + require.NoError(t, err) + + credentials, mappingObjects, err := pd.Match([]vc.VerifiableCredential{*cred}) + + assert.Error(t, err) + assert.Empty(t, credentials) + assert.Empty(t, mappingObjects) + }) + + t.Run("matching with organization identifier", func(t *testing.T) { + pd, err := ParsePresentationDefinition([]byte(`{ + "id": "pd_dezi_with_org", + "input_descriptors": [ + { + "id": "dezi_org_credential", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "DeziIDTokenCredential" + } + }, + { + "id": "organization_identifier", + "path": ["$.credentialSubject.identifier"], + "filter": { + "type": "string", + "const": "87654321" + } + } + ] + } + } + ] + }`)) + require.NoError(t, err) + + credentials, mappingObjects, err := pd.Match([]vc.VerifiableCredential{*cred}) + + require.NoError(t, err) + require.Len(t, credentials, 1) + require.Len(t, mappingObjects, 1) + }) + + t.Run("matching employee roles", func(t *testing.T) { + pd, err := ParsePresentationDefinition([]byte(`{ + "id": "pd_dezi_with_roles", + "input_descriptors": [ + { + "id": "dezi_roles_credential", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "DeziIDTokenCredential" + } + }, + { + "id": "employee_roles", + "path": ["$.credentialSubject.employee.roles[*]"], + "filter": { + "type": "string" + } + } + ] + } + } + ] + }`)) + require.NoError(t, err) + + credentials, _, err := pd.Match([]vc.VerifiableCredential{*cred}) + + require.NoError(t, err) + require.Len(t, credentials, 1) + }) +} + func TestEmployeeCredential(t *testing.T) { pd, err := ParsePresentationDefinition([]byte(`{ "format": { diff --git a/vcr/verifier/verifier.go b/vcr/verifier/verifier.go index 8cf4ba7328..eeb696be0a 100644 --- a/vcr/verifier/verifier.go +++ b/vcr/verifier/verifier.go @@ -22,11 +22,12 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/pki" - "github.com/nuts-foundation/nuts-node/vcr/revocation" "strings" "time" + "github.com/nuts-foundation/nuts-node/pki" + "github.com/nuts-foundation/nuts-node/vcr/revocation" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -122,7 +123,6 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus if revoked { return types.ErrRevoked } - } // Check the credentialStatus if the credential is revoked @@ -162,6 +162,10 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus } // Check signature + // DeziIDTokenCredential: signature is verified by Dezi id_token inside the credential. Signature verification is skipped here. + if credentialToVerify.IsType(credential.DeziIDTokenCredentialTypeURI) { + 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 8c60c4b75c..ff1cc31f68 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -23,8 +23,6 @@ import ( "crypto" "encoding/json" "errors" - "github.com/nuts-foundation/nuts-node/storage/orm" - "github.com/nuts-foundation/nuts-node/test/pki" "net/http" "net/http/httptest" "os" @@ -41,7 +39,9 @@ import ( "github.com/nuts-foundation/nuts-node/crypto/storage/spi" "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/storage/orm" "github.com/nuts-foundation/nuts-node/test/io" + "github.com/nuts-foundation/nuts-node/test/pki" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/revocation" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" @@ -305,7 +305,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) @@ -380,6 +380,16 @@ func TestVerifier_Verify(t *testing.T) { assert.ErrorIs(t, err, expectedError) }) }) + t.Run("DeziIDTokenCredential", func(t *testing.T) { + ctx := newMockContext(t) + ctx.store.EXPECT().GetRevocations(gomock.Any()).Return(nil, ErrNotFound) + validAt := time.Date(2023, 12, 7, 7, 20, 27, 0, time.UTC) + + cred := createDeziCredential(t, "did:web:example.com") + + err := ctx.verifier.Verify(*cred, true, true, &validAt) + assert.NoError(t, err) + }) } func Test_verifier_CheckAndStoreRevocation(t *testing.T) { @@ -858,3 +868,44 @@ func newMockContext(t *testing.T) mockContext { trustConfig: trustConfig, } } + +// createDeziIDToken creates a signed Dezi id_token according to https://www.dezi.nl/documenten/2024/05/08/koppelvlakspecificatie-dezi-online-koppelvlak-1_-platformleverancier +func createDeziCredential(t *testing.T, holderDID string) *vc.VerifiableCredential { + exp := time.Unix(1701933697, 0) + iat := time.Unix(1701933627, 0) + idToken, err := credential.CreateTestDeziIDToken(iat, exp) + require.NoError(t, err) + + credentialMap := map[string]any{ + "@context": []any{ + "https://www.w3.org/2018/credentials/v1", + }, + "type": []string{"VerifiableCredential", "DeziIDTokenCredential"}, + "issuer": holderDID, + "id": holderDID + "#1", + "issuanceDate": iat.Format(time.RFC3339Nano), + "expirationDate": exp.Format(time.RFC3339Nano), + "credentialSubject": map[string]any{ + "@type": "DeziIDTokenSubject", + "identifier": "87654321", + "name": "Zorgaanbieder", + "employee": map[string]any{ + "@type": "HealthcareWorker", + "identifier": "900000009", + "initials": "B.B.", + "surnamePrefix": "van der", + "surname": "Jansen", + "roles": []string{"01.041", "30.000", "01.010", "01.011"}, + }, + }, + "proof": map[string]any{ + "type": "DeziIDJWT", + "jwt": string(idToken), + }, + } + data, err := json.Marshal(credentialMap) + require.NoError(t, err) + cred, err := vc.ParseVerifiableCredential(string(data)) + require.NoError(t, err) + return cred +} 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{}{