From 773071f875a2b57621ff7d1c7ca898850d7e9102 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:01:10 +0000 Subject: [PATCH 1/3] VCR: Introduce 'wallet.SearchCredentials()' which also returns revocation status --- discovery/client.go | 7 +- docs/_static/vcr/vcr_v2.yaml | 33 ++++ vcr/api/vcr/v2/api.go | 29 ++- vcr/api/vcr/v2/api_test.go | 60 ++++++ vcr/api/vcr/v2/generated.go | 217 ++++++++++++++++++++++ vcr/api/vcr/v2/registry_test.go | 13 +- vcr/holder/interface.go | 7 + vcr/holder/memory_wallet.go | 5 + vcr/holder/mock.go | 15 ++ vcr/holder/sql_wallet.go | 7 +- vcr/holder/sql_wallet_test.go | 53 +++++- vcr/pe/schema/gen/go.mod | 4 +- vcr/pe/schema/gen/go.sum | 14 -- vcr/revocation/statuslist2021_issuer.go | 3 +- vcr/revocation/statuslist2021_verifier.go | 3 +- vcr/revocation/types.go | 3 - vcr/revocation/types_test.go | 3 +- vcr/verifier/interface.go | 9 +- vcr/verifier/mock.go | 8 +- vcr/verifier/verifier.go | 63 ++++--- vcr/verifier/verifier_test.go | 82 ++++---- 21 files changed, 525 insertions(+), 113 deletions(-) diff --git a/discovery/client.go b/discovery/client.go index 01d1e52f55..9ee3afe2fb 100644 --- a/discovery/client.go +++ b/discovery/client.go @@ -22,6 +22,10 @@ import ( "context" "errors" "fmt" + "slices" + "strings" + "time" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -36,9 +40,6 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/types" "github.com/nuts-foundation/nuts-node/vdr/didsubject" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "slices" - "strings" - "time" ) // clientRegistrationManager is a client component, responsible for managing registrations on a Discovery Service. diff --git a/docs/_static/vcr/vcr_v2.yaml b/docs/_static/vcr/vcr_v2.yaml index b491db09f7..6087bc3011 100644 --- a/docs/_static/vcr/vcr_v2.yaml +++ b/docs/_static/vcr/vcr_v2.yaml @@ -569,6 +569,39 @@ paths: description: Credential has been removed from the wallet. default: $ref: '../common/error_response.yaml' + /internal/vcr/v2/holder/{subjectID}/vc/search: + parameters: + - name: subjectID + in: path + description: Subject ID of the wallet owner at this node. + required: true + content: + plain/text: + schema: + type: string + example: 90BC1AE9-752B-432F-ADC3-DD9F9C61843C + get: + summary: "Searches for verifiable credentials in the holder's wallet" + description: > + The SearchVCResult contains a list of matching credentials in the wallet regardless of validity. + This allows applications to build a listing of all credentials in wallets, regardless of its status (expired/revoked). + The entry may contain a revocation if the credential has been revoked with a credential of type CredentialRevocation. + + error returns: + * 404 - Subject not found + * 500 - An error occurred while processing the request + operationId: "searchCredentialsInWallet" + tags: + - credential + responses: + "200": + description: A list of matching credentials + content: + application/json: + schema: + $ref: '#/components/schemas/SearchVCResults' + default: + $ref: '../common/error_response.yaml' components: schemas: VerifiableCredential: diff --git a/vcr/api/vcr/v2/api.go b/vcr/api/vcr/v2/api.go index 9dbe0be05f..97b183b4c1 100644 --- a/vcr/api/vcr/v2/api.go +++ b/vcr/api/vcr/v2/api.go @@ -466,6 +466,29 @@ func (w *Wrapper) GetCredentialsInWallet(ctx context.Context, request GetCredent return GetCredentialsInWallet200JSONResponse(credentials), nil } +// SearchCredentialsInWallet handles the API request for searching credentials in the holder's wallet +func (w *Wrapper) SearchCredentialsInWallet(ctx context.Context, request SearchCredentialsInWalletRequestObject) (SearchCredentialsInWalletResponseObject, error) { + dids, err := w.SubjectManager.ListDIDs(ctx, request.SubjectID) + if err != nil { + return nil, err + } + + var creds []vc.VerifiableCredential + for _, did := range dids { + creds, err := w.VCR.Wallet().SearchCredential(ctx, did) + if err != nil { + return nil, err + } + creds = append(creds, creds...) + } + + searchResults, err := w.vcsWithRevocationsToSearchResults(creds) + if err != nil { + return nil, err + } + return SearchCredentialsInWallet200JSONResponse(SearchVCResults{searchResults}), nil +} + func (w *Wrapper) RemoveCredentialFromWallet(ctx context.Context, request RemoveCredentialFromWalletRequestObject) (RemoveCredentialFromWalletResponseObject, error) { // get DIDs for holder dids, err := w.SubjectManager.ListDIDs(ctx, request.SubjectID) @@ -532,9 +555,9 @@ func (w *Wrapper) vcsWithRevocationsToSearchResults(foundVCs []vc.VerifiableCred result := make([]SearchVCResult, len(foundVCs)) for i, resolvedVC := range foundVCs { var revocation *Revocation - revocation, err := w.VCR.Verifier().GetRevocation(*resolvedVC.ID) - if err != nil && !errors.Is(err, verifier.ErrNotFound) { - return nil, err + revocation, err := w.VCR.Verifier().GetRevocation(resolvedVC) + if err != nil { + return nil, fmt.Errorf("failed to get revocation for credential %s: %w", resolvedVC.ID.String(), err) } result[i] = SearchVCResult{VerifiableCredential: resolvedVC, Revocation: revocation} } diff --git a/vcr/api/vcr/v2/api_test.go b/vcr/api/vcr/v2/api_test.go index 510978caed..1da63faba5 100644 --- a/vcr/api/vcr/v2/api_test.go +++ b/vcr/api/vcr/v2/api_test.go @@ -831,6 +831,66 @@ func TestWrapper_GetCredentialsInWallet(t *testing.T) { }) } +func TestWrapper_SearchCredentialsInWallet(t *testing.T) { + subjectID := "holder" + vcWithRevocation := testVC + vcWithRevocation.ID = &credentialID + revocationInfo := &Revocation{Reason: "test revocation"} + + 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) + + response, err := testContext.client.SearchCredentialsInWallet(testContext.requestCtx, SearchCredentialsInWalletRequestObject{ + SubjectID: subjectID, + }) + + assert.NoError(t, err) + assert.Equal(t, SearchCredentialsInWallet200JSONResponse(SearchVCResults{VerifiableCredentials: []SearchVCResult{}}), response) + }) + + 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.mockVerifier.EXPECT().GetRevocation(testVC).Return(nil, nil) + + response, err := testContext.client.SearchCredentialsInWallet(testContext.requestCtx, SearchCredentialsInWalletRequestObject{ + SubjectID: subjectID, + }) + + assert.NoError(t, err) + expectedResult := SearchVCResults{VerifiableCredentials: []SearchVCResult{{VerifiableCredential: testVC}}} + assert.Equal(t, SearchCredentialsInWallet200JSONResponse(expectedResult), response) + }) + + 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) + // GetRevocation is then called to get the details + testContext.mockVerifier.EXPECT().GetRevocation(vcWithRevocation).Return(revocationInfo, nil) + + response, err := testContext.client.SearchCredentialsInWallet(testContext.requestCtx, SearchCredentialsInWalletRequestObject{ + SubjectID: subjectID, + }) + + assert.NoError(t, err) + expectedResult := SearchVCResults{VerifiableCredentials: []SearchVCResult{{VerifiableCredential: vcWithRevocation, Revocation: revocationInfo}}} + assert.Equal(t, SearchCredentialsInWallet200JSONResponse(expectedResult), response) + }) + + t.Run("subject not found", func(t *testing.T) { + testContext := newMockContext(t) + testContext.mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{}, didsubject.ErrSubjectNotFound) + + _, err := testContext.client.SearchCredentialsInWallet(testContext.requestCtx, SearchCredentialsInWalletRequestObject{SubjectID: subjectID}) + + assert.ErrorIs(t, err, didsubject.ErrSubjectNotFound) + }) +} + 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 188bae5474..520dec6c92 100644 --- a/vcr/api/vcr/v2/generated.go +++ b/vcr/api/vcr/v2/generated.go @@ -490,6 +490,9 @@ type ClientInterface interface { LoadVC(ctx context.Context, subjectID string, body LoadVCJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // SearchCredentialsInWallet request + SearchCredentialsInWallet(ctx context.Context, subjectID string, reqEditors ...RequestEditorFn) (*http.Response, error) + // RemoveCredentialFromWallet request RemoveCredentialFromWallet(ctx context.Context, subjectID string, id string, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -599,6 +602,18 @@ func (c *Client) LoadVC(ctx context.Context, subjectID string, body LoadVCJSONRe return c.Client.Do(req) } +func (c *Client) SearchCredentialsInWallet(ctx context.Context, subjectID string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSearchCredentialsInWalletRequest(c.Server, subjectID) + 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) RemoveCredentialFromWallet(ctx context.Context, subjectID string, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewRemoveCredentialFromWalletRequest(c.Server, subjectID, id) if err != nil { @@ -930,6 +945,37 @@ func NewLoadVCRequestWithBody(server string, subjectID string, contentType strin return req, nil } +// NewSearchCredentialsInWalletRequest generates requests for SearchCredentialsInWallet +func NewSearchCredentialsInWalletRequest(server string, subjectID string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0 = subjectID + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/internal/vcr/v2/holder/%s/vc/search", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewRemoveCredentialFromWalletRequest generates requests for RemoveCredentialFromWallet func NewRemoveCredentialFromWalletRequest(server string, subjectID string, id string) (*http.Request, error) { var err error @@ -1470,6 +1516,9 @@ type ClientWithResponsesInterface interface { LoadVCWithResponse(ctx context.Context, subjectID string, body LoadVCJSONRequestBody, reqEditors ...RequestEditorFn) (*LoadVCResponse, error) + // SearchCredentialsInWalletWithResponse request + SearchCredentialsInWalletWithResponse(ctx context.Context, subjectID string, reqEditors ...RequestEditorFn) (*SearchCredentialsInWalletResponse, error) + // RemoveCredentialFromWalletWithResponse request RemoveCredentialFromWalletWithResponse(ctx context.Context, subjectID string, id string, reqEditors ...RequestEditorFn) (*RemoveCredentialFromWalletResponse, error) @@ -1614,6 +1663,38 @@ func (r LoadVCResponse) StatusCode() int { return 0 } +type SearchCredentialsInWalletResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *SearchVCResults + 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 SearchCredentialsInWalletResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SearchCredentialsInWalletResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type RemoveCredentialFromWalletResponse struct { Body []byte HTTPResponse *http.Response @@ -2038,6 +2119,15 @@ func (c *ClientWithResponses) LoadVCWithResponse(ctx context.Context, subjectID return ParseLoadVCResponse(rsp) } +// SearchCredentialsInWalletWithResponse request returning *SearchCredentialsInWalletResponse +func (c *ClientWithResponses) SearchCredentialsInWalletWithResponse(ctx context.Context, subjectID string, reqEditors ...RequestEditorFn) (*SearchCredentialsInWalletResponse, error) { + rsp, err := c.SearchCredentialsInWallet(ctx, subjectID, reqEditors...) + if err != nil { + return nil, err + } + return ParseSearchCredentialsInWalletResponse(rsp) +} + // RemoveCredentialFromWalletWithResponse request returning *RemoveCredentialFromWalletResponse func (c *ClientWithResponses) RemoveCredentialFromWalletWithResponse(ctx context.Context, subjectID string, id string, reqEditors ...RequestEditorFn) (*RemoveCredentialFromWalletResponse, error) { rsp, err := c.RemoveCredentialFromWallet(ctx, subjectID, id, reqEditors...) @@ -2313,6 +2403,48 @@ func ParseLoadVCResponse(rsp *http.Response) (*LoadVCResponse, error) { return response, nil } +// ParseSearchCredentialsInWalletResponse parses an HTTP response from a SearchCredentialsInWalletWithResponse call +func ParseSearchCredentialsInWalletResponse(rsp *http.Response) (*SearchCredentialsInWalletResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SearchCredentialsInWalletResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest SearchVCResults + 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 +} + // ParseRemoveCredentialFromWalletResponse parses an HTTP response from a RemoveCredentialFromWalletWithResponse call func ParseRemoveCredentialFromWalletResponse(rsp *http.Response) (*RemoveCredentialFromWalletResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -2807,6 +2939,9 @@ type ServerInterface interface { // Load a VerifiableCredential into the holders wallet. // (POST /internal/vcr/v2/holder/{subjectID}/vc) LoadVC(ctx echo.Context, subjectID string) error + // Searches for verifiable credentials in the holder's wallet + // (GET /internal/vcr/v2/holder/{subjectID}/vc/search) + SearchCredentialsInWallet(ctx echo.Context, subjectID string) error // Remove a VerifiableCredential from the holders wallet. // (DELETE /internal/vcr/v2/holder/{subjectID}/vc/{id}) RemoveCredentialFromWallet(ctx echo.Context, subjectID string, id string) error @@ -2891,6 +3026,21 @@ func (w *ServerInterfaceWrapper) LoadVC(ctx echo.Context) error { return err } +// SearchCredentialsInWallet converts echo context to params. +func (w *ServerInterfaceWrapper) SearchCredentialsInWallet(ctx echo.Context) error { + var err error + // ------------- Path parameter "subjectID" ------------- + var subjectID string + + subjectID = ctx.Param("subjectID") + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.SearchCredentialsInWallet(ctx, subjectID) + return err +} + // RemoveCredentialFromWallet converts echo context to params. func (w *ServerInterfaceWrapper) RemoveCredentialFromWallet(ctx echo.Context) error { var err error @@ -3114,6 +3264,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL 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) + router.GET(baseURL+"/internal/vcr/v2/holder/:subjectID/vc/search", wrapper.SearchCredentialsInWallet) router.DELETE(baseURL+"/internal/vcr/v2/holder/:subjectID/vc/:id", wrapper.RemoveCredentialFromWallet) router.POST(baseURL+"/internal/vcr/v2/issuer/vc", wrapper.IssueVC) router.GET(baseURL+"/internal/vcr/v2/issuer/vc/search", wrapper.SearchIssuedVCs) @@ -3243,6 +3394,44 @@ func (response LoadVCdefaultApplicationProblemPlusJSONResponse) VisitLoadVCRespo return json.NewEncoder(w).Encode(response.Body) } +type SearchCredentialsInWalletRequestObject struct { + SubjectID string `json:"subjectID"` +} + +type SearchCredentialsInWalletResponseObject interface { + VisitSearchCredentialsInWalletResponse(w http.ResponseWriter) error +} + +type SearchCredentialsInWallet200JSONResponse SearchVCResults + +func (response SearchCredentialsInWallet200JSONResponse) VisitSearchCredentialsInWalletResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type SearchCredentialsInWalletdefaultApplicationProblemPlusJSONResponse 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 SearchCredentialsInWalletdefaultApplicationProblemPlusJSONResponse) VisitSearchCredentialsInWalletResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type RemoveCredentialFromWalletRequestObject struct { SubjectID string `json:"subjectID"` Id string `json:"id"` @@ -3716,6 +3905,9 @@ type StrictServerInterface interface { // Load a VerifiableCredential into the holders wallet. // (POST /internal/vcr/v2/holder/{subjectID}/vc) LoadVC(ctx context.Context, request LoadVCRequestObject) (LoadVCResponseObject, error) + // Searches for verifiable credentials in the holder's wallet + // (GET /internal/vcr/v2/holder/{subjectID}/vc/search) + SearchCredentialsInWallet(ctx context.Context, request SearchCredentialsInWalletRequestObject) (SearchCredentialsInWalletResponseObject, error) // Remove a VerifiableCredential from the holders wallet. // (DELETE /internal/vcr/v2/holder/{subjectID}/vc/{id}) RemoveCredentialFromWallet(ctx context.Context, request RemoveCredentialFromWalletRequestObject) (RemoveCredentialFromWalletResponseObject, error) @@ -3851,6 +4043,31 @@ func (sh *strictHandler) LoadVC(ctx echo.Context, subjectID string) error { return nil } +// SearchCredentialsInWallet operation middleware +func (sh *strictHandler) SearchCredentialsInWallet(ctx echo.Context, subjectID string) error { + var request SearchCredentialsInWalletRequestObject + + request.SubjectID = subjectID + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.SearchCredentialsInWallet(ctx.Request().Context(), request.(SearchCredentialsInWalletRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SearchCredentialsInWallet") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(SearchCredentialsInWalletResponseObject); ok { + return validResponse.VisitSearchCredentialsInWalletResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // RemoveCredentialFromWallet operation middleware func (sh *strictHandler) RemoveCredentialFromWallet(ctx echo.Context, subjectID string, id string) error { var request RemoveCredentialFromWalletRequestObject diff --git a/vcr/api/vcr/v2/registry_test.go b/vcr/api/vcr/v2/registry_test.go index f8ff5e834a..50e58921f9 100644 --- a/vcr/api/vcr/v2/registry_test.go +++ b/vcr/api/vcr/v2/registry_test.go @@ -21,13 +21,14 @@ package v2 import ( "encoding/json" "errors" + "testing" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/test" "github.com/stretchr/testify/require" - "testing" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -115,7 +116,7 @@ func TestWrapper_SearchVCs(t *testing.T) { // Not an organization VC, but doesn't matter actualVC := test.ValidNutsAuthorizationCredential(t) ctx.vcr.EXPECT().Search(ctx.requestCtx, searchTerms, false, gomock.Any()).Return([]vc.VerifiableCredential{actualVC}, nil) - ctx.mockVerifier.EXPECT().GetRevocation(*actualVC.ID).Return(nil, nil) + ctx.mockVerifier.EXPECT().GetRevocation(actualVC).Return(nil, nil) expectedResponse := SearchVCs200JSONResponse(SearchVCResults{[]SearchVCResult{{VerifiableCredential: actualVC}}}) response, err := ctx.client.SearchVCs(ctx.requestCtx, SearchVCsRequestObject{Body: &request}) @@ -153,7 +154,7 @@ func TestWrapper_SearchVCs(t *testing.T) { // Not an organization VC, but doesn't matter actualVC := test.ValidNutsAuthorizationCredential(t) ctx.vcr.EXPECT().Search(ctx.requestCtx, searchTerms, false, gomock.Any()).Return([]vc.VerifiableCredential{actualVC}, nil) - ctx.mockVerifier.EXPECT().GetRevocation(*actualVC.ID).Return(nil, nil) + ctx.mockVerifier.EXPECT().GetRevocation(actualVC).Return(nil, nil) expectedResponse := SearchVCs200JSONResponse(SearchVCResults{[]SearchVCResult{{VerifiableCredential: actualVC}}}) response, err := ctx.client.SearchVCs(ctx.requestCtx, SearchVCsRequestObject{Body: &request}) @@ -189,7 +190,7 @@ func TestWrapper_SearchVCs(t *testing.T) { // Not an organization VC, but doesn't matter actualVC := test.ValidNutsAuthorizationCredential(t) ctx.vcr.EXPECT().Search(ctx.requestCtx, searchTerms, false, gomock.Any()).Return([]vc.VerifiableCredential{actualVC}, nil) - ctx.mockVerifier.EXPECT().GetRevocation(*actualVC.ID).Return(nil, nil) + ctx.mockVerifier.EXPECT().GetRevocation(actualVC).Return(nil, nil) expectedResponse := SearchVCs200JSONResponse(SearchVCResults{[]SearchVCResult{{VerifiableCredential: actualVC}}}) response, err := ctx.client.SearchVCs(ctx.requestCtx, SearchVCsRequestObject{Body: &request}) @@ -312,12 +313,12 @@ func TestWrapper_SearchVCs(t *testing.T) { // Not an organization VC, but doesn't matter actualVC := test.ValidNutsAuthorizationCredential(t) ctx.vcr.EXPECT().Search(ctx.requestCtx, searchTerms, false, gomock.Any()).Return([]vc.VerifiableCredential{actualVC}, nil) - ctx.mockVerifier.EXPECT().GetRevocation(*actualVC.ID).Return(nil, errors.New("failure")) + ctx.mockVerifier.EXPECT().GetRevocation(gomock.Any()).Return(nil, errors.New("failure")) response, err := ctx.client.SearchVCs(ctx.requestCtx, SearchVCsRequestObject{Body: &request}) assert.Empty(t, response) - assert.EqualError(t, err, "failure") + assert.EqualError(t, err, "failed to get revocation for credential did:nuts:GvkzxsezHvEc8nGhgz6Xo3jbqkHwswLmWw3CYtCm7hAW#38E90E8C-F7E5-4333-B63A-F9DD155A0272: failure") }) } diff --git a/vcr/holder/interface.go b/vcr/holder/interface.go index 8ac19c153d..0483b17943 100644 --- a/vcr/holder/interface.go +++ b/vcr/holder/interface.go @@ -20,6 +20,7 @@ package holder import ( "context" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -56,6 +57,12 @@ 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) + // Remove removes the given credential from the wallet. // If the credential is not in the wallet, it returns ErrNotFound. Remove(ctx context.Context, holderDID did.DID, credentialID ssi.URI) error diff --git a/vcr/holder/memory_wallet.go b/vcr/holder/memory_wallet.go index 543813411d..8e466719a0 100644 --- a/vcr/holder/memory_wallet.go +++ b/vcr/holder/memory_wallet.go @@ -21,6 +21,7 @@ package holder import ( "context" "errors" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -77,6 +78,10 @@ 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) Remove(_ context.Context, _ did.DID, _ ssi.URI) error { return errors.New("memory wallet is read-only") } diff --git a/vcr/holder/mock.go b/vcr/holder/mock.go index 69db728228..bd81a024fd 100644 --- a/vcr/holder/mock.go +++ b/vcr/holder/mock.go @@ -152,3 +152,18 @@ func (mr *MockWalletMockRecorder) Remove(ctx, holderDID, credentialID any) *gomo mr.mock.ctrl.T.Helper() 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) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchCredential", ctx, holderDID) + 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 { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchCredential", reflect.TypeOf((*MockWallet)(nil).SearchCredential), ctx, holderDID) +} diff --git a/vcr/holder/sql_wallet.go b/vcr/holder/sql_wallet.go index d837c273e0..c66b6b92da 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 { @@ -139,6 +140,10 @@ 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) Remove(ctx context.Context, holderDID did.DID, credentialID ssi.URI) error { err := h.walletStore.remove(holderDID, credentialID) if err == nil { diff --git a/vcr/holder/sql_wallet_test.go b/vcr/holder/sql_wallet_test.go index 18921129e0..75d4767538 100644 --- a/vcr/holder/sql_wallet_test.go +++ b/vcr/holder/sql_wallet_test.go @@ -21,6 +21,9 @@ package holder import ( "context" "encoding/json" + "testing" + "time" + "github.com/google/uuid" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/audit" @@ -31,8 +34,6 @@ import ( "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" "gorm.io/gorm" - "testing" - "time" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" @@ -227,6 +228,52 @@ func Test_sqlWallet_List(t *testing.T) { }) } +func Test_sqlWallet_SearchCredential(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) + require.NoError(t, err) + require.NotNil(t, list) + assert.Empty(t, list) + }) + 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 + 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) + + // SearchCredential should return all credentials, even though they would be filtered by List + list, err := sut.SearchCredential(ctx, vdr.TestDIDA) + require.NoError(t, err) + require.Len(t, list, 2) + + // 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) { + 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) + + list, err := sut.SearchCredential(ctx, vdr.TestDIDA) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, credA.ID.String(), list[0].ID.String()) + }) +} + func Test_sqlWallet_Diagnostics(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) t.Run("empty wallet", func(t *testing.T) { @@ -395,7 +442,7 @@ func (t testVerifier) IsRevoked(credentialID ssi.URI) (bool, error) { panic("implement me") } -func (t testVerifier) GetRevocation(id ssi.URI) (*credential.Revocation, error) { +func (t testVerifier) GetRevocation(cred vc.VerifiableCredential) (*credential.Revocation, error) { panic("implement me") } diff --git a/vcr/pe/schema/gen/go.mod b/vcr/pe/schema/gen/go.mod index b872a8ae8e..05c918dc65 100644 --- a/vcr/pe/schema/gen/go.mod +++ b/vcr/pe/schema/gen/go.mod @@ -2,6 +2,4 @@ module github.com/nuts-foundation/nuts-node/vcr/pe/gen/schema go 1.21 -require ( - github.com/a-h/generate v0.0.0-20220105161013-96c14dfdfb60 // indirect -) +require github.com/a-h/generate v0.0.0-20220105161013-96c14dfdfb60 diff --git a/vcr/pe/schema/gen/go.sum b/vcr/pe/schema/gen/go.sum index 1f994a18a1..d5af305fbb 100644 --- a/vcr/pe/schema/gen/go.sum +++ b/vcr/pe/schema/gen/go.sum @@ -1,16 +1,2 @@ -github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8= -github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= -github.com/PaesslerAG/gval v1.2.2 h1:Y7iBzhgE09IGTt5QgGQ2IdaYYYOU134YGHBThD+wm9E= -github.com/PaesslerAG/gval v1.2.2/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= -github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= -github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk= -github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= -github.com/PaesslerAG/jsonpath v0.1.2-0.20230323094847-3484786d6f97 h1:XIsQOSBJi/9Bexr+rjUpuYi0IkQ+YqNKKlE7Yt/sw9Q= -github.com/PaesslerAG/jsonpath v0.1.2-0.20230323094847-3484786d6f97/go.mod h1:zTyVtYhYjcHpfCtqnCMxejgp0pEEwb/xJzhn05NrkJk= github.com/a-h/generate v0.0.0-20220105161013-96c14dfdfb60 h1:/rNdG6EuzjwcR1KRFpF+9qWmWh2xIcz84QOeMGr/2L8= github.com/a-h/generate v0.0.0-20220105161013-96c14dfdfb60/go.mod h1:traiLYQ0YD7qUMCdjo6/jSaJRPHXniX4HVs+PhEhYpc= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/vcr/revocation/statuslist2021_issuer.go b/vcr/revocation/statuslist2021_issuer.go index be935a1825..387717e0c0 100644 --- a/vcr/revocation/statuslist2021_issuer.go +++ b/vcr/revocation/statuslist2021_issuer.go @@ -30,6 +30,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/vcr/types" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/google/uuid" @@ -427,7 +428,7 @@ func (cs *StatusList2021) Revoke(ctx context.Context, credentialID ssi.URI, entr err = tx.Create(&revocation).Error if err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { - return errRevoked // already revoked + return types.ErrRevoked // already revoked } return err } diff --git a/vcr/revocation/statuslist2021_verifier.go b/vcr/revocation/statuslist2021_verifier.go index 4071ac54cd..47f514229f 100644 --- a/vcr/revocation/statuslist2021_verifier.go +++ b/vcr/revocation/statuslist2021_verifier.go @@ -30,6 +30,7 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/vcr/log" + "github.com/nuts-foundation/nuts-node/vcr/types" "gorm.io/gorm/clause" ) @@ -96,7 +97,7 @@ func (cs *StatusList2021) Verify(credentialToVerify vc.VerifiableCredential) err return err } if revoked { - return errRevoked + return types.ErrRevoked } } return nil diff --git a/vcr/revocation/types.go b/vcr/revocation/types.go index 66f20d02ce..4bde9dd649 100644 --- a/vcr/revocation/types.go +++ b/vcr/revocation/types.go @@ -52,9 +52,6 @@ var statusList2021CredentialTypeURI = ssi.MustParseURI(StatusList2021CredentialT // errNotFound wraps types.ErrNotFound to clarify which StatusList2021Credential is not found var errNotFound = fmt.Errorf("status list: %w", types.ErrNotFound) -// errRevoked wraps types.ErrRevoked to clarify the source of the error -var errRevoked = fmt.Errorf("status list: %w", types.ErrRevoked) - // errUnsupportedPurpose limits current usage to 'revocation' var errUnsupportedPurpose = errors.New("status list: purpose not supported") diff --git a/vcr/revocation/types_test.go b/vcr/revocation/types_test.go index 91b08c6716..e0a642b5ea 100644 --- a/vcr/revocation/types_test.go +++ b/vcr/revocation/types_test.go @@ -22,10 +22,11 @@ import ( "context" "crypto" "encoding/json" - "github.com/nuts-foundation/nuts-node/vdr/resolver" "testing" "time" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/storage" diff --git a/vcr/verifier/interface.go b/vcr/verifier/interface.go index 768b7a6caa..7f7edbbdfe 100644 --- a/vcr/verifier/interface.go +++ b/vcr/verifier/interface.go @@ -20,10 +20,11 @@ package verifier import ( "errors" - "github.com/nuts-foundation/nuts-node/core" "io" "time" + "github.com/nuts-foundation/nuts-node/core" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/vcr/credential" @@ -40,9 +41,9 @@ type Verifier interface { VerifySignature(credentialToVerify vc.VerifiableCredential, at *time.Time) error // IsRevoked checks if the credential is revoked IsRevoked(credentialID ssi.URI) (bool, error) - // GetRevocation returns the first revocation by credential ID - // Returns an ErrNotFound when the revocation is not in the store - GetRevocation(id ssi.URI) (*credential.Revocation, error) + // GetRevocation returns the first revocation for the given credential. + // If the credential is not revoked, it returns no revocation and no error. + GetRevocation(cred vc.VerifiableCredential) (*credential.Revocation, error) // RegisterRevocation stores the revocation in the store // before storing the revocation gets validated RegisterRevocation(revocation credential.Revocation) error diff --git a/vcr/verifier/mock.go b/vcr/verifier/mock.go index 046ca93093..a56070622f 100644 --- a/vcr/verifier/mock.go +++ b/vcr/verifier/mock.go @@ -45,18 +45,18 @@ func (m *MockVerifier) EXPECT() *MockVerifierMockRecorder { } // GetRevocation mocks base method. -func (m *MockVerifier) GetRevocation(id ssi.URI) (*credential.Revocation, error) { +func (m *MockVerifier) GetRevocation(cred vc.VerifiableCredential) (*credential.Revocation, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetRevocation", id) + ret := m.ctrl.Call(m, "GetRevocation", cred) ret0, _ := ret[0].(*credential.Revocation) ret1, _ := ret[1].(error) return ret0, ret1 } // GetRevocation indicates an expected call of GetRevocation. -func (mr *MockVerifierMockRecorder) GetRevocation(id any) *gomock.Call { +func (mr *MockVerifierMockRecorder) GetRevocation(cred any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRevocation", reflect.TypeOf((*MockVerifier)(nil).GetRevocation), id) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRevocation", reflect.TypeOf((*MockVerifier)(nil).GetRevocation), cred) } // IsRevoked mocks base method. diff --git a/vcr/verifier/verifier.go b/vcr/verifier/verifier.go index 8cf4ba7328..3bb33793ed 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" @@ -114,30 +115,15 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus } // Check revocation status - if credentialToVerify.ID != nil { - revoked, err := v.IsRevoked(*credentialToVerify.ID) - if err != nil { - return err - } - if revoked { - return types.ErrRevoked - } - - } - - // Check the credentialStatus if the credential is revoked - err := v.credentialStatus.Verify(credentialToVerify) + rev, err := v.GetRevocation(credentialToVerify) if err != nil { // soft fail, only return an error when revocation is confirmed and log everything else - if errors.Is(err, types.ErrRevoked) { - return err - } else { - // TODO: what log level - bs, _ := json.Marshal(credentialToVerify) - log.Logger().WithError(err).WithField("credential", string(bs)).Info("CredentialStatus verification failed") - } + bs, _ := json.Marshal(credentialToVerify) + log.Logger().WithError(err).WithField("credential", string(bs)).Info("credential revocation verification failed") + } + if rev != nil { + return types.ErrRevoked } - // Check trust status if !allowUntrusted { for _, t := range credentialToVerify.Type { @@ -195,14 +181,33 @@ func (v *verifier) IsRevoked(credentialID ssi.URI) (bool, error) { return true, nil } -func (v *verifier) GetRevocation(credentialID ssi.URI) (*credential.Revocation, error) { - revocation, err := v.store.GetRevocations(credentialID) - if err != nil { +func (v *verifier) GetRevocation(cred vc.VerifiableCredential) (*credential.Revocation, error) { + if cred.ID != nil { + var rev []*credential.Revocation + rev, err := v.store.GetRevocations(*cred.ID) + if err != nil && !errors.Is(err, ErrNotFound) { + // revocation check error + return nil, err + } + if len(rev) > 0 { + // revoked + return rev[0], nil + } + } + // Check the credentialStatus if the credential is revoked + err := v.credentialStatus.Verify(cred) + if errors.Is(err, types.ErrRevoked) { + // revoked + return &credential.Revocation{ + Issuer: cred.Issuer, + Subject: *cred.ID, + }, nil + } else if err != nil { + // revocation check error return nil, err } - - // GetRevocations returns ErrNotFound for len == 0 - return revocation[0], nil + // no revocation found + return nil, nil } func (v *verifier) RegisterRevocation(revocation credential.Revocation) error { diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 8c60c4b75c..2b6037267f 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" @@ -33,6 +31,10 @@ import ( "testing" "time" + "github.com/nuts-foundation/nuts-node/core/to" + "github.com/nuts-foundation/nuts-node/storage/orm" + "github.com/nuts-foundation/nuts-node/test/pki" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" ssi "github.com/nuts-foundation/go-did" @@ -152,7 +154,7 @@ func TestVerifier_Verify(t *testing.T) { // mock context http.DefaultClient = ts.Client() // newMockContext sets credentialStatus.client to http.DefaultClient ctx := newMockContext(t) - ctx.store.EXPECT().GetRevocations(gomock.Any()).Return([]*credential.Revocation{{}}, ErrNotFound).AnyTimes() + ctx.store.EXPECT().GetRevocations(gomock.Any()).Return([]*credential.Revocation{}, ErrNotFound).AnyTimes() db := storage.NewTestStorageEngine(t).GetSQLDatabase() ctx.verifier.credentialStatus = revocation.NewStatusList2021(db, ts.Client(), "https://example.com") ctx.verifier.credentialStatus.(*revocation.StatusList2021).VerifySignature = func(_ vc.VerifiableCredential, _ *time.Time) error { return nil } // don't check signatures on 'downloaded' StatusList2021Credentials @@ -328,18 +330,6 @@ func TestVerifier_Verify(t *testing.T) { err = ctx.verifier.Verify(*cred, false, true, &validAt) assert.NoError(t, err) }) - t.Run("ok revoked", func(t *testing.T) { - cred, err := buildX509Credential(chain, signingCert, rootCert, signingKey, ura) - assert.NoError(t, err) - ctx := newMockContext(t) - ctx.store.EXPECT().GetRevocations(*cred.ID) - for _, vcType := range cred.Type { - _ = ctx.trustConfig.AddTrust(vcType, cred.Issuer) - } - validAt := time.Now() - err = ctx.verifier.Verify(*cred, false, true, &validAt) - assert.EqualError(t, err, "credential is revoked") - }) t.Run("untrusted", func(t *testing.T) { cred, err := buildX509Credential(chain, signingCert, rootCert, signingKey, ura) assert.NoError(t, err) @@ -798,30 +788,46 @@ func Test_verifier_IsRevoked(t *testing.T) { } func TestVerifier_GetRevocation(t *testing.T) { - rawRevocation, _ := os.ReadFile("../test/ld-revocation.json") - revocation := credential.Revocation{} - assert.NoError(t, json.Unmarshal(rawRevocation, &revocation)) + cred := vc.VerifiableCredential{ + Issuer: ssi.MustParseURI("did:web:example.com"), + ID: to.Ptr(ssi.MustParseURI("http://example.com/credential/123")), + } - t.Run("it returns nil, ErrNotFound if no revocation is found", func(t *testing.T) { - sut := newMockContext(t) - sut.store.EXPECT().GetRevocations(revocation.Subject).Return(nil, ErrNotFound) - result, err := sut.verifier.GetRevocation(revocation.Subject) - assert.Equal(t, ErrNotFound, err) - assert.Nil(t, result) - }) - t.Run("it returns the revocation if found", func(t *testing.T) { - sut := newMockContext(t) - sut.store.EXPECT().GetRevocations(revocation.Subject).Return([]*credential.Revocation{&revocation}, nil) - result, err := sut.verifier.GetRevocation(revocation.Subject) - assert.NoError(t, err) - assert.Equal(t, revocation, *result) + t.Run("did:nuts", func(t *testing.T) { + rawRevocation, _ := os.ReadFile("../test/ld-revocation.json") + expected := credential.Revocation{} + assert.NoError(t, json.Unmarshal(rawRevocation, &expected)) + t.Run("nothing if no revocation is found", func(t *testing.T) { + sut := newMockContext(t) + sut.store.EXPECT().GetRevocations(*cred.ID).Return(nil, ErrNotFound) + result, err := sut.verifier.GetRevocation(cred) + assert.NoError(t, err) + assert.Nil(t, result) + }) + t.Run("it returns the revocation if found", func(t *testing.T) { + sut := newMockContext(t) + sut.store.EXPECT().GetRevocations(*cred.ID).Return([]*credential.Revocation{&expected}, nil) + result, err := sut.verifier.GetRevocation(cred) + assert.NoError(t, err) + assert.Equal(t, expected, *result) + }) + t.Run("it returns the error if the store returns an error", func(t *testing.T) { + sut := newMockContext(t) + sut.store.EXPECT().GetRevocations(*cred.ID).Return(nil, errors.New("foo")) + result, err := sut.verifier.GetRevocation(cred) + assert.EqualError(t, err, "foo") + assert.Nil(t, result) + }) }) - t.Run("it returns the error if the store returns an error", func(t *testing.T) { - sut := newMockContext(t) - sut.store.EXPECT().GetRevocations(revocation.Subject).Return(nil, errors.New("foo")) - result, err := sut.verifier.GetRevocation(revocation.Subject) - assert.EqualError(t, err, "foo") - assert.Nil(t, result) + t.Run("StatusList2021", func(t *testing.T) { + t.Run("not revoked", func(t *testing.T) { + sut := newMockContext(t) + sut.store.EXPECT().GetRevocations(*cred.ID).Return(nil, ErrNotFound) + actual, err := sut.verifier.GetRevocation(cred) + assert.NoError(t, err) + assert.Nil(t, actual) + }) + // revoked test is very hard, and already tested as part of Verify() }) } @@ -848,7 +854,9 @@ func newMockContext(t *testing.T) mockContext { verifierStore := NewMockStore(ctrl) trustConfig := trust.NewConfig(path.Join(io.TestDirectory(t), "trust.yaml")) db := orm.NewTestDatabase(t) + verifier := NewVerifier(verifierStore, didResolver, keyResolver, jsonldManager, trustConfig, revocation.NewStatusList2021(db, nil, ""), nil).(*verifier) + return mockContext{ ctrl: ctrl, verifier: verifier, From b136b2e2f24cd6ed1c1e4309e1734cf5b99c6206 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 11 Feb 2026 13:33:53 +0100 Subject: [PATCH 2/3] fix test --- vcr/api/vcr/v2/api.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vcr/api/vcr/v2/api.go b/vcr/api/vcr/v2/api.go index 97b183b4c1..8d7c21b128 100644 --- a/vcr/api/vcr/v2/api.go +++ b/vcr/api/vcr/v2/api.go @@ -473,20 +473,20 @@ func (w *Wrapper) SearchCredentialsInWallet(ctx context.Context, request SearchC return nil, err } - var creds []vc.VerifiableCredential + var allCreds []vc.VerifiableCredential for _, did := range dids { creds, err := w.VCR.Wallet().SearchCredential(ctx, did) if err != nil { return nil, err } - creds = append(creds, creds...) + allCreds = append(allCreds, creds...) } - searchResults, err := w.vcsWithRevocationsToSearchResults(creds) + searchResults, err := w.vcsWithRevocationsToSearchResults(allCreds) if err != nil { return nil, err } - return SearchCredentialsInWallet200JSONResponse(SearchVCResults{searchResults}), nil + return SearchCredentialsInWallet200JSONResponse(SearchVCResults{VerifiableCredentials: searchResults}), nil } func (w *Wrapper) RemoveCredentialFromWallet(ctx context.Context, request RemoveCredentialFromWalletRequestObject) (RemoveCredentialFromWalletResponseObject, error) { From 2d2fe3173a47d51b272cf0b3cd7ec69ad84cb1e0 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 11 Feb 2026 16:03:47 +0100 Subject: [PATCH 3/3] comment on API --- docs/_static/vcr/vcr_v2.yaml | 4 +++- vcr/api/vcr/v2/generated.go | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/_static/vcr/vcr_v2.yaml b/docs/_static/vcr/vcr_v2.yaml index 6087bc3011..9366c2f263 100644 --- a/docs/_static/vcr/vcr_v2.yaml +++ b/docs/_static/vcr/vcr_v2.yaml @@ -491,7 +491,9 @@ paths: type: string example: 90BC1AE9-752B-432F-ADC3-DD9F9C61843C get: - summary: List all Verifiable Credentials in the holder's wallet. + summary: > + List all Verifiable Credentials in the holder's wallet. It will only return non-expired or non-revoked credentials. + If you want to list all credentials regardless of their validity, use the search API. operationId: getCredentialsInWallet tags: - credential diff --git a/vcr/api/vcr/v2/generated.go b/vcr/api/vcr/v2/generated.go index 520dec6c92..23e163a6a2 100644 --- a/vcr/api/vcr/v2/generated.go +++ b/vcr/api/vcr/v2/generated.go @@ -2933,7 +2933,7 @@ type ServerInterface interface { // Create a new Verifiable Presentation for a set of Verifiable Credentials. // (POST /internal/vcr/v2/holder/vp) CreateVP(ctx echo.Context) error - // List all Verifiable Credentials in the holder's wallet. + // List all Verifiable Credentials in the holder's wallet. It will only return non-expired or non-revoked credentials. If you want to list all credentials regardless of their validity, use the search API. // (GET /internal/vcr/v2/holder/{subjectID}/vc) GetCredentialsInWallet(ctx echo.Context, subjectID string) error // Load a VerifiableCredential into the holders wallet. @@ -3899,7 +3899,7 @@ type StrictServerInterface interface { // 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) - // List all Verifiable Credentials in the holder's wallet. + // List all Verifiable Credentials in the holder's wallet. It will only return non-expired or non-revoked credentials. If you want to list all credentials regardless of their validity, use the search API. // (GET /internal/vcr/v2/holder/{subjectID}/vc) GetCredentialsInWallet(ctx context.Context, request GetCredentialsInWalletRequestObject) (GetCredentialsInWalletResponseObject, error) // Load a VerifiableCredential into the holders wallet.