diff --git a/docs/_static/vcr/vcr_v2.yaml b/docs/_static/vcr/vcr_v2.yaml index ff65540466..9f16eadae6 100644 --- a/docs/_static/vcr/vcr_v2.yaml +++ b/docs/_static/vcr/vcr_v2.yaml @@ -605,6 +605,72 @@ paths: $ref: '#/components/schemas/SearchVCResults' default: $ref: '../common/error_response.yaml' + /internal/vcr/v2/holder/expiring: + get: + summary: List credentials across all wallets on this node that are expired or about to expire. + description: | + Returns all credentials held by any subject on this node whose `expirationDate` is at or before + `now + within`. This includes credentials that are already expired. Credentials without an + `expirationDate` are never returned, since they do not expire. + + Operators can use this endpoint to monitor all wallets on the node and refresh credentials before + they expire. + + error returns: + * 400 - Invalid value for the `within` parameter + * 500 - An error occurred while processing the request + operationId: getExpiringCredentialsInWallet + tags: + - credential + parameters: + - name: within + in: query + description: | + Time window relative to now in which a credential's `expirationDate` falls for it to be considered + expiring. Accepts a Go duration string: a number followed by a unit, e.g. `720h` (30 days), + `24h` (1 day) or `30m` (30 minutes). The largest supported unit is the hour (`h`); there is no + day or week unit. Must be non-negative. Defaults to 720h (30 days). Use `0s` to return only + already-expired credentials. + required: false + schema: + type: string + default: "720h" + example: "24h" + - name: excludeTypes + in: query + description: | + Credential type(s) to exclude from the result. A credential is excluded if any of its + types matches any of the supplied values. Useful for suppressing credentials that are + expected to expire and are kept for audit purposes (e.g. `NutsAuthorizationCredential`). + Repeat the parameter to exclude multiple types. + required: false + schema: + type: array + items: + type: string + example: ["NutsAuthorizationCredential"] + responses: + "200": + description: | + Map of subject ID to the list of expired or about-to-expire credentials held by that subject. + Only subjects with at least one such credential are included. + content: + application/json: + schema: + type: object + additionalProperties: + type: array + items: + $ref: "#/components/schemas/ExpiringCredential" + example: + 90BC1AE9-752B-432F-ADC3-DD9F9C61843C: + - id: "did:web:issuer.example.com#c4199b74-0c0a-4e09-a463-6927553e65f5" + holder: "did:web:example.com:iam:123" + issuer: "did:web:issuer.example.com" + type: ["NutsOrganizationCredential"] + expirationDate: "2026-05-15T12:00:00Z" + default: + $ref: '../common/error_response.yaml' components: schemas: VerifiableCredential: @@ -616,6 +682,44 @@ components: Revocation: $ref: '../common/ssi_types.yaml#/components/schemas/Revocation' + ExpiringCredential: + type: object + description: | + Summary of a Verifiable Credential in a wallet on this node that is expired or about to expire. + Contains only the fields needed for monitoring; the full credential can be retrieved via the + wallet search endpoints using the `id`. + required: + - id + - holder + - issuer + - type + - expirationDate + properties: + id: + description: ID of the credential (the `id` property of the Verifiable Credential). + type: string + example: "did:web:issuer.example.com#c4199b74-0c0a-4e09-a463-6927553e65f5" + holder: + description: DID of the wallet holding the credential. + type: string + example: "did:web:example.com:iam:123" + issuer: + description: DID of the credential's issuer. + type: string + example: "did:web:issuer.example.com" + type: + description: | + Credential type(s), excluding the generic `VerifiableCredential` type. + type: array + items: + type: string + example: ["NutsOrganizationCredential"] + expirationDate: + description: RFC3339 time at which the credential expires. + type: string + format: date-time + example: "2026-05-15T12:00:00Z" + IssueVCRequest: type: object description: A request for issuing a new Verifiable Credential. diff --git a/docs/pages/deployment/monitoring.rst b/docs/pages/deployment/monitoring.rst index 296c293de3..efe5b2aab9 100644 --- a/docs/pages/deployment/monitoring.rst +++ b/docs/pages/deployment/monitoring.rst @@ -133,6 +133,42 @@ Explanation of ambiguous/complex entries in the diagnostics: Note: the ``network`` and ``vdr`` entries only apply to ``did:nuts``. +Expiring wallet credentials +*************************** + +Credentials held in the node's wallets generally need to be renewed before they expire, otherwise they can no longer be used. +The following endpoint lists credentials across all wallets on the node that are expired or about to expire, so a monitoring +system can alert operators to renew them in time: + +.. code-block:: text + + GET /internal/vcr/v2/holder/expiring + +It returns a JSON object keyed by subject ID, where each value is the list of that subject's expiring credentials: + +.. code-block:: json + + { + "90BC1AE9-752B-432F-ADC3-DD9F9C61843C": [ + { + "id": "did:web:issuer.example.com#c4199b74-0c0a-4e09-a463-6927553e65f5", + "holder": "did:web:example.com:iam:123", + "issuer": "did:web:issuer.example.com", + "type": ["NutsOrganizationCredential"], + "expirationDate": "2026-05-15T12:00:00Z" + } + ] + } + +Subjects without any expiring credentials are omitted. Credentials without an ``expirationDate`` never expire and are never returned. + +Query parameters: + +* ``within`` - time window (relative to now) in which a credential's ``expirationDate`` must fall to be considered expiring. A Go duration string such as ``720h`` (30 days), ``24h`` (1 day) or ``30m`` (30 minutes); the largest unit is the hour. Defaults to ``720h`` (30 days). Use ``0s`` to return only credentials that have already expired. +* ``excludeTypes`` - credential type(s) to exclude from the result, repeat the parameter to exclude multiple. Useful for suppressing credentials that are expected to expire and are kept for audit purposes (e.g. ``NutsAuthorizationCredential``). + +Already-expired credentials are included by default so they remain visible until they are cleaned up. + Metrics ******* diff --git a/storage/sql_migrations/011_credential_expiration.sql b/storage/sql_migrations/011_credential_expiration.sql new file mode 100644 index 0000000000..21e296feb7 --- /dev/null +++ b/storage/sql_migrations/011_credential_expiration.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- expiration_date is the credential's expirationDate as seconds since Unix epoch. Credentials without +-- an expirationDate get a far-future sentinel (9999-12-31) rather than null; null marks an existing +-- row not yet backfilled. The application backfills existing rows after migration. +alter table credential add expiration_date integer null; +create index idx_credential_expiration_date on credential (expiration_date); + +-- +goose Down +drop index idx_credential_expiration_date; +alter table credential drop column expiration_date; diff --git a/vcr/api/vcr/v2/api.go b/vcr/api/vcr/v2/api.go index 8d7c21b128..40325ec76d 100644 --- a/vcr/api/vcr/v2/api.go +++ b/vcr/api/vcr/v2/api.go @@ -49,6 +49,10 @@ var clockFn = func() time.Time { return time.Now() } +// defaultExpiringWithin is the default time window used by GetExpiringCredentialsInWallet +// when the `within` query parameter is not supplied. +const defaultExpiringWithin = 30 * 24 * time.Hour + var _ StrictServerInterface = (*Wrapper)(nil) // Wrapper implements the generated interface from oapi-codegen @@ -475,7 +479,7 @@ func (w *Wrapper) SearchCredentialsInWallet(ctx context.Context, request SearchC var allCreds []vc.VerifiableCredential for _, did := range dids { - creds, err := w.VCR.Wallet().SearchCredential(ctx, did) + creds, err := w.VCR.Wallet().Search(ctx, holder.HolderDID(did)) if err != nil { return nil, err } @@ -489,6 +493,90 @@ func (w *Wrapper) SearchCredentialsInWallet(ctx context.Context, request SearchC return SearchCredentialsInWallet200JSONResponse(SearchVCResults{VerifiableCredentials: searchResults}), nil } +// GetExpiringCredentialsInWallet returns credentials across all wallets on this node that have an +// expirationDate at or before now + within, grouped by subject ID. Already-expired credentials are +// included. Credentials without an expirationDate are never returned because they don't expire. +// Credentials whose type matches any of the excludeTypes values are omitted. Subjects without any +// expiring credentials are omitted from the result. +func (w *Wrapper) GetExpiringCredentialsInWallet(ctx context.Context, request GetExpiringCredentialsInWalletRequestObject) (GetExpiringCredentialsInWalletResponseObject, error) { + within := defaultExpiringWithin + if request.Params.Within != nil { + parsed, err := time.ParseDuration(*request.Params.Within) + if err != nil { + return nil, core.InvalidInputError("invalid value for within: %w", err) + } + if parsed < 0 { + return nil, core.InvalidInputError("within must not be negative") + } + within = parsed + } + var excludeTypes []string + if request.Params.ExcludeTypes != nil { + excludeTypes = *request.Params.ExcludeTypes + } + + subjects, err := w.SubjectManager.List(ctx) + if err != nil { + return nil, err + } + // Invert the subject → DIDs map for grouping results back into subjects. + holderToSubject := make(map[string]string) + for subjectID, dids := range subjects { + for _, d := range dids { + holderToSubject[d.String()] = subjectID + } + } + + // Single cross-wallet query: SQL filters on expiration_date and type, no per-subject loop. + creds, err := w.VCR.Wallet().Search(ctx, + holder.ExpiresAt(clockFn().Add(within)), + holder.ExcludeCredentialTypes(excludeTypes...), + ) + if err != nil { + return nil, err + } + + result := make(map[string][]ExpiringCredential) + for _, cred := range creds { + holderDID, err := cred.SubjectDID() + if err != nil { + // Wallet storage requires a subject DID; this shouldn't happen for stored credentials. + continue + } + subjectID, ok := holderToSubject[holderDID.String()] + if !ok { + // Holder belongs to a deactivated/removed subject — normal post-deactivation state + // since SubjectManager.Deactivate doesn't cascade to the wallet. + continue + } + result[subjectID] = append(result[subjectID], toExpiringCredential(cred, *holderDID)) + } + + return GetExpiringCredentialsInWallet200JSONResponse(result), nil +} + +// toExpiringCredential builds a monitoring-friendly summary of a credential. The Wallet stores +// credentials per holder DID, so the holder is supplied by the caller rather than re-derived. +func toExpiringCredential(cred vc.VerifiableCredential, holder did.DID) ExpiringCredential { + types := make([]string, 0, len(cred.Type)) + for _, t := range cred.Type { + if s := t.String(); s != "VerifiableCredential" { + types = append(types, s) + } + } + var id string + if cred.ID != nil { + id = cred.ID.String() + } + return ExpiringCredential{ + Id: id, + Holder: holder.String(), + Issuer: cred.Issuer.String(), + Type: types, + ExpirationDate: *cred.ExpirationDate, + } +} + func (w *Wrapper) RemoveCredentialFromWallet(ctx context.Context, request RemoveCredentialFromWalletRequestObject) (RemoveCredentialFromWalletResponseObject, error) { // get DIDs for holder dids, err := w.SubjectManager.ListDIDs(ctx, request.SubjectID) diff --git a/vcr/api/vcr/v2/api_test.go b/vcr/api/vcr/v2/api_test.go index e0d8d22776..7239471a17 100644 --- a/vcr/api/vcr/v2/api_test.go +++ b/vcr/api/vcr/v2/api_test.go @@ -840,7 +840,7 @@ func TestWrapper_SearchCredentialsInWallet(t *testing.T) { t.Run("ok - no results", func(t *testing.T) { testContext := newMockContext(t) testContext.mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{holderDID}, nil) - testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID).Return([]vc.VerifiableCredential{}, nil) + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any()).Return([]vc.VerifiableCredential{}, nil) response, err := testContext.client.SearchCredentialsInWallet(testContext.requestCtx, SearchCredentialsInWalletRequestObject{ SubjectID: subjectID, @@ -853,7 +853,7 @@ func TestWrapper_SearchCredentialsInWallet(t *testing.T) { t.Run("ok - not revoked", func(t *testing.T) { testContext := newMockContext(t) testContext.mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{holderDID}, nil) - testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID).Return([]vc.VerifiableCredential{testVC}, nil) + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any()).Return([]vc.VerifiableCredential{testVC}, nil) testContext.mockVerifier.EXPECT().GetRevocation(testVC).Return(nil, nil) response, err := testContext.client.SearchCredentialsInWallet(testContext.requestCtx, SearchCredentialsInWalletRequestObject{ @@ -868,7 +868,7 @@ func TestWrapper_SearchCredentialsInWallet(t *testing.T) { t.Run("ok - revoked", func(t *testing.T) { testContext := newMockContext(t) testContext.mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{holderDID}, nil) - testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID).Return([]vc.VerifiableCredential{vcWithRevocation}, nil) + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any()).Return([]vc.VerifiableCredential{vcWithRevocation}, nil) // GetRevocation is then called to get the details testContext.mockVerifier.EXPECT().GetRevocation(vcWithRevocation).Return(revocationInfo, nil) @@ -891,6 +891,192 @@ func TestWrapper_SearchCredentialsInWallet(t *testing.T) { }) } +func TestWrapper_GetExpiringCredentialsInWallet(t *testing.T) { + now := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC) + originalClock := clockFn + clockFn = func() time.Time { return now } + t.Cleanup(func() { clockFn = originalClock }) + + issuerURI := ssi.MustParseURI("did:web:issuer.example.com") + otherHolderDID := did.MustParseDID("did:web:example.com:iam:other") + makeVC := func(idSuffix string, holder did.DID, exp *time.Time) vc.VerifiableCredential { + id := ssi.MustParseURI("did:web:issuer.example.com#" + idSuffix) + return vc.VerifiableCredential{ + Type: []ssi.URI{vc.VerifiableCredentialTypeV1URI(), ssi.MustParseURI("NutsOrganizationCredential")}, + ID: &id, + Issuer: issuerURI, + CredentialSubject: []map[string]any{{"id": holder.String()}}, + ExpirationDate: exp, + } + } + expired := now.Add(-24 * time.Hour) + soon := now.Add(10 * 24 * time.Hour) + farFuture := now.Add(365 * 24 * time.Hour) + expiredVC := makeVC("expired", holderDID, &expired) + soonVC := makeVC("soon", holderDID, &soon) + farVC := makeVC("far", holderDID, &farFuture) + otherSubjectExpiredVC := makeVC("other-expired", otherHolderDID, &expired) + + expectedExpiredEntry := ExpiringCredential{ + Id: "did:web:issuer.example.com#expired", + Holder: holderDID.String(), + Issuer: issuerURI.String(), + Type: []string{"NutsOrganizationCredential"}, + ExpirationDate: expired, + } + expectedSoonEntry := ExpiringCredential{ + Id: "did:web:issuer.example.com#soon", + Holder: holderDID.String(), + Issuer: issuerURI.String(), + Type: []string{"NutsOrganizationCredential"}, + ExpirationDate: soon, + } + expectedFarEntry := ExpiringCredential{ + Id: "did:web:issuer.example.com#far", + Holder: holderDID.String(), + Issuer: issuerURI.String(), + Type: []string{"NutsOrganizationCredential"}, + ExpirationDate: farFuture, + } + expectedOtherEntry := ExpiringCredential{ + Id: "did:web:issuer.example.com#other-expired", + Holder: otherHolderDID.String(), + Issuer: issuerURI.String(), + Type: []string{"NutsOrganizationCredential"}, + ExpirationDate: expired, + } + + // SQL filter semantics (within/excludeTypes) are covered in Test_sqlWallet_Search. Tests here + // verify that the handler passes options through and groups Search results by subject. + + t.Run("ok - groups expiring credentials by subject; subjects with none are omitted", func(t *testing.T) { + testContext := newMockContext(t) + emptySubjectDID := did.MustParseDID("did:web:example.com:iam:empty") + testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{ + "holder-a": {holderDID}, + "holder-b": {otherHolderDID}, + "holder-c": {emptySubjectDID}, + }, nil) + // Mock returns what SQL would have returned: only credentials within the default 30d window. + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any(), gomock.Any()). + Return([]vc.VerifiableCredential{expiredVC, soonVC, otherSubjectExpiredVC}, nil) + + response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{}) + + assert.NoError(t, err) + assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{ + "holder-a": {expectedExpiredEntry, expectedSoonEntry}, + "holder-b": {expectedOtherEntry}, + }, response) + }) + + t.Run("ok - returns whatever Search returns; threshold computed from clockFn+within", func(t *testing.T) { + testContext := newMockContext(t) + testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{ + "holder-a": {holderDID}, + }, nil) + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any(), gomock.Any()). + Return([]vc.VerifiableCredential{expiredVC, soonVC, farVC}, nil) + within := "8760h" + + response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{ + Params: GetExpiringCredentialsInWalletParams{Within: &within}, + }) + + assert.NoError(t, err) + assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{ + "holder-a": {expectedExpiredEntry, expectedSoonEntry, expectedFarEntry}, + }, response) + }) + + t.Run("ok - excludeTypes parameter is passed through to Search", func(t *testing.T) { + testContext := newMockContext(t) + testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{ + "holder-a": {holderDID}, + "holder-b": {otherHolderDID}, + }, nil) + // Mock returns what SQL would return after applying NOT IN (NutsAuthorizationCredential): + // only the org-cred for holder-a; holder-b's auth cred is filtered out and that subject is + // omitted from the response. + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any(), gomock.Any()). + Return([]vc.VerifiableCredential{expiredVC}, nil) + excludeTypes := []string{"NutsAuthorizationCredential"} + + response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{ + Params: GetExpiringCredentialsInWalletParams{ExcludeTypes: &excludeTypes}, + }) + + assert.NoError(t, err) + assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{ + "holder-a": {expectedExpiredEntry}, + }, response) + }) + + t.Run("ok - credential whose holder isn't a current subject is silently skipped", func(t *testing.T) { + // Subject deactivation doesn't cascade to the wallet, so the wallet can return rows whose + // holder DID no longer maps to any subject. The handler skips them rather than failing or + // surfacing them under an empty key. + testContext := newMockContext(t) + orphanDID := did.MustParseDID("did:web:example.com:iam:orphan") + orphanedVC := makeVC("orphaned", orphanDID, &expired) + testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{ + "holder-a": {holderDID}, + }, nil) + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any(), gomock.Any()). + Return([]vc.VerifiableCredential{expiredVC, orphanedVC}, nil) + + response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{}) + + assert.NoError(t, err) + assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{ + "holder-a": {expectedExpiredEntry}, + }, response) + }) + + t.Run("ok - no subjects returns empty map", func(t *testing.T) { + testContext := newMockContext(t) + testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{}, nil) + testContext.mockWallet.EXPECT().Search(testContext.requestCtx, gomock.Any(), gomock.Any()). + Return([]vc.VerifiableCredential{}, nil) + + response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{}) + + assert.NoError(t, err) + assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{}, response) + }) + + t.Run("error - invalid within", func(t *testing.T) { + testContext := newMockContext(t) + within := "not-a-duration" + + _, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{ + Params: GetExpiringCredentialsInWalletParams{Within: &within}, + }) + + assert.ErrorContains(t, err, "invalid value for within") + }) + + t.Run("error - negative within", func(t *testing.T) { + testContext := newMockContext(t) + within := "-1h" + + _, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{ + Params: GetExpiringCredentialsInWalletParams{Within: &within}, + }) + + assert.ErrorContains(t, err, "within must not be negative") + }) + + t.Run("error - subject manager fails", func(t *testing.T) { + testContext := newMockContext(t) + testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(nil, assert.AnError) + + _, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{}) + + assert.ErrorIs(t, err, assert.AnError) + }) +} + func TestWrapper_RemoveCredentialFromSubjectWallet(t *testing.T) { didNuts := did.MustParseDID("did:nuts:123") didWeb := did.MustParseDID("did:web:example.com") diff --git a/vcr/api/vcr/v2/generated.go b/vcr/api/vcr/v2/generated.go index 23e163a6a2..1ba0fa3c5a 100644 --- a/vcr/api/vcr/v2/generated.go +++ b/vcr/api/vcr/v2/generated.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/labstack/echo/v4" "github.com/oapi-codegen/runtime" @@ -99,6 +100,26 @@ type CredentialIssuer struct { Issuer string `json:"issuer"` } +// ExpiringCredential Summary of a Verifiable Credential in a wallet on this node that is expired or about to expire. +// Contains only the fields needed for monitoring; the full credential can be retrieved via the +// wallet search endpoints using the `id`. +type ExpiringCredential struct { + // ExpirationDate RFC3339 time at which the credential expires. + ExpirationDate time.Time `json:"expirationDate"` + + // Holder DID of the wallet holding the credential. + Holder string `json:"holder"` + + // Id ID of the credential (the `id` property of the Verifiable Credential). + Id string `json:"id"` + + // Issuer DID of the credential's issuer. + Issuer string `json:"issuer"` + + // Type Credential type(s), excluding the generic `VerifiableCredential` type. + Type []string `json:"type"` +} + // IssueVCRequest A request for issuing a new Verifiable Credential. type IssueVCRequest struct { // Context The resolvable context of the credentialSubject as URI. If omitted, the "https://nuts.nl/credentials/v1" context is used. @@ -244,6 +265,22 @@ type VPVerificationResult struct { Validity bool `json:"validity"` } +// GetExpiringCredentialsInWalletParams defines parameters for GetExpiringCredentialsInWallet. +type GetExpiringCredentialsInWalletParams struct { + // Within Time window relative to now in which a credential's `expirationDate` falls for it to be considered + // expiring. Accepts a Go duration string: a number followed by a unit, e.g. `720h` (30 days), + // `24h` (1 day) or `30m` (30 minutes). The largest supported unit is the hour (`h`); there is no + // day or week unit. Must be non-negative. Defaults to 720h (30 days). Use `0s` to return only + // already-expired credentials. + Within *string `form:"within,omitempty" json:"within,omitempty"` + + // ExcludeTypes Credential type(s) to exclude from the result. A credential is excluded if any of its + // types matches any of the supplied values. Useful for suppressing credentials that are + // expected to expire and are kept for audit purposes (e.g. `NutsAuthorizationCredential`). + // Repeat the parameter to exclude multiple types. + ExcludeTypes *[]string `form:"excludeTypes,omitempty" json:"excludeTypes,omitempty"` +} + // SearchIssuedVCsParams defines parameters for SearchIssuedVCs. type SearchIssuedVCsParams struct { // CredentialType The type of the credential @@ -477,6 +514,9 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { + // GetExpiringCredentialsInWallet request + GetExpiringCredentialsInWallet(ctx context.Context, params *GetExpiringCredentialsInWalletParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // CreateVPWithBody request with any body CreateVPWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -542,6 +582,18 @@ type ClientInterface interface { ListUntrusted(ctx context.Context, credentialType string, reqEditors ...RequestEditorFn) (*http.Response, error) } +func (c *Client) GetExpiringCredentialsInWallet(ctx context.Context, params *GetExpiringCredentialsInWalletParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetExpiringCredentialsInWalletRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) CreateVPWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewCreateVPRequestWithBody(c.Server, contentType, body) if err != nil { @@ -830,6 +882,71 @@ func (c *Client) ListUntrusted(ctx context.Context, credentialType string, reqEd return c.Client.Do(req) } +// NewGetExpiringCredentialsInWalletRequest generates requests for GetExpiringCredentialsInWallet +func NewGetExpiringCredentialsInWalletRequest(server string, params *GetExpiringCredentialsInWalletParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/internal/vcr/v2/holder/expiring") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Within != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "within", runtime.ParamLocationQuery, *params.Within); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.ExcludeTypes != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "excludeTypes", runtime.ParamLocationQuery, *params.ExcludeTypes); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewCreateVPRequest calls the generic CreateVP builder with application/json body func NewCreateVPRequest(server string, body CreateVPJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -1503,6 +1620,9 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { + // GetExpiringCredentialsInWalletWithResponse request + GetExpiringCredentialsInWalletWithResponse(ctx context.Context, params *GetExpiringCredentialsInWalletParams, reqEditors ...RequestEditorFn) (*GetExpiringCredentialsInWalletResponse, error) + // CreateVPWithBodyWithResponse request with any body CreateVPWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateVPResponse, error) @@ -1568,6 +1688,38 @@ type ClientWithResponsesInterface interface { ListUntrustedWithResponse(ctx context.Context, credentialType string, reqEditors ...RequestEditorFn) (*ListUntrustedResponse, error) } +type GetExpiringCredentialsInWalletResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *map[string][]ExpiringCredential + ApplicationproblemJSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r GetExpiringCredentialsInWalletResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetExpiringCredentialsInWalletResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type CreateVPResponse struct { Body []byte HTTPResponse *http.Response @@ -2076,6 +2228,15 @@ func (r ListUntrustedResponse) StatusCode() int { return 0 } +// GetExpiringCredentialsInWalletWithResponse request returning *GetExpiringCredentialsInWalletResponse +func (c *ClientWithResponses) GetExpiringCredentialsInWalletWithResponse(ctx context.Context, params *GetExpiringCredentialsInWalletParams, reqEditors ...RequestEditorFn) (*GetExpiringCredentialsInWalletResponse, error) { + rsp, err := c.GetExpiringCredentialsInWallet(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetExpiringCredentialsInWalletResponse(rsp) +} + // CreateVPWithBodyWithResponse request with arbitrary body returning *CreateVPResponse func (c *ClientWithResponses) CreateVPWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateVPResponse, error) { rsp, err := c.CreateVPWithBody(ctx, contentType, body, reqEditors...) @@ -2284,6 +2445,48 @@ func (c *ClientWithResponses) ListUntrustedWithResponse(ctx context.Context, cre return ParseListUntrustedResponse(rsp) } +// ParseGetExpiringCredentialsInWalletResponse parses an HTTP response from a GetExpiringCredentialsInWalletWithResponse call +func ParseGetExpiringCredentialsInWalletResponse(rsp *http.Response) (*GetExpiringCredentialsInWalletResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetExpiringCredentialsInWalletResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest map[string][]ExpiringCredential + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + // ParseCreateVPResponse parses an HTTP response from a CreateVPWithResponse call func ParseCreateVPResponse(rsp *http.Response) (*CreateVPResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -2930,6 +3133,9 @@ func ParseListUntrustedResponse(rsp *http.Response) (*ListUntrustedResponse, err // ServerInterface represents all server handlers. type ServerInterface interface { + // List credentials across all wallets on this node that are expired or about to expire. + // (GET /internal/vcr/v2/holder/expiring) + GetExpiringCredentialsInWallet(ctx echo.Context, params GetExpiringCredentialsInWalletParams) error // Create a new Verifiable Presentation for a set of Verifiable Credentials. // (POST /internal/vcr/v2/holder/vp) CreateVP(ctx echo.Context) error @@ -2985,6 +3191,33 @@ type ServerInterfaceWrapper struct { Handler ServerInterface } +// GetExpiringCredentialsInWallet converts echo context to params. +func (w *ServerInterfaceWrapper) GetExpiringCredentialsInWallet(ctx echo.Context) error { + var err error + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetExpiringCredentialsInWalletParams + // ------------- Optional query parameter "within" ------------- + + err = runtime.BindQueryParameter("form", true, false, "within", ctx.QueryParams(), ¶ms.Within) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter within: %s", err)) + } + + // ------------- Optional query parameter "excludeTypes" ------------- + + err = runtime.BindQueryParameter("form", true, false, "excludeTypes", ctx.QueryParams(), ¶ms.ExcludeTypes) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter excludeTypes: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetExpiringCredentialsInWallet(ctx, params) + return err +} + // CreateVP converts echo context to params. func (w *ServerInterfaceWrapper) CreateVP(ctx echo.Context) error { var err error @@ -3261,6 +3494,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL Handler: si, } + router.GET(baseURL+"/internal/vcr/v2/holder/expiring", wrapper.GetExpiringCredentialsInWallet) router.POST(baseURL+"/internal/vcr/v2/holder/vp", wrapper.CreateVP) router.GET(baseURL+"/internal/vcr/v2/holder/:subjectID/vc", wrapper.GetCredentialsInWallet) router.POST(baseURL+"/internal/vcr/v2/holder/:subjectID/vc", wrapper.LoadVC) @@ -3280,6 +3514,44 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL } +type GetExpiringCredentialsInWalletRequestObject struct { + Params GetExpiringCredentialsInWalletParams +} + +type GetExpiringCredentialsInWalletResponseObject interface { + VisitGetExpiringCredentialsInWalletResponse(w http.ResponseWriter) error +} + +type GetExpiringCredentialsInWallet200JSONResponse map[string][]ExpiringCredential + +func (response GetExpiringCredentialsInWallet200JSONResponse) VisitGetExpiringCredentialsInWalletResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetExpiringCredentialsInWalletdefaultApplicationProblemPlusJSONResponse struct { + Body struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + StatusCode int +} + +func (response GetExpiringCredentialsInWalletdefaultApplicationProblemPlusJSONResponse) VisitGetExpiringCredentialsInWalletResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type CreateVPRequestObject struct { Body *CreateVPJSONRequestBody } @@ -3896,6 +4168,9 @@ func (response ListUntrusteddefaultApplicationProblemPlusJSONResponse) VisitList // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // List credentials across all wallets on this node that are expired or about to expire. + // (GET /internal/vcr/v2/holder/expiring) + GetExpiringCredentialsInWallet(ctx context.Context, request GetExpiringCredentialsInWalletRequestObject) (GetExpiringCredentialsInWalletResponseObject, error) // Create a new Verifiable Presentation for a set of Verifiable Credentials. // (POST /internal/vcr/v2/holder/vp) CreateVP(ctx context.Context, request CreateVPRequestObject) (CreateVPResponseObject, error) @@ -3958,6 +4233,31 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// GetExpiringCredentialsInWallet operation middleware +func (sh *strictHandler) GetExpiringCredentialsInWallet(ctx echo.Context, params GetExpiringCredentialsInWalletParams) error { + var request GetExpiringCredentialsInWalletRequestObject + + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetExpiringCredentialsInWallet(ctx.Request().Context(), request.(GetExpiringCredentialsInWalletRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetExpiringCredentialsInWallet") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetExpiringCredentialsInWalletResponseObject); ok { + return validResponse.VisitGetExpiringCredentialsInWalletResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // CreateVP operation middleware func (sh *strictHandler) CreateVP(ctx echo.Context) error { var request CreateVPRequestObject diff --git a/vcr/credential/store/sql.go b/vcr/credential/store/sql.go index a23181c3e9..273eded05d 100644 --- a/vcr/credential/store/sql.go +++ b/vcr/credential/store/sql.go @@ -22,6 +22,8 @@ import ( "encoding/json" "fmt" "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr/log" "gorm.io/gorm" "strconv" "strings" @@ -37,6 +39,10 @@ type CredentialRecord struct { SubjectID string // Type contains the 'type' property of the Verifiable Credential (not being 'VerifiableCredential'). Type *string + // ExpirationDate is the credential's 'expirationDate' as seconds since Unix epoch. Credentials + // without an expirationDate carry the neverExpires sentinel rather than NULL; NULL means the row + // predates this column and has not been backfilled yet. + ExpirationDate *int64 `gorm:"column:expiration_date"` // Raw contains the raw JSON of the Verifiable Credential. Raw string Properties []CredentialPropertyRecord `gorm:"foreignKey:CredentialID;references:ID"` @@ -87,6 +93,13 @@ func (c CredentialStore) Store(db *gorm.DB, credential vc.VerifiableCredential) break } } + // Set expiration date (seconds since Unix epoch). Credentials without an expirationDate get the + // neverExpires sentinel rather than NULL, so the backfill never has to revisit them. + exp := neverExpires + if credential.ExpirationDate != nil && !credential.ExpirationDate.IsZero() { + exp = credential.ExpirationDate.Unix() + } + newCredential.ExpirationDate = &exp // Create key-value properties of the credential subject, which is then stored in the property table for searching. if len(credential.CredentialSubject) != 1 { return nil, fmt.Errorf("expected exactly one credential subject, got %d", len(credential.CredentialSubject)) @@ -125,6 +138,82 @@ func (c CredentialStore) Store(db *gorm.DB, credential vc.VerifiableCredential) return &newCredential, nil } +// neverExpires is the expiration_date sentinel for credentials that have no expirationDate. Storing +// it instead of NULL lets BackfillExpirationDates run once and never revisit these rows on +// subsequent startups (NULL means "stored before this column existed, not yet backfilled"). The +// value is 9999-12-31T23:59:59Z, far enough in the future that the "expiring within X" range query +// never matches it, so a never-expiring credential is correctly excluded without query special-casing. +// +// TODO: remove together with BackfillExpirationDates in v7. v7+ stores can map "no expirationDate" +// back to NULL directly; a migration may NULL out any remaining sentinels. +const neverExpires int64 = 253402300799 + +// BackfillExpirationDates populates the expiration_date column for credentials stored before the +// column existed, by parsing each credential whose column is still NULL and writing its expiration +// (or the neverExpires sentinel when it has none). Updates are batched in transactions of +// backfillBatchSize. +// +// Every NULL row must be parsed: a JWT-encoded credential keeps its expiry in a base64 `exp` claim +// that no SQL text filter can match, so there is no cheap way to pre-select only rows that have an +// expiration. Because each processed row is written to a non-NULL value (real date or sentinel), it +// drops out of the NULL set, so the backfill is effectively one-shot — after the first run only +// rows that fail to parse (which shouldn't happen) remain NULL. +// +// Pagination uses the id as a keyset cursor rather than relying on updated rows leaving the result +// set, so unparseable rows that stay NULL don't stall the walk. +// +// TODO: remove in v7 — by then all v6 nodes will have backfilled their existing rows, and v7+ +// stores populate expiration_date directly on Store(). +func BackfillExpirationDates(db *gorm.DB) error { + const backfillBatchSize = 500 + lastID := "" + for { + var records []CredentialRecord + err := db.Model(&CredentialRecord{}). + Where("expiration_date IS NULL AND id > ?", lastID). + Order("id"). + Limit(backfillBatchSize). + Find(&records).Error + if err != nil { + return fmt.Errorf("backfill expiration_date: query: %w", err) + } + if len(records) == 0 { + return nil + } + err = db.Transaction(func(tx *gorm.DB) error { + for _, record := range records { + parsed, err := vc.ParseVerifiableCredential(record.Raw) + if err != nil { + // Unparseable raw blob shouldn't happen — Store() parsed it before persisting. + // Skip rather than abort the whole backfill, but warn so it's investigatable. + log.Logger(). + WithError(err). + WithField(core.LogFieldCredentialID, record.ID). + Warn("backfill expiration_date: unable to parse stored credential") + continue + } + exp := neverExpires + if parsed.ExpirationDate != nil && !parsed.ExpirationDate.IsZero() { + exp = parsed.ExpirationDate.Unix() + } + if err := tx.Model(&CredentialRecord{}). + Where("id = ?", record.ID). + Update("expiration_date", exp).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + return fmt.Errorf("backfill expiration_date: update: %w", err) + } + lastID = records[len(records)-1].ID + if len(records) < backfillBatchSize { + return nil + } + } +} + // stripWhitespaceAndLinebreaks removes all whitespace and linebreaks from a string. func stripWhitespaceAndLinebreaks(s string) string { return strings.ReplaceAll(strings.ReplaceAll(s, " ", ""), "\n", "") diff --git a/vcr/credential/store/sql_test.go b/vcr/credential/store/sql_test.go index 3284d8d379..6a72e0cb07 100644 --- a/vcr/credential/store/sql_test.go +++ b/vcr/credential/store/sql_test.go @@ -19,7 +19,15 @@ package store import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "encoding/json" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/storage" @@ -28,7 +36,6 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" "gorm.io/gorm/schema" - "testing" ) var vcAlice vc.VerifiableCredential @@ -137,6 +144,151 @@ func TestCredentialStore_Store(t *testing.T) { }) } +func TestCredentialStore_Store_ExpirationDate(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + t.Cleanup(func() { _ = storageEngine.Shutdown() }) + db := storageEngine.GetSQLDatabase() + + t.Run("populates expiration_date when credential has expirationDate", func(t *testing.T) { + setupStore(t, db) + exp := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) + cred := createPersonCredential("exp-1", "did:example:alice", nil) + cred.ExpirationDate = &exp + + _, err := CredentialStore{}.Store(db, cred) + require.NoError(t, err) + + var got CredentialRecord + require.NoError(t, db.First(&got, "id = ?", "exp-1").Error) + require.NotNil(t, got.ExpirationDate) + assert.Equal(t, exp.Unix(), *got.ExpirationDate) + }) + + t.Run("writes the never-expires sentinel when credential has no expirationDate", func(t *testing.T) { + setupStore(t, db) + cred := createPersonCredential("noexp-1", "did:example:bob", nil) + + _, err := CredentialStore{}.Store(db, cred) + require.NoError(t, err) + + var got CredentialRecord + require.NoError(t, db.First(&got, "id = ?", "noexp-1").Error) + require.NotNil(t, got.ExpirationDate) + assert.Equal(t, neverExpires, *got.ExpirationDate) + }) +} + +func TestBackfillExpirationDates(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + t.Cleanup(func() { _ = storageEngine.Shutdown() }) + db := storageEngine.GetSQLDatabase() + + storeWithExpiration := func(t *testing.T, id string, exp *time.Time) vc.VerifiableCredential { + t.Helper() + cred := createPersonCredential(id, "did:example:"+id, nil) + if exp != nil { + cred.ExpirationDate = exp + // Re-marshal so Raw includes expirationDate (simulates how Store() ingests it normally). + data, err := cred.MarshalJSON() + require.NoError(t, err) + parsed, err := vc.ParseVerifiableCredential(string(data)) + require.NoError(t, err) + cred = *parsed + } + _, err := CredentialStore{}.Store(db, cred) + require.NoError(t, err) + return cred + } + + t.Run("backfills rows whose expiration_date is NULL but raw has expirationDate", func(t *testing.T) { + setupStore(t, db) + exp := time.Date(2030, 6, 15, 12, 0, 0, 0, time.UTC) + storeWithExpiration(t, "bf-1", &exp) + // Simulate the pre-migration state: column unset on an existing row that does have raw expirationDate. + require.NoError(t, db.Exec("UPDATE credential SET expiration_date = NULL WHERE id = ?", "bf-1").Error) + + require.NoError(t, BackfillExpirationDates(db)) + + var got CredentialRecord + require.NoError(t, db.First(&got, "id = ?", "bf-1").Error) + require.NotNil(t, got.ExpirationDate) + assert.Equal(t, exp.Unix(), *got.ExpirationDate) + }) + + t.Run("backfills rows without expirationDate to the never-expires sentinel", func(t *testing.T) { + setupStore(t, db) + storeWithExpiration(t, "bf-2", nil) + // Simulate the pre-migration state: an existing row with no raw expirationDate and a NULL column. + require.NoError(t, db.Exec("UPDATE credential SET expiration_date = NULL WHERE id = ?", "bf-2").Error) + + require.NoError(t, BackfillExpirationDates(db)) + + var after CredentialRecord + require.NoError(t, db.First(&after, "id = ?", "bf-2").Error) + require.NotNil(t, after.ExpirationDate) + assert.Equal(t, neverExpires, *after.ExpirationDate) + }) + + t.Run("idempotent: re-running is a no-op when nothing needs backfilling", func(t *testing.T) { + setupStore(t, db) + exp := time.Date(2030, 6, 15, 12, 0, 0, 0, time.UTC) + storeWithExpiration(t, "bf-3", &exp) + + require.NoError(t, BackfillExpirationDates(db)) + require.NoError(t, BackfillExpirationDates(db)) + + var got CredentialRecord + require.NoError(t, db.First(&got, "id = ?", "bf-3").Error) + require.NotNil(t, got.ExpirationDate) + assert.Equal(t, exp.Unix(), *got.ExpirationDate) + }) + + t.Run("backfills JWT-encoded credentials, whose raw has no 'expirationDate' literal", func(t *testing.T) { + setupStore(t, db) + exp := time.Date(2030, 6, 15, 12, 0, 0, 0, time.UTC) + jwtVC := jwtCredentialWithExpiration(t, "did:example:jwt-bf#1", "did:example:jwt-holder", exp) + // The JWT stores its expiry in a base64 `exp` claim, so the raw text must not contain the + // literal "expirationDate" — a substring pre-filter would skip it. + require.NotContains(t, jwtVC.Raw(), "expirationDate") + _, err := CredentialStore{}.Store(db, jwtVC) + require.NoError(t, err) + // Simulate the pre-migration state: column unset. + require.NoError(t, db.Exec("UPDATE credential SET expiration_date = NULL WHERE id = ?", "did:example:jwt-bf#1").Error) + + require.NoError(t, BackfillExpirationDates(db)) + + var got CredentialRecord + require.NoError(t, db.First(&got, "id = ?", "did:example:jwt-bf#1").Error) + require.NotNil(t, got.ExpirationDate) + assert.Equal(t, exp.Unix(), *got.ExpirationDate) + }) +} + +// jwtCredentialWithExpiration builds a JWT-encoded Verifiable Credential with an `exp` claim. Its +// Raw() is the compact JWT, so the expiry only exists as a base64-encoded claim and never as a +// literal "expirationDate" in the stored text. +func jwtCredentialWithExpiration(t *testing.T, id, subjectID string, exp time.Time) vc.VerifiableCredential { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + token := jwt.New() + require.NoError(t, token.Set(jwt.JwtIDKey, id)) + require.NoError(t, token.Set(jwt.IssuerKey, "did:example:jwt-issuer")) + require.NoError(t, token.Set(jwt.SubjectKey, subjectID)) + require.NoError(t, token.Set(jwt.ExpirationKey, exp)) + require.NoError(t, token.Set("vc", map[string]interface{}{ + "type": "PersonCredential", + "credentialSubject": map[string]interface{}{"id": subjectID}, + })) + signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, privateKey)) + require.NoError(t, err) + parsed, err := vc.ParseVerifiableCredential(string(signedToken)) + require.NoError(t, err) + return *parsed +} + func sliceToMap(slice []CredentialPropertyRecord) map[string]string { var result = make(map[string]string) for _, curr := range slice { diff --git a/vcr/holder/interface.go b/vcr/holder/interface.go index 5776f30f6f..86c24d8c3f 100644 --- a/vcr/holder/interface.go +++ b/vcr/holder/interface.go @@ -59,11 +59,11 @@ type Wallet interface { // If the wallet does not contain any credentials for the given holder, it returns an empty list. List(ctx context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) - // SearchCredential returns all credentials in the wallet for the given holder. - // Unlike List, which filters out expired and revoked credentials, SearchCredential returns all credentials - // regardless of their validity status (signature, expired/revoked). - // This can be used to find credentials that can be removed, e.g. because they are expired or revoked. - SearchCredential(ctx context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) + // Search returns wallet credentials matching the given filter options. Without any options it + // returns every credential in the wallet. Unlike List, Search does not validate the credentials + // it returns — it filters purely on stored metadata (holder, type, expiration_date), so callers + // can use it to find credentials that should be cleaned up (expired, wrong type, etc.). + Search(ctx context.Context, opts ...SearchOption) ([]vc.VerifiableCredential, error) // Remove removes the given credential from the wallet. // If the credential is not in the wallet, it returns ErrNotFound. diff --git a/vcr/holder/memory_wallet.go b/vcr/holder/memory_wallet.go index 9adbb3af0f..79aba3f0a7 100644 --- a/vcr/holder/memory_wallet.go +++ b/vcr/holder/memory_wallet.go @@ -21,6 +21,7 @@ package holder import ( "context" "errors" + "slices" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" @@ -79,8 +80,38 @@ func (m memoryWallet) List(_ context.Context, holderDID did.DID) ([]vc.Verifiabl return m.credentials[holderDID], nil } -func (m memoryWallet) SearchCredential(_ context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) { - return m.credentials[holderDID], nil +func (m memoryWallet) Search(_ context.Context, opts ...SearchOption) ([]vc.VerifiableCredential, error) { + q := buildSearchQuery(opts) + var results []vc.VerifiableCredential + for holderDID, creds := range m.credentials { + if q.holderDID != nil && holderDID != *q.holderDID { + continue + } + for _, cred := range creds { + if len(q.excludeCredentialTypes) > 0 { + excluded := false + for _, t := range cred.Type { + if slices.Contains(q.excludeCredentialTypes, t.String()) { + excluded = true + break + } + } + if excluded { + continue + } + } + if q.expiresAt != nil { + if cred.ExpirationDate == nil || cred.ExpirationDate.IsZero() { + continue + } + if cred.ExpirationDate.After(*q.expiresAt) { + continue + } + } + results = append(results, cred) + } + } + return results, nil } func (m memoryWallet) Remove(_ context.Context, _ did.DID, _ ssi.URI) error { diff --git a/vcr/holder/mock.go b/vcr/holder/mock.go index e12b0f8019..839fa034fb 100644 --- a/vcr/holder/mock.go +++ b/vcr/holder/mock.go @@ -153,17 +153,22 @@ func (mr *MockWalletMockRecorder) Remove(ctx, holderDID, credentialID any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockWallet)(nil).Remove), ctx, holderDID, credentialID) } -// SearchCredential mocks base method. -func (m *MockWallet) SearchCredential(ctx context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) { +// Search mocks base method. +func (m *MockWallet) Search(ctx context.Context, opts ...SearchOption) ([]vc.VerifiableCredential, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SearchCredential", ctx, holderDID) + varargs := []any{ctx} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Search", varargs...) ret0, _ := ret[0].([]vc.VerifiableCredential) ret1, _ := ret[1].(error) return ret0, ret1 } -// SearchCredential indicates an expected call of SearchCredential. -func (mr *MockWalletMockRecorder) SearchCredential(ctx, holderDID any) *gomock.Call { +// Search indicates an expected call of Search. +func (mr *MockWalletMockRecorder) Search(ctx any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchCredential", reflect.TypeOf((*MockWallet)(nil).SearchCredential), ctx, holderDID) + varargs := append([]any{ctx}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockWallet)(nil).Search), varargs...) } diff --git a/vcr/holder/sql_wallet.go b/vcr/holder/sql_wallet.go index 7323332558..cad7310610 100644 --- a/vcr/holder/sql_wallet.go +++ b/vcr/holder/sql_wallet.go @@ -56,12 +56,19 @@ type sqlWallet struct { func NewSQLWallet( keyResolver resolver.KeyResolver, keyStore crypto.KeyStore, verifier verifier.Verifier, jsonldManager jsonld.JSONLD, storageEngine storage.Engine) Wallet { + db := storageEngine.GetSQLDatabase() + // Migrate the expiration_date column for credentials stored before the column existed. + // Idempotent; safe to run on every startup. Logged-and-continued on error to avoid blocking + // node start. + if err := store.BackfillExpirationDates(db); err != nil { + log.Logger().WithError(err).Warn("failed to migrate credential expiration_date column") + } return &sqlWallet{ keyResolver: keyResolver, keyStore: keyStore, verifier: verifier, jsonldManager: jsonldManager, - walletStore: walletStore{db: storageEngine.GetSQLDatabase()}, + walletStore: walletStore{db: db}, } } @@ -141,8 +148,8 @@ func (h sqlWallet) List(_ context.Context, holderDID did.DID) ([]vc.VerifiableCr return validCredentials, nil } -func (h sqlWallet) SearchCredential(_ context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) { - return h.walletStore.list(holderDID) +func (h sqlWallet) Search(_ context.Context, opts ...SearchOption) ([]vc.VerifiableCredential, error) { + return h.walletStore.search(buildSearchQuery(opts)) } func (h sqlWallet) Remove(ctx context.Context, holderDID did.DID, credentialID ssi.URI) error { @@ -197,12 +204,33 @@ func (s walletStore) count() (int64, error) { } func (s walletStore) list(holderDID did.DID) ([]vc.VerifiableCredential, error) { + return s.search(searchQuery{holderDID: &holderDID}) +} + +func (s walletStore) search(q searchQuery) ([]vc.VerifiableCredential, error) { + tx := s.db.Model(walletRecord{}).Preload("Credential") + // Join credential when any option filters on credential.* columns. + if len(q.excludeCredentialTypes) > 0 || q.expiresAt != nil { + tx = tx.Joins("JOIN credential ON credential.id = wallet_credential.credential_id") + } + if q.holderDID != nil { + tx = tx.Where("wallet_credential.holder_did = ?", q.holderDID.String()) + } + if len(q.excludeCredentialTypes) > 0 { + // Credentials with a NULL type column are kept; only known types are excluded. + tx = tx.Where("credential.type IS NULL OR credential.type NOT IN ?", q.excludeCredentialTypes) + } + if q.expiresAt != nil { + // Credentials without an expirationDate carry the never-expires sentinel (a far-future + // value), and legacy un-backfilled rows are NULL; <= excludes both, so neither shows up as + // expiring. + tx = tx.Where("credential.expiration_date <= ?", q.expiresAt.Unix()) + } var records []walletRecord - err := s.db.Model(walletRecord{}).Preload("Credential").Where("holder_did = ?", holderDID.String()).Find(&records).Error - if err != nil { + if err := tx.Find(&records).Error; err != nil { return nil, err } - results := make([]vc.VerifiableCredential, 0) + results := make([]vc.VerifiableCredential, 0, len(records)) for _, record := range records { verifiableCredential, err := vc.ParseVerifiableCredential(record.Credential.Raw) if err != nil { diff --git a/vcr/holder/sql_wallet_test.go b/vcr/holder/sql_wallet_test.go index 75d4767538..fc652a5da4 100644 --- a/vcr/holder/sql_wallet_test.go +++ b/vcr/holder/sql_wallet_test.go @@ -228,50 +228,110 @@ func Test_sqlWallet_List(t *testing.T) { }) } -func Test_sqlWallet_SearchCredential(t *testing.T) { +func Test_sqlWallet_Search(t *testing.T) { ctx := context.Background() storageEngine := storage.NewTestStorageEngine(t) + t.Run("empty", func(t *testing.T) { resetStore(t, storageEngine.GetSQLDatabase()) sut := NewSQLWallet(nil, nil, testVerifier{}, nil, storageEngine) - list, err := sut.SearchCredential(ctx, vdr.TestDIDA) + list, err := sut.Search(ctx, HolderDID(vdr.TestDIDA)) require.NoError(t, err) require.NotNil(t, list) assert.Empty(t, list) }) + t.Run("no options - returns all credentials in the wallet", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + sut := NewSQLWallet(nil, nil, testVerifier{}, nil, storageEngine) + credA := createCredential(vdr.TestMethodDIDA.String()) + credB := createCredential(vdr.TestMethodDIDB.String()) + require.NoError(t, sut.Put(ctx, credA, credB)) + + list, err := sut.Search(ctx) + require.NoError(t, err) + require.Len(t, list, 2) + }) t.Run("returns all credentials including expired/revoked", func(t *testing.T) { resetStore(t, storageEngine.GetSQLDatabase()) - // SearchCredential should not filter by validity, so we pass a testVerifier that would filter + // Search must not filter on validity, so we pass a testVerifier that would filter via List. sut := NewSQLWallet(nil, nil, testVerifier{err: types.ErrCredentialNotValidAtTime}, nil, storageEngine) expected1 := createCredential(vdr.TestMethodDIDA.String()) expected2 := createCredential(vdr.TestMethodDIDA.String()) - err := sut.Put(ctx, expected1, expected2) - require.NoError(t, err) + require.NoError(t, sut.Put(ctx, expected1, expected2)) - // SearchCredential should return all credentials, even though they would be filtered by List - list, err := sut.SearchCredential(ctx, vdr.TestDIDA) + list, err := sut.Search(ctx, HolderDID(vdr.TestDIDA)) require.NoError(t, err) require.Len(t, list, 2) - // Compare with List which should filter them out + // Compare with List which should filter them out. filteredList, err := sut.List(ctx, vdr.TestDIDA) require.NoError(t, err) require.Len(t, filteredList, 0) }) - t.Run("returns credentials from specified holder only", func(t *testing.T) { + t.Run("HolderDID - returns credentials from specified holder only", func(t *testing.T) { resetStore(t, storageEngine.GetSQLDatabase()) sut := NewSQLWallet(nil, nil, testVerifier{}, nil, storageEngine) credA := createCredential(vdr.TestMethodDIDA.String()) credB := createCredential(vdr.TestMethodDIDB.String()) - err := sut.Put(ctx, credA, credB) - require.NoError(t, err) + require.NoError(t, sut.Put(ctx, credA, credB)) - list, err := sut.SearchCredential(ctx, vdr.TestDIDA) + list, err := sut.Search(ctx, HolderDID(vdr.TestDIDA)) require.NoError(t, err) require.Len(t, list, 1) assert.Equal(t, credA.ID.String(), list[0].ID.String()) }) + t.Run("ExpiresAt - includes credentials expiring at or before threshold, skips no-expiration", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + sut := NewSQLWallet(nil, nil, testVerifier{}, nil, storageEngine) + now := time.Now() + expired := now.Add(-24 * time.Hour) + soon := now.Add(24 * time.Hour) + farFuture := now.Add(365 * 24 * time.Hour) + expiredVC := createCredentialWithExpiration(vdr.TestMethodDIDA.String(), &expired) + soonVC := createCredentialWithExpiration(vdr.TestMethodDIDA.String(), &soon) + farVC := createCredentialWithExpiration(vdr.TestMethodDIDA.String(), &farFuture) + noExpVC := createCredentialWithExpiration(vdr.TestMethodDIDA.String(), nil) + require.NoError(t, sut.Put(ctx, expiredVC, soonVC, farVC, noExpVC)) + + // Threshold = now + 7d -> expiredVC and soonVC match; farVC and noExpVC excluded. + list, err := sut.Search(ctx, ExpiresAt(now.Add(7*24*time.Hour))) + require.NoError(t, err) + require.Len(t, list, 2) + ids := []string{list[0].ID.String(), list[1].ID.String()} + assert.Contains(t, ids, expiredVC.ID.String()) + assert.Contains(t, ids, soonVC.ID.String()) + }) + t.Run("ExcludeCredentialTypes - drops matching types, keeps others", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + sut := NewSQLWallet(nil, nil, testVerifier{}, nil, storageEngine) + companyVC := createCredential(vdr.TestMethodDIDA.String()) // CompanyCredential + require.NoError(t, sut.Put(ctx, companyVC)) + + // Excluding the only stored type returns nothing. + list, err := sut.Search(ctx, ExcludeCredentialTypes("CompanyCredential")) + require.NoError(t, err) + assert.Empty(t, list) + + // Excluding an unrelated type leaves the credential in the result. + list, err = sut.Search(ctx, ExcludeCredentialTypes("OtherCredential")) + require.NoError(t, err) + require.Len(t, list, 1) + }) + t.Run("combined options AND together", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + sut := NewSQLWallet(nil, nil, testVerifier{}, nil, storageEngine) + expired := time.Now().Add(-24 * time.Hour) + expiredA := createCredentialWithExpiration(vdr.TestMethodDIDA.String(), &expired) + expiredB := createCredentialWithExpiration(vdr.TestMethodDIDB.String(), &expired) + require.NoError(t, sut.Put(ctx, expiredA, expiredB)) + + // HolderDID + ExpiresAt: only DIDA's expired credential. + list, err := sut.Search(ctx, HolderDID(vdr.TestDIDA), ExpiresAt(time.Now())) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, expiredA.ID.String(), list[0].ID.String()) + }) } func Test_sqlWallet_Diagnostics(t *testing.T) { @@ -349,6 +409,15 @@ func createCredential(keyID string) vc.VerifiableCredential { return testCredential } +// createCredentialWithExpiration builds a wallet-storable credential and sets its expirationDate +// (nil = no expirationDate, i.e. never expires). The credential ID stays the unique one assigned +// by createCredential, so Raw and the parsed-back VC agree on ID after a Put/Search round-trip. +func createCredentialWithExpiration(keyID string, expirationDate *time.Time) vc.VerifiableCredential { + cred := createCredential(keyID) + cred.ExpirationDate = expirationDate + return cred +} + func Test_sqlWallet_IsEmpty(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) t.Run("empty", func(t *testing.T) { diff --git a/vcr/holder/wallet_search.go b/vcr/holder/wallet_search.go new file mode 100644 index 0000000000..a4dc8458a6 --- /dev/null +++ b/vcr/holder/wallet_search.go @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package holder + +import ( + "time" + + "github.com/nuts-foundation/go-did/did" +) + +// SearchOption is a filter that narrows the results returned by Wallet.Search. Multiple options +// are combined with logical AND. +type SearchOption func(*searchQuery) + +// searchQuery holds the resolved filter state for a single Wallet.Search call. +type searchQuery struct { + holderDID *did.DID + excludeCredentialTypes []string + expiresAt *time.Time +} + +// HolderDID restricts results to credentials held by the given DID. +func HolderDID(d did.DID) SearchOption { + return func(q *searchQuery) { q.holderDID = &d } +} + +// ExcludeCredentialTypes drops credentials whose type matches any of the given values. Credentials +// without a stored type (NULL) are kept. +func ExcludeCredentialTypes(types ...string) SearchOption { + return func(q *searchQuery) { + if len(types) == 0 { + return + } + q.excludeCredentialTypes = append(q.excludeCredentialTypes, types...) + } +} + +// ExpiresAt restricts results to credentials whose expirationDate is set and falls at or before t. +// Credentials without an expirationDate are not returned. Callers compute t from their own clock +// so this is a fixed threshold from the wallet's perspective. +func ExpiresAt(t time.Time) SearchOption { + return func(q *searchQuery) { q.expiresAt = &t } +} + +// buildSearchQuery applies the options to a fresh searchQuery and returns it. +func buildSearchQuery(opts []SearchOption) searchQuery { + var q searchQuery + for _, opt := range opts { + opt(&q) + } + return q +}