diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index 62879525f0..fd1b7a7b78 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -40,6 +40,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "net/http" + "net/http/httptest" "net/url" "reflect" "testing" @@ -165,8 +166,12 @@ func TestWrapper_GetSignSessionStatus(t *testing.T) { response, err := ctx.wrapper.GetSignSessionStatus(ctx.audit, sessionObj) - assert.Equal(t, expectedResponse, response) - assert.NoError(t, err) + require.NoError(t, err) + actualResponseJSON := httptest.NewRecorder() + require.NoError(t, response.VisitGetSignSessionStatusResponse(actualResponseJSON)) + expectedResponseJSON := httptest.NewRecorder() + require.NoError(t, expectedResponse.VisitGetSignSessionStatusResponse(expectedResponseJSON)) + assert.JSONEq(t, string(expectedResponseJSON.Body.Bytes()), string(actualResponseJSON.Body.Bytes())) }) t.Run("nok - SigningSessionStatus returns error", func(t *testing.T) { diff --git a/auth/services/oauth/authz_server_test.go b/auth/services/oauth/authz_server_test.go index 33c06ce382..a8f75548d3 100644 --- a/auth/services/oauth/authz_server_test.go +++ b/auth/services/oauth/authz_server_test.go @@ -525,7 +525,7 @@ func TestService_validateAuthorizationCredentials(t *testing.T) { err := ctx.oauthService.validateAuthorizationCredentials(tokenCtx) - assert.EqualError(t, err, "invalid jwt.vcs: cannot unmarshal authorization credential: json: cannot unmarshal string into Go value of type map[string]interface {}") + assert.EqualError(t, err, "invalid jwt.vcs: cannot unmarshal authorization credential: failed to parse token: invalid character '}' looking for beginning of value") }) t.Run("error - jwt.iss <> credentialSubject.ID mismatch", func(t *testing.T) { diff --git a/auth/services/selfsigned/signer.go b/auth/services/selfsigned/signer.go index f7d6b40f52..c4a74f2d5c 100644 --- a/auth/services/selfsigned/signer.go +++ b/auth/services/selfsigned/signer.go @@ -35,6 +35,7 @@ import ( "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "net/url" "time" @@ -119,7 +120,7 @@ func (v *signer) createVP(ctx context.Context, s types.Session, issuanceDate tim } expirationData := issuanceDate.Add(24 * time.Hour) - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Context: []ssi.URI{credential.NutsV1ContextURI}, Type: []ssi.URI{ssi.MustParseURI(credentialType)}, Issuer: issuerID.URI(), @@ -127,7 +128,7 @@ func (v *signer) createVP(ctx context.Context, s types.Session, issuanceDate tim ExpirationDate: &expirationData, CredentialSubject: s.CredentialSubject(), } - verifiableCredential, err := v.vcr.Issuer().Issue(ctx, credentialOptions, false, false) + verifiableCredential, err := v.vcr.Issuer().Issue(ctx, template, issuer.CredentialOptions{}) if err != nil { return nil, fmt.Errorf("issue VC failed: %w", err) } diff --git a/auth/services/selfsigned/signer_test.go b/auth/services/selfsigned/signer_test.go index 11f41fecae..38221ddef2 100644 --- a/auth/services/selfsigned/signer_test.go +++ b/auth/services/selfsigned/signer_test.go @@ -132,7 +132,10 @@ func TestSessionStore_SigningSessionStatus(t *testing.T) { t.Run("status completed returns VP on SigningSessionResult", func(t *testing.T) { mockContext := newMockContext(t) ss := NewSigner(mockContext.vcr, "").(*signer) - mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), false, false).Return(&testVC, nil) + mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), issuer.CredentialOptions{ + Publish: false, + Public: false, + }).Return(&testVC, nil) mockContext.wallet.EXPECT().BuildPresentation(context.TODO(), gomock.Len(1), gomock.Any(), &employer, true).Return(&testVP, nil) sp, err := ss.StartSigningSession(contract.Contract{RawContractText: testContract}, params) @@ -191,16 +194,10 @@ func TestSessionStore_SigningSessionStatus(t *testing.T) { t.Run("correct VC options are passed to issuer", func(t *testing.T) { mockContext := newMockContext(t) ss := NewSigner(mockContext.vcr, "").(*signer) - mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), false, false).DoAndReturn( - func(arg0 interface{}, unsignedCredential interface{}, public interface{}, publish interface{}) (*vc.VerifiableCredential, error) { - isPublic, ok := public.(bool) - isPublished, ok2 := publish.(bool) - credential, ok3 := unsignedCredential.(vc.VerifiableCredential) - require.True(t, ok) - require.True(t, ok2) - require.True(t, ok3) - assert.False(t, isPublic) - assert.False(t, isPublished) + mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), issuer.CredentialOptions{}).DoAndReturn( + func(arg0 interface{}, credential vc.VerifiableCredential, options issuer.CredentialOptions) (*vc.VerifiableCredential, error) { + assert.False(t, options.Public) + assert.False(t, options.Publish) assert.Equal(t, employer.URI(), credential.Issuer) assert.Equal(t, []ssi.URI{ssi.MustParseURI("NutsEmployeeCredential")}, credential.Type) @@ -241,7 +238,10 @@ func TestSessionStore_SigningSessionStatus(t *testing.T) { t.Run("error on VC issuance", func(t *testing.T) { mockContext := newMockContext(t) ss := NewSigner(mockContext.vcr, "").(*signer) - mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), false, false).Return(nil, errors.New("error")) + mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), issuer.CredentialOptions{ + Publish: false, + Public: false, + }).Return(nil, errors.New("error")) sp, err := ss.StartSigningSession(contract.Contract{RawContractText: testContract}, params) require.NoError(t, err) @@ -256,7 +256,10 @@ func TestSessionStore_SigningSessionStatus(t *testing.T) { t.Run("error on building VP", func(t *testing.T) { mockContext := newMockContext(t) ss := NewSigner(mockContext.vcr, "").(*signer) - mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), false, false).Return(&testVC, nil) + mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), issuer.CredentialOptions{ + Publish: false, + Public: false, + }).Return(&testVC, nil) mockContext.wallet.EXPECT().BuildPresentation(context.TODO(), gomock.Len(1), gomock.Any(), &employer, true).Return(nil, errors.New("error")) sp, err := ss.StartSigningSession(contract.Contract{RawContractText: testContract}, params) diff --git a/auth/services/selfsigned/validator_test.go b/auth/services/selfsigned/validator_test.go index 046c23c94e..54c5ee8899 100644 --- a/auth/services/selfsigned/validator_test.go +++ b/auth/services/selfsigned/validator_test.go @@ -91,7 +91,10 @@ func TestSigner_Validator_Roundtrip(t *testing.T) { // #2428: NutsEmployeeCredential does not need to be trusted, but the issuer needs to have a trusted NutsOrganizationCredential (chain of trust). // Issue() automatically trusts the issuer, so untrust it for asserting trust chain behavior - nutsOrgCred, err := vcrContext.VCR.Issuer().Issue(audit.TestContext(), createOrganizationCredential(issuerDID), false, false) + nutsOrgCred, err := vcrContext.VCR.Issuer().Issue(audit.TestContext(), createOrganizationCredential(issuerDID), issuer.CredentialOptions{ + Publish: false, + Public: false, + }) require.NoError(t, err) err = vcrContext.VCR.StoreCredential(*nutsOrgCred, nil) // Need to explicitly store, since we didn't publish it. require.NoError(t, err) @@ -203,7 +206,10 @@ func TestValidator_VerifyVP(t *testing.T) { // Otherwise, the NutsOrganizationCredential is not yet valid or might be expired. return vpValidTime.Add(-1 * time.Hour) } - nutsOrgCred, err := vcrContext.VCR.Issuer().Issue(audit.TestContext(), createOrganizationCredential(didDocument.ID.String()), false, false) + nutsOrgCred, err := vcrContext.VCR.Issuer().Issue(audit.TestContext(), createOrganizationCredential(didDocument.ID.String()), issuer.CredentialOptions{ + Publish: false, + Public: false, + }) require.NoError(t, err) err = vcrContext.VCR.StoreCredential(*nutsOrgCred, &vpValidTime) // Need to explicitly store, since we didn't publish it. require.NoError(t, err) diff --git a/docs/_static/vcr/vcr_v2.yaml b/docs/_static/vcr/vcr_v2.yaml index 052b134bdd..dec7b008f9 100644 --- a/docs/_static/vcr/vcr_v2.yaml +++ b/docs/_static/vcr/vcr_v2.yaml @@ -455,6 +455,13 @@ components: description: RFC3339 time string until when the credential is valid. type: string example: "2012-01-02T12:00:00Z" + format: + description: Proof format for the credential (JSON-LD or JWT). If not set, it defaults to JSON-LD. + default: ldp_vc + type: string + enum: + - ldp_vc + - jwt_vc publishToNetwork: description: | If set, the node publishes this credential to the network. This is the default behaviour. @@ -587,6 +594,13 @@ components: type: string description: Date and time at which proof will expire. If omitted, the proof does not have an end date. example: '2021-12-20T09:00:00Z' + format: + description: Proof format for the presentation (JSON-LD or JWT). If not set, it defaults to JSON-LD. + default: ldp_vp + type: string + enum: + - ldp_vp + - jwt_vp VPVerificationRequest: required: diff --git a/go.mod b/go.mod index 407d92af4f..a88caadddc 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/nats-io/nats-server/v2 v2.10.1 github.com/nats-io/nats.go v1.30.1 github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b - github.com/nuts-foundation/go-did v0.6.5 + github.com/nuts-foundation/go-did v0.6.6-0.20230929063840-997f267c2776 github.com/nuts-foundation/go-leia/v4 v4.0.0 github.com/nuts-foundation/go-stoabs v1.9.0 // check the oapi-codegen tool version in the makefile when upgrading the runtime diff --git a/go.sum b/go.sum index d9ab41d7a7..b3b2957d88 100644 --- a/go.sum +++ b/go.sum @@ -437,6 +437,10 @@ github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b h1:80 github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b/go.mod h1:6YUioYirD6/8IahZkoS4Ypc8xbeJW76Xdk1QKcziNTM= github.com/nuts-foundation/go-did v0.6.5 h1:y2gPygRN1gBeMI9y8OIWwARp8NpHHheqnbpLwCxajFw= github.com/nuts-foundation/go-did v0.6.5/go.mod h1:Jb3IgnO2Zeed970JMIlfjr4g1kvikmgWUJA0EfeDEFE= +github.com/nuts-foundation/go-did v0.6.6-0.20230929062946-723b4514eb2e h1:2KmobyTrKmyhXMR2es3qvVRqaZMGBHg2R3WI7F1rBpE= +github.com/nuts-foundation/go-did v0.6.6-0.20230929062946-723b4514eb2e/go.mod h1:Jb3IgnO2Zeed970JMIlfjr4g1kvikmgWUJA0EfeDEFE= +github.com/nuts-foundation/go-did v0.6.6-0.20230929063840-997f267c2776 h1:BJ2MyIj8U3mxplXzTLUVrUmStqlLVYA1LVrTFUqHZhE= +github.com/nuts-foundation/go-did v0.6.6-0.20230929063840-997f267c2776/go.mod h1:Jb3IgnO2Zeed970JMIlfjr4g1kvikmgWUJA0EfeDEFE= github.com/nuts-foundation/go-leia/v4 v4.0.0 h1:/unYCk18qGG2HWcJK4ld4CaM6k7Tdr0bR1vQd1Jwfcg= github.com/nuts-foundation/go-leia/v4 v4.0.0/go.mod h1:A246dA4nhY99OPCQpG/XbQ/iPyyfSaJchanivuPWpao= github.com/nuts-foundation/go-stoabs v1.9.0 h1:zK+ugfolaJYyBvGwsRuavLVdycXk4Yw/1gI+tz17lWQ= diff --git a/vcr/api/vcr/v2/api.go b/vcr/api/vcr/v2/api.go index a7d9204289..d77368d0d6 100644 --- a/vcr/api/vcr/v2/api.go +++ b/vcr/api/vcr/v2/api.go @@ -24,6 +24,7 @@ import ( "errors" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vdr/resolver" "net/http" @@ -86,27 +87,25 @@ func (w *Wrapper) ResolveStatusCode(err error) int { // IssueVC handles the API request for credential issuing. func (w Wrapper) IssueVC(ctx context.Context, request IssueVCRequestObject) (IssueVCResponseObject, error) { - var ( - publish bool - public bool - ) - - // publish is true by default + options := issuer.CredentialOptions{ + Publish: true, + } if request.Body.PublishToNetwork != nil { - publish = *request.Body.PublishToNetwork - } else { - publish = true + options.Publish = *request.Body.PublishToNetwork + } + if request.Body.Format != nil { + options.Format = string(*request.Body.Format) } // Check param constraints: if request.Body.Visibility == nil || *request.Body.Visibility == "" { - if publish { + if options.Publish { return nil, core.InvalidInputError("visibility must be set when publishing credential") } } else { // visibility is set // Visibility can only be used when publishing - if !publish { + if !options.Publish { return nil, core.InvalidInputError("visibility setting is only allowed when publishing to the network") } // Check if the values are in range @@ -114,7 +113,7 @@ func (w Wrapper) IssueVC(ctx context.Context, request IssueVCRequestObject) (Iss return nil, core.InvalidInputError("invalid value for visibility") } // Set the actual value - public = *request.Body.Visibility == Public + options.Public = *request.Body.Visibility == Public } // Set default context, if not set @@ -136,8 +135,17 @@ func (w Wrapper) IssueVC(ctx context.Context, request IssueVCRequestObject) (Iss if err := json.Unmarshal(rawRequest, &requestedVC); err != nil { return nil, err } + // Copy parsed credential to keep control over what we pass to the issuer, + // (and also makes unit testing easier since vc.VerifiableCredential has unexported fields that can't be set). + template := vc.VerifiableCredential{ + Context: requestedVC.Context, + Type: requestedVC.Type, + Issuer: requestedVC.Issuer, + ExpirationDate: requestedVC.ExpirationDate, + CredentialSubject: requestedVC.CredentialSubject, + } - vcCreated, err := w.VCR.Issuer().Issue(ctx, requestedVC, publish, public) + vcCreated, err := w.VCR.Issuer().Issue(ctx, template, options) if err != nil { return nil, err } @@ -253,6 +261,10 @@ func (w *Wrapper) CreateVP(ctx context.Context, request CreateVPRequestObject) ( presentationOptions.ProofOptions.ProofPurpose = string(purpose) } + if request.Body.Format != nil { + presentationOptions.Format = string(*request.Body.Format) + } + // pass context and type as ssi.URI if request.Body.Context != nil { for _, sc := range *request.Body.Context { diff --git a/vcr/api/vcr/v2/api_test.go b/vcr/api/vcr/v2/api_test.go index 33eb6f98b9..0a417864ed 100644 --- a/vcr/api/vcr/v2/api_test.go +++ b/vcr/api/vcr/v2/api_test.go @@ -68,7 +68,10 @@ func TestWrapper_IssueVC(t *testing.T) { Visibility: &public, } // assert that credential.NutsV1ContextURI is added if the request does not contain @context - testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Eq(expectedRequestedVC), true, true).Return(&expectedRequestedVC, nil) + testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, expectedRequestedVC, issuer.CredentialOptions{ + Publish: true, + Public: true, + }).Return(&expectedRequestedVC, nil) response, err := testContext.client.IssueVC(testContext.requestCtx, IssueVCRequestObject{Body: &request}) @@ -100,9 +103,8 @@ func TestWrapper_IssueVC(t *testing.T) { public := Public request := IssueVCRequest{ - Type: expectedRequestedVC.Type[0].String(), - Issuer: expectedRequestedVC.Issuer.String(), - //CredentialSubject: expectedRequestedVC.CredentialSubject, + Type: expectedRequestedVC.Type[0].String(), + Issuer: expectedRequestedVC.Issuer.String(), Visibility: &public, } @@ -129,7 +131,10 @@ func TestWrapper_IssueVC(t *testing.T) { } expectedVC := vc.VerifiableCredential{} expectedResponse := IssueVC200JSONResponse(expectedVC) - testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), true, false).Return(&expectedVC, nil) + testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), issuer.CredentialOptions{ + Publish: true, + Public: false, + }).Return(&expectedVC, nil) response, err := testContext.client.IssueVC(testContext.requestCtx, IssueVCRequestObject{Body: &request}) @@ -150,7 +155,10 @@ func TestWrapper_IssueVC(t *testing.T) { } expectedVC := vc.VerifiableCredential{} expectedResponse := IssueVC200JSONResponse(expectedVC) - testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), true, true).Return(&expectedVC, nil) + testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), issuer.CredentialOptions{ + Publish: true, + Public: true, + }).Return(&expectedVC, nil) response, err := testContext.client.IssueVC(testContext.requestCtx, IssueVCRequestObject{Body: &request}) @@ -219,7 +227,10 @@ func TestWrapper_IssueVC(t *testing.T) { } expectedVC := vc.VerifiableCredential{} expectedResponse := IssueVC200JSONResponse(expectedVC) - testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), false, false).Return(&expectedVC, nil) + testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), issuer.CredentialOptions{ + Publish: false, + Public: false, + }).Return(&expectedVC, nil) response, err := testContext.client.IssueVC(testContext.requestCtx, IssueVCRequestObject{Body: &request}) @@ -267,7 +278,7 @@ func TestWrapper_IssueVC(t *testing.T) { t.Run(test.name, func(t *testing.T) { testContext := newMockContext(t) - testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, test.err) + testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), gomock.Any()).Return(nil, test.err) _, err := testContext.client.IssueVC(testContext.requestCtx, IssueVCRequestObject{Body: &validIssueRequest}) diff --git a/vcr/api/vcr/v2/generated.go b/vcr/api/vcr/v2/generated.go index 82e78fd22f..fcd74b91b8 100644 --- a/vcr/api/vcr/v2/generated.go +++ b/vcr/api/vcr/v2/generated.go @@ -22,6 +22,12 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) +// Defines values for CreateVPRequestFormat. +const ( + JwtVp CreateVPRequestFormat = "jwt_vp" + LdpVp CreateVPRequestFormat = "ldp_vp" +) + // Defines values for CreateVPRequestProofPurpose. const ( AssertionMethod CreateVPRequestProofPurpose = "assertionMethod" @@ -31,6 +37,12 @@ const ( KeyAgreement CreateVPRequestProofPurpose = "keyAgreement" ) +// Defines values for IssueVCRequestFormat. +const ( + JwtVc IssueVCRequestFormat = "jwt_vc" + LdpVc IssueVCRequestFormat = "ldp_vc" +) + // Defines values for IssueVCRequestVisibility. const ( Private IssueVCRequestVisibility = "private" @@ -54,6 +66,9 @@ type CreateVPRequest struct { // Expires Date and time at which proof will expire. If omitted, the proof does not have an end date. Expires *string `json:"expires,omitempty"` + // Format Proof format for the presentation (JSON-LD or JWT). If not set, it defaults to JSON-LD. + Format *CreateVPRequestFormat `json:"format,omitempty"` + // ProofPurpose The specific intent for the proof, the reason why an entity created it. Acts as a safeguard to prevent the // proof from being misused for a purpose other than the one it was intended for. ProofPurpose *CreateVPRequestProofPurpose `json:"proofPurpose,omitempty"` @@ -68,6 +83,9 @@ type CreateVPRequest struct { VerifiableCredentials []VerifiableCredential `json:"verifiableCredentials"` } +// CreateVPRequestFormat Proof format for the presentation (JSON-LD or JWT). If not set, it defaults to JSON-LD. +type CreateVPRequestFormat string + // CreateVPRequestProofPurpose The specific intent for the proof, the reason why an entity created it. Acts as a safeguard to prevent the // proof from being misused for a purpose other than the one it was intended for. type CreateVPRequestProofPurpose string @@ -93,6 +111,9 @@ type IssueVCRequest struct { // ExpirationDate RFC3339 time string until when the credential is valid. ExpirationDate *string `json:"expirationDate,omitempty"` + // Format Proof format for the credential (JSON-LD or JWT). If not set, it defaults to JSON-LD. + Format *IssueVCRequestFormat `json:"format,omitempty"` + // Issuer DID according to Nuts specification. Issuer string `json:"issuer"` @@ -110,6 +131,9 @@ type IssueVCRequest struct { Visibility *IssueVCRequestVisibility `json:"visibility,omitempty"` } +// IssueVCRequestFormat Proof format for the credential (JSON-LD or JWT). If not set, it defaults to JSON-LD. +type IssueVCRequestFormat string + // IssueVCRequestVisibility When publishToNetwork is true, the credential can be published publicly or privately to the holder. // This field is mandatory if publishToNetwork is true to prevent accidents. It defaults to "private". type IssueVCRequestVisibility string diff --git a/vcr/holder/interface.go b/vcr/holder/interface.go index a0129daa19..0b11ba7e43 100644 --- a/vcr/holder/interface.go +++ b/vcr/holder/interface.go @@ -33,6 +33,11 @@ var VerifiableCredentialLDContextV1 = ssi.MustParseURI("https://www.w3.org/2018/ // VerifiablePresentationLDType holds the JSON-LD type for Verifiable Presentations. var VerifiablePresentationLDType = ssi.MustParseURI("VerifiablePresentation") +const ( + JSONLDPresentationFormat = vc.JSONLDPresentationProofFormat + JWTPresentationFormat = vc.JWTPresentationProofFormat +) + // Wallet holds Verifiable Credentials and can present them. type Wallet interface { core.Diagnosable @@ -62,4 +67,7 @@ type PresentationOptions struct { AdditionalTypes []ssi.URI // ProofOptions contains the options for a specific proof. ProofOptions proof.ProofOptions + // Format contains the requested format for the VerifiablePresentation. If not set, it defaults to JSON-LD. + // Valid options are: ldp_vp or jwt_vp + Format string } diff --git a/vcr/holder/wallet.go b/vcr/holder/wallet.go index 24d2657939..372603dfb0 100644 --- a/vcr/holder/wallet.go +++ b/vcr/holder/wallet.go @@ -24,6 +24,8 @@ import ( "encoding/json" "errors" "fmt" + "github.com/lestrrat-go/jwx/jws" + "github.com/lestrrat-go/jwx/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -36,6 +38,7 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/nuts-foundation/nuts-node/vcr/verifier" "github.com/nuts-foundation/nuts-node/vdr/resolver" + "time" ) const statsShelf = "stats" @@ -90,6 +93,48 @@ func (h wallet) BuildPresentation(ctx context.Context, credentials []vc.Verifiab } } + switch options.Format { + case JWTPresentationFormat: + return h.buildJWTPresentation(ctx, *signerDID, credentials, options, key) + case "": + fallthrough + case JSONLDPresentationFormat: + return h.buildJSONLDPresentation(ctx, credentials, options, key) + default: + return nil, errors.New("unsupported presentation proof format") + } +} + +// buildJWTPresentation builds a JWT presentation according to https://www.w3.org/TR/vc-data-model/#json-web-token +func (h wallet) buildJWTPresentation(ctx context.Context, subjectDID did.DID, credentials []vc.VerifiableCredential, options PresentationOptions, key crypto.Key) (*vc.VerifiablePresentation, error) { + headers := map[string]interface{}{ + jws.TypeKey: "JWT", + } + claims := map[string]interface{}{ + jwt.IssuerKey: subjectDID.String(), + jwt.SubjectKey: subjectDID.String(), + "vp": vc.VerifiablePresentation{ + Context: append([]ssi.URI{VerifiableCredentialLDContextV1}, options.AdditionalContexts...), + Type: append([]ssi.URI{VerifiablePresentationLDType}, options.AdditionalTypes...), + VerifiableCredential: credentials, + }, + } + if options.ProofOptions.Created.IsZero() { + claims[jwt.NotBeforeKey] = time.Now().Unix() + } else { + claims[jwt.NotBeforeKey] = int(options.ProofOptions.Created.Unix()) + } + if options.ProofOptions.Expires != nil { + claims[jwt.ExpirationKey] = int(options.ProofOptions.Expires.Unix()) + } + token, err := h.keyStore.SignJWT(ctx, claims, headers, key) + if err != nil { + return nil, fmt.Errorf("unable to sign JWT presentation: %w", err) + } + return vc.ParseVerifiablePresentation(token) +} + +func (h wallet) buildJSONLDPresentation(ctx context.Context, credentials []vc.VerifiableCredential, options PresentationOptions, key crypto.Key) (*vc.VerifiablePresentation, error) { ldContext := []ssi.URI{VerifiableCredentialLDContextV1, signature.JSONWebSignature2020Context} ldContext = append(ldContext, options.AdditionalContexts...) types := []ssi.URI{VerifiablePresentationLDType} @@ -119,15 +164,8 @@ func (h wallet) BuildPresentation(ctx context.Context, credentials []vc.Verifiab if err != nil { return nil, fmt.Errorf("unable to sign VP with LD proof: %w", err) } - - var signedVP vc.VerifiablePresentation - signedVPData, _ := json.Marshal(signingResult) - err = json.Unmarshal(signedVPData, &signedVP) - if err != nil { - return nil, err - } - - return &signedVP, nil + resultJSON, _ := json.Marshal(signingResult) + return vc.ParseVerifiablePresentation(string(resultJSON)) } func (h wallet) Put(ctx context.Context, credentials ...vc.VerifiableCredential) error { diff --git a/vcr/holder/wallet_test.go b/vcr/holder/wallet_test.go index a2b9dfca02..a4c9d30dfc 100644 --- a/vcr/holder/wallet_test.go +++ b/vcr/holder/wallet_test.go @@ -58,60 +58,137 @@ func TestWallet_BuildPresentation(t *testing.T) { _ = keyStorage.SavePrivateKey(ctx, key.KID(), key.PrivateKey) keyStore := crypto.NewTestCryptoInstance(keyStorage) - options := PresentationOptions{ProofOptions: proof.ProofOptions{}} + t.Run("JSON-LD", func(t *testing.T) { + t.Run("is default", func(t *testing.T) { + ctrl := gomock.NewController(t) - t.Run("ok - one VC", func(t *testing.T) { - ctrl := gomock.NewController(t) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) - keyResolver := resolver.NewMockKeyResolver(ctrl) - keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) + w := New(keyResolver, keyStore, nil, jsonldManager, nil) - w := New(keyResolver, keyStore, nil, jsonldManager, nil) + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, PresentationOptions{}, &testDID, false) - resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, false) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, JSONLDPresentationFormat, result.Format()) + }) + t.Run("ok - one VC", func(t *testing.T) { + ctrl := gomock.NewController(t) - require.NoError(t, err) - assert.NotNil(t, resultingPresentation) - }) - t.Run("ok - custom options", func(t *testing.T) { - ctrl := gomock.NewController(t) - specialType := ssi.MustParseURI("SpecialPresentation") - options := PresentationOptions{ - AdditionalContexts: []ssi.URI{credential.NutsV1ContextURI}, - AdditionalTypes: []ssi.URI{specialType}, - ProofOptions: proof.ProofOptions{ - ProofPurpose: "authentication", - }, - } - keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) - keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) + w := New(keyResolver, keyStore, nil, jsonldManager, nil) - w := New(keyResolver, keyStore, nil, jsonldManager, nil) + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, PresentationOptions{Format: JSONLDPresentationFormat}, &testDID, false) - resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, false) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, JSONLDPresentationFormat, result.Format()) + }) + t.Run("ok - custom options", func(t *testing.T) { + ctrl := gomock.NewController(t) + specialType := ssi.MustParseURI("SpecialPresentation") + options := PresentationOptions{ + AdditionalContexts: []ssi.URI{credential.NutsV1ContextURI}, + AdditionalTypes: []ssi.URI{specialType}, + ProofOptions: proof.ProofOptions{ + ProofPurpose: "authentication", + }, + Format: JSONLDPresentationFormat, + } + keyResolver := resolver.NewMockKeyResolver(ctrl) - require.NoError(t, err) - require.NotNil(t, resultingPresentation) - assert.True(t, resultingPresentation.IsType(specialType)) - assert.True(t, resultingPresentation.ContainsContext(credential.NutsV1ContextURI)) - proofs, _ := resultingPresentation.Proofs() - require.Len(t, proofs, 1) - assert.Equal(t, proofs[0].ProofPurpose, "authentication") + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) + + w := New(keyResolver, keyStore, nil, jsonldManager, nil) + + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, false) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsType(specialType)) + assert.True(t, result.ContainsContext(credential.NutsV1ContextURI)) + proofs, _ := result.Proofs() + require.Len(t, proofs, 1) + assert.Equal(t, proofs[0].ProofPurpose, "authentication") + assert.Equal(t, JSONLDPresentationFormat, result.Format()) + }) + t.Run("ok - multiple VCs", func(t *testing.T) { + ctrl := gomock.NewController(t) + + keyResolver := resolver.NewMockKeyResolver(ctrl) + + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDA.URI(), key.Public(), nil) + + w := New(keyResolver, keyStore, nil, jsonldManager, nil) + + resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential, testCredential}, PresentationOptions{Format: JSONLDPresentationFormat}, &testDID, false) + + assert.NoError(t, err) + assert.NotNil(t, resultingPresentation) + }) }) - t.Run("ok - multiple VCs", func(t *testing.T) { - ctrl := gomock.NewController(t) + t.Run("JWT", func(t *testing.T) { + options := PresentationOptions{Format: JWTPresentationFormat} + t.Run("ok - one VC", func(t *testing.T) { + ctrl := gomock.NewController(t) - keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) - keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDA.URI(), key.Public(), nil) + w := New(keyResolver, keyStore, nil, jsonldManager, nil) - w := New(keyResolver, keyStore, nil, jsonldManager, nil) + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, false) - resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential, testCredential}, options, &testDID, false) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JWTPresentationFormat, result.Format()) + assert.NotNil(t, result.JWT()) + }) + t.Run("ok - multiple VCs", func(t *testing.T) { + ctrl := gomock.NewController(t) - assert.NoError(t, err) - assert.NotNil(t, resultingPresentation) + keyResolver := resolver.NewMockKeyResolver(ctrl) + + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDA.URI(), key.Public(), nil) + + w := New(keyResolver, keyStore, nil, jsonldManager, nil) + + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential, testCredential}, options, &testDID, false) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JWTPresentationFormat, result.Format()) + assert.NotNil(t, result.JWT()) + }) + t.Run("optional proof options", func(t *testing.T) { + exp := time.Now().Local().Truncate(time.Second) + options := PresentationOptions{ + Format: JWTPresentationFormat, + ProofOptions: proof.ProofOptions{ + Expires: &exp, + Created: exp.Add(-1 * time.Hour), + }, + } + + ctrl := gomock.NewController(t) + + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) + + w := New(keyResolver, keyStore, nil, jsonldManager, nil) + + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, false) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JWTPresentationFormat, result.Format()) + assert.NotNil(t, result.JWT()) + assert.Equal(t, *options.ProofOptions.Expires, result.JWT().Expiration().Local()) + assert.Equal(t, options.ProofOptions.Created, result.JWT().NotBefore().Local()) + }) }) t.Run("validation", func(t *testing.T) { created := time.Now() @@ -149,6 +226,22 @@ func TestWallet_BuildPresentation(t *testing.T) { assert.EqualError(t, err, "invalid credential (id="+testCredential.ID.String()+"): failed") assert.Nil(t, resultingPresentation) }) + t.Run("unsupported format", func(t *testing.T) { + ctrl := gomock.NewController(t) + + keyResolver := resolver.NewMockKeyResolver(ctrl) + mockVerifier := verifier.NewMockVerifier(ctrl) + mockVerifier.EXPECT().Validate(gomock.Any(), gomock.Any()) + + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) + + w := New(keyResolver, keyStore, mockVerifier, jsonldManager, nil) + + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, PresentationOptions{Format: "paper"}, &testDID, true) + + assert.EqualError(t, err, "unsupported presentation proof format") + assert.Nil(t, result) + }) }) t.Run("deriving signer from VCs", func(t *testing.T) { options := PresentationOptions{ProofOptions: proof.ProofOptions{}} diff --git a/vcr/issuer/interface.go b/vcr/issuer/interface.go index f6e55c002a..b6ecf4c7cb 100644 --- a/vcr/issuer/interface.go +++ b/vcr/issuer/interface.go @@ -47,9 +47,7 @@ type keyResolver interface { // Issuer is a role in the network for a party who issues credentials about a subject to a holder. type Issuer interface { // Issue issues a credential by signing an unsigned credential. - // The publish param indicates if the credendential should be published to the network. - // The public param instructs the Publisher to publish the param with a certain visibility. - Issue(ctx context.Context, unsignedCredential vc.VerifiableCredential, publish, public bool) (*vc.VerifiableCredential, error) + Issue(ctx context.Context, template vc.VerifiableCredential, options CredentialOptions) (*vc.VerifiableCredential, error) // Revoke revokes a credential by the provided type. // It requires access to the private key of the issuer which will be used to sign the revocation. // It returns an error when the credential is not issued by this node or is already revoked. @@ -86,3 +84,19 @@ type CredentialSearcher interface { // If the passed context is empty, it'll not be part of the search query on the DB. SearchCredential(credentialType ssi.URI, issuer did.DID, subject *ssi.URI) ([]vc.VerifiableCredential, error) } + +const ( + JSONLDCredentialFormat = vc.JSONLDCredentialProofFormat + JWTCredentialFormat = vc.JWTCredentialsProofFormat +) + +// CredentialOptions specifies options for issuing a credential. +type CredentialOptions struct { + // Format specifies the proof format for the issued credential. If not set, it defaults to JSON-LD. + // Valid options are: ldp_vc or jwt_vc + Format string + // Publish param indicates if the credential should be published to the network. + Publish bool + // Public param instructs the Publisher to publish the param with a certain visibility. + Public bool +} diff --git a/vcr/issuer/issuer.go b/vcr/issuer/issuer.go index 100c6062e0..18727c3e90 100644 --- a/vcr/issuer/issuer.go +++ b/vcr/issuer/issuer.go @@ -21,7 +21,10 @@ package issuer import ( "context" "encoding/json" + "errors" "fmt" + "github.com/lestrrat-go/jwx/jws" + "github.com/lestrrat-go/jwx/jwt" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr/resolver" "time" @@ -89,8 +92,8 @@ type issuer struct { // Issue creates a new credential, signs, stores it. // If publish is true, it publishes the credential to the network using the configured Publisher // Use the public flag to pass the visibility settings to the Publisher. -func (i issuer) Issue(ctx context.Context, credentialOptions vc.VerifiableCredential, publish, public bool) (*vc.VerifiableCredential, error) { - createdVC, err := i.buildVC(ctx, credentialOptions) +func (i issuer) Issue(ctx context.Context, template vc.VerifiableCredential, options CredentialOptions) (*vc.VerifiableCredential, error) { + createdVC, err := i.buildVC(ctx, template, options) if err != nil { return nil, err } @@ -123,10 +126,10 @@ func (i issuer) Issue(ctx context.Context, credentialOptions vc.VerifiableCreden return nil, fmt.Errorf("unable to store the issued credential: %w", err) } - if publish { + if options.Publish { // Try to issue over OpenID4VCI if it's enabled and if the credential is not public // (public credentials are always published on the network). - if i.openidHandlerFn != nil && !public { + if i.openidHandlerFn != nil && !options.Public { success, err := i.issueUsingOpenID4VCI(ctx, *createdVC) if err != nil { // An error occurred, but it's not because the wallet/issuer doesn't support OpenID4VCI. @@ -145,7 +148,7 @@ func (i issuer) Issue(ctx context.Context, credentialOptions vc.VerifiableCreden Info("Wallet or issuer does not support OpenID4VCI, fallback to publish over Nuts network") } } - if err := i.networkPublisher.PublishCredential(ctx, *createdVC, public); err != nil { + if err := i.networkPublisher.PublishCredential(ctx, *createdVC, options.Public); err != nil { return nil, fmt.Errorf("unable to publish the issued credential: %w", err) } } @@ -179,12 +182,12 @@ func (i issuer) issueUsingOpenID4VCI(ctx context.Context, credential vc.Verifiab return true, i.vcrStore.StoreCredential(credential, nil) } -func (i issuer) buildVC(ctx context.Context, credentialOptions vc.VerifiableCredential) (*vc.VerifiableCredential, error) { - if len(credentialOptions.Type) != 1 { +func (i issuer) buildVC(ctx context.Context, template vc.VerifiableCredential, options CredentialOptions) (*vc.VerifiableCredential, error) { + if len(template.Type) != 1 { return nil, core.InvalidInputError("can only issue credential with 1 type") } - issuerDID, err := did.ParseDID(credentialOptions.Issuer.String()) + issuerDID, err := did.ParseDID(template.Issuer.String()) if err != nil { return nil, fmt.Errorf("failed to parse issuer: %w", err) } @@ -201,13 +204,16 @@ func (i issuer) buildVC(ctx context.Context, credentialOptions vc.VerifiableCred credentialID := ssi.MustParseURI(fmt.Sprintf("%s#%s", issuerDID.String(), uuid.New().String())) unsignedCredential := vc.VerifiableCredential{ - Context: credentialOptions.Context, + Context: template.Context, ID: &credentialID, - Type: credentialOptions.Type, - CredentialSubject: credentialOptions.CredentialSubject, - Issuer: credentialOptions.Issuer, - ExpirationDate: credentialOptions.ExpirationDate, - IssuanceDate: TimeFunc(), + Type: template.Type, + CredentialSubject: template.CredentialSubject, + Issuer: template.Issuer, + ExpirationDate: template.ExpirationDate, + IssuanceDate: template.IssuanceDate, + } + if unsignedCredential.IssuanceDate.IsZero() { + unsignedCredential.IssuanceDate = TimeFunc() } if !unsignedCredential.ContainsContext(vc.VCContextV1URI()) { unsignedCredential.Context = append(unsignedCredential.Context, vc.VCContextV1URI()) @@ -218,28 +224,63 @@ func (i issuer) buildVC(ctx context.Context, credentialOptions vc.VerifiableCred unsignedCredential.Type = append(unsignedCredential.Type, defaultType) } + switch options.Format { + case JWTCredentialFormat: + return i.buildJWTCredential(ctx, unsignedCredential, key) + case "": + fallthrough + case JSONLDCredentialFormat: + return i.buildJSONLDCredential(ctx, unsignedCredential, key) + default: + return nil, errors.New("unsupported credential proof format") + } +} + +func (i issuer) buildJWTCredential(ctx context.Context, template vc.VerifiableCredential, key crypto.Key) (*vc.VerifiableCredential, error) { + subjectDID, err := template.SubjectDID() + if err != nil { + return nil, err + } + headers := map[string]interface{}{ + jws.TypeKey: "JWT", + } + claims := map[string]interface{}{ + jwt.NotBeforeKey: template.IssuanceDate, + jwt.IssuerKey: template.Issuer.String(), + jwt.SubjectKey: subjectDID.String(), + "vc": vc.VerifiableCredential{ + Context: template.Context, + Type: template.Type, + CredentialSubject: template.CredentialSubject, + }, + } + if template.ID != nil { + claims[jwt.JwtIDKey] = template.ID.String() + } + if template.ExpirationDate != nil { + claims[jwt.ExpirationKey] = *template.ExpirationDate + } + token, err := i.keyStore.SignJWT(ctx, claims, headers, key) + if err != nil { + return nil, fmt.Errorf("unable to sign JWT credential: %w", err) + } + return vc.ParseVerifiableCredential(token) +} + +func (i issuer) buildJSONLDCredential(ctx context.Context, unsignedCredential vc.VerifiableCredential, key crypto.Key) (*vc.VerifiableCredential, error) { credentialAsMap := map[string]interface{}{} b, _ := json.Marshal(unsignedCredential) _ = json.Unmarshal(b, &credentialAsMap) - // Set created date to the issuanceDate if set - created := TimeFunc() - if !credentialOptions.IssuanceDate.IsZero() { - created = credentialOptions.IssuanceDate - } - proofOptions := proof.ProofOptions{Created: created} + proofOptions := proof.ProofOptions{Created: unsignedCredential.IssuanceDate} webSig := signature.JSONWebSignature2020{ContextLoader: i.jsonldManager.DocumentLoader(), Signer: i.keyStore} signingResult, err := proof.NewLDProof(proofOptions).Sign(ctx, credentialAsMap, webSig, key) if err != nil { return nil, err } - - b, _ = json.Marshal(signingResult) - signedCredential := &vc.VerifiableCredential{} - _ = json.Unmarshal(b, signedCredential) - - return signedCredential, nil + credentialJSON, _ := json.Marshal(signingResult) + return vc.ParseVerifiableCredential(string(credentialJSON)) } func (i issuer) Revoke(ctx context.Context, credentialID ssi.URI) (*credential.Revocation, error) { diff --git a/vcr/issuer/issuer_test.go b/vcr/issuer/issuer_test.go index ab5f4b3569..f403cbc866 100644 --- a/vcr/issuer/issuer_test.go +++ b/vcr/issuer/issuer_test.go @@ -20,6 +20,7 @@ package issuer import ( "context" + crypt "crypto" "encoding/json" "errors" "fmt" @@ -54,57 +55,110 @@ func Test_issuer_buildVC(t *testing.T) { issuerDID, _ := did.ParseDID(issuerID.String()) ctx := audit.TestContext() - t.Run("it builds and signs a VC", func(t *testing.T) { - ctrl := gomock.NewController(t) - kid := "did:nuts:123#abc" + const kid = "did:nuts:123#abc" + const subjectDID = "did:nuts:456" + schemaOrgContext := ssi.MustParseURI("https://schema.org") + issuance, err := time.Parse(time.RFC3339, "2022-01-02T12:00:00Z") + require.NoError(t, err) + + expirationDate := issuance.Add(time.Hour) + template := vc.VerifiableCredential{ + Context: []ssi.URI{schemaOrgContext}, + Type: []ssi.URI{credentialType}, + Issuer: issuerID, + IssuanceDate: issuance, + ExpirationDate: &expirationDate, + CredentialSubject: []interface{}{map[string]interface{}{ + "id": subjectDID, + }}, + } + keyStore := crypto.NewMemoryCryptoInstance() + signingKey, err := keyStore.New(ctx, func(key crypt.PublicKey) (string, error) { + return kid, nil + }) + require.NoError(t, err) - keyResolverMock := NewMockkeyResolver(ctrl) - keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(crypto.NewTestKey(kid), nil) - jsonldManager := jsonld.NewTestJSONLDManager(t) - sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: crypto.NewMemoryCryptoInstance()} - schemaOrgContext := ssi.MustParseURI("https://schema.org") + t.Run("JSON-LD", func(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctrl := gomock.NewController(t) + keyResolverMock := NewMockkeyResolver(ctrl) + keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(signingKey, nil) + jsonldManager := jsonld.NewTestJSONLDManager(t) + sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: keyStore} - issuance, err := time.Parse(time.RFC3339, "2022-01-02T12:00:00Z") - assert.NoError(t, err) + result, err := sut.buildVC(ctx, template, CredentialOptions{Format: JSONLDCredentialFormat}) + require.NoError(t, err) + require.NotNil(t, result) + assert.Contains(t, result.Type, credentialType, "expected vc to be of right type") + assert.Equal(t, JSONLDCredentialFormat, result.Format()) + assert.Equal(t, issuerID.String(), result.Issuer.String(), "expected correct issuer") + assert.Contains(t, result.Context, schemaOrgContext) + assert.Contains(t, result.Context, vc.VCContextV1URI()) + // Assert proof + proofs, _ := result.Proofs() + assert.Equal(t, kid, proofs[0].VerificationMethod.String(), "expected to be signed with the kid") + assert.Equal(t, issuance, proofs[0].Created) + }) + t.Run("is default", func(t *testing.T) { + ctrl := gomock.NewController(t) - credentialOptions := vc.VerifiableCredential{ - Context: []ssi.URI{schemaOrgContext}, - Type: []ssi.URI{credentialType}, - Issuer: issuerID, - IssuanceDate: issuance, - CredentialSubject: []interface{}{map[string]interface{}{ - "id": "did:nuts:456", - }}, - } - result, err := sut.buildVC(ctx, credentialOptions) - require.NoError(t, err) - require.NotNil(t, result) - assert.Contains(t, result.Type, credentialType, "expected vc to be of right type") - proofs, _ := result.Proofs() - assert.Equal(t, kid, proofs[0].VerificationMethod.String(), "expected to be signed with the kid") - assert.Equal(t, issuerID.String(), result.Issuer.String(), "expected correct issuer") - assert.Contains(t, result.Context, schemaOrgContext) - assert.Contains(t, result.Context, vc.VCContextV1URI()) - assert.Equal(t, issuance, proofs[0].Created) + keyResolverMock := NewMockkeyResolver(ctrl) + keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(signingKey, nil) + jsonldManager := jsonld.NewTestJSONLDManager(t) + sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: keyStore} + + result, err := sut.buildVC(ctx, template, CredentialOptions{}) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JSONLDCredentialFormat, result.Format()) + }) + }) + t.Run("JWT", func(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctrl := gomock.NewController(t) + keyResolverMock := NewMockkeyResolver(ctrl) + keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(signingKey, nil) + jsonldManager := jsonld.NewTestJSONLDManager(t) + sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: keyStore} + + result, err := sut.buildVC(ctx, template, CredentialOptions{Format: JWTCredentialFormat}) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JWTCredentialFormat, result.Format()) + assert.Contains(t, result.Type, credentialType, "expected vc to be of right type") + assert.Contains(t, result.Context, schemaOrgContext) + assert.Contains(t, result.Context, vc.VCContextV1URI()) + assert.Equal(t, template.IssuanceDate.Local(), result.IssuanceDate.Local()) + assert.Equal(t, template.ExpirationDate.Local(), result.ExpirationDate.Local()) + assert.Equal(t, template.Issuer, result.Issuer) + assert.Equal(t, template.CredentialSubject, result.CredentialSubject) + assert.Empty(t, result.Proof) + // Assert JWT + require.NotNil(t, result.JWT()) + assert.Equal(t, subjectDID, result.JWT().Subject()) + assert.Equal(t, result.IssuanceDate, result.JWT().NotBefore()) + assert.Equal(t, *result.ExpirationDate, result.JWT().Expiration()) + assert.Equal(t, result.ID.String(), result.JWT().JwtID()) + }) }) t.Run("it does not add the default context twice", func(t *testing.T) { ctrl := gomock.NewController(t) - kid := "did:nuts:123#abc" keyResolverMock := NewMockkeyResolver(ctrl) - keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(crypto.NewTestKey(kid), nil) + keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(signingKey, nil) jsonldManager := jsonld.NewTestJSONLDManager(t) - sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: crypto.NewMemoryCryptoInstance()} + sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: keyStore} - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Context: []ssi.URI{vc.VCContextV1URI()}, Type: []ssi.URI{credentialType}, Issuer: issuerID, IssuanceDate: time.Now(), } - result, err := sut.buildVC(ctx, credentialOptions) + result, err := sut.buildVC(ctx, template, CredentialOptions{}) require.NoError(t, err) require.NotNil(t, result) @@ -116,10 +170,10 @@ func Test_issuer_buildVC(t *testing.T) { t.Run("wrong amount of credential types", func(t *testing.T) { sut := issuer{} - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Type: []ssi.URI{}, } - result, err := sut.buildVC(ctx, credentialOptions) + result, err := sut.buildVC(ctx, template, CredentialOptions{}) assert.ErrorIs(t, err, core.InvalidInputError("can only issue credential with 1 type")) assert.Nil(t, result) @@ -128,14 +182,27 @@ func Test_issuer_buildVC(t *testing.T) { t.Run("missing issuer", func(t *testing.T) { sut := issuer{} - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Type: []ssi.URI{credentialType}, } - result, err := sut.buildVC(ctx, credentialOptions) + result, err := sut.buildVC(ctx, template, CredentialOptions{}) assert.ErrorIs(t, err, did.ErrInvalidDID) assert.Nil(t, result) }) + t.Run("unsupported proof format", func(t *testing.T) { + ctrl := gomock.NewController(t) + + keyResolverMock := NewMockkeyResolver(ctrl) + keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(signingKey, nil) + jsonldManager := jsonld.NewTestJSONLDManager(t) + sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: keyStore} + + result, err := sut.buildVC(ctx, template, CredentialOptions{Format: "paper"}) + + assert.EqualError(t, err, "unsupported credential proof format") + assert.Nil(t, result) + }) }) t.Run("error - returned from used services", func(t *testing.T) { @@ -146,11 +213,11 @@ func Test_issuer_buildVC(t *testing.T) { keyResolverMock.EXPECT().ResolveAssertionKey(ctx, *issuerDID).Return(nil, errors.New("b00m!")) sut := issuer{keyResolver: keyResolverMock} - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Type: []ssi.URI{credentialType}, Issuer: issuerID, } - _, err := sut.buildVC(ctx, credentialOptions) + _, err := sut.buildVC(ctx, template, CredentialOptions{}) assert.EqualError(t, err, "failed to sign credential: could not resolve an assertionKey for issuer: b00m!") }) @@ -161,11 +228,11 @@ func Test_issuer_buildVC(t *testing.T) { keyResolverMock.EXPECT().ResolveAssertionKey(ctx, *issuerDID).Return(nil, resolver.ErrNotFound) sut := issuer{keyResolver: keyResolverMock} - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Type: []ssi.URI{credentialType}, Issuer: issuerID, } - _, err := sut.buildVC(ctx, credentialOptions) + _, err := sut.buildVC(ctx, template, CredentialOptions{}) assert.ErrorIs(t, err, core.InvalidInputError("failed to sign credential: could not resolve an assertionKey for issuer: unable to find the DID document")) }) }) @@ -177,7 +244,7 @@ func Test_issuer_Issue(t *testing.T) { issuerKeyID := issuerDID.String() + "#abc" holderDID := did.MustParseDID("did:nuts:456") - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Context: []ssi.URI{credential.NutsV1ContextURI}, Type: []ssi.URI{credentialType}, Issuer: issuerDID.URI(), @@ -202,7 +269,10 @@ func Test_issuer_Issue(t *testing.T) { keyStore: crypto.NewMemoryCryptoInstance(), } - result, err := sut.Issue(ctx, credentialOptions, false, true) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: false, + Public: true, + }) require.NoError(t, err) assert.Contains(t, result.Type, credentialType, "expected vc to be of right type") proofs, _ := result.Proofs() @@ -244,7 +314,10 @@ func Test_issuer_Issue(t *testing.T) { networkPublisher: publisher, } - result, err := sut.Issue(ctx, credentialOptions, true, false) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: true, + Public: false, + }) require.NoError(t, err) assert.NotNil(t, result) @@ -266,7 +339,10 @@ func Test_issuer_Issue(t *testing.T) { networkPublisher: publisher, } - result, err := sut.Issue(ctx, credentialOptions, true, false) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: true, + Public: false, + }) require.NoError(t, err) assert.NotNil(t, result) @@ -291,7 +367,10 @@ func Test_issuer_Issue(t *testing.T) { networkPublisher: publisher, } - result, err := sut.Issue(ctx, credentialOptions, true, false) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: true, + Public: false, + }) require.NoError(t, err) assert.NotNil(t, result) @@ -324,7 +403,10 @@ func Test_issuer_Issue(t *testing.T) { vcrStore: vcrStore, } - result, err := sut.Issue(ctx, credentialOptions, true, false) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: true, + Public: false, + }) require.NoError(t, err) assert.NotNil(t, result) @@ -346,7 +428,10 @@ func Test_issuer_Issue(t *testing.T) { keyStore: crypto.NewMemoryCryptoInstance(), } - result, err := sut.Issue(ctx, credentialOptions, false, true) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: false, + Public: true, + }) assert.EqualError(t, err, "unable to store the issued credential: b00m!") assert.Nil(t, result) }) @@ -366,7 +451,10 @@ func Test_issuer_Issue(t *testing.T) { keyStore: crypto.NewMemoryCryptoInstance(), } - result, err := sut.Issue(ctx, credentialOptions, true, true) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: true, + Public: true, + }) assert.EqualError(t, err, "unable to publish the issued credential: b00m!") assert.Nil(t, result) }) @@ -379,7 +467,10 @@ func Test_issuer_Issue(t *testing.T) { Issuer: issuerDID.URI(), } - result, err := sut.Issue(ctx, credentialOptions, true, true) + result, err := sut.Issue(ctx, credentialOptions, CredentialOptions{ + Publish: true, + Public: true, + }) assert.EqualError(t, err, "can only issue credential with 1 type") assert.Nil(t, result) @@ -393,12 +484,15 @@ func Test_issuer_Issue(t *testing.T) { mockStore := NewMockStore(ctrl) sut := issuer{keyResolver: keyResolverMock, store: mockStore, jsonldManager: jsonldManager, keyStore: crypto.NewMemoryCryptoInstance()} - invalidCred := credentialOptions + invalidCred := template invalidCred.CredentialSubject = []interface{}{ map[string]interface{}{"foo": "bar"}, } - result, err := sut.Issue(ctx, invalidCred, true, true) + result, err := sut.Issue(ctx, invalidCred, CredentialOptions{ + Publish: true, + Public: true, + }) assert.EqualError(t, err, "validation failed: invalid property: Dropping property that did not expand into an absolute IRI or keyword.") assert.Nil(t, result) }) diff --git a/vcr/issuer/leia_store_test.go b/vcr/issuer/leia_store_test.go index af7d8162f2..d12a8b6ace 100644 --- a/vcr/issuer/leia_store_test.go +++ b/vcr/issuer/leia_store_test.go @@ -88,51 +88,52 @@ func TestLeiaIssuerStore_StoreCredential(t *testing.T) { } func Test_leiaStore_StoreAndSearchCredential(t *testing.T) { - vcToStore := vc.VerifiableCredential{} - _ = json.Unmarshal([]byte(jsonld.TestCredential), &vcToStore) + expected := vc.VerifiableCredential{} + _ = json.Unmarshal([]byte(jsonld.TestCredential), &expected) + expectedJSON, _ := expected.MarshalJSON() t.Run("store", func(t *testing.T) { sut := newStore(t) - err := sut.StoreCredential(vcToStore) + err := sut.StoreCredential(expected) assert.NoError(t, err) t.Run("and search", func(t *testing.T) { - issuerDID, _ := did.ParseDID(vcToStore.Issuer.String()) + issuerDID, _ := did.ParseDID(expected.Issuer.String()) subjectID := ssi.MustParseURI("did:nuts:GvkzxsezHvEc8nGhgz6Xo3jbqkHwswLmWw3CYtCm7hAW") t.Run("for all issued credentials for a issuer", func(t *testing.T) { - res, err := sut.SearchCredential(vcToStore.Type[0], *issuerDID, nil) + res, err := sut.SearchCredential(expected.Type[0], *issuerDID, nil) assert.NoError(t, err) require.Len(t, res, 1) foundVC := res[0] - assert.Equal(t, vcToStore, foundVC) + require.NoError(t, err) + foundJSON, _ := foundVC.MarshalJSON() + assert.JSONEq(t, string(expectedJSON), string(foundJSON)) }) t.Run("for all issued credentials for a issuer and subject", func(t *testing.T) { - res, err := sut.SearchCredential(vcToStore.Type[0], *issuerDID, &subjectID) + res, err := sut.SearchCredential(expected.Type[0], *issuerDID, &subjectID) assert.NoError(t, err) require.Len(t, res, 1) - - foundVC := res[0] - assert.Equal(t, vcToStore, foundVC) + foundJSON, _ := res[0].MarshalJSON() + assert.JSONEq(t, string(expectedJSON), string(foundJSON)) }) t.Run("without context", func(t *testing.T) { - res, err := sut.SearchCredential(vcToStore.Type[0], *issuerDID, nil) + res, err := sut.SearchCredential(expected.Type[0], *issuerDID, nil) assert.NoError(t, err) require.Len(t, res, 1) - - foundVC := res[0] - assert.Equal(t, vcToStore, foundVC) + foundJSON, _ := res[0].MarshalJSON() + assert.JSONEq(t, string(expectedJSON), string(foundJSON)) }) t.Run("no results", func(t *testing.T) { t.Run("unknown issuer", func(t *testing.T) { unknownIssuerDID, _ := did.ParseDID("did:nuts:123") - res, err := sut.SearchCredential(vcToStore.Type[0], *unknownIssuerDID, nil) + res, err := sut.SearchCredential(expected.Type[0], *unknownIssuerDID, nil) assert.NoError(t, err) require.Len(t, res, 0) }) @@ -146,7 +147,7 @@ func Test_leiaStore_StoreAndSearchCredential(t *testing.T) { t.Run("unknown subject", func(t *testing.T) { unknownSubject := ssi.MustParseURI("did:nuts:unknown") - res, err := sut.SearchCredential(vcToStore.Type[0], *issuerDID, &unknownSubject) + res, err := sut.SearchCredential(expected.Type[0], *issuerDID, &unknownSubject) assert.NoError(t, err) require.Len(t, res, 0) }) @@ -158,22 +159,24 @@ func Test_leiaStore_StoreAndSearchCredential(t *testing.T) { } func Test_leiaStore_GetCredential(t *testing.T) { - vcToGet := vc.VerifiableCredential{} - _ = json.Unmarshal([]byte(jsonld.TestCredential), &vcToGet) + expected := vc.VerifiableCredential{} + _ = json.Unmarshal([]byte(jsonld.TestCredential), &expected) + expectedJSON, _ := expected.MarshalJSON() t.Run("with a known credential", func(t *testing.T) { store := newStore(t) - assert.NoError(t, store.StoreCredential(vcToGet)) + assert.NoError(t, store.StoreCredential(expected)) t.Run("it finds the credential by id", func(t *testing.T) { - foundCredential, err := store.GetCredential(*vcToGet.ID) - assert.NoError(t, err) - assert.Equal(t, *foundCredential, vcToGet) + foundCredential, err := store.GetCredential(*expected.ID) + require.NoError(t, err) + foundJSON, _ := foundCredential.MarshalJSON() + assert.JSONEq(t, string(expectedJSON), string(foundJSON)) }) }) t.Run("no results", func(t *testing.T) { store := newStore(t) - foundCredential, err := store.GetCredential(*vcToGet.ID) + foundCredential, err := store.GetCredential(*expected.ID) assert.ErrorIs(t, err, types.ErrNotFound) assert.Nil(t, foundCredential) }) @@ -181,17 +184,17 @@ func Test_leiaStore_GetCredential(t *testing.T) { t.Run("multiple results", func(t *testing.T) { store := newStore(t) // store once - assert.NoError(t, store.StoreCredential(vcToGet)) + assert.NoError(t, store.StoreCredential(expected)) // store twice lstore := store.(*leiaIssuerStore) rawStructWithSameID := struct { ID *ssi.URI `json:"id,omitempty"` - }{ID: vcToGet.ID} + }{ID: expected.ID} asBytes, _ := json.Marshal(rawStructWithSameID) lstore.issuedCollection().Add([]leia.Document{asBytes}) t.Run("it fails", func(t *testing.T) { - foundCredential, err := store.GetCredential(*vcToGet.ID) + foundCredential, err := store.GetCredential(*expected.ID) assert.ErrorIs(t, err, types.ErrMultipleFound) assert.Nil(t, foundCredential) }) diff --git a/vcr/issuer/mock.go b/vcr/issuer/mock.go index d41c9326d3..5724b935df 100644 --- a/vcr/issuer/mock.go +++ b/vcr/issuer/mock.go @@ -134,18 +134,18 @@ func (m *MockIssuer) EXPECT() *MockIssuerMockRecorder { } // Issue mocks base method. -func (m *MockIssuer) Issue(ctx context.Context, unsignedCredential vc.VerifiableCredential, publish, public bool) (*vc.VerifiableCredential, error) { +func (m *MockIssuer) Issue(ctx context.Context, template vc.VerifiableCredential, options CredentialOptions) (*vc.VerifiableCredential, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Issue", ctx, unsignedCredential, publish, public) + ret := m.ctrl.Call(m, "Issue", ctx, template, options) ret0, _ := ret[0].(*vc.VerifiableCredential) ret1, _ := ret[1].(error) return ret0, ret1 } // Issue indicates an expected call of Issue. -func (mr *MockIssuerMockRecorder) Issue(ctx, unsignedCredential, publish, public any) *gomock.Call { +func (mr *MockIssuerMockRecorder) Issue(ctx, template, options any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Issue", reflect.TypeOf((*MockIssuer)(nil).Issue), ctx, unsignedCredential, publish, public) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Issue", reflect.TypeOf((*MockIssuer)(nil).Issue), ctx, template, options) } // Revoke mocks base method. diff --git a/vcr/store.go b/vcr/store.go index 4c7ae0b292..e74c467711 100644 --- a/vcr/store.go +++ b/vcr/store.go @@ -44,7 +44,7 @@ func (c *vcr) StoreCredential(credential vc.VerifiableCredential, validAt *time. if credential.ID != nil { existingCredential, err := c.find(*credential.ID) if err == nil { - if reflect.DeepEqual(existingCredential, credential) { + if credentialsEqual(existingCredential, credential) { log.Logger(). WithField(core.LogFieldCredentialID, *credential.ID). Info("Credential already exists") @@ -64,6 +64,18 @@ func (c *vcr) StoreCredential(credential vc.VerifiableCredential, validAt *time. return c.writeCredential(credential) } +func credentialsEqual(a vc.VerifiableCredential, b vc.VerifiableCredential) bool { + // go-leia returns pretty-printed JSON documents, so the verifiable credentials have different `raw` properties. + // thus we need to unmarshal the verifiable credentials into maps and compare those. + aJSON, _ := json.Marshal(a) + bJSON, _ := json.Marshal(b) + aMap := map[string]interface{}{} + bMap := map[string]interface{}{} + _ = json.Unmarshal(aJSON, &aMap) + _ = json.Unmarshal(bJSON, &bMap) + return reflect.DeepEqual(aMap, bMap) +} + func (c *vcr) writeCredential(subject vc.VerifiableCredential) error { vcType := "VerifiableCredential" customTypes := credential.ExtractTypes(subject) diff --git a/vcr/test/openid4vci_integration_test.go b/vcr/test/openid4vci_integration_test.go index c1e6d76116..fd9a0f943c 100644 --- a/vcr/test/openid4vci_integration_test.go +++ b/vcr/test/openid4vci_integration_test.go @@ -24,6 +24,7 @@ import ( "github.com/nuts-foundation/nuts-node/core" httpModule "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/network/log" + "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/didnuts" @@ -69,7 +70,10 @@ func TestOpenID4VCIHappyFlow(t *testing.T) { "id": holderDID.URI().String(), "purposeOfUse": "test", }) - issuedVC, err := vcrService.Issuer().Issue(ctx, credential, true, false) + issuedVC, err := vcrService.Issuer().Issue(ctx, credential, issuer.CredentialOptions{ + Publish: true, + Public: false, + }) require.NoError(t, err) require.NotNil(t, issuedVC) @@ -124,7 +128,10 @@ func TestOpenID4VCIConnectionReuse(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - _, err := vcrService.Issuer().Issue(ctx, credential, true, false) + _, err := vcrService.Issuer().Issue(ctx, credential, issuer.CredentialOptions{ + Publish: true, + Public: false, + }) if err != nil { errChan <- err return @@ -200,7 +207,10 @@ func TestOpenID4VCIDisabled(t *testing.T) { }) vcrService := system.FindEngineByName("vcr").(vcr.VCR) - _, err := vcrService.Issuer().Issue(audit.TestContext(), credential, true, false) + _, err := vcrService.Issuer().Issue(audit.TestContext(), credential, issuer.CredentialOptions{ + Publish: true, + Public: false, + }) assert.ErrorContains(t, err, "unable to publish the issued credential") })