diff --git a/component/wallet-cli/pkg/oidc4vci/oidc4vci_flow.go b/component/wallet-cli/pkg/oidc4vci/oidc4vci_flow.go index e2c7e87fd..dd0c06d9b 100644 --- a/component/wallet-cli/pkg/oidc4vci/oidc4vci_flow.go +++ b/component/wallet-cli/pkg/oidc4vci/oidc4vci_flow.go @@ -429,7 +429,7 @@ func (f *Flow) Run(ctx context.Context) ([]*verifiable.Credential, error) { f.perfInfo.GetAccessToken = time.Since(start) - return f.receiveVC(token, openIDConfig, credentialIssuer) + return f.receiveVC(token, openIDConfig, credentialOfferResponse, credentialIssuer) } func (f *Flow) Signer() jose.Signer { @@ -710,6 +710,7 @@ func (f *Flow) getAttestationVP() (string, error) { func (f *Flow) receiveVC( token *oauth2.Token, wellKnown *issuerv1.WellKnownOpenIDIssuerConfiguration, + credentialOfferResponse *oidc4ci.CredentialOfferResponse, credentialIssuer string, ) ([]*verifiable.Credential, error) { start := time.Now() @@ -722,14 +723,16 @@ func (f *Flow) receiveVC( return nil, fmt.Errorf("build proof: %w", err) } - credentialFilters, err := f.getCredentialRequestOIDCCredentialFilters(wellKnown) + credentialFilters, err := f.getCredentialRequestOIDCCredentialFilters(credentialOfferResponse, wellKnown) if err != nil { return nil, fmt.Errorf("getCredentialRequestOIDCCredentialFormat: %w", err) } var parseCredentialResponseDataList []*parseCredentialResponseData - if f.useBatchCredentialsEndpoint { + canUseBatchCredentialsEndpoint := lo.FromPtr(wellKnown.BatchCredentialEndpoint) != "" && len(credentialFilters) > 0 + + if canUseBatchCredentialsEndpoint || f.useBatchCredentialsEndpoint { batchCredentialEndpoint := lo.FromPtr(wellKnown.BatchCredentialEndpoint) if batchCredentialEndpoint == "" { return nil, errors.New("BatchCredentialEndpoint is not enalbed for given profile") @@ -1003,6 +1006,7 @@ func (f *Flow) doCredentialRequest( // getCredentialRequestOIDCCredentialFilters returns list of credentialType and credentialFormat pairs // used for Credential request or Batch Credential request. func (f *Flow) getCredentialRequestOIDCCredentialFilters( + credentialOfferResponse *oidc4ci.CredentialOfferResponse, wellKnown *issuerv1.WellKnownOpenIDIssuerConfiguration, ) ([]*credentialFilter, error) { // Take default value as f.credentialFilters @@ -1012,45 +1016,14 @@ func (f *Flow) getCredentialRequestOIDCCredentialFilters( // CredentialFilters is not supplied: - var credentialFilters []*credentialFilter - if len(f.credentialConfigurationIDs) > 0 { // CredentialConfigurationID option available so take format from well-known configuration. - for _, credentialConfigurationID := range f.credentialConfigurationIDs { - credentialConf := wellKnown.CredentialConfigurationsSupported.AdditionalProperties[credentialConfigurationID] - format := credentialConf.Format - if format == "" { - return nil, fmt.Errorf( - "unable to obtain OIDC credential format from issuer well-known configuration. "+ - "Check if `issuer.credentialMetadata.credential_configurations_supported` contains key `%s` "+ - "with nested `format` field", credentialConfigurationID) - } - - if credentialConf.CredentialDefinition == nil { - return nil, fmt.Errorf( - "unable to obtain credential type from issuer well-known configuration. "+ - "Check if `issuer.credentialMetadata.credential_configurations_supported` contains key `%s` "+ - "with nested `credential_definition` field", credentialConfigurationID) - } - - credentialType, ok := lo.Find(credentialConf.CredentialDefinition.Type, func(item string) bool { - return item != "VerifiableCredential" - }) - if !ok { - return nil, fmt.Errorf( - "unable to get credential type using credential configuration ID %s", credentialConfigurationID) - } - - credentialFilters = append(credentialFilters, &credentialFilter{ - credentialType: credentialType, - oidcCredentialFormat: vcsverifiable.OIDCFormat(format), - }) - } - - return credentialFilters, nil + return f.getCredentialFiltersFromCredentialConfigurationIDs(f.credentialConfigurationIDs, wellKnown) } if len(f.scopes) > 0 { + var credentialFilters []*credentialFilter + // scopes option available so take format from well-known configuration. // Spec: https://openid.github.io/OpenID4VCI/openid-4-verifiable-credential-issuance-wg-draft.html#section-5.1.2 for _, scope := range f.scopes { @@ -1086,7 +1059,49 @@ func (f *Flow) getCredentialRequestOIDCCredentialFilters( return credentialFilters, nil } - return nil, errors.New("obtain OIDC credential format") + // Get credential filter from credential offer. + return f.getCredentialFiltersFromCredentialConfigurationIDs( + credentialOfferResponse.CredentialConfigurationIDs, wellKnown) +} + +func (f *Flow) getCredentialFiltersFromCredentialConfigurationIDs( + credentialConfigurationIDs []string, + wellKnown *issuerv1.WellKnownOpenIDIssuerConfiguration, +) ([]*credentialFilter, error) { + var credentialFilters []*credentialFilter + + for _, credentialConfigurationID := range credentialConfigurationIDs { + credentialConf := wellKnown.CredentialConfigurationsSupported.AdditionalProperties[credentialConfigurationID] + format := credentialConf.Format + if format == "" { + return nil, fmt.Errorf( + "unable to obtain OIDC credential format from issuer well-known configuration. "+ + "Check if `issuer.credentialMetadata.credential_configurations_supported` contains key `%s` "+ + "with nested `format` field", credentialConfigurationID) + } + + if credentialConf.CredentialDefinition == nil { + return nil, fmt.Errorf( + "unable to obtain credential type from issuer well-known configuration. "+ + "Check if `issuer.credentialMetadata.credential_configurations_supported` contains key `%s` "+ + "with nested `credential_definition` field", credentialConfigurationID) + } + + credentialType, ok := lo.Find(credentialConf.CredentialDefinition.Type, func(item string) bool { + return item != "VerifiableCredential" + }) + if !ok { + return nil, fmt.Errorf( + "unable to get credential type using credential configuration ID %s", credentialConfigurationID) + } + + credentialFilters = append(credentialFilters, &credentialFilter{ + credentialType: credentialType, + oidcCredentialFormat: vcsverifiable.OIDCFormat(format), + }) + } + + return credentialFilters, nil } func (f *Flow) handleIssuanceAck( diff --git a/pkg/restapi/v1/oidc4ci/controller.go b/pkg/restapi/v1/oidc4ci/controller.go index 492fdd079..3d47aa105 100644 --- a/pkg/restapi/v1/oidc4ci/controller.go +++ b/pkg/restapi/v1/oidc4ci/controller.go @@ -970,7 +970,7 @@ func (c *Controller) OidcBatchCredential(e echo.Context) error { //nolint:funlen credentialRequest := cr did, aud, err = c.HandleProof(ar.GetClient().GetID(), &credentialRequest, session) if err != nil { - return fmt.Errorf("handle proof: %w", err) + return err } var credentialTypes []string diff --git a/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance.go b/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance.go index 89a2088d9..e00dd4c42 100644 --- a/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance.go +++ b/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance.go @@ -437,7 +437,7 @@ func (s *Service) prepareCredentialOffer( resp := &CredentialOfferResponse{ CredentialIssuer: issuerURL, - CredentialConfigurationIDs: lo.Uniq(credentialConfigurationIDs), + CredentialConfigurationIDs: credentialConfigurationIDs, Grants: CredentialOfferGrant{}, } diff --git a/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance_test.go b/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance_test.go index 234f5e7a4..7e837c999 100644 --- a/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance_test.go +++ b/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance_test.go @@ -276,7 +276,7 @@ func TestService_InitiateIssuance(t *testing.T) { assert.Equal(t, "did:123", data.DID) assert.False(t, data.WalletInitiatedIssuance) - assert.Len(t, data.CredentialConfiguration, 2) + assert.Len(t, data.CredentialConfiguration, 3) prcCredConf := data.CredentialConfiguration[0] assert.NotEmpty(t, prcCredConf.CredentialTemplate) @@ -302,6 +302,18 @@ func TestService_InitiateIssuance(t *testing.T) { assert.NotEmpty(t, prcCredConf.PreAuthCodeExpiresAt) assert.Empty(t, prcCredConf.AuthorizationDetails) + univDegreeCredConf = data.CredentialConfiguration[2] + assert.NotEmpty(t, univDegreeCredConf.CredentialTemplate) + assert.Equal(t, verifiable.OIDCFormat("jwt_vc_json"), univDegreeCredConf.OIDCCredentialFormat) + assert.Empty(t, univDegreeCredConf.ClaimEndpoint) + assert.NotEmpty(t, univDegreeCredConf.ClaimDataID) + assert.Equal(t, "vc_name2", univDegreeCredConf.CredentialName) + assert.Equal(t, "vc_desc2", univDegreeCredConf.CredentialDescription) + assert.Equal(t, "UniversityDegreeCredentialIdentifier", univDegreeCredConf.CredentialConfigurationID) + assert.NotEmpty(t, univDegreeCredConf.CredentialExpiresAt) + assert.NotEmpty(t, prcCredConf.PreAuthCodeExpiresAt) + assert.Empty(t, prcCredConf.AuthorizationDetails) + return &oidc4ci.Transaction{ ID: "txID", TransactionData: oidc4ci.TransactionData{ @@ -320,12 +332,19 @@ func TestService_InitiateIssuance(t *testing.T) { }, CredentialConfigurationID: "UniversityDegreeCredentialIdentifier", }, + { + OIDCCredentialFormat: verifiable.JwtVCJsonLD, + CredentialTemplate: &profileapi.CredentialTemplate{ + ID: "templateID2", + }, + CredentialConfigurationID: "UniversityDegreeCredentialIdentifier", + }, }, }, }, nil }) - mocks.jsonSchemaValidator.EXPECT().Validate(gomock.Any(), gomock.Any(), gomock.Any()).Times(2).Return(nil) + mocks.jsonSchemaValidator.EXPECT().Validate(gomock.Any(), gomock.Any(), gomock.Any()).Times(3).Return(nil) mocks.pinGenerator.EXPECT().Generate(gomock.Any()).Return("123456789") chunks := &dataprotect.EncryptedData{ @@ -333,10 +352,10 @@ func TestService_InitiateIssuance(t *testing.T) { EncryptedNonce: []byte{0x0, 0x2}, } - mocks.crypto.EXPECT().Encrypt(gomock.Any(), gomock.Any()).Times(2). + mocks.crypto.EXPECT().Encrypt(gomock.Any(), gomock.Any()).Times(3). Return(chunks, nil) - mocks.claimDataStore.EXPECT().Create(gomock.Any(), gomock.Any()).Times(2).DoAndReturn( + mocks.claimDataStore.EXPECT().Create(gomock.Any(), gomock.Any()).Times(3).DoAndReturn( func(ctx context.Context, data *oidc4ci.ClaimData) (string, error) { assert.Equal(t, chunks, data.EncryptedData) @@ -413,6 +432,16 @@ func TestService_InitiateIssuance(t *testing.T) { CredentialName: "vc_name2", CredentialDescription: "vc_desc2", }, + { + ClaimData: map[string]interface{}{ + "key3": "value3", + }, + ClaimEndpoint: "", + CredentialTemplateID: "templateID2", + CredentialExpiresAt: now, + CredentialName: "vc_name2", + CredentialDescription: "vc_desc2", + }, }, } @@ -421,7 +450,7 @@ func TestService_InitiateIssuance(t *testing.T) { check: func(t *testing.T, resp *oidc4ci.InitiateIssuanceResponse, err error) { require.NoError(t, err) assert.NotNil(t, resp.Tx) - require.Equal(t, "https://wallet.example.com/initiate_issuance?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fvcs.pb.example.com%2Foidc%2Fidp%22%2C%22credential_configuration_ids%22%3A%5B%22PermanentResidentCardIdentifier%22%2C%22UniversityDegreeCredentialIdentifier%22%5D%2C%22grants%22%3A%7B%22authorization_code%22%3A%7B%22issuer_state%22%3A%22eyJhbGciOiJSU0Et%22%7D%7D%7D", resp.InitiateIssuanceURL) //nolint + require.Equal(t, "https://wallet.example.com/initiate_issuance?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fvcs.pb.example.com%2Foidc%2Fidp%22%2C%22credential_configuration_ids%22%3A%5B%22PermanentResidentCardIdentifier%22%2C%22UniversityDegreeCredentialIdentifier%22%2C%22UniversityDegreeCredentialIdentifier%22%5D%2C%22grants%22%3A%7B%22authorization_code%22%3A%7B%22issuer_state%22%3A%22eyJhbGciOiJSU0Et%22%7D%7D%7D", resp.InitiateIssuanceURL) //nolint require.Equal(t, oidc4ci.ContentTypeApplicationJSON, resp.ContentType) }, }, diff --git a/test/bdd/features/oidc4vc_api.feature b/test/bdd/features/oidc4vc_api.feature index b989a6d00..d444679f0 100644 --- a/test/bdd/features/oidc4vc_api.feature +++ b/test/bdd/features/oidc4vc_api.feature @@ -93,6 +93,7 @@ Feature: OIDC4VC REST API Scenario Outline: OIDC Batch credential issuance and verification Pre Auth flow (request all credentials by credential type) Given Profile "" issuer has been authorized with username "profile-user-issuer-1" and password "profile-user-issuer-1-pwd" And User holds credential "" with templateID "nil" + And User wants to make credentials request based on credential offer "" And Profile "" verifier has been authorized with username "profile-user-verifier-1" and password "profile-user-verifier-1-pwd" When User interacts with Wallet to initiate batch credential issuance using pre authorization code flow @@ -103,12 +104,13 @@ Feature: OIDC4VC REST API And Verifier with profile "" requests deleted interactions claims # In examples below Initiate Issuence request and Credential request are based on credentialType param. Examples: - | issuerProfile | credentialType | issuedCredentialsAmount | verifierProfile | presentationDefinitionID | fields | + | issuerProfile | credentialType | useCredentialOfferForCredentialRequest | issuedCredentialsAmount | verifierProfile | presentationDefinitionID | fields | # SDJWT issuer, JWT verifier, no limit disclosure in PD query. - | bank_issuer/v1.0 | UniversityDegreeCredential,CrudeProductCredential,VerifiedEmployee | 3 | v_myprofile_jwt/v1.0 | 32f54163-no-limit-disclosure-single-field | degree_type_id | - | bank_issuer/v1.0 | UniversityDegreeCredential,CrudeProductCredential | 2 | v_myprofile_jwt/v1.0 | 32f54163-no-limit-disclosure-single-field | degree_type_id | +# | bank_issuer/v1.0 | UniversityDegreeCredential,CrudeProductCredential,VerifiedEmployee | false | 3 | v_myprofile_jwt/v1.0 | 32f54163-no-limit-disclosure-single-field | degree_type_id | +# | bank_issuer/v1.0 | UniversityDegreeCredential,CrudeProductCredential | false | 2 | v_myprofile_jwt/v1.0 | 32f54163-no-limit-disclosure-single-field | degree_type_id | # Same VC type - | bank_issuer/v1.0 | UniversityDegreeCredential,UniversityDegreeCredential | 2 | v_myprofile_jwt/v1.0 | 32f54163-no-limit-disclosure-single-field | degree_type_id | +# | bank_issuer/v1.0 | UniversityDegreeCredential,UniversityDegreeCredential | false | 2 | v_myprofile_jwt/v1.0 | 32f54163-no-limit-disclosure-single-field | degree_type_id | + | bank_issuer/v1.0 | UniversityDegreeCredential,UniversityDegreeCredential | true | 2 | v_myprofile_jwt/v1.0 | 32f54163-no-limit-disclosure-single-field | degree_type_id | @oidc4vc_rest_auth_flow_credential_conf_id Scenario Outline: OIDC credential issuance and verification Auth flow using credential configuration ID to request specific credential type @@ -277,16 +279,19 @@ Feature: OIDC4VC REST API And User holds credential "UniversityDegreeCredential" with templateID "universityDegreeTemplateID" Then Malicious attacker stealing auth code from User and using "malicious_attacker_id" ClientID makes /token request and receives "invalid_client" error + @oidc4vc_auth_malicious_attacker_changed_signingKeyID Scenario: OIDC credential issuance and verification Auth flow (Malicious attacker changed signingKeyID & calling credential endpoint with it) Given Profile "bank_issuer/v1.0" issuer has been authorized with username "profile-user-issuer-1" and password "profile-user-issuer-1-pwd" And User holds credential "UniversityDegreeCredential" with templateID "universityDegreeTemplateID" Then Malicious attacker changed JWT kid header and makes /credential request and receives "invalid_or_missing_proof" error + @oidc4vc_auth_malicious_attacker_changed_jwt Scenario: OIDC credential issuance and verification Auth flow (Malicious attacker changed JWT signature value & calling credential endpoint with it) Given Profile "bank_issuer/v1.0" issuer has been authorized with username "profile-user-issuer-1" and password "profile-user-issuer-1-pwd" And User holds credential "UniversityDegreeCredential" with templateID "universityDegreeTemplateID" Then Malicious attacker changed signature value and makes /credential request and receives "invalid_or_missing_proof" error + @oidc4vc_auth_malicious_attacker_changed_nonce Scenario: OIDC credential issuance and verification Auth flow (Malicious attacker changed nonce & calling credential endpoint with it) Given Profile "bank_issuer/v1.0" issuer has been authorized with username "profile-user-issuer-1" and password "profile-user-issuer-1-pwd" And User holds credential "UniversityDegreeCredential" with templateID "universityDegreeTemplateID" diff --git a/test/bdd/pkg/v1/oidc4vc/oidc4vci.go b/test/bdd/pkg/v1/oidc4vc/oidc4vci.go index 81f0d2720..51ef4a9de 100644 --- a/test/bdd/pkg/v1/oidc4vc/oidc4vci.go +++ b/test/bdd/pkg/v1/oidc4vc/oidc4vci.go @@ -143,19 +143,19 @@ func (s *Steps) runOIDC4VCIPreAuth(initiateOIDC4CIRequest initiateOIDC4VCIReques opts = append(opts, options...) - credentialTypes := strings.Split(s.issuedCredentialType, ",") + if !s.useCredentialOfferCredConfigIDForCredentialRequest { + credentialTypes := strings.Split(s.issuedCredentialType, ",") - // Set option filters - for _, credentialType := range credentialTypes { - format := s.getIssuerOIDCCredentialFormat(credentialType) - opts = append(opts, oidc4vci.WithCredentialFilter(credentialType, format)) + // Set option filters + for _, credentialType := range credentialTypes { + format := s.getIssuerOIDCCredentialFormat(credentialType) + opts = append(opts, oidc4vci.WithCredentialFilter(credentialType, format)) + } } opts = s.addProofBuilder(opts) - flow, err := oidc4vci.NewFlow(s.oidc4vciProvider, - opts..., - ) + flow, err := oidc4vci.NewFlow(s.oidc4vciProvider, opts...) if err != nil { return fmt.Errorf("init pre-auth flow: %w", err) } @@ -412,6 +412,19 @@ func (s *Steps) credentialTypeTemplateID(issuedCredentialType, issuedCredentialT return nil } +// useCredentialOfferForCredentialRequest let's Wallet to create credential request payload +// based on credetnial_configuration_ids from credential offer response rather then credential filters (credentialType & credentialFormat). +// In CLI flow, this behavior can be reproduced via omitting `credential-type` and `credential-format` flags. +// Can be used only for pre authorized flow. +// For testing purpose only. +func (s *Steps) useCredentialOfferForCredentialRequest(trueStr string) error { + use, _ := strconv.ParseBool(trueStr) + + s.useCredentialOfferCredConfigIDForCredentialRequest = use + + return nil +} + func (s *Steps) runOIDC4CIAuthWithErrorInvalidClient(updatedClientID, errorContains string) error { resp, err := s.initiateCredentialIssuance(s.getInitiateIssuanceRequestAuthFlow()) if err != nil { diff --git a/test/bdd/pkg/v1/oidc4vc/steps.go b/test/bdd/pkg/v1/oidc4vc/steps.go index 4519ba97c..706c1e31d 100644 --- a/test/bdd/pkg/v1/oidc4vc/steps.go +++ b/test/bdd/pkg/v1/oidc4vc/steps.go @@ -56,10 +56,11 @@ type Steps struct { wallet *wallet.Wallet wellKnownService *wellknown.Service - issuedCredentialType string - issuedCredentialTemplateID string - vpClaimsTransactionID string - presentationDefinitionID string + issuedCredentialType string + issuedCredentialTemplateID string + vpClaimsTransactionID string + presentationDefinitionID string + useCredentialOfferCredConfigIDForCredentialRequest bool // Stress testing usersNum int @@ -89,6 +90,7 @@ func (s *Steps) RegisterSteps(sc *godog.ScenarioContext) { sc.Step(`^Profile "([^"]*)" issuer has been authorized with username "([^"]*)" and password "([^"]*)"$`, s.authorizeIssuerProfileUser) sc.Step(`^Profile "([^"]*)" verifier has been authorized with username "([^"]*)" and password "([^"]*)"$`, s.authorizeVerifierProfileUser) sc.Step(`^User holds credential "([^"]*)" with templateID "([^"]*)"$`, s.credentialTypeTemplateID) + sc.Step(`^User wants to make credentials request based on credential offer "([^"]*)"$`, s.useCredentialOfferForCredentialRequest) sc.Step(`^User saves issued credentials`, s.saveCredentials) sc.Step(`^"([^"]*)" credentials are issued$`, s.checkIssuedCredential) sc.Step(`^issued credential history is updated`, s.checkIssuedCredentialHistoryStep) @@ -157,6 +159,7 @@ func (s *Steps) ResetAndSetup() error { s.stressResult = nil s.proofType = "jwt" s.initiateIssuanceApiVersion = "" + s.useCredentialOfferCredConfigIDForCredentialRequest = false s.composeFeatureEnabled = false s.composeCredential = nil