diff --git a/acapy_agent/protocols/issue_credential/v2_0/formats/anoncreds/handler.py b/acapy_agent/protocols/issue_credential/v2_0/formats/anoncreds/handler.py index a25e4bb62c..0836a7b4e2 100644 --- a/acapy_agent/protocols/issue_credential/v2_0/formats/anoncreds/handler.py +++ b/acapy_agent/protocols/issue_credential/v2_0/formats/anoncreds/handler.py @@ -2,14 +2,14 @@ import json import logging -from typing import Mapping, Optional, Tuple +from typing import List, Mapping, Optional, Tuple -from anoncreds import CredentialDefinition, Schema +from anoncreds import CredentialDefinition from marshmallow import RAISE -from ......anoncreds.base import AnonCredsResolutionError +from ......anoncreds.base import AnonCredsObjectNotFound, AnonCredsResolutionError from ......anoncreds.holder import AnonCredsHolder, AnonCredsHolderError -from ......anoncreds.issuer import CATEGORY_CRED_DEF, CATEGORY_SCHEMA, AnonCredsIssuer +from ......anoncreds.issuer import CATEGORY_CRED_DEF, AnonCredsIssuer from ......anoncreds.models.credential import AnoncredsCredentialSchema from ......anoncreds.models.credential_offer import AnoncredsCredentialOfferSchema from ......anoncreds.models.credential_proposal import ( @@ -204,15 +204,54 @@ async def _create(): offer_json = await issuer.create_credential_offer(cred_def_id) return json.loads(offer_json) - async with self.profile.session() as session: - cred_def_entry = await session.handle.fetch(CATEGORY_CRED_DEF, cred_def_id) - cred_def_dict = CredentialDefinition.load(cred_def_entry.value).to_dict() - schema_entry = await session.handle.fetch( - CATEGORY_SCHEMA, cred_def_dict["schemaId"] + async def _get_attr_names(schema_id) -> List[str] | None: + """Fetch attribute names for a given schema ID from the registry.""" + if not schema_id: + return None + try: + schema_result = await registry.get_schema(self.profile, schema_id) + return schema_result.schema.attr_names + except AnonCredsObjectNotFound: + LOGGER.info(f"Schema not found for schema_id={schema_id}") + return None + except AnonCredsResolutionError as e: + LOGGER.warning(f"Schema resolution failed for schema_id={schema_id}: {e}") + return None + + async def _fetch_schema_attr_names( + anoncreds_attachment, cred_def_id + ) -> List[str] | None: + """Determine schema attribute names from schema_id or cred_def_id.""" + schema_id = anoncreds_attachment.get("schema_id") + attr_names = await _get_attr_names(schema_id) + + if attr_names: + return attr_names + + if cred_def_id: + async with self.profile.session() as session: + cred_def_entry = await session.handle.fetch( + CATEGORY_CRED_DEF, cred_def_id + ) + cred_def_dict = CredentialDefinition.load( + cred_def_entry.value + ).to_dict() + return await _get_attr_names(cred_def_dict.get("schemaId")) + + return None + + attr_names = None + registry = self.profile.inject(AnonCredsRegistry) + + attr_names = await _fetch_schema_attr_names(anoncreds_attachment, cred_def_id) + + if not attr_names: + raise V20CredFormatError( + "Could not determine schema attributes. If you did not create the " + "schema, then you need to provide the schema_id." ) - schema_dict = Schema.load(schema_entry.value).to_dict() - schema_attrs = set(schema_dict["attrNames"]) + schema_attrs = set(attr_names) preview_attrs = set(cred_proposal_message.credential_preview.attr_dict()) if preview_attrs != schema_attrs: raise V20CredFormatError( diff --git a/acapy_agent/protocols/issue_credential/v2_0/formats/anoncreds/tests/test_handler.py b/acapy_agent/protocols/issue_credential/v2_0/formats/anoncreds/tests/test_handler.py index 07f2d948c4..48bb244191 100644 --- a/acapy_agent/protocols/issue_credential/v2_0/formats/anoncreds/tests/test_handler.py +++ b/acapy_agent/protocols/issue_credential/v2_0/formats/anoncreds/tests/test_handler.py @@ -4,13 +4,22 @@ from unittest import IsolatedAsyncioTestCase import pytest +from anoncreds import CredentialDefinition from marshmallow import ValidationError from .......anoncreds.holder import AnonCredsHolder from .......anoncreds.issuer import AnonCredsIssuer +from .......anoncreds.models.credential_definition import ( + CredDef, + CredDefValue, + CredDefValuePrimary, +) +from .......anoncreds.registry import AnonCredsRegistry from .......anoncreds.revocation import AnonCredsRevocationRegistryFullError from .......cache.base import BaseCache from .......cache.in_memory import InMemoryCache +from .......config.provider import ClassProvider +from .......indy.credx.issuer import CATEGORY_CRED_DEF from .......ledger.base import BaseLedger from .......ledger.multiple_ledger.ledger_requests_executor import ( IndyLedgerRequestsExecutor, @@ -193,9 +202,38 @@ class TestV20AnonCredsCredFormatHandler(IsolatedAsyncioTestCase): async def asyncSetUp(self): - self.profile = await create_test_profile() + self.profile = await create_test_profile( + { + "wallet.type": "askar-anoncreds", + } + ) self.context = self.profile.context + # Context + self.cache = InMemoryCache() + self.profile.context.injector.bind_instance(BaseCache, self.cache) + + # Issuer + self.issuer = mock.MagicMock(AnonCredsIssuer, autospec=True) + self.profile.context.injector.bind_instance(AnonCredsIssuer, self.issuer) + + # Holder + self.holder = mock.MagicMock(AnonCredsHolder, autospec=True) + self.profile.context.injector.bind_instance(AnonCredsHolder, self.holder) + + # Anoncreds registry + self.profile.context.injector.bind_instance( + AnonCredsRegistry, AnonCredsRegistry() + ) + registry = self.profile.context.inject_or(AnonCredsRegistry) + legacy_indy_registry = ClassProvider( + "acapy_agent.anoncreds.default.legacy_indy.registry.LegacyIndyRegistry", + # supported_identifiers=[], + # method_name="", + ).provide(self.profile.context.settings, self.profile.context.injector) + await legacy_indy_registry.setup(self.profile.context) + registry.register(legacy_indy_registry) + # Ledger self.ledger = mock.MagicMock(BaseLedger, autospec=True) self.ledger.get_schema = mock.CoroutineMock(return_value=SCHEMA) @@ -214,18 +252,6 @@ async def asyncSetUp(self): ) ), ) - # Context - self.cache = InMemoryCache() - self.profile.context.injector.bind_instance(BaseCache, self.cache) - - # Issuer - self.issuer = mock.MagicMock(AnonCredsIssuer, autospec=True) - self.profile.context.injector.bind_instance(AnonCredsIssuer, self.issuer) - - # Holder - self.holder = mock.MagicMock(AnonCredsHolder, autospec=True) - self.profile.context.injector.bind_instance(AnonCredsHolder, self.holder) - self.handler = AnonCredsCredFormatHandler(self.profile) assert self.handler.profile @@ -338,68 +364,123 @@ 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(":") - - cred_preview = V20CredPreview( - attributes=( - V20CredAttrSpec(name="legalName", value="value"), - V20CredAttrSpec(name="jurisdictionId", value="value"), - V20CredAttrSpec(name="incorporationDate", value="value"), - ) - ) - - cred_proposal = V20CredProposal( - credential_preview=cred_preview, - formats=[ - V20CredFormat( - attach_id="0", - format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ - V20CredFormat.Format.ANONCREDS.api + async def test_create_offer_cant_find_schema_in_wallet_or_data_registry(self): + with self.assertRaises(V20CredFormatError): + await self.handler.create_offer( + V20CredProposal( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.ANONCREDS.api + ], + ) + ], + filters_attach=[ + AttachDecorator.data_base64( + {"cred_def_id": CRED_DEF_ID}, ident="0" + ) ], ) - ], - filters_attach=[ - AttachDecorator.data_base64({"cred_def_id": CRED_DEF_ID}, ident="0") - ], - ) + ) - cred_def_record = StorageRecord( - CRED_DEF_SENT_RECORD_TYPE, - CRED_DEF_ID, - { - "schema_id": SCHEMA_ID, - "schema_issuer_did": schema_id_parts[0], - "schema_name": schema_id_parts[-2], - "schema_version": schema_id_parts[-1], - "issuer_did": TEST_DID, - "cred_def_id": CRED_DEF_ID, - "epoch": str(int(time())), - }, + @mock.patch.object( + AnonCredsRegistry, + "get_schema", + mock.CoroutineMock( + return_value=mock.MagicMock(schema=mock.MagicMock(attr_names=["score"])) + ), + ) + @mock.patch.object( + AnonCredsIssuer, + "create_credential_offer", + mock.CoroutineMock(return_value=json.dumps(ANONCREDS_OFFER)), + ) + @mock.patch.object( + CredentialDefinition, + "load", + mock.MagicMock(to_dict=mock.MagicMock(return_value={"schemaId": SCHEMA_ID})), + ) + async def test_create_offer(self): + self.issuer.create_credential_offer = mock.CoroutineMock({}) + # With a schema_id + await self.handler.create_offer( + V20CredProposal( + credential_preview=V20CredPreview( + attributes=(V20CredAttrSpec(name="score", value="0"),) + ), + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.ANONCREDS.api + ], + ) + ], + filters_attach=[ + AttachDecorator.data_base64( + {"cred_def_id": CRED_DEF_ID, "schema_id": SCHEMA_ID}, ident="0" + ) + ], + ) ) - await self.session.storage.add_record(cred_def_record) - - self.issuer.create_credential_offer = mock.CoroutineMock( - return_value=json.dumps(ANONCREDS_OFFER) + # Only with cred_def_id + async with self.profile.session() as session: + await session.handle.insert( + CATEGORY_CRED_DEF, + CRED_DEF_ID, + CredDef( + issuer_id=TEST_DID, + schema_id=SCHEMA_ID, + tag="tag", + type="CL", + value=CredDefValue( + primary=CredDefValuePrimary("n", "s", {}, "rctxt", "z") + ), + ).to_json(), + tags={}, + ) + await self.handler.create_offer( + V20CredProposal( + credential_preview=V20CredPreview( + attributes=(V20CredAttrSpec(name="score", value="0"),) + ), + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.ANONCREDS.api + ], + ) + ], + filters_attach=[ + AttachDecorator.data_base64({"cred_def_id": CRED_DEF_ID}, ident="0") + ], + ) ) - - (cred_format, attachment) = await self.handler.create_offer(cred_proposal) - - self.issuer.create_credential_offer.assert_called_once_with(CRED_DEF_ID) - - # assert identifier match - assert cred_format.attach_id == self.handler.format.api == attachment.ident - - # assert content of attachment is proposal data - assert attachment.content == ANONCREDS_OFFER - - # assert data is encoded as base64 - assert attachment.data.base64 - - self.issuer.create_credential_offer.reset_mock() - await self.handler.create_offer(cred_proposal) - self.issuer.create_credential_offer.assert_not_called() + # Wrong attribute name + with self.assertRaises(V20CredFormatError): + await self.handler.create_offer( + V20CredProposal( + credential_preview=V20CredPreview( + attributes=(V20CredAttrSpec(name="wrong", value="0"),) + ), + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.ANONCREDS.api + ], + ) + ], + filters_attach=[ + AttachDecorator.data_base64( + {"cred_def_id": CRED_DEF_ID, "schema_id": SCHEMA_ID}, + ident="0", + ) + ], + ) + ) @pytest.mark.skip(reason="Anoncreds-break") async def test_create_offer_no_cache(self): diff --git a/scenarios/examples/anoncreds_issuance_and_revocation/example.py b/scenarios/examples/anoncreds_issuance_and_revocation/example.py index d74a813b40..b38beb64d9 100644 --- a/scenarios/examples/anoncreds_issuance_and_revocation/example.py +++ b/scenarios/examples/anoncreds_issuance_and_revocation/example.py @@ -36,6 +36,7 @@ async def main(): """Test Controller protocols.""" issuer_name = "issuer" + token_hex(8) + issuer_without_schema_name = "issuer" + token_hex(8) async with Controller(base_url=AGENCY) as agency: issuer = await agency.post( "/multitenancy/wallet", @@ -46,6 +47,15 @@ async def main(): }, response=CreateWalletResponse, ) + issuer_without_schema = await agency.post( + "/multitenancy/wallet", + json={ + "label": issuer_without_schema_name, + "wallet_name": issuer_without_schema_name, + "wallet_type": "askar", + }, + response=CreateWalletResponse, + ) async with ( Controller( @@ -53,6 +63,11 @@ async def main(): wallet_id=issuer.wallet_id, subwallet_token=issuer.token, ) as issuer, + Controller( + base_url=AGENCY, + wallet_id=issuer_without_schema.wallet_id, + subwallet_token=issuer_without_schema.token, + ) as issuer_without_schema, Controller(base_url=HOLDER_ANONCREDS) as holder_anoncreds, Controller(base_url=HOLDER_INDY) as holder_indy, ): @@ -113,8 +128,8 @@ async def main(): holder_anoncreds, issuer_conn_with_anoncreds_holder.connection_id, holder_anoncreds_conn.connection_id, - cred_def.credential_definition_id, {"firstname": "Anoncreds", "lastname": "Holder"}, + cred_def_id=cred_def.credential_definition_id, issuer_id=public_did.did, schema_id=schema.schema_id, schema_issuer_id=public_did.did, @@ -162,8 +177,8 @@ async def main(): holder_indy, issuer_conn_with_indy_holder.connection_id, holder_indy_conn.connection_id, - cred_def.credential_definition_id, {"firstname": "Indy", "lastname": "Holder"}, + cred_def_id=cred_def.credential_definition_id, issuer_id=public_did.did, schema_id=schema.schema_id, schema_issuer_id=public_did.did, @@ -214,7 +229,15 @@ async def main(): "wallet_name": issuer_name, }, ) + # Wait for the upgrade to complete + await asyncio.sleep(1) + await issuer_without_schema.post( + "/anoncreds/wallet/upgrade", + params={ + "wallet_name": issuer_without_schema_name, + }, + ) # Wait for the upgrade to complete await asyncio.sleep(2) @@ -275,8 +298,8 @@ async def main(): holder_anoncreds, issuer_conn_with_anoncreds_holder.connection_id, holder_anoncreds_conn.connection_id, - cred_def.credential_definition_state["credential_definition_id"], {"middlename": "Anoncreds"}, + cred_def_id=cred_def.credential_definition_state["credential_definition_id"], issuer_id=public_did.did, schema_id=schema.schema_state["schema_id"], schema_issuer_id=public_did.did, @@ -310,8 +333,8 @@ async def main(): holder_indy, issuer_conn_with_indy_holder.connection_id, holder_indy_conn.connection_id, - cred_def.credential_definition_state["credential_definition_id"], {"middlename": "Indy"}, + cred_def_id=cred_def.credential_definition_state["credential_definition_id"], issuer_id=public_did.did, schema_id=schema.schema_state["schema_id"], schema_issuer_id=public_did.did, @@ -340,6 +363,85 @@ async def main(): await holder_indy.record(topic="revocation-notification") + """ + This section of the test script demonstrates the issuance, presentation and + revocation of a credential where the issuer did not create the schema. + """ + print( + "***Begin issuance, presentation and revocation of " + "credential without schema***" + ) + issuer_conn_with_anoncreds_holder, holder_anoncreds_conn = await didexchange( + issuer_without_schema, holder_anoncreds + ) + + public_did = ( + await issuer_without_schema.post( + "/wallet/did/create", + json={"method": "sov", "options": {"key_type": "ed25519"}}, + response=DIDResult, + ) + ).result + assert public_did + + async with ClientSession() as session: + register_url = genesis_url.replace("/genesis", "/register") + async with session.post( + register_url, + json={ + "did": public_did.did, + "verkey": public_did.verkey, + "alias": None, + "role": "ENDORSER", + }, + ) as resp: + assert resp.ok + + await issuer_without_schema.post( + "/wallet/did/public", params=params(did=public_did.did) + ) + cred_def = await issuer_without_schema.post( + "/anoncreds/credential-definition", + json={ + "credential_definition": { + "issuerId": public_did.did, + "schemaId": schema.schema_state["schema_id"], + "tag": token_hex(8), + }, + "options": {"support_revocation": True, "revocation_registry_size": 10}, + }, + response=CredDefResultAnoncreds, + ) + issuer_cred_ex, _ = await anoncreds_issue_credential_v2( + issuer_without_schema, + holder_anoncreds, + issuer_conn_with_anoncreds_holder.connection_id, + holder_anoncreds_conn.connection_id, + {"middlename": "Anoncreds"}, + cred_def_id=cred_def.credential_definition_state["credential_definition_id"], + schema_id=schema.schema_state["schema_id"], + ) + await anoncreds_present_proof_v2( + holder_anoncreds, + issuer_without_schema, + holder_anoncreds_conn.connection_id, + issuer_conn_with_anoncreds_holder.connection_id, + requested_attributes=[{"name": "middlename"}], + ) + await issuer_without_schema.post( + url="/anoncreds/revocation/revoke", + json={ + "connection_id": issuer_conn_with_anoncreds_holder.connection_id, + "rev_reg_id": issuer_cred_ex.details.rev_reg_id, + "cred_rev_id": issuer_cred_ex.details.cred_rev_id, + "publish": True, + "notify": True, + "notify_version": "v1_0", + }, + ) + + await holder_anoncreds.record(topic="revocation-notification") + if __name__ == "__main__": logging_to_stdout() diff --git a/scenarios/examples/restart_anoncreds_upgrade/example.py b/scenarios/examples/restart_anoncreds_upgrade/example.py index b73b46e5d8..ee1305f969 100644 --- a/scenarios/examples/restart_anoncreds_upgrade/example.py +++ b/scenarios/examples/restart_anoncreds_upgrade/example.py @@ -56,8 +56,8 @@ async def connect_agents_and_issue_credentials( invitee, inviter_conn.connection_id, invitee_conn.connection_id, - inviter_cred_def.credential_definition_id, {"firstname": fname, "lastname": lname}, + inviter_cred_def.credential_definition_id, ) # Present the the credential's attributes @@ -105,8 +105,8 @@ async def connect_agents_and_issue_credentials( invitee, inviter_conn.connection_id, invitee_conn.connection_id, - inviter_cred_def.credential_definition_id, {"firstname": f"{fname}2", "lastname": f"{lname}2"}, + inviter_cred_def.credential_definition_id, ) print(">>> Done!") @@ -154,7 +154,7 @@ async def verify_issued_credentials(issuer, issued_cred_count, revoked_cred_coun rev_reg_id = cred_exch[cred_type]["rev_reg_id"] cred_rev_id = cred_exch[cred_type]["cred_rev_id"] cred_rev_id = int(cred_rev_id) - if not rev_reg_id in registries: + if rev_reg_id not in registries: if is_issuer_anoncreds: registries[rev_reg_id] = await issuer.get( f"/anoncreds/revocation/registry/{rev_reg_id}/issued/indy_recs", @@ -177,7 +177,7 @@ async def verify_recd_credentials(holder, active_cred_count, revoked_cred_count) "wallet.type" ) == "askar-anoncreds" - credentials = await holder.get(f"/credentials") + credentials = await holder.get("/credentials") credentials = credentials["results"] assert len(credentials) == (active_cred_count + revoked_cred_count) registries = {} @@ -186,7 +186,7 @@ async def verify_recd_credentials(holder, active_cred_count, revoked_cred_count) for credential in credentials: rev_reg_id = credential["rev_reg_id"] cred_rev_id = int(credential["cred_rev_id"]) - if not rev_reg_id in registries: + if rev_reg_id not in registries: if is_holder_anoncreds: registries[rev_reg_id] = await holder.get( f"/anoncreds/revocation/registry/{rev_reg_id}/issued/indy_recs", @@ -205,7 +205,7 @@ async def verify_recd_credentials(holder, active_cred_count, revoked_cred_count) async def verify_recd_presentations(verifier, recd_pres_count): - presentations = await verifier.get(f"/present-proof-2.0/records") + presentations = await verifier.get("/present-proof-2.0/records") presentations = presentations["results"] assert recd_pres_count == len(presentations) diff --git a/scenarios/examples/util.py b/scenarios/examples/util.py index b323b88b86..f7d54e15d0 100644 --- a/scenarios/examples/util.py +++ b/scenarios/examples/util.py @@ -200,8 +200,8 @@ async def anoncreds_issue_credential_v2( holder: Controller, issuer_connection_id: str, holder_connection_id: str, - cred_def_id: str, attributes: Mapping[str, str], + cred_def_id: str, issuer_id: Optional[str] = None, schema_id: Optional[str] = None, schema_issuer_id: Optional[str] = None,