From 8cda03b78d0dada65505a81707ab90c27770a9cc Mon Sep 17 00:00:00 2001 From: EmadAnwer Date: Fri, 24 May 2024 09:55:35 +0300 Subject: [PATCH 1/5] Add support for revocable credentials in vc_di handler Signed-off-by: EmadAnwer --- aries_cloudagent/anoncreds/revocation.py | 206 ++++++++++++++++++ .../v2_0/formats/vc_di/handler.py | 28 ++- 2 files changed, 226 insertions(+), 8 deletions(-) diff --git a/aries_cloudagent/anoncreds/revocation.py b/aries_cloudagent/anoncreds/revocation.py index 80e7d8c16e..9ee358b5d8 100644 --- a/aries_cloudagent/anoncreds/revocation.py +++ b/aries_cloudagent/anoncreds/revocation.py @@ -20,6 +20,7 @@ RevocationRegistryDefinition, RevocationRegistryDefinitionPrivate, RevocationStatusList, + W3cCredential, ) from aries_askar.error import AskarError from requests import RequestException, Session @@ -41,6 +42,7 @@ CATEGORY_CRED_DEF_PRIVATE, STATE_FINISHED, AnonCredsIssuer, + AnonCredsIssuerError, ) from .models.anoncreds_revocation import ( RevList, @@ -720,6 +722,9 @@ async def upload_tails_file(self, rev_reg_def: RevRegDef): backoff=-0.5, max_attempts=5, # heuristic: respect HTTP timeout ) + print("#1 tails_server", upload_success) + print("#2 tails_server", result) + if not upload_success: raise AnonCredsRevocationError( f"Tails file for rev reg for {rev_reg_def.cred_def_id} " @@ -895,6 +900,207 @@ async def get_or_create_active_registry(self, cred_def_id: str) -> RevRegDefResu # Credential Operations + async def _create_credential_w3c( + self, + credential_definition_id: str, + schema_attributes: List[str], + credential_offer: dict, + credential_request: dict, + credential_values: dict, + rev_reg_def_id: Optional[str] = None, + tails_file_path: Optional[str] = None, + ) -> Tuple[str, str]: + try: + async with self.profile.session() as session: + cred_def = await session.handle.fetch( + CATEGORY_CRED_DEF, credential_definition_id + ) + cred_def_private = await session.handle.fetch( + CATEGORY_CRED_DEF_PRIVATE, credential_definition_id + ) + except AskarError as err: + raise AnonCredsRevocationError( + "Error retrieving credential definition" + ) from err + if not cred_def or not cred_def_private: + raise AnonCredsRevocationError( + "Credential definition not found for credential issuance" + ) + + raw_values = {} + for attribute in schema_attributes: + # Ensure every attribute present in schema to be set. + # Extraneous attribute names are ignored. + try: + credential_value = credential_values[attribute] + except KeyError: + raise AnonCredsRevocationError( + "Provided credential values are missing a value " + f"for the schema attribute '{attribute}'" + ) + + raw_values[attribute] = str(credential_value) + + if rev_reg_def_id and tails_file_path: + try: + async with self.profile.transaction() as txn: + rev_list = await txn.handle.fetch(CATEGORY_REV_LIST, rev_reg_def_id) + rev_reg_def = await txn.handle.fetch( + CATEGORY_REV_REG_DEF, rev_reg_def_id + ) + rev_key = await txn.handle.fetch( + CATEGORY_REV_REG_DEF_PRIVATE, rev_reg_def_id + ) + if not rev_list: + raise AnonCredsRevocationError("Revocation registry not found") + if not rev_reg_def: + raise AnonCredsRevocationError( + "Revocation registry definition not found" + ) + if not rev_key: + raise AnonCredsRevocationError( + "Revocation registry definition private data not found" + ) + rev_info = rev_list.value_json + rev_info_tags = rev_list.tags + rev_reg_index = rev_info["next_index"] + try: + rev_reg_def = RevocationRegistryDefinition.load( + rev_reg_def.raw_value + ) + rev_list = RevocationStatusList.load(rev_info["rev_list"]) + except AnoncredsError as err: + raise AnonCredsRevocationError( + "Error loading revocation registry definition" + ) from err + if rev_reg_index > rev_reg_def.max_cred_num: + raise AnonCredsRevocationRegistryFullError( + "Revocation registry is full" + ) + rev_info["next_index"] = rev_reg_index + 1 + await txn.handle.replace( + CATEGORY_REV_LIST, + rev_reg_def_id, + value_json=rev_info, + tags=rev_info_tags, + ) + await txn.commit() + except AskarError as err: + raise AnonCredsRevocationError( + "Error updating revocation registry index" + ) from err + + # rev_info["next_index"] is 1 based but getting from + # rev_list is zero based... + revoc = CredentialRevocationConfig( + rev_reg_def, + rev_key.raw_value, + rev_list, + rev_reg_index, + ) + credential_revocation_id = str(rev_reg_index) + else: + revoc = None + credential_revocation_id = None + rev_list = None + + try: + credential = await asyncio.get_event_loop().run_in_executor( + None, + lambda: W3cCredential.create( + cred_def=cred_def.raw_value, + cred_def_private=cred_def_private.raw_value, + cred_offer=credential_offer, + cred_request=credential_request, + attr_raw_values=raw_values, + revocation_config=revoc, + ), + ) + + except AnoncredsError as err: + raise AnonCredsRevocationError("Error creating credential") from err + + return credential.to_json(), credential_revocation_id + + async def create_credential_w3c( + self, + credential_offer: dict, + credential_request: dict, + credential_values: dict, + *, + retries: int = 5, + ) -> Tuple[str, str, str]: + """Create a credential. + + Args: + credential_offer: Credential Offer to create credential for + credential_request: Credential request to create credential for + credential_values: Values to go in credential + revoc_reg_id: ID of the revocation registry + retries: number of times to retry credential creation + + Returns: + A tuple of created credential and revocation id + + """ + issuer = AnonCredsIssuer(self.profile) + anoncreds_registry = self.profile.inject(AnonCredsRegistry) + schema_id = credential_offer["schema_id"] + schema_result = await anoncreds_registry.get_schema(self.profile, schema_id) + cred_def_id = credential_offer["cred_def_id"] + schema_attributes = schema_result.schema_value.attr_names + + revocable = await issuer.cred_def_supports_revocation(cred_def_id) + + for attempt in range(max(retries, 1)): + if attempt > 0: + LOGGER.info( + "Retrying credential creation for revocation registry %s", + cred_def_id, + ) + await asyncio.sleep(2) + + rev_reg_def_result = None + if revocable: + rev_reg_def_result = await self.get_or_create_active_registry( + cred_def_id + ) + if ( + rev_reg_def_result.revocation_registry_definition_state.state + != STATE_FINISHED + ): + continue + rev_reg_def_id = rev_reg_def_result.rev_reg_def_id + tails_file_path = self.get_local_tails_path( + rev_reg_def_result.rev_reg_def + ) + else: + rev_reg_def_id = None + tails_file_path = None + + try: + cred_json, cred_rev_id = await self._create_credential_w3c( + cred_def_id, + schema_attributes, + credential_offer, + credential_request, + credential_values, + rev_reg_def_id, + tails_file_path, + ) + except AnonCredsRevocationRegistryFullError: + continue + + if rev_reg_def_result: + if ( + rev_reg_def_result.rev_reg_def.value.max_cred_num + <= int(cred_rev_id) + 1 + ): + await self.handle_full_registry(rev_reg_def_id) + return cred_json, cred_rev_id, rev_reg_def_id + + raise AnonCredsRevocationError("Failed to create credential after retrying") + async def _create_credential( self, credential_definition_id: str, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/vc_di/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/vc_di/handler.py index e2a688db96..9a38f5db9c 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/vc_di/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/vc_di/handler.py @@ -460,15 +460,30 @@ async def issue_credential( async with ledger: schema_id = await ledger.credential_definition_id2schema_id(cred_def_id) + cred_def = await ledger.get_credential_definition(cred_def_id) + revocable = cred_def["value"].get("revocation") + print("#5 revocable from vc_di: ", revocable) legacy_offer = await self._prepare_legacy_offer(cred_offer, schema_id) legacy_request = await self._prepare_legacy_request(cred_request, cred_def_id) - issuer = AnonCredsIssuer(self.profile) - - credential = await issuer.create_credential_w3c( - legacy_offer, legacy_request, cred_values - ) + cred_rev_id = None + rev_reg_def_id = None + credential = None + if revocable: + issuer = AnonCredsRevocation(self.profile) + ( + credential, + cred_rev_id, + rev_reg_def_id, + ) = await issuer.create_credential_w3c( + legacy_offer, legacy_request, cred_values + ) + else: + issuer = AnonCredsIssuer(self.profile) + credential = await issuer.create_credential_w3c( + legacy_offer, legacy_request, cred_values + ) vcdi_credential = { "credential": json.loads(credential), @@ -476,9 +491,6 @@ async def issue_credential( result = self.get_format_data(CRED_20_ISSUE, vcdi_credential) - cred_rev_id = None - rev_reg_def_id = None - async with self._profile.transaction() as txn: detail_record = V20CredExRecordIndy( cred_ex_id=cred_ex_record.cred_ex_id, From cff8b992bc690b258f93bfbf76309dc8edbfeb31 Mon Sep 17 00:00:00 2001 From: EmadAnwer Date: Sun, 2 Jun 2024 16:20:30 +0300 Subject: [PATCH 2/5] feat: Refactor revocation module and improve credential handling Signed-off-by: EmadAnwer --- aries_cloudagent/anoncreds/revocation.py | 69 +++++----- .../anoncreds/tests/test_revocation.py | 127 ++++++++++++++++++ .../v2_0/formats/vc_di/handler.py | 19 ++- 3 files changed, 173 insertions(+), 42 deletions(-) diff --git a/aries_cloudagent/anoncreds/revocation.py b/aries_cloudagent/anoncreds/revocation.py index 2eb1888fdd..8973ab0f91 100644 --- a/aries_cloudagent/anoncreds/revocation.py +++ b/aries_cloudagent/anoncreds/revocation.py @@ -39,7 +39,6 @@ CATEGORY_CRED_DEF_PRIVATE, STATE_FINISHED, AnonCredsIssuer, - AnonCredsIssuerError, ) from .models.anoncreds_revocation import ( RevList, @@ -719,8 +718,6 @@ async def upload_tails_file(self, rev_reg_def: RevRegDef): backoff=-0.5, max_attempts=5, # heuristic: respect HTTP timeout ) - print("#1 tails_server", upload_success) - print("#2 tails_server", result) if not upload_success: raise AnonCredsRevocationError( @@ -899,29 +896,29 @@ async def get_or_create_active_registry(self, cred_def_id: str) -> RevRegDefResu async def _create_credential_w3c( self, - credential_definition_id: str, + w3c_credential_definition_id: str, schema_attributes: List[str], - credential_offer: dict, - credential_request: dict, - credential_values: dict, + w3c_credential_offer: dict, + w3c_credential_request: dict, + w3c_credential_values: dict, rev_reg_def_id: Optional[str] = None, tails_file_path: Optional[str] = None, ) -> Tuple[str, str]: try: async with self.profile.session() as session: cred_def = await session.handle.fetch( - CATEGORY_CRED_DEF, credential_definition_id + CATEGORY_CRED_DEF, w3c_credential_definition_id ) cred_def_private = await session.handle.fetch( - CATEGORY_CRED_DEF_PRIVATE, credential_definition_id + CATEGORY_CRED_DEF_PRIVATE, w3c_credential_definition_id ) except AskarError as err: raise AnonCredsRevocationError( - "Error retrieving credential definition" + "Error retrieving w3c_credential definition" ) from err if not cred_def or not cred_def_private: raise AnonCredsRevocationError( - "Credential definition not found for credential issuance" + "Credential definition not found for w3c_credential issuance" ) raw_values = {} @@ -929,14 +926,14 @@ async def _create_credential_w3c( # Ensure every attribute present in schema to be set. # Extraneous attribute names are ignored. try: - credential_value = credential_values[attribute] + w3c_credential_value = w3c_credential_values[attribute] except KeyError: raise AnonCredsRevocationError( - "Provided credential values are missing a value " + "Provided w3c_credential values are missing a value " f"for the schema attribute '{attribute}'" ) - raw_values[attribute] = str(credential_value) + raw_values[attribute] = str(w3c_credential_value) if rev_reg_def_id and tails_file_path: try: @@ -995,20 +992,20 @@ async def _create_credential_w3c( rev_list, rev_reg_index, ) - credential_revocation_id = str(rev_reg_index) + w3c_credential_revocation_id = str(rev_reg_index) else: revoc = None - credential_revocation_id = None + w3c_credential_revocation_id = None rev_list = None try: - credential = await asyncio.get_event_loop().run_in_executor( + w3c_credential = await asyncio.get_event_loop().run_in_executor( None, lambda: W3cCredential.create( cred_def=cred_def.raw_value, cred_def_private=cred_def_private.raw_value, - cred_offer=credential_offer, - cred_request=credential_request, + cred_offer=w3c_credential_offer, + cred_request=w3c_credential_request, attr_raw_values=raw_values, revocation_config=revoc, ), @@ -1017,34 +1014,34 @@ async def _create_credential_w3c( except AnoncredsError as err: raise AnonCredsRevocationError("Error creating credential") from err - return credential.to_json(), credential_revocation_id + return w3c_credential.to_json(), w3c_credential_revocation_id async def create_credential_w3c( self, - credential_offer: dict, - credential_request: dict, - credential_values: dict, + w3c_credential_offer: dict, + w3c_credential_request: dict, + w3c_credential_values: dict, *, retries: int = 5, ) -> Tuple[str, str, str]: - """Create a credential. + """Create a w3c_credential. Args: - credential_offer: Credential Offer to create credential for - credential_request: Credential request to create credential for - credential_values: Values to go in credential + w3c_credential_offer: Credential Offer to create w3c_credential for + w3c_credential_request: Credential request to create w3c_credential for + w3c_credential_values: Values to go in w3c_credential revoc_reg_id: ID of the revocation registry - retries: number of times to retry credential creation + retries: number of times to retry w3c_credential creation Returns: - A tuple of created credential and revocation id + A tuple of created w3c_credential and revocation id """ issuer = AnonCredsIssuer(self.profile) anoncreds_registry = self.profile.inject(AnonCredsRegistry) - schema_id = credential_offer["schema_id"] + schema_id = w3c_credential_offer["schema_id"] schema_result = await anoncreds_registry.get_schema(self.profile, schema_id) - cred_def_id = credential_offer["cred_def_id"] + cred_def_id = w3c_credential_offer["cred_def_id"] schema_attributes = schema_result.schema_value.attr_names revocable = await issuer.cred_def_supports_revocation(cred_def_id) @@ -1052,7 +1049,7 @@ async def create_credential_w3c( for attempt in range(max(retries, 1)): if attempt > 0: LOGGER.info( - "Retrying credential creation for revocation registry %s", + "Retrying w3c_credential creation for revocation registry %s", cred_def_id, ) await asyncio.sleep(2) @@ -1079,9 +1076,9 @@ async def create_credential_w3c( cred_json, cred_rev_id = await self._create_credential_w3c( cred_def_id, schema_attributes, - credential_offer, - credential_request, - credential_values, + w3c_credential_offer, + w3c_credential_request, + w3c_credential_values, rev_reg_def_id, tails_file_path, ) @@ -1096,7 +1093,7 @@ async def create_credential_w3c( await self.handle_full_registry(rev_reg_def_id) return cred_json, cred_rev_id, rev_reg_def_id - raise AnonCredsRevocationError("Failed to create credential after retrying") + raise AnonCredsRevocationError("Failed to create w3c_credential after retrying") async def _create_credential( self, diff --git a/aries_cloudagent/anoncreds/tests/test_revocation.py b/aries_cloudagent/anoncreds/tests/test_revocation.py index 26a75d57c2..064a0db5de 100644 --- a/aries_cloudagent/anoncreds/tests/test_revocation.py +++ b/aries_cloudagent/anoncreds/tests/test_revocation.py @@ -11,6 +11,7 @@ RevocationRegistryDefinitionPrivate, RevocationStatusList, Schema, + W3cCredential, ) from aries_askar import AskarError, AskarErrorCode from requests import RequestException, Session @@ -1380,3 +1381,129 @@ async def test_clear_pending_revocations_with_non_anoncreds_session(self): await self.revocation.clear_pending_revocations( self.profile.session(), rev_reg_def_id="test-rev-reg-id" ) + + @mock.patch.object( + AnonCredsIssuer, "cred_def_supports_revocation", return_value=True + ) + async def test_create_credential_w3c(self, mock_supports_revocation): + self.profile.inject = mock.Mock( + return_value=mock.MagicMock( + get_schema=mock.CoroutineMock( + return_value=GetSchemaResult( + schema_id="CsQY9MGeD3CQP4EyuVFo5m:2:MYCO Biomarker:0.0.3", + schema=AnonCredsSchema( + issuer_id="CsQY9MGeD3CQP4EyuVFo5m", + name="MYCO Biomarker:0.0.3", + version="1.0", + attr_names=["attr1", "attr2"], + ), + schema_metadata={}, + resolution_metadata={}, + ) + ) + ) + ) + self.revocation.get_or_create_active_registry = mock.CoroutineMock( + return_value=RevRegDefResult( + job_id="test-job-id", + revocation_registry_definition_state=RevRegDefState( + state=RevRegDefState.STATE_FINISHED, + revocation_registry_definition_id="active-reg-reg", + revocation_registry_definition=rev_reg_def, + ), + registration_metadata={}, + revocation_registry_definition_metadata={}, + ) + ) + + self.revocation._create_credential_w3c = mock.CoroutineMock( + return_value=({"cred": "cred"}, 98) + ) + + result = await self.revocation.create_credential_w3c( + w3c_credential_offer={ + "schema_id": "CsQY9MGeD3CQP4EyuVFo5m:2:MYCO Biomarker:0.0.3", + "cred_def_id": "CsQY9MGeD3CQP4EyuVFo5m:3:CL:14951:MYCO_Biomarker", + "key_correctness_proof": {}, + "nonce": "nonce", + }, + w3c_credential_request={}, + w3c_credential_values={}, + ) + + assert isinstance(result, tuple) + assert mock_supports_revocation.call_count == 1 + + @mock.patch.object(InMemoryProfileSession, "handle") + @mock.patch.object(W3cCredential, "create", return_value=mock.MagicMock()) + async def test_create_credential_w3c_private_no_rev_reg_or_tails( + self, mock_create, mock_handle + ): + mock_handle.fetch = mock.CoroutineMock(side_effect=[MockEntry(), MockEntry()]) + await self.revocation._create_credential_w3c( + w3c_credential_definition_id="test-cred-def-id", + schema_attributes=["attr1", "attr2"], + w3c_credential_offer={ + "schema_id": "CsQY9MGeD3CQP4EyuVFo5m:2:MYCO Biomarker:0.0.3", + "cred_def_id": "CsQY9MGeD3CQP4EyuVFo5m:3:CL:14951:MYCO_Biomarker", + "key_correctness_proof": {}, + "nonce": "nonce", + }, + w3c_credential_request={}, + w3c_credential_values={ + "attr1": "value1", + "attr2": "value2", + }, + ) + assert mock_create.called + + # askar error retrieving cred def + mock_handle.fetch = mock.CoroutineMock( + side_effect=AskarError(AskarErrorCode.UNEXPECTED, "test") + ) + with self.assertRaises(test_module.AnonCredsRevocationError): + await self.revocation._create_credential_w3c( + w3c_credential_definition_id="test-cred-def-id", + schema_attributes=["attr1", "attr2"], + w3c_credential_offer={}, + w3c_credential_request={}, + w3c_credential_values={}, + ) + + # missing cred def or cred def private + mock_handle.fetch = mock.CoroutineMock(side_effect=[None, MockEntry()]) + with self.assertRaises(test_module.AnonCredsRevocationError): + await self.revocation._create_credential_w3c( + w3c_credential_definition_id="test-cred-def-id", + schema_attributes=["attr1", "attr2"], + w3c_credential_offer={}, + w3c_credential_request={}, + w3c_credential_values={}, + ) + mock_handle.fetch = mock.CoroutineMock(side_effect=[MockEntry(), None]) + with self.assertRaises(test_module.AnonCredsRevocationError): + await self.revocation._create_credential_w3c( + w3c_credential_definition_id="test-cred-def-id", + schema_attributes=["attr1", "attr2"], + w3c_credential_offer={}, + w3c_credential_request={}, + w3c_credential_values={}, + ) + + mock_handle.fetch = mock.CoroutineMock(side_effect=[MockEntry(), None]) + with self.assertRaises(test_module.AnonCredsRevocationError): + await self.revocation._create_credential_w3c( + w3c_credential_definition_id="test-cred-def-id", + schema_attributes=["attr1", "attr2"], + w3c_credential_offer={ + "schema_id": "CsQY9MGeD3CQP4EyuVFo5m:2:MYCO Biomarker:0.0.3", + "cred_def_id": "CsQY9MGeD3CQP4EyuVFo5m:3:CL:14951:MYCO_Biomarker", + "key_correctness_proof": {}, + "nonce": "nonce", + }, + w3c_credential_request={}, + w3c_credential_values={ + "x": "value1", + "y": "value2", + }, + ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/vc_di/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/vc_di/handler.py index 9a38f5db9c..100b44530e 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/vc_di/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/vc_di/handler.py @@ -7,7 +7,7 @@ import json import logging from typing import Mapping, Tuple - +from anoncreds import W3cCredential from ...models.cred_ex_record import V20CredExRecord from ...models.detail.indy import ( V20CredExRecordIndy, @@ -462,7 +462,6 @@ async def issue_credential( schema_id = await ledger.credential_definition_id2schema_id(cred_def_id) cred_def = await ledger.get_credential_definition(cred_def_id) revocable = cred_def["value"].get("revocation") - print("#5 revocable from vc_di: ", revocable) legacy_offer = await self._prepare_legacy_offer(cred_offer, schema_id) legacy_request = await self._prepare_legacy_request(cred_request, cred_def_id) @@ -566,10 +565,18 @@ async def store_credential( cred_def_result = await anoncreds_registry.get_credential_definition( self.profile, cred["proof"][0]["verificationMethod"] ) - if cred["proof"][0].get("rev_reg_id"): + # TODO: remove loading of W3cCredential and use the credential directly + try: + cred_w3c = W3cCredential.load(cred) + rev_reg_id = cred_w3c.rev_reg_id + rev_reg_index = cred_w3c.rev_reg_index + except AnonCredsHolderError as e: + LOGGER.error(f"Error receiving credential: {e.error_code} - {e.message}") + raise e + if rev_reg_id: rev_reg_def_result = ( await anoncreds_registry.get_revocation_registry_definition( - self.profile, cred["proof"][0]["rev_reg_id"] + self.profile, rev_reg_id ) ) rev_reg_def = rev_reg_def_result.revocation_registry @@ -600,8 +607,8 @@ async def store_credential( ) detail_record.cred_id_stored = cred_id_stored - detail_record.rev_reg_id = cred["proof"][0].get("rev_reg_id", None) - detail_record.cred_rev_id = cred["proof"][0].get("cred_rev_id", None) + detail_record.rev_reg_id = rev_reg_id + detail_record.cred_rev_id = rev_reg_index async with self.profile.session() as session: # Store detail record, emit event From 3442c07900ee35de04f8d8d3634d5e958ff0e5e8 Mon Sep 17 00:00:00 2001 From: Ian Costanzo Date: Mon, 3 Jun 2024 12:17:49 -0700 Subject: [PATCH 3/5] Integration tests for vc_di cred revocation Signed-off-by: Ian Costanzo --- demo/features/0453-issue-credential.feature | 7 ++-- demo/features/0454-present-proof.feature | 39 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/demo/features/0453-issue-credential.feature b/demo/features/0453-issue-credential.feature index fbe1c49c07..a6736295b7 100644 --- a/demo/features/0453-issue-credential.feature +++ b/demo/features/0453-issue-credential.feature @@ -64,9 +64,10 @@ Feature: RFC 0453 Aries agent issue credential @WalletType_Askar_AnonCreds @SwitchCredTypeTest Examples: - | Acme_capabilities | Bob_capabilities | Schema_name | Credential_data | Acme_extra | Bob_extra | New_Cred_Type | - | --public-did --wallet-type askar-anoncreds | --wallet-type askar-anoncreds | driverslicense | Data_DL_NormalizedValues | | | vc_di | - | --public-did --wallet-type askar-anoncreds --cred-type vc_di | --wallet-type askar-anoncreds | driverslicense | Data_DL_NormalizedValues | | | indy | + | Acme_capabilities | Bob_capabilities | Schema_name | Credential_data | Acme_extra | Bob_extra | New_Cred_Type | + | --public-did --wallet-type askar-anoncreds | --wallet-type askar-anoncreds | driverslicense | Data_DL_NormalizedValues | | | vc_di | + | --public-did --wallet-type askar-anoncreds --cred-type vc_di | --wallet-type askar-anoncreds | driverslicense | Data_DL_NormalizedValues | | | indy | + | --public-did --wallet-type askar-anoncreds --cred-type vc_di --revocation | --wallet-type askar-anoncreds | driverslicense | Data_DL_NormalizedValues | | | indy | @T003-RFC0453 Scenario Outline: Holder accepts a deleted credential offer diff --git a/demo/features/0454-present-proof.feature b/demo/features/0454-present-proof.feature index f40552e003..bdbc2155c4 100644 --- a/demo/features/0454-present-proof.feature +++ b/demo/features/0454-present-proof.feature @@ -303,3 +303,42 @@ Feature: RFC 0454 Aries agent present proof Examples: | issuer1 | Acme1_capabilities | Bob_cap | Schema_name_1 | Credential_data_1 | Proof_request | | Acme1 | --revocation --public-did --wallet-type askar-anoncreds | --wallet-type askar-anoncreds | driverslicense_v2 | Data_DL_MaxValues | DL_age_over_19_v2 | + + @T003-RFC0454.5 + Scenario Outline: Present Proof for a vc_di-issued credential using "legacy" indy proof and the proof validates + Given we have "2" agents + | name | role | capabilities | extra | + | Acme | issuer | | | + | Bob | holder | | | + And "Acme" and "Bob" have an existing connection + And "Acme" is ready to issue a credential for + When "Acme" offers a credential with data + When "Bob" has the credential issued + When "Acme" sets the credential type to + When "Acme" sends a request with explicit revocation status for proof presentation to "Bob" + Then "Acme" has the proof verified + + @WalletType_Askar_AnonCreds @SwitchCredTypeTest + Examples: + | Acme_capabilities | Bob_capabilities | Schema_name | Credential_data | Acme_extra | Bob_extra | New_Cred_Type | Proof_request | + | --public-did --wallet-type askar-anoncreds --cred-type vc_di --revocation | --wallet-type askar-anoncreds | driverslicense_v2 | Data_DL_MaxValues | | | indy | DL_age_over_19_v2 | + + @T003-RFC0454.6 + Scenario Outline: Present Proof for a vc_di-issued credential using "legacy" indy proof and credential is revoked and the proof fails + Given we have "2" agents + | name | role | capabilities | extra | + | Acme | issuer | | | + | Bob | holder | | | + And "Acme" and "Bob" have an existing connection + And "Acme" is ready to issue a credential for + When "Acme" offers a credential with data + When "Bob" has the credential issued + When "Acme" sets the credential type to + And "Acme" revokes the credential + When "Acme" sends a request for proof presentation to "Bob" + Then "Acme" has the proof verification fail + + @WalletType_Askar_AnonCreds @SwitchCredTypeTest + Examples: + | Acme_capabilities | Bob_capabilities | Schema_name | Credential_data | Acme_extra | Bob_extra | New_Cred_Type | Proof_request | + | --public-did --wallet-type askar-anoncreds --cred-type vc_di --revocation | --wallet-type askar-anoncreds | driverslicense_v2 | Data_DL_MaxValues | | | indy | DL_age_over_19_v2 | From aff4b188407ad110e583862a6143645f356d0015 Mon Sep 17 00:00:00 2001 From: EmadAnwer Date: Tue, 4 Jun 2024 21:55:57 +0300 Subject: [PATCH 4/5] refactor: remove duplicated code Signed-off-by: EmadAnwer --- aries_cloudagent/anoncreds/revocation.py | 252 +++++------------- .../anoncreds/tests/test_revocation.py | 91 ++----- 2 files changed, 89 insertions(+), 254 deletions(-) diff --git a/aries_cloudagent/anoncreds/revocation.py b/aries_cloudagent/anoncreds/revocation.py index 8973ab0f91..84a03ef866 100644 --- a/aries_cloudagent/anoncreds/revocation.py +++ b/aries_cloudagent/anoncreds/revocation.py @@ -8,7 +8,7 @@ import os import time from pathlib import Path -from typing import List, NamedTuple, Optional, Sequence, Tuple +from typing import List, NamedTuple, Optional, Sequence, Tuple, Union from urllib.parse import urlparse import base58 @@ -894,128 +894,6 @@ async def get_or_create_active_registry(self, cred_def_id: str) -> RevRegDefResu # Credential Operations - async def _create_credential_w3c( - self, - w3c_credential_definition_id: str, - schema_attributes: List[str], - w3c_credential_offer: dict, - w3c_credential_request: dict, - w3c_credential_values: dict, - rev_reg_def_id: Optional[str] = None, - tails_file_path: Optional[str] = None, - ) -> Tuple[str, str]: - try: - async with self.profile.session() as session: - cred_def = await session.handle.fetch( - CATEGORY_CRED_DEF, w3c_credential_definition_id - ) - cred_def_private = await session.handle.fetch( - CATEGORY_CRED_DEF_PRIVATE, w3c_credential_definition_id - ) - except AskarError as err: - raise AnonCredsRevocationError( - "Error retrieving w3c_credential definition" - ) from err - if not cred_def or not cred_def_private: - raise AnonCredsRevocationError( - "Credential definition not found for w3c_credential issuance" - ) - - raw_values = {} - for attribute in schema_attributes: - # Ensure every attribute present in schema to be set. - # Extraneous attribute names are ignored. - try: - w3c_credential_value = w3c_credential_values[attribute] - except KeyError: - raise AnonCredsRevocationError( - "Provided w3c_credential values are missing a value " - f"for the schema attribute '{attribute}'" - ) - - raw_values[attribute] = str(w3c_credential_value) - - if rev_reg_def_id and tails_file_path: - try: - async with self.profile.transaction() as txn: - rev_list = await txn.handle.fetch(CATEGORY_REV_LIST, rev_reg_def_id) - rev_reg_def = await txn.handle.fetch( - CATEGORY_REV_REG_DEF, rev_reg_def_id - ) - rev_key = await txn.handle.fetch( - CATEGORY_REV_REG_DEF_PRIVATE, rev_reg_def_id - ) - if not rev_list: - raise AnonCredsRevocationError("Revocation registry not found") - if not rev_reg_def: - raise AnonCredsRevocationError( - "Revocation registry definition not found" - ) - if not rev_key: - raise AnonCredsRevocationError( - "Revocation registry definition private data not found" - ) - rev_info = rev_list.value_json - rev_info_tags = rev_list.tags - rev_reg_index = rev_info["next_index"] - try: - rev_reg_def = RevocationRegistryDefinition.load( - rev_reg_def.raw_value - ) - rev_list = RevocationStatusList.load(rev_info["rev_list"]) - except AnoncredsError as err: - raise AnonCredsRevocationError( - "Error loading revocation registry definition" - ) from err - if rev_reg_index > rev_reg_def.max_cred_num: - raise AnonCredsRevocationRegistryFullError( - "Revocation registry is full" - ) - rev_info["next_index"] = rev_reg_index + 1 - await txn.handle.replace( - CATEGORY_REV_LIST, - rev_reg_def_id, - value_json=rev_info, - tags=rev_info_tags, - ) - await txn.commit() - except AskarError as err: - raise AnonCredsRevocationError( - "Error updating revocation registry index" - ) from err - - # rev_info["next_index"] is 1 based but getting from - # rev_list is zero based... - revoc = CredentialRevocationConfig( - rev_reg_def, - rev_key.raw_value, - rev_list, - rev_reg_index, - ) - w3c_credential_revocation_id = str(rev_reg_index) - else: - revoc = None - w3c_credential_revocation_id = None - rev_list = None - - try: - w3c_credential = await asyncio.get_event_loop().run_in_executor( - None, - lambda: W3cCredential.create( - cred_def=cred_def.raw_value, - cred_def_private=cred_def_private.raw_value, - cred_offer=w3c_credential_offer, - cred_request=w3c_credential_request, - attr_raw_values=raw_values, - revocation_config=revoc, - ), - ) - - except AnoncredsError as err: - raise AnonCredsRevocationError("Error creating credential") from err - - return w3c_credential.to_json(), w3c_credential_revocation_id - async def create_credential_w3c( self, w3c_credential_offer: dict, @@ -1030,70 +908,19 @@ async def create_credential_w3c( w3c_credential_offer: Credential Offer to create w3c_credential for w3c_credential_request: Credential request to create w3c_credential for w3c_credential_values: Values to go in w3c_credential - revoc_reg_id: ID of the revocation registry retries: number of times to retry w3c_credential creation Returns: A tuple of created w3c_credential and revocation id """ - issuer = AnonCredsIssuer(self.profile) - anoncreds_registry = self.profile.inject(AnonCredsRegistry) - schema_id = w3c_credential_offer["schema_id"] - schema_result = await anoncreds_registry.get_schema(self.profile, schema_id) - cred_def_id = w3c_credential_offer["cred_def_id"] - schema_attributes = schema_result.schema_value.attr_names - - revocable = await issuer.cred_def_supports_revocation(cred_def_id) - - for attempt in range(max(retries, 1)): - if attempt > 0: - LOGGER.info( - "Retrying w3c_credential creation for revocation registry %s", - cred_def_id, - ) - await asyncio.sleep(2) - - rev_reg_def_result = None - if revocable: - rev_reg_def_result = await self.get_or_create_active_registry( - cred_def_id - ) - if ( - rev_reg_def_result.revocation_registry_definition_state.state - != STATE_FINISHED - ): - continue - rev_reg_def_id = rev_reg_def_result.rev_reg_def_id - tails_file_path = self.get_local_tails_path( - rev_reg_def_result.rev_reg_def - ) - else: - rev_reg_def_id = None - tails_file_path = None - - try: - cred_json, cred_rev_id = await self._create_credential_w3c( - cred_def_id, - schema_attributes, - w3c_credential_offer, - w3c_credential_request, - w3c_credential_values, - rev_reg_def_id, - tails_file_path, - ) - except AnonCredsRevocationRegistryFullError: - continue - - if rev_reg_def_result: - if ( - rev_reg_def_result.rev_reg_def.value.max_cred_num - <= int(cred_rev_id) + 1 - ): - await self.handle_full_registry(rev_reg_def_id) - return cred_json, cred_rev_id, rev_reg_def_id - - raise AnonCredsRevocationError("Failed to create w3c_credential after retrying") + return await self._create_credential_helper( + w3c_credential_offer, + w3c_credential_request, + w3c_credential_values, + W3cCredential, + retries=retries, + ) async def _create_credential( self, @@ -1102,9 +929,26 @@ async def _create_credential( credential_offer: dict, credential_request: dict, credential_values: dict, + credential_type: Union[Credential, W3cCredential], rev_reg_def_id: Optional[str] = None, tails_file_path: Optional[str] = None, ) -> Tuple[str, str]: + """Create a credential. + + Args: + credential_definition_id: The credential definition ID + schema_attributes: The schema attributes + credential_offer: The credential offer + credential_request: The credential request + credential_values: The credential values + credential_type: The credential type + rev_reg_def_id: The revocation registry definition ID + tails_file_path: The tails file path + + Returns: + A tuple of created credential and revocation ID + + """ try: async with self.profile.session() as session: cred_def = await session.handle.fetch( @@ -1207,14 +1051,13 @@ async def _create_credential( try: credential = await asyncio.get_event_loop().run_in_executor( None, - lambda: Credential.create( - cred_def.raw_value, - cred_def_private.raw_value, - credential_offer, - credential_request, - raw_values, - None, - revoc, + lambda: credential_type.create( + cred_def=cred_def.raw_value, + cred_def_private=cred_def_private.raw_value, + cred_offer=credential_offer, + cred_request=credential_request, + attr_raw_values=raw_values, + revocation_config=revoc, ), ) except AnoncredsError as err: @@ -1242,6 +1085,36 @@ async def create_credential( Returns: A tuple of created credential and revocation id + """ + return await self._create_credential_helper( + credential_offer, + credential_request, + credential_values, + Credential, + retries=retries, + ) + + async def _create_credential_helper( + self, + credential_offer: dict, + credential_request: dict, + credential_values: dict, + credential_type: Union[Credential, W3cCredential], + *, + retries: int = 5, + ) -> Tuple[str, str, str]: + """Create a credential. + + Args: + credential_offer: Credential Offer to create credential for + credential_request: Credential request to create credential for + credential_values: Values to go in credential + credential_type: Credential or W3cCredential + retries: number of times to retry credential creation + + Returns: + A tuple of created credential and revocation id + """ issuer = AnonCredsIssuer(self.profile) anoncreds_registry = self.profile.inject(AnonCredsRegistry) @@ -1284,6 +1157,7 @@ async def create_credential( credential_offer, credential_request, credential_values, + credential_type, rev_reg_def_id, tails_file_path, ) diff --git a/aries_cloudagent/anoncreds/tests/test_revocation.py b/aries_cloudagent/anoncreds/tests/test_revocation.py index 064a0db5de..f4a3a12ad6 100644 --- a/aries_cloudagent/anoncreds/tests/test_revocation.py +++ b/aries_cloudagent/anoncreds/tests/test_revocation.py @@ -11,7 +11,9 @@ RevocationRegistryDefinitionPrivate, RevocationStatusList, Schema, - W3cCredential, + # AnoncredsError, + # W3cCredential, + # CredentialRevocationConfig, ) from aries_askar import AskarError, AskarErrorCode from requests import RequestException, Session @@ -1025,6 +1027,7 @@ async def test_create_credential_private_no_rev_reg_or_tails( "attr1": "value1", "attr2": "value2", }, + credential_type=Credential, ) assert mock_create.called @@ -1039,6 +1042,7 @@ async def test_create_credential_private_no_rev_reg_or_tails( credential_offer={}, credential_request={}, credential_values={}, + credential_type=Credential, ) # missing cred def or cred def private @@ -1050,6 +1054,7 @@ async def test_create_credential_private_no_rev_reg_or_tails( credential_offer={}, credential_request={}, credential_values={}, + credential_type=Credential, ) mock_handle.fetch = mock.CoroutineMock(side_effect=[MockEntry(), None]) with self.assertRaises(test_module.AnonCredsRevocationError): @@ -1059,6 +1064,7 @@ async def test_create_credential_private_no_rev_reg_or_tails( credential_offer={}, credential_request={}, credential_values={}, + credential_type=Credential, ) @mock.patch.object(InMemoryProfileSession, "handle") @@ -1087,6 +1093,7 @@ async def call_test_func(): }, rev_reg_def_id="test-rev-reg-def-id", tails_file_path="tails-file-path", + credential_type=Credential, ) # missing rev list @@ -1416,7 +1423,8 @@ async def test_create_credential_w3c(self, mock_supports_revocation): ) ) - self.revocation._create_credential_w3c = mock.CoroutineMock( + # Test private funtion seperately - very large + self.revocation._create_credential = mock.CoroutineMock( return_value=({"cred": "cred"}, 98) ) @@ -1434,76 +1442,29 @@ async def test_create_credential_w3c(self, mock_supports_revocation): assert isinstance(result, tuple) assert mock_supports_revocation.call_count == 1 + @pytest.mark.asyncio @mock.patch.object(InMemoryProfileSession, "handle") - @mock.patch.object(W3cCredential, "create", return_value=mock.MagicMock()) - async def test_create_credential_w3c_private_no_rev_reg_or_tails( - self, mock_create, mock_handle - ): + async def test_create_credential_w3c_keyerror(self, mock_handle): mock_handle.fetch = mock.CoroutineMock(side_effect=[MockEntry(), MockEntry()]) - await self.revocation._create_credential_w3c( - w3c_credential_definition_id="test-cred-def-id", - schema_attributes=["attr1", "attr2"], - w3c_credential_offer={ - "schema_id": "CsQY9MGeD3CQP4EyuVFo5m:2:MYCO Biomarker:0.0.3", - "cred_def_id": "CsQY9MGeD3CQP4EyuVFo5m:3:CL:14951:MYCO_Biomarker", - "key_correctness_proof": {}, - "nonce": "nonce", - }, - w3c_credential_request={}, - w3c_credential_values={ - "attr1": "value1", - "attr2": "value2", - }, - ) - assert mock_create.called - - # askar error retrieving cred def - mock_handle.fetch = mock.CoroutineMock( - side_effect=AskarError(AskarErrorCode.UNEXPECTED, "test") - ) - with self.assertRaises(test_module.AnonCredsRevocationError): - await self.revocation._create_credential_w3c( - w3c_credential_definition_id="test-cred-def-id", - schema_attributes=["attr1", "attr2"], - w3c_credential_offer={}, - w3c_credential_request={}, - w3c_credential_values={}, - ) - - # missing cred def or cred def private - mock_handle.fetch = mock.CoroutineMock(side_effect=[None, MockEntry()]) - with self.assertRaises(test_module.AnonCredsRevocationError): - await self.revocation._create_credential_w3c( - w3c_credential_definition_id="test-cred-def-id", - schema_attributes=["attr1", "attr2"], - w3c_credential_offer={}, - w3c_credential_request={}, - w3c_credential_values={}, - ) - mock_handle.fetch = mock.CoroutineMock(side_effect=[MockEntry(), None]) - with self.assertRaises(test_module.AnonCredsRevocationError): - await self.revocation._create_credential_w3c( - w3c_credential_definition_id="test-cred-def-id", - schema_attributes=["attr1", "attr2"], - w3c_credential_offer={}, - w3c_credential_request={}, - w3c_credential_values={}, - ) - - mock_handle.fetch = mock.CoroutineMock(side_effect=[MockEntry(), None]) - with self.assertRaises(test_module.AnonCredsRevocationError): - await self.revocation._create_credential_w3c( - w3c_credential_definition_id="test-cred-def-id", + with pytest.raises(test_module.AnonCredsRevocationError) as excinfo: + await self.revocation._create_credential( + credential_definition_id="test-cred-def-id", schema_attributes=["attr1", "attr2"], - w3c_credential_offer={ + credential_offer={ "schema_id": "CsQY9MGeD3CQP4EyuVFo5m:2:MYCO Biomarker:0.0.3", "cred_def_id": "CsQY9MGeD3CQP4EyuVFo5m:3:CL:14951:MYCO_Biomarker", "key_correctness_proof": {}, "nonce": "nonce", }, - w3c_credential_request={}, - w3c_credential_values={ - "x": "value1", - "y": "value2", + credential_request={}, + credential_values={ + "X": "value1", + "Y": "value2", }, + credential_type=Credential, ) + + assert str(excinfo.value) == ( + "Provided credential values are missing a value " + "for the schema attribute 'attr1'" + ) From 630830e9b468012011b59bab4074a3bb6b18b9f9 Mon Sep 17 00:00:00 2001 From: EmadAnwer Date: Sun, 9 Jun 2024 17:41:10 +0300 Subject: [PATCH 5/5] ADD: tests for VCDI handler - test_create_offer - test_create_request - test_issue_credential_revocable - test_match_sent_cred_def_id_error - test_store_credential Signed-off-by: EmadAnwer --- .../v2_0/formats/vc_di/tests/test_handler.py | 375 +++++++++++++----- 1 file changed, 283 insertions(+), 92 deletions(-) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/vc_di/tests/test_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/vc_di/tests/test_handler.py index 04d3c552ef..02a99804b2 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/vc_di/tests/test_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/vc_di/tests/test_handler.py @@ -2,8 +2,25 @@ from time import time import json +from aries_cloudagent.anoncreds.models.anoncreds_cred_def import ( + CredDef, + GetCredDefResult, +) +from aries_cloudagent.anoncreds.models.anoncreds_revocation import ( + GetRevRegDefResult, + RevRegDef, +) +from aries_cloudagent.anoncreds.registry import AnonCredsRegistry +from aries_cloudagent.askar.profile_anon import AskarAnoncredsProfile +from aries_cloudagent.protocols.issue_credential.v2_0.messages.cred_issue import ( + V20CredIssue, +) +from aries_cloudagent.protocols.issue_credential.v2_0.models.cred_ex_record import ( + V20CredExRecord, +) +from aries_cloudagent.wallet.did_info import DIDInfo import pytest -from .......anoncreds.holder import AnonCredsHolder +from .......anoncreds.holder import AnonCredsHolder, AnonCredsHolderError from .......messaging.credential_definitions.util import ( CRED_DEF_SENT_RECORD_TYPE, ) @@ -176,7 +193,8 @@ "data_model_version": "2.0", "binding_proof": { "anoncreds_link_secret": { - "entropy": "M7PyEDW7WfLDA8UH4BPhVN", + "prover_did": f"did:sov:{TEST_DID}", + "entropy": f"did:sov:{TEST_DID}", "cred_def_id": CRED_DEF_ID, "blinded_ms": { "u": "10047077609650450290609991930929594521921208780899757965398360086992099381832995073955506958821655372681970112562804577530208651675996528617262693958751195285371230790988741041496869140904046414320278189103736789305088489636024127715978439300785989247215275867951013255925809735479471883338351299180591011255281885961242995409072561940244771612447316409017677474822482928698183528232263803799926211692640155689629903898365777273000566450465466723659861801656618726777274689021162957914736922404694190070274236964163273886807208820068271673047750886130307545831668836096290655823576388755329367886670574352063509727295", @@ -234,7 +252,9 @@ async def asyncSetUp(self): self.profile = self.session.profile self.context = self.profile.context setattr(self.profile, "session", mock.MagicMock(return_value=self.session)) - + self.session.wallet.get_public_did = mock.CoroutineMock( + return_value=DIDInfo(TEST_DID, None, None, None, True) + ) # Ledger Ledger = mock.MagicMock() self.ledger = Ledger() @@ -381,7 +401,6 @@ async def test_receive_proposal(self): # Not much to assert. Receive proposal doesn't do anything await self.handler.receive_proposal(cred_ex_record, cred_proposal_message) - @pytest.mark.skip(reason="Anoncreds-break") async def test_create_offer(self): schema_id_parts = SCHEMA_ID.split(":") @@ -423,26 +442,36 @@ async def test_create_offer(self): ) await self.session.storage.add_record(cred_def_record) - self.issuer.create_credential = mock.CoroutineMock( - return_value=json.dumps(VCDI_OFFER) - ) + with mock.patch.object( + test_module, "AnonCredsIssuer", return_value=self.issuer + ) as mock_issuer: + self.issuer.create_credential_offer = mock.CoroutineMock( + return_value=json.dumps( + VCDI_OFFER["binding_method"]["anoncreds_link_secret"] + ) + ) - (cred_format, attachment) = await self.handler.create_offer(cred_proposal) + self.issuer.match_created_credential_definitions = mock.CoroutineMock( + return_value=CRED_DEF_ID + ) - self.issuer.create_credential.assert_called_once_with(CRED_DEF_ID) + (cred_format, attachment) = await self.handler.create_offer(cred_proposal) - # assert identifier match - assert cred_format.attach_id == self.handler.format.api == attachment.ident + self.issuer.create_credential_offer.assert_called_once_with(CRED_DEF_ID) - # assert content of attachment is proposal data - assert attachment.content == VCDI_OFFER + assert cred_format.attach_id == self.handler.format.api == attachment.ident - # assert data is encoded as base64 - assert attachment.data.base64 + assert ( + attachment.content["binding_method"]["anoncreds_link_secret"] + == VCDI_OFFER["binding_method"]["anoncreds_link_secret"] + ) + + # Assert data is encoded as base64 + assert attachment.data.base64 + # TODO: fix this get_public_did return None in the sc + # (cred_format, attachment) = await self.handler.create_offer(cred_proposal) - self.issuer.create_credential_offer.reset_mock() - (cred_format, attachment) = await self.handler.create_offer(cred_proposal) - self.issuer.create_credential_offer.assert_not_called() + # self.issuer.create_credential_offer.assert_not_called() @pytest.mark.skip(reason="Anoncreds-break") async def test_receive_offer(self): @@ -452,9 +481,25 @@ async def test_receive_offer(self): # Not much to assert. Receive offer doesn't do anything await self.handler.receive_offer(cred_ex_record, cred_offer_message) - @pytest.mark.skip(reason="Anoncreds-break") async def test_create_request(self): + # Define your mock credential definition + mock_credential_definition_result = GetCredDefResult( + credential_definition=CredDef( + issuer_id=TEST_DID, schema_id=SCHEMA_ID, type="CL", tag="tag1", value={} + ), + credential_definition_id=CRED_DEF_ID, + resolution_metadata={}, + credential_definition_metadata={}, + ) + mock_creds_registry = mock.MagicMock() + mock_creds_registry.get_credential_definition = mock.AsyncMock( + return_value=mock_credential_definition_result + ) + + # Inject the MagicMock into the context + self.context.injector.bind_instance(AnonCredsRegistry, mock_creds_registry) + holder_did = "did" cred_offer = V20CredOffer( @@ -466,62 +511,67 @@ async def test_create_request(self): ], ) ], - # TODO here offers_attach=[AttachDecorator.data_base64(VCDI_OFFER, ident="0")], ) - cred_ex_record = V20CredExRecordIndy( + cred_ex_record = V20CredExRecord( cred_ex_id="dummy-id", - state=V20CredExRecordIndy.STATE_OFFER_RECEIVED, + state=V20CredExRecord.STATE_OFFER_RECEIVED, cred_offer=cred_offer.serialize(), ) - cred_def = {"cred": "def"} - self.ledger.get_credential_definition = mock.CoroutineMock( - return_value=cred_def - ) - cred_req_meta = {} self.holder.create_credential_request = mock.CoroutineMock( return_value=(json.dumps(VCDI_CRED_REQ), json.dumps(cred_req_meta)) ) - (cred_format, attachment) = await self.handler.create_request( - cred_ex_record, {"holder_did": holder_did} - ) - - self.holder.create_credential_request.assert_called_once_with( - VCDI_OFFER, cred_def, holder_did - ) - - # assert identifier match - assert cred_format.attach_id == self.handler.format.api == attachment.ident + self.profile = mock.MagicMock(AskarAnoncredsProfile) + self.context.injector.bind_instance(AskarAnoncredsProfile, self.profile) + with mock.patch.object( + AnonCredsHolder, "create_credential_request", mock.CoroutineMock() + ) as mock_create: - # assert content of attachment is proposal data - assert attachment.content == VCDI_CRED_REQ + mock_create.return_value = ( + json.dumps(VCDI_CRED_REQ["binding_proof"]["anoncreds_link_secret"]), + json.dumps(cred_req_meta), + ) + (cred_format, attachment) = await self.handler.create_request( + cred_ex_record, {"holder_did": holder_did} + ) - # assert data is encoded as base64 - assert attachment.data.base64 + legacy_offer = await self.handler._prepare_legacy_offer( + VCDI_OFFER, SCHEMA_ID + ) + mock_create.assert_called_once_with( + legacy_offer, + mock_credential_definition_result.credential_definition, + holder_did, + ) + assert cred_format.attach_id == self.handler.format.api == attachment.ident - # cover case with cache (change ID to prevent already exists error) - cred_ex_record._id = "dummy-id2" - await self.handler.create_request(cred_ex_record, {"holder_did": holder_did}) + del VCDI_CRED_REQ["binding_proof"]["anoncreds_link_secret"]["prover_did"] + assert attachment.content == VCDI_CRED_REQ + assert attachment.data.base64 - # cover case with no cache in injection context - self.context.injector.clear_binding(BaseCache) - cred_ex_record._id = "dummy-id3" - self.context.injector.bind_instance( - BaseMultitenantManager, - mock.MagicMock(MultitenantManager, autospec=True), - ) - with mock.patch.object( - IndyLedgerRequestsExecutor, - "get_ledger_for_identifier", - mock.CoroutineMock(return_value=(None, self.ledger)), - ): + cred_ex_record._id = "dummy-id2" await self.handler.create_request( cred_ex_record, {"holder_did": holder_did} ) + self.context.injector.clear_binding(BaseCache) + cred_ex_record._id = "dummy-id3" + self.context.injector.bind_instance( + BaseMultitenantManager, + mock.MagicMock(MultitenantManager, autospec=True), + ) + with mock.patch.object( + IndyLedgerRequestsExecutor, + "get_ledger_for_identifier", + mock.CoroutineMock(return_value=(None, self.ledger)), + ): + await self.handler.create_request( + cred_ex_record, {"holder_did": holder_did} + ) + @pytest.mark.skip(reason="Anoncreds-break") async def test_receive_request(self): cred_ex_record = mock.MagicMock() @@ -530,7 +580,6 @@ async def test_receive_request(self): # Not much to assert. Receive request doesn't do anything await self.handler.receive_request(cred_ex_record, cred_request_message) - @pytest.mark.skip(reason="Anoncreds-break") async def test_issue_credential_revocable(self): attr_values = { "legalName": "value", @@ -552,7 +601,6 @@ async def test_issue_credential_revocable(self): ], ) ], - # TODO here offers_attach=[AttachDecorator.data_base64(VCDI_OFFER, ident="0")], ) cred_request = V20CredRequest( @@ -564,59 +612,52 @@ async def test_issue_credential_revocable(self): ], ) ], - # TODO here requests_attach=[AttachDecorator.data_base64(VCDI_CRED_REQ, ident="0")], ) - cred_ex_record = V20CredExRecordIndy( + cred_ex_record = V20CredExRecord( cred_ex_id="dummy-cxid", cred_offer=cred_offer.serialize(), cred_request=cred_request.serialize(), - initiator=V20CredExRecordIndy.INITIATOR_SELF, - role=V20CredExRecordIndy.ROLE_ISSUER, - state=V20CredExRecordIndy.STATE_REQUEST_RECEIVED, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, ) cred_rev_id = "1000" - self.issuer.create_credential = mock.CoroutineMock( - return_value=(json.dumps(VCDI_CRED), cred_rev_id) - ) - - with mock.patch.object(test_module, "IndyRevocation", autospec=True) as revoc: - revoc.return_value.get_or_create_active_registry = mock.CoroutineMock( - return_value=( - mock.MagicMock( # active_rev_reg_rec - revoc_reg_id=REV_REG_ID, - ), - mock.MagicMock( # rev_reg - tails_local_path="dummy-path", - get_or_fetch_local_tails_path=(mock.CoroutineMock()), - max_creds=10, - ), - ) + dummy_registry = "dummy-registry" + expected_credential = json.dumps(VCDI_CRED) + + # Mock AnonCredsRevocation and its method create_credential_w3c + with mock.patch.object( + test_module, "AnonCredsRevocation", autospec=True + ) as MockAnonCredsRevocation: + mock_issuer = MockAnonCredsRevocation.return_value + mock_issuer.create_credential_w3c = mock.CoroutineMock( + return_value=(expected_credential, cred_rev_id, dummy_registry) ) + # Call the method under test (cred_format, attachment) = await self.handler.issue_credential( cred_ex_record, retries=1 ) - - self.issuer.create_credential.assert_called_once_with( - SCHEMA, - VCDI_OFFER, - VCDI_CRED_REQ, + legacy_offer = await self.handler._prepare_legacy_offer( + VCDI_OFFER, SCHEMA_ID + ) + legacy_request = await self.handler._prepare_legacy_request( + VCDI_CRED_REQ, CRED_DEF_ID + ) + # Verify the mocked method was called with the expected parameters + mock_issuer.create_credential_w3c.assert_called_once_with( + legacy_offer, + legacy_request, attr_values, - REV_REG_ID, - "dummy-path", ) - # assert identifier match + # Assert the results are as expected assert cred_format.attach_id == self.handler.format.api == attachment.ident - - # assert content of attachment is proposal data - assert attachment.content == VCDI_CRED - - # assert data is encoded as base64 assert attachment.data.base64 + assert attachment.content == {"credential": VCDI_CRED} @pytest.mark.skip(reason="Anoncreds-break") async def test_issue_credential_non_revocable(self): @@ -702,3 +743,153 @@ async def test_issue_credential_non_revocable(self): # assert data is encoded as base64 assert attachment.data.base64 + + @pytest.mark.asyncio + async def test_match_sent_cred_def_id_error(self): + tag_query = {"tag": "test_tag"} + + with self.assertRaises(V20CredFormatError) as context: + await self.handler._match_sent_cred_def_id(tag_query) + assert "Issuer has no operable cred def for proposal spec " in str( + context.exception + ) + + @pytest.mark.asyncio + async def test_store_credential(self): + attr_values = { + "legalName": "value", + "jurisdictionId": "value", + "incorporationDate": "value", + } + cred_preview = V20CredPreview( + attributes=[ + V20CredAttrSpec(name=k, value=v) for (k, v) in attr_values.items() + ] + ) + cred_offer = V20CredOffer( + credential_preview=cred_preview, + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.VC_DI.api + ], + ) + ], + offers_attach=[AttachDecorator.data_base64(VCDI_OFFER, ident="0")], + ) + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.VC_DI.api + ], + ) + ], + requests_attach=[AttachDecorator.data_base64(VCDI_CRED_REQ, ident="0")], + ) + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.VC_DI.api + ], + ) + ], + credentials_attach=[AttachDecorator.data_base64(VCDI_CRED, ident="0")], + ) + cred_ex_record = V20CredExRecord( + cred_ex_id="dummy-cxid", + cred_offer=cred_offer.serialize(), + cred_request=cred_request.serialize(), + cred_issue=cred_issue.serialize(), + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + ) + cred_id = "dummy-cred-id" + + # Define your mock credential definition + mock_credential_definition_result = GetCredDefResult( + credential_definition=CredDef( + issuer_id=TEST_DID, schema_id=SCHEMA_ID, type="CL", tag="tag1", value={} + ), + credential_definition_id=CRED_DEF_ID, + resolution_metadata={}, + credential_definition_metadata={}, + ) + mock_creds_registry = mock.AsyncMock() + mock_creds_registry.get_credential_definition = mock.AsyncMock( + return_value=mock_credential_definition_result + ) + + revocation_registry = RevRegDef( + cred_def_id=CRED_DEF_ID, + issuer_id=TEST_DID, + tag="tag1", + type="CL_ACCUM", + value={}, + ) + + mock_creds_registry.get_revocation_registry_definition = mock.AsyncMock( + return_value=GetRevRegDefResult( + revocation_registry=revocation_registry, + revocation_registry_id="rr-id", + resolution_metadata={}, + revocation_registry_metadata={}, + ) + ) + # Inject the MagicMock into the context + self.context.injector.bind_instance(AnonCredsRegistry, mock_creds_registry) + self.profile = mock.AsyncMock(AskarAnoncredsProfile) + self.context.injector.bind_instance(AskarAnoncredsProfile, self.profile) + with mock.patch.object( + test_module.AnonCredsRevocation, + "get_or_fetch_local_tails_path", + mock.CoroutineMock(), + ) as mock_get_or_fetch_local_tails_path: + with self.assertRaises(V20CredFormatError) as context: + await self.handler.store_credential(cred_ex_record, cred_id) + assert ( + "No credential exchange didcomm/ detail record found for cred ex id dummy-cxid" + in str(context.exception) + ) + + record = V20CredExRecordIndy( + cred_ex_indy_id="dummy-cxid", + rev_reg_id="rr-id", + cred_ex_id="dummy-cxid", + cred_id_stored=cred_id, + cred_request_metadata="dummy-metadata", + cred_rev_id="0", + ) + + record.save = mock.CoroutineMock() + self.handler.get_detail_record = mock.AsyncMock(return_value=record) + with mock.patch.object( + AnonCredsHolder, + "store_credential_w3c", + mock.AsyncMock(), + ) as mock_store_credential: + # Error case: no cred ex record found + + await self.handler.store_credential(cred_ex_record, cred_id) + + mock_store_credential.assert_called_once_with( + mock_credential_definition_result.credential_definition.serialize(), + VCDI_CRED["credential"], + record.cred_request_metadata, + None, + credential_id=cred_id, + rev_reg_def=revocation_registry.serialize(), + ) + + with mock.patch.object( + AnonCredsHolder, + "store_credential_w3c", + mock.AsyncMock(side_effect=AnonCredsHolderError), + ) as mock_store_credential: + with self.assertRaises(AnonCredsHolderError) as context: + await self.handler.store_credential(cred_ex_record, cred_id)